diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/hw_drivers | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/hw_drivers')
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 Binary files differnew file mode 100644 index 00000000..8f8e6489 --- /dev/null +++ b/addons/hw_drivers/static/img/False.jpg diff --git a/addons/hw_drivers/static/img/True.jpg b/addons/hw_drivers/static/img/True.jpg Binary files differnew file mode 100644 index 00000000..0488574a --- /dev/null +++ b/addons/hw_drivers/static/img/True.jpg 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> |
