summaryrefslogtreecommitdiff
path: root/addons/hw_drivers
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/hw_drivers
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/hw_drivers')
-rw-r--r--addons/hw_drivers/__init__.py11
-rw-r--r--addons/hw_drivers/__manifest__.py22
-rw-r--r--addons/hw_drivers/connection_manager.py77
-rw-r--r--addons/hw_drivers/controllers/__init__.py5
-rwxr-xr-xaddons/hw_drivers/controllers/driver.py107
-rw-r--r--addons/hw_drivers/controllers/proxy.py22
-rw-r--r--addons/hw_drivers/driver.py53
-rw-r--r--addons/hw_drivers/event_manager.py56
-rw-r--r--addons/hw_drivers/exception_logger.py23
-rw-r--r--addons/hw_drivers/http.py34
-rw-r--r--addons/hw_drivers/interface.py70
-rw-r--r--addons/hw_drivers/iot_handlers/drivers/DisplayDriver.py220
-rw-r--r--addons/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver.py367
-rw-r--r--addons/hw_drivers/iot_handlers/drivers/PrinterDriver.py271
-rw-r--r--addons/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py144
-rw-r--r--addons/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py316
-rw-r--r--addons/hw_drivers/iot_handlers/interfaces/DisplayInterface.py38
-rw-r--r--addons/hw_drivers/iot_handlers/interfaces/PrinterInterface.py37
-rw-r--r--addons/hw_drivers/iot_handlers/interfaces/SerialInterface.py18
-rw-r--r--addons/hw_drivers/iot_handlers/interfaces/USBInterface.py29
-rw-r--r--addons/hw_drivers/main.py104
-rw-r--r--addons/hw_drivers/static/img/False.jpgbin0 -> 546 bytes
-rw-r--r--addons/hw_drivers/static/img/True.jpgbin0 -> 542 bytes
-rw-r--r--addons/hw_drivers/static/src/js/worker.js60
-rw-r--r--addons/hw_drivers/tools/helpers.py279
-rw-r--r--addons/hw_drivers/views/pos_display.html94
26 files changed, 2457 insertions, 0 deletions
diff --git a/addons/hw_drivers/__init__.py b/addons/hw_drivers/__init__.py
new file mode 100644
index 00000000..da5e919c
--- /dev/null
+++ b/addons/hw_drivers/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import connection_manager
+from . import controllers
+from . import driver
+from . import event_manager
+from . import exception_logger
+from . import http
+from . import interface
+from . import main
diff --git a/addons/hw_drivers/__manifest__.py b/addons/hw_drivers/__manifest__.py
new file mode 100644
index 00000000..5d37c438
--- /dev/null
+++ b/addons/hw_drivers/__manifest__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Hardware Proxy',
+ 'category': 'Hidden',
+ 'sequence': 6,
+ 'summary': 'Connect the Web Client to Hardware Peripherals',
+ 'website': 'https://www.odoo.com/page/iot',
+ 'description': """
+Hardware Poxy
+=============
+
+This module allows you to remotely use peripherals connected to this server.
+
+This modules only contains the enabling framework. The actual devices drivers
+are found in other modules that must be installed separately.
+
+""",
+ 'installable': False,
+ 'license': 'LGPL-3',
+}
diff --git a/addons/hw_drivers/connection_manager.py b/addons/hw_drivers/connection_manager.py
new file mode 100644
index 00000000..1ae7fb58
--- /dev/null
+++ b/addons/hw_drivers/connection_manager.py
@@ -0,0 +1,77 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+import logging
+import subprocess
+import requests
+from threading import Thread
+import time
+import urllib3
+
+from odoo.modules.module import get_resource_path
+from odoo.addons.hw_drivers.main import iot_devices, manager
+from odoo.addons.hw_drivers.tools import helpers
+
+_logger = logging.getLogger(__name__)
+
+class ConnectionManager(Thread):
+ def __init__(self):
+ super(ConnectionManager, self).__init__()
+ self.pairing_code = False
+ self.pairing_uuid = False
+
+ def run(self):
+ if not helpers.get_odoo_server_url() and not helpers.access_point():
+ end_time = datetime.now() + timedelta(minutes=5)
+ while (datetime.now() < end_time):
+ self._connect_box()
+ time.sleep(10)
+ self.pairing_code = False
+ self.pairing_uuid = False
+ self._refresh_displays()
+
+ def _connect_box(self):
+ data = {
+ 'jsonrpc': 2.0,
+ 'params': {
+ 'pairing_code': self.pairing_code,
+ 'pairing_uuid': self.pairing_uuid,
+ }
+ }
+
+ try:
+ urllib3.disable_warnings()
+ req = requests.post('https://iot-proxy.odoo.com/odoo-enterprise/iot/connect-box', json=data, verify=False)
+ result = req.json().get('result', {})
+ if all(key in result for key in ['pairing_code', 'pairing_uuid']):
+ self.pairing_code = result['pairing_code']
+ self.pairing_uuid = result['pairing_uuid']
+ elif all(key in result for key in ['url', 'token', 'db_uuid', 'enterprise_code']):
+ self._connect_to_server(result['url'], result['token'], result['db_uuid'], result['enterprise_code'])
+ except Exception as e:
+ _logger.error('Could not reach iot-proxy.odoo.com')
+ _logger.error('A error encountered : %s ' % e)
+
+ def _connect_to_server(self, url, token, db_uuid, enterprise_code):
+ if db_uuid and enterprise_code:
+ helpers.add_credential(db_uuid, enterprise_code)
+
+ # Save DB URL and token
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, '', token, 'noreboot'])
+ # Notify the DB, so that the kanban view already shows the IoT Box
+ manager.send_alldevices()
+ # Restart to checkout the git branch, get a certificate, load the IoT handlers...
+ subprocess.check_call(["sudo", "service", "odoo", "restart"])
+
+ def _refresh_displays(self):
+ """Refresh all displays to hide the pairing code"""
+ for d in iot_devices:
+ if iot_devices[d].device_type == 'display':
+ iot_devices[d].action({
+ 'action': 'display_refresh'
+ })
+
+connection_manager = ConnectionManager()
+connection_manager.daemon = True
+connection_manager.start()
diff --git a/addons/hw_drivers/controllers/__init__.py b/addons/hw_drivers/controllers/__init__.py
new file mode 100644
index 00000000..ad287d4d
--- /dev/null
+++ b/addons/hw_drivers/controllers/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import driver
+from . import proxy
diff --git a/addons/hw_drivers/controllers/driver.py b/addons/hw_drivers/controllers/driver.py
new file mode 100755
index 00000000..ba2f2965
--- /dev/null
+++ b/addons/hw_drivers/controllers/driver.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from base64 import b64decode
+import json
+import logging
+import os
+import subprocess
+import time
+
+from odoo import http, tools
+from odoo.http import send_file
+from odoo.modules.module import get_resource_path
+
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.main import iot_devices, manager
+from odoo.addons.hw_drivers.tools import helpers
+
+_logger = logging.getLogger(__name__)
+
+
+class DriverController(http.Controller):
+ @http.route('/hw_drivers/action', type='json', auth='none', cors='*', csrf=False, save_session=False)
+ def action(self, session_id, device_identifier, data):
+ """
+ This route is called when we want to make a action with device (take picture, printing,...)
+ We specify in data from which session_id that action is called
+ And call the action of specific device
+ """
+ iot_device = iot_devices.get(device_identifier)
+ if iot_device:
+ iot_device.data['owner'] = session_id
+ data = json.loads(data)
+ iot_device.action(data)
+ return True
+ return False
+
+ @http.route('/hw_drivers/check_certificate', type='http', auth='none', cors='*', csrf=False, save_session=False)
+ def check_certificate(self):
+ """
+ This route is called when we want to check if certificate is up-to-date
+ Used in cron.daily
+ """
+ helpers.check_certificate()
+
+ @http.route('/hw_drivers/event', type='json', auth='none', cors='*', csrf=False, save_session=False)
+ def event(self, listener):
+ """
+ listener is a dict in witch there are a sessions_id and a dict of device_identifier to listen
+ """
+ req = event_manager.add_request(listener)
+
+ # Search for previous events and remove events older than 5 seconds
+ oldest_time = time.time() - 5
+ for event in list(event_manager.events):
+ if event['time'] < oldest_time:
+ del event_manager.events[0]
+ continue
+ if event['device_identifier'] in listener['devices'] and event['time'] > listener['last_event']:
+ event['session_id'] = req['session_id']
+ return event
+
+ # Wait for new event
+ if req['event'].wait(50):
+ req['event'].clear()
+ req['result']['session_id'] = req['session_id']
+ return req['result']
+
+ @http.route('/hw_drivers/box/connect', type='http', auth='none', cors='*', csrf=False, save_session=False)
+ def connect_box(self, token):
+ """
+ This route is called when we want that a IoT Box will be connected to a Odoo DB
+ token is a base 64 encoded string and have 2 argument separate by |
+ 1 - url of odoo DB
+ 2 - token. This token will be compared to the token of Odoo. He have 1 hour lifetime
+ """
+ server = helpers.get_odoo_server_url()
+ image = get_resource_path('hw_drivers', 'static/img', 'False.jpg')
+ if not server:
+ credential = b64decode(token).decode('utf-8').split('|')
+ url = credential[0]
+ token = credential[1]
+ if len(credential) > 2:
+ # IoT Box send token with db_uuid and enterprise_code only since V13
+ db_uuid = credential[2]
+ enterprise_code = credential[3]
+ helpers.add_credential(db_uuid, enterprise_code)
+ try:
+ subprocess.check_call([get_resource_path('point_of_sale', 'tools/posbox/configuration/connect_to_server.sh'), url, '', token, 'noreboot'])
+ manager.send_alldevices()
+ image = get_resource_path('hw_drivers', 'static/img', 'True.jpg')
+ helpers.odoo_restart(3)
+ except subprocess.CalledProcessError as e:
+ _logger.error('A error encountered : %s ' % e.output)
+ if os.path.isfile(image):
+ with open(image, 'rb') as f:
+ return f.read()
+
+ @http.route('/hw_drivers/download_logs', type='http', auth='none', cors='*', csrf=False, save_session=False)
+ def download_logs(self):
+ """
+ Downloads the log file
+ """
+ if tools.config['logfile']:
+ res = send_file(tools.config['logfile'], mimetype="text/plain", as_attachment=True)
+ res.headers['Cache-Control'] = 'no-cache'
+ return res
diff --git a/addons/hw_drivers/controllers/proxy.py b/addons/hw_drivers/controllers/proxy.py
new file mode 100644
index 00000000..b5124772
--- /dev/null
+++ b/addons/hw_drivers/controllers/proxy.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+
+proxy_drivers = {}
+
+class ProxyController(http.Controller):
+ @http.route('/hw_proxy/hello', type='http', auth='none', cors='*')
+ def hello(self):
+ return "ping"
+
+ @http.route('/hw_proxy/handshake', type='json', auth='none', cors='*')
+ def handshake(self):
+ return True
+
+ @http.route('/hw_proxy/status_json', type='json', auth='none', cors='*')
+ def status_json(self):
+ statuses = {}
+ for driver in proxy_drivers:
+ statuses[driver] = proxy_drivers[driver].get_status()
+ return statuses
diff --git a/addons/hw_drivers/driver.py b/addons/hw_drivers/driver.py
new file mode 100644
index 00000000..459c3c9b
--- /dev/null
+++ b/addons/hw_drivers/driver.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from threading import Thread, Event
+
+from odoo.addons.hw_drivers.main import drivers, iot_devices
+
+
+class DriverMetaClass(type):
+ def __new__(cls, clsname, bases, attrs):
+ newclass = super(DriverMetaClass, cls).__new__(cls, clsname, bases, attrs)
+ if hasattr(newclass, 'priority'):
+ newclass.priority += 1
+ else:
+ newclass.priority = 0
+ drivers.append(newclass)
+ return newclass
+
+
+class Driver(Thread, metaclass=DriverMetaClass):
+ """
+ Hook to register the driver into the drivers list
+ """
+ connection_type = ''
+
+ def __init__(self, identifier, device):
+ super(Driver, self).__init__()
+ self.dev = device
+ self.device_identifier = identifier
+ self.device_name = ''
+ self.device_connection = ''
+ self.device_type = ''
+ self.device_manufacturer = ''
+ self.data = {'value': ''}
+ self._stopped = Event()
+
+ @classmethod
+ def supported(cls, device):
+ """
+ On specific driver override this method to check if device is supported or not
+ return True or False
+ """
+ return False
+
+ def action(self, data):
+ """
+ On specific driver override this method to make a action with device (take picture, printing,...)
+ """
+ raise NotImplementedError()
+
+ def disconnect(self):
+ self._stopped.set()
+ del iot_devices[self.device_identifier]
diff --git a/addons/hw_drivers/event_manager.py b/addons/hw_drivers/event_manager.py
new file mode 100644
index 00000000..f76968f9
--- /dev/null
+++ b/addons/hw_drivers/event_manager.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+from threading import Event
+import time
+
+from odoo.http import request
+
+class EventManager(object):
+ def __init__(self):
+ self.events = []
+ self.sessions = {}
+
+ def _delete_expired_sessions(self, max_time=70):
+ '''
+ Clears sessions that are no longer called.
+
+ :param max_time: time a session can stay unused before being deleted
+ '''
+ now = time.time()
+ expired_sessions = [
+ session
+ for session in self.sessions
+ if now - self.sessions[session]['time_request'] > max_time
+ ]
+ for session in expired_sessions:
+ del self.sessions[session]
+
+ def add_request(self, listener):
+ self.session = {
+ 'session_id': listener['session_id'],
+ 'devices': listener['devices'],
+ 'event': Event(),
+ 'result': {},
+ 'time_request': time.time(),
+ }
+ self._delete_expired_sessions()
+ self.sessions[listener['session_id']] = self.session
+ return self.sessions[listener['session_id']]
+
+ def device_changed(self, device):
+ event = {
+ **device.data,
+ 'device_identifier': device.device_identifier,
+ 'time': time.time(),
+ 'request_data': json.loads(request.params['data']) if request and 'data' in request.params else None,
+ }
+ self.events.append(event)
+ for session in self.sessions:
+ if device.device_identifier in self.sessions[session]['devices'] and not self.sessions[session]['event'].isSet():
+ self.sessions[session]['result'] = event
+ self.sessions[session]['event'].set()
+
+
+event_manager = EventManager()
diff --git a/addons/hw_drivers/exception_logger.py b/addons/hw_drivers/exception_logger.py
new file mode 100644
index 00000000..0d1890b5
--- /dev/null
+++ b/addons/hw_drivers/exception_logger.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import sys
+
+
+class ExceptionLogger:
+ """
+ Redirect Exceptions to the logger to keep track of them in the log file.
+ """
+
+ def __init__(self):
+ self.logger = logging.getLogger()
+
+ def write(self, message):
+ if message != '\n':
+ self.logger.error(message)
+
+ def flush(self):
+ pass
+
+sys.stderr = ExceptionLogger()
diff --git a/addons/hw_drivers/http.py b/addons/hw_drivers/http.py
new file mode 100644
index 00000000..b0ea9bee
--- /dev/null
+++ b/addons/hw_drivers/http.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+
+
+class IoTBoxHttpRequest(http.HttpRequest):
+ def dispatch(self):
+ if self._is_cors_preflight(http.request.endpoint):
+ # Using the PoS in debug mode in v12, the call to '/hw_proxy/handshake' contains the
+ # 'X-Debug-Mode' header, which was removed from 'Access-Control-Allow-Headers' in v13.
+ # When the code of http.py is not checked out to v12 (i.e. in Community), the connection
+ # fails as the header is rejected and none of the devices can be used.
+ headers = {
+ 'Access-Control-Max-Age': 60 * 60 * 24,
+ 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Debug-Mode'
+ }
+ return http.Response(status=200, headers=headers)
+ return super(IoTBoxHttpRequest, self).dispatch()
+
+
+class IoTBoxRoot(http.Root):
+ def setup_db(self, httprequest):
+ # No database on the IoT Box
+ pass
+
+ def get_request(self, httprequest):
+ # Override HttpRequestwith IoTBoxHttpRequest
+ if httprequest.mimetype not in ("application/json", "application/json-rpc"):
+ return IoTBoxHttpRequest(httprequest)
+ return super(IoTBoxRoot, self).get_request(httprequest)
+
+http.Root = IoTBoxRoot
+http.root = IoTBoxRoot()
diff --git a/addons/hw_drivers/interface.py b/addons/hw_drivers/interface.py
new file mode 100644
index 00000000..dbecbbf4
--- /dev/null
+++ b/addons/hw_drivers/interface.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+from threading import Thread
+import time
+
+from odoo.addons.hw_drivers.main import drivers, interfaces, iot_devices
+
+_logger = logging.getLogger(__name__)
+
+
+class InterfaceMetaClass(type):
+ def __new__(cls, clsname, bases, attrs):
+ new_interface = super(InterfaceMetaClass, cls).__new__(cls, clsname, bases, attrs)
+ interfaces[clsname] = new_interface
+ return new_interface
+
+
+class Interface(Thread, metaclass=InterfaceMetaClass):
+ _loop_delay = 3 # Delay (in seconds) between calls to get_devices or 0 if it should be called only once
+ _detected_devices = {}
+ connection_type = ''
+
+ def __init__(self):
+ super(Interface, self).__init__()
+ self.drivers = sorted([d for d in drivers if d.connection_type == self.connection_type], key=lambda d: d.priority, reverse=True)
+
+ def run(self):
+ while self.connection_type and self.drivers:
+ self.update_iot_devices(self.get_devices())
+ if not self._loop_delay:
+ break
+ time.sleep(self._loop_delay)
+
+ def update_iot_devices(self, devices={}):
+ added = devices.keys() - self._detected_devices
+ removed = self._detected_devices - devices.keys()
+ # keys() returns a dict_keys, and the values of that stay in sync with the
+ # original dictionary if it changes. This means that get_devices needs to return
+ # a newly created dictionary every time. If it doesn't do that and reuses the
+ # same dictionary, this logic won't detect any changes that are made. Could be
+ # avoided by converting the dict_keys into a regular dict. The current logic
+ # also can't detect if a device is replaced by a different one with the same
+ # key. Also, _detected_devices starts out as a class variable but gets turned
+ # into an instance variable here. It would be better if it was an instance
+ # variable from the start to avoid confusion.
+ self._detected_devices = devices.keys()
+
+ for identifier in removed:
+ if identifier in iot_devices:
+ iot_devices[identifier].disconnect()
+ _logger.info('Device %s is now disconnected', identifier)
+
+ for identifier in added:
+ for driver in self.drivers:
+ if driver.supported(devices[identifier]):
+ _logger.info('Device %s is now connected', identifier)
+ d = driver(identifier, devices[identifier])
+ d.daemon = True
+ iot_devices[identifier] = d
+ # Start the thread after creating the iot_devices entry so the
+ # thread can assume the iot_devices entry will exist while it's
+ # running, at least until the `disconnect` above gets triggered
+ # when `removed` is not empty.
+ d.start()
+ break
+
+ def get_devices(self):
+ raise NotImplementedError()
diff --git a/addons/hw_drivers/iot_handlers/drivers/DisplayDriver.py b/addons/hw_drivers/iot_handlers/drivers/DisplayDriver.py
new file mode 100644
index 00000000..0b79dd5b
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/drivers/DisplayDriver.py
@@ -0,0 +1,220 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import jinja2
+import json
+import logging
+import netifaces as ni
+import os
+import subprocess
+import threading
+import time
+import urllib3
+
+from odoo import http
+from odoo.addons.hw_drivers.connection_manager import connection_manager
+from odoo.addons.hw_drivers.driver import Driver
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.main import iot_devices
+from odoo.addons.hw_drivers.tools import helpers
+
+path = os.path.realpath(os.path.join(os.path.dirname(__file__), '../../views'))
+loader = jinja2.FileSystemLoader(path)
+
+jinja_env = jinja2.Environment(loader=loader, autoescape=True)
+jinja_env.filters["json"] = json.dumps
+
+pos_display_template = jinja_env.get_template('pos_display.html')
+
+_logger = logging.getLogger(__name__)
+
+
+class DisplayDriver(Driver):
+ connection_type = 'display'
+
+ def __init__(self, identifier, device):
+ super(DisplayDriver, self).__init__(identifier, device)
+ self.device_type = 'display'
+ self.device_connection = 'hdmi'
+ self.device_name = device['name']
+ self.event_data = threading.Event()
+ self.owner = False
+ self.rendered_html = ''
+ if self.device_identifier != 'distant_display':
+ self._x_screen = device.get('x_screen', '0')
+ self.load_url()
+
+ @classmethod
+ def supported(cls, device):
+ return True # All devices with connection_type == 'display' are supported
+
+ @classmethod
+ def get_default_display(cls):
+ displays = list(filter(lambda d: iot_devices[d].device_type == 'display', iot_devices))
+ return len(displays) and iot_devices[displays[0]]
+
+ def action(self, data):
+ if data.get('action') == "update_url" and self.device_identifier != 'distant_display':
+ self.update_url(data.get('url'))
+ elif data.get('action') == "display_refresh" and self.device_identifier != 'distant_display':
+ self.call_xdotools('F5')
+ elif data.get('action') == "take_control":
+ self.take_control(self.data['owner'], data.get('html'))
+ elif data.get('action') == "customer_facing_display":
+ self.update_customer_facing_display(self.data['owner'], data.get('html'))
+ elif data.get('action') == "get_owner":
+ self.data = {
+ 'value': '',
+ 'owner': self.owner,
+ }
+ event_manager.device_changed(self)
+
+ def run(self):
+ while self.device_identifier != 'distant_display' and not self._stopped.isSet():
+ time.sleep(60)
+ if self.url != 'http://localhost:8069/point_of_sale/display/' + self.device_identifier:
+ # Refresh the page every minute
+ self.call_xdotools('F5')
+
+ def update_url(self, url=None):
+ os.environ['DISPLAY'] = ":0." + self._x_screen
+ os.environ['XAUTHORITY'] = '/run/lightdm/pi/xauthority'
+ firefox_env = os.environ.copy()
+ firefox_env['HOME'] = '/tmp/' + self._x_screen
+ self.url = url or 'http://localhost:8069/point_of_sale/display/' + self.device_identifier
+ new_window = subprocess.call(['xdotool', 'search', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox'])
+ subprocess.Popen(['firefox', self.url], env=firefox_env)
+ if new_window:
+ self.call_xdotools('F11')
+
+ def load_url(self):
+ url = None
+ if helpers.get_odoo_server_url():
+ # disable certifiacte verification
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ try:
+ response = http.request('GET', "%s/iot/box/%s/display_url" % (helpers.get_odoo_server_url(), helpers.get_mac_address()))
+ if response.status == 200:
+ data = json.loads(response.data.decode('utf8'))
+ url = data[self.device_identifier]
+ except json.decoder.JSONDecodeError:
+ url = response.data.decode('utf8')
+ except Exception:
+ pass
+ return self.update_url(url)
+
+ def call_xdotools(self, keystroke):
+ os.environ['DISPLAY'] = ":0." + self._x_screen
+ os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority"
+ try:
+ subprocess.call(['xdotool', 'search', '--sync', '--onlyvisible', '--screen', self._x_screen, '--class', 'Firefox', 'key', keystroke])
+ return "xdotool succeeded in stroking " + keystroke
+ except:
+ return "xdotool threw an error, maybe it is not installed on the IoTBox"
+
+ def update_customer_facing_display(self, origin, html=None):
+ if origin == self.owner:
+ self.rendered_html = html
+ self.event_data.set()
+
+ def get_serialized_order(self):
+ # IMPLEMENTATION OF LONGPOLLING
+ # Times out 2 seconds before the JS request does
+ if self.event_data.wait(28):
+ self.event_data.clear()
+ return {'rendered_html': self.rendered_html}
+ return {'rendered_html': False}
+
+ def take_control(self, new_owner, html=None):
+ # ALLOW A CASHIER TO TAKE CONTROL OVER THE POSBOX, IN CASE OF MULTIPLE CASHIER PER DISPLAY
+ self.owner = new_owner
+ self.rendered_html = html
+ self.data = {
+ 'value': '',
+ 'owner': self.owner,
+ }
+ event_manager.device_changed(self)
+ self.event_data.set()
+
+class DisplayController(http.Controller):
+
+ @http.route('/hw_proxy/display_refresh', type='json', auth='none', cors='*')
+ def display_refresh(self):
+ display = DisplayDriver.get_default_display()
+ if display and display.device_identifier != 'distant_display':
+ return display.call_xdotools('F5')
+
+ @http.route('/hw_proxy/customer_facing_display', type='json', auth='none', cors='*')
+ def customer_facing_display(self, html=None):
+ display = DisplayDriver.get_default_display()
+ if display:
+ display.update_customer_facing_display(http.request.httprequest.remote_addr, html)
+ return {'status': 'updated'}
+ return {'status': 'failed'}
+
+ @http.route('/hw_proxy/take_control', type='json', auth='none', cors='*')
+ def take_control(self, html=None):
+ display = DisplayDriver.get_default_display()
+ if display:
+ display.take_control(http.request.httprequest.remote_addr, html)
+ return {
+ 'status': 'success',
+ 'message': 'You now have access to the display',
+ }
+
+ @http.route('/hw_proxy/test_ownership', type='json', auth='none', cors='*')
+ def test_ownership(self):
+ display = DisplayDriver.get_default_display()
+ if display and display.owner == http.request.httprequest.remote_addr:
+ return {'status': 'OWNER'}
+ return {'status': 'NOWNER'}
+
+ @http.route(['/point_of_sale/get_serialized_order', '/point_of_sale/get_serialized_order/<string:display_identifier>'], type='json', auth='none')
+ def get_serialized_order(self, display_identifier=None):
+ if display_identifier:
+ display = iot_devices.get(display_identifier)
+ else:
+ display = DisplayDriver.get_default_display()
+
+ if display:
+ return display.get_serialized_order()
+ return {
+ 'rendered_html': False,
+ 'error': "No display found",
+ }
+
+ @http.route(['/point_of_sale/display', '/point_of_sale/display/<string:display_identifier>'], type='http', auth='none')
+ def display(self, display_identifier=None):
+ cust_js = None
+ interfaces = ni.interfaces()
+
+ with open(os.path.join(os.path.dirname(__file__), "../../static/src/js/worker.js")) as js:
+ cust_js = js.read()
+
+ display_ifaces = []
+ for iface_id in interfaces:
+ if 'wlan' in iface_id or 'eth' in iface_id:
+ iface_obj = ni.ifaddresses(iface_id)
+ ifconfigs = iface_obj.get(ni.AF_INET, [])
+ essid = helpers.get_ssid()
+ for conf in ifconfigs:
+ if conf.get('addr'):
+ display_ifaces.append({
+ 'iface_id': iface_id,
+ 'essid': essid,
+ 'addr': conf.get('addr'),
+ 'icon': 'sitemap' if 'eth' in iface_id else 'wifi',
+ })
+
+ if not display_identifier:
+ display_identifier = DisplayDriver.get_default_display().device_identifier
+
+ return pos_display_template.render({
+ 'title': "Odoo -- Point of Sale",
+ 'breadcrumb': 'POS Client display',
+ 'cust_js': cust_js,
+ 'display_ifaces': display_ifaces,
+ 'display_identifier': display_identifier,
+ 'pairing_code': connection_manager.pairing_code,
+ })
diff --git a/addons/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver.py b/addons/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver.py
new file mode 100644
index 00000000..29c7b12b
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/drivers/KeyboardUSBDriver.py
@@ -0,0 +1,367 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import ctypes
+import evdev
+import json
+import logging
+from lxml import etree
+import os
+from pathlib import Path
+from queue import Queue, Empty
+import subprocess
+from threading import Lock
+import time
+import urllib3
+from usb import util
+
+from odoo import http, _
+from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
+from odoo.addons.hw_drivers.driver import Driver
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.main import iot_devices
+from odoo.addons.hw_drivers.tools import helpers
+
+_logger = logging.getLogger(__name__)
+xlib = ctypes.cdll.LoadLibrary('libX11.so.6')
+
+
+class KeyboardUSBDriver(Driver):
+ connection_type = 'usb'
+ keyboard_layout_groups = []
+ available_layouts = []
+
+ def __init__(self, identifier, device):
+ if not hasattr(KeyboardUSBDriver, 'display'):
+ os.environ['XAUTHORITY'] = "/run/lightdm/pi/xauthority"
+ KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8"))
+
+ super(KeyboardUSBDriver, self).__init__(identifier, device)
+ self.device_connection = 'direct'
+ self.device_name = self._set_name()
+
+ # from https://github.com/xkbcommon/libxkbcommon/blob/master/test/evdev-scancodes.h
+ self._scancode_to_modifier = {
+ 42: 'left_shift',
+ 54: 'right_shift',
+ 58: 'caps_lock',
+ 69: 'num_lock',
+ 100: 'alt_gr', # right alt
+ }
+ self._tracked_modifiers = {modifier: False for modifier in self._scancode_to_modifier.values()}
+
+ if not KeyboardUSBDriver.available_layouts:
+ KeyboardUSBDriver.load_layouts_list()
+ KeyboardUSBDriver.send_layouts_list()
+
+ for evdev_device in [evdev.InputDevice(path) for path in evdev.list_devices()]:
+ if (device.idVendor == evdev_device.info.vendor) and (device.idProduct == evdev_device.info.product):
+ self.input_device = evdev_device
+
+ self._set_device_type('scanner') if self._is_scanner() else self._set_device_type()
+
+ @classmethod
+ def supported(cls, device):
+ for cfg in device:
+ for itf in cfg:
+ if itf.bInterfaceClass == 3 and itf.bInterfaceProtocol != 2:
+ device.interface_protocol = itf.bInterfaceProtocol
+ return True
+ return False
+
+ @classmethod
+ def get_status(self):
+ """Allows `hw_proxy.Proxy` to retrieve the status of the scanners"""
+ status = 'connected' if any(iot_devices[d].device_type == "scanner" for d in iot_devices) else 'disconnected'
+ return {'status': status, 'messages': ''}
+
+ @classmethod
+ def send_layouts_list(cls):
+ server = helpers.get_odoo_server_url()
+ if server:
+ urllib3.disable_warnings()
+ pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ server = server + '/iot/keyboard_layouts'
+ try:
+ pm.request('POST', server, fields={'available_layouts': json.dumps(cls.available_layouts)})
+ except Exception as e:
+ _logger.error('Could not reach configured server')
+ _logger.error('A error encountered : %s ' % e)
+
+ @classmethod
+ def load_layouts_list(cls):
+ tree = etree.parse("/usr/share/X11/xkb/rules/base.xml", etree.XMLParser(ns_clean=True, recover=True))
+ layouts = tree.xpath("//layout")
+ for layout in layouts:
+ layout_name = layout.xpath("./configItem/name")[0].text
+ layout_description = layout.xpath("./configItem/description")[0].text
+ KeyboardUSBDriver.available_layouts.append({
+ 'name': layout_description,
+ 'layout': layout_name,
+ })
+ for variant in layout.xpath("./variantList/variant"):
+ variant_name = variant.xpath("./configItem/name")[0].text
+ variant_description = variant.xpath("./configItem/description")[0].text
+ KeyboardUSBDriver.available_layouts.append({
+ 'name': variant_description,
+ 'layout': layout_name,
+ 'variant': variant_name,
+ })
+
+ def _set_name(self):
+ try:
+ manufacturer = util.get_string(self.dev, self.dev.iManufacturer)
+ product = util.get_string(self.dev, self.dev.iProduct)
+ return ("%s - %s") % (manufacturer, product)
+ except ValueError as e:
+ _logger.warning(e)
+ return _('Unknown input device')
+
+ def action(self, data):
+ if data.get('action', False) == 'update_layout':
+ layout = {
+ 'layout': data.get('layout'),
+ 'variant': data.get('variant'),
+ }
+ self._change_keyboard_layout(layout)
+ self.save_layout(layout)
+ elif data.get('action', False) == 'update_is_scanner':
+ is_scanner = {'is_scanner': data.get('is_scanner')}
+ self.save_is_scanner(is_scanner)
+ else:
+ self.data['value'] = ''
+ event_manager.device_changed(self)
+
+ def run(self):
+ try:
+ for event in self.input_device.read_loop():
+ if self._stopped.isSet():
+ break
+ if event.type == evdev.ecodes.EV_KEY:
+ data = evdev.categorize(event)
+
+ modifier_name = self._scancode_to_modifier.get(data.scancode)
+ if modifier_name:
+ if modifier_name in ('caps_lock', 'num_lock'):
+ if data.keystate == 1:
+ self._tracked_modifiers[modifier_name] = not self._tracked_modifiers[modifier_name]
+ else:
+ self._tracked_modifiers[modifier_name] = bool(data.keystate) # 1 for keydown, 0 for keyup
+ elif data.keystate == 1:
+ self.key_input(data.scancode)
+
+ except Exception as err:
+ _logger.warning(err)
+
+ def _change_keyboard_layout(self, new_layout):
+ """Change the layout of the current device to what is specified in
+ new_layout.
+
+ Args:
+ new_layout (dict): A dict containing two keys:
+ - layout (str): The layout code
+ - variant (str): An optional key to represent the variant of the
+ selected layout
+ """
+ if hasattr(self, 'keyboard_layout'):
+ KeyboardUSBDriver.keyboard_layout_groups.remove(self.keyboard_layout)
+
+ if new_layout:
+ self.keyboard_layout = new_layout.get('layout') or 'us'
+ if new_layout.get('variant'):
+ self.keyboard_layout += "(%s)" % new_layout['variant']
+ else:
+ self.keyboard_layout = 'us'
+
+ KeyboardUSBDriver.keyboard_layout_groups.append(self.keyboard_layout)
+ subprocess.call(["setxkbmap", "-display", ":0.0", ",".join(KeyboardUSBDriver.keyboard_layout_groups)])
+
+ # Close then re-open display to refresh the mapping
+ xlib.XCloseDisplay(KeyboardUSBDriver.display)
+ KeyboardUSBDriver.display = xlib.XOpenDisplay(bytes(":0.0", "utf-8"))
+
+ def save_layout(self, layout):
+ """Save the layout to a file on the box to read it when restarting it.
+ We need that in order to keep the selected layout after a reboot.
+
+ Args:
+ new_layout (dict): A dict containing two keys:
+ - layout (str): The layout code
+ - variant (str): An optional key to represent the variant of the
+ selected layout
+ """
+ file_path = Path.home() / 'odoo-keyboard-layouts.conf'
+ if file_path.exists():
+ data = json.loads(file_path.read_text())
+ else:
+ data = {}
+ data[self.device_identifier] = layout
+ helpers.write_file('odoo-keyboard-layouts.conf', json.dumps(data))
+
+ def save_is_scanner(self, is_scanner):
+ """Save the type of device.
+ We need that in order to keep the selected type of device after a reboot.
+ """
+ file_path = Path.home() / 'odoo-keyboard-is-scanner.conf'
+ if file_path.exists():
+ data = json.loads(file_path.read_text())
+ else:
+ data = {}
+ data[self.device_identifier] = is_scanner
+ helpers.write_file('odoo-keyboard-is-scanner.conf', json.dumps(data))
+ self._set_device_type('scanner') if is_scanner.get('is_scanner') else self._set_device_type()
+
+ def load_layout(self):
+ """Read the layout from the saved filed and set it as current layout.
+ If no file or no layout is found we use 'us' by default.
+ """
+ file_path = Path.home() / 'odoo-keyboard-layouts.conf'
+ if file_path.exists():
+ data = json.loads(file_path.read_text())
+ layout = data.get(self.device_identifier, {'layout': 'us'})
+ else:
+ layout = {'layout': 'us'}
+ self._change_keyboard_layout(layout)
+
+ def _is_scanner(self):
+ """Read the device type from the saved filed and set it as current type.
+ If no file or no device type is found we try to detect it automatically.
+ """
+ device_name = self.device_name.lower()
+ scanner_name = ['barcode', 'scanner', 'reader']
+ is_scanner = any(x in device_name for x in scanner_name) or self.dev.interface_protocol == '0'
+
+ file_path = Path.home() / 'odoo-keyboard-is-scanner.conf'
+ if file_path.exists():
+ data = json.loads(file_path.read_text())
+ is_scanner = data.get(self.device_identifier, {}).get('is_scanner', is_scanner)
+ return is_scanner
+
+ def _keyboard_input(self, scancode):
+ """Deal with a keyboard input. Send the character corresponding to the
+ pressed key represented by its scancode to the connected Odoo instance.
+
+ Args:
+ scancode (int): The scancode of the pressed key.
+ """
+ self.data['value'] = self._scancode_to_char(scancode)
+ if self.data['value']:
+ event_manager.device_changed(self)
+
+ def _barcode_scanner_input(self, scancode):
+ """Deal with a barcode scanner input. Add the new character scanned to
+ the current barcode or complete the barcode if "Return" is pressed.
+ When a barcode is completed, two tasks are performed:
+ - Send a device_changed update to the event manager to notify the
+ listeners that the value has changed (used in Enterprise).
+ - Add the barcode to the list barcodes that are being queried in
+ Community.
+
+ Args:
+ scancode (int): The scancode of the pressed key.
+ """
+ if scancode == 28: # Return
+ self.data['value'] = self._current_barcode
+ event_manager.device_changed(self)
+ self._barcodes.put((time.time(), self._current_barcode))
+ self._current_barcode = ''
+ else:
+ self._current_barcode += self._scancode_to_char(scancode)
+
+ def _set_device_type(self, device_type='keyboard'):
+ """Modify the device type between 'keyboard' and 'scanner'
+
+ Args:
+ type (string): Type wanted to switch
+ """
+ if device_type == 'scanner':
+ self.device_type = 'scanner'
+ self.key_input = self._barcode_scanner_input
+ self._barcodes = Queue()
+ self._current_barcode = ''
+ self.input_device.grab()
+ self.read_barcode_lock = Lock()
+ else:
+ self.device_type = 'keyboard'
+ self.key_input = self._keyboard_input
+ self.load_layout()
+
+ def _scancode_to_char(self, scancode):
+ """Translate a received scancode to a character depending on the
+ selected keyboard layout and the current state of the keyboard's
+ modifiers.
+
+ Args:
+ scancode (int): The scancode of the pressed key, to be translated to
+ a character
+
+ Returns:
+ str: The translated scancode.
+ """
+ # Scancode -> Keysym : Depends on the keyboard layout
+ group = KeyboardUSBDriver.keyboard_layout_groups.index(self.keyboard_layout)
+ modifiers = self._get_active_modifiers(scancode)
+ keysym = ctypes.c_int(xlib.XkbKeycodeToKeysym(KeyboardUSBDriver.display, scancode + 8, group, modifiers))
+
+ # Translate Keysym to a character
+ key_pressed = ctypes.create_string_buffer(5)
+ xlib.XkbTranslateKeySym(KeyboardUSBDriver.display, ctypes.byref(keysym), 0, ctypes.byref(key_pressed), 5, ctypes.byref(ctypes.c_int()))
+ if key_pressed.value:
+ return key_pressed.value.decode('utf-8')
+ return ''
+
+ def _get_active_modifiers(self, scancode):
+ """Get the state of currently active modifiers.
+
+ Args:
+ scancode (int): The scancode of the key being translated
+
+ Returns:
+ int: The current state of the modifiers:
+ 0 -- Lowercase
+ 1 -- Highercase or (NumLock + key pressed on keypad)
+ 2 -- AltGr
+ 3 -- Highercase + AltGr
+ """
+ modifiers = 0
+ uppercase = (self._tracked_modifiers['right_shift'] or self._tracked_modifiers['left_shift']) ^ self._tracked_modifiers['caps_lock']
+ if uppercase or (scancode in [71, 72, 73, 75, 76, 77, 79, 80, 81, 82, 83] and self._tracked_modifiers['num_lock']):
+ modifiers += 1
+
+ if self._tracked_modifiers['alt_gr']:
+ modifiers += 2
+
+ return modifiers
+
+ def read_next_barcode(self):
+ """Get the value of the last barcode that was scanned but not sent yet
+ and not older than 5 seconds. This function is used in Community, when
+ we don't have access to the IoTLongpolling.
+
+ Returns:
+ str: The next barcode to be read or an empty string.
+ """
+
+ # Previous query still running, stop it by sending a fake barcode
+ if self.read_barcode_lock.locked():
+ self._barcodes.put((time.time(), ""))
+
+ with self.read_barcode_lock:
+ try:
+ timestamp, barcode = self._barcodes.get(True, 55)
+ if timestamp > time.time() - 5:
+ return barcode
+ except Empty:
+ return ''
+
+proxy_drivers['scanner'] = KeyboardUSBDriver
+
+
+class KeyboardUSBController(http.Controller):
+ @http.route('/hw_proxy/scanner', type='json', auth='none', cors='*')
+ def get_barcode(self):
+ scanners = [iot_devices[d] for d in iot_devices if iot_devices[d].device_type == "scanner"]
+ if scanners:
+ return scanners[0].read_next_barcode()
+ time.sleep(5)
+ return None
diff --git a/addons/hw_drivers/iot_handlers/drivers/PrinterDriver.py b/addons/hw_drivers/iot_handlers/drivers/PrinterDriver.py
new file mode 100644
index 00000000..bbd79f1c
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/drivers/PrinterDriver.py
@@ -0,0 +1,271 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from base64 import b64decode
+from cups import IPPError, IPP_PRINTER_IDLE, IPP_PRINTER_PROCESSING, IPP_PRINTER_STOPPED
+import dbus
+import io
+import logging
+import netifaces as ni
+import os
+from PIL import Image, ImageOps
+import re
+import subprocess
+import tempfile
+from uuid import getnode as get_mac
+
+from odoo import http
+from odoo.addons.hw_drivers.connection_manager import connection_manager
+from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
+from odoo.addons.hw_drivers.driver import Driver
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.iot_handlers.interfaces.PrinterInterface import PPDs, conn, cups_lock
+from odoo.addons.hw_drivers.main import iot_devices
+from odoo.addons.hw_drivers.tools import helpers
+
+_logger = logging.getLogger(__name__)
+
+RECEIPT_PRINTER_COMMANDS = {
+ 'star': {
+ 'center': b'\x1b\x1d\x61\x01', # ESC GS a n
+ 'cut': b'\x1b\x64\x02', # ESC d n
+ 'title': b'\x1b\x69\x01\x01%s\x1b\x69\x00\x00', # ESC i n1 n2
+ 'drawers': [b'\x07', b'\x1a'] # BEL & SUB
+ },
+ 'escpos': {
+ 'center': b'\x1b\x61\x01', # ESC a n
+ 'cut': b'\x1d\x56\x41\n', # GS V m
+ 'title': b'\x1b\x21\x30%s\x1b\x21\x00', # ESC ! n
+ 'drawers': [b'\x1b\x3d\x01', b'\x1b\x70\x00\x19\x19', b'\x1b\x70\x01\x19\x19'] # ESC = n then ESC p m t1 t2
+ }
+}
+
+def cups_notification_handler(message, uri, device_identifier, state, reason, accepting_jobs):
+ if device_identifier in iot_devices:
+ reason = reason if reason != 'none' else None
+ state_value = {
+ IPP_PRINTER_IDLE: 'connected',
+ IPP_PRINTER_PROCESSING: 'processing',
+ IPP_PRINTER_STOPPED: 'stopped'
+ }
+ iot_devices[device_identifier].update_status(state_value[state], message, reason)
+
+# Create a Cups subscription if it doesn't exist yet
+try:
+ conn.getSubscriptions('/printers/')
+except IPPError:
+ conn.createSubscription(
+ uri='/printers/',
+ recipient_uri='dbus://',
+ events=['printer-state-changed']
+ )
+
+# Listen for notifications from Cups
+bus = dbus.SystemBus()
+bus.add_signal_receiver(cups_notification_handler, signal_name="PrinterStateChanged", dbus_interface="org.cups.cupsd.Notifier")
+
+
+class PrinterDriver(Driver):
+ connection_type = 'printer'
+
+ def __init__(self, identifier, device):
+ super(PrinterDriver, self).__init__(identifier, device)
+ self.device_type = 'printer'
+ self.device_connection = device['device-class'].lower()
+ self.device_name = device['device-make-and-model']
+ self.state = {
+ 'status': 'connecting',
+ 'message': 'Connecting to printer',
+ 'reason': None,
+ }
+ self.send_status()
+
+ self.receipt_protocol = 'star' if 'STR_T' in device['device-id'] else 'escpos'
+ if 'direct' in self.device_connection and any(cmd in device['device-id'] for cmd in ['CMD:STAR;', 'CMD:ESC/POS;']):
+ self.print_status()
+
+ @classmethod
+ def supported(cls, device):
+ if device.get('supported', False):
+ return True
+ protocol = ['dnssd', 'lpd', 'socket']
+ if any(x in device['url'] for x in protocol) and device['device-make-and-model'] != 'Unknown' or 'direct' in device['device-class']:
+ model = cls.get_device_model(device)
+ ppdFile = ''
+ for ppd in PPDs:
+ if model and model in PPDs[ppd]['ppd-product']:
+ ppdFile = ppd
+ break
+ with cups_lock:
+ if ppdFile:
+ conn.addPrinter(name=device['identifier'], ppdname=ppdFile, device=device['url'])
+ else:
+ conn.addPrinter(name=device['identifier'], device=device['url'])
+ conn.setPrinterInfo(device['identifier'], device['device-make-and-model'])
+ conn.enablePrinter(device['identifier'])
+ conn.acceptJobs(device['identifier'])
+ conn.setPrinterUsersAllowed(device['identifier'], ['all'])
+ conn.addPrinterOptionDefault(device['identifier'], "usb-no-reattach", "true")
+ conn.addPrinterOptionDefault(device['identifier'], "usb-unidir", "true")
+ return True
+ return False
+
+ @classmethod
+ def get_device_model(cls, device):
+ device_model = ""
+ if device.get('device-id'):
+ for device_id in [device_lo for device_lo in device['device-id'].split(';')]:
+ if any(x in device_id for x in ['MDL', 'MODEL']):
+ device_model = device_id.split(':')[1]
+ break
+ elif device.get('device-make-and-model'):
+ device_model = device['device-make-and-model']
+ return re.sub("[\(].*?[\)]", "", device_model).strip()
+
+ @classmethod
+ def get_status(cls):
+ status = 'connected' if any(iot_devices[d].device_type == "printer" and iot_devices[d].device_connection == 'direct' for d in iot_devices) else 'disconnected'
+ return {'status': status, 'messages': ''}
+
+ def action(self, data):
+ if data.get('action') == 'cashbox':
+ self.open_cashbox()
+ elif data.get('action') == 'print_receipt':
+ self.print_receipt(b64decode(data['receipt']))
+ else:
+ self.print_raw(b64decode(data['document']))
+
+ def disconnect(self):
+ self.update_status('disconnected', 'Printer was disconnected')
+ super(PrinterDriver, self).disconnect()
+
+ def update_status(self, status, message, reason=None):
+ """Updates the state of the current printer.
+
+ Args:
+ status (str): The new value of the status
+ message (str): A comprehensive message describing the status
+ reason (str): The reason fo the current status
+ """
+ if self.state['status'] != status or self.state['reason'] != reason:
+ self.state = {
+ 'status': status,
+ 'message': message,
+ 'reason': reason,
+ }
+ self.send_status()
+
+ def send_status(self):
+ """ Sends the current status of the printer to the connected Odoo instance.
+ """
+ self.data = {
+ 'value': '',
+ 'state': self.state,
+ }
+ event_manager.device_changed(self)
+
+ def print_raw(self, data):
+ process = subprocess.Popen(["lp", "-d", self.device_identifier], stdin=subprocess.PIPE)
+ process.communicate(data)
+
+ def print_receipt(self, receipt):
+ im = Image.open(io.BytesIO(receipt))
+
+ # Convert to greyscale then to black and white
+ im = im.convert("L")
+ im = ImageOps.invert(im)
+ im = im.convert("1")
+
+ print_command = getattr(self, 'format_%s' % self.receipt_protocol)(im)
+ self.print_raw(print_command)
+
+ def format_star(self, im):
+ width = int((im.width + 7) / 8)
+
+ raster_init = b'\x1b\x2a\x72\x41'
+ raster_page_length = b'\x1b\x2a\x72\x50\x30\x00'
+ raster_send = b'\x62'
+ raster_close = b'\x1b\x2a\x72\x42'
+
+ raster_data = b''
+ dots = im.tobytes()
+ while len(dots):
+ raster_data += raster_send + width.to_bytes(2, 'little') + dots[:width]
+ dots = dots[width:]
+
+ return raster_init + raster_page_length + raster_data + raster_close
+
+ def format_escpos(self, im):
+ width = int((im.width + 7) / 8)
+
+ raster_send = b'\x1d\x76\x30\x00'
+ max_slice_height = 255
+
+ raster_data = b''
+ dots = im.tobytes()
+ while len(dots):
+ im_slice = dots[:width*max_slice_height]
+ slice_height = int(len(im_slice) / width)
+ raster_data += raster_send + width.to_bytes(2, 'little') + slice_height.to_bytes(2, 'little') + im_slice
+ dots = dots[width*max_slice_height:]
+
+ return raster_data + RECEIPT_PRINTER_COMMANDS['escpos']['cut']
+
+ def print_status(self):
+ """Prints the status ticket of the IoTBox on the current printer."""
+ wlan = ''
+ ip = ''
+ mac = ''
+ homepage = ''
+ pairing_code = ''
+
+ ssid = helpers.get_ssid()
+ wlan = '\nWireless network:\n%s\n\n' % ssid
+
+ interfaces = ni.interfaces()
+ ips = []
+ for iface_id in interfaces:
+ iface_obj = ni.ifaddresses(iface_id)
+ ifconfigs = iface_obj.get(ni.AF_INET, [])
+ for conf in ifconfigs:
+ if conf.get('addr') and conf.get('addr'):
+ ips.append(conf.get('addr'))
+ if len(ips) == 0:
+ ip = '\nERROR: Could not connect to LAN\n\nPlease check that the IoTBox is correc-\ntly connected with a network cable,\n that the LAN is setup with DHCP, and\nthat network addresses are available'
+ elif len(ips) == 1:
+ ip = '\nIP Address:\n%s\n' % ips[0]
+ else:
+ ip = '\nIP Addresses:\n%s\n' % '\n'.join(ips)
+
+ if len(ips) >= 1:
+ ips_filtered = [i for i in ips if i != '127.0.0.1']
+ main_ips = ips_filtered and ips_filtered[0] or '127.0.0.1'
+ mac = '\nMAC Address:\n%s\n' % helpers.get_mac_address()
+ homepage = '\nHomepage:\nhttp://%s:8069\n\n' % main_ips
+
+ code = connection_manager.pairing_code
+ if code:
+ pairing_code = '\nPairing Code:\n%s\n' % code
+
+ commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
+ title = commands['title'] % b'IoTBox Status'
+ self.print_raw(commands['center'] + title + b'\n' + wlan.encode() + mac.encode() + ip.encode() + homepage.encode() + pairing_code.encode() + commands['cut'])
+
+ def open_cashbox(self):
+ """Sends a signal to the current printer to open the connected cashbox."""
+ commands = RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
+ for drawer in commands['drawers']:
+ self.print_raw(drawer)
+
+
+class PrinterController(http.Controller):
+
+ @http.route('/hw_proxy/default_printer_action', type='json', auth='none', cors='*')
+ def default_printer_action(self, data):
+ printer = next((d for d in iot_devices if iot_devices[d].device_type == 'printer' and iot_devices[d].device_connection == 'direct'), None)
+ if printer:
+ iot_devices[printer].action(data)
+ return True
+ return False
+
+proxy_drivers['printer'] = PrinterDriver
diff --git a/addons/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py b/addons/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py
new file mode 100644
index 00000000..412d773e
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/drivers/SerialBaseDriver.py
@@ -0,0 +1,144 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import namedtuple
+from contextlib import contextmanager
+import logging
+import serial
+from threading import Lock
+import time
+import traceback
+
+from odoo import _
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.driver import Driver
+
+_logger = logging.getLogger(__name__)
+
+SerialProtocol = namedtuple(
+ 'SerialProtocol',
+ "name baudrate bytesize stopbits parity timeout writeTimeout measureRegexp statusRegexp "
+ "commandTerminator commandDelay measureDelay newMeasureDelay "
+ "measureCommand emptyAnswerValid")
+
+
+@contextmanager
+def serial_connection(path, protocol, is_probing=False):
+ """Opens a serial connection to a device and closes it automatically after use.
+
+ :param path: path to the device
+ :type path: string
+ :param protocol: an object containing the serial protocol to connect to a device
+ :type protocol: namedtuple
+ :param is_probing: a flag thet if set to `True` makes the timeouts longer, defaults to False
+ :type is_probing: bool, optional
+ """
+
+ PROBING_TIMEOUT = 1
+ port_config = {
+ 'baudrate': protocol.baudrate,
+ 'bytesize': protocol.bytesize,
+ 'stopbits': protocol.stopbits,
+ 'parity': protocol.parity,
+ 'timeout': PROBING_TIMEOUT if is_probing else protocol.timeout, # longer timeouts for probing
+ 'writeTimeout': PROBING_TIMEOUT if is_probing else protocol.writeTimeout # longer timeouts for probing
+ }
+ connection = serial.Serial(path, **port_config)
+ yield connection
+ connection.close()
+
+
+class SerialDriver(Driver):
+ """Abstract base class for serial drivers."""
+
+ _protocol = None
+ connection_type = 'serial'
+
+ STATUS_CONNECTED = 'connected'
+ STATUS_ERROR = 'error'
+ STATUS_CONNECTING = 'connecting'
+
+ def __init__(self, identifier, device):
+ """ Attributes initialization method for `SerialDriver`.
+
+ :param device: path to the device
+ :type device: str
+ """
+
+ super(SerialDriver, self).__init__(identifier, device)
+ self._actions = {
+ 'get_status': self._push_status,
+ }
+ self.device_connection = 'serial'
+ self._device_lock = Lock()
+ self._status = {'status': self.STATUS_CONNECTING, 'message_title': '', 'message_body': ''}
+ self._set_name()
+
+ def _get_raw_response(connection):
+ pass
+
+ def _push_status(self):
+ """Updates the current status and pushes it to the frontend."""
+
+ self.data['status'] = self._status
+ event_manager.device_changed(self)
+
+ def _set_name(self):
+ """Tries to build the device's name based on its type and protocol name but falls back on a default name if that doesn't work."""
+
+ try:
+ name = ('%s serial %s' % (self._protocol.name, self.device_type)).title()
+ except Exception:
+ name = 'Unknown Serial Device'
+ self.device_name = name
+
+ def _take_measure(self):
+ pass
+
+ def _do_action(self, data):
+ """Helper function that calls a specific action method on the device.
+
+ :param data: the `_actions` key mapped to the action method we want to call
+ :type data: string
+ """
+
+ try:
+ with self._device_lock:
+ self._actions[data['action']](data)
+ time.sleep(self._protocol.commandDelay)
+ except Exception:
+ msg = _('An error occured while performing action %s on %s') % (data, self.device_name)
+ _logger.exception(msg)
+ self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
+ self._push_status()
+
+ def action(self, data):
+ """Establish a connection with the device if needed and have it perform a specific action.
+
+ :param data: the `_actions` key mapped to the action method we want to call
+ :type data: string
+ """
+
+ if self._connection and self._connection.isOpen():
+ self._do_action(data)
+ else:
+ with serial_connection(self.device_identifier, self._protocol) as connection:
+ self._connection = connection
+ self._do_action(data)
+
+ def run(self):
+ """Continuously gets new measures from the device."""
+
+ try:
+ with serial_connection(self.device_identifier, self._protocol) as connection:
+ self._connection = connection
+ self._status['status'] = self.STATUS_CONNECTED
+ self._push_status()
+ while not self._stopped.isSet():
+ self._take_measure()
+ time.sleep(self._protocol.newMeasureDelay)
+ except Exception:
+ msg = _('Error while reading %s', self.device_name)
+ _logger.exception(msg)
+ self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
+ self._push_status()
diff --git a/addons/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py b/addons/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py
new file mode 100644
index 00000000..cc1d5469
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/drivers/SerialScaleDriver.py
@@ -0,0 +1,316 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import namedtuple
+import logging
+import re
+import serial
+import threading
+import time
+
+from odoo import http
+from odoo.addons.hw_drivers.controllers.proxy import proxy_drivers
+from odoo.addons.hw_drivers.event_manager import event_manager
+from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection
+
+
+_logger = logging.getLogger(__name__)
+
+# Only needed to ensure compatibility with older versions of Odoo
+ACTIVE_SCALE = None
+new_weight_event = threading.Event()
+
+ScaleProtocol = namedtuple('ScaleProtocol', SerialProtocol._fields + ('zeroCommand', 'tareCommand', 'clearCommand', 'autoResetWeight'))
+
+# 8217 Mettler-Toledo (Weight-only) Protocol, as described in the scale's Service Manual.
+# e.g. here: https://www.manualslib.com/manual/861274/Mettler-Toledo-Viva.html?page=51#manual
+# Our recommended scale, the Mettler-Toledo "Ariva-S", supports this protocol on
+# both the USB and RS232 ports, it can be configured in the setup menu as protocol option 3.
+# We use the default serial protocol settings, the scale's settings can be configured in the
+# scale's menu anyway.
+Toledo8217Protocol = ScaleProtocol(
+ name='Toledo 8217',
+ baudrate=9600,
+ bytesize=serial.SEVENBITS,
+ stopbits=serial.STOPBITS_ONE,
+ parity=serial.PARITY_EVEN,
+ timeout=1,
+ writeTimeout=1,
+ measureRegexp=b"\x02\\s*([0-9.]+)N?\\r",
+ statusRegexp=b"\x02\\s*(\\?.)\\r",
+ commandDelay=0.2,
+ measureDelay=0.5,
+ newMeasureDelay=0.2,
+ commandTerminator=b'',
+ measureCommand=b'W',
+ zeroCommand=b'Z',
+ tareCommand=b'T',
+ clearCommand=b'C',
+ emptyAnswerValid=False,
+ autoResetWeight=False,
+)
+
+# The ADAM scales have their own RS232 protocol, usually documented in the scale's manual
+# e.g at https://www.adamequipment.com/media/docs/Print%20Publications/Manuals/PDF/AZEXTRA/AZEXTRA-UM.pdf
+# https://www.manualslib.com/manual/879782/Adam-Equipment-Cbd-4.html?page=32#manual
+# Only the baudrate and label format seem to be configurable in the AZExtra series.
+ADAMEquipmentProtocol = ScaleProtocol(
+ name='Adam Equipment',
+ baudrate=4800,
+ bytesize=serial.EIGHTBITS,
+ stopbits=serial.STOPBITS_ONE,
+ parity=serial.PARITY_NONE,
+ timeout=0.2,
+ writeTimeout=0.2,
+ measureRegexp=b"\s*([0-9.]+)kg", # LABEL format 3 + KG in the scale settings, but Label 1/2 should work
+ statusRegexp=None,
+ commandTerminator=b"\r\n",
+ commandDelay=0.2,
+ measureDelay=0.5,
+ # AZExtra beeps every time you ask for a weight that was previously returned!
+ # Adding an extra delay gives the operator a chance to remove the products
+ # before the scale starts beeping. Could not find a way to disable the beeps.
+ newMeasureDelay=5,
+ measureCommand=b'P',
+ zeroCommand=b'Z',
+ tareCommand=b'T',
+ clearCommand=None, # No clear command -> Tare again
+ emptyAnswerValid=True, # AZExtra does not answer unless a new non-zero weight has been detected
+ autoResetWeight=True, # AZExtra will not return 0 after removing products
+)
+
+
+# Ensures compatibility with older versions of Odoo
+class ScaleReadOldRoute(http.Controller):
+ @http.route('/hw_proxy/scale_read', type='json', auth='none', cors='*')
+ def scale_read(self):
+ if ACTIVE_SCALE:
+ return {'weight': ACTIVE_SCALE._scale_read_old_route()}
+ return None
+
+
+class ScaleDriver(SerialDriver):
+ """Abstract base class for scale drivers."""
+ last_sent_value = None
+
+ def __init__(self, identifier, device):
+ super(ScaleDriver, self).__init__(identifier, device)
+ self.device_type = 'scale'
+ self._set_actions()
+ self._is_reading = True
+
+ # Ensures compatibility with older versions of Odoo
+ # Only the last scale connected is kept
+ global ACTIVE_SCALE
+ ACTIVE_SCALE = self
+ proxy_drivers['scale'] = ACTIVE_SCALE
+
+ # Ensures compatibility with older versions of Odoo
+ # and allows using the `ProxyDevice` in the point of sale to retrieve the status
+ def get_status(self):
+ """Allows `hw_proxy.Proxy` to retrieve the status of the scales"""
+
+ status = self._status
+ return {'status': status['status'], 'messages': [status['message_title'], ]}
+
+ def _set_actions(self):
+ """Initializes `self._actions`, a map of action keys sent by the frontend to backend action methods."""
+
+ self._actions.update({
+ 'read_once': self._read_once_action,
+ 'set_zero': self._set_zero_action,
+ 'set_tare': self._set_tare_action,
+ 'clear_tare': self._clear_tare_action,
+ 'start_reading': self._start_reading_action,
+ 'stop_reading': self._stop_reading_action,
+ })
+
+ def _start_reading_action(self, data):
+ """Starts asking for the scale value."""
+ self._is_reading = True
+
+ def _stop_reading_action(self, data):
+ """Stops asking for the scale value."""
+ self._is_reading = False
+
+ def _clear_tare_action(self, data):
+ """Clears the scale current tare weight."""
+
+ # if the protocol has no clear tare command, we can just tare again
+ clearCommand = self._protocol.clearCommand or self._protocol.tareCommand
+ self._connection.write(clearCommand + self._protocol.commandTerminator)
+
+ def _read_once_action(self, data):
+ """Reads the scale current weight value and pushes it to the frontend."""
+
+ self._read_weight()
+ self.last_sent_value = self.data['value']
+ event_manager.device_changed(self)
+
+ def _set_zero_action(self, data):
+ """Makes the weight currently applied to the scale the new zero."""
+
+ self._connection.write(self._protocol.zeroCommand + self._protocol.commandTerminator)
+
+ def _set_tare_action(self, data):
+ """Sets the scale's current weight value as tare weight."""
+
+ self._connection.write(self._protocol.tareCommand + self._protocol.commandTerminator)
+
+ @staticmethod
+ def _get_raw_response(connection):
+ """Gets raw bytes containing the updated value of the device.
+
+ :param connection: a connection to the device's serial port
+ :type connection: pyserial.Serial
+ :return: the raw response to a weight request
+ :rtype: str
+ """
+
+ answer = []
+ while True:
+ char = connection.read(1)
+ if not char:
+ break
+ else:
+ answer.append(bytes(char))
+ return b''.join(answer)
+
+ def _read_weight(self):
+ """Asks for a new weight from the scale, checks if it is valid and, if it is, makes it the current value."""
+
+ protocol = self._protocol
+ self._connection.write(protocol.measureCommand + protocol.commandTerminator)
+ answer = self._get_raw_response(self._connection)
+ match = re.search(self._protocol.measureRegexp, answer)
+ if match:
+ self.data = {
+ 'value': float(match.group(1)),
+ 'status': self._status
+ }
+
+ # Ensures compatibility with older versions of Odoo
+ def _scale_read_old_route(self):
+ """Used when the iot app is not installed"""
+ with self._device_lock:
+ self._read_weight()
+ return self.data['value']
+
+ def _take_measure(self):
+ """Reads the device's weight value, and pushes that value to the frontend."""
+
+ with self._device_lock:
+ self._read_weight()
+ if self.data['value'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR:
+ self.last_sent_value = self.data['value']
+ event_manager.device_changed(self)
+
+
+class Toledo8217Driver(ScaleDriver):
+ """Driver for the Toldedo 8217 serial scale."""
+ _protocol = Toledo8217Protocol
+
+ def __init__(self, identifier, device):
+ super(Toledo8217Driver, self).__init__(identifier, device)
+ self.device_manufacturer = 'Toledo'
+
+ @classmethod
+ def supported(cls, device):
+ """Checks whether the device, which port info is passed as argument, is supported by the driver.
+
+ :param device: path to the device
+ :type device: str
+ :return: whether the device is supported by the driver
+ :rtype: bool
+ """
+
+ protocol = cls._protocol
+
+ try:
+ with serial_connection(device['identifier'], protocol, is_probing=True) as connection:
+ connection.write(b'Ehello' + protocol.commandTerminator)
+ time.sleep(protocol.commandDelay)
+ answer = connection.read(8)
+ if answer == b'\x02E\rhello':
+ connection.write(b'F' + protocol.commandTerminator)
+ return True
+ except serial.serialutil.SerialTimeoutException:
+ pass
+ except Exception:
+ _logger.exception('Error while probing %s with protocol %s' % (device, protocol.name))
+ return False
+
+
+class AdamEquipmentDriver(ScaleDriver):
+ """Driver for the Adam Equipment serial scale."""
+
+ _protocol = ADAMEquipmentProtocol
+ priority = 0 # Test the supported method of this driver last, after all other serial drivers
+
+ def __init__(self, identifier, device):
+ super(AdamEquipmentDriver, self).__init__(identifier, device)
+ self._is_reading = False
+ self._last_weight_time = 0
+ self.device_manufacturer = 'Adam'
+
+ def _check_last_weight_time(self):
+ """The ADAM doesn't make the difference between a value of 0 and "the same value as last time":
+ in both cases it returns an empty string.
+ With this, unless the weight changes, we give the user `TIME_WEIGHT_KEPT` seconds to log the new weight,
+ then change it back to zero to avoid keeping it indefinetely, which could cause issues.
+ In any case the ADAM must always go back to zero before it can weight again.
+ """
+
+ TIME_WEIGHT_KEPT = 10
+
+ if self.data['value'] is None:
+ if time.time() - self._last_weight_time > TIME_WEIGHT_KEPT:
+ self.data['value'] = 0
+ else:
+ self._last_weight_time = time.time()
+
+ def _take_measure(self):
+ """Reads the device's weight value, and pushes that value to the frontend."""
+
+ if self._is_reading:
+ with self._device_lock:
+ self._read_weight()
+ self._check_last_weight_time()
+ if self.data['value'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR:
+ self.last_sent_value = self.data['value']
+ event_manager.device_changed(self)
+ else:
+ time.sleep(0.5)
+
+ # Ensures compatibility with older versions of Odoo
+ def _scale_read_old_route(self):
+ """Used when the iot app is not installed"""
+
+ time.sleep(3)
+ with self._device_lock:
+ self._read_weight()
+ self._check_last_weight_time()
+ return self.data['value']
+
+ @classmethod
+ def supported(cls, device):
+ """Checks whether the device at `device` is supported by the driver.
+
+ :param device: path to the device
+ :type device: str
+ :return: whether the device is supported by the driver
+ :rtype: bool
+ """
+
+ protocol = cls._protocol
+
+ try:
+ with serial_connection(device['identifier'], protocol, is_probing=True) as connection:
+ connection.write(protocol.measureCommand + protocol.commandTerminator)
+ # Checking whether writing to the serial port using the Adam protocol raises a timeout exception is about the only thing we can do.
+ return True
+ except serial.serialutil.SerialTimeoutException:
+ pass
+ except Exception:
+ _logger.exception('Error while probing %s with protocol %s' % (device, protocol.name))
+ return False
diff --git a/addons/hw_drivers/iot_handlers/interfaces/DisplayInterface.py b/addons/hw_drivers/iot_handlers/interfaces/DisplayInterface.py
new file mode 100644
index 00000000..57e7a4af
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/interfaces/DisplayInterface.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from re import sub, finditer
+import subprocess
+
+from odoo.addons.hw_drivers.interface import Interface
+
+
+class DisplayInterface(Interface):
+ _loop_delay = 0
+ connection_type = 'display'
+
+ def get_devices(self):
+ display_devices = {}
+ displays = subprocess.check_output(['tvservice', '-l']).decode()
+ x_screen = 0
+ for match in finditer('Display Number (\d), type HDMI (\d)', displays):
+ display_id, hdmi_id = match.groups()
+ tvservice_output = subprocess.check_output(['tvservice', '-nv', display_id]).decode().strip()
+ if tvservice_output:
+ display_name = tvservice_output.split('=')[1]
+ display_identifier = sub('[^a-zA-Z0-9 ]+', '', display_name).replace(' ', '_') + "_" + str(hdmi_id)
+ iot_device = {
+ 'identifier': display_identifier,
+ 'name': display_name,
+ 'x_screen': str(x_screen),
+ }
+ display_devices[display_identifier] = iot_device
+ x_screen += 1
+
+ if not len(display_devices):
+ # No display connected, create "fake" device to be accessed from another computer
+ display_devices['distant_display'] = {
+ 'name': "Distant Display",
+ }
+
+ return display_devices
diff --git a/addons/hw_drivers/iot_handlers/interfaces/PrinterInterface.py b/addons/hw_drivers/iot_handlers/interfaces/PrinterInterface.py
new file mode 100644
index 00000000..ca601dbc
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/interfaces/PrinterInterface.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from cups import Connection as cups_connection
+from re import sub
+from threading import Lock
+
+from odoo.addons.hw_drivers.interface import Interface
+
+conn = cups_connection()
+PPDs = conn.getPPDs()
+cups_lock = Lock() # We can only make one call to Cups at a time
+
+class PrinterInterface(Interface):
+ _loop_delay = 120
+ connection_type = 'printer'
+
+ def get_devices(self):
+ printer_devices = {}
+ with cups_lock:
+ printers = conn.getPrinters()
+ devices = conn.getDevices()
+ for printer in printers:
+ path = printers.get(printer).get('device-uri', False)
+ if path and path in devices:
+ devices.get(path).update({'supported': True}) # these printers are automatically supported
+ for path in devices:
+ if 'uuid=' in path:
+ identifier = sub('[^a-zA-Z0-9_]', '', path.split('uuid=')[1])
+ elif 'serial=' in path:
+ identifier = sub('[^a-zA-Z0-9_]', '', path.split('serial=')[1])
+ else:
+ identifier = sub('[^a-zA-Z0-9_]', '', path)
+ devices[path]['identifier'] = identifier
+ devices[path]['url'] = path
+ printer_devices[identifier] = devices[path]
+ return printer_devices
diff --git a/addons/hw_drivers/iot_handlers/interfaces/SerialInterface.py b/addons/hw_drivers/iot_handlers/interfaces/SerialInterface.py
new file mode 100644
index 00000000..ae639697
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/interfaces/SerialInterface.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from glob import glob
+
+from odoo.addons.hw_drivers.interface import Interface
+
+
+class SerialInterface(Interface):
+ connection_type = 'serial'
+
+ def get_devices(self):
+ serial_devices = {}
+ for identifier in glob('/dev/serial/by-path/*'):
+ serial_devices[identifier] = {
+ 'identifier': identifier
+ }
+ return serial_devices
diff --git a/addons/hw_drivers/iot_handlers/interfaces/USBInterface.py b/addons/hw_drivers/iot_handlers/interfaces/USBInterface.py
new file mode 100644
index 00000000..a40e82d6
--- /dev/null
+++ b/addons/hw_drivers/iot_handlers/interfaces/USBInterface.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from usb import core
+
+from odoo.addons.hw_drivers.interface import Interface
+
+
+class USBInterface(Interface):
+ connection_type = 'usb'
+
+ def get_devices(self):
+ """
+ USB devices are identified by a combination of their `idVendor` and
+ `idProduct`. We can't be sure this combination in unique per equipment.
+ To still allow connecting multiple similar equipments, we complete the
+ identifier by a counter. The drawbacks are we can't be sure the equipments
+ will get the same identifiers after a reboot or a disconnect/reconnect.
+ """
+ usb_devices = {}
+ devs = core.find(find_all=True)
+ cpt = 2
+ for dev in devs:
+ identifier = "usb_%04x:%04x" % (dev.idVendor, dev.idProduct)
+ if identifier in usb_devices:
+ identifier += '_%s' % cpt
+ cpt += 1
+ usb_devices[identifier] = dev
+ return usb_devices
diff --git a/addons/hw_drivers/main.py b/addons/hw_drivers/main.py
new file mode 100644
index 00000000..ccbbed52
--- /dev/null
+++ b/addons/hw_drivers/main.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from dbus.mainloop.glib import DBusGMainLoop
+import json
+import logging
+import socket
+from threading import Thread
+import time
+import urllib3
+
+from odoo.addons.hw_drivers.tools import helpers
+
+_logger = logging.getLogger(__name__)
+
+drivers = []
+interfaces = {}
+iot_devices = {}
+
+
+class Manager(Thread):
+ def send_alldevices(self):
+ """
+ This method send IoT Box and devices informations to Odoo database
+ """
+ server = helpers.get_odoo_server_url()
+ if server:
+ subject = helpers.read_file_first_line('odoo-subject.conf')
+ if subject:
+ domain = helpers.get_ip().replace('.', '-') + subject.strip('*')
+ else:
+ domain = helpers.get_ip()
+ iot_box = {
+ 'name': socket.gethostname(),
+ 'identifier': helpers.get_mac_address(),
+ 'ip': domain,
+ 'token': helpers.get_token(),
+ 'version': helpers.get_version(),
+ }
+ devices_list = {}
+ for device in iot_devices:
+ identifier = iot_devices[device].device_identifier
+ devices_list[identifier] = {
+ 'name': iot_devices[device].device_name,
+ 'type': iot_devices[device].device_type,
+ 'manufacturer': iot_devices[device].device_manufacturer,
+ 'connection': iot_devices[device].device_connection,
+ }
+ data = {'params': {'iot_box': iot_box, 'devices': devices_list,}}
+ # disable certifiacte verification
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ try:
+ http.request(
+ 'POST',
+ server + "/iot/setup",
+ body=json.dumps(data).encode('utf8'),
+ headers={
+ 'Content-type': 'application/json',
+ 'Accept': 'text/plain',
+ },
+ )
+ except Exception as e:
+ _logger.error('Could not reach configured server')
+ _logger.error('A error encountered : %s ' % e)
+ else:
+ _logger.warning('Odoo server not set')
+
+ def run(self):
+ """
+ Thread that will load interfaces and drivers and contact the odoo server with the updates
+ """
+
+ helpers.check_git_branch()
+ helpers.check_certificate()
+
+ # We first add the IoT Box to the connected DB because IoT handlers cannot be downloaded if
+ # the identifier of the Box is not found in the DB. So add the Box to the DB.
+ self.send_alldevices()
+ helpers.download_iot_handlers()
+ helpers.load_iot_handlers()
+
+ # Start the interfaces
+ for interface in interfaces.values():
+ i = interface()
+ i.daemon = True
+ i.start()
+
+ # Check every 3 secondes if the list of connected devices has changed and send the updated
+ # list to the connected DB.
+ self.previous_iot_devices = []
+ while 1:
+ if iot_devices != self.previous_iot_devices:
+ self.send_alldevices()
+ self.previous_iot_devices = iot_devices.copy()
+ time.sleep(3)
+
+
+# Must be started from main thread
+DBusGMainLoop(set_as_default=True)
+
+manager = Manager()
+manager.daemon = True
+manager.start()
diff --git a/addons/hw_drivers/static/img/False.jpg b/addons/hw_drivers/static/img/False.jpg
new file mode 100644
index 00000000..8f8e6489
--- /dev/null
+++ b/addons/hw_drivers/static/img/False.jpg
Binary files differ
diff --git a/addons/hw_drivers/static/img/True.jpg b/addons/hw_drivers/static/img/True.jpg
new file mode 100644
index 00000000..0488574a
--- /dev/null
+++ b/addons/hw_drivers/static/img/True.jpg
Binary files differ
diff --git a/addons/hw_drivers/static/src/js/worker.js b/addons/hw_drivers/static/src/js/worker.js
new file mode 100644
index 00000000..8de75617
--- /dev/null
+++ b/addons/hw_drivers/static/src/js/worker.js
@@ -0,0 +1,60 @@
+ $(function() {
+ "use strict";
+ // mergedHead will be turned to true the first time we receive something from a new host
+ // It allows to transform the <head> only once
+ var mergedHead = false;
+ var current_client_url = "";
+
+ function longpolling() {
+ $.ajax({
+ type: 'POST',
+ url: window.location.origin + '/point_of_sale/get_serialized_order/' + display_identifier,
+ dataType: 'json',
+ beforeSend: function(xhr){xhr.setRequestHeader('Content-Type', 'application/json');},
+ data: JSON.stringify({jsonrpc: '2.0'}),
+
+ success: function(data) {
+ if (data.result.error) {
+ $('.error-message').text(data.result.error);
+ $('.error-message').removeClass('d-none');
+ setTimeout(longpolling, 5000);
+ return;
+ }
+ if (data.result.rendered_html) {
+ var trimmed = $.trim(data.result.rendered_html);
+ var $parsedHTML = $('<div>').html($.parseHTML(trimmed,true)); // WARNING: the true here will executes any script present in the string to parse
+ var new_client_url = $parsedHTML.find(".resources > base").attr('href');
+
+ if (!mergedHead || (current_client_url !== new_client_url)) {
+
+ mergedHead = true;
+ current_client_url = new_client_url;
+ $("head").children().not('.origin').remove();
+ $("head").append($parsedHTML.find(".resources").html());
+ }
+
+ $(".container-fluid").html($parsedHTML.find('.pos-customer_facing_display').html());
+ $(".container-fluid").attr('class', 'container-fluid').addClass($parsedHTML.find('.pos-customer_facing_display').attr('class'));
+
+ var d = $('.pos_orderlines_list');
+ d.scrollTop(d.prop("scrollHeight"));
+
+ // Here we execute the code coming from the pos, apparently $.parseHTML() executes scripts right away,
+ // Since we modify the dom afterwards, the script might not have any effect
+ if (typeof foreign_js !== 'undefined' && $.isFunction(foreign_js)) {
+ foreign_js();
+ }
+ }
+ longpolling();
+ },
+
+ error: function (jqXHR, status, err) {
+ setTimeout(longpolling, 5000);
+ },
+
+ timeout: 30000,
+ });
+ };
+
+ longpolling();
+ });
diff --git a/addons/hw_drivers/tools/helpers.py b/addons/hw_drivers/tools/helpers.py
new file mode 100644
index 00000000..3254e179
--- /dev/null
+++ b/addons/hw_drivers/tools/helpers.py
@@ -0,0 +1,279 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import datetime
+from importlib import util
+import io
+import json
+import logging
+import netifaces
+from OpenSSL import crypto
+import os
+from pathlib import Path
+import subprocess
+import urllib3
+import zipfile
+from threading import Thread
+import time
+
+from odoo import _, http
+from odoo.modules.module import get_resource_path
+
+_logger = logging.getLogger(__name__)
+
+#----------------------------------------------------------
+# Helper
+#----------------------------------------------------------
+
+class IoTRestart(Thread):
+ """
+ Thread to restart odoo server in IoT Box when we must return a answer before
+ """
+ def __init__(self, delay):
+ Thread.__init__(self)
+ self.delay = delay
+
+ def run(self):
+ time.sleep(self.delay)
+ subprocess.check_call(["sudo", "service", "odoo", "restart"])
+
+def access_point():
+ return get_ip() == '10.11.12.1'
+
+def add_credential(db_uuid, enterprise_code):
+ write_file('odoo-db-uuid.conf', db_uuid)
+ write_file('odoo-enterprise-code.conf', enterprise_code)
+
+def check_certificate():
+ """
+ Check if the current certificate is up to date or not authenticated
+ """
+ server = get_odoo_server_url()
+ if server:
+ path = Path('/etc/ssl/certs/nginx-cert.crt')
+ if path.exists():
+ with path.open('r') as f:
+ cert = crypto.load_certificate(crypto.FILETYPE_PEM, f.read())
+ cert_end_date = datetime.datetime.strptime(cert.get_notAfter().decode('utf-8'), "%Y%m%d%H%M%SZ") - datetime.timedelta(days=10)
+ for key in cert.get_subject().get_components():
+ if key[0] == b'CN':
+ cn = key[1].decode('utf-8')
+ if cn == 'OdooTempIoTBoxCertificate' or datetime.datetime.now() > cert_end_date:
+ _logger.info(_('Your certificate %s must be updated') % (cn))
+ load_certificate()
+ else:
+ _logger.info(_('Your certificate %s is valid until %s') % (cn, cert_end_date))
+ else:
+ load_certificate()
+
+def check_git_branch():
+ """
+ Check if the local branch is the same than the connected Odoo DB and
+ checkout to match it if needed.
+ """
+ server = get_odoo_server_url()
+ if server:
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ try:
+ response = http.request(
+ 'POST',
+ server + "/web/webclient/version_info",
+ body = '{}',
+ headers = {'Content-type': 'application/json'}
+ )
+
+ if response.status == 200:
+ git = ['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git']
+
+ db_branch = json.loads(response.data)['result']['server_serie'].replace('~', '-')
+ if not subprocess.check_output(git + ['ls-remote', 'origin', db_branch]):
+ db_branch = 'master'
+
+ local_branch = subprocess.check_output(git + ['symbolic-ref', '-q', '--short', 'HEAD']).decode('utf-8').rstrip()
+
+ if db_branch != local_branch:
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
+ subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/drivers/*"])
+ subprocess.check_call(["rm", "-rf", "/home/pi/odoo/addons/hw_drivers/iot_handlers/interfaces/*"])
+ subprocess.check_call(git + ['branch', '-m', db_branch])
+ subprocess.check_call(git + ['remote', 'set-branches', 'origin', db_branch])
+ os.system('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/posbox_update.sh')
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
+
+ except Exception as e:
+ _logger.error('Could not reach configured server')
+ _logger.error('A error encountered : %s ' % e)
+
+def check_image():
+ """
+ Check if the current image of IoT Box is up to date
+ """
+ url = 'https://nightly.odoo.com/master/iotbox/SHA1SUMS.txt'
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ response = http.request('GET', url)
+ checkFile = {}
+ valueActual = ''
+ for line in response.data.decode().split('\n'):
+ if line:
+ value, name = line.split(' ')
+ checkFile.update({value: name})
+ if name == 'iotbox-latest.zip':
+ valueLastest = value
+ elif name == get_img_name():
+ valueActual = value
+ if valueActual == valueLastest:
+ return False
+ version = checkFile.get(valueLastest, 'Error').replace('iotboxv', '').replace('.zip', '').split('_')
+ return {'major': version[0], 'minor': version[1]}
+
+def get_img_name():
+ major, minor = get_version().split('.')
+ return 'iotboxv%s_%s.zip' % (major, minor)
+
+def get_ip():
+ try:
+ return netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr']
+ except:
+ return netifaces.ifaddresses('wlan0')[netifaces.AF_INET][0]['addr']
+
+def get_mac_address():
+ try:
+ return netifaces.ifaddresses('eth0')[netifaces.AF_LINK][0]['addr']
+ except:
+ return netifaces.ifaddresses('wlan0')[netifaces.AF_LINK][0]['addr']
+
+def get_ssid():
+ ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive
+ if not ap:
+ return subprocess.check_output(['grep', '-oP', '(?<=ssid=).*', '/etc/hostapd/hostapd.conf']).decode('utf-8').rstrip()
+ process_iwconfig = subprocess.Popen(['iwconfig'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwconfig.stdout, stdout=subprocess.PIPE)
+ return subprocess.check_output(['sed', 's/.*"\\(.*\\)"/\\1/'], stdin=process_grep.stdout).decode('utf-8').rstrip()
+
+def get_odoo_server_url():
+ ap = subprocess.call(['systemctl', 'is-active', '--quiet', 'hostapd']) # if service is active return 0 else inactive
+ if not ap:
+ return False
+ return read_file_first_line('odoo-remote-server.conf')
+
+def get_token():
+ return read_file_first_line('token')
+
+def get_version():
+ return subprocess.check_output(['cat', '/var/odoo/iotbox_version']).decode().rstrip()
+
+def get_wifi_essid():
+ wifi_options = []
+ process_iwlist = subprocess.Popen(['sudo', 'iwlist', 'wlan0', 'scan'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ process_grep = subprocess.Popen(['grep', 'ESSID:"'], stdin=process_iwlist.stdout, stdout=subprocess.PIPE).stdout.readlines()
+ for ssid in process_grep:
+ essid = ssid.decode('utf-8').split('"')[1]
+ if essid not in wifi_options:
+ wifi_options.append(essid)
+ return wifi_options
+
+def load_certificate():
+ """
+ Send a request to Odoo with customer db_uuid and enterprise_code to get a true certificate
+ """
+ db_uuid = read_file_first_line('odoo-db-uuid.conf')
+ enterprise_code = read_file_first_line('odoo-enterprise-code.conf')
+ if db_uuid and enterprise_code:
+ url = 'https://www.odoo.com/odoo-enterprise/iot/x509'
+ data = {
+ 'params': {
+ 'db_uuid': db_uuid,
+ 'enterprise_code': enterprise_code
+ }
+ }
+ urllib3.disable_warnings()
+ http = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ response = http.request(
+ 'POST',
+ url,
+ body = json.dumps(data).encode('utf8'),
+ headers = {'Content-type': 'application/json', 'Accept': 'text/plain'}
+ )
+ result = json.loads(response.data.decode('utf8'))['result']
+ if result:
+ write_file('odoo-subject.conf', result['subject_cn'])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/"])
+ Path('/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem'])
+ Path('/root_bypass_ramdisks/etc/ssl/certs/nginx-cert.crt').write_text(result['x509_pem'])
+ Path('/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem'])
+ Path('/root_bypass_ramdisks/etc/ssl/private/nginx-cert.key').write_text(result['private_key_pem'])
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/root_bypass_ramdisks/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
+ subprocess.check_call(["sudo", "service", "nginx", "restart"])
+
+def download_iot_handlers(auto=True):
+ """
+ Get the drivers from the configured Odoo server
+ """
+ server = get_odoo_server_url()
+ if server:
+ urllib3.disable_warnings()
+ pm = urllib3.PoolManager(cert_reqs='CERT_NONE')
+ server = server + '/iot/get_handlers'
+ try:
+ resp = pm.request('POST', server, fields={'mac': get_mac_address(), 'auto': auto})
+ if resp.data:
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
+ drivers_path = Path.home() / 'odoo/addons/hw_drivers/iot_handlers'
+ zip_file = zipfile.ZipFile(io.BytesIO(resp.data))
+ zip_file.extractall(drivers_path)
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
+ except Exception as e:
+ _logger.error('Could not reach configured server')
+ _logger.error('A error encountered : %s ' % e)
+
+def load_iot_handlers():
+ """
+ This method loads local files: 'odoo/addons/hw_drivers/iot_handlers/drivers' and
+ 'odoo/addons/hw_drivers/iot_handlers/interfaces'
+ And execute these python drivers and interfaces
+ """
+ for directory in ['interfaces', 'drivers']:
+ path = get_resource_path('hw_drivers', 'iot_handlers', directory)
+ filesList = os.listdir(path)
+ for file in filesList:
+ path_file = os.path.join(path, file)
+ spec = util.spec_from_file_location(file, path_file)
+ if spec:
+ module = util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ http.addons_manifest = {}
+ http.root = http.Root()
+
+def odoo_restart(delay):
+ IR = IoTRestart(delay)
+ IR.start()
+
+def read_file_first_line(filename):
+ path = Path.home() / filename
+ path = Path('/home/pi/' + filename)
+ if path.exists():
+ with path.open('r') as f:
+ return f.readline().strip('\n')
+ return ''
+
+def unlink_file(filename):
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
+ path = Path.home() / filename
+ if path.exists():
+ path.unlink()
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
+
+def write_file(filename, text):
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/"])
+ path = Path.home() / filename
+ path.write_text(text)
+ subprocess.call(["sudo", "mount", "-o", "remount,ro", "/"])
+ subprocess.call(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/etc/cups"])
diff --git a/addons/hw_drivers/views/pos_display.html b/addons/hw_drivers/views/pos_display.html
new file mode 100644
index 00000000..4cd91f68
--- /dev/null
+++ b/addons/hw_drivers/views/pos_display.html
@@ -0,0 +1,94 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="cache-control" content="no-cache" />
+ <meta http-equiv="pragma" content="no-cache" />
+ <title class="origin">{{ title or "Odoo's IoTBox" }}</title>
+ <script class="origin" type="text/javascript" src="/web/static/lib/jquery/jquery.js"></script>
+ <link class="origin" rel="stylesheet" href="/web/static/lib/bootstrap/css/bootstrap.css">
+ <script class="origin" type="text/javascript" src="/web/static/lib/bootstrap/js/bootstrap.min.js"></script>
+ <link rel="stylesheet" type="text/css" href="/web/static/lib/fontawesome/css/font-awesome.css"/>
+ <script type="text/javascript" class="origin">
+ var display_identifier = '{{ display_identifier }}';
+ {{ cust_js|safe }}
+ </script>
+ <style class="origin">
+ html, body {
+ height: 100%;
+ }
+ </style>
+ <style>
+ body {
+ background: linear-gradient(to right bottom, #77717e, #c9a8a9);
+ height: 100vh;
+ }
+ .pos-display-boxes {
+ position: absolute;
+ right: 20px;
+ bottom: 20px;
+ }
+ .pos-display-box {
+ padding: 10px 20px;
+ background: rgba(0, 0, 0, 0.17);
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ box-shadow: 1px 1px 0px 0px rgba(60, 60, 60, 0.4);
+ color: #fff;
+ border-radius: 8px;
+ width: 500px;
+ margin-top: 20px;
+ }
+ .pos-display-box hr {
+ background-color: #fff;
+ }
+ .info-text {
+ font-size: 15px;
+ }
+ .table-pos-info {
+ color: #fff;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="container-fluid">
+ <div class="text-center pt-5">
+ <img style="width: 150px;" src="/web/static/src/img/logo_inverse_white_206px.png">
+ <p class="mt-3" style="color: #fff;font-size: 30px;">IoTBox</p>
+ </div>
+ <div class="pos-display-boxes">
+ {% if pairing_code %}
+ <div class="pos-display-box">
+ <h4 class="text-center mb-3">Pairing Code</h4>
+ <hr/>
+ <h4 class="text-center mb-3">{{ pairing_code }}</h4>
+ </div>
+ {% endif %}
+ <div class="pos-display-box">
+ <h4 class="text-center mb-3">POS Client display</h4>
+ <table class="table table-hover table-sm table-pos-info">
+ <thead>
+ <tr>
+ <th>Interface</th>
+ <th>IP</th>
+ </tr>
+ </thead>
+ <tbody>
+ {% for display_iface in display_ifaces -%}
+ <tr>
+ <td><i class="fa fa-{{ display_iface.icon }}"/> {{ display_iface.essid }}</td>
+ <td>{{ display_iface.addr }}</td>
+ </tr>
+ {%- endfor %}
+ </tbody>
+ </table>
+ <p class="mb-2 info-text">
+ <i class="fa fa-info-circle mr-1"></i>The customer cart will be displayed here once a Point of Sale session is started.
+ </p>
+ <p class="mb-2 info-text">
+ <i class="fa fa-info-circle mr-1"></i>Odoo version 11 or above is required.
+ </p>
+ <div class="error-message alert alert-danger mb-2 d-none" role="alert" />
+ </div>
+ </div>
+ </div>
+ </body>
+</html>