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:
84
emmc-provisioning/cloud-init/fileserver/brightness-api.py
Normal file
84
emmc-provisioning/cloud-init/fileserver/brightness-api.py
Normal 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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
302
emmc-provisioning/cloud-init/fileserver/brightness-overlay.py
Normal file
302
emmc-provisioning/cloud-init/fileserver/brightness-overlay.py
Normal 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 1–5, 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 + 1–5 + 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()
|
||||
@@ -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 18–22 lux).
|
||||
# At poll_interval=2s and hysteresis_polls=3, a level change takes max 6s.
|
||||
hysteresis_polls = 3
|
||||
334
emmc-provisioning/cloud-init/fileserver/screen-brightness.py
Normal file
334
emmc-provisioning/cloud-init/fileserver/screen-brightness.py
Normal 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", # 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)
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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)"
|
||||
@@ -48,7 +48,7 @@ if [[ -z "$FIRST_BOOT_CONF" ]]; then
|
||||
fi
|
||||
|
||||
# Step enable flags (default: all enabled)
|
||||
for _n in 01 02 03 04 05 06 07 08 09 10 11 12 13; do
|
||||
for _n in 01 02 03 04 05 06 07 08 09 10 11 12 13 14; do
|
||||
eval "ENABLE_STEP_${_n}=\"\${ENABLE_STEP_${_n}:-1}\""
|
||||
done
|
||||
|
||||
@@ -165,6 +165,7 @@ elif [[ "$CURRENT_PHASE" == "2" ]]; then
|
||||
run_step 10 cmdline
|
||||
run_step 11 oneshots
|
||||
run_step 12 log_permissions
|
||||
run_step 14 screen_brightness
|
||||
phase2_cleanup
|
||||
run_step 13 reboot
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
# 2. Copy this file to the boot partition as "user-data" (with meta-data and optional network-config).
|
||||
# 3. Edit BOOTSTRAP_URL below to match your server (or set it once in the runcmd section).
|
||||
#
|
||||
# DNS: This config uses systemd-resolved; /etc/resolv.conf is a stub and DNS comes from DHCP
|
||||
# (LXC option 6). Ensure bootstrap.sh does not overwrite /etc/resolv.conf. See docs/DEVICE-DNS-DHCP-RESOLVCONF.md.
|
||||
# DNS: Use NetworkManager rc-manager=symlink so /etc/resolv.conf gets DHCP DNS (LXC option 6).
|
||||
# RPi OS does not use systemd-resolved by default. Ensure bootstrap.sh does not overwrite
|
||||
# /etc/resolv.conf. See docs/DEVICE-DNS-DHCP-RESOLVCONF.md.
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
@@ -15,7 +16,7 @@ package_upgrade: false
|
||||
# Keep /etc/hosts in sync with hostname (from meta-data or set below)
|
||||
manage_etc_hosts: true
|
||||
|
||||
# DNS is managed by systemd-resolved; we do not overwrite /etc/resolv.conf
|
||||
# Do not overwrite /etc/resolv.conf; NetworkManager will manage it with DHCP DNS
|
||||
manage_resolv_conf: false
|
||||
|
||||
packages:
|
||||
@@ -28,76 +29,18 @@ write_files:
|
||||
PasswordAuthentication yes
|
||||
PermitRootLogin no
|
||||
|
||||
# Push current DHCP DNS into systemd-resolved (for dhcpcd/dhclient when NM doesn't feed resolved).
|
||||
# With no args: discover DNS from lease or resolvectl and push to resolved for default IF.
|
||||
# NetworkManager feeds resolved automatically; this covers first boot and non-NM setups.
|
||||
- path: /usr/local/bin/update-resolv-from-dhcp.sh
|
||||
content: |
|
||||
#!/bin/sh
|
||||
# Push DHCP DNS to systemd-resolved so resolv.conf (stub) uses it.
|
||||
IF="${IFACE:-$(ip -o -4 route show to default 2>/dev/null | awk '{print $5}' | head -1)}"
|
||||
[ -z "$IF" ] && exit 0
|
||||
DNS=""
|
||||
if [ -s /run/systemd/resolve/resolv.conf ]; then
|
||||
DNS=$(grep -E '^nameserver\s+' /run/systemd/resolve/resolv.conf | awk '{print $2}' | tr '\n' ' ')
|
||||
fi
|
||||
if [ -z "$DNS" ]; then
|
||||
DNS=$(resolvectl dns "$IF" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | tr '\n' ' ')
|
||||
fi
|
||||
if [ -z "$DNS" ]; then
|
||||
LEASE=$(ls /var/lib/dhcp/dhclient.*.leases 2>/dev/null | head -1)
|
||||
[ -n "$LEASE" ] && DNS=$(grep -oP 'option domain-name-servers \K[^;]+' "$LEASE" 2>/dev/null | tr ',' '\n' | tr -d ' ' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | tr '\n' ' ')
|
||||
fi
|
||||
[ -n "$DNS" ] && resolvectl dns "$IF" $DNS
|
||||
permissions: '0755'
|
||||
|
||||
# dhclient: feed systemd-resolved on every lease acquire/renew (DHCP provides new_domain_name_servers)
|
||||
- path: /etc/dhcp/dhclient-exit-hooks.d/zzz-update-resolv-conf
|
||||
content: |
|
||||
#!/bin/sh
|
||||
# Run by dhclient on exit; push DHCP DNS into systemd-resolved.
|
||||
[ -z "$new_domain_name_servers" ] && exit 0
|
||||
[ -z "$interface" ] && exit 0
|
||||
resolvectl dns "$interface" $new_domain_name_servers
|
||||
permissions: '0755'
|
||||
|
||||
# NetworkManager: resolved is fed by NM by default; this only runs our script as fallback (e.g. if resolved started late).
|
||||
- path: /etc/NetworkManager/dispatcher.d/99-update-resolv-from-dhcp
|
||||
content: |
|
||||
#!/bin/sh
|
||||
[ "$2" = "up" ] || [ "$2" = "dhcp4-change" ] || exit 0
|
||||
export IFACE="$1"
|
||||
/usr/local/bin/update-resolv-from-dhcp.sh
|
||||
permissions: '0755'
|
||||
|
||||
# Tell NetworkManager to send DHCP DNS to systemd-resolved (so every DHCP update is applied).
|
||||
- path: /etc/NetworkManager/conf.d/99-use-resolved.conf
|
||||
# NetworkManager: manage resolv.conf via symlink so it gets DNS from DHCP (option 6 from LXC).
|
||||
# RPi OS does not use systemd-resolved; NM writes /etc/resolv.conf -> /run/NetworkManager/resolv.conf.
|
||||
- path: /etc/NetworkManager/conf.d/99-resolv-dhcp.conf
|
||||
content: |
|
||||
[main]
|
||||
dns=systemd-resolved
|
||||
rc-manager=unmanaged
|
||||
|
||||
# Fallback: push DHCP DNS to resolved once when network is up (e.g. dhcpcd-only or first boot).
|
||||
- path: /etc/systemd/system/update-resolv-from-dhcp.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Push DHCP DNS to systemd-resolved
|
||||
After=network-online.target systemd-resolved.service
|
||||
WantedBy=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/update-resolv-from-dhcp.sh
|
||||
RemainAfterExit=yes
|
||||
rc-manager=symlink
|
||||
permissions: '0644'
|
||||
|
||||
runcmd:
|
||||
# Use systemd-resolved for DNS; /etc/resolv.conf -> stub so all lookups go through resolved (DHCP DNS applied by NM/hooks).
|
||||
- systemctl enable systemd-resolved.service
|
||||
- systemctl start systemd-resolved.service
|
||||
- rm -f /etc/resolv.conf && ln -s /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
|
||||
# Push current DHCP DNS into resolved once at first boot (in case NM hasn't applied yet).
|
||||
- /usr/local/bin/update-resolv-from-dhcp.sh
|
||||
- systemctl enable update-resolv-from-dhcp.service
|
||||
# Remove static resolv.conf so NM creates its symlink with DHCP DNS (file.server will resolve).
|
||||
- rm -f /etc/resolv.conf
|
||||
- systemctl restart NetworkManager || true
|
||||
- systemctl enable ssh
|
||||
- systemctl start ssh
|
||||
# Download and run bootstrap script (edit URL to match your file server)
|
||||
|
||||
Reference in New Issue
Block a user