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>
200 lines
8.4 KiB
Python
200 lines
8.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||
|
||
import ctypes
|
||
import datetime
|
||
import logging
|
||
|
||
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import (
|
||
CtypesTerminalDriver,
|
||
ulong_pointer, # noqa: F401
|
||
double_pointer, # noqa: F401
|
||
import_ctypes_library,
|
||
create_ctypes_string_buffer,
|
||
)
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
# All the terminal errors can be found in the section "Codes d'erreur" here:
|
||
# https://help.winbooks.be/space/HelpLogFr/1278150/Liaison+vers+le+terminal+de+paiement+Banksys+en+TCP%2FIP#Codes-d'erreur
|
||
TERMINAL_ERRORS = {
|
||
'1802': 'Terminal is busy',
|
||
'1803': 'Timeout expired',
|
||
'1811': 'Technical problem',
|
||
'1822': 'Connection failure',
|
||
'2000': 'Unknown acquirer identifier',
|
||
'2100': 'Action code not supported',
|
||
'2625': 'Corrupted message',
|
||
'2629': 'User cancellation',
|
||
'2631': 'Host cancellation',
|
||
'2632': 'Host error',
|
||
'2633': 'Operation already performed',
|
||
'2634': 'Operation busy',
|
||
'2635': 'Operation not performed',
|
||
'2800': 'Doesn’t exist',
|
||
'2802': 'Not allowed',
|
||
'2806': 'Bad signature',
|
||
'2807': 'Conditional field missing',
|
||
'2808': 'Not found',
|
||
'2809': 'Dependency not found',
|
||
'2810': 'Bad value',
|
||
'2811': 'Bad sequence',
|
||
'2812': 'Device attachment',
|
||
'2813': 'Unexpected field',
|
||
'3100': 'Chip card expected',
|
||
'3101': 'Card not well read',
|
||
'3102': 'Condition of use not satisfied',
|
||
'4000': 'Purse technical problem',
|
||
'4001': 'Purse host identifier invalid',
|
||
'4002': 'Purse SDA certificate error',
|
||
'4003': 'Purse extended SDA certificate error',
|
||
'4004': 'Purse in red list',
|
||
'4005': 'Purse is locked for credit',
|
||
'4006': 'Purse is locked for debit',
|
||
'4007': 'Purse expired',
|
||
'4008': 'Purse state error',
|
||
'4009': 'Purse recovery error',
|
||
'4010': 'Purse key identifier error',
|
||
'4011': 'Purse balance too large',
|
||
'4012': 'Insufficient purse balance',
|
||
'4100': 'No purse in reader and time out expired',
|
||
'4101': 'Time-out on fallback card reading',
|
||
'4102': 'Problem linked to card',
|
||
'4103': 'Card information not available',
|
||
'4200': 'Entered amount invalid',
|
||
'4201': 'Double operation',
|
||
'4202': 'Invalid currency',
|
||
'4203': 'Amount higher than authorized amount',
|
||
'4204': 'Floor limit exceeded in EMV mode',
|
||
'4205': 'Transaction refused by the terminal in EMV mode',
|
||
'4206': 'Transaction refused by the card in EMV mode',
|
||
'4207': 'Product not available',
|
||
'4300': 'Service (already) activated',
|
||
'4301': 'Service (already) deactivated',
|
||
'4302': 'Maximal transaction number per (calendar) month reached',
|
||
'4303': 'Maximal uncollected journals number reached',
|
||
'4304': 'Service activation not supported',
|
||
'4305': 'Maximum transaction records reached',
|
||
'4306': 'Maximum service activation number reached',
|
||
'6003': 'Paper jam',
|
||
'6004': 'Remove previous ticket',
|
||
'6005': 'No paper',
|
||
'6006': 'Low paper',
|
||
'6008': 'Printer specific',
|
||
'7806': 'Product not allowed',
|
||
'7808': 'Bad pump number',
|
||
'7816': 'Incorrect pump session number',
|
||
'7817': 'Transaction amount null',
|
||
'7818': 'Transaction amount null and quantity null',
|
||
'7819': 'Pump unhooked time-out expiration',
|
||
'9002': 'No key fault',
|
||
'9003': 'Cryptographic fault',
|
||
'9004': 'No PIN fault',
|
||
'9005': 'Bad MAC',
|
||
'9006': 'Bad MDC',
|
||
}
|
||
|
||
# Manually cancelled by cashier, do not show these errors
|
||
IGNORE_ERRORS = [
|
||
'2628', # External Equipment Cancellation
|
||
'2630', # Device Cancellation
|
||
]
|
||
|
||
easyCTEP = import_ctypes_library('ctep', 'libeasyctep.so')
|
||
|
||
# int startTransaction(
|
||
easyCTEP.startTransaction.argtypes = [
|
||
ctypes.c_void_p, # std::shared_ptr<ect::CTEPTerminal> trm
|
||
ctypes.c_char_p, # char const* amount
|
||
ctypes.c_char_p, # char const* reference
|
||
ctypes.c_ulong, # unsigned long action_identifier
|
||
ctypes.c_char_p, # char* merchant_receipt
|
||
ctypes.c_char_p, # char* customer_receipt
|
||
ctypes.c_char_p, # char* card
|
||
ctypes.c_char_p # char* error
|
||
]
|
||
|
||
# int abortTransaction(std::shared_ptr<ect::CTEPTerminal> trm, char* error)
|
||
easyCTEP.abortTransaction.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||
|
||
class WorldlineDriver(CtypesTerminalDriver):
|
||
connection_type = 'ctep'
|
||
|
||
def __init__(self, identifier, device):
|
||
super(WorldlineDriver, self).__init__(identifier, device)
|
||
self.device_name = 'Worldline terminal %s' % self.device_identifier
|
||
self.device_manufacturer = 'Worldline'
|
||
|
||
def processTransaction(self, transaction):
|
||
if transaction['amount'] <= 0:
|
||
return self.send_status(error='The terminal cannot process negative or null transactions.', request_data=transaction)
|
||
|
||
# Force to wait before starting the transaction if necessary
|
||
self._check_transaction_delay()
|
||
# Notify transaction start
|
||
self.send_status(stage='WaitingForCard', request_data=transaction)
|
||
|
||
# Transaction
|
||
merchant_receipt = create_ctypes_string_buffer()
|
||
customer_receipt = create_ctypes_string_buffer()
|
||
card = create_ctypes_string_buffer()
|
||
error_code = create_ctypes_string_buffer()
|
||
transaction_id = transaction['TransactionID']
|
||
transaction_amount = transaction['amount'] / 100
|
||
transaction_action_identifier = transaction['actionIdentifier']
|
||
_logger.info('start transaction #%d amount: %f action_identifier: %d', transaction_id, transaction_amount, transaction_action_identifier)
|
||
result = easyCTEP.startTransaction(
|
||
ctypes.byref(self.dev), # std::shared_ptr<ect::CTEPTerminal> trm
|
||
ctypes.c_char_p(str(transaction_amount).encode('utf-8')), # char const* amount
|
||
ctypes.c_char_p(str(transaction_id).encode('utf-8')), # char const* reference
|
||
ctypes.c_ulong(transaction_action_identifier), # unsigned long action_identifier
|
||
merchant_receipt, # char* merchant_receipt
|
||
customer_receipt, # char* customer_receipt
|
||
card, # char* card
|
||
error_code, # char* error
|
||
)
|
||
self.next_transaction_min_dt = datetime.datetime.now() + datetime.timedelta(seconds=self.DELAY_TIME_BETWEEN_TRANSACTIONS)
|
||
|
||
if result == 1:
|
||
_logger.info('succesfully finished transaction #%d', transaction_id)
|
||
# Transaction successful
|
||
self.send_status(
|
||
response='Approved',
|
||
ticket=customer_receipt.value.decode(),
|
||
ticket_merchant=merchant_receipt.value.decode(),
|
||
card=card.value.decode(),
|
||
transaction_id=transaction['actionIdentifier'],
|
||
request_data=transaction,
|
||
)
|
||
elif result == 0:
|
||
error_code = error_code.value.decode('utf-8')
|
||
# Transaction failed
|
||
if error_code not in IGNORE_ERRORS:
|
||
error_msg = f'transaction #{transaction_id} error: {error_code}: {TERMINAL_ERRORS.get(error_code, "Transaction Error")}'
|
||
_logger.info(error_msg)
|
||
self.send_status(error=error_msg, request_data=transaction)
|
||
# Transaction was cancelled
|
||
else:
|
||
_logger.info("transaction #%d cancelled by PoS user", transaction_id)
|
||
self.send_status(stage='Cancel', request_data=transaction)
|
||
elif result == -1:
|
||
# Terminal disconnection, check status manually
|
||
_logger.warning("terminal disconnected during transaction #%d", transaction_id)
|
||
self.send_status(disconnected=True, request_data=transaction)
|
||
|
||
def cancelTransaction(self, transaction):
|
||
# Force to wait before starting the transaction if necessary
|
||
self._check_transaction_delay()
|
||
self.send_status(stage='waitingCancel', request_data=transaction)
|
||
|
||
error_code = create_ctypes_string_buffer()
|
||
_logger.info("cancel transaction request for %s", transaction)
|
||
result = easyCTEP.abortTransaction(ctypes.byref(self.dev), error_code) # std::shared_ptr<ect::CTEPTerminal> trm
|
||
_logger.debug("end cancel transaction request")
|
||
|
||
if not result:
|
||
error_code = error_code.value.decode('utf-8')
|
||
error_msg = f'Cancellation failed: {error_code}: {TERMINAL_ERRORS.get(error_code, "cancellation error")}'
|
||
_logger.info(error_msg)
|
||
self.send_status(stage='Cancel', error=error_msg, request_data=transaction)
|