asic_rs/miners/factory/
mod.rs

1mod commands;
2mod hardware;
3mod model;
4mod traits;
5
6use anyhow::Result;
7use futures::future::FutureExt;
8use futures::{Stream, StreamExt, pin_mut, stream};
9use ipnet::IpNet;
10use rand::seq::SliceRandom;
11use reqwest::StatusCode;
12use reqwest::header::HeaderMap;
13use std::collections::HashSet;
14use std::net::IpAddr;
15use std::net::Ipv4Addr;
16use std::str::FromStr;
17use std::time::Duration;
18use tokio::net::TcpStream;
19use tokio::task::JoinSet;
20use tokio::time::timeout;
21
22use super::commands::MinerCommand;
23use super::util::{send_rpc_command, send_web_command};
24use crate::data::device::{MinerFirmware, MinerMake, MinerModel};
25use crate::miners::backends::antminer::AntMiner;
26use crate::miners::backends::avalonminer::AvalonMiner;
27use crate::miners::backends::bitaxe::BitAxe;
28use crate::miners::backends::epic::PowerPlay;
29use crate::miners::backends::marathon::Marathon;
30use crate::miners::backends::traits::{GetMinerData, MinerConstructor};
31use crate::miners::backends::vnish::Vnish;
32use crate::miners::backends::whatsminer::WhatsMiner;
33use crate::miners::factory::traits::VersionSelection;
34use std::net::SocketAddr;
35use traits::{DiscoveryCommands, ModelSelection};
36
37const IDENTIFICATION_TIMEOUT: Duration = Duration::from_secs(10);
38const CONNECTIVITY_TIMEOUT: Duration = Duration::from_secs(1);
39const CONNECTIVITY_RETRIES: u32 = 3;
40
41fn calculate_optimal_concurrency(ip_count: usize) -> usize {
42    // Adaptive concurrency based on scale
43    match ip_count {
44        0..=100 => 100,      // Small networks - conservative
45        101..=1000 => 250,   // Medium networks - moderate
46        1001..=5000 => 500,  // Large networks - aggressive
47        5001..=10000 => 750, // Very large networks - high throughput
48        _ => 1000,           // Massive mining operations - maximum throughput
49    }
50}
51
52/// Fast port connectivity check with TCP optimizations
53async fn check_port_open(ip: IpAddr, port: u16, connectivity_timeout: Duration) -> bool {
54    let addr: SocketAddr = (ip, port).into();
55
56    let stream = match timeout(connectivity_timeout, TcpStream::connect(addr)).await {
57        Ok(Ok(stream)) => stream,
58        _ => return false,
59    };
60
61    // disable Nagle's algorithm for immediate transmission
62    let _ = stream.set_nodelay(true);
63
64    // immediate close without waiting for lingering data
65    let _ = stream.set_linger(Some(Duration::from_secs(0)));
66
67    true
68}
69
70async fn get_miner_type_from_command(
71    ip: IpAddr,
72    command: MinerCommand,
73) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
74    match command {
75        MinerCommand::RPC {
76            command,
77            parameters: _,
78        } => {
79            let response = send_rpc_command(&ip, command).await?;
80            parse_type_from_socket(response)
81        }
82        MinerCommand::WebAPI {
83            command,
84            parameters: _,
85        } => {
86            let response = send_web_command(&ip, command).await?;
87            parse_type_from_web(response)
88        }
89        _ => None,
90    }
91}
92
93fn parse_type_from_socket(
94    response: serde_json::Value,
95) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
96    let json_string = response.to_string().to_uppercase();
97    match () {
98        _ if json_string.contains("BOSMINER") || json_string.contains("BOSER") => {
99            Some((None, Some(MinerFirmware::BraiinsOS)))
100        }
101        _ if json_string.contains("LUXMINER") => Some((None, Some(MinerFirmware::LuxOS))),
102        _ if json_string.contains("MARAFW") || json_string.contains("KAONSU") => {
103            Some((None, Some(MinerFirmware::Marathon)))
104        }
105        _ if json_string.contains("VNISH") => Some((None, Some(MinerFirmware::VNish))),
106        _ if json_string.contains("BITMICRO") || json_string.contains("BTMINER") => {
107            Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
108        }
109        _ if json_string.contains("ANTMINER") => {
110            Some((Some(MinerMake::AntMiner), Some(MinerFirmware::Stock)))
111        }
112        _ if json_string.contains("AVALON") => {
113            Some((Some(MinerMake::AvalonMiner), Some(MinerFirmware::Stock)))
114        }
115        _ => None,
116    }
117}
118
119fn parse_type_from_web(
120    response: (String, HeaderMap, StatusCode),
121) -> Option<(Option<MinerMake>, Option<MinerFirmware>)> {
122    let (resp_text, resp_headers, resp_status) = response;
123    let auth_header = match resp_headers.get("www-authenticate") {
124        Some(header) => header.to_str().unwrap(),
125        None => "",
126    };
127    let algo_header = match resp_headers.get("algorithm") {
128        Some(header) => header.to_str().unwrap(),
129        None => "",
130    };
131    let redirect_header = match resp_headers.get("location") {
132        Some(header) => header.to_str().unwrap(),
133        None => "",
134    };
135    match () {
136        _ if resp_status == 401 && algo_header.contains("MD5") => {
137            Some((None, Some(MinerFirmware::Marathon)))
138        }
139        _ if resp_status == 401 && auth_header.contains("realm=\"antMiner") => {
140            Some((Some(MinerMake::AntMiner), Some(MinerFirmware::Stock)))
141        }
142        _ if resp_text.contains("Braiins OS") => Some((None, Some(MinerFirmware::BraiinsOS))),
143        _ if resp_text.contains("Luxor Firmware") => Some((None, Some(MinerFirmware::LuxOS))),
144        _ if resp_text.contains("AxeOS") => {
145            Some((Some(MinerMake::BitAxe), Some(MinerFirmware::Stock)))
146        }
147        _ if resp_text.contains("Miner Web Dashboard") => Some((None, Some(MinerFirmware::EPic))),
148        _ if resp_text.contains("Avalon") => {
149            Some((Some(MinerMake::AvalonMiner), Some(MinerFirmware::Stock)))
150        }
151        _ if resp_text.contains("AnthillOS") => Some((None, Some(MinerFirmware::VNish))),
152        _ if redirect_header.contains("https://") && resp_status == 307
153            || resp_text.contains("/cgi-bin/luci") =>
154        {
155            Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
156        }
157        _ => None,
158    }
159}
160
161fn select_backend(
162    ip: IpAddr,
163    model: Option<MinerModel>,
164    firmware: Option<MinerFirmware>,
165    version: Option<semver::Version>,
166) -> Option<Box<dyn GetMinerData>> {
167    match (model, firmware) {
168        (Some(MinerModel::WhatsMiner(_)), Some(MinerFirmware::Stock)) => {
169            Some(WhatsMiner::new(ip, model?, version))
170        }
171        (Some(MinerModel::BitAxe(_)), Some(MinerFirmware::Stock)) => {
172            Some(BitAxe::new(ip, model?, version))
173        }
174        (Some(MinerModel::AvalonMiner(_)), Some(MinerFirmware::Stock)) => {
175            Some(AvalonMiner::new(ip, model?, version))
176        }
177        (Some(MinerModel::AntMiner(_)), Some(MinerFirmware::Stock)) => {
178            Some(AntMiner::new(ip, model?, version))
179        }
180        (Some(_), Some(MinerFirmware::VNish)) => Some(Vnish::new(ip, model?, version)),
181        (Some(_), Some(MinerFirmware::EPic)) => Some(PowerPlay::new(ip, model?, version)),
182        (Some(_), Some(MinerFirmware::Marathon)) => Some(Marathon::new(ip, model?, version)),
183        _ => None,
184    }
185}
186
187pub struct MinerFactory {
188    search_makes: Option<Vec<MinerMake>>,
189    search_firmwares: Option<Vec<MinerFirmware>>,
190    ips: Vec<IpAddr>,
191    identification_timeout: Duration,
192    connectivity_timeout: Duration,
193    connectivity_retries: u32,
194    concurrent: Option<usize>,
195    check_port: bool,
196}
197
198impl Default for MinerFactory {
199    fn default() -> Self {
200        Self::new()
201    }
202}
203
204impl MinerFactory {
205    pub async fn scan_miner(&self, ip: IpAddr) -> Result<Option<Box<dyn GetMinerData>>> {
206        // Quick port check first to avoid wasting time on dead IPs
207        if (1..self.connectivity_retries).next().is_some() {
208            if self.check_port && !check_port_open(ip, 80, self.connectivity_timeout).await {
209                return Ok(None);
210            } else {
211                return self.get_miner(ip).await;
212            }
213        }
214        Ok(None)
215    }
216
217    pub async fn get_miner(&self, ip: IpAddr) -> Result<Option<Box<dyn GetMinerData>>> {
218        let search_makes = self.search_makes.clone().unwrap_or(vec![
219            MinerMake::AntMiner,
220            MinerMake::WhatsMiner,
221            MinerMake::AvalonMiner,
222            MinerMake::EPic,
223            MinerMake::Braiins,
224            MinerMake::BitAxe,
225        ]);
226        let search_firmwares = self.search_firmwares.clone().unwrap_or(vec![
227            MinerFirmware::Stock,
228            MinerFirmware::BraiinsOS,
229            MinerFirmware::VNish,
230            MinerFirmware::EPic,
231            MinerFirmware::HiveOS,
232            MinerFirmware::LuxOS,
233            MinerFirmware::Marathon,
234            MinerFirmware::MSKMiner,
235        ]);
236        let mut commands: HashSet<MinerCommand> = HashSet::new();
237
238        for make in search_makes {
239            for command in make.get_discovery_commands() {
240                commands.insert(command);
241            }
242        }
243        for firmware in search_firmwares {
244            for command in firmware.get_discovery_commands() {
245                commands.insert(command);
246            }
247        }
248
249        let mut discovery_tasks = JoinSet::new();
250        for command in commands {
251            let _ = discovery_tasks.spawn(get_miner_type_from_command(ip, command));
252        }
253
254        let timeout = tokio::time::sleep(self.identification_timeout).fuse();
255        let tasks = tokio::spawn(async move {
256            loop {
257                if discovery_tasks.is_empty() {
258                    return None;
259                };
260                match discovery_tasks.join_next().await.unwrap_or(Ok(None)) {
261                    Ok(Some(result)) => {
262                        return Some(result);
263                    }
264                    _ => continue,
265                };
266            }
267        });
268
269        pin_mut!(timeout, tasks);
270
271        let miner_info = tokio::select!(
272            Ok(miner_info) = &mut tasks => {
273                miner_info
274            },
275            _ = &mut timeout => {
276                None
277            }
278        );
279
280        match miner_info {
281            Some((Some(make), Some(MinerFirmware::Stock))) => {
282                let model = make.get_model(ip).await;
283                let version = make.get_version(ip).await;
284
285                Ok(select_backend(
286                    ip,
287                    model,
288                    Some(MinerFirmware::Stock),
289                    version,
290                ))
291            }
292            Some((_, Some(firmware))) => {
293                let model = firmware.get_model(ip).await;
294                let version = firmware.get_version(ip).await;
295
296                if let Some(model) = model {
297                    return Ok(select_backend(ip, Some(model), Some(firmware), version));
298                }
299
300                Ok(select_backend(ip, model, Some(firmware), version))
301            }
302            Some((Some(make), firmware)) => {
303                let model = make.get_model(ip).await;
304                let version = make.get_version(ip).await;
305
306                Ok(select_backend(ip, model, firmware, version))
307            }
308            _ => Ok(None),
309        }
310    }
311
312    pub fn new() -> MinerFactory {
313        MinerFactory {
314            search_makes: None,
315            search_firmwares: None,
316            ips: Vec::new(),
317            identification_timeout: IDENTIFICATION_TIMEOUT,
318            connectivity_timeout: CONNECTIVITY_TIMEOUT,
319            connectivity_retries: CONNECTIVITY_RETRIES,
320            concurrent: None,
321            check_port: true, // Enable port checking by default
322        }
323    }
324
325    // Port checking
326    pub fn with_port_check(mut self, enabled: bool) -> Self {
327        self.check_port = enabled;
328        self
329    }
330
331    // Concurrency limiting
332    pub fn with_concurrent_limit(mut self, limit: usize) -> Self {
333        self.concurrent = Some(limit);
334        self
335    }
336
337    pub fn with_adaptive_concurrency(mut self) -> Self {
338        self.concurrent = Some(calculate_optimal_concurrency(self.ips.len()));
339        self
340    }
341
342    fn update_adaptive_concurrency(&mut self) {
343        if self.concurrent.is_none() {
344            self.concurrent = Some(calculate_optimal_concurrency(self.ips.len()));
345        }
346    }
347
348    // Timeout
349    pub fn with_identification_timeout(mut self, timeout: Duration) -> Self {
350        self.identification_timeout = timeout;
351        self
352    }
353
354    pub fn with_identification_timeout_secs(mut self, timeout_secs: u64) -> Self {
355        self.identification_timeout = Duration::from_secs(timeout_secs);
356        self
357    }
358
359    pub fn with_connectivity_timeout(mut self, timeout: Duration) -> Self {
360        self.connectivity_timeout = timeout;
361        self
362    }
363
364    pub fn with_connectivity_timeout_secs(mut self, timeout_secs: u64) -> Self {
365        self.connectivity_timeout = Duration::from_secs(timeout_secs);
366        self
367    }
368
369    pub fn with_connectivity_retries(mut self, retries: u32) -> Self {
370        self.connectivity_retries = retries;
371        self
372    }
373
374    // Makes
375    pub fn with_search_makes(mut self, search_makes: Vec<MinerMake>) -> Self {
376        self.search_makes = Some(search_makes);
377        self
378    }
379
380    pub fn with_makes(mut self, makes: Vec<MinerMake>) -> Self {
381        self.search_makes = Some(makes);
382        self
383    }
384
385    pub fn add_search_make(mut self, search_make: MinerMake) -> Self {
386        if self.search_makes.is_none() {
387            self.search_makes = Some(vec![search_make]);
388        } else {
389            self.search_makes.as_mut().unwrap().push(search_make);
390        }
391        self
392    }
393
394    pub fn remove_search_make(mut self, search_make: MinerMake) -> Self {
395        if let Some(makes) = self.search_makes.as_mut() {
396            makes.retain(|val| *val != search_make);
397        }
398        self
399    }
400
401    // Firmwares
402    pub fn with_search_firmwares(mut self, search_firmwares: Vec<MinerFirmware>) -> Self {
403        self.search_firmwares = Some(search_firmwares);
404        self
405    }
406
407    pub fn with_firmwares(mut self, firmwares: Vec<MinerFirmware>) -> Self {
408        self.search_firmwares = Some(firmwares);
409        self
410    }
411
412    pub fn add_search_firmware(mut self, search_firmware: MinerFirmware) -> Self {
413        if self.search_firmwares.is_none() {
414            self.search_firmwares = Some(vec![search_firmware]);
415        } else {
416            self.search_firmwares
417                .as_mut()
418                .unwrap()
419                .push(search_firmware);
420        }
421        self
422    }
423
424    pub fn remove_search_firmware(mut self, search_firmware: MinerFirmware) -> Self {
425        if let Some(firmwares) = self.search_firmwares.as_mut() {
426            firmwares.retain(|val| *val != search_firmware);
427        }
428        self
429    }
430
431    // Subnet handlers
432    /// Set IPs from a subnet
433    pub fn with_subnet(mut self, subnet: &str) -> Result<Self> {
434        let ips = self.hosts_from_subnet(subnet)?;
435        self.ips = ips;
436        self.shuffle_ips();
437        Ok(self)
438    }
439    fn hosts_from_subnet(&self, subnet: &str) -> Result<Vec<IpAddr>> {
440        let network = IpNet::from_str(subnet)?;
441        Ok(network.hosts().collect())
442    }
443
444    /// Randomize IP order to avoid bursts to a single switch/segment
445    fn shuffle_ips(&mut self) {
446        let mut rng = rand::rng();
447        self.ips.shuffle(&mut rng);
448    }
449
450    // Octet handlers
451    /// Set IPs from octet ranges
452    pub fn with_octets(
453        mut self,
454        octet1: &str,
455        octet2: &str,
456        octet3: &str,
457        octet4: &str,
458    ) -> Result<Self> {
459        let ips = self.hosts_from_octets(octet1, octet2, octet3, octet4)?;
460        self.ips = ips;
461        self.shuffle_ips();
462        self.update_adaptive_concurrency();
463        Ok(self)
464    }
465    fn hosts_from_octets(
466        &self,
467        octet1: &str,
468        octet2: &str,
469        octet3: &str,
470        octet4: &str,
471    ) -> Result<Vec<IpAddr>> {
472        let octet1_range = parse_octet_range(octet1)?;
473        let octet2_range = parse_octet_range(octet2)?;
474        let octet3_range = parse_octet_range(octet3)?;
475        let octet4_range = parse_octet_range(octet4)?;
476
477        Ok(generate_ips_from_ranges(
478            &octet1_range,
479            &octet2_range,
480            &octet3_range,
481            &octet4_range,
482        ))
483    }
484
485    // Range handler
486    /// Set IPs from a range string in the format "10.1-199.0.1-199"
487    pub fn with_range(self, range_str: &str) -> Result<Self> {
488        let parts: Vec<&str> = range_str.split('.').collect();
489        if parts.len() != 4 {
490            return Err(anyhow::anyhow!(
491                "Invalid IP range format. Expected format: 10.1-199.0.1-199"
492            ));
493        }
494
495        self.with_octets(parts[0], parts[1], parts[2], parts[3])
496    }
497
498    /// Return current scan IPs
499    pub fn hosts(&self) -> Vec<IpAddr> {
500        self.ips.clone()
501    }
502
503    /// Get current count of scan IPs
504    pub fn len(&self) -> usize {
505        self.ips.len()
506    }
507
508    /// Check if the list of IPs is empty
509    pub fn is_empty(&self) -> bool {
510        self.ips.is_empty()
511    }
512
513    /// Scan the IPs specified in the factory
514    pub async fn scan(&self) -> Result<Vec<Box<dyn GetMinerData>>> {
515        if self.ips.is_empty() {
516            return Err(anyhow::anyhow!(
517                "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
518            ));
519        }
520
521        let concurrency = self
522            .concurrent
523            .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
524
525        let miners: Vec<Box<dyn GetMinerData>> = stream::iter(self.ips.iter().copied())
526            .map(|ip| async move { self.scan_miner(ip).await.ok().flatten() })
527            .buffer_unordered(concurrency)
528            .filter_map(|miner_opt| async move { miner_opt })
529            .collect()
530            .await;
531
532        Ok(miners)
533    }
534
535    pub fn scan_stream(&self) -> Result<impl Stream<Item = Box<dyn GetMinerData>>> {
536        if self.ips.is_empty() {
537            return Err(anyhow::anyhow!(
538                "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
539            ));
540        }
541
542        let concurrency = self
543            .concurrent
544            .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
545
546        let stream = stream::iter(
547            self.ips
548                .iter()
549                .copied()
550                .map(move |ip| async move { self.scan_miner(ip).await.ok().flatten() }),
551        )
552        .buffer_unordered(concurrency)
553        .filter_map(|miner_opt| async move { miner_opt });
554
555        Ok(Box::pin(stream))
556    }
557
558    pub fn scan_stream_with_ip(
559        &self,
560    ) -> Result<impl Stream<Item = (IpAddr, Option<Box<dyn GetMinerData>>)>> {
561        if self.ips.is_empty() {
562            return Err(anyhow::anyhow!(
563                "No IPs to scan. Use with_subnet, with_octets, or with_range to set IPs."
564            ));
565        }
566
567        let concurrency = self
568            .concurrent
569            .unwrap_or(calculate_optimal_concurrency(self.ips.len()));
570
571        let stream = stream::iter(
572            self.ips
573                .iter()
574                .copied()
575                .map(move |ip| async move { (ip, self.scan_miner(ip).await.ok().flatten()) }),
576        )
577        .buffer_unordered(concurrency);
578
579        Ok(Box::pin(stream))
580    }
581
582    /// Scan for miners by specific octets
583    pub async fn scan_by_octets(
584        self,
585        octet1: &str,
586        octet2: &str,
587        octet3: &str,
588        octet4: &str,
589    ) -> Result<Vec<Box<dyn GetMinerData>>> {
590        self.with_octets(octet1, octet2, octet3, octet4)?
591            .scan()
592            .await
593    }
594
595    /// Scan for miners by IP range in the format "10.1-199.0.1-199"
596    pub async fn scan_by_range(self, range_str: &str) -> Result<Vec<Box<dyn GetMinerData>>> {
597        self.with_range(range_str)?.scan().await
598    }
599}
600
601/// Helper function to parse an octet range string like "1-199" into a vector of u8 values
602fn parse_octet_range(range_str: &str) -> Result<Vec<u8>> {
603    if range_str.contains('-') {
604        let parts: Vec<&str> = range_str.split('-').collect();
605        if parts.len() != 2 {
606            return Err(anyhow::anyhow!("Invalid range format: {}", range_str));
607        }
608
609        let start: u8 = parts[0].parse()?;
610        let end: u8 = parts[1].parse()?;
611
612        if start > end {
613            return Err(anyhow::anyhow!(
614                "Invalid range: start > end in {}",
615                range_str
616            ));
617        }
618
619        Ok((start..=end).collect())
620    } else {
621        // Single value
622        let value: u8 = range_str.parse()?;
623        Ok(vec![value])
624    }
625}
626
627/// Generate all IPv4 addresses from octet ranges
628fn generate_ips_from_ranges(
629    octet1_range: &[u8],
630    octet2_range: &[u8],
631    octet3_range: &[u8],
632    octet4_range: &[u8],
633) -> Vec<IpAddr> {
634    let mut ips = Vec::new();
635
636    for &o1 in octet1_range {
637        for &o2 in octet2_range {
638            for &o3 in octet3_range {
639                for &o4 in octet4_range {
640                    ips.push(IpAddr::V4(Ipv4Addr::new(o1, o2, o3, o4)));
641                }
642            }
643        }
644    }
645
646    ips
647}
648
649#[cfg(test)]
650mod tests {
651    use super::*;
652
653    #[test]
654    fn test_parse_type_from_socket_whatsminer_2024_09_30() {
655        const RAW_DATA: &str = r#"{"STATUS": [{"STATUS": "S", "Msg": "Device Details"}], "DEVDETAILS": [{"DEVDETAILS": 0, "Name": "SM", "ID": 0, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}, {"DEVDETAILS": 1, "Name": "SM", "ID": 1, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}, {"DEVDETAILS": 2, "Name": "SM", "ID": 2, "Driver": "bitmicro", "Kernel": "", "Model": "M30S+_VE40"}], "id": 1}"#;
656        let parsed_data = serde_json::from_str(RAW_DATA).unwrap();
657        let result = parse_type_from_socket(parsed_data);
658        assert_eq!(
659            result,
660            Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
661        )
662    }
663
664    #[test]
665    fn test_parse_type_from_web_whatsminer_2024_09_30() {
666        let mut headers = HeaderMap::new();
667        headers.insert("location", "https://example.com/".parse().unwrap());
668
669        let response_data = (String::from(""), headers, StatusCode::TEMPORARY_REDIRECT);
670
671        let result = parse_type_from_web(response_data);
672        assert_eq!(
673            result,
674            Some((Some(MinerMake::WhatsMiner), Some(MinerFirmware::Stock)))
675        )
676    }
677
678    #[test]
679    fn test_parse_octet_range() {
680        // Test single value
681        let result = parse_octet_range("10").unwrap();
682        assert_eq!(result, vec![10]);
683
684        // Test range
685        let result = parse_octet_range("1-5").unwrap();
686        assert_eq!(result, vec![1, 2, 3, 4, 5]);
687
688        // Test larger range
689        let result = parse_octet_range("200-255").unwrap();
690        assert_eq!(result, (200..=255).collect::<Vec<u8>>());
691
692        // Test invalid range (start > end)
693        let result = parse_octet_range("200-100");
694        assert!(result.is_err());
695
696        // Test invalid format
697        let result = parse_octet_range("1-5-10");
698        assert!(result.is_err());
699
700        // Test invalid value
701        let result = parse_octet_range("300");
702        assert!(result.is_err());
703    }
704
705    #[test]
706    fn test_generate_ips_from_ranges() {
707        let octet1 = vec![192];
708        let octet2 = vec![168];
709        let octet3 = vec![1];
710        let octet4 = vec![1, 2];
711
712        let ips = generate_ips_from_ranges(&octet1, &octet2, &octet3, &octet4);
713
714        assert_eq!(ips.len(), 2);
715        assert!(ips.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))));
716        assert!(ips.contains(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 2))));
717    }
718}