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

Binary file not shown.

Binary file not shown.

BIN
fusion_iot/iot/iot_handlers/._lib Executable file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,855 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from binascii import unhexlify
import logging
from time import sleep
from traceback import format_exc
from zlib import crc32
import socket
from odoo.addons.iot_drivers.driver import Driver
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.iot_handlers.interfaces.SocketInterface import socket_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)
class IngenicoTagType():
"""Tag type Function.
This class is used to make working with the provided Ingenico tags easier.
Instances of this class should only be generated by the static list
provided by Ingenico.
"""
def __init__(self, name, tag, tagFormat, tagLen):
"""
Args:
name (str): Human readable tag name.
tag (b): Identification tag formated as a byteArray.
tagformat (str): Format of the tag content.
* b: boolean values. Each boolean is 1 bit.
* a: ASCII characters
* i: Binari Code Decimals
* x: Hexadecimal digits
tagLen (int): Length of the tag content. This value is always the numbers of bytes
(This is not always the case in the official documentation provided by Ingenico!!)
"""
self.name = name
self.tag = tag
self.format = tagFormat
self.len = tagLen
def getDict(self):
"""Get a dictionary with the tag
Returns {
name (str): tag name,
tag (b): Tag identifier,
tagLen (int): The length of the tag identifier,
format (str): format of the tag content,
len (int): Length of the tag content
}
"""
return {
'name': self.name,
'tag': self.tag,
'tagLen': len(self.tag)/2,
'format': self.format,
'len': self.len,
}
def hasTag(self, tag):
"""Check if tag is equal
Check if a tag is equal, regardless of the case of the characters. The case does not change anything
in hexadecimal, but comparing without upper/lower would still give false negatives.
Returns True if equal
"""
return tag.upper() == self.tag.upper()
class IngenicoMessage():
"""Base Class for Ingenico Messages.
Use OutgoingIngenicoMessage or IncommingIngenicoMessage instead to initialize messages.
_const: Most of these constants are provided by Ingenico and should not be changed.
"""
_const = type('',(),{
'keepAliveInterval': b'\x00\x05',
'magic' : b'P4Y-ECR!',
'messageType' : {
'HelloRequest' : b'\x00\x00\x00\x16', #!< Request# a# connection# with# the# ECR.
'HelloResponse' : b'\x00\x00\x00\x17', #!< Result# of# the# connection# request.
'KeepAliveRequest' : b'\x00\x00\x00\x18', #!< Notification# of# status# and# keep-alive.
'KeepAliveResponse' : b'\x00\x00\x00\x19', #!< Result# of# the# notification.
'ByeRequest' : b'\x00\x00\x00\x20', #!< Request# to# terminate# the# connection.
'ByeResponse' : b'\x00\x00\x00\x21', #!< Result# of# terminate# connection# request.
'AcquirerDownloadListRequest' : b'\x00\x00\x00\x30', # The# ECR# Requests# CTAP# to# give# a# list# with# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'AcquirerDownloadListResponse' : b'\x00\x00\x00\x31', # CTAP# sends# the# ECR# a# list# of# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'AcquirerDelMsgListRequest' : b'\x00\x00\x00\x32', # The# ECR# Requests# CTAP# to# give# a# list# with# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'AcquirerDelMsgListResponse' : b'\x00\x00\x00\x33', # CTAP# sends# the# ECR# a# list# of# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'AcquirerDelMsgRequest' : b'\x00\x00\x00\x34', # The# ECR# Requests# CTAP# to# give# a# list# with# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'AcquirerDelMsgResponse' : b'\x00\x00\x00\x35', # CTAP# sends# the# ECR# a# list# of# available# acquirers# (used# to# select# one# for# an# acquirer# download)
'PerformAcqDownLoadRequest' : b'\x00\x00\x00\x36',
'PerformAcqDownLoadResponse' : b'\x00\x00\x00\x37',
'SecuritySchemeListRequest' : b'\x00\x00\x00\x38', # The# ECR# Requests# CTAP# to# give# a# list# with# available# security-schemes# (used# to# select# one# for# an# security-scheme# download)
'SecuritySchemeListResponse' : b'\x00\x00\x00\x39', # CTAP# sends# the# ECR# a# list# of# available# security-schemes# (used# to# select# one# for# an# security-scheme# download)
'PerformKeyLoadRequest' : b'\x00\x00\x00\x40',
'PerformKeyLoadResponse' : b'\x00\x00\x00\x41',
'PrintInfoRequest' : b'\x00\x00\x00\x46',
'PrintInfoResponse' : b'\x00\x00\x00\x47',
'TransactionRequest' : b'\x00\x00\x00\x48', #!< Request# to# perform# a# transaction.
'TransactionResponse' : b'\x00\x00\x00\x49', #!< Result# of# the# transaction.
'TotalsRequest' : b'\x00\x00\x00\x50', #!< Request# an# overview# of# the# counters# (print# on# terminal# or# send# to# ECR).
'TotalsResponse' : b'\x00\x00\x00\x51', #!< Result# of# the# totals# request.
'LastTicketRequest' : b'\x00\x00\x00\x52', #!< Request# the# last# ticket# (print# on# terminal# or# send# to# ECR).# (Was# called# print# request# in# IDD).
'LastTicketResponse' : b'\x00\x00\x00\x53', #!< Result# of# the# last# ticket# request.# (Was# called# print# response# in# IDD).
'CancelRequest' : b'\x00\x00\x00\x54', #!< Request# the# cancellation# of# an# on-going# operation.
'CancelResponse' : b'\x00\x00\x00\x55', #!< Result# of# the# cancellation# request.
'LastTransactionRequest' : b'\x00\x00\x00\x56', #!< Request# the# result# of# the# last# transaction.
'LastTransactionResponse' : b'\x00\x00\x00\x57', #!< Result# of# the# last# transaction# request.
'PrintConfirmationRequest' : b'\x00\x00\x00\x64', #!< Print# confirmation# from# ECR# to# terminal# when# a# card# holder# ticket# must# be# printed# on# the# ECR.
'PrintConfirmationResponse' : b'\x00\x00\x00\x65', #!< Print# confirmation# from# ECR# to# terminal# when# a# card# holder# ticket# must# be# printed# on# the# ECR.
'PrintRequest' : b'\x00\x00\x00\x66', #!< Request# to# print# some# data# (e.g.# ECR# ticket)# on# the# terminal# printer.# (ECR# does# not# need# a# printer# then)# (this# message# is# not# in# IDD).
'PrintResponse' : b'\x00\x00\x00\x67', #!< Result# of# the# print# request# (this# message# is# not# in# IDD).
'IntermediateResultRequest' : b'\x00\x00\x00\x68', #!< Request# with# the# intermediate# result.
'IntermediateResultResponse' : b'\x00\x00\x00\x69', #!< Result# of# the# intermediate# result# request# (continue/abort# transaction).
'InformationReport' : b'\x00\x00\x00\x80', #!< Report# the# progress# of# a# transaction# and# other# information# like# merchant# messages.
'SettingsRequest' : b'\x00\x00\x00\x82', #!< Change# one# or# more# settings# in# the# terminal.
'SettingsResponse' : b'\x00\x00\x00\x83', #!< Result# of# the# change# settings# request.
'VersionInformationRequest' : b'\x00\x00\x00\x90', #!< Request# the# version# of# the# terminal# software
'VersionInformationResponse' : b'\x00\x00\x00\x91', #!< Result# of# the# version# request.
'PerformTmsSessionReuqest' : b'\x00\x00\x00\x92',
'PerformTmsSessionResponse' : b'\x00\x00\x00\x93',
'RebootAndClearCtapDataBaseRequest' : b'\x00\x00\x01\x00', # there# is# no# response# on# this# command:# the# terminal# will# reboot
# Transparent# mode# messages.
'TmTransparentModeRequest' : b'\x00\x00\x10\x00', #!< Request# to# start# or# stop# transparent# mode.
'TmTransparentModeResponse' : b'\x00\x00\x10\x01', #!< Result# of# the# request# to# start# or# stop# transparent# mode.
'TmUiControlRequest' : b'\x00\x00\x10\x02', #!< Request# to# update# the# user# interface# (buzzer,# display,# LEDs)# when# in# transparent# mode.
'TmUiControlResponse' : b'\x00\x00\x10\x03', #!< Result# of# the# UI# request.
'TmAuthenticateRequest' : b'\x00\x00\x10\x04', #!< Request# to# authenticate# to# the# card# when# in# transparent# mode.
'TmAuthenticateResponse' : b'\x00\x00\x10\x05', #!< Result# of# the# authenticate# request.
'TmReadCardDataRequest' : b'\x00\x00\x10\x06', #!< Request# to# read# data# from# the# card# when# in# transparent# mode.
'TmReadCardDataResponse' : b'\x00\x00\x10\x07', #!< Result# of# the# read# card# data# request.
'TmWriteCardDataRequest' : b'\x00\x00\x10\x08', #!< Request# to# write# data# to# the# card# when# in# transparent# mode.
'TmWriteCardDataResponse' : b'\x00\x00\x10\x09', #!< Result# of# the# write# card# data# request.
'TmStatusRequest' : b'\x00\x00\x10\x10', #!< Request# the# status# of# the# transparent# mode.
'TmStatusResponse' : b'\x00\x00\x10\x11', #!< Result# of# the# status# request.
},
'tagType' : [
IngenicoTagType( 'None' , '00' , '' ,False ),
# Header, body, footer primitive tags.
IngenicoTagType( 'TransactionStage' , '0E' , 'x' , 1 ),
IngenicoTagType( 'StageMessage' , '0F' , 'a' , False ),
IngenicoTagType( 'ProtocolId' , '10' , 'i' , 4 ),
IngenicoTagType( 'MessageType' , '11' , 'i' , 4 ),
IngenicoTagType( 'TerminalId' , '12' , 'a' , False ),
IngenicoTagType( 'EcrId' , '13' , 'a' , False ),
IngenicoTagType( 'SequenceNumber' , '14' , 'x' , 2 ),
IngenicoTagType( 'KeepAliveReason' , '15' , 'x' , 1 ),
IngenicoTagType( 'ResultCode' , '16' , 'x' , 2 ),
IngenicoTagType( 'ByeReason' , '17' , 'x' , 1 ),
IngenicoTagType( 'Language' , '18' , 'a' , False ),
IngenicoTagType( 'TerminalState' , '19' , 'b' , 8 ),
IngenicoTagType( 'TransactionId' , '1A' , 'x' , 8 ),
IngenicoTagType( 'PrintResult' , '1B' , '' , False ),
IngenicoTagType( 'Mdc' , '1C' , 'x' , False ),
IngenicoTagType( 'MerchantText' , '1D' , 'a' , False ),
IngenicoTagType( 'CancelReason' , '1E' , 'x' , 1 ),
# Communication parameter group tags.
IngenicoTagType( 'IpAddress' , '40' , '' , False ),
IngenicoTagType( 'PortNumber' , '41' , '' , False ),
# Connection parameters groups tags.
IngenicoTagType( 'ConnectionTimeout' , '47' , '' , False ),
IngenicoTagType( 'ConnectionRetries' , '48' , '' , False ),
IngenicoTagType( 'KeepAliveInterval' , '49' , 'i' , 2 ),
# Ticket data group tags.
IngenicoTagType( 'TicketType' , '4A' , 'x' , False ),
IngenicoTagType( 'TicketHeader' , '4B' , '' , False ),
IngenicoTagType( 'TicketBody' , '4C' , 'a' , False ),
IngenicoTagType( 'TicketFooter' , '4D' , '' , False ),
# Print data group.
IngenicoTagType( 'PrintOrigin' , '4E' , '' , False ),
IngenicoTagType( 'PaperWidth' , '4F' , '' , False ),
# Transaction (information) group tags.
IngenicoTagType( 'Amount' , '50' , 'i' , 4 ),
IngenicoTagType( 'CurrencyCode' , '51' , 'i' , 4 ),
IngenicoTagType( 'CurrencyExponent' , '52' , 'i' , 2 ), # Named Decimal in IDD.
IngenicoTagType( 'ProgressReportLanguage' , '53' , 'a' , 2 ),
IngenicoTagType( 'TransactionType' , '54' , '' , False ),
IngenicoTagType( 'MerchantTransactionReference' , '55' , '' , False ),
IngenicoTagType( 'TransactionResult' , '56' , '' , False ),
IngenicoTagType( 'TransactionDateTime' , '57' , '' , False ),
IngenicoTagType( 'IntermediateResultMode' , '58' , '' , False ),
IngenicoTagType( 'TransactionMode' , '59' , '' , False ),
IngenicoTagType( 'AuthorisationCode' , '1F70' , '' , False ),
IngenicoTagType( 'Token' , '1F71' , '' , False ),
# Settings# (result) group.
IngenicoTagType( 'SettingId' , '5A' , '' , False ),
IngenicoTagType( 'SettingType' , '5B' , '' , False ),
IngenicoTagType( 'SettingValue' , '5C' , '' , False ),
IngenicoTagType( 'SettingResult' , '5D' , '' , False ),
IngenicoTagType( 'TotalsType' , '5F54' , '' , False ),
IngenicoTagType( 'InfoType' , '5F55' , '' , False ),
# Version information tags.
IngenicoTagType( 'ApplicationId' , '80' , '' , False ),
IngenicoTagType( 'LogicalId' , '81' , 'a' , False ),
IngenicoTagType( 'SerialNumber' , '82' , 'a' , False ),
IngenicoTagType( 'VersionNumber' , '83' , '' , False ),
IngenicoTagType( 'VersionString' , '84' , '' , False ),
IngenicoTagType( 'ExtraInformationName' , '85' , '' , False ),
IngenicoTagType( 'ExtraInformationValue' , '86' , '' , False ),
IngenicoTagType( 'ExtraInformationUnit' , '87' , '' , False ),
# Group tags.
IngenicoTagType( 'Group_EncryptionParameters' , 'A0' , 'GRP' , False ),
IngenicoTagType( 'Group_CommunicationParameters', 'A1' , 'GRP' , False ),
IngenicoTagType( 'Group_SupportedLanguages' , 'A2' , 'TBL' , False ),
IngenicoTagType( 'Group_TransactionData' , 'A3' , 'GRP' , False ),
IngenicoTagType( 'Group_ConnectionParameters' , 'A4' , 'GRP' , False ),
IngenicoTagType( 'Group_PrintData' , 'A5' , 'GRP' , False ),
IngenicoTagType('Group_TicketData', 'A6', 'TBL', False),
IngenicoTagType( 'Group_ExtraInformation' , 'A7' , 'GRP' , False ),
IngenicoTagType( 'Group_TransactionInformation' , 'A8' , 'GRP' , False ),
IngenicoTagType( 'Group_TcpParameter' , 'A9' , 'GRP' , False ),
IngenicoTagType( 'Group_UsbParameters' , 'AA' , 'GRP' , False ),
IngenicoTagType( 'Group_SerialParameters' , 'AB' , 'GRP' , False ),
IngenicoTagType( 'Group_Settings' , 'AC' , 'GRP' , False ),
IngenicoTagType( 'Group_SettingsResult' , 'AD' , 'GRP' , False ),
# General group tags.
IngenicoTagType( 'Group_Header' , 'E1' , 'GRP' , False ),
IngenicoTagType( 'Group_Body' , 'E2' , 'GRP' , False ),
IngenicoTagType( 'Group_Footer' , 'E3' , 'GRP' , False ),
IngenicoTagType( 'Group_TableRecord' , 'EF' , 'REC' , False ), # Used for repeated fields. e.g. The ticket data tag contains for each ticket a table record tag.
IngenicoTagType( 'Group_Root' , 'F0' , 'GRP' , False ),
# Transparent mode tags.
IngenicoTagType( 'TmTransparentMode' , '1F01' , '' , False ),
IngenicoTagType( 'TmCardDetectionTimeout' , '1F02' , '' , False ),
IngenicoTagType( 'TmCardUid' , '1F10' , '' , False ),
IngenicoTagType( 'TmCardAtr' , '1F11' , '' , False ),
IngenicoTagType( 'TmCardType' , '1F12' , '' , False ),
# Transparent mode UI Control tags.
IngenicoTagType( 'TmDisplayText' , '1F20' , '' , False ),
IngenicoTagType( 'TmBeepType' , '1F21' , '' , False ),
IngenicoTagType( 'TmLedControl' , '1F22' , '' , False ),
# Transparent mode authentication/read data/writ,.
IngenicoTagType( 'TmKey' , '1F30' , '' , False ),
IngenicoTagType( 'TmAddress' , '1F31' , '' , False ),
IngenicoTagType( 'TmDataSize' , '1F32' , '' , False ),
IngenicoTagType( 'TmData' , '1F33' , '' , False ),
# Transparent mode groups.
IngenicoTagType( 'Group_TmTransparentMode' , '3F01' , '' , False ),
IngenicoTagType( 'Group_TmUiControl' , '3F02' , '' , False ),
IngenicoTagType( 'RebootAndClearType' , 'C1' , '' , False ),
IngenicoTagType( 'SendOrDelete' , 'C2' , '' , False ), # used# for# pending# messages# :# 1=send# 2=delete
IngenicoTagType( 'Group_AcquirerList' , 'E7' , '' , False ), # group: AcquirerIdentifier=0xDF68, AcquirerLabelName=0xDF69
IngenicoTagType( 'Group_SecuritySchemeList' , 'BF01' , '' , False ), # group: SecuritySchemeIdentifier=0xDF6A, SecuritySchemeLabelName=0xDF6B
IngenicoTagType( 'CardholderLanguage' , 'DF1A' , '' , False ),
IngenicoTagType( 'Card_Brand_Identifier' , 'DF5F' , 'i' , 2 ),
IngenicoTagType( 'SecuritySchemeIdentifier' , 'DF8204' , '' , False ),
IngenicoTagType( 'AcquirerIdentifier' , 'DF68' , '' , False ),
IngenicoTagType( 'AcquirerLabelName' , 'DF69' , '' , False ),
],
'transactionStage' : {
b'\x00' : 'None',
b'\x01' : 'WaitingForCard',
b'\x02' : 'WaitingForPin',
b'\x03' : 'WaitingForTransaction',
b'\x04' : 'Finished',
b'\x05' : 'WaitingForTipInput',
b'\x06' : 'WaitingForConfirmationService',
b'\x07' : 'WaitingForConfirmationAmount',
b'\x08' : 'WaitingForConfirmationServiceAndAmount',
b'\x09' : 'WaitingForCardRemoval',
b'\x0a' : 'WaitingForLastTransactionResult',
b'\x0b' : 'WaitingForApplicationSelection',
b'\x0c' : 'CardDetected',
b'\x0d' : 'WaitingForIntermediateResult',
b'\x0e' : 'CardRemoved',
},
'transactionResult' : {
b'\x00' : 'Approved',
b'\x01' : 'Error',
b'\x02' : 'Declined',
b'\x03' : 'Stopped',
b'\x04' : 'TechnicalProblem',
b'\x05' : 'TransparentMode',
},
'cancelReasons' : {
'manual' : b'\x00',
'system' : b'\x01',
},
'byeReasons' : {
'Deactivate' : b'\x01',
'Shutdown' : b'\x02',
'Reboot' : b'\x03',
'Reconnect' : b'\x04',
'BatteryEmpty' : b'\x05',
},
})()
@classmethod
def _getTagDetailsByCode(cls, tagCode):
"""Search for tag in _const using the hex identifier.
Returns InenicoTagType instance.
Args:
tagCode (b): hexadecimal identifier of tag.
"""
return next((tagType for tagType in cls._const.tagType if tagType.hasTag(tagCode) == True), None)
@classmethod
def _getTagDetailsByName(cls, tagName):
"""Search for tag in _const providing the Human readable name.
Returns InenicoTagType instance.
Args:
tagCode (b): hexadecimal identifier of tag.
"""
return next((tagType for tagType in cls._const.tagType if tagType.name == tagName), None)
def __init__(self, dev):
"""Base Initialisation of Ingenico Message.
Args:
dev (Obj): tcp socket (or other device with byte-based send and recv function)
"""
self.dev = dev
class OutgoingIngenicoMessage(IngenicoMessage):
@staticmethod
def _withLength(msg, length):
"""Return tag content with given length.
Some tags have to have a fixed length to be accepted by the payment terminal. This function will add null-bytes
to match the required length.
Args:
msg (b): the message to edit
length (int): wanted length
"""
try:
toAdd = length - len(msg)
except:
_logger.error(format_exc())
if toAdd > 0:
return b'\x00' * toAdd + msg
return msg
@staticmethod
def _getCRC32(msg):
"""Return the crc for the specified message as a bytestring.
The result will always be 4 bytes long.
Args:
msg (b): the message to calculate the CRC for
"""
return unhexlify('{:08x}'.format(crc32(msg)))
@classmethod
def _generateTag(cls, tagName, content):
"""Return formatted tag with tag identifier + length + content.
The content of a tag often includes other tags, these have to be already formatted.
Args:
tagName (str): Human readable tag name
content (b): formatted tag content
"""
tag = cls._getTagDetailsByName(tagName)
if tag.len:
return unhexlify(tag.tag) + chr(tag.len).encode() + cls._withLength(content, tag.len)
return unhexlify(tag.tag) + chr(len(content)).encode() + content
@classmethod
def _generateMsg(cls, header, body, footer):
"""Return The formatted outgoing message including MessageLength and Magic string.
This is the very last step of the message generation. All arguments have to be completely formatted.
Args:
header (b)
body (b)
footer (b)
"""
root = cls._generateTag("Group_Root", header + body + footer)
msgLength = (len(cls._const.magic + root)).to_bytes(3, byteorder='big')
while len(msgLength) < 4:
msgLength = b'\x00' + msgLength
return msgLength + cls._const.magic + root
def __init__(self, dev, terminalId, ecrId, protocolId, messageType, sequence, **kwargs):
"""Initialisation of Outgoing Ingenico messages.
After initialisation the message will be automatically generated. the send function can be called to send the
message to the device.
Args:
dev (Obj): tcp socket (or other device with byte-based send and recv function)
protocolId
messageType
Kwargs:
keepAliveInterval
keepAliveResult
resultCode
transactionId
amount
reason
"""
super().__init__(dev)
self.terminalId = terminalId
self.ecrId = ecrId
self.protocolId = protocolId
messageTypes = self._const.messageType
self.messageTypeId = messageTypes[messageType]
self.sequence = sequence
self.resultCode = b'\x00'
if messageType in ["CancelRequest", "ByeRequest", "KeepAliveResponse"]:
self.reason = kwargs["reason"]
elif messageType == "HelloResponse":
self.keepAliveInterval = self._const.keepAliveInterval
elif messageType == "LastTransactionStatusRequest":
self.transactionId = kwargs["transactionId"]
elif messageType == "TransactionRequest":
self.transactionId = kwargs["transactionId"]
self.amount = kwargs["amount"]
header = self._generateHeader()
body, mdc = self._generateBody(self.messageTypeId)
footer = self._generateFooter(mdc)
self.message = self._generateMsg(header, body, footer)
self.send()
def _generateHeader(self):
"""Return formatted header.
The header does not depend on the message type.
"""
return self._generateTag( "Group_Header",
self._generateTag( "ProtocolId", self.protocolId) +
self._generateTag( "MessageType", self.messageTypeId) +
self._generateTag( "TerminalId", self.terminalId) +
self._generateTag( "EcrId", self.ecrId.encode()) +
self._generateTag( "SequenceNumber", self.sequence)
)
def _generateFooter(self, mdc):
"""Return the formatted footer
The footer can only be created after the body has been generated.
Args:
mdc (b): The Modification Detection Code generated on the Body tag.
"""
return self._generateTag( "Group_Footer", mdc)
def _generateMDC(self, innerBody):
"""Return the Modification Detection Code needed to generate the footer.
This function gets called after generating the body and before generating the footer.
Args:
innerBody (b): formatted body excluding body-tag and length.
"""
return self._generateTag("Mdc", self._getCRC32(innerBody))
def _generateBody(self, messageTypeId):
"""Return formatted body and Modification Detection Code.
Args:
messageTypeId (b): Hexadecimal message type identifier.
"""
innerBody = b''
messageTypes = self._const.messageType
if messageTypeId == messageTypes["HelloResponse"]:
innerBody = self._generateTag( "ResultCode", self.resultCode) + \
self._generateTag( "Group_ConnectionParameters",
self._generateTag( "KeepAliveInterval", self.keepAliveInterval,))
elif messageTypeId == messageTypes["KeepAliveResponse"]:
innerBody = self._generateTag( "KeepAliveReason", self.reason) + \
self._generateTag( "ResultCode", self.resultCode)
elif messageTypeId == messageTypes["TransactionRequest"]:
innerBody = self._generateTag( "TransactionId",
unhexlify('{:016x}'.format(int(self.transactionId)))) +\
self._generateTag( "Group_TransactionData", self._generateTag( "Amount" ,
int(str(self.amount), 16).to_bytes(4, byteorder='big'))) +\
self._generateTag( "Group_PrintData", self._generateTag( "PrintOrigin",b'\x02'))
elif messageTypeId == messageTypes["CancelRequest"]:
innerBody = self._generateTag( "CancelReason", self._const.cancelReasons[self.reason])
return self._generateTag( "Group_Body", innerBody), self._generateMDC(innerBody)
def send(self):
"""Send the generated message to the device.
This is the only function that has to be called manually!
"""
self.dev.send(self.message)
class IncomingIngenicoMessage(IngenicoMessage):
@staticmethod
def _hexToInt(byteArray):
return int.from_bytes(byteArray, byteorder='big')
def _getMsg(self, length ):
"""Return a dictionary of the next tag in the buffer.
Returns the decoded content of an message tag. If the tag is an group of other tags, this function will get
called again to generate an dictionary of the entire message tree.
Returns length left in parent tag.
Args:
length (int): length left to be read in the parent tag.
"""
tag = self._getTag()
tag['len'], lengthBytes = self._getLength()
if tag['format'] in ['GRP', 'REC']:
xTags = {}
xMsgLength = tag['len']
while xMsgLength > 0:
xTag, xMsgLength = self._getMsg(xMsgLength)
xTags[xTag['name']] = xTag['msg']
tag["msg"] = xTags
elif tag['format'] == 'TBL':
xTags = []
xMsgLength = tag['len']
while xMsgLength > 0:
xTag, xMsgLength = self._getMsg(xMsgLength)
if xTag['format'] != 'REC':
_logger.warning("Expected REC field but got %s with tag %s", xTag['format'], xTag['tag'])
xTags.append(xTag['msg'])
tag["msg"] = xTags
else:
tag["msg"] = self.dev.recv(tag['len'])
return tag, length - (tag['len'] + lengthBytes +tag['tagLen'] )
def __init__(self, dev):
"""Initialisation of incomming Ingenico messages.
After initialisation there will be a check if there is an Ingenico message available. If so, the message will
be requested from the socket buffer and will be decoded. The data will be made available in the variable
_tagTree
All data is read directly from the device buffer. It is from upmost importance to call the read functions in the
correct sequence. The messages from Ingenico have the Tag Length Value format. Becouse the mixed content of the
messages the standard Python TLV library cannot be used to decode the messages.
Raises:
ValueError: If the `Magic String` is not found an error will be thrown indicating the received message is
no Ingenico message.
Args:
dev (Obj): tcp socket (or other device with byte-based send and recv function)
"""
super().__init__(dev)
# If we're being called in `supported`, `self.dev` will be a socket. If we're
# being called in `run`, `self.dev` will be an IngenicoDriver and `self.dev.dev`
# will be the socket.
if hasattr(self.dev, 'dev'):
_logger.debug("Listening on: %s", self.dev.dev)
# Receive message length and reduce it with length of magic string
_logger.debug("Waiting for message length")
length = self._hexToInt(self.dev.recv(4)) - 8
# Check if message is from Ingenico terminal by comparing magic string
_logger.debug("Waiting for magic string")
self.magic = self.dev.recv(8)
if self.magic and self.magic == self._const.magic:
# Receive and decode message
self._tagTree, leftLength = self._getMsg(length)
else:
_logger.warning('Out of magic!')
def _getLength(self):
"""Returns the message length of the tag as well as the length of the message length itself
The length is read directly from the device buffer. It is important to call this function only after receiving
the tag identifier.
"""
# The message length has a short form that fits in one byte (for lengths < 128)
# and a variable length long form where bit 8 is set on the first byte and
# the other bits specify the amount of bytes that follow. Those bytes then
# contain the length of the actual message. See section 2.1.2 in v1.0.9 of the
# TLV Cash Register Interface Specification. It's allowed to encode
# lengths < 128 using the long form.
length = int(self.dev.recv(1).hex(), 16)
if length // 128 == 1:
return int(self.dev.recv(length % 128).hex(), 16), 1 + length % 128
else:
return length, 1
def _getTag(self):
"""Return the tag identifier
The tag identifier is read directly from the device buffer.
"""
tagLength = 1
tag = self.dev.recv(1).hex()
if int(tag, 16) % 32 == 31:
getNext = True
while (getNext):
tagLength += 1
nextByte = self.dev.recv(1).hex()
if (int(nextByte, 16) < 128):
getNext = False
tag += nextByte
tagObject = self._getTagDetailsByCode(tag)
return tagObject.getDict()
def getProtocolId(self):
"""Return The Protocol Id from the tagtree.
"""
return self._tagTree['msg']['Group_Header']['ProtocolId']
def getTerminalId(self):
"""Return The Protocol Id from the tagtree.
"""
return self._tagTree['msg']['Group_Header']['TerminalId']
def getTransactionResult(self):
"""Return The Protocol Id from the tagtree.
"""
if 'TransactionResult' in self._tagTree['msg']['Group_Body'].keys():
return self._const.transactionResult[self._tagTree['msg']['Group_Body']['TransactionResult']]
return False
def getTransactionStage(self):
"""Return The Transaction Stage from the tagtree.
If the transaction stage is not found return False.
"""
if 'TransactionStage' in self._tagTree['msg']['Group_Body'].keys():
return self._const.transactionStage[self._tagTree['msg']['Group_Body']['TransactionStage']]
return False
def getTransactionTicket(self):
"""Return The Transaction ticket from the tagtree.
If there is no ticket data available return False.
"""
if 'Group_TicketData' in self._tagTree['msg']['Group_Body']:
# We currently don't do anything with different ticket types and ignore
# headers and footers. The TLV Cash Register Interface spec mentions header
# (4.5.7.2) and footer (4.5.7.4) currently being empty, but that they might
# be implemented in the future.
ticket_data = self._tagTree['msg']['Group_Body']['Group_TicketData']
ticket_bodies = [r['TicketBody'] for r in ticket_data if 'TicketBody' in r and r['TicketBody']]
return b'\n'.join(ticket_bodies)
return False
def getKeepAliveInterval(self):
"""Return the keep alive interval from the tagtree.
If there is connection data available return False.
"""
if 'Group_ConnectionParameters' in self._tagTree['msg'].keys():
return self._tagTree['msg']['Group_ConnectionParameters']['KeepAliveInterval']
return False
def getKeepAliveReasonId(self):
"""Return The keep alive reason from the tagtree.
If the message is no keep alive message return False.
"""
if 'KeepAliveReason' in self._tagTree['msg']['Group_Body'].keys():
return self._tagTree['msg']['Group_Body']['KeepAliveReason']
return False
def getMessageType(self):
"""Return The message type from the constants, as found in the tagtree.
"""
messageTypeId = self._tagTree['msg']['Group_Header']['MessageType']
return next((mt for mt, mtId in self._const.messageType.items() if mtId == messageTypeId and not mt == "HelloResponse" ), None)
class IngenicoDriver(Driver):
connection_type = 'socket'
_ecrId = 'odoo'
def __init__(self, identifier, device):
super(IngenicoDriver, self).__init__(identifier, device)
self.dev = device.dev
self._terminalId = device.terminalId
self._protocolId = device.protocolId
self._sequence = 0
self.device_type = 'payment'
self.device_connection = 'network'
self.device_name = 'Ingenico payment terminal'
self.device_manufacturer = 'Ingenico'
self.cid = None
self._actions.update({
'': self._action_default,
})
@classmethod
def supported(cls, device):
"""Try to initialize a connection with the payment terminal.
Override
"""
try:
# Setup socket connection
msg = IncomingIngenicoMessage(device.dev)
if msg and msg.magic == b'P4Y-ECR!' and msg.getMessageType() == "HelloRequest":
device.terminalId = msg.getTerminalId()
device.protocolId = msg.getProtocolId()
OutgoingIngenicoMessage( device.dev, device.terminalId, cls._ecrId, device.protocolId, "HelloResponse", b'\x00')
return True
elif msg and msg.magic == b'P4Y-ECR!' and msg.getMessageType() == "KeepAliveRequest":
device.terminalId = msg.getTerminalId()
device.protocolId = msg.getProtocolId()
OutgoingIngenicoMessage(device.dev, device.terminalId, cls._ecrId, device.protocolId, "KeepAliveResponse", b'\x00', reason=msg.getKeepAliveReasonId())
return True
return False
except Exception:
_logger.error(format_exc())
return False
def disconnect(self):
# Close the socket but leave the socket_devices entry. If we were to delete it,
# and the SocketInterface gets a new connection from the terminal, it would
# create a new socket_devices entry with the same key. Interface's
# update_iot_devices method would not detect that something changed and no new
# IngenicoDriver thread would be created, resulting in a deadlock. What will
# instead happen by leaving the socket_devices entry, is that
# replace_socket_device will get called instead. It will update
# _detected_devices in Interface, so update_iot_devices will create a new
# IngenicoDriver thread. None of this is ideal, but a cleaner fix would require
# changing the architecture of Interface and how interfaces and drivers can
# talk to each other.
sock = socket_devices[self.device_identifier].dev
try:
sock.shutdown(socket.SHUT_RD)
except OSError:
# A bad file descriptor OSError will be thrown if the socket was already
# closed
pass
sock.close()
super().disconnect()
def _getSequence(self):
"""Returns the sequence number for the next outgoing message.
The sequence of incomming and outgoing messages are unrelated. If the sequence of outgoing messages is wrong
the terminal will automatically close the connection.
"""
self._sequence += 1
return (self._sequence%(256**2)).to_bytes(2,byteorder='big')
def _outgoingMessage(self, messageType, **kwargs):
"""Base function to generate in instance of OutgoingIngenicoMessage.
"""
OutgoingIngenicoMessage( self, self._terminalId, self._ecrId,
self._protocolId, messageType, self._getSequence(), **kwargs)
def _action_default(self, data):
"""Action trigered on request from Odoo.
Override
"""
try:
self.data = {'value': '', 'Stage': False, 'Response': False, 'Ticket': False, 'Error': False}
if data['messageType'] == 'Transaction':
self.cid = data['cid']
if data['amount'] < 0:
raise ValueError("The transaction amount value should be positive")
self._outgoingMessage( "TransactionRequest", transactionId=data['TransactionID'], amount=data['amount'])
elif data['messageType'] == 'Cancel':
self._outgoingMessage( "CancelRequest", reason=data['reason'])
except Exception as e:
error_message = "Error while performing transaction request to the Ingenico payment terminal"
_logger.exception(error_message)
self.data["Error"] = "{}\n{}: {}".format(error_message, type(e).__name__, e)
self.data["cid"] = self.cid
event_manager.device_changed(self)
def recv(self, length):
try:
return self.dev.recv(length)
except socket.error as e:
_logger.error("Socket error in recv: %s", e)
def send(self, request):
try:
return self.dev.send(request)
except socket.error as e:
_logger.error("Socket error in send: %s", e)
def run(self):
"""If an payment terminal is found, start listening for messages from the terminal.
Override
"""
try:
self.data = {'value': '', 'Stage': False, 'Response': False, 'Ticket': False, 'Error': False}
while not self._stopped.is_set():
sleep(1)
_logger.debug("Waiting for incoming message")
msg = IncomingIngenicoMessage(self)
_logger.debug("Incoming message received")
if msg and msg.magic == b'P4Y-ECR!':
self.data['value'] = 'Connected'
self.data["Response"] = False
self.data["Error"] = False
msgType = msg.getMessageType()
to_notify = False
stage = msg.getTransactionStage()
if stage and stage != self.data['Stage']:
self.data['Stage'] = stage
if stage in ['WaitingForCard', 'WaitingForPin']:
to_notify = True
self.data['cid'] = self.cid
if msgType == "KeepAliveRequest":
self._outgoingMessage( "KeepAliveResponse", reason=msg.getKeepAliveReasonId())
elif msgType == "TransactionResponse":
self.data["Response"] = msg.getTransactionResult() if msg.getTransactionResult() else self.data["Response"]
if self.data["Response"] == 'Error':
self.data["Error"] = 'Canceled'
self.data["Ticket"] = msg.getTransactionTicket() if msg.getTransactionTicket() else self.data["Ticket"]
to_notify = True
if to_notify:
event_manager.device_changed(self)
else:
_logger.info("Terminating due to an invalid message")
self.disconnect()
break
except Exception:
_logger.info("Terminating due to an exception")
self.disconnect()
_logger.error(format_exc())

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
from time import sleep
from logging import getLogger
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import CtypesTerminalDriver, import_ctypes_library, CTYPES_BUFFER_SIZE, create_ctypes_string_buffer
_logger = getLogger(__name__)
CANCELLED_BY_POS = 2 # Error code returned when you press "cancel" in PoS
# Load library
LIB_NAME = 'libsix_odoo_w.dll' if IS_WINDOWS else 'libsix_odoo_l.so'
TIMAPI = import_ctypes_library('tim', LIB_NAME)
# int six_cancel_transaction(t_terminal_manager *terminal_manager)
TIMAPI.six_cancel_transaction.argtypes = [ctypes.c_void_p]
# int six_perform_transaction
TIMAPI.six_perform_transaction.argtypes = [
ctypes.c_void_p, # t_terminal_manager *terminal_manager
ctypes.c_char_p, # char *pos_id
ctypes.c_int, # int user_id
ctypes.c_int, # int transaction_type
ctypes.c_int, # int amount
ctypes.c_char_p, # char *currency_str
ctypes.c_char_p, # char *transaction_id,
ctypes.c_char_p, # char *merchant_receipt
ctypes.c_char_p, # char *customer_receipt
ctypes.c_char_p, # char *card_number
ctypes.c_char_p, # char *card_brand
ctypes.POINTER(ctypes.c_int), # int *error_code
ctypes.c_char_p, # char *error
]
TIMAPI.six_terminal_balance.argtypes = [
ctypes.c_void_p, # t_terminal_manager *terminal_manager
ctypes.c_char_p, # char *balance_receipt_buffer
ctypes.POINTER(ctypes.c_int), # int *n_receipts
]
class SixDriver(CtypesTerminalDriver):
connection_type = 'tim'
def __init__(self, identifier, device):
super(SixDriver, self).__init__(identifier, device)
self.device_name = 'Six terminal %s' % self.device_identifier
self.device_manufacturer = 'Six'
def processTransaction(self, transaction):
if transaction['amount'] <= 0:
return self.send_status(
error='The terminal cannot process negative or null transaction amounts.',
request_data=transaction
)
elif transaction['transactionType'] not in ['Refund', 'Payment']:
return self.send_status(
error='Invalid transaction type.',
request_data=transaction
)
# Notify PoS about the transaction start
self.send_status(stage='WaitingForCard', request_data=transaction)
# Transaction buffers
transaction_id = create_ctypes_string_buffer()
merchant_receipt = create_ctypes_string_buffer()
customer_receipt = create_ctypes_string_buffer()
card_number = create_ctypes_string_buffer()
card_brand = create_ctypes_string_buffer()
error_code = ctypes.c_int(0)
error = create_ctypes_string_buffer()
# Transaction
try:
_logger.info('Start transaction #%s', transaction)
result = TIMAPI.six_perform_transaction(
self.dev, # t_terminal_manager *terminal_manager
str(transaction['posId']).encode(), # char *pos_id
ctypes.c_int(transaction['userId']), # int user_id
ctypes.c_int(1) if transaction['transactionType'] == 'Payment' else ctypes.c_int(2), # int transaction_type
ctypes.c_int(transaction['amount']), # int amount
transaction['currency'].encode(), # char *currency_str
transaction_id, # char *transaction_id
merchant_receipt, # char *merchant_receipt
customer_receipt, # char *customer_receipt
card_number, # char *card_number
card_brand, # char *card_brand
ctypes.byref(error_code), # int *error_code
error, # char *error
)
# Transaction successful
if result == 1:
_logger.info('Successfully finished transaction #%s', transaction)
self.send_status(
response='Approved',
ticket=customer_receipt.value.decode(),
ticket_merchant=merchant_receipt.value.decode(),
card=card_brand.value.decode(),
card_no=card_number.value.decode(),
transaction_id=transaction_id.value.decode(),
request_data=transaction,
)
# Transaction failed
elif result == 0:
# If cancelled by Odoo Pos
if error_code.value == CANCELLED_BY_POS:
sleep(3) # Wait a couple of seconds between cancel requests as per documentation
_logger.info("Transaction #%s cancelled by PoS user", transaction)
self.send_status(stage='Cancel', request_data=transaction)
_logger.info("Transaction %s cancelled by Odoo PoS", transaction)
# If an error was encountered
else:
error_message = f"{error_code.value}: {error.value.decode()}"
_logger.info("Transaction #%s failed with error: %s", transaction, error_message)
self.send_status(error=error_message, request_data=transaction)
# Terminal disconnected
elif result == -1:
_logger.warning("Terminal disconnected during transaction #%s", transaction)
self.send_status(disconnected=True)
except OSError:
_logger.exception("Failed to perform Six transaction. Check for potential segmentation faults")
sleep(3) # needed to space out transaction requests
self.send_status(
error="An error has occured. Check the transaction result manually with the payment provider",
request_data=transaction,
)
def cancelTransaction(self, transaction):
self.send_status(stage='waitingCancel', request_data=transaction)
if not self.terminal_busy:
# In case of restart after sending a payment request, the terminal is not busy
# but the pos is still waiting for the transaction confirmation: we need to be
# able to unblock it pressing cancel
return self.send_status(stage="Cancel", request_data=transaction)
try:
_logger.info("cancel transaction request for %s", transaction)
if not TIMAPI.six_cancel_transaction(ctypes.cast(self.dev, ctypes.c_void_p)):
_logger.info("Transaction #%s could not be cancelled", transaction)
self.send_status(stage='Cancel', error='Transaction could not be cancelled', request_data=transaction)
except OSError:
_logger.exception("Failed to cancel Six transaction. Check for potential segmentation faults.")
sleep(3) # needed to space out cancellation requests
self.send_status(
error="An error has occured when cancelling Six transaction. Check the transaction result manually with the payment provider",
request_data=transaction,
)
def six_terminal_balance(self, data):
balance_receipt_buffer = create_ctypes_string_buffer()
n_receipts = ctypes.c_int(0)
try:
_logger.info("Requesting terminal balance")
result = TIMAPI.six_terminal_balance(self.dev, balance_receipt_buffer, ctypes.byref(n_receipts))
if result:
_logger.info("Terminal balance request success")
# If this ever occurs the C code will need to be adapted to handle multiple receipts
n_receipts_val = n_receipts.value
receipt_val = balance_receipt_buffer.value.decode()
if n_receipts_val > 1:
_logger.warning("%s receipts returned from terminal balance request, only the first will be used", n_receipts_val)
elif n_receipts_val == -1:
receipt_val += "\nTruncated receipt"
_logger.warning("The balance receipt was truncated, consider increasing the buffer size")
self.send_status(request_data=data, ticket=receipt_val)
else:
_logger.info("Failed to get terminal balance")
self.send_status(error="Failed to get terminal balance", request_data=data)
except OSError:
_logger.exception("Failed to get terminal balance. Check for potential segmentation faults.")
self.send_status(
error="An error has occured when requesting the terminal balance. Check the terminal manually",
request_data=data,
)

