mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 13:12:06 +02:00
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:
parent
3037cab43e
commit
aee3ee8bf7
1472 changed files with 194608 additions and 0 deletions
43
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/__init__.py
Normal file
43
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/__init__.py
Normal 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)
|
||||
|
|
@ -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',
|
||||
}
|
||||
128
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/browser.py
Normal file
128
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/browser.py
Normal 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)
|
||||
|
|
@ -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')
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
81
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/driver.py
Normal file
81
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/driver.py
Normal 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]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
41
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/http.py
Normal file
41
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/http.py
Normal 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
|
||||
91
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/interface.py
Normal file
91
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/interface.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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'})
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
181
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/main.py
Normal file
181
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/main.py
Normal 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()
|
||||
171
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/server_logger.py
Normal file
171
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/server_logger.py
Normal 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)
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
body {
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
color: white;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/* global owl */
|
||||
|
||||
const { useState, useEnv } = owl;
|
||||
|
||||
export default function useStore() {
|
||||
const env = useEnv();
|
||||
return useState(env.store);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"),
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from . import system
|
||||
from . import helpers
|
||||
from . import wifi
|
||||
from . import route
|
||||
from . import upgrade
|
||||
|
|
@ -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")
|
||||
642
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/helpers.py
Normal file
642
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/helpers.py
Normal 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
|
||||
31
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/route.py
Normal file
31
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/route.py
Normal 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
|
||||
|
|
@ -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
|
||||
204
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/upgrade.py
Normal file
204
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/upgrade.py
Normal 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
|
||||
)
|
||||
327
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/wifi.py
Normal file
327
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/wifi.py
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
100
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/webrtc_client.py
Normal file
100
odoo-bringout-oca-ocb-iot_drivers/iot_drivers/webrtc_client.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue