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.
This commit is contained in:
nearxos
2026-03-06 14:45:23 +02:00
parent 8233304ee2
commit 0844adbcbe
22 changed files with 2021 additions and 86 deletions

View File

@@ -0,0 +1,334 @@
#!/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)