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

856 lines
44 KiB
Python

# -*- 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())