commit
4e6fc6645c
10 changed files with 280 additions and 52 deletions
22
NightLight.Core.Tests/Arbitraries.fs
Normal file
22
NightLight.Core.Tests/Arbitraries.fs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
namespace NightLight.Core.Tests
|
||||||
|
|
||||||
|
open System
|
||||||
|
open FsCheck.FSharp
|
||||||
|
open NightLight.Core.Models
|
||||||
|
|
||||||
|
type Arbitraries =
|
||||||
|
static member Interaction() =
|
||||||
|
let genTimeChangedInteraction =
|
||||||
|
gen {
|
||||||
|
let! time = ArbMap.defaults |> ArbMap.generate<DateTime>
|
||||||
|
return Interaction.TimeChanged time
|
||||||
|
}
|
||||||
|
|
||||||
|
let genHumanInteraction =
|
||||||
|
gen {
|
||||||
|
let! light = Gen.elements lights
|
||||||
|
let! humanInteraction = Gen.elements [ LightTurnedOn light; LightTurnedOff light ]
|
||||||
|
return Interaction.HumanInteraction humanInteraction
|
||||||
|
}
|
||||||
|
|
||||||
|
Gen.oneof [ genTimeChangedInteraction; genHumanInteraction ] |> Arb.fromGen
|
||||||
135
NightLight.Core.Tests/FakeHome.fs
Normal file
135
NightLight.Core.Tests/FakeHome.fs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
namespace NightLight.Core.Tests
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.Text.RegularExpressions
|
||||||
|
open NightLight.Core.Models
|
||||||
|
open NightLight.Core.Core
|
||||||
|
open FsToolkit.ErrorHandling
|
||||||
|
open FSharp.Data
|
||||||
|
|
||||||
|
type HumanInteraction =
|
||||||
|
| LightTurnedOn of Light
|
||||||
|
| LightTurnedOff of Light
|
||||||
|
|
||||||
|
type Interaction =
|
||||||
|
| HumanInteraction of HumanInteraction
|
||||||
|
| TimeChanged of DateTime
|
||||||
|
|
||||||
|
type Color =
|
||||||
|
| White
|
||||||
|
| Yellow
|
||||||
|
| Red
|
||||||
|
|
||||||
|
type LightState =
|
||||||
|
| Off
|
||||||
|
| On of Brightness: byte * Color: Color
|
||||||
|
|
||||||
|
type FakeLight(light: Light) =
|
||||||
|
let mutable hasPower = false
|
||||||
|
let mutable brightness: byte = 255uy
|
||||||
|
let mutable color: Color = White
|
||||||
|
|
||||||
|
member _.LightWithState = light, if hasPower then On(brightness, color) else Off
|
||||||
|
|
||||||
|
member _.TurnOn() = hasPower <- true
|
||||||
|
|
||||||
|
member _.TurnOff() = hasPower <- false
|
||||||
|
|
||||||
|
member _.SetBrightness(newBrightness: byte) =
|
||||||
|
if hasPower then
|
||||||
|
brightness <- newBrightness
|
||||||
|
|
||||||
|
member _.SetColor(newColor: Color) =
|
||||||
|
if hasPower then
|
||||||
|
color <- newColor
|
||||||
|
|
||||||
|
type FakeHome(now: DateTime) =
|
||||||
|
let mutable time = now
|
||||||
|
|
||||||
|
let mutable nightLightStateMachine = NightLightStateMachine now
|
||||||
|
|
||||||
|
let assertIsOkAndGet result =
|
||||||
|
match result with
|
||||||
|
| Ok value -> value
|
||||||
|
| Error error -> failwith $"Expected Ok, got Error {error}"
|
||||||
|
|
||||||
|
let friendlyNameToFakeLight =
|
||||||
|
lights
|
||||||
|
|> Seq.map (fun light -> light.FriendlyName, FakeLight light)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let processCommand command =
|
||||||
|
option {
|
||||||
|
let! friendlyName =
|
||||||
|
let m = Regex.Match(command.Topic, "^zigbee2mqtt/(.+)/set$")
|
||||||
|
if m.Success then Some m.Groups.[1].Value else None
|
||||||
|
|
||||||
|
let! fakeLight = Map.tryFind friendlyName friendlyNameToFakeLight
|
||||||
|
|
||||||
|
let parsedPayload = JsonValue.Parse command.Payload
|
||||||
|
|
||||||
|
match parsedPayload.TryGetProperty "brightness" with
|
||||||
|
| Some(JsonValue.Number newBrightness) -> fakeLight.SetBrightness(byte newBrightness)
|
||||||
|
| None -> ()
|
||||||
|
| value -> failwith $"Unexpected brightness value {value}"
|
||||||
|
|
||||||
|
match parsedPayload.TryGetProperty "color" with
|
||||||
|
| Some color ->
|
||||||
|
match color.TryGetProperty "x", color.TryGetProperty "y" with
|
||||||
|
| Some(JsonValue.Number 0.3227M), Some(JsonValue.Number 0.329M) -> fakeLight.SetColor White
|
||||||
|
| Some(JsonValue.Number 0.6942M), Some(JsonValue.Number 0.2963M) -> fakeLight.SetColor Red
|
||||||
|
| _ -> failwith $"Unexpected color value {color}"
|
||||||
|
| None -> ()
|
||||||
|
|
||||||
|
match parsedPayload.TryGetProperty "color_temp" with
|
||||||
|
| Some(JsonValue.Number temperature) when temperature = 454M -> fakeLight.SetColor Yellow
|
||||||
|
| None -> ()
|
||||||
|
| value -> failwith $"Unexpected color temperature value {value}"
|
||||||
|
}
|
||||||
|
|> ignore
|
||||||
|
|
||||||
|
let sendEvent event =
|
||||||
|
let newState, commands =
|
||||||
|
event |> nightLightStateMachine.OnEventReceived |> assertIsOkAndGet
|
||||||
|
|
||||||
|
commands |> Seq.iter processCommand
|
||||||
|
nightLightStateMachine <- newState
|
||||||
|
|
||||||
|
member _.Time = time
|
||||||
|
|
||||||
|
member _.LightStates = friendlyNameToFakeLight.Values |> Seq.map _.LightWithState
|
||||||
|
|
||||||
|
member _.Interact(interaction: Interaction) =
|
||||||
|
match interaction with
|
||||||
|
| HumanInteraction(LightTurnedOn light) ->
|
||||||
|
friendlyNameToFakeLight[light.FriendlyName].TurnOn()
|
||||||
|
|
||||||
|
{ Topic = "zigbee2mqtt/bridge/event"
|
||||||
|
Payload =
|
||||||
|
$@"{{
|
||||||
|
""type"": ""device_announce"",
|
||||||
|
""data"": {{ ""friendly_name"": ""{light.FriendlyName}"" }}
|
||||||
|
}}" }
|
||||||
|
|> ReceivedZigbeeEvent
|
||||||
|
|> sendEvent
|
||||||
|
| HumanInteraction(LightTurnedOff light) -> friendlyNameToFakeLight[light.FriendlyName].TurnOff()
|
||||||
|
| TimeChanged newTime ->
|
||||||
|
time <- newTime
|
||||||
|
newTime |> Event.TimeChanged |> sendEvent
|
||||||
|
|
||||||
|
type FakeHome with
|
||||||
|
member this.Interact(interactions: Interaction seq) = interactions |> Seq.iter this.Interact
|
||||||
|
|
||||||
|
member this.ForAllLightsThatAreOn condition =
|
||||||
|
this.LightStates
|
||||||
|
|> Seq.choose (fun (light, state) ->
|
||||||
|
match state with
|
||||||
|
| On(brightness, color) -> Some(light, brightness, color)
|
||||||
|
| Off -> None)
|
||||||
|
|> Seq.forall condition
|
||||||
|
|
||||||
|
member this.IsDay() =
|
||||||
|
this.Time.TimeOfDay >= TimeSpan.FromHours 5.5
|
||||||
|
&& this.Time.TimeOfDay < TimeSpan.FromHours 20.5
|
||||||
|
|
||||||
|
member this.IsNight() = not (this.IsDay())
|
||||||
28
NightLight.Core.Tests/NightLight.Core.Tests.fsproj
Normal file
28
NightLight.Core.Tests/NightLight.Core.Tests.fsproj
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="FakeHome.fs" />
|
||||||
|
<Compile Include="Arbitraries.fs" />
|
||||||
|
<Compile Include="NightLightTests.fs" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="FsCheck" Version="3.3.0" />
|
||||||
|
<PackageReference Include="FsCheck.Xunit" Version="3.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../NightLight.Core/NightLight.Core.fsproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
29
NightLight.Core.Tests/NightLightTests.fs
Normal file
29
NightLight.Core.Tests/NightLightTests.fs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
namespace NightLight.Core.Tests
|
||||||
|
|
||||||
|
open System
|
||||||
|
open FsCheck.Xunit
|
||||||
|
open FsCheck.FSharp
|
||||||
|
|
||||||
|
[<Properties(Arbitrary = [| typeof<Arbitraries> |])>]
|
||||||
|
type NightLightTests() =
|
||||||
|
[<Property>]
|
||||||
|
let ``Brightness should always be under 255`` (now: DateTime) (interactions: Interaction list) =
|
||||||
|
let fakeHome = FakeHome now
|
||||||
|
fakeHome.Interact interactions
|
||||||
|
fakeHome.ForAllLightsThatAreOn(fun (_, brightness, _) -> brightness < 255uy)
|
||||||
|
|
||||||
|
[<Property>]
|
||||||
|
let ``Lights should be red during the night`` (now: DateTime) (interactions: Interaction list) =
|
||||||
|
let fakeHome = FakeHome now
|
||||||
|
fakeHome.Interact interactions
|
||||||
|
|
||||||
|
fakeHome.IsNight()
|
||||||
|
==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = Red)
|
||||||
|
|
||||||
|
[<Property>]
|
||||||
|
let ``Lights should be white or yellow during the day`` (now: DateTime) (interactions: Interaction list) =
|
||||||
|
let fakeHome = FakeHome now
|
||||||
|
fakeHome.Interact interactions
|
||||||
|
|
||||||
|
fakeHome.IsDay()
|
||||||
|
==> fakeHome.ForAllLightsThatAreOn(fun (_, _, color) -> color = White || color = Yellow)
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
module NightLight.Core.Core
|
|
||||||
|
|
||||||
open NightLight.Core.Models
|
|
||||||
open NightLight.Core.PartsOfDay
|
|
||||||
open NightLight.Core.ZigbeeEvents
|
|
||||||
open NightLight.Core.ZigbeeCommands
|
|
||||||
open NightLight.Core.Moods
|
|
||||||
open FsToolkit.ErrorHandling
|
|
||||||
|
|
||||||
let internal tryFindLight friendlyName =
|
|
||||||
Seq.tryFind (fun light -> light.FriendlyName = friendlyName) lights
|
|
||||||
|
|
||||||
let internal generateZigbeeCommandToFixLight partOfDay light =
|
|
||||||
let color, brightness =
|
|
||||||
getDesiredMood light.Room partOfDay |> getDesiredColorAndBrightness light.Bulb
|
|
||||||
|
|
||||||
generateZigbeeCommand light.FriendlyName color brightness
|
|
||||||
|
|
||||||
let onEventReceived (state: State) (event: Event) : Result<State * Message seq, ParseEventError> =
|
|
||||||
result {
|
|
||||||
let partOfDay = getPartOfDay state.Time
|
|
||||||
|
|
||||||
match event with
|
|
||||||
| ReceivedZigbeeEvent payload ->
|
|
||||||
let! zigbeeEvent = parseZigbeeEvent payload |> Result.mapError ParseZigbeeEventError
|
|
||||||
|
|
||||||
return
|
|
||||||
state,
|
|
||||||
match zigbeeEvent with
|
|
||||||
| DeviceAnnounce friendlyName ->
|
|
||||||
let maybeLight = tryFindLight friendlyName
|
|
||||||
|
|
||||||
match maybeLight with
|
|
||||||
| Some light -> generateZigbeeCommandToFixLight partOfDay light |> Seq.singleton
|
|
||||||
| None -> Seq.empty
|
|
||||||
| TimeChanged time ->
|
|
||||||
let newState = { Time = time }
|
|
||||||
let newPartOfDay = getPartOfDay time
|
|
||||||
|
|
||||||
return
|
|
||||||
newState,
|
|
||||||
if partOfDay <> newPartOfDay then
|
|
||||||
lights |> Seq.map (generateZigbeeCommandToFixLight newPartOfDay)
|
|
||||||
else
|
|
||||||
Seq.empty
|
|
||||||
}
|
|
||||||
|
|
@ -2,8 +2,6 @@ module NightLight.Core.Models
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
|
||||||
type State = { Time: DateTime }
|
|
||||||
|
|
||||||
type Message = { Topic: string; Payload: string }
|
type Message = { Topic: string; Payload: string }
|
||||||
|
|
||||||
type Event =
|
type Event =
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<Compile Include="Moods.fs" />
|
<Compile Include="Moods.fs" />
|
||||||
<Compile Include="ZigbeeEvents.fs" />
|
<Compile Include="ZigbeeEvents.fs" />
|
||||||
<Compile Include="ZigbeeCommands.fs" />
|
<Compile Include="ZigbeeCommands.fs" />
|
||||||
<Compile Include="Core.fs" />
|
<Compile Include="NightLightStateMachine.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
||||||
48
NightLight.Core/NightLightStateMachine.fs
Normal file
48
NightLight.Core/NightLightStateMachine.fs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
module NightLight.Core.Core
|
||||||
|
|
||||||
|
open System
|
||||||
|
open NightLight.Core.Models
|
||||||
|
open NightLight.Core.PartsOfDay
|
||||||
|
open NightLight.Core.ZigbeeEvents
|
||||||
|
open NightLight.Core.ZigbeeCommands
|
||||||
|
open NightLight.Core.Moods
|
||||||
|
open FsToolkit.ErrorHandling
|
||||||
|
|
||||||
|
let internal tryFindLight friendlyName =
|
||||||
|
Seq.tryFind (fun light -> light.FriendlyName = friendlyName) lights
|
||||||
|
|
||||||
|
let internal generateZigbeeCommandToFixLight partOfDay light =
|
||||||
|
let color, brightness =
|
||||||
|
getDesiredMood light.Room partOfDay |> getDesiredColorAndBrightness light.Bulb
|
||||||
|
|
||||||
|
generateZigbeeCommand light.FriendlyName color brightness
|
||||||
|
|
||||||
|
type NightLightStateMachine(time: DateTime) =
|
||||||
|
member this.OnEventReceived(event: Event) : Result<NightLightStateMachine * Message seq, ParseEventError> =
|
||||||
|
result {
|
||||||
|
let partOfDay = getPartOfDay time
|
||||||
|
|
||||||
|
match event with
|
||||||
|
| ReceivedZigbeeEvent payload ->
|
||||||
|
let! zigbeeEvent = parseZigbeeEvent payload |> Result.mapError ParseZigbeeEventError
|
||||||
|
|
||||||
|
return
|
||||||
|
this,
|
||||||
|
match zigbeeEvent with
|
||||||
|
| DeviceAnnounce friendlyName ->
|
||||||
|
let maybeLight = tryFindLight friendlyName
|
||||||
|
|
||||||
|
match maybeLight with
|
||||||
|
| Some light -> generateZigbeeCommandToFixLight partOfDay light |> Seq.singleton
|
||||||
|
| None -> Seq.empty
|
||||||
|
| TimeChanged newTime ->
|
||||||
|
let newState = NightLightStateMachine newTime
|
||||||
|
let newPartOfDay = getPartOfDay newTime
|
||||||
|
|
||||||
|
return
|
||||||
|
newState,
|
||||||
|
if partOfDay <> newPartOfDay then
|
||||||
|
lights |> Seq.map (generateZigbeeCommandToFixLight newPartOfDay)
|
||||||
|
else
|
||||||
|
Seq.empty
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NightLight", "NightLight\Ni
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NightLight.Core", "NightLight.Core\NightLight.Core.fsproj", "{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NightLight.Core", "NightLight.Core\NightLight.Core.fsproj", "{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "NightLight.Core.Tests", "NightLight.Core.Tests\NightLight.Core.Tests.fsproj", "{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
@ -38,6 +40,18 @@ Global
|
||||||
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x64.Build.0 = Release|Any CPU
|
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x86.ActiveCfg = Release|Any CPU
|
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x86.Build.0 = Release|Any CPU
|
{FE406BDE-B6C1-4BDA-A29D-54D50A7828A9}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{23C7B106-B1B8-49A8-B2CF-22C078C8DDB3}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,12 @@ let private publishZigbeeCommands (mqttClient: IMqttClient) (logger: ILogger) (c
|
||||||
|> Async.Ignore
|
|> Async.Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
let private handleEvent (mqttClient: IMqttClient) (logger: ILogger) (state: State) (event: Event) =
|
let private handleEvent (mqttClient: IMqttClient) (logger: ILogger) (state: NightLightStateMachine) (event: Event) =
|
||||||
match event with
|
match event with
|
||||||
| ReceivedZigbeeEvent payload -> logger.LogInformation("Received message with payload {Payload}", payload)
|
| ReceivedZigbeeEvent payload -> logger.LogInformation("Received message with payload {Payload}", payload)
|
||||||
| _ -> ()
|
| _ -> ()
|
||||||
|
|
||||||
let result = event |> onEventReceived state
|
let result = event |> state.OnEventReceived
|
||||||
|
|
||||||
match result with
|
match result with
|
||||||
| Ok(newState, commands) ->
|
| Ok(newState, commands) ->
|
||||||
|
|
@ -77,7 +77,7 @@ let mainAsync _ =
|
||||||
let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build()
|
let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build()
|
||||||
|
|
||||||
let stateLock = new SemaphoreSlim(1, 1)
|
let stateLock = new SemaphoreSlim(1, 1)
|
||||||
let mutable state = { Time = DateTime.Now }
|
let mutable state = NightLightStateMachine DateTime.Now
|
||||||
|
|
||||||
mqttClient.add_ApplicationMessageReceivedAsync (fun e ->
|
mqttClient.add_ApplicationMessageReceivedAsync (fun e ->
|
||||||
async {
|
async {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue