Merge pull request #1 from svenvanheugten/introduce-state
Introduce the ability to remember state
This commit is contained in:
commit
f6fdbddafd
12 changed files with 184 additions and 137 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
|
||||||
|
[*.{fs,fsi,fsx}]
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
trim_trailing_whitespace=true
|
||||||
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
module internal NightLight.Configuration
|
|
||||||
|
|
||||||
open NightLight.Moods
|
|
||||||
open NightLight.Lights
|
|
||||||
|
|
||||||
let getDesiredColorAndBrightness bulb mood =
|
|
||||||
let white = ColorByCoordinates(0.3227, 0.329)
|
|
||||||
let yellow = ColorByTemperature 454
|
|
||||||
let red = ColorByCoordinates(0.6942, 0.2963)
|
|
||||||
|
|
||||||
match bulb, mood with
|
|
||||||
| IkeaBulb, White -> white, Brightness 254
|
|
||||||
| IkeaBulb, Yellow -> yellow, Brightness 210
|
|
||||||
| IkeaBulb, Red -> red, Brightness 254
|
|
||||||
| PaulmannBulb, White -> white, Brightness 35
|
|
||||||
| PaulmannBulb, Yellow -> yellow, Brightness 35
|
|
||||||
| PaulmannBulb, Red -> red, Brightness 80
|
|
||||||
|
|
||||||
let lights =
|
|
||||||
[ { FriendlyName = "Vardagsrum - Fönsterlampa"
|
|
||||||
Room = LivingRoom
|
|
||||||
Bulb = IkeaBulb }
|
|
||||||
{ FriendlyName = "Vardagsrum - Vägglampa"
|
|
||||||
Room = LivingRoom
|
|
||||||
Bulb = PaulmannBulb }
|
|
||||||
{ FriendlyName = "Vardagsrum - Golvlampa"
|
|
||||||
Room = LivingRoom
|
|
||||||
Bulb = PaulmannBulb }
|
|
||||||
{ FriendlyName = "Badrum - Taklampa"
|
|
||||||
Room = Bathroom
|
|
||||||
Bulb = IkeaBulb }
|
|
||||||
{ FriendlyName = "Sovrum - Nattduksbordlampa"
|
|
||||||
Room = Bedroom
|
|
||||||
Bulb = IkeaBulb } ]
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
module NightLight.Core
|
module NightLight.Core.Core
|
||||||
|
|
||||||
open NightLight.PartsOfDay
|
open NightLight.Core.Models
|
||||||
open NightLight.ZigbeeEvents
|
open NightLight.Core.PartsOfDay
|
||||||
open NightLight.ZigbeeCommands
|
open NightLight.Core.ZigbeeEvents
|
||||||
open NightLight.Moods
|
open NightLight.Core.ZigbeeCommands
|
||||||
open NightLight.Lights
|
open NightLight.Core.Moods
|
||||||
open NightLight.Configuration
|
|
||||||
open FsToolkit.ErrorHandling
|
open FsToolkit.ErrorHandling
|
||||||
|
|
||||||
let internal tryFindLight friendlyName =
|
let internal tryFindLight friendlyName =
|
||||||
|
|
@ -17,19 +16,31 @@ let internal generateZigbeeCommandToFixLight partOfDay light =
|
||||||
|
|
||||||
generateZigbeeCommand light.FriendlyName color brightness
|
generateZigbeeCommand light.FriendlyName color brightness
|
||||||
|
|
||||||
let onZigbeeEventReceived (partOfDay: PartOfDay) (decodedPayload: string) =
|
let onEventReceived (state: State) (event: Event) : Result<State * Message seq, ParseEventError> =
|
||||||
result {
|
result {
|
||||||
let! zigbeeEvent = parseZigbeeEvent decodedPayload
|
let partOfDay = getPartOfDay state.Time
|
||||||
|
|
||||||
return
|
match event with
|
||||||
match zigbeeEvent with
|
| ReceivedZigbeeEvent payload ->
|
||||||
| DeviceAnnounce friendlyName ->
|
let! zigbeeEvent = parseZigbeeEvent payload |> Result.mapError ParseZigbeeEventError
|
||||||
let maybeLight = tryFindLight friendlyName
|
|
||||||
|
|
||||||
match maybeLight with
|
return
|
||||||
| Some light -> generateZigbeeCommandToFixLight partOfDay light |> Seq.singleton
|
state,
|
||||||
| None -> Seq.empty
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
let onPartOfDayChanged (partOfDay: PartOfDay) =
|
|
||||||
lights |> Seq.map (generateZigbeeCommandToFixLight partOfDay)
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
module internal NightLight.Lights
|
|
||||||
|
|
||||||
open NightLight.Moods
|
|
||||||
|
|
||||||
type Bulb =
|
|
||||||
| IkeaBulb
|
|
||||||
| PaulmannBulb
|
|
||||||
|
|
||||||
type Color =
|
|
||||||
| ColorByCoordinates of float * float
|
|
||||||
| ColorByTemperature of int
|
|
||||||
|
|
||||||
type Brightness = Brightness of int
|
|
||||||
|
|
||||||
type Light =
|
|
||||||
{ FriendlyName: string
|
|
||||||
Room: Room
|
|
||||||
Bulb: Bulb }
|
|
||||||
53
NightLight.Core/Models.fs
Normal file
53
NightLight.Core/Models.fs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
module NightLight.Core.Models
|
||||||
|
|
||||||
|
open System
|
||||||
|
|
||||||
|
type State = { Time: DateTime }
|
||||||
|
|
||||||
|
type Message = { Topic: string; Payload: string }
|
||||||
|
|
||||||
|
type Event =
|
||||||
|
| ReceivedZigbeeEvent of Message
|
||||||
|
| TimeChanged of DateTime
|
||||||
|
|
||||||
|
type ParseZigbeeEventError =
|
||||||
|
| InvalidJson
|
||||||
|
| MissingTypeField
|
||||||
|
| MissingDataField
|
||||||
|
| MissingFriendlyNameField
|
||||||
|
| InvalidTypeField
|
||||||
|
| InvalidFriendlyNameField
|
||||||
|
| UnknownType
|
||||||
|
|
||||||
|
type ParseEventError = ParseZigbeeEventError of ParseZigbeeEventError
|
||||||
|
|
||||||
|
type Room =
|
||||||
|
| Bathroom
|
||||||
|
| LivingRoom
|
||||||
|
| Bedroom
|
||||||
|
|
||||||
|
type Bulb =
|
||||||
|
| IkeaBulb
|
||||||
|
| PaulmannBulb
|
||||||
|
|
||||||
|
type Light =
|
||||||
|
{ FriendlyName: string
|
||||||
|
Room: Room
|
||||||
|
Bulb: Bulb }
|
||||||
|
|
||||||
|
let lights =
|
||||||
|
[ { FriendlyName = "Vardagsrum - Fönsterlampa"
|
||||||
|
Room = LivingRoom
|
||||||
|
Bulb = IkeaBulb }
|
||||||
|
{ FriendlyName = "Vardagsrum - Vägglampa"
|
||||||
|
Room = LivingRoom
|
||||||
|
Bulb = PaulmannBulb }
|
||||||
|
{ FriendlyName = "Vardagsrum - Golvlampa"
|
||||||
|
Room = LivingRoom
|
||||||
|
Bulb = PaulmannBulb }
|
||||||
|
{ FriendlyName = "Badrum - Taklampa"
|
||||||
|
Room = Bathroom
|
||||||
|
Bulb = IkeaBulb }
|
||||||
|
{ FriendlyName = "Sovrum - Nattduksbordlampa"
|
||||||
|
Room = Bedroom
|
||||||
|
Bulb = IkeaBulb } ]
|
||||||
|
|
@ -1,20 +1,35 @@
|
||||||
module internal NightLight.Moods
|
module internal NightLight.Core.Moods
|
||||||
|
|
||||||
open NightLight.PartsOfDay
|
open NightLight.Core.PartsOfDay
|
||||||
|
open NightLight.Core.Models
|
||||||
|
|
||||||
type Mood =
|
type Mood =
|
||||||
| White
|
| White
|
||||||
| Yellow
|
| Yellow
|
||||||
| Red
|
| Red
|
||||||
|
|
||||||
type Room =
|
|
||||||
| Bathroom
|
|
||||||
| LivingRoom
|
|
||||||
| Bedroom
|
|
||||||
|
|
||||||
let getDesiredMood room partOfDay =
|
let getDesiredMood room partOfDay =
|
||||||
match room, partOfDay with
|
match room, partOfDay with
|
||||||
| Bathroom, Day -> White
|
| Bathroom, Day -> White
|
||||||
| LivingRoom, Day -> Yellow
|
| LivingRoom, Day -> Yellow
|
||||||
| Bedroom, Day -> Yellow
|
| Bedroom, Day -> Yellow
|
||||||
| _, Night -> Red
|
| _, Night -> Red
|
||||||
|
|
||||||
|
type Color =
|
||||||
|
| ColorByCoordinates of float * float
|
||||||
|
| ColorByTemperature of int
|
||||||
|
|
||||||
|
type Brightness = Brightness of int
|
||||||
|
|
||||||
|
let getDesiredColorAndBrightness bulb mood =
|
||||||
|
let white = ColorByCoordinates(0.3227, 0.329)
|
||||||
|
let yellow = ColorByTemperature 454
|
||||||
|
let red = ColorByCoordinates(0.6942, 0.2963)
|
||||||
|
|
||||||
|
match bulb, mood with
|
||||||
|
| IkeaBulb, White -> white, Brightness 254
|
||||||
|
| IkeaBulb, Yellow -> yellow, Brightness 210
|
||||||
|
| IkeaBulb, Red -> red, Brightness 254
|
||||||
|
| PaulmannBulb, White -> white, Brightness 35
|
||||||
|
| PaulmannBulb, Yellow -> yellow, Brightness 35
|
||||||
|
| PaulmannBulb, Red -> red, Brightness 80
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="Models.fs" />
|
||||||
<Compile Include="PartsOfDay.fs" />
|
<Compile Include="PartsOfDay.fs" />
|
||||||
<Compile Include="Moods.fs" />
|
<Compile Include="Moods.fs" />
|
||||||
<Compile Include="Lights.fs" />
|
|
||||||
<Compile Include="ZigbeeEvents.fs" />
|
<Compile Include="ZigbeeEvents.fs" />
|
||||||
<Compile Include="ZigbeeCommands.fs" />
|
<Compile Include="ZigbeeCommands.fs" />
|
||||||
<Compile Include="Configuration.fs" />
|
|
||||||
<Compile Include="Core.fs" />
|
<Compile Include="Core.fs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
module NightLight.PartsOfDay
|
module internal NightLight.Core.PartsOfDay
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
|
||||||
|
|
@ -8,5 +8,9 @@ type PartOfDay =
|
||||||
|
|
||||||
let getPartOfDay (dateTime: DateTime) =
|
let getPartOfDay (dateTime: DateTime) =
|
||||||
match dateTime with
|
match dateTime with
|
||||||
| _ when dateTime.TimeOfDay >= TimeSpan.FromHours 4.75 && dateTime.TimeOfDay < TimeSpan.FromHours 20.5 -> Day
|
| _ when
|
||||||
|
dateTime.TimeOfDay >= TimeSpan.FromHours 5.5
|
||||||
|
&& dateTime.TimeOfDay < TimeSpan.FromHours 20.5
|
||||||
|
->
|
||||||
|
Day
|
||||||
| _ -> Night
|
| _ -> Night
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
module NightLight.ZigbeeCommands
|
module internal NightLight.Core.ZigbeeCommands
|
||||||
|
|
||||||
open System.Text.Json.Nodes
|
open System.Text.Json.Nodes
|
||||||
open NightLight.Lights
|
open NightLight.Core.Models
|
||||||
|
open NightLight.Core.Moods
|
||||||
|
|
||||||
type ZigbeeCommand = ZigbeeCommand of Topic: string * Payload: string
|
let generateZigbeeCommand friendlyName targetColor targetBrightness =
|
||||||
|
|
||||||
let internal generateZigbeeCommand friendlyName targetColor targetBrightness =
|
|
||||||
let commandObj = JsonObject()
|
let commandObj = JsonObject()
|
||||||
|
|
||||||
match targetColor with
|
match targetColor with
|
||||||
|
|
@ -23,4 +22,4 @@ let internal generateZigbeeCommand friendlyName targetColor targetBrightness =
|
||||||
let topic = $"zigbee2mqtt/{friendlyName}/set"
|
let topic = $"zigbee2mqtt/{friendlyName}/set"
|
||||||
let payload = commandObj.ToJsonString()
|
let payload = commandObj.ToJsonString()
|
||||||
|
|
||||||
ZigbeeCommand(topic, payload)
|
{ Topic = topic; Payload = payload }
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
module NightLight.ZigbeeEvents
|
module internal NightLight.Core.ZigbeeEvents
|
||||||
|
|
||||||
|
open NightLight.Core.Models
|
||||||
open FsToolkit.ErrorHandling
|
open FsToolkit.ErrorHandling
|
||||||
open FSharp.Data
|
open FSharp.Data
|
||||||
|
|
||||||
type ZigbeeEvent = DeviceAnnounce of FriendlyName: string
|
type ZigbeeEvent = DeviceAnnounce of FriendlyName: string
|
||||||
|
|
||||||
type ParseZigbeeEventError =
|
let parseZigbeeEvent (message: Message) =
|
||||||
| InvalidJson
|
|
||||||
| MissingTypeField
|
|
||||||
| MissingDataField
|
|
||||||
| MissingFriendlyNameField
|
|
||||||
| InvalidTypeField
|
|
||||||
| InvalidFriendlyNameField
|
|
||||||
| UnknownType
|
|
||||||
|
|
||||||
let internal parseZigbeeEvent str =
|
|
||||||
result {
|
result {
|
||||||
let! jsonValue = JsonValue.TryParse str |> Result.requireSome InvalidJson
|
let! jsonValue = JsonValue.TryParse message.Payload |> Result.requireSome InvalidJson
|
||||||
|
|
||||||
let! messageType = jsonValue.TryGetProperty "type" |> Result.requireSome MissingTypeField
|
let! messageType = jsonValue.TryGetProperty "type" |> Result.requireSome MissingTypeField
|
||||||
let! messageData = jsonValue.TryGetProperty "data" |> Result.requireSome MissingDataField
|
let! messageData = jsonValue.TryGetProperty "data" |> Result.requireSome MissingDataField
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,58 @@
|
||||||
open System
|
open System
|
||||||
open System.Text
|
open System.Text
|
||||||
|
open System.Threading
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
open MQTTnet
|
open MQTTnet
|
||||||
open MQTTnet.Protocol
|
open MQTTnet.Protocol
|
||||||
open NightLight.PartsOfDay
|
open NightLight.Core.Models
|
||||||
open NightLight.ZigbeeEvents
|
open NightLight.Core.Core
|
||||||
open NightLight.ZigbeeCommands
|
|
||||||
open NightLight.Core
|
|
||||||
|
|
||||||
let private generateMqttMessage zigbeeCommand =
|
let private generateMqttMessage zigbeeCommand =
|
||||||
match zigbeeCommand with
|
MqttApplicationMessageBuilder()
|
||||||
| ZigbeeCommand(topic, payload) ->
|
.WithTopic(zigbeeCommand.Topic)
|
||||||
MqttApplicationMessageBuilder()
|
.WithPayload(zigbeeCommand.Payload)
|
||||||
.WithTopic(topic)
|
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
.WithPayload(payload)
|
.Build()
|
||||||
.WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce)
|
|
||||||
.Build()
|
|
||||||
|
|
||||||
let private publishZigbeeCommands (mqttClient: IMqttClient) (logger: ILogger) (commands: ZigbeeCommand seq) =
|
let private publishZigbeeCommands (mqttClient: IMqttClient) (logger: ILogger) (commands: Message seq) =
|
||||||
async {
|
async {
|
||||||
commands
|
commands
|
||||||
|> Seq.iter (fun command ->
|
|> Seq.iter (fun command ->
|
||||||
match command with
|
logger.LogInformation("Publishing message {Payload} to topic {Topic}...", command.Payload, command.Topic))
|
||||||
| ZigbeeCommand(topic, payload) ->
|
|
||||||
logger.LogInformation("Publishing message {Payload} to topic {Topic}...", payload, topic))
|
|
||||||
|
|
||||||
return!
|
return!
|
||||||
commands
|
commands
|
||||||
|> Seq.map generateMqttMessage
|
|> Seq.map generateMqttMessage
|
||||||
|> Seq.map mqttClient.PublishAsync
|
|> Seq.map (fun message -> async { return! mqttClient.PublishAsync message |> Async.AwaitTask })
|
||||||
|> Seq.map Async.AwaitTask
|
|
||||||
|> Async.Sequential
|
|> Async.Sequential
|
||||||
|> Async.Ignore
|
|> Async.Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
let private onMqttMessageReceived (mqttClient: IMqttClient) (logger: ILogger) (message: MqttApplicationMessage) =
|
let private handleEvent (mqttClient: IMqttClient) (logger: ILogger) (state: State) (event: Event) =
|
||||||
|
match event with
|
||||||
|
| ReceivedZigbeeEvent payload -> logger.LogInformation("Received message with payload {Payload}", payload)
|
||||||
|
| _ -> ()
|
||||||
|
|
||||||
|
let result = event |> onEventReceived state
|
||||||
|
|
||||||
|
match result with
|
||||||
|
| Ok(newState, commands) ->
|
||||||
|
async {
|
||||||
|
do! publishZigbeeCommands mqttClient logger commands
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
| Error(ParseZigbeeEventError UnknownType) -> async.Return state
|
||||||
|
| Error e ->
|
||||||
|
logger.LogError("Error {Error} while {Event}", e, event)
|
||||||
|
async.Return state
|
||||||
|
|
||||||
|
let private decodeMqttApplicationMessage (message: MqttApplicationMessage) =
|
||||||
let payload = message.Payload
|
let payload = message.Payload
|
||||||
let decodedPayload = Encoding.UTF8.GetString(&payload)
|
let decodedPayload = Encoding.UTF8.GetString(&payload)
|
||||||
|
|
||||||
logger.LogInformation("Received message with payload {Payload}", decodedPayload)
|
{ Topic = message.Topic
|
||||||
|
Payload = decodedPayload }
|
||||||
let commandsResult =
|
|
||||||
decodedPayload |> onZigbeeEventReceived (getPartOfDay DateTime.Now)
|
|
||||||
|
|
||||||
match commandsResult with
|
|
||||||
| Ok commands -> publishZigbeeCommands mqttClient logger commands
|
|
||||||
| Error UnknownType -> async.Return()
|
|
||||||
| Error e ->
|
|
||||||
logger.LogError("Error {Error} while processing {Payload}", e, payload)
|
|
||||||
async.Return()
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
[<EntryPoint>]
|
||||||
let mainAsync _ =
|
let mainAsync _ =
|
||||||
|
|
@ -73,8 +76,21 @@ let mainAsync _ =
|
||||||
|
|
||||||
let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build()
|
let mqttClientOptions = MqttClientOptionsBuilder().WithTcpServer(server).Build()
|
||||||
|
|
||||||
|
let stateLock = new SemaphoreSlim(1, 1)
|
||||||
|
let mutable state = { Time = DateTime.Now }
|
||||||
|
|
||||||
mqttClient.add_ApplicationMessageReceivedAsync (fun e ->
|
mqttClient.add_ApplicationMessageReceivedAsync (fun e ->
|
||||||
onMqttMessageReceived mqttClient logger e.ApplicationMessage
|
async {
|
||||||
|
let event = ReceivedZigbeeEvent <| decodeMqttApplicationMessage e.ApplicationMessage
|
||||||
|
|
||||||
|
do! stateLock.WaitAsync() |> Async.AwaitTask
|
||||||
|
|
||||||
|
try
|
||||||
|
let! newState = event |> handleEvent mqttClient logger state
|
||||||
|
state <- newState
|
||||||
|
finally
|
||||||
|
stateLock.Release() |> ignore
|
||||||
|
}
|
||||||
|> Async.StartAsTask
|
|> Async.StartAsTask
|
||||||
:> Task)
|
:> Task)
|
||||||
|
|
||||||
|
|
@ -85,14 +101,14 @@ let mainAsync _ =
|
||||||
|> Async.AwaitTask
|
|> Async.AwaitTask
|
||||||
|> Async.Ignore
|
|> Async.Ignore
|
||||||
|
|
||||||
let mutable previousPartOfDay: PartOfDay option = None
|
|
||||||
|
|
||||||
while true do
|
while true do
|
||||||
let currentPartOfDay = getPartOfDay DateTime.Now
|
do! stateLock.WaitAsync() |> Async.AwaitTask
|
||||||
|
|
||||||
if previousPartOfDay <> Some currentPartOfDay then
|
try
|
||||||
do! onPartOfDayChanged currentPartOfDay |> publishZigbeeCommands mqttClient logger
|
let! newState = TimeChanged DateTime.Now |> handleEvent mqttClient logger state
|
||||||
previousPartOfDay <- Some currentPartOfDay
|
state <- newState
|
||||||
|
finally
|
||||||
|
stateLock.Release() |> ignore
|
||||||
|
|
||||||
do! Async.Sleep 10_000
|
do! Async.Sleep 10_000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
This is an F# program that turns all the lights in our apartment
|
This is an F# program that turns all the lights in our apartment
|
||||||
|
|
||||||
* _red_ at 8.30pm in the evening, and
|
* _red_ at 8.30pm in the evening, and
|
||||||
* _white_/_yellow_ (depending on the room) at 4.45am in the morning.
|
* _white_/_yellow_ (depending on the room) at 5.30am in the morning.
|
||||||
|
|
||||||
`NightLight.Core` is the functional core, and `NightLight` is the imperative shell.
|
`NightLight.Core` is the functional core, and `NightLight` is the imperative shell.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue