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,153 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import datetime
import logging
from abc import abstractmethod
from queue import Queue
from time import sleep
from odoo.addons.iot_drivers.driver import Driver
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
from odoo.tools.misc import file_path
_logger = logging.getLogger(__name__)
# Buffer size big enough to hold every string incoming from ctypes libraries
# The biggest strings stored in this buffer are the receipts
CTYPES_BUFFER_SIZE = 10000
# Define pointers and argument types for ctypes function calls
ulong_pointer = ctypes.POINTER(ctypes.c_ulong)
double_pointer = ctypes.POINTER(ctypes.c_double)
def import_ctypes_library(lib_subfolder, lib_name):
"""
Import a library using ctypes, independently of the OS.
:param lib_subfolder: The subfolder where the library is located under "iot_drivers/iot_handlers/lib"
:param lib_name: The name of the library file. Must respect the OS extension (.so/.dll), otherwise ValueError will be raised
Example: if the library is located under "iot_drivers/iot_handlers/lib/ctep/libeasyctep.so", then
lib_subfolder = "ctep" and lib_name = "libeasyctep.so"
"""
if IS_WINDOWS:
supported_lib_extensions = '.dll'
import_library_method = ctypes.WinDLL
else:
supported_lib_extensions = '.so'
import_library_method = ctypes.CDLL
try:
lib_path = file_path(f'iot_drivers/iot_handlers/lib/{lib_subfolder}/{lib_name}', supported_lib_extensions)
ctypes_lib = import_library_method(lib_path)
_logger.info('Successfully imported ctypes library "%s" from %s', lib_name, lib_path)
return ctypes_lib
except (OSError, ValueError, FileNotFoundError):
_logger.exception('Failed to import ctypes library "%s" from iot_drivers/iot_handlers/lib/%s/', lib_name, lib_subfolder)
def create_ctypes_string_buffer():
"""
Create a ctypes buffer of CTYPES_BUFFER_SIZE size
"""
return ctypes.create_string_buffer(CTYPES_BUFFER_SIZE)
class CtypesTerminalDriver(Driver):
"""
This class is the parent class of all the terminal drivers using ctypes.
Worldline and Six drivers are inheriting from this class.
"""
DELAY_TIME_BETWEEN_TRANSACTIONS = 5 # seconds
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'payment'
self.device_connection = 'network'
self.cid = None
self.owner = None
self.queue_actions = Queue()
self.terminal_busy = False
self._actions[''] = self._action_default
self.next_transaction_min_dt = datetime.datetime.min
@classmethod
def supported(cls, device):
# Currently all devices detected through TimInterface or CTEPInterface are supported
return True
def _action_default(self, data):
data_message_type = data.get('messageType')
_logger.debug('%s: _action_default %s %s', self.device_name, data_message_type, data)
if data_message_type in ['Transaction', 'Balance']:
if self.terminal_busy:
self.send_status(error=f'{self.device_name} is currently busy. Try again later.', request_data=data)
else:
self.terminal_busy = True
self.queue_actions.put(data)
elif data_message_type == 'Cancel':
self.cancelTransaction(data)
def run(self):
while True:
# If the queue is empty, the call of "get" will block and wait for it to get an item
action = self.queue_actions.get()
action_type = action.get('messageType')
_logger.debug("%s: Starting next action in queue: %s", self.device_name, action_type)
if action_type == 'Transaction':
self.processTransaction(action)
elif action_type == 'Balance':
self.six_terminal_balance(action) # Only for Worldline "Six" (TIM)
self.terminal_busy = False
def _check_transaction_delay(self):
# After a payment has been processed, the display on the terminal still shows some
# information for about 4-5 seconds. No request can be processed during this period.
delay_diff = (self.next_transaction_min_dt - datetime.datetime.now()).total_seconds()
if delay_diff > 0:
if delay_diff > self.DELAY_TIME_BETWEEN_TRANSACTIONS:
# Theoretically not possible, but to avoid sleeping for ages, we cap the value
_logger.warning('%s: Transaction delay difference is too high %.2f force set as default', self.device_name, delay_diff)
delay_diff = self.DELAY_TIME_BETWEEN_TRANSACTIONS
_logger.info('%s: Previous transaction is too recent, will sleep for %.2f seconds', self.device_name, delay_diff)
sleep(delay_diff)
def send_status(self, value='', response=False, stage=False, ticket=False, ticket_merchant=False, card=False, card_no=False, transaction_id=False, error=False, disconnected=False, request_data=False):
self.data['status'] = 'success' # always success: let service handle errors
self.data['result'] = {
'value': value,
'Stage': stage,
'Response': response,
'Ticket': ticket,
'TicketMerchant': ticket_merchant,
'Card': card,
'CardNo': card_no,
'PaymentTransactionID': transaction_id,
'Error': error,
'Disconnected': disconnected,
'cid': request_data.get('cid'),
}
# TODO: add `stacklevel=2` in image with python version > 3.8
_logger.debug('%s: send_status data: %s', self.device_name, self.data, stack_info=True)
event_manager.device_changed(self)
# The following methods need to be implemented by the children classes
@abstractmethod
def processTransaction(self, transaction):
"""
Method implementing the transaction processing
"""
@abstractmethod
def cancelTransaction(self, transaction):
"""
Method implementing the ongoing transaction request cancellation
"""
def six_terminal_balance(self, transaction):
"""
Method implementing the terminal balance request (only for Worldline "Six")
Not an abstract method as it remains undefined for Worldline
"""