Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
47cd229
Add tvOS app: TCP mesh map with Bonjour discovery
garthvh Jul 24, 2026
0991176
Brand the tvOS app and use circle short-name node pins
garthvh Jul 24, 2026
20de904
Fix focus-sound static on the tvOS map and add a Re-center button
garthvh Jul 24, 2026
437dddc
Zoom in when selecting a node hidden inside a cluster
garthvh Jul 24, 2026
f74d5e7
Silence residual focus static: non-focusable pins + debounced selecti…
garthvh Jul 24, 2026
634c11d
Fix visual static on node transitions: cut long flights, track row focus
garthvh Jul 24, 2026
49571c6
Escape the map focus trap with the Menu button
garthvh Jul 24, 2026
95de976
Move Re-center and Disconnect into the node list column
garthvh Jul 24, 2026
1308283
Merge branch 'main' into feat/tvos-mesh-map
garthvh Jul 24, 2026
98ed705
fix(tvos): cancellable connect + defer map selection until annotation…
garthvh Jul 26, 2026
c44fcd5
feat(tvos): use the brand wordmark for the Meshtastic logo lockup
garthvh Jul 26, 2026
f88b6f4
feat(tvos): selectable clusters + zoom onto the selected node
garthvh Jul 26, 2026
c47bf33
feat(tvos): keep the screen awake while foregrounded
garthvh Jul 26, 2026
a4ee513
feat(tvos): slim SwiftData persistence for the node store
garthvh Jul 26, 2026
37f707e
feat(tvos): settings screen — clear node database + map type
garthvh Jul 26, 2026
fed8917
fix(tvos): reliable list focus, coincident-node spread, tighter selec…
garthvh Jul 26, 2026
9f0e612
feat(tvos): default node info, compact list rows, scrollable detail
garthvh Jul 26, 2026
5a546eb
style(tvos): header scrim + design-standards color palette
garthvh Jul 26, 2026
60b95a2
refactor(tvos): Dynamic-Type fonts + shared layout tokens (design audit)
garthvh Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions Meshtastic TV/App/ConnectView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// ConnectView.swift
// Meshtastic TV
//
// Copyright(c) Garth Vander Houwen 7/24/26.
//
// Bonjour-discovered nodes (tap to connect) plus manual host / port entry,
// under a Meshtastic logo hero.
//

import SwiftUI

