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_base/static/src/network_utils/._http.js
Normal file
BIN
fusion_iot/iot_base/static/src/network_utils/._http.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot_base/static/src/network_utils/._longpolling.js
Normal file
BIN
fusion_iot/iot_base/static/src/network_utils/._longpolling.js
Normal file
Binary file not shown.
42
fusion_iot/iot_base/static/src/network_utils/http.js
Normal file
42
fusion_iot/iot_base/static/src/network_utils/http.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
/**
|
||||
* Format the endpoint to send the request to
|
||||
* Used to ensure the request is sent with the same protocol as the current page
|
||||
* (e.g. if the current page is HTTPS, the request will be sent to the IoT Box using HTTPS)
|
||||
* @param {string} ip IP Address of the IoT Box
|
||||
* @param {string} route Route to send the request to
|
||||
* @param {boolean} forceHttp If true, always use HTTP even in HTTPS context (for browser supporting LNA)
|
||||
* @returns {string} The formatted endpoint
|
||||
*/
|
||||
export function formatEndpoint(ip, route, forceHttp = false) {
|
||||
const protocol = forceHttp ? "http:" : window.location.protocol;
|
||||
const url = new URL(`${protocol}//${ip}`);
|
||||
url.pathname = route;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a POST request to the IoT Box
|
||||
* @param {string} ip IP Address of the IoT Box
|
||||
* @param {string} route Endpoint to send the request to
|
||||
* @param {Record<string, unknown>} params Parameters to send with the request (optional)
|
||||
* @param {number} timeout Time before the request times out (default: 6000ms)
|
||||
* @param {Record<string, unknown>} headers HTTP headers to send with the request (optional)
|
||||
* @param {AbortSignal} abortSignal AbortSignal used to cancel the request early (optional)
|
||||
* @param {boolean} useLna If true, use local targetAddressSpace + Force HTTP
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function post(ip, route, params = {}, timeout = 6000, headers = {}, abortSignal = null, useLna = false) {
|
||||
const endpoint = formatEndpoint(ip, route, useLna);
|
||||
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||
const response = await browser.fetch(endpoint, {
|
||||
body: JSON.stringify({'params': params}),
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json", ...headers},
|
||||
signal: abortSignal ? AbortSignal.any([abortSignal, timeoutSignal]) : timeoutSignal,
|
||||
targetAddressSpace: useLna ? "local" : undefined,
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
236
fusion_iot/iot_base/static/src/network_utils/longpolling.js
Normal file
236
fusion_iot/iot_base/static/src/network_utils/longpolling.js
Normal file
@@ -0,0 +1,236 @@
|
||||
import { registry } from '@web/core/registry';
|
||||
import { post } from '@iot_base/network_utils/http';
|
||||
import { uuid } from "@web/core/utils/strings";
|
||||
import { _t } from '@web/core/l10n/translation';
|
||||
|
||||
export class IoTLongpolling {
|
||||
static serviceDependencies = ["notification", "orm"];
|
||||
actionRoute = '/iot_drivers/action';
|
||||
pollRoute = '/iot_drivers/event';
|
||||
|
||||
rpcDelay = 1500;
|
||||
maxRpcDelay = 15000;
|
||||
|
||||
_retries = 0;
|
||||
_listeners = {};
|
||||
|
||||
constructor() {
|
||||
this.setup(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup in addition to constructor to allow patching
|
||||
*/
|
||||
setup({ notification, orm }) {
|
||||
this._session_id = uuid();
|
||||
this._delayedStartPolling(this.rpcDelay);
|
||||
this.notification = notification;
|
||||
this.orm = orm;
|
||||
this.useLna = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a device_identifier to listeners[iot_ip] and restart polling
|
||||
*
|
||||
* @param {string} iot_ip
|
||||
* @param {Array} devices list of devices
|
||||
* @param {string} listener_id
|
||||
* @param {boolean} fallback if true, no notification will be displayed on fail
|
||||
* @param {Callback} callback
|
||||
*/
|
||||
async addListener(iot_ip, devices, listener_id, callback, fallback = true) {
|
||||
if (!this._listeners[iot_ip]) {
|
||||
this._listeners[iot_ip] = {
|
||||
last_event: 0,
|
||||
devices: {},
|
||||
session_id: this._session_id,
|
||||
abortController: null,
|
||||
};
|
||||
}
|
||||
for (const device of devices) {
|
||||
this._listeners[iot_ip].devices[device] = {
|
||||
listener_id: listener_id,
|
||||
device_identifier: device,
|
||||
callback: callback,
|
||||
};
|
||||
}
|
||||
this.stopPolling(iot_ip);
|
||||
this.startPolling(iot_ip, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening to iot device with id `device_identifier`
|
||||
* @param {string} iot_ip
|
||||
* @param {string} device_identifier
|
||||
* @param {string} listener_id
|
||||
*/
|
||||
removeListener(iot_ip, device_identifier, listener_id) {
|
||||
const listener = this._listeners[iot_ip];
|
||||
const device = listener.devices[device_identifier];
|
||||
if (device && device.listener_id === listener_id) {
|
||||
delete listener.devices[device_identifier];
|
||||
if (!Object.keys(listener.devices).length) {
|
||||
this.stopPolling(iot_ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an action on device_identifier
|
||||
* Action depends on the driver that supports the device
|
||||
*
|
||||
* @param {string} iot_ip
|
||||
* @param {string} device_identifier
|
||||
* @param {Object} data contains the information needed to perform an action on this device_identifier
|
||||
* @param {boolean} fallback if true, no notification will be displayed on fail
|
||||
* @param {string} route endpoint to call on the IoT Box (default: /iot_drivers/action)
|
||||
*/
|
||||
action(iot_ip, device_identifier, data, fallback = false, route = null) {
|
||||
this.protocol = window.location.protocol;
|
||||
const body = {
|
||||
session_id: this._session_id,
|
||||
device_identifier: device_identifier,
|
||||
data,
|
||||
};
|
||||
return this._rpcIoT(iot_ip, route || this.actionRoute, body, undefined, fallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a long polling, i.e. it continually opens a long poll
|
||||
* connection as long as it is not stopped (@see `stopPolling`)
|
||||
* @param {string} iot_ip
|
||||
* @param {boolean} fallback if true, no notification will be displayed on fail
|
||||
*/
|
||||
startPolling(iot_ip, fallback = true) {
|
||||
if (iot_ip) {
|
||||
if (!this._listeners[iot_ip].abortController) {
|
||||
this._poll(iot_ip, fallback);
|
||||
}
|
||||
} else {
|
||||
const self = this;
|
||||
Object.keys(this._listeners).forEach((ip) => {
|
||||
self.startPolling(ip);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops any started long polling
|
||||
*
|
||||
* Aborts a pending long-poll so that we immediately remove ourselves
|
||||
* from listening on notifications on this channel.
|
||||
*/
|
||||
stopPolling(iot_ip) {
|
||||
if (this._listeners[iot_ip].abortController) {
|
||||
this._listeners[iot_ip].abortController.abort();
|
||||
this._listeners[iot_ip].abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
_delayedStartPolling(delay) {
|
||||
// ``fallback: true`` to avoid error notification on longpolling setup
|
||||
setTimeout(() => this.startPolling(null, true), delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an RPC to the box
|
||||
* Used to do both polling or action
|
||||
*
|
||||
* @param {string} iot_ip IP of the IoT Box
|
||||
* @param {string} route endpoint to call on the IoT Box
|
||||
* @param {Object} params information needed to perform an action or the listener for the polling
|
||||
* @param {number} timeout time before the request times out (undefined to use default timeout from http.js)
|
||||
* @param {boolean} fallback if true, no notification will be displayed on fail
|
||||
* @param {Object} headers headers to send with the request (optional, allows patching)
|
||||
*/
|
||||
async _rpcIoT(iot_ip, route, params, timeout = undefined, fallback = false, headers = undefined) {
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
|
||||
if (this._listeners[iot_ip] && route === this.pollRoute) {
|
||||
this._listeners[iot_ip].abortController = abortController;
|
||||
}
|
||||
return await post(iot_ip, route, params, timeout, headers, abortController.signal, this.useLna);
|
||||
} catch (error) {
|
||||
if (!fallback && error?.name !== "AbortError") {
|
||||
this._doWarnFail(iot_ip);
|
||||
}
|
||||
throw new Error("Longpolling action failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a poll request to an IoT Box
|
||||
*
|
||||
* @param {string} iot_ip
|
||||
* @param {boolean} fallback if true, no notification will be displayed on fail
|
||||
*/
|
||||
_poll(iot_ip, fallback = true) {
|
||||
const listener = this._listeners[iot_ip];
|
||||
|
||||
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
|
||||
this._rpcIoT(iot_ip, this.pollRoute, { listener: listener }, 60000, fallback).then(
|
||||
(result) => {
|
||||
this._retries = 0;
|
||||
this._listeners[iot_ip].abortController = null;
|
||||
if (result.result) {
|
||||
if (this._session_id === result.result.session_id) {
|
||||
this._onSuccess(iot_ip, result.result);
|
||||
}
|
||||
}
|
||||
const remainingDevices = Object.keys(this._listeners[iot_ip].devices || {});
|
||||
if (remainingDevices.length > 0 && !this._listeners[iot_ip].abortController) {
|
||||
this._poll(iot_ip);
|
||||
}
|
||||
},
|
||||
(e) => {
|
||||
if (e.name === "TimeoutError") {
|
||||
this._onError();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_onSuccess(iot_ip, result) {
|
||||
this._listeners[iot_ip].last_event = result.time;
|
||||
this._listeners[iot_ip].devices[result.device_identifier]?.callback(result);
|
||||
this._retries = 0;
|
||||
}
|
||||
|
||||
_onError() {
|
||||
this._retries++;
|
||||
this._delayedStartPolling(Math.min(this.rpcDelay * this._retries, this.maxRpcDelay));
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is needed in _poll.
|
||||
* @param {string} url
|
||||
*/
|
||||
_doWarnFail(url) {
|
||||
this.notification.add(
|
||||
_t("Failed to reach IoT Box at %s", url),
|
||||
{
|
||||
title: _t("Connection to IoT Box failed"),
|
||||
type: "danger",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable using Local Network Access.
|
||||
* This forces HTTP on all IoT requests.
|
||||
* @param {boolean} isLnaEnabled
|
||||
*/
|
||||
setLna(isLnaEnabled) {
|
||||
this.useLna = isLnaEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
export const iotLongpollingService = {
|
||||
dependencies: IoTLongpolling.serviceDependencies,
|
||||
start(_, deps) {
|
||||
return new IoTLongpolling(deps);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category('services').add('iot_longpolling', iotLongpollingService);
|
||||
Reference in New Issue
Block a user