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 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 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 }; (0..board_cnt)
390 .map(|idx| {
391 let key = format!("HB{idx}");
392
393 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 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}