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/view_widgets/._device_list.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._device_list.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._header_button.xml
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._header_button.xml
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._iot_download_logs.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._iot_download_logs.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._iot_remote_debug.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._iot_remote_debug.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._iot_remote_debug.xml
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._iot_remote_debug.xml
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._iot_reset_password.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._iot_reset_password.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._iot_restart_odoo.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._iot_restart_odoo.js
Normal file
Binary file not shown.
BIN
fusion_iot/iot/static/src/view_widgets/._test_iot_box.js
Normal file
BIN
fusion_iot/iot/static/src/view_widgets/._test_iot_box.js
Normal file
Binary file not shown.
34
fusion_iot/iot/static/src/view_widgets/device_list.js
Normal file
34
fusion_iot/iot/static/src/view_widgets/device_list.js
Normal 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);
|
||||
14
fusion_iot/iot/static/src/view_widgets/header_button.xml
Normal file
14
fusion_iot/iot/static/src/view_widgets/header_button.xml
Normal 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>
|
||||
53
fusion_iot/iot/static/src/view_widgets/iot_download_logs.js
Normal file
53
fusion_iot/iot/static/src/view_widgets/iot_download_logs.js
Normal 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);
|
||||
100
fusion_iot/iot/static/src/view_widgets/iot_remote_debug.js
Normal file
100
fusion_iot/iot/static/src/view_widgets/iot_remote_debug.js
Normal 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);
|
||||
29
fusion_iot/iot/static/src/view_widgets/iot_remote_debug.xml
Normal file
29
fusion_iot/iot/static/src/view_widgets/iot_remote_debug.xml
Normal 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>
|
||||
58
fusion_iot/iot/static/src/view_widgets/iot_reset_password.js
Normal file
58
fusion_iot/iot/static/src/view_widgets/iot_reset_password.js
Normal 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);
|
||||
58
fusion_iot/iot/static/src/view_widgets/iot_restart_odoo.js
Normal file
58
fusion_iot/iot/static/src/view_widgets/iot_restart_odoo.js
Normal 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);
|
||||
122
fusion_iot/iot/static/src/view_widgets/test_iot_box.js
Normal file
122
fusion_iot/iot/static/src/view_widgets/test_iot_box.js
Normal 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);
|
||||
Reference in New Issue
Block a user