add missing payment providers and iot modules for 19.0

Add 19 payment provider modules needed by the sale module:
payment_adyen, payment_aps, payment_asiapay, payment_authorize,
payment_buckaroo, payment_demo, payment_dpo, payment_flutterwave,
payment_iyzico, payment_mercado_pago, payment_mollie, payment_nuvei,
payment_paymob, payment_paypal, payment_razorpay, payment_redsys,
payment_stripe, payment_worldline, payment_xendit

Add 3 IoT modules needed for point_of_sale:
iot_base, iot_box_image, iot_drivers

Note: Stripe test API keys replaced with placeholders.

🤖 assisted by claude
This commit is contained in:
Ernad Husremovic 2026-03-09 15:44:59 +01:00
parent 3037cab43e
commit aee3ee8bf7
1472 changed files with 194608 additions and 0 deletions

View file

@ -0,0 +1,43 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from functools import wraps
import requests
import logging
from . import server_logger
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
from . import tools
from . import websocket_client
from . import webrtc_client
_logger = logging.getLogger(__name__)
_logger.warning("==== Starting Odoo ====")
_get = requests.get
_post = requests.post
def set_default_options(func):
@wraps(func)
def wrapper(*args, **kwargs):
headers = kwargs.pop('headers', None) or {}
verify = kwargs.pop('verify', False)
headers['User-Agent'] = 'OdooIoTBox/1.0'
server_url = tools.helpers.get_odoo_server_url()
db_name = tools.helpers.get_conf('db_name')
if server_url and db_name and args[0].startswith(server_url) and '/web/login?db=' not in args[0]:
headers['X-Odoo-Database'] = db_name
return func(*args, headers=headers, verify=verify, **kwargs)
return wrapper
requests.get = set_default_options(_get)
requests.post = set_default_options(_post)

View file

