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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import iot_box
from . import iot_channel
from . import iot_device
from . import ir_actions_report
from . import ir_config_parameter
from . import ir_http
from . import update

View File

@@ -0,0 +1,128 @@
from datetime import timedelta
import logging
import secrets
from urllib.parse import urlsplit
from odoo import api, fields, models
from odoo.http import request
_logger = logging.getLogger(__name__)
class IotBox(models.Model):
_name = 'iot.box'
_description = 'IoT Box'
name = fields.Char('Name', required=True)
identifier = fields.Char(string='Identifier', readonly=True)
device_ids = fields.One2many('iot.device', 'iot_id', string="Devices")
device_count = fields.Integer(compute='_compute_device_count')
ip = fields.Char('Domain Address', readonly=True)
drivers_auto_update = fields.Boolean('Automatic drivers update', help='Automatically update drivers when the IoT Box boots', default=True)
version = fields.Char('Image Version', readonly=True)
version_commit_url = fields.Html(readonly=True, compute='_compute_commit_url')
company_id = fields.Many2one('res.company', 'Company')
ssl_certificate_end_date = fields.Datetime('SSL Certificate End Date', readonly=True)
must_install_fdm_module = fields.Boolean(
"A fiscal data module is connected to this IoT Box", readonly=True, compute="_compute_must_install_fdm_module"
)
def _default_token(self):
"""Generate a token used in the iot box "token" field or by the wizards used to connect a new IoT Box.
Also clears the token from the configuration parameters if used to assign the "token" field to
make the token unique per IoT Box.
:return: The token generated by the wizard.
"""
icp_sudo = self.env['ir.config_parameter'].sudo()
iot_token_param = icp_sudo.search([('key', '=', 'iot.iot_token')], limit=1)
iot_token_value = iot_token_param.value if iot_token_param else None
if (
not iot_token_param
or not iot_token_value
or iot_token_param.write_date + timedelta(minutes=15) < fields.Datetime.now()
):
_logger.info("No valid token found in the configuration parameters, generating a new one.")
iot_token_value = secrets.token_hex(16)
icp_sudo.set_param('iot.iot_token', iot_token_value)
return iot_token_value
token = fields.Char(default=lambda self: self._default_token(), readonly=True)
def _compute_device_count(self):
for box in self:
box.device_count = len(box.device_ids)
@api.ondelete(at_uninstall=True)
def _unlink_iot_box(self):
self.env['iot.channel'].send_message({
"iot_identifiers": self.mapped('identifier')
}, 'server_clear')
def open_homepage(self):
self.ensure_one()
scheme = urlsplit(request.httprequest.referrer).scheme
return {
'type': 'ir.actions.act_url',
'url': f'{scheme}://{self.ip}' if scheme == 'https' else f'{scheme}://{self.ip}:8069',
'target': 'new',
}
@api.model
def connect_iot_box(self, local_iot_boxes):
"""
This method is called when pressing the "Connect" button in the IoT app.
Used to connect a new IoT Box to a database.
:return: action to open the wizard view depending on the result of the iot-proxy request sent by the wizard
"""
wizard = self.env['add.iot.box'].create({})
self.env['iot.discovered.box'].create([
{
"pairing_code": box["pairing_code"],
"serial_number": box.get("serial_number"),
"add_iot_box_wizard_id": wizard.id,
}
for box in local_iot_boxes
])
return wizard.add_iot_box_wizard_action()
@api.depends('device_ids')
def _compute_must_install_fdm_module(self):
is_module_installed = (
self.env['ir.module.module'].sudo().search([('name', '=', 'pos_blackbox_be')], limit=1).state == 'installed'
)
for box in self:
box.must_install_fdm_module = (
self.env.company.country_id.code == "BE"
and not is_module_installed
and any(device.type == 'fiscal_data_module' for device in box.device_ids)
)
def install_fdm_module(self):
"""Install the pos_blackbox_be module if it is not installed and a fiscal data module is connected to the IoT Box."""
if not self.must_install_fdm_module:
return
module = self.env['ir.module.module'].sudo().search([('name', '=', 'pos_blackbox_be')], limit=1)
if module and module.state != 'installed':
module.button_immediate_install()
_logger.info("pos_blackbox_be module installed successfully.")
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
_logger.warning("pos_blackbox_be module is already installed or not found.")
return None
@api.depends('version')
def _compute_commit_url(self):
base_url = "https://www.github.com/odoo/odoo/commit/"
for box in self:
if box.version and "#" in box.version:
image_version, commit_hash = box.version.split("#", 1)
box.version_commit_url = (
f'<span>{image_version}#<a href="{base_url}{commit_hash}" target="_blank">{commit_hash}</a></span>'
)
else:
box.version_commit_url = f'<span>{box.version}</span>' if box.version else False

