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/iot_handlers/interfaces/._BTInterface_L.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._BTInterface_L.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_L.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_L.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_W.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_W.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._SocketInterface.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._SocketInterface.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._TIMInterface.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._TIMInterface.py
Normal file
Binary file not shown.
72
fusion_iot/iot/iot_handlers/interfaces/BTInterface_L.py
Normal file
72
fusion_iot/iot/iot_handlers/interfaces/BTInterface_L.py
Normal 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()
|
||||
44
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_L.py
Normal file
44
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_L.py
Normal 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
|
||||
50
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_W.py
Normal file
50
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_W.py
Normal 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
|
||||
122
fusion_iot/iot/iot_handlers/interfaces/SocketInterface.py
Normal file
122
fusion_iot/iot/iot_handlers/interfaces/SocketInterface.py
Normal 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()
|
||||
108
fusion_iot/iot/iot_handlers/interfaces/TIMInterface.py
Normal file
108
fusion_iot/iot/iot_handlers/interfaces/TIMInterface.py
Normal 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
|
||||
Reference in New Issue
Block a user