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