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..."