asic_rs/miners/backends/luxminer/v1/
mod.rs

1use crate::data::board::{BoardData, ChipData};
2use crate::data::device::{
3    DeviceInfo, HashAlgorithm, MinerControlBoard, MinerFirmware, MinerMake, MinerModel,
4};
5use crate::data::fan::FanData;
6use crate::data::hashrate::{HashRate, HashRateUnit};
7use crate::data::message::{MessageSeverity, MinerMessage};
8use crate::data::pool::{PoolData, PoolURL};
9use crate::miners::backends::traits::*;
10use crate::miners::commands::MinerCommand;
11use crate::miners::data::{
12    DataCollector, DataExtensions, DataExtractor, DataField, DataLocation, get_by_pointer,
13};
14use anyhow;
15use async_trait::async_trait;
16use macaddr::MacAddr;
17use measurements::{AngularVelocity, Frequency, Power, Temperature, Voltage};
18use rpc::LUXMinerRPCAPI;
19use serde_json::Value;
20use std::collections::HashMap;
21use std::net::IpAddr;
22use std::str::FromStr;
23use std::time::Duration;
24
25mod rpc;
26
27#[derive(Debug)]
28pub struct LuxMinerV1 {
29    pub ip: IpAddr,
30    pub rpc: LUXMinerRPCAPI,
31    pub device_info: DeviceInfo,
32}
33
34impl LuxMinerV1 {
35    pub fn new(ip: IpAddr, model: MinerModel) -> Self {
36        LuxMinerV1 {
37            ip,
38            rpc: LUXMinerRPCAPI::new(ip),
39            device_info: DeviceInfo::new(
40                MinerMake::AntMiner,
41                model,
42                MinerFirmware::LuxOS,
43                HashAlgorithm::SHA256,
44            ),
45        }
46    }
47
48    fn parse_temp_string(temp_str: &str) -> Option<Temperature> {
49        let temps: Vec<f64> = temp_str
50            .split('-')
51            .filter_map(|s| s.parse().ok())
52            .filter(|&temp| temp > 0.0)
53            .collect();
54
55        if !temps.is_empty() {
56            let avg = temps.iter().sum::<f64>() / temps.len() as f64;
57            Some(Temperature::from_celsius(avg))
58        } else {
59            None
60        }
61    }
62}
63
64#[async_trait]
65impl APIClient for LuxMinerV1 {
66    async fn get_api_result(&self, command: &MinerCommand) -> anyhow::Result<Value> {
67        match command {
68            MinerCommand::RPC { .. } => self.rpc.get_api_result(command).await,
69            _ => Err(anyhow::anyhow!("Unsupported command type for LuxMiner API")),
70        }
71    }
72}
73
74impl GetDataLocations for LuxMinerV1 {
75    fn get_locations(&self, data_field: DataField) -> Vec<DataLocation> {
76        const RPC_VERSION: MinerCommand = MinerCommand::RPC {
77            command: "version",
78            parameters: None,
79        };
80
81        const RPC_STATS: MinerCommand = MinerCommand::RPC {
82            command: "stats",
83            parameters: None,
84        };
85
86        const RPC_SUMMARY: MinerCommand = MinerCommand::RPC {
87            command: "summary",
88            parameters: None,
89        };
90
91        const RPC_POOLS: MinerCommand = MinerCommand::RPC {
92            command: "pools",
93            parameters: None,
94        };
95
96        const RPC_CONFIG: MinerCommand = MinerCommand::RPC {
97            command: "config",
98            parameters: None,
99        };
100
101        const RPC_FANS: MinerCommand = MinerCommand::RPC {
102            command: "fans",
103            parameters: None,
104        };
105
106        const RPC_POWER: MinerCommand = MinerCommand::RPC {
107            command: "power",
108            parameters: None,
109        };
110
111        const RPC_PROFILES: MinerCommand = MinerCommand::RPC {
112            command: "profiles",
113            parameters: None,
114        };
115
116        const RPC_TEMPS: MinerCommand = MinerCommand::RPC {
117            command: "temps",
118            parameters: None,
119        };
120
121        const RPC_DEVS: MinerCommand = MinerCommand::RPC {
122            command: "devs",
123            parameters: None,
124        };
125
126        match data_field {
127            DataField::Mac => vec![(
128                RPC_CONFIG,
129                DataExtractor {
130                    func: get_by_pointer,
131                    key: Some("/CONFIG/0/MACAddr"),
132                    tag: None,
133                },
134            )],
135            DataField::Fans => vec![(
136                RPC_FANS,
137                DataExtractor {
138                    func: get_by_pointer,
139                    key: Some("/FANS"),
140                    tag: None,
141                },
142            )],
143            DataField::ApiVersion => vec![(
144                RPC_VERSION,
145                DataExtractor {
146                    func: get_by_pointer,
147                    key: Some("/VERSION/0/API"),
148                    tag: None,
149                },
150            )],
151            DataField::FirmwareVersion => vec![(
152                RPC_VERSION,
153                DataExtractor {
154                    func: get_by_pointer,
155                    key: Some("/VERSION/0/Miner"),
156                    tag: None,
157                },
158            )],
159            DataField::Hostname => vec![(
160                RPC_CONFIG,
161                DataExtractor {
162                    func: get_by_pointer,
163                    key: Some("/CONFIG/0/Hostname"),
164                    tag: None,
165                },
166            )],
167            DataField::Hashboards => vec![
168                (
169                    MinerCommand::RPC {
170                        command: "healthchipget",
171                        parameters: Some(Value::String("0".to_string())),
172                    },
173                    DataExtractor {
174                        func: get_by_pointer,
175                        key: Some("/CHIPS"),
176                        tag: Some("CHIPS_0"),
177                    },
178                ),
179                (
180                    MinerCommand::RPC {
181                        command: "healthchipget",
182                        parameters: Some(Value::String("1".to_string())),
183                    },
184                    DataExtractor {
185                        func: get_by_pointer,
186                        key: Some("/CHIPS"),
187                        tag: Some("CHIPS_1"),
188                    },
189                ),
190                (
191                    MinerCommand::RPC {
192                        command: "healthchipget",
193                        parameters: Some(Value::String("2".to_string())),
194                    },
195                    DataExtractor {
196                        func: get_by_pointer,
197                        key: Some("/CHIPS"),
198                        tag: Some("CHIPS_2"),
199                    },
200                ),
201                (
202                    RPC_STATS,
203                    DataExtractor {
204                        func: get_by_pointer,
205                        key: Some("/STATS/1"),
206                        tag: Some("STATS"),
207                    },
208                ),
209                (
210                    RPC_TEMPS,
211                    DataExtractor {
212                        func: get_by_pointer,
213                        key: Some(""),
214                        tag: None,
215                    },
216                ),
217                (
218                    MinerCommand::RPC {
219                        command: "voltageget",
220                        parameters: Some(Value::String("0".to_string())),
221                    },
222                    DataExtractor {
223                        func: get_by_pointer,
224                        key: Some("/VOLTAGE"),
225                        tag: Some("VOLTAGE_0"),
226                    },
227                ),
228                (
229                    MinerCommand::RPC {
230                        command: "voltageget",
231                        parameters: Some(Value::String("1".to_string())),
232                    },
233                    DataExtractor {
234                        func: get_by_pointer,
235                        key: Some("/VOLTAGE"),
236                        tag: Some("VOLTAGE_1"),
237                    },
238                ),
239                (
240                    MinerCommand::RPC {
241                        command: "voltageget",
242                        parameters: Some(Value::String("2".to_string())),
243                    },
244                    DataExtractor {
245                        func: get_by_pointer,
246                        key: Some("/VOLTAGE"),
247                        tag: Some("VOLTAGE_2"),
248                    },
249                ),
250                (
251                    MinerCommand::RPC {
252                        command: "voltageget",
253                        parameters: Some(Value::String("0".to_string())),
254                    },
255                    DataExtractor {
256                        func: get_by_pointer,
257                        key: Some("/VOLTAGE"),
258                        tag: Some("VOLTAGE_PSU"),
259                    },
260                ),
261                (
262                    RPC_TEMPS,
263                    DataExtractor {
264                        func: get_by_pointer,
265                        key: Some(""),
266                        tag: Some("TEMPS"),
267                    },
268                ),
269                (
270                    RPC_DEVS,
271                    DataExtractor {
272                        func: get_by_pointer,
273                        key: Some("/DEVS"),
274                        tag: Some("DEVS"),
275                    },
276                ),
277            ],
278            DataField::LightFlashing => vec![(
279                RPC_CONFIG,
280                DataExtractor {
281                    func: get_by_pointer,
282                    key: Some("/CONFIG/0/RedLed"),
283                    tag: None,
284                },
285            )],
286            DataField::IsMining => vec![(
287                RPC_SUMMARY,
288                DataExtractor {
289                    func: get_by_pointer,
290                    key: Some("/SUMMARY/0/GHS 5s"),
291                    tag: None,
292                },
293            )],
294            DataField::Uptime => vec![(
295                RPC_STATS,
296                DataExtractor {
297                    func: get_by_pointer,
298                    key: Some("/STATS/1/Elapsed"),
299                    tag: None,
300                },
301            )],
302            DataField::Pools => vec![(
303                RPC_POOLS,
304                DataExtractor {
305                    func: get_by_pointer,
306                    key: Some("/POOLS"),
307                    tag: None,
308                },
309            )],
310            DataField::Wattage => vec![(
311                RPC_POWER,
312                DataExtractor {
313                    func: get_by_pointer,
314                    key: Some("/POWER/0/Watts"),
315                    tag: None,
316                },
317            )],
318            DataField::WattageLimit => vec![
319                (
320                    RPC_CONFIG,
321                    DataExtractor {
322                        func: get_by_pointer,
323                        key: Some("/CONFIG/0/Profile"),
324                        tag: Some("Profile"),
325                    },
326                ),
327                (
328                    RPC_PROFILES,
329                    DataExtractor {
330                        func: get_by_pointer,
331                        key: Some("/PROFILES"),
332                        tag: Some("Profiles"),
333                    },
334                ),
335            ],
336            DataField::SerialNumber => vec![(
337                RPC_CONFIG,
338                DataExtractor {
339                    func: get_by_pointer,
340                    key: Some("/CONFIG/0/SerialNumber"),
341                    tag: None,
342                },
343            )],
344            DataField::Messages => vec![(
345                RPC_SUMMARY,
346                DataExtractor {
347                    func: get_by_pointer,
348                    key: Some("/STATUS"),
349                    tag: None,
350                },
351            )],
352            DataField::ControlBoardVersion => vec![(
353                RPC_CONFIG,
354                DataExtractor {
355                    func: get_by_pointer,
356                    key: Some("/CONFIG/0/ControlBoardType"),
357                    tag: None,
358                },
359            )],
360            DataField::Hashrate => vec![(
361                RPC_STATS,
362                DataExtractor {
363                    func: get_by_pointer,
364                    key: Some("/STATS/1/GHS 5s"),
365                    tag: None,
366                },
367            )],
368            DataField::ExpectedHashrate => vec![(
369                RPC_DEVS,
370                DataExtractor {
371                    func: get_by_pointer,
372                    key: Some("/DEVS"),
373                    tag: None,
374                },
375            )],
376            DataField::FluidTemperature => vec![(
377                RPC_TEMPS,
378                DataExtractor {
379                    func: get_by_pointer,
380                    key: Some(""),
381                    tag: None,
382                },
383            )],
384            _ => vec![],
385        }
386    }
387}
388
389impl GetIP for LuxMinerV1 {
390    fn get_ip(&self) -> IpAddr {
391        self.ip
392    }
393}
394
395impl GetDeviceInfo for LuxMinerV1 {
396    fn get_device_info(&self) -> DeviceInfo {
397        self.device_info
398    }
399}
400
401impl CollectData for LuxMinerV1 {
402    fn get_collector(&self) -> DataCollector<'_> {
403        DataCollector::new(self)
404    }
405}
406
407impl GetMAC for LuxMinerV1 {
408    fn parse_mac(&self, data: &HashMap<DataField, Value>) -> Option<MacAddr> {
409        data.extract::<String>(DataField::Mac)
410            .and_then(|s| MacAddr::from_str(&s.to_uppercase()).ok())
411    }
412}
413
414impl GetHostname for LuxMinerV1 {
415    fn parse_hostname(&self, data: &HashMap<DataField, Value>) -> Option<String> {
416        data.extract::<String>(DataField::Hostname)
417    }
418}
419
420impl GetApiVersion for LuxMinerV1 {
421    fn parse_api_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
422        data.extract::<String>(DataField::ApiVersion)
423    }
424}
425
426impl GetFluidTemperature for LuxMinerV1 {
427    fn parse_fluid_temperature(&self, data: &HashMap<DataField, Value>) -> Option<Temperature> {
428        let temps_response = data.get(&DataField::FluidTemperature)?;
429
430        let metadata = temps_response.get("METADATA")?.as_array()?;
431
432        let mut inlet_field = None;
433        let mut outlet_field = None;
434
435        for item in metadata {
436            if let Some(label) = item.get("Label").and_then(|v| v.as_str()) {
437                for (key, _) in item.as_object()? {
438                    if key != "Label" {
439                        match label {
440                            "Water Inlet" => inlet_field = Some(key.clone()),
441                            "Water Outlet" => outlet_field = Some(key.clone()),
442                            _ => {}
443                        }
444                        break;
445                    }
446                }
447            }
448        }
449
450        let temps = temps_response.get("TEMPS")?.as_array()?;
451
452        let mut inlet_temps = Vec::new();
453        let mut outlet_temps = Vec::new();
454
455        for temp_data in temps {
456            if let Some(field) = &inlet_field
457                && let Some(temp) = temp_data.get(field).and_then(|v| v.as_f64())
458                && temp > 0.0
459            {
460                inlet_temps.push(temp);
461            }
462
463            if let Some(field) = &outlet_field
464                && let Some(temp) = temp_data.get(field).and_then(|v| v.as_f64())
465                && temp > 0.0
466            {
467                outlet_temps.push(temp);
468            }
469        }
470
471        let avg_inlet = if !inlet_temps.is_empty() {
472            Some(inlet_temps.iter().sum::<f64>() / inlet_temps.len() as f64)
473        } else {
474            None
475        };
476
477        let avg_outlet = if !outlet_temps.is_empty() {
478            Some(outlet_temps.iter().sum::<f64>() / outlet_temps.len() as f64)
479        } else {
480            None
481        };
482
483        match (avg_inlet, avg_outlet) {
484            (Some(inlet), Some(outlet)) => Some(Temperature::from_celsius((inlet + outlet) / 2.0)),
485            (Some(inlet), None) => Some(Temperature::from_celsius(inlet)),
486            (None, Some(outlet)) => Some(Temperature::from_celsius(outlet)),
487            (None, None) => None,
488        }
489    }
490}
491
492impl GetFirmwareVersion for LuxMinerV1 {
493    fn parse_firmware_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
494        data.extract::<String>(DataField::FirmwareVersion)
495    }
496}
497
498impl GetHashboards for LuxMinerV1 {
499    fn parse_hashboards(&self, data: &HashMap<DataField, Value>) -> Vec<BoardData> {
500        let mut boards: Vec<BoardData> = Vec::new();
501        let board_count = self.device_info.hardware.boards.unwrap_or(3);
502        for idx in 0..board_count {
503            boards.push(BoardData {
504                hashrate: None,
505                position: idx,
506                expected_hashrate: None,
507                board_temperature: None,
508                intake_temperature: None,
509                outlet_temperature: None,
510                expected_chips: self.device_info.hardware.chips,
511                working_chips: None,
512                serial_number: None,
513                chips: vec![],
514                voltage: None,
515                frequency: None,
516                tuned: Some(false),
517                active: Some(false),
518            });
519        }
520
521        if let Some(devs_data) = data
522            .get(&DataField::Hashboards)
523            .and_then(|v| v.as_object())
524            .and_then(|obj| obj.get("DEVS"))
525            .and_then(|v| v.as_array())
526        {
527            for (idx, dev) in devs_data.iter().enumerate() {
528                if let Some(dev_object) = dev.as_object() {
529                    if let Some(serial_number) =
530                        dev_object.get("SerialNumber").and_then(|v| v.as_str())
531                    {
532                        boards[idx].serial_number = Some(serial_number.to_string());
533                    }
534
535                    if let Some(expected_hashrate) =
536                        dev_object.get("Nominal MHS").and_then(|v| v.as_f64())
537                    {
538                        boards[idx].expected_hashrate = Some(
539                            HashRate {
540                                value: expected_hashrate,
541                                unit: HashRateUnit::MegaHash,
542                                algo: String::from("SHA256"),
543                            }
544                            .as_unit(HashRateUnit::TeraHash),
545                        );
546                    }
547                }
548            }
549        }
550
551        if let Some(stats_data) = data
552            .get(&DataField::Hashboards)
553            .and_then(|v| v.get("STATS"))
554        {
555            for idx in 1..=board_count {
556                let board_idx = (idx - 1) as usize;
557                if let Some(hashrate) = stats_data
558                    .get(format!("chain_rate{}", idx))
559                    .and_then(|v| v.as_f64())
560                    .map(|f| {
561                        HashRate {
562                            value: f,
563                            unit: HashRateUnit::GigaHash,
564                            algo: String::from("SHA256"),
565                        }
566                        .as_unit(HashRateUnit::TeraHash)
567                    })
568                {
569                    boards[board_idx].hashrate = Some(hashrate);
570                }
571
572                if let Some(board_temp) = stats_data
573                    .get(format!("temp_pcb{}", idx))
574                    .and_then(|v| v.as_str())
575                    .and_then(Self::parse_temp_string)
576                {
577                    boards[board_idx].board_temperature = Some(board_temp);
578                }
579
580                if let Some(chip_temp) = stats_data
581                    .get(format!("temp_chip{}", idx))
582                    .and_then(|v| v.as_str())
583                    .and_then(Self::parse_temp_string)
584                {
585                    boards[board_idx].intake_temperature = Some(chip_temp);
586                }
587
588                if let Some(frequency) = stats_data
589                    .get(format!("freq{}", idx))
590                    .and_then(|v| v.as_u64())
591                    .map(|f| Frequency::from_megahertz(f as f64))
592                {
593                    boards[board_idx].frequency = Some(frequency);
594                }
595            }
596        }
597
598        if let Some(temps_object) = data
599            .get(&DataField::Hashboards)
600            .and_then(|v| v.pointer("/TEMPS"))
601            && let Some(temps_array) = temps_object.get("TEMPS").and_then(|v| v.as_array())
602        {
603            for temp_entry in temps_array {
604                if let Some(board_id) = temp_entry.get("ID").and_then(|v| v.as_u64()) {
605                    let board_idx = board_id as usize;
606                    if board_idx < boards.len() {
607                        let exhaust_temps: Vec<f64> = vec![
608                            temp_entry.get("TopLeft").and_then(|v| v.as_f64()),
609                            temp_entry.get("BottomLeft").and_then(|v| v.as_f64()),
610                        ]
611                        .into_iter()
612                        .flatten()
613                        .filter(|&t| t > 0.0)
614                        .collect();
615
616                        if !exhaust_temps.is_empty() {
617                            let avg_exhaust =
618                                exhaust_temps.iter().sum::<f64>() / exhaust_temps.len() as f64;
619                            boards[board_idx].outlet_temperature =
620                                Some(Temperature::from_celsius(avg_exhaust));
621                        }
622
623                        let intake_temps: Vec<f64> = vec![
624                            temp_entry.get("TopRight").and_then(|v| v.as_f64()),
625                            temp_entry.get("BottomRight").and_then(|v| v.as_f64()),
626                        ]
627                        .into_iter()
628                        .flatten()
629                        .filter(|&t| t > 0.0)
630                        .collect();
631
632                        if !intake_temps.is_empty() {
633                            let avg_intake =
634                                intake_temps.iter().sum::<f64>() / intake_temps.len() as f64;
635                            boards[board_idx].intake_temperature =
636                                Some(Temperature::from_celsius(avg_intake));
637                        }
638                    }
639                }
640            }
641        }
642
643        if let Some(voltage_data) = data.get(&DataField::Hashboards) {
644            for (idx, tag) in (0..3).map(|i| (i, format!("/VOLTAGE_{}/0", i))) {
645                if let Some(voltage_object) = voltage_data.pointer(&tag).and_then(|v| v.as_object())
646                    && let Some(voltage) = voltage_object.get("Voltage").and_then(|v| v.as_f64())
647                {
648                    boards[idx].voltage = match voltage {
649                        0.0 => voltage_data
650                            .pointer("/VOLTAGE_PSU/0/Voltage")
651                            .and_then(|v| v.as_f64())
652                            .map(Voltage::from_volts), // If we cant read from each board, try the PSU
653                        _ => Some(Voltage::from_volts(voltage)),
654                    }
655                }
656            }
657        }
658
659        if let Some(chips_data) = data.get(&DataField::Hashboards) {
660            for (idx, tag) in (0..3).map(|i| (i, format!("CHIPS_{}", i))) {
661                if let Some(arr) = chips_data.get(&tag).and_then(|v| v.as_array()) {
662                    boards[idx].chips = arr
663                        .iter()
664                        .filter_map(|v| v.as_object())
665                        .map(|o| ChipData {
666                            position: o.get("Chip").and_then(|v| v.as_u64()).unwrap() as u16,
667                            temperature: None,
668                            hashrate: o.get("GHS 1m").and_then(|v| v.as_f64()).map(|hr| HashRate {
669                                value: hr,
670                                unit: HashRateUnit::GigaHash,
671                                algo: "SHA256".into(),
672                            }),
673                            frequency: o
674                                .get("Frequency")
675                                .and_then(|v| v.as_f64())
676                                .map(Frequency::from_megahertz),
677                            tuned: o.get("Healthy").and_then(|v| v.as_str()).map(|s| s == "Y"),
678                            working: o.get("Healthy").and_then(|v| v.as_str()).map(|s| s == "Y"),
679                            voltage: None,
680                        })
681                        .collect();
682                }
683            }
684        }
685
686        for b in &mut boards {
687            if !b.chips.is_empty() {
688                b.working_chips = Some(
689                    b.chips
690                        .iter()
691                        .filter(|c| c.working.unwrap_or(false))
692                        .count() as u16,
693                );
694                let total_hr: f64 = b
695                    .chips
696                    .iter()
697                    .filter_map(|c| c.hashrate.as_ref())
698                    .map(|h| h.value)
699                    .sum();
700                if total_hr > 0.0 {
701                    b.hashrate = Some(
702                        HashRate {
703                            value: total_hr,
704                            unit: HashRateUnit::GigaHash,
705                            algo: "SHA256".into(),
706                        }
707                        .as_unit(HashRateUnit::TeraHash),
708                    );
709                }
710                let freqs: Vec<f64> = b
711                    .chips
712                    .iter()
713                    .filter_map(|c| c.frequency.as_ref())
714                    .map(|f| f.as_megahertz())
715                    .collect();
716                if !freqs.is_empty() {
717                    b.frequency = Some(Frequency::from_megahertz(
718                        freqs.iter().sum::<f64>() / freqs.len() as f64,
719                    ));
720                }
721                let active = b.working_chips.unwrap_or(0) > 0
722                    || b.hashrate.as_ref().map(|h| h.value > 0.0).unwrap_or(false);
723                b.active = Some(active);
724                b.tuned = Some(active);
725            }
726        }
727
728        boards
729    }
730}
731
732impl GetHashrate for LuxMinerV1 {
733    fn parse_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
734        data.extract_map::<f64, _>(DataField::Hashrate, |f| {
735            HashRate {
736                value: f,
737                unit: HashRateUnit::GigaHash,
738                algo: String::from("SHA256"),
739            }
740            .as_unit(HashRateUnit::TeraHash)
741        })
742    }
743}
744
745impl GetExpectedHashrate for LuxMinerV1 {
746    fn parse_expected_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
747        let data = data
748            .get(&DataField::ExpectedHashrate)
749            .and_then(|v| v.as_array())?;
750        let expected_boards = self.device_info.hardware.boards.unwrap_or(3);
751
752        let mut expected_hashrate = 0.0;
753
754        for idx in 0..expected_boards {
755            if let Some(hashrate) = data[idx as usize]
756                .get("Nominal MHS")
757                .and_then(|v| v.as_f64())
758            {
759                expected_hashrate += hashrate;
760            }
761        }
762
763        Some(
764            HashRate {
765                value: expected_hashrate,
766                unit: HashRateUnit::MegaHash,
767                algo: String::from("SHA256"),
768            }
769            .as_unit(HashRateUnit::TeraHash),
770        )
771    }
772}
773
774impl GetFans for LuxMinerV1 {
775    fn parse_fans(&self, data: &HashMap<DataField, Value>) -> Vec<FanData> {
776        data.get(&DataField::Fans)
777            .and_then(|v| v.as_array())
778            .into_iter()
779            .flatten()
780            .enumerate()
781            .filter_map(|(idx, fan_info)| {
782                let rpm = fan_info.get("RPM")?.as_f64()?;
783                Some(FanData {
784                    position: idx as i16,
785                    rpm: Some(AngularVelocity::from_rpm(rpm)),
786                })
787            })
788            .collect()
789    }
790}
791
792impl GetLightFlashing for LuxMinerV1 {
793    fn parse_light_flashing(&self, data: &HashMap<DataField, Value>) -> Option<bool> {
794        data.extract::<String>(DataField::LightFlashing)
795            .map(|s| s.to_lowercase() != "auto")
796    }
797}
798
799impl GetUptime for LuxMinerV1 {
800    fn parse_uptime(&self, data: &HashMap<DataField, Value>) -> Option<Duration> {
801        data.extract_map::<u64, _>(DataField::Uptime, Duration::from_secs)
802    }
803}
804
805impl GetIsMining for LuxMinerV1 {
806    fn parse_is_mining(&self, data: &HashMap<DataField, Value>) -> bool {
807        data.extract::<f64>(DataField::IsMining)
808            .map(|hr| hr > 0.0)
809            .unwrap_or(false)
810    }
811}
812
813impl GetPools for LuxMinerV1 {
814    fn parse_pools(&self, data: &HashMap<DataField, Value>) -> Vec<PoolData> {
815        data.get(&DataField::Pools)
816            .and_then(|v| v.as_array())
817            .into_iter()
818            .flatten()
819            .enumerate()
820            .map(|(idx, pool)| PoolData {
821                position: Some(idx as u16),
822                url: pool
823                    .get("URL")
824                    .and_then(|v| v.as_str())
825                    .map(|s| PoolURL::from(s.to_string())),
826                user: pool.get("User").and_then(|v| v.as_str()).map(String::from),
827                alive: pool
828                    .get("Status")
829                    .and_then(|v| v.as_str())
830                    .map(|s| s == "Alive"),
831                active: pool.get("Stratum Active").and_then(|v| v.as_bool()),
832                accepted_shares: pool.get("Accepted").and_then(|v| v.as_u64()),
833                rejected_shares: pool.get("Rejected").and_then(|v| v.as_u64()),
834            })
835            .collect()
836    }
837}
838
839impl GetSerialNumber for LuxMinerV1 {
840    fn parse_serial_number(&self, data: &HashMap<DataField, Value>) -> Option<String> {
841        match data.extract::<String>(DataField::SerialNumber) {
842            Some(s) if !s.is_empty() => Some(s),
843            _ => None,
844        }
845    }
846}
847
848impl GetControlBoardVersion for LuxMinerV1 {
849    fn parse_control_board_version(
850        &self,
851        data: &HashMap<DataField, Value>,
852    ) -> Option<MinerControlBoard> {
853        data.extract::<String>(DataField::ControlBoardVersion)
854            .and_then(|s| MinerControlBoard::from_str(&s).ok())
855    }
856}
857
858impl GetWattage for LuxMinerV1 {
859    fn parse_wattage(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
860        data.extract_map::<f64, _>(DataField::Wattage, Power::from_watts)
861    }
862}
863
864impl GetWattageLimit for LuxMinerV1 {
865    fn parse_wattage_limit(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
866        let wattage_limit_data = data.get(&DataField::WattageLimit)?;
867        let profile_name = wattage_limit_data.get("Profile")?.as_str()?;
868        let profiles = wattage_limit_data.get("Profiles")?.as_array()?;
869
870        let profile = profiles
871            .iter()
872            .find(|item| item.get("Profile Name").and_then(|v| v.as_str()) == Some(profile_name))?;
873
874        let watts = profile.get("Watts")?.as_f64()?;
875
876        Some(Power::from_watts(watts))
877    }
878}
879
880impl GetPsuFans for LuxMinerV1 {}
881
882impl GetMessages for LuxMinerV1 {
883    fn parse_messages(&self, data: &HashMap<DataField, Value>) -> Vec<MinerMessage> {
884        data.get(&DataField::Messages)
885            .and_then(|v| v.as_array())
886            .into_iter()
887            .flatten()
888            .enumerate()
889            .filter_map(|(idx, item)| {
890                let status = item.get("STATUS")?.as_str()?;
891                (status != "S").then(|| {
892                    let text = item
893                        .get("Msg")
894                        .and_then(|v| v.as_str())
895                        .unwrap_or("Unknown error");
896                    let severity = match status {
897                        "E" => MessageSeverity::Error,
898                        "W" => MessageSeverity::Warning,
899                        _ => MessageSeverity::Info,
900                    };
901                    MinerMessage::new(0, idx as u64, text.to_string(), severity)
902                })
903            })
904            .collect()
905    }
906}
907
908#[async_trait]
909impl SetFaultLight for LuxMinerV1 {
910    async fn set_fault_light(&self, fault: bool) -> anyhow::Result<bool> {
911        let mode = match fault {
912            true => "blink",
913            false => "auto",
914        };
915        Ok(self.rpc.ledset("red", mode).await.is_ok())
916    }
917}
918
919#[async_trait]
920impl SetPowerLimit for LuxMinerV1 {
921    #[allow(unused_variables)]
922    async fn set_power_limit(&self, limit: Power) -> anyhow::Result<bool> {
923        anyhow::bail!("Unsupported command");
924    }
925}
926
927#[async_trait]
928impl Restart for LuxMinerV1 {
929    async fn restart(&self) -> anyhow::Result<bool> {
930        Ok(self.rpc.reboot_device().await.is_ok())
931    }
932}
933
934#[async_trait]
935impl Pause for LuxMinerV1 {
936    #[allow(unused_variables)]
937    async fn pause(&self, at_time: Option<Duration>) -> anyhow::Result<bool> {
938        Ok(self.rpc.sleep().await.is_ok())
939    }
940}
941
942#[async_trait]
943impl Resume for LuxMinerV1 {
944    #[allow(unused_variables)]
945    async fn resume(&self, at_time: Option<Duration>) -> anyhow::Result<bool> {
946        Ok(self.rpc.wakeup().await.is_ok())
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::data::device::models::antminer::AntMinerModel::S19KPro;
954    use crate::test::api::MockAPIClient;
955    use crate::test::json::luxminer::v1::{
956        CONFIG, DEVS, FANS, HEALTHCHIPGET_0, HEALTHCHIPGET_1, HEALTHCHIPGET_2, POOLS, POWER,
957        PROFILES, STATS, SUMMARY, TEMPS, VERSION, VOLTAGEGET_0, VOLTAGEGET_1, VOLTAGEGET_2,
958    };
959
960    #[tokio::test]
961
962    async fn test_luxminer_v1() -> anyhow::Result<()> {
963        let miner = LuxMinerV1::new(IpAddr::from([127, 0, 0, 1]), MinerModel::AntMiner(S19KPro));
964
965        let mut results = HashMap::new();
966        let version_cmd = MinerCommand::RPC {
967            command: "version",
968            parameters: None,
969        };
970
971        let stats_cmd = MinerCommand::RPC {
972            command: "stats",
973            parameters: None,
974        };
975
976        let summary_cmd = MinerCommand::RPC {
977            command: "summary",
978            parameters: None,
979        };
980
981        let pools_cmd = MinerCommand::RPC {
982            command: "pools",
983            parameters: None,
984        };
985
986        let config_cmd = MinerCommand::RPC {
987            command: "config",
988            parameters: None,
989        };
990
991        let fans_cmd = MinerCommand::RPC {
992            command: "fans",
993            parameters: None,
994        };
995
996        let power_cmd = MinerCommand::RPC {
997            command: "power",
998            parameters: None,
999        };
1000
1001        let profiles_cmd = MinerCommand::RPC {
1002            command: "profiles",
1003            parameters: None,
1004        };
1005
1006        let temps_cmd = MinerCommand::RPC {
1007            command: "temps",
1008            parameters: None,
1009        };
1010
1011        let devs_cmd = MinerCommand::RPC {
1012            command: "devs",
1013            parameters: None,
1014        };
1015
1016        results.insert(version_cmd, Value::from_str(VERSION)?);
1017        results.insert(stats_cmd, Value::from_str(STATS)?);
1018        results.insert(summary_cmd, Value::from_str(SUMMARY)?);
1019        results.insert(pools_cmd, Value::from_str(POOLS)?);
1020        results.insert(config_cmd, Value::from_str(CONFIG)?);
1021        results.insert(fans_cmd, Value::from_str(FANS)?);
1022        results.insert(power_cmd, Value::from_str(POWER)?);
1023        results.insert(profiles_cmd, Value::from_str(PROFILES)?);
1024        results.insert(temps_cmd, Value::from_str(TEMPS)?);
1025        results.insert(devs_cmd, Value::from_str(DEVS)?);
1026
1027        results.insert(
1028            MinerCommand::RPC {
1029                command: "voltageget",
1030                parameters: Some(Value::String("0".to_string())),
1031            },
1032            Value::from_str(VOLTAGEGET_0)?,
1033        );
1034        results.insert(
1035            MinerCommand::RPC {
1036                command: "voltageget",
1037                parameters: Some(Value::String("1".to_string())),
1038            },
1039            Value::from_str(VOLTAGEGET_1)?,
1040        );
1041        results.insert(
1042            MinerCommand::RPC {
1043                command: "voltageget",
1044                parameters: Some(Value::String("2".to_string())),
1045            },
1046            Value::from_str(VOLTAGEGET_2)?,
1047        );
1048        results.insert(
1049            MinerCommand::RPC {
1050                command: "healthchipget",
1051                parameters: Some(Value::String("0".to_string())),
1052            },
1053            Value::from_str(HEALTHCHIPGET_0)?,
1054        );
1055        results.insert(
1056            MinerCommand::RPC {
1057                command: "healthchipget",
1058                parameters: Some(Value::String("1".to_string())),
1059            },
1060            Value::from_str(HEALTHCHIPGET_1)?,
1061        );
1062        results.insert(
1063            MinerCommand::RPC {
1064                command: "healthchipget",
1065                parameters: Some(Value::String("2".to_string())),
1066            },
1067            Value::from_str(HEALTHCHIPGET_2)?,
1068        );
1069
1070        let mock_api = MockAPIClient::new(results);
1071
1072        let mut collector = DataCollector::new_with_client(&miner, &mock_api);
1073        let data = collector.collect_all().await;
1074
1075        let miner_data = miner.parse_data(data);
1076
1077        assert_eq!(
1078            miner_data.mac,
1079            Some(MacAddr::from_str("62:f7:5e:b7:10:46")?)
1080        );
1081        assert_eq!(
1082            miner_data.serial_number,
1083            Some("JYZZB0UBDJABF06RB".to_string())
1084        );
1085        assert_eq!(miner_data.hostname, Some("UrlacherS19k".to_string()));
1086        assert_eq!(miner_data.api_version, Some("3.7".to_string()));
1087        assert_eq!(
1088            miner_data.firmware_version,
1089            Some("2025.4.8.220305".to_string())
1090        );
1091        assert_eq!(
1092            miner_data.control_board_version,
1093            Some(MinerControlBoard::CVITek)
1094        );
1095        assert_eq!(miner_data.wattage, Some(Power::from_watts(1051f64)));
1096        assert_eq!(miner_data.wattage_limit, Some(Power::from_watts(1188f64)));
1097        assert_eq!(miner_data.fans.len(), 4);
1098        assert_eq!(miner_data.hashboards[0].chips.len(), 77);
1099        assert_eq!(miner_data.pools.len(), 4);
1100
1101        Ok(())
1102    }
1103}