From 8ac4016e335d84c53b13b3bb3bc3ea59917f56d3 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 17 Mar 2026 19:30:34 +0100 Subject: [PATCH] Add WebServer settings app --- c_mpos/micropython.cmake | 1 + .../assets/settings.py | 7 + .../META-INF/MANIFEST.JSON | 23 +++ .../assets/webserver.py | 112 +++++++++++ internal_filesystem/lib/mpos/__init__.py | 3 +- internal_filesystem/lib/mpos/main.py | 7 +- .../lib/mpos/webserver/__init__.py | 3 +- .../lib/mpos/webserver/webrepl.py | 182 ++++++++++++++++++ .../lib/mpos/webserver/webrepl_http.py | 2 +- .../lib/mpos/webserver/webserver.py | 115 +++++++++++ scripts/build_mpos.sh | 10 + 11 files changed, 458 insertions(+), 7 deletions(-) create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.webserver/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.webserver/assets/webserver.py create mode 100644 internal_filesystem/lib/mpos/webserver/webrepl.py create mode 100644 internal_filesystem/lib/mpos/webserver/webserver.py diff --git a/c_mpos/micropython.cmake b/c_mpos/micropython.cmake index 9669288a..cc443ae8 100644 --- a/c_mpos/micropython.cmake +++ b/c_mpos/micropython.cmake @@ -18,6 +18,7 @@ set(MPOS_C_SOURCES ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/version_db.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/decode.c ${CMAKE_CURRENT_LIST_DIR}/quirc/lib/quirc.c +# ${CMAKE_CURRENT_LIST_DIR}/../lvgl_micropython/lib/micropython/extmod/modwebrepl.c # ${CMAKE_CURRENT_LIST_DIR}/src/font_Noto_Sans_sat_emojis_compressed.c ) diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 1088fa9e..19b8ef73 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -18,6 +18,12 @@ class LaunchHotspot(Activity): AppManager.start_app("com.micropythonos.hotspot") +class LaunchWebServer(Activity): + + def onCreate(self): + AppManager.start_app("com.micropythonos.webserver") + + class Settings(SettingsActivity): """Override getIntent to provide prefs and settings via Intent extras""" @@ -53,6 +59,7 @@ class Settings(SettingsActivity): intent.putExtra("settings", [ {"title": "Wi-Fi", "key": "wifi_settings", "ui": "activity", "activity_class": LaunchWiFi}, {"title": "Hotspot", "key": "hotspot_settings", "ui": "activity", "activity_class": LaunchHotspot}, + {"title": "WebServer", "key": "webserver_settings", "ui": "activity", "activity_class": LaunchWebServer}, # Basic settings, alphabetically: {"title": "Light/Dark Theme", "key": "theme_light_dark", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")], "changed_callback": self.theme_changed}, {"title": "Theme Color", "key": "theme_primary_color", "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors, "changed_callback": self.theme_changed, "default_value": AppearanceManager.DEFAULT_PRIMARY_COLOR}, diff --git a/internal_filesystem/builtin/apps/com.micropythonos.webserver/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.webserver/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..7a7614d5 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.webserver/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "WebServer", +"publisher": "MicroPythonOS", +"short_description": "Configure and control the WebServer.", +"long_description": "Configure WebServer settings, start or stop the WebREPL web server.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.webserver/icons/com.micropythonos.webserver_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.webserver/mpks/com.micropythonos.webserver_0.1.0.mpk", +"fullname": "com.micropythonos.webserver", +"version": "0.1.0", +"category": "networking", +"activities": [ + { + "entrypoint": "assets/webserver.py", + "classname": "WebServerApp", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/builtin/apps/com.micropythonos.webserver/assets/webserver.py b/internal_filesystem/builtin/apps/com.micropythonos.webserver/assets/webserver.py new file mode 100644 index 00000000..8867b828 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.webserver/assets/webserver.py @@ -0,0 +1,112 @@ +import lvgl as lv + +from mpos import Activity, DisplayMetrics, Intent, SettingsActivity, SharedPreferences, WebServer + + +class WebServerApp(Activity): + status_label = None + detail_label = None + action_button = None + action_label = None + settings_button = None + prefs = None + + def onCreate(self): + self.prefs = SharedPreferences(WebServer.PREFS_NAMESPACE, defaults=WebServer.DEFAULTS) + screen = lv.obj() + screen.set_style_border_width(0, lv.PART.MAIN) + screen.set_style_pad_all(DisplayMetrics.pct_of_width(3), lv.PART.MAIN) + screen.set_flex_flow(lv.FLEX_FLOW.COLUMN) + + header = lv.label(screen) + header.set_text("WebServer") + header.set_style_text_font(lv.font_montserrat_20, lv.PART.MAIN) + + self.status_label = lv.label(screen) + self.status_label.set_style_text_font(lv.font_montserrat_14, lv.PART.MAIN) + self.status_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.status_label.set_width(lv.pct(100)) + + self.detail_label = lv.label(screen) + self.detail_label.set_style_text_font(lv.font_montserrat_12, lv.PART.MAIN) + self.detail_label.set_long_mode(lv.label.LONG_MODE.WRAP) + self.detail_label.set_width(lv.pct(100)) + + button_row = lv.obj(screen) + button_row.set_width(lv.pct(100)) + button_row.set_height(lv.SIZE_CONTENT) + button_row.set_style_border_width(0, lv.PART.MAIN) + button_row.set_style_pad_all(0, lv.PART.MAIN) + button_row.set_flex_flow(lv.FLEX_FLOW.ROW) + button_row.set_style_flex_main_place(lv.FLEX_ALIGN.SPACE_BETWEEN, lv.PART.MAIN) + + self.action_button = lv.button(button_row) + self.action_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.action_button.add_event_cb(self.toggle_webserver, lv.EVENT.CLICKED, None) + self.action_label = lv.label(self.action_button) + self.action_label.center() + + self.settings_button = lv.button(button_row) + self.settings_button.set_size(lv.pct(45), lv.SIZE_CONTENT) + self.settings_button.add_event_cb(self.open_settings, lv.EVENT.CLICKED, None) + settings_label = lv.label(self.settings_button) + settings_label.set_text("Settings") + settings_label.center() + + self.setContentView(screen) + + def onResume(self, screen): + super().onResume(screen) + self.refresh_status() + + def refresh_status(self): + status = WebServer.status() + state_text = "Running" if status.get("started") else "Stopped" + self.status_label.set_text(f"Status: {state_text}") + autostart_text = "On" if status.get("autostart") else "Off" + port = status.get("port") + self.detail_label.set_text(f"Port: {port}\nAutostart: {autostart_text}") + + button_text = "Stop" if status.get("started") else "Start" + self.action_label.set_text(button_text) + self.action_label.center() + + def toggle_webserver(self, event): + if WebServer.is_started(): + WebServer.stop() + else: + WebServer.start() + self.refresh_status() + + def open_settings(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra( + "settings", + [ + { + "title": "Autostart", + "key": "autostart", + "ui": "radiobuttons", + "ui_options": [("On", "True"), ("Off", "False")], + "changed_callback": self.settings_changed, + }, + { + "title": "Port", + "key": "port", + "placeholder": "WebServer port, e.g. 7890", + "changed_callback": self.settings_changed, + }, + { + "title": "Password", + "key": "password", + "placeholder": "Max 9 characters", + "changed_callback": self.settings_changed, + }, + ], + ) + self.startActivity(intent) + + def settings_changed(self, new_value): + WebServer.apply_settings(restart_if_running=True) + self.refresh_status() diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index b76cfa72..0f58334f 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -19,6 +19,7 @@ from .build_info import BuildInfo # Battery manager (imported early for UI dependencies) from .battery_manager import BatteryManager +from .webserver.webserver import WebServer # Common activities from .app.activities.chooser import ChooserActivity @@ -67,7 +68,7 @@ __all__ = [ "Activity", "SharedPreferences", "ConnectivityManager", "DownloadManager", "WifiService", "AudioManager", "Intent", - "ActivityNavigator", "AppManager", "TaskManager", "CameraManager", "BatteryManager", + "ActivityNavigator", "AppManager", "TaskManager", "CameraManager", "BatteryManager", "WebServer", # Device and build info "DeviceInfo", "BuildInfo", # Common activities diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index dfb188cf..78260113 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -229,11 +229,10 @@ async def asyncio_repl(): TaskManager.create_task(asyncio_repl()) # only gets started after TaskManager.start() try: - import webrepl - from mpos.webserver import accept_handler as webrepl_accept_handler - webrepl.start(port=7890, password="MPOSweb26", accept_handler=webrepl_accept_handler) # password is max 9 characters + from mpos import WebServer + WebServer.auto_start() except Exception as e: - print(f"Could not start webrepl - this is normal on desktop systems: {e}") + print(f"Could not start webserver - this is normal on desktop systems: {e}") async def ota_rollback_cancel(): try: diff --git a/internal_filesystem/lib/mpos/webserver/__init__.py b/internal_filesystem/lib/mpos/webserver/__init__.py index 473cfec3..8d44cd85 100644 --- a/internal_filesystem/lib/mpos/webserver/__init__.py +++ b/internal_filesystem/lib/mpos/webserver/__init__.py @@ -1,5 +1,6 @@ """Web server helpers for MicroPythonOS.""" from .webrepl_http import accept_handler +from .webserver import WebServer -__all__ = ["accept_handler"] +__all__ = ["accept_handler", "WebServer"] diff --git a/internal_filesystem/lib/mpos/webserver/webrepl.py b/internal_filesystem/lib/mpos/webserver/webrepl.py new file mode 100644 index 00000000..ec12e843 --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webrepl.py @@ -0,0 +1,182 @@ +# This module should be imported from REPL, not run from command line. +import binascii +import hashlib +from micropython import const +try: + import network +except ImportError: + network = None +import os +import socket +import sys +import websocket +import _webrepl + +listen_s = None +client_s = None + +DEBUG = 0 + +_DEFAULT_STATIC_HOST = const("https://micropython.org/webrepl/") +static_host = _DEFAULT_STATIC_HOST + + +def server_handshake(cl): + req = cl.makefile("rwb", 0) + # Skip HTTP GET line. + l = req.readline() + if DEBUG: + sys.stdout.write(repr(l)) + + webkey = None + upgrade = False + websocket = False + + while True: + l = req.readline() + if not l: + # EOF in headers. + return False + if l == b"\r\n": + break + if DEBUG: + sys.stdout.write(l) + h, v = [x.strip() for x in l.split(b":", 1)] + if DEBUG: + print((h, v)) + if h == b"Sec-WebSocket-Key": + webkey = v + elif h == b"Connection" and b"Upgrade" in v: + upgrade = True + elif h == b"Upgrade" and v == b"websocket": + websocket = True + + if not (upgrade and websocket and webkey): + return False + + if DEBUG: + print("Sec-WebSocket-Key:", webkey, len(webkey)) + + d = hashlib.sha1(webkey) + d.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + respkey = d.digest() + respkey = binascii.b2a_base64(respkey)[:-1] + if DEBUG: + print("respkey:", respkey) + + cl.send( + b"""\ +HTTP/1.1 101 Switching Protocols\r +Upgrade: websocket\r +Connection: Upgrade\r +Sec-WebSocket-Accept: """ + ) + cl.send(respkey) + cl.send("\r\n\r\n") + + return True + + +def send_html(cl): + cl.send( + b"""\ +HTTP/1.0 200 OK\r +\r +\r +\r +""" + ) + cl.close() + + +def setup_conn(port, accept_handler): + global listen_s + listen_s = socket.socket() + listen_s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + ai = socket.getaddrinfo("0.0.0.0", port) + addr = ai[0][4] + + listen_s.bind(addr) + listen_s.listen(1) + if accept_handler: + listen_s.setsockopt(socket.SOL_SOCKET, 20, accept_handler) + if network: + for i in (network.WLAN.IF_AP, network.WLAN.IF_STA): + iface = network.WLAN(i) + if iface.active(): + print("WebREPL server started on http://%s:%d/" % (iface.ifconfig()[0], port)) + return listen_s + + +def accept_conn(listen_sock): + global client_s + cl, remote_addr = listen_sock.accept() + + if not server_handshake(cl): + send_html(cl) + return False + + prev = os.dupterm(None) + os.dupterm(prev) + if prev: + print("\nConcurrent WebREPL connection from", remote_addr, "rejected") + cl.close() + return False + print("\nWebREPL connection from:", remote_addr) + client_s = cl + + ws = websocket.websocket(cl, True) + ws = _webrepl._webrepl(ws) + cl.setblocking(False) + # notify REPL on socket incoming data (ESP32/ESP8266-only) + if hasattr(os, "dupterm_notify"): + cl.setsockopt(socket.SOL_SOCKET, 20, os.dupterm_notify) + os.dupterm(ws) + + return True + + +def stop(): + global listen_s, client_s + os.dupterm(None) + if client_s: + client_s.close() + if listen_s: + listen_s.close() + + +def start(port=8266, password=None, accept_handler=accept_conn): + global static_host + stop() + webrepl_pass = password + if webrepl_pass is None: + try: + import webrepl_cfg + + webrepl_pass = webrepl_cfg.PASS + if hasattr(webrepl_cfg, "BASE"): + static_host = webrepl_cfg.BASE + except: + print("WebREPL is not configured, run 'import webrepl_setup'") + + _webrepl.password(webrepl_pass) + s = setup_conn(port, accept_handler) + + if accept_handler is None: + print("Starting webrepl in foreground mode") + # Run accept_conn to serve HTML until we get a websocket connection. + while not accept_conn(s): + pass + elif password is None: + print("Started webrepl in normal mode") + else: + print("Started webrepl in manual override mode") + + +def start_foreground(port=8266, password=None): + start(port, password, None) diff --git a/internal_filesystem/lib/mpos/webserver/webrepl_http.py b/internal_filesystem/lib/mpos/webserver/webrepl_http.py index aac9eed5..0c5f9f64 100644 --- a/internal_filesystem/lib/mpos/webserver/webrepl_http.py +++ b/internal_filesystem/lib/mpos/webserver/webrepl_http.py @@ -3,7 +3,7 @@ import socket import uio import _webrepl -import webrepl +from . import webrepl import websocket WEBREPL_HTML_PATH = "builtin/html/webrepl_inlined_minified.html" diff --git a/internal_filesystem/lib/mpos/webserver/webserver.py b/internal_filesystem/lib/mpos/webserver/webserver.py new file mode 100644 index 00000000..0588aa2c --- /dev/null +++ b/internal_filesystem/lib/mpos/webserver/webserver.py @@ -0,0 +1,115 @@ +"""WebServer control for MicroPythonOS.""" + +from ..config import SharedPreferences +from .webrepl_http import accept_handler + + +class WebServer: + PREFS_NAMESPACE = "com.micropythonos.webserver" + DEFAULTS = { + "autostart": "False", + "port": "7890", + "password": "MPOSweb26", + } + + _started = False + _port = None + _password = None + _autostart = None + _last_error = None + + @classmethod + def _prefs(cls): + return SharedPreferences(cls.PREFS_NAMESPACE, defaults=cls.DEFAULTS) + + @classmethod + def _parse_bool(cls, value): + return str(value).lower() in ("true", "1", "yes", "on") + + @classmethod + def _parse_port(cls, value): + try: + return int(value) + except Exception: + return int(cls.DEFAULTS["port"]) + + @classmethod + def _sanitize_password(cls, value): + if not value: + value = cls.DEFAULTS["password"] + if len(value) > 9: + value = value[:9] + return value + + @classmethod + def load_settings(cls): + prefs = cls._prefs() + cls._autostart = cls._parse_bool(prefs.get_string("autostart", cls.DEFAULTS["autostart"])) + cls._port = cls._parse_port(prefs.get_string("port", cls.DEFAULTS["port"])) + cls._password = cls._sanitize_password(prefs.get_string("password", cls.DEFAULTS["password"])) + + @classmethod + def status(cls): + cls.load_settings() + return { + "state": "started" if cls._started else "stopped", + "started": cls._started, + "port": cls._port, + "password": cls._password, + "autostart": cls._autostart, + "last_error": cls._last_error, + } + + @classmethod + def is_started(cls): + return cls._started + + @classmethod + def start(cls): + cls.load_settings() + try: + from . import webrepl + + webrepl.start(port=cls._port, password=cls._password, accept_handler=accept_handler) + cls._started = True + cls._last_error = None + print(f"WebServer started on port {cls._port}") + return True + except Exception as exc: + cls._last_error = exc + cls._started = False + print(f"WebServer start failed: {exc}") + return False + + @classmethod + def stop(cls): + try: + from . import webrepl + + if hasattr(webrepl, "stop"): + webrepl.stop() + cls._started = False + cls._last_error = None + print("WebServer stopped") + return True + except Exception as exc: + cls._last_error = exc + print(f"WebServer stop failed: {exc}") + return False + + @classmethod + def apply_settings(cls, restart_if_running=True): + was_running = cls._started + cls.load_settings() + if was_running and restart_if_running: + cls.stop() + cls.start() + return cls.status() + + @classmethod + def auto_start(cls): + cls.load_settings() + if cls._autostart: + return cls.start() + print("WebServer autostart disabled") + return False diff --git a/scripts/build_mpos.sh b/scripts/build_mpos.sh index 436fe7a7..4c2e4da5 100755 --- a/scripts/build_mpos.sh +++ b/scripts/build_mpos.sh @@ -155,6 +155,16 @@ elif [ "$target" == "unix" -o "$target" == "macOS" ]; then manifest=$(readlink -f "$codebasedir"/manifests/manifest.py) frozenmanifest="FROZEN_MANIFEST=$manifest" + # Ensure WebREPL native module is enabled for unix/macOS builds. + mpconfig_unix="$codebasedir"/lvgl_micropython/lib/micropython/ports/unix/mpconfigport.h + if ! grep -q "MICROPY_PY_WEBREPL" "$mpconfig_unix"; then + echo "Enabling MICROPY_PY_WEBREPL in $mpconfig_unix" + sed -i.backup '/#include "mpconfigvariant.h"/a \ +\n#ifndef MICROPY_PY_WEBREPL\n#define MICROPY_PY_WEBREPL (1)\n#endif\n' "$mpconfig_unix" + else + echo "MICROPY_PY_WEBREPL already configured in $mpconfig_unix" + fi + # Comment out @micropython.viper decorator for Unix/macOS builds # (cross-compiler doesn't support Viper native code emitter) echo "Temporarily commenting out @micropython.viper decorator for Unix/macOS build..."