Files
Odoo-Modules/fusion_iot/iot/iot_handlers/interfaces/SocketInterface.py
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

123 lines
5.7 KiB
Python

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()