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,273 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import {
makeMockEnv,
models,
defineModels,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { uuid } from "@web/core/utils/strings";
import { browser } from "@web/core/browser/browser";
import { IotHttpService } from "@iot/network_utils/iot_http_service";
class IotChannel extends models.Model {
get_iot_channel() {
return "mockChannel";
}
}
defineModels({ IotChannel });
class DummyOrm {
async searchRead(model, domain, _fields) {
const [[, , iotBoxId]] = domain;
if (iotBoxId === 1) {
return [{ id: iotBoxId, ip: "127.0.0.1", identifier: "box-123" }];
}
return [{ id: iotBoxId, ip: "127.0.0.1", identifier: "box-456" }];
}
}
let iotHttpService;
let websocketMessages;
let notification;
let longpolling;
let websocket;
let webRtc;
let orm;
let onSuccess;
let onFailure;
let calledCallback = "pending";
beforeEach(async () => {
await makeMockEnv();
websocketMessages = [];
notification = {
added: [],
add: function (msg, opts) {
this.added.push({ msg, opts });
},
};
orm = new DummyOrm({ ip: "127.0.0.1", identifier: "box-123" });
patchWithCleanup(browser, {
fetch: async (_url) => {
return true;
},
});
// if we should receive a failed response from the IoT Box
let webRtcShouldFail = false;
let longpollingShouldFail = false;
let websocketShouldFail = false;
// if calling the action should fail
let webRtcShouldThrow = false;
let longpollingShouldThrow = false;
let websocketShouldThrow = false;
webRtc = {
sendMessage: async (identifier, payload, actionId, _messageType) => {
if (webRtcShouldThrow) {
throw new Error("webrtc sendMessage failed");
}
return actionId || uuid();
},
onMessage: async (identifier, deviceIdentifier, actionId, onSuccess, onFailure) => {
if (webRtcShouldThrow) {
throw new Error("webrtc onMessage failed");
}
if (webRtcShouldFail) {
onFailure({ status: "disconnected" }, deviceIdentifier);
return;
}
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
},
setThrow: (v) => {
webRtcShouldThrow = v;
},
setFail: (v) => {
webRtcShouldFail = v;
},
};
longpolling = {
sendMessage: async (ip, payload, actionId, _hasFallback) => {
if (longpollingShouldThrow) {
throw new Error("longpolling sendMessage failed");
}
return actionId || uuid();
},
onMessage: (ip, deviceIdentifier, onSuccess, onFailure, actionId) => {
if (longpollingShouldThrow) {
throw new Error("longpolling onMessage failed");
}
if (longpollingShouldFail) {
onFailure({ status: "disconnected" }, deviceIdentifier);
return;
}
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
},
setThrow: (v) => {
longpollingShouldThrow = v;
},
setFail: (v) => {
longpollingShouldFail = v;
},
};
websocket = {
sendMessage: async (identifier, payload, actionId, messageType) => {
if (websocketShouldThrow) {
throw new Error("websocket sendMessage failed");
}
websocketMessages.push({ identifier, payload, actionId, messageType });
return actionId || uuid();
},
onMessage: (
identifier,
deviceIdentifier,
onSuccess,
onFailure,
_messageType,
_actionId
) => {
if (websocketShouldThrow) {
throw new Error("websocket onMessage failed");
}
if (websocketShouldFail) {
onFailure({ status: "disconnected" }, deviceIdentifier);
return;
}
onSuccess({ status: "success", device_identifier: deviceIdentifier }, deviceIdentifier);
},
setThrow: (v) => {
websocketShouldThrow = v;
},
setFail: (v) => {
websocketShouldFail = v;
},
};
onSuccess = () => {
calledCallback = "onSuccess";
};
onFailure = () => {
calledCallback = "onFailure";
};
iotHttpService = new IotHttpService({ iot_longpolling: longpolling, websocket, webRtc, notification, orm } );
});
describe("action", () => {
test("uses WebRTC first and succeeds", async () => {
await iotHttpService.action(1, "device-1", { foo: "bar" }, onSuccess, onFailure);
expect(calledCallback).toBe("onSuccess");
expect(iotHttpService.connectionStatus).toBe("webrtc");
});
test("fallback to longpolling when WebRTC fails", async () => {
webRtc.setThrow(true);
await iotHttpService.action(1, "device-2", { a: "b" }, onSuccess, onFailure);
expect(calledCallback).toBe("onSuccess");
expect(iotHttpService.connectionStatus).toBe("longpolling");
});
test("fallback to websocket when both WebRTC and longpolling fail", async () => {
webRtc.setThrow(true);
longpolling.setThrow(true);
await iotHttpService.action(1, "device-3", { x: "y" }, onSuccess, onFailure);
expect(calledCallback).toBe("onSuccess");
expect(iotHttpService.connectionStatus).toBe("websocket");
});
test("all methods fail and onFailure is invoked with disconnected status", async () => {
webRtc.setThrow(true);
longpolling.setThrow(true);
websocket.setThrow(true);
await iotHttpService.action(1, "device-4", { something: "else" }, onSuccess, onFailure);
expect(calledCallback).toBe("onFailure");
expect(iotHttpService.connectionStatus).toBe("offline");
});
test("invalid iotBoxId (array unwraps Many2one)", async () => {
await iotHttpService.action([1], "device-array", { foo: "bar" }, onSuccess);
expect(calledCallback).toBe("onSuccess");
});
test("recent longpolling failure short-circuits longpolling path", async () => {
// simulate that longpolling just failed
iotHttpService.longpollingFailedTimestamp = Date.now();
webRtc.setThrow(true);
// longpolling should be skipped due to recent failure, so websocket is used
await iotHttpService.action(1, "device-5", { test: "val" }, onSuccess);
expect(calledCallback).toBe("onSuccess");
expect(iotHttpService.connectionStatus).toBe("websocket");
});
test("webrtc onMessage calls back onFailure", async () => {
webRtc.setFail(true); // make WebRTC onMessage report failure
await iotHttpService.action(1, "device-webrtc-fail", { foo: "bar" }, onSuccess, onFailure);
expect(calledCallback).toBe("onFailure");
expect(iotHttpService.connectionStatus).toBe("webrtc"); // received failure callback from webrtc
});
test("longpolling onMessage calls back onFailure", async () => {
webRtc.setThrow(true);
longpolling.setFail(true); // make longpolling onMessage report failure
await iotHttpService.action(
1,
"device-longpolling-fail",
{ foo: "bar" },
onSuccess,
onFailure
);
expect(calledCallback).toBe("onFailure");
expect(iotHttpService.connectionStatus).toBe("longpolling"); // received failure callback from longpolling
});
test("websocket onMessage calls back onFailure", async () => {
webRtc.setThrow(true);
longpolling.setThrow(true);
websocket.setFail(true); // make websocket onMessage report failure
await iotHttpService.action(
1,
"device-websocket-fail",
{ foo: "bar" },
onSuccess,
onFailure
);
expect(calledCallback).toBe("onFailure");
expect(iotHttpService.connectionStatus).toBe("websocket"); // received failure callback from websocket
});
test("IoT Box records were cached after success, then removed after failure", async () => {
await iotHttpService.action(
1,
"device-2",
{ a: "attempt-1-succeeds" },
onSuccess,
onFailure
);
await iotHttpService.action(
2,
"device-2",
{ a: "attempt-1-succeeds" },
onSuccess,
onFailure
);
expect(iotHttpService.cachedIotBoxes[1]?.identifier).toBe("box-123");
expect(iotHttpService.cachedIotBoxes[2]?.identifier).toBe("box-456");
// ensure that the second record (and only this one) is removed after failure
webRtc.setThrow(true);
longpolling.setThrow(true);
websocket.setThrow(true);
await iotHttpService.action(2, "device-2", { a: "attempt-2-fails" }, onSuccess, onFailure);
expect(iotHttpService.cachedIotBoxes[1]?.identifier).toBe("box-123");
expect(iotHttpService.cachedIotBoxes[2]).toBe(undefined);
});
});

View File

@@ -0,0 +1,247 @@
import { IotWebRtc } from "@iot/network_utils/iot_webrtc";
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import {
defineModels,
makeMockEnv,
models,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { EventBus } from "@odoo/owl";
class IotChannel extends models.Model {
get_iot_channel() {
return "mockChannel";
}
}
defineModels({ IotChannel });
class MockRtcDataChannel extends EventTarget {
constructor(id) {
super();
this.id = id;
this.readyState = "open";
this._messagesSent = [];
}
send(message) {
this._messagesSent.push(message);
}
close() {}
}
class MockRtcPeerConnection extends EventTarget {
static _instances = 0;
constructor() {
super();
MockRtcPeerConnection._instances += 1;
}
get sctp() {
return { maxMessageSize: 100 };
}
createDataChannel(id) {
return new MockRtcDataChannel(id);
}
createOffer() {
this.signalingState = "have-local-offer";
return "mockOffer";
}
setLocalDescription(description) {
this.localDescription = description;
return;
}
setRemoteDescription(description) {
this.remoteDescription = description;
this.connectionState = "connected";
return;
}
close() {}
}
const websocketMessages = [];
const setupWebRtc = () => {
const bus = new EventBus();
const busCallbacks = new Map();
const mockBusService = {
addChannel: () => {},
subscribe: (type, callback) => {
busCallbacks.set(callback, (event) => callback(event.detail));
bus.addEventListener(type, busCallbacks.get(callback));
},
unsubscribe: (type, callback) => bus.removeEventListener(type, busCallbacks.get(callback)),
trigger: bus.trigger.bind(bus),
};
const mockWebsocket = {
iotChannel: "mockChannel",
sendMessage: (iotIdentifier, message, messageId, messageType) => {
websocketMessages.push({ iotIdentifier, message, messageId, messageType });
},
};
MockRtcPeerConnection._instances = 0;
return { webRtc: new IotWebRtc(mockBusService, mockWebsocket), bus };
};
const setupWebRtcWithConnection = async (identifier) => {
const { webRtc, bus } = setupWebRtc();
await webRtc.openConnection(identifier);
bus.trigger("webrtc_answer", { iot_box_identifier: identifier, answer: "mockAnswer" });
return { webRtc, bus };
};
beforeEach(async () => {
await makeMockEnv();
websocketMessages.splice(0, websocketMessages.length);
patchWithCleanup(window, {
RTCPeerConnection: MockRtcPeerConnection,
});
});
describe("opening connection", () => {
test("sends webrtc offer via the websocket", async () => {
const { webRtc } = setupWebRtc();
await webRtc.openConnection("iot");
expect(websocketMessages).toHaveLength(1);
expect(websocketMessages[0].messageType).toBe("webrtc_offer");
expect(websocketMessages[0].message).toEqual({ offer: "mockOffer" });
});
test("saves connection to connections dict", async () => {
const { webRtc } = setupWebRtc();
await webRtc.openConnection("iot");
expect(webRtc.connections["iot"]).toBeOfType("object");
expect(webRtc.connections["iot"].id).toBeOfType("string");
expect(webRtc.connections["iot"].connection).toBeInstanceOf(MockRtcPeerConnection);
expect(webRtc.connections["iot"].channel).toBeInstanceOf(MockRtcDataChannel);
});
test("sets local offer", async () => {
const { webRtc } = setupWebRtc();
await webRtc.openConnection("iot");
expect(webRtc.connections["iot"].connection).toBeInstanceOf(MockRtcPeerConnection);
expect(webRtc.connections["iot"].connection.localDescription).toBe("mockOffer");
});
test("sets remote offer received via bus", async () => {
const { webRtc, bus } = setupWebRtc();
await webRtc.openConnection("iot");
bus.trigger("webrtc_answer", { iot_box_identifier: "iot", answer: "mockAnswer" });
expect(webRtc.connections["iot"].connection.remoteDescription).toBe("mockAnswer");
});
test("only opens the connection once", async () => {
const { webRtc } = setupWebRtc();
const openConnectionPromise1 = webRtc.openConnection("iot");
const openConnectionPromise2 = webRtc.openConnection("iot");
await Promise.all([openConnectionPromise1, openConnectionPromise2]);
expect(MockRtcPeerConnection._instances).toBe(1);
});
test("opens separate connections per IoT box", async () => {
const { webRtc, bus } = setupWebRtc();
const openConnectionPromise1 = webRtc.openConnection("iot");
const openConnectionPromise2 = webRtc.openConnection("iot2");
await Promise.all([openConnectionPromise1, openConnectionPromise2]);
bus.trigger("webrtc_answer", { iot_box_identifier: "iot", answer: "mockAnswer" });
bus.trigger("webrtc_answer", { iot_box_identifier: "iot2", answer: "mockAnswer2" });
expect(MockRtcPeerConnection._instances).toBe(2);
expect(webRtc.connections["iot"].connection.remoteDescription).toBe("mockAnswer");
expect(webRtc.connections["iot2"].connection.remoteDescription).toBe("mockAnswer2");
});
});
describe("sending message", () => {
test("message is sent", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testValue" };
await webRtc.sendMessage("iot", testMessage);
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
expect(sentMessage).toMatchObject(testMessage);
expect(sentMessage.session_id).toBeOfType("string");
});
test("session_id is set if not provided", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testValue" };
await webRtc.sendMessage("iot", testMessage);
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
expect(sentMessage.session_id).toBeOfType("string");
});
test("session_id and message_type are included in message", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testValue" };
await webRtc.sendMessage("iot", testMessage, "testId", "test_type");
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(1);
const sentMessage = JSON.parse(webRtc.connections["iot"].channel._messagesSent[0]);
expect(sentMessage.session_id).toBe("testId");
expect(sentMessage.message_type).toBe("test_type");
});
test("large messages are chunked", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testLongMessageToMakeChunkingBeRequired" };
await webRtc.sendMessage("iot", testMessage, "testId", "test_type");
expect(webRtc.connections["iot"].channel._messagesSent).toHaveLength(4);
expect(webRtc.connections["iot"].channel._messagesSent[0]).toBe("chunked_start");
expect(webRtc.connections["iot"].channel._messagesSent[3]).toBe("chunked_end");
const fullMessage = JSON.parse(
webRtc.connections["iot"].channel._messagesSent.slice(1, 3).join("")
);
expect(fullMessage).toMatchObject(testMessage);
});
test("throws if connection is disconnected", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testValue" };
webRtc.connections["iot"].connection.connectionState = "disconnected";
await expect(webRtc.sendMessage("iot", testMessage)).rejects.toMatch(
"WebRTC connection for iot is 'disconnected'"
);
});
test("throws if data channel is closed", async () => {
const { webRtc } = await setupWebRtcWithConnection("iot");
const testMessage = { testKey: "testValue" };
webRtc.connections["iot"].channel.readyState = "closed";
await expect(webRtc.sendMessage("iot", testMessage)).rejects.toMatch(
"WebRTC channel for iot is 'closed'"
);
});
});