Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions Sources/KitchenSyncApp/Discovery/DeviceMatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ enum DeviceMatch {
/// units working, not because it is correct.
///
/// The firmware advertises generic `_http._tcp` with no TXT record, so the hostname is all a
/// client has. `ks_hostname()` builds `<product>-<last two MAC bytes>`: `kitchensync-a4f2` on
/// the P4, `kstouch-dfd0` on the Touch. Missing `kstouch` is what made a real device invisible.
/// client has. The hostname is `<product>-<last two MAC bytes>`: `kitchensync-a4f2` on the
/// P4, `kstouch-dfd0` on the Touch, `x32link-7a1c` on the headless (ESP-035). Missing
/// `kstouch` is what made a real device invisible once already.
private static func matchesKnownHostname(_ name: String) -> Bool {
knownHostPrefixes.contains { name.hasPrefix($0) }
}

private static let knownHostPrefixes = ["kitchensync", "kstouch"]
/// `x32link` joined this list in ESP-035 — but adding it here was the SMALL half. That
/// unit had no mDNS whatsoever: it never advertised, so no matcher could have found it.
/// The user saw it exactly that way — "I see kstouch, not the headless" — while the box
/// sat there powered, on the network, and following the session.
private static let knownHostPrefixes = ["kitchensync", "kstouch", "x32link"]
}
19 changes: 18 additions & 1 deletion Sources/KitchenSyncApp/Models/KsConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,23 @@ struct KsConfig: Decodable, Equatable {
var wifi: [WifiSlot]
var clock: [ClockOutputConfig]

/// The device's STORED free-run tempo, BPM. **`nil` means this build can't set a
/// tempo** — it's listener-only (X32Link), so `/config.json` omits `bpm`
/// (`link-devices` ESP-037, gated by `KsCaps.settable_tempo`), same capability rule
/// as `metronome`/`led`.
///
/// This is the tempo you SET — distinct from `KsStatus.bpm`, the tempo the device is
/// currently CLOCKING (Link's, when a session is driving). The tempo control shows
/// this one; it doesn't jump around when Link takes over.
var tempo: Double?

private enum CodingKeys: String, CodingKey {
case clockOutEnabled = "clock_out", metronomeEnabled = "metronome"
case metronomeAccent = "metro_accent", metronomeVolume = "metro_vol"
case metronomeVoice = "metro_voice", ledEnabled = "led"
case ledBrightness = "led_bright", ledMode = "led_mode", ledFade = "led_fade"
case ledBeatColor = "led_beat", ledAccentColor = "led_accent"
case followBeatEnabled = "follow_beat", wifi, clock
case followBeatEnabled = "follow_beat", tempo = "bpm", wifi, clock
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -130,6 +140,7 @@ struct KsConfig: Decodable, Equatable {
}

followBeatEnabled = try c.decodeIfPresent(Bool.self, forKey: .followBeatEnabled)
tempo = try c.decodeIfPresent(Double.self, forKey: .tempo) // ESP-037: nil = listener-only
wifi = try c.decode([WifiSlot].self, forKey: .wifi)
clock = try c.decode([ClockOutputConfig].self, forKey: .clock)
}
Expand Down Expand Up @@ -294,6 +305,12 @@ extension KsConfig {
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 }

// ESP-037: only reflect the set tempo if this device HAS a settable one. On a
// listener-only config (tempo == nil) a set is meaningless and must not conjure
// a value the device doesn't keep — the same nil-means-not-fitted rule as the
// capability sections above.
case .setTempo(let v): if tempo != nil { tempo = v }
}
}

Expand Down
12 changes: 12 additions & 0 deletions Sources/KitchenSyncApp/Models/KsLiveEdit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ enum KsLiveEdit {
case outputSwing(index: Int, Int)
case outputFollowsLink(index: Int, Bool)

/// ESP-037: the device-global free-run tempo, BPM. Drives the clock when the device
/// is solo; a Link session still wins. Tap, numeric entry, and the ± steppers ALL
/// resolve to this one number — tap is computed in the app (local timing, no WiFi
/// jitter), never sent as tap events over the network.
case setTempo(Double)

/// The one `(key, value)` pair this edit POSTs. `/live` is a PARTIAL patch
/// (`ks_form_apply` on the firmware) — fields not present keep their
/// current value, so sending exactly one pair per edit is correct, not a
Expand All @@ -47,9 +53,15 @@ enum KsLiveEdit {
case .outputPhase(let i, let v): return ("clk\(i)_phase", String(v))
case .outputSwing(let i, let v): return ("clk\(i)_swing", String(v))
case .outputFollowsLink(let i, let v): return ("clk\(i)_follow", boolValue(v))
case .setTempo(let v): return ("bpm", bpmValue(v))
}
}

