- InstanceEndpoint 取代 Instance.origin(旧数据 init(from:) 读成 .direct, 写盘只输出 endpoint;dedupKey 直通用 origin、中继用 relay:<nodeID>) - RelayWire:Swift 侧重算 contracts/test-vectors.json(CanonicalJSON 手写解析器, 因为 Foundation 的解码器会抹掉'拼写',而 intent 摘要定义的正是拼写) - NodeTransport 抽象 + DirectTransport / RelayTransport / NodeTransportRouter; DirectTransport 是薄适配,直通路径行为不变 - RelayTransport:信封构造、签名、加密、验签、解密;响应验签失败即丢弃, 四类以上失败各有独立 UpstreamError 分支与用户文案 - DeviceIdentity(沙盒文件 0600,配对时由节点分配 deviceID)、RelayPairing、配对 sheet - 卡片通道徽标 + freshness 副标题;危险写 intent 在发送瞬间生成 - scripts/export-app-routes.swift 生成 vendored 路由表,AppRoutesContractTests 反向守卫 - 测试 78→92:向量互算、迁移(字面量旧 JSON)、配对四类失败、路由器、 传输往返与各类拒绝路径
224 lines
9.2 KiB
Swift
224 lines
9.2 KiB
Swift
import Foundation
|
|
|
|
public struct DeviceService: Sendable {
|
|
private let transport: any NodeTransport
|
|
|
|
public init(transport: any NodeTransport) {
|
|
self.transport = transport
|
|
}
|
|
|
|
/// Direct-mode construction, kept for the existing call sites and tests.
|
|
public init(
|
|
client: any SimAdminClientProtocol,
|
|
sessions: InstanceSessionStore,
|
|
credentials: (any CredentialStore)? = nil
|
|
) {
|
|
self.init(transport: DirectTransport(client: client, sessions: sessions, credentials: credentials))
|
|
}
|
|
|
|
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
|
|
let routes: [(id: String, title: String, route: UpstreamRoute)] = [
|
|
("device", "设备", .device),
|
|
("sim", "SIM", .sim),
|
|
("network", "蜂窝网络", .network),
|
|
("stats", "系统资源", .stats),
|
|
("cpu", "CPU", .statsCpu),
|
|
("connectivity", "连接状态", .connectivity)
|
|
]
|
|
|
|
var sections: [DeviceSection] = []
|
|
var successful = 0
|
|
var unauthorized = false
|
|
for item in routes {
|
|
do {
|
|
let response = try await request(item.route, instance)
|
|
let values = try Self.flatten(Self.dataObject(response.data))
|
|
sections.append(DeviceSection(id: item.id, title: item.title, values: values))
|
|
successful += 1
|
|
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
|
unauthorized = true
|
|
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
|
} catch {
|
|
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
|
}
|
|
}
|
|
if successful == 0 {
|
|
throw unauthorized ? DeviceServiceError.unauthorized : DeviceServiceError.invalidResponse
|
|
}
|
|
return DeviceSnapshot(sections: sections)
|
|
}
|
|
|
|
public func summary(_ instance: Instance) async throws -> DeviceSummary {
|
|
let routes: [(id: String, route: UpstreamRoute)] = [
|
|
("device", .device),
|
|
("sim", .sim),
|
|
("network", .network),
|
|
("stats", .stats),
|
|
("cpu", .statsCpu),
|
|
("connectivity", .connectivity)
|
|
]
|
|
|
|
var sections: [(String, [String: String])] = []
|
|
var successful = 0
|
|
for item in routes {
|
|
do {
|
|
let response = try await request(item.route, instance)
|
|
let values = try Self.flatten(Self.dataObject(response.data))
|
|
sections.append((item.id, values))
|
|
successful += 1
|
|
} catch {
|
|
continue
|
|
}
|
|
}
|
|
guard successful > 0 else {
|
|
throw DeviceServiceError.invalidResponse
|
|
}
|
|
return Self.summary(from: sections)
|
|
}
|
|
|
|
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
|
try await transport.send(route, instanceID: instance.id, endpoint: instance.endpoint)
|
|
}
|
|
|
|
static func dataObject(_ data: Data) throws -> [String: Any] {
|
|
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw DeviceServiceError.invalidResponse
|
|
}
|
|
if let payload = root["data"] as? [String: Any] { return payload }
|
|
if let response = root["response"] as? [String: Any],
|
|
let payload = response["data"] as? [String: Any] {
|
|
return payload
|
|
}
|
|
return root
|
|
}
|
|
|
|
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
|
|
func key(_ sectionID: String, _ names: [String]) -> String? {
|
|
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
|
|
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
|
|
}
|
|
|
|
func number(_ text: String?) -> Double? {
|
|
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
|
|
return Double(text)
|
|
}
|
|
|
|
func bool(_ text: String?) -> Bool? {
|
|
switch text?.lowercased() {
|
|
case "是", "true", "yes", "1": return true
|
|
case "否", "false", "no", "0": return false
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
let linkState = DeviceLinkState(
|
|
hardwareOnline: bool(key("device", ["powered"])),
|
|
controlOnline: bool(key("device", ["online"])),
|
|
simPresent: sections.contains { $0.0 == "sim" },
|
|
cellularOnline: key("network", ["registration_status"])?
|
|
.lowercased()
|
|
.hasPrefix("registered")
|
|
)
|
|
|
|
return DeviceSummary(
|
|
deviceName: key("device", ["model", "name", "device"]),
|
|
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
|
|
operatorName: key("network", ["operator_name", "operator", "carrier"]),
|
|
networkTechnology: key(
|
|
"network",
|
|
["network_type", "technology_preference", "technology", "radio_mode"]
|
|
),
|
|
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
|
|
.flatMap { Int($0.rounded()) },
|
|
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
|
|
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
|
|
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
|
|
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
|
|
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
|
|
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
|
|
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
|
|
Int($0.rounded())
|
|
},
|
|
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
|
|
firmwareVersion: key("device", ["revision", "firmware_version"]),
|
|
totalStorageBytes: Self.byteCount(
|
|
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
|
|
),
|
|
availableStorageBytes: Self.byteCount(
|
|
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
|
|
),
|
|
downloadRateBytesPerSecond: Self.byteRate(
|
|
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
|
|
),
|
|
uploadRateBytesPerSecond: Self.byteRate(
|
|
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
|
|
),
|
|
messagesToday: number(
|
|
key("connectivity", ["messages_today", "messages", "sms_today"])
|
|
).flatMap { Int($0.rounded()) },
|
|
uptimeSeconds: number(
|
|
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
|
|
) ?? number(key("stats", ["uptime_uptime_seconds"]))
|
|
?? number(key("stats", ["system_uptime_seconds"])),
|
|
linkState: linkState
|
|
)
|
|
}
|
|
|
|
private static func byteCount(_ text: String?) -> Int64? {
|
|
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
|
return nil
|
|
}
|
|
return Int64(value.rounded())
|
|
}
|
|
|
|
private static func byteRate(_ text: String?) -> Double? {
|
|
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
|
|
var output: [String: String] = [:]
|
|
flatten(source, prefix: "", into: &output, remaining: maximum)
|
|
return output
|
|
}
|
|
|
|
private static func flatten(
|
|
_ source: [String: Any],
|
|
prefix: String,
|
|
into output: inout [String: String],
|
|
remaining: Int
|
|
) {
|
|
for (key, value) in source.sorted(by: { $0.key < $1.key }) {
|
|
guard output.count < remaining else { return }
|
|
let fullKey = prefix.isEmpty ? key : "\(prefix)_\(key)"
|
|
switch value {
|
|
case let text as String:
|
|
output[fullKey] = text
|
|
case let flag as Bool:
|
|
output[fullKey] = flag ? "是" : "否"
|
|
case let number as Int:
|
|
output[fullKey] = String(number)
|
|
case let number as Double:
|
|
output[fullKey] = String(format: "%.1f", number)
|
|
case let array as [Any]:
|
|
let text = array.compactMap { entry -> String? in
|
|
if let record = entry as? [String: Any] {
|
|
let preferred = ["name", "zone", "mount_point", "interface", "model_name"]
|
|
.compactMap { record[$0] as? String }
|
|
.first
|
|
if let preferred { return preferred }
|
|
}
|
|
return entry as? String
|
|
}.joined(separator: "、")
|
|
output[fullKey] = text
|
|
case let nested as [String: Any]:
|
|
flatten(nested, prefix: fullKey, into: &output, remaining: remaining - output.count)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|