From aa72100148f3af7949a183da427b11c8346151da Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Tue, 13 Oct 2026 22:50:04 +0900 Subject: [PATCH] reboot command beside poweroff, one polkit grant; forward yukart/gnss/+; mock uses current names --allow-poweroff becomes --allow-power. deploy/50-yukart-gw-power.pkla adds logind's reboot actions and the deploy removes the old poweroff-only file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- ...-poweroff.pkla => 50-yukart-gw-power.pkla} | 11 +++--- deploy/yukart-gw.service | 4 +-- docs/impl/plans/INITIAL.md | 4 +-- scripts/deploy.sh | 12 ++++--- src/bus.rs | 2 +- src/client.rs | 36 ++++++++++--------- src/main.rs | 8 ++--- src/mock.rs | 14 ++++---- 8 files changed, 48 insertions(+), 43 deletions(-) rename deploy/{50-yukart-gw-poweroff.pkla => 50-yukart-gw-power.pkla} (57%) diff --git a/deploy/50-yukart-gw-poweroff.pkla b/deploy/50-yukart-gw-power.pkla similarity index 57% rename from deploy/50-yukart-gw-poweroff.pkla rename to deploy/50-yukart-gw-power.pkla index a0fb85c..6750521 100644 --- a/deploy/50-yukart-gw-poweroff.pkla +++ b/deploy/50-yukart-gw-power.pkla @@ -1,12 +1,13 @@ -# polkit (0.105, local authority): lets the svc-gw user power the host off through -# logind, with no sudo. svc-gw runs `systemctl poweroff` for the display's -# `poweroff` command (contracts BOUNDARY.md). Nothing else is granted. +# polkit (0.105, local authority): lets the svc-gw user power the host off or reboot it +# through logind, with no sudo. svc-gw runs `systemctl poweroff` / `systemctl reboot` +# for the display's `poweroff` and `reboot` commands (contracts BOUNDARY.md). Nothing +# else is granted. # Installed to /etc/polkit-1/localauthority/50-local.d/ by scripts/deploy.sh. # multiple-sessions: an ssh login is another session; ignore-inhibit: no inhibitor lock # (none are taken on this host today) should leave the button silently doing nothing. -[yukart-gw: power off] +[yukart-gw: power off and reboot] Identity=unix-user:mikkeli -Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.power-off-ignore-inhibit +Action=org.freedesktop.login1.power-off;org.freedesktop.login1.power-off-multiple-sessions;org.freedesktop.login1.power-off-ignore-inhibit;org.freedesktop.login1.reboot;org.freedesktop.login1.reboot-multiple-sessions;org.freedesktop.login1.reboot-ignore-inhibit ResultAny=yes ResultInactive=yes ResultActive=yes diff --git a/deploy/yukart-gw.service b/deploy/yukart-gw.service index b6e189c..c71706c 100644 --- a/deploy/yukart-gw.service +++ b/deploy/yukart-gw.service @@ -8,8 +8,8 @@ StartLimitIntervalSec=0 [Service] User=mikkeli # Add --mock only on a bench unit: every message then says so, and displays must show it. -# --allow-poweroff: the display may shut this host down (contracts BOUNDARY.md, poweroff). Only on the Orin. -ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 --allow-poweroff +# --allow-power: the display may power this host off or reboot it (contracts BOUNDARY.md). Only on the Orin. +ExecStart=/usr/local/bin/yukart-gw --listen 0.0.0.0:7700 --allow-power Restart=always RestartSec=2 diff --git a/docs/impl/plans/INITIAL.md b/docs/impl/plans/INITIAL.md index a1aa0ce..0b6903d 100644 --- a/docs/impl/plans/INITIAL.md +++ b/docs/impl/plans/INITIAL.md @@ -23,8 +23,8 @@ directly. 0. **Boundary v0** — in place: WebSocket on `:7700`, hello, heartbeats both ways, `mark` forwarded to the recorder's control socket, mock state behind `--mock`. Protocol in contracts [`docs/BOUNDARY.md`](gitea:yukart/contracts). - **Bus input** — in place (2026-10-12): subscribes to `yukart/vehicle/+` and - `yukart/host/+`, keeps the newest envelope per signal, marks stale after 5 s + **Bus input** — in place (2026-10-12): subscribes to `yukart/vehicle/+`, + `yukart/host/+` and `yukart/gnss/+`, keeps the newest envelope per signal, marks stale after 5 s or when known only from a retained copy. Bus or mock, never both. 1. Static file serving for the basemap — smallest useful thing, unblocks the map. 2. Nav HTTP proxy to [svc-nav](gitea:yukart/svc-nav). diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0525707..f8055f4 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -6,8 +6,8 @@ # env: BUILD_HOST (default: mikkeli-orin-nano-2) # # Builds what is pushed, not the working tree: the build host pulls master of -# svc-gw and contracts under ~/dev/yukart/. Native on purpose: the poweroff command -# runs systemctl, and std::process::Command cross-compiled against the workstation's +# svc-gw and contracts under ~/dev/yukart/. Native on purpose: the poweroff and reboot +# commands run systemctl, and std::process::Command cross-compiled against the workstation's # newer glibc links GLIBC_2.39 symbols (pidfd_spawnp) the car cannot load. set -euo pipefail @@ -31,13 +31,15 @@ scripts/check-glibc.sh "$BIN" ssh "$HOST" 'mkdir -p ~/infra/svc-gw' scp -q "$BIN" "$HOST:infra/svc-gw/svc-gw.new" scp -q deploy/yukart-gw.service "$HOST:infra/svc-gw/yukart-gw.service" -scp -q deploy/50-yukart-gw-poweroff.pkla "$HOST:infra/svc-gw/50-yukart-gw-poweroff.pkla" +scp -q deploy/50-yukart-gw-power.pkla "$HOST:infra/svc-gw/50-yukart-gw-power.pkla" ssh "$HOST" 'set -e mv ~/infra/svc-gw/svc-gw.new ~/infra/svc-gw/svc-gw sudo ln -sfn "$HOME/infra/svc-gw/svc-gw" /usr/local/bin/yukart-gw sudo install -m 0644 ~/infra/svc-gw/yukart-gw.service /etc/systemd/system/yukart-gw.service - # polkit, not sudo, lets the service power the host off (the poweroff command). - sudo install -m 0644 ~/infra/svc-gw/50-yukart-gw-poweroff.pkla /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-poweroff.pkla + # polkit, not sudo, lets the service power the host off or reboot it (the poweroff and + # reboot commands). The file was named 50-yukart-gw-poweroff.pkla before reboot joined. + sudo install -m 0644 ~/infra/svc-gw/50-yukart-gw-power.pkla /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-power.pkla + sudo rm -f /etc/polkit-1/localauthority/50-local.d/50-yukart-gw-poweroff.pkla ~/infra/svc-gw/50-yukart-gw-poweroff.pkla sudo systemctl daemon-reload sudo systemctl enable yukart-gw.service >/dev/null sudo systemctl restart yukart-gw.service diff --git a/src/bus.rs b/src/bus.rs index d0a4831..48538c7 100644 --- a/src/bus.rs +++ b/src/bus.rs @@ -15,7 +15,7 @@ use std::time::{Duration, Instant}; use rumqttc::{Client, Event, MqttOptions, Packet, QoS}; use serde_json::{json, Map, Value}; -const TOPICS: [&str; 2] = ["yukart/vehicle/+", "yukart/host/+"]; +const TOPICS: [&str; 3] = ["yukart/vehicle/+", "yukart/host/+", "yukart/gnss/+"]; const STALE: Duration = Duration::from_secs(5); struct Entry { diff --git a/src/client.rs b/src/client.rs index 4fe4a16..20c09f3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -26,8 +26,8 @@ pub struct Ctx { pub hub: Hub, pub hello: Value, pub recorder_ctl: String, - /// `--allow-poweroff`: off by default, so a gateway on a workstation can never shut it down. - pub allow_poweroff: bool, + /// `--allow-power`: off by default, so a gateway on a workstation can never shut it down or reboot it. + pub allow_power: bool, /// Acks by command id, shared across connections so a retry after reconnect is answered, not re-run. pub acks: Mutex>, } @@ -130,39 +130,41 @@ fn run(ctx: &Ctx, msg: &Value) -> (bool, String) { Err(e) => (false, format!("recorder unreachable: {e}")), } } - "poweroff" => poweroff(ctx, &msg["args"]), + "poweroff" => power(ctx, &msg["args"], "poweroff"), + "reboot" => power(ctx, &msg["args"], "reboot"), other => (false, format!("unknown command '{other}'")), } } -/// The host's boot id: a poweroff command must carry the current one, so a resend +/// The host's boot id: a poweroff or reboot command must carry the current one, so a resend /// from before a reboot cannot shut the next boot down. pub fn boot_id() -> String { std::fs::read_to_string("/proc/sys/kernel/random/boot_id").unwrap_or_default().trim().to_string() } -fn poweroff(ctx: &Ctx, args: &Value) -> (bool, String) { - if !ctx.allow_poweroff { - return (false, "poweroff not enabled on this gateway (--allow-poweroff)".into()); +/// `poweroff` or `reboot`: the same guards, the same grant, a different systemctl verb. +fn power(ctx: &Ctx, args: &Value, verb: &'static str) -> (bool, String) { + if !ctx.allow_power { + return (false, format!("{verb} not enabled on this gateway (--allow-power)")); } - if args["confirm"].as_str() != Some("poweroff") { - return (false, "poweroff needs args.confirm = \"poweroff\"".into()); + if args["confirm"].as_str() != Some(verb) { + return (false, format!("{verb} needs args.confirm = \"{verb}\"")); } if args["boot_id"].as_str() != Some(boot_id().as_str()) { - return (false, "poweroff refused: boot_id is not this boot's".into()); + return (false, format!("{verb} refused: boot_id is not this boot's")); } - eprintln!("svc-gw: poweroff requested by a client"); + eprintln!("svc-gw: {verb} requested by a client"); // Ack first: the reply has to reach the client before the services stop. - thread::spawn(|| { + thread::spawn(move || { thread::sleep(Duration::from_secs(2)); - // Unprivileged: polkit grants this user logind's power-off (deploy/50-yukart-gw-poweroff.pkla). - match Command::new("systemctl").args(["poweroff", "--no-ask-password"]).status() { + // Unprivileged: polkit grants this user logind's power-off and reboot (deploy/50-yukart-gw-power.pkla). + match Command::new("systemctl").args([verb, "--no-ask-password"]).status() { Ok(s) if s.success() => {} - Ok(s) => eprintln!("svc-gw: systemctl poweroff exited {s}"), - Err(e) => eprintln!("svc-gw: systemctl poweroff: {e}"), + Ok(s) => eprintln!("svc-gw: systemctl {verb} exited {s}"), + Err(e) => eprintln!("svc-gw: systemctl {verb}: {e}"), } }); - (true, "shutting down in 2 s".into()) + (true, if verb == "reboot" { "rebooting in 2 s" } else { "shutting down in 2 s" }.into()) } fn recorder(path: &str, line: &str) -> std::io::Result { diff --git a/src/main.rs b/src/main.rs index 0489f5a..408a872 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,7 @@ usage: svc-gw [options] --mock synthesise vehicle state, flagged as mock in every message (no bus) --broker HOST:PORT MQTT broker; '' for none (127.0.0.1:1883) --recorder-ctl PATH recorder control socket, for 'mark' (/run/yukart-recorder/ctl.sock) - --allow-poweroff accept the 'poweroff' command (the Orin's unit only; never on a workstation)"; + --allow-power accept the 'poweroff' and 'reboot' commands (the Orin's unit only; never on a workstation)"; pub fn now_us() -> i64 { let d = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); @@ -54,13 +54,13 @@ fn main() -> ExitCode { let mut listen = "0.0.0.0:7700".to_string(); let mut mock = false; let mut recorder_ctl = "/run/yukart-recorder/ctl.sock".to_string(); - let mut allow_poweroff = false; + let mut allow_power = false; let mut broker = "127.0.0.1:1883".to_string(); let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { "--mock" => mock = true, - "--allow-poweroff" => allow_poweroff = true, + "--allow-power" => allow_power = true, "--listen" => listen = args.next().unwrap_or_default(), "--recorder-ctl" => recorder_ctl = args.next().unwrap_or_default(), "--broker" => broker = args.next().unwrap_or_default(), @@ -99,7 +99,7 @@ fn main() -> ExitCode { "timeout_ms": client::TIMEOUT.as_millis() as u64, }), recorder_ctl, - allow_poweroff, + allow_power, acks: Mutex::new(Default::default()), }); diff --git a/src/mock.rs b/src/mock.rs index a78d326..b4923c9 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -40,20 +40,20 @@ impl Mock { s.insert(name.to_string(), reg.envelope(name, v, q, true)); }; put("engine_rpm", json!(rpm.round()), None); - put("vehicle_speed_kph", json!((speed * 10.0_f64).round() / 10.0), None); - put("coolant_temp_c", json!(self.coolant.round()), None); - // Throttle drops out for 5 s in every 40: a signal that disappears. + put("gnss_speed_kph", json!((speed * 10.0_f64).round() / 10.0), None); + put("obd_coolant_temp_c", json!(self.coolant.round()), None); + // The pedal drops out for 5 s in every 40: a signal that disappears. if t % 40.0 >= 5.0 { - put("throttle_pct", json!(throttle), None); + put("accel_pedal_pct", json!(throttle), None); } // Intake temperature is stuck at its last value and marked stale for 10 s in every 30. if t % 30.0 < 10.0 { - put("intake_air_temp_c", json!(31.0), Some("stale")); + put("obd_intake_air_temp_c", json!(31.0), Some("stale")); } else { - put("intake_air_temp_c", json!((31.0 + (t * 0.05).sin()).round()), None); + put("obd_intake_air_temp_c", json!((31.0 + (t * 0.05).sin()).round()), None); } // Fuel level is known but has no value: the registry has it, the car never sent it. - put("fuel_level_pct", Value::Null, None); + put("obd_fuel_level_pct", Value::Null, None); s } }