add reboot command

This commit is contained in:
legop3
2026-02-18 15:09:36 -05:00
parent eb52c8c6ac
commit 40e21d4ac5
11 changed files with 211 additions and 129 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -31,6 +31,7 @@ type inboundMessage struct {
Horn *hornPayload `json:"horn,omitempty"`
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
Song *songPayload `json:"song,omitempty"`
Reboot *rebootPayload `json:"reboot,omitempty"`
}
type driveDirectPayload struct {
@@ -87,6 +88,10 @@ type songNote struct {
Duration int `json:"duration"`
}
type rebootPayload struct {
DelayMs int `json:"delayMs,omitempty"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+43
View File
@@ -217,11 +217,54 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
slot = clampInt(*msg.Song.Slot, 0, 4)
}
return c.adapter.PlaySong(slot, msg.Song.Notes)
case msg.Reboot != nil || msg.Type == "reboot":
return c.handleRebootCommand(msg.Reboot)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
}
func (c *WSClient) handleRebootCommand(payload *rebootPayload) error {
if err := c.adapter.DriveDirect(0, 0); err != nil {
return fmt.Errorf("stop drive before reboot: %w", err)
}
if err := c.adapter.MotorPWM(0, 0, 0); err != nil {
return fmt.Errorf("stop aux motors before reboot: %w", err)
}
if err := c.adapter.StartOI(); err != nil {
return fmt.Errorf("enter passive mode before reboot: %w", err)
}
delay := 300 * time.Millisecond
if payload != nil && payload.DelayMs > 0 {
delay = time.Duration(clampInt(payload.DelayMs, 50, 5000)) * time.Millisecond
}
c.connMu.Lock()
if c.rebootIssued {
c.connMu.Unlock()
return fmt.Errorf("reboot already pending")
}
c.rebootIssued = true
c.connMu.Unlock()
c.emitEvent("system.rebooting", map[string]any{
"source": "remoteCommand",
"delayMs": delay.Milliseconds(),
})
go func() {
time.Sleep(delay)
c.log.Printf("rebooting pi after remote reboot command")
cmd := exec.Command("systemctl", "reboot")
if err := cmd.Start(); err != nil {
c.log.Printf("reboot command failed: %v", err)
}
}()
return nil
}
func (c *WSClient) applyAutoSideBrush(left, right int) {
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
if c.autoSideOn {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DCtb7Ryh.js"></script>
<script type="module" crossorigin src="/assets/index-BIUOTGXU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
</head>
<body>
+9 -2
View File
@@ -64,12 +64,19 @@ io.on('connection', (socket) => {
if (!roverId) {
throw new Error('roverId required');
}
if (!type) {
throw new Error('type required');
}
const payload = data ? { ...data } : {};
const isRebootCommand = type === 'reboot';
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
if (!isSongCommand && !roverManager.canDrive(roverId, socket)) {
const isAdminSocket = isAdmin(socket);
if (isRebootCommand && !isAdminSocket) {
throw new Error('Not authorized');
}
if (!isSongCommand && !isRebootCommand && !roverManager.canDrive(roverId, socket)) {
throw new Error('Not your turn or no control');
}
const isAdminSocket = isAdmin(socket);
const driveDirect = payload?.driveDirect;
if (type === 'drive' && driveDirect && !isAdminSocket) {
const left = Number(driveDirect.left);
+25 -1
View File
@@ -10,9 +10,11 @@ const MODES = [
];
export default function AdminPanel() {
const { session, lockRover, setMode, requestControl, setCommunityGoal, setAdminReason, adminLogs } = useSession();
const { session, lockRover, setMode, requestControl, setCommunityGoal, setAdminReason, rebootRover, adminLogs } =
useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const [rebootStates, setRebootStates] = useState({});
const health = session?.health || null;
const currentGoal = session?.communityGoal?.text || '';
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
@@ -54,6 +56,20 @@ export default function AdminPanel() {
}
};
const handleReboot = async (rover) => {
if (!rover?.id) return;
const ok = window.confirm(`Reboot rover "${rover.name || rover.id}" now?`);
if (!ok) return;
setRebootStates((prev) => ({ ...prev, [rover.id]: true }));
try {
await rebootRover(rover.id);
} catch (err) {
alert(err.message);
} finally {
setRebootStates((prev) => ({ ...prev, [rover.id]: false }));
}
};
const handleGoalSave = async () => {
try {
await setCommunityGoal(goalDraft);
@@ -176,6 +192,14 @@ export default function AdminPanel() {
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
Force
</button>
<button
type="button"
onClick={() => handleReboot(rover)}
disabled={Boolean(rebootStates[rover.id])}
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
>
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
</button>
</div>
)}
/>
+3
View File
@@ -20,6 +20,7 @@ const SessionContext = createContext({
triggerReplay: async () => {},
setCommunityGoal: async () => {},
setAdminReason: async () => {},
rebootRover: async () => {},
});
function useAckEmitter(socket) {
@@ -117,6 +118,8 @@ export function SessionProvider({ children }) {
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
rebootRover: (roverId) =>
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
pushAlert: (alert) =>
setAlerts((prev) => [
...prev.slice(-49),