Files
Kiwy-Signage/linux/monitor_player.py
T
ske087 3ac7f836c4 Port the player to Raspberry Pi OS Trixie 64-bit (Linux-only branch)
Replaces the Windows port with a Raspberry Pi / Linux implementation on
Raspberry Pi OS "Trixie" (Debian 13, aarch64, Wayland/labwc). The Windows
code is removed here but preserved on the Windows-Player branch.

Entry point
-----------
linux/run_linux.py replaces windows/run_win.py. src/main.py stays
platform-neutral; all Pi-specific behaviour is injected from linux/.

Five bugs that prevented the port (all measured on real hardware)
----------------------------------------------------------------
1. Kivy's PyPI wheel bundles an SDL2 built WITHOUT the wayland driver, so
   no window could be created (Trixie has no X server). linux/fix_kivy_sdl2.sh
   symlinks the system SDL2 over the bundled filename.
2. SDL2 requires WAYLAND_DISPLAY to be *set* - the socket alone is not enough,
   unlike wlopm. This broke every systemd/cron/autostart launch.
   linux_display.ensure_session_environment() detects and exports it.
3. Kivy's Clock resolves callbacks via func.__name__; a patch assigned under a
   different name crashed the player ~20s after a successful start.
4. The inherited signal_screen_activity() shelled out to tvservice, xdotool and
   ydotool - none exist on Trixie - and mis-escaped 'wlopm --on \*', so the
   display blanked after 10 minutes.
5. The launchers ran src/main.py directly, bypassing every platform patch and
   resolving the data directory one level too high.

Web links
---------
- --ozone-platform-hint=auto does NOT fall back to Wayland on Chromium 152; it
  aborts. The platform is now chosen explicitly.
- The keyring password prompt is suppressed via the ENVIRONMENT, not the flags:
  launch_env() strips DBUS_SESSION_BUS_ADDRESS for the child so Chromium cannot
  reach gnome-keyring-daemon.
- Teardown kills the whole process group (needs start_new_session=True);
  previously it silently fell back to terminate() and orphaned children.

Video normalisation
-------------------
A 4K video cannot play on a Pi 4: ffpyplayer decodes in software, measured at
0.90x realtime (1080p is 3.03x). Oversized media is downscaled to 1920x1080 at
sync time using the hardware h264_v4l2m2m encoder (~31s for an 18s clip),
triggered by resolution only so already-playable files are untouched.

src/media_state.py owns the shared on-disk contract: a .kiwy-converting marker
makes the player skip the item while it is being rebuilt, then the converted
file is played instead. If nothing is playable at all (a single-item playlist
whose only video is converting), the player loops the intro video rather than
leaving a blank screen.

Also fixed
----------
- network_monitor: replaced netsh/ifconfig/dhclient with nmcli (Trixie uses
  NetworkManager; ifconfig and dhclient are not even installed).
- Removed the Windows-only focus keeper/guardian from main.py.
- main.py: duplicate SDL_AUDIODRIVER setdefault (a silent no-op); Settings
  "Test connection" now uses tempfile.gettempdir().
- config/app_config.json: credentials blanked so a fresh clone runs the
  first-run setup flow.

Verification
------------
linux/test_media_state.py 18/18, test_linux_patches.py 21/21,
test_linux_browser_flags.py 27/27. Verified live against a real DigiServer:
image -> weblink -> image -> video with correct durations, zero leaked Chromium
processes, and no throttling over a 10 minute monitored run.
2026-09-13 21:57:49 +03:00

468 lines
16 KiB
Python

