From 8d597294a43d620763d333d3944996edc7fd3909 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?lollipopkit=F0=9F=8F=B3=EF=B8=8F=E2=80=8D=E2=9A=A7?= =?UTF-8?q?=EF=B8=8F?= <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 28 Jul 2025 22:26:29 +0800 Subject: [PATCH] feat: amd gpu (#831) --- lib/data/model/app/shell_func.dart | 102 ++--- lib/data/model/server/amd.dart | 188 ++++++++ lib/data/model/server/server.dart | 2 + .../server/server_status_update_req.dart | 7 + lib/view/page/server/detail/misc.dart | 38 +- lib/view/page/server/detail/view.dart | 77 +++- test/amd_smi_test.dart | 412 ++++++++++++++++++ 7 files changed, 759 insertions(+), 67 deletions(-) create mode 100644 lib/data/model/server/amd.dart create mode 100644 test/amd_smi_test.dart diff --git a/lib/data/model/app/shell_func.dart b/lib/data/model/app/shell_func.dart index 2fca8db6..92814c38 100644 --- a/lib/data/model/app/shell_func.dart +++ b/lib/data/model/app/shell_func.dart @@ -16,6 +16,12 @@ enum ShellFunc { /// The suffix `\t` is for formatting static const cmdDivider = '\necho $seperator\n\t'; + /// Cached Linux status commands string + static final _linuxStatusCmds = StatusCmdType.values.map((e) => e.cmd).join(cmdDivider); + + /// Cached BSD status commands string + static final _bsdStatusCmds = BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider); + /// srvboxm -> ServerBox Mobile static const scriptFile = 'srvboxm_v${BuildData.script}.sh'; static const scriptDirHome = '~/.config/server_box'; @@ -28,13 +34,10 @@ enum ShellFunc { /// Default is [scriptDirTmp]/[scriptFile], if this path is not accessible, /// it will be changed to [scriptDirHome]/[scriptFile]. static String getScriptDir(String id) { - final customScriptDir = ServerProvider.pick( - id: id, - )?.value.spi.custom?.scriptDir; + final customScriptDir = ServerProvider.pick(id: id)?.value.spi.custom?.scriptDir; if (customScriptDir != null) return customScriptDir; - return _scriptDirMap.putIfAbsent(id, () { - return scriptDirTmp; - }); + _scriptDirMap[id] ??= scriptDirTmp; + return _scriptDirMap[id]!; } static void switchScriptDir(String id) => switch (_scriptDirMap[id]) { @@ -68,43 +71,24 @@ chmod 755 $scriptPath String exec(String id) => 'sh ${getScriptPath(id)} -$flag'; - String get name { - switch (this) { - case ShellFunc.status: - return 'status'; - // case ShellFunc.docker: - // // `dockeR` -> avoid conflict with `docker` command - // return 'dockeR'; - case ShellFunc.process: - return 'process'; - case ShellFunc.shutdown: - return 'ShutDown'; - case ShellFunc.reboot: - return 'Reboot'; - case ShellFunc.suspend: - return 'Suspend'; - } - } + String get name => switch (this) { + ShellFunc.status => 'status', + ShellFunc.process => 'process', + ShellFunc.shutdown => 'ShutDown', + ShellFunc.reboot => 'Reboot', + ShellFunc.suspend => 'Suspend', + }; - String get _cmd { - switch (this) { - case ShellFunc.status: - return ''' + String get _cmd => switch (this) { + ShellFunc.status => + ''' if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then -\t${StatusCmdType.values.map((e) => e.cmd).join(cmdDivider)} +\t$_linuxStatusCmds else -\t${BSDStatusCmdType.values.map((e) => e.cmd).join(cmdDivider)} -fi'''; - // case ShellFunc.docker: - // return ''' - // result=\$(docker version 2>&1 | grep "permission denied") - // if [ "\$result" != "" ]; then - // \t${_dockerCmds.join(_cmdDivider)} - // else - // \t${_dockerCmds.map((e) => "sudo -S $e").join(_cmdDivider)} - // fi'''; - case ShellFunc.process: - return ''' +\t$_bsdStatusCmds +fi''', + ShellFunc.process => + ''' if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then \tif [ "\$isBusybox" != "" ]; then \t\tps w @@ -114,30 +98,29 @@ if [ "\$macSign" = "" ] && [ "\$bsdSign" = "" ]; then else \tps -ax fi -'''; - case ShellFunc.shutdown: - return ''' +''', + ShellFunc.shutdown => + ''' if [ "\$userId" = "0" ]; then \tshutdown -h now else \tsudo -S shutdown -h now -fi'''; - case ShellFunc.reboot: - return ''' +fi''', + ShellFunc.reboot => + ''' if [ "\$userId" = "0" ]; then \treboot else \tsudo -S reboot -fi'''; - case ShellFunc.suspend: - return ''' +fi''', + ShellFunc.suspend => + ''' if [ "\$userId" = "0" ]; then \tsystemctl suspend else \tsudo -S systemctl suspend -fi'''; - } - } +fi''', + }; static String allScript(Map? customCmds) { final sb = StringBuffer(); @@ -163,9 +146,7 @@ exec 2>/dev/null // Write each func for (final func in values) { final customCmdsStr = () { - if (func == ShellFunc.status && - customCmds != null && - customCmds.isNotEmpty) { + if (func == ShellFunc.status && customCmds != null && customCmds.isNotEmpty) { return '$cmdDivider\n\t${customCmds.values.join(cmdDivider)}'; } return ''; @@ -212,18 +193,15 @@ enum StatusCmdType { cpu._('cat /proc/stat | grep cpu'), uptime._('uptime'), conn._('cat /proc/net/snmp'), - disk._( - 'lsblk --bytes --json --output FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID', - ), + disk._('lsblk --bytes --json --output FSTYPE,PATH,NAME,KNAME,MOUNTPOINT,FSSIZE,FSUSED,FSAVAIL,FSUSE%,UUID'), mem._("cat /proc/meminfo | grep -E 'Mem|Swap'"), tempType._('cat /sys/class/thermal/thermal_zone*/type'), tempVal._('cat /sys/class/thermal/thermal_zone*/temp'), host._('cat /etc/hostname'), diskio._('cat /proc/diskstats'), - battery._( - 'for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done', - ), + battery._('for f in /sys/class/power_supply/*/uevent; do cat "\$f"; echo; done'), nvidia._('nvidia-smi -q -x'), + amd._('if command -v amd-smi >/dev/null 2>&1; then amd-smi list --json && amd-smi metric --json; elif command -v rocm-smi >/dev/null 2>&1; then rocm-smi --json || rocm-smi --showunique --showuse --showtemp --showfan --showclocks --showmemuse --showpower; elif command -v radeontop >/dev/null 2>&1; then timeout 2s radeontop -d - -l 1 | tail -n +2; else echo "No AMD GPU monitoring tools found"; fi'), sensors._('sensors'), diskSmart._('for d in \$(lsblk -dn -o KNAME); do smartctl -a -j /dev/\$d; echo; done'), cpuBrand._('cat /proc/cpuinfo | grep "model name"'); @@ -258,6 +236,8 @@ extension StatusCmdTypeX on StatusCmdType { StatusCmdType.host => l10n.host, StatusCmdType.uptime => l10n.uptime, StatusCmdType.battery => l10n.battery, + StatusCmdType.sensors => l10n.sensors, + StatusCmdType.disk => l10n.disk, final val => val.name, }; } diff --git a/lib/data/model/server/amd.dart b/lib/data/model/server/amd.dart new file mode 100644 index 00000000..f822b938 --- /dev/null +++ b/lib/data/model/server/amd.dart @@ -0,0 +1,188 @@ +import 'dart:convert'; + +/// AMD GPU monitoring data structures +/// Supports both amd-smi and rocm-smi tools +/// Example JSON output: +/// [ +/// { +/// "name": "AMD Radeon RX 7900 XTX", +/// "device_id": "0", +/// "temp": 45, +/// "power": "120W / 355W", +/// "memory": { +/// "total": 24576, +/// "used": 1024, +/// "unit": "MB", +/// "processes": [ +/// { +/// "pid": 2456, +/// "name": "firefox", +/// "memory": 512 +/// } +/// ] +/// }, +/// "utilization": 75, +/// "fan_speed": 1200, +/// "clock_speed": 2400 +/// } +/// ] + +class AmdSmi { + static List fromJson(String raw) { + try { + final jsonData = json.decode(raw); + if (jsonData is! List) return []; + + return jsonData + .map((gpu) => _parseGpuItem(gpu)) + .where((item) => item != null) + .cast() + .toList(); + } catch (e) { + return []; + } + } + + static AmdSmiItem? _parseGpuItem(Map gpu) { + try { + final name = gpu['name'] ?? gpu['card_model'] ?? gpu['device_name'] ?? 'Unknown AMD GPU'; + final deviceId = gpu['device_id']?.toString() ?? gpu['gpu_id']?.toString() ?? '0'; + + // Temperature parsing + final tempRaw = gpu['temperature'] ?? gpu['temp'] ?? gpu['gpu_temp']; + final temp = _parseIntValue(tempRaw); + + // Power parsing + final powerDraw = gpu['power_draw'] ?? gpu['current_power']; + final powerCap = gpu['power_cap'] ?? gpu['power_limit'] ?? gpu['max_power']; + final power = _formatPower(powerDraw, powerCap); + + // Memory parsing + final memory = _parseMemory(gpu['memory'] ?? gpu['vram'] ?? {}); + + // Utilization parsing + final utilization = _parseIntValue(gpu['utilization'] ?? gpu['gpu_util'] ?? gpu['activity']); + + // Fan speed parsing + final fanSpeed = _parseIntValue(gpu['fan_speed'] ?? gpu['fan_rpm']); + + // Clock speed parsing + final clockSpeed = _parseIntValue(gpu['clock_speed'] ?? gpu['gpu_clock'] ?? gpu['sclk']); + + return AmdSmiItem( + deviceId: deviceId, + name: name, + temp: temp, + power: power, + memory: memory, + utilization: utilization, + fanSpeed: fanSpeed, + clockSpeed: clockSpeed, + ); + } catch (e) { + return null; + } + } + + static int _parseIntValue(dynamic value) { + if (value == null) return 0; + if (value is int) return value; + if (value is String) { + // Remove units and parse (e.g., "45°C" -> 45, "1200 RPM" -> 1200) + final cleanValue = value.replaceAll(RegExp(r'[^\d]'), ''); + return int.tryParse(cleanValue) ?? 0; + } + return 0; + } + + static String _formatPower(dynamic draw, dynamic cap) { + final drawValue = _parseIntValue(draw); + final capValue = _parseIntValue(cap); + + if (drawValue == 0 && capValue == 0) return 'N/A'; + if (capValue == 0) return '${drawValue}W'; + return '${drawValue}W / ${capValue}W'; + } + + static AmdSmiMem _parseMemory(Map memData) { + final total = _parseIntValue(memData['total'] ?? memData['total_memory']); + final used = _parseIntValue(memData['used'] ?? memData['used_memory']); + final unit = memData['unit']?.toString() ?? 'MB'; + + final processes = []; + final processesData = memData['processes']; + if (processesData is List) { + for (final proc in processesData) { + if (proc is Map) { + final process = _parseProcess(proc); + if (process != null) processes.add(process); + } + } + } + + return AmdSmiMem(total, used, unit, processes); + } + + static AmdSmiMemProcess? _parseProcess(Map procData) { + final pid = _parseIntValue(procData['pid']); + final name = procData['name']?.toString() ?? procData['process_name']?.toString() ?? 'Unknown'; + final memory = _parseIntValue(procData['memory'] ?? procData['used_memory']); + + if (pid == 0) return null; + return AmdSmiMemProcess(pid, name, memory); + } +} + +class AmdSmiItem { + final String deviceId; + final String name; + final int temp; + final String power; + final AmdSmiMem memory; + final int utilization; + final int fanSpeed; + final int clockSpeed; + + const AmdSmiItem({ + required this.deviceId, + required this.name, + required this.temp, + required this.power, + required this.memory, + required this.utilization, + required this.fanSpeed, + required this.clockSpeed, + }); + + @override + String toString() { + return 'AmdSmiItem{name: $name, temp: $temp, power: $power, utilization: $utilization%, memory: $memory}'; + } +} + +class AmdSmiMem { + final int total; + final int used; + final String unit; + final List processes; + + const AmdSmiMem(this.total, this.used, this.unit, this.processes); + + @override + String toString() { + return 'AmdSmiMem{total: $total, used: $used, unit: $unit, processes: ${processes.length}}'; + } +} + +class AmdSmiMemProcess { + final int pid; + final String name; + final int memory; + + const AmdSmiMemProcess(this.pid, this.name, this.memory); + + @override + String toString() { + return 'AmdSmiMemProcess{pid: $pid, name: $name, memory: $memory}'; + } +} \ No newline at end of file diff --git a/lib/data/model/server/server.dart b/lib/data/model/server/server.dart index 076bc220..1bc5fd22 100644 --- a/lib/data/model/server/server.dart +++ b/lib/data/model/server/server.dart @@ -1,6 +1,7 @@ import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/conn.dart'; import 'package:server_box/data/model/server/cpu.dart'; @@ -42,6 +43,7 @@ class ServerStatus { DiskIO diskIO; List diskSmart; List? nvidia; + List? amd; final List batteries = []; final Map more = {}; final List sensors = []; diff --git a/lib/data/model/server/server_status_update_req.dart b/lib/data/model/server/server_status_update_req.dart index e3c9b3ab..ae9e2466 100644 --- a/lib/data/model/server/server_status_update_req.dart +++ b/lib/data/model/server/server_status_update_req.dart @@ -1,5 +1,6 @@ import 'package:fl_lib/fl_lib.dart'; import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/conn.dart'; import 'package:server_box/data/model/server/cpu.dart'; @@ -143,6 +144,12 @@ Future _getLinuxStatus(ServerStatusUpdateReq req) async { Loggers.app.warning(e, s); } + try { + req.ss.amd = AmdSmi.fromJson(StatusCmdType.amd.find(segments)); + } catch (e, s) { + Loggers.app.warning(e, s); + } + try { final battery = StatusCmdType.battery.find(segments); diff --git a/lib/view/page/server/detail/misc.dart b/lib/view/page/server/detail/misc.dart index d98da2e4..7a4fd5fa 100644 --- a/lib/view/page/server/detail/misc.dart +++ b/lib/view/page/server/detail/misc.dart @@ -1,7 +1,7 @@ part of 'view.dart'; extension on _ServerDetailPageState { - void _onTapGpuItem(NvidiaSmiItem item) { + void _onTapNvidiaGpuItem(NvidiaSmiItem item) { final processes = item.memory.processes; final displayCount = processes.length > 5 ? 5 : processes.length; final height = displayCount * 47.0; @@ -19,6 +19,24 @@ extension on _ServerDetailPageState { ); } + void _onTapAmdGpuItem(AmdSmiItem item) { + final processes = item.memory.processes; + final displayCount = processes.length > 5 ? 5 : processes.length; + final height = displayCount * 47.0; + context.showRoundDialog( + title: item.name, + child: SizedBox( + width: double.maxFinite, + height: height, + child: ListView.builder( + itemCount: processes.length, + itemBuilder: (_, idx) => _buildAmdGpuProcessItem(processes[idx]), + ), + ), + actions: Btnx.oks, + ); + } + void _onTapGpuProcessItem(NvidiaSmiMemProcess process) { context.showRoundDialog( title: '${process.pid}', @@ -37,6 +55,24 @@ extension on _ServerDetailPageState { ); } + void _onTapAmdGpuProcessItem(AmdSmiMemProcess process) { + context.showRoundDialog( + title: '${process.pid}', + titleMaxLines: 1, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UIs.height13, + Text('Memory: ${process.memory} ${process.memory > 1024 ? 'MB' : 'KB'}'), + UIs.height13, + Text('Process: ${process.name}'), + ], + ), + actions: [TextButton(onPressed: () => context.pop(), child: Text(libL10n.close))], + ); + } + void _onTapCustomItem(MapEntry cmd) { context.showRoundDialog( title: cmd.key, diff --git a/lib/view/page/server/detail/view.dart b/lib/view/page/server/detail/view.dart index f9c23eb0..6856a750 100644 --- a/lib/view/page/server/detail/view.dart +++ b/lib/view/page/server/detail/view.dart @@ -8,6 +8,7 @@ import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/core/route.dart'; import 'package:server_box/data/model/app/server_detail_card.dart'; import 'package:server_box/data/model/app/shell_func.dart'; +import 'package:server_box/data/model/server/amd.dart'; import 'package:server_box/data/model/server/battery.dart'; import 'package:server_box/data/model/server/cpu.dart'; import 'package:server_box/data/model/server/disk.dart'; @@ -410,9 +411,23 @@ class _ServerDetailPageState extends State with SingleTickerPr Widget? _buildGpuView(Server si) { final ss = si.status; - if (ss.nvidia == null || ss.nvidia?.isEmpty == true) return null; + final hasNvidia = ss.nvidia != null && ss.nvidia!.isNotEmpty; + final hasAmd = ss.amd != null && ss.amd!.isNotEmpty; + + if (!hasNvidia && !hasAmd) return null; + + final children = []; + + // Add NVIDIA GPUs + if (hasNvidia) { + children.addAll(ss.nvidia!.map((e) => _buildNvidiaGpuItem(e))); + } + + // Add AMD GPUs + if (hasAmd) { + children.addAll(ss.amd!.map((e) => _buildAmdGpuItem(e))); + } - final children = ss.nvidia?.map((e) => _buildGpuItem(e)).toList() ?? []; return ExpandTile( title: const Text('GPU'), leading: const Icon(Icons.memory, size: 17), @@ -421,7 +436,7 @@ class _ServerDetailPageState extends State with SingleTickerPr ).cardx; } - Widget _buildGpuItem(NvidiaSmiItem item) { + Widget _buildNvidiaGpuItem(NvidiaSmiItem item) { final mem = item.memory; return ListTile( title: Text(item.name, style: UIs.text13), @@ -441,7 +456,36 @@ class _ServerDetailPageState extends State with SingleTickerPr mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - IconButton(onPressed: () => _onTapGpuItem(item), icon: const Icon(Icons.info_outline, size: 17)), + IconButton( + onPressed: () => _onTapNvidiaGpuItem(item), + icon: const Icon(Icons.info_outline, size: 17), + ), + ], + ), + ); + } + + Widget _buildAmdGpuItem(AmdSmiItem item) { + final mem = item.memory; + return ListTile( + title: Text('${item.name} (AMD)', style: UIs.text13), + leading: Text( + '${item.utilization}%\n${item.temp} °C', + style: UIs.text12Grey, + textScaler: _textFactor, + textAlign: TextAlign.center, + ), + subtitle: Text( + '${item.power} - FAN ${item.fanSpeed} RPM\n${item.clockSpeed} MHz\n${mem.used} / ${mem.total} ${mem.unit}', + style: UIs.text12Grey, + textScaler: _textFactor, + ), + contentPadding: const EdgeInsets.only(left: 17, right: 17), + trailing: Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(onPressed: () => _onTapAmdGpuItem(item), icon: const Icon(Icons.info_outline, size: 17)), ], ), ); @@ -468,6 +512,27 @@ class _ServerDetailPageState extends State with SingleTickerPr ); } + Widget _buildAmdGpuProcessItem(AmdSmiMemProcess process) { + return ListTile( + title: Text( + process.name, + style: UIs.text12, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textScaler: _textFactor, + ), + subtitle: Text( + 'PID: ${process.pid} - ${process.memory} MiB', + style: UIs.text12Grey, + textScaler: _textFactor, + ), + trailing: InkWell( + onTap: () => _onTapAmdGpuProcessItem(process), + child: const Icon(Icons.info_outline, size: 17), + ), + ); + } + Widget? _buildDiskView(Server si) { final ss = si.status; final children = []; @@ -646,7 +711,9 @@ class _ServerDetailPageState extends State with SingleTickerPr if (smart.model != null) details.add('Model: ${smart.model}'); if (smart.serial != null) details.add('Serial: ${smart.serial}'); - if (smart.temperature != null) details.add('Temperature: ${smart.temperature!.toStringAsFixed(1)}°C'); + if (smart.temperature != null) { + details.add('Temperature: ${smart.temperature!.toStringAsFixed(1)}°C'); + } if (smart.powerOnHours != null) { details.add('Power On: ${smart.powerOnHours} ${libL10n.hour}'); diff --git a/test/amd_smi_test.dart b/test/amd_smi_test.dart new file mode 100644 index 00000000..d7b6d7fc --- /dev/null +++ b/test/amd_smi_test.dart @@ -0,0 +1,412 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:server_box/data/model/server/amd.dart'; + +const _amdSmiRaw = ''' +[ + { + "name": "AMD Radeon RX 7900 XTX", + "device_id": "0", + "temp": 45, + "power_draw": 120, + "power_cap": 355, + "memory": { + "total": 24576, + "used": 1024, + "unit": "MB", + "processes": [ + { + "pid": 2456, + "name": "firefox", + "memory": 512 + }, + { + "pid": 3784, + "name": "blender", + "memory": 256 + } + ] + }, + "utilization": 75, + "fan_speed": 1200, + "clock_speed": 2400 + }, + { + "name": "AMD Radeon RX 6800 XT", + "device_id": "1", + "temp": 38, + "power_draw": 85, + "power_cap": 300, + "memory": { + "total": 16384, + "used": 512, + "unit": "MB", + "processes": [] + }, + "utilization": 25, + "fan_speed": 800, + "clock_speed": 2100 + } +] +'''; + +const _amdSmiRocmRaw = ''' +[ + { + "card_model": "AMD Radeon RX 6700 XT", + "gpu_id": "card0", + "temperature": "42°C", + "power_draw": "95", + "power_cap": "230", + "vram": { + "total_memory": 12288, + "used_memory": 768, + "unit": "MiB", + "processes": [ + { + "pid": 1234, + "process_name": "game.exe", + "used_memory": 512 + } + ] + }, + "gpu_util": "60%", + "fan_rpm": "950 RPM", + "sclk": "1800MHz" + } +] +'''; + +const _amdSmiAlternativeRaw = ''' +[ + { + "device_name": "Radeon RX 580", + "gpu_temp": 55, + "current_power": 150, + "power_limit": 185, + "memory": { + "total": 8192, + "used": 2048, + "unit": "MB" + }, + "activity": 90, + "fan_speed": 1500, + "gpu_clock": 1366 + } +] +'''; + +const _amdSmiEdgeCasesRaw = ''' +[ + { + "name": "Unknown AMD GPU", + "device_id": "", + "temp": null, + "power": null, + "memory": {}, + "utilization": null, + "fan_speed": null, + "clock_speed": null + }, + { + "name": "AMD Test GPU", + "device_id": "test", + "temp": "50°C", + "power_draw": 100, + "memory": { + "total": "16384MB", + "used": "2048MB" + }, + "utilization": "80%", + "fan_speed": "1100 RPM", + "clock_speed": "2000 MHz" + } +] +'''; + +const _invalidJson = ''' +{ + "invalid": "not an array" +} +'''; + +const _emptyArray = '[]'; + +const _malformedJson = ''' +[ + { + "name": "Test GPU" + // missing closing brace +'''; + +void main() { + group('AmdSmi JSON parsing', () { + test('parse standard AMD SMI output', () { + final gpus = AmdSmi.fromJson(_amdSmiRaw); + expect(gpus.length, 2); + + final gpu1 = gpus[0]; + expect(gpu1.name, 'AMD Radeon RX 7900 XTX'); + expect(gpu1.deviceId, '0'); + expect(gpu1.temp, 45); + expect(gpu1.power, '120W / 355W'); + expect(gpu1.memory.total, 24576); + expect(gpu1.memory.used, 1024); + expect(gpu1.memory.unit, 'MB'); + expect(gpu1.memory.processes.length, 2); + expect(gpu1.memory.processes[0].pid, 2456); + expect(gpu1.memory.processes[0].name, 'firefox'); + expect(gpu1.memory.processes[0].memory, 512); + expect(gpu1.memory.processes[1].pid, 3784); + expect(gpu1.memory.processes[1].name, 'blender'); + expect(gpu1.memory.processes[1].memory, 256); + expect(gpu1.utilization, 75); + expect(gpu1.fanSpeed, 1200); + expect(gpu1.clockSpeed, 2400); + + final gpu2 = gpus[1]; + expect(gpu2.name, 'AMD Radeon RX 6800 XT'); + expect(gpu2.deviceId, '1'); + expect(gpu2.temp, 38); + expect(gpu2.power, '85W / 300W'); + expect(gpu2.memory.total, 16384); + expect(gpu2.memory.used, 512); + expect(gpu2.memory.unit, 'MB'); + expect(gpu2.memory.processes.length, 0); + expect(gpu2.utilization, 25); + expect(gpu2.fanSpeed, 800); + expect(gpu2.clockSpeed, 2100); + }); + + test('parse ROCm SMI output with different field names', () { + final gpus = AmdSmi.fromJson(_amdSmiRocmRaw); + expect(gpus.length, 1); + + final gpu = gpus[0]; + expect(gpu.name, 'AMD Radeon RX 6700 XT'); + expect(gpu.deviceId, 'card0'); + expect(gpu.temp, 42); + expect(gpu.power, '95W / 230W'); + expect(gpu.memory.total, 12288); + expect(gpu.memory.used, 768); + expect(gpu.memory.unit, 'MiB'); + expect(gpu.memory.processes.length, 1); + expect(gpu.memory.processes[0].pid, 1234); + expect(gpu.memory.processes[0].name, 'game.exe'); + expect(gpu.memory.processes[0].memory, 512); + expect(gpu.utilization, 60); + expect(gpu.fanSpeed, 950); + expect(gpu.clockSpeed, 1800); + }); + + test('parse alternative field names', () { + final gpus = AmdSmi.fromJson(_amdSmiAlternativeRaw); + expect(gpus.length, 1); + + final gpu = gpus[0]; + expect(gpu.name, 'Radeon RX 580'); + expect(gpu.deviceId, '0'); + expect(gpu.temp, 55); + expect(gpu.power, '150W / 185W'); + expect(gpu.memory.total, 8192); + expect(gpu.memory.used, 2048); + expect(gpu.memory.unit, 'MB'); + expect(gpu.memory.processes.length, 0); + expect(gpu.utilization, 90); + expect(gpu.fanSpeed, 1500); + expect(gpu.clockSpeed, 1366); + }); + + test('handle edge cases and string parsing', () { + final gpus = AmdSmi.fromJson(_amdSmiEdgeCasesRaw); + expect(gpus.length, 2); + + final gpu1 = gpus[0]; + expect(gpu1.name, 'Unknown AMD GPU'); + expect(gpu1.deviceId, ''); + expect(gpu1.temp, 0); + expect(gpu1.power, 'N/A'); + expect(gpu1.memory.total, 0); + expect(gpu1.memory.used, 0); + expect(gpu1.memory.unit, 'MB'); + expect(gpu1.memory.processes.length, 0); + expect(gpu1.utilization, 0); + expect(gpu1.fanSpeed, 0); + expect(gpu1.clockSpeed, 0); + + final gpu2 = gpus[1]; + expect(gpu2.name, 'AMD Test GPU'); + expect(gpu2.deviceId, 'test'); + expect(gpu2.temp, 50); + expect(gpu2.power, '100W'); + expect(gpu2.memory.total, 16384); + expect(gpu2.memory.used, 2048); + expect(gpu2.memory.unit, 'MB'); + expect(gpu2.utilization, 80); + expect(gpu2.fanSpeed, 1100); + expect(gpu2.clockSpeed, 2000); + }); + + test('handle invalid JSON gracefully', () { + final gpus1 = AmdSmi.fromJson(_invalidJson); + expect(gpus1.length, 0); + + final gpus2 = AmdSmi.fromJson(_malformedJson); + expect(gpus2.length, 0); + + final gpus3 = AmdSmi.fromJson('invalid json'); + expect(gpus3.length, 0); + }); + + test('handle empty array', () { + final gpus = AmdSmi.fromJson(_emptyArray); + expect(gpus.length, 0); + }); + }); + + group('AmdSmi helper methods', () { + test('_parseIntValue handles various input types', () { + expect(AmdSmi.fromJson('[{"name":"test","temp":42}]')[0].temp, 42); + expect(AmdSmi.fromJson('[{"name":"test","temp":"45°C"}]')[0].temp, 45); + expect(AmdSmi.fromJson('[{"name":"test","temp":"1200 RPM"}]')[0].temp, 1200); + expect(AmdSmi.fromJson('[{"name":"test","temp":"N/A"}]')[0].temp, 0); + expect(AmdSmi.fromJson('[{"name":"test","temp":null}]')[0].temp, 0); + }); + + test('_formatPower handles different power scenarios', () { + final gpu1 = AmdSmi.fromJson('[{"name":"test","power_draw":100,"power_cap":200}]')[0]; + expect(gpu1.power, '100W / 200W'); + + final gpu2 = AmdSmi.fromJson('[{"name":"test","power_draw":50}]')[0]; + expect(gpu2.power, '50W'); + + final gpu3 = AmdSmi.fromJson('[{"name":"test"}]')[0]; + expect(gpu3.power, 'N/A'); + }); + + test('_parseMemory handles missing memory data', () { + final gpu = AmdSmi.fromJson('[{"name":"test"}]')[0]; + expect(gpu.memory.total, 0); + expect(gpu.memory.used, 0); + expect(gpu.memory.unit, 'MB'); + expect(gpu.memory.processes.length, 0); + }); + + test('_parseProcess filters invalid processes', () { + const jsonWithInvalidProcess = ''' + [ + { + "name": "Test GPU", + "memory": { + "processes": [ + { + "pid": 0, + "name": "invalid", + "memory": 100 + }, + { + "pid": 1234, + "name": "valid", + "memory": 200 + } + ] + } + } + ] + '''; + + final gpu = AmdSmi.fromJson(jsonWithInvalidProcess)[0]; + expect(gpu.memory.processes.length, 1); + expect(gpu.memory.processes[0].pid, 1234); + expect(gpu.memory.processes[0].name, 'valid'); + }); + }); + + group('AmdSmi data classes', () { + test('AmdSmiItem toString', () { + final memory = AmdSmiMem(8192, 2048, 'MB', []); + final item = AmdSmiItem( + deviceId: '0', + name: 'Test GPU', + temp: 45, + power: '100W / 200W', + memory: memory, + utilization: 75, + fanSpeed: 1200, + clockSpeed: 2400, + ); + + final toString = item.toString(); + expect(toString, contains('Test GPU')); + expect(toString, contains('45')); + expect(toString, contains('100W / 200W')); + expect(toString, contains('75%')); + }); + + test('AmdSmiMem toString', () { + final process = AmdSmiMemProcess(1234, 'test', 512); + final memory = AmdSmiMem(8192, 2048, 'MB', [process]); + + final toString = memory.toString(); + expect(toString, contains('8192')); + expect(toString, contains('2048')); + expect(toString, contains('MB')); + expect(toString, contains('1')); + }); + + test('AmdSmiMemProcess toString', () { + final process = AmdSmiMemProcess(1234, 'firefox', 512); + + final toString = process.toString(); + expect(toString, contains('1234')); + expect(toString, contains('firefox')); + expect(toString, contains('512')); + }); + }); + + group('AmdSmi robustness', () { + test('handles malformed GPU objects gracefully', () { + const malformedGpuJson = ''' + [ + { + "name": "Valid GPU", + "temp": 45 + }, + { + "malformed": true + }, + { + "name": "Another Valid GPU", + "temp": 50 + } + ] + '''; + + final gpus = AmdSmi.fromJson(malformedGpuJson); + expect(gpus.length, 3); + expect(gpus[0].name, 'Valid GPU'); + expect(gpus[0].temp, 45); + expect(gpus[1].name, 'Unknown AMD GPU'); + expect(gpus[1].temp, 0); + expect(gpus[2].name, 'Another Valid GPU'); + expect(gpus[2].temp, 50); + }); + + test('handles missing required fields with defaults', () { + const minimalGpuJson = ''' + [ + {} + ] + '''; + + final gpus = AmdSmi.fromJson(minimalGpuJson); + expect(gpus.length, 1); + expect(gpus[0].name, 'Unknown AMD GPU'); + expect(gpus[0].deviceId, '0'); + expect(gpus[0].temp, 0); + expect(gpus[0].power, 'N/A'); + expect(gpus[0].utilization, 0); + expect(gpus[0].fanSpeed, 0); + expect(gpus[0].clockSpeed, 0); + }); + }); +} \ No newline at end of file