diff --git a/apps/macos/Sources/WizApp/AppDelegate.swift b/apps/macos/Sources/WizApp/AppDelegate.swift index 5ba67e3..fdd44ad 100644 --- a/apps/macos/Sources/WizApp/AppDelegate.swift +++ b/apps/macos/Sources/WizApp/AppDelegate.swift @@ -174,10 +174,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } /// Close `window` if it's the stray placeholder-scene window: a titled, - /// non-sheet window that isn't our controller window. (The dropdown is an - /// NSMenu, whose window isn't titled, so it's never matched here.) + /// non-sheet, non-panel window that isn't our controller window. (The dropdown + /// is an NSMenu, whose window isn't titled, so it's never matched here. + /// NSPanels are excluded because system panels — NSColorPanel, NSAlert, + /// open/save — are titled too, and this observer runs for the app's lifetime; + /// the stray SwiftUI Settings window is a plain NSWindow.) private func closeIfStray(_ window: NSWindow) { - guard window.styleMask.contains(.titled), !window.isSheet, + guard window.styleMask.contains(.titled), !window.isSheet, !(window is NSPanel), window !== windowController?.window else { return } window.close() diff --git a/apps/macos/Sources/WizApp/AppState.swift b/apps/macos/Sources/WizApp/AppState.swift index 8a09e7d..f8a7484 100644 --- a/apps/macos/Sources/WizApp/AppState.swift +++ b/apps/macos/Sources/WizApp/AppState.swift @@ -462,13 +462,15 @@ final class AppState: ObservableObject { presets = stores.loadPresets(defaults: core.defaultPresets()) // Only restore a *saved* light (lights are added via Discover → Save): the - // last-used one by IP, else any saved light. With nothing saved, nothing is - // selected — so we never show a phantom "press Connect to control …" for a - // light that isn't saved. + // last-used one by IP, else the first saved light *by name* — a Dictionary's + // order is randomized per process, so an unordered `first` could pick a + // different light each launch. With nothing saved, nothing is selected — so + // we never show a phantom "press Connect to control …" for a light that + // isn't saved. let lastIp = stores.loadLastIp() if !lastIp.isEmpty, let (mac, light) = savedLights.first(where: { $0.value.ip == lastIp }) { selectLight(name: light.name, ip: light.ip, mac: mac, persistIp: false) - } else if let (mac, light) = savedLights.first { + } else if let (mac, light) = savedLights.min(by: { $0.value.name < $1.value.name }) { selectLight(name: light.name, ip: light.ip, mac: mac, persistIp: false) } @@ -643,18 +645,32 @@ final class AppState: ObservableObject { } } - /// Fold the bulb's white channels (`c`/`w`) into the displayed RGB. The engine - /// infers control state from r/g/b alone (model.js `parsePilot`), so a colour the - /// bulb renders with its white LEDs — a pastel, or anything set from the phone - /// app — reads back over-saturated; this recombines them so the swatch and hex - /// match what the eye sees. RGB mode only, and a no-op when no white is lit. - private func perceivedState(_ parsed: LightState, from result: [String: Any]) -> LightState { + /// Fold the bulb's white channels (`c`/`w`) back into the RGB we adopt. The + /// engine infers control state from r/g/b alone (model.js `parsePilot`), so a + /// colour the bulb renders with its white LEDs reads back wrong without this. + /// Two cases: + /// + /// - **Equal `c`/`w`** — the signature of our own "Brighter colours" split + /// (model.js `rgbToWhiteMixed` always drives both equally): invert it + /// *exactly*, recovering the colour the user picked. This is what keeps + /// read-backs stable — folding the *perceived* colour into `state.rgb` and + /// later re-sending it washed every colour toward pure white (the drift + /// color.js explicitly warns about). + /// - **Uneven `c`/`w`** — a foreign sender (e.g. the phone app) weighted the + /// whites separately; that split can't be inverted, so adopt the engine's + /// perceived approximation once. Display and any further edits then both + /// start from what the eye sees, and the next read-back is a fixed point. + /// + /// RGB mode only, and a no-op when no white is lit. + private func foldedState(_ parsed: LightState, from result: [String: Any]) -> LightState { guard parsed.mode == .rgb else { return parsed } let c = (result["c"] as? NSNumber)?.intValue ?? 0 let w = (result["w"] as? NSNumber)?.intValue ?? 0 guard c != 0 || w != 0 else { return parsed } var next = parsed - next.rgb = core.perceivedRgb(parsed.rgb, c: c, w: w) + next.rgb = + core.whiteMixedToRgb(parsed.rgb, c: c, w: w) + ?? core.perceivedRgb(parsed.rgb, c: c, w: w) return next } @@ -674,7 +690,7 @@ final class AppState: ObservableObject { self.scheduleNextPoll(after: Self.healthPollInterval) // Preserve the user's last RGB if the bulb reports white mode (so // flipping back to RGB restores their colour rather than white). - var next = self.perceivedState(parsed, from: result) + var next = self.foldedState(parsed, from: result) if next.mode == .white { next.rgb = self.state.rgb } // A running scene reports no colour; keep the last one so leaving it restores. if next.scene != nil { @@ -910,7 +926,7 @@ final class AppState: ObservableObject { if Date().timeIntervalSince(self.lastLocalEdit) > 3, let parsed = self.core.parsePilot(result) { - var next = self.perceivedState(parsed, from: result) + var next = self.foldedState(parsed, from: result) if next.mode == .white { next.rgb = self.state.rgb } if next.scene != nil { next.rgb = self.state.rgb @@ -971,7 +987,7 @@ final class AppState: ObservableObject { self.lastLocalEdit == editToken, let result = result, let parsed = self.core.parsePilot(result) else { return } - var next = self.perceivedState(parsed, from: result) + var next = self.foldedState(parsed, from: result) if next.mode == .white { next.rgb = self.state.rgb } if next.scene != nil { next.rgb = self.state.rgb @@ -992,7 +1008,7 @@ final class AppState: ObservableObject { guard let client = client else { return } lastLocalEdit = Date() let params = core.buildSetPilotParams(state, bounds: deviceBounds(), whiteMix: whiteMix) - client.apply(state: state, params: params) + client.send(params) if state.mode == .rgb { scheduleDeviceRgbSave() } bump() } @@ -1095,13 +1111,17 @@ final class AppState: ObservableObject { /// Turn the light off *synchronously*, blocking until the datagrams are on the /// wire. The normal `setPower(false)` path debounces and sends on a background /// queue, but the system can cut Wi-Fi the instant the sleep / terminate - /// handler returns — a deferred send would never leave the machine. `sendNow` - /// fires the off command 3× (≈240 ms) to ride out UDP loss; the optimistic - /// local `state.on = false` keeps the menu-bar icon correct on wake. We send - /// regardless of the last-known on/off so a stale "off" can't strand a lit - /// bulb. No-op without a selected light. + /// handler returns — a deferred send would never leave the machine. Any pending + /// debounced send is cancelled first (waiting it out if it's mid-flight), so a + /// colour edit from moments before the lid closed can't fire *after* the off + /// and turn the light back on. `sendNow` then fires the off command 3× + /// (≈240 ms) to ride out UDP loss; the optimistic local `state.on = false` + /// keeps the menu-bar icon correct on wake. We send regardless of the + /// last-known on/off so a stale "off" can't strand a lit bulb. No-op without a + /// selected light. private func sendPowerOffNow() { guard hasLight, let client = client else { return } + client.cancelPendingSync() client.sendNow(["state": false]) state.on = false bump() @@ -1244,7 +1264,8 @@ final class AppState: ObservableObject { if mac == selectedMac { // Removed the light we're using: clear the selection + disconnect, and forget // it as the last-used light, so nothing shows as selected or connected. - stores.saveLastIp("") + // (`clearLastIp`, not `saveLastIp("")` — the latter guards empty and no-ops.) + stores.clearLastIp() selectLight(name: "", ip: "", mac: "", persistIp: false, connect: false) } bump() diff --git a/apps/macos/Sources/WizApp/DropdownContentView.swift b/apps/macos/Sources/WizApp/DropdownContentView.swift index 752ca49..2778814 100644 --- a/apps/macos/Sources/WizApp/DropdownContentView.swift +++ b/apps/macos/Sources/WizApp/DropdownContentView.swift @@ -470,7 +470,7 @@ final class DropdownContentView: NSView { color.gradientColors = app.state.mode == .rgb ? hueStops() : tempStops() } if let speed = speedSlider, !speed.isTracking { - speed.value = Double(app.state.scene?.speed ?? 50) + speed.value = Double(app.state.scene?.speed ?? 100) } sceneHeaderLabel?.stringValue = app.availableScenes.first { $0.id == app.state.scene?.id }?.name ?? "Choose a scene" @@ -485,7 +485,7 @@ final class DropdownContentView: NSView { ? "Colour (hue) — \(Int(app.hsv.h))°" : "Colour temperature — \(app.state.temp) K" colorRow?.toolTip = colorTip colorSlider?.toolTip = colorTip - let speedTip = "Scene speed — \(app.state.scene?.speed ?? 50)%" + let speedTip = "Scene speed — \(app.state.scene?.speed ?? 100)%" speedRow?.toolTip = speedTip speedSlider?.toolTip = speedTip powerSwitch?.toolTip = app.state.on ? "On — tap to turn off" : "Off — tap to turn on" @@ -751,9 +751,11 @@ final class DropdownContentView: NSView { private func makeSpeedRow() -> NSView { let slider = GradientSliderControl() - slider.minValue = 1 - slider.maxValue = 100 - slider.value = Double(app.state.scene?.speed ?? 50) + // The engine's firmware band (10–200, 100 = normal), so the slider can't + // drift from what clampSpeed enforces. + slider.minValue = Double(app.core.speedRange.lowerBound) + slider.maxValue = Double(app.core.speedRange.upperBound) + slider.value = Double(app.state.scene?.speed ?? 100) // A progress track: accent-filled up to the thumb, grey after it. slider.progressFill = (filled: .controlAccentColor, unfilled: Self.nsColor([90, 90, 90])) slider.onEditing = { [weak self] v in self?.app.setSceneSpeed(Int(v.rounded())) } diff --git a/apps/macos/Sources/WizApp/Views/ColorWheelView.swift b/apps/macos/Sources/WizApp/Views/ColorWheelView.swift index fa55635..4fa40cc 100644 --- a/apps/macos/Sources/WizApp/Views/ColorWheelView.swift +++ b/apps/macos/Sources/WizApp/Views/ColorWheelView.swift @@ -109,11 +109,17 @@ final class WheelNSView: NSView { let cs = CGColorSpaceCreateDeviceRGB() let info = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) - guard let ctx = CGContext( - data: &pixels, width: dim, height: dim, bitsPerComponent: 8, bytesPerRow: dim * 4, - space: cs, bitmapInfo: info.rawValue), - let cg = ctx.makeImage() - else { return NSImage(size: bounds.size) } + // Both the context and makeImage() must run inside withUnsafeMutableBytes: + // a pointer bridged via `&pixels` is only valid for the duration of that one + // call, so holding the context past it would be undefined behaviour. + let cg: CGImage? = pixels.withUnsafeMutableBytes { buf in + guard let ctx = CGContext( + data: buf.baseAddress, width: dim, height: dim, bitsPerComponent: 8, + bytesPerRow: dim * 4, space: cs, bitmapInfo: info.rawValue) + else { return nil } + return ctx.makeImage() + } + guard let cg = cg else { return NSImage(size: bounds.size) } return NSImage(cgImage: cg, size: bounds.size) } diff --git a/apps/macos/Sources/WizApp/Views/DiscoveryView.swift b/apps/macos/Sources/WizApp/Views/DiscoveryView.swift index 55517b4..77eacf9 100644 --- a/apps/macos/Sources/WizApp/Views/DiscoveryView.swift +++ b/apps/macos/Sources/WizApp/Views/DiscoveryView.swift @@ -37,7 +37,9 @@ struct DiscoveryView: View { .foregroundStyle(.secondary) .listRowSeparator(.hidden) } - ForEach(unsavedDiscovered, id: \.mac) { light in + // Identified by the whole value, not `\.mac` — replies without a MAC + // all carry `""` there, which would collide as ForEach ids. + ForEach(unsavedDiscovered, id: \.self) { light in discoveredRow(light) .listRowSeparator(.hidden) } diff --git a/apps/macos/Sources/WizApp/Views/ScenesView.swift b/apps/macos/Sources/WizApp/Views/ScenesView.swift index 15dbf8b..1bba2c7 100644 --- a/apps/macos/Sources/WizApp/Views/ScenesView.swift +++ b/apps/macos/Sources/WizApp/Views/ScenesView.swift @@ -34,15 +34,18 @@ struct SceneSpeedView: View { Label("Speed", systemImage: "speedometer") .font(.subheadline) Spacer() - Text("\(app.state.scene?.speed ?? 50)%") + // 100% = normal; the bulb only reports a speed once one has been set. + Text("\(app.state.scene?.speed ?? 100)%") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } GradientSlider( value: Binding( - get: { Double(app.state.scene?.speed ?? 50) }, + get: { Double(app.state.scene?.speed ?? 100) }, set: { app.setSceneSpeed(Int($0.rounded())) }), - range: 1...100, + // The engine's firmware band (10–200), so the slider can't drift from + // what clampSpeed enforces. + range: Double(app.core.speedRange.lowerBound)...Double(app.core.speedRange.upperBound), colors: [], progressFill: (filled: .accentColor, unfilled: Color(rgb: [90, 90, 90]))) } diff --git a/apps/macos/Sources/WizKit/Discovery.swift b/apps/macos/Sources/WizKit/Discovery.swift index 2c6ac73..66676b6 100644 --- a/apps/macos/Sources/WizKit/Discovery.swift +++ b/apps/macos/Sources/WizKit/Discovery.swift @@ -12,7 +12,9 @@ import Foundation /// netmask via `getifaddrs`), which materially improves the hit rate. public enum Discovery { /// A discovered bulb. `name` is the module name, falling back to the MAC. - public struct Light: Equatable { + /// `mac` is `""` when the reply omitted one. Hashable so lists can identify + /// rows by the whole value (two MAC-less bulbs would collide on `mac` alone). + public struct Light: Equatable, Hashable { public let name: String public let ip: String public let mac: String @@ -139,7 +141,10 @@ public enum Discovery { while let cur = ptr { defer { ptr = cur.pointee.ifa_next } let flags = Int32(cur.pointee.ifa_flags) - guard (flags & IFF_UP) != 0, (flags & IFF_LOOPBACK) == 0, + // IFF_BROADCAST: only interfaces that actually have a broadcast address. + // A point-to-point link (utun VPNs etc.) has a /32 mask, so addr | ~mask + // would just unicast the probe back at our own address. + guard (flags & IFF_UP) != 0, (flags & IFF_LOOPBACK) == 0, (flags & IFF_BROADCAST) != 0, let sa = cur.pointee.ifa_addr, sa.pointee.sa_family == sa_family_t(AF_INET), let nm = cur.pointee.ifa_netmask else { continue } diff --git a/apps/macos/Sources/WizKit/Resources/wiz-core.global.js b/apps/macos/Sources/WizKit/Resources/wiz-core.global.js index d3786bf..521656a 100644 --- a/apps/macos/Sources/WizKit/Resources/wiz-core.global.js +++ b/apps/macos/Sources/WizKit/Resources/wiz-core.global.js @@ -62,7 +62,8 @@ var WizCore = (() => { stateMatchesPreset: () => stateMatchesPreset, toDimming: () => toDimming, warmGlowKelvin: () => warmGlowKelvin, - wheelToHS: () => wheelToHS + wheelToHS: () => wheelToHS, + whiteMixedToRgb: () => whiteMixedToRgb }); // packages/core/src/color.js @@ -193,8 +194,8 @@ var WizCore = (() => { } var clampBrightness = (n) => clampInt(n, 0, 100); var clampTemp = (k) => clampInt(k, TEMP_MIN, TEMP_MAX); - var SPEED_MIN = 1; - var SPEED_MAX = 100; + var SPEED_MIN = 10; + var SPEED_MAX = 200; var clampSpeed = (n) => clampInt(n, SPEED_MIN, SPEED_MAX); var toDimming = (brightness) => clampInt(brightness, DIMMING_MIN, DIMMING_MAX); var clampRgb = (rgb) => rgb.map((c) => clampInt(c, 0, 255)); @@ -252,6 +253,11 @@ var WizCore = (() => { const white = Math.min(r, g, b); return { r: r - white, g: g - white, b: b - white, c: white, w: white }; } + function whiteMixedToRgb(rgb, c = 0, w = 0) { + if (c !== w) return null; + const white = clampInt(c, 0, 255); + return clampRgb(rgb.map((channel) => Number(channel) + white)); + } function deviceBoundsFromConfig(modelConfig) { const bounds = {}; if (!modelConfig || typeof modelConfig !== "object") return bounds; @@ -331,6 +337,7 @@ var WizCore = (() => { }; } function stateMatchesPreset(state, preset) { + if (!state.on || state.scene) return false; if (preset.mode !== state.mode) return false; if ((preset.brightness ?? 100) !== state.brightness) return false; if (preset.mode === "rgb") { diff --git a/apps/macos/Sources/WizKit/Stores.swift b/apps/macos/Sources/WizKit/Stores.swift index 30c912f..908bc79 100644 --- a/apps/macos/Sources/WizKit/Stores.swift +++ b/apps/macos/Sources/WizKit/Stores.swift @@ -1,10 +1,15 @@ import Foundation -/// Persistence layer, sharing the exact on-disk schema with the wiz-light-core CLI so -/// the two interoperate against one `~/Library/Application Support/ -/// WizLightController` directory. Reads are corruption-tolerant (missing or -/// malformed → defaults); writes are atomic (temp file + `replaceItemAt`) so a -/// crash mid-write can't truncate good data. +/// Persistence layer, using the exact on-disk schema of the wiz-light-core CLI +/// stores. **Note on location:** the released app runs under the App Sandbox, so +/// `.applicationSupportDirectory` resolves *inside its container* +/// (`~/Library/Containers/com.wizlightcontroller.app/Data/Library/Application +/// Support/WizLightController`) — not the CLI's `~/Library/Application +/// Support/WizLightController`. The two tools therefore keep the same format but +/// each owns its own copy; only an unsandboxed dev run (`swift run` / +/// `swift test`, no entitlements applied) lands in the CLI's directory. Reads +/// are corruption-tolerant (missing or malformed → defaults); writes are atomic +/// (temp file + `replaceItemAt`) so a crash mid-write can't truncate good data. /// /// File map (mirrors `wiz-light-core` stores): /// - `settings.json` `{accent, highlight, autoSync}` @@ -153,6 +158,13 @@ public final class Stores { writeText(trimmed, to: lastIpURL) } + /// Forget the remembered last-used IP (e.g. the selected light was removed). + /// `saveLastIp("")` can't do this — it guards against empty writes — so this + /// removes the file instead. + public func clearLastIp() { + try? FileManager.default.removeItem(at: lastIpURL) + } + /// The last colour remembered for `mac`, or `nil` when unknown — so the wheel /// can open on this specific light's colour and a white→RGB flip restores it. public func loadDeviceRgb(_ mac: String) -> [Int]? { @@ -177,8 +189,8 @@ public final class Stores { /// The bulb's identity (MAC / model / firmware) remembered for a host, so the /// menu header and Settings → Device can show it on launch — before, or /// without, a live connection. Stored alongside its IP and returned only when - /// the IP still matches, so an identity left over from a different address (the - /// IP changed, or the CLI repointed `last_ip`) is ignored rather than shown + /// the IP still matches, so an identity left over from a different address + /// (the IP changed, or `last_ip` was repointed) is ignored rather than shown /// against the wrong bulb. public func loadLastDevice( forIp ip: String diff --git a/apps/macos/Sources/WizKit/WizClient.swift b/apps/macos/Sources/WizKit/WizClient.swift index 37b9825..36a1622 100644 --- a/apps/macos/Sources/WizKit/WizClient.swift +++ b/apps/macos/Sources/WizKit/WizClient.swift @@ -12,10 +12,10 @@ import Foundation /// fire-and-forget datagrams. Each `WizClient` owns a serial queue; the public /// methods are safe to call from the main actor. /// -/// `@unchecked Sendable`: the only mutable state (`pendingParams`, -/// `debounceWork`) is confined to the private serial `queue`; the query/send -/// methods (`getPilot`, `sendNow`, `setPilot`) are stateless socket calls. This -/// lets `AppState` hand the client to a background queue for the blocking +/// `@unchecked Sendable`: the mutable state is either confined to the private +/// serial `queue` (`pendingParams`, `debounceWork`) or guarded by `genLock` +/// (`sendGen`); the query methods (`getPilot` etc.) are stateless socket calls. +/// This lets `AppState` hand the client to a background queue for the blocking /// `getPilot` without tripping Sendable diagnostics. public final class WizClient: @unchecked Sendable { // MARK: - Tunables (match wiz-light-core's protocol defaults) @@ -30,12 +30,34 @@ public final class WizClient: @unchecked Sendable { public let host: String - /// Serializes coalescing + sends so a burst of `apply`/`power` calls from the + /// Serializes coalescing + sends so a burst of `send`/`power` calls from the /// UI funnels into a single debounced datagram. private let queue = DispatchQueue(label: "com.wizlightcontroller.client") private var pendingParams: [String: Any]? private var debounceWork: DispatchWorkItem? + /// Send generation, mirroring the engine `WizLight`'s `#sendGen`: every + /// `sendNow` bumps it, each retry iteration re-checks it (so a newer send + /// abandons an in-flight retry loop and a stale payload can't land after the + /// new one), and a debounced send armed before a direct `sendNow` is dropped + /// when its window elapses. Guarded by `genLock` — `sendNow` runs on whatever + /// thread called it (main for the sleep power-off, `queue` for debounced sends). + private var sendGen = 0 + private let genLock = NSLock() + + private func bumpGen() -> Int { + genLock.lock() + defer { genLock.unlock() } + sendGen += 1 + return sendGen + } + + private func currentGen() -> Int { + genLock.lock() + defer { genLock.unlock() } + return sendGen + } + public init(host: String) { self.host = host } @@ -70,7 +92,11 @@ public final class WizClient: @unchecked Sendable { /// Send a no-param request and return the parsed `result`, or `nil` on a 1s /// timeout / any socket error. Never throws. Safe off the main thread (it - /// blocks on `recvfrom`). + /// blocks on `recvfrom`). Only a datagram *from the queried host* counts — + /// the socket's ephemeral port is otherwise open to any sender, and a stray + /// (or spoofed) reply must not be read as the bulb's state; foreign datagrams + /// are skipped and the receive keeps waiting out the timeout (mirrors the + /// engine `query`). private func query(method: String, host: String) -> [String: Any]? { guard let payload = Self.encode(method: method, params: [:]) else { return nil } @@ -94,14 +120,29 @@ public final class WizClient: @unchecked Sendable { guard sent >= 0 else { return nil } var buf = [UInt8](repeating: 0, count: 4096) - let n = recvfrom(fd, &buf, buf.count, 0, nil, nil) - guard n > 0 else { return nil } + let deadline = Date().addingTimeInterval(Double(Self.timeoutMs) / 1000) + while true { + var from = sockaddr_in() + var fromLen = socklen_t(MemoryLayout.size) + let n = withUnsafeMutablePointer(to: &from) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + recvfrom(fd, &buf, buf.count, 0, sa, &fromLen) + } + } + guard n > 0 else { return nil } // timeout (EAGAIN) or socket error + // Not the bulb we asked — keep waiting (bounded by the deadline, so a + // chatty foreign sender can't keep the call alive past the timeout). + guard from.sin_addr.s_addr == addr.sin_addr.s_addr else { + if Date() >= deadline { return nil } + continue + } - let data = Data(buf[0.. [Int] { JSNum.intArray(call("perceivedRgb", [rgb, c, w]).toArray()) ?? rgb } + /// Exact inverse of the engine's white-mix split (model.js `rgbToWhiteMixed`): + /// reconstructs the logical colour from a pilot whose achromatic part rides + /// the white LEDs. `nil` when the split isn't invertible (`c != w` — a colour + /// set by a foreign sender that weights cool/warm separately); callers then + /// fall back to ``perceivedRgb(_:c:w:)`` for a display approximation. + public func whiteMixedToRgb(_ rgb: [Int], c: Int, w: Int) -> [Int]? { + let v = call("whiteMixedToRgb", [rgb, c, w]) + return (v.isNull || v.isUndefined) ? nil : JSNum.intArray(v.toArray()) + } + /// Wheel point → hue/saturation, or `nil` outside the wheel. public func wheelToHS(x: Double, y: Double, size: Double) -> (h: Double, s: Double)? { let v = call("wheelToHS", [x, y, size]) @@ -200,7 +211,18 @@ public final class WizCore { return lo...hi } - /// The seeded presets, grouped `rgb` / `white`, preserving insertion order. + /// The dynamic-scene speed band (validate.js `SPEED_MIN`/`SPEED_MAX`, the + /// firmware-accepted 10–200 where 100 = normal) — the single source of truth, + /// so the speed sliders can't drift from what `clampSpeed` enforces. + public var speedRange: ClosedRange { + let lo = Int(core.objectForKeyedSubscript("SPEED_MIN").toInt32()) + let hi = Int(core.objectForKeyedSubscript("SPEED_MAX").toInt32()) + return lo...hi + } + + /// The seeded presets, grouped `rgb` / `white`, sorted by name — the JS→Swift + /// dictionary bridge loses the engine's insertion order, so an explicit sort + /// keeps the order stable (and matches `Stores.loadPresets`). public func defaultPresets() -> [LightMode: [Preset]] { let groups = core.objectForKeyedSubscript("DEFAULT_PRESETS") var result: [LightMode: [Preset]] = [.rgb: [], .white: []] diff --git a/apps/macos/Tests/WizKitTests/WizCoreTests.swift b/apps/macos/Tests/WizKitTests/WizCoreTests.swift index cbd2f40..f544cac 100644 --- a/apps/macos/Tests/WizKitTests/WizCoreTests.swift +++ b/apps/macos/Tests/WizKitTests/WizCoreTests.swift @@ -20,6 +20,23 @@ final class WizCoreTests: XCTestCase { XCTAssertEqual(core.perceivedRgb([255, 0, 65], c: 0, w: 111), [255, 101, 140]) // FF658C } + func testWhiteMixedToRgbInversion() { + // Our own "Brighter colours" split drives c/w equally and inverts exactly — + // this is what keeps read-backs stable instead of washing toward white. + XCTAssertEqual(core.whiteMixedToRgb([55, 0, 0], c: 200, w: 200), [255, 200, 200]) + XCTAssertEqual(core.whiteMixedToRgb([10, 20, 30], c: 0, w: 0), [10, 20, 30]) + // A foreign uneven split can't be inverted; callers fall back to perceivedRgb. + XCTAssertNil(core.whiteMixedToRgb([255, 0, 65], c: 0, w: 111)) + } + + func testSpeedRange() { + // The firmware band (10–200, 100 = normal) — drives the speed sliders. + XCTAssertEqual(core.speedRange, 10...200) + XCTAssertEqual(core.clampSpeed(0), 10) + XCTAssertEqual(core.clampSpeed(150), 150) + XCTAssertEqual(core.clampSpeed(999), 200) + } + func testWheelGeometry() { XCTAssertNotNil(core.wheelToHS(x: 120, y: 120, size: 240)) // centre is valid XCTAssertNil(core.wheelToHS(x: 1000, y: 1000, size: 240)) // outside the wheel @@ -132,5 +149,10 @@ final class WizCoreTests: XCTestCase { XCTAssertEqual(state.mode, .white) XCTAssertEqual(state.temp, 3000) XCTAssertTrue(core.stateMatchesPreset(state, relax)) + + // An off light never highlights a preset — the bulb isn't showing it. + var off = state + off.on = false + XCTAssertFalse(core.stateMatchesPreset(off, relax)) } } diff --git a/apps/macos/build/Info.plist b/apps/macos/build/Info.plist index 6800db2..92f7930 100644 --- a/apps/macos/build/Info.plist +++ b/apps/macos/build/Info.plist @@ -13,9 +13,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 5.1.0 + 5.2.0 CFBundleVersion - 19 + 20 CFBundleIconFile AppIcon diff --git a/package.json b/package.json index 06814a3..6c509ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wiz-light-controller", - "version": "5.1.0", + "version": "5.2.0", "private": true, "description": "Fast, local, cloud-free controller for Philips WiZ lights — a modular JavaScript engine (reused by a CLI and a native macOS app via JavaScriptCore).", "license": "GPL-3.0-or-later", diff --git a/packages/cli/README.md b/packages/cli/README.md index 8df1e88..d0e3f2e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -17,22 +17,25 @@ wiz discover find WiZ bulbs on your LAN wiz status [ip] show a bulb's current state wiz on [ip] turn a bulb on wiz off [ip] turn a bulb off -wiz color <#rrggbb | r g b> [ip] set an RGB colour -wiz temp [ip] set white temperature -wiz brightness <0-100> [ip] set brightness +wiz color [ip] <#rrggbb | r g b> set an RGB colour +wiz temp [ip] set white temperature +wiz brightness [ip] <0-100> set brightness wiz presets list the saved presets -wiz preset [ip] apply a preset +wiz preset [ip] apply a preset +wiz scenes [ip] list the bulb's dynamic scenes +wiz scene [ip] run a dynamic scene wiz lights list saved lights -wiz save [ip] save the current light under a name +wiz save [ip] save the current light under a name ``` -`[ip]` is optional and falls back to the last-used bulb. Run `wiz --help`, or `wiz --help`, for details. +A leading `[ip]` is optional everywhere and falls back to the last-used bulb. Run `wiz --help`, or `wiz --help`, for details. ## Example ```bash wiz discover -wiz color "#ff0080" 192.168.1.50 +wiz color 192.168.1.50 "#ff0080" wiz temp 2700 wiz brightness 60 +wiz scene party --speed 120 ``` diff --git a/packages/cli/package.json b/packages/cli/package.json index 860117a..ff3ebf0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "wiz-light-cli", - "version": "5.1.0", + "version": "5.2.0", "description": "Fast, local, cloud-free command-line controller for Philips WiZ lights.", "license": "GPL-3.0-or-later", "homepage": "https://github.com/MegaManSec/wiz-light-controller#readme", diff --git a/packages/cli/src/commands.js b/packages/cli/src/commands.js index 88c4ebf..bb71623 100644 --- a/packages/cli/src/commands.js +++ b/packages/cli/src/commands.js @@ -13,6 +13,7 @@ import { parsePilot, buildSetPilotParams, applyPreset, + whiteMixedToRgb, findScene, scenesForDevice, sceneName, @@ -41,17 +42,35 @@ const fail = (message) => { // ---------- shared resolution helpers ---------- +/** A string *shaped* like a dotted-quad (even when out of range, e.g. + * `10.0.0.999`) — so a mistyped IP fails loudly instead of being silently + * read as the command's argument. */ +const looksLikeIp = (value) => + typeof value === 'string' && /^\d{1,3}(\.\d{1,3}){3}$/.test(value.trim()); + /** - * Resolve the target bulb IP: explicit positional first, else the last-used IP - * from the store. Validates and throws a clear message when neither is usable. + * Resolve the target bulb and the command's remaining arguments. The leading + * positional is taken as the IP only when it actually parses as one; otherwise + * every positional is an argument and the last-used IP applies — the documented + * "`` is optional everywhere" behaviour (`wiz color ff0000` works against + * the remembered bulb). A positional merely *shaped* like an IP still fails + * loudly rather than being misread as an argument. */ -async function resolveIp(positional, stores) { - const ip = positional ?? (await stores.lastState.loadIp()); +async function resolveTarget(positionals, stores) { + const [first] = positionals; + if (isValidIp(first)) return { ip: first.trim(), args: positionals.slice(1) }; + if (looksLikeIp(first)) fail(`Not a valid IPv4 address: ${first}`); + const ip = await stores.lastState.loadIp(); if (!ip) fail('No IP given and no previous bulb remembered. Pass an (e.g. 10.0.0.5).'); - if (!isValidIp(ip)) fail(`Not a valid IPv4 address: ${ip}`); - return ip; + return { ip, args: positionals }; } +/** Commands with a fixed argument shape reject leftovers instead of silently + * ignoring them (e.g. a typo'd IP that fell through to the argument list). */ +const rejectExtraArgs = (args) => { + if (args.length) fail(`Unexpected argument: ${args[0]}`); +}; + /** Parse and validate the optional `--brightness` flag, or return undefined. */ function parseBrightness(values) { if (values.brightness === undefined) return undefined; @@ -80,9 +99,11 @@ function parseSpeed(values) { function parsePositiveInt(value, label) { if (value === undefined) return undefined; if (typeof value !== 'string') fail(`--${label} needs a positive number.`); - const n = Number(value); + // Floor before the bounds check, so a fractional value below 1 (e.g. 0.5, + // which floors to 0) is rejected rather than passed through as 0. + const n = Math.floor(Number(value)); if (!Number.isFinite(n) || n <= 0) fail(`--${label} must be a positive number.`); - return Math.floor(n); + return n; } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -127,10 +148,13 @@ function formatState(ip, state, result = {}) { } else { lines.push(` mode ${state.mode}`); if (state.mode === 'rgb') { - // Fold the bulb's white channels (c/w) in so the swatch matches what the eye - // sees (and the official app), not just the raw colour LEDs. - const rgb = perceivedRgb(state.rgb, result.c, result.w); - lines.push(` colour ${swatch(rgb)} ${rgbToHex(rgb)}`); + // Fold the bulb's white channels (c/w) in so the swatch matches what the + // eye sees: an equal c/w split is our own white-mix and inverts exactly to + // the colour that was set; anything else gets the perceived approximation. + const rgb = + whiteMixedToRgb(state.rgb, result.c, result.w) ?? + perceivedRgb(state.rgb, result.c, result.w); + lines.push(` colour ${joinParts(swatch(rgb), rgbToHex(rgb))}`); } else { lines.push(` temperature ${state.temp}K`); } @@ -139,11 +163,15 @@ function formatState(ip, state, result = {}) { return lines.join('\n'); } +/** Join already-styled fragments with single spaces, skipping empty ones (the + * swatch is `''` when colour is off, which would otherwise leave a gap). */ +const joinParts = (...parts) => parts.filter(Boolean).join(' '); + function presetLine(name, p) { const at = `@ ${p.brightness ?? 100}%`; if (p.mode === 'white') return ` ${cyan(name)} — ${p.temp}K ${at}`; const rgb = [p.r, p.g, p.b]; - return ` ${swatch(rgb)} ${cyan(name)} — ${rgbToHex(rgb)} ${at}`; + return ` ${joinParts(swatch(rgb), cyan(name))} — ${rgbToHex(rgb)} ${at}`; } // ---------- commands ---------- @@ -164,12 +192,15 @@ async function cmdDiscover({ values }) { return; } for (const l of lights) { - print(`${bold(l.name)} — ${l.ip} — ${dim(formatMac(l.mac))}`); + // A reply can omit the MAC; skip the segment rather than printing a blank. + const mac = l.mac ? ` — ${dim(formatMac(l.mac))}` : ''; + print(`${bold(l.name)} — ${l.ip}${mac}`); } } async function cmdStatus({ positionals, stores }) { - const ip = await resolveIp(positionals[0], stores); + const { ip, args } = await resolveTarget(positionals, stores); + rejectExtraArgs(args); // Keep the raw result (not just the parsed state) so the swatch can fold in the // bulb's white channels (c/w) for a true-to-eye colour. const result = await queryWithRetry(ip); @@ -182,7 +213,8 @@ async function cmdStatus({ positionals, stores }) { const powerCommand = (on) => async ({ positionals, stores }) => { - const ip = await resolveIp(positionals[0], stores); + const { ip, args } = await resolveTarget(positionals, stores); + rejectExtraArgs(args); // One-shot: send immediately rather than via the debounced `power()` path. await light(ip).sendNow({ state: on }); await stores.lastState.saveIp(ip); @@ -190,8 +222,7 @@ const powerCommand = }; async function cmdColor({ positionals, values, stores }) { - const ip = await resolveIp(positionals[0], stores); - const rest = positionals.slice(1); + const { ip, args: rest } = await resolveTarget(positionals, stores); if (rest.length === 0) fail('Give a colour: a hex string or three 0-255 values.'); let rgb; @@ -218,13 +249,16 @@ async function cmdColor({ positionals, values, stores }) { ); await light(ip).sendNow(params); await stores.lastState.saveIp(ip); - print(`${bold(ip)} set to ${swatch(rgb)} ${rgbToHex(rgb)} @ ${brightness}%`); + print(`${bold(ip)} set to ${joinParts(swatch(rgb), rgbToHex(rgb))} @ ${brightness}%`); } async function cmdTemp({ positionals, values, stores }) { - const ip = await resolveIp(positionals[0], stores); - const kelvin = Number(positionals[1]); - if (!Number.isFinite(kelvin)) fail('Give a colour temperature in Kelvin (e.g. 4000).'); + const { ip, args } = await resolveTarget(positionals, stores); + if (args.length > 1) rejectExtraArgs(args.slice(1)); + const kelvin = Number(args[0]); + if (args.length === 0 || !Number.isFinite(kelvin)) { + fail('Give a colour temperature in Kelvin (e.g. 4000).'); + } const brightness = parseBrightness(values) ?? DEFAULT_STATE.brightness; const bounds = await deviceBounds(ip); @@ -238,9 +272,10 @@ async function cmdTemp({ positionals, values, stores }) { } async function cmdBrightness({ positionals, stores }) { - const ip = await resolveIp(positionals[0], stores); - if (positionals[1] === undefined) fail('Give a brightness from 0 to 100.'); - const value = Number(positionals[1]); + const { ip, args } = await resolveTarget(positionals, stores); + if (args.length > 1) rejectExtraArgs(args.slice(1)); + if (args[0] === undefined) fail('Give a brightness from 0 to 100.'); + const value = Number(args[0]); if (!Number.isFinite(value) || value < 0 || value > 100) { fail('Brightness must be a number from 0 to 100.'); } @@ -267,7 +302,8 @@ async function cmdPresets({ values, stores }) { ['White', presets.white], ]; for (const [label, group] of groups) { - const entries = Object.entries(group ?? {}); + // Sorted by name — the same stable order the macOS app shows. + const entries = Object.entries(group ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); if (entries.length === 0) continue; print(bold(label)); for (const [name, p] of entries) print(presetLine(name, p)); @@ -275,8 +311,9 @@ async function cmdPresets({ values, stores }) { } async function cmdPreset({ positionals, values, stores }) { - const ip = await resolveIp(positionals[0], stores); - const name = positionals[1]; + const { ip, args } = await resolveTarget(positionals, stores); + // Join the rest so a multi-word name ("Full White") works even unquoted. + const name = args.join(' ').trim(); if (!name) fail('Give a preset name (see `wiz presets`).'); const presets = await stores.presets.load(); @@ -301,9 +338,12 @@ async function liveStateOrDefault(ip) { async function cmdScenes({ positionals, values, stores }) { // scenesForDevice(null) returns every scene, so an unreachable/omitted bulb still - // lists them; a reachable one narrows to what it can actually show. - const ip = positionals[0] ?? (await stores.lastState.loadIp()); - const model = ip && isValidIp(ip) ? await getModelConfig(ip) : null; + // lists them; a reachable one narrows to what it can actually show. An explicit + // positional must be an IP — a typo'd one fails rather than silently listing all. + const [first] = positionals; + if (first !== undefined && !isValidIp(first)) fail(`Not a valid IPv4 address: ${first}`); + const ip = first ?? (await stores.lastState.loadIp()); + const model = ip ? await getModelConfig(ip) : null; const list = scenesForDevice(model); if (values.json) { print(JSON.stringify(list, null, 2)); @@ -316,9 +356,9 @@ async function cmdScenes({ positionals, values, stores }) { } async function cmdScene({ positionals, values, stores }) { - const ip = await resolveIp(positionals[0], stores); + const { ip, args } = await resolveTarget(positionals, stores); // Join the rest so a multi-word name ("Pastel Colors") works even unquoted. - const wanted = positionals.slice(1).join(' ').trim(); + const wanted = args.join(' ').trim(); if (!wanted) fail('Give a scene name or id (see `wiz scenes`).'); const scene = findScene(wanted); if (!scene) fail(`Unknown scene: ${wanted}. Run \`wiz scenes\` to list them.`); @@ -354,8 +394,9 @@ async function cmdLights({ values, stores }) { } async function cmdSave({ positionals, stores }) { - const ip = await resolveIp(positionals[0], stores); - const name = positionals[1]; + const { ip, args } = await resolveTarget(positionals, stores); + // Join the rest so a multi-word name ("Living Room") works even unquoted. + const name = args.join(' ').trim(); if (!name) fail('Give a name to save this light under (e.g. `wiz save 10.0.0.5 Desk`).'); const result = await queryWithRetry(ip); diff --git a/packages/cli/src/help.js b/packages/cli/src/help.js index 4f36a85..59b5e49 100644 --- a/packages/cli/src/help.js +++ b/packages/cli/src/help.js @@ -66,7 +66,10 @@ export const COMMANDS = { preset: { summary: 'Apply a named preset', usage: 'wiz preset [] [--brightness <0-100>]', - details: ['Looks the name up across the RGB and white preset groups.'], + details: [ + 'Looks the name up across the RGB and white preset groups. Multi-word', + 'names ("Full White") work unquoted.', + ], }, scenes: { summary: 'List the bulb’s dynamic scenes', @@ -78,11 +81,11 @@ export const COMMANDS = { }, scene: { summary: 'Run a dynamic scene', - usage: 'wiz scene [] [--speed <1-100>] [--brightness <0-100>]', + usage: 'wiz scene [] [--speed <10-200>] [--brightness <0-100>]', details: [ 'Accepts a scene name (case-insensitive) or its id — see `wiz scenes`.', '', - ' --speed <1-100> Animation speed (default: keep the bulb’s current)', + ' --speed <10-200> Animation speed, 100 = normal (default: keep the bulb’s current)', ' --brightness <0-100> Brightness percent', '', 'Examples:', @@ -97,7 +100,10 @@ export const COMMANDS = { save: { summary: 'Save a bulb by name', usage: 'wiz save [] ', - details: ['Resolves the bulb’s MAC and stores it for later reference.'], + details: [ + 'Resolves the bulb’s MAC and stores it for later reference. Multi-word', + 'names ("Living Room") work unquoted.', + ], }, }; diff --git a/packages/cli/src/output.js b/packages/cli/src/output.js index 1d9d469..6dde97b 100644 --- a/packages/cli/src/output.js +++ b/packages/cli/src/output.js @@ -16,7 +16,8 @@ export const yellow = wrap(33, 39); export const cyan = wrap(36, 39); export const gray = wrap(90, 39); -/** A filled swatch in 24-bit colour, falling back to a hex label when colour is off. */ +/** A filled swatch in 24-bit colour, or `''` when colour is off — callers print + * the hex label alongside (and skip the empty fragment when composing). */ export function swatch([r, g, b]) { if (!colorEnabled) return ''; return `[48;2;${r};${g};${b}m `; diff --git a/packages/core/package.json b/packages/core/package.json index 6a61883..3154eef 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "wiz-light-core", - "version": "5.1.0", + "version": "5.2.0", "description": "Local WiZ light engine: protocol, discovery, colour math, and persisted state. Zero runtime dependencies.", "license": "GPL-3.0-or-later", "homepage": "https://github.com/MegaManSec/wiz-light-controller#readme", diff --git a/packages/core/src/discovery.js b/packages/core/src/discovery.js index 2c09b5d..ef7d1eb 100644 --- a/packages/core/src/discovery.js +++ b/packages/core/src/discovery.js @@ -12,7 +12,7 @@ const defaultCreateSocket = () => dgram.createSocket({ type: 'udp4', reuseAddr: * @typedef {Object} DiscoveredLight * @property {string} name Module name, falling back to the MAC. * @property {string} ip - * @property {string} mac + * @property {string} mac `''` when the reply omitted one (never undefined). */ /** @@ -81,7 +81,12 @@ export function discover({ const mac = result.mac; const key = mac || rinfo.address; if (found.has(key)) return; - const light = { name: result.moduleName || mac || rinfo.address, ip: rinfo.address, mac }; + // mac is '' (not undefined) when unreported, matching the Swift Discovery. + const light = { + name: result.moduleName || mac || rinfo.address, + ip: rinfo.address, + mac: mac || '', + }; found.set(key, light); onFound?.(light); }); diff --git a/packages/core/src/model.js b/packages/core/src/model.js index 5f1a7fc..5a9d0dc 100644 --- a/packages/core/src/model.js +++ b/packages/core/src/model.js @@ -127,6 +127,27 @@ export function rgbToWhiteMixed([r, g, b]) { return { r: r - white, g: g - white, b: b - white, c: white, w: white }; } +/** + * Exact inverse of {@link rgbToWhiteMixed}: reconstruct the original colour from + * wire channels whose achromatic part rides the white LEDs. Only our own split + * is invertible — it always drives both white channels equally — so a pilot with + * `c !== w` (e.g. a colour set by the official app, which weights cool/warm + * separately) returns `null` and display callers fall back to + * {@link perceivedRgb}. Inverting — rather than folding the *perceived* colour + * back into the state — keeps read-backs stable: perceived values are + * display-only and wash toward white if ever re-sent. + * + * @param {[number, number, number]} rgb chromatic remainder channels (0–255) + * @param {number} [c] cool-white channel value + * @param {number} [w] warm-white channel value + * @returns {[number, number, number]|null} + */ +export function whiteMixedToRgb(rgb, c = 0, w = 0) { + if (c !== w) return null; + const white = clampInt(c, 0, 255); + return clampRgb(rgb.map((channel) => Number(channel) + white)); +} + /** * Derive per-device send bounds from a `getModelConfig` result: the bulb's real * white range (`cctRange`) and dimming floor (`minDimLevel`). Returns a `bounds` @@ -284,8 +305,11 @@ export function applyPreset(state, preset) { }; } -/** True when `state` already reflects `preset` (used to highlight the active preset). */ +/** True when `state` already reflects `preset` (used to highlight the active + * preset). Never matches while the light is off or a dynamic scene is running — + * the bulb isn't showing the preset then, whatever the remembered colour says. */ export function stateMatchesPreset(state, preset) { + if (!state.on || state.scene) return false; if (preset.mode !== state.mode) return false; if ((preset.brightness ?? 100) !== state.brightness) return false; if (preset.mode === 'rgb') { diff --git a/packages/core/src/protocol.js b/packages/core/src/protocol.js index 882c1b1..d3f8676 100644 --- a/packages/core/src/protocol.js +++ b/packages/core/src/protocol.js @@ -52,6 +52,9 @@ export function sendPilot( * Send a no-param request (`getPilot`, `getModelConfig`, `getSystemConfig`, …) * and resolve with the bulb's `result` object, or `null` on timeout or any * error. Never rejects — callers treat `null` as "unreachable / unsupported". + * Only a datagram *from the queried host* settles the promise — the socket's + * ephemeral port is otherwise open to any sender, and a stray (or spoofed) + * reply must not be read as the bulb's state. */ export function query( host, @@ -59,6 +62,7 @@ export function query( { port = DEFAULT_PORT, timeoutMs = DEFAULT_TIMEOUT_MS, createSocket = defaultCreateSocket } = {}, ) { assertHost(host); + const source = host.trim(); return new Promise((resolve) => { const socket = createSocket(); let settled = false; @@ -72,7 +76,8 @@ export function query( const timer = setTimeout(() => finish(null), timeoutMs); socket.on('error', () => finish(null)); - socket.on('message', (msg) => { + socket.on('message', (msg, rinfo) => { + if (rinfo?.address !== source) return; // not the bulb we asked — keep waiting try { finish(JSON.parse(msg.toString('utf8')).result ?? null); } catch { @@ -152,7 +157,9 @@ export class WizLight { * Every call coalesced into one window shares a single promise that settles * when that window's send completes (or resolves early if {@link close} * cancels it), so an awaited call never hangs just because a later call - * superseded it. + * superseded it. A {@link sendNow} issued *after* the window was armed also + * supersedes it — the stale payload is dropped (resolved, like a close) so a + * debounced edit can't land after a direct send (e.g. a forced power-off). */ send(params) { this.#pending = params; @@ -167,11 +174,16 @@ export class WizLight { this.#deferred = { promise, resolve, reject }; } const deferred = this.#deferred; + const gen = this.#sendGen; // window-arm generation; a direct send bumps it this.#timer = setTimeout(() => { this.#timer = null; this.#deferred = null; const next = this.#pending; this.#pending = null; + if (gen !== this.#sendGen) { + deferred.resolve(); // superseded by a direct sendNow — drop, don't hang + return; + } this.sendNow(next).then(deferred.resolve, deferred.reject); }, this.debounceMs); return deferred.promise; diff --git a/packages/core/src/validate.js b/packages/core/src/validate.js index fd56772..b8b84f9 100644 --- a/packages/core/src/validate.js +++ b/packages/core/src/validate.js @@ -46,11 +46,11 @@ export const clampBrightness = (n) => clampInt(n, 0, 100); /** Clamp a colour temperature to the WiZ-supported Kelvin range. */ export const clampTemp = (k) => clampInt(k, TEMP_MIN, TEMP_MAX); -/** Dynamic-scene animation speed band (matches the WiZ app / pywizlight). */ -export const SPEED_MIN = 1; -export const SPEED_MAX = 100; +/** Dynamic-scene animation speed band (matches the WiZ app / pywizlight: 100 = normal). */ +export const SPEED_MIN = 10; +export const SPEED_MAX = 200; -/** Clamp a dynamic-scene speed to the meaningful [1, 100] band. */ +/** Clamp a dynamic-scene speed to the firmware-accepted [10, 200] band. */ export const clampSpeed = (n) => clampInt(n, SPEED_MIN, SPEED_MAX); /** diff --git a/packages/core/test/discovery.test.js b/packages/core/test/discovery.test.js index 91b2287..ed3704c 100644 --- a/packages/core/test/discovery.test.js +++ b/packages/core/test/discovery.test.js @@ -167,6 +167,7 @@ describe('discovery: collecting bulbs', () => { const result = await promise; assert.equal(result.length, 1); assert.equal(result[0].ip, '10.0.0.7'); + assert.equal(result[0].mac, '', 'mac is an empty string, never undefined'); }); it('derives name from moduleName, then mac, then address', async () => { diff --git a/packages/core/test/model.test.js b/packages/core/test/model.test.js index 39d680c..dd83f99 100644 --- a/packages/core/test/model.test.js +++ b/packages/core/test/model.test.js @@ -5,6 +5,7 @@ import { parsePilot, buildSetPilotParams, rgbToWhiteMixed, + whiteMixedToRgb, deviceBoundsFromConfig, deviceCapabilities, describeDevice, @@ -212,6 +213,35 @@ describe('model: rgbToWhiteMixed', () => { }); }); +describe('model: whiteMixedToRgb', () => { + it('exactly inverts rgbToWhiteMixed, so read-backs are stable (no drift)', () => { + for (const rgb of [ + [255, 200, 200], + [255, 180, 180], + [255, 0, 0], + [255, 255, 255], + [10, 128, 90], + ]) { + const { r, g, b, c, w } = rgbToWhiteMixed(rgb); + assert.deepEqual(whiteMixedToRgb([r, g, b], c, w), rgb, `round-trips ${rgb}`); + } + }); + + it('is the identity when no white is lit (or the channels are omitted)', () => { + assert.deepEqual(whiteMixedToRgb([10, 20, 30], 0, 0), [10, 20, 30]); + assert.deepEqual(whiteMixedToRgb([10, 20, 30]), [10, 20, 30]); + }); + + it('returns null for an uneven split (a foreign sender weights c/w separately)', () => { + assert.equal(whiteMixedToRgb([255, 0, 65], 0, 111), null); + assert.equal(whiteMixedToRgb([255, 0, 65], 60, 40), null); + }); + + it('clamps channels that would overflow on a foreign equal split', () => { + assert.deepEqual(whiteMixedToRgb([200, 200, 200], 100, 100), [255, 255, 255]); + }); +}); + describe('model: applyPreset', () => { const base = { on: false, mode: 'rgb', rgb: [1, 2, 3], temp: 4000, brightness: 33 }; @@ -263,22 +293,42 @@ describe('model: stateMatchesPreset', () => { it('rejects on a different mode', () => { assert.equal( - stateMatchesPreset({ mode: 'white', rgb: [255, 0, 0], brightness: 100 }, red), + stateMatchesPreset({ on: true, mode: 'white', rgb: [255, 0, 0], brightness: 100 }, red), false, ); }); it('rejects on a brightness mismatch', () => { - assert.equal(stateMatchesPreset({ mode: 'rgb', rgb: [255, 0, 0], brightness: 40 }, red), false); + assert.equal( + stateMatchesPreset({ on: true, mode: 'rgb', rgb: [255, 0, 0], brightness: 40 }, red), + false, + ); }); it('rejects on any rgb channel mismatch', () => { assert.equal( - stateMatchesPreset({ mode: 'rgb', rgb: [254, 0, 0], brightness: 100 }, red), + stateMatchesPreset({ on: true, mode: 'rgb', rgb: [254, 0, 0], brightness: 100 }, red), + false, + ); + assert.equal( + stateMatchesPreset({ on: true, mode: 'rgb', rgb: [255, 1, 0], brightness: 100 }, red), + false, + ); + }); + + it('never matches while the light is off (the bulb is not showing the preset)', () => { + assert.equal( + stateMatchesPreset({ on: false, mode: 'rgb', rgb: [255, 0, 0], brightness: 100 }, red), false, ); + }); + + it('never matches while a dynamic scene is running', () => { assert.equal( - stateMatchesPreset({ mode: 'rgb', rgb: [255, 1, 0], brightness: 100 }, red), + stateMatchesPreset( + { on: true, mode: 'rgb', rgb: [255, 0, 0], brightness: 100, scene: { id: 4 } }, + red, + ), false, ); }); @@ -286,14 +336,14 @@ describe('model: stateMatchesPreset', () => { it('defaults a preset without brightness to 100 when comparing', () => { assert.equal( stateMatchesPreset( - { mode: 'white', temp: 3000, brightness: 100 }, + { on: true, mode: 'white', temp: 3000, brightness: 100 }, { mode: 'white', temp: 3000 }, ), true, ); assert.equal( stateMatchesPreset( - { mode: 'white', temp: 3000, brightness: 40 }, + { on: true, mode: 'white', temp: 3000, brightness: 40 }, { mode: 'white', temp: 3000 }, ), false, @@ -302,8 +352,14 @@ describe('model: stateMatchesPreset', () => { it('compares temperature for white presets', () => { const relax = DEFAULT_PRESETS.white.Relax; - assert.equal(stateMatchesPreset({ mode: 'white', temp: 3000, brightness: 100 }, relax), true); - assert.equal(stateMatchesPreset({ mode: 'white', temp: 3001, brightness: 100 }, relax), false); + assert.equal( + stateMatchesPreset({ on: true, mode: 'white', temp: 3000, brightness: 100 }, relax), + true, + ); + assert.equal( + stateMatchesPreset({ on: true, mode: 'white', temp: 3001, brightness: 100 }, relax), + false, + ); }); it('matches the corresponding default-preset round trip via applyPreset', () => { @@ -481,7 +537,13 @@ describe('model: dynamic scenes', () => { it('buildSetPilotParams clamps an out-of-range scene speed', () => { const p = buildSetPilotParams({ on: true, brightness: 100, scene: { id: 4, speed: 999 } }); - assert.equal(p.speed, 100); + assert.equal(p.speed, 200); + const q = buildSetPilotParams({ on: true, brightness: 100, scene: { id: 4, speed: 1 } }); + assert.equal(q.speed, 10); + }); + + it('parsePilot keeps a reported speed above 100 (the band is 10–200)', () => { + assert.equal(parsePilot({ state: true, sceneId: 4, speed: 150 }).scene.speed, 150); }); it('a scene still yields { state: false } when off', () => { diff --git a/packages/core/test/protocol.test.js b/packages/core/test/protocol.test.js index 2b2f9b9..884d99a 100644 --- a/packages/core/test/protocol.test.js +++ b/packages/core/test/protocol.test.js @@ -12,7 +12,9 @@ import { } from '../src/protocol.js'; import { makeFakeSocket, flush } from './helpers.js'; -const HOST = '10.0.0.42'; +// Matches the fake socket's default reply rinfo (helpers.js) — query() ignores +// datagrams from any other source. +const HOST = '10.0.0.50'; describe('protocol: constants', () => { it('exposes the documented transport defaults', () => { @@ -97,7 +99,26 @@ describe('protocol: queryPilot', () => { const socket = makeFakeSocket(); const promise = queryPilot(HOST, { createSocket: () => socket }); await flush(); - socket.emit('message', Buffer.from(JSON.stringify({ env: 'pro' }), 'utf8'), {}); + socket.reply(undefined); // a reply from the bulb with no `result` field + assert.equal(await promise, null); + }); + + it('ignores a datagram from a different host (only the bulb settles the query)', async () => { + const socket = makeFakeSocket(); + const promise = queryPilot(HOST, { createSocket: () => socket }); + await flush(); + // A stray/spoofed reply from elsewhere on the LAN must not be read as state. + socket.reply({ state: false, dimming: 1 }, { address: '10.9.9.9', port: 38899 }); + socket.reply({ state: true, dimming: 80 }); + assert.deepEqual(await promise, { state: true, dimming: 80 }); + }); + + it('resolves null on timeout when only foreign datagrams arrive', async () => { + const socket = makeFakeSocket(); + const promise = queryPilot(HOST, { timeoutMs: 1000, createSocket: () => socket }); + await flush(); + socket.reply({ state: true }, { address: '10.9.9.9', port: 38899 }); + mock.timers.tick(1000); assert.equal(await promise, null); }); @@ -351,4 +372,27 @@ describe('protocol: WizLight.send (debounced)', () => { await flush(); assert.equal(sockets.length, 0, 'nothing should be transmitted after close'); }); + + it('a direct sendNow supersedes a pending debounced send (the stale payload is dropped)', async () => { + const { light, sockets } = makeLight(); + // A colour edit is debouncing… then a forced power-off goes out directly + // (e.g. the macOS app turning the light off as the Mac sleeps). + const debounced = light.send({ state: true, r: 200 }); + const direct = light.sendNow({ state: false }); + await flush(); // first off datagram sent, retry gap armed + mock.timers.tick(120); // sendNow's retry gap (retries: 2) + await flush(); + await direct; + assert.equal(sockets.length, 2, 'the direct send went out in full'); + + mock.timers.tick(250); // the stale debounce window elapses… + await flush(); + mock.timers.tick(1000); + await flush(); + await debounced; // …its promise settles (dropped, not failed) + assert.equal(sockets.length, 2, 'the stale payload was never transmitted'); + for (const s of sockets) { + assert.deepEqual(s.sent[0].message.params, { state: false }, 'only the off went out'); + } + }); }); diff --git a/packages/core/test/validate.test.js b/packages/core/test/validate.test.js index 06c68c0..69affe5 100644 --- a/packages/core/test/validate.test.js +++ b/packages/core/test/validate.test.js @@ -146,11 +146,13 @@ describe('validate: clampTemp', () => { }); describe('validate: clampSpeed', () => { - it('constrains a dynamic-scene speed to [1, 100]', () => { + it('constrains a dynamic-scene speed to the firmware band [10, 200]', () => { + assert.equal(SPEED_MIN, 10); + assert.equal(SPEED_MAX, 200); assert.equal(clampSpeed(0), SPEED_MIN); - assert.equal(clampSpeed(1), 1); - assert.equal(clampSpeed(50), 50); + assert.equal(clampSpeed(10), 10); assert.equal(clampSpeed(100), 100); + assert.equal(clampSpeed(200), 200); assert.equal(clampSpeed(999), SPEED_MAX); }); });