"""monitor_player.py — CPU, GPU, temperature and memory monitor for the player.
A 24/7 signage player on a Raspberry Pi 4 fails in slow, quiet ways: the SoC
throttles, the CPU governor parks a core, memory creeps up until the OOM killer
picks a victim. This samples the numbers that reveal those trends before they
become a blank screen.
Usage:
# live view, 5s interval, until Ctrl+C
.venv/bin/python linux/monitor_player.py
# 10 minutes at 2s, also written to CSV
.venv/bin/python linux/monitor_player.py --interval 2 --duration 600
# background logging only (no live view)
.venv/bin/python linux/monitor_player.py --quiet --duration 3600 \
--csv logs/monitor-$(date +%F-%H%M).csv
What is measured, and why each one matters:
| Metric | Why it matters here |
|--------|--------------------|
| Temperature | Pi 4 throttles at 80 °C (soft limit 85 °C). Sustained high temp means the case or heatsink is inadequate for 24/7. |
| Throttle flags | ``vcgencmd get_throttled`` distinguishes *currently throttled* from *has throttled since boot* — the second one only (bits 16-19) reveals a problem that already happened. |
| ARM clock | Dropping below the configured max mid-run is the signature of thermal/voltage capping. |
| V3D / pixel clock | The VideoCore GPU clock. A weblink drives it; a static image barely does. |
| Core voltage | Under-voltage (bit 0 of the throttle mask) is almost always an inadequate PSU, and it corrupts SD cards. |
| Per-core CPU | One pegged core is normal for Kivy (single-threaded main loop); all four pegged is not. |
| Player PSS | Proportional set size of the player process — the honest figure. |
| Chromium total | A *count* as much as memory: a rising count means weblinks are leaking. |
| /dev/shm | Chromium renders through shared memory; if it fills, pages render blank. |
GPU utilisation is deliberately absent: the Pi 4's V3D exposes no
``gpu_busy_percent`` (that is a Pi 5 / v3d-drm feature) and
``/sys/kernel/debug/dri/0/gpu_stats`` does not exist here. GPU activity is
therefore inferred from the clock domains and the temperature, which is the
best this hardware offers.
"""
from __future__ import annotations
import argparse
import csv
import os
import subprocess
import sys
import time
from datetime import datetime
try:
import psutil
except ImportError:
print('psutil is required: .venv/bin/pip install psutil', file=sys.stderr)
raise SystemExit(2)
# ── Constants ────────────────────────────────────────────────────────
#: Pi 4 begins soft-throttling around here. Warning threshold, not a limit.
TEMP_WARN_C = 70.0
#: Active soft-throttle limit on Pi 4.
TEMP_CRITICAL_C = 80.0
#: ``vcgencmd get_throttled`` bit meanings. The 16-19 group is sticky: it
#: records that the condition occurred at any point since boot, which is the
#: only way to catch an intermittent brown-out on an unattended device.
THROTTLE_BITS = {
0: ('NOW', 'under-voltage'),
1: ('NOW', 'arm frequency capped'),
2: ('NOW', 'currently throttled'),
3: ('NOW', 'soft temperature limit'),
16: ('HAS', 'under-voltage occurred'),
17: ('HAS', 'arm frequency capping occurred'),
18: ('HAS', 'throttling occurred'),
19: ('HAS', 'soft temperature limit occurred'),
}
CLOCK_DOMAINS = ('arm', 'core', 'v3d', 'h264', 'pixel')
CSV_FIELDS = [
'timestamp', 'uptime_s',
'cpu_total_pct', 'cpu0_pct', 'cpu1_pct', 'cpu2_pct', 'cpu3_pct',
'load1', 'load5', 'load15',
'temp_c', 'volt_core_v',
'arm_mhz', 'core_mhz', 'v3d_mhz', 'pixel_mhz', 'h264_mhz',
'governor', 'arm_cur_mhz_cfg',
'mem_used_mb', 'mem_avail_mb', 'shm_used_mb',
'player_pss_mb', 'player_cpu_pct', 'player_threads',
'chromium_procs', 'chromium_pss_mb',
'throttled_raw', 'throttle_flags',
]
def _run(args, timeout=5):
"""Run a command, return stripped stdout, or '' on any failure."""
try:
result = subprocess.run(
args, capture_output=True, text=True, timeout=timeout, check=False,
)
return (result.stdout or '').strip()
except Exception:
return ''
def _read(path):
try:
with open(path) as fh:
return fh.read().strip()
except Exception:
return ''
# ── Individual metrics ───────────────────────────────────────────────
def read_temp_c():
"""SoC temperature in °C from hwmon, falling back to vcgencmd/thermal."""
raw = _read('/sys/class/hwmon/hwmon0/temp1_input')
if raw.isdigit():
return int(raw) / 1000.0
for zone in ('/sys/class/thermal/thermal_zone0/temp',):
raw = _read(zone)
if raw.isdigit():
return int(raw) / 1000.0
out = _run(['vcgencmd', 'measure_temp'])
# "temp=50.1'C"
try:
return float(out.split('=')[1].split("'")[0])
except Exception:
return None
def read_clock_mhz(domain):
"""Clock frequency in MHz for a vcgencmd domain."""
out = _run(['vcgencmd', 'measure_clock', domain])
# "frequency(48)=1800457088"
try:
return int(out.split('=')[1]) / 1_000_000.0
except Exception:
return None
def read_core_volts():
out = _run(['vcgencmd', 'measure_volts', 'core'])
# "volt=0.9160V"
try:
return float(out.split('=')[1].rstrip('V'))
except Exception:
return None
def read_throttled():
"""(raw_int, [human readable flags]) from vcgencmd get_throttled."""
out = _run(['vcgencmd', 'get_throttled'])
# "throttled=0x0"
try:
raw = int(out.split('=')[1], 16)
except Exception:
return None, []
flags = []
for bit, (when, label) in THROTTLE_BITS.items():
if raw & (1 << bit):
flags.append(f'{when}:{label}')
return raw, flags
def pss_mb(pid):
"""PSS in MB for a process, or None. RSS double-counts shared pages."""
total_kb = 0
try:
with open(f'/proc/{pid}/smaps_rollup') as fh:
for line in fh:
if line.startswith('Pss:'):
total_kb = int(line.split()[1])
break
except Exception:
return None
return total_kb / 1024.0
def find_procs(pattern):
"""PIDs whose cmdline matches ``pattern``, excluding this script."""
pids = []
me = os.getpid()
for proc in psutil.process_iter(['pid', 'cmdline']):
try:
if proc.info['pid'] == me:
continue
cmdline = ' '.join(proc.info['cmdline'] or [])
if pattern in cmdline and 'monitor_player' not in cmdline:
pids.append(proc.info['pid'])
except Exception:
continue
return pids
#: ``psutil.Process`` objects kept alive between samples. ``cpu_percent()``
#: needs a *previous* reading for the same process to compute a delta, so a
#: freshly constructed Process always reports 0.0. Caching these is what makes
#: the player's own CPU figure meaningful instead of a constant zero.
_proc_cache: dict[int, 'psutil.Process'] = {}
def player_cpu_percent(pid):
"""CPU % for ``pid`` since the previous sample."""
try:
proc = _proc_cache.get(pid)
if proc is None or not proc.is_running():
proc = psutil.Process(pid)
_proc_cache[pid] = proc
proc.cpu_percent(interval=None) # establish the baseline
return 0.0
return proc.cpu_percent(interval=None)
except Exception:
return None
def sample():
"""Take one measurement. Never raises; missing metrics become None."""
now = time.time()
row = {'timestamp': datetime.now().isoformat(timespec='seconds')}
# CPU. interval=None means "since the previous call" — with a fixed sample
# period this yields the usage over exactly that window.
try:
row['cpu_total_pct'] = psutil.cpu_percent(interval=None)
per_core = psutil.cpu_percent(interval=None, percpu=True)
for i in range(4):
row[f'cpu{i}_pct'] = per_core[i] if i < len(per_core) else None
load = os.getloadavg()
row['load1'], row['load5'], row['load15'] = load
except Exception:
row.update({'cpu_total_pct': None, 'load1': None, 'load5': None,
'load15': None})
# Thermal / power
row['temp_c'] = read_temp_c()
row['volt_core_v'] = read_core_volts()
# Clocks
for domain in CLOCK_DOMAINS:
row[f'{domain}_mhz'] = read_clock_mhz(domain)
# Governor and configured max
row['governor'] = _read(
'/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor')
cur = _read('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
row['arm_cur_mhz_cfg'] = int(cur) / 1000.0 if cur.isdigit() else None
# Memory
try:
mem = psutil.virtual_memory()
row['mem_used_mb'] = (mem.total - mem.available) / 1024 / 1024
row['mem_avail_mb'] = mem.available / 1024 / 1024
shm = psutil.disk_usage('/dev/shm')
row['shm_used_mb'] = shm.used / 1024 / 1024
except Exception:
row.update({'mem_used_mb': None, 'mem_avail_mb': None,
'shm_used_mb': None})
# The player
player_pids = find_procs('run_linux.py')
if player_pids:
pid = player_pids[0]
row['player_pss_mb'] = pss_mb(pid)
row['player_cpu_pct'] = player_cpu_percent(pid)
try:
proc = _proc_cache.get(pid) or psutil.Process(pid)
with proc.oneshot():
row['player_threads'] = proc.num_threads()
# Uptime from the process, for correlating with playback.
row['uptime_s'] = now - proc.create_time()
except Exception:
pass
else:
row['player_pss_mb'] = None
row['player_cpu_pct'] = None
row['player_threads'] = None
# Chromium tree (weblinks). Count is as important as memory: a rising
# count means teardown is leaking, which is what kills a 24/7 install.
chromium_pids = find_procs('chromium')
row['chromium_procs'] = len(chromium_pids)
total = 0.0
for pid in chromium_pids:
value = pss_mb(pid)
if value:
total += value
row['chromium_pss_mb'] = total if chromium_pids else 0.0
# Throttling
raw, flags = read_throttled()
row['throttled_raw'] = hex(raw) if raw is not None else None
row['throttle_flags'] = ','.join(flags) if flags else ''
# Fill any field we never set so the CSV stays rectangular.
for field in CSV_FIELDS:
row.setdefault(field, None)
return row
# ── Presentation ─────────────────────────────────────────────────────
def fmt(value, width=6, spec='.1f'):
if value is None:
return ' ' * (width - 2) + '--'
return f'{value:{width}{spec}}'
def print_header():
print()
print(' time CPU% cores(0-3) temp ARM V3D volt RAM '
'player chrome gov flags')
print(' ' + '-' * 84)
def print_row(row, tick):
cores = ' '.join(
fmt(row.get(f'cpu{i}_pct'), 3, '.0f') for i in range(4))
flags = (row.get('throttle_flags') or '').replace('HAS:', 'had:') \
.replace('NOW:', 'NOW:')
warn = ''
temp = row.get('temp_c')
if temp is not None and temp >= TEMP_CRITICAL_C:
warn = ' ***HOT***'
elif temp is not None and temp >= TEMP_WARN_C:
warn = ' *warm*'
governor = (row.get('governor') or '')[:6]
print(
f' {row["timestamp"][11:19]} '
f'{fmt(row.get("cpu_total_pct"), 4, ".0f")} '
f' {cores} '
f'{fmt(temp, 5)}C '
f'{fmt(row.get("arm_mhz"), 6, ".0f")} '
f'{fmt(row.get("v3d_mhz"), 5, ".0f")} '
f'{fmt(row.get("volt_core_v"), 6, ".3f")}V'
f'{fmt(row.get("mem_used_mb"), 5, ".0f")}M '
f'{fmt(row.get("player_pss_mb"), 4, ".0f")}M '
f'{fmt(row.get("chromium_pss_mb"), 5, ".0f")}M '
f'{governor:<6} '
f'{flags}{warn}'
)
def summarise(rows):
"""Print min/avg/max for the run — the part that reveals a trend."""
if not rows:
return
print('\n ' + '=' * 84)
print(f' SUMMARY over {len(rows)} samples '
f'({rows[0]["timestamp"][11:19]} -> {rows[-1]["timestamp"][11:19]})')
print(' ' + '=' * 84)
def stat(field, spec='.1f', unit=''):
values = [r[field] for r in rows
if isinstance(r.get(field), (int, float))]
if not values:
return f' {field:<18} (no data)'
return (f' {field:<18} min {min(values):>8{spec}}{unit} '
f'avg {sum(values)/len(values):>8{spec}}{unit} '
f'max {max(values):>8{spec}}{unit}')
for field, spec, unit in (
('cpu_total_pct', '.1f', '%'),
('temp_c', '.1f', 'C'),
('arm_mhz', '.0f', 'MHz'),
('v3d_mhz', '.0f', 'MHz'),
('volt_core_v', '.3f', 'V'),
('mem_avail_mb', '.0f', 'MB'),
('player_pss_mb', '.0f', 'MB'),
('player_cpu_pct', '.1f', '%'),
('chromium_procs', '.0f', ''),
('chromium_pss_mb', '.0f', 'MB'),
):
print(stat(field, spec, unit))
# Trend: first vs last quarter, which is what matters for a leak.
def trend(field):
values = [r[field] for r in rows
if isinstance(r.get(field), (int, float))]
if len(values) < 8:
return ''
head = values[:max(1, len(values) // 4)]
tail = values[-max(1, len(values) // 4):]
delta = sum(tail) / len(tail) - sum(head) / len(head)
arrow = 'RISING ' if delta > 0 else 'falling'
return (f' {field:<18} {arrow} {delta:+8.1f} '
f'(first->last quarter)')
print('\n Trend (the leak check):')
for field in ('player_pss_mb', 'chromium_procs', 'mem_avail_mb', 'temp_c'):
line = trend(field)
if line:
print(line)
# Throttle history is the headline finding on a Pi.
all_flags = set()
for row in rows:
if row.get('throttle_flags'):
all_flags.update(row['throttle_flags'].split(','))
print('\n Throttle / power events during this run:')
print(f' {", ".join(sorted(all_flags)) if all_flags else "none — clean"}')
def main():
parser = argparse.ArgumentParser(
description='Monitor CPU, GPU clock, temperature and memory for the player.')
parser.add_argument('--interval', type=float, default=5.0,
help='seconds between samples (default 5)')
parser.add_argument('--duration', type=float, default=0,
help='seconds to run; 0 = until Ctrl+C (default 0)')
parser.add_argument('--csv', default='',
help='write samples to this CSV file')
parser.add_argument('--quiet', action='store_true',
help='no live output (for background logging)')
args = parser.parse_args()
# Prime the CPU counters: the first cpu_percent() call always returns 0.0
# because it has no previous sample to compare against.
psutil.cpu_percent(interval=None)
psutil.cpu_percent(interval=None, percpu=True)
for pid in find_procs('run_linux.py'):
try:
_proc_cache[pid] = psutil.Process(pid)
_proc_cache[pid].cpu_percent(interval=None)
except Exception:
pass
writer = None
csv_handle = None
if args.csv:
os.makedirs(os.path.dirname(os.path.abspath(args.csv)), exist_ok=True)
csv_handle = open(args.csv, 'w', newline='')
writer = csv.DictWriter(csv_handle, fieldnames=CSV_FIELDS)
writer.writeheader()
if not args.quiet:
print_header()
rows = []
started = time.time()
tick = 0
try:
while True:
time.sleep(args.interval)
tick += 1
row = sample()
rows.append(row)
if writer:
writer.writerow(row)
csv_handle.flush()
if not args.quiet:
print_row(row, tick)
if args.duration and (time.time() - started) >= args.duration:
break
except KeyboardInterrupt:
if not args.quiet:
print('\n stopped by user')
finally:
if csv_handle:
csv_handle.close()
if not args.quiet:
print(f' CSV written to {args.csv}')
if not args.quiet:
summarise(rows)
return 0
if __name__ == '__main__':
raise SystemExit(main())