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,34 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
export class DeviceListField extends X2ManyField {
setup() {
super.setup();
this.orm = useService("orm");
this.action = useService("action");
}
/**
* By default the `X2ManyField` opens records in a dialog,
* however this dialog doesn't run the `js_class` Controller
* which is responsible for saving fields to the IoT box.
*
* We override the behaviour to open the regular form view
* for the device, working around the issue.
* @override
*/
async openRecord(record) {
const action = await this.orm.call(record.resModel, "get_formview_action", [[record.resId]], {
context: this.props.context,
});
await this.action.doAction(action);
}
}
export const deviceListField = {
...x2ManyField,
component: DeviceListField,
};
registry.category("fields").add("device_list_field", deviceListField);

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="iot.HeaderButton">
<button type="button"
t-att-class="'btn btn-' + props.btn_class"
t-att-data-tooltip="props.btn_name"
t-att-aria-label="props.btn_name"
t-on-click.stop="onClick">
<t t-esc='props.btn_name'/>
</button>
</t>
</templates>

View File

@@ -0,0 +1,53 @@
import { registry } from '@web/core/registry';
import { useService } from '@web/core/utils/hooks';
import { _t } from '@web/core/l10n/translation';
import { Component } from "@odoo/owl";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { formatEndpoint } from "@iot_base/network_utils/http";
export class IoTBoxDownloadLogs extends Component {
static template = `iot.HeaderButton`;
static props = {
...standardWidgetProps,
btn_name: { type: String },
btn_class: { type: String },
};
setup() {
super.setup();
this.notification = useService('notification');
this.http = useService('http');
}
get ip_url() {
return formatEndpoint(this.props.record.data.ip, '');
}
get name() {
return this.props.record.data.name;
}
async onClick() {
try {
const response = await this.http.get(this.ip_url + '/hw_proxy/hello', 'text');
if (response == 'ping') {
window.location = this.ip_url + '/iot_drivers/download_logs';
} else {
this.doWarnFail();
}
} catch {
this.doWarnFail();
}
}
doWarnFail() {
this.notification.add(_t('Failed to download logs from %s', this.name), { type: "danger" });
}
}
export const ioTBoxDownloadLogs = {
component: IoTBoxDownloadLogs,
extractProps: ({ attrs }) => {
return {
btn_name: attrs.btn_name,
btn_class: attrs.btn_class
};
},
};
registry.category("view_widgets").add("iot_download_logs", ioTBoxDownloadLogs);

View File

@@ -0,0 +1,100 @@
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { _t } from "@web/core/l10n/translation";
export class IoTRemoteDebug extends Component {
static template = `iot.HeaderButton`;
static props = {
...standardWidgetProps,
btn_name: { type: String },
btn_class: { type: String },
};
setup() {
super.setup();
this.iotHttp = useService("iot_http");
this.dialog = useService("dialog");
this.notification = useService("notification");
this.state = useState({ enabled: false });
// Get ngrok status on view load
this.iotHttp.websocket.onMessage(this.identifier, null, this.onMessageUpdateStatus.bind(this));
this.iotHttp.websocket.sendMessage(this.identifier, { 'status': true }, null, 'remote_debug');
}
get identifier() {
return this.props.record.data.identifier;
}
async onClick() {
this.dialog.add(TokenDialog, {
validate: this.enableRemoteDebug.bind(this),
enabled: this.state.enabled,
});
}
async enableRemoteDebug(token) {
this.iotHttp.websocket.onMessage(
this.identifier,
null,
(message) => {
this.onMessageUpdateStatus(message);
if (token && !this.state.enabled) {
return this.onFailure();
}
this.notification.add(
_t("Remote debug is %s.", this.state.enabled ? _t("enabled") : _t("disabled")), {
type: "info",
}
);
},
this.onFailure.bind(this),
);
this.iotHttp.websocket.sendMessage(this.identifier, { token }, null, "remote_debug");
}
onMessageUpdateStatus(message) {
this.state.enabled = message.result?.enabled;
}
onFailure() {
this.notification.add(_t("Failed to toggle remote debug."), {
type: "danger",
});
}
}
export const ioTRemoteDebug = {
component: IoTRemoteDebug,
extractProps: ({ attrs }) => {
return {
btn_name: attrs.btn_name,
btn_class: attrs.btn_class,
};
},
};
export class TokenDialog extends Component {
static template = "iot.RemoteDebugDialog";
static components = { Dialog };
static props = {
validate: Function,
close: Function,
enabled: Boolean,
};
setup() {
this.state = useState({ token: "" });
}
validate() {
this.props.validate(this.state.token);
this.props.close();
}
}
registry.category("view_widgets").add("iot_remote_debug", ioTRemoteDebug);

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="iot.RemoteDebugDialog">
<Dialog title.translate="Remote Debug">
<div t-if="!props.enabled" class="d-flex flex-column gap-3">
<div>
You can enable remote debug on your IoT Box by providing a Ngrok authtoken.<br/>
<span><b>Be careful:</b> it enables remote access to your local network to the owner of the token.</span>
</div>
<div class="d-flex gap-3 w-50">
<label class="form-label">Token</label>
<div class="o_field_widget o_field_char w-100">
<input t-model="state.token" class="o_input" type="text"/>
</div>
</div>
</div>
<div t-else="">
Remote debug is enabled, the owner of the token you provided has access to both your IoT Box
and local network. If it's unintended, click on "Disable" below.
</div>
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="validate" t-att-disabled="!state.token and !props.enabled">
<t t-esc="props.enabled ? 'Disable' : 'Enable'"/>
</button>
<button class="btn btn-secondary" t-on-click="props.close">Discard</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,58 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { Component } from "@odoo/owl";
export class IoTResetPassword extends Component {
static template = `iot.HeaderButton`;
static props = {
...standardWidgetProps,
btn_name: { type: String },
btn_class: { type: String },
};
setup() {
super.setup();
this.longpolling = useService("iot_longpolling");
this.notification = useService("notification");
}
get ip() {
return this.props.record.data.ip;
}
get name() {
return this.props.record.data.name;
}
async onClick() {
try {
const response = await this.longpolling.action(this.ip, null, null, false, "/iot_drivers/generate_password");
if (!response?.result?.password) {
return this.doWarnFail();
}
this.notification.add(response.result.password, {
type: "info",
title: _t("New SSH password for %s", this.name),
});
} catch (error) {
console.error(error);
}
}
doWarnFail() {
this.notification.add(_t("Failed to reset %s password.", this.name), { type: "danger" });
}
}
export const ioTResetPassword = {
component: IoTResetPassword,
extractProps: ({ attrs }) => {
return {
btn_name: attrs.btn_name,
btn_class: attrs.btn_class,
};
},
};
registry.category("view_widgets").add("iot_reset_password", ioTResetPassword);

