1use std::str;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum PtzDirection {
13 Up,
14 Down,
15 Left,
16 Right,
17}
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum IrMode {
22 On,
23 Off,
24 Auto,
25}
26
27#[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 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 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 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 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
154pub fn parse_control_message(prefix: &str, topic: &str, payload: &[u8]) -> Option<ControlCommand> {
162 let parts: Vec<&str> = topic.split('/').collect();
163
164 if parts.len() < 3 || parts[0] != prefix {
168 return None;
169 }
170
171 let camera = parts[1].to_string();
172
173 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 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 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 Some(ControlCommand::Reboot {
304 camera: camera.to_string(),
305 })
306 }
307 "ptz" => {
308 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 };
319 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 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 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 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}