diff --git a/docs/display-blank-investigation.md b/docs/display-blank-investigation.md new file mode 100644 index 0000000..dff5778 --- /dev/null +++ b/docs/display-blank-investigation.md @@ -0,0 +1,91 @@ +# Тёмный экран при горящей подсветке: расследование + +Дата: 2026-11-11. Экземпляр: возврат с объекта (жалоба: «подсветка горит, картинки нет, лечится передёргиванием питания»), питание PoE. +Плата: `Rockchip RK3566 EVB2 LP4X V10 Board`, ядро 5.10.209 (сборка 2025-12-02), Debian 11. Панель MIPI-DSI 480×480, драйвер `simple-panel-dsi`, контроллер панели по init-последовательности ST7701S (`FF 77 01 00 00 10`). + +## Коротко + +1. **Сбой воспроизведён без PoE.** Достаточно (а) тёплой перезагрузки или (б) короткого импульса на линии сброса панели: панель гаснет, подсветка горит, SoC продолжает выводить кадры, ядро не пишет ни одной ошибки. +2. **Со стороны SoC сбой не виден.** Конвейер VOP2 → DSI активен, режим 480x480p60, кадр выводится, подсветка включена. DSI работает в video mode без подтверждений от панели, поэтому потерю инициализации панели ядро обнаружить не может. +3. **Панель после загрузки остаётся без управления по питанию.** Ядро принимает дисплей, инициализированный U-Boot (передача логотипа), и не выполняет последовательность включения панели. Линии питания панели остаются **входами без драйвера**: + + | GPIO | назначение (DT) | после загрузки | после повторной инициализации драйвером | + |---|---|---|---| + | gpio-16 (GPIO0_C0) | `reset-gpios`, активный низкий | out, high | out, high | + | gpio-17 (GPIO0_C1) | `powers-gpios` | **in, low** | out, high | + | gpio-18 (GPIO0_C2) | `enable-gpios` | out, high | out, high | + | gpio-20 (GPIO0_C4) | `avee-gpios` | **in, high** | out, high | + + Неуправляемая линия включения питания панели (`powers`, `avee`) уязвима к помехам. Импульсная помеха или просадка по PoE может снять питание или сбросить контроллер панели при живом SoC. Это совпадает с наблюдениями на объектах: ядро живо, тач работает, картинки нет. +4. **Лечится без перезагрузки:** `blank` / `unblank` дисплея (`/sys/class/graphics/fb0/blank`: 4, затем 0) при остановленном UI. Драйвер панели выполняет полную последовательность: питание, сброс, init-команды. После этого все четыре линии становятся управляемыми выходами. + +## Как воспроизвести + +Все команды выполняются от root на устройстве. + +**А. Тёплая перезагрузка.** `systemctl reboot` (питание не снимается). После загрузки экран остался тёмным при горящей подсветке. Повторяемость уточняется (см. ниже). + +**Б. Импульс сброса панели в обход драйвера** (имитация помехи на линии сброса): +```sh +busybox devmem 0xfdd60004 32 0x00010000 # GPIO0 SWPORT_DR_H: бит 0 (GPIO0_C0, reset) = 0, маска только на этот бит +sleep 0.2 +busybox devmem 0xfdd60004 32 0x00010001 # вернуть 1 +``` +Результат: картинка пропала, подсветка горит, в `dmesg` ни одной строки. + +**Проверка, что SoC выводит кадры:** +`cat /sys/kernel/debug/dri/0/summary`. В нём видно `Video Port1: ACTIVE`, `DSI-1`, `480x480p60`, `Smart0-win0: ACTIVE`. + +**Восстановление** (обе ситуации А и Б, проверено визуально): +```sh +systemctl stop buttontask +echo 4 > /sys/class/graphics/fb0/blank +echo 0 > /sys/class/graphics/fb0/blank +systemctl start buttontask +``` +(В прошивке это делает `bt-displaywatch recover` или жест «10 с в нижнем левом углу».) + +## Что просить у производителя платы / BSP + +1. В device tree и U-Boot: линии `powers-gpios` (GPIO0_C1) и `avee-gpios` (GPIO0_C4) должны с момента включения быть **выходами** в активном уровне. Не оставлять их входами после передачи дисплея от U-Boot ядру. Подтянуть их резисторами на плате, если это предусмотрено схемой. +2. Объяснить, почему после тёплой перезагрузки (питание панели не снималось) панель не инициализируется заново. Вероятно, U-Boot не выполняет сброс и init-последовательность панели, если она уже была включена, а ядро не делает этого из-за передачи логотипа. +3. Проверить помехоустойчивость линии сброса панели (GPIO0_C0) и питания панели при работе от PoE: просадки и импульсные помехи при включении других PoE-нагрузок. +4. По возможности: способ из ядра узнать, что панель жива. Например, периодическое DCS-чтение `get_power_mode` (0x0A), чтобы сбой обнаруживался автоматически. + +## Что сделано в нашем ПО (обход) + +- `bt-displaywatch` переинициализирует панель (blank/unblank) по ошибкам DRM/DSI в ядре, по пропаданию Ethernet-линка (признак просадки PoE), по запросу (жест, CLI). При повторяющихся сбоях он перезагружает устройство. +- Жест для персонала: удерживать нижний левый угол экрана 10 с. +- При каждой загрузке, до запуска UI, `bt-displaywatch` выполняет одну такую переинициализацию. Юнит запускается `Before=buttontask.service`, функция `boot_takeover`. Драйвер берёт линии питания панели под управление, и закрывается тёмный экран после тёплой перезагрузки. Проверено на возвращённом экземпляре: после загрузки `powers`/`avee` стали `out hi`, UI стартует после переинициализации (~0,5 с чёрного экрана до UI). +- Раз в сутки ночью (01:30 UTC = 04:30 МСК, настраивается через `DISPLAYWATCH_DAILY_UTC`) выполняется профилактическая переинициализация: экран моргает примерно на 6 с, приложение перезапускается. Тихий сбой панели (без ошибок ядра и без падения линка) со стороны SoC не обнаружить, поэтому так он длится не дольше суток. Без синхронизированного времени переинициализация выполняется раз в 24 часа аптайма. +- В журнале инцидентов (`/var/log/buttontask/display-incidents.log`) при каждом старте записывается `COLD boot` (питание пропадало) или `WARM boot`, а также как закончилась предыдущая загрузка. + +## Прочие наблюдения + +- Сетевая нагрузка не влияет: 5 минут UDP 80 Мбит/с (2,1 млн пакетов). Ошибок на `eth0` 0, нагрузка CPU < 1, перезагрузок нет. +- По часам rk809 (без батарейки) видно, пропадало ли питание перед загрузкой. Ядро пишет `rk808-rtc: setting system clock to …`: значение `2017-08-04T09:00:0x` означает, что питание пропадало, реальное время означает тёплый перезапуск. Все загрузки этого экземпляра в журнале начинались после пропадания питания. +- `kernel.panic = 0`: при панике ядра плата зависает, а не перезагружается (на прошивке без аппаратного вотчдога). + +## Объяснение простыми словами + +Внутри кнопки два «работника»: **компьютер** (плата) и **экран**. + +Компьютер рисует картинку и всё время отправляет её на экран по проводу, как телевизионный сигнал. Экран эту картинку показывает. Но перед началом работы экран нужно **«разбудить»**: подать на него питание в правильном порядке и проговорить ему набор команд «проснись, вот твои настройки». + +**Что идёт не так.** При включении экран будит загрузчик платы. После этого основная система не берёт экран под свой контроль: провода, которые держат питание экрана, остаются «брошенными», их никто не держит. Если в сети питания PoE случается короткий скачок или помеха, экран от этого «засыпает» или сбрасывается и забывает свои настройки. Компьютер при этом ничего не замечает: он продолжает отправлять картинку, а ответа от экрана не ждёт. Поэтому получается так: + +- подсветка горит (она питается отдельно); +- картинки нет (экран «спит»); +- кнопка живая, тач работает, но ничего не видно; +- помогает только выдернуть и вставить питание: тогда всё будится заново. + +**Почему это не наша программа.** Мы повторили сбой на возвращённой кнопке без всякого PoE: достаточно одного короткого импульса на проводе сброса экрана, и экран гаснет, а система не видит ни одной ошибки. Иногда то же самое происходит и после обычной перезагрузки. Это особенность платы и её начальной прошивки (U-Boot и device tree от производителя платы), а не приложения. + +**Что мы сделали.** +- При каждом включении наша программа сразу «будит» экран сама и забирает его провода под контроль, чтобы они больше не болтались. +- Если в сети или в системе случился сбой, похожий на причину, экран будится заново автоматически. +- Раз в сутки ночью экран будится профилактически, на несколько секунд. +- У персонала есть «кнопка спасения»: подержать палец 10 секунд в нижнем левом углу экрана. + +**Что должен сделать производитель платы.** Исправить начальную прошивку, чтобы провода питания экрана с самого включения были под управлением. Проверить, насколько плата и экран устойчивы к помехам по питанию PoE. + diff --git a/webconfig/buttontask-displaywatch.service b/webconfig/buttontask-displaywatch.service index be8a5b4..ce800c8 100644 --- a/webconfig/buttontask-displaywatch.service +++ b/webconfig/buttontask-displaywatch.service @@ -1,6 +1,8 @@ [Unit] Description=ButtonTask display/GUI watchdog (panel re-init, crash escalation) -After=buttontask.service +# Before the UI: on a fresh boot it re-inits the panel while nobody holds the +# display, then reports READY and buttontask starts (see boot_takeover). +Before=buttontask.service StartLimitIntervalSec=0 [Service] @@ -9,6 +11,8 @@ StartLimitIntervalSec=0 Type=notify NotifyAccess=main WatchdogSec=60 +# Never hold the UI back for long if something hangs before READY. +TimeoutStartSec=30 Environment=BUTTONTASK_ROOT=/opt/buttontask Environment=PYTHONUNBUFFERED=1 ExecStart=/usr/bin/python3 /opt/buttontask/scripts/bt-displaywatch run diff --git a/webconfig/scripts/bt-displaywatch b/webconfig/scripts/bt-displaywatch index d4867b3..7d589fb 100755 --- a/webconfig/scripts/bt-displaywatch +++ b/webconfig/scripts/bt-displaywatch @@ -18,7 +18,12 @@ What it does: * run/display-recover.request (written by the Qt bottom-left corner hold or `bt-displaywatch recover`) -> re-init the display * at start, records how the previous boot ended (clean or power-cut/hang) - and the display/link errors it had + and the display/link errors it had; cold vs warm boot from the RTC + * fresh boot: re-inits the panel before the UI starts (boot_takeover) + * once a day at DISPLAYWATCH_DAILY_UTC (default 01:30 UTC = 04:30 MSK): + preventive re-init. A panel knocked out silently (no kernel error, no + link drop) is invisible to the SoC; this bounds such a dark screen to a + day. Without a synced clock it runs every 24 h of uptime instead. Display re-init = stop the UI (releases DRM master), DPMS off/on through fb0/blank (DSI panel gets unprepare/prepare -> init sequence re-sent), start @@ -70,6 +75,17 @@ REBOOT_MAX = env_int("DISPLAYWATCH_REBOOT_MAX", 3) # auto reboots per LINK_RECOVER = os.environ.get("DISPLAYWATCH_LINK_RECOVER", "1") != "0" LINK_SETTLE = 5 # s after link up before re-init BOOT_GRACE = 30 # s of uptime: probe-time kernel noise is ignored +DAILY_UTC = os.environ.get("DISPLAYWATCH_DAILY_UTC", "01:30") # "" disables +DAILY_MIN_GAP = 20 * 3600 # s (monotonic) between preventive re-inits +DAILY_FALLBACK = 24 * 3600 # s of uptime without one -> run anyway (no NTP) +# rk809 RTC has no backup battery: a power loss resets it to this moment +# (2017-08-04 09:00:00 UTC). The kernel copies the RTC to the system clock at +# boot, so a boot that starts within RTC_COLD_SPAN of it followed a power loss. +# A warm reset (reboot, watchdog, panic) keeps the RTC running. +RTC_RESET_EPOCH = 1501837200 +RTC_COLD_SPAN = 300 +RTC_SYNC_EVERY = 600 # s; we write NTP time to the RTC ourselves +RTC_BOOT = re.compile(r"setting system clock to (\S+) UTC \((\d+)\)") # Errors that mean the display pipeline is stuck. DRM_HARD = re.compile( @@ -93,7 +109,18 @@ def now(): def stamp(): - return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + # No RTC: until NTP syncs, the clock starts at a fixed old date after each + # boot. Uptime keeps lines readable whatever the clock says. + return (time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + + f" up{time.clock_gettime(time.CLOCK_BOOTTIME):>7.0f}s") + + +def boot_id(): + try: + with open("/proc/sys/kernel/random/boot_id", encoding="ascii") as f: + return f.read().strip().replace("-", "") + except OSError: + return "" def incident(msg): @@ -225,6 +252,7 @@ class Watch: def __init__(self): self.st = load_state() self.last_recover = 0.0 + self.last_daily = time.monotonic() # boot_takeover just did one self.link_down = {} # iface -> time it went down self.pending = None # (when, reason) delayed re-init self.restarts = [] @@ -252,7 +280,65 @@ class Watch: run(["systemctl", "reboot"], timeout=30) # -- display re-init -- - def recover(self, reason, manual=False): + # -- panel power takeover at boot -- + def boot_takeover(self): + """Run the panel driver's full power-on sequence once per boot. + + The kernel inherits the display from U-Boot (logo hand-off) and never + runs the panel prepare: powers/avee GPIOs stay undriven inputs and a + warm reboot can leave the panel dark with the backlight on (both seen + on a returned unit, see docs/display-blank-investigation.md). A DPMS + off/on makes the driver power, reset and init the panel and drive those + lines. Done before the UI starts (unit is Before=buttontask.service). + """ + try: + with open("/proc/uptime", encoding="ascii") as f: + uptime = float(f.read().split()[0]) + except (OSError, ValueError): + uptime = 0.0 + if uptime > 300: + incident("boot takeover skipped: not a fresh boot (installed while running)") + return + ui = unit_props().get("ActiveState") in ("active", "activating", "reloading") + if ui: + run(["systemctl", "stop", UNIT], timeout=60) + try: + with open(FB_BLANK, "w") as f: + f.write("4\n") + time.sleep(0.5) + with open(FB_BLANK, "w") as f: + f.write("0\n") + incident(f"boot takeover: panel re-initialised by the driver{' (ui restarted)' if ui else ''}") + except OSError as e: + incident(f"boot takeover failed: {e}") + if ui: + run(["systemctl", "start", UNIT], timeout=60) + self.nrestarts = None + self.quiet_until = now() + 20 + + # -- daily preventive re-init -- + def check_daily(self): + if not DAILY_UTC: + return + mono = time.monotonic() + since = mono - self.last_daily + if since < DAILY_MIN_GAP: + return + at_time = time.strftime("%H:%M", time.gmtime()) == DAILY_UTC + if not at_time and since < DAILY_FALLBACK: + return + r = run(["timedatectl", "show", "-p", "NTPSynchronized", "--value"], timeout=10) + synced = r is not None and r.stdout.strip() == "yes" + if synced and at_time: + why = f"daily preventive ({DAILY_UTC} UTC)" + elif not synced and since >= DAILY_FALLBACK: + why = "daily preventive (no NTP: 24 h of uptime)" + else: + return + self.last_daily = mono + self.recover(why, manual=True, count=False) + + def recover(self, reason, manual=False, count=True): gap = 15 if manual else RECOVER_GAP if now() - self.last_recover < gap: incident(f"recover skipped (last one {int(now() - self.last_recover)}s ago): {reason}") @@ -262,8 +348,9 @@ class Watch: self.reboot(f"{RECOVER_MAX} display re-inits in {RECOVER_WINDOW // 60} min did not help; last: {reason}") return self.last_recover = now() - self.st["recovers"].append(self.last_recover) - save_state(self.st) + if count: # planned re-inits must not push towards a reboot + self.st["recovers"].append(self.last_recover) + save_state(self.st) was_active = unit_props().get("ActiveState") in ("active", "activating", "reloading") incident(f"RECOVER display: {reason} (ui {'running' if was_active else 'stopped'})") @@ -392,18 +479,63 @@ class Watch: pass self.recover(f"manual ({reason})", manual=True) + # -- cold/warm boot from the RTC -- + def boot_kind(self): + r = run(["journalctl", "-q", "-k", "-b", "0", "--no-pager", "-o", "cat", + ], timeout=60) + m = RTC_BOOT.search(r.stdout) if r is not None else None + if not m: + return "boot kind unknown (no RTC line)" + rtc = int(m.group(2)) + if RTC_RESET_EPOCH <= rtc < RTC_RESET_EPOCH + RTC_COLD_SPAN: + return f"COLD boot: power was lost (RTC reset, {m.group(1)})" + return f"WARM boot: power stayed on (reboot/watchdog/panic; RTC {m.group(1)})" + + def sync_rtc(self): + """Keep the RTC on real time so the next boot can tell cold from warm + even after a short boot (the kernel's own 11-min sync may not run).""" + r = run(["timedatectl", "show", "-p", "NTPSynchronized", "--value"], timeout=10) + if r is None or r.stdout.strip() != "yes": + return + w = run(["hwclock", "--systohc", "--utc"], timeout=15) + if w is not None and w.returncode == 0 and not self.rtc_synced: + self.rtc_synced = True + incident("rtc: set from NTP time") + # -- previous boot summary -- def previous_boot(self): - r = run(["journalctl", "-q", "-b", "-1", "-n", "40", "--no-pager", "-o", "cat"], timeout=30) + # The previous boot is identified by the boot_id saved in our state, + # not by "journalctl -b -1": without an RTC every boot starts at the + # same old date, so journald's time-sorted boot list is unreliable. + cur = boot_id() + prev = self.prev_boot_id + if not self.new_boot: + incident("start (service restart, same boot)") + return + if not prev: + incident(f"start; boot {cur[:8]}: {self.boot_kind()}; previous boot unknown (first run)") + return + sel = ["-b", prev] + r = run(["journalctl", "-q", *sel, "-n", "40", "--no-pager", "-o", "cat"], timeout=30) if r is None or r.returncode != 0 or not r.stdout.strip(): - incident("start; previous boot: no journal") + incident(f"start; boot {cur[:8]}: {self.boot_kind()}; previous boot {prev[:8]}: no journal") return clean = bool(CLEAN_SHUTDOWN.search(r.stdout)) - last = run(["journalctl", "-q", "-b", "-1", "-n", "1", "--no-pager", "-o", "short-iso"], timeout=30) - last_ts = last.stdout.strip().split(" ", 1)[0] if last and last.stdout.strip() else "?" - incident(f"start; previous boot ended {'cleanly' if clean else 'ABRUPTLY (power cut / hang)'}, " - f"last journal entry {last_ts}") - k = run(["journalctl", "-q", "-k", "-b", "-1", "--no-pager", "-o", "short-monotonic"], timeout=60) + last = run(["journalctl", "-q", *sel, "-n", "1", "--no-pager", "-o", "short-monotonic"], timeout=30) + last_up = "?" + if last and last.stdout.strip(): + try: + last_up = str(round(float(last.stdout.strip().split("]", 1)[0].lstrip("[ ")))) + except ValueError: + pass + incident(f"start; boot {cur[:8]}: {self.boot_kind()}") + incident(f"previous boot {prev[:8]} ended " + f"{'cleanly' if clean else 'ABRUPTLY (power cut / hang / hw watchdog)'} " + f"after {last_up}s uptime") + tail = [l for l in r.stdout.splitlines() if l.strip()][-3:] + for line in tail: + incident(f" last: {short(line)}") + k = run(["journalctl", "-q", "-k", *sel, "--no-pager", "-o", "short-monotonic"], timeout=60) if k is None: return hits = [] @@ -424,11 +556,22 @@ class Watch: stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) def loop(self): + # New boot or just a service restart (OTA, crash)? Decided from the + # boot_id we saved, before anything slow, so the UI is not delayed. + cur = boot_id() + self.prev_boot_id = self.st.get("boot_id", "") + self.new_boot = not (cur and self.prev_boot_id == cur) + if self.new_boot: + self.st["boot_id"] = cur + save_state(self.st) + self.boot_takeover() + sd_notify("READY=1") # buttontask (After= us) may start now self.previous_boot() proc = self.follow() buf = b"" - sd_notify("READY=1") - next_unit = 0.0 + next_unit = 0.0 # time.monotonic(): immune to clock steps + next_rtc = 0.0 + self.rtc_synced = False while True: sd_notify("WATCHDOG=1") if proc.poll() is not None: @@ -447,9 +590,14 @@ class Watch: _, reason = self.pending self.pending = None self.recover(reason) - if now() >= next_unit: - next_unit = now() + 10 + mono = time.monotonic() + if mono >= next_unit: + next_unit = mono + 10 self.check_unit() + self.check_daily() + if mono >= next_rtc: + next_rtc = mono + RTC_SYNC_EVERY + self.sync_rtc() def main():