private func boolValue(_ v: Bool) -> String { v ? "1" : "0" }
private func colorValue(_ v: UInt32) -> String { String(format: "#%06X", v & 0xFFFFFF) }
/// Milli-BPM precision, locale-independent (the "." must not become a "," on a
/// European phone — the firmware parses with atof, which is C-locale).
private func bpmValue(_ v: Double) -> String {
String(format: "%.3f", locale: Locale(identifier: "en_US_POSIX"), v)
}
}
15 changes: 15 additions & 0 deletions Sources/KitchenSyncApp/ViewModels/DeviceDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,21 @@ final class DeviceDetailViewModel: ObservableObject {
/// The previous poll's tick health — the other half of a rate.
private var lastTick: TickHealth?

/// True when a Link session is currently driving the clock. Then Link owns the tempo
/// (the box follows it), so the set controls stand down.
var tempoIsLinkDriven: Bool { (status?.peers ?? 0) > 0 }

/// The tempo the control should SHOW. Not the stored set value — the tempo the box is
/// actually CLOCKING (`status.bpm`) when it's running, falling back to the stored set
/// value before the first poll. So it follows Link while a session drives, and when
/// Link leaves there's no jump: the box keeps Link's tempo, and the control was
/// already showing it. `nil` on a listener-only device (no settable tempo at all).
var tempoDisplay: Double? {
guard let stored = config?.tempo else { return nil }
if let b = status?.bpm, b > 0 { return b }
return stored
}

func refreshStatus() async {
do {
let fresh = try await client.fetchStatus()
Expand Down
10 changes: 10 additions & 0 deletions Sources/KitchenSyncApp/Views/DeviceDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ struct DeviceDetailView: View {
meterBridge
masterTransport

// ESP-037: the set-tempo control, only when the device has a settable
// tempo (config.tempo != nil — a listener-only box omits it).
if let t = vm.tempoDisplay {
TempoControl(
tempo: t,
linkDriving: vm.tempoIsLinkDriven,
onSet: { bpm in Task { await vm.apply(.setTempo(bpm)) } }
)
}

if let failure = vm.liveEditFailure {
liveEditBanner(failure)
}
Expand Down
174 changes: 174 additions & 0 deletions Sources/KitchenSyncApp/Views/TempoControl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import SwiftUI

/// The set-tempo surface (ESP-037). Three ways in, one number out:
///
/// - **Tap** — computed HERE, from local tap intervals, never sent as tap events over
/// WiFi (network jitter would wreck the timing). The app resolves taps to a BPM and
/// sends that.
/// - **Numeric** — type it.
/// - **± steppers** — nudge by 1.
///
/// All three call `onSet(bpm)`, which routes through `KsLiveEdit.setTempo` → `/live`.
/// Only shown when the device actually has a settable tempo (`config.tempo != nil`);
/// a listener-only box has no business drawing this.
///
/// The number shown is the SET tempo (`config.tempo`), not the effective one the clock
/// is running (`status.bpm`) — so it doesn't jump when a Link session takes over. When
/// Link IS driving, the control says so, because a tempo you set that the device is
/// currently ignoring is exactly the kind of silent lie this app keeps stamping out.
struct TempoControl: View {
/// The tempo to SHOW — the effective one (what the box is clocking), so it follows
/// Link and never jumps. See `DeviceDetailViewModel.tempoDisplay`.
let tempo: Double
/// True when a Link session is driving. Link owns the tempo; the set controls stand
/// down and just report Link's value.
let linkDriving: Bool
let onSet: (Double) -> Void

static let minBPM = 20.0
static let maxBPM = 300.0

@State private var editing = false
@State private var draft = ""
@State private var tapTimes: [Date] = []
/// The decimalPad keyboard has NO return key, so `.onSubmit` never fires — a typed
/// tempo could never be committed. Focus drives it instead: a "Set" button in the
/// keyboard toolbar, and commit-on-dismiss when you tap away.
@FocusState private var fieldFocused: Bool
/// Show an edit immediately instead of waiting a poll for `status.bpm` to catch up —
/// otherwise a ± tap looks dead for half a second, then snaps. Cleared once the
/// device's reported tempo converges on it.
@State private var optimistic: Double?

private var shown: Double { optimistic ?? tempo }
private var editable: Bool { !linkDriving }

private func clamp(_ v: Double) -> Double { min(Self.maxBPM, max(Self.minBPM, v)) }

private func commit(_ v: Double) {
let c = clamp(v)
optimistic = c
onSet(c)
}

private func step(_ delta: Double) { commit((shown + delta).rounded()) }

/// Resolve taps to a BPM locally. Keep only taps within 2 s of each other (a gap
/// starts a fresh count), average the intervals, and set once we have two taps.
private func tap() {
let now = Date()
if let last = tapTimes.last, now.timeIntervalSince(last) > 2.0 { tapTimes = [] }
tapTimes.append(now)
if tapTimes.count > 6 { tapTimes.removeFirst(tapTimes.count - 6) }
guard tapTimes.count >= 2 else { return }
var intervals: [TimeInterval] = []
for i in 1..<tapTimes.count { intervals.append(tapTimes[i].timeIntervalSince(tapTimes[i - 1])) }
let avg = intervals.reduce(0, +) / Double(intervals.count)
guard avg > 0 else { return }
commit((60.0 / avg).rounded())
}

private func commitDraft() {
guard editing else { return } // avoid a double-commit (Set button + focus-loss)
editing = false
fieldFocused = false
if let v = Double(draft.replacingOccurrences(of: ",", with: ".")) { commit(v) }
}

private func beginEdit() {
guard editable else { return }
draft = shown.formatted(.number.precision(.fractionLength(0...1)))
editing = true
fieldFocused = true
}

var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Text("TEMPO")
.font(.ksMono(10)).tracking(1.8)
.foregroundStyle(Color(hex: 0x6F8A4D))
Spacer()
if linkDriving {
Text("LINK IS DRIVING")
.font(.ksMono(10)).tracking(1.8)
.foregroundStyle(KS.amberText)
}
}

HStack(spacing: 10) {
stepButton("−") { step(-1) }

Group {
if editing {
TextField("", text: $draft)
.keyboardType(.decimalPad)
.multilineTextAlignment(.center)
.font(.ksDisplay(30))
.foregroundStyle(KS.ink)
.focused($fieldFocused)
.onChange(of: fieldFocused) { _, focused in
if !focused { commitDraft() } // tapped away → set it
}
} else {
Text(shown.formatted(.number.precision(.fractionLength(0...1))))
.font(.ksDisplay(30))
.foregroundStyle(editable ? KS.ink : KS.mut)
.onTapGesture(perform: beginEdit)
}
}
.frame(maxWidth: .infinity)
.overlay(alignment: .bottom) {
Text("BPM").font(.ksMono(9)).tracking(1.6)
.foregroundStyle(KS.mut).offset(y: 12)
}

stepButton("+") { step(1) }

Button(action: tap) {
Text("TAP")
.font(.ksDisplay(15)).tracking(1.2)
.foregroundStyle(KS.onInk)
.frame(width: 66, height: 44)
.background(KS.ledFill, in: RoundedRectangle(cornerRadius: 9))
}
.buttonStyle(.plain)
.accessibilityLabel("Tap tempo")
}
.disabled(!editable) // Link owns the tempo while it's driving
.opacity(editable ? 1 : 0.5)
}
.padding(14)
.background(KS.panel, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(KS.line))
// The decimalPad has no return key — give the number a way to commit.
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("Set", action: commitDraft)
.disabled(!editing)
}
}
// The device caught up (its reported tempo reached our optimistic value, within
// rounding): drop the optimistic override and follow the device again.
.onChange(of: tempo) { _, new in
if let o = optimistic, abs(new - o) < 0.75 { optimistic = nil }
}
}

