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/lib/._ctypes_terminal_driver.py
Normal file
BIN
fusion_iot/iot/iot_handlers/lib/._ctypes_terminal_driver.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/lib/._load_worldline_library.sh
Normal file
BIN
fusion_iot/iot/iot_handlers/lib/._load_worldline_library.sh
Normal file
Binary file not shown.
153
fusion_iot/iot/iot_handlers/lib/ctypes_terminal_driver.py
Normal file
153
fusion_iot/iot/iot_handlers/lib/ctypes_terminal_driver.py
Normal 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
|
||||
"""
|
||||
Reference in New Issue
Block a user