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