feat(viz): DataFeeds OSC listener + HUD
This commit is contained in:
@@ -66,6 +66,7 @@ struct ContentView: View {
|
||||
@StateObject private var settings = RenderSettings()
|
||||
@StateObject private var poseListener = PoseOSCListener()
|
||||
@StateObject private var arkitListener = ArkitOSCListener()
|
||||
@StateObject private var dataFeeds = DataFeedsOSCListener()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
@@ -76,6 +77,7 @@ struct ContentView: View {
|
||||
renderer.startOSCServer()
|
||||
poseListener.start()
|
||||
arkitListener.start()
|
||||
dataFeeds.start()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(
|
||||
for: .toggleSettings)) { _ in
|
||||
@@ -97,6 +99,15 @@ struct ContentView: View {
|
||||
// HUD coin haut-gauche : mode + touches + pose
|
||||
HUDOverlay(settings: settings, poseListener: poseListener)
|
||||
|
||||
// Data feeds HUD : telemetrie open-data (eco2mix, velib, ...)
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
DataHUDOverlay(data: dataFeeds, settings: settings)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Bouton settings coin haut-droit
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// UDP :57127 listener that consumes /data/<feed>/* messages from
|
||||
/// the Python data_feeds package and exposes the latest per-feed
|
||||
/// payloads to the SwiftUI HUD via @Published.
|
||||
@MainActor
|
||||
final class DataFeedsOSCListener: ObservableObject {
|
||||
struct TransientEvent: Equatable, Identifiable {
|
||||
let id = UUID()
|
||||
let feed: String
|
||||
let key: String
|
||||
let value: Double
|
||||
let seenAt: TimeInterval
|
||||
}
|
||||
|
||||
@Published var samples: [String: [String: Double]] = [:]
|
||||
@Published var counts: [String: Int] = [:]
|
||||
@Published var heartbeats: [String: TimeInterval] = [:]
|
||||
@Published var recentEvents: [TransientEvent] = []
|
||||
|
||||
private var listener: NWListener?
|
||||
static let defaultPort: UInt16 = 57127
|
||||
private static let maxEvents: Int = 32
|
||||
|
||||
func start(port: UInt16 = DataFeedsOSCListener.defaultPort) {
|
||||
do {
|
||||
let params = NWParameters.udp
|
||||
params.allowLocalEndpointReuse = true
|
||||
let l = try NWListener(using: params,
|
||||
on: NWEndpoint.Port(rawValue: port)!)
|
||||
l.newConnectionHandler = { [weak self] conn in
|
||||
conn.start(queue: .global(qos: .userInitiated))
|
||||
self?.receive(on: conn)
|
||||
}
|
||||
l.start(queue: .global())
|
||||
self.listener = l
|
||||
NSLog("DataFeedsOSCListener UDP :%d", port)
|
||||
} catch {
|
||||
NSLog("DataFeedsOSCListener bind :%d failed: %@",
|
||||
Int(port), String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func receive(on conn: NWConnection) {
|
||||
conn.receiveMessage { [weak self] data, _, _, error in
|
||||
if let d = data, !d.isEmpty {
|
||||
self?.handle(packet: d)
|
||||
}
|
||||
if error == nil { self?.receive(on: conn) }
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func handle(packet: Data) {
|
||||
guard let (address, types, payload) = parseOSCHeader(packet) else {
|
||||
return
|
||||
}
|
||||
let args = parseOSCArgs(types: types, data: payload)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.apply(address: address, args: args)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(address: String, args: [Any]) {
|
||||
// address shape : /data/<feed>/<route>
|
||||
let trimmed = address.hasPrefix("/")
|
||||
? String(address.dropFirst()) : address
|
||||
let comps = trimmed.split(separator: "/")
|
||||
guard comps.count >= 3, comps[0] == "data" else { return }
|
||||
let feed = String(comps[1])
|
||||
let route = String(comps[2])
|
||||
switch route {
|
||||
case "count":
|
||||
if let n = args.first as? Int32 {
|
||||
counts[feed] = Int(n)
|
||||
}
|
||||
case "heartbeat":
|
||||
if let t = args.first as? Float {
|
||||
heartbeats[feed] = TimeInterval(t)
|
||||
} else if let t = args.first as? Double {
|
||||
heartbeats[feed] = t
|
||||
}
|
||||
case "sample":
|
||||
guard args.count >= 2 else { return }
|
||||
let key: String
|
||||
switch args[0] {
|
||||
case let s as String: key = s
|
||||
case let i as Int32: key = String(i)
|
||||
default: return
|
||||
}
|
||||
let value: Double
|
||||
switch args[1] {
|
||||
case let f as Float: value = Double(f)
|
||||
case let d as Double: value = d
|
||||
case let i as Int32: value = Double(i)
|
||||
default: return
|
||||
}
|
||||
var d = samples[feed] ?? [:]
|
||||
d[key] = value
|
||||
samples[feed] = d
|
||||
let ev = TransientEvent(feed: feed, key: key, value: value,
|
||||
seenAt: CFAbsoluteTimeGetCurrent())
|
||||
recentEvents.append(ev)
|
||||
if recentEvents.count > Self.maxEvents {
|
||||
recentEvents.removeFirst(recentEvents.count - Self.maxEvents)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Minimal OSC parser (mirror of ArkitOSCListener helpers)
|
||||
|
||||
private nonisolated func align4(_ n: Int) -> Int { (n + 3) & ~3 }
|
||||
|
||||
private nonisolated func parseOSCHeader(_ data: Data
|
||||
) -> (String, String, Data)? {
|
||||
guard let endAddr = data.firstIndex(of: 0) else { return nil }
|
||||
let address = String(data: data[..<endAddr], encoding: .ascii) ?? ""
|
||||
let addrEnd = align4(endAddr - data.startIndex + 1)
|
||||
guard addrEnd < data.count else { return nil }
|
||||
let rest = data[(data.startIndex + addrEnd)...]
|
||||
guard let comma = rest.first, comma == UInt8(ascii: ","),
|
||||
let endTypes = rest.firstIndex(of: 0) else { return nil }
|
||||
let typesData = rest[rest.startIndex.advanced(by: 1)..<endTypes]
|
||||
let types = String(data: typesData, encoding: .ascii) ?? ""
|
||||
let typesEnd = align4(endTypes - rest.startIndex + 1)
|
||||
guard typesEnd <= rest.count else { return nil }
|
||||
let payload = rest[rest.startIndex.advanced(by: typesEnd)...]
|
||||
return (address, types, Data(payload))
|
||||
}
|
||||
|
||||
private nonisolated func parseOSCArgs(types: String, data: Data) -> [Any] {
|
||||
var args: [Any] = []
|
||||
var offset = 0
|
||||
for t in types {
|
||||
switch t {
|
||||
case "i":
|
||||
guard offset + 4 <= data.count else { return args }
|
||||
let v = data.withUnsafeBytes {
|
||||
$0.loadUnaligned(fromByteOffset: offset, as: Int32.self)
|
||||
}.bigEndian
|
||||
args.append(v)
|
||||
offset += 4
|
||||
case "f":
|
||||
guard offset + 4 <= data.count else { return args }
|
||||
let raw = data.withUnsafeBytes {
|
||||
$0.loadUnaligned(fromByteOffset: offset, as: UInt32.self)
|
||||
}.bigEndian
|
||||
args.append(Float(bitPattern: raw))
|
||||
offset += 4
|
||||
case "d":
|
||||
guard offset + 8 <= data.count else { return args }
|
||||
let raw = data.withUnsafeBytes {
|
||||
$0.loadUnaligned(fromByteOffset: offset, as: UInt64.self)
|
||||
}.bigEndian
|
||||
args.append(Double(bitPattern: raw))
|
||||
offset += 8
|
||||
case "s":
|
||||
let start = offset
|
||||
while offset < data.count
|
||||
&& data[data.startIndex.advanced(by: offset)] != 0 {
|
||||
offset += 1
|
||||
}
|
||||
let lo = data.startIndex.advanced(by: start)
|
||||
let hi = data.startIndex.advanced(by: offset)
|
||||
let slice = data[lo..<hi]
|
||||
let s = String(data: slice, encoding: .ascii) ?? ""
|
||||
args.append(s)
|
||||
offset = align4(offset + 1)
|
||||
default:
|
||||
return args
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Compact HUD overlay : per-feed counters + last sample chip.
|
||||
struct DataHUDOverlay: View {
|
||||
@ObservedObject var data: DataFeedsOSCListener
|
||||
@ObservedObject var settings: RenderSettings
|
||||
|
||||
var body: some View {
|
||||
if settings.showDataHUD {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(data.samples.keys.sorted(), id: \.self) { feed in
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(freshColor(for: feed))
|
||||
.frame(width: 6, height: 6)
|
||||
Text(feed)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
Spacer()
|
||||
Text(summary(for: feed))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color.black.opacity(0.4))
|
||||
.cornerRadius(6)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func freshColor(for feed: String) -> Color {
|
||||
let now = Date().timeIntervalSince1970
|
||||
let last = data.heartbeats[feed] ?? 0
|
||||
let age = now - last
|
||||
if age < 60 { return .green }
|
||||
if age < 600 { return .yellow }
|
||||
return .red
|
||||
}
|
||||
|
||||
private func summary(for feed: String) -> String {
|
||||
let s = data.samples[feed] ?? [:]
|
||||
if let n = data.counts[feed] {
|
||||
return "\(s.count) keys / \(n)"
|
||||
}
|
||||
return "\(s.count) keys"
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,7 @@ final class RenderSettings: ObservableObject {
|
||||
|
||||
// Settings panel visibility
|
||||
@Published var showPanel: Bool = false
|
||||
|
||||
// Data feeds HUD overlay (open-data telemetry chips)
|
||||
@Published var showDataHUD: Bool = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user