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
28 changes: 28 additions & 0 deletions Sources/KitchenSyncApp/Discovery/SetupNetwork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// A device that has never joined the user's network comes up in **SoftAP setup mode**,
/// broadcasting its own open WiFi and serving its config web server at a fixed address. This is
/// where first-run provisioning happens (T-026): the app reaches the device *on its own AP*,
/// confirms it's ours, and hands it the user's WiFi credentials.
///
/// Pure constants + an SSID test, so the setup UI and any `NEHotspotConfiguration` join can be
/// asserted without standing up a radio — the same split `DeviceMatch` uses for LAN discovery.
enum SetupNetwork {
/// The address the firmware serves its web server on in SoftAP mode — fixed across the
/// fleet (`wifi_link.c` / `KitchenSyncTouch.ino`, both at `192.168.4.1`).
static let host = "192.168.4.1"

/// The setup SSIDs the fleet broadcasts, by product:
/// - `KitchenSync-Setup` — the P4 (`wifi_link.c` `AP_SSID`)
/// - `KSTouch-Config` — the Touch (`KitchenSyncTouch.ino` `WiFi.softAP`)
/// Matched as a **family of prefixes**, not one string, so a new product's setup AP is
/// recognised without an app change — the same reason `DeviceMatch` matches host prefixes.
static let ssidPrefixes = ["kitchensync-setup", "kstouch-config"]

/// True if `ssid` is one of our devices' setup APs. Case-insensitive: a WiFi SSID's case is
/// not meaningful, and iOS reports it however the radio saw it.
static func isSetupAP(ssid: String) -> Bool {
let lower = ssid.lowercased()
return ssidPrefixes.contains { lower.hasPrefix($0) }
}
}
9 changes: 9 additions & 0 deletions Sources/KitchenSyncApp/Networking/KitchenSyncClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ struct KitchenSyncClient {
try await postForm("/save", fields: config.saveFormFields(wifiEdits: wifiEdits))
}

/// T-026: first-run WiFi provisioning. POSTs ONLY the credentials to `/save` — a partial
/// form (no `full_form`), so the firmware leaves every other setting alone and this can
/// never clobber a device's config while merely joining it to a network. The device stores
/// the credentials and reboots to join. Point the client at `SetupNetwork.host` — the
/// device's own SoftAP — for this, since it isn't on the LAN yet.
func provisionWifi(ssid: String, password: String) async throws {
try await postForm("/save", fields: ["wifi_ssid": ssid, "wifi_pass": password])
}

/// POST /transport?out=N|all&play=1|0 — quantized Start/Stop (ESP-011).
/// Takes effect on the next bar line; poll `/status.launch[N]` to watch it
/// arm, then run.
Expand Down
57 changes: 57 additions & 0 deletions Sources/KitchenSyncApp/ViewModels/SetupViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