View File

@@ -0,0 +1,29 @@
import secrets
from odoo import api, models
class IotChannel(models.AbstractModel):
_name = 'iot.channel'
_description = "The Websocket IoT Channel"
def get_iot_channel(self):
"""Get the IoT websocket channel name (unique for every company).
:return: The IoT websocket channel used to send the message
"""
ir_config_parameter = self.env['ir.config_parameter'].sudo()
ws_channel = ir_config_parameter.get_param('iot.ws_channel')
if not ws_channel:
ws_channel = ir_config_parameter.set_param('iot.ws_channel', f'iot_channel-{secrets.token_hex(16)}')
return ws_channel
@api.model
def send_message(self, message, message_type='iot_action'):
"""Send a message to a device via websocket.
:param dict message: The message to send to the IoT Box
:param str message_type: The type of the message (Default: call an action on a device)
"""
self.env['bus.bus']._sendone(self.get_iot_channel(), message_type, message)

View File

@@ -0,0 +1,111 @@
from odoo import api, fields, models
class IotDevice(models.Model):
_name = 'iot.device'
_description = 'IOT Device'
iot_id = fields.Many2one('iot.box', string='IoT Box', required=True, index=True, ondelete='cascade')
name = fields.Char('Name')
identifier = fields.Char(string='Identifier', readonly=True)
type = fields.Selection([
('printer', 'Printer'),
('camera', 'Camera'),
('keyboard', 'Keyboard'),
('scanner', 'Barcode Scanner'),
('device', 'Device'),
('payment', 'Payment Terminal'),
('scale', 'Scale'),
('display', 'Display'),
('fiscal_data_module', 'Fiscal Data Module'),
('unsupported', 'Unsupported'),
],
readonly=True,
default='device',
string='Type',
help="Type of device.",
)
manufacturer = fields.Char(string='Manufacturer', readonly=True)
connection = fields.Selection([
('network', 'Network'),
('direct', 'USB'),
('bluetooth', 'Bluetooth'),
('serial', 'Serial'),
('hdmi', 'HDMI'),
],
readonly=True,
string="Connection",
help="Type of connection.",
)
report_ids = fields.Many2many('ir.actions.report', string='Reports')
iot_ip = fields.Char(related="iot_id.ip")
company_id = fields.Many2one('res.company', 'Company', related="iot_id.company_id")
connected_status = fields.Selection([
('disconnected', 'Disconnected'),
('connected', 'Connected'),
],
default='disconnected',
readonly=True
)
keyboard_layout = fields.Many2one('iot.keyboard.layout', string='Keyboard Layout')
display_url = fields.Char(
'Display URL',
help=(
"URL of the page that will be displayed by the device, "
"leave empty to use the customer facing display of the POS."
)
)
manual_measurement = fields.Boolean(
'Manual Measurement',
compute="_compute_manual_measurement",
help="Manually read the measurement from the device"
)
is_scanner = fields.Boolean(
string='Is Scanner',
compute="_compute_is_scanner",
inverse="_set_scanner",
help="Manually switch the device type between keyboard and scanner"
)
subtype = fields.Selection([
('receipt_printer', 'Receipt Printer'),
('label_printer', 'Label Printer'),
('office_printer', 'Office Printer'),
('', '')
],
default='',
help='Subtype of device.',
)
@api.depends('name', 'iot_id', 'connection')
@api.depends_context('formatted_display_name')
def _compute_display_name(self):
connection_display_values = dict(self._fields['connection'].selection)
for device in self:
if device.env.context.get("formatted_display_name"):
connection = connection_display_values.get(device.connection, device.connection) if device.connection else ''
device.display_name = f"{device.name} \t --{connection}-- \t --{device.iot_id.name}--"
else:
device.display_name = f"{device.name}"
@api.depends('type')
def _compute_is_scanner(self):
for device in self:
device.is_scanner = device.type == 'scanner'
def _set_scanner(self):
for device in self:
device.type = 'scanner' if device.is_scanner else 'keyboard'
@api.depends('manufacturer')
def _compute_manual_measurement(self):
for device in self:
device.manual_measurement = device.manufacturer == 'Adam'
class IotKeyboardLayout(models.Model):
_name = 'iot.keyboard.layout'
_description = 'Keyboard Layout'
name = fields.Char('Name')
layout = fields.Char('Layout')
variant = fields.Char('Variant')

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import fields, models, _
from odoo.exceptions import UserError
from lxml.etree import ParserError
class IrActionsReport(models.Model):
_inherit = 'ir.actions.report'
device_ids = fields.Many2many('iot.device', string='IoT Devices', domain="[('type', '=', 'printer')]",
help='When setting a device here, the report will be printed through this device on the IoT Box')
def render_document(self, device_id_list, res_ids, data=None):
"""Render a document to be printed by the IoT Box through client
:param device_id_list: The list of device ids to print the document
:param res_ids: The list of record ids to print
:param data: The data to pass to the report
:return: The list of documents to print with information about the device
"""
device_ids = self.env['iot.device'].browse(device_id_list)
if len(device_id_list) != len(device_ids.exists()):
raise UserError(_(
"One of the printer used to print the document has been removed.\n"
"To reset printers, go to the IoT App, Configuration tab, \"Reset Linked Printers\" and retry the operation."
))
datas = self._render(self.report_name, res_ids, data=data)
data_bytes = datas[0]
data_base64 = base64.b64encode(data_bytes)
return [{
"iotBoxId": device.iot_id.id,
"deviceId": device.id,
"deviceIdentifier": device.identifier,
"deviceName": device.display_name,
"document": data_base64,
} for device in device_ids] # As it is called via JS, we format keys to camelCase
def report_action(self, docids, data=None, config=True):
result = super().report_action(docids, data, config)
if result.get('type') != 'ir.actions.report':
return result
device = self.device_ids and self.device_ids[0]
if data and data.get('device_id'):
device = self.env['iot.device'].browse(data['device_id'])
result['id'] = self.id
result['device_ids'] = device.mapped('identifier')
return result
def _get_readable_fields(self):
return super()._get_readable_fields() | {
"device_ids",
}
def get_action_wizard(self, selected_device_ids=None):
self.ensure_one()
wizard = self.env['select.printers.wizard'].create({
'display_device_ids': self.device_ids,
'device_ids': selected_device_ids
})
return {
'name': _("Select Printers for %s", self.name),
'res_id': wizard.id,
'type': 'ir.actions.act_window',
'res_model': 'select.printers.wizard',
'target': 'new',
'views': [[False, 'form']],
'context': {
'report_id': self.id,
},
}
def _render_qweb_pdf(self, report_ref, *args, **kwargs):
"""Override to ensure the user is informed when trying to print an empty report
without an IoT printer.
This can happen when trying to print delivery labels, that have empty reports used for assigning
IoT printers.
"""
try:
return super()._render_qweb_pdf(report_ref, *args, **kwargs)
except ParserError:
raise UserError(_(
"The report you are trying to print requires an IoT Box to be printed.\n"
"Make sure you linked the report '%s' to the corresponding IoT printer device.",
report_ref
))

