1use std::collections::HashMap;
10
11use serde_json::{Map, Value, json};
12use tracing::debug;
13
14use super::Controller;
15use super::support::require_session;
16use crate::command::requests::{ApplyPortEntry, ApplyPortsRequest};
17use crate::core_error::CoreError;
18use crate::model::{
19 MacAddress, PoeMode, PortMode, PortProfile, PortSpeedSetting, PortState, StpState,
20};
21
22#[derive(Debug, Default, Clone)]
26pub struct PortProfileUpdate {
27 pub name: Option<String>,
29 pub mode: Option<PortMode>,
31 pub native_network_id: Option<String>,
33 pub tagged_network_ids: Option<Vec<String>>,
36 pub poe_mode: Option<PoeMode>,
38 pub speed_setting: Option<PortSpeedSetting>,
40}
41
42impl Controller {
43 pub async fn list_device_ports(
48 &self,
49 device_mac: &MacAddress,
50 ) -> Result<Vec<PortProfile>, CoreError> {
51 let guard = self.inner.session_client.lock().await;
52 let session = require_session(guard.as_ref())?;
53
54 let device = session
55 .get_device(device_mac.as_str())
56 .await?
57 .ok_or_else(|| CoreError::DeviceNotFound {
58 identifier: device_mac.to_string(),
59 })?;
60
61 let network_lookup = build_network_lookup(&session.list_network_conf().await?);
62
63 let overrides = device
64 .extra
65 .get("port_overrides")
66 .and_then(Value::as_array)
67 .cloned()
68 .unwrap_or_default();
69 let port_table = device
70 .extra
71 .get("port_table")
72 .and_then(Value::as_array)
73 .cloned()
74 .unwrap_or_default();
75
76 let override_map: HashMap<u32, Value> = overrides
79 .iter()
80 .filter_map(|o| port_idx(o).map(|idx| (idx, o.clone())))
81 .collect();
82
83 let mut profiles: Vec<PortProfile> = if port_table.is_empty() {
86 overrides
87 .iter()
88 .filter_map(|o| {
89 let idx = port_idx(o)?;
90 Some(build_profile(idx, None, Some(o), &network_lookup))
91 })
92 .collect()
93 } else {
94 port_table
95 .iter()
96 .filter_map(|row| {
97 let idx = port_idx(row)?;
98 Some(build_profile(
99 idx,
100 Some(row),
101 override_map.get(&idx),
102 &network_lookup,
103 ))
104 })
105 .collect()
106 };
107
108 profiles.sort_by_key(|p| p.index);
109 Ok(profiles)
110 }
111
112 pub async fn resolve_network_session_id(
118 &self,
119 identifier: &str,
120 ) -> Result<(String, Option<u16>), CoreError> {
121 let guard = self.inner.session_client.lock().await;
122 let session = require_session(guard.as_ref())?;
123
124 let records = session.list_network_conf().await?;
125
126 if let Some(hit) = records.iter().find(|rec| {
128 rec.get("_id")
129 .and_then(Value::as_str)
130 .is_some_and(|id| id == identifier)
131 }) {
132 return Ok((identifier.to_owned(), parse_vlan_id(hit)));
133 }
134
135 let matches: Vec<&Value> = records
136 .iter()
137 .filter(|rec| {
138 rec.get("name")
139 .and_then(Value::as_str)
140 .is_some_and(|name| name.eq_ignore_ascii_case(identifier))
141 })
142 .collect();
143
144 match matches.len() {
145 0 => Err(CoreError::NetworkNotFound {
146 identifier: identifier.to_owned(),
147 }),
148 1 => {
149 let rec = matches[0];
150 let id = rec
151 .get("_id")
152 .and_then(Value::as_str)
153 .ok_or_else(|| CoreError::NetworkNotFound {
154 identifier: identifier.to_owned(),
155 })?
156 .to_owned();
157 Ok((id, parse_vlan_id(rec)))
158 }
159 _ => Err(CoreError::ValidationFailed {
160 message: format!(
161 "network name {identifier:?} is ambiguous ({} matches); specify the session _id instead",
162 matches.len()
163 ),
164 }),
165 }
166 }
167
168 pub async fn update_device_port(
171 &self,
172 device_mac: &MacAddress,
173 port_idx_target: u32,
174 update: &PortProfileUpdate,
175 ) -> Result<(), CoreError> {
176 if port_idx_target == 0 {
177 return Err(CoreError::ValidationFailed {
178 message: "port index must be 1-based (UniFi switches number ports starting at 1)"
179 .to_owned(),
180 });
181 }
182 let guard = self.inner.session_client.lock().await;
183 let session = require_session(guard.as_ref())?;
184
185 let device = session
186 .get_device(device_mac.as_str())
187 .await?
188 .ok_or_else(|| CoreError::DeviceNotFound {
189 identifier: device_mac.to_string(),
190 })?;
191
192 let known_ports: Vec<u32> = device
197 .extra
198 .get("port_table")
199 .and_then(Value::as_array)
200 .map(|table| table.iter().filter_map(port_idx).collect())
201 .unwrap_or_default();
202 if !known_ports.is_empty() && !known_ports.contains(&port_idx_target) {
203 let max = known_ports.iter().max().copied().unwrap_or(0);
204 return Err(CoreError::ValidationFailed {
205 message: format!(
206 "port {port_idx_target} does not exist on this device (valid range 1..={max})"
207 ),
208 });
209 }
210
211 let mut overrides: Vec<Value> = device
212 .extra
213 .get("port_overrides")
214 .and_then(Value::as_array)
215 .cloned()
216 .unwrap_or_default();
217
218 let slot = overrides
219 .iter_mut()
220 .find(|entry| port_idx(entry) == Some(port_idx_target));
221
222 let existing = slot.as_ref().map(|value| match value {
223 Value::Object(map) => map.clone(),
224 _ => Map::new(),
225 });
226 let mut next = existing.unwrap_or_default();
227 next.insert("port_idx".into(), json!(port_idx_target));
228 apply_update(&mut next, update);
229
230 match slot {
231 Some(entry) => *entry = Value::Object(next),
232 None => overrides.push(Value::Object(next)),
233 }
234
235 debug!(port_idx_target, "updating port_overrides");
236 session
237 .update_device_port_overrides(device.id.as_str(), overrides)
238 .await?;
239 Ok(())
240 }
241
242 pub async fn apply_device_ports(
253 &self,
254 device_mac: &MacAddress,
255 request: &ApplyPortsRequest,
256 ) -> Result<ApplyPortsSummary, CoreError> {
257 let guard = self.inner.session_client.lock().await;
258 let session = require_session(guard.as_ref())?;
259
260 let device = session
263 .get_device(device_mac.as_str())
264 .await?
265 .ok_or_else(|| CoreError::DeviceNotFound {
266 identifier: device_mac.to_string(),
267 })?;
268 let networks = session.list_network_conf().await?;
269
270 let mut ops: Vec<(u32, EntryOp)> = Vec::with_capacity(request.ports.len());
279 for entry in &request.ports {
280 let op = if entry.reset {
281 EntryOp::Reset
282 } else if entry_is_empty_patch(entry) {
283 continue;
284 } else {
285 EntryOp::Update(entry_to_update(entry, &networks)?)
286 };
287 ops.push((entry.index, op));
288 }
289
290 let mut overrides: Vec<Value> = device
291 .extra
292 .get("port_overrides")
293 .and_then(Value::as_array)
294 .cloned()
295 .unwrap_or_default();
296
297 let mut summary = ApplyPortsSummary::default();
298 for (port_idx_target, op) in ops {
299 match op {
300 EntryOp::Reset => {
301 let before = overrides.len();
302 overrides.retain(|entry| port_idx(entry) != Some(port_idx_target));
303 if overrides.len() != before {
304 summary.reset += 1;
305 }
306 }
307 EntryOp::Update(update) => {
308 let slot = overrides
309 .iter_mut()
310 .find(|entry| port_idx(entry) == Some(port_idx_target));
311 let existing = slot.as_ref().map(|value| match value {
312 Value::Object(map) => map.clone(),
313 _ => Map::new(),
314 });
315 let mut next = existing.unwrap_or_default();
316 next.insert("port_idx".into(), json!(port_idx_target));
317 apply_update(&mut next, &update);
318 match slot {
319 Some(entry) => *entry = Value::Object(next),
320 None => overrides.push(Value::Object(next)),
321 }
322 summary.applied += 1;
323 }
324 }
325 }
326
327 debug!(
328 device = %device_mac,
329 applied = summary.applied,
330 reset = summary.reset,
331 "applying batch port_overrides",
332 );
333 session
334 .update_device_port_overrides(device.id.as_str(), overrides)
335 .await?;
336 Ok(summary)
337 }
338}
339
340#[derive(Debug, Default, Clone, Copy)]
342pub struct ApplyPortsSummary {
343 pub applied: usize,
345 pub reset: usize,
347}
348
349impl Controller {
350 pub async fn export_device_ports(
361 &self,
362 device_mac: &MacAddress,
363 include_all: bool,
364 ) -> Result<ApplyPortsRequest, CoreError> {
365 let guard = self.inner.session_client.lock().await;
366 let session = require_session(guard.as_ref())?;
367
368 let device = session
369 .get_device(device_mac.as_str())
370 .await?
371 .ok_or_else(|| CoreError::DeviceNotFound {
372 identifier: device_mac.to_string(),
373 })?;
374
375 let overrides: Vec<Value> = device
376 .extra
377 .get("port_overrides")
378 .and_then(Value::as_array)
379 .cloned()
380 .unwrap_or_default();
381
382 let mut entries: Vec<ApplyPortEntry> = overrides
383 .iter()
384 .filter_map(|raw| port_idx(raw).map(|idx| override_to_entry(idx, raw)))
385 .collect();
386
387 if include_all {
388 let covered: std::collections::HashSet<u32> = entries.iter().map(|e| e.index).collect();
389 let port_table: Vec<Value> = device
390 .extra
391 .get("port_table")
392 .and_then(Value::as_array)
393 .cloned()
394 .unwrap_or_default();
395 for row in &port_table {
396 if let Some(idx) = port_idx(row)
397 && !covered.contains(&idx)
398 {
399 entries.push(ApplyPortEntry {
400 index: idx,
401 name: row.get("name").and_then(Value::as_str).map(str::to_owned),
402 ..ApplyPortEntry::default()
403 });
404 }
405 }
406 }
407
408 entries.sort_by_key(|e| e.index);
409 Ok(ApplyPortsRequest { ports: entries })
410 }
411}
412
413fn override_to_entry(index: u32, raw: &Value) -> ApplyPortEntry {
414 let name = raw
415 .get("name")
416 .and_then(Value::as_str)
417 .filter(|s| !s.is_empty())
418 .map(str::to_owned);
419
420 let op_mode = raw.get("op_mode").and_then(Value::as_str);
421 let tagged_mgmt = raw.get("tagged_vlan_mgmt").and_then(Value::as_str);
422 let mode = match (op_mode, tagged_mgmt) {
423 (Some("mirror"), _) => Some("mirror".to_owned()),
424 (Some("switch") | None, Some("block_all")) => Some("access".to_owned()),
425 (Some("switch") | None, Some("auto" | "custom")) => Some("trunk".to_owned()),
426 _ => None,
427 };
428
429 let native_network_id = raw
430 .get("native_networkconf_id")
431 .and_then(Value::as_str)
432 .filter(|s| !s.is_empty())
433 .map(str::to_owned);
434
435 let tagged_list: Option<Vec<String>> = raw
436 .get("tagged_networkconf_ids")
437 .and_then(Value::as_array)
438 .map(|arr| {
439 arr.iter()
440 .filter_map(|v| v.as_str().map(str::to_owned))
441 .collect()
442 });
443 let tagged_network_ids = if tagged_mgmt == Some("custom") {
451 tagged_list
452 } else {
453 tagged_list.filter(|list| !list.is_empty())
454 };
455
456 let tagged_all = if mode.as_deref() == Some("trunk") && tagged_mgmt == Some("auto") {
457 Some(true)
458 } else {
459 None
460 };
461
462 let poe = raw
463 .get("poe_mode")
464 .and_then(Value::as_str)
465 .filter(|s| !s.is_empty())
466 .map(str::to_owned);
467
468 let speed = match (
472 raw.get("autoneg").and_then(Value::as_bool),
473 raw.get("speed").and_then(Value::as_str),
474 ) {
475 (Some(false), Some(s)) if !s.is_empty() => Some(s.to_owned()),
476 _ => None,
477 };
478
479 ApplyPortEntry {
480 index,
481 name,
482 mode,
483 native_network_id,
484 tagged_network_ids,
485 tagged_all,
486 poe,
487 speed,
488 reset: false,
489 }
490}
491
492enum EntryOp {
493 Reset,
494 Update(PortProfileUpdate),
495}
496
497fn entry_is_empty_patch(entry: &ApplyPortEntry) -> bool {
500 entry.name.is_none()
501 && entry.mode.is_none()
502 && entry.native_network_id.is_none()
503 && entry.tagged_network_ids.is_none()
504 && entry.tagged_all.is_none()
505 && entry.poe.is_none()
506 && entry.speed.is_none()
507 && !entry.reset
508}
509
510fn entry_to_update(
511 entry: &ApplyPortEntry,
512 networks: &[Value],
513) -> Result<PortProfileUpdate, CoreError> {
514 if let (Some(true), Some(list)) = (entry.tagged_all, entry.tagged_network_ids.as_deref())
515 && !list.is_empty()
516 {
517 return Err(CoreError::ValidationFailed {
518 message: format!(
519 "port {}: tagged_all=true conflicts with a non-empty tagged_network_ids list",
520 entry.index
521 ),
522 });
523 }
524
525 let mode = entry
526 .mode
527 .as_deref()
528 .map(parse_apply_mode)
529 .transpose()
530 .map_err(|e| context_err(entry.index, e))?;
531
532 let mode = if entry.tagged_all == Some(true) {
535 Some(PortMode::Trunk)
536 } else {
537 mode
538 };
539
540 let native_network_id = entry
541 .native_network_id
542 .as_deref()
543 .map(|id| resolve_network_to_id(id, networks))
544 .transpose()
545 .map_err(|e| context_err(entry.index, e))?;
546
547 let tagged_network_ids = if entry.tagged_all == Some(true) {
548 None
549 } else if let Some(list) = entry.tagged_network_ids.as_deref() {
550 let resolved: Result<Vec<String>, _> = list
551 .iter()
552 .map(|id| resolve_network_to_id(id, networks))
553 .collect();
554 Some(resolved.map_err(|e| context_err(entry.index, e))?)
555 } else {
556 None
557 };
558
559 let poe_mode = entry
560 .poe
561 .as_deref()
562 .map(parse_apply_poe)
563 .transpose()
564 .map_err(|e| context_err(entry.index, e))?;
565
566 let speed_setting = entry
567 .speed
568 .as_deref()
569 .map(parse_apply_speed)
570 .transpose()
571 .map_err(|e| context_err(entry.index, e))?;
572
573 Ok(PortProfileUpdate {
574 name: entry.name.clone(),
575 mode,
576 native_network_id,
577 tagged_network_ids,
578 poe_mode,
579 speed_setting,
580 })
581}
582
583fn context_err(port_index: u32, err: CoreError) -> CoreError {
584 if let CoreError::ValidationFailed { message } = &err {
585 CoreError::ValidationFailed {
586 message: format!("port {port_index}: {message}"),
587 }
588 } else {
589 err
590 }
591}
592
593fn resolve_network_to_id(identifier: &str, networks: &[Value]) -> Result<String, CoreError> {
594 if networks
595 .iter()
596 .any(|r| r.get("_id").and_then(Value::as_str) == Some(identifier))
597 {
598 return Ok(identifier.to_owned());
599 }
600 let matches: Vec<&Value> = networks
601 .iter()
602 .filter(|r| {
603 r.get("name")
604 .and_then(Value::as_str)
605 .is_some_and(|n| n.eq_ignore_ascii_case(identifier))
606 })
607 .collect();
608 match matches.len() {
609 0 => Err(CoreError::NetworkNotFound {
610 identifier: identifier.to_owned(),
611 }),
612 1 => matches[0]
613 .get("_id")
614 .and_then(Value::as_str)
615 .map(str::to_owned)
616 .ok_or_else(|| CoreError::NetworkNotFound {
617 identifier: identifier.to_owned(),
618 }),
619 _ => Err(CoreError::ValidationFailed {
620 message: format!(
621 "network name {identifier:?} is ambiguous ({} matches); specify the session _id instead",
622 matches.len()
623 ),
624 }),
625 }
626}
627
628fn parse_apply_mode(raw: &str) -> Result<PortMode, CoreError> {
629 match raw {
630 "access" => Ok(PortMode::Access),
631 "trunk" => Ok(PortMode::Trunk),
632 "mirror" => Ok(PortMode::Mirror),
633 _ => Err(CoreError::ValidationFailed {
634 message: format!("invalid mode {raw:?}, expected access | trunk | mirror"),
635 }),
636 }
637}
638
639fn parse_apply_poe(raw: &str) -> Result<PoeMode, CoreError> {
640 match raw {
641 "on" | "auto" => Ok(PoeMode::Auto),
642 "off" => Ok(PoeMode::Off),
643 "pasv24" => Ok(PoeMode::Passive24V),
644 "passthrough" => Ok(PoeMode::Passthrough),
645 _ => Err(CoreError::ValidationFailed {
646 message: format!(
647 "invalid poe {raw:?}, expected on | off | auto | pasv24 | passthrough"
648 ),
649 }),
650 }
651}
652
653fn parse_apply_speed(raw: &str) -> Result<PortSpeedSetting, CoreError> {
654 match raw {
655 "auto" => Ok(PortSpeedSetting::Auto),
656 "10" => Ok(PortSpeedSetting::Mbps10),
657 "100" => Ok(PortSpeedSetting::Mbps100),
658 "1000" => Ok(PortSpeedSetting::Mbps1000),
659 "2500" => Ok(PortSpeedSetting::Mbps2500),
660 "5000" => Ok(PortSpeedSetting::Mbps5000),
661 "10000" => Ok(PortSpeedSetting::Mbps10000),
662 _ => Err(CoreError::ValidationFailed {
663 message: format!(
664 "invalid speed {raw:?}, expected auto | 10 | 100 | 1000 | 2500 | 5000 | 10000"
665 ),
666 }),
667 }
668}
669
670fn port_idx(value: &Value) -> Option<u32> {
673 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
674 value
675 .get("port_idx")
676 .and_then(Value::as_u64)
677 .map(|v| v as u32)
678}
679
680fn parse_vlan_id(rec: &Value) -> Option<u16> {
681 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
682 rec.get("vlan")
683 .and_then(|v| {
684 v.as_u64()
685 .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
686 })
687 .map(|v| v as u16)
688}
689
690struct NetworkLookup {
691 by_id: HashMap<String, NetworkInfo>,
692}
693
694struct NetworkInfo {
695 name: Option<String>,
696 vlan_id: Option<u16>,
697}
698
699fn build_network_lookup(records: &[Value]) -> NetworkLookup {
700 let mut by_id = HashMap::new();
701 for rec in records {
702 let Some(id) = rec.get("_id").and_then(Value::as_str) else {
703 continue;
704 };
705 by_id.insert(
706 id.to_owned(),
707 NetworkInfo {
708 name: rec.get("name").and_then(Value::as_str).map(str::to_owned),
709 vlan_id: parse_vlan_id(rec),
710 },
711 );
712 }
713 NetworkLookup { by_id }
714}
715
716impl NetworkLookup {
717 fn name(&self, id: &str) -> Option<String> {
718 self.by_id.get(id).and_then(|n| n.name.clone())
719 }
720 fn vlan(&self, id: &str) -> Option<u16> {
721 self.by_id.get(id).and_then(|n| n.vlan_id)
722 }
723}
724
725fn build_profile(
726 index: u32,
727 row: Option<&Value>,
728 override_: Option<&Value>,
729 networks: &NetworkLookup,
730) -> PortProfile {
731 let link_state = row
732 .and_then(|r| r.get("up"))
733 .and_then(Value::as_bool)
734 .map_or(PortState::Unknown, |up| {
735 if up { PortState::Up } else { PortState::Down }
736 });
737
738 let name = first_string(&[override_, row], "name");
739
740 let native_network_id =
741 first_string(&[override_, row], "native_networkconf_id").filter(|s| !s.is_empty());
742 let tagged_network_ids = first_array(&[override_, row], "tagged_networkconf_ids")
743 .cloned()
744 .unwrap_or_default()
745 .iter()
746 .filter_map(|v| v.as_str().map(str::to_owned))
747 .collect::<Vec<_>>();
748
749 let tagged_vlan_mgmt = first_string(&[override_, row], "tagged_vlan_mgmt");
750 let op_mode = first_string(&[override_, row], "op_mode");
751 let tagged_all = tagged_vlan_mgmt.as_deref() == Some("auto");
752
753 let mode = classify_mode(
754 op_mode.as_deref(),
755 tagged_vlan_mgmt.as_deref(),
756 &tagged_network_ids,
757 );
758
759 let native_vlan_id = native_network_id
760 .as_deref()
761 .and_then(|id| networks.vlan(id));
762 let native_network_name = native_network_id
763 .as_deref()
764 .and_then(|id| networks.name(id));
765 let tagged_vlan_ids = tagged_network_ids
766 .iter()
767 .filter_map(|id| networks.vlan(id))
768 .collect();
769 let tagged_network_names = tagged_network_ids
770 .iter()
771 .filter_map(|id| networks.name(id))
772 .collect();
773
774 let poe_mode = first_string(&[override_, row], "poe_mode")
775 .as_deref()
776 .map(parse_poe_mode);
777 let speed_setting = parse_speed(
778 first_string(&[override_, row], "speed").as_deref(),
779 first_bool(&[override_, row], "autoneg"),
780 );
781 let link_speed_mbps = row
782 .and_then(|r| r.get("speed"))
783 .and_then(Value::as_u64)
784 .map(|v| {
785 #[allow(clippy::as_conversions, clippy::cast_possible_truncation)]
786 {
787 v as u32
788 }
789 });
790
791 let stp_state = first_string(&[override_, row], "stp_state")
792 .as_deref()
793 .map_or(StpState::Unknown, parse_stp_state);
794
795 let port_profile_id = first_string(&[override_, row], "portconf_id").filter(|s| !s.is_empty());
796
797 PortProfile {
798 index,
799 name,
800 link_state,
801 mode,
802 native_network_id,
803 native_vlan_id,
804 native_network_name,
805 tagged_network_ids,
806 tagged_vlan_ids,
807 tagged_network_names,
808 tagged_all,
809 poe_mode,
810 speed_setting,
811 link_speed_mbps,
812 stp_state,
813 port_profile_id,
814 }
815}
816
817fn first_string(sources: &[Option<&Value>], key: &str) -> Option<String> {
818 sources
819 .iter()
820 .flatten()
821 .find_map(|v| v.get(key).and_then(Value::as_str).map(str::to_owned))
822}
823
824fn first_bool(sources: &[Option<&Value>], key: &str) -> Option<bool> {
825 sources
826 .iter()
827 .flatten()
828 .find_map(|v| v.get(key).and_then(Value::as_bool))
829}
830
831fn first_array<'a>(sources: &[Option<&'a Value>], key: &str) -> Option<&'a Vec<Value>> {
832 sources
833 .iter()
834 .flatten()
835 .find_map(|v| v.get(key).and_then(Value::as_array))
836}
837
838fn classify_mode(
839 op_mode: Option<&str>,
840 tagged_vlan_mgmt: Option<&str>,
841 tagged_ids: &[String],
842) -> PortMode {
843 if op_mode == Some("mirror") {
844 return PortMode::Mirror;
845 }
846 match tagged_vlan_mgmt {
847 Some("block_all") => PortMode::Access,
848 Some("auto" | "custom") => PortMode::Trunk,
849 _ => {
850 if tagged_ids.is_empty() {
851 PortMode::Unknown
852 } else {
853 PortMode::Trunk
854 }
855 }
856 }
857}
858
859fn parse_poe_mode(raw: &str) -> PoeMode {
860 match raw {
861 "auto" => PoeMode::Auto,
862 "off" => PoeMode::Off,
863 "pasv24" => PoeMode::Passive24V,
864 "passthrough" => PoeMode::Passthrough,
865 _ => PoeMode::Other,
866 }
867}
868
869fn parse_speed(raw: Option<&str>, autoneg: Option<bool>) -> Option<PortSpeedSetting> {
870 if autoneg == Some(true) {
871 return Some(PortSpeedSetting::Auto);
872 }
873 match raw {
874 Some("auto") => Some(PortSpeedSetting::Auto),
875 Some("10") => Some(PortSpeedSetting::Mbps10),
876 Some("100") => Some(PortSpeedSetting::Mbps100),
877 Some("1000") => Some(PortSpeedSetting::Mbps1000),
878 Some("2500") => Some(PortSpeedSetting::Mbps2500),
879 Some("5000") => Some(PortSpeedSetting::Mbps5000),
880 Some("10000") => Some(PortSpeedSetting::Mbps10000),
881 None | Some(_) => None,
882 }
883}
884
885fn parse_stp_state(raw: &str) -> StpState {
886 match raw {
887 "disabled" => StpState::Disabled,
888 "blocking" => StpState::Blocking,
889 "listening" => StpState::Listening,
890 "learning" => StpState::Learning,
891 "forwarding" => StpState::Forwarding,
892 "broken" => StpState::Broken,
893 _ => StpState::Unknown,
894 }
895}
896
897fn apply_update(target: &mut Map<String, Value>, update: &PortProfileUpdate) {
898 if let Some(name) = &update.name {
899 target.insert("name".into(), json!(name));
900 }
901
902 if let Some(mode) = update.mode {
903 match mode {
904 PortMode::Access => {
905 target.insert("op_mode".into(), json!("switch"));
906 target.insert("tagged_vlan_mgmt".into(), json!("block_all"));
907 target.insert("tagged_networkconf_ids".into(), json!([]));
908 }
909 PortMode::Trunk => {
910 target.insert("op_mode".into(), json!("switch"));
911 if update.tagged_network_ids.is_some() {
915 target.insert("tagged_vlan_mgmt".into(), json!("custom"));
916 } else {
917 target.insert("tagged_vlan_mgmt".into(), json!("auto"));
918 }
919 }
920 PortMode::Mirror => {
921 target.insert("op_mode".into(), json!("mirror"));
922 }
923 PortMode::Unknown => {}
924 }
925 }
926
927 if let Some(id) = &update.native_network_id {
928 target.insert("native_networkconf_id".into(), json!(id));
929 }
930
931 if let Some(tagged) = &update.tagged_network_ids {
932 target.insert("tagged_networkconf_ids".into(), json!(tagged));
933 if !matches!(update.mode, Some(PortMode::Access | PortMode::Mirror)) {
940 target.insert("tagged_vlan_mgmt".into(), json!("custom"));
941 }
942 }
943
944 if let Some(poe) = update.poe_mode {
945 target.insert(
946 "poe_mode".into(),
947 json!(match poe {
948 PoeMode::Off => "off",
949 PoeMode::Passive24V => "pasv24",
950 PoeMode::Passthrough => "passthrough",
951 PoeMode::Auto | PoeMode::Other => "auto",
952 }),
953 );
954 }
955
956 if let Some(speed) = update.speed_setting {
957 match speed {
958 PortSpeedSetting::Auto => {
959 target.insert("autoneg".into(), json!(true));
964 target.remove("speed");
965 }
966 other => {
967 target.insert("autoneg".into(), json!(false));
968 target.insert(
969 "speed".into(),
970 json!(match other {
971 PortSpeedSetting::Mbps10 => "10",
972 PortSpeedSetting::Mbps100 => "100",
973 PortSpeedSetting::Mbps1000 => "1000",
974 PortSpeedSetting::Mbps2500 => "2500",
975 PortSpeedSetting::Mbps5000 => "5000",
976 PortSpeedSetting::Mbps10000 => "10000",
977 PortSpeedSetting::Auto => "auto",
978 }),
979 );
980 }
981 }
982 }
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 fn sample_networks() -> NetworkLookup {
990 build_network_lookup(&[
991 json!({ "_id": "n1", "name": "infra", "vlan": 10 }),
992 json!({ "_id": "n2", "name": "personal", "vlan": 20 }),
993 ])
994 }
995
996 #[test]
997 fn classify_mode_detects_mirror() {
998 assert_eq!(classify_mode(Some("mirror"), None, &[]), PortMode::Mirror);
999 }
1000
1001 #[test]
1002 fn classify_mode_detects_access_and_trunk() {
1003 assert_eq!(
1004 classify_mode(Some("switch"), Some("block_all"), &[]),
1005 PortMode::Access
1006 );
1007 assert_eq!(
1008 classify_mode(Some("switch"), Some("auto"), &[]),
1009 PortMode::Trunk
1010 );
1011 assert_eq!(
1012 classify_mode(Some("switch"), Some("custom"), &["n2".into()]),
1013 PortMode::Trunk
1014 );
1015 }
1016
1017 #[test]
1018 fn parse_speed_autoneg_beats_explicit() {
1019 assert_eq!(
1020 parse_speed(Some("1000"), Some(true)),
1021 Some(PortSpeedSetting::Auto)
1022 );
1023 assert_eq!(
1024 parse_speed(Some("1000"), Some(false)),
1025 Some(PortSpeedSetting::Mbps1000)
1026 );
1027 }
1028
1029 #[test]
1030 fn build_profile_uses_overrides_before_live_state() {
1031 let row = json!({
1032 "port_idx": 10,
1033 "up": true,
1034 "speed": 1000,
1035 "name": "auto-name",
1036 "tagged_vlan_mgmt": "auto",
1037 "native_networkconf_id": "n1",
1038 "poe_mode": "auto",
1039 "stp_state": "forwarding",
1040 });
1041 let override_ = json!({
1042 "port_idx": 10,
1043 "name": "mac-mini",
1044 "tagged_vlan_mgmt": "custom",
1045 "tagged_networkconf_ids": ["n2"],
1046 "native_networkconf_id": "n1",
1047 "poe_mode": "off",
1048 "autoneg": false,
1049 "speed": "1000",
1050 });
1051 let profile = build_profile(10, Some(&row), Some(&override_), &sample_networks());
1052 assert_eq!(profile.name.as_deref(), Some("mac-mini"));
1053 assert_eq!(profile.mode, PortMode::Trunk);
1054 assert_eq!(profile.native_vlan_id, Some(10));
1055 assert_eq!(profile.native_network_name.as_deref(), Some("infra"));
1056 assert_eq!(profile.tagged_vlan_ids, vec![20]);
1057 assert_eq!(profile.tagged_network_names, vec!["personal"]);
1058 assert_eq!(profile.poe_mode, Some(PoeMode::Off));
1059 assert_eq!(profile.speed_setting, Some(PortSpeedSetting::Mbps1000));
1060 assert_eq!(profile.link_speed_mbps, Some(1000));
1061 assert_eq!(profile.stp_state, StpState::Forwarding);
1062 assert_eq!(profile.link_state, PortState::Up);
1063 }
1064
1065 #[test]
1066 fn apply_update_access_mode_clears_tagged_list() {
1067 let mut target = Map::new();
1068 target.insert("port_idx".into(), json!(10));
1069 target.insert("tagged_networkconf_ids".into(), json!(["old"]));
1070 apply_update(
1071 &mut target,
1072 &PortProfileUpdate {
1073 mode: Some(PortMode::Access),
1074 native_network_id: Some("n1".into()),
1075 ..PortProfileUpdate::default()
1076 },
1077 );
1078 assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("block_all")));
1079 assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!([])));
1080 assert_eq!(target.get("native_networkconf_id"), Some(&json!("n1")));
1081 assert_eq!(target.get("op_mode"), Some(&json!("switch")));
1082 }
1083
1084 #[test]
1085 fn apply_update_trunk_with_tagged_list_marks_custom() {
1086 let mut target = Map::new();
1087 target.insert("port_idx".into(), json!(10));
1088 apply_update(
1089 &mut target,
1090 &PortProfileUpdate {
1091 mode: Some(PortMode::Trunk),
1092 native_network_id: Some("n1".into()),
1093 tagged_network_ids: Some(vec!["n2".into()]),
1094 ..PortProfileUpdate::default()
1095 },
1096 );
1097 assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("custom")));
1098 assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!(["n2"])));
1099 }
1100
1101 #[test]
1102 fn apply_update_tagged_list_alone_marks_custom() {
1103 let mut target = Map::new();
1107 target.insert("port_idx".into(), json!(10));
1108 target.insert("tagged_vlan_mgmt".into(), json!("auto"));
1109 apply_update(
1110 &mut target,
1111 &PortProfileUpdate {
1112 tagged_network_ids: Some(vec!["n2".into()]),
1113 ..PortProfileUpdate::default()
1114 },
1115 );
1116 assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("custom")));
1117 assert_eq!(target.get("tagged_networkconf_ids"), Some(&json!(["n2"])));
1118 }
1119
1120 #[test]
1121 fn apply_update_access_mode_keeps_block_all_even_with_tagged_list() {
1122 let mut target = Map::new();
1125 apply_update(
1126 &mut target,
1127 &PortProfileUpdate {
1128 mode: Some(PortMode::Access),
1129 tagged_network_ids: Some(vec!["n2".into()]),
1130 ..PortProfileUpdate::default()
1131 },
1132 );
1133 assert_eq!(target.get("tagged_vlan_mgmt"), Some(&json!("block_all")));
1134 }
1135
1136 #[test]
1137 fn apply_update_speed_fixed_disables_autoneg() {
1138 let mut target = Map::new();
1139 apply_update(
1140 &mut target,
1141 &PortProfileUpdate {
1142 speed_setting: Some(PortSpeedSetting::Mbps2500),
1143 ..PortProfileUpdate::default()
1144 },
1145 );
1146 assert_eq!(target.get("autoneg"), Some(&json!(false)));
1147 assert_eq!(target.get("speed"), Some(&json!("2500")));
1148 }
1149
1150 #[test]
1155 fn apply_update_speed_auto_omits_speed_field() {
1156 let mut target = Map::new();
1157 target.insert("speed".into(), json!("1000"));
1158 apply_update(
1159 &mut target,
1160 &PortProfileUpdate {
1161 speed_setting: Some(PortSpeedSetting::Auto),
1162 ..PortProfileUpdate::default()
1163 },
1164 );
1165 assert_eq!(target.get("autoneg"), Some(&json!(true)));
1166 assert_eq!(target.get("speed"), None);
1167 }
1168
1169 #[test]
1170 fn override_to_entry_round_trips_basic_fields() {
1171 let raw = json!({
1172 "port_idx": 1,
1173 "name": "uplink",
1174 "op_mode": "switch",
1175 "tagged_vlan_mgmt": "auto",
1176 "native_networkconf_id": "n1",
1177 "poe_mode": "auto",
1178 "autoneg": true,
1179 });
1180 let entry = override_to_entry(1, &raw);
1181 assert_eq!(entry.index, 1);
1182 assert_eq!(entry.name.as_deref(), Some("uplink"));
1183 assert_eq!(entry.mode.as_deref(), Some("trunk"));
1184 assert_eq!(entry.native_network_id.as_deref(), Some("n1"));
1185 assert_eq!(entry.tagged_all, Some(true));
1186 assert_eq!(entry.poe.as_deref(), Some("auto"));
1187 assert!(entry.speed.is_none());
1189 }
1190
1191 #[test]
1192 fn override_to_entry_pinned_speed_emits_value() {
1193 let raw = json!({
1194 "port_idx": 4,
1195 "autoneg": false,
1196 "speed": "1000",
1197 });
1198 let entry = override_to_entry(4, &raw);
1199 assert_eq!(entry.speed.as_deref(), Some("1000"));
1200 }
1201
1202 #[test]
1203 fn override_to_entry_access_mode_from_block_all() {
1204 let raw = json!({
1205 "port_idx": 2,
1206 "op_mode": "switch",
1207 "tagged_vlan_mgmt": "block_all",
1208 });
1209 let entry = override_to_entry(2, &raw);
1210 assert_eq!(entry.mode.as_deref(), Some("access"));
1211 assert!(entry.tagged_all.is_none());
1212 }
1213
1214 #[test]
1215 fn override_to_entry_preserves_empty_custom_trunk_tagged_list() {
1216 let raw = json!({
1220 "port_idx": 5,
1221 "op_mode": "switch",
1222 "tagged_vlan_mgmt": "custom",
1223 "native_networkconf_id": "n1",
1224 "tagged_networkconf_ids": []
1225 });
1226 let entry = override_to_entry(5, &raw);
1227 assert_eq!(entry.mode.as_deref(), Some("trunk"));
1228 assert_eq!(entry.tagged_network_ids.as_deref(), Some(&[][..]));
1229 assert!(entry.tagged_all.is_none());
1230 }
1231
1232 #[test]
1233 fn override_to_entry_drops_empty_list_for_auto_trunks() {
1234 let raw = json!({
1238 "port_idx": 6,
1239 "op_mode": "switch",
1240 "tagged_vlan_mgmt": "auto",
1241 "tagged_networkconf_ids": []
1242 });
1243 let entry = override_to_entry(6, &raw);
1244 assert!(entry.tagged_network_ids.is_none());
1245 assert_eq!(entry.tagged_all, Some(true));
1246 }
1247
1248 #[test]
1249 fn entry_to_update_rejects_invalid_strings() {
1250 let networks: Vec<Value> = vec![];
1251 let entry = ApplyPortEntry {
1252 index: 1,
1253 mode: Some("bogus".into()),
1254 ..ApplyPortEntry::default()
1255 };
1256 let err = entry_to_update(&entry, &networks).expect_err("invalid mode should error");
1257 assert!(matches!(err, CoreError::ValidationFailed { .. }));
1258 }
1259
1260 #[test]
1261 fn entry_is_empty_patch_skips_bare_index_entries() {
1262 let bare = ApplyPortEntry {
1263 index: 4,
1264 ..ApplyPortEntry::default()
1265 };
1266 assert!(entry_is_empty_patch(&bare));
1267
1268 let with_name = ApplyPortEntry {
1269 index: 4,
1270 name: Some("foo".into()),
1271 ..ApplyPortEntry::default()
1272 };
1273 assert!(!entry_is_empty_patch(&with_name));
1274
1275 let reset = ApplyPortEntry {
1276 index: 4,
1277 reset: true,
1278 ..ApplyPortEntry::default()
1279 };
1280 assert!(
1281 !entry_is_empty_patch(&reset),
1282 "reset is a real instruction, not an empty patch"
1283 );
1284
1285 let cleared_tagged = ApplyPortEntry {
1286 index: 4,
1287 tagged_network_ids: Some(vec![]),
1288 ..ApplyPortEntry::default()
1289 };
1290 assert!(
1291 !entry_is_empty_patch(&cleared_tagged),
1292 "Some(vec![]) is the explicit clear instruction"
1293 );
1294 }
1295
1296 #[test]
1297 fn entry_to_update_rejects_tagged_all_with_non_empty_list() {
1298 let networks: Vec<Value> = vec![json!({ "_id": "n1", "name": "infra" })];
1299 let entry = ApplyPortEntry {
1300 index: 1,
1301 tagged_all: Some(true),
1302 tagged_network_ids: Some(vec!["infra".into()]),
1303 ..ApplyPortEntry::default()
1304 };
1305 let err = entry_to_update(&entry, &networks).expect_err("conflict should error");
1306 assert!(matches!(err, CoreError::ValidationFailed { .. }));
1307 }
1308}