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