342 lines
16 KiB
Python
342 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import hashlib
|
|
import io
|
|
import itertools
|
|
import json
|
|
import logging
|
|
import pathlib
|
|
import pprint
|
|
import re
|
|
import textwrap
|
|
import werkzeug
|
|
import zipfile
|
|
|
|
from werkzeug.exceptions import NotFound
|
|
|
|
from odoo import http
|
|
from odoo.http import request, Response, Stream
|
|
from odoo.modules import get_module_path
|
|
from odoo.tools.misc import str2bool
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_iot_logger = logging.getLogger(__name__ + '.iot_log')
|
|
# We want to catch any log level that the IoT send
|
|
_iot_logger.setLevel(logging.DEBUG)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def ensure_unique_name(name):
|
|
existing_names = request.env['iot.box'].sudo().search([('name', 'ilike', name + '%')]).mapped('name')
|
|
base_name = name
|
|
suffix = 1
|
|
while name in existing_names:
|
|
name = f"{base_name} ({suffix})"
|
|
suffix += 1
|
|
|
|
return name
|
|
|
|
|
|
class IoTController(http.Controller):
|
|
def _search_box(self, identifier):
|
|
return request.env['iot.box'].sudo().search([('identifier', '=', identifier)], limit=1)
|
|
|
|
@http.route('/iot/get_handlers', type='http', auth='public', csrf=False)
|
|
def get_handlers(self, identifier, auto):
|
|
"""Return a zip file containing all the IoT handlers for the given IoT Box.
|
|
|
|
:param identifier: The identifier of the IoT Box.
|
|
:param auto: If True, the IoT Box will automatically update its handlers.
|
|
:return: A zip file containing all the IoT handlers.
|
|
"""
|
|
# Check if identifier is of one of the IoT Boxes
|
|
box = self._search_box(identifier)
|
|
if not box or (auto == 'True' and not box.drivers_auto_update):
|
|
raise werkzeug.exceptions.Unauthorized(
|
|
description="No IoT box found with identifier '%s' or auto update disabled on the box." % identifier
|
|
)
|
|
|
|
# '_L.py' files for Linux and '_W.py' for Windows
|
|
is_windows = box.version[0] == "W"
|
|
incompatible_filename = "_L.py" if is_windows else "_W.py"
|
|
module_ids = request.env['ir.module.module'].sudo().search([('state', '=', 'installed')])
|
|
modules = module_ids.mapped('name') + ["iot_drivers", "pos_blackbox_be"] # add pos_blackbox_be to detect blackbox devices without the module installed
|
|
|
|
if not is_windows and re.search(r"\d{4}\.\d{2}\.\d{2}", box.version):
|
|
# New IoT Boxes get drivers from git repository, not from installed modules
|
|
# for partners/clients that want to download custom drivers from the db, we only download
|
|
# custom drivers, to avoid overwriting the git ones
|
|
modules = [
|
|
m for m in modules
|
|
if m not in {"iot", "iot_drivers", "pos_blackbox_be", "l10n_se_pos", "pos_iot_six", "quality_iot"}
|
|
]
|
|
|
|
fobj = io.BytesIO()
|
|
with zipfile.ZipFile(fobj, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
for module in modules:
|
|
module_path = get_module_path(module)
|
|
if module_path:
|
|
iot_handlers = pathlib.Path(module_path) / 'iot_handlers'
|
|
for handler in iot_handlers.glob('*/*'):
|
|
if handler.name.startswith(('.', '_')) or handler.name.endswith(incompatible_filename):
|
|
continue
|
|
zf.write(handler, handler.relative_to(iot_handlers)) # In order to remove the absolute path
|
|
|
|
etag = hashlib.sha256(fobj.getvalue()).hexdigest()
|
|
# If the file has not been modified since the last request, return a 304 (Not Modified)
|
|
if etag == request.httprequest.headers.get('If-None-Match'):
|
|
return request.make_response('', headers=[('ETag', etag)], status=304)
|
|
|
|
return Stream(
|
|
type='data',
|
|
data=fobj.getvalue(),
|
|
download_name='iot_handlers.zip',
|
|
etag=etag,
|
|
size=fobj.tell(),
|
|
public=True,
|
|
).get_response()
|
|
|
|
@http.route('/iot/keyboard_layouts', type='http', auth='public', csrf=False)
|
|
def load_keyboard_layouts(self, available_layouts):
|
|
if not request.env['iot.keyboard.layout'].sudo().search_count([]):
|
|
request.env['iot.keyboard.layout'].sudo().create(json.loads(available_layouts))
|
|
return ''
|
|
|
|
@http.route('/iot/box/<string:identifier>/display_url', type='http', auth='public')
|
|
def get_url(self, identifier):
|
|
urls = {}
|
|
iotbox = self._search_box(identifier)
|
|
if iotbox:
|
|
iot_devices = iotbox.device_ids.filtered(lambda device: device.type == 'display')
|
|
for device in iot_devices:
|
|
urls[device.identifier] = device.display_url
|
|
return json.dumps(urls)
|
|
|
|
@http.route('/iot/box/send_websocket', type='jsonrpc', auth='public')
|
|
def iot_box_send_websocket(self, session_id, iot_box_identifier, device_identifier, status, **kwargs):
|
|
"""Called by the IoT Box once an operation is over. We then forward
|
|
the acknowledgment to the user who made the request to inform him
|
|
of the success of the operation.
|
|
|
|
:param session_id: ID of the operation
|
|
:param iot_box_identifier: The IP of the IoT box (used to find the box)
|
|
:param device_identifier: The IoT device identifier
|
|
:param status: Status of the last action (success, error, ...)
|
|
:param kwargs:
|
|
"""
|
|
box = self._search_box(iot_box_identifier)
|
|
if not box:
|
|
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
|
|
return
|
|
|
|
if (
|
|
device_identifier
|
|
and device_identifier != box.identifier # target the box itself
|
|
and not request.env["iot.device"].sudo().search(
|
|
[('identifier', '=', device_identifier), ('iot_id', '=', box.id)], limit=1
|
|
)
|
|
):
|
|
_logger.warning(
|
|
"No IoT device found with identifier '%s' (iot_box_identifier: %s). Request ignored",
|
|
device_identifier, iot_box_identifier
|
|
)
|
|
return
|
|
request.env['iot.channel'].send_message({
|
|
'session_id': session_id or kwargs.get("owner"), # TODO: replace "owner" by "session_id" in drivers
|
|
'iot_box_identifier': iot_box_identifier,
|
|
'device_identifier': device_identifier,
|
|
'message': {
|
|
'status': status,
|
|
'result': kwargs.get('result', {}),
|
|
'action_args': kwargs.get('action_args', {})
|
|
},
|
|
}, message_type='operation_confirmation')
|
|
_logger.info('Received websocket message for iot box %s, device %s, session_id %s with status %s and kwargs %s',
|
|
box.name, device_identifier, session_id, status, kwargs)
|
|
|
|
@http.route('/iot/box/webrtc_answer', type='jsonrpc', auth='public')
|
|
def iot_box_webrtc_answer(self, iot_box_identifier, answer):
|
|
"""Called by the IoT Box after receiving a WebRTC offer from a user.
|
|
The IoT box sends its WebRTC answer and we forward it to the user so
|
|
they can establish the connection.
|
|
|
|
:param iot_box_identifier: The identifier (serial number) of the IoT box
|
|
:param answer: The WebRTC answer object
|
|
"""
|
|
box = self._search_box(iot_box_identifier)
|
|
if not box:
|
|
_logger.warning("No IoT Box found with identifier: '%s'. Request ignored", iot_box_identifier)
|
|
raise NotFound()
|
|
|
|
request.env['iot.channel'].send_message({
|
|
'iot_box_identifier': iot_box_identifier,
|
|
'answer': answer,
|
|
}, message_type='webrtc_answer')
|
|
|
|
@http.route('/iot/setup', type='jsonrpc', auth='public')
|
|
def update_box(self, iot_box, devices):
|
|
"""This function receives a dict from the iot box with information from it
|
|
as well as devices connected and supported by this box.
|
|
This function create the box and the devices and set the status (connected / disconnected)
|
|
of devices linked with this box
|
|
|
|
:param dict iot_box: IoT Box information
|
|
:param dict devices: IoT devices information
|
|
:return: IoT websocket channel
|
|
"""
|
|
# Update or create box
|
|
iot_identifier = iot_box['identifier']
|
|
new_iot_ip = iot_box['ip']
|
|
new_iot_version = iot_box['version']
|
|
box = self._search_box(iot_identifier) or self._search_box(iot_box.get('mac'))
|
|
create_update_value = {
|
|
"identifier": iot_identifier, # Ensure upgrade from MAC to serial number
|
|
'ip': new_iot_ip,
|
|
'version': new_iot_version,
|
|
}
|
|
if box:
|
|
current_data = (box.identifier, box.ip, box.version)
|
|
if (current_data) != (iot_identifier, new_iot_ip, new_iot_version):
|
|
_logger.warning('Updating %s %s with data: %s', box.name, current_data, create_update_value)
|
|
box.write(create_update_value)
|
|
else:
|
|
name = 'IoT Box' if new_iot_version.startswith('L') else 'Virtual IoT Box'
|
|
create_update_value['name'] = ensure_unique_name(name)
|
|
icp_sudo = request.env['ir.config_parameter'].sudo()
|
|
iot_token = icp_sudo.get_param('iot.iot_token')
|
|
if iot_token and iot_token == iot_box['token']:
|
|
create_update_value['identifier'] = iot_identifier
|
|
_logger.info('Creating IoT with data: %s', create_update_value)
|
|
box = request.env['iot.box'].sudo().create(create_update_value)
|
|
|
|
# Clear the used token to force creating a new one for next IoT Box
|
|
icp_sudo.set_param('iot.iot_token', '')
|
|
else:
|
|
_logger.warning('Token mismatch for IoT %s expected %s got %s', iot_identifier, iot_token, iot_box['token'])
|
|
return None
|
|
|
|
_logger.info('IoT %s devices:\n%s', box, pprint.pformat(devices))
|
|
# Update or create devices
|
|
if box:
|
|
previously_connected_iot_devices = request.env['iot.device'].sudo().search([
|
|
('iot_id', '=', box.id),
|
|
('connected_status', '=', 'connected')
|
|
])
|
|
connected_iot_devices = request.env['iot.device'].sudo()
|
|
for device_identifier in devices:
|
|
available_types = [s[0] for s in request.env['iot.device']._fields['type'].selection]
|
|
available_connections = [s[0] for s in request.env['iot.device']._fields['connection'].selection]
|
|
|
|
data_device = devices[device_identifier]
|
|
if data_device['type'] in available_types and data_device['connection'] in available_connections:
|
|
# Special case to handle serial port change for blackbox
|
|
if data_device['type'] == 'fiscal_data_module' and 'BODO001' in data_device['name']:
|
|
existing_blackbox = connected_iot_devices.search([
|
|
('iot_id', '=', box.id), ('name', 'like', 'BODO001'), ('type', '=', 'fiscal_data_module')
|
|
], limit=1)
|
|
if existing_blackbox:
|
|
existing_blackbox.write({'identifier': device_identifier})
|
|
connected_iot_devices |= existing_blackbox
|
|
continue
|
|
|
|
device = connected_iot_devices.search([
|
|
('iot_id', '=', box.id), ('identifier', '=', device_identifier)
|
|
])
|
|
|
|
# If an `iot.device` record isn't found for this `device`, create a new one.
|
|
if not device:
|
|
device = request.env['iot.device'].sudo().create({
|
|
'iot_id': box.id,
|
|
'name': data_device['name'],
|
|
'identifier': device_identifier,
|
|
'type': data_device['type'],
|
|
'manufacturer': data_device.get('manufacturer'),
|
|
'connection': data_device['connection'],
|
|
'subtype': data_device.get('subtype', ''),
|
|
})
|
|
elif device and device.type != data_device.get('type') or (device.subtype == '' and device.type == 'printer'):
|
|
device.write({
|
|
'name': data_device.get('name'),
|
|
'type': data_device.get('type'),
|
|
'manufacturer': data_device.get('manufacturer'),
|
|
'subtype': data_device.get('subtype', '')
|
|
})
|
|
|
|
connected_iot_devices |= device
|
|
# Mark the received devices as connected, disconnect the others.
|
|
connected_iot_devices.write({'connected_status': 'connected'})
|
|
(previously_connected_iot_devices - connected_iot_devices).write({'connected_status': 'disconnected'})
|
|
iot_channel = request.env['iot.channel'].sudo().get_iot_channel()
|
|
return iot_channel
|
|
return None
|
|
|
|
def _is_iot_log_enabled(self):
|
|
return str2bool(request.env['ir.config_parameter'].sudo().get_param('iot.should_log_iot_logs', True))
|
|
|
|
@http.route('/iot/log', type='http', auth='public', csrf=False)
|
|
def receive_iot_log(self):
|
|
IOT_ELEMENT_SEPARATOR = b'<log/>\n'
|
|
IOT_LOG_LINE_SEPARATOR = b','
|
|
IOT_IDENTIFIER_PREFIX = b'identifier '
|
|
|
|
def log_line_transformation(log_line):
|
|
split = log_line.split(IOT_LOG_LINE_SEPARATOR, 1)
|
|
return {'levelno': int(split[0]), 'line_formatted': split[1].decode('utf-8')}
|
|
|
|
def log_current_level():
|
|
_iot_logger.log(
|
|
log_level,
|
|
"%s%s",
|
|
init_log_message,
|
|
textwrap.indent("\n".join(['', *log_lines]), ' | ')
|
|
)
|
|
|
|
def finish_request():
|
|
return Response(status=200)
|
|
|
|
if not self._is_iot_log_enabled():
|
|
return finish_request()
|
|
|
|
request_data = request.httprequest.get_data()
|
|
if request_data.endswith(IOT_ELEMENT_SEPARATOR):
|
|
# Do not use rstrip as some characters of the separator might be at the end of the log line
|
|
request_data = request_data[:-len(IOT_ELEMENT_SEPARATOR)]
|
|
request_data_split = request_data.split(IOT_ELEMENT_SEPARATOR)
|
|
if len(request_data_split) < 2:
|
|
return finish_request()
|
|
|
|
identifier_details = request_data_split.pop(0)
|
|
if not identifier_details.startswith(IOT_IDENTIFIER_PREFIX):
|
|
return finish_request()
|
|
|
|
identifier = identifier_details[len(IOT_IDENTIFIER_PREFIX):]
|
|
iot_box = self._search_box(identifier)
|
|
if not iot_box:
|
|
return finish_request()
|
|
|
|
log_details = map(log_line_transformation, request_data_split)
|
|
init_log_message = "IoT box log '%s' #%d received:" % (iot_box.name, iot_box.id)
|
|
|
|
for log_level, log_group in itertools.groupby(log_details, key=lambda log: log['levelno']): # noqa: B007
|
|
log_lines = [log_line['line_formatted'] for log_line in log_group]
|
|
log_current_level()
|
|
|
|
return finish_request()
|
|
|
|
@http.route('/iot/box/update_certificate_status', type='jsonrpc', auth='public')
|
|
def update_certificate_status(self, identifier, ssl_certificate_end_date):
|
|
"""Update the SSL certificate end date for the IoT Box.
|
|
|
|
:param str identifier: IoT Box identifier
|
|
:param str ssl_certificate_end_date: SSL certificate end date
|
|
"""
|
|
box = self._search_box(identifier)
|
|
if not box:
|
|
_logger.warning("No IoT Box found with identifier '%s'. Request ignored", identifier)
|
|
return
|
|
|
|
box.write({'ssl_certificate_end_date': ssl_certificate_end_date})
|