feat(iot): repackaged Odoo iot modules + Fusion Plating sensor wrapper
Phase A of the IoT initiative — gets the server-side infrastructure
in place before the Raspberry Pi hardware arrives, so the iot admin
UI + /fp/iot/ingest endpoint are ready to accept the first real
temperature reading as soon as the Pi is wired up.
New top-level folder: fusion_iot/
1. **iot_base/** — Odoo S.A. iot_base module, copied from
RePackaged-Odoo verbatim. LGPL-3 upstream, no changes needed.
2. **iot/** — Odoo S.A. iot module, repackaged:
- `models/update.py` neutralised (removed the publisher_warranty
IoT-Box-counting report that phones home to odoo.com for
enterprise licence enforcement)
- `iot_handlers/lib/load_worldline_library.sh` deleted (proprietary
Worldline payment lib fetch from download.odoo.com, not needed)
- `wizard/add_iot_box.py._connect_iot_box_with_pairing_code` —
upstream called odoo.com's iot-proxy to resolve pairing codes;
replaced with a no-op. Pi-side iot_drivers proxy registers
directly with this Odoo server instead.
- Manifest rebranded with an explicit changelog preamble.
3. **fusion_plating_iot/** — new plating-specific wrapper:
- `fp.tank.sensor` — maps an iot.device (or a direct-HTTP-ingest
sensor) to a fusion.plating.tank + fusion.plating.bath.parameter.
Supports DS18B20, PT100/1000, pH, conductivity, level. Per-sensor
alert_min/max overrides.
- `fp.tank.reading` — append-only time-series. On create, evaluates
against sensor's alert range. On in-spec → out-of-spec TRANSITION,
auto-raises a fusion.plating.quality.hold (once per excursion,
no spam during sustained out-of-spec).
- `POST /fp/iot/ingest` — shared-secret HTTP endpoint for sensors
bypassing the Pi proxy. Token via X-FP-IOT-Token header OR body.
Accepts single-reading or batch payloads.
- Menu under Plating → Operations → Sensors & Readings.
- Tank form inherits get a Sensors tab inline.
Deployed to entech. Verified end-to-end:
- Install: iot_base + iot + fusion_plating_iot all 'installed'
- Smoke test: in-spec → out-of-spec → hold raised (HOLD-0010);
continued excursion → NO duplicate hold; back-in-spec → NEW
excursion → NEW hold (HOLD-0011) ✓
- HTTP endpoint: correct token → 200 accepted; wrong token → 401;
unknown device_serial → 404; batch payload → 200 accepted=N ✓
Phase B (when Raspberry Pi hardware arrives): DS18B20 iot_handler
driver for the Pi-side iot_drivers proxy + systemd service on
vanilla Raspberry Pi OS + first live reading from physical probe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
fusion_iot/iot/iot_handlers/._drivers
Executable file
BIN
fusion_iot/iot/iot_handlers/._drivers
Executable file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/._interfaces
Executable file
BIN
fusion_iot/iot/iot_handlers/._interfaces
Executable file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/._lib
Executable file
BIN
fusion_iot/iot/iot_handlers/._lib
Executable file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/drivers/._IngenicoDriver.py
Normal file
BIN
fusion_iot/iot/iot_handlers/drivers/._IngenicoDriver.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/drivers/._SixDriver.py
Normal file
BIN
fusion_iot/iot/iot_handlers/drivers/._SixDriver.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/drivers/._WorldlineDriver_L.py
Normal file
BIN
fusion_iot/iot/iot_handlers/drivers/._WorldlineDriver_L.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/drivers/._WorldlineDriver_W.py
Normal file
BIN
fusion_iot/iot/iot_handlers/drivers/._WorldlineDriver_W.py
Normal file
Binary file not shown.
855
fusion_iot/iot/iot_handlers/drivers/IngenicoDriver.py
Normal file
855
fusion_iot/iot/iot_handlers/drivers/IngenicoDriver.py
Normal 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())
|
||||
180
fusion_iot/iot/iot_handlers/drivers/SixDriver.py
Normal file
180
fusion_iot/iot/iot_handlers/drivers/SixDriver.py
Normal 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,
|
||||
)
|
||||
199
fusion_iot/iot/iot_handlers/drivers/WorldlineDriver_L.py
Normal file
199
fusion_iot/iot/iot_handlers/drivers/WorldlineDriver_L.py
Normal 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': 'Doesn’t exist',
|
||||
'2802': 'Not allowed',
|
||||
'2806': 'Bad signature',
|
||||
'2807': 'Conditional field missing',
|
||||
'2808': 'Not found',
|
||||
'2809': 'Dependency not found',
|
||||
'2810': 'Bad value',
|
||||
'2811': 'Bad sequence',
|
||||
'2812': 'Device attachment',
|
||||
'2813': 'Unexpected field',
|
||||
'3100': 'Chip card expected',
|
||||
'3101': 'Card not well read',
|
||||
'3102': 'Condition of use not satisfied',
|
||||
'4000': 'Purse technical problem',
|
||||
'4001': 'Purse host identifier invalid',
|
||||
'4002': 'Purse SDA certificate error',
|
||||
'4003': 'Purse extended SDA certificate error',
|
||||
'4004': 'Purse in red list',
|
||||
'4005': 'Purse is locked for credit',
|
||||
'4006': 'Purse is locked for debit',
|
||||
'4007': 'Purse expired',
|
||||
'4008': 'Purse state error',
|
||||
'4009': 'Purse recovery error',
|
||||
'4010': 'Purse key identifier error',
|
||||
'4011': 'Purse balance too large',
|
||||
'4012': 'Insufficient purse balance',
|
||||
'4100': 'No purse in reader and time out expired',
|
||||
'4101': 'Time-out on fallback card reading',
|
||||
'4102': 'Problem linked to card',
|
||||
'4103': 'Card information not available',
|
||||
'4200': 'Entered amount invalid',
|
||||
'4201': 'Double operation',
|
||||
'4202': 'Invalid currency',
|
||||
'4203': 'Amount higher than authorized amount',
|
||||
'4204': 'Floor limit exceeded in EMV mode',
|
||||
'4205': 'Transaction refused by the terminal in EMV mode',
|
||||
'4206': 'Transaction refused by the card in EMV mode',
|
||||
'4207': 'Product not available',
|
||||
'4300': 'Service (already) activated',
|
||||
'4301': 'Service (already) deactivated',
|
||||
'4302': 'Maximal transaction number per (calendar) month reached',
|
||||
'4303': 'Maximal uncollected journals number reached',
|
||||
'4304': 'Service activation not supported',
|
||||
'4305': 'Maximum transaction records reached',
|
||||
'4306': 'Maximum service activation number reached',
|
||||
'6003': 'Paper jam',
|
||||
'6004': 'Remove previous ticket',
|
||||
'6005': 'No paper',
|
||||
'6006': 'Low paper',
|
||||
'6008': 'Printer specific',
|
||||
'7806': 'Product not allowed',
|
||||
'7808': 'Bad pump number',
|
||||
'7816': 'Incorrect pump session number',
|
||||
'7817': 'Transaction amount null',
|
||||
'7818': 'Transaction amount null and quantity null',
|
||||
'7819': 'Pump unhooked time-out expiration',
|
||||
'9002': 'No key fault',
|
||||
'9003': 'Cryptographic fault',
|
||||
'9004': 'No PIN fault',
|
||||
'9005': 'Bad MAC',
|
||||
'9006': 'Bad MDC',
|
||||
}
|
||||
|
||||
# Manually cancelled by cashier, do not show these errors
|
||||
IGNORE_ERRORS = [
|
||||
'2628', # External Equipment Cancellation
|
||||
'2630', # Device Cancellation
|
||||
]
|
||||
|
||||
easyCTEP = import_ctypes_library('ctep', 'libeasyctep.so')
|
||||
|
||||
# int startTransaction(
|
||||
easyCTEP.startTransaction.argtypes = [
|
||||
ctypes.c_void_p, # std::shared_ptr<ect::CTEPTerminal> trm
|
||||
ctypes.c_char_p, # char const* amount
|
||||
ctypes.c_char_p, # char const* reference
|
||||
ctypes.c_ulong, # unsigned long action_identifier
|
||||
ctypes.c_char_p, # char* merchant_receipt
|
||||
ctypes.c_char_p, # char* customer_receipt
|
||||
ctypes.c_char_p, # char* card
|
||||
ctypes.c_char_p # char* error
|
||||
]
|
||||
|
||||
# int abortTransaction(std::shared_ptr<ect::CTEPTerminal> trm, char* error)
|
||||
easyCTEP.abortTransaction.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||
|
||||
class WorldlineDriver(CtypesTerminalDriver):
|
||||
connection_type = 'ctep'
|
||||
|
||||
def __init__(self, identifier, device):
|
||||
super(WorldlineDriver, self).__init__(identifier, device)
|
||||
self.device_name = 'Worldline terminal %s' % self.device_identifier
|
||||
self.device_manufacturer = 'Worldline'
|
||||
|
||||
def processTransaction(self, transaction):
|
||||
if transaction['amount'] <= 0:
|
||||
return self.send_status(error='The terminal cannot process negative or null transactions.', request_data=transaction)
|
||||
|
||||
# Force to wait before starting the transaction if necessary
|
||||
self._check_transaction_delay()
|
||||
# Notify transaction start
|
||||
self.send_status(stage='WaitingForCard', request_data=transaction)
|
||||
|
||||
# Transaction
|
||||
merchant_receipt = create_ctypes_string_buffer()
|
||||
customer_receipt = create_ctypes_string_buffer()
|
||||
card = create_ctypes_string_buffer()
|
||||
error_code = create_ctypes_string_buffer()
|
||||
transaction_id = transaction['TransactionID']
|
||||
transaction_amount = transaction['amount'] / 100
|
||||
transaction_action_identifier = transaction['actionIdentifier']
|
||||
_logger.info('start transaction #%d amount: %f action_identifier: %d', transaction_id, transaction_amount, transaction_action_identifier)
|
||||
result = easyCTEP.startTransaction(
|
||||
ctypes.byref(self.dev), # std::shared_ptr<ect::CTEPTerminal> trm
|
||||
ctypes.c_char_p(str(transaction_amount).encode('utf-8')), # char const* amount
|
||||
ctypes.c_char_p(str(transaction_id).encode('utf-8')), # char const* reference
|
||||
ctypes.c_ulong(transaction_action_identifier), # unsigned long action_identifier
|
||||
merchant_receipt, # char* merchant_receipt
|
||||
customer_receipt, # char* customer_receipt
|
||||
card, # char* card
|
||||
error_code, # char* error
|
||||
)
|
||||
self.next_transaction_min_dt = datetime.datetime.now() + datetime.timedelta(seconds=self.DELAY_TIME_BETWEEN_TRANSACTIONS)
|
||||
|
||||
if result == 1:
|
||||
_logger.info('succesfully finished transaction #%d', transaction_id)
|
||||
# Transaction successful
|
||||
self.send_status(
|
||||
response='Approved',
|
||||
ticket=customer_receipt.value.decode(),
|
||||
ticket_merchant=merchant_receipt.value.decode(),
|
||||
card=card.value.decode(),
|
||||
transaction_id=transaction['actionIdentifier'],
|
||||
request_data=transaction,
|
||||
)
|
||||
elif result == 0:
|
||||
error_code = error_code.value.decode('utf-8')
|
||||
# Transaction failed
|
||||
if error_code not in IGNORE_ERRORS:
|
||||
error_msg = f'transaction #{transaction_id} error: {error_code}: {TERMINAL_ERRORS.get(error_code, "Transaction Error")}'
|
||||
_logger.info(error_msg)
|
||||
self.send_status(error=error_msg, request_data=transaction)
|
||||
# Transaction was cancelled
|
||||
else:
|
||||
_logger.info("transaction #%d cancelled by PoS user", transaction_id)
|
||||
self.send_status(stage='Cancel', request_data=transaction)
|
||||
elif result == -1:
|
||||
# Terminal disconnection, check status manually
|
||||
_logger.warning("terminal disconnected during transaction #%d", transaction_id)
|
||||
self.send_status(disconnected=True, request_data=transaction)
|
||||
|
||||
def cancelTransaction(self, transaction):
|
||||
# Force to wait before starting the transaction if necessary
|
||||
self._check_transaction_delay()
|
||||
self.send_status(stage='waitingCancel', request_data=transaction)
|
||||
|
||||
error_code = create_ctypes_string_buffer()
|
||||
_logger.info("cancel transaction request for %s", transaction)
|
||||
result = easyCTEP.abortTransaction(ctypes.byref(self.dev), error_code) # std::shared_ptr<ect::CTEPTerminal> trm
|
||||
_logger.debug("end cancel transaction request")
|
||||
|
||||
if not result:
|
||||
error_code = error_code.value.decode('utf-8')
|
||||
error_msg = f'Cancellation failed: {error_code}: {TERMINAL_ERRORS.get(error_code, "cancellation error")}'
|
||||
_logger.info(error_msg)
|
||||
self.send_status(stage='Cancel', error=error_msg, request_data=transaction)
|
||||
217
fusion_iot/iot/iot_handlers/drivers/WorldlineDriver_W.py
Normal file
217
fusion_iot/iot/iot_handlers/drivers/WorldlineDriver_W.py
Normal 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': 'Doesn’t exist',
|
||||
'2802': 'Not allowed',
|
||||
'2806': 'Bad signature',
|
||||
'2807': 'Conditional field missing',
|
||||
'2808': 'Not found',
|
||||
'2809': 'Dependency not found',
|
||||
'2810': 'Bad value',
|
||||
'2811': 'Bad sequence',
|
||||
'2812': 'Device attachment',
|
||||
'2813': 'Unexpected field',
|
||||
'3100': 'Chip card expected',
|
||||
'3101': 'Card not well read',
|
||||
'3102': 'Condition of use not satisfied',
|
||||
'4000': 'Purse technical problem',
|
||||
'4001': 'Purse host identifier invalid',
|
||||
'4002': 'Purse SDA certificate error',
|
||||
'4003': 'Purse extended SDA certificate error',
|
||||
'4004': 'Purse in red list',
|
||||
'4005': 'Purse is locked for credit',
|
||||
'4006': 'Purse is locked for debit',
|
||||
'4007': 'Purse expired',
|
||||
'4008': 'Purse state error',
|
||||
'4009': 'Purse recovery error',
|
||||
'4010': 'Purse key identifier error',
|
||||
'4011': 'Purse balance too large',
|
||||
'4012': 'Insufficient purse balance',
|
||||
'4100': 'No purse in reader and time out expired',
|
||||
'4101': 'Time-out on fallback card reading',
|
||||
'4102': 'Problem linked to card',
|
||||
'4103': 'Card information not available',
|
||||
'4200': 'Entered amount invalid',
|
||||
'4201': 'Double operation',
|
||||
'4202': 'Invalid currency',
|
||||
'4203': 'Amount higher than authorized amount',
|
||||
'4204': 'Floor limit exceeded in EMV mode',
|
||||
'4205': 'Transaction refused by the terminal in EMV mode',
|
||||
'4206': 'Transaction refused by the card in EMV mode',
|
||||
'4207': 'Product not available',
|
||||
'4300': 'Service (already) activated',
|
||||
'4301': 'Service (already) deactivated',
|
||||
'4302': 'Maximal transaction number per (calendar) month reached',
|
||||
'4303': 'Maximal uncollected journals number reached',
|
||||
'4304': 'Service activation not supported',
|
||||
'4305': 'Maximum transaction records reached',
|
||||
'4306': 'Maximum service activation number reached',
|
||||
'6003': 'Paper jam',
|
||||
'6004': 'Remove previous ticket',
|
||||
'6005': 'No paper',
|
||||
'6006': 'Low paper',
|
||||
'6008': 'Printer specific',
|
||||
'7806': 'Product not allowed',
|
||||
'7808': 'Bad pump number',
|
||||
'7816': 'Incorrect pump session number',
|
||||
'7817': 'Transaction amount null',
|
||||
'7818': 'Transaction amount null and quantity null',
|
||||
'7819': 'Pump unhooked time-out expiration',
|
||||
'9002': 'No key fault',
|
||||
'9003': 'Cryptographic fault',
|
||||
'9004': 'No PIN fault',
|
||||
'9005': 'Bad MAC',
|
||||
'9006': 'Bad MDC',
|
||||
}
|
||||
|
||||
# Manually cancelled by cashier, do not show these errors
|
||||
IGNORE_ERRORS = [
|
||||
'2628', # External Equipment Cancellation
|
||||
'2630', # Device Cancellation
|
||||
]
|
||||
|
||||
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,
|
||||
)
|
||||
BIN
fusion_iot/iot/iot_handlers/interfaces/._BTInterface_L.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._BTInterface_L.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_L.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_L.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_W.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._CTEPInterface_W.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._SocketInterface.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._SocketInterface.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/interfaces/._TIMInterface.py
Normal file
BIN
fusion_iot/iot/iot_handlers/interfaces/._TIMInterface.py
Normal file
Binary file not shown.
72
fusion_iot/iot/iot_handlers/interfaces/BTInterface_L.py
Normal file
72
fusion_iot/iot/iot_handlers/interfaces/BTInterface_L.py
Normal 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()
|
||||
44
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_L.py
Normal file
44
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_L.py
Normal 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
|
||||
50
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_W.py
Normal file
50
fusion_iot/iot/iot_handlers/interfaces/CTEPInterface_W.py
Normal 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
|
||||
122
fusion_iot/iot/iot_handlers/interfaces/SocketInterface.py
Normal file
122
fusion_iot/iot/iot_handlers/interfaces/SocketInterface.py
Normal 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()
|
||||
108
fusion_iot/iot/iot_handlers/interfaces/TIMInterface.py
Normal file
108
fusion_iot/iot/iot_handlers/interfaces/TIMInterface.py
Normal 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
|
||||
BIN
fusion_iot/iot/iot_handlers/lib/._ctypes_terminal_driver.py
Normal file
BIN
fusion_iot/iot/iot_handlers/lib/._ctypes_terminal_driver.py
Normal file
Binary file not shown.
BIN
fusion_iot/iot/iot_handlers/lib/._load_worldline_library.sh
Normal file
BIN
fusion_iot/iot/iot_handlers/lib/._load_worldline_library.sh
Normal file
Binary file not shown.
153
fusion_iot/iot/iot_handlers/lib/ctypes_terminal_driver.py
Normal file
153
fusion_iot/iot/iot_handlers/lib/ctypes_terminal_driver.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import ctypes
|
||||
import datetime
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from queue import Queue
|
||||
from time import sleep
|
||||
|
||||
|
||||
from odoo.addons.iot_drivers.driver import Driver
|
||||
from odoo.addons.iot_drivers.event_manager import event_manager
|
||||
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
|
||||
from odoo.tools.misc import file_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Buffer size big enough to hold every string incoming from ctypes libraries
|
||||
# The biggest strings stored in this buffer are the receipts
|
||||
CTYPES_BUFFER_SIZE = 10000
|
||||
|
||||
# Define pointers and argument types for ctypes function calls
|
||||
ulong_pointer = ctypes.POINTER(ctypes.c_ulong)
|
||||
double_pointer = ctypes.POINTER(ctypes.c_double)
|
||||
|
||||
def import_ctypes_library(lib_subfolder, lib_name):
|
||||
"""
|
||||
Import a library using ctypes, independently of the OS.
|
||||
:param lib_subfolder: The subfolder where the library is located under "iot_drivers/iot_handlers/lib"
|
||||
:param lib_name: The name of the library file. Must respect the OS extension (.so/.dll), otherwise ValueError will be raised
|
||||
Example: if the library is located under "iot_drivers/iot_handlers/lib/ctep/libeasyctep.so", then
|
||||
lib_subfolder = "ctep" and lib_name = "libeasyctep.so"
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
supported_lib_extensions = '.dll'
|
||||
import_library_method = ctypes.WinDLL
|
||||
else:
|
||||
supported_lib_extensions = '.so'
|
||||
import_library_method = ctypes.CDLL
|
||||
|
||||
try:
|
||||
lib_path = file_path(f'iot_drivers/iot_handlers/lib/{lib_subfolder}/{lib_name}', supported_lib_extensions)
|
||||
ctypes_lib = import_library_method(lib_path)
|
||||
_logger.info('Successfully imported ctypes library "%s" from %s', lib_name, lib_path)
|
||||
return ctypes_lib
|
||||
except (OSError, ValueError, FileNotFoundError):
|
||||
_logger.exception('Failed to import ctypes library "%s" from iot_drivers/iot_handlers/lib/%s/', lib_name, lib_subfolder)
|
||||
|
||||
def create_ctypes_string_buffer():
|
||||
"""
|
||||
Create a ctypes buffer of CTYPES_BUFFER_SIZE size
|
||||
"""
|
||||
return ctypes.create_string_buffer(CTYPES_BUFFER_SIZE)
|
||||
|
||||
class CtypesTerminalDriver(Driver):
|
||||
"""
|
||||
This class is the parent class of all the terminal drivers using ctypes.
|
||||
Worldline and Six drivers are inheriting from this class.
|
||||
"""
|
||||
|
||||
DELAY_TIME_BETWEEN_TRANSACTIONS = 5 # seconds
|
||||
|
||||
def __init__(self, identifier, device):
|
||||
super().__init__(identifier, device)
|
||||
self.device_type = 'payment'
|
||||
self.device_connection = 'network'
|
||||
self.cid = None
|
||||
self.owner = None
|
||||
self.queue_actions = Queue()
|
||||
self.terminal_busy = False
|
||||
|
||||
self._actions[''] = self._action_default
|
||||
self.next_transaction_min_dt = datetime.datetime.min
|
||||
|
||||
@classmethod
|
||||
def supported(cls, device):
|
||||
# Currently all devices detected through TimInterface or CTEPInterface are supported
|
||||
return True
|
||||
|
||||
def _action_default(self, data):
|
||||
data_message_type = data.get('messageType')
|
||||
_logger.debug('%s: _action_default %s %s', self.device_name, data_message_type, data)
|
||||
if data_message_type in ['Transaction', 'Balance']:
|
||||
if self.terminal_busy:
|
||||
self.send_status(error=f'{self.device_name} is currently busy. Try again later.', request_data=data)
|
||||
else:
|
||||
self.terminal_busy = True
|
||||
self.queue_actions.put(data)
|
||||
elif data_message_type == 'Cancel':
|
||||
self.cancelTransaction(data)
|
||||
|
||||
def run(self):
|
||||
while True:
|
||||
# If the queue is empty, the call of "get" will block and wait for it to get an item
|
||||
action = self.queue_actions.get()
|
||||
action_type = action.get('messageType')
|
||||
_logger.debug("%s: Starting next action in queue: %s", self.device_name, action_type)
|
||||
if action_type == 'Transaction':
|
||||
self.processTransaction(action)
|
||||
elif action_type == 'Balance':
|
||||
self.six_terminal_balance(action) # Only for Worldline "Six" (TIM)
|
||||
self.terminal_busy = False
|
||||
|
||||
def _check_transaction_delay(self):
|
||||
# After a payment has been processed, the display on the terminal still shows some
|
||||
# information for about 4-5 seconds. No request can be processed during this period.
|
||||
delay_diff = (self.next_transaction_min_dt - datetime.datetime.now()).total_seconds()
|
||||
if delay_diff > 0:
|
||||
if delay_diff > self.DELAY_TIME_BETWEEN_TRANSACTIONS:
|
||||
# Theoretically not possible, but to avoid sleeping for ages, we cap the value
|
||||
_logger.warning('%s: Transaction delay difference is too high %.2f force set as default', self.device_name, delay_diff)
|
||||
delay_diff = self.DELAY_TIME_BETWEEN_TRANSACTIONS
|
||||
_logger.info('%s: Previous transaction is too recent, will sleep for %.2f seconds', self.device_name, delay_diff)
|
||||
sleep(delay_diff)
|
||||
|
||||
def send_status(self, value='', response=False, stage=False, ticket=False, ticket_merchant=False, card=False, card_no=False, transaction_id=False, error=False, disconnected=False, request_data=False):
|
||||
self.data['status'] = 'success' # always success: let service handle errors
|
||||
self.data['result'] = {
|
||||
'value': value,
|
||||
'Stage': stage,
|
||||
'Response': response,
|
||||
'Ticket': ticket,
|
||||
'TicketMerchant': ticket_merchant,
|
||||
'Card': card,
|
||||
'CardNo': card_no,
|
||||
'PaymentTransactionID': transaction_id,
|
||||
'Error': error,
|
||||
'Disconnected': disconnected,
|
||||
'cid': request_data.get('cid'),
|
||||
}
|
||||
# TODO: add `stacklevel=2` in image with python version > 3.8
|
||||
_logger.debug('%s: send_status data: %s', self.device_name, self.data, stack_info=True)
|
||||
event_manager.device_changed(self)
|
||||
|
||||
# The following methods need to be implemented by the children classes
|
||||
@abstractmethod
|
||||
def processTransaction(self, transaction):
|
||||
"""
|
||||
Method implementing the transaction processing
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def cancelTransaction(self, transaction):
|
||||
"""
|
||||
Method implementing the ongoing transaction request cancellation
|
||||
"""
|
||||
|
||||
def six_terminal_balance(self, transaction):
|
||||
"""
|
||||
Method implementing the terminal balance request (only for Worldline "Six")
|
||||
Not an abstract method as it remains undefined for Worldline
|
||||
"""
|
||||
Reference in New Issue
Block a user