<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.
335 lines
14 KiB
Python
335 lines
14 KiB
Python
#!/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", # 0–20 lux → NIGHT mode
|
||
"lux_dusk_max": "200", # 21–200 lux → DUSK mode
|
||
"lux_day_dim_max": "500", # 201–500 lux → DAY-DIM
|
||
"lux_day_normal_max": "1000", # 501–1000 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)
|