feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper

Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.

New top-level folder: fusion_iot/

1. **iot_base/** — Odoo S.A. iot_base module, copied from
   RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.

2. **iot/** — Odoo S.A. iot module, repackaged:
   - `models/update.py` neutralised (removed the publisher_warranty
     IoT-Box-counting report that phones home to odoo.com for
     enterprise licence enforcement)
   - `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
     Worldline payment lib fetch from download.odoo.com, not needed)
   - `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
     upstream called odoo.com's iot-proxy to resolve pairing codes;
     replaced with a no-op. Pi-side iot_drivers proxy registers
     directly with this Odoo server instead.
   - Manifest rebranded with an explicit changelog preamble.

3. **fusion_plating_iot/** — new plating-specific wrapper:
   - `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
     sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
     Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
     alert_min/max overrides.
   - `fp.tank.reading` — append-only time-series. On create, evaluates
     against sensor's alert range. On in-spec → out-of-spec TRANSITION,
     auto-raises a fusion.plating.quality.hold (once per excursion,
     no spam during sustained out-of-spec).
   - `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
     bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
     Accepts single-reading or batch payloads.
   - Menu under Plating → Operations → Sensors & Readings.
   - Tank form inherits get a Sensors tab inline.

Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
  continued excursion → NO duplicate hold; back-in-spec → NEW
  excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
  unknown device_serial → 404; batch payload → 200 accepted=N ✓

Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-19 10:46:45 -04:00
parent c118b7c6b5
commit 6e964c230f
419 changed files with 76449 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
import { registry } from "@web/core/registry";
import { post } from "@iot_base/network_utils/http";
import { uuid } from "@web/core/utils/strings";
import { IotWebsocket } from "@iot/network_utils/iot_websocket";
import { _t } from "@web/core/l10n/translation";
import { IotWebRtc } from "./iot_webrtc";
export const PRINTER_MESSAGES = {
ERROR_FAILED: _t("Failed to initiate print"),
ERROR_OFFLINE: _t("Printer is not ready"),
ERROR_TIMEOUT: _t("Printing timed out"),
ERROR_NO_PAPER: _t("Out of paper"),
ERROR_UNREACHABLE: _t("Printer is unreachable"),
ERROR_UNKNOWN: _t("Unknown printer error occurred"),
WARNING_LOW_PAPER: _t("Paper is low"),
};
export const FDM_MESSAGES = {
'000': _t("Blackbox is running and operational"),
'001': _t("PIN accepted."),
101: _t("Fiscal Data Module memory 90% full."),
102: _t("Repeated request. This request was already handled by the fiscal data module."),
103: _t("Operation wasn't saved on the blackbox"),
199: _t("Unspecified warning."),
201: _t("No Vat Signing Card or Vat Signing Card broken."),
202: _t("Please activate the Vat Signing Card with PIN."),
203: _t("Vat Signing Card blocked."),
204: _t("Invalid PIN."),
205: _t("Fiscal Data Module memory full."),
206: _t("Unknown identifier."),
207: _t("Invalid data in message sent to the blackbox."),
208: _t("Fiscal Data Module not operational. Please restart the blackbox"),
209: _t("Fiscal Data Module real time clock corrupt."),
210: _t("Vat Signing Card not compatible with Fiscal Data Module."),
299: _t("Unspecified error."),
300: _t("Blackbox responded with invalid response. Please check the cable connection and the power supply, then retry. Restart if necessary"),
301: _t("Blackbox did not respond to your request. This usually means it has disconnected. Please check its cable connection and its power supply. Restart if necessary."),
426: _t("Blackbox driver update required. Please restart your IoT Box to update the blackbox driver."),
};
/**
* Class to handle IoT actions
* The class is used to send actions to IoT devices and handle fallbacks
* in case the request fails: it will try to send the request using
* HTTP POST method and then using the websocket.
*/
export class IotHttpService {
longpollingFailedTimestamp = null;
webRtcFailedTimestamp = null;
connectionStatus = "webrtc"; // webrtc, longpolling, websocket, offline
connectionTypes = [
this._webRtc.bind(this),
this._longpolling.bind(this),
this._websocket.bind(this)
];
cachedIotBoxes = {};
constructor() {
this.setup(...arguments);
}
/**
* @param {import("services").ServiceFactories & { websocket: IotWebsocket } & { webRtc: IotWebRtc } }} services
*/
setup({ iot_longpolling, websocket, webRtc, notification, orm }) {
this.longpolling = iot_longpolling;
this.websocket = websocket;
this.webRtc = webRtc;
this.notification = notification;
this.orm = orm;
}
onFailure(_message, deviceIdentifier, _messageId) {
this.notification.add(_t("Failed to reach the IoT Box for device: %s", deviceIdentifier), { type: "danger" });
}
cacheIotBoxRecords(boxes) {
for (const box of boxes) {
this.cachedIotBoxes[box.id] = { ip: box.ip, identifier: box.identifier, version: box.version };
}
}
async getIotBoxData(iotBoxId) {
const record = await this.orm.searchRead("iot.box", [["id", "=", iotBoxId]], ["id", "ip", "identifier", "version"]);
if (!record) {
throw new Error(`No IoT Box found`);
}
return record;
}
_ensureLongpollingEnabled() {
if (
this.longpollingFailedTimestamp &&
Date.now() - this.longpollingFailedTimestamp < 5 * 60 * 1000
) {
throw new Error("Longpolling is temporarily disabled due to a recent failure.");
}
}
_ensureWebRtcEnabled() {
if (
this.webRtcFailedTimestamp &&
Date.now() - this.webRtcFailedTimestamp < 20 * 60 * 1000
) {
throw new Error("WebRTC is temporarily disabled due to a recent failure.");
}
}
async _webRtc({ identifier, version, deviceIdentifier, data, messageId, onSuccess, onFailure, messageType }) {
if (/\d{4}\.\d{2}\.\d{2}/.test(version)) {
throw new Error("IoT box does not support WebRTC, skipping.");
}
this._ensureWebRtcEnabled();
try {
await this.webRtc.onMessage(identifier, deviceIdentifier, messageId, onSuccess, onFailure);
if (data) {
await this.webRtc.sendMessage(identifier, {
device_identifier: deviceIdentifier,
data,
}, messageId, messageType);
}
} catch (error) {
this.webRtcFailedTimestamp = Date.now();
throw error;
}
this.connectionStatus = "webrtc";
}
async _longpolling({ ip, deviceIdentifier, data, messageId, onSuccess, onFailure }) {
this._ensureLongpollingEnabled();
try {
this.longpolling.onMessage(ip, deviceIdentifier, onSuccess, onFailure, messageId);
if (data) {
const response =
await this.longpolling.sendMessage(ip, { device_identifier: deviceIdentifier, data }, messageId, true);
if (response?.result === false) {
onFailure({status: "disconnected"}, deviceIdentifier, messageId);
}
}
} catch (e) {
this.longpollingFailedTimestamp = Date.now();
throw e;
}
this.connectionStatus = "longpolling";
}
async _websocket({ identifier, deviceIdentifier, data, messageId, onSuccess, onFailure, messageType }) {
const onFailureWithTimeout = (...args) => {
onFailure(...args);
this.connectionStatus = "offline";
};
this.websocket.onMessage(identifier, deviceIdentifier, onSuccess, onFailureWithTimeout, "operation_confirmation", messageId);
if (data) {
this.websocket.sendMessage(
identifier,
{
device_identifiers: [deviceIdentifier],
device_identifier: deviceIdentifier, // compatibility with v19.1+ IoT Boxes
...data
},
messageId,
messageType,
);
}
this.connectionStatus = "websocket";
}
async _attemptFallbacks({ iotBoxId, deviceIdentifier, data, onFailure }) {
if (!["number", "string"].includes(typeof iotBoxId)) {
iotBoxId = iotBoxId[0]; // iotBoxId is the ``Many2one`` field, we need the actual ID
}
if (!this.cachedIotBoxes[iotBoxId]) {
this.cacheIotBoxRecords(await this.getIotBoxData(iotBoxId))
}
const { ip, identifier, version } = this.cachedIotBoxes[iotBoxId];
// if we target the box instead of a device, we want longpolling to handle action as messageType
const messageType = deviceIdentifier === identifier ? data.action : undefined;
const params = { ip, identifier, version, data, messageType, ...arguments[0] };
for (const connectionType of this.connectionTypes) {
try {
return await connectionType(params);
} catch (e) {
console.debug("IoT Box action: attempted method failed, attempting another protocol.", e);
}
}
// If all the connection types failed, run the onFailure callback and remove the cached IoT Box data
delete this.cachedIotBoxes[iotBoxId];
this.connectionStatus = "offline";
onFailure({ status: "disconnected" }, deviceIdentifier);
}
/**
* Listen for events on the IoT Box
* @param iotBoxId IoT Box record ID
* @param deviceIdentifier Identifier of the device connected to the IoT Box
* @param {(message: Record<string, unknown>, deviceId: string) => void} onSuccess Callback to run when a message is received
* @param {(message: Record<string, unknown>, deviceId: string) => void} onFailure Callback to run when the request fails
* @param {string|null} messageId Unique identifier for the message (optional)
* @returns {Promise<void>}
*/
async onMessage(
iotBoxId,
deviceIdentifier,
onSuccess = () => {},
onFailure = (...args) => this.onFailure(...args),
messageId = null,
) {
// Attempt to listen for messages using the defined connection types
await this._attemptFallbacks({
iotBoxId,
deviceIdentifier,
messageId,
onSuccess,
onFailure,
});
}
/**
* Call for an action method on the IoT Box
* @param iotBoxId IoT Box record ID
* @param deviceIdentifier Identifier of the device connected to the IoT Box
* @param data Data to send
* @param {(message: Record<string, unknown>, deviceId: string) => void} onSuccess Callback to run when a message is received
* @param {(message: Record<string, unknown>, deviceId: string) => void} onFailure Callback to run when the request fails
* @param {string|null} messageId Unique identifier for the message (optional)
* @returns {Promise<void>}
*/
async action(
iotBoxId,
deviceIdentifier,
data,
onSuccess = () => {},
onFailure = (...args) => this.onFailure(...args),
messageId = null,
) {
messageId ??= uuid();
if (!data) {
data = {};
}
data.action_unique_id = messageId;
await this._attemptFallbacks({
iotBoxId,
deviceIdentifier,
data,
messageId,
onSuccess,
onFailure,
});
}
}
export const iotHttpService = {
dependencies: ["notification", "orm", "bus_service", "iot_longpolling", "lazy_session"],
start(env, services) {
const { iot_longpolling, bus_service } = services;
const iotWebsocket = new IotWebsocket(services);
const iotWebRtc = new IotWebRtc(bus_service, iotWebsocket);
const webRtc = {
sendMessage: iotWebRtc.sendMessage.bind(iotWebRtc),
onMessage: iotWebRtc.onMessage.bind(iotWebRtc),
};
const longpolling = {
sendMessage: iot_longpolling.sendMessage.bind(iot_longpolling),
onMessage: iot_longpolling.onMessage.bind(iot_longpolling),
};
const websocket = {
sendMessage: iotWebsocket.sendMessage.bind(iotWebsocket),
onMessage: iotWebsocket.onMessage.bind(iotWebsocket),
};
const iot = new IotHttpService({ ...services, websocket: iotWebsocket, webRtc: iotWebRtc });
const cacheIotBoxRecords = iot.cacheIotBoxRecords.bind(iot);
const action = iot.action.bind(iot);
const onMessage = iot.onMessage.bind(iot);
// Expose only those functions to the environment
// status is a getter to have a reactive value
return {
post, action, webRtc, longpolling, websocket, onMessage, cacheIotBoxRecords, get status() {
return iot.connectionStatus;
}
};
},
};
registry.category("services").add("iot_http", iotHttpService);