View File

@@ -0,0 +1,58 @@
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
import { Component } from "@odoo/owl";
export class IoTRestartOdoo extends Component {
static template = `iot.HeaderButton`;
static props = {
...standardWidgetProps,
btn_name: { type: String },
btn_class: { type: String },
};
setup() {
super.setup();
this.dialog = useService("dialog");
this.iotHttpService = useService("iot_http");
this.notification = useService("notification");
}
async onClick() {
this.dialog.add(ConfirmationDialog, {
body: _t("Are you sure you want to restart Odoo on the IoT Box?"),
confirm: this.restartOdoo.bind(this),
cancel: () => {},
});
}
restartOdoo() {
const { identifier, name } = this.props.record.data;
this.iotHttpService.action(
this.props.record._config.resId,
identifier,
{ action: "restart_odoo" },
() => this.notification.add(_t("%s is currently restarting", name), {
type: "info",
}),
() => {
this.notification.add(
_t("Failed to send the restart command to the IoT Box ('%s')", name), { type: "danger" }
)
},
);
}
}
export const ioTRestartOdoo = {
component: IoTRestartOdoo,
extractProps: ({ attrs }) => {
return {
btn_name: attrs.btn_name,
btn_class: attrs.btn_class,
};
},
};
registry.category("view_widgets").add("iot_restart_odoo", ioTRestartOdoo);

View File

@@ -0,0 +1,122 @@
import { registry } from '@web/core/registry';
import { useService } from '@web/core/utils/hooks';
import { uuid } from "@web/core/utils/strings";
import { _t } from '@web/core/l10n/translation';
import { Component } from "@odoo/owl";
import { standardWidgetProps } from "@web/views/widgets/standard_widget_props";
export class TestIotBox extends Component {
static template = `iot.HeaderButton`;
static props = {
...standardWidgetProps,
btn_name: { type: String },
btn_class: { type: String },
};
setup() {
super.setup();
this.notification = useService('notification');
this.iotHttpService = useService('iot_http');
}
async onClick() {
const { ip, identifier } = this.props.record.data;
const requestId = uuid();
this.completeSuccess = true;
const failureCallback = (protocol) => {
this.notification.add(_t("Communication protocol '%s' is not working properly.", protocol), {
type: 'danger'
});
this.completeSuccess = false;
}
this.removeTestingNotification = this.notification.add(
_t("Testing communication with IoT Box and network quality, please wait..."),
{
type: 'info',
autocloseDelay: 30000,
}
);
// Check webRTC
try {
await this.iotHttpService.webRtc.onMessage(identifier, identifier, requestId, () => {}, () => {
failureCallback("WebRTC");
});
await this.iotHttpService.webRtc.sendMessage(identifier, {}, requestId, "test_protocol");
} catch {
// Catch connection timeout (not going through onFailure)
failureCallback("WebRTC");
}
// Check longpolling (no onMessage as we only check if the endpoint is reachable)
try {
await this.iotHttpService.longpolling.sendMessage(ip, {
device_identifier: identifier,
data: {}
}, requestId, true);
} catch {
failureCallback("Longpolling");
}
// Check websocket
this.iotHttpService.websocket.onMessage(
identifier,
identifier,
this.onConnectionTestSuccess.bind(this),
() => failureCallback("Websocket"),
undefined,
requestId,
);
await this.iotHttpService.websocket.sendMessage(identifier, {}, requestId, "test_connection");
}
onConnectionTestSuccess(data) {
this.removeTestingNotification?.();
if (this.completeSuccess) {
this.notification.add(_t("All communication protocols are working properly."), { type: 'success' });
}
if (!data.result) {
this.notification.add(
_t("Failed to check IoT Box network, check that it's connected to the Internet."), {
type: 'danger'
}
);
return;
}
const { lan_quality, wan_quality } = data.result;
let type = 'success';
if (lan_quality === "normal" || wan_quality === "normal") {
type = 'info';
}
if (lan_quality === "slow" || wan_quality === "slow") {
type = 'warning';
}
if (lan_quality === "unreachable" || wan_quality === "unreachable") {
type = 'danger';
}
this.notification.add(
_t("IoT Box local network is %(lan_quality)s and internet is %(wan_quality)s", {
lan_quality,
wan_quality
}),
{
type,
autocloseDelay: 6000, // display longer to read (= websocket timeout)
}
);
}
}
export const testIotBox = {
component: TestIotBox,
extractProps: ({ attrs }) => {
return {
btn_name: attrs.btn_name,
btn_class: attrs.btn_class
};
},
};
registry.category("view_widgets").add("test_iot_box", testIotBox);