View File

@@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class IrConfigParameter(models.Model):
_inherit = 'ir.config_parameter'
@api.model
def set_param(self, key, value):
if key == 'web.base.url' and not value.startswith('http://localhost'):
iot_box_identifiers = self.env['iot.box'].search([]).mapped('identifier')
self.env['iot.channel'].send_message({
'iot_identifiers': iot_box_identifiers,
'server_url': value,
}, 'server_update')
return super().set_param(key, value)

View File

@@ -0,0 +1,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@api.model
def lazy_session_info(self):
res = super().lazy_session_info()
res['iot_channel'] = self.env['iot.channel'].get_iot_channel()
return res

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License LGPL-3 — repackaged from Odoo S.A. iot module.
#
# The upstream version inherited publisher_warranty.contract to report
# installed IoT-Box counts back to Odoo S.A. for enterprise licensing.
# That's phone-home for license enforcement, so this is an explicit no-op.
#
# Keeping the file (rather than deleting it) so the upstream import path
# `odoo.addons.iot.models.update` still resolves — any addon that happens
# to import this module won't break.
from odoo import models
class Publisher_WarrantyContract(models.AbstractModel):
_inherit = "publisher_warranty.contract"
_description = 'Publisher Warranty Contract For IoT Box (neutralised)'
# No _get_message override — upstream appended an IoTBox count here.