View File

@@ -0,0 +1,202 @@
import { range } from "@web/core/utils/numbers";
import { uuid } from "@web/core/utils/strings";
const CONNECT_TIMEOUT_MS = 5000;
/**
* @typedef {{ id: string, connection: RTCPeerConnection, channel: RTCDataChannel }} RtcConnection
*/
export class IotWebRtc {
constructor() {
this.setup(...arguments);
}
/**
* @param {import("@web/model/model").Services["bus_service"]} busService
* @param {import("@iot/network_utils/iot_websocket").IotWebsocket} iotWebsocket
*/
async setup(busService, iotWebsocket) {
/**
* @type {Record<string, RtcConnection>}
*/
this.connections = {};
this.busService = busService;
this.websocket = iotWebsocket;
}
/**
* Send a message to the IoT Box
* @param {string} iotIdentifier Identifier (serial no.) of the IoT Box
* @param {Record<string, unknown>} message Data to send to the device
* @param {string?} actionId Unique identifier for the message (optional)
* @param {string} messageType Type of message to send (optional)
* @returns {Promise<string>} The action ID
*/
async sendMessage(iotIdentifier, message, actionId = null, messageType = "iot_action") {
const rtcConnection = await this.waitForConnection(iotIdentifier);
if (rtcConnection.connection.connectionState !== "connected") {
throw new Error(
`WebRTC connection for ${iotIdentifier} is '${rtcConnection.connection.connectionState}'`
);
}
if (rtcConnection.channel.readyState !== "open") {
throw new Error(
`WebRTC channel for ${iotIdentifier} is '${rtcConnection.channel.readyState}'`
);
}
actionId ??= uuid();
const messageString = JSON.stringify({
...message,
session_id: actionId,
message_type: messageType,
});
if (messageString.length >= rtcConnection.connection.sctp.maxMessageSize) {
this._sendChunkedMessage(rtcConnection, messageString);
} else {
rtcConnection.channel.send(messageString);
}
return actionId;
}
/**
* @param {RtcConnection} rtcConnection
* @param {string} message
*/
async _sendChunkedMessage(rtcConnection, message) {
const chunkSize = rtcConnection.connection.sctp.maxMessageSize;
const numberOfChunks = Math.ceil(message.length / chunkSize);
rtcConnection.channel.send("chunked_start");
for (const chunk of range(0, numberOfChunks)) {
rtcConnection.channel.send(message.slice(chunk * chunkSize, (chunk + 1) * chunkSize));
}
rtcConnection.channel.send("chunked_end");
}
/**
* Add a listener for events/messages coming from the IoT Box.
* This method allows defining callbacks for success and failure cases.
* @param {string} iotIdentifier Identifier (serial no.) of the IoT Box
* @param {string} deviceIdentifier Identifier of the device connected to the IoT Box
* @param {string?} actionId Identifier to match the specific response we are listening for
* @param {(message: Record<string, unknown>, deviceId: string) => void} onSuccess Callback to run when a message is received
* @param {(message: Record<string, unknown>, deviceId: string) => void} onFailure Callback to run when the request fails
*/
async onMessage(
iotIdentifier,
deviceIdentifier,
actionId = null,
onSuccess = () => {},
onFailure = () => {}
) {
const connection = await this.waitForConnection(iotIdentifier);
const messageCallback = (event) => {
const message = JSON.parse(event.data);
if (
message.device_identifier === deviceIdentifier &&
(!actionId ||
actionId === message.action_args?.session_id ||
actionId === message.owner)
) {
const callback = message.status === "success" || message.status?.status === "connected" ? onSuccess : onFailure;
callback(message);
connection.channel.removeEventListener("message", messageCallback);
}
};
connection.channel.addEventListener("message", messageCallback);
}
/**
* @param {string} iotIdentifier
*/
async waitForConnection(iotIdentifier) {
const { connection, channel } = await this.openConnection(iotIdentifier);
if (!["new", "connecting"].includes(connection.connectionState)) {
return this.connections[iotIdentifier];
}
const connectedPromise = new Promise((resolve, reject) => {
const onConnectionChange = () => {
if (connection.connectionState === "connected") {
resolve();
} else if (
["failed", "closed", "disconnected"].includes(connection.connectionState)
) {
reject(`WebRTC connection is '${connection.connectionState}'`);
} else {
return;
}
connection.removeEventListener("connectionstatechange", onConnectionChange);
};
connection.addEventListener("connectionstatechange", onConnectionChange);
setTimeout(() => reject("WebRTC connection timed out"), CONNECT_TIMEOUT_MS);
});
const channelOpenPromise = new Promise((resolve) => {
const onOpen = () => {
resolve();
channel.removeEventListener("open", onOpen);
};
channel.addEventListener("open", onOpen);
});
await connectedPromise;
await channelOpenPromise;
return this.connections[iotIdentifier];
}
/**
* @param {string} iotIdentifier
*/
async openConnection(iotIdentifier) {
if (this.connections[iotIdentifier]) {
return this.connections[iotIdentifier];
}
const peerConnection = new RTCPeerConnection();
const dataChannel = peerConnection.createDataChannel("iot");
this.connections[iotIdentifier] = {
id: uuid(),
connection: peerConnection,
channel: dataChannel,
};
const offer = await peerConnection.createOffer();
peerConnection.setLocalDescription(offer);
const onConnectionChange = () => {
if (["failed", "closed", "disconnected"].includes(peerConnection.connectionState)) {
dataChannel.close();
peerConnection.close();
delete this.connections[iotIdentifier];
peerConnection.removeEventListener("connectionstatechange", onConnectionChange);
}
};
peerConnection.addEventListener("connectionstatechange", onConnectionChange);
const onIotAnswer = (payload) => {
const { iot_box_identifier, answer } = payload;
if (
iot_box_identifier !== iotIdentifier ||
peerConnection.signalingState !== "have-local-offer"
) {
return;
}
peerConnection.setRemoteDescription(answer);
this.busService.unsubscribe("webrtc_answer", onIotAnswer);
};
this.busService.subscribe("webrtc_answer", onIotAnswer);
this.busService.addChannel(this.websocket.iotChannel);
await this.websocket.sendMessage(iotIdentifier, { offer }, null, "webrtc_offer");
return this.connections[iotIdentifier];
}
}

