From 88c572f44745c313b0f57e0ae0f9b6d16232287a Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Tue, 13 Oct 2026 18:35:49 +0900 Subject: [PATCH] Shut down Orin: confirmed poweroff, sent once with the hello's boot_id Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz --- lib/boundary.dart | 21 +++++++++++++++++ lib/main.dart | 57 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/lib/boundary.dart b/lib/boundary.dart index 031fbe2..8ae16e6 100644 --- a/lib/boundary.dart +++ b/lib/boundary.dart @@ -93,6 +93,27 @@ class Boundary extends ChangeNotifier { return id; } + /// Send once, never resent: for commands that must not run twice, like `poweroff`, whose + /// effect wipes svc-gw's ack cache. Null when not connected; nothing is queued then. + String? commandOnce(String cmd, [Map args = const {}]) { + if (_ws == null || hello == null) return null; + final id = '${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}-${_rng.nextInt(1 << 32).toRadixString(36)}'; + _add(jsonEncode({'t': 'cmd', 'id': id, 'cmd': cmd, 'args': args})); + return id; + } + + /// The ack for [id], if it arrives within [within]. + Future ackFor(String id, {Duration within = const Duration(seconds: 3)}) async { + final end = DateTime.now().add(within); + while (DateTime.now().isBefore(end)) { + for (final a in acks) { + if (a.id == id) return a; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + return null; + } + Future _loop() async { while (_running) { _set(LinkState.connecting, 'connecting to $url'); diff --git a/lib/main.dart b/lib/main.dart index ea2ad9c..d29edca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -260,6 +260,12 @@ class LinkBar extends StatelessWidget { icon: const Icon(Icons.checklist), label: const Text('Captures'), ), + if (b.state == LinkState.up && b.hello?['boot_id'] is String) + TextButton.icon( + onPressed: () => shutDownOrin(context, b), + icon: const Icon(Icons.power_settings_new), + label: const Text('Shut down Orin'), + ), ], ), ), @@ -268,6 +274,57 @@ class LinkBar extends StatelessWidget { } } +/// Confirm, then send `poweroff` once (contracts BOUNDARY.md): it carries this +/// connection's boot id, so it can never take effect on a later boot. +Future shutDownOrin(BuildContext context, Boundary b) async { + final host = b.hello?['host'] ?? 'the Orin'; + final yes = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Shut down $host?'), + content: const Text( + 'Recording stops and the session is closed cleanly. ' + 'The Orin stays off until its power button is pressed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Shut down'), + ), + ], + ), + ); + if (yes != true || !context.mounted) return; + final messenger = ScaffoldMessenger.of(context); + final id = b.commandOnce('poweroff', { + 'confirm': 'poweroff', + 'boot_id': b.hello?['boot_id'], + }); + if (id == null) { + messenger.showSnackBar( + const SnackBar(content: Text('Not sent: no link to the Orin')), + ); + return; + } + final ack = await b.ackFor(id); + messenger.showSnackBar( + SnackBar( + content: Text( + ack == null + ? 'No reply: the Orin may or may not be shutting down' + : ack.ok + ? 'Orin: ${ack.detail}' + : 'Refused: ${ack.detail}', + ), + ), + ); +} + class SignalList extends StatelessWidget { const SignalList({super.key, required this.boundary}); final Boundary boundary;