macOS: add clawdis-mac node describe and verbose list
parent
742027a447
commit
82d8526732
|
|
@ -14,7 +14,9 @@ enum ControlRequestHandler {
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
var connected: Bool
|
var connected: Bool
|
||||||
|
var paired: Bool
|
||||||
var capabilities: [String]?
|
var capabilities: [String]?
|
||||||
|
var commands: [String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NodeListResult: Codable {
|
struct NodeListResult: Codable {
|
||||||
|
|
@ -34,7 +36,9 @@ enum ControlRequestHandler {
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteIp: String?
|
var remoteIp: String?
|
||||||
var connected: Bool?
|
var connected: Bool?
|
||||||
|
var paired: Bool?
|
||||||
var caps: [String]?
|
var caps: [String]?
|
||||||
|
var commands: [String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
var ts: Int?
|
var ts: Int?
|
||||||
|
|
@ -109,6 +113,9 @@ enum ControlRequestHandler {
|
||||||
case .nodeList:
|
case .nodeList:
|
||||||
return await self.handleNodeList()
|
return await self.handleNodeList()
|
||||||
|
|
||||||
|
case let .nodeDescribe(nodeId):
|
||||||
|
return await self.handleNodeDescribe(nodeId: nodeId)
|
||||||
|
|
||||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||||
return await self.handleNodeInvoke(
|
return await self.handleNodeInvoke(
|
||||||
nodeId: nodeId,
|
nodeId: nodeId,
|
||||||
|
|
@ -448,6 +455,18 @@ enum ControlRequestHandler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func handleNodeDescribe(nodeId: String) async -> Response {
|
||||||
|
do {
|
||||||
|
let data = try await GatewayConnection.shared.request(
|
||||||
|
method: "node.describe",
|
||||||
|
params: ["nodeId": AnyCodable(nodeId)],
|
||||||
|
timeoutMs: 10_000)
|
||||||
|
return Response(ok: true, payload: data)
|
||||||
|
} catch {
|
||||||
|
return Response(ok: false, message: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult {
|
static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult {
|
||||||
let nodes = payload.nodes.map { n -> NodeListNode in
|
let nodes = payload.nodes.map { n -> NodeListNode in
|
||||||
NodeListNode(
|
NodeListNode(
|
||||||
|
|
@ -459,14 +478,16 @@ enum ControlRequestHandler {
|
||||||
modelIdentifier: n.modelIdentifier,
|
modelIdentifier: n.modelIdentifier,
|
||||||
remoteAddress: n.remoteIp,
|
remoteAddress: n.remoteIp,
|
||||||
connected: n.connected == true,
|
connected: n.connected == true,
|
||||||
capabilities: n.caps)
|
paired: n.paired == true,
|
||||||
|
capabilities: n.caps,
|
||||||
|
commands: n.commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sorted = nodes.sorted { a, b in
|
let sorted = nodes.sorted { a, b in
|
||||||
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
let pairedNodeIds = sorted.map(\.nodeId).sorted()
|
let pairedNodeIds = sorted.filter(\.paired).map(\.nodeId).sorted()
|
||||||
let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted()
|
let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted()
|
||||||
|
|
||||||
return NodeListResult(
|
return NodeListResult(
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ struct ClawdisCLI {
|
||||||
private struct ParsedCLIRequest {
|
private struct ParsedCLIRequest {
|
||||||
var request: Request
|
var request: Request
|
||||||
var kind: Kind
|
var kind: Kind
|
||||||
|
var verbose: Bool = false
|
||||||
|
|
||||||
enum Kind {
|
enum Kind {
|
||||||
case generic
|
case generic
|
||||||
|
|
@ -215,7 +216,32 @@ struct ClawdisCLI {
|
||||||
guard let sub = args.popFirst() else { throw CLIError.help }
|
guard let sub = args.popFirst() else { throw CLIError.help }
|
||||||
switch sub {
|
switch sub {
|
||||||
case "list":
|
case "list":
|
||||||
return ParsedCLIRequest(request: .nodeList, kind: .generic)
|
var verbose = false
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--verbose":
|
||||||
|
verbose = true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ParsedCLIRequest(request: .nodeList, kind: .generic, verbose: verbose)
|
||||||
|
|
||||||
|
case "describe":
|
||||||
|
var nodeId: String?
|
||||||
|
while !args.isEmpty {
|
||||||
|
let arg = args.removeFirst()
|
||||||
|
switch arg {
|
||||||
|
case "--node":
|
||||||
|
nodeId = args.popFirst()
|
||||||
|
default:
|
||||||
|
if nodeId == nil { nodeId = arg }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let nodeId else { throw CLIError.help }
|
||||||
|
return ParsedCLIRequest(request: .nodeDescribe(nodeId: nodeId), kind: .generic)
|
||||||
|
|
||||||
case "invoke":
|
case "invoke":
|
||||||
var nodeId: String?
|
var nodeId: String?
|
||||||
var command: String?
|
var command: String?
|
||||||
|
|
@ -438,11 +464,15 @@ struct ClawdisCLI {
|
||||||
struct Node: Decodable {
|
struct Node: Decodable {
|
||||||
var nodeId: String
|
var nodeId: String
|
||||||
var displayName: String?
|
var displayName: String?
|
||||||
|
var platform: String?
|
||||||
|
var version: String?
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
var connected: Bool
|
var connected: Bool
|
||||||
|
var paired: Bool?
|
||||||
var capabilities: [String]?
|
var capabilities: [String]?
|
||||||
|
var commands: [String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
var pairedNodeIds: [String]?
|
var pairedNodeIds: [String]?
|
||||||
|
|
@ -474,9 +504,74 @@ struct ClawdisCLI {
|
||||||
if let ip { parts.append(ip) }
|
if let ip { parts.append(ip) }
|
||||||
if let family { parts.append("device: \(family)") }
|
if let family { parts.append("device: \(family)") }
|
||||||
if let model { parts.append("hw: \(model)") }
|
if let model { parts.append("hw: \(model)") }
|
||||||
|
let paired = n.paired ?? true
|
||||||
|
parts.append(paired ? "paired" : "unpaired")
|
||||||
parts.append(n.connected ? "connected" : "disconnected")
|
parts.append(n.connected ? "connected" : "disconnected")
|
||||||
parts.append("caps: \(capsText)")
|
parts.append("caps: \(capsText)")
|
||||||
print(parts.joined(separator: " · "))
|
print(parts.joined(separator: " · "))
|
||||||
|
|
||||||
|
if parsed.verbose {
|
||||||
|
let platform = (n.platform ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let version = (n.version ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !platform.isEmpty || !version.isEmpty {
|
||||||
|
let pv = [platform.isEmpty ? nil : platform, version.isEmpty ? nil : version]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.joined(separator: " ")
|
||||||
|
if !pv.isEmpty { print(" platform: \(pv)") }
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands = n.commands?.sorted() ?? []
|
||||||
|
if !commands.isEmpty {
|
||||||
|
print(" commands: \(commands.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .nodeDescribe = parsed.request, let payload = response.payload {
|
||||||
|
struct NodeDescribeResult: Decodable {
|
||||||
|
var nodeId: String
|
||||||
|
var displayName: String?
|
||||||
|
var platform: String?
|
||||||
|
var version: String?
|
||||||
|
var deviceFamily: String?
|
||||||
|
var modelIdentifier: String?
|
||||||
|
var remoteIp: String?
|
||||||
|
var caps: [String]?
|
||||||
|
var commands: [String]?
|
||||||
|
var paired: Bool?
|
||||||
|
var connected: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
if let decoded = try? JSONDecoder().decode(NodeDescribeResult.self, from: payload) {
|
||||||
|
let nameTrimmed = decoded.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let name = nameTrimmed.isEmpty ? decoded.nodeId : nameTrimmed
|
||||||
|
|
||||||
|
let ipTrimmed = decoded.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let ip = ipTrimmed.isEmpty ? nil : ipTrimmed
|
||||||
|
|
||||||
|
let familyTrimmed = decoded.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let family = familyTrimmed.isEmpty ? nil : familyTrimmed
|
||||||
|
let modelTrimmed = decoded.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let model = modelTrimmed.isEmpty ? nil : modelTrimmed
|
||||||
|
|
||||||
|
let caps = decoded.caps?.sorted().joined(separator: ",")
|
||||||
|
let capsText = caps.map { "[\($0)]" } ?? "?"
|
||||||
|
let commands = decoded.commands?.sorted() ?? []
|
||||||
|
|
||||||
|
var parts: [String] = ["Node:", name, decoded.nodeId]
|
||||||
|
if let ip { parts.append(ip) }
|
||||||
|
if let family { parts.append("device: \(family)") }
|
||||||
|
if let model { parts.append("hw: \(model)") }
|
||||||
|
if let paired = decoded.paired { parts.append(paired ? "paired" : "unpaired") }
|
||||||
|
if let connected = decoded.connected { parts.append(connected ? "connected" : "disconnected") }
|
||||||
|
parts.append("caps: \(capsText)")
|
||||||
|
print(parts.joined(separator: " · "))
|
||||||
|
if !commands.isEmpty {
|
||||||
|
print("Commands:")
|
||||||
|
for c in commands { print("- \(c)") }
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -558,7 +653,8 @@ struct ClawdisCLI {
|
||||||
[--session <key>] [--deliver] [--to <E.164>]
|
[--session <key>] [--deliver] [--to <E.164>]
|
||||||
|
|
||||||
Nodes:
|
Nodes:
|
||||||
clawdis-mac node list # paired + connected nodes (+ capabilities when available)
|
clawdis-mac node list [--verbose] # paired + connected nodes (+ capabilities when available)
|
||||||
|
clawdis-mac node describe --node <id>
|
||||||
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
||||||
|
|
||||||
Canvas:
|
Canvas:
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ public enum Request: Sendable {
|
||||||
case canvasSnapshot(session: String, outPath: String?)
|
case canvasSnapshot(session: String, outPath: String?)
|
||||||
case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?)
|
case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?)
|
||||||
case nodeList
|
case nodeList
|
||||||
|
case nodeDescribe(nodeId: String)
|
||||||
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
|
case nodeInvoke(nodeId: String, command: String, paramsJSON: String?)
|
||||||
case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?)
|
case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?)
|
||||||
case cameraClip(facing: CameraFacing?, durationMs: Int?, includeAudio: Bool, outPath: String?)
|
case cameraClip(facing: CameraFacing?, durationMs: Int?, includeAudio: Bool, outPath: String?)
|
||||||
|
|
@ -187,6 +188,7 @@ extension Request: Codable {
|
||||||
case canvasSnapshot
|
case canvasSnapshot
|
||||||
case canvasA2UI
|
case canvasA2UI
|
||||||
case nodeList
|
case nodeList
|
||||||
|
case nodeDescribe
|
||||||
case nodeInvoke
|
case nodeInvoke
|
||||||
case cameraSnap
|
case cameraSnap
|
||||||
case cameraClip
|
case cameraClip
|
||||||
|
|
@ -259,6 +261,10 @@ extension Request: Codable {
|
||||||
case .nodeList:
|
case .nodeList:
|
||||||
try container.encode(Kind.nodeList, forKey: .type)
|
try container.encode(Kind.nodeList, forKey: .type)
|
||||||
|
|
||||||
|
case let .nodeDescribe(nodeId):
|
||||||
|
try container.encode(Kind.nodeDescribe, forKey: .type)
|
||||||
|
try container.encode(nodeId, forKey: .nodeId)
|
||||||
|
|
||||||
case let .nodeInvoke(nodeId, command, paramsJSON):
|
case let .nodeInvoke(nodeId, command, paramsJSON):
|
||||||
try container.encode(Kind.nodeInvoke, forKey: .type)
|
try container.encode(Kind.nodeInvoke, forKey: .type)
|
||||||
try container.encode(nodeId, forKey: .nodeId)
|
try container.encode(nodeId, forKey: .nodeId)
|
||||||
|
|
@ -349,6 +355,10 @@ extension Request: Codable {
|
||||||
case .nodeList:
|
case .nodeList:
|
||||||
self = .nodeList
|
self = .nodeList
|
||||||
|
|
||||||
|
case .nodeDescribe:
|
||||||
|
let nodeId = try container.decode(String.self, forKey: .nodeId)
|
||||||
|
self = .nodeDescribe(nodeId: nodeId)
|
||||||
|
|
||||||
case .nodeInvoke:
|
case .nodeInvoke:
|
||||||
let nodeId = try container.decode(String.self, forKey: .nodeId)
|
let nodeId = try container.decode(String.self, forKey: .nodeId)
|
||||||
let command = try container.decode(String.self, forKey: .nodeCommand)
|
let command = try container.decode(String.self, forKey: .nodeCommand)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import Testing
|
||||||
modelIdentifier: "iPad14,5",
|
modelIdentifier: "iPad14,5",
|
||||||
remoteIp: "192.168.0.88",
|
remoteIp: "192.168.0.88",
|
||||||
connected: true,
|
connected: true,
|
||||||
|
paired: true,
|
||||||
caps: ["canvas", "camera"]),
|
caps: ["canvas", "camera"]),
|
||||||
ControlRequestHandler.GatewayNodeListPayload.Node(
|
ControlRequestHandler.GatewayNodeListPayload.Node(
|
||||||
nodeId: "n2",
|
nodeId: "n2",
|
||||||
|
|
@ -25,6 +26,7 @@ import Testing
|
||||||
modelIdentifier: "iPhone14,2",
|
modelIdentifier: "iPhone14,2",
|
||||||
remoteIp: nil,
|
remoteIp: nil,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
paired: true,
|
||||||
caps: nil),
|
caps: nil),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue