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,84 @@
#!/usr/bin/env python3
"""
Local HTTP API for screen-brightness override (crew manual control per MSC.191(79)).
Listens on 127.0.0.1 only. Run as root so it can write /run/screen-brightness/override.
"""
import os
import urllib.parse
from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
OVERRIDE_FILE = Path("/run/screen-brightness/override")
RUN_DIR = OVERRIDE_FILE.parent
HOST = "127.0.0.1"
PORT = 8765
class BrightnessHandler(BaseHTTPRequestHandler):
def do_POST(self):
path = self.path.split("?")[0]
if path != "/brightness" and path != "/":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode("utf-8", errors="ignore") if length else ""
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
level = (query.get("level") or [None])[0] or (urllib.parse.parse_qs(body).get("level") or [None])[0]
if not level:
self.send_response(400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"missing level=1|2|3|4|5|auto")
return
level = level.strip().lower()
if level == "auto":
value = "auto"
elif level in ("1", "2", "3", "4", "5"):
value = level
else:
self.send_response(400)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"level must be 1, 2, 3, 4, 5, or auto")
return
try:
RUN_DIR.mkdir(parents=True, exist_ok=True)
OVERRIDE_FILE.write_text(value + "\n")
except Exception as e:
self.send_response(500)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(f"write failed: {e}".encode())
return
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(f"ok {value}\n".encode())
def do_GET(self):
if self.path.split("?")[0] in ("/", "/brightness", "/state"):
state_file = Path("/run/screen-brightness/state")
if state_file.exists():
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(state_file.read_bytes())
else:
self.send_response(404)
self.end_headers()
else:
self.send_error(404)
def log_message(self, format, *args):
pass # quiet; use journal if needed
def main():
if os.geteuid() != 0:
raise SystemExit("brightness-api must run as root to write /run/screen-brightness/override")
server = HTTPServer((HOST, PORT), BrightnessHandler)
server.serve_forever()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Local API for screen brightness override (crew manual control)
Documentation=file:///usr/local/share/doc/screen-brightness/SCREEN-BRIGHTNESS-MANUAL-SETUP.md
After=network.target
Before=lightdm.service
[Service]
Type=simple
ExecStart=/usr/local/bin/brightness-api.py
Restart=on-failure
RestartSec=3
User=root
# Listen on 127.0.0.1 only
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,6 @@
#!/bin/sh
# Start the brightness overlay after a short delay so the Wayland/X session
# and compositor are ready (autostart often runs before the display is usable).
SCRIPT_DIR="$(dirname "$0")"
sleep 6
exec "$SCRIPT_DIR/brightness-overlay.py"

View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Brightness overlay
Comment=Overlay button to control screen brightness (1-5, Auto)
Exec=/home/pi/brightness-overlay-launch.sh
Hidden=false
NoDisplay=true
X-GNOME-Autostart-enabled=true

View File

