diff --git a/.gitignore b/.gitignore index 42f5336..a23f25e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ DerivedData/ # macOS .DS_Store .gstack/ + +# Xcode derived data when built with -derivedDataPath (e.g. for device installs). +# .gitignore already had DerivedData/; this is the same thing under another name, and +# 2121 files of it went in on the previous commit before anyone noticed. +build/ diff --git a/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..27a4f38 --- /dev/null +++ b/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000..10146d8 Binary files /dev/null and b/Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png differ diff --git a/Sources/KitchenSyncApp/Assets.xcassets/Contents.json b/Sources/KitchenSyncApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/KitchenSyncApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/KitchenSyncApp/Discovery/DiscoveryStatus.swift b/Sources/KitchenSyncApp/Discovery/DiscoveryStatus.swift new file mode 100644 index 0000000..f6751e6 --- /dev/null +++ b/Sources/KitchenSyncApp/Discovery/DiscoveryStatus.swift @@ -0,0 +1,68 @@ +import Foundation +import Network + +/// Why the device list is empty — or why it isn't. +/// +/// This type exists because "empty" used to be the app's only answer, and it is the least +/// useful one it could give. A KitchenSync that is powered, on the network, and audibly +/// following the Link session looked EXACTLY the same in this app as no device at all. +/// +/// The cause was iOS's Local Network permission. Denied, iOS does not fail the Bonjour +/// browse — it returns nothing, forever, with no error surfaced to the app. So the user +/// goes and debugs their WiFi, their router, their firmware: everything except the one +/// toggle in Settings that actually turns it back on. +/// +/// iOS *does* say so, via `kDNSServiceErr_PolicyDenied`. `KitchenSyncDiscovery` simply +/// never set a `stateUpdateHandler` and never listened. +/// +/// Pure, and mapped from `NWBrowser.State`, so it can be tested without a browser. +enum DiscoveryStatus: Equatable { + /// The browser is coming up. Not yet news. + case starting + /// Browsing normally. An empty list here really does mean "nothing on the network". + case browsing + /// Waiting on something transient — the WiFi is down, the interface is coming back. + /// It recovers on its own, so do NOT send the user off to change a setting. + case waiting + /// **iOS is refusing us the local network.** Nothing else will fix this and no amount + /// of waiting will change it. The only way out is the user's Settings app. + case permissionDenied + /// The browser died. `NWBrowser` does not come back from `.failed` on its own — + /// somebody has to restart it. + case failed + /// Cancelled, because we asked. + case stopped + + /// `kDNSServiceErr_PolicyDenied`. The one signal that means "the user has to act". + private static let policyDenied = DNSServiceErrorType(-65570) + + init(browserState: NWBrowser.State) { + switch browserState { + case .setup: + self = .starting + case .ready: + self = .browsing + case .cancelled: + self = .stopped + case .waiting(let error): + // A permission denial ARRIVES AS A WAIT, which is precisely the trap: it looks + // like something that will resolve itself, and it never does. Only the DNS + // policy code tells the two apart. + self = Self.isPolicyDenied(error) ? .permissionDenied : .waiting + case .failed(let error): + self = Self.isPolicyDenied(error) ? .permissionDenied : .failed + @unknown default: + self = .waiting + } + } + + private static func isPolicyDenied(_ error: NWError) -> Bool { + if case .dns(let code) = error { return code == policyDenied } + return false + } + + /// True only when the USER can fix it. Everything else is ours or the network's, and + /// putting a "change your privacy settings" banner on those would be the same crime in + /// the other direction — a signal that sends someone to fix the wrong thing. + var needsUserAction: Bool { self == .permissionDenied } +} diff --git a/Sources/KitchenSyncApp/Discovery/KitchenSyncDiscovery.swift b/Sources/KitchenSyncApp/Discovery/KitchenSyncDiscovery.swift index 8a76485..cbd169e 100644 --- a/Sources/KitchenSyncApp/Discovery/KitchenSyncDiscovery.swift +++ b/Sources/KitchenSyncApp/Discovery/KitchenSyncDiscovery.swift @@ -24,6 +24,11 @@ import Network final class KitchenSyncDiscovery { var onHostnamesChanged: ((Set) -> Void)? + /// Why the list is empty. Nothing used to ask this question, so a device that was + /// powered, on the network and audibly following Link looked identical to no device at + /// all — see `DiscoveryStatus`. + var onStatusChanged: ((DiscoveryStatus) -> Void)? + private var browser: NWBrowser? private let queue = DispatchQueue(label: "dev.ericdahl.kitchensync.discovery") @@ -31,6 +36,21 @@ final class KitchenSyncDiscovery { let parameters = NWParameters() parameters.includePeerToPeer = false let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters) + + // There was NO state handler here at all. A browser that iOS refused (Local Network + // permission) or that died on a network drop stayed dead, forever, in total silence: + // no devices, no error, nothing to go on. NWBrowser does not recover from `.failed` + // by itself, so somebody has to restart it, and nobody can if nobody is told. + browser.stateUpdateHandler = { [weak self] state in + let status = DiscoveryStatus(browserState: state) + self?.onStatusChanged?(status) + + // A denial is NOT restartable — iOS will refuse the new browser exactly as it + // refused this one, and retrying in a loop would just hide the one fact the user + // needs. Only a genuine failure gets another go. + if status == .failed { self?.restart() } + } + browser.browseResultsChangedHandler = { [weak self] results, _ in let matched = results.compactMap { result -> String? in guard case let .service(name, _, _, _) = result.endpoint else { return nil } @@ -47,10 +67,23 @@ final class KitchenSyncDiscovery { } func stop() { + browser?.stateUpdateHandler = nil // cancelling is not news; don't report .stopped browser?.cancel() browser = nil } + /// Rebuild the browser after a genuine failure. Backed off a little, because a network + /// that just dropped will not be back within the millisecond, and a hot retry loop + /// would burn the radio for nothing. + private func restart() { + queue.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self, self.browser != nil else { return } // stop() won the race + self.browser?.cancel() + self.browser = nil + self.start() + } + } + /// The TXT record, if the responder published one. Firmware doesn't yet (`link-devices` /// ESP-031), so this is `nil` for every unit in the field today — which is exactly why /// `DeviceMatch` keeps a hostname fallback. diff --git a/Sources/KitchenSyncApp/Models/KsConfig.swift b/Sources/KitchenSyncApp/Models/KsConfig.swift index e5c1960..844cd39 100644 --- a/Sources/KitchenSyncApp/Models/KsConfig.swift +++ b/Sources/KitchenSyncApp/Models/KsConfig.swift @@ -42,22 +42,48 @@ struct WifiSlot: Decodable, Equatable { /// mirror the `POST /save` / `POST /live` form grammar 1:1 /// (`ks_config_set()` in `ks_config.c`); `saveFormFields()` below is the /// encode-side mirror of this decode. +/// The metronome, when a speaker is actually fitted. `nil` on a device without one — +/// and `nil` is NOT the same statement as "a metronome that is switched off". +struct MetronomeConfig: Equatable { + var enabled: Bool + var accent: Bool + var volume: Int // 0...100 + var voice: Int // 0=Tone 1=Click 2=Wood +} + +/// The LED strip, when one is actually wired. `nil` on a device without one. +struct LedConfig: Equatable { + var enabled: Bool + var brightness: Int // 0...100 + var mode: Int // 0=chase 1=flash 2=fill + var fade: Int // 0...100 + var beatColor: UInt32 // 0xRRGGBB + var accentColor: UInt32 +} + struct KsConfig: Decodable, Equatable { - static let outputCount = 4 // KS_CLOCK_OUTPUTS - static let wifiSlotCount = 3 // KS_WIFI_SLOTS + /// The MAXIMUM a device can have, not an assumption about any given one — the real + /// count is `clock.count`, which the firmware sets to what is actually FITTED + /// (`link-devices` ESP-030). One DIN jack on a Touch, four on a P4. + static let maxOutputCount = 4 // KS_CLOCK_OUTPUTS + static let wifiSlotCount = 3 // KS_WIFI_SLOTS var clockOutEnabled: Bool - var metronomeEnabled: Bool - var metronomeAccent: Bool - var metronomeVolume: Int // 0...100 - var metronomeVoice: Int // 0=Tone 1=Click 2=Wood - var ledEnabled: Bool - var ledBrightness: Int // 0...100 - var ledMode: Int // 0=chase 1=flash 2=fill - var ledFade: Int // 0...100 - var ledBeatColor: UInt32 // 0xRRGGBB - var ledAccentColor: UInt32 - var followBeatEnabled: Bool + + /// **`nil` means the hardware ISN'T FITTED — not that it's switched off.** + /// + /// A device must not report hardware it doesn't have, so absent hardware is absent + /// from `/config.json` entirely (`link-devices` ESP-030). `metronome: false` would + /// claim "there is a speaker and it's off", which on a Touch is simply untrue — and + /// this app would draw a volume slider for a speaker that doesn't exist. + /// + /// Capability is a property of the BUILD, not the product: solder a strip onto a + /// Touch, flip one firmware flag, and `led` starts arriving — and the LED section + /// appears here with no change to this app. + var metronome: MetronomeConfig? + var led: LedConfig? + var followBeatEnabled: Bool? + var wifi: [WifiSlot] var clock: [ClockOutputConfig] @@ -73,19 +99,37 @@ struct KsConfig: Decodable, Equatable { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) clockOutEnabled = try c.decode(Bool.self, forKey: .clockOutEnabled) - metronomeEnabled = try c.decode(Bool.self, forKey: .metronomeEnabled) - metronomeAccent = try c.decode(Bool.self, forKey: .metronomeAccent) - metronomeVolume = try c.decode(Int.self, forKey: .metronomeVolume) - metronomeVoice = try c.decode(Int.self, forKey: .metronomeVoice) - ledEnabled = try c.decode(Bool.self, forKey: .ledEnabled) - ledBrightness = try c.decode(Int.self, forKey: .ledBrightness) - ledMode = try c.decode(Int.self, forKey: .ledMode) - ledFade = try c.decode(Int.self, forKey: .ledFade) - let beatColorString = try c.decode(String.self, forKey: .ledBeatColor) - ledBeatColor = try Self.parseHexColor(beatColorString, forKey: .ledBeatColor, in: c) - let accentColorString = try c.decode(String.self, forKey: .ledAccentColor) - ledAccentColor = try Self.parseHexColor(accentColorString, forKey: .ledAccentColor, in: c) - followBeatEnabled = try c.decode(Bool.self, forKey: .followBeatEnabled) + + // The presence of the ENABLE key is what says the hardware is fitted. Once it's + // there, the rest of its group must be too — a half-reported metronome is a + // firmware bug, and decoding it into defaults would hide that. + if let enabled = try c.decodeIfPresent(Bool.self, forKey: .metronomeEnabled) { + metronome = MetronomeConfig( + enabled: enabled, + accent: try c.decode(Bool.self, forKey: .metronomeAccent), + volume: try c.decode(Int.self, forKey: .metronomeVolume), + voice: try c.decode(Int.self, forKey: .metronomeVoice) + ) + } else { + metronome = nil + } + + if let enabled = try c.decodeIfPresent(Bool.self, forKey: .ledEnabled) { + let beat = try c.decode(String.self, forKey: .ledBeatColor) + let accent = try c.decode(String.self, forKey: .ledAccentColor) + led = LedConfig( + enabled: enabled, + brightness: try c.decode(Int.self, forKey: .ledBrightness), + mode: try c.decode(Int.self, forKey: .ledMode), + fade: try c.decode(Int.self, forKey: .ledFade), + beatColor: try Self.parseHexColor(beat, forKey: .ledBeatColor, in: c), + accentColor: try Self.parseHexColor(accent, forKey: .ledAccentColor, in: c) + ) + } else { + led = nil + } + + followBeatEnabled = try c.decodeIfPresent(Bool.self, forKey: .followBeatEnabled) wifi = try c.decode([WifiSlot].self, forKey: .wifi) clock = try c.decode([ClockOutputConfig].self, forKey: .clock) } @@ -166,18 +210,30 @@ extension KsConfig { func saveFormFields(wifiEdits: [WifiCredentialEdit]) -> [String: String] { var fields: [String: String] = [ "clock_out": Self.boolField(clockOutEnabled), - "metronome": Self.boolField(metronomeEnabled), - "metro_accent": Self.boolField(metronomeAccent), - "metro_vol": String(metronomeVolume), - "metro_voice": String(metronomeVoice), - "led": Self.boolField(ledEnabled), - "led_bright": String(ledBrightness), - "led_mode": String(ledMode), - "led_fade": String(ledFade), - "led_beat": Self.colorField(ledBeatColor), - "led_accent": Self.colorField(ledAccentColor), - "follow_beat": Self.boolField(followBeatEnabled), ] + + // Only send what the device HAS. Posting `metronome=0` to a board with no speaker + // would be writing a setting for hardware that isn't there — and, worse, this app + // would have had to invent a value for it, which is exactly the fabricated-defaults + // clobbering that `/config.json` exists to prevent. Absent stays absent, in both + // directions. + if let m = metronome { + fields["metronome"] = Self.boolField(m.enabled) + fields["metro_accent"] = Self.boolField(m.accent) + fields["metro_vol"] = String(m.volume) + fields["metro_voice"] = String(m.voice) + } + if let l = led { + fields["led"] = Self.boolField(l.enabled) + fields["led_bright"] = String(l.brightness) + fields["led_mode"] = String(l.mode) + fields["led_fade"] = String(l.fade) + fields["led_beat"] = Self.colorField(l.beatColor) + fields["led_accent"] = Self.colorField(l.accentColor) + } + if let followBeatEnabled { + fields["follow_beat"] = Self.boolField(followBeatEnabled) + } // Keyed by `edit.id` (the slot number), not array position — a caller // that filters or reorders `wifiEdits` must not silently relabel which // saved network gets touched. @@ -204,6 +260,47 @@ extension KsConfig { fileprivate static func colorField(_ value: UInt32) -> String { String(format: "#%06X", value & 0xFFFFFF) } + + /// Fold one ACCEPTED live edit into the local config, mirroring what `/live` just + /// did on the device. Call this ONLY after the POST returned 2xx: this is not an + /// optimistic guess, it is bookkeeping for a change that already happened. + /// + /// Without it a working `/live` still LOOKS broken — the POST succeeds, the device + /// obeys, and the number on screen never moves, because `/config.json` is only + /// re-fetched on demand. + /// + /// A capability that isn't fitted (`metronome == nil`, `led == nil`) stays nil. An + /// edit can't bring hardware into existence, and an index past the outputs this + /// device actually has is dropped rather than grown into — the array's length IS + /// the output count (ESP-030), so appending to it would invent a jack. + mutating func apply(_ edit: KsLiveEdit) { + switch edit { + case .clockOutEnabled(let v): clockOutEnabled = v + + case .metronomeAccent(let v): metronome?.accent = v + case .metronomeVolume(let v): metronome?.volume = v + case .metronomeVoice(let v): metronome?.voice = v + + case .ledEnabled(let v): led?.enabled = v + case .ledBrightness(let v): led?.brightness = v + case .ledMode(let v): led?.mode = v + case .ledFade(let v): led?.fade = v + case .ledBeatColor(let v): led?.beatColor = v + case .ledAccentColor(let v): led?.accentColor = v + + case .outputEnabled(let i, let v): withOutput(i) { $0.enabled = v } + case .outputCable(let i, let v): withOutput(i) { $0.cable = v } + case .outputPPQN(let i, let v): withOutput(i) { $0.ppqn = v } + case .outputPhase(let i, let v): withOutput(i) { $0.phaseMilliBeats = v } + case .outputSwing(let i, let v): withOutput(i) { $0.swingMilliBeats = v } + case .outputFollowsLink(let i, let v): withOutput(i) { $0.followsLinkTransport = v } + } + } + + private mutating func withOutput(_ index: Int, _ change: (inout ClockOutputConfig) -> Void) { + guard clock.indices.contains(index) else { return } + change(&clock[index]) + } } /// The Settings screen's per-slot WiFi input — SSID plus an optionally-typed diff --git a/Sources/KitchenSyncApp/Models/KsStatus.swift b/Sources/KitchenSyncApp/Models/KsStatus.swift index d6af6e0..5ce911b 100644 --- a/Sources/KitchenSyncApp/Models/KsStatus.swift +++ b/Sources/KitchenSyncApp/Models/KsStatus.swift @@ -47,7 +47,7 @@ struct KsStatus: Decodable, Equatable { var followBeatConfidence: Double var followBeatValid: Bool - /// One state per clock output (`KsConfig.outputCount` == 4). + /// One state per clock output (`KsConfig.maxOutputCount` == 4). var launch: [TransportLaunchState] var playing: Bool var linkOwnsTransport: Bool diff --git a/Sources/KitchenSyncApp/ViewModels/DeviceDetailViewModel.swift b/Sources/KitchenSyncApp/ViewModels/DeviceDetailViewModel.swift index eefc786..33681c1 100644 --- a/Sources/KitchenSyncApp/ViewModels/DeviceDetailViewModel.swift +++ b/Sources/KitchenSyncApp/ViewModels/DeviceDetailViewModel.swift @@ -126,11 +126,48 @@ final class DeviceDetailViewModel: ObservableObject { } } + /// Why a live edit didn't take. These are kept APART on purpose: the recurring bug + /// in this app has been collapsing two situations into one signal and sending the + /// user to fix the wrong thing (T-009, T-010, T-016, T-018). + enum LiveEditFailure: Equatable { + /// HTTP 404 — this firmware has no `/live` route at all. Nothing the user can + /// do on this screen fixes it; the way out is an OTA update. Exactly the state + /// the Touch was in when NUDGE and SWING "didn't work". + case unsupportedByFirmware + /// The device answered and refused, or answered with an error. The device is + /// right there and talking — this is about the VALUE, not the connection. + case rejectedByDevice + /// We never got an answer. Say "can't reach it", not "it said no". + case unreachable + } + + /// Set when a live edit fails; cleared by the next one that succeeds. The view + /// shows this — a silent failure is what made the original bug invisible. + @Published private(set) var liveEditFailure: LiveEditFailure? + /// Apply one live-safe edit immediately. `KsLiveEdit`'s closed case set is the /// ONLY thing that reaches `/live` — reboot-required fields have no case, so /// they cannot be routed here even by mistake. They go through `save`. + /// + /// This used to be `try? await client.postLive(edit)`. The Touch had no `/live` + /// route, so every nudge came back 404 and the `try?` dropped it on the floor: + /// the stepper moved, no error appeared, and the device never changed. A silent + /// failure is worse than a loud one — the user cannot even tell you what broke. + /// + /// The local config is updated ONLY after the device accepts the edit, so the + /// screen never shows a number the device does not have. func apply(_ edit: KsLiveEdit) async { - try? await client.postLive(edit) + do { + try await client.postLive(edit) + config?.apply(edit) + liveEditFailure = nil + } catch KitchenSyncClientError.httpStatus(404) { + liveEditFailure = .unsupportedByFirmware + } catch KitchenSyncClientError.httpStatus { + liveEditFailure = .rejectedByDevice + } catch { + liveEditFailure = .unreachable + } } /// The FULL form. Persists to NVS and **reboots the device** — it drops out of diff --git a/Sources/KitchenSyncApp/ViewModels/DeviceListViewModel.swift b/Sources/KitchenSyncApp/ViewModels/DeviceListViewModel.swift index d040ee4..f82e0a2 100644 --- a/Sources/KitchenSyncApp/ViewModels/DeviceListViewModel.swift +++ b/Sources/KitchenSyncApp/ViewModels/DeviceListViewModel.swift @@ -3,6 +3,11 @@ import Foundation @MainActor final class DeviceListViewModel: ObservableObject { @Published private(set) var devices: [KitchenSyncDevice] = [] + + /// Why the list looks the way it does. An empty list is only honest when we are actually + /// BROWSING — if iOS has denied us the local network, "no devices" is a lie, and the + /// device sitting on the bench blinking in time with the session proves it. + @Published private(set) var discoveryStatus: DiscoveryStatus = .starting @Published private(set) var statuses: [String: KsStatus] = [:] // keyed by device.id (== host) /// Whether a device is still answering. @@ -54,6 +59,9 @@ final class DeviceListViewModel: ObservableObject { discovery.onHostnamesChanged = { [weak self] hostnames in Task { @MainActor in self?.merge(discovered: hostnames) } } + discovery.onStatusChanged = { [weak self] status in + Task { @MainActor in self?.discoveryStatus = status } + } discovery.start() pollTask = Task { while !Task.isCancelled { diff --git a/Sources/KitchenSyncApp/Views/DeviceDetailView.swift b/Sources/KitchenSyncApp/Views/DeviceDetailView.swift index 6d29f19..22b0562 100644 --- a/Sources/KitchenSyncApp/Views/DeviceDetailView.swift +++ b/Sources/KitchenSyncApp/Views/DeviceDetailView.swift @@ -35,15 +35,15 @@ struct DeviceDetailView: View { meterBridge masterTransport + if let failure = vm.liveEditFailure { + liveEditBanner(failure) + } + if let config = vm.config { - clockOutSection(config) - metronomeSection(config) - ledSection(config) + outputs(config) } else { configUnavailable } - - diagnostics } .padding() } @@ -77,9 +77,16 @@ struct DeviceDetailView: View { } .sheet(isPresented: $showingSettings) { if let config = vm.config { - DeviceSettingsSheet(deviceName: device.displayName, config: config) { draft, wifiEdits in - Task { await vm.save(draft, wifiEdits: wifiEdits) } - } + // The LIVE surface. Device Setup (the reboot-required half) is a door INSIDE it, + // never adjacent to a live control. + DeviceLiveSettingsSheet( + deviceName: device.displayName, + config: config, + status: vm.status, + clockFault: vm.clockFault, + onEdit: { edit in Task { await vm.apply(edit) } }, + onSave: { draft, wifiEdits in Task { await vm.save(draft, wifiEdits: wifiEdits) } } + ) } } .sheet(isPresented: $showingFirmware) { @@ -241,24 +248,51 @@ struct DeviceDetailView: View { } } - // MARK: Live sections + // MARK: The outputs, as they appear ON STAGE + // + // Transport, plus the two controls you actually reach for mid-set: NUDGE and SWING. Cable, + // rate, follow-Link and enable are in Settings — knocking a cable assignment loose during a + // song is a way to ruin it, and you cannot knock what is not on the screen. + + /// A live edit that didn't take. This banner exists because its absence was the bug: + /// NUDGE and SWING POSTed to a `/live` route the Touch didn't have, got a 404, and + /// the app said NOTHING. The stepper moved and the device didn't, which is the one + /// outcome a performance surface must never produce. + /// + /// Each case says a different true thing. "Can't reach it" and "it said no" send the + /// user to different places, and guessing between them is the mistake this codebase + /// keeps making (T-009, T-010, T-016, T-018). + private func liveEditBanner(_ failure: DeviceDetailViewModel.LiveEditFailure) -> some View { + let message: String + switch failure { + case .unsupportedByFirmware: + message = "This firmware can't take live edits. Update it to nudge and swing." + case .rejectedByDevice: + message = "The device refused that change." + case .unreachable: + message = "Couldn't reach the device. The change was not applied." + } + return HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + Text(message) + .font(.footnote) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .foregroundStyle(KS.ember) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(KS.emberFill, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(KS.ember.opacity(0.35))) + .accessibilityElement(children: .combine) + } - private func clockOutSection(_ config: KsConfig) -> some View { + private func outputs(_ config: KsConfig) -> some View { KSSectionRail(title: "MIDI CLOCK OUT") { - HStack { - Text("master enable").font(.ksMono(12)).foregroundStyle(KS.mut) - Spacer() - Toggle("", isOn: Binding( - get: { config.clockOutEnabled }, - set: { v in Task { await vm.apply(.clockOutEnabled(v)) } } - )) - .toggleStyle(KSSwitchStyle()) - .labelsHidden() - .accessibilityLabel("Master clock out enable") - } - + // One card per element. The array LENGTH is the device's real output count — + // 1 on a Touch, 4 on a P4 — never a hardcoded 4. ForEach(Array(config.clock.enumerated()), id: \.offset) { i, out in - OutputCardView( + PerformanceOutputCard( index: i, config: out, launch: vm.status?.launch.indices.contains(i) == true ? vm.status!.launch[i] : .stopped, @@ -271,99 +305,7 @@ struct DeviceDetailView: View { } } - private func metronomeSection(_ config: KsConfig) -> some View { - KSSectionRail(title: "METRONOME") { - // NOT a toggle. `metronome` enable has no KsLiveEdit case — it requires a - // full /save, which REBOOTS the device. It is deliberately unreachable - // from this screen. (T-007 gives it a home behind a warning.) - RebootOnlyRow(label: "enable", value: config.metronomeEnabled ? "ON" : "OFF") - - HStack { - Text("accent").font(.ksMono(12)).foregroundStyle(KS.mut) - Spacer() - Toggle("", isOn: Binding( - get: { config.metronomeAccent }, - set: { v in Task { await vm.apply(.metronomeAccent(v)) } } - )) - .toggleStyle(KSSwitchStyle()) - .labelsHidden() - .accessibilityLabel("Metronome accent") - } - - KSLiveSlider(label: "VOL", range: 0...100, value: config.metronomeVolume) { v in - Task { await vm.apply(.metronomeVolume(v)) } - } - - KSField(prefix: "VOICE") { - Picker("Voice", selection: Binding( - get: { config.metronomeVoice }, - set: { v in Task { await vm.apply(.metronomeVoice(v)) } } - )) { - Text("Tone").tag(0) - Text("Click").tag(1) - Text("Wood").tag(2) - } - .pickerStyle(.menu) - .tint(KS.ink) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } - - private func ledSection(_ config: KsConfig) -> some View { - KSSectionRail(title: "LED STRIP · VISUAL METRONOME") { - HStack { - Text("enable").font(.ksMono(12)).foregroundStyle(KS.mut) - Spacer() - Toggle("", isOn: Binding( - get: { config.ledEnabled }, - set: { v in Task { await vm.apply(.ledEnabled(v)) } } - )) - .toggleStyle(KSSwitchStyle()) - .labelsHidden() - .accessibilityLabel("LED enable") - } - KSLiveSlider(label: "BRIGHT", range: 0...100, value: config.ledBrightness) { v in - Task { await vm.apply(.ledBrightness(v)) } - } - KSLiveSlider(label: "FADE", range: 0...100, value: config.ledFade) { v in - Task { await vm.apply(.ledFade(v)) } - } - - KSField(prefix: "MODE") { - Picker("Mode", selection: Binding( - get: { config.ledMode }, - set: { v in Task { await vm.apply(.ledMode(v)) } } - )) { - Text("Chase").tag(0) - Text("Flash").tag(1) - Text("Fill").tag(2) - } - .pickerStyle(.menu) - .tint(KS.ink) - .frame(maxWidth: .infinity, alignment: .trailing) - } - - KSField(prefix: "BEAT") { - ColorPicker("Beat colour", selection: Binding( - get: { Color(hex: config.ledBeatColor) }, - set: { c in Task { await vm.apply(.ledBeatColor(c.rgb24)) } } - ), supportsOpacity: false) - .labelsHidden() - .frame(maxWidth: .infinity, alignment: .trailing) - } - - KSField(prefix: "ACCENT") { - ColorPicker("Accent colour", selection: Binding( - get: { Color(hex: config.ledAccentColor) }, - set: { c in Task { await vm.apply(.ledAccentColor(c.rgb24)) } } - ), supportsOpacity: false) - .labelsHidden() - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - } /// Why the settings aren't here — attributed from the actual failure, not guessed /// from whether `/status` happens to be answering. @@ -407,71 +349,7 @@ struct DeviceDetailView: View { // MARK: Diagnostics - private var diagnostics: some View { - KSSectionRail(title: "DIAGNOSTICS") { - DisclosureGroup { - VStack(spacing: 6) { - if let t = vm.status?.tickHealth { - // These are LIFETIME totals — the firmware says so. A non-zero value means - // "this happened at some point", usually at boot, and colouring it amber - // forever is how a real, healthy device ends up permanently flagged. The - // number is still worth SHOWING; it just isn't an alarm. Only a counter - // that is MOVING right now warns (see `hasDiagWarning`). - DiagRow("dropped ticks", "\(t.droppedTicks) since boot", - warn: vm.clockFault == .droppingNow) - DiagRow("bursts", "\(t.bursts)") - DiagRow("max gap µs", "\(t.maxGapMicros)") - DiagRow("max work µs", "\(t.maxWorkMicros)") - DiagRow("overruns", "\(t.overruns) since boot", - warn: vm.clockFault == .droppingNow) - - if vm.clockFault == .droppingNow { - Text("The clock task is dropping ticks RIGHT NOW.") - .font(.ksMono(11)) - .foregroundStyle(KS.amberText) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 2) - } - } - if let p = vm.status?.phaseHealth { - // maxStepMicros is the number that, unread, cost 138 seconds of - // silent DIN clock in ESP-027. It SHOULD carry a warning - // threshold — but nobody has told us what that threshold is, and - // inventing one would be worse than showing the raw number. - DiagRow("max origin step µs", "\(p.maxStepMicros)") - DiagRow("rtt min/max µs", "\(p.rttMinMicros)/\(p.rttMaxMicros)") - } - if vm.status?.tickHealth == nil && vm.status?.phaseHealth == nil { - Text("not measured") - .font(.ksMono(12)) - .foregroundStyle(KS.mut) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(.top, 8) - } label: { - HStack(spacing: 6) { - Text("tick / phase health") - .font(.ksMono(12)) - .foregroundStyle(KS.mut) - if hasDiagWarning { - Circle().fill(KS.amber).frame(width: 6, height: 6) - } - } - } - .tint(KS.mut) - } - } - /// A RATE, not a lifetime total. - /// - /// This used to be `droppedTicks > 0`. But those are LIFETIME counters, so a real device that - /// dropped 47 ticks at boot and has been flawless since lit an amber warning **forever** — and - /// a warning that is always on is one nobody reads, which makes it worthless on the day it - /// matters. Only a counter that MOVES between polls is news. See `ClockFault`. - private var hasDiagWarning: Bool { - vm.clockFault == .droppingNow - } } // MARK: - Pieces diff --git a/Sources/KitchenSyncApp/Views/DeviceListView.swift b/Sources/KitchenSyncApp/Views/DeviceListView.swift index 76536c2..299f16d 100644 --- a/Sources/KitchenSyncApp/Views/DeviceListView.swift +++ b/Sources/KitchenSyncApp/Views/DeviceListView.swift @@ -17,7 +17,14 @@ struct DeviceListView: View { var body: some View { NavigationStack { Group { - if vm.devices.isEmpty { + // Order matters. "Looking for KitchenSync units on this network…" is a LIE + // when iOS has denied us the local network — we are not looking, and we + // never will be. That message is what sent the user off to debug a WiFi + // network that was working perfectly, while the device sat on the bench + // blinking in time with the session. + if vm.discoveryStatus.needsUserAction && vm.devices.isEmpty { + localNetworkBlocked + } else if vm.devices.isEmpty { emptyState } else { list @@ -42,6 +49,10 @@ struct DeviceListView: View { private var list: some View { List { + if vm.discoveryStatus.needsUserAction { + Section { localNetworkBlocked } + } + if !discovered.isEmpty { Section { ForEach(discovered) { device in @@ -78,6 +89,44 @@ struct DeviceListView: View { .refreshable { await vm.refreshNow() } } + /// iOS is refusing us the local network, so there is nothing to look for and no point + /// pretending otherwise. + /// + /// This is the ONE failure on this screen the user can actually fix, and the app used + /// to say nothing at all about it — Bonjour just returns an empty set forever. Name the + /// cause, and put them one tap from the switch. + private var localNetworkBlocked: some View { + VStack(spacing: 14) { + KSPowerLED(alive: false) + + Text("iOS is blocking Local Network access") + .font(.ksMono(14)) + .foregroundStyle(KS.ember) + .multilineTextAlignment(.center) + + Text("KitchenSync can't see your devices without it — discovery finds nothing, even when a unit is powered up and following the session.") + .font(.ksMono(12)) + .foregroundStyle(KS.mut) + .multilineTextAlignment(.center) + + Button("Open Settings") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .font(.ksDisplay(14)) + .tracking(1.4) + + Button("Add by IP") { addingDevice = true } + .font(.ksMono(12)) + .foregroundStyle(KS.mut) + } + .padding(28) + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + /// Not "No Devices." The overwhelmingly common cause is the phone being on the /// wrong SSID, or a network that blocks mDNS — so say that, and offer the way out. private var emptyState: some View { diff --git a/Sources/KitchenSyncApp/Views/DeviceLiveSettingsSheet.swift b/Sources/KitchenSyncApp/Views/DeviceLiveSettingsSheet.swift new file mode 100644 index 0000000..9131624 --- /dev/null +++ b/Sources/KitchenSyncApp/Views/DeviceLiveSettingsSheet.swift @@ -0,0 +1,385 @@ +import SwiftUI + +/// Everything you can change **live**. Nothing here reboots the device. +/// +/// That is a structural guarantee, not a promise: every control on this sheet routes through a +/// `KsLiveEdit` case, and there is no case for a reboot-only field (WiFi, metronome *enable*, +/// follow-beat *enable*). Those live behind the door at the bottom, on their own surface, with the +/// consequence band and the `WRITE & REBOOT` key. +/// +/// **No surface ever mixes live and reboot controls.** The device's own web page puts the metronome +/// ENABLE toggle and the metro-accent toggle forty pixels apart, as the identical component, in the +/// same box — one applies instantly and the other restarts the device mid-set, with nothing on +/// screen to tell them apart. Better labelling would not have fixed that. Not being adjacent does. +/// +/// Sections appear only for hardware the device actually reports (`link-devices` ESP-030): no +/// speaker, no click section; no strip, no LED section. Solder a strip onto a Touch and flip one +/// FIRMWARE flag, and the LED section shows up here with no change to this app. +struct DeviceLiveSettingsSheet: View { + let deviceName: String + let config: KsConfig + let status: KsStatus? + let clockFault: ClockFault + let onEdit: (KsLiveEdit) -> Void + let onSave: (KsConfig, [WifiCredentialEdit]) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var showingSetup = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + liveBanner + + clockOutputs + + // Only if a speaker is fitted. A volume slider for a speaker that doesn't + // exist is a UI lying about the hardware. + if let m = config.metronome { click(m) } + if let l = config.led { led(l) } + + diagnostics + + deviceSetupDoor + } + .padding() + } + .background(KS.bg.ignoresSafeArea()) + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + .sheet(isPresented: $showingSetup) { + DeviceSettingsSheet(deviceName: deviceName, config: config, onSave: onSave) + } + } + .presentationDetents([.large]) + } + + /// The counterpart of Device Setup's consequence band. It says the *reassuring* thing, and it + /// is the reason a user can touch anything here without thinking twice. + private var liveBanner: some View { + Text("Everything on this screen applies instantly. Nothing here reboots the device.") + .font(.ksMono(12)) + .foregroundStyle(KS.mut) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(KS.panel.opacity(0.5), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 10, style: .continuous).strokeBorder(KS.line)) + } + + // MARK: Clock outputs — the set-once half. Nudge and swing stay on Performance. + + private var clockOutputs: some View { + KSSectionRail(title: "MIDI CLOCK OUT") { + HStack { + Text("master enable").font(.ksMono(12)).foregroundStyle(KS.mut) + Spacer() + Toggle("", isOn: Binding( + get: { config.clockOutEnabled }, + set: { onEdit(.clockOutEnabled($0)) } + )) + .toggleStyle(KSSwitchStyle()) + .labelsHidden() + .accessibilityLabel("Master clock out enable") + } + + // One card per element — the array LENGTH is the device's real output count + // (1 on a Touch, 4 on a P4), never a hardcoded 4. + ForEach(Array(config.clock.enumerated()), id: \.offset) { i, out in + SettingsOutputCard(index: i, config: out, onEdit: onEdit) + } + } + } + + // MARK: Click — only when a speaker is fitted + + private func click(_ m: MetronomeConfig) -> some View { + KSSectionRail(title: "CLICK") { + // The metronome ENABLE is NOT here. It has no KsLiveEdit case — it needs a full + // /save, which REBOOTS. It lives behind the door, and this row only reports it. + RebootOnlyStatusRow(label: "enable", value: m.enabled ? "ON" : "OFF") + + HStack { + Text("accent").font(.ksMono(12)).foregroundStyle(KS.mut) + Spacer() + Toggle("", isOn: Binding( + get: { m.accent }, + set: { onEdit(.metronomeAccent($0)) } + )) + .toggleStyle(KSSwitchStyle()) + .labelsHidden() + .accessibilityLabel("Click accent") + } + + KSLiveSlider(label: "VOL", range: 0...100, value: m.volume) { onEdit(.metronomeVolume($0)) } + + KSField(prefix: "VOICE") { + Picker("Voice", selection: Binding( + get: { m.voice }, + set: { onEdit(.metronomeVoice($0)) } + )) { + Text("Tone").tag(0) + Text("Click").tag(1) + Text("Wood").tag(2) + } + .pickerStyle(.menu) + .tint(KS.ink) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + // MARK: LED — only when a strip is wired + + private func led(_ l: LedConfig) -> some View { + KSSectionRail(title: "LED STRIP") { + HStack { + Text("enable").font(.ksMono(12)).foregroundStyle(KS.mut) + Spacer() + Toggle("", isOn: Binding(get: { l.enabled }, set: { onEdit(.ledEnabled($0)) })) + .toggleStyle(KSSwitchStyle()) + .labelsHidden() + .accessibilityLabel("LED enable") + } + + KSLiveSlider(label: "BRIGHT", range: 0...100, value: l.brightness) { onEdit(.ledBrightness($0)) } + KSLiveSlider(label: "FADE", range: 0...100, value: l.fade) { onEdit(.ledFade($0)) } + + KSField(prefix: "MODE") { + Picker("Mode", selection: Binding(get: { l.mode }, set: { onEdit(.ledMode($0)) })) { + Text("Chase").tag(0) + Text("Flash").tag(1) + Text("Fill").tag(2) + } + .pickerStyle(.menu) + .tint(KS.ink) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + KSField(prefix: "BEAT") { + ColorPicker("Beat colour", selection: Binding( + get: { Color(hex: l.beatColor) }, + set: { onEdit(.ledBeatColor($0.rgb24)) } + ), supportsOpacity: false) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .trailing) + } + + KSField(prefix: "ACCENT") { + ColorPicker("Accent colour", selection: Binding( + get: { Color(hex: l.accentColor) }, + set: { onEdit(.ledAccentColor($0.rgb24)) } + ), supportsOpacity: false) + .labelsHidden() + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + // MARK: Diagnostics + + private var diagnostics: some View { + KSSectionRail(title: "DIAGNOSTICS") { + DisclosureGroup { + VStack(spacing: 6) { + if let t = status?.tickHealth { + // LIFETIME totals. Shown, but not an alarm — only a counter that is MOVING + // right now is news (T-018). Warning on `> 0` lit an amber dot forever on a + // device that dropped a tick at boot and has been flawless since. + DiagLine("dropped ticks", "\(t.droppedTicks) since boot", + warn: clockFault == .droppingNow) + DiagLine("overruns", "\(t.overruns) since boot", + warn: clockFault == .droppingNow) + DiagLine("max gap µs", "\(t.maxGapMicros)") + DiagLine("max work µs", "\(t.maxWorkMicros)") + + if clockFault == .droppingNow { + Text("The clock task is dropping ticks RIGHT NOW.") + .font(.ksMono(11)) + .foregroundStyle(KS.amberText) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + if let p = status?.phaseHealth { + // maxStepMicros is the number that, unread, cost 138 seconds of silent DIN + // clock (ESP-027). It SHOULD have a warning threshold — but nobody has told + // us what it is, and inventing one would be worse than showing the number. + DiagLine("max origin step µs", "\(p.maxStepMicros)") + DiagLine("rtt min/max µs", "\(p.rttMinMicros)/\(p.rttMaxMicros)") + } + if status?.tickHealth == nil && status?.phaseHealth == nil { + Text("not measured") + .font(.ksMono(12)) + .foregroundStyle(KS.mut) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.top, 8) + } label: { + HStack(spacing: 6) { + Text("tick / phase health").font(.ksMono(12)).foregroundStyle(KS.mut) + if clockFault == .droppingNow { + Circle().fill(KS.amber).frame(width: 6, height: 6) + } + } + } + .tint(KS.mut) + } + } + + // MARK: The door + + /// A DOOR, not a section. Everything on the other side of it reboots the device, and it is on + /// its own surface for exactly that reason — the whole point of this split is that a live + /// slider and a reboot toggle are never adjacent. + private var deviceSetupDoor: some View { + Button { showingSetup = true } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("DEVICE SETUP") + .font(.ksDisplay(12.5, .semibold)) + .tracking(1.4) + .foregroundStyle(KS.amberText) + Text("WiFi, metronome enable, follow beat — these reboot the device") + .font(.ksMono(11)) + .foregroundStyle(KS.mut) + .multilineTextAlignment(.leading) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(KS.mut) + } + .padding(12) + .background(KS.consequence, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(KS.amber.opacity(0.35)) + ) + } + .buttonStyle(.plain) + } +} + +/// One clock output's SET-ONCE controls. Nudge and swing are not here — they're on Performance, +/// because they're what you reach for while the band is playing. +private struct SettingsOutputCard: View { + let index: Int + let config: ClockOutputConfig + let onEdit: (KsLiveEdit) -> Void + + private static let rates: [(value: Int, label: String)] = [ + (24, "MIDI clock (24)"), (48, "×2 (48)"), (12, "÷2 (12)"), (6, "÷4 (6)"), + (4, "1/16 (4)"), (2, "1/8 (2)"), (1, "1/4 (1)"), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("CLOCK OUT \(index + 1)") + .font(.ksDisplay(11, .semibold)) + .tracking(2) + .foregroundStyle(KS.mut) + Spacer() + Toggle("", isOn: Binding( + get: { config.enabled }, + set: { onEdit(.outputEnabled(index: index, $0)) } + )) + .toggleStyle(KSSwitchStyle()) + .labelsHidden() + .accessibilityLabel("Enable clock out \(index + 1)") + } + + KSField(prefix: "CABLE") { + Picker("Cable", selection: Binding( + get: { config.cable }, + set: { onEdit(.outputCable(index: index, $0)) } + )) { + ForEach(0..<4, id: \.self) { c in + Text("USB \(["A", "B", "C", "D"][c])").tag(c) + } + } + .pickerStyle(.menu).tint(KS.ink) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + // The firmware's exact option strings — the user reads the same words on the device's + // own page. Not rewritten into prettier iOS copy. + KSField(prefix: "RATE") { + Picker("Rate", selection: Binding( + get: { config.ppqn }, + set: { onEdit(.outputPPQN(index: index, $0)) } + )) { + ForEach(Self.rates, id: \.value) { Text($0.label).tag($0.value) } + } + .pickerStyle(.menu).tint(KS.ink) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + HStack(spacing: 10) { + Toggle("", isOn: Binding( + get: { config.followsLinkTransport }, + set: { onEdit(.outputFollowsLink(index: index, $0)) } + )) + .toggleStyle(KSSwitchStyle()) + .labelsHidden() + + Text("follow Link transport") + .font(.ksMono(12)) + .foregroundStyle(KS.mut) + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Follow Link transport, output \(index + 1)") + } + .padding(12) + .background(KS.panel.opacity(0.5), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).strokeBorder(KS.line)) + } +} + +/// A setting that cannot be changed from a LIVE surface, because changing it reboots the device. +/// Rendered as a value with a chevron, never as a control — you cannot fat-finger a read-only row +/// into restarting a box mid-set. +private struct RebootOnlyStatusRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label).font(.ksMono(12)).foregroundStyle(KS.mut) + Spacer() + Text(value).font(.ksMono(13, .medium)).foregroundStyle(KS.ink) + KSPill(text: "REBOOTS", color: KS.amber) + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(label): \(value). Changing this reboots the device; it lives in Device Setup.") + } +} + +private struct DiagLine: View { + let label: String + let value: String + var warn = false + + init(_ label: String, _ value: String, warn: Bool = false) { + self.label = label + self.value = value + self.warn = warn + } + + var body: some View { + HStack { + Text(label).font(.ksMono(11)).foregroundStyle(KS.mut) + Spacer() + Text(value) + .font(.ksMono(12, .medium)) + .monospacedDigit() + .foregroundStyle(warn ? KS.amberText : KS.ink) + } + } +} diff --git a/Sources/KitchenSyncApp/Views/DeviceSettingsSheet.swift b/Sources/KitchenSyncApp/Views/DeviceSettingsSheet.swift index 364e743..32f3242 100644 --- a/Sources/KitchenSyncApp/Views/DeviceSettingsSheet.swift +++ b/Sources/KitchenSyncApp/Views/DeviceSettingsSheet.swift @@ -47,8 +47,14 @@ struct DeviceSettingsSheet: View { // first "for the phone setup flow" — but if you're in this sheet, // discovery already found the device. WiFi is the least likely thing // you're changing and by far the most dangerous to fat-finger. Last. - metronome - followBeat + // + // These sections appear ONLY if the device reports the hardware. A board + // with no speaker doesn't get a metronome toggle — drawing one for + // hardware that isn't fitted is how a UI lies. Solder a strip onto a + // Touch and flip one FIRMWARE flag, and the section shows up here with + // no change to this app at all. + if draft.metronome != nil { metronome } + if draft.followBeatEnabled != nil { followBeat } wifi writeAndReboot @@ -107,7 +113,11 @@ struct DeviceSettingsSheet: View { label: "enable", key: "metronome", caption: "The audio codec only starts at boot, so this cannot be applied live. Volume, voice and accent CAN — they're on the device screen.", - isOn: Binding(get: { draft.metronomeEnabled }, set: { draft.metronomeEnabled = $0 }) + // Only rendered when `draft.metronome != nil`, so the device HAS a speaker. + isOn: Binding( + get: { draft.metronome?.enabled ?? false }, + set: { draft.metronome?.enabled = $0 } + ) ) } } @@ -118,7 +128,10 @@ struct DeviceSettingsSheet: View { label: "enable", key: "follow_beat", caption: "Listens to the room and follows the beat it hears.", - isOn: Binding(get: { draft.followBeatEnabled }, set: { draft.followBeatEnabled = $0 }) + isOn: Binding( + get: { draft.followBeatEnabled ?? false }, + set: { draft.followBeatEnabled = $0 } + ) ) } } diff --git a/Sources/KitchenSyncApp/Views/OutputCardView.swift b/Sources/KitchenSyncApp/Views/OutputCardView.swift deleted file mode 100644 index 1b67972..0000000 --- a/Sources/KitchenSyncApp/Views/OutputCardView.swift +++ /dev/null @@ -1,185 +0,0 @@ -import SwiftUI - -/// One of the four clock outputs. -/// -/// **Every control in this card has a `KsLiveEdit` case, so the card cannot -/// reboot anything.** That is a structural fact, not a visual promise — there is -/// no case in the enum for a reboot-required field, so one cannot be wired up here -/// even by mistake. Reboot-required settings live behind the settings sheet (T-007). -/// -/// Layout departs from the device's web UI deliberately: the page puts `RUN` in the -/// middle of the config stack, between CABLE and NUDGE. Here transport comes FIRST. -/// On a phone the transport is why you opened the app; cable and swing are why you -/// opened it once, three weeks ago. -struct OutputCardView: View { - let index: Int - let config: ClockOutputConfig - let launch: TransportLaunchState - let bpm: Double - let linkOwnsTransport: Bool - let onEdit: (KsLiveEdit) -> Void - let onTransport: (Bool) -> Void - - /// Link owns THIS output only if it's set to follow Link and Link is driving. - private var linkOwned: Bool { linkOwnsTransport && config.followsLinkTransport } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - - TransportButton(state: launch, bpm: bpm, linkOwned: linkOwned, onTap: onTransport) - .disabled(!config.enabled) - - VStack(spacing: 8) { - cablePicker - ratePicker - nudge - swing - followLink - } - // A disabled output DIMS — it does not vanish. The web hides the section - // (`.hide{display:none!important}`), which on a phone is a layout jump - // under the user's thumb, and hides the very information you need in - // order to decide whether to turn it on. You can still read that Out 3 - // is USB C at ÷2 while it's off. - .opacity(config.enabled ? 1 : 0.5) - .disabled(!config.enabled) - } - .padding(14) - .background(KS.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(KS.line.opacity(config.enabled ? 1 : 0.5)) - ) - } - - private var header: some View { - HStack { - Text("CLOCK OUT \(index + 1)") - .font(.ksDisplay(11, .semibold)) - .tracking(2) - .foregroundStyle(KS.mut) - .accessibilityLabel("Clock out \(index + 1)") - - Spacer() - - Toggle("", isOn: Binding( - get: { config.enabled }, - set: { onEdit(.outputEnabled(index: index, $0)) } - )) - .toggleStyle(KSSwitchStyle()) - .labelsHidden() - .accessibilityLabel("Enable clock out \(index + 1)") - } - } - - // USB-MIDI virtual cable — Midihub USB A..D. - private var cablePicker: some View { - KSField(prefix: "CABLE") { - Picker("Cable", selection: Binding( - get: { config.cable }, - set: { onEdit(.outputCable(index: index, $0)) } - )) { - ForEach(0..<4, id: \.self) { c in - Text("USB \(["A", "B", "C", "D"][c])").tag(c) - } - } - .pickerStyle(.menu) - .tint(KS.ink) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - - /// The firmware's exact option strings (`ks_web.cpp`'s `build_outputs()`), - /// values and labels both. Deliberately NOT rewritten into prettier iOS copy — - /// the user reads these same words on the device's own page. - private static let rates: [(value: Int, label: String)] = [ - (24, "MIDI clock (24)"), - (48, "×2 (48)"), - (12, "÷2 (12)"), - (6, "÷4 (6)"), - (4, "1/16 (4)"), - (2, "1/8 (2)"), - (1, "1/4 (1)"), - ] - - private var ratePicker: some View { - KSField(prefix: "RATE") { - Picker("Rate", selection: Binding( - get: { config.ppqn }, - set: { onEdit(.outputPPQN(index: index, $0)) } - )) { - ForEach(Self.rates, id: \.value) { rate in - Text(rate.label).tag(rate.value) - } - } - .pickerStyle(.menu) - .tint(KS.ink) - .frame(maxWidth: .infinity, alignment: .trailing) - } - } - - // Latency-comp nudge: −250…250 milli-beats, step 5 (per `build_outputs()`). - private var nudge: some View { - KSStepperField( - prefix: "NUDGE", - value: Binding(get: { config.phaseMilliBeats }, set: { _ in }), - range: -250...250, - onCommit: { onEdit(.outputPhase(index: index, $0)) } - ) - } - - // Swing: 0…250 milli-beats, 0 = straight. - private var swing: some View { - KSStepperField( - prefix: "SWING", - value: Binding(get: { config.swingMilliBeats }, set: { _ in }), - range: 0...250, - onCommit: { onEdit(.outputSwing(index: index, $0)) } - ) - } - - /// Picks which single thing owns this output's Start/Stop. Exactly one master - /// per output, never two. - private var followLink: some View { - HStack(spacing: 10) { - Toggle("", isOn: Binding( - get: { config.followsLinkTransport }, - set: { onEdit(.outputFollowsLink(index: index, $0)) } - )) - .toggleStyle(KSSwitchStyle()) - .labelsHidden() - - Text("follow Link transport") - .font(.ksMono(12)) - .foregroundStyle(KS.mut) - - Spacer() - } - .accessibilityElement(children: .combine) - .accessibilityLabel("Follow Link transport") - } -} - -#Preview { - ScrollView { - VStack(spacing: 14) { - OutputCardView( - index: 0, - config: ClockOutputConfig(enabled: true, cable: 0, ppqn: 24, - phaseMilliBeats: 15, swingMilliBeats: 0, - followsLinkTransport: false), - launch: .armed, bpm: 128, linkOwnsTransport: false, - onEdit: { _ in }, onTransport: { _ in } - ) - OutputCardView( - index: 2, - config: .disabled, - launch: .stopped, bpm: 128, linkOwnsTransport: false, - onEdit: { _ in }, onTransport: { _ in } - ) - } - .padding() - } - .background(KS.bg) -} diff --git a/Sources/KitchenSyncApp/Views/PerformanceOutputCard.swift b/Sources/KitchenSyncApp/Views/PerformanceOutputCard.swift new file mode 100644 index 0000000..2ef5b98 --- /dev/null +++ b/Sources/KitchenSyncApp/Views/PerformanceOutputCard.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// One clock output, as it appears **on stage**. +/// +/// The transport button, plus the two controls you actually reach for mid-set: NUDGE and SWING. +/// Nothing else. Cable, rate and follow-Link live in Settings, because knocking a cable assignment +/// loose during a song is a way to ruin it, and you cannot knock what is not on the screen. +/// +/// NUDGE earns its place here on the firmware's own account: `nudge_mbeats` is live via `/nudge`, +/// no reboot, and the comment says it "slides the RC-505 into the pocket". That is a playing +/// control, not a setup one. Swing is the same kind of thing — a groove you feel your way to while +/// the band is going. +/// +/// Every control here has a `KsLiveEdit` case, so this card structurally cannot reboot the device. +struct PerformanceOutputCard: View { + let index: Int + let config: ClockOutputConfig + let launch: TransportLaunchState + let bpm: Double + let linkOwnsTransport: Bool + let onEdit: (KsLiveEdit) -> Void + let onTransport: (Bool) -> Void + + private var linkOwned: Bool { linkOwnsTransport && config.followsLinkTransport } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + + TransportButton(state: launch, bpm: bpm, linkOwned: linkOwned, onTap: onTransport) + .disabled(!config.enabled) + + VStack(spacing: 8) { + KSStepperField( + prefix: "NUDGE", + value: Binding(get: { config.phaseMilliBeats }, set: { _ in }), + range: -250...250, + onCommit: { onEdit(.outputPhase(index: index, $0)) } + ) + KSStepperField( + prefix: "SWING", + value: Binding(get: { config.swingMilliBeats }, set: { _ in }), + range: 0...250, + onCommit: { onEdit(.outputSwing(index: index, $0)) } + ) + } + // A disabled output DIMS — it does not vanish. Hiding it is a layout jump under the + // user's thumb, and you lose the ability to see what it's set to before enabling it. + .opacity(config.enabled ? 1 : 0.5) + .disabled(!config.enabled) + } + .padding(14) + .background(KS.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(KS.line.opacity(config.enabled ? 1 : 0.5)) + ) + } + + /// Read-only on stage. Enabling or disabling an output is a Settings act — doing it by accident + /// mid-song silences a device, and the transport button below is the only thing here that + /// should be one tap from changing what the audience hears. + private var header: some View { + HStack { + Text("CLOCK OUT \(index + 1)") + .font(.ksDisplay(11, .semibold)) + .tracking(2) + .foregroundStyle(KS.mut) + + Spacer() + + if !config.enabled { + KSPill(text: "OFF") + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Clock out \(index + 1)\(config.enabled ? "" : ", off")") + } +} diff --git a/Tests/KitchenSyncAppTests/CapabilityConfigTests.swift b/Tests/KitchenSyncAppTests/CapabilityConfigTests.swift new file mode 100644 index 0000000..ce6e0bd --- /dev/null +++ b/Tests/KitchenSyncAppTests/CapabilityConfigTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import KitchenSyncApp + +/// TDD: written before `KsConfig` can decode a device that doesn't have everything. +/// +/// **A device must not report hardware it does not have** (`link-devices` ESP-030). Absent +/// hardware is ABSENT from `/config.json`, never reported `false` — emitting `led:false` on a +/// board with no strip is the same class of lie as the firmware's `sync:1` over a wire that had +/// been dead for 138 seconds, and this app would dutifully draw an LED section for a device that +/// cannot light anything. +/// +/// So the app must decode an HONEST document. Today `KsConfig` requires every field, so it throws +/// on one — which would mean the firmware told the truth and the app called it broken. +/// +/// And capability belongs to the BUILD, not the product: solder a strip onto that Touch, flip one +/// firmware flag, and the LED section must appear here **with no app change at all.** +final class CapabilityConfigTests: XCTestCase { + /// The REAL Touch's `/config.json`, captured verbatim from `kstouch-dfd0` on 2026-07-14. + /// No metronome, no LED, no follow-beat, one clock output, three WiFi slots. + static let realTouchConfigJSON = """ + {"clock_out":true, + "wifi":[{"ssid":"Bench-2G","pass_set":true},{"ssid":"","pass_set":false},{"ssid":"","pass_set":false}], + "clock":[{"en":true,"cable":0,"ppqn":24,"phase":0,"swing":0,"follow":false}]} + """.data(using: .utf8)! + + func test_a_device_without_a_speaker_or_strip_still_decodes() throws { + let config = try JSONDecoder().decode(KsConfig.self, from: Self.realTouchConfigJSON) + + XCTAssertTrue(config.clockOutEnabled) + XCTAssertEqual(config.wifi.count, 3) + XCTAssertEqual(config.wifi[0].ssid, "Bench-2G") + XCTAssertTrue(config.wifi[0].passwordIsSet) + } + + /// Absent means ABSENT — not `false`. `false` would mean "there is a metronome and it is + /// switched off", which is a different (and untrue) statement. + func test_hardware_the_device_lacks_reads_as_absent_not_false() throws { + let config = try JSONDecoder().decode(KsConfig.self, from: Self.realTouchConfigJSON) + + XCTAssertNil(config.metronome, "no speaker is fitted — this is not 'a metronome, off'") + XCTAssertNil(config.led, "no strip is wired") + XCTAssertNil(config.followBeatEnabled, "no mic is fitted") + } + + /// The clock array's LENGTH is the device's real output count. The Touch has ONE. + func test_the_output_count_comes_from_the_array_not_a_constant() throws { + let config = try JSONDecoder().decode(KsConfig.self, from: Self.realTouchConfigJSON) + + XCTAssertEqual(config.clock.count, 1, "one DIN jack — render one card, not four") + } +} diff --git a/Tests/KitchenSyncAppTests/DeviceDetailViewModelTests.swift b/Tests/KitchenSyncAppTests/DeviceDetailViewModelTests.swift index 7fcdb49..217e186 100644 --- a/Tests/KitchenSyncAppTests/DeviceDetailViewModelTests.swift +++ b/Tests/KitchenSyncAppTests/DeviceDetailViewModelTests.swift @@ -96,7 +96,7 @@ final class DeviceDetailViewModelTests: XCTestCase { await vm.loadConfig() - XCTAssertEqual(vm.config?.ledBrightness, 60) + XCTAssertEqual(vm.config?.led?.brightness, 60) XCTAssertEqual(vm.config?.wifi.first?.ssid, "TestNet") // The password is never on the wire, so it can never be in the model. XCTAssertEqual(vm.config?.wifi.first?.passwordIsSet, true) diff --git a/Tests/KitchenSyncAppTests/DiscoveryStatusTests.swift b/Tests/KitchenSyncAppTests/DiscoveryStatusTests.swift new file mode 100644 index 0000000..e63322d --- /dev/null +++ b/Tests/KitchenSyncAppTests/DiscoveryStatusTests.swift @@ -0,0 +1,66 @@ +import Network +import XCTest +@testable import KitchenSyncApp + +/// TDD, from a bug the user hit on real hardware: *"I don't see the s3touch in the app — +/// even though it's following the Link session."* +/// +/// The device was advertising `_http._tcp` correctly the whole time, and the same build +/// found it instantly in the Simulator. What was different on the phone was iOS's **Local +/// Network permission**, which had been reset by reinstalling the app. +/// +/// When that permission is denied, iOS does not fail the browse — it just returns NOTHING, +/// forever. `KitchenSyncDiscovery` didn't even set a `stateUpdateHandler`, so a browser +/// that was dead on arrival stayed dead in silence and the list sat empty with no +/// explanation. The user is then sent to debug their network, their WiFi, their firmware — +/// everything except the one switch that actually turns it back on. +/// +/// The recurring bug of this codebase, one more time: a failure with no signal (T-009, +/// T-010, T-016, T-018, and `link_owns`). iOS DOES tell us — `kDNSServiceErr_PolicyDenied`. +/// We just never listened. +/// +/// The mapping is pure so it can be asserted without standing up an `NWBrowser`. +final class DiscoveryStatusTests: XCTestCase { + + /// THE ONE THAT MATTERS. -65570 is `kDNSServiceErr_PolicyDenied`: iOS is refusing us + /// the local network, and no amount of waiting will change that. + func test_a_policy_denied_browser_is_reported_as_permission_denied() { + let denied = NWError.dns(DNSServiceErrorType(-65570)) + + XCTAssertEqual(DiscoveryStatus(browserState: .waiting(denied)), .permissionDenied) + } + + /// ...and it is NOT confused with a transient wait. A browser waiting because the WiFi + /// is still coming up will recover on its own; telling the user to go change a Settings + /// toggle for that is sending them to fix the wrong thing. + func test_an_ordinary_wait_is_not_reported_as_permission_denied() { + let transient = NWError.posix(.ENETDOWN) + + XCTAssertEqual(DiscoveryStatus(browserState: .waiting(transient)), .waiting) + } + + func test_a_ready_browser_is_browsing() { + XCTAssertEqual(DiscoveryStatus(browserState: .ready), .browsing) + } + + /// A FAILED browser never comes back by itself — NWBrowser is done. Somebody has to + /// restart it, and nobody can if nobody is told. + func test_a_failed_browser_is_reported_as_failed() { + XCTAssertEqual(DiscoveryStatus(browserState: .failed(.posix(.ENETDOWN))), .failed) + } + + func test_setup_and_cancelled_are_not_errors() { + XCTAssertEqual(DiscoveryStatus(browserState: .setup), .starting) + XCTAssertEqual(DiscoveryStatus(browserState: .cancelled), .stopped) + } + + /// Only `permissionDenied` is the user's to fix. Everything else is ours or the + /// network's, and must not put a "go change your privacy settings" banner on screen. + func test_only_permission_denial_asks_the_user_to_do_something() { + XCTAssertTrue(DiscoveryStatus.permissionDenied.needsUserAction) + XCTAssertFalse(DiscoveryStatus.browsing.needsUserAction) + XCTAssertFalse(DiscoveryStatus.waiting.needsUserAction) + XCTAssertFalse(DiscoveryStatus.failed.needsUserAction) + XCTAssertFalse(DiscoveryStatus.starting.needsUserAction) + } +} diff --git a/Tests/KitchenSyncAppTests/KsConfigTests.swift b/Tests/KitchenSyncAppTests/KsConfigTests.swift index 6d1573f..5688d77 100644 --- a/Tests/KitchenSyncAppTests/KsConfigTests.swift +++ b/Tests/KitchenSyncAppTests/KsConfigTests.swift @@ -19,21 +19,21 @@ final class KsConfigDecodingTests: XCTestCase { func test_decodes_top_level_fields() throws { let config = try JSONDecoder().decode(KsConfig.self, from: Data(sampleConfigJSON.utf8)) XCTAssertTrue(config.clockOutEnabled) - XCTAssertFalse(config.metronomeEnabled) - XCTAssertTrue(config.metronomeAccent) - XCTAssertEqual(config.metronomeVolume, 80) - XCTAssertEqual(config.metronomeVoice, 0) - XCTAssertFalse(config.ledEnabled) - XCTAssertEqual(config.ledBrightness, 60) - XCTAssertEqual(config.ledMode, 0) - XCTAssertEqual(config.ledFade, 55) - XCTAssertFalse(config.followBeatEnabled) + XCTAssertEqual(config.metronome?.enabled, false) + XCTAssertEqual(config.metronome?.accent, true) + XCTAssertEqual(config.metronome?.volume, 80) + XCTAssertEqual(config.metronome?.voice, 0) + XCTAssertEqual(config.led?.enabled, false) + XCTAssertEqual(config.led?.brightness, 60) + XCTAssertEqual(config.led?.mode, 0) + XCTAssertEqual(config.led?.fade, 55) + XCTAssertEqual(config.followBeatEnabled, false) } func test_decodes_hash_prefixed_hex_colors() throws { let config = try JSONDecoder().decode(KsConfig.self, from: Data(sampleConfigJSON.utf8)) - XCTAssertEqual(config.ledBeatColor, 0x00B400) - XCTAssertEqual(config.ledAccentColor, 0xDC6E00) + XCTAssertEqual(config.led?.beatColor, 0x00B400) + XCTAssertEqual(config.led?.accentColor, 0xDC6E00) } func test_decodes_wifi_slots_in_order_without_ever_seeing_a_password() throws { @@ -99,7 +99,7 @@ final class KsConfigSaveFormFieldsTests: XCTestCase { func test_per_output_fields_present_for_all_four_outputs() throws { let config = try JSONDecoder().decode(KsConfig.self, from: Data(sampleConfigJSON.utf8)) let fields = config.saveFormFields(wifiEdits: []) - for index in 0.. DeviceDetailViewModel { + DeviceDetailViewModel( + client: KitchenSyncClient(host: "kstouch-dfd0.local", + session: StubURLProtocol.session()) + ) + } + + /// A one-output device, phase and swing at zero — the Touch, as shipped. + /// Dummy SSID: a fixture is a public artifact (repo, CI log, PR diff), and the real + /// network's name has no business in one. + private static let configJSON = """ + {"clock_out":false, + "wifi":[{"ssid":"Bench-2G","pass_set":true}], + "clock":[{"en":false,"cable":0,"ppqn":24,"phase":0,"swing":0,"follow":true}]} + """.data(using: .utf8)! + + private func loadedVM() async -> DeviceDetailViewModel { + StubURLProtocol.routes["GET /config.json"] = .init(body: Self.configJSON) + let vm = makeVM() + await vm.loadConfig() + return vm + } + + // MARK: - THE BUG + + /// The exact shape of the reported bug: firmware with no `/live` route. The app + /// must not shrug this off. + func test_a_404_from_live_is_reported_not_swallowed() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init(status: 404) + + await vm.apply(.outputPhase(index: 0, -40)) + + XCTAssertNotNil(vm.liveEditFailure, + "a 404 from /live must surface — swallowing it is what made this bug invisible") + } + + /// ...and the stepper must not sit there displaying a nudge the device never took. + func test_a_failed_live_edit_leaves_the_displayed_value_at_the_devices_truth() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init(status: 404) + + await vm.apply(.outputPhase(index: 0, -40)) + + XCTAssertEqual(vm.config?.clock[0].phaseMilliBeats, 0, + "the device is still at 0; showing -40 would be a lie") + } + + /// A transport error (device unplugged mid-edit) is a failure too. The user does + /// not care WHICH way it failed — they care that it did. + func test_a_transport_error_from_live_is_reported() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init(status: 500) + + await vm.apply(.outputSwing(index: 0, 90)) + + XCTAssertNotNil(vm.liveEditFailure) + XCTAssertEqual(vm.config?.clock[0].swingMilliBeats, 0) + } + + // MARK: - THE HAPPY PATH + + /// Even a WORKING /live looked broken: nothing updated the local config, so the + /// value on screen never moved. The edit we just made must be reflected. + func test_a_successful_live_edit_updates_the_displayed_value() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init() + + await vm.apply(.outputPhase(index: 0, -40)) + + XCTAssertEqual(vm.config?.clock[0].phaseMilliBeats, -40) + XCTAssertNil(vm.liveEditFailure) + } + + func test_a_successful_live_edit_updates_swing_too() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init() + + await vm.apply(.outputSwing(index: 0, 90)) + + XCTAssertEqual(vm.config?.clock[0].swingMilliBeats, 90) + } + + /// A success after a failure must CLEAR the failure. Otherwise the error banner is + /// permanent — the T-018 mistake (a lifetime counter shown as a live fault) all + /// over again. + func test_a_successful_edit_clears_a_previous_failure() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init(status: 404) + await vm.apply(.outputPhase(index: 0, -40)) + XCTAssertNotNil(vm.liveEditFailure) + + StubURLProtocol.routes["POST /live"] = .init() + await vm.apply(.outputPhase(index: 0, -40)) + + XCTAssertNil(vm.liveEditFailure, "the device is answering again; stop shouting") + } + + /// An edit aimed at an output this device does not have must not crash or invent + /// one. The Touch has ONE output; the app's `maxOutputCount` is 4. + func test_an_edit_to_an_output_that_does_not_exist_is_ignored_locally() async { + let vm = await loadedVM() + StubURLProtocol.routes["POST /live"] = .init() + + await vm.apply(.outputPhase(index: 3, -40)) + + XCTAssertEqual(vm.config?.clock.count, 1, "never grow the array to fit an edit") + } +} diff --git a/Tests/KitchenSyncAppTests/LiveRebootPartitionTests.swift b/Tests/KitchenSyncAppTests/LiveRebootPartitionTests.swift index 13ae0a2..0484790 100644 --- a/Tests/KitchenSyncAppTests/LiveRebootPartitionTests.swift +++ b/Tests/KitchenSyncAppTests/LiveRebootPartitionTests.swift @@ -34,7 +34,7 @@ final class LiveRebootPartitionTests: XCTestCase { .ledFade(50), .ledBeatColor(0x00FF00), .ledAccentColor(0xFF0000), - ] + (0.. [KsLiveEdit] in + ] + (0.. [KsLiveEdit] in [ .outputEnabled(index: i, true), .outputCable(index: i, 0), diff --git a/Tests/KitchenSyncAppTests/RealTouchStatusTests.swift b/Tests/KitchenSyncAppTests/RealTouchStatusTests.swift new file mode 100644 index 0000000..db114a5 --- /dev/null +++ b/Tests/KitchenSyncAppTests/RealTouchStatusTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import KitchenSyncApp + +/// The test this codebase never had, and it is the reason a bug survived a "verified" +/// firmware fix: CLAUDE.md says it out loud — "Nothing has ever spoken to a real +/// device. Every network test runs against a stub." +/// +/// Every fixture in this suite was WRITTEN BY ME from a reading of the firmware. That +/// makes them a test of my reading, not of the device. These bytes are different: they +/// are a VERBATIM capture from a KitchenSync Touch (MAC a4:cb:8f:da:df:d0, fw 2.2.0) +/// on 2026-07-14, `curl http://192.168.0.101/status`. Nothing has been prettified, +/// reordered, or filled in. +/// +/// The user's report — "play and stop not working... app UI not changing per +/// play/stop" — is exactly what a silently failing `/status` decode looks like from the +/// outside: the buttons still POST (so the device obeys), but `status` stays nil, and +/// `DeviceDetailView` falls back to `.stopped` for every output. The transport works +/// and the screen lies about it. +@MainActor +final class RealTouchStatusTests: XCTestCase { + + /// Captured from the device WHILE STOPPED. Note `launch:[0]` — one output, not four. + private static let stoppedJSON = """ + {"bpm":123.0,"min":0.0,"peers":1,"usb":false,"tx":935,"fw":"2.2.0","follow_enabled":false,\ + "follow_bpm":0.0,"follow_confidence":0.0,"follow_valid":false,"launch":[0],"playing":false,\ + "link_owns":false,"drop":0,"burst":0,"gap":1470,"work":215,"over":0,"core":1,"w_beats":47,\ + "w_clock":78,"reprime":0,"clk":"locked","pulses":935,"sync":1,"clock":1,"transport":0,\ + "cue":0,"tfail":0,"tzero":1,"ccancel":0,"wtport":90,"beats":1913.15,"locked":1,\ + "bsactive":1,"btn":-1,"btnlows":0,"btnpress":0} + """.data(using: .utf8)! + + /// The SAME device, captured mid-song: `launch:[2]`, `playing:true`. + private static let runningJSON = """ + {"bpm":123.0,"min":0.0,"peers":1,"usb":false,"tx":1137,"fw":"2.2.0","follow_enabled":false,\ + "follow_bpm":0.0,"follow_confidence":0.0,"follow_valid":false,"launch":[2],"playing":true,\ + "link_owns":false,"drop":0,"burst":0,"gap":1472,"work":222,"over":0,"core":1,"w_beats":43,\ + "w_clock":86,"reprime":0,"clk":"locked","pulses":1137,"sync":1,"clock":1,"transport":2,\ + "cue":0,"tfail":0,"tzero":1,"ccancel":0,"wtport":93,"beats":1921.56,"locked":1,\ + "bsactive":1,"btn":-1,"btnlows":0,"btnpress":0} + """.data(using: .utf8)! + + /// The BROKEN bytes, kept deliberately. This is what the device sent before the fix: + /// `link_owns:true` while its transport obeyed every play and stop it was given. + /// A regression here means the fleet has started lying again. + private static let lyingLinkOwnsJSON = """ + {"bpm":123.0,"min":0.0,"peers":1,"usb":false,"tx":6372,"fw":"2.2.0","follow_enabled":false,\ + "follow_bpm":0.0,"follow_confidence":0.0,"follow_valid":false,"launch":[0],"playing":false,\ + "link_owns":true,"drop":0,"burst":0,"gap":1477,"work":234,"over":0,"core":1,"w_beats":42,\ + "w_clock":87,"reprime":0,"clk":"locked","pulses":6372,"sync":1,"clock":1,"transport":0,\ + "cue":0,"tfail":0,"tzero":1,"ccancel":0,"wtport":105,"beats":456.14,"locked":1,\ + "bsactive":1,"btn":-1,"btnlows":0,"btnpress":0} + """.data(using: .utf8)! + + /// If this throws, the app is BLIND to a real device — and blind in the worst way: + /// `apply`/`transport` still POST, so the hardware obeys while the screen never moves. + func test_the_real_touch_status_decodes_at_all() throws { + XCTAssertNoThrow(try JSONDecoder().decode(KsStatus.self, from: Self.stoppedJSON)) + } + + func test_a_stopped_touch_decodes_as_stopped() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.stoppedJSON) + + XCTAssertEqual(s.bpm, 123.0) + XCTAssertEqual(s.peers, 1) + XCTAssertEqual(s.launch, [.stopped]) // ONE output. The Touch has one jack. + XCTAssertFalse(s.playing) + } + + /// THE ONE THAT MATTERS. Press play, and the app must SEE it. + func test_a_running_touch_decodes_as_running() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.runningJSON) + + XCTAssertEqual(s.launch, [.running]) + XCTAssertTrue(s.playing) + } + + /// The Touch reports one output; `maxOutputCount` is 4. The array's LENGTH is the + /// truth (ESP-030) — nothing may pad it out to four and invent three dead jacks. + func test_the_launch_array_is_not_padded_to_the_max_output_count() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.stoppedJSON) + XCTAssertEqual(s.launch.count, 1) + } + + // MARK: - "play and stop not working" + + /// THE BUG, reduced to one assertion. + /// + /// `link_owns` is a LIVE claim. `ks_status.h`: "when link_owns is true the manual + /// PLAY/STOP buttons are ignored, so the UI greys them". The app obeys that contract + /// — `TransportAppearance.acceptsTap = !linkOwned` — and so it refused to even SEND + /// the tap. + /// + /// But the Touch was passing `link_proto_start_stop_seen()`, which is STICKY: "has a + /// peer EVER published a StartStopState", latched true until the last peer leaves. + /// And `ktouch_transport.c` never consults Link start/stop at all — measured on the + /// bench, the device obeys POST /transport every single time, stopped -> armed -> + /// running, with link_owns true throughout. + /// + /// So the device claimed "I ignore your play button" while cheerfully obeying it, and + /// the app believed the claim. A HISTORICAL flag published as a LIVE state — the same + /// bug as T-018 (lifetime droppedTicks shown as a live fault) and ESP-028 (sync:1 + /// over a wire that had been dead for 138 seconds). + func test_the_app_can_start_a_stopped_touch() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.stoppedJSON) + + let button = TransportAppearance.appearance(for: s.launch[0], + linkOwned: s.linkOwnsTransport) + + XCTAssertTrue(button.acceptsTap, + "the device obeys play/stop — a UI that greys the button is lying to the user") + } + + /// ...and it must be able to stop it again. + func test_the_app_can_stop_a_running_touch() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.runningJSON) + + let button = TransportAppearance.appearance(for: s.launch[0], + linkOwned: s.linkOwnsTransport) + + XCTAssertTrue(button.acceptsTap) + XCTAssertEqual(button.label, "PLAYING") + } + + /// The app's contract-following is NOT the bug, and must not be "fixed" away. + /// + /// If a device genuinely does defer to Link — the P4 does, via ks_tick arbitration — + /// then `link_owns:true` is true, its manual buttons really are ignored, and greying + /// them is correct: a button that does nothing is worse than a button that says so. + /// The Touch's mistake was CLAIMING that while obeying anyway. + func test_a_device_that_really_does_defer_to_link_still_greys_the_button() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.lyingLinkOwnsJSON) + + let button = TransportAppearance.appearance(for: s.launch[0], + linkOwned: s.linkOwnsTransport) + + XCTAssertFalse(button.acceptsTap, + "link_owns is a LIVE claim; honouring it is right. The fix belongs in whoever LIES.") + } + + /// The Touch has no Link-phase block (`xf*`), so `phaseHealth` must be nil rather + /// than a row of flattering zeroes — and its ABSENCE must not fail the whole decode, + /// which is the trap that would take the entire screen down with it. + func test_a_missing_phase_block_does_not_sink_the_decode() throws { + let s = try JSONDecoder().decode(KsStatus.self, from: Self.stoppedJSON) + XCTAssertNil(s.phaseHealth) + XCTAssertNotNil(s.tickHealth, "the Touch DOES send the tick block") + } +} diff --git a/Tools/make_app_icon.swift b/Tools/make_app_icon.swift new file mode 100644 index 0000000..ffa3563 --- /dev/null +++ b/Tools/make_app_icon.swift @@ -0,0 +1,141 @@ +#!/usr/bin/env swift +// +// Renders the KitchenSync app icon. Source-controlled and re-runnable: +// +// swift Tools/make_app_icon.swift Sources/KitchenSyncApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png +// +// The icon is drawn, not drawn-over: it uses the same palette as the device's own +// web UI (KS.bg / KS.panel / KS.led), because the app was built to look like the +// thing it controls. +// +// WHAT IT IS: a MIDI clock pulse train in lime on charcoal, with one taller ACCENT +// pulse — the downbeat. That is literally what this box emits, and a square wave +// still reads as a square wave at 40px, which "KITCHEN·SYNC" in a display face +// absolutely does not. +// +// iOS rules: full-bleed square, NO alpha, NO rounded corners — the OS masks it. + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +let S = 1024.0 + +guard let out = CommandLine.arguments.dropFirst().first else { + FileHandle.standardError.write("usage: make_app_icon.swift \n".data(using: .utf8)!) + exit(2) +} + +let space = CGColorSpaceCreateDeviceRGB() +// noneSkipLast = opaque. An app icon with an alpha channel is rejected at submission. +guard let ctx = CGContext(data: nil, width: Int(S), height: Int(S), bitsPerComponent: 8, + bytesPerRow: 0, space: space, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { + FileHandle.standardError.write("could not create context\n".data(using: .utf8)!) + exit(1) +} + +func rgb(_ hex: UInt32, _ a: Double = 1) -> CGColor { + CGColor(colorSpace: space, components: [ + Double((hex >> 16) & 0xFF) / 255, + Double((hex >> 8) & 0xFF) / 255, + Double(hex & 0xFF) / 255, a, + ])! +} + +// ---- the panel --------------------------------------------------------------- +// KS.panel -> KS.bg, top to bottom. The same brushed-dark unit the web UI renders. +let bg = CGGradient(colorsSpace: space, + colors: [rgb(0x161B21), rgb(0x0B0E11), rgb(0x070809)] as CFArray, + locations: [0, 0.55, 1])! +ctx.drawLinearGradient(bg, start: CGPoint(x: 0, y: S), end: CGPoint(x: 0, y: 0), options: []) + +// A lime glow behind the wave — the LED bleeding into the panel. Sells the "this +// thing is powered and running" feeling that the hardware's own LED gives you. +let glow = CGGradient(colorsSpace: space, + colors: [rgb(0xB6FF36, 0.16), rgb(0xB6FF36, 0.0)] as CFArray, + locations: [0, 1])! +ctx.drawRadialGradient(glow, + startCenter: CGPoint(x: S / 2, y: S * 0.5), startRadius: 0, + endCenter: CGPoint(x: S / 2, y: S * 0.5), endRadius: S * 0.52, + options: []) + +// ---- the pulse train --------------------------------------------------------- +// Four beats. Beat one is the ACCENT and rises higher, exactly like the accent LED +// and the metronome's accented downbeat. Asymmetry is what stops this reading as a +// generic waveform: it has a downbeat, so it has a bar, so it is a CLOCK. + +// Proportions matter more than the idea here. The first cut used a 59px stroke on an +// 86px pulse: the gaps filled in and the whole thing read as a lime blob. A pulse +// train is only legible if the GAPS survive, so the line stays thin relative to the +// duty cycle, and there are THREE beats, not four — fewer, bigger, clearer. + +let lo = S * 0.335 // the low rail +let hi = S * 0.600 // a normal pulse +let hiAccent = S * 0.700 // the downbeat, one step prouder +let left = S * 0.115 +let right = S - left +let beats = 3.0 +let period = (right - left) / beats +let pulseWidth = period * 0.50 +let gap = period - pulseWidth + +let stroke = S * 0.042 +let path = CGMutablePath() +// Start and end on the LOW rail with equal tails. The first cut began on a rising +// edge and ended with a long flat run, which pulled the whole mark left. +path.move(to: CGPoint(x: left, y: lo)) +for beat in 0 ..< Int(beats) { + let x0 = left + gap / 2 + Double(beat) * period + let top = beat == 0 ? hiAccent : hi + path.addLine(to: CGPoint(x: x0, y: lo)) + path.addLine(to: CGPoint(x: x0, y: top)) // rising edge + path.addLine(to: CGPoint(x: x0 + pulseWidth, y: top)) + path.addLine(to: CGPoint(x: x0 + pulseWidth, y: lo)) // falling edge +} +path.addLine(to: CGPoint(x: right, y: lo)) + +// SHARP corners. Round joins turned this into a bent pipe; a clock edge is square, +// and squaring the joins is the whole difference between "waveform" and "maze". +ctx.setLineCap(.butt) +ctx.setLineJoin(.miter) +ctx.setMiterLimit(10) + +// The mark should look EMITTED, not printed — so it needs a real glow. +// +// Two dead ends first, both worth naming: ONE wide translucent stroke has a hard +// outer edge and reads as a dark-green BORDER around the wave. STACKING several +// translucent strokes just turns that one hard edge into a set of them — visible +// concentric banding, a contour map. Neither is a glow, because a glow FALLS OFF and +// a stroke does not. +// +// A shadow does. Zero offset + a blur radius + the LED colour is a real Gaussian +// bloom around the path. Two passes, because one is too timid to see on charcoal. +for (blur, alpha) in [(S * 0.075, 0.75), (S * 0.030, 0.85)] as [(Double, Double)] { + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: blur, color: rgb(0x9BE32A, alpha)) + ctx.setStrokeColor(rgb(0xB6FF36)) + ctx.setLineWidth(stroke) + ctx.addPath(path) + ctx.strokePath() + ctx.restoreGState() +} + +// The filament itself, crisp and unshadowed, over its own bloom. +ctx.setStrokeColor(rgb(0xE4FFA8)) +ctx.setLineWidth(stroke) +ctx.addPath(path) +ctx.strokePath() + +// ---- write ------------------------------------------------------------------- +guard let image = ctx.makeImage() else { exit(1) } +let url = URL(fileURLWithPath: out) +try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) +guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else { + exit(1) +} +CGImageDestinationAddImage(dest, image, nil) +guard CGImageDestinationFinalize(dest) else { exit(1) } +print("wrote \(out)") diff --git a/docs/plans/2026-07-14-performance-settings-split-design.md b/docs/plans/2026-07-14-performance-settings-split-design.md new file mode 100644 index 0000000..b3724bb --- /dev/null +++ b/docs/plans/2026-07-14-performance-settings-split-design.md @@ -0,0 +1,85 @@ +# Performance / Settings split + +**Date:** 2026-07-14 +**Status:** agreed, implementing + +## Why + +The device screen is one long scroll: tempo, transport, output cards, per-output cable/rate/nudge/ +swing, metronome, LED, diagnostics. On stage you want transport and nothing you can fat-finger. At +soundcheck you want the knobs. Right now they're interleaved, so the stage case carries the +soundcheck case's clutter — and its hazards. + +## The split criterion + +Not "advanced vs basic". **What do you actually reach for MID-SET?** + +- **Transport** — obviously. +- **Nudge** — yes. The firmware's own comment says it "slides the RC-505 into the pocket", it's live + via `/nudge`, no reboot. That's a playing control, not a setup one. +- **Swing** — yes. A groove control you feel your way to while the band plays. +- Everything else — cable, PPQN, follow-Link, click volume, LED, WiFi, diagnostics — is set once and + left alone. + +## The three surfaces + +### PERFORMANCE — the device screen, where you always land + +- Tempo glass, beat dot, tempo source +- Meter bridge: peers, USB-MIDI, MIDI clock in, follow beat, transport, clock pulses (read-only) +- Master `PLAY` / `STOP` +- Per output: the big transport button, **NUDGE**, **SWING** + +Nothing else. **Nothing on this screen changes your sound** — only your timing and your transport. +You cannot knock a cable assignment or a clock division loose mid-set, because they are not there to +knock. + +### SETTINGS (⚙ sheet) — everything LIVE + +Applies instantly; the field pulses lime to prove it. + +- Per output: enable, CABLE, RATE, follow-Link +- Click: volume, voice, accent — **only if a speaker is fitted** +- LED: brightness, mode, fade, colours — **only if a strip is wired** +- Diagnostics (tick / phase health) +- A door at the bottom → **Device Setup** + +### DEVICE SETUP (sheet from Settings) — everything that REBOOTS + +- Metronome **enable**, follow-beat **enable**, WiFi +- Consequence band, `REBOOTS` tags, `WRITE & REBOOT` key, confirmation naming the device + +## The invariant, restated + +**No surface ever mixes live and reboot controls.** + +That is not decoration. The device's own web page puts the metronome **enable** toggle and the +**accent** toggle forty pixels apart, as the identical component, in the same box — one applies +instantly, the other reboots the device mid-set, and nothing on screen says so. That trap is what +this app exists to fix, and it is fixed *structurally*, not with labels: + +- `KSLiveControl` cannot be constructed without a `KsLiveEdit` case, and no such case exists for a + reboot-only field. +- `LiveRebootPartitionTests` fails CI if any `saveFormFields` key is neither live-editable nor + declared reboot-required. + +Merging everything into one Settings sheet would have re-created that adjacency with better +labelling. Better labelling is not the same as impossible. + +## Capability still drives visibility + +Sections appear only for hardware the device reports (`link-devices` ESP-030). No speaker, no click +section. No strip, no LED section. Solder a strip onto a Touch, flip one firmware flag, and the LED +section appears — with no change to this app. + +## Firmware update stays in the toolbar + +**Not** inside Settings. Settings needs `/config.json`, which 404s on old firmware — and a firmware +update is how you *escape* that state. Gating the escape behind the thing that's broken would strand +the user (T-009). + +## What this does NOT change + +- The quantized transport model (arm → bar line → run), and never predicting it client-side. +- The blink at beat rate on `.armed`. +- The reboot flow: `WRITE & REBOOT` → `isRebooting` → clears when `/status` answers. diff --git a/project.yml b/project.yml index e500fec..5c54b15 100644 --- a/project.yml +++ b/project.yml @@ -43,6 +43,11 @@ targets: MARKETING_VERSION: "0.1.0" CURRENT_PROJECT_VERSION: "1" SWIFT_STRICT_CONCURRENCY: complete + # The icon is DRAWN, not pasted: Tools/make_app_icon.swift renders + # Assets.xcassets/AppIcon.appiconset/icon-1024.png from the same palette the + # device's own web UI uses. Re-run it to change the art; don't hand-edit a PNG + # nobody can regenerate. + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon KitchenSyncAppTests: type: bundle.unit-test diff --git a/tasks/T-015.md b/tasks/T-015.md index 866c5ce..dafee49 100644 --- a/tasks/T-015.md +++ b/tasks/T-015.md @@ -1,7 +1,7 @@ --- id: T-015 title: Stop hardcoding 4 clock outputs — drive the UI off the actual array length -status: todo +status: done assignee: null priority: high tags: