oca-ocb-core/odoo-bringout-oca-ocb-iot_drivers/iot_drivers/tools/helpers.py
Ernad Husremovic aee3ee8bf7 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
2026-03-09 15:45:22 +01:00

642 lines
21 KiB
Python

# 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