1use anyhow::{Result, anyhow};
2use async_trait::async_trait;
3use macaddr::MacAddr;
4use measurements::{AngularVelocity, Frequency, Power, Temperature};
5use serde_json::Value;
6use std::collections::HashMap;
7use std::net::IpAddr;
8use std::str::FromStr;
9use std::time::Duration;
10
11use crate::data::board::BoardData;
12use crate::data::device::{DeviceInfo, HashAlgorithm, MinerFirmware, MinerMake, MinerModel};
13use crate::data::fan::FanData;
14use crate::data::hashrate::{HashRate, HashRateUnit};
15use crate::data::message::{MessageSeverity, MinerMessage};
16use crate::data::pool::{PoolData, PoolURL};
17use crate::miners::backends::traits::*;
18use crate::miners::commands::MinerCommand;
19use crate::miners::data::{
20 DataCollector, DataExtensions, DataExtractor, DataField, DataLocation, get_by_pointer,
21};
22
23use rpc::AntMinerRPCAPI;
24use web::AntMinerWebAPI;
25
26mod rpc;
27mod web;
28
29#[derive(Debug)]
30pub struct AntMinerV2020 {
31 pub ip: IpAddr,
32 pub rpc: AntMinerRPCAPI,
33 pub web: AntMinerWebAPI,
34 pub device_info: DeviceInfo,
35}
36
37impl AntMinerV2020 {
38 pub fn new(ip: IpAddr, model: MinerModel) -> Self {
39 AntMinerV2020 {
40 ip,
41 rpc: AntMinerRPCAPI::new(ip),
42 web: AntMinerWebAPI::new(ip),
43 device_info: DeviceInfo::new(
44 MinerMake::AntMiner,
45 model,
46 MinerFirmware::Stock,
47 HashAlgorithm::SHA256,
48 ),
49 }
50 }
51
52 pub fn with_auth(
53 ip: IpAddr,
54 model: MinerModel,
55 firmware: MinerFirmware,
56 username: String,
57 password: String,
58 ) -> Self {
59 AntMinerV2020 {
60 ip,
61 rpc: AntMinerRPCAPI::new(ip),
62 web: AntMinerWebAPI::with_auth(ip, username, password),
63 device_info: DeviceInfo::new(
64 MinerMake::AntMiner,
65 model,
66 firmware,
67 HashAlgorithm::SHA256,
68 ),
69 }
70 }
71
72 fn parse_temp_string(temp_str: &str) -> Option<Temperature> {
73 let temps: Vec<f64> = temp_str
74 .split('-')
75 .filter_map(|s| s.parse().ok())
76 .filter(|&temp| temp > 0.0)
77 .collect();
78
79 if !temps.is_empty() {
80 let avg = temps.iter().sum::<f64>() / temps.len() as f64;
81 Some(Temperature::from_celsius(avg))
82 } else {
83 None
84 }
85 }
86
87 fn _calculate_average_temp_s21_hyd(chain: &Value) -> Option<Temperature> {
88 let mut temps = Vec::new();
89
90 if let Some(temp_pic) = chain.get("temp_pic").and_then(|v| v.as_array()) {
91 for i in 1..=3 {
92 if let Some(temp) = temp_pic.get(i).and_then(|v| v.as_f64())
93 && temp != 0.0
94 {
95 temps.push(temp);
96 }
97 }
98 }
99
100 if let Some(temp_pcb) = chain.get("temp_pcb").and_then(|v| v.as_array()) {
101 if let Some(temp) = temp_pcb.get(1).and_then(|v| v.as_f64())
102 && temp != 0.0
103 {
104 temps.push(temp);
105 }
106 if let Some(temp) = temp_pcb.get(3).and_then(|v| v.as_f64())
107 && temp != 0.0
108 {
109 temps.push(temp);
110 }
111 }
112
113 if !temps.is_empty() {
114 let avg = temps.iter().sum::<f64>() / temps.len() as f64;
115 Some(Temperature::from_celsius(avg))
116 } else {
117 None
118 }
119 }
120
121 fn _calculate_average_temp_pcb(chain: &Value) -> Option<Temperature> {
122 if let Some(temp_pcb) = chain.get("temp_pcb").and_then(|v| v.as_array()) {
123 let temps: Vec<f64> = temp_pcb
124 .iter()
125 .filter_map(|v| v.as_f64())
126 .filter(|&temp| temp != 0.0)
127 .collect();
128
129 if !temps.is_empty() {
130 let avg = temps.iter().sum::<f64>() / temps.len() as f64;
131 Some(Temperature::from_celsius(avg))
132 } else {
133 None
134 }
135 } else {
136 None
137 }
138 }
139
140 fn _calculate_average_temp_chip(chain: &Value) -> Option<Temperature> {
141 if let Some(temp_chip) = chain.get("temp_chip").and_then(|v| v.as_array()) {
142 let temps: Vec<f64> = temp_chip
143 .iter()
144 .filter_map(|v| v.as_f64())
145 .filter(|&temp| temp != 0.0)
146 .collect();
147
148 if !temps.is_empty() {
149 let avg = temps.iter().sum::<f64>() / temps.len() as f64;
150 Some(Temperature::from_celsius(avg))
151 } else {
152 None
153 }
154 } else {
155 None
156 }
157 }
158}
159
160#[async_trait]
161impl APIClient for AntMinerV2020 {
162 async fn get_api_result(&self, command: &MinerCommand) -> Result<Value> {
163 match command {
164 MinerCommand::RPC { .. } => self.rpc.get_api_result(command).await,
165 MinerCommand::WebAPI { .. } => self.web.get_api_result(command).await,
166 _ => Err(anyhow!("Unsupported command type for Antminer API")),
167 }
168 }
169}
170
171impl GetDataLocations for AntMinerV2020 {
172 fn get_locations(&self, data_field: DataField) -> Vec<DataLocation> {
173 let version_cmd = MinerCommand::RPC {
174 command: "version",
175 parameters: None,
176 };
177
178 let stats_cmd = MinerCommand::RPC {
179 command: "stats",
180 parameters: None,
181 };
182
183 let summary_cmd = MinerCommand::RPC {
184 command: "summary",
185 parameters: None,
186 };
187
188 let pools_cmd = MinerCommand::RPC {
189 command: "pools",
190 parameters: None,
191 };
192
193 let system_info_cmd = MinerCommand::WebAPI {
194 command: "get_system_info",
195 parameters: None,
196 };
197
198 let blink_status_cmd = MinerCommand::WebAPI {
199 command: "get_blink_status",
200 parameters: None,
201 };
202
203 let miner_conf_cmd = MinerCommand::WebAPI {
204 command: "get_miner_conf",
205 parameters: None,
206 };
207
208 let web_summary_cmd = MinerCommand::WebAPI {
209 command: "summary",
210 parameters: None,
211 };
212
213 match data_field {
214 DataField::Mac => vec![(
215 system_info_cmd,
216 DataExtractor {
217 func: get_by_pointer,
218 key: Some("/macaddr"),
219 tag: None,
220 },
221 )],
222 DataField::ApiVersion => vec![(
223 version_cmd,
224 DataExtractor {
225 func: get_by_pointer,
226 key: Some("/VERSION/0/API"),
227 tag: None,
228 },
229 )],
230 DataField::FirmwareVersion => vec![(
231 version_cmd,
232 DataExtractor {
233 func: get_by_pointer,
234 key: Some("/VERSION/0/CompileTime"),
235 tag: None,
236 },
237 )],
238 DataField::Hostname => vec![(
239 system_info_cmd,
240 DataExtractor {
241 func: get_by_pointer,
242 key: Some("/hostname"),
243 tag: None,
244 },
245 )],
246 DataField::Hashrate => vec![(
247 summary_cmd,
248 DataExtractor {
249 func: get_by_pointer,
250 key: Some("/SUMMARY/0/GHS 5s"),
251 tag: None,
252 },
253 )],
254 DataField::ExpectedHashrate => vec![(
255 stats_cmd,
256 DataExtractor {
257 func: get_by_pointer,
258 key: Some("/STATS/1/total_rateideal"),
259 tag: None,
260 },
261 )],
262 DataField::Fans => vec![(
263 stats_cmd,
264 DataExtractor {
265 func: get_by_pointer,
266 key: Some("/STATS/1"),
267 tag: None,
268 },
269 )],
270 DataField::Hashboards => vec![(
271 stats_cmd,
272 DataExtractor {
273 func: get_by_pointer,
274 key: Some("/STATS/1"),
275 tag: None,
276 },
277 )],
278 DataField::LightFlashing => vec![(
279 blink_status_cmd,
280 DataExtractor {
281 func: get_by_pointer,
282 key: Some("/blink"),
283 tag: None,
284 },
285 )],
286 DataField::IsMining => vec![(
287 miner_conf_cmd,
288 DataExtractor {
289 func: get_by_pointer,
290 key: Some("/bitmain-work-mode"),
291 tag: None,
292 },
293 )],
294 DataField::Uptime => vec![(
295 stats_cmd,
296 DataExtractor {
297 func: get_by_pointer,
298 key: Some("/STATS/1/Elapsed"),
299 tag: None,
300 },
301 )],
302 DataField::Pools => vec![(
303 pools_cmd,
304 DataExtractor {
305 func: get_by_pointer,
306 key: Some("/POOLS"),
307 tag: None,
308 },
309 )],
310 DataField::Wattage => vec![(
311 stats_cmd,
312 DataExtractor {
313 func: get_by_pointer,
314 key: Some("/STATS/1"),
315 tag: None,
316 },
317 )],
318 DataField::SerialNumber => vec![(
319 system_info_cmd,
320 DataExtractor {
321 func: get_by_pointer,
322 key: Some("/serial_no"), tag: None,
324 },
325 )],
326 DataField::Messages => vec![(
327 web_summary_cmd,
328 DataExtractor {
329 func: get_by_pointer,
330 key: Some("/SUMMARY/0/status"),
331 tag: None,
332 },
333 )],
334 _ => vec![],
335 }
336 }
337}
338
339impl GetIP for AntMinerV2020 {
340 fn get_ip(&self) -> IpAddr {
341 self.ip
342 }
343}
344
345impl GetDeviceInfo for AntMinerV2020 {
346 fn get_device_info(&self) -> DeviceInfo {
347 self.device_info
348 }
349}
350
351impl CollectData for AntMinerV2020 {
352 fn get_collector(&self) -> DataCollector<'_> {
353 DataCollector::new(self)
354 }
355}
356
357impl GetMAC for AntMinerV2020 {
358 fn parse_mac(&self, data: &HashMap<DataField, Value>) -> Option<MacAddr> {
359 data.extract::<String>(DataField::Mac)
360 .and_then(|s| MacAddr::from_str(&s).ok())
361 }
362}
363
364impl GetHostname for AntMinerV2020 {
365 fn parse_hostname(&self, data: &HashMap<DataField, Value>) -> Option<String> {
366 data.extract::<String>(DataField::Hostname)
367 }
368}
369
370impl GetApiVersion for AntMinerV2020 {
371 fn parse_api_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
372 data.extract::<String>(DataField::ApiVersion)
373 }
374}
375
376impl GetFirmwareVersion for AntMinerV2020 {
377 fn parse_firmware_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
378 data.extract::<String>(DataField::FirmwareVersion)
379 }
380}
381
382impl GetHashboards for AntMinerV2020 {
383 fn parse_hashboards(&self, data: &HashMap<DataField, Value>) -> Vec<BoardData> {
384 let mut hashboards: Vec<BoardData> = Vec::new();
385 let board_count = self.device_info.hardware.boards.unwrap_or(3);
386
387 for idx in 0..board_count {
388 hashboards.push(BoardData {
389 hashrate: None,
390 position: idx,
391 expected_hashrate: None,
392 board_temperature: None,
393 intake_temperature: None,
394 outlet_temperature: None,
395 expected_chips: self.device_info.hardware.chips,
396 working_chips: None,
397 serial_number: None,
398 chips: vec![],
399 voltage: None,
400 frequency: None,
401 tuned: Some(false),
402 active: Some(false),
403 });
404 }
405
406 if let Some(stats_data) = data.get(&DataField::Hashboards) {
407 for idx in 1..=board_count {
408 let board_idx = (idx - 1) as usize;
409 if board_idx >= hashboards.len() {
410 break;
411 }
412
413 if let Some(hashrate) = stats_data
414 .get(format!("chain_rate{}", idx))
415 .and_then(|v| v.as_str())
416 .and_then(|s| s.parse::<f64>().ok())
417 .map(|f| {
418 HashRate {
419 value: f,
420 unit: HashRateUnit::GigaHash,
421 algo: String::from("SHA256"),
422 }
423 .as_unit(HashRateUnit::TeraHash)
424 })
425 {
426 hashboards[board_idx].hashrate = Some(hashrate);
427 }
428
429 if let Some(working_chips) = stats_data
430 .get(format!("chain_acn{}", idx))
431 .and_then(|v| v.as_u64())
432 .map(|u| u as u16)
433 {
434 hashboards[board_idx].working_chips = Some(working_chips);
435 }
436
437 if let Some(board_temp) = stats_data
438 .get(format!("temp_pcb{}", idx))
439 .and_then(|v| v.as_str())
440 .and_then(Self::parse_temp_string)
441 {
442 hashboards[board_idx].board_temperature = Some(board_temp);
443 }
444
445 if let Some(frequency) = stats_data
446 .get(format!("freq{}", idx))
447 .and_then(|v| v.as_u64())
448 .map(|f| Frequency::from_megahertz(f as f64))
449 {
450 hashboards[board_idx].frequency = Some(frequency);
451 }
452
453 let has_hashrate = hashboards[board_idx]
454 .hashrate
455 .as_ref()
456 .map(|h| h.value > 0.0)
457 .unwrap_or(false);
458 let has_chips = hashboards[board_idx]
459 .working_chips
460 .map(|chips| chips > 0)
461 .unwrap_or(false);
462
463 hashboards[board_idx].active = Some(has_hashrate || has_chips);
464 hashboards[board_idx].tuned = Some(has_hashrate || has_chips);
465 }
466 }
467
468 hashboards
469 }
470}
471
472impl GetHashrate for AntMinerV2020 {
473 fn parse_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
474 data.extract_map::<f64, _>(DataField::Hashrate, |f| {
475 HashRate {
476 value: f,
477 unit: HashRateUnit::GigaHash,
478 algo: String::from("SHA256"),
479 }
480 .as_unit(HashRateUnit::TeraHash)
481 })
482 }
483}
484
485impl GetExpectedHashrate for AntMinerV2020 {
486 fn parse_expected_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
487 data.extract_map::<f64, _>(DataField::ExpectedHashrate, |f| {
488 HashRate {
489 value: f,
490 unit: HashRateUnit::GigaHash,
491 algo: String::from("SHA256"),
492 }
493 .as_unit(HashRateUnit::TeraHash)
494 })
495 }
496}
497
498impl GetFans for AntMinerV2020 {
499 fn parse_fans(&self, data: &HashMap<DataField, Value>) -> Vec<FanData> {
500 let mut fans: Vec<FanData> = Vec::new();
501
502 if let Some(stats_data) = data.get(&DataField::Fans) {
503 for i in 1..=self.device_info.hardware.fans.unwrap_or(4) {
504 if let Some(fan_speed) =
505 stats_data.get(format!("fan{}", i)).and_then(|v| v.as_f64())
506 && fan_speed > 0.0
507 {
508 fans.push(FanData {
509 position: (i - 1) as i16,
510 rpm: Some(AngularVelocity::from_rpm(fan_speed)),
511 });
512 }
513 }
514 }
515
516 fans
517 }
518}
519
520impl GetLightFlashing for AntMinerV2020 {
521 fn parse_light_flashing(&self, data: &HashMap<DataField, Value>) -> Option<bool> {
522 data.extract::<bool>(DataField::LightFlashing).or_else(|| {
523 data.extract::<String>(DataField::LightFlashing)
524 .map(|s| s.to_lowercase() == "true" || s == "1")
525 })
526 }
527}
528
529impl GetUptime for AntMinerV2020 {
530 fn parse_uptime(&self, data: &HashMap<DataField, Value>) -> Option<Duration> {
531 data.extract_map::<u64, _>(DataField::Uptime, Duration::from_secs)
532 }
533}
534
535impl GetIsMining for AntMinerV2020 {
536 fn parse_is_mining(&self, data: &HashMap<DataField, Value>) -> bool {
537 data.extract::<String>(DataField::IsMining)
538 .map(|status| {
539 let status_lower = status.to_lowercase();
540 status_lower != "stopped" && status_lower != "idle" && status_lower != "sleep"
541 })
542 .or_else(|| data.extract::<f64>(DataField::Hashrate).map(|hr| hr > 0.0))
543 .unwrap_or(false)
544 }
545}
546
547impl GetPools for AntMinerV2020 {
548 fn parse_pools(&self, data: &HashMap<DataField, Value>) -> Vec<PoolData> {
549 let mut pools: Vec<PoolData> = Vec::new();
550
551 if let Some(pools_data) = data.get(&DataField::Pools)
552 && let Some(pools_array) = pools_data.as_array()
553 {
554 for (idx, pool_info) in pools_array.iter().enumerate() {
555 let url = pool_info
556 .get("URL")
557 .and_then(|v| v.as_str())
558 .map(|s| PoolURL::from(s.to_string()));
559
560 let user = pool_info
561 .get("User")
562 .and_then(|v| v.as_str())
563 .map(String::from);
564
565 let alive = pool_info
566 .get("Status")
567 .and_then(|v| v.as_str())
568 .map(|s| s == "Alive");
569
570 let active = pool_info.get("Stratum Active").and_then(|v| v.as_bool());
571
572 let accepted_shares = pool_info.get("Accepted").and_then(|v| v.as_u64());
573
574 let rejected_shares = pool_info.get("Rejected").and_then(|v| v.as_u64());
575
576 pools.push(PoolData {
577 position: Some(idx as u16),
578 url,
579 accepted_shares,
580 rejected_shares,
581 active,
582 alive,
583 user,
584 });
585 }
586 }
587
588 pools
589 }
590}
591
592impl GetSerialNumber for AntMinerV2020 {
593 fn parse_serial_number(&self, data: &HashMap<DataField, Value>) -> Option<String> {
594 data.extract::<String>(DataField::SerialNumber)
595 }
596}
597
598impl GetControlBoardVersion for AntMinerV2020 {}
599
600impl GetWattage for AntMinerV2020 {
601 fn parse_wattage(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
602 if let Some(stats_data) = data.get(&DataField::Wattage) {
603 if let Some(chain_power) = stats_data.get("chain_power")
604 && let Some(power_str) = chain_power.as_str()
605 {
606 if let Some(watt_part) = power_str.split_whitespace().next()
608 && let Ok(watts) = watt_part.parse::<f64>()
609 {
610 return Some(Power::from_watts(watts));
611 }
612 }
613
614 if let Some(power) = stats_data
615 .get("power")
616 .or_else(|| stats_data.get("Power"))
617 .and_then(|v| v.as_f64())
618 {
619 return Some(Power::from_watts(power));
620 }
621 }
622 None
623 }
624}
625
626impl GetWattageLimit for AntMinerV2020 {}
627
628impl GetFluidTemperature for AntMinerV2020 {
629 fn parse_fluid_temperature(&self, data: &HashMap<DataField, Value>) -> Option<Temperature> {
630 if self.device_info.model.to_string().contains("S21+ Hyd")
632 && let Some(hashboards_data) = data.get(&DataField::Hashboards)
633 && let Some(chains) = hashboards_data.as_array()
634 {
635 let mut temps = Vec::new();
636
637 for chain in chains {
638 if let Some(temp_pcb) = chain.get("temp_pcb").and_then(|v| v.as_array()) {
639 if let Some(inlet) = temp_pcb.first().and_then(|v| v.as_f64())
641 && inlet != 0.0
642 {
643 temps.push(inlet);
644 }
645 if let Some(outlet) = temp_pcb.get(2).and_then(|v| v.as_f64())
646 && outlet != 0.0
647 {
648 temps.push(outlet);
649 }
650 }
651 }
652
653 if !temps.is_empty() {
654 let avg = temps.iter().sum::<f64>() / temps.len() as f64;
655 return Some(Temperature::from_celsius(avg));
656 }
657 }
658 None
659 }
660}
661
662impl GetPsuFans for AntMinerV2020 {}
663
664impl GetMessages for AntMinerV2020 {
665 fn parse_messages(&self, data: &HashMap<DataField, Value>) -> Vec<MinerMessage> {
666 let mut messages = Vec::new();
667
668 if let Some(status_data) = data.get(&DataField::Messages)
669 && let Some(status_array) = status_data.as_array()
670 {
671 for (idx, item) in status_array.iter().enumerate() {
672 if let Some(status) = item.get("status").and_then(|v| v.as_str())
673 && status != "s"
674 {
675 let message_text = item
677 .get("msg")
678 .and_then(|v| v.as_str())
679 .unwrap_or("Unknown error")
680 .to_string();
681
682 let severity = match status.to_lowercase().as_str() {
683 "e" => MessageSeverity::Error,
684 "w" => MessageSeverity::Warning,
685 _ => MessageSeverity::Info,
686 };
687
688 messages.push(MinerMessage::new(0, idx as u64, message_text, severity));
689 }
690 }
691 }
692
693 messages
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::data::device::models::antminer::AntMinerModel;
701 use crate::test::api::MockAPIClient;
702 use crate::test::json::bmminer::antminer_modern::{
703 AM_DEVS, AM_POOLS, AM_STATS, AM_SUMMARY, AM_VERSION,
704 };
705
706 #[tokio::test]
707 async fn test_antminer() {
708 let miner = AntMinerV2020::new(
709 IpAddr::from([127, 0, 0, 1]),
710 MinerModel::AntMiner(AntMinerModel::S19Pro),
711 );
712
713 let mut results = HashMap::new();
714
715 let stats_cmd = MinerCommand::RPC {
716 command: "stats",
717 parameters: None,
718 };
719
720 let version_cmd = MinerCommand::RPC {
721 command: "version",
722 parameters: None,
723 };
724
725 let summary_cmd = MinerCommand::RPC {
726 command: "summary",
727 parameters: None,
728 };
729
730 let devs_cmd = MinerCommand::RPC {
731 command: "devs",
732 parameters: None,
733 };
734
735 let pools_cmd = MinerCommand::RPC {
736 command: "pools",
737 parameters: None,
738 };
739
740 results.insert(stats_cmd, Value::from_str(AM_STATS).unwrap());
741 results.insert(version_cmd, Value::from_str(AM_VERSION).unwrap());
742 results.insert(summary_cmd, Value::from_str(AM_SUMMARY).unwrap());
743 results.insert(devs_cmd, Value::from_str(AM_DEVS).unwrap());
744 results.insert(pools_cmd, Value::from_str(AM_POOLS).unwrap());
745
746 let mock_api = MockAPIClient::new(results);
747
748 let mut collector = DataCollector::new_with_client(&miner, &mock_api);
749 let data = collector.collect_all().await;
750
751 let miner_data = miner.parse_data(data);
752
753 assert_eq!(miner_data.ip.to_string(), "127.0.0.1".to_owned());
754 assert_eq!(miner_data.hashboards.len(), 3);
755 assert_eq!(miner_data.light_flashing, None);
756 assert_eq!(miner_data.fans.len(), 4);
757 assert_eq!(
758 miner_data.expected_hashrate.unwrap(),
759 HashRate {
760 value: 110.0,
761 unit: HashRateUnit::TeraHash,
762 algo: "SHA256".to_string(),
763 }
764 );
765 assert_eq!(
766 miner_data.hashrate.unwrap(),
767 HashRate {
768 value: 110.56689,
769 unit: HashRateUnit::TeraHash,
770 algo: "SHA256".to_string(),
771 }
772 );
773 }
774}