@ -0,0 +1,27 @@
# 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/app/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.
""",
'assets': {
'iot_drivers.assets': [ # dummy asset name to make sure it does not load outside of IoT homepage
'iot_drivers/static/**/*',
],
},
'installable': False,
'author': 'Odoo S.A.',
'license': 'LGPL-3',
}

View file

@ -0,0 +1,128 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import subprocess
from enum import Enum
from odoo.addons.iot_drivers.tools import helpers
_logger = logging.getLogger(__name__)
CHROMIUM_ARGS = [
'--incognito',
'--disable-infobars',
'--noerrdialogs',
'--no-first-run',
'--bwsi', # Use chromium without signing in
'--disable-extensions', # Disable extensions as they fill up /tmp
'--disk-cache-dir=/dev/null', # Disable disk cache
'--disk-cache-size=1', # Set disk cache size to 1 byte
'--log-level=3', # Reduce amount of logs
]
class BrowserState(Enum):
"""Enum to represent the state of the browser"""
NORMAL = 'normal'
KIOSK = 'kiosk'
FULLSCREEN = 'fullscreen'
class Browser:
"""Methods to interact with a browser"""
def __init__(self, url, _x_screen, env):
"""
:param url: URL to open in the browser
:param _x_screen: X screen number
:param env: Environment variables (e.g. os.environ.copy())
:param kiosk: Whether the browser should be in kiosk mode
"""
self.url = url
self.browser = 'chromium-browser'
self.browser_process_name = 'chromium'
self.state = BrowserState.NORMAL
self._x_screen = _x_screen
self._set_environment(env)
self.open_browser()
def _set_environment(self, env):
"""
Set the environment variables for the browser
:param env: Environment variables (os.environ.copy())
"""
self.env = env
self.env['DISPLAY'] = f':0.{self._x_screen}'
self.env['XAUTHORITY'] = '/run/lightdm/pi/xauthority'
for key in ['HOME', 'XDG_RUNTIME_DIR', 'XDG_CACHE_HOME']:
self.env[key] = '/tmp/' + self._x_screen
def open_browser(self, url=None, state=BrowserState.FULLSCREEN):
"""
open the browser with the given URL, or reopen it if it is already open
:param url: URL to open in the browser
:param state: State of the browser (normal, kiosk, fullscreen)
"""
self.url = url or self.url
self.state = state
# Reopen to take new url or additional args into account
self.close_browser()
browser_args = list(CHROMIUM_ARGS)
if state == BrowserState.KIOSK:
browser_args.extend(["--kiosk", "--touch-events"])
elif state == BrowserState.FULLSCREEN:
browser_args.append("--start-fullscreen")
subprocess.Popen(
[
self.browser,
self.url,
*browser_args,
],
env=self.env,
)
helpers.save_browser_state(url=self.url)
def close_browser(self):
"""close the browser"""
# Kill browser instance (can't `instance.pkill()` as we can't keep the instance after Odoo service restarts)
# We need to terminate it because Odoo will create a new instance each time it is restarted.
subprocess.run(['pkill', self.browser_process_name], check=False)
def xdotool_keystroke(self, keystroke):
"""
Execute a keystroke using xdotool
:param keystroke: Keystroke to execute
"""
subprocess.run([
'xdotool', 'search',
'--sync', '--onlyvisible',
'--screen', self._x_screen,
'--class', self.browser_process_name,
'key', keystroke,
], check=False)
def xdotool_type(self, text):
"""
Type text using xdotool
:param text: Text to type
"""
subprocess.run([
'xdotool', 'search',
'--sync', '--onlyvisible',
'--screen', self._x_screen,
'--class', self.browser_process_name,
'type', text,
], check=False)
def refresh(self):
"""Refresh the current tab"""
self.xdotool_keystroke('ctrl+r')
def disable_kiosk_mode(self):
"""Removes arguments to chromium-browser cli to open it without kiosk mode"""
if self.state == BrowserState.KIOSK:
self.open_browser(state=BrowserState.FULLSCREEN)

View file

@ -0,0 +1,28 @@
import secrets
import sys
import textwrap
from passlib.hash import pbkdf2_sha512
from odoo.cli import Command
from odoo.tools import config
class GenProxyToken(Command):
""" Generate and (re)set proxy access token in config file """
def generate_token(self, length=16):
token = secrets.token_hex(int(length / 2))
split_size = int(length / 4)
return '-'.join(textwrap.wrap(token, split_size))
def run(self, cmdargs):
self.parser.add_argument('-c', '--config', type=str, help="Specify an alternate config file")
self.parser.add_argument('--token-length', type=int, help="Token Length", default=16)
args, _ = self.parser.parse_known_args()
if args.config:
config.rcfile = args.config
token = self.generate_token(length=args.token_length)
config['proxy_access_token'] = pbkdf2_sha512.hash(token)
config.save()
sys.stdout.write(f'{token}\n')

View file

@ -0,0 +1,115 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
from threading import Thread
import time
from odoo.addons.iot_drivers.main import iot_devices, manager
from odoo.addons.iot_drivers.tools import helpers, upgrade, wifi
from odoo.addons.iot_drivers.tools.system import IS_RPI, IS_TEST
_logger = logging.getLogger(__name__)
class ConnectionManager(Thread):
daemon = True
def __init__(self):
super().__init__()
self.pairing_code = False
self.pairing_uuid = False
self.pairing_code_expired = False
self.new_database_url = False
self.iot_box_registered = False
self.n_times_polled = -1
requests.packages.urllib3.disable_warnings()
def _register_iot_box(self):
""" This method is called to register the IoT Box on odoo.com and get a pairing code"""
req = self._call_iot_proxy()
if all(key in req for key in ['pairing_code', 'pairing_uuid']):
self.pairing_code = req['pairing_code']
self.pairing_uuid = req['pairing_uuid']
if IS_RPI:
self._try_print_pairing_code()
self.iot_box_registered = True
def _get_next_polling_interval(self):
# To avoid spamming odoo.com with requests we gradually space out the requests
# e.g If the pairing code is valid for 2 hours this would lead to max 329 requests
# Starting with 15 seconds and ending with 40s interval, staying under 20s for 50 min
self.n_times_polled += 1
return 14 + 1.01 ** self.n_times_polled
def run(self):
# Double loop is needed in case the IoT Box isn't initially connected to the internet
while True:
while self._should_poll_to_connect_database():
if not self.iot_box_registered:
self._register_iot_box()
self._poll_pairing_result()
time.sleep(self._get_next_polling_interval())
time.sleep(5)
def _should_poll_to_connect_database(self):
return (
not helpers.get_odoo_server_url() and
helpers.get_ip() and
not (IS_RPI and wifi.is_access_point()) and
not self.pairing_code_expired
)
def _call_iot_proxy(self):
data = {
'params': {
'pairing_code': self.pairing_code,
'pairing_uuid': self.pairing_uuid,
'serial_number': helpers.get_identifier(),
}
}
try:
req = requests.post(
'https://iot-proxy.odoo.com/odoo-enterprise/iot/connect-box',
json=data,
timeout=5,
)
req.raise_for_status()
if req.json().get('error') == 'expired':
self.pairing_code_expired = True
self.pairing_code = False
self.pairing_uuid = False
return req.json().get('result', {})
except Exception:
_logger.exception('Could not reach iot-proxy.odoo.com')
return {}
def _poll_pairing_result(self):
result = self._call_iot_proxy()
if 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'])
def _connect_to_server(self, url, token, db_uuid, enterprise_code):
self.new_database_url = url
# Save DB URL and token
helpers.save_conf_server(url, token, db_uuid, enterprise_code)
# Send already detected devices and IoT Box info to the database
manager._send_all_devices()
# Switch git branch before restarting, this avoids restarting twice
upgrade.check_git_branch()
# Restart to get a certificate, load the IoT handlers...
helpers.odoo_restart(2)
def _try_print_pairing_code(self):
printers = [device for device in iot_devices.values() if device.device_type == 'printer' and device.connected_by_usb and device.device_subtype in ['receipt_printer', 'label_printer']]
for printer in printers:
printer.print_status()
connection_manager = ConnectionManager()
if not IS_TEST:
connection_manager.start()

View file

@ -0,0 +1,5 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import driver
from . import proxy
from . import homepage

View file

@ -0,0 +1,96 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
import logging
import os
from socket import gethostname
import time
from werkzeug.exceptions import InternalServerError
from zlib import adler32
from odoo import http, tools
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.tools import helpers, route
_logger = logging.getLogger(__name__)
DEVICE_TYPES = [
"display", "printer", "scanner", "keyboard", "camera", "device", "payment", "scale", "fiscal_data_module"
]
class DriverController(http.Controller):
@helpers.toggleable
@route.iot_route('/iot_drivers/action', type='jsonrpc', cors='*', csrf=False)
def action(self, session_id, device_identifier, data):
"""This route is called when we want to make an 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
"""
# If device_identifier is a type of device, we take the first device of this type
# required for longpolling with community db
if device_identifier in DEVICE_TYPES:
device_identifier = next((d for d in iot_devices if iot_devices[d].device_type == device_identifier), None)
iot_device = iot_devices.get(device_identifier)
if not iot_device:
_logger.warning("IoT Device with identifier %s not found", device_identifier)
return False
data['session_id'] = session_id # ensure session_id is in data as for websocket communication
_logger.debug("Calling action %s for device %s", data.get('action', ''), device_identifier)
iot_device.action(data)
return True
@helpers.toggleable
@route.iot_route('/iot_drivers/event', type='jsonrpc', cors='*', csrf=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']
_logger.debug("Event %s found for device %s ", event, event['device_identifier'])
return event
# Wait for new event
if req['event'].wait(50):
req['event'].clear()
req['result']['session_id'] = req['session_id']
return req['result']
@route.iot_route('/iot_drivers/download_logs', type='http', cors='*', csrf=False)
def download_logs(self):
"""
Downloads the log file
"""
log_path = tools.config['logfile'] or "/var/log/odoo/odoo-server.log"
try:
stat = os.stat(log_path)
except FileNotFoundError:
raise InternalServerError("Log file has not been found. Check your Log file configuration.")
check = adler32(log_path.encode())
log_file_name = f"iot-odoo-{gethostname()}-{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log"
# intentionally don't use Stream.from_path as the path used is not in the addons path
# for instance, for the iot-box it will be in /var/log/odoo
return http.Stream(
type='path',
path=log_path,
download_name=log_file_name,
etag=f'{int(stat.st_mtime)}-{stat.st_size}-{check}',
last_modified=stat.st_mtime,
size=stat.st_size,
mimetype='text/plain',
).get_response(
mimetype='text/plain', as_attachment=True
)

View file

@ -0,0 +1,486 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import netifaces
import subprocess
import threading
import time
from itertools import groupby
from pathlib import Path
from odoo import http
from odoo.addons.iot_drivers.tools import certificate, helpers, route, upgrade, wifi
from odoo.addons.iot_drivers.tools.system import IOT_SYSTEM, IS_RPI
from odoo.addons.iot_drivers.main import iot_devices, unsupported_devices
from odoo.addons.iot_drivers.connection_manager import connection_manager
from odoo.tools.misc import file_path
from odoo.addons.iot_drivers.server_logger import (
check_and_update_odoo_config_log_to_server_option,
get_odoo_config_log_to_server_option,
close_server_log_sender_handler,
)
_logger = logging.getLogger(__name__)
IOT_LOGGING_PREFIX = 'iot-logging-'
INTERFACE_PREFIX = 'interface-'
DRIVER_PREFIX = 'driver-'
AVAILABLE_LOG_LEVELS = ('debug', 'info', 'warning', 'error')
AVAILABLE_LOG_LEVELS_WITH_PARENT = AVAILABLE_LOG_LEVELS + ('parent',)
CONTENT_SECURITY_POLICY = (
"default-src 'none';"
"script-src 'self' 'unsafe-eval';" # OWL requires `unsafe-eval` to render templates
"connect-src 'self';"
"img-src 'self' data:;" # `data:` scheme required as Bootstrap uses it for embedded SVGs
"style-src 'self';"
"font-src 'self';"
)
class IotBoxOwlHomePage(http.Controller):
def __init__(self):
super().__init__()
self.updating = threading.Lock()
@route.iot_route('/', type='http')
def index(self):
return http.Stream.from_path("iot_drivers/views/index.html").get_response(content_security_policy=CONTENT_SECURITY_POLICY)
@route.iot_route('/logs', type='http')
def logs_page(self):
return http.Stream.from_path("iot_drivers/views/logs.html").get_response(content_security_policy=CONTENT_SECURITY_POLICY)
@route.iot_route('/status', type='http')
def status_page(self):
return http.Stream.from_path("iot_drivers/views/status_display.html").get_response(content_security_policy=CONTENT_SECURITY_POLICY)
# ---------------------------------------------------------- #
# GET methods #
# -> Always use json.dumps() to return a JSON response #
# ---------------------------------------------------------- #
@route.iot_route('/iot_drivers/restart_odoo_service', type='http', cors='*')
def odoo_service_restart(self):
helpers.odoo_restart(0)
return json.dumps({
'status': 'success',
'message': 'Odoo service restarted',
})
@route.iot_route('/iot_drivers/iot_logs', type='http', cors='*')
def get_iot_logs(self):
logs_path = "/var/log/odoo/odoo-server.log" if IS_RPI else Path().absolute().parent.joinpath('odoo.log')
with open(logs_path, encoding="utf-8") as file:
return json.dumps({
'status': 'success',
'logs': file.read(),
})
@route.iot_route('/iot_drivers/six_payment_terminal_clear', type='http', cors='*')
def clear_six_terminal(self):
helpers.update_conf({'six_payment_terminal': ''})
return json.dumps({
'status': 'success',
'message': 'Successfully cleared Six Payment Terminal',
})
@route.iot_route('/iot_drivers/clear_credential', type='http', cors='*')
def clear_credential(self):
helpers.update_conf({
'db_uuid': '',
'enterprise_code': '',
})
helpers.odoo_restart(0)
return json.dumps({
'status': 'success',
'message': 'Successfully cleared credentials',
})
@route.iot_route('/iot_drivers/wifi_clear', type='http', cors='*', linux_only=True)
def clear_wifi_configuration(self):
helpers.update_conf({'wifi_ssid': '', 'wifi_password': ''})
wifi.disconnect()
return json.dumps({
'status': 'success',
'message': 'Successfully disconnected from wifi',
})
@route.iot_route('/iot_drivers/server_clear', type='http', cors='*')
def clear_server_configuration(self):
helpers.disconnect_from_server()
close_server_log_sender_handler()
return json.dumps({
'status': 'success',
'message': 'Successfully disconnected from server',
})
@route.iot_route('/iot_drivers/ping', type='http', cors='*')
def ping(self):
return json.dumps({
'status': 'success',
'message': 'pong',
})
@route.iot_route('/iot_drivers/data', type="http", cors='*')
def get_homepage_data(self):
network_interfaces = []
if IS_RPI:
ssid = wifi.get_current() or wifi.get_access_point_ssid()
for iface_id in netifaces.interfaces():
if iface_id == 'lo':
continue # Skip loopback interface (127.0.0.1)
is_wifi = 'wlan' in iface_id
network_interfaces.extend([{
'id': iface_id,
'is_wifi': is_wifi,
'ssid': ssid if is_wifi else None,
'ip': conf.get('addr', 'No Internet'),
} for conf in netifaces.ifaddresses(iface_id).get(netifaces.AF_INET, [])])
devices = [{
'name': device.device_name,
'type': device.device_type,
'identifier': device.device_identifier,
'connection': device.device_connection,
} for device in iot_devices.values()]
devices += list(unsupported_devices.values())
def device_type_key(device):
return device['type']
grouped_devices = {
device_type: list(devices)
for device_type, devices in groupby(sorted(devices, key=device_type_key), device_type_key)
}
six_terminal = helpers.get_conf('six_payment_terminal') or 'Not Configured'
network_qr_codes = wifi.generate_network_qr_codes() if IS_RPI else {}
odoo_server_url = helpers.get_odoo_server_url() or ''
odoo_uptime_seconds = time.monotonic() - helpers.odoo_start_time
system_uptime_seconds = time.monotonic() - helpers.system_start_time
return json.dumps({
'db_uuid': helpers.get_conf('db_uuid'),
'enterprise_code': helpers.get_conf('enterprise_code'),
'ip': helpers.get_ip(),
'identifier': helpers.get_identifier(),
'mac_address': helpers.get_mac_address(),
'devices': grouped_devices,
'server_status': odoo_server_url,
'pairing_code': connection_manager.pairing_code,
'new_database_url': connection_manager.new_database_url,
'pairing_code_expired': connection_manager.pairing_code_expired and not odoo_server_url,
'six_terminal': six_terminal,
'is_access_point_up': IS_RPI and wifi.is_access_point(),
'network_interfaces': network_interfaces,
'version': helpers.get_version(),
'system': IOT_SYSTEM,
'odoo_uptime_seconds': odoo_uptime_seconds,
'system_uptime_seconds': system_uptime_seconds,
'certificate_end_date': certificate.get_certificate_end_date(),
'wifi_ssid': helpers.get_conf('wifi_ssid'),
'qr_code_wifi': network_qr_codes.get('qr_wifi'),
'qr_code_url': network_qr_codes.get('qr_url'),
})
@route.iot_route('/iot_drivers/wifi', type="http", cors='*', linux_only=True)
def get_available_wifi(self):
return json.dumps({
'currentWiFi': wifi.get_current(),
'availableWiFi': wifi.get_available_ssids(),
})
@route.iot_route('/iot_drivers/version_info', type="http", cors='*', linux_only=True)
def get_version_info(self):
# Check branch name and last commit hash on IoT Box
current_commit = upgrade.git("rev-parse", "HEAD")
current_branch = upgrade.git("rev-parse", "--abbrev-ref", "HEAD")
if not current_commit or not current_branch:
return json.dumps({
'status': 'error',
'message': 'Failed to retrieve current commit or branch',
})
last_available_commit = upgrade.git("ls-remote", "origin", current_branch)
if not last_available_commit:
_logger.error("Failed to retrieve last commit available for branch origin/%s", current_branch)
return json.dumps({
'status': 'error',
'message': 'Failed to retrieve last commit available for branch origin/' + current_branch,
})
last_available_commit = last_available_commit.split()[0].strip()
return json.dumps({
'status': 'success',
# Checkout requires db to align with its version (=branch)
'odooIsUpToDate': current_commit == last_available_commit or not bool(helpers.get_odoo_server_url()),
'imageIsUpToDate': IS_RPI and not bool(helpers.check_image()),
'currentCommitHash': current_commit,
})
@route.iot_route('/iot_drivers/log_levels', type="http", cors='*')
def log_levels(self):
drivers_list = helpers.get_handlers_files_to_load(
file_path('iot_drivers/iot_handlers/drivers'))
interfaces_list = helpers.get_handlers_files_to_load(
file_path('iot_drivers/iot_handlers/interfaces'))
return json.dumps({
'title': "Odoo's IoT Box - Handlers list",
'breadcrumb': 'Handlers list',
'drivers_list': drivers_list,
'interfaces_list': interfaces_list,
'server': helpers.get_odoo_server_url(),
'is_log_to_server_activated': get_odoo_config_log_to_server_option(),
'root_logger_log_level': self._get_logger_effective_level_str(logging.getLogger()),
'odoo_current_log_level': self._get_logger_effective_level_str(logging.getLogger('odoo')),
'recommended_log_level': 'warning',
'available_log_levels': AVAILABLE_LOG_LEVELS,
'drivers_logger_info': self._get_iot_handlers_logger(drivers_list, 'drivers'),
'interfaces_logger_info': self._get_iot_handlers_logger(interfaces_list, 'interfaces'),
})
@route.iot_route('/iot_drivers/load_iot_handlers', type="http", cors='*')
def load_iot_handlers(self):
helpers.download_iot_handlers(False)
helpers.odoo_restart(0)
return json.dumps({
'status': 'success',
'message': 'IoT Handlers loaded successfully',
})
@route.iot_route('/iot_drivers/is_ngrok_enabled', type="http", linux_only=True)
def is_ngrok_enabled(self):
return json.dumps({'enabled': helpers.is_ngrok_enabled()})
# ---------------------------------------------------------- #
# POST methods #
# -> Never use json.dumps() it will be done automatically #
# ---------------------------------------------------------- #
@route.iot_route('/iot_drivers/six_payment_terminal_add', type="jsonrpc", methods=['POST'], cors='*')
def add_six_terminal(self, terminal_id):
if terminal_id.isdigit():
helpers.update_conf({'six_payment_terminal': terminal_id})
else:
_logger.warning('Ignoring invalid Six TID: "%s". Only digits are allowed', terminal_id)
return self.clear_six_terminal()
return {
'status': 'success',
'message': 'Successfully saved Six Payment Terminal',
}
@route.iot_route('/iot_drivers/save_credential', type="jsonrpc", methods=['POST'], cors='*')
def save_credential(self, db_uuid, enterprise_code):
helpers.update_conf({
'db_uuid': db_uuid,
'enterprise_code': enterprise_code,
})
helpers.odoo_restart(0)
return {
'status': 'success',
'message': 'Successfully saved credentials',
}
@route.iot_route('/iot_drivers/update_wifi', type="jsonrpc", methods=['POST'], cors='*', linux_only=True)
def update_wifi(self, essid, password):
if wifi.reconnect(essid, password, force_update=True):
helpers.update_conf({'wifi_ssid': essid, 'wifi_password': password})
res_payload = {
'status': 'success',
'message': 'Connecting to ' + essid,
}
else:
res_payload = {
'status': 'error',
'message': 'Failed to connect to ' + essid,
}
return res_payload
@route.iot_route(
'/iot_drivers/generate_password', type="jsonrpc", methods=["POST"], cors='*', linux_only=True
)
def generate_password(self):
return {
'password': helpers.generate_password(),
}
@route.iot_route('/iot_drivers/enable_ngrok', type="jsonrpc", methods=['POST'], linux_only=True)
def enable_remote_connection(self, auth_token):
return {'status': 'success' if helpers.toggle_remote_connection(auth_token) else 'failure'}
@route.iot_route('/iot_drivers/disable_ngrok', type="jsonrpc", methods=['POST'], linux_only=True)
def disable_remote_connection(self):
return {'status': 'success' if helpers.toggle_remote_connection() else 'failure'}
@route.iot_route('/iot_drivers/connect_to_server', type="jsonrpc", methods=['POST'], cors='*')
def connect_to_odoo_server(self, token):
if token:
try:
if len(token.split('|')) == 4:
# Old style token with pipe separators (pre v18 DB)
url, token, db_uuid, enterprise_code = token.split('|')
configuration = helpers.parse_url(url)
helpers.save_conf_server(configuration["url"], token, db_uuid, enterprise_code)
else:
# New token using query params (v18+ DB)
configuration = helpers.parse_url(token)
helpers.save_conf_server(**configuration)
except ValueError:
_logger.warning("Wrong server token: %s", token)
return {
'status': 'failure',
'message': 'Invalid URL provided.',
}
except (subprocess.CalledProcessError, OSError, Exception):
return {
'status': 'failure',
'message': 'Failed to write server configuration files on IoT. Please try again.',
}
# 1 sec delay for IO operations (save_conf_server)
helpers.odoo_restart(1)
return {
'status': 'success',
'message': 'Successfully connected to db, IoT will restart to update the configuration.',
}
@route.iot_route('/iot_drivers/log_levels_update', type="jsonrpc", methods=['POST'], cors='*')
def update_log_level(self, name, value):
if not name.startswith(IOT_LOGGING_PREFIX) and name != 'log-to-server':
return {
'status': 'error',
'message': 'Invalid logger name',
}
if name == 'log-to-server':
check_and_update_odoo_config_log_to_server_option(value)
name = name[len(IOT_LOGGING_PREFIX):]
if name == 'root':
self._update_logger_level('', value, AVAILABLE_LOG_LEVELS)
elif name == 'odoo':
self._update_logger_level('odoo', value, AVAILABLE_LOG_LEVELS)
self._update_logger_level('werkzeug', value if value != 'debug' else 'info', AVAILABLE_LOG_LEVELS)
elif name.startswith(INTERFACE_PREFIX):
logger_name = name[len(INTERFACE_PREFIX):]
self._update_logger_level(logger_name, value, AVAILABLE_LOG_LEVELS_WITH_PARENT, 'interfaces')
elif name.startswith(DRIVER_PREFIX):
logger_name = name[len(DRIVER_PREFIX):]
self._update_logger_level(logger_name, value, AVAILABLE_LOG_LEVELS_WITH_PARENT, 'drivers')
else:
_logger.warning('Unhandled iot logger: %s', name)
return {
'status': 'success',
'message': 'Logger level updated',
}
@route.iot_route('/iot_drivers/update_git_tree', type="jsonrpc", methods=['POST'], cors='*', linux_only=True)
def update_git_tree(self):
upgrade.check_git_branch()
return {
'status': 'success',
'message': 'Successfully updated the IoT Box',
}
# ---------------------------------------------------------- #
# Utils #
# ---------------------------------------------------------- #
def _get_iot_handlers_logger(self, handlers_name, iot_handler_folder_name):
handlers_loggers_level = dict()
for handler_name in handlers_name:
handler_logger = self._get_iot_handler_logger(handler_name, iot_handler_folder_name)
if not handler_logger:
# Might happen if the file didn't define a logger (or not init yet)
handlers_loggers_level[handler_name] = False
_logger.debug('Unable to find logger for handler %s', handler_name)
continue
logger_parent = handler_logger.parent
handlers_loggers_level[handler_name] = {
'level': self._get_logger_effective_level_str(handler_logger),
'is_using_parent_level': handler_logger.level == logging.NOTSET,
'parent_name': logger_parent.name,
'parent_level': self._get_logger_effective_level_str(logger_parent),
}
return handlers_loggers_level
def _update_logger_level(self, logger_name, new_level, available_log_levels, handler_folder=False):
"""Update (if necessary) Odoo's configuration and logger to the given logger_name to the given level.
The responsibility of saving the config file is not managed here.
:param logger_name: name of the logging logger to change level
:param new_level: new log level to set for this logger
:param available_log_levels: iterable of logs levels allowed (for initial check)
:param str handler_folder: optional string of the IoT handler folder name ('interfaces' or 'drivers')
"""
# We store the timestamp to reset the log level to warning after a week (7 days * 24 hours * 3600 seconds)
# This is to avoid sending polluted logs with debug messages to the db
conf = {'log_level_reset_timestamp': str(time.time() + 7 * 24 * 3600)}
if new_level not in available_log_levels:
_logger.warning('Unknown level to set on logger %s: %s', logger_name, new_level)
return
if handler_folder:
logger = self._get_iot_handler_logger(logger_name, handler_folder)
if not logger:
_logger.warning('Unable to change log level for logger %s as logger missing', logger_name)
return
logger_name = logger.name
ODOO_TOOL_CONFIG_HANDLER_NAME = 'log_handler'
LOG_HANDLERS = (helpers.get_conf(ODOO_TOOL_CONFIG_HANDLER_NAME, section='options') or []).split(',')
LOGGER_PREFIX = logger_name + ':'
IS_NEW_LEVEL_PARENT = new_level == 'parent'
if not IS_NEW_LEVEL_PARENT:
intended_to_find = LOGGER_PREFIX + new_level.upper()
if intended_to_find in LOG_HANDLERS:
# There is nothing to do, the entry is already inside
return
# We remove every occurrence for the given logger
log_handlers_without_logger = [
log_handler for log_handler in LOG_HANDLERS if not log_handler.startswith(LOGGER_PREFIX)
]
if IS_NEW_LEVEL_PARENT:
# We must check that there is no existing entries using this logger (whatever the level)
if len(log_handlers_without_logger) == len(LOG_HANDLERS):
return
# We add if necessary new logger entry
# If it is "parent" it means we want it to inherit from the parent logger.
# In order to do this we have to make sure that no entries for the logger exists in the
# `log_handler` (which is the case at this point as long as we don't re-add an entry)
new_level_upper_case = new_level.upper()
if not IS_NEW_LEVEL_PARENT:
new_entry = LOGGER_PREFIX + new_level_upper_case
log_handlers_without_logger.append(new_entry)
_logger.debug('Adding to odoo config log_handler: %s', new_entry)
conf[ODOO_TOOL_CONFIG_HANDLER_NAME] = ','.join(log_handlers_without_logger)
# Update the logger dynamically
real_new_level = logging.NOTSET if IS_NEW_LEVEL_PARENT else new_level_upper_case
_logger.debug('Change logger %s level to %s', logger_name, real_new_level)
logging.getLogger(logger_name).setLevel(real_new_level)
helpers.update_conf(conf, section='options')
def _get_logger_effective_level_str(self, logger):
return logging.getLevelName(logger.getEffectiveLevel()).lower()
def _get_iot_handler_logger(self, handler_name, handler_folder_name):
"""
Get Odoo Iot logger given an IoT handler name
:param handler_name: name of the IoT handler
:param handler_folder_name: IoT handler folder name (interfaces or drivers)
:return: logger if any, False otherwise
"""
odoo_addon_handler_path = helpers.compute_iot_handlers_addon_name(handler_folder_name, handler_name)
return odoo_addon_handler_path in logging.Logger.manager.loggerDict and \
logging.getLogger(odoo_addon_handler_path)

View file

@ -0,0 +1,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.addons.iot_drivers.tools import route
proxy_drivers = {}
class ProxyController(http.Controller):
@route.iot_route('/hw_proxy/hello', type='http', cors='*')
def hello(self):
return "ping"
@route.iot_route('/hw_proxy/status_json', type='jsonrpc', cors='*')
def status_json(self):
return {
driver: instance.get_status()
for driver, instance in proxy_drivers.items()
}

View file

@ -0,0 +1,81 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from threading import Thread, Event
from odoo.addons.iot_drivers.main import drivers, iot_devices
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.tools.helpers import toggleable
from odoo.tools.lru import LRU
_logger = logging.getLogger(__name__)
class Driver(Thread):
"""Hook to register the driver into the drivers list"""
connection_type = ''
daemon = True
priority = 0
def __init__(self, identifier, device):
super().__init__()
self.dev = device
self.device_identifier = identifier
self.device_name = ''
self.device_connection = ''
self.device_type = ''
self.device_manufacturer = ''
self.data = {'value': '', 'result': ''} # TODO: deprecate "value"?
self._actions = {}
self._stopped = Event()
self._recent_action_ids = LRU(256)
def __init_subclass__(cls):
super().__init_subclass__()
if cls not in drivers:
drivers.append(cls)
@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
@toggleable
def action(self, data):
"""Helper function that calls a specific action method on the device.
:param dict data: the action method name and the parameters to be passed to it
:return: the result of the action method
"""
if self._check_if_action_is_duplicate(data.get('action_unique_id')):
return
action = data.get('action', '')
session_id = data.get('session_id')
if session_id:
self.data["owner"] = session_id
try:
response = {'status': 'success', 'result': self._actions[action](data), 'action_args': {**data}}
except Exception as e:
_logger.exception("Error while executing action %s with params %s", action, data)
response = {'status': 'error', 'result': str(e), 'action_args': {**data}}
# Make response available to /event route or websocket
# printers handle their own events (low on paper, etc.)
if self.device_type != "printer":
event_manager.device_changed(self, response)
def _check_if_action_is_duplicate(self, action_unique_id):
if not action_unique_id:
return False
if action_unique_id in self._recent_action_ids:
_logger.warning("Duplicate action %s received, ignoring", action_unique_id)
return True
self._recent_action_ids[action_unique_id] = action_unique_id
return False
def disconnect(self):
self._stopped.set()
del iot_devices[self.device_identifier]

View file

@ -0,0 +1,84 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from threading import Event
import time
from odoo.http import request
from odoo.addons.iot_drivers.tools import helpers
from odoo.addons.iot_drivers.webrtc_client import webrtc_client
from odoo.addons.iot_drivers.websocket_client import send_to_controller
class EventManager:
def __init__(self):
self.events = []
self.sessions = {}
def _delete_expired_sessions(self, ttl=70):
"""Clear sessions that are no longer called.
:param int ttl: time a session can stay unused before being deleted
"""
self.sessions = {
session: self.sessions[session]
for session in self.sessions
if self.sessions[session]['time_request'] + ttl < time.time()
}
def add_request(self, listener):
"""Create a new session for the listener.
:param dict listener: listener id and devices
:return: the session created
"""
session_id = listener['session_id']
session = {
'session_id': session_id,
'devices': listener['devices'],
'event': Event(),
'result': {},
'time_request': time.time(),
}
self._delete_expired_sessions()
self.sessions[session_id] = session
return session
def device_changed(self, device, data=None):
"""Register a new event.
If ``data`` is provided, it means that the caller is the action method,
it will be used as the event data (instead of the one provided by the request).
:param Driver device: actual device class
:param dict data: data returned by the device (optional)
"""
data = data or (request.params.get('data', {}) if request else {})
# Make notification available to longpolling event route
event = {
**device.data,
'device_identifier': device.device_identifier,
'time': time.time(),
**data,
}
send_to_controller({
**event,
'session_id': data.get('action_args', {}).get('session_id', ''),
'iot_box_identifier': helpers.get_identifier(),
**data,
})
webrtc_client.send(event)
self.events.append(event)
for session in self.sessions:
session_devices = self.sessions[session]['devices']
if (
any(d in [device.device_identifier, device.device_type] for d in session_devices)
and not self.sessions[session]['event'].is_set()
):
if device.device_type in session_devices:
event['device_identifier'] = device.device_type # allow device type as identifier (longpolling)
self.sessions[session]['result'] = event
self.sessions[session]['event'].set()
event_manager = EventManager()

View file

@ -0,0 +1,38 @@
from io import StringIO
import logging
import sys
from odoo.addons.iot_drivers.tools.system import IS_TEST
_logger = logging.getLogger(__name__)
class ExceptionLogger:
"""
Redirect any unhandled python exception to the logger to keep track of them in the log file.
"""
def __init__(self):
self._buffer = StringIO()
def write(self, message):
self._buffer.write(message)
if message.endswith('\n'):
self._flush_buffer()
def _flush_buffer(self):
self._buffer.seek(0)
_logger.error(self._buffer.getvalue().rstrip('\n'))
self._buffer = StringIO() # Reset the buffer
def flush(self):
if self._buffer.tell() > 0:
self._flush_buffer()
def close(self):
self.flush()
if not IS_TEST:
sys.stderr = ExceptionLogger()

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import collections
import odoo.http
from odoo.http import JsonRPCDispatcher, serialize_exception
from odoo.addons.iot_drivers.tools.system import IS_TEST
from werkzeug.exceptions import Forbidden
class JsonRPCDispatcherPatch(JsonRPCDispatcher):
def handle_error(self, exc: Exception) -> collections.abc.Callable:
"""Monkey patch the handle_error method to add HTTP 403 Forbidden
error handling.
:param exc: the exception that occurred.
:returns: a WSGI application
"""
error = {
'code': 200, # this code is the JSON-RPC level code, it is
# distinct from the HTTP status code. This
# code is ignored and the value 200 (while
# misleading) is totally arbitrary.
'message': "Odoo Server Error",
'data': serialize_exception(exc),
}
if isinstance(exc, Forbidden):
error['code'] = 403
error['message'] = "403: Forbidden"
error['data'] = {"message": error['data']["message"]} # only keep the message, not the traceback
return self._response(error=error)
if not IS_TEST:
# Test IoT system is expected to handle Odoo database unlike "real" IoT systems.
def db_list(force=False, host=None):
return []
odoo.http.db_list = db_list

View file

@ -0,0 +1,91 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from threading import Thread
import time
from odoo.addons.iot_drivers.main import drivers, interfaces, iot_devices, unsupported_devices
_logger = logging.getLogger(__name__)
class Interface(Thread):
_loop_delay = 3 # Delay (in seconds) between calls to get_devices or 0 if it should be called only once
connection_type = ''
allow_unsupported = False
daemon = True
def __init__(self):
super().__init__()
self._detected_devices = set()
self.drivers = sorted([d for d in drivers if d.connection_type == self.connection_type], key=lambda d: d.priority, reverse=True)
def __init_subclass__(cls):
super().__init_subclass__()
interfaces[cls.__name__] = cls
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 add_device(self, identifier, device):
if identifier in iot_devices:
return
supported_driver = next(
(driver for driver in self.drivers if driver.supported(device)),
None
)
if supported_driver:
_logger.info('Device %s is now connected', identifier)
if identifier in unsupported_devices:
del unsupported_devices[identifier]
d = supported_driver(identifier, device)
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()
elif self.allow_unsupported and identifier not in unsupported_devices:
_logger.info('Unsupported device %s is now connected', identifier)
unsupported_devices[identifier] = {
'name': f'Unknown device ({self.connection_type})',
'identifier': identifier,
'type': 'unsupported',
'connection': 'direct' if self.connection_type == 'usb' else self.connection_type,
}
def remove_device(self, identifier):
if identifier in iot_devices:
iot_devices[identifier].disconnect()
_logger.info('Device %s is now disconnected', identifier)
elif self.allow_unsupported and identifier in unsupported_devices:
del unsupported_devices[identifier]
_logger.info('Unsupported device %s is now disconnected', identifier)
def update_iot_devices(self, devices=None):
if devices is None:
devices = {}
added = devices.keys() - self._detected_devices
removed = self._detected_devices - devices.keys()
unsupported = {device for device in unsupported_devices if device in devices}
self._detected_devices = set(devices.keys())
for identifier in removed:
self.remove_device(identifier)
for identifier in added | unsupported:
self.add_device(identifier, devices[identifier])
def get_devices(self):
raise NotImplementedError()
def start(self):
try:
super().start()
except Exception:
_logger.exception("Interface %s could not be started", str(self))

View file

@ -0,0 +1,150 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import os
import requests
import subprocess
import time
import werkzeug
from odoo import http
from odoo.addons.iot_drivers.browser import Browser, BrowserState
from odoo.addons.iot_drivers.driver import Driver
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.tools import helpers, route
from odoo.addons.iot_drivers.tools.helpers import Orientation
_logger = logging.getLogger(__name__)
class DisplayDriver(Driver):
connection_type = 'display'
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'display'
self.device_connection = 'hdmi'
self.device_name = device['name']
self.owner = False
self.customer_display_data = {}
saved_url, self.orientation = helpers.load_browser_state()
self._x_screen = device.get('x_screen', '0')
self.url = saved_url or self.get_url_from_db() or 'http://localhost:8069/status/'
self.browser = Browser(self.url, self._x_screen, os.environ.copy())
self.set_orientation(self.orientation)
self._actions.update({
'update_url': self._action_update_url,
'display_refresh': self._action_display_refresh,
'open_kiosk': self._action_open_kiosk,
'rotate_screen': self._action_rotate_screen,
'open': self._action_open_customer_display,
'close': self._action_close_customer_display,
'set': self._action_set_customer_display,
})
@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 run(self):
while not self._stopped.is_set() and "pos_customer_display" not in self.url:
time.sleep(60)
if self.url != 'http://localhost:8069/status/' and self.browser.state != BrowserState.KIOSK:
# Refresh the page every minute
self.browser.refresh()
def update_url(self, url=None):
self.url = (
url
or helpers.load_browser_state()[0]
or 'http://localhost:8069/status/'
)
browser_state = BrowserState.KIOSK if "/pos-self/" in self.url else BrowserState.FULLSCREEN
self.browser.open_browser(self.url, browser_state)
@helpers.require_db
def get_url_from_db(self, server_url=None):
"""Get the display URL provided by the connected database.
:param server_url: The URL of the connected database (provided by decorator).
:return: URL to display or None.
"""
try:
response = requests.get(f"{server_url}/iot/box/{helpers.get_identifier()}/display_url", timeout=5)
response.raise_for_status()
data = json.loads(response.content.decode())
return data.get(self.device_identifier)
except requests.exceptions.RequestException:
_logger.exception("Failed to get display URL from server")
except json.decoder.JSONDecodeError:
return response.content.decode('utf8')
def _action_update_url(self, data):
helpers.save_browser_state(url=data.get('url'))
self.update_url(data.get('url'))
def _action_display_refresh(self, data):
self.browser.refresh()
def _action_open_kiosk(self, data):
origin = helpers.get_odoo_server_url()
self.update_url(f"{origin}/pos-self/{data.get('pos_id')}?access_token={data.get('access_token')}")
self.set_orientation(Orientation.RIGHT)
def _action_rotate_screen(self, data):
orientation = data.get('orientation', 'NORMAL').upper()
self.set_orientation(Orientation[orientation])
def _action_open_customer_display(self, data):
if not data.get('pos_id') or not data.get('access_token'):
return
origin = helpers.get_odoo_server_url() or http.request.httprequest.origin
self.update_url(f"{origin}/pos_customer_display/{data['pos_id']}/{data['access_token']}")
def _action_close_customer_display(self, data):
helpers.update_conf({"browser_url": "", "screen_orientation": ""})
self.browser.disable_kiosk_mode()
self.update_url()
def _action_set_customer_display(self, data):
if not data.get('data'):
return
self.data['customer_display_data'] = data['data']
def set_orientation(self, orientation=Orientation.NORMAL):
if type(orientation) is not Orientation:
raise TypeError("orientation must be of type Orientation")
subprocess.run(['wlr-randr', '--output', self.device_identifier, '--transform', orientation.value], check=True)
# Update touchscreen mapping to this display
subprocess.run(
['sed', '-i', f's/HDMI-A-[12]/{self.device_identifier}/', '/home/odoo/.config/labwc/rc.xml'],
check=False,
)
# Tell labwc to reload its configuration
subprocess.run(['pkill', '-HUP', 'labwc'], check=False)
helpers.save_browser_state(orientation=orientation)
class DisplayController(http.Controller):
@route.iot_route('/hw_proxy/customer_facing_display', type='jsonrpc', cors='*')
def customer_facing_display(self):
display = self.ensure_display()
return display.data.get('customer_display_data', {})
def ensure_display(self):
display: DisplayDriver = DisplayDriver.get_default_display()
if not display:
raise werkzeug.exceptions.ServiceUnavailable(description="No display connected")
return display

View file

@ -0,0 +1,382 @@
# 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 queue import Queue, Empty
import re
import requests
import subprocess
from threading import Lock
import time
from usb import util
from odoo import http
from odoo.addons.iot_drivers.controllers.proxy import proxy_drivers
from odoo.addons.iot_drivers.driver import Driver
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.tools import helpers, route
_logger = logging.getLogger(__name__)
xlib = ctypes.cdll.LoadLibrary('libX11.so.6')
class KeyboardUSBDriver(Driver):
# The list of devices can be found in /proc/bus/input/devices
# or "ls -l /dev/input/by-path"
# Note: the user running "evdev" commands must be inside the "input" group
# Each device's input will correspond to a file in /dev/input/event*
# The exact file can be found by looking at the "Handlers" line in /proc/bus/input/devices
# Example: "H: Handlers=sysrq kbd leds event0" -> The file used is /dev/input/event0
# If you read the file "/dev/input/event0" you will get the input from the device in real time
# One usb device can have multiple associated event files (like a foot pedal which has 3 event files)
connection_type = 'usb'
keyboard_layout_groups = []
available_layouts = []
input_devices = []
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().__init__(identifier, device)
self.device_connection = 'direct'
self.device_name = self._set_name()
self._actions.update({
'update_layout': self._update_layout,
'update_is_scanner': self._save_is_scanner,
'': self._action_default,
})
# 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_devices.append(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
@helpers.require_db
def send_layouts_list(cls, server_url=None):
try:
response = requests.post(
server_url + '/iot/keyboard_layouts',
data={'available_layouts': json.dumps(cls.available_layouts)}, timeout=5
)
response.raise_for_status()
except requests.exceptions.RequestException:
_logger.exception('Could not reach configured server to send available layouts')
@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)
if manufacturer and product:
return re.sub(r"[^\w \-+/*&]", '', "%s - %s" % (manufacturer, product))
except ValueError as e:
_logger.warning(e)
return 'Unknown input device'
def run(self):
try:
for device in self.input_devices:
for event in device.read_loop():
if self._stopped.is_set():
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: # noqa: BLE001
_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 = helpers.path_file('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 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 = helpers.path_file('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 _action_default(self, data):
self.data['value'] = ''
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 = helpers.path_file('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 _save_is_scanner(self, data):
"""Save the type of device.
We need that in order to keep the selected type of device after a reboot.
"""
is_scanner = {'is_scanner': data.get('is_scanner')}
file_path = helpers.path_file('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 _update_layout(self, data):
layout = {
'layout': data.get('layout'),
'variant': data.get('variant'),
}
self._change_keyboard_layout(layout)
self.save_layout(layout)
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 = ''
for device in self.input_devices:
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):
@route.iot_route('/hw_proxy/scanner', type='jsonrpc', 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

View file

@ -0,0 +1,134 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import logging
import PyKCS11
from passlib.context import CryptContext
from odoo import http
from odoo.tools.config import config
from odoo.addons.iot_drivers.tools import route
from odoo.addons.iot_drivers.tools.system import IOT_SYSTEM, IS_RPI, IS_WINDOWS
_logger = logging.getLogger(__name__)
crypt_context = CryptContext(schemes=['pbkdf2_sha512'])
class EtaUsbController(http.Controller):
def _is_access_token_valid(self, access_token):
stored_hash = config.get('proxy_access_token')
if not stored_hash:
# empty password/hash => authentication forbidden
return False
return crypt_context.verify(access_token, stored_hash)
@route.iot_route('/hw_l10n_eg_eta/certificate', type='http', cors='*', csrf=False, methods=['POST'])
def eta_certificate(self, pin, access_token):
"""Gets the certificate from the token and returns it to the main odoo instance so that we can prepare the
cades-bes object on the main odoo instance rather than this middleware
:param pin: pin of the token
:param access_token: token shared with the main odoo instance
:return: json object with the certificate
"""
if not self._is_access_token_valid(access_token):
return self._get_error_template('unauthorized')
session, error = self._get_session(pin)
if error:
return error
try:
cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0]
cert_bytes = bytes(session.getAttributeValue(cert, [PyKCS11.CKA_VALUE])[0])
payload = {
'certificate': base64.b64encode(cert_bytes).decode()
}
return json.dumps(payload)
except Exception as ex:
_logger.exception('Error while getting ETA certificate')
return self._get_error_template(str(ex))
finally:
session.logout()
session.closeSession()
@route.iot_route('/hw_l10n_eg_eta/sign', type='http', cors='*', csrf=False, methods=['POST'])
def eta_sign(self, pin, access_token, invoices):
"""Check if the access_token is valid and sign the invoices accessing the usb key with the pin.
:param pin: pin of the token
:param access_token: token shared with the main odoo instance
:param invoices: dictionary of invoices. Keys are invoices ids, value are the base64 encoded binaries to sign
:return: json object with the signed invoices
"""
if not self._is_access_token_valid(access_token):
return self._get_error_template('unauthorized')
session, error = self._get_session(pin)
if error:
return error
try:
cert = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_CERTIFICATE)])[0]
cert_id = session.getAttributeValue(cert, [PyKCS11.CKA_ID])[0]
priv_key = session.findObjects([(PyKCS11.CKA_CLASS, PyKCS11.CKO_PRIVATE_KEY), (PyKCS11.CKA_ID, cert_id)])[0]
invoice_dict = dict()
invoices = json.loads(invoices)
for invoice, eta_inv in invoices.items():
to_sign = base64.b64decode(eta_inv)
signed_data = session.sign(priv_key, to_sign, PyKCS11.Mechanism(PyKCS11.CKM_SHA256_RSA_PKCS))
invoice_dict[invoice] = base64.b64encode(bytes(signed_data)).decode()
payload = {
'invoices': json.dumps(invoice_dict),
}
return json.dumps(payload)
except Exception as ex:
_logger.exception('Error while signing invoices')
return self._get_error_template(str(ex))
finally:
session.logout()
session.closeSession()
def _get_session(self, pin):
session = False
lib, error = self.get_crypto_lib()
if error:
return session, error
try:
pkcs11 = PyKCS11.PyKCS11Lib()
pkcs11.load(pkcs11dll_filename=lib)
except PyKCS11.PyKCS11Error:
return session, self._get_error_template('missing_dll')
slots = pkcs11.getSlotList(tokenPresent=True)
if not slots:
return session, self._get_error_template('no_drive')
if len(slots) > 1:
return session, self._get_error_template('multiple_drive')
try:
session = pkcs11.openSession(slots[0], PyKCS11.CKF_SERIAL_SESSION | PyKCS11.CKF_RW_SESSION)
session.login(pin)
except Exception as ex: # noqa: BLE001
error = self._get_error_template(str(ex))
return session, error
def get_crypto_lib(self):
error = lib = False
if IOT_SYSTEM == 'Darwin':
lib = '/Library/OpenSC/lib/onepin-opensc-pkcs11.so'
elif IS_RPI:
lib = '/usr/lib/x86_64-linux-gnu/opensc-pkcs11.so'
elif IS_WINDOWS:
lib = 'C:/Windows/System32/eps2003csp11.dll'
else:
error = self._get_error_template('unsupported_system')
return lib, error
def _get_error_template(self, error_str):
return json.dumps({
'error': error_str,
})

View file

@ -0,0 +1,253 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import serial
import time
import struct
import json
from functools import reduce
from odoo import http
from odoo.addons.iot_drivers.iot_handlers.drivers.serial_base_driver import SerialDriver, SerialProtocol, serial_connection
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.tools import route
_logger = logging.getLogger(__name__)
TremolG03Protocol = SerialProtocol(
name='Tremol G03',
baudrate=115200,
bytesize=serial.EIGHTBITS,
stopbits=serial.STOPBITS_ONE,
parity=serial.PARITY_NONE,
timeout=4,
writeTimeout=0.2,
measureRegexp=None,
statusRegexp=None,
commandTerminator=b'',
commandDelay=0.2,
measureDelay=3,
newMeasureDelay=0.2,
measureCommand=b'',
emptyAnswerValid=False,
)
STX = 0x02
ETX = 0x0A
ACK = 0x06
NACK = 0x15
# Dictionary defining the output size of expected from various commands
COMMAND_OUTPUT_SIZE = {
0x30: 7,
0x31: 7,
0x38: 157,
0x39: 155,
0x60: 40,
0x68: 23,
}
FD_ERRORS = {
0x30: 'OK',
0x32: 'Registers overflow',
0x33: 'Clock failure or incorrect date & time',
0x34: 'Opened fiscal receipt',
0x39: 'Incorrect password',
0x3b: '24 hours block - missing Z report',
0x3d: 'Interrupt power supply in fiscal receipt (one time until status is read)',
0x3e: 'Overflow EJ',
0x3f: 'Insufficient conditions',
}
COMMAND_ERRORS = {
0x30: 'OK',
0x31: 'Invalid command',
0x32: 'Illegal command',
0x33: 'Z daily report is not zero',
0x34: 'Syntax error',
0x35: 'Input registers orverflow',
0x36: 'Zero input registers',
0x37: 'Unavailable transaction for correction',
0x38: 'Insufficient amount on hand',
}
class TremolG03Driver(SerialDriver):
"""Driver for the Kenyan Tremol G03 fiscal device."""
_protocol = TremolG03Protocol
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'fiscal_data_module'
self.message_number = 0
@classmethod
def get_default_device(cls):
fiscal_devices = list(filter(lambda d: iot_devices[d].device_type == 'fiscal_data_module', iot_devices))
return len(fiscal_devices) and iot_devices[fiscal_devices[0]]
@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:
protocol = cls._protocol
with serial_connection(device['identifier'], protocol) as connection:
connection.write(b'\x09')
time.sleep(protocol.commandDelay)
response = connection.read(1)
if response == b'\x40':
return True
except serial.serialutil.SerialTimeoutException:
pass
except Exception:
_logger.exception('Error while probing %s with protocol %s', device, protocol.name)
# ----------------
# HELPERS
# ----------------
@staticmethod
def generate_checksum(message):
""" Generate the checksum bytes for the bytes provided.
:param message: bytes representing the part of the message from which the checksum is calculated
:returns: two checksum bytes calculated from the message
This checksum is calculated as:
1) XOR of all bytes of the bytes
2) Conversion of the one XOR byte into the two bytes of the checksum by
adding 30h to each half-byte of the XOR
eg. to_check = \x12\x23\x34\x45\x56
XOR of all bytes in to_check = \x16
checksum generated as \x16 -> \x31 \x36
"""
xor = reduce(lambda a, b: a ^ b, message)
return bytes([(xor >> 4) + 0x30, (xor & 0xf) + 0x30])
# ----------------
# COMMUNICATION
# ----------------
def send(self, msgs):
""" Send and receive messages to/from the fiscal device over serial connection
Generate the wrapped message from the msgs and send them to the device.
The wrapping contains the <STX> (starting byte) <LEN> (length byte)
and <NBL> (message number byte) at the start and two <CS> (checksum
bytes), and the <ETX> line-feed byte at the end.
:param msgs: A list of byte strings representing the <CMD> and <DATA>
components of the serial message.
:return: A list of the responses (if any) from the device. If the
response is an ack, it wont be part of this list.
"""
with self._device_lock:
replies = []
for msg in msgs:
self.message_number += 1
core_message = struct.pack('BB%ds' % (len(msg)), len(msg) + 34, self.message_number + 32, msg)
request = struct.pack('B%ds2sB' % (len(core_message)), STX, core_message, self.generate_checksum(core_message), ETX)
time.sleep(self._protocol.commandDelay)
self._connection.write(request)
_logger.debug('Debug send request: %s', request)
# If we know the expected output size, we can set the read
# buffer to match the size of the output.
output_size = COMMAND_OUTPUT_SIZE.get(msg[0])
if output_size:
try:
response = self._connection.read(output_size)
except serial.serialutil.SerialTimeoutException:
_logger.exception('Timeout error while reading response to command %s', msg)
self.data['status'] = "Device timeout error"
else:
time.sleep(self._protocol.measureDelay)
response = self._connection.read_all()
_logger.debug('Debug send response: %s', response)
if not response:
self.data['status'] = "No response"
_logger.error("Sent request: %s,\n Received no response", request)
self.abort_post()
break
if response[0] == ACK:
# In the case where either byte is not 0x30, there has been an error
if response[2] != 0x30 or response[3] != 0x30:
self.data['status'] = response[2:4].decode('cp1251')
_logger.error(
"Sent request: %s,\n Received fiscal device error: %s \n Received command error: %s",
request, FD_ERRORS.get(response[2], 'Unknown fiscal device error'),
COMMAND_ERRORS.get(response[3], 'Unknown command error'),
)
self.abort_post()
break
replies.append('')
elif response[0] == NACK:
self.data['status'] = "Received NACK"
_logger.error("Sent request: %s,\n Received NACK \x15", request)
self.abort_post()
break
elif response[0] == 0x02:
self.data['status'] = "ok"
size = response[1] - 35
reply = response[4:4 + size]
replies.append(reply.decode('cp1251'))
return {'replies': replies, 'status': self.data['status']}
def abort_post(self):
""" Cancel the posting of the invoice
In the event of an error, it is better to try to cancel the posting of
the invoice, since the state of the invoice on the device will remain
open otherwise, blocking further invoices being sent.
"""
self.message_number += 1
abort = struct.pack('BBB', 35, self.message_number + 32, 0x39)
request = struct.pack('B3s2sB', STX, abort, self.generate_checksum(abort), ETX)
self._connection.write(request)
_logger.debug('Debug abort_post request: %s', request)
response = self._connection.read(COMMAND_OUTPUT_SIZE[0x39])
_logger.debug('Debug abort_post response: %s', response)
if response and response[0] == 0x02:
self.data['status'] += "\n The invoice was successfully cancelled"
_logger.info("Invoice successfully cancelled")
else:
self.data['status'] += "\n The invoice could not be cancelled."
_logger.error("Failed to cancel invoice, received response: %s", response)
class TremolG03Controller(http.Controller):
@route.iot_route('/hw_proxy/l10n_ke_cu_send', type='http', cors='*', csrf=False, methods=['POST'])
def l10n_ke_cu_send(self, messages, company_vat):
""" Posts the messages sent to this endpoint to the fiscal device connected to the server
:param messages: The messages (consisting of <CMD> and <DATA>) to
send to the fiscal device.
:returns: Dictionary containing a list of the responses from
fiscal device and status of the fiscal device.
"""
device = TremolG03Driver.get_default_device()
if device:
# First run the command to get the fiscal device numbers
device_numbers = device.send([b'\x60'])
# If the vat doesn't match, abort
if device_numbers['status'] != 'ok':
return device_numbers
serial_number, device_vat, _dummy = device_numbers['replies'][0].split(';')
if device_vat != company_vat:
return json.dumps({'status': 'The company vat number does not match that of the device'})
messages = json.loads(messages)
device.message_number = 0
resp = json.dumps({**device.send([msg.encode('cp1251') for msg in messages]), 'serial_number': serial_number})
return resp
else:
return json.dumps({'status': 'The fiscal device is not connected to the proxy server'})

View file

@ -0,0 +1,273 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b64decode
from cups import IPPError, IPP_JOB_COMPLETED, IPP_JOB_PROCESSING, IPP_JOB_PENDING, CUPS_FORMAT_AUTO
from escpos import printer
from escpos.escpos import EscposIO
import escpos.exceptions
import logging
import netifaces as ni
import time
from odoo import http
from odoo.addons.iot_drivers.connection_manager import connection_manager
from odoo.addons.iot_drivers.controllers.proxy import proxy_drivers
from odoo.addons.iot_drivers.iot_handlers.drivers.printer_driver_base import PrinterDriverBase
from odoo.addons.iot_drivers.iot_handlers.interfaces.printer_interface_L import conn, cups_lock
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.tools import helpers, wifi, route
_logger = logging.getLogger(__name__)
class PrinterDriver(PrinterDriverBase):
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_connection = device['device-class'].lower()
self.receipt_protocol = 'star' if 'STR_T' in device['device-id'] else 'escpos'
self.connected_by_usb = self.device_connection == 'direct'
self.device_name = device['device-make-and-model']
self.ip = device.get('ip')
if any(cmd in device['device-id'] for cmd in ['CMD:STAR;', 'CMD:ESC/POS;']):
self.device_subtype = "receipt_printer"
elif any(cmd in device['device-id'] for cmd in ['COMMAND SET:ZPL;', 'CMD:ESCLABEL;']):
self.device_subtype = "label_printer"
else:
self.device_subtype = "office_printer"
if self.device_subtype == "receipt_printer" and self.receipt_protocol == 'escpos':
self._init_escpos(device)
self.print_status()
def _init_escpos(self, device):
if device.get('usb_product'):
def usb_matcher(usb_device):
return (
usb_device.manufacturer and usb_device.manufacturer.lower() == device['usb_manufacturer'] and
usb_device.product == device['usb_product'] and
usb_device.serial_number == device['usb_serial_number']
)
self.escpos_device = printer.Usb(usb_args={"custom_match": usb_matcher})
elif device.get('ip'):
self.escpos_device = printer.Network(device['ip'], timeout=5)
else:
return
try:
self.escpos_device.open()
self.escpos_device.set_with_default(align='center')
self.escpos_device.close()
except escpos.exceptions.Error as e:
_logger.info("%s - Could not initialize escpos class: %s", self.device_name, e)
self.escpos_device = None
@classmethod
def supported(cls, device):
return True
def disconnect(self):
self.send_status('disconnected', 'Printer was disconnected')
super().disconnect()
def print_raw(self, data):
"""Print raw data to the printer
:param data: The data to print
"""
if not self.check_printer_status():
return
try:
with cups_lock:
job_id = conn.createJob(self.device_identifier, 'Odoo print job', {'document-format': CUPS_FORMAT_AUTO})
conn.startDocument(self.device_identifier, job_id, 'Odoo print job', CUPS_FORMAT_AUTO, 1)
conn.writeRequestData(data, len(data))
conn.finishDocument(self.device_identifier)
self.job_ids.append(job_id)
except IPPError:
_logger.exception("Printing failed")
self.send_status(status='error', message='ERROR_FAILED')
@classmethod
def format_star(cls, 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
@classmethod
def _get_iot_status(cls):
identifier = helpers.get_identifier()
mac_address = helpers.get_mac_address()
pairing_code = connection_manager.pairing_code
ssid = wifi.get_access_point_ssid() if wifi.is_access_point() else wifi.get_current()
ips = []
for iface_id in ni.interfaces():
iface_obj = ni.ifaddresses(iface_id)
ifconfigs = iface_obj.get(ni.AF_INET, [])
for conf in ifconfigs:
if 'addr' in conf and conf['addr'] not in ['127.0.0.1', '10.11.12.1']:
ips.append(conf['addr'])
return {"identifier": identifier, "mac_address": mac_address, "pairing_code": pairing_code, "ssid": ssid, "ips": ips}
def print_status(self, data=None):
"""Prints the status ticket of the IoT Box on the current printer.
:param data: If not None, it means that it has been called from the action route, meaning
that no matter the connection type, the printer should print the status ticket.
"""
if not self.connected_by_usb and not data:
return
if self.device_subtype == "receipt_printer":
self.print_status_receipt()
elif self.device_subtype == "label_printer":
self.print_status_zpl()
else:
title, body = self._printer_status_content()
self.print_raw(title + b'\r\n' + body.decode().replace('\n', '\r\n').encode())
def print_status_receipt(self):
"""Prints the status ticket of the IoT Box on the current printer."""
title, body = self._printer_status_content()
commands = self.RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
if self.escpos_device:
try:
with EscposIO(self.escpos_device) as dev:
dev.printer.set(align='center', double_height=True, double_width=True)
dev.printer.textln(title.decode())
dev.printer.set_with_default(align='center', double_height=False, double_width=False)
dev.writelines(body.decode())
dev.printer.qr(f"http://{helpers.get_ip()}", size=6)
return
except (escpos.exceptions.Error, OSError, AssertionError):
_logger.warning("Failed to print QR status receipt, falling back to simple receipt")
title = commands['title'] % title
self.print_raw(commands['center'] + title + b'\n' + body + commands['cut'])
def print_status_zpl(self):
iot_status = self._get_iot_status()
title = "IoT Box Connected" if helpers.get_odoo_server_url() else "IoT Box Status"
command = f"^XA^CI28 ^FT35,40 ^A0N,30 ^FD{title}^FS"
p = 85
if iot_status["pairing_code"]:
command += f"^FT35,{p} ^A0N,25 ^FDGo to the IoT app, click \"Connect\",^FS"
p += 35
command += f"^FT35,{p} ^A0N,25 ^FDPairing code: {iot_status['pairing_code']}^FS"
p += 35
if iot_status["ssid"]:
command += f"^FT35,{p} ^A0N,25 ^FDWi-Fi: {iot_status['ssid']}^FS"
p += 35
if iot_status["identifier"]:
command += f"^FT35,{p} ^A0N,25 ^FDIdentifier: {iot_status['identifier']}^FS"
p += 35
if iot_status["ips"]:
command += f"^FT35,{p} ^A0N,25 ^FDIP: {', '.join(iot_status['ips'])}^FS"
p += 35
command += "^XZ"
self.print_raw(command.encode())
def _printer_status_content(self):
"""Formats the status information of the IoT Box into a title and a body.
:return: The title and the body of the status ticket
:rtype: tuple of bytes
"""
wlan = identifier = homepage = pairing_code = mac_address = ""
iot_status = self._get_iot_status()
if iot_status["pairing_code"]:
pairing_code = (
'\nOdoo not connected\n'
'Go to the IoT app, click "Connect",\n'
'Pairing Code: %s\n' % iot_status["pairing_code"]
)
if iot_status['ssid']:
wlan = '\nWireless network:\n%s\n' % iot_status["ssid"]
ips = iot_status["ips"]
if len(ips) == 0:
ip = (
"\nERROR: Could not connect to LAN\n\nPlease check that the IoT Box 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 = '\nIoT Box IP Address:\n%s\n' % ips[0]
else:
ip = '\nIoT Box IP Addresses:\n%s\n' % '\n'.join(ips)
if len(ips) >= 1:
identifier = '\nIdentifier:\n%s\n' % iot_status["identifier"]
mac_address = '\nMac Address:\n%s\n' % iot_status["mac_address"]
homepage = '\nIoT Box Homepage:\nhttp://%s:8069\n' % ips[0]
title = b'IoT Box Connected' if helpers.get_odoo_server_url() else b'IoT Box Status'
body = pairing_code + wlan + identifier + mac_address + ip + homepage
return title, body.encode()
def _action_default(self, data):
_logger.debug("_action_default called for printer %s", self.device_name)
self.print_raw(b64decode(data['document']))
return {'print_id': data['print_id']} if 'print_id' in data else {}
def _cancel_job_with_error(self, job_id, error_message):
self.job_ids.remove(job_id)
conn.cancelJob(job_id)
self.send_status(status='error', message=error_message)
def _check_job_status(self, job_id):
try:
with cups_lock:
job = conn.getJobAttributes(job_id, requested_attributes=['job-state', 'job-state-reasons', 'job-printer-state-message', 'time-at-creation'])
_logger.debug("job details for job id #%d: %s", job_id, job)
job_state = job['job-state']
if job_state == IPP_JOB_COMPLETED:
self.job_ids.remove(job_id)
self.send_status(status='success')
# Generic timeout, e.g. USB printer has been unplugged
elif job['time-at-creation'] + self.job_timeout_seconds < time.time():
self._cancel_job_with_error(job_id, 'ERROR_TIMEOUT')
# Cannot reach network printer
elif job_state == IPP_JOB_PROCESSING and 'printer is unreachable' in job.get('job-printer-state-message', ''):
self._cancel_job_with_error(job_id, 'ERROR_UNREACHABLE')
# Any other failure state
elif job_state not in [IPP_JOB_PROCESSING, IPP_JOB_PENDING]:
self._cancel_job_with_error(job_id, 'ERROR_UNKNOWN')
except IPPError:
_logger.exception('IPP error occurred while fetching CUPS jobs')
self.job_ids.remove(job_id)
class PrinterController(http.Controller):
@route.iot_route('/hw_proxy/default_printer_action', type='jsonrpc', 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

View file

@ -0,0 +1,161 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from base64 import b64decode
from datetime import datetime, timezone
from escpos import printer
import escpos.exceptions
import io
import win32print
import pywintypes
import ghostscript
from odoo.addons.iot_drivers.controllers.proxy import proxy_drivers
from odoo.addons.iot_drivers.iot_handlers.drivers.printer_driver_base import PrinterDriverBase
from odoo.addons.iot_drivers.tools import helpers
from odoo.tools.mimetypes import guess_mimetype
from odoo.addons.iot_drivers.iot_handlers.interfaces.printer_interface_W import win32print_lock
_logger = logging.getLogger(__name__)
class PrinterDriver(PrinterDriverBase):
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_connection = self._compute_device_connection(device)
self.device_name = device.get('identifier')
self.printer_handle = device.get('printer_handle')
self.receipt_protocol = 'escpos'
if any(cmd in device['identifier'] for cmd in ['STAR', 'Receipt']):
self.device_subtype = "receipt_printer"
elif "ZPL" in device['identifier']:
self.device_subtype = "label_printer"
else:
self.device_subtype = "office_printer"
if self.device_subtype == "receipt_printer" and self.receipt_protocol == 'escpos':
self._init_escpos(device)
def _init_escpos(self, device):
if self.device_connection != 'network':
return
self.escpos_device = printer.Network(device['port'], timeout=5)
try:
self.escpos_device.open()
self.escpos_device.close()
except escpos.exceptions.Error:
_logger.exception("Could not initialize escpos class")
self.escpos_device = None
@classmethod
def supported(cls, device):
return True
@staticmethod
def _compute_device_connection(device):
return 'direct' if device['port'].startswith(('USB', 'TMUSB', 'COM', 'LPT')) else 'network'
def disconnect(self):
self.send_status('disconnected', 'Printer was disconnected')
super().disconnect()
def print_raw(self, data):
if not self.check_printer_status():
return
with win32print_lock:
job_id = win32print.StartDocPrinter(self.printer_handle, 1, ('', None, "RAW"))
win32print.StartPagePrinter(self.printer_handle)
win32print.WritePrinter(self.printer_handle, data)
win32print.EndPagePrinter(self.printer_handle)
win32print.EndDocPrinter(self.printer_handle)
self.job_ids.append(job_id)
def print_report(self, data):
with win32print_lock:
helpers.write_file('document.pdf', data, 'wb')
file_name = helpers.path_file('document.pdf')
printer = self.device_name
args = [
"-dPrinted", "-dBATCH", "-dNOPAUSE", "-dNOPROMPT",
"-q",
"-sDEVICE#mswinpr2",
f'-sOutputFile#%printer%{printer}',
f'{file_name}'
]
_logger.debug("Printing report with ghostscript using %s", args)
stderr_buf = io.BytesIO()
stdout_buf = io.BytesIO()
stdout_log_level = logging.DEBUG
try:
ghostscript.Ghostscript(*args, stdout=stdout_buf, stderr=stderr_buf)
self.send_status(status='success')
except Exception:
_logger.exception("Error while printing report, ghostscript args: %s, error buffer: %s", args, stderr_buf.getvalue())
stdout_log_level = logging.ERROR # some stdout value might contains relevant error information
self.send_status(status='error', message='ERROR_FAILED')
raise
finally:
_logger.log(stdout_log_level, "Ghostscript stdout: %s", stdout_buf.getvalue())
def _action_default(self, data):
_logger.debug("_action_default called for printer %s", self.device_name)
document = b64decode(data['document'])
mimetype = guess_mimetype(document)
if mimetype == 'application/pdf':
self.print_report(document)
else:
self.print_raw(document)
_logger.debug("_action_default finished with mimetype %s for printer %s", mimetype, self.device_name)
return {'print_id': data['print_id']} if 'print_id' in data else {}
def print_status(self, _data=None):
"""Prints the status ticket of the IoT Box on the current printer.
:param _data: dict provided by the action route
"""
if self.device_subtype == "receipt_printer":
commands = self.RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
self.print_raw(commands['center'] + (commands['title'] % b'IoT Box Test Receipt') + commands['cut'])
elif self.device_type == "label_printer":
self.print_raw("^XA^CI28 ^FT35,40 ^A0N,30 ^FDIoT Box Test Label^FS^XZ".encode()) # noqa: UP012
else:
self.print_raw("IoT Box Test Page".encode()) # noqa: UP012
def _cancel_job_with_error(self, job_id, error_message):
self.job_ids.remove(job_id)
win32print.SetJob(self.printer_handle, job_id, 0, None, win32print.JOB_CONTROL_DELETE)
self.send_status(status='error', message=error_message)
def _check_job_status(self, job_id):
try:
job = win32print.GetJob(self.printer_handle, job_id, win32print.JOB_INFO_1)
elapsed_time = datetime.now(timezone.utc) - job['Submitted']
_logger.debug('job details for job id #%d: %s', job_id, job)
if job['Status'] & win32print.JOB_STATUS_PRINTED:
self.job_ids.remove(job_id)
self.send_status(status='success')
# Print timeout, e.g. network printer is disconnected
if elapsed_time.seconds > self.job_timeout_seconds:
self._cancel_job_with_error(job_id, 'ERROR_TIMEOUT')
# Generic error, e.g. USB printer is not connected
elif job['Status'] & win32print.JOB_STATUS_ERROR:
self._cancel_job_with_error(job_id, 'ERROR_UNKNOWN')
except pywintypes.error as error:
# GetJob returns error 87 (incorrect parameter) if the print job doesn't exist.
# Windows deletes print jobs on completion, so this actually means the print
# was succcessful.
if error.winerror == 87:
self.send_status(status='success')
else:
_logger.exception('Win32 error occurred while querying print job')
self.job_ids.remove(job_id)
proxy_drivers['printer'] = PrinterDriver

View file

@ -0,0 +1,277 @@
from abc import ABC, abstractmethod
from base64 import b64decode
from escpos.escpos import EscposIO
import escpos.printer
import escpos.exceptions
import io
import logging
from PIL import Image, ImageOps
import re
import time
from odoo.addons.iot_drivers.driver import Driver
from odoo.addons.iot_drivers.main import iot_devices
from odoo.addons.iot_drivers.event_manager import event_manager
_logger = logging.getLogger(__name__)
def _read_escpos_with_retry(self):
"""Replaces the python-escpos read function to perform repeated reads.
This is necessary due to an issue when using USB, where it takes several
reads to get the answer of the command that has just been sent.
"""
assert self.device
for attempt in range(5):
if result := self.device.read(self.in_ep, 16):
return result
time.sleep(0.05)
_logger.debug("Read attempt %s failed", attempt)
# Monkeypatch the USB printer read method with our retrying version
escpos.printer.Usb._read = _read_escpos_with_retry
class PrinterDriverBase(Driver, ABC):
connection_type = 'printer'
job_timeout_seconds = 30
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 __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'printer'
self.job_ids = []
self.escpos_device = None
self._actions.update({
'cashbox': self.open_cashbox,
'print_receipt': self.print_receipt,
'status': self.print_status,
'': self._action_default,
})
@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 send_status(self, status, message=None):
"""Sends a status update event for the printer.
:param str status: The value of the status
:param str message: A comprehensive message describing the status
"""
self.data['status'] = status
self.data['message'] = message
event_manager.device_changed(self, {'session_id': self.data.get('owner')})
def print_receipt(self, data):
_logger.debug("print_receipt called for printer %s", self.device_name)
receipt = b64decode(data['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)
@classmethod
def format_escpos_bit_image_raster(cls, im):
""" prints with the `GS v 0`-command """
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 + cls.RECEIPT_PRINTER_COMMANDS['escpos']['cut']
@classmethod
def extract_columns_from_picture(cls, im, line_height):
# Code inspired from python esc pos library:
# https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/image.py#L73-L89
width_pixels, height_pixels = im.size
for left in range(0, width_pixels, line_height):
box = (left, 0, left + line_height, height_pixels)
im_chunk = im.transform((line_height, height_pixels), Image.EXTENT, box)
yield im_chunk.tobytes()
def format_escpos_bit_image_column(
self, im, high_density_vertical=True, high_density_horizontal=True, size_scale=100
):
"""Prints with the `ESC *`-command
reference: https://reference.epson-biz.com/modules/ref_escpos/index.php?content_id=88
:param im: PIL image
:param high_density_vertical: high density in vertical direction
:param high_density_horizontal: high density in horizontal direction
:param size_scale: picture scale in percentage,
e.g: 50 -> half the size (horizontally and vertically)
"""
size_scale_ratio = size_scale / 100
size_scale_width = int(im.width * size_scale_ratio)
size_scale_height = int(im.height * size_scale_ratio)
im = im.resize((size_scale_width, size_scale_height))
# escpos ESC * command print column per column
# (instead of usual row by row).
# So we transpose the picture to ease the calculations
im = im.transpose(Image.ROTATE_270).transpose(Image.FLIP_LEFT_RIGHT)
# Most of the code here is inspired from python escpos library
# https://github.com/python-escpos/python-escpos/blob/4a0f5855ef118a2009b843a3a106874701d8eddf/src/escpos/escpos.py#L237C9-L251
ESC = b'\x1b'
density_byte = (1 if high_density_horizontal else 0) + \
(32 if high_density_vertical else 0)
nL = im.height & 0xFF
nH = (im.height >> 8) & 0xFF
HEADER = ESC + b'*' + bytes([density_byte, nL, nH])
raster_data = ESC + b'3\x10' # Adjust line-feed size
line_height = 24 if high_density_vertical else 8
for column in self.extract_columns_from_picture(im, line_height):
raster_data += HEADER + column + b'\n'
raster_data += ESC + b'2' # Reset line-feed size
return raster_data + self.RECEIPT_PRINTER_COMMANDS['escpos']['cut']
def format_escpos(self, im):
# Epson support different command to print pictures.
# We use by default "GS v 0", but it is incompatible with certain
# printer models (like TM-U2x0)
# As we are pretty limited in the information that we have, we will
# use the printer name to parse some configuration value
# Printer name examples:
# EpsonTMM30
# -> Print using raster mode
# TM-U220__IMC_LDV_LDH_SCALE70__
# -> Print using column bit image mode (without vertical and
# horizontal density and a scale of 70%)
# Default image printing mode
image_mode = 'raster'
options_str = self.device_name.split('__')
option_str = ""
if len(options_str) > 2:
option_str = options_str[1].upper()
if option_str.startswith('IMC'):
image_mode = 'column'
if image_mode == 'raster':
return self.format_escpos_bit_image_raster(im)
# Default printing mode parameters
high_density_vertical = True
high_density_horizontal = True
scale = 100
# Parse the printer name to get the needed parameters
# The separator need to not be filtered by `get_identifier`
options = option_str.split('_')
for option in options:
if option == 'LDV':
high_density_vertical = False
elif option == 'LDH':
high_density_horizontal = False
elif option.startswith('SCALE'):
scale_value_str = re.search(r'\d+$', option)
if scale_value_str is not None:
scale = int(scale_value_str.group())
else:
raise ValueError("Missing printer SCALE parameter integer value in option: " + option)
return self.format_escpos_bit_image_column(im, high_density_vertical, high_density_horizontal, scale)
def open_cashbox(self, data):
"""Sends a signal to the current printer to open the connected cashbox."""
_logger.debug("open_cashbox called for printer %s", self.device_name)
commands = self.RECEIPT_PRINTER_COMMANDS[self.receipt_protocol]
for drawer in commands['drawers']:
self.print_raw(drawer)
def check_printer_status(self):
if not self.escpos_device:
return True
try:
with EscposIO(self.escpos_device, autocut=False) as esc:
esc.printer.open()
if not esc.printer.is_online():
self.send_status(status='error', message='ERROR_OFFLINE')
return False
paper_status = esc.printer.paper_status()
if paper_status == 0:
self.send_status(status='error', message='ERROR_NO_PAPER')
return False
elif paper_status == 1:
self.send_status(status='warning', message='WARNING_LOW_PAPER')
except (escpos.exceptions.Error, OSError, AssertionError):
self.escpos_device = None
_logger.warning("Failed to query ESC/POS status")
return True
def run(self):
while True:
# We monitor ongoing jobs by polling them every second.
# Ideally we would receive events instead of polling, but unfortunately CUPS
# events do not trigger with all printers, and win32print has no event mechanism.
for job_id in self.job_ids:
self._check_job_status(job_id)
time.sleep(1)
@abstractmethod
def print_raw(self, data):
"""Sends the raw data to the printer.
:param data: The data to send to the printer
"""
pass
@abstractmethod
def print_status(self, data):
"""Method called to test a printer, printing a status page."""
pass
@abstractmethod
def _action_default(self, data):
"""Action called when no action name is provided in the action data."""
pass
@abstractmethod
def _check_job_status(self, job_id):
"""Method called to poll the status of a print job."""
pass

View file

@ -0,0 +1,161 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from typing import NamedTuple
from contextlib import contextmanager
import logging
import serial
from threading import Lock
import time
import traceback
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.driver import Driver
_logger = logging.getLogger(__name__)
class SerialProtocol(NamedTuple):
name: any
baudrate: any
bytesize: any
stopbits: any
parity: any
timeout: any
writeTimeout: any
measureRegexp: any
statusRegexp: any
commandTerminator: any
commandDelay: any
measureDelay: any
newMeasureDelay: any
measureCommand: any
emptyAnswerValid: any
@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'
STATUS_DISCONNECTED = 'disconnected'
def __init__(self, identifier, device):
""" Attributes initialization method for `SerialDriver`.
:param device: path to the device
:type device: str
"""
super().__init__(identifier, device)
self._actions.update({
'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
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: # noqa: BLE001
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
"""
with self._device_lock:
try:
self._actions[data['action']](data)
time.sleep(self._protocol.commandDelay)
except Exception:
msg = f'An error occurred while performing action "{data}" on "{self.device_name}"'
_logger.exception(msg)
self._status = {'status': self.STATUS_ERROR, 'message_title': msg, 'message_body': traceback.format_exc()}
self._push_status()
self._status = {'status': self.STATUS_CONNECTED, 'message_title': '', 'message_body': ''}
self.data['status'] = self._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
"""
self.data["owner"] = data.get('session_id')
self.data["action_args"] = {**data}
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)
event_manager.device_changed(self, data) # Make response available to /event route or websocket
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.is_set():
self._take_measure()
time.sleep(self._protocol.newMeasureDelay)
self._status['status'] = self.STATUS_DISCONNECTED
self._push_status()
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()

View file

@ -0,0 +1,218 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
import serial
import threading
import time
from odoo import http
from odoo.addons.iot_drivers.controllers.proxy import proxy_drivers
from odoo.addons.iot_drivers.event_manager import event_manager
from odoo.addons.iot_drivers.iot_handlers.drivers.serial_base_driver import SerialDriver, SerialProtocol, serial_connection
_logger = logging.getLogger(__name__)
# Only needed to expose scale via hw_proxy (used by Community edition)
ACTIVE_SCALE = None
new_weight_event = threading.Event()
# 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 = SerialProtocol(
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*\\?([^\x00])\\r",
commandDelay=0.2,
measureDelay=0.5,
newMeasureDelay=0.2,
commandTerminator=b'',
measureCommand=b'W',
emptyAnswerValid=False,
)
# HW Proxy is used by Community edition
class ScaleReadHardwareProxy(http.Controller):
@http.route('/hw_proxy/scale_read', type='jsonrpc', auth='none', cors='*')
def scale_read(self):
if ACTIVE_SCALE:
return {'weight': ACTIVE_SCALE._scale_read_hw_proxy()}
return None
class ScaleDriver(SerialDriver):
"""Abstract base class for scale drivers."""
last_sent_value = None
def __init__(self, identifier, device):
super().__init__(identifier, device)
self.device_type = 'scale'
self._set_actions()
self._is_reading = True
# The HW Proxy can only expose one scale,
# only the last scale connected is kept
global ACTIVE_SCALE # noqa: PLW0603
ACTIVE_SCALE = self
proxy_drivers['scale'] = ACTIVE_SCALE
# Used by the HW Proxy in Community edition
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,
'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 _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['result']
@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 = {
'result': float(match.group(1)),
'status': self._status
}
else:
self._read_status(answer)
# Ensures compatibility with Community edition
def _scale_read_hw_proxy(self):
"""Used when the iot app is not installed"""
with self._device_lock:
self._read_weight()
return self.data['result']
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['result'] != self.last_sent_value or self._status['status'] == self.STATUS_ERROR:
self.last_sent_value = self.data['result']
event_manager.device_changed(self)
class Toledo8217Driver(ScaleDriver):
"""Driver for the Toldedo 8217 serial scale."""
_protocol = Toledo8217Protocol
def __init__(self, identifier, device):
super().__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.reset_input_buffer()
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)
connection.reset_input_buffer()
return True
except serial.serialutil.SerialTimeoutException:
pass
except Exception:
_logger.exception('Error while probing %s with protocol %s', device, protocol.name)
return False
def _read_status(self, answer):
"""
Status byte in form of an ascii character (Ex: 'D') is sent if scale is in motion, or is net/gross weight is negative or over capacity.
Convert the status byte to a binary string, and check its bits to see if there is an error.
LSB is the last char so the binary string is read in reverse and the first char is a parity bit, so we ignore it.
:param answer: scale answer (Example: b'\x02?D\r')
:type answer: bytestring
"""
status_char_error_bits = (
'Scale in motion', # 0
'Over capacity', # 1
'Under zero', # 2
'Outside zero capture range', # 3
'Center of zero', # 4
'Net weight', # 5
'Bad Command from host', # 6
)
status_match = self._protocol.statusRegexp and re.search(self._protocol.statusRegexp, answer)
if status_match:
status_char = status_match.group(1).decode() # Example: b'D' extracted from b'\x02?D\r'
binary_status_char = format(ord(status_char), '08b') # Example: '00001101'
for index, bit in enumerate(binary_status_char[1:][::-1]): # Read the bits in reverse order (LSB is at the last char) + ignore the first "parity" bit
if int(bit):
_logger.debug("Scale error: %s. Status string: %s. Scale answer: %s.", status_char_error_bits[index], binary_status_char, answer)
self.data = {
'result': 0,
'status': self._status,
}
break

View file

@ -0,0 +1,39 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import re
import subprocess
from odoo.addons.iot_drivers.interface import Interface
_logger = logging.getLogger(__name__)
class DisplayInterface(Interface):
_loop_delay = 3
connection_type = 'display'
def get_devices(self):
randr_result = subprocess.run(['wlr-randr'], capture_output=True, text=True, check=False)
if randr_result.returncode != 0:
return {}
displays = re.findall(r"\((HDMI-A-\d)\)", randr_result.stdout)
return {
monitor: self._add_device(monitor, x_screen)
for x_screen, monitor in enumerate(displays)
}
@classmethod
def _add_device(cls, display_identifier, x_screen):
"""Creates a display_device dict.
:param display_identifier: the identifier of the display
:param x_screen: the x screen number
:return: the display device dict
"""
return {
'identifier': display_identifier,
'name': 'Display - ' + display_identifier,
'x_screen': str(x_screen),
}

View file

@ -0,0 +1,245 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from cups import Connection as CupsConnection, IPPError
from itertools import groupby
from threading import Lock
from urllib.parse import urlsplit, parse_qs, unquote
from zeroconf import (
IPVersion,
ServiceBrowser,
ServiceStateChange,
Zeroconf,
)
import logging
import pyudev
import re
import time
from odoo.addons.iot_drivers.interface import Interface
from odoo.addons.iot_drivers.main import iot_devices
_logger = logging.getLogger(__name__)
conn = CupsConnection()
PPDs = conn.getPPDs()
cups_lock = Lock() # We can only make one call to Cups at a time
class PrinterInterface(Interface):
connection_type = 'printer'
_loop_delay = 20 # Default delay between calls to get_devices
def __init__(self):
super().__init__()
self.start_time = time.time()
def get_devices(self):
discovered_devices = {}
with cups_lock:
printers = conn.getPrinters()
devices = conn.getDevices()
# get and adjust configuration of printers already added in cups
for printer_name, printer in printers.items():
path = printer.get('device-uri')
if path and printer_name != self.get_identifier(path):
device_class = 'direct' if 'usb' in path else 'network'
printer.update({
'already-configured': True,
'device-class': device_class,
'device-make-and-model': printer_name, # give name set in Cups
'device-id': '',
})
devices.update({printer_name: printer})
# filter devices (both added and not added in cups) to show as detected by the IoT Box
for path, device in devices.items():
identifier, device = self.process_device(path, device)
url_is_supported = any(protocol in device["url"] for protocol in ['dnssd', 'lpd', 'socket'])
model_is_valid = device["device-make-and-model"] != "Unknown"
printer_is_usb = "direct" in device["device-class"]
if (url_is_supported and model_is_valid) or printer_is_usb:
discovered_devices.update({identifier: device})
if not device.get("already-configured"):
self.set_up_printer_in_cups(device)
# Let get_devices be called again every 20 seconds (get_devices of PrinterInterface
# takes between 4 and 15 seconds) but increase the delay to 2 minutes if it has been
# running for more than 1 hour
if self.start_time and time.time() - self.start_time > 3600:
self._loop_delay = 120
self.start_time = None # Reset start_time to avoid changing the loop delay again
return self.deduplicate_printers(discovered_devices)
def process_device(self, path, device):
identifier = self.get_identifier(path)
device.update({
'identifier': identifier,
'url': path,
})
if device['device-class'] == 'direct':
device.update(self.get_usb_info(path))
elif device['device-class'] == 'network':
device['ip'] = self.get_ip(path)
return identifier, device
def get_identifier(self, path):
"""
Necessary because the path is not always a valid Cups identifier,
as it may contain characters typically found in URLs or paths.
- Removes characters: ':', '/', '.', '\', and space.
- Removes the exact strings: "uuid=" and "serial=".
Example 1:
Input: "ipp://printers/printer1:1234/abcd"
Output: "ippprintersprinter11234abcd"
Example 2:
Input: "uuid=1234-5678-90ab-cdef"
Output: "1234-5678-90ab-cdef
"""
return re.sub(r'[:\/\.\\ ]|(uuid=)|(serial=)', '', path)
def get_ip(self, device_path):
hostname = urlsplit(device_path).hostname
if hostname and hostname.endswith(".local"):
zeroconf_name = unquote(hostname.lower()) + "."
if zeroconf_name in self.printer_ip_map:
return self.printer_ip_map[zeroconf_name]
return hostname
@staticmethod
def get_usb_info(device_path):
parsed_url = urlsplit(device_path)
parsed_query = parse_qs(parsed_url.query)
manufacturer = parsed_url.hostname
product = parsed_url.path.removeprefix("/")
serial = parsed_query["serial"][0] if "serial" in parsed_query else None
if manufacturer and product and serial:
return {
"usb_manufacturer": manufacturer,
"usb_product": product,
"usb_serial_number": serial,
}
else:
return {}
@staticmethod
def deduplicate_printers(discovered_printers):
result = []
sorted_printers = sorted(discovered_printers.values(), key=lambda printer: str(printer.get('ip')))
for ip, printers_with_same_ip in groupby(sorted_printers, lambda printer: printer.get('ip')):
already_registered_identifier = next((
identifier for identifier, device in iot_devices.items()
if device.device_type == 'printer' and ip and ip == device.ip
), None)
if already_registered_identifier:
result.append({'identifier': already_registered_identifier})
continue
printers_with_same_ip = sorted(printers_with_same_ip, key=lambda printer: printer['identifier'])
if ip is None or len(printers_with_same_ip) == 1:
result += printers_with_same_ip
continue
chosen_printer = next((
printer for printer in printers_with_same_ip
if 'CMD:' in printer['device-id'] or 'ZPL' in printer['device-id']
), None)
if not chosen_printer:
chosen_printer = printers_with_same_ip[0]
result.append(chosen_printer)
return {printer['identifier']: printer for printer in result}
def monitor_for_printers(self):
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by('usb')
def on_device_change(udev_device):
if udev_device.action != 'add' or udev_device.driver != 'usblp':
return
try:
device_id = udev_device.attributes.asstring('ieee1284_id')
manufacturer = udev_device.parent.attributes.asstring('manufacturer')
product = udev_device.parent.attributes.asstring('product')
serial = udev_device.parent.attributes.asstring('serial')
except KeyError as err:
_logger.warning("Could not hotplug printer, field '%s' is not present", err.args[0])
return
path = f"usb://{manufacturer}/{product}?serial={serial}"
iot_device = {
'device-class': 'direct',
'device-make-and-model': f'{manufacturer} {product}',
'device-id': device_id,
}
identifier, iot_device = self.process_device(path, iot_device)
self.add_device(identifier, iot_device)
observer = pyudev.MonitorObserver(monitor, callback=on_device_change)
observer.start()
def start_zeroconf_listener(self):
self.printer_ip_map = {}
service_types = [
"_printer._tcp.local.",
"_pdl-datastream._tcp.local.",
"_ipp._tcp.local.",
"_ipps._tcp.local.",
]
def on_service_change(zeroconf, service_type, name, state_change):
if state_change is not ServiceStateChange.Added:
return
info = zeroconf.get_service_info(service_type, name)
if info and info.addresses:
address = info.parsed_addresses(IPVersion.V4Only)[0]
self.printer_ip_map[name.lower()] = address
zeroconf = Zeroconf(ip_version=IPVersion.V4Only)
self.zeroconf_browser = ServiceBrowser(zeroconf, service_types, handlers=[on_service_change])
@staticmethod
def set_up_printer_in_cups(device):
"""Configure detected printer in cups: ppd files, name, info, groups, ...
:param dict device: printer device to configure in cups (detected but not added)
"""
fallback_model = device.get('device-make-and-model', "")
model = next((
device_id.split(":")[1] for device_id in device.get('device-id', "").split(";")
if any(key in device_id for key in ['MDL', 'MODEL'])
), fallback_model)
model = re.sub(r"[\(].*?[\)]", "", model).strip()
ppdname_argument = next(({"ppdname": ppd} for ppd in PPDs if model and model in PPDs[ppd]['ppd-product']), {})
try:
with cups_lock:
conn.addPrinter(name=device['identifier'], device=device['url'], **ppdname_argument)
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")
except IPPError:
_logger.exception("Failed to add printer '%s'", device['identifier'])
def start(self):
super().start()
self.start_zeroconf_listener()
self.monitor_for_printers()

View file

@ -0,0 +1,46 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from threading import Lock
import win32print
from odoo.addons.iot_drivers.interface import Interface
_logger = logging.getLogger(__name__)
win32print_lock = Lock() # Calling win32print in parallel can cause failed prints
class PrinterInterface(Interface):
_loop_delay = 30
connection_type = 'printer'
def get_devices(self):
printer_devices = {}
with win32print_lock:
printers = win32print.EnumPrinters(win32print.PRINTER_ENUM_LOCAL)
for printer in printers:
identifier = printer[2]
handle_printer = win32print.OpenPrinter(identifier)
# The value "2" is the level of detail we want to get from the printer, see:
# https://learn.microsoft.com/en-us/windows/win32/printdocs/getprinter#parameters
printer_details = win32print.GetPrinter(handle_printer, 2)
printer_port = None
if printer_details:
# see: https://learn.microsoft.com/en-us/windows/win32/printdocs/printer-info-2#members
printer_port = printer_details.get('pPortName')
if printer_port is None:
_logger.warning('Printer "%s" has no port name. Used dummy port', identifier)
printer_port = 'IOT_DUMMY_PORT'
if printer_port == "PORTPROMPT:":
# discard virtual printers (like "Microsoft Print to PDF") as they will trigger dialog boxes prompt
continue
printer_devices[identifier] = {
'identifier': identifier,
'printer_handle': handle_printer,
'port': printer_port,
}
return printer_devices

View file

@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from serial.tools.list_ports import comports
from odoo.addons.iot_drivers.tools.system import IS_WINDOWS
from odoo.addons.iot_drivers.interface import Interface
class SerialInterface(Interface):
connection_type = 'serial'
allow_unsupported = True
def get_devices(self):
serial_devices = {
port.device: {'identifier': port.device}
for port in comports()
if IS_WINDOWS or port.subsystem != 'amba'
# RPI 5 uses ttyAMA10 as a console serial port for system messages: odoo interprets it as scale -> avoid it
}
return serial_devices

View file

@ -0,0 +1,48 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from usb import core
from odoo.addons.iot_drivers.interface import Interface
class USBInterface(Interface):
connection_type = 'usb'
allow_unsupported = True
@staticmethod
def usb_matcher(dev):
# USB Class codes documentation: https://www.usb.org/defined-class-codes
# Ignore USB hubs (9) and printers (7)
if dev.bDeviceClass in [7, 9]:
return False
# If the device has generic base class (0) check its interface descriptor
elif dev.bDeviceClass == 0:
for conf in dev:
for interface in conf:
if interface.bInterfaceClass == 7: # 7 = printer
return False
# Ignore serial adapters
try:
return dev.product != "USB2.0-Ser!"
except ValueError:
return True
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, custom_match=self.usb_matcher)
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

View file

@ -0,0 +1,181 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import requests
import schedule
import subprocess
from threading import Thread
import time
from odoo.addons.iot_drivers.tools import certificate, helpers, upgrade, wifi
from odoo.addons.iot_drivers.tools.system import IS_RPI
from odoo.addons.iot_drivers.websocket_client import WebsocketClient
if IS_RPI:
from dbus.mainloop.glib import DBusGMainLoop
DBusGMainLoop(set_as_default=True) # Must be started from main thread
_logger = logging.getLogger(__name__)
drivers = []
interfaces = {}
iot_devices = {}
unsupported_devices = {}
class Manager(Thread):
daemon = True
ws_channel = ""
def __init__(self):
super().__init__()
self.identifier = helpers.get_identifier()
self.domain = self._get_domain()
self.version = helpers.get_version(detailed_version=True)
self.previous_iot_devices = {}
self.previous_unsupported_devices = {}
def _get_domain(self):
"""
Get the iot box domain based on the IP address and subject.
"""
subject = helpers.get_conf('subject')
ip_addr = helpers.get_ip()
if subject and ip_addr:
return ip_addr.replace('.', '-') + subject.strip('*')
return ip_addr or '127.0.0.1'
def _get_changes_to_send(self):
"""
Check if the IoT Box information has changed since the last time it was sent.
Returns True if any tracked property has changed.
"""
changed = False
current_devices = set(iot_devices.keys()) | set(unsupported_devices.keys())
previous_devices = set(self.previous_iot_devices.keys()) | set(self.previous_unsupported_devices.keys())
if current_devices != previous_devices:
self.previous_iot_devices = iot_devices.copy()
self.previous_unsupported_devices = unsupported_devices.copy()
changed = True
# IP address change
new_domain = self._get_domain()
if self.domain != new_domain:
self.domain = new_domain
changed = True
# Version change
new_version = helpers.get_version(detailed_version=True)
if self.version != new_version:
self.version = new_version
changed = True
return changed
@helpers.require_db
def _send_all_devices(self, server_url=None):
"""This method send IoT Box and devices information to Odoo database
As the server can be down or not started yet (in case of local testing),
we retry to send the data several times with a delay between each attempt.
:param server_url: URL of the Odoo server (provided by decorator).
"""
iot_box = {
'identifier': self.identifier,
'ip': self.domain,
'token': helpers.get_token(),
'version': self.version,
}
devices_list = {}
for device in self.previous_iot_devices.values():
identifier = device.device_identifier
devices_list[identifier] = {
'name': device.device_name,
'type': device.device_type,
'manufacturer': device.device_manufacturer,
'connection': device.device_connection,
'subtype': device.device_subtype if device.device_type == 'printer' else '',
}
devices_list.update(self.previous_unsupported_devices)
delay = .5
max_retries = 5
for attempt in range(1, max_retries + 1):
try:
response = requests.post(
server_url + "/iot/setup",
json={'params': {'iot_box': iot_box, 'devices': devices_list}},
timeout=5,
)
response.raise_for_status()
data = response.json()
self.ws_channel = data.get('result', '')
break # Success, exit the retry loop
except requests.exceptions.RequestException:
if attempt < max_retries:
_logger.warning(
'Could not reach configured server to send all IoT devices, retrying in %s seconds (%d/%d attempts)',
delay, attempt, max_retries, exc_info=True
)
time.sleep(delay)
else:
_logger.exception('Could not reach configured server to send all IoT devices after %d attempts.', max_retries)
except ValueError:
_logger.exception('Could not load JSON data: Received data is not valid JSON.\nContent:\n%s', response.content)
break
def run(self):
"""Thread that will load interfaces and drivers and contact the odoo server
with the updates. It will also reconnect to the Wi-Fi if the connection is lost.
"""
if IS_RPI:
# ensure that the root filesystem is writable retro compatibility (TODO: remove this in 19.0)
subprocess.run(["sudo", "mount", "-o", "remount,rw", "/"], check=False)
subprocess.run(["sudo", "mount", "-o", "remount,rw", "/root_bypass_ramdisks/"], check=False)
wifi.reconnect(helpers.get_conf('wifi_ssid'), helpers.get_conf('wifi_password'))
helpers.start_nginx_server()
_logger.info("IoT Box Image version: %s", helpers.get_version(detailed_version=True))
upgrade.check_git_branch()
if IS_RPI and helpers.get_odoo_server_url():
helpers.generate_password()
certificate.ensure_validity()
# 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_all_devices()
helpers.download_iot_handlers()
helpers.load_iot_handlers()
for interface in interfaces.values():
interface().start()
# Set scheduled actions
schedule.every().day.at("00:00").do(certificate.ensure_validity)
schedule.every().day.at("00:00").do(helpers.reset_log_level)
# Set up the websocket connection
ws_client = WebsocketClient(self.ws_channel)
if ws_client:
ws_client.start()
# Check every 3 seconds if the list of connected devices has changed and send the updated
# list to the connected DB.
while 1:
try:
if self._get_changes_to_send():
self._send_all_devices()
if IS_RPI and helpers.get_ip() != '10.11.12.1':
wifi.reconnect(helpers.get_conf('wifi_ssid'), helpers.get_conf('wifi_password'))
time.sleep(3)
schedule.run_pending()
except Exception:
# No matter what goes wrong, the Manager loop needs to keep running
_logger.exception("Manager loop unexpected error")
manager = Manager()
manager.start()

View file

@ -0,0 +1,171 @@
import logging
import queue
import requests
import threading
import time
from odoo.addons.iot_drivers.tools import helpers
from odoo.addons.iot_drivers.tools.system import IS_TEST
from odoo.netsvc import ColoredFormatter
_logger = logging.getLogger(__name__)
IOT_LOG_TO_SERVER_CONFIG_NAME = 'iot_log_to_server' # config name in odoo.conf
class AsyncHTTPHandler(logging.Handler):
"""
Custom logging handler which send IoT logs using asynchronous requests.
To avoid spamming the server, we send logs by batch each X seconds
"""
_MAX_QUEUE_SIZE = 1000
"""Maximum queue size. If a log record is received but the queue if full it will be discarded"""
_MAX_BATCH_SIZE = 50
"""Maximum number of sent logs batched at once. Used to avoid too heavy request. Log records still in the queue will
be handle in future flushes"""
_FLUSH_INTERVAL = 0.5
"""How much seconds it will sleep before checking for new logs to send"""
_REQUEST_TIMEOUT = 0.5
"""Amount of seconds to wait per log to send before timeout"""
_DELAY_BEFORE_NO_SERVER_LOG = 5 * 60 # 5 minutes
"""Minimum delay in seconds before we log a server disconnection.
Used in order to avoid the IoT log file to have a log recorded each _FLUSH_INTERVAL (as this value is very small)"""
def __init__(self, odoo_server_url, active):
"""
:param odoo_server_url: Odoo Server URL
"""
super().__init__()
self._odoo_server_url = odoo_server_url
self._db_name = helpers.get_conf('db_name') or ''
self._log_queue = queue.Queue(self._MAX_QUEUE_SIZE)
self._flush_thread = None
self._active = None
self._next_disconnection_time = None
self.toggle_active(active)
def toggle_active(self, is_active):
"""
Switch it on or off the handler (depending on the IoT setting) without the need to close/reset it
"""
self._active = is_active
if self._active and self._odoo_server_url:
# Start the thread to periodically flush logs
self._flush_thread = threading.Thread(target=self._periodic_flush, name="ThreadServerLogSender", daemon=True)
self._flush_thread.start()
else:
self._flush_thread and self._flush_thread.join() # let a last flush
def _periodic_flush(self):
odoo_session = requests.Session()
while self._odoo_server_url and self._active: # allow to exit the loop on thread.join
time.sleep(self._FLUSH_INTERVAL)
self._flush_logs(odoo_session)
def _flush_logs(self, odoo_session):
def convert_to_byte(s):
return bytes(s, encoding="utf-8") + b'<log/>\n'
def convert_server_line(log_level, line_formatted):
return convert_to_byte(f"{log_level},{line_formatted}")
def empty_queue():
yield convert_to_byte(f"identifier {helpers.get_identifier()}")
for _ in range(self._MAX_BATCH_SIZE):
# Use a limit to avoid having too heavy requests & infinite loop of the queue receiving new entries
try:
log_record = self._log_queue.get_nowait()
yield convert_server_line(log_record.levelno, self.format(log_record))
except queue.Empty:
break
# Report to the server if the queue is close from saturation
if queue_size >= .8 * self._MAX_QUEUE_SIZE:
log_message = "The IoT {} queue is saturating: {}/{} ({:.2f}%)".format( # noqa: UP032
self.__class__.__name__, queue_size, self._MAX_QUEUE_SIZE,
100 * queue_size / self._MAX_QUEUE_SIZE)
_logger.warning(log_message) # As we don't log our own logs, this will be part of the IoT logs
# In order to report this to the server (on the current batch) we will append it manually
yield convert_server_line(logging.WARNING, log_message)
queue_size = self._log_queue.qsize() # This is an approximate value
if not self._odoo_server_url or queue_size == 0:
return
try:
odoo_session.post(
self._odoo_server_url + '/iot/log',
data=empty_queue(),
headers={'X-Odoo-Database': self._db_name},
timeout=self._REQUEST_TIMEOUT
).raise_for_status()
self._next_disconnection_time = None
except (requests.exceptions.ConnectTimeout, requests.exceptions.ConnectionError) as request_errors:
now = time.time()
if not self._next_disconnection_time or now >= self._next_disconnection_time:
_logger.info("Connection with the server to send the logs failed. It is likely down: %s", request_errors)
self._next_disconnection_time = now + self._DELAY_BEFORE_NO_SERVER_LOG
except Exception as _:
_logger.exception('Unexpected error happened while sending logs to server')
def emit(self, record):
# This is important that this method is as fast as possible.
# The log calls will be waiting for this function to finish
if not self._active:
return
try: # noqa: SIM105
self._log_queue.put_nowait(record)
except queue.Full:
pass
def close(self):
self.toggle_active(False)
super().close()
def close_server_log_sender_handler():
_server_log_sender_handler.close()
def get_odoo_config_log_to_server_option():
# Enabled by default if not in test mode
return not IS_TEST and (helpers.get_conf(IOT_LOG_TO_SERVER_CONFIG_NAME, section='options') or True)
def check_and_update_odoo_config_log_to_server_option(new_state):
"""
:return: wherever the config file need to be updated or not
"""
if get_odoo_config_log_to_server_option() != new_state:
helpers.update_conf({IOT_LOG_TO_SERVER_CONFIG_NAME, new_state}, section='options')
_server_log_sender_handler.toggle_active(new_state)
return True
return False
def _server_log_sender_handler_filter(log_record):
def _filter_my_logs():
"""Filter out our own logs (to avoid infinite loop)"""
return log_record.name == __name__
def _filter_frequent_irrelevant_calls():
"""Filter out this frequent irrelevant HTTP calls, to avoid spamming the server with useless logs"""
return (
log_record.name == 'werkzeug'
and log_record.args
and len(log_record.args) > 0
and str(log_record.args[0]).startswith('GET /hw_proxy/hello ')
)
return not (_filter_my_logs() or _filter_frequent_irrelevant_calls())
# The server URL is set once at initlialisation as the IoT will always restart if the URL is changed
# The only other possible case is when the server URL value is "Cleared",
# in this case we force close the log handler (as it does not make sense anymore)
_server_log_sender_handler = AsyncHTTPHandler(helpers.get_odoo_server_url(), get_odoo_config_log_to_server_option())
if not IS_TEST:
_server_log_sender_handler.setFormatter(ColoredFormatter('%(asctime)s %(pid)s %(levelname)s %(dbname)s %(name)s: %(message)s %(perf_info)s'))
_server_log_sender_handler.addFilter(_server_log_sender_handler_filter)
# Set it in the 'root' logger, on which every logger (including odoo) is a child
logging.getLogger().addHandler(_server_log_sender_handler)

View file

@ -0,0 +1,35 @@
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
<path d="M3.51001 1080H76.35L1153.55 0H3.51001V1080Z" fill="url(#o_app_switcher_gradient_01)"/>
<path d="M76.35 1080H842.98L1920 0.18V0H1153.55L76.35 1080Z" fill="url(#o_app_switcher_gradient_02)"/>
<path d="M1920 0.180176L842.98 1080H1063.11L1920 220.88V0.180176Z" fill="url(#o_app_switcher_gradient_03)"/>
<path d="M1920 1080V220.88L1063.11 1080H1920Z" fill="url(#o_app_switcher_gradient_04)"/>
<rect width="1920" height="1080" fill="url(#o_app_switcher_gradient_05)" fill-opacity="0.25"/>
<rect width="1920" height="1080" fill="#E9E6F9" fill-opacity="0.25"/>
<defs>
<linearGradient id="o_app_switcher_gradient_01" x1="-222.43" y1="727.19" x2="904.26" y2="-76.67" gradientUnits="userSpaceOnUse">
<stop offset="0.1" stop-color="white"/>
<stop offset="0.36" stop-color="#FEFEFE"/>
<stop offset="0.68" stop-color="#EAE7F9"/>
<stop offset="1" stop-color="#E4E9F7"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_02" x1="407.23" y1="1021.82" x2="1848.47" y2="-153.08" gradientUnits="userSpaceOnUse">
<stop offset="0.32" stop-color="#FEFEFE"/>
<stop offset="0.66" stop-color="#EAE7F9"/>
<stop offset="1" stop-color="#E5E2F6"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_03" x1="1142.33" y1="846.57" x2="1951.83" y2="136.16" gradientUnits="userSpaceOnUse">
<stop offset="0.15" stop-color="white"/>
<stop offset="0.51" stop-color="#F7F0FD"/>
<stop offset="0.85" stop-color="#F0E7F9"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_04" x1="1409.74" y1="1071" x2="2070.98" y2="526.01" gradientUnits="userSpaceOnUse">
<stop offset="0.45" stop-color="white"/>
<stop offset="0.88" stop-color="#F7F0FD"/>
<stop offset="1" stop-color="#ECE5F8"/>
</linearGradient>
<radialGradient id="o_app_switcher_gradient_05" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(540 960)">
<stop stop-color="#9996A9" stop-opacity="0.53"/>
<stop offset="1" stop-color="#7A768F"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,186 @@
/* global owl */
import { SingleData } from "./components/SingleData.js";
import { FooterButtons } from "./components/FooterButtons.js";
import { ServerDialog } from "./components/dialog/ServerDialog.js";
import { WifiDialog } from "./components/dialog/WifiDialog.js";
import useStore from "./hooks/useStore.js";
import { UpdateDialog } from "./components/dialog/UpdateDialog.js";
import { DeviceDialog } from "./components/dialog/DeviceDialog.js";
import { SixDialog } from "./components/dialog/SixDialog.js";
import { LoadingFullScreen } from "./components/LoadingFullScreen.js";
import { IconButton } from "./components/IconButton.js";
const { Component, xml, useState, onWillStart } = owl;
export class Homepage extends Component {
static props = {};
static components = {
SingleData,
FooterButtons,
ServerDialog,
WifiDialog,
UpdateDialog,
DeviceDialog,
SixDialog,
LoadingFullScreen,
IconButton,
};
setup() {
this.store = useStore();
this.state = useState({ data: {}, loading: true, waitRestart: false });
this.store.advanced = localStorage.getItem("showAdvanced") === "true";
this.store.dev = new URLSearchParams(window.location.search).has("debug");
onWillStart(async () => {
await this.loadInitialData();
});
setInterval(() => {
this.loadInitialData();
}, 10000);
}
get numDevices() {
return Object.values(this.state.data.devices)
.map((devices) => devices.length)
.reduce((a, b) => a + b, 0);
}
get networkStatus() {
if (
!this.store.isLinux ||
this.state.data.network_interfaces.some((netInterface) => !netInterface.is_wifi)
) {
return "Ethernet";
}
const wifiInterface = this.state.data.network_interfaces.find(
(netInterface) => netInterface.ssid
);
if (wifiInterface) {
return this.state.data.is_access_point_up
? 'No internet connection - click on "Configure"'
: `Wi-Fi: ${wifiInterface.ssid}`;
}
return "Not Connected";
}
async loadInitialData() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/data",
});
if (data.system === "Linux") {
this.store.isLinux = true;
}
this.state.data = data;
this.store.base = data;
this.state.loading = false;
this.store.update = new Date().getTime();
} catch {
console.warn("Error while fetching data");
}
}
async restartOdooService() {
try {
await this.store.rpc({
url: "/iot_drivers/restart_odoo_service",
});
this.state.waitRestart = true;
} catch {
console.warn("Error while restarting Odoo Service");
}
}
toggleAdvanced() {
this.store.advanced = !this.store.advanced;
localStorage.setItem("showAdvanced", this.store.advanced);
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Restarting IoT Box, please wait...
</t>
</LoadingFullScreen>
<div t-if="!this.state.loading" class="w-100 d-flex flex-column align-items-center justify-content-center background">
<div class="bg-white p-4 rounded overflow-auto position-relative w-100 main-container">
<div class="position-absolute end-0 top-0 mt-3 me-4 d-flex gap-1">
<IconButton t-if="!store.base.is_access_point_up" onClick.bind="toggleAdvanced" icon="this.store.advanced ? 'fa-cog' : 'fa-cogs'" />
<IconButton onClick.bind="restartOdooService" icon="'fa-power-off'" />
</div>
<div class="d-flex mb-4 flex-column align-items-center justify-content-center">
<h4 class="text-center m-0">IoT Box</h4>
</div>
<div t-if="!state.data.certificate_end_date and !store.base.is_access_point_up" class="alert alert-warning" role="alert">
<p class="m-0 fw-bold">
This IoT Box doesn't have a valid certificate.
</p>
<small>
The IoT Box should get a certificate automatically when paired with a database. If it doesn't,
try to restart it.
</small>
</div>
<div t-if="store.advanced and state.data.certificate_end_date and !store.base.is_access_point_up" class="alert alert-info" role="alert">
Your IoT Box subscription is valid until <span class="fw-bold" t-esc="state.data.certificate_end_date"/>.
</div>
<div t-if="store.base.is_access_point_up" class="alert alert-info" role="alert">
<p class="m-0 fw-bold">No Internet Connection</p>
<small>
Please connect your IoT Box to internet via an ethernet cable or via Wi-Fi by clicking on "Configure" below
</small>
</div>
<SingleData name="'Identifier'" value="state.data.identifier" icon="'fa-address-card'" />
<SingleData t-if="store.advanced" name="'Mac Address'" value="state.data.mac_address" icon="'fa-address-book'" />
<SingleData t-if="store.advanced" name="'Version'" value="state.data.version" icon="'fa-microchip'">
<t t-set-slot="button">
<UpdateDialog />
</t>
</SingleData>
<SingleData t-if="store.advanced" name="'IP address'" value="state.data.ip" icon="'fa-globe'" />
<SingleData t-if="store.isLinux" name="'Internet Status'" value="networkStatus" icon="'fa-wifi'">
<t t-set-slot="button">
<WifiDialog />
</t>
</SingleData>
<SingleData t-if="!store.base.is_access_point_up" name="'Odoo database connected'" value="state.data.server_status" icon="'fa-link'">
<t t-set-slot="button">
<ServerDialog />
</t>
</SingleData>
<SingleData t-if="state.data.pairing_code and !this.store.base.is_access_point_up and !state.data.pairing_code_expired" name="'Pairing Code'" value="state.data.pairing_code + ' - Enter this code in the IoT app in your Odoo database'" icon="'fa-code'"/>
<SingleData t-if="state.data.pairing_code_expired" name="'Pairing Code'" value="'Code has expired - restart the IoT Box to generate a new one'" icon="'fa-code'"/>
<SingleData t-if="store.advanced and !store.base.is_access_point_up" name="'Six terminal'" value="state.data.six_terminal" icon="'fa-money'">
<t t-set-slot="button">
<SixDialog />
</t>
</SingleData>
<SingleData t-if="!this.store.base.is_access_point_up" name="'Devices'" value="numDevices + ' devices'" icon="'fa-plug'">
<t t-set-slot="button">
<DeviceDialog />
</t>
</SingleData>
<hr class="mt-5" />
<FooterButtons />
<div class="d-flex justify-content-center gap-2 mt-2" t-if="!store.base.is_access_point_up">
<a href="https://www.odoo.com/fr_FR/help" target="_blank" class="link-primary">Help</a>
<a href="https://www.odoo.com/documentation/latest/applications/general/iot.html" target="_blank" class="link-primary">Documentation</a>
</div>
</div>
</div>
<div t-else="" class="w-100 d-flex align-items-center justify-content-center background">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</t>
`;
}

View file

@ -0,0 +1,39 @@
/* global owl */
import useStore from "../hooks/useStore.js";
import { CredentialDialog } from "./dialog/CredentialDialog.js";
import { HandlerDialog } from "./dialog/HandlerDialog.js";
import { RemoteDebugDialog } from "./dialog/RemoteDebugDialog.js";
import { TimeDialog } from "./dialog/TimeDialog.js";
const { Component, xml } = owl;
export class FooterButtons extends Component {
static props = {};
static components = {
RemoteDebugDialog,
HandlerDialog,
CredentialDialog,
TimeDialog,
};
setup() {
this.store = useStore();
}
static template = xml`
<div class="w-100 d-flex flex-wrap align-items-cente gap-2 justify-content-center" t-translation="off">
<a t-if="store.isLinux and !store.base.is_access_point_up" class="btn btn-primary btn-sm" href="/status" target="_blank">
Status Display
</a>
<a t-if="store.isLinux and !store.base.is_access_point_up" class="btn btn-primary btn-sm" t-att-href="'http://' + this.store.base.ip + ':631'" target="_blank">
Printer Server
</a>
<RemoteDebugDialog t-if="this.store.advanced and this.store.isLinux" />
<CredentialDialog t-if="this.store.advanced" />
<HandlerDialog t-if="this.store.advanced" />
<a t-if="this.store.advanced" class="btn btn-primary btn-sm" href="/logs" target="_blank">View Logs</a>
<TimeDialog/>
</div>
`;
}

View file

@ -0,0 +1,22 @@
/* global owl */
import useStore from "../hooks/useStore.js";
const { Component, xml } = owl;
export class IconButton extends Component {
static props = {
onClick: Function,
icon: String,
};
setup() {
this.store = useStore();
}
static template = xml`
<div class="d-flex align-items-center justify-content-center icon-button btn btn-primary" t-translation="off" t-on-click="this.props.onClick">
<i class="fa" t-att-class="this.props.icon" aria-hidden="true"></i>
</div>
`;
}

View file

@ -0,0 +1,41 @@
/* global owl */
import useStore from "../hooks/useStore.js";
const { Component, xml, onMounted } = owl;
export class LoadingFullScreen extends Component {
static props = {
slots: Object,
};
setup() {
this.store = useStore();
// We delay the RPC verification for 10 seconds to be sure that the Odoo service
// was already restarted
onMounted(() => {
setTimeout(() => {
setInterval(async () => {
try {
await this.store.rpc({
url: "/iot_drivers/ping",
});
window.location.reload();
} catch {
console.warn("Odoo service is probably rebooting.");
}
}, 750);
}, 10000);
});
}
static template = xml`
<div class="position-fixed top-0 start-0 bg-white vh-100 w-100 justify-content-center align-items-center d-flex flex-column gap-3 always-on-top" t-translation="off">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<t t-slot="body" />
</div>
`;
}

View file

@ -0,0 +1,48 @@
/* global owl */
const { Component, xml } = owl;
export class SingleData extends Component {
static props = {
name: String,
value: String,
icon: { type: String, optional: true },
style: { type: String, optional: true },
slots: { type: Object, optional: true },
btnName: { type: String, optional: true },
btnAction: { type: Function, optional: true },
};
static defaultProps = {
style: "primary",
};
get valueIsURL() {
const expression =
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/;
const regex = new RegExp(expression);
if (this.props.value?.match(regex)) {
return true;
} else {
return false;
}
}
static template = xml`
<div class="w-100 d-flex justify-content-between align-items-center bg-light rounded ps-2 pe-3 py-1 mb-2 gap-2" t-translation="off">
<div t-att-class="this.props.style === 'primary' ? 'odoo-bg-primary' : 'odoo-bg-secondary'" class="rounded odoo-pill" />
<div class="flex-grow-1 overflow-hidden">
<h6 class="m-0">
<i t-if="this.props.icon" class="me-2 fa" t-att-class="this.props.icon" aria-hidden="true"></i>
<t t-esc="this.props.name" />
</h6>
<p t-if="!this.valueIsURL" class="m-0 text-secondary one-line" t-esc="this.props.value or 'Not Configured'" />
<a t-if="this.valueIsURL" t-att-href="this.props.value" target="_blank" class="m-0 text-secondary one-line" t-esc="this.props.value" />
</div>
<div t-if="this.props.btnName">
<button class="btn btn-primary btn-sm" t-esc="this.props.btnName" t-on-click="() => this.props.btnAction()" />
</div>
<t t-if="this.props.slots and this.props.slots['button']" t-slot="button" />
</div>
`;
}

View file

@ -0,0 +1,63 @@
/* global owl */
const { Component, xml, useEffect, useRef } = owl;
export class BootstrapDialog extends Component {
static props = {
identifier: String,
slots: Object,
btnName: { type: String, optional: true },
isLarge: { type: Boolean, optional: true },
onOpen: { type: Function, optional: true },
onClose: { type: Function, optional: true },
};
setup() {
this.dialog = useRef("dialog");
useEffect(
() => {
if (!this.dialog || !this.dialog.el) {
return;
}
if (this.props.onOpen) {
this.dialog.el.addEventListener("show.bs.modal", this.props.onOpen);
}
if (this.props.onClose) {
this.dialog.el.addEventListener("hide.bs.modal", this.props.onClose);
}
return () => {
this.dialog.el.removeEventListener("show.bs.modal", this.props.onOpen);
this.dialog.el.removeEventListener("hide.bs.modal", this.props.onClose);
};
},
() => [this.dialog]
);
}
static template = xml`
<t t-translation="off">
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" t-att-data-bs-target="'#'+this.props.identifier" t-esc="this.props.btnName" />
<div t-ref="dialog" t-att-id="this.props.identifier" class="modal modal-dialog-scrollable fade" t-att-class="{'modal-lg': props.isLarge}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<t t-slot="header" />
</div>
<div class="modal-body position-relative dialog-body">
<t t-slot="body" />
</div>
<div class="modal-footer justify-content-around justify-content-md-start flex-wrap gap-1 w-100">
<div class="d-flex gap-2">
<t t-slot="footer" />
</div>
</div>
</div>
</div>
</div>
</t>
`;
}

View file

@ -0,0 +1,85 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState, toRaw } = owl;
export class CredentialDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = toRaw(useStore());
this.state = useState({ waitRestart: false });
this.form = useState({
db_uuid: this.store.base.db_uuid,
enterprise_code: "",
});
}
async connectToServer() {
try {
if (!this.form.db_uuid || !this.form.enterprise_code) {
return;
}
const data = await this.store.rpc({
url: "/iot_drivers/save_credential",
method: "POST",
params: this.form,
});
if (data.status === "success") {
this.state.waitRestart = true;
}
} catch {
console.warn("Error while fetching data");
}
}
async clearConfiguration() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/clear_credential",
});
if (data.status === "success") {
this.state.waitRestart = true;
}
} catch {
console.warn("Error while clearing configuration");
}
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Your IoT Box is currently processing your request. Please wait.
</t>
</LoadingFullScreen>
<BootstrapDialog identifier="'credential-configuration'" btnName="'Credentials'">
<t t-set-slot="header">
Configure Credentials
</t>
<t t-set-slot="body">
<div class="alert alert-info fs-6" role="alert">
Set the Database UUID and your Contract Number you want to use to validate your subscription.
</div>
<div class="d-flex flex-column gap-2 mt-3">
<input type="text" class="form-control" placeholder="Database UUID" t-model="form.db_uuid"/>
<input type="text" class="form-control" placeholder="Odoo contract number" t-model="form.enterprise_code"/>
</div>
</t>
<t t-set-slot="footer">
<button type="submit" class="btn btn-primary btn-sm" t-att-disabled="!form.db_uuid" t-on-click="connectToServer">Connect</button>
<button class="btn btn-secondary btn-sm" t-on-click="clearConfiguration">Clear configuration</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,90 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
const { Component, xml } = owl;
export const DEVICE_ICONS = {
camera: "fa-camera",
device: "fa-plug",
display: "fa-desktop",
fiscal_data_module: "fa-dollar",
keyboard: "fa-keyboard-o",
payment: "fa-credit-card",
printer: "fa-print",
scale: "fa-balance-scale",
scanner: "fa-barcode",
unsupported: "fa-question",
};
export const CONNECTION_ICONS = {
hdmi: "fa-desktop",
direct: "fa-usb",
serial: "fa-usb",
network: "fa-sitemap",
bluetooth: "fa-bluetooth",
};
export class DeviceDialog extends Component {
static props = {};
static components = { BootstrapDialog };
setup() {
this.store = useStore();
this.icons = DEVICE_ICONS;
this.connectionIcons = CONNECTION_ICONS;
}
formatDeviceType(deviceType, numDevices) {
const formattedDeviceType =
deviceType[0].toUpperCase() + deviceType.replaceAll("_", " ").slice(1);
return numDevices === 1 || deviceType === "unsupported"
? formattedDeviceType
: `${formattedDeviceType}s`;
}
get devices() {
return this.store.base.devices;
}
static template = xml`
<t t-translation="off">
<BootstrapDialog identifier="'device-list'" btnName="'Show'" isLarge="true">
<t t-set-slot="header">
Devices list
</t>
<t t-set-slot="body">
<div t-if="Object.keys(devices).length === 0" class="alert alert-warning fs-6" role="alert">
No devices found.
</div>
<div class="accordion">
<div t-foreach="Object.keys(devices)" t-as="deviceType" t-key="deviceType" class="accordion-item">
<h2 class="accordion-header" t-att-id="'heading-' + deviceType">
<button class="accordion-button px-3 d-flex gap-3 collapsed" type="button" data-bs-toggle="collapse" t-att-data-bs-target="'#collapse-' + deviceType" t-att-aria-controls="'collapse-' + deviceType">
<span t-att-class="'color-primary fa fa-fw fa-2x ' + icons[deviceType]"/>
<span class="fs-5 fw-bold" t-out="devices[deviceType].length"/>
<span class="fs-5" t-out="formatDeviceType(deviceType, devices[deviceType].length)"/>
</button>
</h2>
<div t-att-id="'collapse-' + deviceType" class="accordion-collapse collapse" t-att-aria-labelledby="'heading-' + deviceType">
<div class="d-flex flex-column p-1 gap-2">
<div t-foreach="devices[deviceType]" t-as="device" t-key="device.identifier" class="d-flex flex-column bg-light rounded p-2 gap-1">
<span t-out="device.name" class="one-line"/>
<span class="text-secondary one-line">
<span t-att-class="'me-2 fa fa-fw ' + connectionIcons[device.connection]"/>
<t t-out="device.identifier"/>
</span>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-set-slot="footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,154 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState } = owl;
export class HandlerDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = useStore();
this.state = useState({
initialization: true,
waitRestart: false,
loading: false,
handlerData: {},
globalLogger: {},
});
}
onClose() {
this.state.initialization = [];
this.state.handlerData = {};
}
async getHandlerData() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/log_levels",
});
this.state.handlerData = data;
this.state.globalLogger = {
"iot-logging-root": data.root_logger_log_level,
"iot-logging-odoo": data.odoo_current_log_level,
};
this.state.initialization = false;
} catch {
console.warn("Error while fetching data");
}
}
async onChange(name, value) {
try {
await this.store.rpc({
url: "/iot_drivers/log_levels_update",
method: "POST",
params: {
name: name,
value: value,
},
});
} catch {
console.warn("Error while saving data");
}
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Processing your request, please wait...
</t>
</LoadingFullScreen>
<BootstrapDialog identifier="'handler-configuration'" btnName="'Log level'" onOpen.bind="getHandlerData" onClose.bind="onClose">
<t t-set-slot="header">
Handler logging
</t>
<t t-set-slot="body">
<div t-if="this.state.initialization" class="position-absolute top-0 start-0 bg-white h-100 w-100 justify-content-center align-items-center d-flex flex-column gap-3 always-on-top handler-loading">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Currently scanning for initialized drivers and interfaces...</p>
</div>
<t t-else="">
<div class="mb-3">
<h5>Global logs level</h5>
<div class="form-check mb-3">
<input name="log-to-server"
id="log-to-server"
class="form-check-input cursor-pointer"
type="checkbox"
t-att-checked="this.state.handlerData.is_log_to_server_activated"
t-on-change="(ev) => this.onChange(ev.target.name, ev.target.checked)" />
<label class="form-check-label cursor-pointer" for="log-to-server">IoT logs automatically send to server logs</label>
</div>
<div t-foreach="Object.entries(this.state.globalLogger)" t-as="global" t-key="global[0]" class="input-group input-group-sm mb-3">
<label class="input-group-text w-50" t-att-for="global[0]" t-esc="global[0]" />
<select t-att-name="global[0]"
t-if="global[1]"
class="form-select"
t-on-change="(ev) => this.onChange(ev.target.name, ev.target.value)"
t-att-id="global[0]"
t-att-value="global[1]">
<option value="parent">Same as Odoo</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input t-else="" type="text" class="form-control" aria-label="Text input with dropdown button" disabled="true" placeholder="Logger uninitialised" />
</div>
</div>
<div class="mb-3">
<h5>Interfaces logs level</h5>
<div t-foreach="Object.entries(this.state.handlerData.interfaces_logger_info)" t-as="interface" t-key="interface[0]" class="input-group input-group-sm mb-3">
<label class="input-group-text w-50" t-att-for="interface[0]" t-esc="interface[0]" />
<select t-att-name="'iot-logging-interface-'+interface[0]"
t-if="interface[1]"
class="form-select"
t-on-change="(ev) => this.onChange(ev.target.name, ev.target.value)"
t-att-id="interface[0]"
t-att-value="interface[1].is_using_parent_level ? 'parent' : interface[1].level">
<option value="parent">Same as Odoo</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input t-else="" type="text" class="form-control" aria-label="Text input with dropdown button" disabled="true" placeholder="Logger uninitialised" />
</div>
</div>
<div class="mb-3">
<h5>Drivers logs level</h5>
<div t-foreach="Object.entries(this.state.handlerData.drivers_logger_info)" t-as="drivers" t-key="drivers[0]" class="input-group input-group-sm mb-3">
<label class="input-group-text w-50" t-att-for="drivers[0]" t-esc="drivers[0]" />
<select t-att-name="'iot-logging-driver-'+drivers[0]"
t-if="drivers[1]"
class="form-select"
t-on-change="(ev) => this.onChange(ev.target.name, ev.target.value)"
t-att-id="drivers[0]"
t-att-value="drivers[1].is_using_parent_level ? 'parent' : drivers[1].level">
<option value="parent">Same as Odoo</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
<input t-else="" type="text" class="form-control" aria-label="Text input with dropdown button" disabled="true" placeholder="Logger uninitialised" />
</div>
</div>
</t>
</t>
<t t-set-slot="footer">
<button type="button" class="btn btn-primary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,139 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
const { Component, xml, onWillStart, useState } = owl;
export class RemoteDebugDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = useStore();
this.state = useState({
password: "",
loading: false,
ngrok: false,
ngrokToken: "",
loadingNgrok: false,
});
onWillStart(async () => {
await this.isNgrokEnabled();
});
}
async isNgrokEnabled() {
try {
const data = await this.store.rpc({ url: "/iot_drivers/is_ngrok_enabled" });
this.state.ngrok = data.enabled;
if (!this.state.ngrok) {
this.state.ngrokToken = "";
}
} catch {
console.warn("Error while fetching data");
}
}
async generatePassword() {
try {
this.state.loading = true;
const data = await this.store.rpc({
url: "/iot_drivers/generate_password",
method: "POST",
});
this.state.password = data.password;
this.state.loading = false;
} catch {
console.warn("Error while fetching data");
}
}
async enableNgrok() {
if (!this.state.ngrokToken) {
return;
}
this.state.loadingNgrok = true;
try {
await this.store.rpc({
url: "/iot_drivers/enable_ngrok",
method: "POST",
params: {
auth_token: this.state.ngrokToken,
},
});
// Wait 2 seconds to let odoo-ngrok service start
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.isNgrokEnabled();
} catch {
console.warn("Error while enabling remote debugging");
}
this.state.loadingNgrok = false;
}
async disableNgrok() {
this.state.loadingNgrok = true;
try {
await this.store.rpc({
url: "/iot_drivers/disable_ngrok",
method: "POST",
});
// Wait 2 seconds to let odoo-ngrok service stop
await new Promise((resolve) => setTimeout(resolve, 2000));
await this.isNgrokEnabled();
} catch {
console.warn("Error while disabling remote debugging");
}
this.state.loadingNgrok = false;
}
static template = xml`
<t t-translation="off">
<BootstrapDialog identifier="'remote-debug-configuration'" btnName="'Remote debug'">
<t t-set-slot="header">
Remote Debugging
</t>
<t t-set-slot="body">
<div t-if="!state.ngrok" class="alert alert-warning fs-6" role="alert">
This allows someone who give a ngrok authtoken to gain remote access to your IoT Box,
and thus your entire local network. Only enable this for someone you trust.
</div>
<div t-else="" class="alert alert-danger fs-6" role="alert">
Your IoT Box is currently accessible from the internet.
The owner of the ngrok authtoken can access both the IoT Box and your local network.
</div>
<div class="d-flex flex-row gap-2 mb-4">
<input placeholder="Password" t-att-value="this.state.password" class="form-control" readonly="readonly" />
<button class="btn btn-primary btn-sm" t-on-click="generatePassword">
<div t-if="this.state.loading" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<t t-else="">Generate</t>
</button>
</div>
<input t-model="this.state.ngrokToken" placeholder="Authentication token" class="form-control" />
</t>
<t t-set-slot="footer">
<button
type="submit"
class="btn btn-sm"
t-att-class="state.ngrok ? 'btn-primary' : 'btn-secondary'"
t-on-click="state.ngrok ? disableNgrok : enableNgrok"
>
<div t-if="state.loadingNgrok" class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<t t-else="">
<t t-esc="state.ngrok ? 'Disable remote debugging' : 'Enable remote debugging'" />
</t>
</button>
<button type="button" t-att-class="'btn btn-sm btn-' + (state.ngrok ? 'secondary' : 'primary')" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,90 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState } = owl;
export class ServerDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = useStore();
this.state = useState({ waitRestart: false, loading: false, error: null });
this.form = useState({ token: "" });
}
async connectToServer() {
this.state.loading = true;
this.state.error = null;
try {
const data = await this.store.rpc({
url: "/iot_drivers/connect_to_server",
method: "POST",
params: this.form,
});
if (data.status === "success") {
this.state.waitRestart = true;
} else {
this.state.error = data.message;
}
} catch {
console.warn("Error while fetching data");
}
this.state.loading = false;
}
async clearConfiguration() {
this.state.waitRestart = true;
try {
await this.store.rpc({
url: "/iot_drivers/server_clear",
});
} catch {
console.warn("Error while clearing configuration");
}
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Updating Odoo Server information, please wait...
</t>
</LoadingFullScreen>
<BootstrapDialog identifier="'server-configuration'" btnName="'Configure'">
<t t-set-slot="header">
Configure Odoo Database
</t>
<t t-set-slot="body">
<div class="alert alert-warning fs-6 pb-0" role="alert" t-if="!store.base.server_status">
<ol>
<li>Install <b>IoT App</b> on your database,</li>
<li>From the IoT App click on <b>Connect</b> button.</li>
</ol>
</div>
<div class="mt-3">
<div class="input-group-sm mb-3" t-if="!store.base.server_status">
<input type="text" class="form-control" t-model="form.token" placeholder="Server token"/>
</div>
<div class="small" t-else="">
<p class="m-0">
Your current database is: <br/>
<strong t-esc="store.base.server_status" />
</p>
</div>
</div>
</t>
<t t-set-slot="footer">
<button type="submit" class="btn btn-primary btn-sm" t-if="!store.base.server_status" t-on-click="connectToServer" t-att-disabled="state.loading or !form.token" >Connect</button>
<button type="button" class="btn btn-danger btn-sm" t-if="store.base.server_status" t-on-click="clearConfiguration">Disconnect</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,83 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState, toRaw } = owl;
export class SixDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = toRaw(useStore());
this.state = useState({ waitRestart: false });
this.form = useState({ terminal_id: this.store.base.six_terminal });
}
async configureSix() {
try {
if (!this.form.terminal_id) {
return;
}
const data = await this.store.rpc({
url: "/iot_drivers/six_payment_terminal_add",
method: "POST",
params: this.form,
});
if (data.status === "success") {
this.state.waitRestart = true;
}
} catch {
console.warn("Error while fetching data");
}
}
async disconnectSix() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/six_payment_terminal_clear",
});
if (data.status === "success") {
this.state.waitRestart = true;
}
} catch {
console.warn("Error while clearing configuration");
}
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Your IoT Box is currently processing your request. Please wait.
</t>
</LoadingFullScreen>
<BootstrapDialog identifier="'six-configuration'" btnName="'Configure'">
<t t-set-slot="header">
Configure a Six Terminal
</t>
<t t-set-slot="body">
<div class="alert alert-info fs-6" role="alert">
Set the Terminal ID (TID) of the terminal you want to use.
</div>
<div class="mt-3">
<div class="input-group-sm mb-3">
<input type="text" class="form-control" placeholder="Six Terminal ID (digits only)" t-model="this.form.terminal_id" />
</div>
</div>
</t>
<t t-set-slot="footer">
<button type="submit" class="btn btn-primary btn-sm" t-att-disabled="!form.terminal_id" t-on-click="configureSix">Configure</button>
<button class="btn btn-secondary btn-sm" t-on-click="disconnectSix">Disconnect current</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,74 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
const { Component, useState, xml } = owl;
export class TimeDialog extends Component {
static props = {};
static components = { BootstrapDialog };
setup() {
this.store = useStore();
this.state = useState({
odooUptimeSeconds: this.store.base.odoo_uptime_seconds,
systemUptimeSeconds: this.store.base.system_uptime_seconds,
});
setInterval(() => {
this.state.odooUptimeSeconds += 1;
this.state.systemUptimeSeconds += 1;
}, 1000);
}
startDateFromSeconds(uptimeInSeconds) {
const currentTimeMs = new Date().getTime();
const odooUptimeMs = uptimeInSeconds * 1000;
return new Date(currentTimeMs - odooUptimeMs).toUTCString();
}
secondsToHumanReadable(periodInSeconds) {
const SECONDS_PER_HOUR = 3600;
const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
const days = Math.floor(periodInSeconds / SECONDS_PER_DAY);
periodInSeconds = periodInSeconds % SECONDS_PER_DAY;
const hours = Math.floor(periodInSeconds / SECONDS_PER_HOUR);
periodInSeconds = periodInSeconds % SECONDS_PER_HOUR;
const minutes = Math.floor(periodInSeconds / 60);
const seconds = Math.floor(periodInSeconds % 60);
const formatAmount = (amount, name) => `${amount} ${name}${amount === 1 ? "" : "s"}`;
const timeParts = [
formatAmount(days, "day"),
formatAmount(hours, "hour"),
formatAmount(minutes, "minute"),
formatAmount(seconds, "second"),
];
return timeParts.join(", ");
}
static template = xml`
<BootstrapDialog identifier="'time-dialog'" btnName="'View Uptime'">
<t t-set-slot="header">
IoT Box Uptime
</t>
<t t-set-slot="body">
<div class="d-flex flex-column gap-4">
<div>
<h5>Odoo Service</h5>
<div>Running for <b t-out="secondsToHumanReadable(state.odooUptimeSeconds)"/></div>
<div class="text-secondary">Started at <b t-out="startDateFromSeconds(state.odooUptimeSeconds)"/></div>
</div>
<div t-if="store.isLinux">
<h5>Operating System</h5>
<div>Running for <b t-out="secondsToHumanReadable(state.systemUptimeSeconds)"/></div>
<div class="text-secondary">Started at <b t-out="startDateFromSeconds(state.systemUptimeSeconds)"/></div>
</div>
</div>
</t>
<t t-set-slot="footer">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
`;
}

View file

@ -0,0 +1,157 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState } = owl;
export class UpdateDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = useStore();
this.state = useState({
initialization: true,
loading: false,
waitRestart: false,
odooIsUpToDate: false,
imageIsUpToDate: false,
currentCommitHash: "",
});
}
onClose() {
this.state.initialization = [];
}
async getVersionInfo() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/version_info",
});
if (data.status === "error") {
console.error(data.message);
return;
}
this.state.odooIsUpToDate = data.odooIsUpToDate;
this.state.imageIsUpToDate = data.imageIsUpToDate;
this.state.currentCommitHash = data.currentCommitHash;
this.state.initialization = false;
} catch {
console.warn("Error while fetching version info");
}
}
async updateGitTree() {
this.state.waitRestart = true;
try {
const data = await this.store.rpc({
url: "/iot_drivers/update_git_tree",
method: "POST",
});
if (data.status === "error") {
this.state.waitRestart = false;
console.error(data.message);
}
} catch {
console.warn("Error while updating IoT Box.");
}
}
async forceUpdateIotHandlers() {
this.state.waitRestart = true;
try {
await this.store.rpc({
url: "/iot_drivers/load_iot_handlers",
});
} catch {
console.warn("Error while downloading handlers from db.");
}
}
get everythingIsUpToDate() {
return this.state.odooIsUpToDate && this.state.imageIsUpToDate;
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Updating your device, please wait...
</t>
</LoadingFullScreen>
<BootstrapDialog identifier="'update-configuration'" btnName="'Update'" onOpen.bind="getVersionInfo" onClose.bind="onClose">
<t t-set-slot="header">
<div>
Update
<a href="https://www.odoo.com/documentation/latest/applications/general/iot/iot_advanced/updating_iot.html" class="fa fa-question-circle text-decoration-none text-dark" target="_blank"></a>
</div>
</t>
<t t-set-slot="body">
<div t-if="this.state.initialization" class="position-absolute top-0 start-0 bg-white h-100 w-100 justify-content-center align-items-center d-flex flex-column gap-3 always-on-top">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Currently fetching update data...</p>
</div>
<div class="mb-3" t-if="this.store.isLinux">
<h6>Operating System Update</h6>
<div t-if="this.state.imageIsUpToDate" class="text-success px-2 small">
Operating system is up to date
</div>
<div t-else="" class="alert alert-warning small mb-0">
A new version of the operating system is available, see:
<a href="https://www.odoo.com/documentation/latest/applications/general/iot/iot_advanced/updating_iot.html#iot-updating-iot-image-code" target="_blank" class="alert-link">
Flashing the SD Card on IoT Box
</a>
</div>
<div t-if="this.store.dev" class="alert alert-light small">
<a href="https://nightly.odoo.com/master/iotbox/" target="_blank" class="alert-link">
Current: <t t-esc="this.store.base.version"/>
</a>
</div>
</div>
<div class="mb-3">
<h6>IoT Box Update</h6>
<div t-if="this.state.odooIsUpToDate" class="text-success px-2 small">
IoT Box is up to date.
</div>
<div t-else="" class="d-flex justify-content-between align-items-center alert alert-warning small">
A new version of the IoT Box is available
<button class="btn btn-primary btn-sm" t-on-click="updateGitTree">Update</button>
</div>
<div t-if="this.store.dev" class="alert alert-light small">
Current:
<a t-att-href="'https://github.com/odoo/odoo/commit/' + this.state.currentCommitHash" target="_blank" class="alert-link">
<t t-esc="this.state.currentCommitHash"/>
</a>
</div>
</div>
<h6>Drivers Update</h6>
<div class="d-flex gap-2">
<button class="btn btn-secondary btn-sm" t-on-click="forceUpdateIotHandlers">
Force Drivers Update
</button>
</div>
</t>
<t t-set-slot="footer">
<button
type="button"
t-att-class="'btn btn-sm ' + (this.everythingIsUpToDate ? 'btn-primary' : 'btn-secondary')"
data-bs-dismiss="modal">
Close
</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,178 @@
/* global owl */
import useStore from "../../hooks/useStore.js";
import { BootstrapDialog } from "./BootstrapDialog.js";
import { LoadingFullScreen } from "../LoadingFullScreen.js";
const { Component, xml, useState } = owl;
export class WifiDialog extends Component {
static props = {};
static components = { BootstrapDialog, LoadingFullScreen };
setup() {
this.store = useStore();
this.state = useState({
scanning: true,
waitRestart: false,
status: "",
availableWiFi: [],
isPasswordVisible: false,
});
this.form = useState({
essid: "",
password: "",
});
}
onClose() {
this.state.availableWiFi = [];
this.state.scanning = true;
this.form.essid = "";
this.form.password = "";
}
isCurrentlyConnectedToWifi() {
return (
!this.store.base.is_access_point_up &&
this.store.base.network_interfaces.some((netInterface) => netInterface.is_wifi)
);
}
isCurrentlyConnectedToEthernet() {
return this.store.base.network_interfaces.some((netInterface) => !netInterface.is_wifi);
}
async getWiFiNetworks() {
try {
const data = await this.store.rpc({
url: "/iot_drivers/wifi",
});
this.form.essid = data.currentWiFi || "Select Network...";
this.state.availableWiFi = data.availableWiFi;
this.state.scanning = false;
} catch {
console.warn("Error while fetching data");
}
}
async connectToWiFi() {
if (!this.form.essid || !this.form.password) {
return;
}
this.state.waitRestart = true;
const responsePromise = this.store.rpc({
url: "/iot_drivers/update_wifi",
method: "POST",
params: this.form,
});
if (this.isCurrentlyConnectedToEthernet()) {
const data = await responsePromise;
if (data.status !== "success") {
this.state.waitRestart = false;
}
} else {
// The IoT box is no longer reachable, so we can't await the response.
this.state.status = "connecting";
this.state.waitRestart = false;
}
}
async clearConfiguration() {
try {
this.state.waitRestart = true;
const responsePromise = this.store.rpc({
url: "/iot_drivers/wifi_clear",
});
if (this.isCurrentlyConnectedToEthernet()) {
const data = await responsePromise;
if (data.status !== "success") {
this.state.waitRestart = false;
}
} else {
// The IoT box is no longer reachable, so we can't await the response.
this.state.status = "disconnecting";
this.state.waitRestart = false;
}
} catch {
console.warn("Error while clearing configuration");
}
}
togglePasswordVisibility() {
this.state.isPasswordVisible = !this.state.isPasswordVisible;
}
static template = xml`
<t t-translation="off">
<LoadingFullScreen t-if="this.state.waitRestart">
<t t-set-slot="body">
Updating Wi-Fi configuration, please wait...
</t>
</LoadingFullScreen>
<div t-if="state.status" class="position-fixed top-0 start-0 bg-white vh-100 w-100 justify-content-center align-items-center d-flex always-on-top">
<div class="alert alert-success mx-4">
<t t-if="state.status === 'connecting'">
The IoT Box will now attempt to connect to <t t-out="form.essid"/>. The next step is to find your <b>pairing code</b>:
<ul>
<li>You will need a screen or a compatible USB printer connected.</li>
<li>In a few seconds, the pairing code should display on your screen and/or print from your printer.</li>
<li>Once you have the pairing code, you can enter it on the IoT app in your database to pair your IoT Box.</li>
</ul>
In the event that the pairing code does not appear, it may be because the IoT Box failed to connect to the Wi-Fi network.
In this case you will need to reconnect to the Wi-Fi hotspot and try again.
</t>
<t t-if="state.status === 'disconnecting'">
The IoT Box Wi-Fi configuration has been cleared. You will need to connect to the IoT Box hotspot or connect an ethernet cable.
</t>
</div>
</div>
<BootstrapDialog identifier="'wifi-configuration'" btnName="'Configure'" onOpen.bind="getWiFiNetworks" onClose.bind="onClose">
<t t-set-slot="header">
Configure Wi-Fi
</t>
<t t-set-slot="body">
<div t-if="this.state.scanning" class="position-absolute top-0 start-0 bg-white h-100 w-100 justify-content-center align-items-center d-flex flex-column gap-3 always-on-top">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p>Currently scanning for available networks...</p>
</div>
<div class="alert alert-warning fs-6" role="alert">
Here, you can configure how the IoT Box should connect to wireless networks.
Currently, only Open and WPA networks are supported.
</div>
<div class="mt-3">
<div class="mb-3">
<select name="essid" class="form-control" id="wifi-ssid" t-model="this.form.essid">
<option>Select Network...</option>
<option t-foreach="this.state.availableWiFi.filter((wifi) => wifi)" t-as="wifi" t-key="wifi" t-att-value="wifi">
<t t-esc="wifi"/>
</option>
</select>
</div>
<div class="mb-3 d-flex gap-1">
<input name="password" t-att-type="state.isPasswordVisible ? '' : 'password'" class="form-control" aria-label="Username" aria-describedby="basic-addon1" t-model="this.form.password" placeholder="Wi-Fi password"/>
<button class="btn btn-secondary" type="button" t-on-click="togglePasswordVisibility">
<i t-att-class="'fa fa-eye' + (state.isPasswordVisible ? '-slash' : '')"></i>
</button>
</div>
</div>
</t>
<t t-set-slot="footer">
<button type="submit" class="btn btn-primary btn-sm" t-att-disabled="form.essid === 'Select Network...'" t-on-click="connectToWiFi">Connect</button>
<button t-if="this.isCurrentlyConnectedToWifi()" type="submit" class="btn btn-secondary btn-sm" t-on-click="clearConfiguration">
Disconnect From Current
</button>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</t>
</BootstrapDialog>
</t>
`;
}

View file

@ -0,0 +1,97 @@
:root {
--odoo-purple: #714B67
}
.color-primary {
color: var(--odoo-purple);
}
.btn {
transition: none !important;
font-weight: 500;
}
.btn.btn-primary {
background-color: var(--odoo-purple) !important;
border-color: var(--odoo-purple) !important;
}
.btn.btn-primary:hover {
background-color: #52374B !important;
border-color: #52374B !important;
}
.btn.btn-secondary {
color: #374151 !important;
background-color: #E7E9ED !important;
border-color: #E7E9ED !important;
}
.btn.btn-secondary:hover {
color: #1F2937 !important;
background-color: #D8DADD !important;
border-color: #D8DADD !important;
}
.alert.alert-secondary {
color: #017E84 !important;
background-color: #017E8425 !important;
border-color: #017E8480 !important;
}
.odoo-bg-primary, .bg-primary, .form-check-input:checked, .nav-pills .nav-link.active {
background-color: var(--odoo-purple) !important;
border-color: var(--odoo-purple) !important;
}
.odoo-bg-secondary, .bg-secondary {
background-color: #017E84 !important;
}
.odoo-pill {
width: 7px !important;
height: 50px;
}
.cursor-pointer {
cursor: pointer;
}
.one-line {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.link-primary {
color: var(--odoo-purple) !important;
font-size: 12px;
}
.always-on-top {
z-index: 9999;
}
.background {
background-color: #F1F1F1;
height: 100vh;
}
.icon-button {
width: 25px;
height: 25px;
font-size: 12px;
}
.handler-loading {
min-height: 300px;
}
.main-container {
max-width: 600px;
}
.dialog-body {
max-height: 70vh;
min-height: 40vh;
}

View file

@ -0,0 +1,5 @@
body {
width: 100%;
background-color: black;
color: white;
}

View file

@ -0,0 +1,55 @@
html {
height: 100%;
}
body {
cursor: none;
background: url('/iot_drivers/static/img/background-light.svg') no-repeat center center fixed;
background-size: cover;
height: 100vh;
}
.qr-code-box {
position: absolute;
left: 20px;
bottom: 20px;
}
.qr-code {
display: flex;
justify-content: center;
align-items: center;
}
.qr-code img {
width: 150px; /* Ensure wifi and url QR code images appear as same size */
height: 150px;
object-fit: contain;
}
.status-display-boxes {
position: absolute;
right: 20px;
bottom: 20px;
}
.status-display-box {
padding: 10px 20px;
background: rgba(255, 255, 255, 0.17);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 5px 0 rgba(60, 60, 60, 0.4);
border-radius: 8px;
width: 500px;
margin-top: 20px;
backdrop-filter: blur(5px);
}
.table {
--bs-table-bg: none;
}
.odoo-logo {
width: 150px;
}
.iotbox-name {
font-size: 25px;
}
.device-type {
text-transform: capitalize;
}
ul {
padding-left: 18px;
}

View file

@ -0,0 +1,8 @@
/* global owl */
const { useState, useEnv } = owl;
export default function useStore() {
const env = useEnv();
return useState(env.store);
}

View file

@ -0,0 +1,18 @@
async function getLogs() {
try {
const result = await fetch("/iot_drivers/iot_logs");
if (!result.ok) {
console.warn(`IoT box returned an error (${result.status} ${result.statusText})`);
return;
}
const data = await result.json();
document.getElementById("logs").innerText = data.logs;
document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
} catch (error) {
console.warn(`IoT box is unreachable: ${error}`);
}
}
document.addEventListener("DOMContentLoaded", function () {
setInterval(getLogs, 1000);
});

View file

@ -0,0 +1,17 @@
/* global owl */
import { Homepage } from "./Homepage.js";
import Store from "./store.js";
const { mount, reactive } = owl;
function createStore() {
return reactive(new Store());
}
mount(Homepage, document.body, {
env: {
store: createStore(),
},
dev: new URLSearchParams(window.location.search).has("debug"),
});

View file

@ -0,0 +1,178 @@
/* global owl */
import { DEVICE_ICONS } from "./components/dialog/DeviceDialog.js";
const { Component, mount, xml, useState } = owl;
const STATUS_POLL_DELAY_MS = 5000;
class StatusPage extends Component {
static props = {};
setup() {
this.state = useState({ data: {}, loading: true });
this.icons = DEVICE_ICONS;
this.loadInitialData();
}
async loadInitialData() {
try {
const response = await fetch("/iot_drivers/data");
this.state.data = await response.json();
this.state.loading = false;
} catch {
console.warn("Error while fetching data");
}
setTimeout(() => this.loadInitialData(), STATUS_POLL_DELAY_MS);
}
get accessPointSsid() {
return this.state.data.network_interfaces.filter((i) => i.is_wifi)[0]?.ssid;
}
static template = xml`
<t t-translation="off">
<div class="text-center pt-5">
<img class="odoo-logo" src="/web/static/img/logo2.png" alt="Odoo logo"/>
</div>
<div t-if="state.loading || state.data.new_database_url" class="position-fixed top-0 start-0 vh-100 w-100 justify-content-center align-items-center d-flex flex-column gap-5">
<div class="spinner-border">
<span class="visually-hidden">Loading...</span>
</div>
<span t-if="state.data.new_database_url" class="fs-4">
Connecting to <t t-out="state.data.new_database_url"/>, please wait
</span>
</div>
<div t-else="" class="container-fluid">
<!-- QR Codes shown on status page -->
<div class="qr-code-box">
<div class="status-display-box qr-code">
<div>
<h4 class="text-center mb-1">IoT Box Configuration</h4>
<hr/>
<!-- If the IoT Box is connected to internet -->
<div t-if="!state.data.is_access_point_up and state.data.qr_code_url">
<p>
1. Connect to
<!-- Only wifi connection is shown as ethernet connections look like "Wired connection 2" -->
<t t-if="state.data.wifi_ssid">
<b>
<t t-out="state.data.wifi_ssid"/>
</b>
</t>
<t t-else=""> the IoT Box network</t>
<br/>
<br/>
<div t-if="state.data.qr_code_wifi" class="qr-code">
<img t-att-src="state.data.qr_code_wifi" alt="QR Code Wi-FI"/>
</div>
</p>
<p>
2. Open the IoT Box setup page
<br/>
<br/>
<div class="qr-code">
<img t-att-src="state.data.qr_code_url" alt="QR Code Homepage"/>
</div>
</p>
</div>
<!-- If the IoT Box is in access point and not connected to internet yet -->
<div t-elif="state.data.is_access_point_up and state.data.qr_code_wifi and state.data.qr_code_url">
<p>Scan this QR code with your smartphone to connect to the IoT box's <b>Wi-Fi hotspot</b>:</p>
<div class="qr-code">
<img t-att-src="state.data.qr_code_wifi" alt="QR Code Access Point"/>
</div>
<br/>
<br/>
<p>Once you are connected to the Wi-Fi hotspot, you can scan this QR code to access the IoT box <b>Wi-Fi configuration page</b>:</p>
<div class="qr-code">
<img t-att-src="state.data.qr_code_url" alt="QR Code Wifi Config"/>
</div>
<br/>
<br/>
</div>
</div>
</div>
</div>
<div class="status-display-boxes">
<div t-if="(state.data.pairing_code || state.data.pairing_code_expired) and !state.data.is_access_point_up" class="status-display-box">
<h4 class="text-center mb-3">Pairing Code</h4>
<hr/>
<t t-if="state.data.pairing_code and !state.data.pairing_code_expired">
<h4 t-out="state.data.pairing_code" class="text-center mb-3"/>
<p class="text-center mb-3">
Enter this code in the IoT app in your Odoo database to pair the IoT Box.
</p>
</t>
<p t-else="" class="text-center mb-3">
The pairing code has expired. Please restart your IoT Box to generate a new one.
</p>
</div>
<div t-if="state.data.is_access_point_up and accessPointSsid" class="status-display-box">
<h4 class="text-center mb-3">No Internet Connection</h4>
<hr/>
<p class="mb-3">
Please connect your IoT Box to internet via an ethernet cable or connect to Wi-FI network<br/>
<a class="alert-link" t-out="accessPointSsid" /><br/>
to configure a Wi-Fi connection on the IoT Box
</p>
</div>
<div class="status-display-box">
<h4 class="text-center mb-3">Status display</h4>
<h5 class="mb-1">General</h5>
<table class="table table-hover table-sm">
<tbody>
<tr>
<td class="col-3"><i class="me-1 fa fa-fw fa-id-card"/>Identifier</td>
<td class="col-3" t-out="state.data.identifier"/>
</tr>
<tr>
<td class="col-3"><i class="me-1 fa fa-fw fa-address-book"/>Mac Address</td>
<td class="col-3" t-out="state.data.mac_address"/>
</tr>
<tr t-if="state.data.server_status">
<td class="col-3"><i class="me-1 fa fa-fw fa-database"/>Database</td>
<td class="col-3" t-out="state.data.server_status"/>
</tr>
</tbody>
</table>
<h5 class="mb-1" t-if="state.data.network_interfaces.length > 0">Internet Connection</h5>
<table class="table table-hover table-sm" t-if="state.data.network_interfaces.length > 0">
<tbody>
<tr t-foreach="state.data.network_interfaces" t-as="interface" t-key="interface.id">
<td class="col-3"><i t-att-class="'me-1 fa fa-fw fa-' + (interface.is_wifi ? 'wifi' : 'sitemap')"/><t t-out="interface.is_wifi ? interface.ssid : 'Ethernet'"/></td>
<td class="col-3" t-out="interface.ip"/>
</tr>
</tbody>
</table>
<div t-if="Object.keys(state.data.devices).length > 0">
<h5 class="mb-1">Devices</h5>
<table class="table table-hover table-sm">
<tbody>
<tr t-foreach="Object.keys(state.data.devices)" t-as="deviceType" t-key="deviceType">
<td class="device-type col-3">
<i t-att-class="'me-1 fa fa-fw fa- ' + icons[deviceType]"/>
<t t-out="deviceType.replaceAll('_', ' ') + (deviceType === 'unsupported' ? '' : 's')"/>
</td>
<td class="col-3">
<ul>
<li t-foreach="state.data.devices[deviceType].slice(0, 10)" t-as="device" t-key="device.identifier">
<t t-out="device.name"/>
</li>
<li t-if="state.data.devices[deviceType].length > 10">...</li>
</ul>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</t>
`;
}
mount(StatusPage, document.body);

View file

@ -0,0 +1,34 @@
export default class Store {
constructor() {
this.setup();
}
setup() {
this.url = "";
this.base = {};
this.update = 0;
this.isLinux = false;
this.advanced = false;
}
async rpc({ url, method = "GET", params = {} }) {
if (method === "POST") {
const response = await fetch(url, {
method,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
params,
}),
});
const data = await response.json();
return data.result;
} else if (method === "GET") {
const response = await fetch(url);
return await response.json();
}
return false;
}
}

View file

@ -0,0 +1,5 @@
from . import system
from . import helpers
from . import wifi
from . import route
from . import upgrade

View file

@ -0,0 +1,143 @@
import datetime
import logging
import requests
from cryptography import x509
from cryptography.x509.oid import NameOID
from pathlib import Path
from odoo.addons.iot_drivers.tools.helpers import (
get_conf,
get_identifier,
get_path_nginx,
odoo_restart,
require_db,
start_nginx_server,
update_conf,
)
from odoo.addons.iot_drivers.tools.system import IS_RPI, IS_TEST, IS_WINDOWS
_logger = logging.getLogger(__name__)
@require_db
def ensure_validity():
"""Ensure that the certificate is up to date
Load a new if the current one is not valid or if there is none.
This method also sends the certificate end date to the database.
"""
inform_database(get_certificate_end_date() or download_odoo_certificate())
def get_certificate_end_date():
"""Check if the certificate is up to date and valid
:return: End date of the certificate if it is valid, None otherwise
:rtype: str
"""
base_path = [get_path_nginx(), 'conf'] if IS_WINDOWS else ['/etc/ssl/certs']
path = Path(*base_path, 'nginx-cert.crt')
if not path.exists():
return None
try:
cert = x509.load_pem_x509_certificate(path.read_bytes())
except ValueError:
_logger.exception("Unable to read certificate file.")
return None
common_name = next(
(name_attribute.value for name_attribute in cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)), ''
)
cert_end_date = cert.not_valid_after
if (
common_name == 'OdooTempIoTBoxCertificate'
or datetime.datetime.now() > cert_end_date - datetime.timedelta(days=10)
):
_logger.debug("SSL certificate '%s' must be updated.", common_name)
return None
_logger.debug("SSL certificate '%s' is valid until %s", common_name, cert_end_date)
return str(cert_end_date)
def download_odoo_certificate(retry=0):
"""Send a request to Odoo with customer db_uuid and enterprise_code
to get a true certificate
"""
if IS_TEST:
_logger.info("Skipping certificate download in test mode.")
return None
db_uuid = get_conf('db_uuid')
enterprise_code = get_conf('enterprise_code')
if not db_uuid:
return None
try:
response = requests.post(
'https://www.odoo.com/odoo-enterprise/iot/x509',
json={'params': {'db_uuid': db_uuid, 'enterprise_code': enterprise_code}},
timeout=95, # let's encrypt library timeout
)
response.raise_for_status()
response_body = response.json()
except (requests.exceptions.RequestException, ValueError) as e:
_logger.warning("An error occurred while trying to reach odoo.com to get a new certificate: %s", e)
if retry < 5:
return download_odoo_certificate(retry=retry + 1)
return _logger.exception("Maximum attempt to download the odoo.com certificate reached")
server_error = response_body.get('error')
if server_error:
_logger.error("Server error received from odoo.com while trying to get the certificate: %s", server_error)
return None
result = response_body.get('result', {})
certificate_error = result.get('error')
if certificate_error:
_logger.warning("Error received from odoo.com while trying to get the certificate: %s", certificate_error)
return None
update_conf({'subject': result['subject_cn']})
certificate = result['x509_pem']
private_key = result['private_key_pem']
if not certificate or not private_key: # ensure not empty strings
_logger.error("The certificate received from odoo.com is not valid.")
return None
if IS_RPI:
Path('/etc/ssl/certs/nginx-cert.crt').write_text(certificate, encoding='utf-8')
Path('/root_bypass_ramdisks/etc/ssl/certs/nginx-cert.crt').write_text(certificate, encoding='utf-8')
Path('/etc/ssl/private/nginx-cert.key').write_text(private_key, encoding='utf-8')
Path('/root_bypass_ramdisks/etc/ssl/private/nginx-cert.key').write_text(private_key, encoding='utf-8')
start_nginx_server()
return str(x509.load_pem_x509_certificate(certificate.encode()).not_valid_after)
else:
Path(get_path_nginx(), 'conf', 'nginx-cert.crt').write_text(certificate, encoding='utf-8')
Path(get_path_nginx(), 'conf', 'nginx-cert.key').write_text(private_key, encoding='utf-8')
odoo_restart(3)
return None
@require_db
def inform_database(ssl_certificate_end_date, server_url=None):
"""Inform the database about the certificate end date.
If end date is ``None``, we avoid sending a useless request.
:param str ssl_certificate_end_date: End date of the SSL certificate
:param str server_url: URL of the Odoo server (provided by decorator).
"""
if not ssl_certificate_end_date:
return
try:
response = requests.post(
server_url + "/iot/box/update_certificate_status",
json={'params': {'identifier': get_identifier(), 'ssl_certificate_end_date': ssl_certificate_end_date}},
timeout=5,
)
response.raise_for_status()
except requests.exceptions.RequestException:
_logger.exception("Could not reach configured server to inform about the certificate status")

View file

@ -0,0 +1,642 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import configparser
from enum import Enum
from functools import cache, wraps
from importlib import util
import inspect
import io
import logging
import netifaces
from pathlib import Path
import re
import requests
import secrets
import subprocess
import socket
from urllib.parse import parse_qs
import urllib3.util
import sys
from threading import Thread, Lock
import time
import zipfile
from werkzeug.exceptions import Locked
from odoo import http, release, service
from odoo.addons.iot_drivers.tools.system import IOT_CHAR, IOT_RPI_CHAR, IOT_WINDOWS_CHAR, IS_RPI, IS_TEST, IS_WINDOWS
from odoo.tools.func import reset_cached_properties
from odoo.tools.misc import file_path
lock = Lock()
_logger = logging.getLogger(__name__)
if IS_RPI:
import crypt
class Orientation(Enum):
"""xrandr/wlr-randr screen orientation for kiosk mode"""
NORMAL = 'normal'
INVERTED = '180'
LEFT = '90'
RIGHT = '270'
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)
service.server.restart()
def toggleable(function):
"""Decorate a function to enable or disable it based on the value
of the associated configuration parameter.
"""
fname = f"<function {function.__module__}.{function.__qualname__}>"
@wraps(function)
def devtools_wrapper(*args, **kwargs):
if args and args[0].__class__.__name__ == 'DriverController':
if get_conf('longpolling', section='devtools'):
_logger.warning("Refusing call to %s: longpolling is disabled by devtools", fname)
raise Locked("Longpolling disabled by devtools") # raise to make the http request fail
elif function.__name__ == 'action':
action = args[1].get('action', 'default') # first argument is self (containing Driver instance), second is 'data'
disabled_actions = (get_conf('actions', section='devtools') or '').split(',')
if action in disabled_actions or '*' in disabled_actions:
_logger.warning("Ignoring call to %s: '%s' action is disabled by devtools", fname, action)
return None
elif get_conf('general', section='devtools'):
_logger.warning("Ignoring call to %s: method is disabled by devtools", fname)
return None
return function(*args, **kwargs)
return devtools_wrapper
def require_db(function):
"""Decorator to check if the IoT Box is connected to the internet
and to a database before executing the function.
This decorator injects the ``server_url`` parameter if the function has it.
"""
@wraps(function)
def wrapper(*args, **kwargs):
fname = f"<function {function.__module__}.{function.__qualname__}>"
server_url = get_odoo_server_url()
iot_box_ip = get_ip()
if not iot_box_ip or iot_box_ip == "10.11.12.1" or not server_url:
_logger.info('Ignoring the function %s without a connected database', fname)
return
arg_name = 'server_url'
if arg_name in inspect.signature(function).parameters:
_logger.debug('Adding server_url param to %s', fname)
kwargs[arg_name] = server_url
return function(*args, **kwargs)
return wrapper
if IS_WINDOWS:
def start_nginx_server():
path_nginx = get_path_nginx()
if path_nginx:
_logger.info('Start Nginx server: %s\\nginx.exe', path_nginx)
subprocess.Popen([str(path_nginx / 'nginx.exe')], cwd=str(path_nginx))
elif IS_RPI:
def start_nginx_server():
subprocess.check_call(["sudo", "service", "nginx", "restart"])
else:
def start_nginx_server():
pass
def check_image():
"""Check if the current image of IoT Box is up to date
:return: dict containing major and minor versions of the latest image available
:rtype: dict
"""
try:
response = requests.get('https://nightly.odoo.com/master/iotbox/SHA1SUMS.txt', timeout=5)
response.raise_for_status()
data = response.content.decode()
except requests.exceptions.HTTPError:
_logger.exception('Could not reach the server to get the latest image version')
return False
check_file = {}
value_actual = ''
for line in data.split('\n'):
if line:
value, name = line.split(' ')
check_file.update({value: name})
if name == 'iotbox-latest.zip':
value_latest = value
elif name == get_img_name():
value_actual = value
if value_actual == value_latest: # pylint: disable=E0601
return False
version = check_file.get(value_latest, 'Error').replace('iotboxv', '').replace('.zip', '').split('_')
return {'major': version[0], 'minor': version[1]}
def save_conf_server(url, token, db_uuid, enterprise_code, db_name=None):
"""
Save server configurations in odoo.conf
:param url: The URL of the server
:param token: The token to authenticate the server
:param db_uuid: The database UUID
:param enterprise_code: The enterprise code
:param db_name: The database name
"""
update_conf({
'remote_server': url,
'token': token,
'db_uuid': db_uuid,
'enterprise_code': enterprise_code,
'db_name': db_name,
})
get_odoo_server_url.cache_clear()
def generate_password():
"""
Generate an unique code to secure raspberry pi
"""
alphabet = 'abcdefghijkmnpqrstuvwxyz23456789'
password = ''.join(secrets.choice(alphabet) for i in range(12))
try:
shadow_password = crypt.crypt(password, crypt.mksalt())
subprocess.run(('sudo', 'usermod', '-p', shadow_password, 'pi'), check=True)
subprocess.run(('sudo', 'cp', '/etc/shadow', '/root_bypass_ramdisks/etc/shadow'), check=True)
return password
except subprocess.CalledProcessError as e:
_logger.exception("Failed to generate password: %s", e.output)
return 'Error: Check IoT log'
def get_img_name():
major, minor = get_version()[1:].split('.')
return 'iotboxv%s_%s.zip' % (major, minor)
def get_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(('8.8.8.8', 1)) # Google DNS
return s.getsockname()[0]
except OSError as e:
_logger.warning("Could not get local IP address: %s", e)
return None
finally:
s.close()
@cache
def get_identifier():
if IS_RPI:
return read_file_first_line('/sys/firmware/devicetree/base/serial-number').strip("\x00")
elif IS_TEST:
return 'test_identifier'
# On windows, get motherboard's uuid (serial number isn't reliable as it's not always present)
command = ['powershell', '-Command', "(Get-CimInstance Win32_ComputerSystemProduct).UUID"]
p = subprocess.run(command, stdout=subprocess.PIPE, check=False)
identifier = get_conf('generated_identifier') # Fallback identifier if windows does not return mb UUID
if p.returncode == 0 and p.stdout.decode().strip():
return p.stdout.decode().strip()
_logger.error("Failed to get Windows IoT serial number, defaulting to a random identifier")
if not identifier:
identifier = secrets.token_hex()
update_conf({'generated_identifier': identifier})
return identifier
def get_mac_address():
interfaces = netifaces.interfaces()
for interface in interfaces:
if netifaces.ifaddresses(interface).get(netifaces.AF_INET):
addr = netifaces.ifaddresses(interface).get(netifaces.AF_LINK)[0]['addr']
if addr != '00:00:00:00:00:00':
return addr
def get_path_nginx():
return path_file('nginx')
@cache
def get_odoo_server_url():
"""Get the URL of the linked Odoo database.
:return: The URL of the linked Odoo database.
:rtype: str or None
"""
return get_conf('remote_server')
def get_token():
""":return: The token to authenticate the server"""
return get_conf('token')
def get_commit_hash():
return subprocess.run(
['git', '--work-tree=/home/pi/odoo/', '--git-dir=/home/pi/odoo/.git', 'rev-parse', '--short', 'HEAD'],
stdout=subprocess.PIPE,
check=True,
).stdout.decode('ascii').strip()
@cache
def get_version(detailed_version=False):
if IS_RPI:
image_version = read_file_first_line('/var/odoo/iotbox_version')
elif IS_WINDOWS:
# updated manually when big changes are made to the windows virtual IoT
image_version = '23.11'
elif IS_TEST:
image_version = 'test'
version = IOT_CHAR + image_version
if detailed_version:
# Note: on windows IoT, the `release.version` finish with the build date
version += f"-{release.version}"
if IS_RPI:
version += f'#{get_commit_hash()}'
return version
def delete_iot_handlers():
"""Delete all drivers, interfaces and libs if any.
This is needed to avoid conflicts with the newly downloaded drivers.
"""
try:
iot_handlers = Path(file_path('iot_drivers/iot_handlers'))
filenames = [
f"odoo/addons/iot_drivers/iot_handlers/{file.relative_to(iot_handlers)}"
for file in iot_handlers.glob('**/*')
if file.is_file()
]
unlink_file(*filenames)
_logger.info("Deleted old IoT handlers")
except OSError:
_logger.exception('Failed to delete old IoT handlers')
@toggleable
@require_db
def download_iot_handlers(auto=True, server_url=None):
"""Get the drivers from the configured Odoo server.
If drivers did not change on the server, download
will be skipped.
:param auto: If True, the download will depend on the parameter set in the database
:param server_url: The URL of the connected Odoo database (provided by decorator).
"""
etag = get_conf('iot_handlers_etag')
try:
response = requests.post(
server_url + '/iot/get_handlers',
data={'identifier': get_identifier(), 'auto': auto},
timeout=8,
headers={'If-None-Match': etag} if etag else None,
)
response.raise_for_status()
except requests.exceptions.RequestException:
_logger.exception('Could not reach configured server to download IoT handlers')
return
data = response.content
if response.status_code == 304 or not data:
_logger.info('No new IoT handler to download')
return
try:
update_conf({'iot_handlers_etag': response.headers['ETag'].strip('"')})
except KeyError:
_logger.exception('No ETag in the response headers')
try:
zip_file = zipfile.ZipFile(io.BytesIO(data))
except zipfile.BadZipFile:
_logger.exception('Bad IoT handlers response received: not a zip file')
return
delete_iot_handlers()
path = path_file('odoo', 'addons', 'iot_drivers', 'iot_handlers')
zip_file.extractall(path)
def compute_iot_handlers_addon_name(handler_kind, handler_file_name):
return "odoo.addons.iot_drivers.iot_handlers.{handler_kind}.{handler_name}".\
format(handler_kind=handler_kind, handler_name=handler_file_name.removesuffix('.py'))
def load_iot_handlers():
"""
This method loads local files: 'odoo/addons/iot_drivers/iot_handlers/drivers' and
'odoo/addons/iot_drivers/iot_handlers/interfaces'
And execute these python drivers and interfaces
"""
for directory in ['interfaces', 'drivers']:
path = file_path(f'iot_drivers/iot_handlers/{directory}')
filesList = get_handlers_files_to_load(path)
for file in filesList:
spec = util.spec_from_file_location(compute_iot_handlers_addon_name(directory, file), str(Path(path).joinpath(file)))
if spec:
module = util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except Exception:
_logger.exception('Unable to load handler file: %s', file)
reset_cached_properties(http.root)
def get_handlers_files_to_load(handler_path):
"""
Get all handler files that an IoT system should load in a list.
- Rpi IoT boxes load file without suffixe and _L
- Windows IoT load file without suffixes and _W
:param handler_path: The path to the directory containing the files (either drivers or interfaces)
:return: files corresponding to the current IoT system
:rtype list:
"""
if IS_RPI:
return [x.name for x in Path(handler_path).glob(f'*[!{IOT_WINDOWS_CHAR}].*')]
elif IS_WINDOWS:
return [x.name for x in Path(handler_path).glob(f'*[!{IOT_RPI_CHAR}].*')]
return []
def odoo_restart(delay=0):
"""
Restart Odoo service
:param delay: Delay in seconds before restarting the service (Default: 0)
"""
IR = IoTRestart(delay)
IR.start()
def path_file(*args):
"""Return the path to the file from IoT Box root or Windows Odoo
server folder
:return: The path to the file
"""
return Path(sys.path[0]).parent.joinpath(*args)
def read_file_first_line(filename):
path = path_file(filename)
if path.exists():
with path.open('r') as f:
return f.readline().strip('\n')
def unlink_file(*filenames):
for filename in filenames:
path = path_file(filename)
if path.exists():
path.unlink()
def write_file(filename, text, mode='w'):
"""This function writes 'text' to 'filename' file
:param filename: The name of the file to write to
:param text: The text to write to the file
:param mode: The mode to open the file in (Default: 'w')
"""
path = path_file(filename)
with open(path, mode) as f:
f.write(text)
def download_from_url(download_url, path_to_filename):
"""
This function downloads from its 'download_url' argument and
saves the result in 'path_to_filename' file
The 'path_to_filename' needs to be a valid path + file name
(Example: 'C:\\Program Files\\Odoo\\downloaded_file.zip')
"""
try:
request_response = requests.get(download_url, timeout=60)
request_response.raise_for_status()
write_file(path_to_filename, request_response.content, 'wb')
_logger.info('Downloaded %s from %s', path_to_filename, download_url)
except requests.exceptions.RequestException:
_logger.exception('Failed to download from %s', download_url)
def unzip_file(path_to_filename, path_to_extract):
"""
This function unzips 'path_to_filename' argument to
the path specified by 'path_to_extract' argument
and deletes the originally used .zip file
Example: unzip_file('C:\\Program Files\\Odoo\\downloaded_file.zip', 'C:\\Program Files\\Odoo\\new_folder'))
Will extract all the contents of 'downloaded_file.zip' to the 'new_folder' location)
"""
try:
path = path_file(path_to_filename)
with zipfile.ZipFile(path) as zip_file:
zip_file.extractall(path_file(path_to_extract))
Path(path).unlink()
_logger.info('Unzipped %s to %s', path_to_filename, path_to_extract)
except Exception:
_logger.exception('Failed to unzip %s', path_to_filename)
def update_conf(values, section='iot.box'):
"""Update odoo.conf with the given key and value.
:param dict values: key-value pairs to update the config with.
:param str section: The section to update the key-value pairs in (Default: iot.box).
"""
_logger.debug("Updating odoo.conf with values: %s", values)
conf = get_conf()
if not conf.has_section(section):
_logger.debug("Creating new section '%s' in odoo.conf", section)
conf.add_section(section)
for key, value in values.items():
conf.set(section, key, value) if value else conf.remove_option(section, key)
with open(path_file("odoo.conf"), "w", encoding='utf-8') as f:
conf.write(f)
def get_conf(key=None, section='iot.box'):
"""Get the value of the given key from odoo.conf, or the full config if no key is provided.
:param key: The key to get the value of.
:param section: The section to get the key from (Default: iot.box).
:return: The value of the key provided or None if it doesn't exist, or full conf object if no key is provided.
"""
conf = configparser.RawConfigParser()
conf.read(path_file("odoo.conf"))
return conf.get(section, key, fallback=None) if key else conf # Return the key's value or the configparser object
def disconnect_from_server():
"""Disconnect the IoT Box from the server"""
update_conf({
'remote_server': '',
'token': '',
'db_uuid': '',
'db_name': '',
'enterprise_code': '',
'screen_orientation': '',
'browser_url': '',
'iot_handlers_etag': '',
'last_websocket_message_id': '',
})
odoo_restart()
def save_browser_state(url=None, orientation=None):
"""Save the browser state to the file
:param url: The URL the browser is on (if None, the URL is not saved)
:param orientation: The orientation of the screen (if None, the orientation is not saved)
"""
to_update = {
"browser_url": url,
"screen_orientation": orientation.name.lower() if orientation else None,
}
# Only update the values that are not None
update_conf({k: v for k, v in to_update.items() if v is not None})
def load_browser_state():
"""Load the browser state from the file
:return: The URL the browser is on and the orientation of the screen (default to NORMAL)
"""
url = get_conf('browser_url')
orientation = get_conf('screen_orientation') or Orientation.NORMAL.name
return url, Orientation[orientation.upper()]
def url_is_valid(url):
"""Checks whether the provided url is a valid one or not
:param url: the URL to check
:return: True if the URL is valid and False otherwise
:rtype: bool
"""
try:
result = urllib3.util.parse_url(url.strip())
return all([result.scheme in ["http", "https"], result.netloc, result.host != 'localhost'])
except urllib3.exceptions.LocationParseError:
return False
def parse_url(url):
"""Parses URL params and returns them as a dictionary starting by the url.
Does not allow multiple params with the same name (e.g. <url>?a=1&a=2 will return the same as <url>?a=1)
:param url: the URL to parse
:return: the dictionary containing the URL and params
:rtype: dict
"""
if not url_is_valid(url):
raise ValueError("Invalid URL provided.")
url = urllib3.util.parse_url(url.strip())
search_params = {
key: value[0]
for key, value in parse_qs(url.query, keep_blank_values=True).items()
}
return {
"url": f"{url.scheme}://{url.netloc}",
**search_params,
}
def reset_log_level():
"""Reset the log level to the default one if the reset timestamp is reached
This timestamp is set by the log controller in `iot_drivers/homepage.py` when the log level is changed
"""
log_level_reset_timestamp = get_conf('log_level_reset_timestamp')
if log_level_reset_timestamp and float(log_level_reset_timestamp) <= time.time():
_logger.info("Resetting log level to default.")
update_conf({
'log_level_reset_timestamp': '',
'log_handler': ':INFO,werkzeug:WARNING',
'log_level': 'info',
})
def _get_system_uptime():
if not IS_RPI:
return 0
uptime_string = read_file_first_line("/proc/uptime")
return float(uptime_string.split(" ")[0])
def _get_raspberry_pi_model():
"""Returns the Raspberry Pi model number (e.g. 4) as an integer
Returns 0 if the model can't be determined, or -1 if called on Windows
:rtype: int
"""
if not IS_RPI:
return -1
with open('/proc/device-tree/model', encoding='utf-8') as model_file:
match = re.search(r'Pi (\d)', model_file.read())
return int(match[1]) if match else 0
raspberry_pi_model = _get_raspberry_pi_model()
odoo_start_time = time.monotonic()
system_start_time = odoo_start_time - _get_system_uptime()
def is_ngrok_enabled():
"""Check if a ngrok tunnel is active on the IoT Box"""
try:
response = requests.get("http://localhost:4040/api/tunnels", timeout=5)
response.raise_for_status()
response.json()
return True
except (requests.exceptions.RequestException, ValueError):
# if the request fails or the response is not valid JSON,
# it means ngrok is not enabled or not running
_logger.debug("Ngrok isn't running.", exc_info=True)
return False
def toggle_remote_connection(token=""):
"""Enable/disable remote connection to the IoT Box using ngrok.
If the token is provided, it will set up ngrok with the
given authtoken, else it will disable the ngrok service.
:param str token: The ngrok authtoken to use for the connection"""
_logger.info("Toggling remote connection with token: %s...", token[:5] if token else "<No Token>")
p = subprocess.run(
['sudo', 'ngrok', 'config', 'add-authtoken', token, '--config', '/home/pi/ngrok.yml'],
check=False,
)
if p.returncode == 0:
subprocess.run(
['sudo', 'systemctl', 'restart' if token else "stop", 'odoo-ngrok.service'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return True
return False

View file

@ -0,0 +1,31 @@
import logging
from odoo.addons.iot_drivers.tools.system import IS_RPI
from odoo import http
_logger = logging.getLogger(__name__)
def iot_route(route=None, linux_only=False, **kwargs):
"""A wrapper for the http.route function that sets useful defaults for IoT:
- ``auth = 'none'``
- ``save_session = False``
Both auth and sessions are useless on IoT since we have no DB and no users.
:param route: The route to be decorated.
:param linux_only: If ``True``, the route will be forbidden for virtual IoT Boxes.
"""
if 'auth' not in kwargs:
kwargs['auth'] = 'none'
if 'save_session' not in kwargs:
kwargs['save_session'] = False
http_decorator = http.route(route, **kwargs)
def decorator(endpoint):
if linux_only and not IS_RPI:
return None # Remove the route if not Linux (will return 404)
return http_decorator(endpoint)
return decorator

View file

@ -0,0 +1,28 @@
"""Operating system-related utilities for the IoT"""
from platform import system, release
IOT_SYSTEM = system()
IOT_RPI_CHAR, IOT_WINDOWS_CHAR, IOT_TEST_CHAR = "L", "W", "T"
IS_WINDOWS = IOT_SYSTEM[0] == IOT_WINDOWS_CHAR
IS_RPI = 'rpi' in release()
IS_TEST = not IS_RPI and not IS_WINDOWS
"""IoT system "Test" correspond to any non-Raspberry Pi nor windows system.
Expected to be Linux or macOS used locally for development purposes."""
IOT_CHAR = IOT_RPI_CHAR if IS_RPI else IOT_WINDOWS_CHAR if IS_WINDOWS else IOT_TEST_CHAR
"""IoT system character used in the identifier and version.
- 'L' for Raspberry Pi
- 'W' for Windows
- 'T' for Test (non-Raspberry Pi nor Windows)"""
if IS_RPI:
def rpi_only(function):
"""Decorator to check if the system is raspberry pi before running the function."""
return function
else:
def rpi_only(_):
"""No-op decorator for non raspberry pi systems."""
return lambda *args, **kwargs: None

View file

@ -0,0 +1,204 @@
"""Module to manage odoo code upgrades using git"""
import logging
import requests
import subprocess
from odoo.addons.iot_drivers.tools.helpers import (
odoo_restart,
path_file,
require_db,
toggleable,
unlink_file,
)
from odoo.addons.iot_drivers.tools.system import rpi_only, IS_RPI, IS_TEST
_logger = logging.getLogger(__name__)
def git(*args):
"""Run a git command with the given arguments, taking system
into account.
:param args: list of arguments to pass to git
"""
git_executable = 'git' if IS_RPI else path_file('git', 'cmd', 'git.exe')
command = [git_executable, f'--work-tree={path_file("odoo")}', f'--git-dir={path_file("odoo", ".git")}', *args]
p = subprocess.run(command, stdout=subprocess.PIPE, text=True, check=False)
if p.returncode == 0:
return p.stdout.strip()
return None
def pip(*args):
"""Run a pip command with the given arguments, taking system
into account.
:param args: list of arguments to pass to pip
"""
python_executable = [] if IS_RPI else [path_file('python', 'python.exe'), '-m']
command = [*python_executable, 'pip', *args]
if IS_RPI and args[0] == 'install':
command.append('--user')
command.append('--break-system-package')
p = subprocess.run(command, stdout=subprocess.PIPE, check=False)
return p.returncode
def get_db_branch(server_url):
"""Get the current branch of the database.
:param server_url: The URL of the connected Odoo database.
:return: the current branch of the database
"""
try:
response = requests.post(server_url + "/web/webclient/version_info", json={}, timeout=5)
response.raise_for_status()
except requests.exceptions.HTTPError:
_logger.exception('Could not reach configured server to get the Odoo version')
return None
try:
return response.json()['result']['server_serie'].replace('~', '-')
except ValueError:
_logger.exception('Could not load JSON data: Received data is not valid JSON.\nContent:\n%s', response.content)
return None
@toggleable
@require_db
def check_git_branch(server_url=None):
"""Check if the local branch is the same as the connected Odoo DB and
checkout to match it if needed.
:param server_url: The URL of the connected Odoo database (provided by decorator).
"""
if IS_TEST:
return
db_branch = get_db_branch(server_url)
if not db_branch:
_logger.warning("Could not get the database branch, skipping git checkout")
return
try:
if not git('ls-remote', 'origin', db_branch):
db_branch = 'master'
local_branch = git('symbolic-ref', '-q', '--short', 'HEAD')
_logger.info("IoT Box git branch: %s / Associated Odoo db's git branch: %s", local_branch, db_branch)
if db_branch != local_branch:
# Repository updates
unlink_file("odoo/.git/shallow.lock") # In case of previous crash/power-off, clean old lockfile
checkout(db_branch)
update_requirements()
# System updates
update_packages()
# Miscellaneous updates (version migrations)
misc_migration_updates()
_logger.warning("Update completed, restarting...")
odoo_restart()
except Exception:
_logger.exception('An error occurred while trying to update the code with git')
def _ensure_production_remote(local_remote):
"""Ensure that the remote repository is the production one
(https://github.com/odoo/odoo.git).
:param local_remote: The name of the remote repository.
"""
production_remote = "https://github.com/odoo/odoo.git"
if git('remote', 'get-url', local_remote) != production_remote:
_logger.info("Setting remote repository to production: %s", production_remote)
git('remote', 'set-url', local_remote, production_remote)
def checkout(branch, remote=None):
"""Checkout to the given branch of the given git remote.
:param branch: The name of the branch to check out.
:param remote: The name of the local git remote to use (usually ``origin`` but computed if not provided).
"""
_logger.info("Preparing local repository for checkout")
git('branch', '-m', branch) # Rename the current branch to the target branch name
remote = remote or git('config', f'branch.{branch}.remote') or 'origin'
_ensure_production_remote(remote)
_logger.warning("Checking out %s/%s", remote, branch)
git('remote', 'set-branches', remote, branch)
git('fetch', remote, branch, '--depth=1', '--prune') # refs/remotes to avoid 'unknown revision'
git('reset', 'FETCH_HEAD', '--hard')
_logger.info("Cleaning the working directory")
git('clean', '-dfx')
def update_requirements():
"""Update the Python requirements of the IoT Box, installing the ones
listed in the requirements.txt file.
"""
requirements_file = path_file('odoo', 'addons', 'iot_box_image', 'configuration', 'requirements.txt')
if not requirements_file.exists():
_logger.info("No requirements file found, not updating.")
return
_logger.info("Updating pip requirements")
pip('install', '-r', requirements_file)
@rpi_only
def update_packages():
"""Update apt packages on the IoT Box, installing the ones listed in
the packages.txt file.
Requires ``writable`` context manager.
"""
packages_file = path_file('odoo', 'addons', 'iot_box_image', 'configuration', 'packages.txt')
if not packages_file.exists():
_logger.info("No packages file found, not updating.")
return
# update and install packages in the foreground
commands = (
"export DEBIAN_FRONTEND=noninteractive && "
"mount -t proc proc /proc && "
"apt-get update && "
f"xargs apt-get -y -o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold' install < {packages_file}"
)
_logger.warning("Updating apt packages")
if subprocess.run(
f'sudo chroot /root_bypass_ramdisks /bin/bash -c "{commands}"', shell=True, check=False
).returncode != 0:
_logger.error("An error occurred while trying to update the packages")
return
# upgrade and remove packages in the background
background_cmd = 'chroot /root_bypass_ramdisks /bin/bash -c "apt-get upgrade -y && apt-get -y autoremove"'
subprocess.Popen(["sudo", "bash", "-c", background_cmd], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
@rpi_only
def misc_migration_updates():
"""Run miscellaneous updates after the code update."""
_logger.warning("Running version migration updates")
if path_file('odoo', 'addons', 'point_of_sale').exists():
# TODO: remove this when v18.0 is deprecated (point_of_sale/tools/posbox/ -> iot_box_image/)
ramdisks_service = "/root_bypass_ramdisks/etc/systemd/system/ramdisks.service"
subprocess.run(
['sudo', 'sed', '-i', 's|iot_box_image|point_of_sale/tools/posbox|g', ramdisks_service], check=False
)
# TODO: Remove this code when v16 is deprecated
with open('/home/pi/odoo/addons/point_of_sale/tools/posbox/configuration/odoo.conf', 'r+', encoding='utf-8') as f:
if "server_wide_modules" not in f.read():
f.write("server_wide_modules=hw_drivers,hw_posbox_homepage,web\n")
if path_file('odoo', 'addons', 'hw_drivers').exists():
# TODO: remove this when v18.4 is deprecated (hw_drivers/,hw_posbox_homepage/ -> iot_drivers/)
subprocess.run(
['sed', '-i', 's|iot_drivers|hw_drivers,hw_posbox_homepage|g', '/home/pi/odoo.conf'], check=False
)

View file

@ -0,0 +1,327 @@
"""Module to manage Wi-Fi connections and access point mode using
NetworkManager and ``nmcli`` tool.
"""
import base64
from io import BytesIO
import logging
import qrcode
import re
import secrets
import subprocess
import time
from pathlib import Path
from functools import cache
from .helpers import get_ip, get_identifier, get_conf
_logger = logging.getLogger(__name__)
START = True
STOP = False
def _nmcli(args, sudo=False):
"""Run nmcli command with given arguments and return the output.
:param args: Arguments to pass to nmcli
:param sudo: Run the command with sudo privileges
:return: Output of the command
:rtype: str
"""
command = ['nmcli', '-t', *args]
if sudo:
command = ['sudo', *command]
p = subprocess.run(command, stdout=subprocess.PIPE, check=False)
if p.returncode == 0:
return p.stdout.decode().strip()
return None
def _scan_network():
"""Scan for connected/available networks and return the SSID.
:return: list of found SSIDs with a flag indicating whether it's the connected network
:rtype: list[tuple[bool, str]]
"""
ssids = _nmcli(['-f', 'ACTIVE,SSID', 'dev', 'wifi'], sudo=True)
ssids_dict = {
ssid.split(':')[-1]: ssid.startswith('yes:')
for ssid in sorted(ssids.splitlines())
if ssid
} if ssids else {}
_logger.debug("Found networks: %s", ssids_dict)
return [(status, ssid) for ssid, status in ssids_dict.items()]
def _reload_network_manager():
"""Reload the NetworkManager service.
Can be useful when ``nmcli`` doesn't respond correctly (e.g. can't fetch available
networks properly).
:return: True if the service is reloaded successfully
:rtype: bool
"""
if subprocess.run(['sudo', 'systemctl', 'reload', 'NetworkManager'], check=False).returncode == 0:
return True
else:
_logger.error('Failed to reload NetworkManager service')
return False
def get_current():
"""Get the SSID of the currently connected network, or None if it is not connected
:return: The connected network's SSID, or None
:rtype: str | None
"""
nmcli_output = _nmcli(['-f', 'GENERAL.CONNECTION,GENERAL.STATE', 'dev', 'show', 'wlan0'])
if not nmcli_output:
return None
ssid_match = re.match(r'GENERAL\.CONNECTION:(\S+)\n', nmcli_output)
if not ssid_match:
return None
return ssid_match[1] if '(connected)' in nmcli_output else None
def get_available_ssids():
"""Get the SSIDs of the available networks. May reload NetworkManager service
if the list doesn't contain all the available networks.
:return: List of available SSIDs
:rtype: list[str]
"""
ssids = _scan_network()
# If the list contains only the connected network, reload network manager and rescan
if len(ssids) == 1 and is_current(ssids[0][1]) and _reload_network_manager():
ssids = _scan_network()
return [ssid for (_, ssid) in ssids]
def is_current(ssid):
"""Check if the given SSID is the one connected."""
return ssid == get_current()
def disconnect():
"""Disconnects from the current network.
:return: True if disconnected successfully
"""
ssid = get_current()
if not ssid:
return True
_logger.info('Disconnecting from network %s', ssid)
_nmcli(['con', 'down', ssid], sudo=True)
if not get_ip():
toggle_access_point(START)
return not is_current(ssid)
def _connect(ssid, password):
"""Disables access point mode and connects to the given
network using the provided password.
:param str ssid: SSID of the network to connect to
:param str password: Password of the network to connect to
:return: True if connected successfully
"""
if ssid not in get_available_ssids() or not toggle_access_point(STOP):
return False
_logger.info('Connecting to network %s', ssid)
_nmcli(['device', 'wifi', 'connect', ssid, 'password', password], sudo=True)
if not _validate_configuration(ssid):
_logger.warning('Failed to make network configuration persistent for %s', ssid)
return is_current(ssid)
def reconnect(ssid=None, password=None, force_update=False):
"""Reconnect to the given network. If a connection to the network already exists,
we can reconnect to it without providing the password (e.g. after a reboot).
If no SSID is provided, we will try to reconnect to the last connected network.
:param str ssid: SSID of the network to reconnect to (optional)
:param str password: Password of the network to reconnect to (optional)
:param bool force_update: Force connection, even if internet is already available through ethernet
:return: True if reconnected successfully
"""
if not force_update:
timer = time.time() + 10 # Required on boot: wait 10 sec (see: https://github.com/odoo/odoo/pull/187862)
while time.time() < timer:
if get_ip():
if is_access_point():
toggle_access_point(STOP)
return True
time.sleep(.5)
if not ssid:
return toggle_access_point(START)
should_start_access_point_on_failure = is_access_point() or not get_ip()
# Try to re-enable an existing connection, or set up a new persistent one
if toggle_access_point(STOP) and not _nmcli(['con', 'up', ssid], sudo=True):
_connect(ssid, password)
connected_successfully = is_current(ssid)
if not connected_successfully and should_start_access_point_on_failure:
toggle_access_point(START)
return connected_successfully
def _validate_configuration(ssid):
"""For security reasons, everything that is saved in the root filesystem
on IoT Boxes is lost after reboot. This method saves the network
configuration file in the right filesystem (``/root_bypass_ramdisks``).
Although it is not mandatory to connect to the Wi-Fi, this method is required
for the network to be reconnected automatically after a reboot.
:param str ssid: SSID of the network to validate
:return: True if the configuration file is saved successfully
:rtype: bool
"""
source_path = Path(f'/etc/NetworkManager/system-connections/{ssid}.nmconnection')
if not source_path.exists():
return False
destination_path = Path('/root_bypass_ramdisks') / source_path.relative_to('/')
# Copy the configuration file to the root filesystem
if subprocess.run(['sudo', 'cp', source_path, destination_path], check=False).returncode == 0:
return True
else:
_logger.error('Failed to apply the network configuration to /root_bypass_ramdisks.')
return False
# -------------------------- #
# Access Point Configuration #
# -------------------------- #
@cache
def get_access_point_ssid():
"""Generate a unique SSID for the access point.
Uses the identifier of the device or a random token if the
identifier was not found.
:return: Generated SSID
:rtype: str
"""
return "IoTBox-" + get_identifier() or secrets.token_hex(8)
def _configure_access_point(on=True):
"""Update the ``hostapd`` configuration file with the given SSID.
This method also adds/deletes a static IP address to the ``wlan0`` interface,
mandatory to allow people to connect to the access point.
:param bool on: Start or stop the access point
:return: True if the configuration is successful
"""
ssid = get_access_point_ssid()
if on:
_logger.info("Starting access point with SSID %s", ssid)
with open('/etc/hostapd/hostapd.conf', 'w', encoding='utf-8') as f:
f.write(f"interface=wlan0\nssid={ssid}\nchannel=1\n")
mode = 'add' if on else 'del'
return (
subprocess.run(
['sudo', 'ip', 'address', mode, '10.11.12.1/24', 'dev', 'wlan0'], check=False, stderr=subprocess.DEVNULL
).returncode == 0
or not on # Don't fail if stopping access point: IP address might not exist
)
def toggle_access_point(state=START):
"""Start or stop an access point.
:param bool state: Start or stop the access point
:return: True if the operation on the access point is successful
:rtype: bool
"""
if not _configure_access_point(state):
return False
mode = 'start' if state else 'stop'
_logger.info("%sing access point.", mode.capitalize())
if subprocess.run(['sudo', 'systemctl', mode, 'hostapd'], check=False).returncode == 0:
return True
else:
_logger.error("Failed to %s access point.", mode)
return False
def is_access_point():
"""Check if the device is currently in access point mode.
:return: True if the device is in access point mode
:rtype: bool
"""
return subprocess.run(
['systemctl', 'is-active', 'hostapd'], stdout=subprocess.DEVNULL, check=False
).returncode == 0
@cache
def generate_qr_code_image(qr_code_data):
"""Generate a QR code based on data argument and return it in base64 image format
Cached to avoir regenerating the same QR code multiple times
:param str qr_code_data: Data to encode in the QR code
:return: The QR code image in base64 format ready to be used in json format
"""
qr_code = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=6,
border=0,
)
qr_code.add_data(qr_code_data)
qr_code.make(fit=True)
img = qr_code.make_image(fill_color="black", back_color="transparent")
buffered = BytesIO()
img.save(buffered, format="PNG")
img_base64 = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_base64}"
def generate_network_qr_codes():
"""Generate a QR codes for the IoT Box network and its homepage
and return them in base64 image format in a dictionary
:return: A dictionary containing the QR codes in base64 format
:rtype: dict
"""
qr_code_images = {
'qr_wifi': None,
'qr_url': generate_qr_code_image(f'http://{get_ip()}'),
}
# Generate QR codes which can be used to connect to the IoT Box Wi-Fi network
if not is_access_point():
wifi_ssid = get_conf('wifi_ssid')
wifi_password = get_conf('wifi_password')
if wifi_ssid and wifi_password:
wifi_data = f"WIFI:S:{wifi_ssid};T:WPA;P:{wifi_password};;;"
qr_code_images['qr_wifi'] = generate_qr_code_image(wifi_data)
else:
access_point_data = f"WIFI:S:{get_access_point_ssid()};T:nopass;;;"
qr_code_images['qr_wifi'] = generate_qr_code_image(access_point_data)
return qr_code_images

View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="pragma" content="no-cache" />
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Odoo's IoT Box</title>
<script src="/web/static/lib/owl/owl.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/index.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/dom/data.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/dom/event-handler.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/dom/manipulator.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/dom/selector-engine.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/config.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/component-functions.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/sanitizer.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/backdrop.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/focustrap.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/util/scrollbar.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/base-component.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/alert.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/button.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/carousel.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/collapse.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/dropdown.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/modal.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/offcanvas.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/tooltip.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/popover.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/scrollspy.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/tab.js"></script>
<script src="/web/static/lib/bootstrap/js/dist/toast.js"></script>
<link href="/web/static/lib/bootstrap/dist/css/bootstrap.css" rel="stylesheet"/>
<link href="/iot_drivers/static/src/app/css/homepage.css" rel="stylesheet"/>
<link href="/web/static/src/libs/fontawesome/css/font-awesome.css" rel="stylesheet"/>
<link rel="icon" type="image/png" href="/iot_drivers/static/img/favicon.png">
</head>
<body>
<script src="/iot_drivers/static/src/app/main.js" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="pragma" content="no-cache" />
<title>Odoo's IoT Box Logs</title>
<script src="/iot_drivers/static/src/app/logs.js"></script>
<link rel="stylesheet" href="/iot_drivers/static/src/app/css/logs.css">
</head>
<body>
<pre id="logs"></pre>
</body>
</html>

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="pragma" content="no-cache" />
<title class="origin">Odoo's IoT Box</title>
<script src="/web/static/lib/owl/owl.js"></script>
<link class="origin" rel="stylesheet" href="/web/static/lib/bootstrap/dist/css/bootstrap.css">
<link class="origin" rel="stylesheet" href="/web/static/src/libs/fontawesome/css/font-awesome.css"/>
<link class="origin" rel="stylesheet" href="/iot_drivers/static/src/app/css/status_display.css">
</head>
<body>
<script src="/iot_drivers/static/src/app/status.js" type="module"></script>
</body>
</html>

View file

@ -0,0 +1,100 @@
import asyncio
import json
import logging
import pprint
from threading import Thread
import time
from aiortc import RTCDataChannel, RTCPeerConnection, RTCSessionDescription
from odoo.addons.iot_drivers import main
_logger = logging.getLogger(__name__)
class WebRtcClient(Thread):
daemon = True
def __init__(self):
super().__init__()
self.connections: set[RTCDataChannel] = set()
self.chunked_message_in_progress: dict[RTCDataChannel, str] = {}
self.event_loop = asyncio.get_event_loop_policy().get_event_loop()
def offer(self, request: dict):
return asyncio.run_coroutine_threadsafe(
self._offer(request), self.event_loop
).result()
def send(self, data: dict):
asyncio.run_coroutine_threadsafe(
self._send(data), self.event_loop
)
async def _offer(self, request: dict):
offer = RTCSessionDescription(sdp=request["sdp"], type=request["type"])
peer_connection = RTCPeerConnection()
@peer_connection.on("datachannel")
def on_datachannel(channel: RTCDataChannel):
self.connections.add(channel)
@channel.on("message")
async def on_message(message_str: str):
# Handle chunked message
if self.chunked_message_in_progress.get(channel) is not None:
if message_str == "chunked_end":
message_str = self.chunked_message_in_progress.pop(channel)
else:
self.chunked_message_in_progress[channel] += message_str
return
elif message_str == "chunked_start":
self.chunked_message_in_progress[channel] = ""
return
# Handle regular message
message = json.loads(message_str)
message_type = message["message_type"]
_logger.info("Received message of type %s:\n%s", message_type, pprint.pformat(message))
if message_type == "iot_action":
device_identifier = message["device_identifier"]
data = message["data"]
data["session_id"] = message["session_id"]
if device_identifier in main.iot_devices:
_logger.info("device '%s' action started with: %s", device_identifier, pprint.pformat(data))
await self.event_loop.run_in_executor(None, lambda: main.iot_devices[device_identifier].action(data))
else:
# Notify that the device is not connected
self.send({
'owner': message['session_id'],
'device_identifier': device_identifier,
'time': time.time(),
'status': 'disconnected',
})
@channel.on("close")
def on_close():
self.connections.discard(channel)
@peer_connection.on("connectionstatechange")
async def on_connectionstatechange():
if peer_connection.connectionState == "failed":
await peer_connection.close()
await peer_connection.setRemoteDescription(offer)
answer = await peer_connection.createAnswer()
await peer_connection.setLocalDescription(answer)
return {"sdp": peer_connection.localDescription.sdp, "type": peer_connection.localDescription.type}
async def _send(self, data: dict):
for connection in self.connections:
connection.send(json.dumps(data))
def run(self):
self.event_loop.run_forever()
webrtc_client = WebRtcClient()
webrtc_client.start()

View file

@ -0,0 +1,170 @@
import json
import logging
import platform
import pprint
import requests
import time
import urllib.parse
import websocket
from threading import Thread
from odoo.addons.iot_drivers import main
from odoo.addons.iot_drivers.tools import helpers
from odoo.addons.iot_drivers.server_logger import close_server_log_sender_handler
from odoo.addons.iot_drivers.webrtc_client import webrtc_client
_logger = logging.getLogger(__name__)
websocket.enableTrace(True, level=logging.getLevelName(_logger.getEffectiveLevel()))
@helpers.require_db
def send_to_controller(params, method="send_websocket", server_url=None):
"""Confirm the operation's completion by sending a response back to the Odoo server
:param params: the parameters to send back to the server
:param method: method to call on the IoT box controller
:param server_url: URL of the Odoo server (provided by decorator).
"""
request_path = f"{server_url}/iot/box/{method}"
try:
response = requests.post(request_path, json={'params': params}, timeout=5)
response.raise_for_status()
except requests.exceptions.RequestException:
_logger.exception('Could not reach database URL: %s', request_path)
def on_error(ws, error):
_logger.error("websocket received an error: %s", error)
@helpers.require_db
class WebsocketClient(Thread):
channel = ""
def on_open(self, ws):
"""
When the client is setup, this function send a message to subscribe to the iot websocket channel
"""
ws.send(json.dumps({
'event_name': 'subscribe',
'data': {
'channels': [self.channel],
'last': self.last_message_id,
'identifier': helpers.get_identifier(),
}
}))
def on_message(self, ws, messages):
"""Synchronously handle messages received by the websocket."""
for message in json.loads(messages):
_logger.debug("websocket received a message: %s", pprint.pformat(message))
self.last_message_id = message['id']
payload = message['message']['payload']
if not helpers.get_identifier() in payload.get('iot_identifiers', []):
continue
match message['message']['type']:
case 'iot_action':
for device_identifier in payload['device_identifiers']:
if device_identifier in main.iot_devices:
_logger.debug("device '%s' action started with: %s", device_identifier, pprint.pformat(payload))
main.iot_devices[device_identifier].action(payload)
else:
# Notify the controller that the device is not connected
send_to_controller({
'session_id': payload.get('session_id', '0'),
'iot_box_identifier': helpers.get_identifier(),
'device_identifier': device_identifier,
'status': 'disconnected',
})
case 'server_clear':
helpers.disconnect_from_server()
close_server_log_sender_handler()
case 'server_update':
helpers.update_conf({
'remote_server': payload['server_url']
})
helpers.get_odoo_server_url.cache_clear()
case 'restart_odoo':
ws.close()
helpers.odoo_restart()
case 'webrtc_offer':
answer = webrtc_client.offer(payload['offer'])
send_to_controller({
'iot_box_identifier': helpers.get_identifier(),
'answer': answer,
}, method="webrtc_answer")
case 'remote_debug':
if platform.system() == 'Windows':
continue
if not payload.get("status"):
helpers.toggle_remote_connection(payload.get("token", ""))
time.sleep(1)
send_to_controller({
'session_id': 0,
'iot_box_identifier': helpers.get_identifier(),
'device_identifier': None,
'status': 'success',
'result': {'enabled': helpers.is_ngrok_enabled()}
})
case _:
continue
def on_close(self, ws, close_status_code, close_msg):
_logger.debug("websocket closed with status: %s", close_status_code)
helpers.update_conf({'last_websocket_message_id': self.last_message_id})
def __init__(self, channel, server_url=None):
"""This class will not be instantiated if no db is connected.
:param str channel: the channel to subscribe to
:param str server_url: URL of the Odoo server (provided by decorator).
"""
self.channel = channel
self.last_message_id = int(helpers.get_conf('last_websocket_message_id') or 0)
self.server_url = server_url
url_parsed = urllib.parse.urlsplit(server_url)
scheme = url_parsed.scheme.replace("http", "ws", 1)
self.websocket_url = urllib.parse.urlunsplit((scheme, url_parsed.netloc, 'websocket', '', ''))
self.db_name = helpers.get_conf('db_name') or ''
self.session_id = ''
super().__init__()
def run(self):
if self.db_name:
session_response = requests.get(
self.server_url + "/web/login?db=" + self.db_name,
allow_redirects=False,
timeout=10,
)
if session_response.status_code in [200, 302]:
self.session_id = session_response.cookies['session_id']
else:
_logger.error("Failed to get session ID, status %s", session_response.status_code)
self.ws = websocket.WebSocketApp(self.websocket_url,
header={"User-Agent": "OdooIoTBox/1.0", "Cookie": f"session_id={self.session_id}"},
on_open=self.on_open, on_message=self.on_message,
on_error=on_error, on_close=self.on_close)
# The IoT synchronised servers can stop in 2 ways that we need to handle:
# A. Gracefully:
# In this case a disconnection signal is sent to the IoT-box
# The websocket is properly closed, but it needs to be established a new connection when
# the server will be back.
#
# B. Forced/killed:
# In this case there is no disconnection signal received
#
# This will also happen with the graceful quit as `reconnect` will trigger if the server
# is offline while attempting the new connection
while True:
try:
run_res = self.ws.run_forever(reconnect=10)
_logger.debug("websocket run_forever return with %s", run_res)
except Exception:
_logger.exception("An unexpected exception happened when running the websocket")
_logger.debug('websocket will try to restart in 10 seconds')
time.sleep(10)