View File

@@ -0,0 +1,96 @@
import { uuid } from "@web/core/utils/strings";
/**
* Class to handle Websocket connections
*/
export class IotWebsocket {
constructor() {
this.setup(...arguments);
}
async setup({ bus_service, orm, lazy_session }) {
this.busService = bus_service;
this.orm = orm;
if (lazy_session) {
lazy_session.getValue("iot_channel", (iotChannel) => {
this.iotChannel = iotChannel;
});
} else {
this.iotChannel = await this.orm.call("iot.channel", "get_iot_channel", [0]);
}
}
/**
* Send a message to the IoT Box
* @param iotBoxIdentifier Identifier of the IoT Box
* @param message Data to send to the device
* @param messageId Unique identifier for the message (optional)
* @param messageType Type of message to send (optional)
* @returns {Promise<*>} The message ID
*/
async sendMessage(iotBoxIdentifier, message, messageId = null, messageType = 'iot_action') {
messageId ??= uuid();
await this.orm.call("iot.channel", "send_message", [
{
iot_identifiers: [iotBoxIdentifier],
iot_identifier: iotBoxIdentifier, // compatibility with v19.1+ IoT Boxes
session_id: messageId,
...message
},
messageType
]);
return messageId;
}
/**
* Add a listener for events/messages coming from the IoT Box.
* This method allows defining callbacks for success and failure cases.
* @param iotBoxIdentifier Identifier of the IoT Box
* @param deviceIdentifier Identifier of the device connected to the IoT Box
* @param onSuccess Callback to run when a message is received (can return ``message``, ``deviceIdentifier``, and ``messageId``)
* @param onFailure Callback to run when the request fails (can return ``deviceIdentifier`` and ``messageId``)
* @param messageType The type of message to listen for (optional)
* @param sessionId The session ID to listen for (optional)
*/
onMessage(
iotBoxIdentifier,
deviceIdentifier,
onSuccess = (_message, _deviceIdentifier, _messageId) => {},
onFailure = (_message, _deviceIdentifier, _messageId) => {},
messageType = 'operation_confirmation',
sessionId = null,
) {
if (!this.iotChannel) {
console.error("No IoT Channel found");
return;
}
const timeoutId = setTimeout(() => {
console.debug("Websocket timeout for", iotBoxIdentifier, deviceIdentifier, sessionId);
onFailure({
status: "timeout",
message: "Timeout waiting for IoT Box response, please try again.",
}, deviceIdentifier, sessionId);
this.busService.unsubscribe(messageType, messageCallback);
}, 6000); // error callback if the listener is not called within 6 seconds
const messageCallback = (event) => {
const { session_id, iot_box_identifier, device_identifier, message } = event;
if (
iot_box_identifier !== iotBoxIdentifier ||
device_identifier !== deviceIdentifier ||
(sessionId && session_id !== sessionId)) {
return;
}
const callback = message.status === "success" || message.status?.status === "connected" ? onSuccess : onFailure;
callback(message);
clearTimeout(timeoutId);
this.busService.unsubscribe(messageType, messageCallback);
}
this.busService.addChannel(this.iotChannel);
this.busService.subscribe(messageType, messageCallback);
}
}