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 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 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 }; (0..board_cnt)
381 .map(|idx| {
382 let key = format!("HB{idx}");
383
384 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 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}