Files
reterminal-dm4/emmc-provisioning/cloud-init/fileserver/screen-brightness.py
nearxos 0844adbcbe Update cloud-init scripts and documentation for enhanced DNS management and provisioning steps</message>
<message>Modify the first-boot.sh script to include an additional step for managing screen brightness during the provisioning process. Update user-data.bootstrap to improve DNS configuration by ensuring NetworkManager manages /etc/resolv.conf correctly, and remove obsolete scripts related to systemd-resolved. Enhance documentation to reflect these changes and clarify the setup process for users, improving overall network boot functionality and user experience.
2026-03-06 14:45:23 +02:00

335 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
reTerminal DM — Bridge Screen Brightness Daemon
================================================
Regulatory alignment
IMO MSC.191(79) §8.1 Display must be dimmable but NEVER extinguished
§8.1.2 Navigator must be able to reset to a preset level
IMO MSC.302(87) Alarm visuals = high luminance; night mode must not
degrade crew night vision
IHO S-52 / IEC 62288 Three operating modes: NIGHT · DUSK · DAY
Operating states
NIGHT Very low lux → level 1 (minimum — "not to extinction")
DUSK Low lux → level 2 (dim)
DAY-* Daylight → levels 3-5 graduated by lux sub-range
ALERT Buzzer ON → level 5 (max) immediately — no smooth stepping
COOLDOWN Buzzer just → hold level 5 for BUZZER_COOLDOWN_SEC seconds,
stopped then fade back one step per poll
OVERRIDE Manual file → fixed level set by navigator (per MSC.191(79))
Sysfs paths (reTerminal DM)
Backlight : /sys/class/backlight/lcd_backlight/brightness (0-5)
Buzzer : /sys/class/leds/usr-buzzer/brightness (0..max, non-zero = ON; often 255)
Light IIO : /sys/bus/iio/devices/iio:device0/in_illuminance_input (lux)
Navigator override (satisfies MSC.191(79) §8.1.2 preset requirement)
echo 3 > /run/screen-brightness/override # force fixed level 3
echo auto > /run/screen-brightness/override # return to ambient control
Read current state (useful for dashboard integration)
cat /run/screen-brightness/state
Config file : /etc/screen-brightness.conf (INI, optional)
Reload config: kill -HUP <pid> (or: systemctl reload screen-brightness)
"""
import configparser
import logging
import logging.handlers
import os
import signal
import sys
import time
from pathlib import Path
from typing import Optional, Tuple
# ── Default configuration ────────────────────────────────────────────────────
DEFAULTS: dict = {
# sysfs paths
"backlight_path": "/sys/class/backlight/lcd_backlight/brightness",
"buzzer_path": "/sys/class/leds/usr-buzzer/brightness",
"lux_path": "/sys/bus/iio/devices/iio:device0/in_illuminance_input",
# timing
"poll_interval": "2", # seconds between lux/ambient reads
"buzzer_poll": "0.5", # seconds between buzzer checks (faster so we catch alarms)
"buzzer_cooldown": "10", # seconds to hold max brightness after buzzer stops
# lux mode boundaries (IHO S-52 three-mode framework)
"lux_night_max": "20", # 020 lux → NIGHT mode
"lux_dusk_max": "200", # 21200 lux → DUSK mode
"lux_day_dim_max": "500", # 201500 lux → DAY-DIM
"lux_day_normal_max": "1000", # 5011000 lux→ DAY-NORMAL
# >1000 lux → DAY-BRIGHT
# backlight levels per mode (1 = minimum, 5 = maximum, 0 is NEVER used)
"level_night": "1",
"level_dusk": "2",
"level_day_dim": "3",
"level_day_normal": "4",
"level_day_bright": "5",
"level_alert": "5", # during ALERT and COOLDOWN states
# hysteresis: require this many consecutive polls agreeing on a new ambient
# target before committing the change (prevents flickering near thresholds)
"hysteresis_polls": "3",
}
CONFIG_FILE = Path("/etc/screen-brightness.conf")
OVERRIDE_FILE = Path("/run/screen-brightness/override")
STATE_FILE = Path("/run/screen-brightness/state")
RUN_DIR = Path("/run/screen-brightness")
# ── Logging — stdout (captured by journald) + syslog ────────────────────────
log = logging.getLogger("screen-brightness")
log.setLevel(logging.INFO)
_fmt_detail = logging.Formatter("%(asctime)s [screen-brightness] %(levelname)s %(message)s")
_fmt_syslog = logging.Formatter("screen-brightness[%(process)d]: %(levelname)s %(message)s")
_stdout_h = logging.StreamHandler(sys.stdout)
_stdout_h.setFormatter(_fmt_detail)
log.addHandler(_stdout_h)
try:
_syslog_h = logging.handlers.SysLogHandler(address="/dev/log")
_syslog_h.setFormatter(_fmt_syslog)
log.addHandler(_syslog_h)
except OSError:
pass # /dev/log not available; journald captures stdout
# ── Config helpers ───────────────────────────────────────────────────────────
def load_config() -> dict:
cfg = dict(DEFAULTS)
if CONFIG_FILE.exists():
parser = configparser.ConfigParser()
parser.read(CONFIG_FILE)
section = "screen-brightness"
if parser.has_section(section):
cfg.update(parser[section])
log.info("Config loaded from %s", CONFIG_FILE)
return cfg
# ── Sysfs helpers ────────────────────────────────────────────────────────────
def _read_int(path: Path, default: Optional[int] = None) -> Optional[int]:
try:
return int(path.read_text().strip())
except Exception as exc:
log.warning("Cannot read %s: %s", path, exc)
return default
def _write_int(path: Path, value: int) -> bool:
try:
path.write_text(f"{value}\n")
return True
except Exception as exc:
log.error("Cannot write %s = %s: %s", path, value, exc)
return False
# ── Override file ────────────────────────────────────────────────────────────
def read_override() -> Optional[int]:
"""Return int level (1-5) if a manual override is active, else None."""
try:
if not OVERRIDE_FILE.exists():
return None
raw = OVERRIDE_FILE.read_text().strip().lower()
if raw in ("auto", ""):
return None
val = int(raw)
if 1 <= val <= 5:
return val
except Exception:
pass
return None
# ── State file (read by dashboard / other services) ──────────────────────────
def write_state(mode: str, level: int, lux: Optional[int],
buzzer: bool, override: bool) -> None:
try:
RUN_DIR.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(
f"mode={mode}\n"
f"level={level}\n"
f"lux={lux if lux is not None else 'N/A'}\n"
f"buzzer={'1' if buzzer else '0'}\n"
f"override={'1' if override else '0'}\n"
f"updated={int(time.time())}\n"
)
except Exception as exc:
log.debug("Cannot write state file: %s", exc)
# ── Lux → ambient level ───────────────────────────────────────────────────────
def lux_to_level(lux: int, cfg: dict) -> Tuple[int, str]:
"""Return (backlight_level, mode_name) for the given lux reading."""
if lux <= int(cfg["lux_night_max"]):
return int(cfg["level_night"]), "NIGHT"
if lux <= int(cfg["lux_dusk_max"]):
return int(cfg["level_dusk"]), "DUSK"
if lux <= int(cfg["lux_day_dim_max"]):
return int(cfg["level_day_dim"]), "DAY-DIM"
if lux <= int(cfg["lux_day_normal_max"]):
return int(cfg["level_day_normal"]), "DAY-NORMAL"
return int(cfg["level_day_bright"]), "DAY-BRIGHT"
# ── Main loop ────────────────────────────────────────────────────────────────
def main() -> None:
cfg = load_config()
BACKLIGHT = Path(cfg["backlight_path"])
BUZZER = Path(cfg["buzzer_path"])
LUX = Path(cfg["lux_path"])
for p in (BACKLIGHT, BUZZER, LUX):
if not p.exists():
log.warning("sysfs path not found at startup (will retry): %s", p)
RUN_DIR.mkdir(parents=True, exist_ok=True)
# SIGHUP → reload config without restart (e.g. systemctl reload)
_reload_flag = {"pending": False}
def _on_sighup(_sig: int, _frame: object) -> None:
_reload_flag["pending"] = True
signal.signal(signal.SIGHUP, _on_sighup)
buzz_interval = float(cfg.get("buzzer_poll", "0.5"))
poll_interval = float(cfg["poll_interval"])
log.info(
"Bridge brightness daemon started | "
"buzzer_poll=%ss lux_poll=%ss cooldown=%ss night≤%slux dusk≤%slux",
buzz_interval, poll_interval, cfg["buzzer_cooldown"],
cfg["lux_night_max"], cfg["lux_dusk_max"],
)
current_level: int = -1 # last level written to sysfs
cooldown_until: float = 0.0 # monotonic time when ALERT cooldown ends
buzzer_was_on: bool = False
# Hysteresis state — track how many consecutive polls agree on a new ambient target
pending_ambient: int = -1
pending_count: int = 0
# Buzzer is checked every buzzer_poll (0.5s); lux/ambient only every poll_interval (2s)
last_lux_poll: float = -999.0 # force first lux read
lux: Optional[int] = None # last successful lux read (reused between lux polls)
target, mode = 1, "NIGHT" # fallback for step calc before first ambient target
while True:
# ── Config reload on SIGHUP ──────────────────────────────────────────
if _reload_flag["pending"]:
cfg = load_config()
BACKLIGHT = Path(cfg["backlight_path"])
BUZZER = Path(cfg["buzzer_path"])
LUX = Path(cfg["lux_path"])
_reload_flag["pending"] = False
pending_ambient = -1
pending_count = 0
buzz_interval = float(cfg.get("buzzer_poll", "0.5"))
poll_interval = float(cfg["poll_interval"])
log.info("Config reloaded (SIGHUP)")
now = time.monotonic()
buzzer_raw = _read_int(BUZZER, default=0) or 0
buzzer = buzzer_raw > 0
override = read_override()
# Read lux only every poll_interval seconds
if now - last_lux_poll >= poll_interval:
last_lux_poll = now
lux = _read_int(LUX, default=None)
# ── Determine target level and mode name ─────────────────────────────
immediate = False # skip smooth stepping when True
if override is not None:
target, mode = override, "OVERRIDE"
immediate = True
elif buzzer:
target, mode = int(cfg["level_alert"]), "ALERT"
cooldown_until = now + int(cfg["buzzer_cooldown"])
immediate = True # alarm: go to max brightness instantly
if not buzzer_was_on:
log.info("ALERT: buzzer ON → brightness %d (immediate)", target)
elif now < cooldown_until:
target, mode = int(cfg["level_alert"]), "COOLDOWN"
immediate = True # hold alert level until cooldown expires
if buzzer_was_on:
remaining = cooldown_until - now
log.info("COOLDOWN: buzzer OFF → hold brightness %d for %.0fs",
target, remaining)
else:
# Ambient mode — lux controls level (use last lux read)
if lux is None:
# Sensor not read yet or unreadable — keep current level
buzzer_was_on = buzzer
time.sleep(buzz_interval)
continue
ambient_target, mode = lux_to_level(lux, cfg)
# Hysteresis: commit new ambient level only after N consecutive
# polls agree (avoids flickering near a lux threshold)
if ambient_target == pending_ambient:
pending_count += 1
else:
pending_ambient = ambient_target
pending_count = 1
if pending_count >= int(cfg["hysteresis_polls"]):
target = ambient_target
else:
# Not stable yet — keep current level if we have one
target = current_level if current_level > 0 else ambient_target
if buzzer_was_on and not buzzer and now >= cooldown_until:
log.info("COOLDOWN done → %s | lux=%d | brightness=%d", mode, lux, target)
# ── Smooth stepping vs immediate ─────────────────────────────────────
# Ambient changes step one level at a time to avoid harsh
# brightness jumps that could disrupt night-adapted vision.
# Alarm and override changes are immediate (safety requirement).
if immediate or current_level < 0:
step = target
elif target > current_level:
step = current_level + 1
elif target < current_level:
step = current_level - 1
else:
step = current_level # no change needed
# ── Write only when level actually changes ───────────────────────────
if step != current_level:
if _write_int(BACKLIGHT, step):
log.info(
"Brightness %s%d | mode=%-10s | lux=%s",
current_level if current_level >= 0 else "",
step, mode,
lux if lux is not None else "N/A",
)
current_level = step
write_state(mode, current_level, lux, buzzer, override is not None)
buzzer_was_on = buzzer
time.sleep(buzz_interval)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
log.info("Stopped by keyboard interrupt.")
sys.exit(0)