Files
encoach_backend_new_v3/odoo/addons/iot_drivers/websocket_client.py
Yamen Ahmad 3e83d8d7d5 feat: add complete EnCoach backend — Odoo 19 + all addons
Includes:
- Odoo 19 framework (odoo/)
- 27 custom EnCoach addons (new_project/custom_addons/)
  - encoach_core, encoach_api, encoach_lms_api, encoach_adaptive_api
  - encoach_exam, encoach_taxonomy, encoach_adaptive, encoach_assignment
  - encoach_ai, encoach_ai_grading, encoach_ai_generation, encoach_ai_media
  - encoach_courseware, encoach_communication, encoach_subscription
  - encoach_notification, encoach_approval, encoach_branding
  - encoach_classroom, encoach_registration, encoach_stats
  - encoach_faq, encoach_ticket, encoach_training, encoach_resources
  - encoach_adaptive_ai, encoach_sis
- 21 OpenEduCat Enterprise modules (new_project/enterprise-19/)
- 14 OpenEduCat Community modules (new_project/openeducat_erp-19.0/)
- Configuration: odoo.conf, requirements.txt, scripts
- 200+ REST API endpoints with JWT authentication
- SRS and test documentation

Made-with: Cursor
2026-04-01 17:10:04 +04:00

202 lines
9.1 KiB
Python

import json
import logging
import platform
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, upgrade
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
"""
self.connect_timestamp = time.monotonic()
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):
self.last_message_id = message['id']
payload = message['message']['payload']
_logger.info("Received message of type %s", message['message']['type'])
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:
start_operation_time = time.perf_counter()
_logger.info("device '%s' action started", device_identifier)
main.iot_devices[device_identifier].action(payload)
_logger.info("device '%s' action finished - %.*f", device_identifier, 3, time.perf_counter() - start_operation_time)
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':
if time.monotonic() < self.connect_timestamp + 5.0:
# This is a hacky way avoid processing an old server_clear message
# In master we can fix this properly by providing the last message ID to the IoT box on connection
_logger.warning("Ignoring server_clear message")
continue
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':
send_to_controller({
'session_id': payload['session_id'],
'iot_box_identifier': helpers.get_identifier(),
'device_identifier': helpers.get_identifier(),
'status': 'success',
})
ws.close()
helpers.odoo_restart()
case 'webrtc_offer':
if not webrtc_client:
continue
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 "test_connection":
send_to_controller({
'session_id': payload['session_id'],
'iot_box_identifier': helpers.get_identifier(),
'device_identifier': helpers.get_identifier(),
'status': 'success',
'result': {
'lan_quality': helpers.check_network(),
'wan_quality': helpers.check_network("www.odoo.com"),
}
})
case 'bundle_changed':
# This message is sent by the DB whenever the web JS asset bundle changes.
# While this is a bit of a hack we use this message to check if the DB has been upgraded,
# since we know the bundle will always change in this situation.
upgrade.check_git_branch()
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__(daemon=True)
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)