View File

@@ -0,0 +1,199 @@
# -*- 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)

View File

@@ -0,0 +1,217 @@
# -*- 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__)
# Load library
easyCTEP = import_ctypes_library('ctep_w', 'libeasyctep.dll')
# int startTransaction(
easyCTEP.startTransaction.argtypes = [
ctypes.c_void_p, # CTEPManager* manager
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(CTEPManager* manager, char* error)
easyCTEP.abortTransaction.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
# 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
]
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)
try:
result = easyCTEP.startTransaction(
ctypes.cast(self.dev, ctypes.c_void_p), # CTEPManager* manager
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:
# Transaction successful
_logger.info('succesfully finished transaction #%d', transaction_id)
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:
# Transaction failed
error_code = error_code.value.decode('utf-8')
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)
except OSError:
_logger.exception("Failed to perform Worldline transaction. Check for potential segmentation faults")
self.send_status(
error="An error has occured. Check the transaction result manually with the payment provider",
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")
try:
result = easyCTEP.abortTransaction(ctypes.cast(self.dev, ctypes.c_void_p), error_code)
_logger.debug("end cancel transaction request")
if not result:
error_code = error_code.value.decode('utf-8')
error_msg = '%s (Error code: %s)' % (TERMINAL_ERRORS.get(error_code, 'Transaction could not be cancelled'), error_code)
_logger.info(error_msg)
self.send_status(stage='Cancel', error=error_msg, request_data=transaction)
except OSError:
_logger.exception("Failed to cancel Worldline transaction. Check for potential segmentation faults.")
self.send_status(
stage='Cancel',
error="An error has occured when cancelling Worldline transaction. Check the transaction result manually with the payment provider",
request_data=transaction,
)

View File

@@ -0,0 +1,72 @@
from gatt import DeviceManager as Gatt_DeviceManager
import dbus
from gi.repository import GLib
import logging
from threading import Thread
from odoo.addons.iot_drivers.interface import Interface
bt_devices = {}
_logger = logging.getLogger(__name__)
class GattBtManager(Gatt_DeviceManager):
def device_discovered(self, device):
identifier = "bt_%s" % device.mac_address
if identifier not in bt_devices:
device.manager = self
bt_devices[identifier] = device
def run(self):
""" Override gatt.DeviceManager.run() method
to avoid calling GObject.MainLoop() deprecated method inside it.
MainLoop.run() will 'infinite loop' until MainLoop.quit()
method is called which we never do, so we don't need to reimplement
the rest of the MainLoop.run() method """
if self._main_loop:
return
self._interface_added_signal = self._bus.add_signal_receiver(
self._interfaces_added,
dbus_interface='org.freedesktop.DBus.ObjectManager',
signal_name='InterfacesAdded')
self._properties_changed_signal = self._bus.add_signal_receiver(
self._properties_changed,
dbus_interface=dbus.PROPERTIES_IFACE,
signal_name='PropertiesChanged',
arg0='org.bluez.Device1',
path_keyword='path')
def disconnect_signals():
for device in self._devices.values():
device.invalidate()
self._properties_changed_signal.remove()
self._interface_added_signal.remove()
self._main_loop = GLib.MainLoop()
try:
self._main_loop.run()
disconnect_signals()
except Exception:
disconnect_signals()
raise
class BtManager(Thread):
def run(self):
dm = GattBtManager(adapter_name='hci0')
for device in [device_con for device_con in dm.devices() if device_con.is_connected()]:
device.disconnect()
dm.start_discovery()
dm.run()
class BTInterface(Interface):
connection_type = 'bluetooth'
def get_devices(self):
return bt_devices.copy()
bm = BtManager()
bm.daemon = True
bm.start()

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import subprocess
import logging
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.helpers import path_file
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, create_ctypes_string_buffer
_logger = logging.getLogger(__name__)
# Check if the Worldline CTEP library exists, download it and set up the linker otherwise
if not path_file('odoo/addons/iot_drivers/iot_handlers/lib/ctep/libeasyctep.so').exists():
load_worldline_library_script = path_file('odoo/addons/iot_drivers/iot_handlers/lib/load_worldline_library.sh')
try:
subprocess.run(["sudo", "sh", load_worldline_library_script], check=True)
except subprocess.CalledProcessError:
_logger.exception('An error encountered while downloading / setting up Worldline CTEP library')
easyCTEP = import_ctypes_library('ctep', 'libeasyctep.so')
# CTEPManager* createCTEPManager(void);
easyCTEP.createCTEPManager.restype = ctypes.c_void_p
# int connectedTerminal(CTEPManager* manager, char* terminal_id, std::shared_ptr<ect::CTEPTerminal> terminal)
easyCTEP.connectedTerminal.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p]
class CTEPInterface(Interface):
_loop_delay = 10
connection_type = 'ctep'
def __init__(self):
super().__init__()
self.manager = easyCTEP.createCTEPManager()
def get_devices(self):
devices = {}
terminal_id = create_ctypes_string_buffer()
device = ctypes.c_void_p()
if easyCTEP.connectedTerminal(self.manager, terminal_id, ctypes.byref(device)):
devices[terminal_id.value.decode('utf-8')] = device
return devices

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
from pathlib import Path
import os
import logging
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.helpers import download_from_url, unzip_file
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, create_ctypes_string_buffer
_logger = logging.getLogger(__name__)
libPath = Path('odoo/addons/iot_drivers/iot_handlers/lib')
easyCTEPPath = libPath / 'ctep_w/libeasyctep.dll'
zipPath = str(libPath / 'ctep_w.zip')
if not easyCTEPPath.exists():
download_from_url('' # Disabled -- community repackage, zipPath)
unzip_file(zipPath, str(libPath / 'ctep_w'))
# Add Worldline dll path so that the linker can find the required dll files
os.environ['PATH'] = str(libPath / 'ctep_w') + os.pathsep + os.environ['PATH']
easyCTEP = import_ctypes_library("ctep_w", "libeasyctep.dll")
easyCTEP.createCTEPManager.restype = ctypes.c_void_p
easyCTEP.connectedTerminal.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
class CTEPInterface(Interface):
_loop_delay = 10
connection_type = 'ctep'
def __init__(self):
super().__init__()
try:
self.manager = easyCTEP.createCTEPManager()
except OSError:
_logger.exception("Failed to initalize CTEPManager")
def get_devices(self):
devices = {}
terminal_id = create_ctypes_string_buffer()
try:
if self.manager and easyCTEP.connectedTerminal(self.manager, terminal_id):
devices[terminal_id.value.decode('utf-8')] = self.manager
except OSError:
_logger.exception("Failed to check if the Worldline terminal is connected")
return devices

View File

@@ -0,0 +1,122 @@
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()

View File

@@ -0,0 +1,108 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ctypes
import os
import logging
from pathlib import Path
import subprocess
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
from odoo.addons.iot_drivers.tools import helpers
from odoo.tools.misc import file_path
from odoo.addons.iot_drivers.iot_handlers.lib.ctypes_terminal_driver import import_ctypes_library, CTYPES_BUFFER_SIZE
_logger = logging.getLogger(__name__)
if IS_WINDOWS:
LIB_PATH = Path('odoo/addons/iot_drivers/iot_handlers/lib')
DOWNLOAD_URL = '' # Disabled -- community repackage
else:
LIB_PATH = file_path('iot_drivers/iot_handlers/lib')
DOWNLOAD_URL = '' # Disabled -- community repackage
# Download and unzip timapi library, overwriting the existing one
TIMAPI_ZIP_PATH = f'{LIB_PATH}/tim.zip'
helpers.download_from_url(DOWNLOAD_URL, TIMAPI_ZIP_PATH)
helpers.unzip_file(TIMAPI_ZIP_PATH, f'{LIB_PATH}/tim')
# Make TIM SDK dependency libraries visible for the linker
if IS_WINDOWS:
LIB_PATH = file_path('iot_drivers/iot_handlers/lib')
os.environ['PATH'] = file_path('iot_drivers/iot_handlers/lib/tim') + os.pathsep + os.environ['PATH']
else:
TIMAPI_DEPENDANCY_LIB = 'libtimapi.so.3'
TIMAPI_DEPENDANCY_LIB_V = f'{TIMAPI_DEPENDANCY_LIB}.38.0-5308'
DEP_LIB_PATH = file_path('iot_drivers/iot_handlers/lib/tim')
USR_LIB_PATH = '/usr/lib'
try:
subprocess.call([f'sudo cp {DEP_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB_V} {USR_LIB_PATH}'], shell=True)
subprocess.call([f'sudo ln -fs {USR_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB_V} {USR_LIB_PATH}/{TIMAPI_DEPENDANCY_LIB}'], shell=True)
except subprocess.CalledProcessError as e:
_logger.error("Failed to link the TIM SDK dependent library: %s", e.output)
# Import Odoo Timapi Library
LIB_NAME = 'libsix_odoo_w.dll' if IS_WINDOWS else 'libsix_odoo_l.so'
TIMAPI = import_ctypes_library('tim', LIB_NAME)
# --- Setup library prototypes ---
# void *six_initialize_manager(int buffer_size) {
TIMAPI.six_initialize_manager.argtypes = [ctypes.c_int]
TIMAPI.six_initialize_manager.restype = ctypes.c_void_p
# int six_setup_terminal_settings(t_terminal_manager *terminal_manager, char *terminal_id);
TIMAPI.six_setup_terminal_settings.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
# int six_terminal_connected(t_terminal_manager *terminal_manager);
TIMAPI.six_terminal_connected.argtypes = [ctypes.c_void_p]
class TIMInterface(Interface):
_loop_delay = 30
connection_type = 'tim'
def __init__(self):
super(TIMInterface, self).__init__()
try:
buffer_size = ctypes.c_int(CTYPES_BUFFER_SIZE)
self.manager = TIMAPI.six_initialize_manager(buffer_size)
except OSError:
_logger.exception("Failed to initalize TIM manager")
if not self.manager:
_logger.error('Failed to allocate memory for TIM Manager')
self.tid = None
def get_devices(self):
if not self.manager:
return {}
# As this code is fetched by the IoT Box from the DB, we can't be sure
# that the IoT Box has the new method `get_conf`.
# This try-except should be replaced by a simple call to `get_conf` in master
try:
new_tid = helpers.get_conf("six_payment_terminal")
except AttributeError:
_logger.warning("Failed to get the Six TID from the configuration file, trying to read it from the old file")
new_tid = helpers.read_file_first_line('odoo-six-payment-terminal.conf')
devices = {}
# If the Six TID setup has changed, reset the settings
if new_tid != self.tid:
self.tid = new_tid
encoded_tid = new_tid.encode() if new_tid else None
try:
if not TIMAPI.six_setup_terminal_settings(self.manager, encoded_tid):
return {}
except OSError:
_logger.exception("Failed to setup Six terminal settings")
return {}
# Check if the terminal is online and responsive
try:
if self.tid and TIMAPI.six_terminal_connected(self.manager):
devices[self.tid] = ctypes.cast(self.manager, ctypes.c_void_p)
except OSError:
_logger.exception("Failed to check if the Six terminal is connected")
return devices

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
"""