Files
Odoo-Modules/fusion_iot/iot/iot_handlers/drivers/WorldlineDriver_L.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

200 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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': 'Doesnt 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)