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

Binary file not shown.

View 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();
}

View 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);