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), _ => 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}