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,72 @@
from gatt import DeviceManager as Gatt_DeviceManager
import dbus
from gi.repository import GLib
import logging
from threading import Thread
from odoo.addons.iot_drivers.interface import Interface
bt_devices = {}
_logger = logging.getLogger(__name__)
class GattBtManager(Gatt_DeviceManager):
def device_discovered(self, device):
identifier = "bt_%s" % device.mac_address
if identifier not in bt_devices:
device.manager = self
bt_devices[identifier] = device
def run(self):
""" Override gatt.DeviceManager.run() method
to avoid calling GObject.MainLoop() deprecated method inside it.
MainLoop.run() will 'infinite loop' until MainLoop.quit()
method is called which we never do, so we don't need to reimplement
the rest of the MainLoop.run() method """
if self._main_loop:
return
self._interface_added_signal = self._bus.add_signal_receiver(
self._interfaces_added,
dbus_interface='org.freedesktop.DBus.ObjectManager',
signal_name='InterfacesAdded')
self._properties_changed_signal = self._bus.add_signal_receiver(
self._properties_changed,
dbus_interface=dbus.PROPERTIES_IFACE,
signal_name='PropertiesChanged',
arg0='org.bluez.Device1',
path_keyword='path')
def disconnect_signals():
for device in self._devices.values():
device.invalidate()
self._properties_changed_signal.remove()
self._interface_added_signal.remove()
self._main_loop = GLib.MainLoop()
try:
self._main_loop.run()
disconnect_signals()
except Exception:
disconnect_signals()
raise
class BtManager(Thread):
def run(self):
dm = GattBtManager(adapter_name='hci0')
for device in [device_con for device_con in dm.devices() if device_con.is_connected()]:
device.disconnect()
dm.start_discovery()
dm.run()
class BTInterface(Interface):
connection_type = 'bluetooth'
def get_devices(self):
return bt_devices.copy()
bm = BtManager()
bm.daemon = True
bm.start()

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import subprocess
import logging
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.helpers import path_file
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, create_ctypes_string_buffer
_logger = logging.getLogger(__name__)
# Check if the Worldline CTEP library exists, download it and set up the linker otherwise
if not path_file('odoo/addons/iot_drivers/iot_handlers/lib/ctep/libeasyctep.so').exists():
load_worldline_library_script = path_file('odoo/addons/iot_drivers/iot_handlers/lib/load_worldline_library.sh')
try:
subprocess.run(["sudo", "sh", load_worldline_library_script], check=True)
except subprocess.CalledProcessError:
_logger.exception('An error encountered while downloading / setting up Worldline CTEP library')
easyCTEP = import_ctypes_library('ctep', 'libeasyctep.so')
# CTEPManager* createCTEPManager(void);
easyCTEP.createCTEPManager.restype = ctypes.c_void_p
# int connectedTerminal(CTEPManager* manager, char* terminal_id, std::shared_ptr<ect::CTEPTerminal> terminal)
easyCTEP.connectedTerminal.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
class CTEPInterface(Interface):
_loop_delay = 10
connection_type = 'ctep'
def __init__(self):
super().__init__()
self.manager = easyCTEP.createCTEPManager()
def get_devices(self):
devices = {}
terminal_id = create_ctypes_string_buffer()
device = ctypes.c_void_p()
if easyCTEP.connectedTerminal(self.manager, terminal_id, ctypes.byref(device)):
devices[terminal_id.value.decode('utf-8')] = device
return devices

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
from pathlib import Path
import os
import logging
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.helpers import download_from_url, unzip_file
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, create_ctypes_string_buffer
_logger = logging.getLogger(__name__)
libPath = Path('odoo/addons/iot_drivers/iot_handlers/lib')
easyCTEPPath = libPath / 'ctep_w/libeasyctep.dll'
zipPath = str(libPath / 'ctep_w.zip')
if not easyCTEPPath.exists():
download_from_url('' # Disabled -- community repackage, zipPath)
unzip_file(zipPath, str(libPath / 'ctep_w'))
# Add Worldline dll path so that the linker can find the required dll files
os.environ['PATH'] = str(libPath / 'ctep_w') + os.pathsep + os.environ['PATH']
easyCTEP = import_ctypes_library("ctep_w", "libeasyctep.dll")
easyCTEP.createCTEPManager.restype = ctypes.c_void_p
easyCTEP.connectedTerminal.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
class CTEPInterface(Interface):
_loop_delay = 10
connection_type = 'ctep'
def __init__(self):
super().__init__()
try:
self.manager = easyCTEP.createCTEPManager()
except OSError:
_logger.exception("Failed to initalize CTEPManager")
def get_devices(self):
devices = {}
terminal_id = create_ctypes_string_buffer()
try:
if self.manager and easyCTEP.connectedTerminal(self.manager, terminal_id):
devices[terminal_id.value.decode('utf-8')] = self.manager
except OSError:
_logger.exception("Failed to check if the Worldline terminal is connected")
return devices

View File

@@ -0,0 +1,122 @@
import logging
import socket
from odoo import _
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.main import iot_devices
_logger = logging.getLogger(__name__)
# Because drivers don't get loaded as normal Python modules but directly in
# load_iot_handlers called by Manager.run, the log levels that get applied to the odoo
# import hierarchy won't apply here. This means DEBUG level messages will not display
# even if specified and INFO messages will show even if the log level is configured to
# be ERROR at the odoo-bin level. In order to work around this, it's possible to
# uncomment this line and set the desired level directly for this module.
# _logger.setLevel(logging.DEBUG)
socket_devices = {}
class SocketInterface(Interface):
connection_type = 'socket'
def __init__(self):
super().__init__()
self.open_socket(9000)
def open_socket(self, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(('', port))
self.sock.listen()
@staticmethod
def create_socket_device(dev, addr):
"""Creates a socket_devices entry that wraps the socket.
The Interface thread will detect it being added and instantiate a corresponding
Driver in iot_devices based on the results of the `supported` call.
"""
_logger.debug("Creating new socket_device")
socket_devices[addr] = type('', (), {'dev': dev})
def replace_socket_device(self, dev, addr):
"""Replaces an existing socket_devices entry.
The socket contained in the socket_devices entry is also used by the Driver
thread defined in iot_devices that's reading and writing from it. The Driver
thread can modify both socket_devices and iot_devices. The Interface thread can
update iot_devices based on changes in socket_devices. In order to clean up
the existing connection, it'll be necessary to actively close it at the TCP
level, wait for the Driver thread to terminate in response to that, and for the
Interface to do any iot_devices related cleanup in response.
After this the new connection can replace the old one.
"""
driver_thread = iot_devices.get(addr)
# Actively close the existing connection and do not allow receiving further
# data. This will result in a currently blocking recv call returning b'' and
# subsequent recv calls raising an OSError about a bad file descriptor.
old_dev = socket_devices[addr].dev
_logger.debug("Closing socket: %s", old_dev)
try:
# If the socket was already closed, a bad file descriptor OSError will be
# raised. This can happen if the IngenicoDriver thread initiated the
# disconnect itself.
old_dev.shutdown(socket.SHUT_RD)
except OSError:
pass
old_dev.close()
if driver_thread:
_logger.debug("Waiting for driver thread to finish")
driver_thread.join()
_logger.debug("Driver thread finished")
del socket_devices[addr]
# Shutting down the socket will result in the corresponding IngenicoDriver
# thread terminating and removing the corresponding entry in iot_devices. In the
# Interface thread _detected_devices will still contain the old socket device.
# This means update_iot_devices won't detect there was a change after
# create_socket_device gets called since that would create a new entry with the
# same key. A composite key of ip and port would avoid that, but this causes
# problems since the key is also reported to the Odoo database, which means a
# new device would show up in the IoT app for each key. _detected_devices is a
# dict_keys, which means we can't directly modify it either. Hence this hack.
_logger.debug("Updating _detected_devices")
new_detected_devices = dict.fromkeys(self._detected_devices, 0)
if addr in new_detected_devices:
del new_detected_devices[addr]
_logger.debug("Updated _detected_devices")
else:
_logger.warning("socket_device entry %s was not found in _detected_devices", addr)
self._detected_devices = new_detected_devices
SocketInterface.create_socket_device(dev, addr)
def get_devices(self):
try:
dev, addr = self.sock.accept()
_logger.debug("Accepted new socket connection: %s", addr)
if not addr:
_logger.warning("Socket accept returned no address")
return socket_devices
if addr[0] not in socket_devices:
self.create_socket_device(dev, addr[0])
else:
# This can happen if the device power cycled or a network cable
# was temporarily unplugged: if the device tries to connect again
# we might still have the old connection open and it needs to be
# cleaned up.
self.replace_socket_device(dev, addr[0])
except OSError:
pass
# update_iot_devices in Interface stores the keys() attribute of the value
# returned here in self._detected_devices. keys() returns a dict_keys object,
# and that stays in sync with the original dictionary. So if we were to directly
# return socket_devices, no difference between the old and new state would ever
# be detected (except the very first time when _detected_devices is an empty
# dict), because they would be exactly the same.
return socket_devices.copy()

View File

@@ -0,0 +1,108 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import os
import logging
from pathlib import Path
import subprocess
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
from odoo.addons.iot_drivers.tools import helpers
from odoo.tools.misc import file_path
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, CTYPES_BUFFER_SIZE
_logger = logging.getLogger(__name__)
if IS_WINDOWS:
LIB_PATH = Path('odoo/addons/iot_drivers/iot_handlers/lib')
DOWNLOAD_URL = '' # Disabled -- community repackage
else:
LIB_PATH = file_path('iot_drivers/iot_handlers/lib')
DOWNLOAD_URL = '' # Disabled -- community repackage
# Download and unzip timapi library, overwriting the existing one
TIMAPI_ZIP_PATH = f'{LIB_PATH}/tim.zip'
helpers.download_from_url(DOWNLOAD_URL, TIMAPI_ZIP_PATH)
helpers.unzip_file(TIMAPI_ZIP_PATH, f'{LIB_PATH}/tim')
# Make TIM SDK dependency libraries visible for the linker
if IS_WINDOWS:
LIB_PATH = file_path('iot_drivers/iot_handlers/lib')
os.environ['PATH'] = file_path('iot_drivers/iot_handlers/lib/tim') + os.pathsep + os.environ['PATH']
else:
TIMAPI_DEPENDANCY_LIB = 'libtimapi.so.3'
TIMAPI_DEPENDANCY_LIB_V = f'{TIMAPI_DEPENDANCY_LIB}.38.0-5308'
DEP_LIB_PATH = file_path('iot_drivers/iot_handlers/lib/tim')
USR_LIB_PATH = '/usr/lib'
try:
subprocess.call([f'sudo cp {DEP_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB_V} {USR_LIB_PATH}'], shell=True)
subprocess.call([f'sudo ln -fs {USR_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB_V} {USR_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB}'], shell=True)
except subprocess.CalledProcessError as e:
_logger.error("Failed to link the TIM SDK dependent library: %s", e.output)
# Import Odoo Timapi Library
LIB_NAME = 'libsix_odoo_w.dll' if IS_WINDOWS else 'libsix_odoo_l.so'
TIMAPI = import_ctypes_library('tim', LIB_NAME)
# --- Setup library prototypes ---
# void *six_initialize_manager(int buffer_size) {
TIMAPI.six_initialize_manager.argtypes = [ctypes.c_int]
TIMAPI.six_initialize_manager.restype = ctypes.c_void_p
# int six_setup_terminal_settings(t_terminal_manager *terminal_manager, char *terminal_id);
TIMAPI.six_setup_terminal_settings.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
# int six_terminal_connected(t_terminal_manager *terminal_manager);
TIMAPI.six_terminal_connected.argtypes = [ctypes.c_void_p]
class TIMInterface(Interface):
_loop_delay = 30
connection_type = 'tim'
def __init__(self):
super(TIMInterface, self).__init__()
try:
buffer_size = ctypes.c_int(CTYPES_BUFFER_SIZE)
self.manager = TIMAPI.six_initialize_manager(buffer_size)
except OSError:
_logger.exception("Failed to initalize TIM manager")
if not self.manager:
_logger.error('Failed to allocate memory for TIM Manager')
self.tid = None
def get_devices(self):
if not self.manager:
return {}
# As this code is fetched by the IoT Box from the DB, we can't be sure
# that the IoT Box has the new method `get_conf`.
# This try-except should be replaced by a simple call to `get_conf` in master
try:
new_tid = helpers.get_conf("six_payment_terminal")
except AttributeError:
_logger.warning("Failed to get the Six TID from the configuration file, trying to read it from the old file")
new_tid = helpers.read_file_first_line('odoo-six-payment-terminal.conf')
devices = {}
# If the Six TID setup has changed, reset the settings
if new_tid != self.tid:
self.tid = new_tid
encoded_tid = new_tid.encode() if new_tid else None
try:
if not TIMAPI.six_setup_terminal_settings(self.manager, encoded_tid):
return {}
except OSError:
_logger.exception("Failed to setup Six terminal settings")
return {}
# Check if the terminal is online and responsive
try:
if self.tid and TIMAPI.six_terminal_connected(self.manager):
devices[self.tid] = ctypes.cast(self.manager, ctypes.c_void_p)
except OSError:
_logger.exception("Failed to check if the Six terminal is connected")
return devices