Files
gsinghpal 6e964c230f 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>
2026-04-19 10:46:45 -04:00

326 lines
15 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import hashlib
import io
import itertools
import json
import logging
import pathlib
import pprint
import textwrap
import werkzeug
import zipfile
from werkzeug.exceptions import NotFound
from odoo import http
from odoo.http import request, Response, Stream
from odoo.modules import get_module_path
from odoo.tools.misc import str2bool
_logger = logging.getLogger(__name__)
_iot_logger = logging.getLogger(__name__ + '.iot_log')
# We want to catch any log level that the IoT send
_iot_logger.setLevel(logging.DEBUG)
_logger = logging.getLogger(__name__)
def ensure_unique_name(name):
existing_names = request.env['iot.box'].sudo().search([('name', 'ilike', name + '%')]).mapped('name')
base_name = name
suffix = 1
while name in existing_names:
name = f"{base_name} ({suffix})"
suffix += 1
return name
class IoTController(http.Controller):
def _search_box(self, identifier):
return request.env['iot.box'].sudo().search([('identifier', '=', identifier)], limit=1)
@http.route('/iot/get_handlers', type='http', auth='public', csrf=False)
def get_handlers(self, identifier, auto):
"""Return a zip file containing all the IoT handlers for the given IoT Box.
:param identifier: The identifier of the IoT Box.
:param auto: If True, the IoT Box will automatically update its handlers.
:return: A zip file containing all the IoT handlers.
"""
# Check if identifier is of one of the IoT Boxes
box = self._search_box(identifier)
if not box or (auto == 'True' and not box.drivers_auto_update):
raise werkzeug.exceptions.Unauthorized(
description="No IoT box found with identifier '%s' or auto update disabled on the box." % identifier
)
# '_L.py' files for Linux and '_W.py' for Windows
incompatible_filename = "_L.py" if box.version[0] == 'W' else "_W.py"
module_ids = request.env['ir.module.module'].sudo().search([('state', '=', 'installed')])
fobj = io.BytesIO()
with zipfile.ZipFile(fobj, 'w', zipfile.ZIP_DEFLATED) as zf:
for module in module_ids.mapped('name') + ['iot_drivers', 'pos_blackbox_be']: # add pos_blackbox_be to detect blackbox devices without the module installed
module_path = get_module_path(module)
if module_path:
iot_handlers = pathlib.Path(module_path) / 'iot_handlers'
for handler in iot_handlers.glob('*/*'):
if handler.name.startswith(('.', '_')) or handler.name.endswith(incompatible_filename):
continue
zf.write(handler, handler.relative_to(iot_handlers)) # In order to remove the absolute path
etag = hashlib.sha256(fobj.getvalue()).hexdigest()
# If the file has not been modified since the last request, return a 304 (Not Modified)
if etag == request.httprequest.headers.get('If-None-Match'):
return request.make_response('', headers=[('ETag', etag)], status=304)
return Stream(
type='data',
data=fobj.getvalue(),
download_name='iot_handlers.zip',
etag=etag,
size=fobj.tell(),
public=True,
).get_response()
@http.route('/iot/keyboard_layouts', type='http', auth='public', csrf=False)
def load_keyboard_layouts(self, available_layouts):
if not request.env['iot.keyboard.layout'].sudo().search_count([]):
request.env['iot.keyboard.layout'].sudo().create(json.loads(available_layouts))
return ''
@http.route('/iot/box/<string:identifier>/display_url', type='http', auth='public')
def get_url(self, identifier):
urls = {}
iotbox = self._search_box(identifier)
if iotbox:
iot_devices = iotbox.device_ids.filtered(lambda device: device.type == 'display')
for device in iot_devices:
urls[device.identifier] = device.display_url
return json.dumps(urls)
@http.route('/iot/box/send_websocket', type='jsonrpc', auth='public')
def iot_box_send_websocket(self, session_id, iot_box_identifier, device_identifier, status, **kwargs):
"""Called by the IoT Box once an operation is over. We then forward
the acknowledgment to the user who made the request to inform him
of the success of the operation.
:param session_id: ID of the operation
:param iot_box_identifier: The IP of the IoT box (used to find the box)
:param device_identifier: The IoT device identifier
:param status: Status of the last action (success, error, ...)
:param kwargs:
"""
box = self._search_box(iot_box_identifier)
if not box:
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
return
if (
device_identifier
and not request.env["iot.device"].sudo().search(
[('identifier', '=', device_identifier), ('iot_id', '=', box.id)], limit=1
)
and device_identifier != box.identifier # target the box itself
):
_logger.warning(
"No IoT device found with identifier '%s' (iot_box_identifier: %s). Request ignored",
device_identifier, iot_box_identifier
)
return
request.env['iot.channel'].send_message({
'session_id': session_id or kwargs.get("owner"), # TODO: replace "owner" by "session_id" in drivers
'iot_box_identifier': iot_box_identifier,
'device_identifier': device_identifier,
'message': {
'status': status,
'result': kwargs.get('result', {}),
'action_args': kwargs.get('action_args', {})
},
}, message_type='operation_confirmation')
@http.route('/iot/box/webrtc_answer', type='jsonrpc', auth='public')
def iot_box_webrtc_answer(self, iot_box_identifier, answer):
"""Called by the IoT Box after receiving a WebRTC offer from a user.
The IoT box sends its WebRTC answer and we forward it to the user so
they can establish the connection.
:param iot_box_identifier: The identifier (serial number) of the IoT box
:param answer: The WebRTC answer object
"""
box = self._search_box(iot_box_identifier)
if not box:
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
raise NotFound()
request.env['iot.channel'].send_message({
'iot_box_identifier': iot_box_identifier,
'answer': answer,
}, message_type='webrtc_answer')
@http.route('/iot/setup', type='jsonrpc', auth='public')
def update_box(self, iot_box, devices):
"""This function receives a dict from the iot box with information from it
as well as devices connected and supported by this box.
This function create the box and the devices and set the status (connected / disconnected)
of devices linked with this box
:param dict iot_box: IoT Box information
:param dict devices: IoT devices information
:return: IoT websocket channel
"""
# Update or create box
iot_identifier = iot_box['identifier'] # IoT Mac Address
new_iot_ip = iot_box['ip']
new_iot_version = iot_box['version']
box = self._search_box(iot_identifier)
create_update_value = {
'ip': new_iot_ip,
'version': new_iot_version,
}
if box:
if (box.ip, box.version) != (new_iot_ip, new_iot_version):
_logger.info('Updating IoT %s with data: %s', box, create_update_value)
box.write(create_update_value)
else:
name = 'IoT Box' if new_iot_version.startswith('L') else 'Virtual IoT Box'
create_update_value['name'] = ensure_unique_name(name)
icp_sudo = request.env['ir.config_parameter'].sudo()
iot_token = icp_sudo.get_param('iot.iot_token')
if iot_token and iot_token == iot_box['token']:
create_update_value['identifier'] = iot_identifier
_logger.info('Creating IoT with data: %s', create_update_value)
box = request.env['iot.box'].sudo().create(create_update_value)
# Clear the used token to force creating a new one for next IoT Box
icp_sudo.set_param('iot.iot_token', '')
else:
_logger.warning('Token mismatch for IoT %s expected %s got %s', iot_identifier, iot_token, iot_box['token'])
return None
_logger.info('IoT %s devices:\n%s', box, pprint.pformat(devices))
# Update or create devices
if box:
previously_connected_iot_devices = request.env['iot.device'].sudo().search([
('iot_id', '=', box.id),
('connected_status', '=', 'connected')
])
connected_iot_devices = request.env['iot.device'].sudo()
for device_identifier in devices:
available_types = [s[0] for s in request.env['iot.device']._fields['type'].selection]
available_connections = [s[0] for s in request.env['iot.device']._fields['connection'].selection]
data_device = devices[device_identifier]
if data_device['type'] in available_types and data_device['connection'] in available_connections:
# Special case to handle serial port change for blackbox
if data_device['type'] == 'fiscal_data_module' and 'BODO001' in data_device['name']:
existing_blackbox = connected_iot_devices.search([
('iot_id', '=', box.id), ('name', 'like', 'BODO001'), ('type', '=', 'fiscal_data_module')
], limit=1)
if existing_blackbox:
existing_blackbox.write({'identifier': device_identifier})
connected_iot_devices |= existing_blackbox
continue
device = connected_iot_devices.search([
('iot_id', '=', box.id), ('identifier', '=', device_identifier)
])
# If an `iot.device` record isn't found for this `device`, create a new one.
if not device:
device = request.env['iot.device'].sudo().create({
'iot_id': box.id,
'name': data_device['name'],
'identifier': device_identifier,
'type': data_device['type'],
'manufacturer': data_device.get('manufacturer'),
'connection': data_device['connection'],
'subtype': data_device.get('subtype', ''),
})
elif device and device.type != data_device.get('type') or (device.subtype == '' and device.type == 'printer'):
device.write({
'name': data_device.get('name'),
'type': data_device.get('type'),
'manufacturer': data_device.get('manufacturer'),
'subtype': data_device.get('subtype', '')
})
connected_iot_devices |= device
# Mark the received devices as connected, disconnect the others.
connected_iot_devices.write({'connected_status': 'connected'})
(previously_connected_iot_devices - connected_iot_devices).write({'connected_status': 'disconnected'})
iot_channel = request.env['iot.channel'].sudo().get_iot_channel()
return iot_channel
return None
def _is_iot_log_enabled(self):
return str2bool(request.env['ir.config_parameter'].sudo().get_param('iot.should_log_iot_logs', True))
@http.route('/iot/log', type='http', auth='public', csrf=False)
def receive_iot_log(self):
IOT_ELEMENT_SEPARATOR = b'<log/>\n'
IOT_LOG_LINE_SEPARATOR = b','
IOT_IDENTIFIER_PREFIX = b'identifier '
def log_line_transformation(log_line):
split = log_line.split(IOT_LOG_LINE_SEPARATOR, 1)
return {'levelno': int(split[0]), 'line_formatted': split[1].decode('utf-8')}
def log_current_level():
_iot_logger.log(
log_level,
"%s%s",
init_log_message,
textwrap.indent("\n".join(['', *log_lines]), ' | ')
)
def finish_request():
return Response(status=200)
if not self._is_iot_log_enabled():
return finish_request()
request_data = request.httprequest.get_data()
if request_data.endswith(IOT_ELEMENT_SEPARATOR):
# Do not use rstrip as some characters of the separator might be at the end of the log line
request_data = request_data[:-len(IOT_ELEMENT_SEPARATOR)]
request_data_split = request_data.split(IOT_ELEMENT_SEPARATOR)
if len(request_data_split) < 2:
return finish_request()
identifier_details = request_data_split.pop(0)
if not identifier_details.startswith(IOT_IDENTIFIER_PREFIX):
return finish_request()
identifier = identifier_details[len(IOT_IDENTIFIER_PREFIX):]
iot_box = self._search_box(identifier)
if not iot_box:
return finish_request()
log_details = map(log_line_transformation, request_data_split)
init_log_message = "IoT box log '%s' #%d received:" % (iot_box.name, iot_box.id)
for log_level, log_group in itertools.groupby(log_details, key=lambda log: log['levelno']): # noqa: B007
log_lines = [log_line['line_formatted'] for log_line in log_group]
log_current_level()
return finish_request()
@http.route('/iot/box/update_certificate_status', type='jsonrpc', auth='public')
def update_certificate_status(self, identifier, ssl_certificate_end_date):
"""Update the SSL certificate end date for the IoT Box.
:param str identifier: IoT Box identifier
:param str ssl_certificate_end_date: SSL certificate end date
"""
box = self._search_box(identifier)
if not box:
_logger.warning("No IoT Box found with identifier '%s'. Request ignored", identifier)
return
box.write({'ssl_certificate_end_date': ssl_certificate_end_date})