private func stepButton(_ label: String, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.font(.ksDisplay(24))
.foregroundStyle(KS.ink)
.frame(width: 44, height: 44)
.background(
LinearGradient(colors: [Color(hex: 0x1A1F25), Color(hex: 0x12161B)],
startPoint: .top, endPoint: .bottom),
in: RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(KS.line))
}
.buttonStyle(.plain)
.accessibilityLabel(label == "−" ? "Tempo down" : "Tempo up")
}
}
33 changes: 33 additions & 0 deletions Tests/KitchenSyncAppTests/DeviceDetailViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,39 @@ final class DeviceDetailViewModelTests: XCTestCase {
"launch":[0,1,2,0],"playing":true,"link_owns":false}
""".data(using: .utf8)!

// ESP-037: the control shows the EFFECTIVE tempo, not the stored set value -- so it
// follows Link while a session drives, and there's no jump when Link leaves.
private static let configTempo150 = """
{"clock_out":true,"bpm":150.000,"wifi":[],"clock":[]}
""".data(using: .utf8)!
private static let statusLinkAt120 = """
{"bpm":120.0,"min":0.0,"peers":1,"usb":false,"tx":0,"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}
""".data(using: .utf8)!

func test_tempo_display_follows_the_effective_bpm_while_link_drives() async {
StubURLProtocol.routes["GET /config.json"] = .init(body: Self.configTempo150)
StubURLProtocol.routes["GET /status"] = .init(body: Self.statusLinkAt120)
let vm = makeVM()

await vm.loadConfig()
await vm.refreshStatus()

XCTAssertEqual(vm.tempoDisplay ?? 0, 120, accuracy: 0.1) // Link's, not the stored 150
XCTAssertTrue(vm.tempoIsLinkDriven)
}

func test_tempo_display_uses_the_stored_value_before_the_first_poll() async {
StubURLProtocol.routes["GET /config.json"] = .init(body: Self.configTempo150)
let vm = makeVM()

await vm.loadConfig() // no status yet

XCTAssertEqual(vm.tempoDisplay ?? 0, 150, accuracy: 0.1)
XCTAssertFalse(vm.tempoIsLinkDriven)
}

func test_refresh_publishes_the_devices_status() async {
StubURLProtocol.routes["GET /status"] = .init(body: Self.statusJSON)
let vm = makeVM()
Expand Down
17 changes: 17 additions & 0 deletions Tests/KitchenSyncAppTests/DeviceMatchTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,21 @@ final class DeviceMatchTests: XCTestCase {
txt: ["dev": "some-other-product"]),
"a TXT record that says it ISN'T ours must beat a hostname that says it is")
}

/// ESP-035: the headless X32Link. It was the last unit in the fleet with NO mDNS at
/// all -- it never advertised, so it could not be found no matter what this matcher
/// did. Now it announces `x32link-<mac>`, and the prefix list has to know the name.
///
/// The user hit this exactly: "I see kstouch. not the headless." The device was
/// powered, on the network, and following the Link session.
func test_the_headless_x32link_is_one_of_ours() {
XCTAssertTrue(DeviceMatch.isKitchenSync(serviceName: "x32link-7a1c", txt: nil))
}

/// The prefix is still a HEURISTIC, and it must not swallow the whole network. A
/// stranger's box at a venue is not ours.
func test_a_stranger_is_still_not_one_of_ours() {
XCTAssertFalse(DeviceMatch.isKitchenSync(serviceName: "brother-printer", txt: nil))
XCTAssertFalse(DeviceMatch.isKitchenSync(serviceName: "x32-mixer", txt: nil))
}
}
16 changes: 16 additions & 0 deletions Tests/KitchenSyncAppTests/KsConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ final class KsConfigDecodingTests: XCTestCase {
XCTAssertEqual(config.followBeatEnabled, false)
}

// ESP-037: the stored free-run tempo. A clock box emits `bpm`; the app shows THIS
// number (distinct from the Link-driven KsStatus.bpm).
func test_decodes_the_stored_tempo_from_bpm() throws {
let json = #"{"clock_out":true,"bpm":128.500,"wifi":[],"clock":[]}"#
let config = try JSONDecoder().decode(KsConfig.self, from: Data(json.utf8))
XCTAssertEqual(config.tempo ?? 0, 128.5, accuracy: 0.001)
}

// A listener-only board (X32Link) omits `bpm` entirely — tempo is nil, not 0, so the
// app knows to hide the tempo control rather than show a fake 0 BPM.
func test_a_config_without_bpm_has_nil_tempo() throws {
let json = #"{"clock_out":false,"wifi":[],"clock":[]}"#
let config = try JSONDecoder().decode(KsConfig.self, from: Data(json.utf8))
XCTAssertNil(config.tempo)
}

func test_decodes_hash_prefixed_hex_colors() throws {
let config = try JSONDecoder().decode(KsConfig.self, from: Data(sampleConfigJSON.utf8))
XCTAssertEqual(config.led?.beatColor, 0x00B400)
Expand Down
Loading
Loading