Skip to content

Commit a7fcece

Browse files
authored
Add heartbeat timeout warning (#1164)
* Add heartbeat timeout warning * Address copilot review comments
1 parent 55f7b14 commit a7fcece

10 files changed

Lines changed: 184 additions & 29 deletions

File tree

gcs/data/default_settings.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@
7979
"display": "Battery Alert",
8080
"suffix": "%",
8181
"group": "Battery"
82+
},
83+
"heartbeatTimeoutSeconds": {
84+
"description": "You'll be notified when heartbeats stop arriving from the aircraft for this long.",
85+
"default": 10,
86+
"type": "number",
87+
"range": [
88+
1,
89+
3600
90+
],
91+
"display": "Heartbeat Timeout",
92+
"suffix": "s",
93+
"group": "Connection"
8294
}
8395
},
8496
"Video": {
@@ -203,4 +215,4 @@
203215
"group": "Data stream rates"
204216
}
205217
}
206-
}
218+
}

gcs/src/components/dashboard/alerts/alert.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ export default function AlertSection() {
2222
<Alert
2323
variant="outline"
2424
color={SeverityColor[alert.severity]}
25-
withCloseButton
25+
withCloseButton={alert.dismissable !== false}
2626
title={alert.category}
2727
icon={<IconAlertTriangle />}
28-
onClose={() => dismissAlert(alert.category, true)}
28+
onClose={
29+
alert.dismissable === false
30+
? undefined
31+
: () => dismissAlert(alert.category, true)
32+
}
2933
>
3034
{alert.jsx}
3135
</Alert>

gcs/src/components/dashboard/alerts/alertConstants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export const AlertCategory = {
22
Altitude: "Altitude",
33
Battery: "Battery",
4+
Heartbeat: "Heartbeat",
45
Speed: "Speed",
56
}
67

gcs/src/components/dashboard/alerts/alertProvider.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ export default function AlertProvider({ children }) {
3131
function dismissAlert(category, manual) {
3232
setAlerts((prevAlerts) => {
3333
const alert = prevAlerts.find((a) => a.category === category)
34+
if (!alert) return prevAlerts
35+
36+
if (manual && alert.dismissable === false) {
37+
return prevAlerts
38+
}
3439

3540
if (manual) {
3641
dismissedAlerts.current.set(category, alert.severity)

gcs/src/components/dashboard/statusBar.jsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import { IconClock, IconNetwork, IconNetworkOff } from "@tabler/icons-react"
1313

1414
// Redux
1515
import { useSelector } from "react-redux"
16-
import { selectAlt, selectBatteryData } from "../../redux/slices/droneInfoSlice"
16+
import {
17+
selectAlt,
18+
selectBatteryData,
19+
selectHeartbeatLastReceivedAt,
20+
} from "../../redux/slices/droneInfoSlice"
21+
import { selectConnectedToDrone } from "../../redux/slices/droneConnectionSlice"
1722
import { selectIsConnectedToSocket } from "../../redux/slices/socketSlice"
1823

1924
// Helper imports
@@ -36,9 +41,11 @@ export function StatusSection({ icon, value, tooltip }) {
3641

3742
export default function StatusBar(props) {
3843
const isConnectedToSocket = useSelector(selectIsConnectedToSocket)
44+
const isConnectedToDrone = useSelector(selectConnectedToDrone)
3945
const [time, setTime] = useState(moment())
4046
const batteryData = useSelector(selectBatteryData)
4147
const alt = useSelector(selectAlt)
48+
const heartbeatLastReceivedAt = useSelector(selectHeartbeatLastReceivedAt)
4249

4350
// Update clock every second
4451
useEffect(() => {
@@ -50,6 +57,7 @@ export default function StatusBar(props) {
5057
const { getSetting } = useSettings()
5158
const { dispatchAlert, dismissAlert } = useAlerts()
5259
const highestAltitudeRef = useRef(0)
60+
const heartbeatAlertActiveRef = useRef(false)
5361

5462
useEffect(() => {
5563
const maxAltitude = getSetting("Dashboard.maxAltitudeAlert")
@@ -126,6 +134,69 @@ export default function StatusBar(props) {
126134
})
127135
}, [batteryData])
128136

137+
useEffect(() => {
138+
const timeoutSeconds = Number(
139+
getSetting("Dashboard.heartbeatTimeoutSeconds") ?? 10,
140+
)
141+
const timeoutMs = Number.isFinite(timeoutSeconds)
142+
? timeoutSeconds * 1000
143+
: 10000
144+
145+
const checkHeartbeatTimeout = () => {
146+
if (!isConnectedToDrone) {
147+
if (heartbeatAlertActiveRef.current) {
148+
dismissAlert(AlertCategory.Heartbeat)
149+
heartbeatAlertActiveRef.current = false
150+
}
151+
return
152+
}
153+
154+
if (heartbeatLastReceivedAt <= 0) {
155+
if (heartbeatAlertActiveRef.current) {
156+
dismissAlert(AlertCategory.Heartbeat)
157+
heartbeatAlertActiveRef.current = false
158+
}
159+
return
160+
}
161+
162+
const elapsedSeconds = Math.max(
163+
0,
164+
Math.floor((Date.now() - heartbeatLastReceivedAt) / 1000),
165+
)
166+
const isHeartbeatStale = elapsedSeconds * 1000 > timeoutMs
167+
168+
if (isHeartbeatStale) {
169+
dispatchAlert({
170+
category: AlertCategory.Heartbeat,
171+
severity: AlertSeverity.Red,
172+
dismissable: false,
173+
jsx: (
174+
<>
175+
Caution! It has been {elapsedSeconds}s since the last heartbeat.
176+
</>
177+
),
178+
})
179+
heartbeatAlertActiveRef.current = true
180+
return
181+
}
182+
183+
if (heartbeatAlertActiveRef.current) {
184+
dismissAlert(AlertCategory.Heartbeat)
185+
heartbeatAlertActiveRef.current = false
186+
}
187+
}
188+
189+
checkHeartbeatTimeout()
190+
const id = setInterval(checkHeartbeatTimeout, 1000)
191+
return () => clearInterval(id)
192+
}, [
193+
dismissAlert,
194+
dispatchAlert,
195+
getSetting,
196+
heartbeatLastReceivedAt,
197+
isConnectedToDrone,
198+
])
199+
129200
return (
130201
<div className={`${props.className} flex flex-col items-end`}>
131202
<div

gcs/src/helpers/settingsProvider.jsx

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createContext, useEffect, useState } from "react"
1+
import { createContext, useCallback, useEffect, useMemo, useState } from "react"
22

33
import { getSettingFromSettings, setSettingInSettings } from "./settings"
44

@@ -42,35 +42,46 @@ export const SettingsProvider = ({ children }) => {
4242
return () => window.ipcRenderer.removeAllListeners("settings:open")
4343
}, [open])
4444

45-
const setSetting = (setting, value) => {
46-
if (settings === null) return
45+
const setSetting = useCallback(
46+
(setting, value) => {
47+
if (settings === null) return
4748

48-
const newSettings = {
49-
version: settings.version,
50-
settings: setSettingInSettings(setting, value, settings.settings),
51-
}
49+
const newSettings = {
50+
version: settings.version,
51+
settings: setSettingInSettings(setting, value, settings.settings),
52+
}
5253

53-
setSettings(newSettings)
54-
window.ipcRenderer.invoke("settings:save-settings", newSettings)
55-
}
54+
setSettings(newSettings)
55+
window.ipcRenderer.invoke("settings:save-settings", newSettings)
56+
},
57+
[settings],
58+
)
5659

57-
const getSetting = (setting) => {
58-
const userSetting = getSettingFromSettings(setting, settings.settings)
59-
const defaultSetting = getSettingFromSettings(setting, DefaultSettings)
60+
const getSetting = useCallback(
61+
(setting) => {
62+
if (settings === null) return null
6063

61-
if (userSetting !== null) return userSetting
64+
const userSetting = getSettingFromSettings(setting, settings.settings)
65+
const defaultSetting = getSettingFromSettings(setting, DefaultSettings)
6266

63-
if (defaultSetting === null || defaultSetting === undefined) {
64-
return null
65-
}
67+
if (userSetting !== null) return userSetting
6668

67-
return defaultSetting.default
68-
}
69+
if (defaultSetting === null || defaultSetting === undefined) {
70+
return null
71+
}
72+
73+
return defaultSetting.default
74+
},
75+
[settings],
76+
)
77+
78+
const contextValue = useMemo(
79+
() => ({ getSetting, setSetting, settings, opened, open, close }),
80+
[getSetting, setSetting, settings, opened, open, close],
81+
)
6982

7083
return (
71-
<SettingsContext.Provider
72-
value={{ getSetting, setSetting, settings, opened, open, close }}
73-
>
84+
<SettingsContext.Provider value={contextValue}>
7485
{settings !== null ? children : <></>}
7586
</SettingsContext.Provider>
7687
)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import {
2+
setHeartbeatData,
3+
resetHeartbeatMonitor,
4+
setHeartbeatMonitorLastReceivedAt,
5+
} from "../slices/droneInfoSlice"
6+
import { setConnected } from "../slices/droneConnectionSlice"
7+
import { socketDisconnected } from "../slices/socketSlice"
8+
9+
const heartbeatMonitorMiddleware = (store) => {
10+
return (next) => (action) => {
11+
const result = next(action)
12+
13+
if (setConnected.match(action)) {
14+
if (action.payload === true) {
15+
store.dispatch(setHeartbeatMonitorLastReceivedAt(Date.now()))
16+
} else if (action.payload === false) {
17+
store.dispatch(resetHeartbeatMonitor())
18+
}
19+
}
20+
21+
if (socketDisconnected.match(action)) {
22+
store.dispatch(resetHeartbeatMonitor())
23+
}
24+
25+
if (setHeartbeatData.match(action)) {
26+
store.dispatch(setHeartbeatMonitorLastReceivedAt(Date.now()))
27+
}
28+
29+
return result
30+
}
31+
}
32+
33+
export default heartbeatMonitorMiddleware

gcs/src/redux/slices/droneInfoSlice.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ const droneInfoSlice = createSlice({
6666
customMode: 0,
6767
systemStatus: 0,
6868
},
69+
heartbeatMonitor: {
70+
lastReceivedAt: 0,
71+
},
6972
onboardControlSensorsEnabled: 0,
7073
onboardControlSensorsHealth: 0,
7174
gpsRawIntData: {
@@ -156,6 +159,14 @@ const droneInfoSlice = createSlice({
156159
state.heartbeatData.customMode = action.payload.custom_mode
157160
state.heartbeatData.systemStatus = action.payload.system_status
158161
},
162+
setHeartbeatMonitorLastReceivedAt: (state, action) => {
163+
if (action.payload !== state.heartbeatMonitor.lastReceivedAt) {
164+
state.heartbeatMonitor.lastReceivedAt = action.payload
165+
}
166+
},
167+
resetHeartbeatMonitor: (state) => {
168+
state.heartbeatMonitor.lastReceivedAt = 0
169+
},
159170
setBatteryData: (state, action) => {
160171
const battery = state.batteryData.filter(
161172
(battery) => battery.id == action.payload.id,
@@ -381,6 +392,8 @@ const droneInfoSlice = createSlice({
381392
selectNavController: (state) => state.navControllerData,
382393
selectDesiredBearing: (state) => state.navControllerData.navBearing,
383394
selectHeartbeat: (state) => state.heartbeatData,
395+
selectHeartbeatLastReceivedAt: (state) =>
396+
state.heartbeatMonitor.lastReceivedAt,
384397
selectIsArmed: (state) => state.isArmed,
385398
selectIsFlying: (state) => state.isFlying,
386399
selectNotificationSound: (state) => state.notificationSound,
@@ -425,6 +438,8 @@ const droneInfoSlice = createSlice({
425438
export const {
426439
setFlightSwVersion,
427440
setHeartbeatData,
441+
setHeartbeatMonitorLastReceivedAt,
442+
resetHeartbeatMonitor,
428443
soundPlayed,
429444
changeSelectedDisplayTelemetry,
430445
setSelectedDisplayTelemetry,
@@ -593,6 +608,7 @@ export const {
593608
selectNavController,
594609
selectDesiredBearing,
595610
selectHeartbeat,
611+
selectHeartbeatLastReceivedAt,
596612
selectIsArmed,
597613
selectIsFlying,
598614
selectReadyToArm,

gcs/src/redux/store.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { combineSlices, configureStore } from "@reduxjs/toolkit"
22
import { defaultDataMessages } from "../helpers/dashboardDefaultDataMessages"
3+
import heartbeatMonitorMiddleware from "./middleware/heartbeatMonitorMiddleware"
34
import armedMiddleware from "./middleware/armedMiddleware"
45
import socketMiddleware from "./middleware/socketMiddleware"
56
import applicationSlice from "./slices/applicationSlice"
@@ -54,7 +55,7 @@ export const store = configureStore({
5455
return getDefaultMiddleware({
5556
immutableCheck: false,
5657
serializableCheck: false,
57-
}).concat([socketMiddleware, armedMiddleware])
58+
}).concat([socketMiddleware, heartbeatMonitorMiddleware, armedMiddleware])
5859
},
5960
})
6061

radio/app/drone.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -761,8 +761,6 @@ def checkForMessages(self) -> None:
761761
break
762762
except (serial.serialutil.SerialException, ConnectionAbortedError):
763763
self.logger.error("Autopilot disconnected", exc_info=True)
764-
if self.droneDisconnectCb:
765-
self.droneDisconnectCb()
766764
self.close()
767765
break
768766
except Exception as e:
@@ -1282,6 +1280,9 @@ def close(self) -> None:
12821280
self.logger.info(f"Cleaning up resources for drone at {self}")
12831281
self.clearAllMessageListeners()
12841282

1283+
if self.droneDisconnectCb:
1284+
self.droneDisconnectCb()
1285+
12851286
self.is_active.clear()
12861287

12871288
if getattr(self, "master", None) is not None:

0 commit comments

Comments
 (0)