mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
add reboot command
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -31,6 +31,7 @@ type inboundMessage struct {
|
|||||||
Horn *hornPayload `json:"horn,omitempty"`
|
Horn *hornPayload `json:"horn,omitempty"`
|
||||||
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
NightVision *nightVisionPayload `json:"nightVision,omitempty"`
|
||||||
Song *songPayload `json:"song,omitempty"`
|
Song *songPayload `json:"song,omitempty"`
|
||||||
|
Reboot *rebootPayload `json:"reboot,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -87,6 +88,10 @@ type songNote struct {
|
|||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rebootPayload struct {
|
||||||
|
DelayMs int `json:"delayMs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ackMessage struct {
|
type ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -217,11 +217,54 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
|
|||||||
slot = clampInt(*msg.Song.Slot, 0, 4)
|
slot = clampInt(*msg.Song.Slot, 0, 4)
|
||||||
}
|
}
|
||||||
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
return c.adapter.PlaySong(slot, msg.Song.Notes)
|
||||||
|
case msg.Reboot != nil || msg.Type == "reboot":
|
||||||
|
return c.handleRebootCommand(msg.Reboot)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
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) {
|
func (c *WSClient) applyAutoSideBrush(left, right int) {
|
||||||
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
if c.cfg == nil || !c.cfg.AutoSideBrush.Enabled {
|
||||||
if c.autoSideOn {
|
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
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -64,12 +64,19 @@ io.on('connection', (socket) => {
|
|||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
throw new Error('roverId required');
|
throw new Error('roverId required');
|
||||||
}
|
}
|
||||||
|
if (!type) {
|
||||||
|
throw new Error('type required');
|
||||||
|
}
|
||||||
const payload = data ? { ...data } : {};
|
const payload = data ? { ...data } : {};
|
||||||
|
const isRebootCommand = type === 'reboot';
|
||||||
const isSongCommand = type === 'song' || (type === 'raw' && isSongRawPayload(payload));
|
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');
|
throw new Error('Not your turn or no control');
|
||||||
}
|
}
|
||||||
const isAdminSocket = isAdmin(socket);
|
|
||||||
const driveDirect = payload?.driveDirect;
|
const driveDirect = payload?.driveDirect;
|
||||||
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
if (type === 'drive' && driveDirect && !isAdminSocket) {
|
||||||
const left = Number(driveDirect.left);
|
const left = Number(driveDirect.left);
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ const MODES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminPanel() {
|
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 roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||||
const [lockStates, setLockStates] = useState({});
|
const [lockStates, setLockStates] = useState({});
|
||||||
|
const [rebootStates, setRebootStates] = useState({});
|
||||||
const health = session?.health || null;
|
const health = session?.health || null;
|
||||||
const currentGoal = session?.communityGoal?.text || '';
|
const currentGoal = session?.communityGoal?.text || '';
|
||||||
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
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 () => {
|
const handleGoalSave = async () => {
|
||||||
try {
|
try {
|
||||||
await setCommunityGoal(goalDraft);
|
await setCommunityGoal(goalDraft);
|
||||||
@@ -176,6 +192,14 @@ export default function AdminPanel() {
|
|||||||
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
|
<button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
|
||||||
Force
|
Force
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const SessionContext = createContext({
|
|||||||
triggerReplay: async () => {},
|
triggerReplay: async () => {},
|
||||||
setCommunityGoal: async () => {},
|
setCommunityGoal: async () => {},
|
||||||
setAdminReason: async () => {},
|
setAdminReason: async () => {},
|
||||||
|
rebootRover: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function useAckEmitter(socket) {
|
function useAckEmitter(socket) {
|
||||||
@@ -117,6 +118,8 @@ export function SessionProvider({ children }) {
|
|||||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||||
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
||||||
|
rebootRover: (roverId) =>
|
||||||
|
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
||||||
pushAlert: (alert) =>
|
pushAlert: (alert) =>
|
||||||
setAlerts((prev) => [
|
setAlerts((prev) => [
|
||||||
...prev.slice(-49),
|
...prev.slice(-49),
|
||||||
|
|||||||
Reference in New Issue
Block a user