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

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