#!/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 (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)