From d3a00b8a7708aaa1138c94261a9ba4acbc6ea52b Mon Sep 17 00:00:00 2001 From: Sven van Heugten Date: Fri, 27 Feb 2026 19:09:13 +0100 Subject: [PATCH] Implement the Right button on the bedroom remote --- NightLight.Core.Tests/FakeHome.fs | 23 ++--- .../InteractionListGenerators.fs | 6 +- NightLight.Core.Tests/NightLightTests.fs | 96 ++++++++++--------- NightLight.Core/NightLightStateMachine.fs | 11 ++- NightLight.Core/ZigbeeEvents.fs | 2 + 5 files changed, 74 insertions(+), 64 deletions(-) diff --git a/NightLight.Core.Tests/FakeHome.fs b/NightLight.Core.Tests/FakeHome.fs index f1f92f7..65bfc62 100644 --- a/NightLight.Core.Tests/FakeHome.fs +++ b/NightLight.Core.Tests/FakeHome.fs @@ -10,6 +10,7 @@ type RemoteInteraction = | RemotePressedOnButton | RemotePressedOffButton | RemotePressedLeftButton + | RemotePressedRightButton type HumanInteraction = | LightPoweredOn of Light @@ -49,9 +50,7 @@ type FakeLight(light: Light) = member _.SetBrightness(newBrightness: byte) = if hasPower then brightness <- newBrightness - - if (lightProps light).Bulb = IkeaBulb then - state <- true + state <- true member _.SetColor(newColor: Color) = if hasPower then @@ -139,6 +138,11 @@ type FakeHome() = Payload = @"{ ""action"": ""arrow_left_click"" }" } |> ReceivedZigbeeEvent |> onEventPublished.Trigger + | RemoteInteraction RemotePressedRightButton -> + { Topic = $"zigbee2mqtt/{remoteControlFriendlyName.Get}" + Payload = @"{ ""action"": ""arrow_right_click"" }" } + |> ReceivedZigbeeEvent + |> onEventPublished.Trigger | TimeChanged newTime -> newTime |> Event.TimeChanged |> onEventPublished.Trigger type FakeHome with @@ -147,19 +151,6 @@ type FakeHome with member this.LightsThatAreOn = this.LightStates |> Seq.filter (snd >> _.IsOn) |> Seq.toList - member this.NonRemotelyControlledLightStates = - this.LightStates - |> Seq.filter (fun (light, _) -> - light = LivingRoomWallLamp - || light = LivingRoomFloorLamp - || light = BathroomCeilingLamp) - |> Seq.toList - - member this.RemotelyControlledLightStates = - this.LightStates - |> Seq.filter (fun (light, _) -> light = RightBedroomLamp || light = LeftBedroomLamp) - |> Seq.toList - member this.Label = this.LightsThatAreOn |> Seq.map (fun (light, state) -> $"{(lightProps light).FriendlyName.Get}: {state}") diff --git a/NightLight.Core.Tests/InteractionListGenerators.fs b/NightLight.Core.Tests/InteractionListGenerators.fs index d91defb..7b7b484 100644 --- a/NightLight.Core.Tests/InteractionListGenerators.fs +++ b/NightLight.Core.Tests/InteractionListGenerators.fs @@ -36,7 +36,11 @@ let private genHumanInteraction = |> Gen.map Interaction.HumanInteraction let private genRemoteInteraction = - Gen.elements [ RemotePressedOnButton; RemotePressedOffButton; RemotePressedLeftButton ] + Gen.elements + [ RemotePressedOnButton + RemotePressedOffButton + RemotePressedLeftButton + RemotePressedRightButton ] |> Gen.map RemoteInteraction let private genInteraction = diff --git a/NightLight.Core.Tests/NightLightTests.fs b/NightLight.Core.Tests/NightLightTests.fs index 3a210a7..99f323b 100644 --- a/NightLight.Core.Tests/NightLightTests.fs +++ b/NightLight.Core.Tests/NightLightTests.fs @@ -82,55 +82,59 @@ type NightLightTests() = |> Prop.trivial (fakeHome.LightsThatAreOn.Length = 0) [ |])>] - let ``All non-remotely controlled lights with power should be on`` (interactions: Interaction list) = + let ``All lights with power should have the correct state`` (interactions: Interaction list) = let fakeHome = createFakeHomeWithNightLightAndInteract interactions - let nonRemotelyControlledLightsWithPower = - fakeHome.NonRemotelyControlledLightStates + let lightsWithPower = + fakeHome.LightStates |> Seq.filter (fun (light, _) -> doesLightHavePowerAfterInteractions light interactions) |> Seq.toList - nonRemotelyControlledLightsWithPower - |> Seq.forall (snd >> _.IsOn) - |> Prop.collect $"{nonRemotelyControlledLightsWithPower.Length} non-remotely controlled light(s) with power" + let lastBedroomRemoteInteraction = + interactions + |> Seq.indexed + |> Seq.choose (fun (index, interaction) -> + match interaction with + | Interaction.RemoteInteraction remoteInteraction -> + match remoteInteraction with + | RemotePressedOnButton + | RemotePressedOffButton + | RemotePressedLeftButton -> Some(index, remoteInteraction) + | RemotePressedRightButton -> None + | _ -> None) + |> Seq.tryLast + + let newDayStartedSinceBedroomRemote = + hasNewDayStartedSince interactions lastBedroomRemoteInteraction + + let hasPressedRight = + interactions + |> Seq.exists (function + | Interaction.RemoteInteraction RemotePressedRightButton -> true + | _ -> false) + + let isExpectedOn light = + match light with + | LeftBedroomLamp + | RightBedroomLamp -> + if newDayStartedSinceBedroomRemote then + true + else + match lastBedroomRemoteInteraction with + | Some(_, RemotePressedOffButton) -> false + | Some(_, RemotePressedLeftButton) -> light = LeftBedroomLamp + | Some(_, RemotePressedOnButton) -> true + | Some(_, RemotePressedRightButton) -> failwith "unexpected" + | None -> true + | LivingRoomWallLamp + | LivingRoomFloorLamp -> not hasPressedRight + | BathroomCeilingLamp -> true + + lightsWithPower + |> Seq.forall (fun (light, state) -> state.IsOn = isExpectedOn light) + |> Prop.collect $"last bedroom remote interaction is {lastBedroomRemoteInteraction |> Option.map snd}" + |> Prop.collect $"pressed right: {hasPressedRight}" + |> Prop.collect $"{lightsWithPower.Length} light(s) with power" + |> Prop.classify newDayStartedSinceBedroomRemote "new day since bedroom remote" |> Prop.label fakeHome.Label - |> Prop.trivial (nonRemotelyControlledLightsWithPower.Length = 0) - - [ |])>] - let ``All remotely-controlled lights with power should have the correct state`` (interactions: Interaction list) = - let fakeHome = createFakeHomeWithNightLightAndInteract interactions - - let remotelyControlledLightsWithPower = - fakeHome.RemotelyControlledLightStates - |> Seq.filter (fun (light, _) -> doesLightHavePowerAfterInteractions light interactions) - |> Seq.toList - - let allOn (ls: (Light * LightState) seq) = ls |> Seq.forall (snd >> _.IsOn) - let allOff (ls: (Light * LightState) seq) = ls |> Seq.forall (snd >> _.IsOff) - - let controlledByLeft ls = - ls |> Seq.filter (fun (light, _) -> light = LeftBedroomLamp) - - let controlledByRight ls = - ls |> Seq.filter (fun (light, _) -> light = RightBedroomLamp) - - let maybeLastRemoteInteraction = tryGetLastRemoteInteraction interactions - - let hasNewDayStartedSinceThen = - hasNewDayStartedSince interactions maybeLastRemoteInteraction - - if hasNewDayStartedSinceThen then - remotelyControlledLightsWithPower |> allOn - else - match maybeLastRemoteInteraction with - | Some(_, RemotePressedOnButton) -> remotelyControlledLightsWithPower |> allOn - | Some(_, RemotePressedOffButton) -> remotelyControlledLightsWithPower |> allOff - | Some(_, RemotePressedLeftButton) -> - remotelyControlledLightsWithPower |> controlledByLeft |> allOn - && remotelyControlledLightsWithPower |> controlledByRight |> allOff - | None -> remotelyControlledLightsWithPower |> allOn - |> Prop.collect $"last remote interaction is {maybeLastRemoteInteraction |> Option.map snd}" - |> Prop.collect $"{remotelyControlledLightsWithPower.Length} remotely controlled light(s) with power" - |> Prop.classify hasNewDayStartedSinceThen "new day has started since then" - |> Prop.label fakeHome.Label - |> Prop.trivial (remotelyControlledLightsWithPower.Length = 0) + |> Prop.trivial (lightsWithPower.Length = 0) diff --git a/NightLight.Core/NightLightStateMachine.fs b/NightLight.Core/NightLightStateMachine.fs index c3b91ec..59fdbc5 100644 --- a/NightLight.Core/NightLightStateMachine.fs +++ b/NightLight.Core/NightLightStateMachine.fs @@ -52,7 +52,11 @@ let internal createOrUpdateNightLightState brightness.Scale(getAlarmWeight time) else brightness - State = if alarm then On else previousState }) + State = + if alarm && (light = RightBedroomLamp || light = LeftBedroomLamp) then + On + else + previousState }) |> Map.ofSeq { Time = time @@ -117,6 +121,11 @@ type NightLightStateMachine private (maybeState: NightLightState option) = |> withAlarmOff |> withStateFor RightBedroomLamp Off |> withStateFor LeftBedroomLamp On + | PressedRight -> + currentState + |> withAlarmOff + |> withStateFor LivingRoomWallLamp Off + |> withStateFor LivingRoomFloorLamp Off NightLightStateMachine(Some newNightLightState), generateZigbeeCommandsForDifference (Some currentState) newNightLightState diff --git a/NightLight.Core/ZigbeeEvents.fs b/NightLight.Core/ZigbeeEvents.fs index 7786a28..13e4301 100644 --- a/NightLight.Core/ZigbeeEvents.fs +++ b/NightLight.Core/ZigbeeEvents.fs @@ -8,6 +8,7 @@ type Action = | PressedOn | PressedOff | PressedLeft + | PressedRight type ZigbeeEvent = | DeviceAnnounce of DeviceFriendlyName @@ -37,6 +38,7 @@ let parseZigbeeEvent (message: Message) = | Some(JsonValue.String "on") -> Ok(ButtonPress PressedOn) | Some(JsonValue.String "off") -> Ok(ButtonPress PressedOff) | Some(JsonValue.String "arrow_left_click") -> Ok(ButtonPress PressedLeft) + | Some(JsonValue.String "arrow_right_click") -> Ok(ButtonPress PressedRight) | Some _ -> Error InvalidActionField | None -> Error MissingActionField | _ -> return! Error <| UnknownTopic message.Topic