Add WebServer settings app

This commit is contained in:
Thomas Farstrike
2026-03-17 19:30:34 +01:00
parent 5b50ce8528
commit 8ac4016e33
11 changed files with 458 additions and 7 deletions
+1
View File
@@ -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
)
@@ -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},
@@ -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"
}
]
}
]
}
@@ -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()
+2 -1
View File
@@ -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
+3 -4
View File
@@ -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:
@@ -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"]
@@ -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
<base href=\""""
)
cl.send(static_host)
cl.send(
b"""\"></base>\r
<script src="webrepl_content.js"></script>\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)
@@ -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"
@@ -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
+10
View File
@@ -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..."