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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnHwDU5yL8L6qgPEN5CV5m
This commit is contained in:
2026-09-26 20:36:18 -03:00
co-authored by Claude Opus 5.5
parent c028f63183
commit 98b3104e94
9 changed files with 963 additions and 1 deletions
+53 -1
View File
@@ -2,7 +2,7 @@
maintainer="snlavaise <snlavaise@gmail.com>"
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
"
@@ -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
@@ -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
@@ -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()
@@ -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'
@@ -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()
@@ -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
@@ -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
@@ -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=<nombre de la conexion>).
[ -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