/// T-026: first-run setup. When a device has never joined the user's network it comes up on its
/// own SoftAP (`SetupNetwork`); the app reaches it there, confirms it's ours, and hands it the
/// user's WiFi credentials over a partial `POST /save`. The device then reboots to join and the
/// device list takes over the reconnect + rediscovery.
///
/// Transport-agnostic on purpose (T-027): the *decision* logic — confirm, then provision — is
/// the same whether the credential transport is HTTP-over-SoftAP (today) or BLE (later); only
/// `provision` swaps.
@MainActor
final class SetupViewModel: ObservableObject {
enum Phase: Equatable {
case idle
case searching
case foundDevice // a device answered on the setup AP — safe to offer setup
case noDevice // nothing ours at the setup address
case provisioning
case provisioned // creds sent; the device is rebooting to join
case failed(String)
}

@Published private(set) var phase: Phase = .idle

private let session: URLSession
init(session: URLSession = .shared) { self.session = session }

private var client: KitchenSyncClient {
KitchenSyncClient(host: SetupNetwork.host, session: session)
}

/// Confirm we're actually on one of our devices' setup APs by reaching its config server —
/// so the setup screen only appears for a real device, not an empty or foreign AP.
func search() async {
phase = .searching
do {
_ = try await client.fetchConfig()
phase = .foundDevice
} catch {
phase = .noDevice
}
}

/// Send the user's network to the device (WiFi fields only — never clobbers other settings).
/// A blank SSID isn't an attempt; a failure surfaces so the user knows it didn't take.
func provision(ssid: String, password: String) async {
let ssid = ssid.trimmingCharacters(in: .whitespacesAndNewlines)
guard !ssid.isEmpty else { return }
phase = .provisioning
do {
try await client.provisionWifi(ssid: ssid, password: password)
phase = .provisioned
} catch {
phase = .failed(error.localizedDescription)
}
}
}
13 changes: 12 additions & 1 deletion Sources/KitchenSyncApp/Views/DeviceListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftUI
struct DeviceListView: View {
@StateObject private var vm = DeviceListViewModel()
@State private var addingDevice = false
@State private var settingUpDevice = false

/// The manual devices, as their own collection. The two sections are not just
/// cosmetic: `removeManualDevices(at:)` takes offsets into the FILTERED manual
Expand Down Expand Up @@ -42,6 +43,9 @@ struct DeviceListView: View {
.sheet(isPresented: $addingDevice) {
AddDeviceView { vm.addManualDevice(host: $0) }
}
.sheet(isPresented: $settingUpDevice) {
SetupDeviceView()
}
}
.task { vm.start() }
.onDisappear { vm.stop() }
Expand Down Expand Up @@ -143,14 +147,21 @@ struct DeviceListView: View {
.foregroundStyle(KS.mut)
.multilineTextAlignment(.center)

Button("Add by IP") { addingDevice = true }
// First-run: a fresh device isn't on the LAN yet (it's on its own setup AP), so
// discovery can't find it — the user needs a path to set it up, not just "add by
// IP". This is the affordance that meets "the app sets up my device" (T-026).
Button("Set up a new device") { settingUpDevice = true }
.font(.ksDisplay(14))
.tracking(1.4)
.foregroundStyle(KS.onInk)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(KS.ledFill, in: Capsule())
.padding(.top, 6)

Button("Add by IP") { addingDevice = true }
.font(.ksMono(12))
.foregroundStyle(KS.mut)
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
Expand Down
137 changes: 137 additions & 0 deletions Sources/KitchenSyncApp/Views/SetupDeviceView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import SwiftUI

/// T-026: first-run setup. Shown when the user joins a fresh device's SoftAP — the device isn't
/// on the LAN yet, so it never appears in discovery, and the app has to meet the "set up my
/// device" expectation here. Confirms a device is on the AP, takes the user's WiFi, hands it
/// over, then points them back to their own network where the device will reappear.
///
/// The decisions live in `SetupViewModel` (tested); this is the thin surface over its phases.
struct SetupDeviceView: View {
@StateObject private var vm = SetupViewModel()
@Environment(\.dismiss) private var dismiss
@State private var ssid = ""
@State private var password = ""

var body: some View {
NavigationStack {
VStack(spacing: 18) {
switch vm.phase {
case .idle, .searching:
searching
case .noDevice:
noDevice
case .foundDevice, .provisioning, .failed:
form
case .provisioned:
done
}
Spacer()
}
.padding()
.frame(maxWidth: .infinity, alignment: .top)
.background(KS.bg.ignoresSafeArea())
.navigationTitle("Set up a device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
.task { await vm.search() }
}

// Confirming a device is actually answering on 192.168.4.1 before we offer a WiFi form.
private var searching: some View {
VStack(spacing: 14) {
ProgressView().tint(KS.ledDim)
Text("Looking for a device in setup mode…")
.font(.ksMono(13)).foregroundStyle(KS.mut)
.multilineTextAlignment(.center)
}
.padding(.top, 40)
}

// The phone isn't on a device AP (or nothing answered). Tell them exactly what to join.
private var noDevice: some View {
VStack(spacing: 14) {
KSPowerLED(alive: false)
Text("No device found in setup mode")
.font(.ksMono(14)).foregroundStyle(KS.ink)
.multilineTextAlignment(.center)
Text("A new device broadcasts its own WiFi. In iOS Settings, join **KitchenSync-Setup** or **KSTouch-Config**, then come back.")
.font(.ksMono(12)).foregroundStyle(KS.mut)
.multilineTextAlignment(.center)
Button("Check again") { Task { await vm.search() } }
.font(.ksDisplay(14)).tracking(1.4)
.foregroundStyle(KS.onInk)
.padding(.horizontal, 20).padding(.vertical, 12)
.background(KS.ledFill, in: Capsule())
.padding(.top, 6)
}
.padding(.top, 30)
}

// The device is there — collect the network to put it on.
private var form: some View {
VStack(spacing: 16) {
Text("Enter the WiFi this device should join. It'll remember it and reconnect on its own from now on.")
.font(.ksMono(12)).foregroundStyle(KS.mut)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)

KSField(prefix: "SSID") {
TextField("Your WiFi network", text: $ssid)
.font(.ksMono(14)).foregroundStyle(KS.ink)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
KSField(prefix: "PASS") {
SecureField("Network password", text: $password)
.font(.ksMono(14)).foregroundStyle(KS.ink)
}

if case .failed(let message) = vm.phase {
Text(message)
.font(.ksMono(11)).foregroundStyle(KS.ember)
.frame(maxWidth: .infinity, alignment: .leading)
}

Button {
Task { await vm.provision(ssid: ssid, password: password) }
} label: {
HStack(spacing: 8) {
if vm.phase == .provisioning { ProgressView().tint(KS.onInk) }
Text(vm.phase == .provisioning ? "Sending…" : "Join network")
}
.font(.ksDisplay(14)).tracking(1.4)
.foregroundStyle(KS.onInk)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(KS.ledFill, in: Capsule())
}
.disabled(ssid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.phase == .provisioning)
.padding(.top, 4)
}
}

// Creds sent; the device is rebooting to join. Close the loop: send them back to their
// network, where discovery will pick the device up.
private var done: some View {
VStack(spacing: 14) {
KSPowerLED(alive: true)
Text("Sent to the device")
.font(.ksMono(14)).foregroundStyle(KS.ink)
Text("It's restarting to join **\(ssid)**. Reconnect this phone to that same WiFi, then it'll appear on the device list.")
.font(.ksMono(12)).foregroundStyle(KS.mut)
.multilineTextAlignment(.center)
Button("Done") { dismiss() }
.font(.ksDisplay(14)).tracking(1.4)
.foregroundStyle(KS.onInk)
.padding(.horizontal, 20).padding(.vertical, 12)
.background(KS.ledFill, in: Capsule())
.padding(.top, 6)
}
.padding(.top, 30)
}
}
53 changes: 53 additions & 0 deletions Tests/KitchenSyncAppTests/ProvisioningTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import XCTest
@testable import KitchenSyncApp

/// T-026: provisioning a device's WiFi is a `POST /save` with ONLY the credentials — a partial
/// form. The device is being set up for the first time; the app must not send `full_form` or
/// any other field, or it would reset settings it can't even see yet. Tested through the REAL
/// client against `StubURLProtocol`, so the actual URL + form encoding is what's asserted.
final class ProvisioningTests: XCTestCase {
override func setUp() { StubURLProtocol.reset() }

private func client() -> KitchenSyncClient {
KitchenSyncClient(host: SetupNetwork.host, session: StubURLProtocol.session())
}

private func formFields(_ body: Data) -> [String: String] {
var out: [String: String] = [:]
for pair in (String(data: body, encoding: .utf8) ?? "").split(separator: "&") {
let kv = pair.split(separator: "=", maxSplits: 1).map(String.init)
let key = kv[0].removingPercentEncoding ?? kv[0]
let raw = kv.count > 1 ? kv[1].replacingOccurrences(of: "+", with: " ") : ""
out[key] = raw.removingPercentEncoding ?? raw
}
return out
}

func test_provisioning_posts_only_the_wifi_fields_to_save() async throws {
StubURLProtocol.routes["POST /save"] = .init()
try await client().provisionWifi(ssid: "StudioNet", password: "downbeat99")

let req = try XCTUnwrap(StubURLProtocol.requests.first)
XCTAssertEqual(req.method, "POST")
XCTAssertEqual(req.url.path, "/save")
XCTAssertEqual(req.url.host, "192.168.4.1")

let fields = formFields(req.body)
XCTAssertEqual(fields["wifi_ssid"], "StudioNet")
XCTAssertEqual(fields["wifi_pass"], "downbeat99")
// The whole point: a partial form. Sending full_form (or another field) would reset
// the device's other settings while merely joining it to WiFi.
XCTAssertNil(fields["full_form"], "provisioning must never send full_form")
XCTAssertNil(fields["clock_out"])
}

func test_a_save_error_during_provisioning_surfaces() async {
StubURLProtocol.routes["POST /save"] = .init(status: 500)
do {
try await client().provisionWifi(ssid: "X", password: "Y")
XCTFail("a 500 from /save must not be swallowed")
} catch {
// expected — the setup flow needs to know provisioning failed
}
}
}
28 changes: 28 additions & 0 deletions Tests/KitchenSyncAppTests/SetupNetworkTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import XCTest
@testable import KitchenSyncApp

/// T-026: a device in SoftAP setup mode isn't on the LAN, so the app recognises it by its
/// setup SSID (to guide/join the AP) and reaches it at the fixed setup address.
final class SetupNetworkTests: XCTestCase {
func test_the_fleet_setup_aps_are_recognised() {
XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KitchenSync-Setup")) // the P4
XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KSTouch-Config")) // the Touch
}

/// A WiFi SSID's case is not meaningful and iOS reports it however the radio saw it.
func test_matching_is_case_insensitive() {
XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "kstouch-config"))
XCTAssertTrue(SetupNetwork.isSetupAP(ssid: "KITCHENSYNC-SETUP"))
}

/// The user's own networks — and a stranger's — must never be mistaken for a device AP.
func test_a_normal_network_is_not_a_setup_ap() {
XCTAssertFalse(SetupNetwork.isSetupAP(ssid: "Skeyelab"))
XCTAssertFalse(SetupNetwork.isSetupAP(ssid: "linksys"))
XCTAssertFalse(SetupNetwork.isSetupAP(ssid: ""))
}

func test_the_setup_host_is_the_fixed_softap_address() {
XCTAssertEqual(SetupNetwork.host, "192.168.4.1")
}
}
Loading
Loading