asic_rs/miners/backends/avalonminer/avalon_a/
mod.rs

1use anyhow::{Result, anyhow};
2use async_trait::async_trait;
3use macaddr::MacAddr;
4use measurements::{AngularVelocity, Power, Temperature, Voltage};
5use serde_json::{Value, json};
6use std::collections::HashMap;
7use std::net::IpAddr;
8use std::str::FromStr;
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10
11use crate::data::board::{BoardData, ChipData};
12use crate::data::device::MinerMake;
13use crate::data::device::{DeviceInfo, HashAlgorithm, MinerFirmware, MinerModel};
14use crate::data::fan::FanData;
15use crate::data::hashrate::{HashRate, HashRateUnit};
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::AvalonMinerRPCAPI;
24
25mod rpc;
26
27#[derive(Debug)]
28pub struct AvalonAMiner {
29    ip: IpAddr,
30    rpc: AvalonMinerRPCAPI,
31    device_info: DeviceInfo,
32}
33
34impl AvalonAMiner {
35    pub fn new(ip: IpAddr, model: MinerModel) -> Self {
36        Self {
37            ip,
38            rpc: AvalonMinerRPCAPI::new(ip),
39            device_info: DeviceInfo::new(
40                MinerMake::AvalonMiner,
41                model,
42                MinerFirmware::Stock,
43                HashAlgorithm::SHA256,
44            ),
45        }
46    }
47}
48
49#[async_trait]
50impl APIClient for AvalonAMiner {
51    async fn get_api_result(&self, command: &MinerCommand) -> Result<Value> {
52        match command {
53            MinerCommand::RPC { .. } => self.rpc.get_api_result(command).await,
54            _ => Err(anyhow!("Unsupported command type for AvalonMiner API")),
55        }
56    }
57}
58
59#[async_trait]
60impl Restart for AvalonAMiner {
61    async fn restart(&self) -> Result<bool> {
62        let data = self.rpc.send_command("restart", false, None).await?;
63
64        if let Some(status) = data.get("STATUS").and_then(|s| s.as_str()) {
65            return Ok(status == "RESTART");
66        }
67
68        Ok(false)
69    }
70}
71#[async_trait]
72impl Pause for AvalonAMiner {
73    async fn pause(&self, after: Option<Duration>) -> Result<bool> {
74        let offset = after.unwrap_or(Duration::from_secs(5));
75        let shutdown_time = SystemTime::now() + offset;
76
77        let timestamp = shutdown_time
78            .duration_since(UNIX_EPOCH)
79            .expect("Shutdown time is before UNIX epoch")
80            .as_secs();
81
82        let data = self
83            .rpc
84            .send_command(
85                "ascset",
86                false,
87                Some(json!(["0", format!("softoff,1:{}", timestamp)])),
88            )
89            .await?;
90
91        if let Some(status) = data.get("STATUS").and_then(|s| s.as_array())
92            && !status.is_empty()
93            && let Some(status_code) = status[0].get("STATUS").and_then(|s| s.as_str())
94            && status_code == "I"
95            && let Some(msg) = status[0].get("Msg").and_then(|m| m.as_str())
96        {
97            return Ok(msg.contains("success softoff"));
98        }
99
100        Ok(false)
101    }
102}
103#[async_trait]
104impl Resume for AvalonAMiner {
105    async fn resume(&self, after: Option<Duration>) -> Result<bool> {
106        let offset = after.unwrap_or(Duration::from_secs(5));
107        let shutdown_time = SystemTime::now() + offset;
108
109        let timestamp = shutdown_time
110            .duration_since(UNIX_EPOCH)
111            .expect("Shutdown time is before UNIX epoch")
112            .as_secs();
113
114        let data = self
115            .rpc
116            .send_command(
117                "ascset",
118                false,
119                Some(json!(["0", format!("softon,1:{}", timestamp)])),
120            )
121            .await?;
122
123        if let Some(status) = data.get("STATUS").and_then(|s| s.as_array())
124            && !status.is_empty()
125            && let Some(status_code) = status[0].get("STATUS").and_then(|s| s.as_str())
126            && status_code == "I"
127            && let Some(msg) = status[0].get("Msg").and_then(|m| m.as_str())
128        {
129            return Ok(msg.contains("success softon"));
130        }
131        Ok(false)
132    }
133}
134#[async_trait]
135impl SetFaultLight for AvalonAMiner {
136    async fn set_fault_light(&self, fault: bool) -> Result<bool> {
137        let command = if fault { "1-1" } else { "1-0" };
138
139        let data = self
140            .rpc
141            .send_command("ascset", false, Some(json!(["0", "led", command])))
142            .await?;
143
144        if let Some(status) = data.get("STATUS").and_then(|s| s.as_array())
145            && let Some(msg) = status
146                .first()
147                .and_then(|s| s.get("Msg"))
148                .and_then(|m| m.as_str())
149        {
150            return Ok(msg == "ASC 0 set OK");
151        }
152
153        Err(anyhow!("Failed to set fault light to {}", command))
154    }
155}
156
157#[async_trait]
158impl SetPowerLimit for AvalonAMiner {
159    async fn set_power_limit(&self, limit: Power) -> Result<bool> {
160        let data = self
161            .rpc
162            .send_command(
163                "ascset",
164                false,
165                Some(json!(["0", "worklevel,set", limit.to_string()])),
166            )
167            .await?;
168
169        if let Some(status) = data.get("STATUS").and_then(|s| s.as_array())
170            && !status.is_empty()
171            && let Some(msg) = status[0].get("Msg").and_then(|m| m.as_str())
172        {
173            return Ok(msg == "ASC 0 set OK");
174        }
175
176        Err(anyhow!("Failed to set power limit"))
177    }
178}
179
180impl GetDataLocations for AvalonAMiner {
181    fn get_locations(&self, data_field: DataField) -> Vec<DataLocation> {
182        let version_cmd: MinerCommand = MinerCommand::RPC {
183            command: "version",
184            parameters: None,
185        };
186        let stats_cmd: MinerCommand = MinerCommand::RPC {
187            command: "stats",
188            parameters: None,
189        };
190        let devs_cmd: MinerCommand = MinerCommand::RPC {
191            command: "devs",
192            parameters: None,
193        };
194        let pools_cmd: MinerCommand = MinerCommand::RPC {
195            command: "pools",
196            parameters: None,
197        };
198
199        match data_field {
200            DataField::Mac => vec![(
201                version_cmd,
202                DataExtractor {
203                    func: get_by_pointer,
204                    key: Some("/VERSION/0/MAC"),
205                    tag: None,
206                },
207            )],
208            DataField::ControlBoardVersion => vec![(
209                version_cmd,
210                DataExtractor {
211                    func: get_by_pointer,
212                    key: Some("/VERSION/0/HWTYPE"),
213                    tag: None,
214                },
215            )],
216            DataField::ApiVersion => vec![(
217                version_cmd,
218                DataExtractor {
219                    func: get_by_pointer,
220                    key: Some("/VERSION/0/API"),
221                    tag: None,
222                },
223            )],
224            DataField::FirmwareVersion => vec![(
225                version_cmd,
226                DataExtractor {
227                    func: get_by_pointer,
228                    key: Some("/VERSION/0/VERSION"),
229                    tag: None,
230                },
231            )],
232            DataField::Hashrate => vec![(
233                devs_cmd,
234                DataExtractor {
235                    func: get_by_pointer,
236                    key: Some("/DEVS/0/MHS 1m"),
237                    tag: None,
238                },
239            )],
240            DataField::ExpectedHashrate => vec![(
241                stats_cmd,
242                DataExtractor {
243                    func: get_by_pointer,
244                    key: Some("/STATS/0/MM ID0/STATS/GHSmm"),
245                    tag: None,
246                },
247            )],
248            DataField::Hashboards => vec![(
249                stats_cmd,
250                DataExtractor {
251                    func: get_by_pointer,
252                    key: Some("/STATS/0/MM ID0"),
253                    tag: None,
254                },
255            )],
256            DataField::Wattage => vec![(
257                stats_cmd,
258                DataExtractor {
259                    func: get_by_pointer,
260                    key: Some("/STATS/0/MM ID0/PS"),
261                    tag: None,
262                },
263            )],
264            DataField::WattageLimit => vec![(
265                stats_cmd,
266                DataExtractor {
267                    func: get_by_pointer,
268                    key: Some("/STATS/0/MM ID0/PS"),
269                    tag: None,
270                },
271            )],
272            DataField::Fans => vec![(
273                stats_cmd,
274                DataExtractor {
275                    func: get_by_pointer,
276                    key: Some("/STATS/0/MM ID0"),
277                    tag: None,
278                },
279            )],
280            DataField::LightFlashing => vec![(
281                stats_cmd,
282                DataExtractor {
283                    func: get_by_pointer,
284                    key: Some("/STATS/0/MM ID0/Led"),
285                    tag: None,
286                },
287            )],
288            DataField::Uptime => vec![(
289                stats_cmd,
290                DataExtractor {
291                    func: get_by_pointer,
292                    key: Some("/STATS/0/Elapsed"),
293                    tag: None,
294                },
295            )],
296            DataField::Pools => vec![(
297                pools_cmd,
298                DataExtractor {
299                    func: get_by_pointer,
300                    key: Some("/POOLS"),
301                    tag: None,
302                },
303            )],
304            _ => vec![],
305        }
306    }
307}
308
309impl GetIP for AvalonAMiner {
310    fn get_ip(&self) -> IpAddr {
311        self.ip
312    }
313}
314
315impl GetDeviceInfo for AvalonAMiner {
316    fn get_device_info(&self) -> DeviceInfo {
317        self.device_info
318    }
319}
320
321impl CollectData for AvalonAMiner {
322    fn get_collector(&self) -> DataCollector<'_> {
323        DataCollector::new(self)
324    }
325}
326
327impl GetMAC for AvalonAMiner {
328    fn parse_mac(&self, data: &HashMap<DataField, Value>) -> Option<MacAddr> {
329        data.extract::<String>(DataField::Mac).and_then(|raw| {
330            let mut mac = raw.trim().to_lowercase();
331            // compact 12-digit → colon-separated
332            if mac.len() == 12 && !mac.contains(':') {
333                let mut colon = String::with_capacity(17);
334                for (i, byte) in mac.chars().enumerate() {
335                    if i > 0 && i % 2 == 0 {
336                        colon.push(':');
337                    }
338                    colon.push(byte);
339                }
340                mac = colon;
341            }
342            MacAddr::from_str(&mac).ok()
343        })
344    }
345}
346
347impl GetSerialNumber for AvalonAMiner {}
348
349impl GetControlBoardVersion for AvalonAMiner {
350    fn parse_control_board_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
351        data.extract::<String>(DataField::ControlBoardVersion)
352    }
353}
354
355impl GetHostname for AvalonAMiner {}
356
357impl GetApiVersion for AvalonAMiner {
358    fn parse_api_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
359        data.extract::<String>(DataField::ApiVersion)
360    }
361}
362
363impl GetFirmwareVersion for AvalonAMiner {
364    fn parse_firmware_version(&self, data: &HashMap<DataField, Value>) -> Option<String> {
365        data.extract::<String>(DataField::FirmwareVersion)
366    }
367}
368
369impl GetHashboards for AvalonAMiner {
370    fn parse_hashboards(&self, data: &HashMap<DataField, Value>) -> Vec<BoardData> {
371        let hw = &self.device_info.hardware;
372        let board_cnt = hw.boards.unwrap_or(1) as usize;
373        let chips_per = hw.chips.unwrap_or(0);
374
375        let hb_info = match data.get(&DataField::Hashboards).and_then(|v| v.as_object()) {
376            Some(v) => v,
377            _ => return Vec::new(),
378        };
379
380        (0..board_cnt)
381            .map(|idx| {
382                let _chip_temp = hb_info
383                    .get("MTmax")
384                    .and_then(|v| v.as_array())
385                    .and_then(|arr| arr.get(idx))
386                    .and_then(|v| v.as_f64())
387                    .map(Temperature::from_celsius);
388
389                let board_temp = hb_info
390                    .get("MTavg")
391                    .and_then(|v| v.as_array())
392                    .and_then(|arr| arr.get(idx))
393                    .and_then(|v| v.as_f64())
394                    .map(Temperature::from_celsius);
395
396                let intake_temp = hb_info
397                    .get("ITemp")
398                    .and_then(|v| v.as_array())
399                    .and_then(|arr| arr.get(idx))
400                    .and_then(|v| v.as_f64())
401                    .map(Temperature::from_celsius);
402
403                let hashrate = hb_info
404                    .get("MGHS")
405                    .and_then(|v| v.as_array())
406                    .and_then(|arr| arr.get(idx))
407                    .and_then(|v| v.as_f64())
408                    .map(|r| HashRate {
409                        value: r,
410                        unit: HashRateUnit::GigaHash,
411                        algo: "SHA256".into(),
412                    });
413
414                let chip_temps: Vec<f64> = hb_info
415                    .get(&format!("PVT_T{idx}"))
416                    .and_then(|v| v.as_array())
417                    .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
418                    .unwrap_or_default();
419
420                let chip_volts: Vec<f64> = hb_info
421                    .get(&format!("PVT_V{idx}"))
422                    .and_then(|v| v.as_array())
423                    .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
424                    .unwrap_or_default();
425
426                let chip_works: Vec<f64> = hb_info
427                    .get(&format!("MW{idx}"))
428                    .and_then(|v| v.as_array())
429                    .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
430                    .unwrap_or_default();
431
432                let mut chips = Vec::new();
433                let max_len = chip_temps.len().max(chip_volts.len()).max(chip_works.len());
434
435                for pos in 0..max_len {
436                    let temp = chip_temps.get(pos).copied().unwrap_or(0.0);
437                    let volt = chip_volts.get(pos).copied().unwrap_or(0.0);
438                    let work = chip_works.get(pos).copied().unwrap_or(0.0);
439
440                    if temp == 0.0 {
441                        continue;
442                    }
443
444                    chips.push(ChipData {
445                        position: pos as u16,
446                        temperature: Some(Temperature::from_celsius(temp)),
447                        voltage: Some(Voltage::from_millivolts(volt)),
448                        working: Some(work > 0.0),
449                        ..Default::default()
450                    });
451                }
452
453                let working_chips = chips.len() as u16;
454                let missing = working_chips == 0;
455
456                BoardData {
457                    position: idx as u8,
458                    expected_chips: Some(chips_per),
459                    working_chips: Some(working_chips),
460                    chips,
461                    intake_temperature: intake_temp,
462                    board_temperature: board_temp,
463                    hashrate,
464                    active: Some(!missing),
465                    ..Default::default()
466                }
467            })
468            .collect()
469    }
470}
471
472impl GetHashrate for AvalonAMiner {
473    fn parse_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
474        data.extract_map::<f64, _>(DataField::Hashrate, |f| HashRate {
475            value: f,
476            unit: HashRateUnit::MegaHash,
477            algo: "SHA256".into(),
478        })
479    }
480}
481
482impl GetExpectedHashrate for AvalonAMiner {
483    fn parse_expected_hashrate(&self, data: &HashMap<DataField, Value>) -> Option<HashRate> {
484        data.extract_map::<f64, _>(DataField::ExpectedHashrate, |f| HashRate {
485            value: f,
486            unit: HashRateUnit::GigaHash,
487            algo: "SHA256".into(),
488        })
489    }
490}
491
492impl GetFans for AvalonAMiner {
493    fn parse_fans(&self, data: &HashMap<DataField, Value>) -> Vec<FanData> {
494        let stats = match data.get(&DataField::Fans) {
495            Some(v) => v,
496            _ => return Vec::new(),
497        };
498
499        let expected_fans = self.device_info.hardware.fans.unwrap_or(0) as usize;
500        if expected_fans == 0 {
501            return Vec::new();
502        }
503
504        (1..=expected_fans)
505            .filter_map(|idx| {
506                let key = format!("Fan{idx}");
507                stats
508                    .get(&key)
509                    .and_then(|val| val.as_f64())
510                    .map(|rpm| FanData {
511                        position: idx as i16,
512                        rpm: Some(AngularVelocity::from_rpm(rpm)),
513                    })
514            })
515            .collect()
516    }
517}
518
519impl GetPsuFans for AvalonAMiner {}
520
521impl GetWattage for AvalonAMiner {
522    fn parse_wattage(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
523        let wattage = data.get(&DataField::Wattage).and_then(|v| v.as_array())?;
524        let wattage = wattage.get(4).and_then(|watts: &Value| watts.as_f64())?;
525        Some(Power::from_watts(wattage))
526    }
527}
528
529impl GetWattageLimit for AvalonAMiner {
530    fn parse_wattage_limit(&self, data: &HashMap<DataField, Value>) -> Option<Power> {
531        let limit = data
532            .get(&DataField::WattageLimit)
533            .and_then(|v| v.as_array())?;
534        let limit = limit.get(6).and_then(|watts: &Value| watts.as_f64())?;
535        Some(Power::from_watts(limit))
536    }
537}
538
539impl GetLightFlashing for AvalonAMiner {
540    fn parse_light_flashing(&self, data: &HashMap<DataField, Value>) -> Option<bool> {
541        data.extract::<bool>(DataField::LightFlashing)
542    }
543}
544
545impl GetMessages for AvalonAMiner {}
546
547impl GetUptime for AvalonAMiner {
548    fn parse_uptime(&self, data: &HashMap<DataField, Value>) -> Option<Duration> {
549        data.extract_map::<u64, _>(DataField::Uptime, Duration::from_secs)
550    }
551}
552
553impl GetFluidTemperature for AvalonAMiner {}
554impl GetIsMining for AvalonAMiner {}
555
556impl GetPools for AvalonAMiner {
557    fn parse_pools(&self, data: &HashMap<DataField, Value>) -> Vec<PoolData> {
558        data.get(&DataField::Pools)
559            .and_then(|v| v.as_array())
560            .map(|slice| slice.to_vec())
561            .unwrap_or_default()
562            .into_iter()
563            .enumerate()
564            .map(|(idx, pool)| PoolData {
565                url: pool
566                    .get("URL")
567                    .and_then(|v| v.as_str())
568                    .map(|x| PoolURL::from(x.to_owned())),
569                user: pool.get("User").and_then(|v| v.as_str()).map(|s| s.into()),
570                position: Some(idx as u16),
571                alive: pool
572                    .get("Status")
573                    .and_then(|v| v.as_str())
574                    .map(|s| s == "Alive"),
575                active: pool.get("Stratum Active").and_then(|v| v.as_bool()),
576                accepted_shares: pool.get("Accepted").and_then(|v| v.as_u64()),
577                rejected_shares: pool.get("Rejected").and_then(|v| v.as_u64()),
578            })
579            .collect()
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::data::device::models::avalon::AvalonMinerModel::Avalon1246;
587    use crate::test::api::MockAPIClient;
588    use crate::test::json::cgminer::avalon::AVALON_A_STATS_PARSED;
589
590    #[tokio::test]
591    async fn test_avalon_a() -> Result<()> {
592        let miner = AvalonAMiner::new(
593            IpAddr::from([127, 0, 0, 1]),
594            MinerModel::AvalonMiner(Avalon1246),
595        );
596        let mut results = HashMap::new();
597        let stats_cmd: MinerCommand = MinerCommand::RPC {
598            command: "stats",
599            parameters: None,
600        };
601
602        results.insert(stats_cmd, Value::from_str(AVALON_A_STATS_PARSED)?);
603
604        let mock_api = MockAPIClient::new(results);
605
606        let mut collector = DataCollector::new_with_client(&miner, &mock_api);
607        let data = collector.collect_all().await;
608
609        let miner_data = miner.parse_data(data);
610
611        assert_eq!(miner_data.uptime, Some(Duration::from_secs(24684)));
612        assert_eq!(miner_data.wattage, Some(Power::from_watts(3189.0)));
613        assert_eq!(miner_data.fans.len(), 4);
614        assert_eq!(miner_data.hashboards[0].chips.len(), 120);
615        assert_eq!(
616            miner_data.average_temperature,
617            Some(Temperature::from_celsius(65.0))
618        );
619
620        Ok(())
621    }
622}