From 98b3104e9415f53b585ed631b486a756385f9213 Mon Sep 17 00:00:00 2001 From: snlavaise Date: Sat, 26 Sep 2026 20:36:18 -0300 Subject: [PATCH] device-motorola-rtwo: package the dashboard and the 5 GHz WiFi workaround Both lived only in the rootfs (/usr/local, /etc). Move them into the device package: - rtwo-dashboard: sway always runs with the system dashboard on the panel (DSI-1); the launcher follows the DP-1 HPD and opens a login console on the external monitor while one is connected. The console runs login as a session leader (setsid -c) and reopens the tty on every loop, since login's vhangup() otherwise leaves the loop with dead fds. Files go to /usr/libexec and /usr/share/rtwo-dashboard. - rtwo-wifi-5ghz-scan: unsticks wpa_supplicant after boot (ath12k). The network now comes from NetworkManager (first WiFi connection) or from /etc/default/rtwo-wifi-5ghz-scan instead of a hardcoded SSID. Both services are enabled through multi-user.target.wants, like rtwo-panel-revive. New dependencies: sway, foot, seatd-launch, python3, font-jetbrains-mono-nerd, font-noto-emoji, iw, networkmanager-cli. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DnHwDU5yL8L6qgPEN5CV5m --- device/testing/device-motorola-rtwo/APKBUILD | 54 +- .../rtwo-dashboard-consola.ini | 8 + .../rtwo-dashboard-foot.ini | 29 + .../rtwo-dashboard-launcher.py | 250 +++++++++ .../rtwo-dashboard-sway.config | 30 ++ .../device-motorola-rtwo/rtwo-dashboard.py | 503 ++++++++++++++++++ .../rtwo-dashboard.service | 13 + .../rtwo-wifi-5ghz-scan.service | 11 + .../rtwo-wifi-5ghz-scan.sh | 66 +++ 9 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard-consola.ini create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard-foot.ini create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard-launcher.py create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard-sway.config create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard.py create mode 100644 device/testing/device-motorola-rtwo/rtwo-dashboard.service create mode 100644 device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.service create mode 100644 device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.sh diff --git a/device/testing/device-motorola-rtwo/APKBUILD b/device/testing/device-motorola-rtwo/APKBUILD index be0ecab33..10c725c31 100644 --- a/device/testing/device-motorola-rtwo/APKBUILD +++ b/device/testing/device-motorola-rtwo/APKBUILD @@ -2,7 +2,7 @@ maintainer="snlavaise " pkgname=device-motorola-rtwo pkgver=1 -pkgrel=36 +pkgrel=37 pkgdesc="Motorola Moto Edge 40 Pro" url="https://postmarketos.org" arch="aarch64" @@ -14,12 +14,20 @@ depends=" firmware-motorola-rtwo-adsp firmware-motorola-rtwo-cdsp firmware-motorola-rtwo-gpu + font-jetbrains-mono-nerd + font-noto-emoji + foot + iw linux-firmware-ath12k linux-firmware-qcom linux-firmware-rtw88 linux-postmarketos-qcom-sm8550 + networkmanager-cli postmarketos-base + python3 + seatd-launch soc-qcom + sway " makedepends="devicepkg-dev" source=" @@ -32,6 +40,14 @@ source=" logind-power-key.conf rtwo-panel-revive.sh rtwo-panel-revive.service + rtwo-dashboard-launcher.py + rtwo-dashboard.py + rtwo-dashboard-sway.config + rtwo-dashboard-foot.ini + rtwo-dashboard-consola.ini + rtwo-dashboard.service + rtwo-wifi-5ghz-scan.sh + rtwo-wifi-5ghz-scan.service " options="!archcheck !check" @@ -114,6 +130,34 @@ package() { install -d "$pkgdir"/usr/lib/systemd/system/multi-user.target.wants ln -s ../rtwo-panel-revive.service \ "$pkgdir"/usr/lib/systemd/system/multi-user.target.wants/rtwo-panel-revive.service + + # Dashboard: sway siempre, con el dashboard en el panel (DSI-1) y una + # consola de login en el monitor (DP-1) mientras haya uno conectado. El + # launcher sigue el HPD de DP-1 y abre/cierra la consola; ver su docstring. + install -Dm755 "$srcdir"/rtwo-dashboard-launcher.py \ + "$pkgdir"/usr/libexec/rtwo-dashboard-launcher + install -Dm755 "$srcdir"/rtwo-dashboard.py \ + "$pkgdir"/usr/libexec/rtwo-dashboard + install -Dm644 "$srcdir"/rtwo-dashboard-sway.config \ + "$pkgdir"/usr/share/rtwo-dashboard/sway.config + install -Dm644 "$srcdir"/rtwo-dashboard-foot.ini \ + "$pkgdir"/usr/share/rtwo-dashboard/foot.ini + install -Dm644 "$srcdir"/rtwo-dashboard-consola.ini \ + "$pkgdir"/usr/share/rtwo-dashboard/consola.ini + install -Dm644 "$srcdir"/rtwo-dashboard.service \ + "$pkgdir"/usr/lib/systemd/system/rtwo-dashboard.service + ln -s ../rtwo-dashboard.service \ + "$pkgdir"/usr/lib/systemd/system/multi-user.target.wants/rtwo-dashboard.service + + # WiFi 5 GHz tras el arranque: destraba el escaneo de wpa_supplicant + # (workaround ath12k). La red sale de NetworkManager o de + # /etc/default/rtwo-wifi-5ghz-scan; ver el script. + install -Dm755 "$srcdir"/rtwo-wifi-5ghz-scan.sh \ + "$pkgdir"/usr/libexec/rtwo-wifi-5ghz-scan + install -Dm644 "$srcdir"/rtwo-wifi-5ghz-scan.service \ + "$pkgdir"/usr/lib/systemd/system/rtwo-wifi-5ghz-scan.service + ln -s ../rtwo-wifi-5ghz-scan.service \ + "$pkgdir"/usr/lib/systemd/system/multi-user.target.wants/rtwo-wifi-5ghz-scan.service } sha512sums=" @@ -126,4 +170,12 @@ cb8ea3e0da0219fdf77f6def6d7a77157a4ef152cc434e3d282457647784b05e7152cf1028d00a55 bab2fbd6078d9ac342708a1e85b7f5d5cff5e1105b5bd633517bf27f9a61499b9903c9aa17d0142c82f8cd874cd9ac03d1ddb33b8c66e40094a7271dd54c0502 logind-power-key.conf 59db456c9ba57352aa8bff0e8ca73bd5b1c5c7f6f9c57e382e5bdd68791c9324d705210e77aa4e945e3869ab11dc1066e1a290c3750a27772c89f83731d28609 rtwo-panel-revive.sh 8e374a7304f8648d33d9087149e53efc5b09ffe1f7bc07b8b10521b7b1163e4787b56ecf3d2871629b1cfcc0037c1c15d084b514412e5e0a90ff66fd8e83b088 rtwo-panel-revive.service +3dfc80a0399e06c9f9708847bfc3e270202e26ecac48e60c0ef8d98a98c9a92b20febb91a90201b6ebd00141c49ec7bd6ad052acfdba587749b71c1f3dd7798c rtwo-dashboard-launcher.py +f9f51c52280e443010d3664bf390e5b9664c0f8b82bec39cd3e36b2fac742b27d6d2bbc32ee4a48d0296c2919eeee7b4728313cb2da69b598e6b593ea7b4a8a9 rtwo-dashboard.py +31c20c566be562ff6755cfb24915a94d9b3ad95a45e73fe71eda7e74851dd5fbfe20416052bda8adaa174243be9c63d098de9063c092637ec51061937f18ca3a rtwo-dashboard-sway.config +92a3b263af1aed7f8f808ecc1f7815dc0951f21723c1d0edaeb5b57ae78e505371396824901a97d4521c710fc2f670ef71579bb96b35835726a381648adb8e21 rtwo-dashboard-foot.ini +c38b7942a7697788ffc62c82f560f6cf5e2f963b1d3ca69de8f9b2d1bfe8143e802cc19e782d01cb12c5ae0347e5a082fad2774e777b4a0696e2070e22004bb6 rtwo-dashboard-consola.ini +5fb089bdb6079ec790fb7c1aafad9c3f1a9aa90731c96d42c5bb788b7b44d485bc7a07befa86fba06eafbde9c38d4aaafb56bf752ec289ea5f817465982ffcbd rtwo-dashboard.service +259ef3adc35e26f263e572a0937e4e701969eaa0c9c6a85cb13f66f3acc3e0395cf5639f882a0196a9881c3cdd7ac505a33f998296af7d11d34fff6e8dddd747 rtwo-wifi-5ghz-scan.sh +0c1bc164f71c10c8b24907d7c9ce91eba8c1983d7d00c5fc4e8c7d3d3b700cb746ad5bece5ba796903ffb3728d8ee108fc08fb95bc8dfe5e2c9311fa829de3d3 rtwo-wifi-5ghz-scan.service " diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard-consola.ini b/device/testing/device-motorola-rtwo/rtwo-dashboard-consola.ini new file mode 100644 index 000000000..e158abb84 --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard-consola.ini @@ -0,0 +1,8 @@ +# foot para la consola de login en el monitor (DP-1) mientras sway ocupa las pantallas +font=JetBrainsMono Nerd Font Mono:pixelsize=20 +dpi-aware=no +pad=4x4 + +[colors-dark] +background=000000 +foreground=c0c0c0 diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard-foot.ini b/device/testing/device-motorola-rtwo/rtwo-dashboard-foot.ini new file mode 100644 index 000000000..3504e7641 --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard-foot.ini @@ -0,0 +1,29 @@ +# rtwo-dashboard v3 (foot dentro de cage, pantalla completa 1080x2400) +[main] +font=JetBrainsMono Nerd Font Mono:pixelsize=42 +dpi-aware=no +pad=0x24 center +term=foot + +[cursor] +blink=no + +[colors-dark] +background=11111b +foreground=cdd6f4 +regular0=45475a +regular1=f38ba8 +regular2=a6e3a1 +regular3=f9e2af +regular4=89b4fa +regular5=f5c2e7 +regular6=94e2d5 +regular7=bac2de +bright0=585b70 +bright1=f38ba8 +bright2=a6e3a1 +bright3=f9e2af +bright4=89b4fa +bright5=f5c2e7 +bright6=94e2d5 +bright7=a6adc8 diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard-launcher.py b/device/testing/device-motorola-rtwo/rtwo-dashboard-launcher.py new file mode 100644 index 000000000..5cae5785c --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard-launcher.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Launcher del rtwo-dashboard (sway, v9 = v6 + espera de DRM + consola solo +con DP-1 activa en sway). + +Un solo sway corriendo siempre, con el dashboard en el panel (DSI-1). Cuando +hay un monitor en DP-1 (estable DEBOUNCE_SECS) el launcher abre ademas una +consola de login (foot + /bin/login) que sway manda al monitor; si DP-1 se +desconecta la cierra en el acto y vuelve el foco al dashboard. + +sway no se reinicia por el HPD: entre un sway y otro fbdev retomaba el DP y +lo entrenaba contra un sink ya desconectado (training fallido + vblank +timeout, el camino que antes reseteaba el telefono). Algunos conversores y +TVs sin alimentacion externa bajan el HPD cada minuto o dos. + +v9: sway.config ya no lanza la consola (en v7 lo hacia siempre y sin monitor +caia en el panel con el foco, tapando el dashboard). La consola se abre solo +cuando sway ya tiene DP-1 activa; si aparece en el panel se cierra. +""" +import json +import glob +import os +import signal +import socket +import subprocess +import time + +DEBOUNCE_SECS = 3 +DP_STATUS = "/sys/class/drm/card1-DP-1/status" +# el panel (DSI-1) debe existir antes de lanzar sway, o wlroots no enumera +# outputs y sway muere +DSI_STATUS = "/sys/class/drm/card1-DSI-1/status" +DRM_DEV = "/dev/dri/card1" +DRM_WAIT_SECS = 60 +RUNTIME_DIR = "/run/rtwo-dashboard" +# libseat de Alpine no trae el backend "builtin": seatd-launch levanta un +# seatd temporal solo para sway. +CMD = ["seatd-launch", "--", "sway", "-c", "/usr/share/rtwo-dashboard/sway.config"] +# login sale solo tras LOGIN_TIMEOUT (60 s) sin teclas: se relanza dentro de la +# misma ventana, como en una tty, para no dejar el monitor en negro. Dos trampas: +# - login se pone en su propio grupo de procesos y sh -c no hace control de +# trabajos -> SIGTTOU, login detenido (estado T) antes del prompt = monitor +# negro con solo el cursor. setsid -c lo hace lider de sesion con la tty. +# - login hace vhangup() de la tty: los fds heredados del sh quedan colgados y +# el siguiente login moria al instante (bucle a ~2000 procesos/s). Por eso se +# reabre la tty por nombre en cada vuelta. +CONSOLE = ["foot", "-c", "/usr/share/rtwo-dashboard/consola.ini", "-T", "rtwo-consola", + "sh", "-c", 't=$(tty); while :; do setsid -c -w sh -c "clear; exec /bin/login"' + ' <"$t" >"$t" 2>&1; sleep 0.5; done'] +ENV = { + "XDG_RUNTIME_DIR": RUNTIME_DIR, + "WLR_RENDERER": "pixman", # sin depender del firmware de la GPU + "WLR_LIBINPUT_NO_DEVICES": "1", + "LANG": "C.UTF-8", +} +# clientes que sway lanza con exec: van en su propia sesion (setsid), asi +# que matar el grupo de seatd-launch no los alcanza +CLIENT_MARK = b"/usr/share/rtwo-dashboard/" + + +def dp_connected(): + try: + with open(DP_STATUS) as f: + return f.read().strip() == "connected" + except OSError: + return False + + +def kill_group(proc, timeout=5): + if proc is None or proc.poll() is not None: + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except ProcessLookupError: + return + try: + proc.wait(timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + + +def kill_clients(): + """Matar bucles sh/foot/login de instancias anteriores de sway.""" + me = os.getpid() + for pid in os.listdir("/proc"): + if not pid.isdigit() or int(pid) == me: + continue + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + cmd = f.read() + except OSError: + continue + if CLIENT_MARK in cmd and b"sway.config" not in cmd: + try: + os.kill(int(pid), signal.SIGTERM) + except ProcessLookupError: + pass + + +def clean_stale_seatd(): + """Borrar /run/seatd.sock si ningun seatd esta vivo.""" + sock = "/run/seatd.sock" + if not os.path.exists(sock): + return + for pid in os.listdir("/proc"): + if pid.isdigit(): + try: + with open(f"/proc/{pid}/comm") as f: + if f.read().strip() == "seatd": + return + except OSError: + pass + try: + os.unlink(sock) + except OSError: + pass + + +def wayland_display(): + """Socket wayland de sway que acepta conexiones (None si no hay).""" + for path in sorted(glob.glob(RUNTIME_DIR + "/wayland-*")): + if path.endswith(".lock"): + continue + s = socket.socket(socket.AF_UNIX) + try: + s.connect(path) + return os.path.basename(path) + except OSError: + continue + finally: + s.close() + return None + + +def wait_for_drm(): + deadline = time.monotonic() + DRM_WAIT_SECS + while time.monotonic() < deadline: + try: + with open(DSI_STATUS) as f: + if f.read().strip() and os.path.exists(DRM_DEV): + return + except OSError: + pass + time.sleep(0.5) + print(f"timeout esperando DSI-1 ({DRM_WAIT_SECS}s); lanzo igual", flush=True) + + +def sway_ipc(env, *args): + """swaymsg al sway actual; devuelve stdout (None si falla).""" + socks = sorted(glob.glob(RUNTIME_DIR + "/sway-ipc.*.sock"), key=os.path.getmtime) + if not socks: + return None + try: + r = subprocess.run(["swaymsg", "-r", "-s", socks[-1], *args], env=env, + capture_output=True, timeout=5) + except subprocess.TimeoutExpired: + return None + return r.stdout if r.returncode == 0 else None + + +def dp_active_in_sway(env): + out = sway_ipc(env, "-t", "get_outputs") + try: + return any(o["name"] == "DP-1" and o["active"] for o in json.loads(out)) + except (TypeError, ValueError, KeyError): + return False + + +def console_on_panel(env): + """True si la ventana rtwo-consola quedo en DSI-1 (nunca deberia).""" + out = sway_ipc(env, "-t", "get_tree") + try: + tree = json.loads(out) + except (TypeError, ValueError): + return False + + def walk(n): + if n.get("name") == "rtwo-consola": + return True + return any(walk(c) for c in n.get("nodes", []) + n.get("floating_nodes", [])) + return any(o.get("name") == "DSI-1" and walk(o) for o in tree.get("nodes", [])) + + +def main(): + os.makedirs(RUNTIME_DIR, mode=0o700, exist_ok=True) + env = dict(os.environ, **ENV) + sway = console = None + state = dp_connected() + since = time.monotonic() + last_check = 0.0 + signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(SystemExit)) + kill_clients() + try: + while True: + now = time.monotonic() + cur = dp_connected() + if cur != state: + state, since = cur, now + print("DP-1 " + ("conectado" if cur else "desconectado"), flush=True) + if sway is not None and sway.poll() is not None: + print(f"sway termino con codigo {sway.returncode}", flush=True) + sway = None + kill_group(console) + console = None + kill_clients() + if sway is None: + clean_stale_seatd() + for old in glob.glob(RUNTIME_DIR + "/sway-ipc.*.sock"): + os.unlink(old) + wait_for_drm() + print("arrancando sway (dashboard en DSI-1)", flush=True) + sway = subprocess.Popen(CMD, env=env, start_new_session=True) + time.sleep(2) + continue + if console is not None and console.poll() is not None: + console = None # logout o foot cerrado: se relanza abajo + if (console is not None and now - last_check >= 2 + and (last_check := now) and console_on_panel(env)): + print("consola en el panel: la cierro", flush=True) + kill_group(console, timeout=2) + console = None + sway_ipc(env, "workspace", "1") + since = now # reintentar tras otro debounce + if not state and console is not None: + # cerrar ya (sin debounce) para que no se mude al panel + kill_group(console, timeout=2) + console = None + sway_ipc(env, "workspace", "1") + elif (state and console is None and now - since >= DEBOUNCE_SECS + and dp_active_in_sway(env)): + wl = wayland_display() + if wl: + print("abriendo consola en DP-1", flush=True) + console = subprocess.Popen( + CONSOLE, env=dict(env, WAYLAND_DISPLAY=wl), + start_new_session=True) + time.sleep(0.3) + except SystemExit: + pass + finally: + kill_group(console, timeout=2) + kill_group(sway) + kill_clients() + + +if __name__ == "__main__": + main() diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard-sway.config b/device/testing/device-motorola-rtwo/rtwo-dashboard-sway.config new file mode 100644 index 000000000..cf94d6968 --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard-sway.config @@ -0,0 +1,30 @@ +# rtwo dashboard sobre sway (2026-09-26, v9) +# sway corre siempre. La consola del monitor la abre/cierra rtwo-dashboard-launcher.py +# segun el HPD de DP-1 (si se lanzara aca, sin monitor caeria en el panel). +# DSI-1 (panel): dashboard. DP-1 (monitor): consola de login, como la tty1. +default_border none +default_floating_border none +focus_follows_mouse no +bar mode invisible +output DSI-1 pos 0 0 scale 1 +# cualquier pantalla externa: su modo preferido +output DP-1 pos 1080 0 scale 1 +# Samsung S19D300: CEA 1280x720@60 (VIC 4, 2o DTD de su EDID). +# - 1366x768 nativo: msm parte el plano en 2 mezcladores -> 683 px impar -> imagen +# corrida en diagonal. +# - modeline VESA 1360x768 (vtotal 795, vsync 6) NO es su timing (nativo: vtotal 798, +# vsync 3, vbp 24): el monitor/conversor HDMI engancha y suelta -> imagen que +# aparece y desaparece y caidas de HPD cada pocos minutos (2026-09-26). +output "Samsung Electric Company S19D300 Unknown" pos 1080 0 scale 1 mode 1280x720@60Hz +workspace 1 output DSI-1 +workspace 2 output DP-1 +assign [title="^rtwo-dashboard$"] workspace 1 +assign [title="^rtwo-consola$"] workspace 2 +for_window [title="^rtwo-(consola|dashboard)$"] fullscreen enable +# el teclado va siempre a la consola del monitor; el tactil se apaga para que +# un toque en el panel no le robe el foco (el dashboard no lo necesita) +for_window [title="^rtwo-consola$"] focus +no_focus [title="^rtwo-dashboard$"] +input type:touch events disabled +# dashboard en el panel (se relanza solo si termina) +exec sh -c 'while :; do foot -c /usr/share/rtwo-dashboard/foot.ini -T rtwo-dashboard python3 /usr/libexec/rtwo-dashboard; sleep 1; done' diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard.py b/device/testing/device-motorola-rtwo/rtwo-dashboard.py new file mode 100644 index 000000000..12d1454e9 --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard.py @@ -0,0 +1,503 @@ +#!/usr/bin/env python3 +"""rtwo-dashboard v3: full-screen system monitor for the Motorola rtwo panel. + +Runs inside the foot terminal, launched full-screen by cage (Wayland kiosk) +from rtwo-dashboard-launcher.py, which also handles showing it after a few +idle seconds and hiding it on the first key press. This script only draws: +square-cornered cards, 24-bit color, gradient bars, history sparklines and color +emoji (Noto Color Emoji through foot/fontconfig). Pure stdlib. + +Only emoji with East Asian Width "W" are used (always 2 cells, no variation +selector), so the box borders line up. +""" +import datetime as _dt +import os +import re +import shutil +import signal +import subprocess +import sys +import time +import unicodedata + +REFRESH_SECS = 1.0 +HIST = 60 + +# Palette (Catppuccin Mocha) +BASE = (17, 17, 27) +SURF = (49, 50, 68) +OVER = (108, 112, 134) +SUB = (166, 173, 200) +TEXT = (205, 214, 244) +BLUE = (137, 180, 250) +LAV = (180, 190, 254) +SAPH = (116, 199, 236) +TEAL = (148, 226, 213) +GREEN = (166, 227, 161) +YEL = (249, 226, 175) +PEACH = (250, 179, 135) +RED = (243, 139, 168) +MAUVE = (203, 166, 247) +PINK = (245, 194, 231) + +ESC = "\033[" +RST = ESC + "0m" +BOLD = ESC + "1m" + + +def fg(c): + return f"{ESC}38;2;{c[0]};{c[1]};{c[2]}m" + + +def bg(c): + return f"{ESC}48;2;{c[0]};{c[1]};{c[2]}m" + + +ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") + + +def cwidth(ch): + o = ord(ch) + if o in (0x200D, 0xFE0F, 0xFE0E) or unicodedata.combining(ch): + return 0 + return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 + + +def vlen(s): + return sum(cwidth(ch) for ch in ANSI.sub("", s)) + + +def pad(s, width): + """Pad (or trim) visible content to exactly `width` cells.""" + n = vlen(s) + if n <= width: + return s + " " * (width - n) + out, seen = [], 0 + for part in re.split(r"(\x1b\[[0-9;?]*[A-Za-z])", s): + if part.startswith("\x1b"): + out.append(part) + continue + for ch in part: + w = cwidth(ch) + if seen + w > width - 1: + return "".join(out) + "…" + " " * (width - 1 - seen) + out.append(ch) + seen += w + return "".join(out) + + +def mix(a, b, t): + t = max(0.0, min(1.0, t)) + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def heat(t): + """0 -> green, 0.6 -> yellow, 1 -> red.""" + if t < 0.6: + return mix(GREEN, YEL, t / 0.6) + return mix(YEL, RED, (t - 0.6) / 0.4) + + +EIGHTHS = " ▏▎▍▌▋▊▉█" + + +def gbar(pct, width, level=None): + """Gradient bar with 1/8 cell resolution. Cell color follows position.""" + pct = max(0.0, min(100.0, pct)) + cells = pct * width / 100.0 + full = int(cells) + part = int((cells - full) * 8) + out = [] + for i in range(width): + col = heat(level if level is not None else i / max(1, width - 1)) + if i < full: + out.append(fg(col) + "█") + elif i == full and part: + out.append(fg(col) + bg(SURF) + EIGHTHS[part] + RST) + else: + out.append(fg(SURF) + "█") + return "".join(out) + RST + + +SPARK = "▁▂▃▄▅▆▇█" + + +def spark(values, width, color, top=None): + vals = list(values)[-width:] + vals = [0.0] * (width - len(vals)) + vals + hi = top if top else (max(vals) or 1.0) + out = [fg(color)] + for v in vals: + idx = int(round(max(0.0, min(1.0, v / hi)) * (len(SPARK) - 1))) + out.append(SPARK[idx] if v > 0 else fg(SURF) + "▁" + fg(color)) + return "".join(out) + RST + + +def card(icon, title, accent, body, width): + """Card with square corners (foot draws stray strokes next to the + rounded arc corners at this font size). `body` lines are pre-colored.""" + inner = width - 4 + head = f" {icon} {BOLD}{fg(accent)}{title}{RST} " + top = (fg(accent) + "┌─" + RST + head + fg(accent) + + "─" * max(0, width - 3 - vlen(head)) + "┐" + RST) + lines = [top] + for b in body: + lines.append(fg(accent) + "│ " + RST + pad(b, inner) + fg(accent) + " │" + RST) + lines.append(fg(accent) + "└" + "─" * (width - 2) + "┘" + RST) + return lines + + +def human(n, digits=1): + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20), ("K", 1 << 10)): + if n >= div: + return f"{n / div:.{digits}f}{unit}" + return f"{n:.0f}B" + + +def rate(bps): + return human(bps, 1) + "/s" if bps >= 1024 else f"{bps:.0f}B/s" + + +def read(path, default=None): + try: + with open(path) as f: + return f.read().strip() + except OSError: + return default + + +def readint(path, default=None): + v = read(path) + try: + return int(v) + except (TypeError, ValueError): + return default + + +def sh(cmd): + try: + return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, text=True).strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +# ---------------------------------------------------------------- data + + +class Cpu: + def __init__(self): + self.prev = self._read() + self.hist = [] + + @staticmethod + def _read(): + out = {} + try: + with open("/proc/stat") as f: + for line in f: + if not line.startswith("cpu"): + break + p = line.split() + vals = list(map(int, p[1:8])) + idle = vals[3] + vals[4] + out[p[0]] = (sum(vals), idle) + except OSError: + pass + return out + + def sample(self): + cur = self._read() + usage = {} + for k, (tot, idle) in cur.items(): + ptot, pidle = self.prev.get(k, (tot, idle)) + dt = tot - ptot + usage[k] = 0.0 if dt <= 0 else 100.0 * (1 - (idle - pidle) / dt) + self.prev = cur + total = usage.get("cpu", 0.0) + self.hist = (self.hist + [total])[-HIST:] + cores = [usage.get(f"cpu{i}", 0.0) for i in range(os.cpu_count() or 8)] + return total, cores + + +def thermal(): + temps = {} + base = "/sys/class/thermal" + try: + zones = os.listdir(base) + except OSError: + return temps + for z in zones: + if not z.startswith("thermal_zone"): + continue + t = read(f"{base}/{z}/type", "") + v = readint(f"{base}/{z}/temp") + if v is not None: + temps[t] = v / 1000.0 + return temps + + +def freqs(): + out = [] + for i in range(os.cpu_count() or 8): + v = readint(f"/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq") + out.append((v or 0) / 1e6) + return out + + +def meminfo(): + m = {} + try: + with open("/proc/meminfo") as f: + for line in f: + k, v = line.split(":", 1) + m[k] = int(v.split()[0]) * 1024 + except (OSError, ValueError): + pass + return m + + +class Net: + def __init__(self, dev="wlan0"): + self.dev = dev + self.prev = self._read() + self.t = time.monotonic() + self.rx_hist, self.tx_hist = [], [] + + def _read(self): + base = f"/sys/class/net/{self.dev}/statistics" + return readint(f"{base}/rx_bytes", 0), readint(f"{base}/tx_bytes", 0) + + def sample(self): + now = time.monotonic() + rx, tx = self._read() + dt = max(1e-3, now - self.t) + r = max(0.0, (rx - self.prev[0]) / dt) + t = max(0.0, (tx - self.prev[1]) / dt) + self.prev, self.t = (rx, tx), now + self.rx_hist = (self.rx_hist + [r])[-HIST:] + self.tx_hist = (self.tx_hist + [t])[-HIST:] + return r, t + + +def wifi(): + info = {"ssid": "", "dbm": None, "freq": None, "ip": ""} + for line in sh(["iw", "dev", "wlan0", "link"]).splitlines(): + line = line.strip() + if line.startswith("SSID:"): + info["ssid"] = line.split(":", 1)[1].strip() + elif line.startswith("signal:"): + try: + info["dbm"] = int(line.split()[1]) + except (IndexError, ValueError): + pass + elif line.startswith("freq:"): + try: + info["freq"] = float(line.split()[1]) + except (IndexError, ValueError): + pass + for part in sh(["ip", "-o", "-4", "addr", "show", "dev", "wlan0"]).split(): + if "/" in part and "." in part: + info["ip"] = part.split("/")[0] + break + return info + + +def battery(): + ps = "/sys/class/power_supply/qcom-battmgr-bat" + b = { + "cap": readint(f"{ps}/capacity"), + "status": read(f"{ps}/status", "?"), + "volt": (readint(f"{ps}/voltage_now") or 0) / 1e6, + "cur": (readint(f"{ps}/current_now") or 0) / 1e6, + "power": (readint(f"{ps}/power_now") or 0) / 1e6, + "temp": (readint(f"{ps}/temp") or 0) / 10.0, + "charge": readint(f"{ps}/charge_counter"), + "cycles": readint(f"{ps}/cycle_count"), + "health": readint(f"{ps}/state_of_health"), + } + usb = "/sys/class/power_supply/qcom-battmgr-usb" + b["usb_v"] = (readint(f"{usb}/voltage_now") or 0) / 1e6 + b["usb_online"] = readint(f"{usb}/online", 0) + return b + + +def hms(secs): + if not secs or secs <= 0: + return "—" + h, m = divmod(int(secs) // 60, 60) + return f"{h}h {m:02d}m" if h else f"{m}m" + + +def uptime(): + try: + secs = float(read("/proc/uptime", "0").split()[0]) + except ValueError: + return "—" + d, rem = divmod(int(secs), 86400) + h, rem = divmod(rem, 3600) + m = rem // 60 + return f"{d}d {h}h {m:02d}m" if d else f"{h}h {m:02d}m" + + +DIAS = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"] +MESES = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"] + + +# ---------------------------------------------------------------- render + + +def split_lr(left, right, width): + gap = width - vlen(left) - vlen(right) + return left + " " * max(1, gap) + right + + +def render(cols, rows, cpu, net): + width = min(cols - 2, 60) + inner = width - 4 + total, cores = cpu.sample() + rx, tx = net.sample() + temps = thermal() + fr = freqs() + mem = meminfo() + w = wifi() + b = battery() + now = _dt.datetime.now() + + out = [] + + # header + host = read("/proc/sys/kernel/hostname", "rtwo") + clock = f"{BOLD}{fg(LAV)}{now:%H:%M:%S}{RST}" + out.append(split_lr(f"📱 {BOLD}{fg(TEXT)}{host}{RST}", f"🕒 {clock}", width)) + fecha = f"{DIAS[now.weekday()]} {now.day} {MESES[now.month - 1]} {now.year}" + out.append(split_lr(f"{fg(SUB)} {fecha}{RST}", f"⏳ {fg(SUB)}{uptime()}{RST}", width)) + out.append("") + + # CPU + cpu_t = max([v for k, v in temps.items() if k.startswith("cpu")] or [0]) + load = read("/proc/loadavg", "0 0 0").split()[:3] + body = [ + split_lr(gbar(total, inner - 6, total / 100), f"{BOLD}{fg(heat(total / 100))}{total:4.0f}%{RST}", inner), + spark(cpu.hist, inner, BLUE, top=100), + ] + core_cells = [] + for i, u in enumerate(cores): + core_cells.append(f"{fg(OVER)}{i}{fg(heat(u / 100))}{SPARK[min(7, int(u / 12.5))]}{RST}") + body.append(" ".join(core_cells) + f" {fg(SUB)}{min(fr):.1f}–{max(fr):.1f} GHz{RST}") + body.append(split_lr(f"🔥 {fg(heat((cpu_t - 30) / 50))}{cpu_t:.0f}°C{RST}", + f"{fg(SUB)}load {' '.join(load)}{RST}", inner)) + out += card("🧠", "CPU", BLUE, body, width) + + # memory + mt, ma = mem.get("MemTotal", 0), mem.get("MemAvailable", 0) + mu = mt - ma + mp = 100.0 * mu / mt if mt else 0 + st, sf = mem.get("SwapTotal", 0), mem.get("SwapFree", 0) + body = [ + split_lr(gbar(mp, inner - 6, mp / 100), f"{BOLD}{fg(heat(mp / 100))}{mp:4.0f}%{RST}", inner), + split_lr(f"{fg(TEXT)}{human(mu)}{fg(OVER)} / {human(mt)}{RST}", + f"{fg(SUB)}zram {human(st - sf)} / {human(st, 0)}{RST}", inner), + ] + out += card("🐏", "Memoria", MAUVE, body, width) + + # storage + try: + s = os.statvfs("/") + dt_, du = s.f_blocks * s.f_frsize, (s.f_blocks - s.f_bfree) * s.f_frsize + except OSError: + dt_ = du = 0 + dp = 100.0 * du / dt_ if dt_ else 0 + body = [ + split_lr(gbar(dp, inner - 6, dp / 100), f"{BOLD}{fg(heat(dp / 100))}{dp:4.0f}%{RST}", inner), + f"{fg(TEXT)}{human(du)}{fg(OVER)} usados de {human(dt_)}{RST}", + ] + out += card("💾", "Almacenamiento", SAPH, body, width) + + # pantalla apaisada (monitor por DP): red y bateria en una segunda columna + two_cols = cols >= 2 * width + 4 + if two_cols: + col1, out = out, [""] * 3 + + # network + if w["ssid"]: + dbm = w["dbm"] if w["dbm"] is not None else -100 + q = max(0.0, min(1.0, (dbm + 90) / 50)) + bars = "".join( + (fg(heat(1 - q)) if q >= (i + 1) / 4 - 0.01 else fg(SURF)) + c + for i, c in enumerate("▂▄▆█") + ) + RST + band = f"{w['freq'] / 1000:.1f} GHz" if w["freq"] else "" + body = [ + split_lr(f"{BOLD}{fg(TEXT)}{w['ssid']}{RST}", f"{bars} {fg(SUB)}{dbm} dBm{RST}", inner), + split_lr(f"🌐 {fg(TEAL)}{w['ip'] or 'sin IP'}{RST}", f"{fg(SUB)}{band}{RST}", inner), + f"📥 {fg(GREEN)}{rate(rx):>9}{RST} " + spark(net.rx_hist, inner - 13, GREEN), + f"📤 {fg(PEACH)}{rate(tx):>9}{RST} " + spark(net.tx_hist, inner - 13, PEACH), + ] + else: + body = [f"{fg(RED)}sin conexión{RST}", f"{fg(OVER)}esperando WiFi…{RST}"] + out += card("📶", "Red", TEAL, body, width) + + # battery + cap = b["cap"] if b["cap"] is not None else 0 + st_txt = {"Charging": "⚡ Cargando", "Discharging": "🔋 Descargando", + "Full": "✅ Completa", "Not charging": "🔌 Conectada"}.get(b["status"], b["status"]) + cap_col = heat(1 - cap / 100) + cur = b["cur"] + sign = "+" if cur > 0 else "" + # time_to_*_avg del firmware viene en otra escala (daba ~441 h): + # estimar con la carga restante (charge_counter, uAh) y la corriente. + left = "—" + if b["charge"] and abs(cur) > 0.02 and cap: + rem_ah = b["charge"] / 1e6 + full_ah = rem_ah * 100.0 / cap + hours = (full_ah - rem_ah) / abs(cur) if cur > 0 else rem_ah / abs(cur) + left = hms(hours * 3600) + body = [ + split_lr(gbar(cap, inner - 6, 1 - cap / 100), f"{BOLD}{fg(cap_col)}{cap:4d}%{RST}", inner), + split_lr(f"{fg(TEXT)}{st_txt}{RST}", f"⏳ {fg(SUB)}{left}{RST}", inner), + split_lr(f"{fg(YEL)}{abs(b['power']):.2f} W{RST} {fg(OVER)}·{RST} " + f"{fg(TEXT)}{b['volt']:.2f} V{RST} {fg(OVER)}·{RST} {fg(TEXT)}{sign}{cur:.2f} A{RST}", + f"🔥 {fg(heat((b['temp'] - 25) / 20))}{b['temp']:.0f}°C{RST}", inner), + ] + extra = f"{fg(SUB)}{b['cycles'] or '?'} ciclos · salud {b['health'] or '?'}%{RST}" + if b["usb_online"] and b["usb_v"] > 1: + extra = split_lr(extra, f"🔌 {fg(SUB)}USB {b['usb_v']:.1f} V{RST}", inner) + body.append(extra) + out += card("🔋", "Batería", GREEN, body, width) + + if two_cols: + n = max(len(col1), len(out)) + col1 += [""] * (n - len(col1)) + out += [""] * (n - len(out)) + out = [pad(a, width) + " " + b for a, b in zip(col1, out)] + width = 2 * width + 4 + + # center + top = max(0, (rows - len(out)) // 2) + left_pad = " " * max(0, (cols - width) // 2) + frame = [""] * top + [left_pad + ln for ln in out] + frame += [""] * max(0, rows - len(frame)) + return frame[:rows] + + +def main(): + sys.stdout.write(ESC + "?25l" + ESC + "2J") + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + cpu, net = Cpu(), Net() + time.sleep(0.3) + try: + while True: + cols, rows = shutil.get_terminal_size((45, 45)) + frame = render(cols, rows, cpu, net) + sys.stdout.write(ESC + "H" + "\r\n".join(ESC + "2K" + ln for ln in frame)) + sys.stdout.flush() + time.sleep(REFRESH_SECS) + except (KeyboardInterrupt, SystemExit): + pass + finally: + sys.stdout.write(RST + ESC + "?25h") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/device/testing/device-motorola-rtwo/rtwo-dashboard.service b/device/testing/device-motorola-rtwo/rtwo-dashboard.service new file mode 100644 index 000000000..ba6658ffc --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-dashboard.service @@ -0,0 +1,13 @@ +[Unit] +Description=rtwo dashboard (sway: dashboard en el panel; consola en el monitor DP-1 si hay) +After=systemd-user-sessions.service getty@tty1.service plymouth-quit-wait.service +Wants=getty@tty1.service + +[Service] +Type=simple +ExecStart=/usr/libexec/rtwo-dashboard-launcher +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.service b/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.service new file mode 100644 index 000000000..f5c3b6a8a --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.service @@ -0,0 +1,11 @@ +[Unit] +Description=rtwo: destrabar wpa_supplicant y conectar a 5 GHz tras el arranque (workaround ath12k) +After=NetworkManager.service wpa_supplicant.service +Wants=NetworkManager.service + +[Service] +Type=simple +ExecStart=/usr/libexec/rtwo-wifi-5ghz-scan + +[Install] +WantedBy=multi-user.target diff --git a/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.sh b/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.sh new file mode 100644 index 000000000..a9c761fb6 --- /dev/null +++ b/device/testing/device-motorola-rtwo/rtwo-wifi-5ghz-scan.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# rtwo (ath12k / WCN7850), 2026-09-25. Tras el arranque, wpa_supplicant queda +# con un escaneo trabado ("Reject scan trigger since one is already pending") +# y su interfaz no sale de 'disconnected': NetworkManager no intenta conectar +# nunca. Ademas, los escaneos normales no recorren los canales de 5 GHz, y la red +# (en 5 GHz) no aparece. +# Hasta que wlan0 conecte: escaneo dirigido a 5 GHz (con 'scan abort' si da +# EBUSY), conexion explicita con nmcli si la red aparece, y reinicio de +# wpa_supplicant (NetworkManager se reengancha solo) si sigue sin conectar. +FREQS="5180 5200 5220 5240 5260 5280 5300 5320 5500 5520 5540 5560 5580 5600 5620 5640 5660 5680 5700 5720 5745 5765 5785 5805 5825" +# Red a destrabar: la primera conexion WiFi de NetworkManager, o la que se fije +# en /etc/default/rtwo-wifi-5ghz-scan (CON=). +[ -r /etc/default/rtwo-wifi-5ghz-scan ] && . /etc/default/rtwo-wifi-5ghz-scan +: "${CON:=$(nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" { print $1; exit }')}" +[ -n "$CON" ] || { echo "sin conexion WiFi en NetworkManager"; exit 0; } +: "${SSID:=$(nmcli -g 802-11-wireless.ssid connection show "$CON")}" + +connected() { + nmcli -t -f DEVICE,STATE device | grep -q '^wlan0:connected$' +} + +scan() { + iw dev wlan0 scan freq $FREQS ssid "$SSID" 2>&1 +} + +i=0 +while [ ! -e /sys/class/net/wlan0 ] && [ $i -lt 60 ]; do + sleep 1; i=$((i + 1)) +done +[ -e /sys/class/net/wlan0 ] || { echo "wlan0 no aparecio"; exit 1; } +sleep 10 + +n=0 +while [ $n -lt 30 ]; do + if connected; then + echo "wlan0 conectado tras $n intentos" + exit 0 + fi + n=$((n + 1)) + + if [ $n -eq 2 ] || [ $((n % 5)) -eq 0 ]; then + echo "#$n: reiniciando wpa_supplicant (escaneo trabado)" + systemctl restart wpa_supplicant + sleep 5 + connected && continue + fi + + out=$(scan) + if echo "$out" | grep -q 'Resource busy'; then + echo "#$n: EBUSY, scan abort y reintento" + iw dev wlan0 scan abort >/dev/null 2>&1 + sleep 1 + out=$(scan) + fi + if echo "$out" | grep -q "SSID: $SSID\$"; then + echo "#$n: $SSID encontrada, conectando con nmcli" + nmcli --wait 30 connection up "$CON" 2>&1 | sed 's/^/ nmcli: /' + else + nbss=$(echo "$out" | grep -c '^BSS ') + err=$(echo "$out" | grep -m1 'command failed') + echo "#$n: $SSID NO encontrada ($nbss BSS en 5 GHz) $err" + fi + sleep 10 +done +echo "wlan0 sigue sin conectar tras $n intentos" +exit 1