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:
BIN
fusion_iot/iot/static/src/network_utils/._iot_http_service.js
Normal file
BIN
fusion_iot/iot/static/src/network_utils/._iot_http_service.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/network_utils/._iot_webrtc.js
Normal file
BIN
fusion_iot/iot/static/src/network_utils/._iot_webrtc.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/network_utils/._iot_websocket.js
Normal file
BIN
fusion_iot/iot/static/src/network_utils/._iot_websocket.js
Normal file
Binary file not shown.
297
fusion_iot/iot/static/src/network_utils/iot_http_service.js
Normal file
297
fusion_iot/iot/static/src/network_utils/iot_http_service.js
Normal 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);
|
||||
202
fusion_iot/iot/static/src/network_utils/iot_webrtc.js
Normal file
202
fusion_iot/iot/static/src/network_utils/iot_webrtc.js
Normal 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];
|
||||
}
|
||||
}
|
||||
96
fusion_iot/iot/static/src/network_utils/iot_websocket.js
Normal file
96
fusion_iot/iot/static/src/network_utils/iot_websocket.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user