You've already forked MicroPythonOS
mirror of
https://github.com/m5stack/MicroPythonOS.git
synced 2026-05-20 11:51:27 -07:00
Add WebServer settings app
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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..."
|
||||
|
||||
Reference in New Issue
Block a user