Skip to main content

bairelay_mqtt/
control.rs

1//! MQTT control topic parsing and command dispatch.
2//!
3//! Parses incoming MQTT messages on `{prefix}/{cam}/control/*` and
4//! `{prefix}/{cam}/query/*` topics into typed `ControlCommand` values.
5//! `{prefix}` is the `mqtt.topic_prefix` config value (default
6//! `"bairelay"`; `"neolink"` for legacy migration).
7
8use std::str;
9
10/// PTZ movement direction.
11#[derive(Debug, Clone, PartialEq)]
12pub enum PtzDirection {
13	Up,
14	Down,
15	Left,
16	Right,
17}
18
19/// IR night-vision mode.
20#[derive(Debug, Clone, PartialEq)]
21pub enum IrMode {
22	On,
23	Off,
24	Auto,
25}
26
27/// A validated control or query command extracted from an MQTT message.
28#[derive(Debug, Clone)]
29pub enum ControlCommand {
30	Floodlight {
31		camera: String,
32		state: bool,
33	},
34	FloodlightTasks {
35		camera: String,
36		state: bool,
37	},
38	Led {
39		camera: String,
40		state: bool,
41	},
42	Ir {
43		camera: String,
44		mode: IrMode,
45	},
46	Pir {
47		camera: String,
48		state: bool,
49	},
50	Reboot {
51		camera: String,
52	},
53	Ptz {
54		camera: String,
55		direction: PtzDirection,
56		amount: f32,
57	},
58	PtzPreset {
59		camera: String,
60		preset_id: u8,
61	},
62	/// Variant used by HA's preset `select` entity, which publishes the
63	/// preset *name* (the option label). The dispatcher resolves the
64	/// name to an id via the camera's `preset_cache`.
65	PtzPresetByName {
66		camera: String,
67		name: String,
68	},
69	PtzAssign {
70		camera: String,
71		preset_id: u8,
72		name: String,
73	},
74	Zoom {
75		camera: String,
76		level: f32,
77	},
78	Siren {
79		camera: String,
80		state: bool,
81	},
82	Wakeup {
83		camera: String,
84		minutes: u32,
85	},
86	// Query commands (triggered by publishing to query topics)
87	QueryBattery {
88		camera: String,
89	},
90	QueryPreview {
91		camera: String,
92	},
93	QueryPir {
94		camera: String,
95	},
96	QueryPtzPreset {
97		camera: String,
98	},
99}
100
101impl ControlCommand {
102	/// Returns the MQTT control topic this command was received on.
103	/// Used for publishing OK/FAIL replies. `prefix` is the configured
104	/// `mqtt.topic_prefix`.
105	pub fn control_topic(&self, prefix: &str) -> String {
106		let cam = self.camera_name();
107		match self {
108			ControlCommand::Floodlight { .. } => format!("{prefix}/{cam}/control/floodlight"),
109			ControlCommand::FloodlightTasks { .. } => {
110				format!("{prefix}/{cam}/control/floodlight_tasks")
111			}
112			ControlCommand::Led { .. } => format!("{prefix}/{cam}/control/led"),
113			ControlCommand::Ir { .. } => format!("{prefix}/{cam}/control/ir"),
114			ControlCommand::Pir { .. } => format!("{prefix}/{cam}/control/pir"),
115			ControlCommand::Reboot { .. } => format!("{prefix}/{cam}/control/reboot"),
116			ControlCommand::Ptz { .. } => format!("{prefix}/{cam}/control/ptz"),
117			ControlCommand::PtzPreset { .. } => format!("{prefix}/{cam}/control/ptz/preset"),
118			ControlCommand::PtzPresetByName { .. } => format!("{prefix}/{cam}/control/ptz/preset"),
119			ControlCommand::PtzAssign { .. } => format!("{prefix}/{cam}/control/ptz/assign"),
120			ControlCommand::Zoom { .. } => format!("{prefix}/{cam}/control/zoom"),
121			ControlCommand::Siren { .. } => format!("{prefix}/{cam}/control/siren"),
122			ControlCommand::Wakeup { .. } => format!("{prefix}/{cam}/control/wakeup"),
123			ControlCommand::QueryBattery { .. } => format!("{prefix}/{cam}/query/battery"),
124			ControlCommand::QueryPreview { .. } => format!("{prefix}/{cam}/query/preview"),
125			ControlCommand::QueryPir { .. } => format!("{prefix}/{cam}/query/pir"),
126			ControlCommand::QueryPtzPreset { .. } => format!("{prefix}/{cam}/query/ptz/preset"),
127		}
128	}
129
130	/// Returns the camera name that this command targets.
131	pub fn camera_name(&self) -> &str {
132		match self {
133			ControlCommand::Floodlight { camera, .. } => camera,
134			ControlCommand::FloodlightTasks { camera, .. } => camera,
135			ControlCommand::Led { camera, .. } => camera,
136			ControlCommand::Ir { camera, .. } => camera,
137			ControlCommand::Pir { camera, .. } => camera,
138			ControlCommand::Reboot { camera, .. } => camera,
139			ControlCommand::Ptz { camera, .. } => camera,
140			ControlCommand::PtzPreset { camera, .. } => camera,
141			ControlCommand::PtzPresetByName { camera, .. } => camera,
142			ControlCommand::PtzAssign { camera, .. } => camera,
143			ControlCommand::Zoom { camera, .. } => camera,
144			ControlCommand::Siren { camera, .. } => camera,
145			ControlCommand::Wakeup { camera, .. } => camera,
146			ControlCommand::QueryBattery { camera } => camera,
147			ControlCommand::QueryPreview { camera } => camera,
148			ControlCommand::QueryPir { camera } => camera,
149			ControlCommand::QueryPtzPreset { camera } => camera,
150		}
151	}
152}
153
154/// Parse an MQTT topic + payload into a `ControlCommand`.
155///
156/// Returns `None` if the topic is unrecognised or the payload is
157/// malformed. `prefix` must match the leading segment of `topic`;
158/// mismatched prefixes return `None` so a reused subscription on a
159/// second prefix cannot trigger a command. All payloads are validated
160/// — no raw user data is passed through unchecked.
161pub fn parse_control_message(prefix: &str, topic: &str, payload: &[u8]) -> Option<ControlCommand> {
162	let parts: Vec<&str> = topic.split('/').collect();
163
164	// Minimum: {prefix} / {cam} / {category} / {action}
165	// Status topic ({prefix}/{cam}/status) has 3 parts but we don't handle that here.
166	// Control/query: {prefix}/{cam}/control/{action} or {prefix}/{cam}/query/{action}
167	if parts.len() < 3 || parts[0] != prefix {
168		return None;
169	}
170
171	let camera = parts[1].to_string();
172
173	// Validate camera name: must be non-empty and contain only ASCII
174	// alphanumerics + `_` + `-`. `char::is_alphanumeric` is the
175	// **Unicode** Alphabetic + Numeric category (e.g. "café", "中文" all
176	// pass) — the doc claims ASCII-only and downstream code (HA
177	// discovery's title_case at discovery/mod.rs:269-272) assumes
178	// closed-world ASCII. Use `is_ascii_alphanumeric` to keep the
179	// promise.
180	if camera.is_empty()
181		|| !camera
182			.chars()
183			.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
184	{
185		return None;
186	}
187
188	let payload_str = str::from_utf8(payload).ok()?;
189	let payload_trimmed = payload_str.trim();
190
191	if parts.len() == 5 && parts[2] == "control" && parts[3] == "ptz" {
192		let sub = parts[4];
193		parse_control_ptz_sub(&camera, sub, payload_trimmed)
194	} else if parts.len() == 5 && parts[2] == "query" && parts[3] == "ptz" {
195		// Every query topic is also the dispatcher's reply topic
196		// (`OK`/`FAIL` published back on `query/...`). Without filtering
197		// the reply parses as a fresh query, which dispatches and
198		// publishes another `OK`, etc. — an unbounded self-loop hammering
199		// the camera. Drop the two reserved reply tokens at the parse
200		// step. Mirrors the same guard on `control/ptz/preset`.
201		if is_reserved_reply_token(payload_trimmed) {
202			return None;
203		}
204		let sub = parts[4];
205		parse_query_ptz_sub(&camera, sub)
206	} else if parts.len() == 4 && parts[2] == "control" {
207		let action = parts[3];
208		parse_control_action(&camera, action, payload_trimmed)
209	} else if parts.len() == 4 && parts[2] == "query" {
210		if is_reserved_reply_token(payload_trimmed) {
211			return None;
212		}
213		let action = parts[3];
214		parse_query_action(&camera, action)
215	} else {
216		None
217	}
218}
219
220fn is_reserved_reply_token(payload: &str) -> bool {
221	payload.eq_ignore_ascii_case("OK") || payload.eq_ignore_ascii_case("FAIL")
222}
223
224fn parse_on_off(payload: &str) -> Option<bool> {
225	match payload.to_lowercase().as_str() {
226		"on" | "true" | "1" => Some(true),
227		"off" | "false" | "0" => Some(false),
228		_ => None,
229	}
230}
231
232fn parse_ir_mode(payload: &str) -> Option<IrMode> {
233	match payload.to_lowercase().as_str() {
234		"on" | "true" | "1" => Some(IrMode::On),
235		"off" | "false" | "0" => Some(IrMode::Off),
236		"auto" => Some(IrMode::Auto),
237		_ => None,
238	}
239}
240
241fn parse_ptz_direction(s: &str) -> Option<PtzDirection> {
242	match s.to_lowercase().as_str() {
243		"up" => Some(PtzDirection::Up),
244		"down" => Some(PtzDirection::Down),
245		"left" => Some(PtzDirection::Left),
246		"right" => Some(PtzDirection::Right),
247		_ => None,
248	}
249}
250
251fn parse_control_action(camera: &str, action: &str, payload: &str) -> Option<ControlCommand> {
252	// Every control topic is also the dispatcher's reply topic (`OK` /
253	// `FAIL` published back on the same `control/...`). Closed-payload
254	// actions (`floodlight`, `led`, `ir`, `pir`, `siren`, `zoom`,
255	// `wakeup`, `ptz`, ...) reject those tokens at the per-arm parser
256	// below, but **`reboot` accepts ANY payload**, so the dispatcher's
257	// own `OK` reply re-parses as a fresh `Reboot` and an unbounded
258	// loop hammers the camera. Filtering at the top of
259	// `parse_control_action` guards every present and future open-
260	// payload action by default. Mirrors the same guard on the query
261	// branches in `parse_control_message`.
262	if is_reserved_reply_token(payload) {
263		return None;
264	}
265	match action {
266		"floodlight" => {
267			let state = parse_on_off(payload)?;
268			Some(ControlCommand::Floodlight {
269				camera: camera.to_string(),
270				state,
271			})
272		}
273		"floodlight_tasks" => {
274			let state = parse_on_off(payload)?;
275			Some(ControlCommand::FloodlightTasks {
276				camera: camera.to_string(),
277				state,
278			})
279		}
280		"led" => {
281			let state = parse_on_off(payload)?;
282			Some(ControlCommand::Led {
283				camera: camera.to_string(),
284				state,
285			})
286		}
287		"ir" => {
288			let mode = parse_ir_mode(payload)?;
289			Some(ControlCommand::Ir {
290				camera: camera.to_string(),
291				mode,
292			})
293		}
294		"pir" => {
295			let state = parse_on_off(payload)?;
296			Some(ControlCommand::Pir {
297				camera: camera.to_string(),
298				state,
299			})
300		}
301		"reboot" => {
302			// Reboot takes no meaningful payload; accept empty or any value
303			Some(ControlCommand::Reboot {
304				camera: camera.to_string(),
305			})
306		}
307		"ptz" => {
308			// Payload format: "{direction}" or "{direction} {amount}"
309			let parts: Vec<&str> = payload.split_whitespace().collect();
310			if parts.is_empty() {
311				return None;
312			}
313			let direction = parse_ptz_direction(parts[0])?;
314			let amount = if parts.len() >= 2 {
315				parts[1].parse::<f32>().ok()?
316			} else {
317				32.0 // default amount
318			};
319			// Validate amount range
320			if !amount.is_finite() || amount < 0.0 {
321				return None;
322			}
323			Some(ControlCommand::Ptz {
324				camera: camera.to_string(),
325				direction,
326				amount,
327			})
328		}
329		"zoom" => {
330			let level = payload.parse::<f32>().ok()?;
331			if !level.is_finite() || level < 0.0 {
332				return None;
333			}
334			Some(ControlCommand::Zoom {
335				camera: camera.to_string(),
336				level,
337			})
338		}
339		"siren" => {
340			let state = parse_on_off(payload)?;
341			Some(ControlCommand::Siren {
342				camera: camera.to_string(),
343				state,
344			})
345		}
346		"wakeup" => {
347			let minutes = payload.parse::<u32>().ok()?;
348			if minutes == 0 || minutes > 1440 {
349				return None;
350			}
351			Some(ControlCommand::Wakeup {
352				camera: camera.to_string(),
353				minutes,
354			})
355		}
356		_ => None,
357	}
358}
359
360fn parse_query_action(camera: &str, action: &str) -> Option<ControlCommand> {
361	match action {
362		"battery" => Some(ControlCommand::QueryBattery {
363			camera: camera.to_string(),
364		}),
365		"preview" => Some(ControlCommand::QueryPreview {
366			camera: camera.to_string(),
367		}),
368		"pir" => Some(ControlCommand::QueryPir {
369			camera: camera.to_string(),
370		}),
371		_ => None,
372	}
373}
374
375fn parse_control_ptz_sub(camera: &str, sub: &str, payload: &str) -> Option<ControlCommand> {
376	match sub {
377		"preset" => {
378			// Numeric → PtzPreset (back-compat with neolink); anything
379			// else → PtzPresetByName, which the dispatcher resolves
380			// against the camera's preset cache. HA's mqtt-discovered
381			// `select` entity always emits the option label, so the
382			// name path is the live one.
383			if let Ok(preset_id) = payload.parse::<u8>() {
384				return Some(ControlCommand::PtzPreset {
385					camera: camera.to_string(),
386					preset_id,
387				});
388			}
389			let name = payload.trim();
390			if name.is_empty() {
391				return None;
392			}
393			// `control/<topic>` is *also* the reply topic for the
394			// dispatcher's `OK`/`FAIL` convention. For closed-payload
395			// commands the reply doesn't reparse; for the open-name
396			// preset variant it would, producing an infinite
397			// self-loop ("OK" reply → reparsed as PtzPresetByName{
398			// name="OK"} → dispatch warns + publishes another "OK"
399			// → ...). Filter the two reserved reply tokens at the
400			// parse step so the loop can't even start. No real preset
401			// is named "OK" or "FAIL".
402			if is_reserved_reply_token(name) {
403				return None;
404			}
405			Some(ControlCommand::PtzPresetByName {
406				camera: camera.to_string(),
407				name: name.to_string(),
408			})
409		}
410		"assign" => {
411			// Payload format: "{id} {name}" (space-separated)
412			let (id_str, name) = payload.split_once(' ')?;
413			let preset_id = id_str.parse::<u8>().ok()?;
414			let name = name.trim();
415			if name.is_empty() {
416				return None;
417			}
418			Some(ControlCommand::PtzAssign {
419				camera: camera.to_string(),
420				preset_id,
421				name: name.to_string(),
422			})
423		}
424		_ => None,
425	}
426}
427
428fn parse_query_ptz_sub(camera: &str, sub: &str) -> Option<ControlCommand> {
429	match sub {
430		"preset" => Some(ControlCommand::QueryPtzPreset {
431			camera: camera.to_string(),
432		}),
433		_ => None,
434	}
435}