asic_rs/miners/backends/antminer/v2020/
mod.rs

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"), // Cant find on 2022 firmware, does exist on 2025 firmware for XP
323                    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                // Parse "3250 W" format
607                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        // For S21+ Hyd models, use inlet/outlet temperature average
631        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                    // Inlet temp (index 0) and outlet temp (index 2)
640                    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                    // 's' means success/ok
676                    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}