Files
CloudHome-Ops/lib/models/device.dart

623 lines
19 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/// 设备状态枚举。
enum DeviceStatus {
/// 在线。
online,
/// 离线。
offline,
/// 告警(指标异常)。
warning;
/// 状态中文名称。
String get label {
switch (this) {
case DeviceStatus.online:
return '在线';
case DeviceStatus.offline:
return '离线';
case DeviceStatus.warning:
return '告警';
}
}
/// 从后端状态字符串解析。
///
/// 规则(与「爱视后台」约定一致):`offline` → 离线、`warning` → 告警、
/// 其余(含 `online` 与未知值)→ 在线。
static DeviceStatus fromString(String? value) {
final status = (value ?? '').toLowerCase();
if (status == 'offline') {
return DeviceStatus.offline;
}
if (status == 'warning') {
return DeviceStatus.warning;
}
return DeviceStatus.online;
}
}
/// 设备指令常量(对应 /device/command/sendByMac|sendByOrg 的 command 字段)。
class DeviceCommand {
DeviceCommand._();
static const String powerOff = 'powerOff';
static const String reboot = 'reboot';
static const String screenshot = 'screenshot';
static const String themeUpdate = 'themeUpdate';
static const String versionUpdate = 'versionUpdate';
static const String marqueeUpdate = 'marqueeUpdate';
static const String weatherUpdate = 'weatherUpdate';
static const String mediaZoneVol = 'mediaZoneVol';
static const String defaultVol = 'defaultVol';
static const String themeClear = 'themeClear';
static const String cacheClear = 'cacheClear';
static const String storeExpire = 'storeExpire';
static const String storeDisable = 'storeDisable';
static const String passwordUpdate = 'passwordUpdate';
static const String worldAd = 'worldAd';
}
/// 设备运行指标(CPU / 内存 / 磁盘 / 网络)。
///
/// 注意:真实后端不提供该类指标,仅 mock 模式有值;
/// 由 [Device.hasMetrics] 标识当前数据是否包含指标。
class DeviceMetrics {
const DeviceMetrics({
required this.cpuUsage,
required this.memoryUsage,
required this.diskUsage,
required this.networkRxKbps,
required this.networkTxKbps,
});
/// 全零指标(真实模式下的占位值,配合 hasMetrics=false 使用)。
const DeviceMetrics.zero()
: cpuUsage = 0,
memoryUsage = 0,
diskUsage = 0,
networkRxKbps = 0,
networkTxKbps = 0;
/// CPU 使用率(0.0 ~ 1.0)。
final double cpuUsage;
/// 内存使用率(0.0 ~ 1.0)。
final double memoryUsage;
/// 磁盘使用率(0.0 ~ 1.0)。
final double diskUsage;
/// 网络下行速率(KB/s)。
final double networkRxKbps;
/// 网络上行速率(KB/s)。
final double networkTxKbps;
/// 从后端 JSON 解析运行指标。
///
/// 兼容嵌套 `metrics` 对象与顶层扁平字段两种形态,并对百分比做 0~100→0~1
/// 归一化;任意字段缺失时安全回退为 0,保证 UI 不崩溃。
factory DeviceMetrics.fromJson(Map<String, dynamic> json) {
return DeviceMetrics(
cpuUsage: _normalizePercent(
json['cpuUsage'] ?? json['cpu_usage'] ?? json['cpu'],
),
memoryUsage: _normalizePercent(
json['memoryUsage'] ??
json['memory_usage'] ??
json['mem'] ??
json['memUsage'] ??
json['memory'],
),
diskUsage: _normalizePercent(
json['diskUsage'] ?? json['disk_usage'] ?? json['disk'],
),
networkRxKbps: _toDouble(
json['networkRxKbps'] ?? json['rxKbps'] ?? json['networkRx'],
),
networkTxKbps: _toDouble(
json['networkTxKbps'] ?? json['txKbps'] ?? json['networkTx'],
),
);
}
Map<String, dynamic> toJson() {
return {
'cpuUsage': cpuUsage,
'memoryUsage': memoryUsage,
'diskUsage': diskUsage,
'networkRxKbps': networkRxKbps,
'networkTxKbps': networkTxKbps,
};
}
}
/// 将后端返回的百分数字段归一化为 0.0~1.0。
///
/// 兼容后端可能返回 0~100(如 87)或 0~1(如 0.87)两种形态,避免 UI 进度条
/// 与百分比文本越界。
double _normalizePercent(dynamic value) {
if (value == null) return 0;
final n = value is num
? value.toDouble()
: double.tryParse(value.toString());
if (n == null) return 0;
return n > 1 ? n / 100 : n;
}
/// 将动态值安全转换为 double,无法解析时返回 0。
double _toDouble(dynamic value) {
if (value == null) return 0;
return (value is num
? value.toDouble()
: double.tryParse(value.toString())) ??
0;
}
/// 设备模型。
///
/// 字段对齐真实后端 DeviceInfo(id / mac / name / deviceSn / deviceUid /
/// status / groupId / lastSyncTime / createTime),并保留 UI 展示兼容字段
/// (ip / location / os / uptimeSeconds / metrics / storeId),真实模式下取默认值。
class Device {
const Device({
required this.id,
required this.mac,
required this.name,
required this.model,
required this.deviceSn,
required this.deviceUid,
required this.status,
required this.groupId,
this.storeId = '',
required this.lastSyncTime,
required this.createTime,
this.ip = '',
this.location = '',
this.storeName = '',
this.os = '',
this.roomNo = '',
this.uptimeSeconds = 0,
this.hasMetrics = false,
this.metrics = const DeviceMetrics.zero(),
});
/// 设备唯一标识。
final String id;
/// 设备 MAC 地址(指令下发的目标标识)。
final String mac;
/// 设备名称(后端可能返回 MAC、SN 或自定义别名)。
final String name;
/// 设备型号(如 rk3566_r、p321)。
///
/// 优先从后端 model/modelName/deviceModel/productModel 等字段解析;
/// 缺失时降级为 [name](若 name 是 MAC 地址则跳过),再降级为 [deviceSn],
/// 最终兜底 [mac],确保不会把 MAC 误当成型号展示。
final String model;
/// 设备序列号。
final String deviceSn;
/// 设备 UID。
final String deviceUid;
/// 当前状态。
final DeviceStatus status;
/// 分组 ID。
final String groupId;
/// 所属门店 ID(用于拉取设备分组列表;后端命名多样,按常见别名解析)。
final String storeId;
/// 房号(房间号,如「802」「A栋301」)。
final String roomNo;
/// 最近一次同步时间。
final DateTime? lastSyncTime;
/// 创建时间。
final DateTime? createTime;
/// IP 地址(真实后端无此字段,默认回退为 MAC)。
final String ip;
/// 机房 / 位置(真实后端无此字段,默认为空)。
final String location;
/// 所属门店名称(真实后端可能返回 storeName / orgName)。
final String storeName;
/// 操作系统描述(真实后端无此字段,默认为空)。
final String os;
/// 已连续运行时长(秒)(真实后端无此字段,默认为 0)。
final int uptimeSeconds;
/// 是否包含运行指标(仅 mock 模式为 true)。
final bool hasMetrics;
/// 运行指标。
final DeviceMetrics metrics;
/// 创建副本并覆盖指定字段。
Device copyWith({
String? id,
String? mac,
String? name,
String? model,
String? deviceSn,
String? deviceUid,
DeviceStatus? status,
String? groupId,
String? storeId,
String? roomNo,
DateTime? lastSyncTime,
DateTime? createTime,
String? ip,
String? location,
String? storeName,
String? os,
int? uptimeSeconds,
bool? hasMetrics,
DeviceMetrics? metrics,
}) {
return Device(
id: id ?? this.id,
mac: mac ?? this.mac,
name: name ?? this.name,
model: model ?? this.model,
deviceSn: deviceSn ?? this.deviceSn,
deviceUid: deviceUid ?? this.deviceUid,
status: status ?? this.status,
groupId: groupId ?? this.groupId,
storeId: storeId ?? this.storeId,
roomNo: roomNo ?? this.roomNo,
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
createTime: createTime ?? this.createTime,
ip: ip ?? this.ip,
location: location ?? this.location,
storeName: storeName ?? this.storeName,
os: os ?? this.os,
uptimeSeconds: uptimeSeconds ?? this.uptimeSeconds,
hasMetrics: hasMetrics ?? this.hasMetrics,
metrics: metrics ?? this.metrics,
);
}
/// 设备标识:统一为「MAC / 房号」组合。
/// 优先 MAC + 房号;任一缺失则只用存在的那项;两者皆空时逐级降级到
/// name → model → mac,确保永不展示空白。
String get macRoomLabel {
final m = mac.trim();
final r = roomNo.trim();
if (m.isNotEmpty && r.isNotEmpty) return '$m / $r';
if (m.isNotEmpty) return m;
if (r.isNotEmpty) return r;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
/// 仅展示 MAC 地址(房号改为独立「房号:XXX」列示,不再用「/」把两者粘在一起)。
/// MAC 为空时逐级降级到 name → model → mac,确保标题不空白。
String get macLabel {
final m = mac.trim();
if (m.isNotEmpty) return m;
if (name.isNotEmpty) return name;
if (model.isNotEmpty) return model;
return m;
}
factory Device.fromJson(Map<String, dynamic> json) {
// 兼容详情接口可能再包一层 {device|info|data:{...}} 的形态,先兜底解包。
final Map<String, dynamic> data = _unwrapDeviceJson(json);
final mac = data['mac']?.toString() ?? '';
final metricsJson = data['metrics'];
// 合并嵌套 metrics 与顶层扁平遥测字段(详情接口可能直接返回 cpuUsage /
// cpu_usage / cpu 等)。任一使用率字段存在即视为有指标数据,便于详情页展示。
final Map<String, dynamic> mergedMetrics = <String, dynamic>{};
if (metricsJson is Map<String, dynamic>) {
mergedMetrics.addAll(metricsJson);
}
final flatCpu = _firstNum(data, [
'cpuUsage',
'cpu_usage',
'cpu',
'cpuPercent',
'cpu_percent',
'cpuRate',
'cpu_rate',
]);
if (flatCpu != null) {
mergedMetrics.putIfAbsent('cpuUsage', () => flatCpu);
}
final flatMem = _firstNum(data, [
'memoryUsage',
'memory_usage',
'mem',
'memUsage',
'memory',
'memoryPercent',
'mem_percent',
]);
if (flatMem != null) {
mergedMetrics.putIfAbsent('memoryUsage', () => flatMem);
}
final flatDisk = _firstNum(data, [
'diskUsage',
'disk_usage',
'disk',
'diskPercent',
'disk_percent',
]);
if (flatDisk != null) {
mergedMetrics.putIfAbsent('diskUsage', () => flatDisk);
}
// 爱视后台列表字段名为 deviceName / device_name,name 字段常缺失或存 MAC/IP。
final name = data['deviceName']?.toString() ??
data['device_name']?.toString() ??
data['name']?.toString() ??
'';
// IP 可能落在 ip / ipAddress / ip_address / deviceIp / device_ip 等多个字段。
final ip = data['ip']?.toString() ??
data['ipAddress']?.toString() ??
data['ip_address']?.toString() ??
data['deviceIp']?.toString() ??
data['device_ip']?.toString() ??
'';
final deviceSn = data['deviceSn']?.toString() ?? '';
// 型号字段后端命名多样,按常见别名逐级解析;解析不到时按规则降级。
final rawModel = _firstString(data, [
'model',
'modelName',
'model_name',
'deviceModel',
'device_model',
'productModel',
'product_model',
'productName',
'product_name',
'board',
'hardware',
'brand',
'deviceType',
'device_type',
'type',
]) ??
'';
final model = _resolveModel(rawModel, name, deviceSn, mac);
return Device(
id: data['id']?.toString() ?? '',
mac: mac,
name: name,
model: model,
deviceSn: deviceSn,
deviceUid: data['deviceUid']?.toString() ?? '',
status: DeviceStatus.fromString(data['status']?.toString()),
groupId: data['groupId']?.toString() ?? '',
storeId: _resolveStoreId(data),
// Web 端设备列表把最近心跳时间存为 heartbeatTime / heartbeat_time,
// 详情接口多为 lastSyncTime;兼容两种命名,避免最后活跃时间解析为空。
lastSyncTime: _parseDateTime(
data['lastSyncTime'] ??
data['last_sync_time'] ??
data['heartbeatTime'] ??
data['heartbeat_time'] ??
data['lastHeartbeat'] ??
data['last_heartbeat'],
),
createTime: _parseDateTime(data['createTime']),
// UI 兼容字段:真实数据缺失时回退。
ip: ip,
location: _firstString(data, [
'location',
'locationDesc',
'loc',
'position',
'place',
'area',
'region',
]) ??
'',
storeName: data['storeName']?.toString() ??
data['orgName']?.toString() ??
data['store']?.toString() ?? '',
os: _firstString(data, [
'os',
'osName',
'system',
'systemVersion',
'osVersion',
'platform',
'osInfo',
'sysVersion',
]) ??
'',
// 房号/房间号字段后端命名极不统一,按常见别名逐级回退。
roomNo: _firstString(data, [
'roomNo',
'room',
'roomNoStr',
'roomNumber',
'room_number',
'roomNum',
'roomNumStr',
'roomId',
'room_id',
'roomName',
'houseNumber',
'house_number',
'houseNo',
'houseNoStr',
'roomCode',
'room_code',
]) ??
'',
uptimeSeconds: _firstNum(data, [
'uptimeSeconds',
'uptime',
'uptimeSec',
'uptimeSecs',
'runTime',
'runningTime',
'onlineTime',
'bootTime',
'upTime',
'up_time',
])?.toInt() ?? 0,
hasMetrics: mergedMetrics.isNotEmpty,
metrics: mergedMetrics.isNotEmpty
? DeviceMetrics.fromJson(mergedMetrics)
: const DeviceMetrics.zero(),
);
}
/// 兜底解包设备 JSON。
///
/// 后端 /device/info/info/{id} 详情接口可能并未把设备对象直接作为响应体,
/// 而是再包一层 `{device|info|data: {...}}`;若顶层没有 `id` 但某嵌套字段
/// 是含 `id` 的 Map,则返回该嵌套 Map,否则原样返回。
/// 这样可避免详情页所有遥测字段因解析不到而显示为空。
static Map<String, dynamic> _unwrapDeviceJson(Map<String, dynamic> json) {
if (json.containsKey('id')) return json;
for (final key in const ['device', 'info', 'data', 'result', 'record']) {
final nested = json[key];
if (nested is Map<String, dynamic> &&
(nested.containsKey('id') || nested.containsKey('mac'))) {
return nested;
}
}
return json;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空数值。
///
/// 兼容后端返回字符串或数字两种形态;任意匹配键取到有效数值即返回,
/// 否则返回 null(交由调用处按默认 0 处理,避免 UI 崩溃)。
static num? _firstNum(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
if (value is num) return value;
final n = double.tryParse(value.toString());
if (n != null) return n;
}
return null;
}
/// 从 [json] 中按 [keys] 顺序取第一个非空字符串值。
static String? _firstString(
Map<String, dynamic> json,
List<String> keys,
) {
for (final key in keys) {
final value = json[key];
if (value == null) continue;
final text = value.toString().trim();
if (text.isNotEmpty) return text;
}
return null;
}
/// 解析设备所属门店 ID。
///
/// 后端字段命名极不统一:可能直接在顶层返回 `storeId` / `orgId`,也可能嵌套在
/// `store` / `storeInfo` / `org` 对象中。本方法先尝试顶层字段,再尝试嵌套对象
/// 中的 `id` / `orgId` / `storeId`,尽力避免分组下拉因取不到门店 ID 而无法加载。
static String _resolveStoreId(Map<String, dynamic> json) {
final topLevel = _firstString(json, [
'storeId',
'storeOrgId',
'orgId',
'store_id',
'org_id',
'storeIdStr',
]);
if (topLevel != null && topLevel.isNotEmpty) return topLevel;
for (final nestedKey in const ['store', 'storeInfo', 'org', 'organization']) {
final nested = json[nestedKey];
if (nested is Map<String, dynamic>) {
final id = _firstString(nested, [
'id',
'orgId',
'storeId',
'store_id',
'org_id',
]);
if (id != null && id.isNotEmpty) return id;
}
}
return '';
}
/// 解析最终展示的型号文本。
///
/// 优先级:
/// 1. 后端明确返回的 [rawModel]
/// 2. [name](仅当非空且不是 MAC 地址时)
/// 3. [deviceSn]
/// 4. [mac](最终兜底,避免空标题)
static String _resolveModel(
String rawModel,
String name,
String deviceSn,
String mac,
) {
if (rawModel.isNotEmpty) return rawModel;
if (name.isNotEmpty && !_isMacAddress(name)) return name;
if (deviceSn.isNotEmpty) return deviceSn;
return mac;
}
/// 判断 [value] 是否为 MAC 地址格式(xx:xx:xx:xx:xx:xx 或 xx-xx-xx-xx-xx-xx)。
static bool _isMacAddress(String value) {
return RegExp(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$')
.hasMatch(value.trim());
}
/// 兼容 `yyyy-MM-dd HH:mm:ss` 与 ISO 8601 两种时间格式。
static DateTime? _parseDateTime(dynamic value) {
if (value == null) {
return null;
}
final text = value.toString().trim();
if (text.isEmpty) {
return null;
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
Map<String, dynamic> toJson() {
return {
'id': id,
'mac': mac,
'name': name,
'model': model,
'deviceSn': deviceSn,
'deviceUid': deviceUid,
'status': status.name,
'groupId': groupId,
'storeId': storeId,
'lastSyncTime': lastSyncTime?.toIso8601String(),
'createTime': createTime?.toIso8601String(),
'ip': ip,
'location': location,
'storeName': storeName,
'os': os,
'roomId': roomNo,
'uptimeSeconds': uptimeSeconds,
'metrics': hasMetrics ? metrics.toJson() : null,
};
}
}