@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
Brightness control overlay — always-on-top icon in bottom-right; click to show controls.
Uses Wayland layer-shell OVERLAY so it stays above Chromium fullscreen/kiosk.
Requires: brightness-api.service running (listens 127.0.0.1:8765).
"""
import logging
import sys
import urllib.request
import urllib.error
LOG_TAG = "brightness-overlay"
logging.basicConfig(
stream=sys.stderr,
level=logging.INFO,
format=f"{LOG_TAG}: %(message)s",
)
log = logging.getLogger(LOG_TAG)
try:
import gi
gi.require_version("Gdk", "3.0")
gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, Gtk, GLib
except Exception as e:
log.error("need PyGObject Gtk: %s", e)
sys.exit(1)
HAS_LAYER_SHELL = False
try:
gi.require_version("GtkLayerShell", "0.1")
from gi.repository import GtkLayerShell
HAS_LAYER_SHELL = True
except Exception:
log.warning("GtkLayerShell not available, using regular window")
API_URL = "http://127.0.0.1:8765/brightness"
API_STATE_URL = "http://127.0.0.1:8765/state"
# Sizes
ICON_SIZE = 44
EXPANDED_WIDTH = 300
PANEL_HEIGHT = 48
def get_current_state():
"""Return (level 15, is_auto). On error return (None, True) so Auto appears active."""
try:
with urllib.request.urlopen(API_STATE_URL, timeout=1) as r:
text = r.read().decode()
level = None
override = "0"
for line in text.strip().splitlines():
line = line.strip()
if line.startswith("level="):
level = line.split("=", 1)[1].strip()
elif line.startswith("override="):
override = line.split("=", 1)[1].strip()
if level and level.isdigit() and 1 <= int(level) <= 5:
return int(level), override == "0"
return (None, True)
except Exception:
return (None, True)
def set_brightness(level):
try:
req = urllib.request.Request(
API_URL,
data=f"level={level}".encode(),
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(req, timeout=2) as r:
log.info("brightness %s: %s", level, r.read().decode().strip())
except urllib.error.URLError as e:
log.warning("API call failed (is brightness-api.service running?): %s", e)
except Exception as e:
log.warning("set_brightness failed: %s", e)
def update_active_buttons(level_buttons, auto_btn, current_level, is_auto):
"""Set .active style on the button that matches current state."""
for i, btn in enumerate(level_buttons):
ctx = btn.get_style_context()
if not is_auto and current_level == i + 1:
ctx.add_class("active")
else:
ctx.remove_class("active")
if auto_btn:
ctx = auto_btn.get_style_context()
if is_auto:
ctx.add_class("active")
else:
ctx.remove_class("active")
def make_window_layer_shell(win):
GtkLayerShell.init_for_window(win)
GtkLayerShell.set_layer(win, GtkLayerShell.Layer.OVERLAY)
# Anchor only to bottom and right so the surface sits in the bottom-right corner
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.TOP, False)
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.LEFT, False)
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.BOTTOM, True)
GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.RIGHT, True)
GtkLayerShell.set_keyboard_mode(win, GtkLayerShell.KeyboardMode.NONE)
GtkLayerShell.set_margin(win, GtkLayerShell.Edge.BOTTOM, 12)
GtkLayerShell.set_margin(win, GtkLayerShell.Edge.RIGHT, 12)
if hasattr(GtkLayerShell, "set_namespace"):
GtkLayerShell.set_namespace(win, "brightness-overlay")
if hasattr(GtkLayerShell, "set_exclusive_zone"):
GtkLayerShell.set_exclusive_zone(win, 0)
log.info("layer-shell OVERLAY anchored bottom-right (always on top)")
def position_fallback_bottom_right(win, width=None, height=None):
"""Position window in bottom-right (X11: only works with toplevel, not POPUP)."""
if width is None:
width = ICON_SIZE
if height is None:
height = ICON_SIZE
display = Gdk.Display.get_default()
if not display:
return
n = display.get_n_monitors()
monitor = display.get_monitor(0) if n else None
if not monitor:
return
geom = monitor.get_geometry()
x = geom.x + geom.width - width - 12
y = geom.y + geom.height - height - 12
win.move(x, y)
def make_window_fallback(win):
# Toplevel (not POPUP) so we can position and have no decorations on X11
win.set_keep_above(True)
win.set_decorated(False)
win.set_type_hint(Gdk.WindowTypeHint.UTILITY) # tool-style window, often undecorated
# Position after map so WM doesn't override
win.connect("map-event", lambda w, e: position_fallback_bottom_right(w))
log.info("X11 fallback: keep-above, undecorated, bottom-right")
def main():
# Normal toplevel: on X11 we can position it and set_decorated(False) removes title bar.
# (POPUP without parent cannot be positioned on X11 and gets "temporary window" / centering.)
win = Gtk.Window()
win.set_decorated(False)
win.set_resizable(False)
win.set_default_size(ICON_SIZE, ICON_SIZE)
win.set_skip_taskbar_hint(True)
win.set_skip_pager_hint(True)
# Transparent window: need RGBA visual and app-paintable
screen = Gdk.Screen.get_default()
if screen and screen.get_rgba_visual():
win.set_visual(screen.get_rgba_visual())
win.set_app_paintable(True)
# CSS: transparent window and overlay; only subtle hover so icon stays visible
style = """
GtkWindow, window {
background-color: transparent;
background-image: none;
}
.brightness-icon-btn {
background-color: transparent;
border-radius: 8px;
padding: 4px;
min-width: 36px;
min-height: 36px;
}
.brightness-icon-btn:hover {
background-color: rgba(0, 0, 0, 0.25);
}
.brightness-panel {
background-color: transparent;
border-radius: 8px;
padding: 6px;
}
.brightness-panel.expanded {
background-color: rgba(0, 0, 0, 0.5);
}
.brightness-panel button {
min-width: 36px;
font-weight: bold;
}
.brightness-panel button.level-btn.active {
background-color: rgba(255, 255, 255, 0.35);
border: 1px solid rgba(255, 255, 255, 0.6);
}
"""
css = Gtk.CssProvider()
css.load_from_data(style.encode())
Gtk.StyleContext.add_provider_for_screen(
Gdk.Screen.get_default(),
css,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
# Main horizontal box: [revealer with controls] [icon button]
main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
# Left part: revealer with Brightness + 15 + Auto
strip_box = Gtk.Box(spacing=6)
strip_box.set_margin_start(8)
strip_box.set_margin_top(6)
strip_box.set_margin_bottom(6)
strip_box.set_margin_end(4)
strip_box.get_style_context().add_class("brightness-panel")
strip_box.pack_start(Gtk.Label(label="Brightness "), False, False, 0)
level_buttons = []
for i in range(1, 6):
btn = Gtk.Button(label=str(i))
btn.get_style_context().add_class("level-btn")
btn.set_can_focus(False)
strip_box.pack_start(btn, False, False, 0)
level_buttons.append(btn)
auto_btn = Gtk.Button(label="Auto")
auto_btn.get_style_context().add_class("level-btn")
auto_btn.set_can_focus(False)
strip_box.pack_start(auto_btn, False, False, 0)
def refresh_active_state():
level, is_auto = get_current_state()
update_active_buttons(level_buttons, auto_btn, level or 3, is_auto)
def on_level_click(btn, level):
set_brightness(level)
# Optimistic update so active state shows immediately
if level == "auto":
update_active_buttons(level_buttons, auto_btn, 3, True)
else:
update_active_buttons(level_buttons, auto_btn, int(level), False)
# Sync from API after a short delay (daemon updates state file periodically)
def delayed_refresh():
refresh_active_state()
return False
GLib.timeout_add(500, delayed_refresh)
for i, btn in enumerate(level_buttons):
btn.connect("clicked", on_level_click, str(i + 1))
auto_btn.connect("clicked", on_level_click, "auto")
revealer = Gtk.Revealer()
revealer.set_reveal_child(False)
revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_LEFT)
revealer.set_transition_duration(150)
revealer.add(strip_box)
main_box.pack_start(revealer, False, False, 0)
# Right part: always-visible icon button (click toggles panel)
try:
icon_img = Gtk.Image.new_from_icon_name("display-brightness-symbolic", Gtk.IconSize.BUTTON)
except Exception:
icon_img = Gtk.Label(label="")
icon_btn = Gtk.Button()
icon_btn.get_style_context().add_class("brightness-icon-btn")
icon_btn.set_can_focus(False)
icon_btn.add(icon_img)
icon_btn.set_relief(Gtk.ReliefStyle.NONE)
expanded = [False] # use list so closure can mutate
use_fallback = not (HAS_LAYER_SHELL and GtkLayerShell.is_supported())
def on_icon_clicked(btn):
expanded[0] = not expanded[0]
revealer.set_reveal_child(expanded[0])
ctx = strip_box.get_style_context()
if expanded[0]:
ctx.add_class("expanded")
GLib.idle_add(refresh_active_state)
win.resize(EXPANDED_WIDTH, PANEL_HEIGHT)
if use_fallback:
position_fallback_bottom_right(win, EXPANDED_WIDTH, PANEL_HEIGHT)
else:
ctx.remove_class("expanded")
win.resize(ICON_SIZE, ICON_SIZE)
if use_fallback:
position_fallback_bottom_right(win, ICON_SIZE, ICON_SIZE)
icon_btn.connect("clicked", on_icon_clicked)
main_box.pack_end(icon_btn, False, False, 0)
win.add(main_box)
if HAS_LAYER_SHELL and GtkLayerShell.is_supported():
make_window_layer_shell(win)
else:
make_window_fallback(win)
win.connect("destroy", Gtk.main_quit)
win.show_all()
GLib.idle_add(refresh_active_state)
log.info("brightness icon visible (click to show controls)")
Gtk.main()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
# screen-brightness.conf — reTerminal DM Bridge Brightness Daemon
#
# Regulatory basis
# IMO MSC.191(79) §8.1 : brightness adjustable, never extinguished
# IMO MSC.302(87) : alarms = high luminance; night mode preserves night vision
# IHO S-52 / IEC 62288 : NIGHT / DUSK / DAY operating modes
#
# Reload without restart: systemctl reload screen-brightness
# Override from bridge console:
# echo 3 | sudo tee /run/screen-brightness/override # fix at level 3
# echo auto | sudo tee /run/screen-brightness/override # return to auto
[screen-brightness]
# ── 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 ───────────────────────────────────────────────────────────────────
# How often the ambient sensor is read (seconds)
poll_interval = 2
# How often the buzzer is checked (seconds). Keep below 1 so alarms are seen quickly.
buzzer_poll = 0.5
# How long to hold maximum brightness after the buzzer stops (seconds)
# IMO MSC.302(87): alarm attention must persist long enough for crew response
buzzer_cooldown = 10
# ── Lux thresholds — IHO S-52 three-mode framework ──────────────────────────
# Adjust these to match the actual lighting environment of your bridge.
#
# 0 — lux_night_max → NIGHT mode (very dark, night watch)
# lux_night_max+1 — lux_dusk_max → DUSK mode (dim, dawn/dusk)
# lux_dusk_max+1 — lux_day_dim_max → DAY-DIM
# lux_day_dim_max+1 — lux_day_normal_max → DAY-NORMAL
# > lux_day_normal_max → DAY-BRIGHT (direct sunlight / tropical day)
lux_night_max = 20
lux_dusk_max = 200
lux_day_dim_max = 500
lux_day_normal_max = 1000
# ── Brightness levels per mode (1 = minimum, 5 = maximum) ───────────────────
# Level 0 is NEVER used — IMO "not to extinction" principle.
# Minimum must remain legible at the navigator's position.
level_night = 1
level_dusk = 2
level_day_dim = 3
level_day_normal = 4
level_day_bright = 5
# Brightness forced during ALERT (buzzer ON) and COOLDOWN states.
# IMO MSC.302(87): alarm visual indicators must be high luminous intensity.
level_alert = 5
# ── Hysteresis ───────────────────────────────────────────────────────────────
# Number of consecutive polls that must agree on a new ambient brightness
# target before the change is committed. Prevents flickering when lux hovers
# near a mode boundary (e.g. sunset making the sensor oscillate 1822 lux).
# At poll_interval=2s and hysteresis_polls=3, a level change takes max 6s.
hysteresis_polls = 3

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)

View File

@@ -0,0 +1,22 @@
[Unit]
Description=reTerminal DM screen brightness daemon (ambient + buzzer)
Documentation=https://wiki.seeedstudio.com/reterminal-dm/
After=multi-user.target
# Wait for the IIO light sensor and backlight sysfs nodes to be available
After=systemd-udev-settle.service
[Service]
Type=simple
# Ensure brightness starts in auto (ambient) mode on every boot
ExecStartPre=/bin/sh -c 'mkdir -p /run/screen-brightness && echo auto > /run/screen-brightness/override'
ExecStart=/usr/local/bin/screen-brightness.py
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
# Needs root to write to /sys/class/backlight and /sys/class/leds
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Step 14: Install screen brightness daemon (ambient light + buzzer-triggered alert)
# Regulatory: IMO MSC.191(79), MSC.302(87), IHO S-52 / IEC 62288
step_14_screen_brightness() {
local script_dst="/usr/local/bin/screen-brightness.py"
local service_dst="/etc/systemd/system/screen-brightness.service"
local conf_dst="/etc/screen-brightness.conf"
log "Downloading screen-brightness.py ..."
curl -fsSL "${FILE_SERVER}/screen-brightness.py" -o "$script_dst" \
|| { log "ERROR: could not download screen-brightness.py"; return 1; }
chmod 755 "$script_dst"
log "Installed $script_dst"
log "Downloading screen-brightness.service ..."
curl -fsSL "${FILE_SERVER}/screen-brightness.service" -o "$service_dst" \
|| { log "ERROR: could not download screen-brightness.service"; return 1; }
chmod 644 "$service_dst"
log "Installed $service_dst"
# Deploy default config only if one doesn't already exist on the device
# (preserves any vessel-specific tuning done post-provisioning)
if [[ ! -f "$conf_dst" ]]; then
log "Downloading screen-brightness.conf (default) ..."
curl -fsSL "${FILE_SERVER}/screen-brightness.conf" -o "$conf_dst" \
|| log "WARNING: could not download screen-brightness.conf (daemon uses built-in defaults)"
[[ -f "$conf_dst" ]] && chmod 644 "$conf_dst" && log "Installed $conf_dst"
else
log "Config $conf_dst already exists — keeping vessel settings"
fi
# Create the runtime dir; tmpfiles.d rule ensures it re-appears after reboot
mkdir -p /run/screen-brightness
cat > /etc/tmpfiles.d/screen-brightness.conf <<'TMPFILES'
d /run/screen-brightness 0755 root root -
TMPFILES
# Brightness API (localhost only) so overlay can set override without root
log "Downloading brightness-api.py ..."
curl -fsSL "${FILE_SERVER}/brightness-api.py" -o /usr/local/bin/brightness-api.py \
&& chmod 755 /usr/local/bin/brightness-api.py \
&& log "Installed /usr/local/bin/brightness-api.py" || true
if curl -fsSL "${FILE_SERVER}/brightness-api.service" -o /etc/systemd/system/brightness-api.service 2>/dev/null; then
chmod 644 /etc/systemd/system/brightness-api.service
systemctl enable brightness-api.service
log "brightness-api.service enabled"
fi
# Brightness overlay (crew manual control on top of kiosk)
if curl -fsSL "${FILE_SERVER}/brightness-overlay.py" -o "$PI_HOME/brightness-overlay.py" 2>/dev/null; then
chmod 755 "$PI_HOME/brightness-overlay.py"
chown "$PI_USER:$PI_USER" "$PI_HOME/brightness-overlay.py"
if curl -fsSL "${FILE_SERVER}/brightness-overlay-launch.sh" -o "$PI_HOME/brightness-overlay-launch.sh" 2>/dev/null; then
chmod 755 "$PI_HOME/brightness-overlay-launch.sh"
chown "$PI_USER:$PI_USER" "$PI_HOME/brightness-overlay-launch.sh"
fi
if curl -fsSL "${FILE_SERVER}/brightness-overlay.desktop" -o /tmp/br-overlay.desktop 2>/dev/null; then
sed "s|/home/pi|$PI_HOME|g" /tmp/br-overlay.desktop > "$AUTOSTART/brightness-overlay.desktop"
chown "$PI_USER:$PI_USER" "$AUTOSTART/brightness-overlay.desktop"
chmod 644 "$AUTOSTART/brightness-overlay.desktop"
rm -f /tmp/br-overlay.desktop
log "brightness-overlay installed (autostart)"
fi
fi
systemctl daemon-reload
systemctl enable screen-brightness.service
log "screen-brightness.service enabled (starts on next boot)"
}

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Test buzzer so screen-brightness daemon raises backlight (ALERT) and holds
# during COOLDOWN. Holds buzzer ON for 3+ seconds so the daemon's 2s poll sees it.
#
# reTerminal DM: /sys/class/leds/usr-buzzer/brightness (0 = off, 1 = on)
# Run as root: sudo ./test-buzzer-brightness.sh
set -e
BUZZER="/sys/class/leds/usr-buzzer/brightness"
MAX="/sys/class/leds/usr-buzzer/max_brightness"
if [[ ! -f "$BUZZER" ]]; then
echo "ERROR: Buzzer not found at $BUZZER" >&2
exit 1
fi
if [[ "$(id -u)" -ne 0 ]]; then
echo "Run as root: sudo $0" >&2
exit 1
fi
# Quick check: is the daemon running? (so we know what to expect)
echo "--- Pre-check ---"
if systemctl is-active --quiet screen-brightness.service 2>/dev/null; then
echo " screen-brightness.service: active"
if [[ -f /run/screen-brightness/state ]]; then
echo " Current state:"
sed 's/^/ /' /run/screen-brightness/state
else
echo " (no /run/screen-brightness/state yet)"
fi
echo " Recent logs:"
journalctl -u screen-brightness.service -n 3 --no-pager 2>/dev/null | sed 's/^/ /' || true
else
echo " screen-brightness.service: NOT ACTIVE — start it with: sudo systemctl start screen-brightness.service"
echo " Brightness will not change until the daemon is running."
fi
echo ""
# Hold buzzer ON for 3 seconds so the daemon's 2s poll interval will see it
ON_VAL=255
if [[ -f "$MAX" ]]; then
ON_VAL="$(cat "$MAX" 2>/dev/null || echo 255)"
fi
echo "Beeping: 3 short beeps, then buzzer ON for 3 s (watch brightness go to 5)..."
for i in 1 2 3; do
echo "$ON_VAL" > "$BUZZER"
sleep 0.15
echo 0 > "$BUZZER"
sleep 0.25
done
echo "$ON_VAL" > "$BUZZER"
sleep 3
echo 0 > "$BUZZER"
echo "Buzzer off. Brightness should stay at 5 for ~10 s (cooldown), then return to ambient."
echo ""
echo "--- After (check state and logs) ---"
if [[ -f /run/screen-brightness/state ]]; then
echo " State:"
sed 's/^/ /' /run/screen-brightness/state
fi
echo " Recent logs:"
journalctl -u screen-brightness.service -n 8 --no-pager 2>/dev/null | sed 's/^/ /' || echo " (no logs or service not running)"