struct ConnectView: View {
@Bindable var client: MeshClient
@StateObject private var discovery = NodeDiscovery()

@AppStorage("tv.lastHost") private var host: String = ""
@AppStorage("tv.lastPort") private var portText: String = "4403"

var body: some View {
NavigationStack {
HStack(alignment: .top, spacing: 60) {
hero
.frame(maxWidth: 640)

Form {
discoveredSection

Section("Manual Connection") {
TextField("IP address or hostname", text: $host)
.keyboardType(.URL)
.textContentType(.URL)
.autocorrectionDisabled()
TextField("Port", text: $portText)
.keyboardType(.numberPad)
Button {
client.connect(host: trimmedHost, port: port)
} label: {
Label("Connect", systemImage: "network")
}
.disabled(trimmedHost.isEmpty)
}

if case .failed(let message) = client.state {
Section {
Label(message, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}

Section {
NavigationLink {
SettingsView()
} label: {
Label("Settings", systemImage: "gearshape")
}
}
}
}
.padding(.top, 40)
}
}

private var hero: some View {
VStack(alignment: .leading, spacing: 28) {
Image("meshtastic-wordmark-white")
.resizable()
.scaledToFit()
.frame(width: 620)
Text("Connect to a Meshtastic node on your network and watch the mesh live on the big screen.")
.font(.title3)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Label("Radios advertising TCP appear automatically", systemImage: "bonjour")
.font(.callout)
.foregroundStyle(Color("LightIndigo"))
}
.frame(maxHeight: .infinity, alignment: .top)
}

@ViewBuilder
private var discoveredSection: some View {
Section {
if discovery.discovered.isEmpty {
HStack(spacing: 16) {
ProgressView()
Text("Searching the local network…")
.foregroundStyle(.secondary)
}
} else {
ForEach(discovery.discovered) { node in
Button {
client.connect(host: node.host, port: node.port)
} label: {
Comment on lines +92 to +94
HStack(spacing: 16) {
Image(systemName: "antenna.radiowaves.left.and.right")
.foregroundStyle(Color("LightIndigo"))
VStack(alignment: .leading, spacing: 4) {
Text(node.name)
Text(verbatim: "\(node.host):\(String(node.port))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
} header: {
Text("Discovered Nodes")
}
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
}

private var trimmedHost: String {
host.trimmingCharacters(in: .whitespacesAndNewlines)
}

private var port: Int {
Int(portText.trimmingCharacters(in: .whitespaces)) ?? 4403
}
Comment on lines +119 to +121
}
172 changes: 172 additions & 0 deletions Meshtastic TV/App/MapScreen.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//
// MapScreen.swift
// Meshtastic TV
//
// Copyright(c) Garth Vander Houwen 7/24/26.
//
// Live mesh map with a focusable node side-list for Siri Remote operation.
// The list is the primary, reliable selection path on tvOS; clicking a pin on
// the map (focus engine) selects too and keeps the list in sync. Rows carry the
// same node-color circle badges as the map pins (CircleText, shared with iOS).
Comment on lines +8 to +10
//
Comment on lines +7 to +11

import SwiftUI
import SwiftData

struct MapScreen: View {
@Bindable var client: MeshClient
@State private var selectedNodeNum: UInt32?
@State private var recenterToken = 0
// Row focus is the browse signal on tvOS: List(selection:) does not follow
// focus for NavigationLink rows, so track it explicitly and mirror it into
// the map selection (debounced map-side).
@FocusState private var focusedNodeNum: UInt32?
@State private var navPath: [UInt32] = []

/// The persisted node store — the map and list read from here, not the client.
@Query private var allNodes: [MeshNode]

/// All nodes for the side list: located first, then alphabetically.
private var sortedNodes: [MeshNode] {
allNodes.sorted {
if $0.hasLocation != $1.hasLocation { return $0.hasLocation }
return $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}

/// Located nodes, most-recently-heard first — the map's data source.
private var locatedNodes: [MeshNode] {
allNodes.filter(\.hasLocation)
.sorted { ($0.lastHeard ?? .distantPast) > ($1.lastHeard ?? .distantPast) }
}

var body: some View {
HStack(spacing: 0) {
nodeList
.frame(width: 520)

MeshTVMapView(
nodes: locatedNodes,
selectedNodeNum: $selectedNodeNum,
recenterToken: recenterToken,
onMenuExit: escapeMap
)
.ignoresSafeArea()
}
}

/// Menu pressed while the map held focus: pop any open node detail and hand
/// focus back to the node list — without this, MKMapView is a focus trap.
private func escapeMap() {
navPath = []
focusedNodeNum = selectedNodeNum ?? sortedNodes.first?.num
}

private var nodeList: some View {
NavigationStack(path: $navPath) {
List {
// Map controls live in the list column — the overlay buttons were
// unreachable by remote once the map started capturing focus.
Section {
Button {
selectedNodeNum = nil
recenterToken += 1
} label: {
Label("Re-center Map", systemImage: "scope")
}
NavigationLink {
SettingsView()
} label: {
Label("Settings", systemImage: "gearshape")
}
Button(role: .destructive) {
client.disconnect()
} label: {
Label("Disconnect", systemImage: "xmark.circle.fill")
}
}

Section {
ForEach(sortedNodes) { node in
NavigationLink(value: node.num) {
NodeRow(node: node)
}
.focused($focusedNodeNum, equals: node.num)
}
} header: {
Text("\(allNodes.count) nodes · \(locatedNodes.count) on map")
}
}
.onChange(of: focusedNodeNum) { _, newValue in
if let newValue { selectedNodeNum = newValue }
}
.navigationDestination(for: UInt32.self) { num in
if let node = allNodes.first(where: { $0.num == num }) {
NodeDetailView(node: node)
}
}
.safeAreaInset(edge: .top) {
header
}
}
}

private var header: some View {
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Image("meshtastic-wordmark-white")
.resizable()
.scaledToFit()
.frame(height: 30)
Text(client.host)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
}

private var disconnectButton: some View {
Button(role: .destructive) {
client.disconnect()
Comment on lines +126 to +151
} label: {
Label("Disconnect", systemImage: "xmark.circle.fill")
}
.buttonStyle(.bordered)
}
Comment on lines +149 to +156
Comment on lines +149 to +156
}

private struct NodeRow: View {
let node: MeshNode

var body: some View {
HStack(spacing: 16) {
// Same circle badge as the map pin (node color + short name).
Comment on lines +162 to +166
CircleText(
text: node.shortName.isEmpty ? "?" : node.shortName,
color: Color(UIColor(hex: node.num)),
circleSize: 52
)
.opacity(node.hasLocation ? 1 : 0.45)

VStack(alignment: .leading, spacing: 4) {
Text(node.displayName)
.lineLimit(1)
HStack(spacing: 12) {
if let battery = node.batteryLevel {
Label("\(battery)%", systemImage: battery > 20 ? "battery.75percent" : "battery.25percent")
.foregroundStyle(Color("LightIndigo"))
}
if !node.hasLocation {
Label("No position", systemImage: "location.slash")
.foregroundStyle(.secondary)
}
}
.font(.caption)
}
Spacer()
}
}
}
33 changes: 33 additions & 0 deletions Meshtastic TV/App/MeshtasticTVApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// MeshtasticTVApp.swift
// Meshtastic TV
//
// Copyright(c) Garth Vander Houwen 7/24/26.
//
// Apple TV client: connect to a Meshtastic node over TCP and show a live mesh map
// operable with the Siri Remote.
//

import SwiftUI
import SwiftData

@main
struct MeshtasticTVApp: App {
private let container: ModelContainer
@State private var client: MeshClient

init() {
// Slim tvOS-local store — just `MeshNode` (see MeshNode.swift). Persists the
// node database so the map is populated on relaunch before the radio re-dumps.
let container = try! ModelContainer(for: MeshNode.self)
self.container = container
_client = State(initialValue: MeshClient(context: container.mainContext))
}
Comment on lines +20 to +26

var body: some Scene {
WindowGroup {
RootView(client: client)
}
.modelContainer(container)
}
}
58 changes: 58 additions & 0 deletions Meshtastic TV/App/RootView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// RootView.swift
// Meshtastic TV
//
// Copyright(c) Garth Vander Houwen 7/24/26.
//

import SwiftUI
import UIKit

struct RootView: View {
@Bindable var client: MeshClient
@Environment(\.scenePhase) private var scenePhase

var body: some View {
Group {
switch client.state {
case .connected:
MapScreen(client: client)
case .connecting:
ConnectingView(host: client.host) { client.disconnect() }
case .disconnected, .failed:
ConnectView(client: client)
}
}
// Meshtastic brand green (the Live Activity / widget tint).
.tint(Color("LightIndigo"))
// This app is a live wall display: keep the big screen awake so the tvOS
// screensaver never interrupts the mesh map while the app is foregrounded.
// Re-asserted on every active transition because the system can reset the
// flag when the app returns to the foreground.
.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
.onChange(of: scenePhase) { _, phase in
UIApplication.shared.isIdleTimerDisabled = (phase == .active)
}
}
}

private struct ConnectingView: View {
let host: String
let onCancel: () -> Void

var body: some View {
VStack(spacing: 48) {
Image("m-logo-white")
.resizable()
.scaledToFit()
.frame(width: 280)
ProgressView()
.scaleEffect(1.6)
.tint(Color("LightIndigo"))
Text("Connecting to \(host)…")
.font(.title2)
.foregroundStyle(.secondary)
Button("Cancel", action: onCancel)
}
}
}
Loading
Loading