From 18453826dd1d19446d4254e37714a01250b4f15e Mon Sep 17 00:00:00 2001 From: Sergey Date: Wed, 26 Jun 2024 19:24:26 +0200 Subject: [PATCH 1/2] Update main.yml Bumped .net version 6 --> 8 in the github workflows --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 435e387..68ea2fb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,7 +17,7 @@ jobs: - name: Setup dotnet core uses: actions/setup-dotnet@v2 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore -s https://api.nuget.org/v3/index.json - name: Build From 64c86739ca5af26348b467ca7d80e9ce793e033e Mon Sep 17 00:00:00 2001 From: Sergey Brutsky Date: Thu, 27 Jun 2024 12:33:49 +0300 Subject: [PATCH 2/2] Added support for Xiaomi Gateway 3 and several sensors --- LICENSE.md | 2 +- Makefile | 2 +- MiHomeConsole/Program.cs | 33 +- .../AsyncBleEventMethodProcessor.cs | 42 ++ .../ActionProcessors/IActionProcessor.cs | 8 + .../ZigbeeHeartBeatCommandProcessor.cs | 45 ++ .../ZigbeeReportCommandProcessor.cs | 34 ++ MiHomeLib/Devices/MiHomeDevice.cs | 6 + MiHomeLib/Devices/Switch.cs | 4 +- MiHomeLib/DevicesV3/AqaraDoorWindowSensor.cs | 12 + MiHomeLib/DevicesV3/AqaraOneChannelRelayEu.cs | 125 +++++ .../AqaraOppleFourButtonsWirelesSwitch.cs | 33 ++ .../AqaraOppleTwoButtonsWirelesSwitch.cs | 35 ++ .../DevicesV3/AqaraOppleWirelesSwitch.cs | 16 + MiHomeLib/DevicesV3/AqaraThSensor.cs | 45 ++ MiHomeLib/DevicesV3/AqaraTwoChannelsRelay.cs | 179 +++++++ MiHomeLib/DevicesV3/AqaraWaterLeakSensor.cs | 26 + MiHomeLib/DevicesV3/BleBatteryDevice.cs | 27 + MiHomeLib/DevicesV3/BleDevice.cs | 38 ++ MiHomeLib/DevicesV3/HoneywellSmokeAlarm.cs | 44 ++ MiHomeLib/DevicesV3/HoneywellSmokeSensor.cs | 66 +++ MiHomeLib/DevicesV3/MiThMonitor2.cs | 47 ++ MiHomeLib/DevicesV3/MiWirelesSwitch.cs | 40 ++ MiHomeLib/DevicesV3/XiaomiDoorWindowSensor.cs | 36 ++ .../DevicesV3/XiaomiDoorWindowSensor2.cs | 63 +++ .../DevicesV3/XiaomiGateway3SubDevice.cs | 14 + MiHomeLib/DevicesV3/XiaomiMotionSensor.cs | 62 +++ MiHomeLib/DevicesV3/XiaomiMotionSensor2.cs | 67 +++ MiHomeLib/DevicesV3/XiaomiPlugCN.cs | 108 ++++ MiHomeLib/DevicesV3/XiaomiThSensor.cs | 59 +++ MiHomeLib/DevicesV3/ZigBeeBatteryDevice.cs | 52 ++ MiHomeLib/DevicesV3/ZigBeeDevice.cs | 78 +++ MiHomeLib/DevicesV3/ZigBeeManageableDevice.cs | 63 +++ .../Exceptions/ModelNotSupportedException.cs | 29 +- .../JsonResponses/BleAsyncEventResponse.cs | 32 ++ MiHomeLib/JsonResponses/BleResponse.cs | 17 + .../JsonResponses/GetDeviceListResponse.cs | 19 + .../JsonResponses/GetDevicePropResponse.cs | 16 + MiHomeLib/JsonResponses/MiioResponse.cs | 19 + .../JsonResponses/ZigbeeHearbeatResponse.cs | 26 + .../JsonResponses/ZigbeeReportResponse.cs | 29 ++ MiHomeLib/MiHome.cs | 394 +++++++------- MiHomeLib/MiHomeLib.csproj | 18 +- MiHomeLib/Miio/AirHumidifier.cs | 243 --------- MiHomeLib/Miio/MiRobotV1.cs | 183 ------- MiHomeLib/Miio/MiioDevice.cs | 90 ---- MiHomeLib/Miio/MiioGateway.cs | 402 --------------- MiHomeLib/Miio/MiioPacket.cs | 60 --- MiHomeLib/Miio/RadioChannel.cs | 30 -- MiHomeLib/MiioDevices/AirHumidifier.cs | 243 +++++++++ MiHomeLib/MiioDevices/MiRobotV1.cs | 184 +++++++ MiHomeLib/MiioDevices/MiioDevice.cs | 86 +++ MiHomeLib/MiioDevices/MiioGateway.cs | 399 ++++++++++++++ MiHomeLib/MiioDevices/MiioPacket.cs | 58 +++ MiHomeLib/MiioDevices/RadioChannel.cs | 29 ++ MiHomeLib/Transport/IDevicesDiscoverer.cs | 9 + MiHomeLib/Transport/IMiioTransport.cs | 16 +- MiHomeLib/Transport/IMqttTransport.cs | 9 + MiHomeLib/Transport/MiioTransport.cs | 55 +- MiHomeLib/Transport/MqttDotNetTransport.cs | 55 ++ .../Transport/TelnetDevicesDiscoverer.cs | 111 ++++ MiHomeLib/Utils/Helpers.cs | 201 ++++---- MiHomeLib/Utils/ITimer.cs | 10 + MiHomeLib/Utils/NetworkHelpers.cs | 68 +++ MiHomeLib/Utils/SimpleTimer.cs | 21 + MiHomeLib/XiaomiGateway2.cs | 7 + MiHomeLib/XiaomiGateway3.cs | 222 ++++++++ .../Devices/DoorWindowSensorTests.cs | 2 +- MiHomeUnitTests/Devices/GatewayTests.cs | 2 +- MiHomeUnitTests/Devices/MotionSensorTests.cs | 4 +- MiHomeUnitTests/Devices/SmokeSensorTests.cs | 2 +- MiHomeUnitTests/Devices/SockerPlugTests.cs | 2 +- MiHomeUnitTests/Devices/SwitchTests.cs | 13 +- MiHomeUnitTests/Devices/ThSensorTests.cs | 2 +- .../Devices/WaterLeakSensorTests.cs | 2 +- .../DevicesV3/AqaraDoorWindowSensorTests.cs | 35 ++ .../DevicesV3/AqaraOneChannelRelayEuTests.cs | 206 ++++++++ ...AqaraOppleFourButtonsWirelesSwitchTests.cs | 52 ++ .../AqaraOppleTwoButtonsWirelesSwitchTests.cs | 52 ++ .../DevicesV3/AqaraThSensorTests.cs | 78 +++ .../DevicesV3/AqaraTwoChannelsRelayTests.cs | 268 ++++++++++ .../DevicesV3/AqaraWaterLeakSensorTests.cs | 34 ++ .../DevicesV3/BleBatteryDeviceTests.cs | 37 ++ .../DevicesV3/HoneywellSmokeAlarmTests.cs | 36 ++ .../DevicesV3/HoneywellSmokeSensorTests.cs | 114 ++++ .../DevicesV3/MiHome3DeviceTests.cs | 49 ++ .../DevicesV3/MiThMonitor2Tests.cs | 59 +++ .../DevicesV3/MiWirelessSwitchTests.cs | 33 ++ .../DevicesV3/XiaomiDoorWindowSensor2Tests.cs | 64 +++ .../DevicesV3/XiaomiDoorWindowSensorTests.cs | 35 ++ .../DevicesV3/XiaomiMotionSensor2Tests.cs | 78 +++ .../DevicesV3/XiaomiMotionSensorTests.cs | 96 ++++ .../DevicesV3/XiaomiPlugCnTests.cs | 160 ++++++ .../DevicesV3/XiaomiThSensorTests.cs | 54 ++ .../DevicesV3/ZigbeeDeviceTests.cs | 110 ++++ MiHomeUnitTests/MiHomeUnitTests.csproj | 11 +- MiHomeUnitTests/Miio/AirHumidifierTests.cs | 17 +- MiHomeUnitTests/Miio/MiRobotV1Tests.cs | 2 +- MiHomeUnitTests/Miio/MiioDeviceTest.cs | 8 +- MiHomeUnitTests/Miio/MiioGatewayTests.cs | 73 +-- MiHomeUnitTests/Miio/MiioPacketTests.cs | 2 +- MiHomeUnitTests/XiaomiGateway3Tests.cs | 451 ++++++++++++++++ README.md | 488 +++--------------- 103 files changed, 5757 insertions(+), 1875 deletions(-) create mode 100644 MiHomeLib/ActionProcessors/AsyncBleEventMethodProcessor.cs create mode 100644 MiHomeLib/ActionProcessors/IActionProcessor.cs create mode 100644 MiHomeLib/ActionProcessors/ZigbeeHeartBeatCommandProcessor.cs create mode 100644 MiHomeLib/ActionProcessors/ZigbeeReportCommandProcessor.cs create mode 100644 MiHomeLib/DevicesV3/AqaraDoorWindowSensor.cs create mode 100644 MiHomeLib/DevicesV3/AqaraOneChannelRelayEu.cs create mode 100644 MiHomeLib/DevicesV3/AqaraOppleFourButtonsWirelesSwitch.cs create mode 100644 MiHomeLib/DevicesV3/AqaraOppleTwoButtonsWirelesSwitch.cs create mode 100644 MiHomeLib/DevicesV3/AqaraOppleWirelesSwitch.cs create mode 100644 MiHomeLib/DevicesV3/AqaraThSensor.cs create mode 100644 MiHomeLib/DevicesV3/AqaraTwoChannelsRelay.cs create mode 100644 MiHomeLib/DevicesV3/AqaraWaterLeakSensor.cs create mode 100644 MiHomeLib/DevicesV3/BleBatteryDevice.cs create mode 100644 MiHomeLib/DevicesV3/BleDevice.cs create mode 100644 MiHomeLib/DevicesV3/HoneywellSmokeAlarm.cs create mode 100644 MiHomeLib/DevicesV3/HoneywellSmokeSensor.cs create mode 100644 MiHomeLib/DevicesV3/MiThMonitor2.cs create mode 100644 MiHomeLib/DevicesV3/MiWirelesSwitch.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiDoorWindowSensor.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiDoorWindowSensor2.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiGateway3SubDevice.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiMotionSensor.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiMotionSensor2.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiPlugCN.cs create mode 100644 MiHomeLib/DevicesV3/XiaomiThSensor.cs create mode 100644 MiHomeLib/DevicesV3/ZigBeeBatteryDevice.cs create mode 100644 MiHomeLib/DevicesV3/ZigBeeDevice.cs create mode 100644 MiHomeLib/DevicesV3/ZigBeeManageableDevice.cs create mode 100644 MiHomeLib/JsonResponses/BleAsyncEventResponse.cs create mode 100644 MiHomeLib/JsonResponses/BleResponse.cs create mode 100644 MiHomeLib/JsonResponses/GetDeviceListResponse.cs create mode 100644 MiHomeLib/JsonResponses/GetDevicePropResponse.cs create mode 100644 MiHomeLib/JsonResponses/MiioResponse.cs create mode 100644 MiHomeLib/JsonResponses/ZigbeeHearbeatResponse.cs create mode 100644 MiHomeLib/JsonResponses/ZigbeeReportResponse.cs delete mode 100644 MiHomeLib/Miio/AirHumidifier.cs delete mode 100644 MiHomeLib/Miio/MiRobotV1.cs delete mode 100644 MiHomeLib/Miio/MiioDevice.cs delete mode 100644 MiHomeLib/Miio/MiioGateway.cs delete mode 100644 MiHomeLib/Miio/MiioPacket.cs delete mode 100644 MiHomeLib/Miio/RadioChannel.cs create mode 100644 MiHomeLib/MiioDevices/AirHumidifier.cs create mode 100644 MiHomeLib/MiioDevices/MiRobotV1.cs create mode 100644 MiHomeLib/MiioDevices/MiioDevice.cs create mode 100644 MiHomeLib/MiioDevices/MiioGateway.cs create mode 100644 MiHomeLib/MiioDevices/MiioPacket.cs create mode 100644 MiHomeLib/MiioDevices/RadioChannel.cs create mode 100644 MiHomeLib/Transport/IDevicesDiscoverer.cs create mode 100644 MiHomeLib/Transport/IMqttTransport.cs create mode 100644 MiHomeLib/Transport/MqttDotNetTransport.cs create mode 100644 MiHomeLib/Transport/TelnetDevicesDiscoverer.cs create mode 100644 MiHomeLib/Utils/ITimer.cs create mode 100644 MiHomeLib/Utils/NetworkHelpers.cs create mode 100644 MiHomeLib/Utils/SimpleTimer.cs create mode 100644 MiHomeLib/XiaomiGateway2.cs create mode 100644 MiHomeLib/XiaomiGateway3.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraDoorWindowSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraOneChannelRelayEuTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraOppleFourButtonsWirelesSwitchTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraOppleTwoButtonsWirelesSwitchTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraThSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraTwoChannelsRelayTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/AqaraWaterLeakSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/BleBatteryDeviceTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/HoneywellSmokeAlarmTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/HoneywellSmokeSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/MiHome3DeviceTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/MiThMonitor2Tests.cs create mode 100644 MiHomeUnitTests/DevicesV3/MiWirelessSwitchTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensor2Tests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiMotionSensor2Tests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiMotionSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiPlugCnTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/XiaomiThSensorTests.cs create mode 100644 MiHomeUnitTests/DevicesV3/ZigbeeDeviceTests.cs create mode 100644 MiHomeUnitTests/XiaomiGateway3Tests.cs diff --git a/LICENSE.md b/LICENSE.md index 37b2ab5..716b470 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Sergey Brutsky +Copyright (c) 2017-2024 Sergey Brutsky Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index ebe3998..4e4949d 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ build: dotnet build MiHomeLib -c Debug run: - dotnet run -p MiHomeConsole + dotnet run --project MiHomeConsole test: dotnet test MiHomeUnitTests diff --git a/MiHomeConsole/Program.cs b/MiHomeConsole/Program.cs index 4b6dd9d..65f1369 100644 --- a/MiHomeConsole/Program.cs +++ b/MiHomeConsole/Program.cs @@ -1,34 +1,21 @@ using System; using MiHomeLib; -namespace MiHomeConsole +namespace MiHomeConsole; +public class Program { - public class Program + public static void Main() { - public static void Main(string[] args) + using var gw3 = new XiaomiGateway3("", ""); { - //Action loggingBuilder = - // builder => builder.AddConsole(x => - // { - // x.DisableColors = true; - // x.Format = ConsoleLoggerFormat.Systemd; - // x.TimestampFormat = " yyyy-MM-d [HH:mm:ss] - "; - // }); - - //MiHome.LoggerFactory = LoggerFactory.Create(loggingBuilder); - //MiHome.LogRawCommands = true; - - // pwd of your gateway (optional, needed only to send commands to your devices) - // and sid of your gateway (optional, use only when you have 2 gateways in your LAN) - //using var miHome = new MiHome("pwd", "sid") - using var miHome = new MiHome(); - - miHome.OnAnyDevice += (_, device) => + gw3.OnDeviceDiscovered += gw3SubDevice => { - Console.WriteLine($"{device.Sid}, {device.GetType()}, {device}"); // all discovered devices + Console.WriteLine(gw3SubDevice.ToString()); }; - Console.ReadLine(); + gw3.DiscoverDevices(); } + + Console.ReadLine(); } -} +} \ No newline at end of file diff --git a/MiHomeLib/ActionProcessors/AsyncBleEventMethodProcessor.cs b/MiHomeLib/ActionProcessors/AsyncBleEventMethodProcessor.cs new file mode 100644 index 0000000..1437832 --- /dev/null +++ b/MiHomeLib/ActionProcessors/AsyncBleEventMethodProcessor.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.DevicesV3; + +namespace MiHomeLib.ActionProcessors; + +public class AsyncBleEventMethodProcessor(Dictionary devices, ILoggerFactory loggerFactory) : IActionProcessor +{ + public const string ACTION = "_async.ble_event"; + private readonly Dictionary _devices = devices; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public void ProcessMessage(JsonNode json) + { + if (!json.AsObject().ContainsKey("params") || !json["params"].AsObject().ContainsKey("dev")) + { + _logger.LogWarning($"Json string --> '{json}' is not valid for ble parsing"); + return; + } + + var parms = json["params"].AsObject(); + var dev = parms["dev"].AsObject(); + + if (!dev.ContainsKey("did")) + { + _logger.LogWarning($"json --> {json} has no 'did' property. Futher processing is impossible"); + return; + } + + var did = dev["did"].ToString(); + + if (!_devices.ContainsKey(did)) + { + _logger.LogWarning($"Device with did '{did}' is unknown. Processing is skipped"); + return; + } + + _devices[did].LastTimeMessageReceived = parms["gwts"].GetValue().UnixSecondsToDateTime(); + _devices[did].ParseData(parms.ToString()); + } +} diff --git a/MiHomeLib/ActionProcessors/IActionProcessor.cs b/MiHomeLib/ActionProcessors/IActionProcessor.cs new file mode 100644 index 0000000..3baf236 --- /dev/null +++ b/MiHomeLib/ActionProcessors/IActionProcessor.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Nodes; + +namespace MiHomeLib.ActionProcessors; + +public interface IActionProcessor +{ + void ProcessMessage(JsonNode json); +} diff --git a/MiHomeLib/ActionProcessors/ZigbeeHeartBeatCommandProcessor.cs b/MiHomeLib/ActionProcessors/ZigbeeHeartBeatCommandProcessor.cs new file mode 100644 index 0000000..4d7ce00 --- /dev/null +++ b/MiHomeLib/ActionProcessors/ZigbeeHeartBeatCommandProcessor.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.DevicesV3; + +namespace MiHomeLib.ActionProcessors; + +public class ZigbeeHeartBeatCommandProcessor(Dictionary devices, ILoggerFactory loggerFactory) : IActionProcessor +{ + public const string ACTION = "heartbeat"; + private readonly Dictionary _devices = devices; + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public void ProcessMessage(JsonNode json) + { + var data = json["params"].Deserialize>>(); + + if (data.Count != 1) + { + _logger.LogWarning($"Wrong structure of heartbeat message --> '{data}'." + + "Processing of such structure is not supported"); + return; + } + + if (!data[0].ContainsKey("did")) + { + _logger.LogWarning("Heartbeat message doesn't contain 'did'." + + "Processing of such structure is not supported"); + return; + } + + var did = data[0]["did"].GetString(); + + if (_devices.ContainsKey(did)) + { + _devices[did].LastTimeMessageReceived = data[0]["time"].GetDouble().UnixMilliSecondsToDateTime(); + (_devices[did] as ZigBeeDevice).ParseData(data[0]["res_list"].ToString()); + } + else + { + _logger.LogWarning($"Did '{did}' is unknown. Cannot process '{ACTION}' command for this device."); + } + } +} diff --git a/MiHomeLib/ActionProcessors/ZigbeeReportCommandProcessor.cs b/MiHomeLib/ActionProcessors/ZigbeeReportCommandProcessor.cs new file mode 100644 index 0000000..636f4a6 --- /dev/null +++ b/MiHomeLib/ActionProcessors/ZigbeeReportCommandProcessor.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.DevicesV3; + +namespace MiHomeLib.ActionProcessors; + +public class ZigbeeReportCommandProcessor: IActionProcessor +{ + public const string ACTION = "report"; + private readonly Dictionary _devices; + private readonly ILogger _logger; + + public ZigbeeReportCommandProcessor(Dictionary devices, ILoggerFactory loggerFactory) + { + _devices = devices; + _logger = loggerFactory.CreateLogger(GetType()); + } + + public void ProcessMessage(JsonNode json) + { + var did = json["did"].ToString(); + + if (_devices.ContainsKey(did)) + { + _devices[did].LastTimeMessageReceived = json["time"].GetValue().UnixMilliSecondsToDateTime(); + (_devices[did] as ZigBeeDevice).ParseData((json["mi_spec"] is not null ? json["mi_spec"] : json["params"]).ToString()); + } + else + { + _logger.LogWarning($"Did '{did}' is unknown. Cannot process '{ACTION}' command for this device."); + } + } +} diff --git a/MiHomeLib/Devices/MiHomeDevice.cs b/MiHomeLib/Devices/MiHomeDevice.cs index bb33c65..49ca88c 100644 --- a/MiHomeLib/Devices/MiHomeDevice.cs +++ b/MiHomeLib/Devices/MiHomeDevice.cs @@ -3,6 +3,7 @@ public abstract class MiHomeDevice { public string Sid { get; } + public string Did { get; } public string Name { get; set; } public string Type { get; } @@ -12,6 +13,11 @@ protected MiHomeDevice(string sid, string type) Type = type; } + protected MiHomeDevice(string did) + { + Did = did; + } + public abstract void ParseData(string command); public override string ToString() diff --git a/MiHomeLib/Devices/Switch.cs b/MiHomeLib/Devices/Switch.cs index 5a03874..e287392 100644 --- a/MiHomeLib/Devices/Switch.cs +++ b/MiHomeLib/Devices/Switch.cs @@ -3,7 +3,7 @@ namespace MiHomeLib.Devices { - public class Switch : MiHomeDevice + public class Switch(string sid) : MiHomeDevice(sid, TypeKey) { public const string TypeKey = "switch"; @@ -13,8 +13,6 @@ public class Switch : MiHomeDevice public event EventHandler OnLongPress; - public Switch(string sid) : base(sid, TypeKey) {} - public float? Voltage { get; set; } public string Status { get; private set; } diff --git a/MiHomeLib/DevicesV3/AqaraDoorWindowSensor.cs b/MiHomeLib/DevicesV3/AqaraDoorWindowSensor.cs new file mode 100644 index 0000000..fdf98e2 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraDoorWindowSensor.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +// This sensor works exactly as XiaomiDoorWindowSensor, no need to repeat all stuff here +public class AqaraDoorWindowSensor(string did, ILoggerFactory loggerFactory) + : XiaomiDoorWindowSensor(did, loggerFactory) +{ + public new const string MARKET_MODEL = "MCCGQ11LM"; + public new const string MODEL = "lumi.sensor_magnet.aq2"; + public override string ToString() => GetBaseInfo(MARKET_MODEL, MODEL) + $"Contact: {Contact}, " + GetBaseToString(); +} diff --git a/MiHomeLib/DevicesV3/AqaraOneChannelRelayEu.cs b/MiHomeLib/DevicesV3/AqaraOneChannelRelayEu.cs new file mode 100644 index 0000000..3d1ab77 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraOneChannelRelayEu.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.Transport; + +namespace MiHomeLib.DevicesV3; + +public class AqaraOneChannelRelayEu : ZigBeeManageableDevice +{ + public enum RelayState + { + Unknown = -1, + On = 1, + Off = 0, + } + public enum PowerMemoryState + { + PowerOff = 0, + Previous = 1, + } + public enum PowerMode + { + Momentary = 1, + Toggle = 2, + } + public const string MARKET_MODEL = "SSM-U01"; + public const string MODEL = "lumi.switch.n0agl1"; + private (int siid, int piid) STATE_RES = (2, 1); + private (int siid, int piid) LOAD_POWER_RES = (3, 2); + private (int siid, int piid) POWER_CONSUMPTION_RES = (3, 1); + private (int siid, int piid) POWER_MEMORY_RES = (5, 1); + private (int siid, int piid) POWER_MODE_RES = (7, 2); + private (int siid, int piid) POWER_OVERLOAD_RES = (5, 6); + private readonly Dictionary<(int siid, int piid), Action> _actions; + public AqaraOneChannelRelayEu(string did, IMqttTransport mqttTransport, ILoggerFactory loggerFactory) : base(did, mqttTransport, loggerFactory) + { + _actions = new() + { + {LOAD_POWER_RES, x => // load power changed + { + var oldValue = LoadPower; + LoadPower = x.GetValue(); + OnLoadPowerChange?.Invoke(oldValue); + } + }, + {STATE_RES, x => // channel state changed + { + var state = x.GetValue() ? 1 : 0; + + if(state == (int)State) return; // No need to emit event when state is already actual + + State = state == 1 ? RelayState.On : RelayState.Off; + OnStateChange?.Invoke(); + } + }, + {POWER_CONSUMPTION_RES, x => // electricity consumption changed + { + var oldValue = PowerConsumption; + PowerConsumption = x.GetValue(); + OnPowerConsumptionChange?.Invoke(oldValue); + } + }, + }; + } + public float LoadPower { get; internal set; } + public float PowerConsumption { get; internal set; } + public RelayState State { get; internal set; } = RelayState.Unknown; + public event Action OnStateChange; + /// + /// Old value passed as an argument in W + /// + public event Action OnLoadPowerChange; + /// + /// Old value passed as an argument in kWh + /// + public event Action OnPowerConsumptionChange; + protected internal override void ParseData(string data) + { + var listProps = JsonSerializer.Deserialize>(data); + + foreach (var prop in listProps) + { + var key = (prop["siid"].GetValue(), prop["piid"].GetValue()); + + if(_actions.ContainsKey(key)) + _actions[key](prop["value"]); + } + } + public void PowerOn() => SendWriteCommand(STATE_RES, 1); + public void PowerOff() => SendWriteCommand(STATE_RES, 0); + public void ToggleState() + { + switch (State) + { + case RelayState.Off: + SendWriteCommand(STATE_RES, 1); + break; + case RelayState.On: + SendWriteCommand(STATE_RES, 0); + break; + }; + } + public void SetPowerMemoryState(PowerMemoryState state) => SendWriteCommand(POWER_MEMORY_RES, (int)state); + public void SetPowerMode(PowerMode mode) => SendWriteCommand(POWER_MODE_RES, (int)mode); + /// + /// Warning ! Be very careful with this function. + /// If you set low threshold and it's reached, device will fall into "protection" mode and stop working + /// To reset this mode you need an external intervention (press button on the device or on external switch) + /// + public void SetPowerOverloadThreshold(int threshold) + { + if(threshold < 0 || threshold > 2200) + throw new ArgumentOutOfRangeException(nameof(threshold), threshold, $"Power overload threshold should be within range 1-2200 watt"); + + SendWriteCommand(POWER_OVERLOAD_RES, threshold); + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Load Power: {LoadPower}W, " + + $"Channel State: {State}"; + } +} diff --git a/MiHomeLib/DevicesV3/AqaraOppleFourButtonsWirelesSwitch.cs b/MiHomeLib/DevicesV3/AqaraOppleFourButtonsWirelesSwitch.cs new file mode 100644 index 0000000..d3ff8df --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraOppleFourButtonsWirelesSwitch.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; +/// +/// WXCJKG12LM lumi.remote.b486opcn01 wireless switch +/// +public class AqaraOppleFourButtonsWirelesSwitch: AqaraOppleTwoButtonsWirelesSwitch +{ + public new const string MARKET_MODEL = "WXCJKG12LM"; + public new const string MODEL = "lumi.remote.b486opcn01"; + private const string BUTTON3_RES_NAME = "13.3.85"; + private const string BUTTON4_RES_NAME = "13.4.85"; + public event Action OnButton3Click; + public event Action OnButton4Click; + public AqaraOppleFourButtonsWirelesSwitch(string did, ILoggerFactory loggerFactory): base(did, loggerFactory) + { + ResNamesToActions.Add(BUTTON3_RES_NAME, x => + { + var val = x.GetInt32(); + if(_validClickValues.Contains(val)) OnButton3Click?.Invoke((ClickArg)val); + }); + + ResNamesToActions.Add(BUTTON4_RES_NAME, x => + { + var val = x.GetInt32(); + if(_validClickValues.Contains(val)) OnButton4Click?.Invoke((ClickArg)val); + }); + } + + public override string ToString() => GetBaseInfo(MARKET_MODEL, MODEL) + GetBaseToString(); +} diff --git a/MiHomeLib/DevicesV3/AqaraOppleTwoButtonsWirelesSwitch.cs b/MiHomeLib/DevicesV3/AqaraOppleTwoButtonsWirelesSwitch.cs new file mode 100644 index 0000000..fd1afce --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraOppleTwoButtonsWirelesSwitch.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +/// +/// WXCJKG11LM lumi.remote.b286opcn01 wireless switch +/// +public class AqaraOppleTwoButtonsWirelesSwitch: AqaraOppleWirelesSwitch +{ + public const string MARKET_MODEL = "WXCJKG11LM"; + public const string MODEL = "lumi.remote.b286opcn01"; + private const string BUTTON1_RES_NAME = "13.1.85"; + private const string BUTTON2_RES_NAME = "13.2.85"; + public event Action OnButton1Click; + public event Action OnButton2Click; + public AqaraOppleTwoButtonsWirelesSwitch(string did, ILoggerFactory loggerFactory): base(did, loggerFactory) + { + ResNamesToActions.Add(BUTTON1_RES_NAME, x => + { + var val = x.GetInt32(); + if(_validClickValues.Contains(val)) OnButton1Click?.Invoke((ClickArg)val); + }); + + ResNamesToActions.Add(BUTTON2_RES_NAME, x => + { + var val = x.GetInt32(); + if(_validClickValues.Contains(val)) OnButton2Click?.Invoke((ClickArg)val); + }); + } + + protected string GetBaseToString() => base.ToString(); + public override string ToString() => GetBaseInfo(MARKET_MODEL, MODEL) + base.ToString(); +} diff --git a/MiHomeLib/DevicesV3/AqaraOppleWirelesSwitch.cs b/MiHomeLib/DevicesV3/AqaraOppleWirelesSwitch.cs new file mode 100644 index 0000000..cb41164 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraOppleWirelesSwitch.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; +public abstract class AqaraOppleWirelesSwitch(string did, ILoggerFactory loggerFactory) : ZigBeeBatteryDevice(did, loggerFactory) +{ + public enum ClickArg + { + SingleClick = 1, + DoubleClick = 2, + TripleClick = 3, + LongPressHold = 16, + LongPressRelease = 17, + } + protected readonly IEnumerable _validClickValues = Helpers.EnumToIntegers(); +} diff --git a/MiHomeLib/DevicesV3/AqaraThSensor.cs b/MiHomeLib/DevicesV3/AqaraThSensor.cs new file mode 100644 index 0000000..ea0b449 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraThSensor.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +// This sensor is exactly like XiaomiThSensor but also has pressure measurement support +public class AqaraThSensor : XiaomiThSensor +{ + public new const string MARKET_MODEL = "WSDCGQ11LM"; + public new const string MODEL = "lumi.weather"; + private const string PRESSURE_RES_NAME = "0.3.85"; + public float Pressure { get; internal set; } + /// + /// Old value pressure (0.1 step) passed as an argument + /// + public event Action OnPressureChange; + public AqaraThSensor(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions.Add(PRESSURE_RES_NAME, x => + { + var oldVal = Pressure; + Pressure = (float)x.GetDouble()/100; + OnPressureChange?.Invoke(oldVal); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["pressure", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + Pressure = props[0].GetValue()/100f; + base.SetProps(props.Skip(1).ToArray()); + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Temperature: {Temperature}°C, " + + $"Humidity: {Humidity}%, " + + $"Pressure: {Pressure}hPa, " + GetBaseToString(); + } +} diff --git a/MiHomeLib/DevicesV3/AqaraTwoChannelsRelay.cs b/MiHomeLib/DevicesV3/AqaraTwoChannelsRelay.cs new file mode 100644 index 0000000..70efe56 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraTwoChannelsRelay.cs @@ -0,0 +1,179 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.Transport; + +namespace MiHomeLib.DevicesV3; + +public class AqaraTwoChannelsRelay : ZigBeeManageableDevice +{ + public enum ChannelState + { + Unknown = -1, + On = 1, + Off = 0, + } + public enum PowerMemoryState + { + PowerOff = 0, + Previous = 1, + } + public enum InterlockState + { + Unknown = -1, + Enabled = 1, + Disabled = 0, + } + public const string MARKET_MODEL = "LLKZMK11LM"; + public const string MODEL = "lumi.relay.c2acn01"; + private const string CHANNEL1_RES_NAME = "4.1.85"; + private const string CHANNEL2_RES_NAME = "4.2.85"; + private const string VOLTAGE_RES_NAME = "0.11.85"; + private const string LOAD_POWER_RES_NAME = "0.12.85"; + private const string ENERGY_RES_NAME = "0.13.85"; + private const string LOAD_CURRENT_RES_NAME = "0.14.85"; + private const string POWER_MEMORY_RES_NAME = "8.0.2030"; + private const string INTERLOCK_RES_NAME = "4.9.85"; + public ChannelState Channel1 { get; internal set; } = ChannelState.Unknown; + public ChannelState Channel2 { get; internal set; } = ChannelState.Unknown; + /// + /// Voltage in volts + /// + public float Voltage { get; internal set; } + /// + /// Load power in watt + /// + public float LoadPower { get; internal set; } + /// + /// Energy in watt + /// + public float Energy { get; internal set; } + /// + /// Load current in milliampers + /// + public float Current { get; internal set; } + public InterlockState Interlock { get; internal set; } = InterlockState.Unknown; + public event Action OnChannel1StateChange; + public event Action OnChannel2StateChange; + public event Action OnLoadPowerChange; + public event Action OnVoltageChange; + public event Action OnEnergyChange; + public event Action OnCurrentChange; + public AqaraTwoChannelsRelay(string did, IMqttTransport mqttTransport, ILoggerFactory loggerFactory) : base(did, mqttTransport, loggerFactory) + { + ResNamesToActions.Add(CHANNEL1_RES_NAME, x => + { + var state = x.GetInt32(); + + if(state == (int)Channel1) return; // No need to emit event when state is already actual + + Channel1 = state == 1 ? ChannelState.On : (state == 0 ? ChannelState.Off : ChannelState.Unknown); + OnChannel1StateChange?.Invoke(); + }); + + ResNamesToActions.Add(CHANNEL2_RES_NAME, x => + { + var state = x.GetInt32(); + + if(state == (int)Channel2) return; // No need to emit event when state is already actual + + Channel2 = state == 1 ? ChannelState.On : (state == 0 ? ChannelState.Off : ChannelState.Unknown); + OnChannel2StateChange?.Invoke(); + }); + + ResNamesToActions.Add(VOLTAGE_RES_NAME, x => + { + (var oldValue, Voltage) = (Voltage, x.GetInt32()/1000f); + OnVoltageChange?.Invoke(oldValue); + }); + + ResNamesToActions.Add(LOAD_POWER_RES_NAME, x => + { + (var oldValue, LoadPower) = (LoadPower, (float)x.GetDouble()); + OnLoadPowerChange?.Invoke(oldValue); + }); + + ResNamesToActions.Add(ENERGY_RES_NAME, x => + { + (var oldValue, Energy) = (Energy, (float)x.GetDouble()); + OnEnergyChange?.Invoke(oldValue); + }); + + ResNamesToActions.Add(LOAD_CURRENT_RES_NAME, x => + { + (var oldValue, Current) = (Current, (float)x.GetDouble()); + OnCurrentChange?.Invoke(oldValue); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["channel_0", "channel_1", "load_voltage", "load_power", "enable_motor_mode", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + Channel1 = StringToState(props[0].GetValue()); + Channel2 = StringToState(props[1].GetValue()); + Voltage = props[2].GetValue() / 1000f; + LoadPower = props[3].GetValue(); + Interlock = (InterlockState)props[4].GetValue(); + base.SetProps(props.Skip(5).ToArray()); + } + private ChannelState StringToState(string state) + { + return state switch + { + "on" => ChannelState.On, + "off" => ChannelState.Off, + _ => ChannelState.Unknown + }; + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Channel 1: {Channel1}, " + + $"Channel 2: {Channel2}, " + + $"Voltage: {Voltage}V, " + + $"Load Power: {LoadPower}W, " + + $"Interlock state: {Interlock}, " + + base.ToString(); + } + public void Channel1PowerOn() => SendWriteCommand(CHANNEL1_RES_NAME, 1); + public void Channel1PowerOff() => SendWriteCommand(CHANNEL1_RES_NAME, 0); + public void Channel1ToggleState() + { + switch (Channel1) + { + case ChannelState.Off: + SendWriteCommand(CHANNEL1_RES_NAME, 1); + break; + case ChannelState.On: + SendWriteCommand(CHANNEL1_RES_NAME, 0); + break; + }; + } + public void Channel2PowerOn() => SendWriteCommand(CHANNEL2_RES_NAME, 1); + public void Channel2PowerOff() => SendWriteCommand(CHANNEL2_RES_NAME, 0); + public void Channel2ToggleState() + { + switch (Channel2) + { + case ChannelState.Off: + SendWriteCommand(CHANNEL2_RES_NAME, 1); + break; + case ChannelState.On: + SendWriteCommand(CHANNEL2_RES_NAME, 0); + break; + }; + } + /// + /// Restore or not previous state after electricity was disrapted and then appeared again + /// + public void SetPowerMemoryState(PowerMemoryState state) => SendWriteCommand(POWER_MEMORY_RES_NAME, (int)state); + /// + /// This function enables/disabled "only one channel can be enabled at a time" logic. + /// If we enable channel1, channel2 will be automatically turned off off and vice versa + /// + public void SetInterlock(InterlockState state) => SendWriteCommand(INTERLOCK_RES_NAME, (int)state); +} diff --git a/MiHomeLib/DevicesV3/AqaraWaterLeakSensor.cs b/MiHomeLib/DevicesV3/AqaraWaterLeakSensor.cs new file mode 100644 index 0000000..cbc1350 --- /dev/null +++ b/MiHomeLib/DevicesV3/AqaraWaterLeakSensor.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; +public class AqaraWaterLeakSensor : ZigBeeBatteryDevice +{ + public const string MARKET_MODEL = "SJCGQ11LM"; + public const string MODEL = "lumi.sensor_wleak.aq1"; + private const string MOISTURE_RES_NAME = "3.1.85"; + public bool Moisture { get; set; } + public event Action OnMoistureChange; + public AqaraWaterLeakSensor(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions.Add(MOISTURE_RES_NAME, x => + { + Moisture = x.GetInt32() == 1; + OnMoistureChange?.Invoke(); + }); + } + + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Moisture {Moisture}, " + base.ToString(); + } +} diff --git a/MiHomeLib/DevicesV3/BleBatteryDevice.cs b/MiHomeLib/DevicesV3/BleBatteryDevice.cs new file mode 100644 index 0000000..b279a3e --- /dev/null +++ b/MiHomeLib/DevicesV3/BleBatteryDevice.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public abstract class BleBatteryDevice : BleDevice +{ + private const int BATTERY_EID = 4106; + + public BleBatteryDevice(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + EidToActions.Add(BATTERY_EID, x => + { + var oldValue = BatteryPercent; + BatteryPercent = x.ToBleByte(); + OnBatteryPercentChange?.Invoke(oldValue); + }); + } + + public byte BatteryPercent { get; set; } + /// + /// Old value battery percent 0-100% (step 1%) passed as an argument + /// + public event Action OnBatteryPercentChange; + + public override string ToString() => $"BatteryPercent: {BatteryPercent}%"; +} diff --git a/MiHomeLib/DevicesV3/BleDevice.cs b/MiHomeLib/DevicesV3/BleDevice.cs new file mode 100644 index 0000000..fe2ddbf --- /dev/null +++ b/MiHomeLib/DevicesV3/BleDevice.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public abstract class BleDevice(string did, ILoggerFactory loggerFactory) : XiaomiGateway3SubDevice(did, loggerFactory) +{ + public string Mac { get; internal set; } + + protected Dictionary> EidToActions = []; + + protected internal override void ParseData(string data) + { + var json = JsonNode.Parse(data); + var events = json["evt"] as JsonArray; + + foreach (var evt in events) + { + var eid = evt["eid"].GetValue(); + + if(EidToActions.ContainsKey(eid)) + { + EidToActions[eid](evt["edata"].ToString().ToLower()); + } + else + { + _logger.LogWarning($"Eid '{eid}' is not supported for this device yet. Please contribute to support."); + } + } + } + + protected override string GetBaseInfo(string marketModel, string model) + { + return base.GetBaseInfo(marketModel, model) + $"Mac: {Mac}, "; + } +} diff --git a/MiHomeLib/DevicesV3/HoneywellSmokeAlarm.cs b/MiHomeLib/DevicesV3/HoneywellSmokeAlarm.cs new file mode 100644 index 0000000..4d1afae --- /dev/null +++ b/MiHomeLib/DevicesV3/HoneywellSmokeAlarm.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class HoneywellSmokeAlarm : BleBatteryDevice +{ + public enum SmokeState + { + Unknown = -1, + SmokeDetected = 1, + NoSmokeDetected = 0, + } + public const string MARKET_MODEL = "JTYJ-GD-03MI"; + public const string MODEL = "lumi.sensor_smoke.mcn02"; + public const int PDID = 2455; + private const int SMOKE_DETECTED_EID = 4117; + public SmokeState Smoke { get; set; } = SmokeState.Unknown; + /// + /// Old value of smoke passed as a parameter + /// + public event Action OnSmokeChange; + + public HoneywellSmokeAlarm(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + EidToActions.Add(SMOKE_DETECTED_EID, x => + { + var val = (SmokeState)int.Parse(x); + + if(Smoke == val) return; // prevent event duplication if state is actual + + (var oldValue, Smoke) = (Smoke, val); + + OnSmokeChange?.Invoke(oldValue); + }); + } + + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Smoke state: {Smoke}, " + base.ToString(); + ; + } +} diff --git a/MiHomeLib/DevicesV3/HoneywellSmokeSensor.cs b/MiHomeLib/DevicesV3/HoneywellSmokeSensor.cs new file mode 100644 index 0000000..b0f60f7 --- /dev/null +++ b/MiHomeLib/DevicesV3/HoneywellSmokeSensor.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.Transport; + +namespace MiHomeLib.DevicesV3; +public class HoneywellSmokeSensor : ZigBeeManageableDevice +{ + public enum SensivityMode + { + // I don't know why exactly these numbers have been selected by vendor + NoSmoke = 67174400, + LowSmoke = 67239936, + MiddleSmoke = 67305472, + } + public const string MARKET_MODEL = "JTYJ-GD-01LM/BW"; + public const string MODEL = "lumi.sensor_smoke"; + private const string SMOKE_DETECTED_RES_NAME = "13.1.85"; + private const string SMOKE_MODE_RES_NAME = "14.1.85"; + private const string SMOKE_DENSITY_RES_NAME = "0.1.85"; + public const int SELF_TEST_MAGIC_NUMBER = 50397184; // only God knows why this number + public bool SmokeDetected { get; set; } + public byte SmokeDensity { get; set; } + public event Action OnSmokeDetected; + public event Action OnSmokeDensityChanged; + public event Action OnSmokeSensivityModeChanged; + public HoneywellSmokeSensor(string did, IMqttTransport mqttTransport, ILoggerFactory loggerFactory) : base(did, mqttTransport, loggerFactory) + { + ResNamesToActions.Add(SMOKE_DETECTED_RES_NAME, x => + { + SmokeDetected = x.GetInt32() == 1; + OnSmokeDetected?.Invoke(); + }); + + ResNamesToActions.Add(SMOKE_DENSITY_RES_NAME, x => + { + var oldValue = SmokeDensity; + SmokeDensity = (byte)x.GetInt32(); + OnSmokeDensityChanged?.Invoke(oldValue); + }); + + ResNamesToActions.Add(SMOKE_MODE_RES_NAME, x => + { + OnSmokeSensivityModeChanged?.Invoke((SensivityMode)x.GetInt32()); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["density", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + SmokeDensity = (byte)props[0].GetValue(); + base.SetProps(props.Skip(1).ToArray()); + } + public void SetSensivity(SensivityMode mode) => SendWriteCommand(SMOKE_MODE_RES_NAME, (int)mode); + public void RunSelfTest() => SendWriteCommand(SMOKE_MODE_RES_NAME, SELF_TEST_MAGIC_NUMBER); + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Smoke detected: {SmokeDetected}, " + + $"Smoke density: {SmokeDensity}%, " + base.ToString(); + } +} diff --git a/MiHomeLib/DevicesV3/MiThMonitor2.cs b/MiHomeLib/DevicesV3/MiThMonitor2.cs new file mode 100644 index 0000000..3b9a630 --- /dev/null +++ b/MiHomeLib/DevicesV3/MiThMonitor2.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class MiThMonitor2 : BleBatteryDevice +{ + public const string MARKET_MODEL = "LYWSD03MMC"; + public const string MODEL = "miaomiaoce.sensor_ht"; + public const int PDID = 1371; + private const int TEMPERATURE_EID = 4100; + private const int HUMIDITY_EID = 4102; + + public MiThMonitor2(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + EidToActions.Add(TEMPERATURE_EID, x => + { + var oldTemperature = Temperature; + Temperature = x.ToBleFloat(); + OnTemperatureChange?.Invoke(oldTemperature); + }); + + EidToActions.Add(HUMIDITY_EID, x => + { + var oldHumidity = Humidity; + Humidity = x.ToBleFloat(); + OnHumidityChange?.Invoke(oldHumidity); + }); + } + public float Temperature { get; set; } + /// + /// Old value temperature -30 - 100°C (0.1 step) passed as an argument + /// + public event Action OnTemperatureChange; + public float Humidity { get; set; } + /// + /// Old value relative humidity 0-100% (0.1 step) passed as an argument + /// + public event Action OnHumidityChange; + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Temperature: {Temperature}°C, " + + $"Humidity: {Humidity}%, " + + $"Battery Percent: {BatteryPercent}% "; + } +} diff --git a/MiHomeLib/DevicesV3/MiWirelesSwitch.cs b/MiHomeLib/DevicesV3/MiWirelesSwitch.cs new file mode 100644 index 0000000..c1c782f --- /dev/null +++ b/MiHomeLib/DevicesV3/MiWirelesSwitch.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +/// +/// WXKG01LM lumi.sensor_switch wireless switch +/// +public class MiWirelesSwitch: ZigBeeBatteryDevice +{ + public const string MARKET_MODEL = "WXKG01LM"; + public const string MODEL = "lumi.sensor_switch"; + public enum ClickArg + { + SingleClick = 1, + DoubleClick = 2, + TripleClick = 3, + QuadrupleClick = 4, + /// + /// More than 4 clicks + /// + ManyClicks = 128, + LongPressHold = 16, + LongPressRelease = 17, + } + private const string CLICK_RES_NAME = "13.1.85"; + public event Action OnClick; + private readonly IEnumerable _validClickValues = Helpers.EnumToIntegers(); + public MiWirelesSwitch(string did, ILoggerFactory loggerFactory): base(did, loggerFactory) + { + ResNamesToActions.Add(CLICK_RES_NAME, x => + { + var val = x.GetInt32(); + if(_validClickValues.Contains(val)) OnClick?.Invoke((ClickArg)val); + }); + } + public override string ToString() => GetBaseInfo(MARKET_MODEL, MODEL) + base.ToString(); +} diff --git a/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor.cs b/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor.cs new file mode 100644 index 0000000..cdaadb0 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class XiaomiDoorWindowSensor : ZigBeeBatteryDevice +{ + public enum DoorWindowContactState + { + Closed = 0, + Opened = 1, + } + public const string MARKET_MODEL = "MCCGQ01LM"; + public const string MODEL = "lumi.sensor_magnet"; + private const string CONTACT_RES_NAME = "3.1.85"; + public DoorWindowContactState Contact { get; set; } + public event Action OnContactChanged; + private readonly IEnumerable _validContactValues = Helpers.EnumToIntegers(); + public XiaomiDoorWindowSensor(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions.Add(CONTACT_RES_NAME, x => + { + var val = x.GetInt32(); + if(!_validContactValues.Contains(val)) return; + + Contact = (DoorWindowContactState)val; + OnContactChanged?.Invoke(); + }); + } + + protected string GetBaseToString() => base.ToString(); + + public override string ToString() => GetBaseInfo(MARKET_MODEL, MODEL) + $"Contact: {Contact}, " + base.ToString(); +} diff --git a/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor2.cs b/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor2.cs new file mode 100644 index 0000000..25d46c2 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiDoorWindowSensor2.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class XiaomiDoorWindowSensor2 : BleBatteryDevice +{ + public enum ContactState + { + Unknown = -1, + Open = 0, + Closed = 1, + // To configure timeout, connect to the device via bluetooth and configure timeout in the + // mobile application + NotClosedAfterConfiguredTimeout = 2, + } + public enum LightState + { + Unknown = -1, + NoLight = 0, + LightDiscovered = 1, + } + public const string MARKET_MODEL = "MCCGQ02HL"; + public const string MODEL = "isa.magnet.dw2hl"; + public const int PDID = 2443; + private const int CONTACT_EID = 4121; + private const int LIGHT_EID = 4120; + public XiaomiDoorWindowSensor2(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + EidToActions.Add(CONTACT_EID, x => + { + var value = (ContactState)int.Parse(x); + + if(value == Contact) return; // prevent event duplication when state is actual + + var oldContact = Contact; + Contact = (ContactState)int.Parse(x); + OnContactChange?.Invoke(oldContact); + }); + + EidToActions.Add(LIGHT_EID, x => + { + var value = (LightState)int.Parse(x); + + if(value == Light) return; // prevent event duplication when state is actual + + var oldLight = Light; + Light = (LightState)int.Parse(x); + OnLightChange?.Invoke(oldLight); + }); + } + public ContactState Contact { get; set; } = ContactState.Unknown; + public event Action OnContactChange; + public LightState Light { get; set; } = LightState.Unknown; + public event Action OnLightChange; + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Contact: {Contact}, " + + $"Light: {Light}, " + + $"Battery Percent: {BatteryPercent}% "; + } +} diff --git a/MiHomeLib/DevicesV3/XiaomiGateway3SubDevice.cs b/MiHomeLib/DevicesV3/XiaomiGateway3SubDevice.cs new file mode 100644 index 0000000..d543050 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiGateway3SubDevice.cs @@ -0,0 +1,14 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public abstract class XiaomiGateway3SubDevice(string did, ILoggerFactory loggerFactory) +{ + public string Did { get; } = did; + public override string ToString() => $"Did: {Did}"; + public DateTime LastTimeMessageReceived { get; internal set; } + protected readonly ILogger _logger = loggerFactory.CreateLogger(); + protected internal abstract void ParseData(string command); + protected virtual string GetBaseInfo(string marketModel, string model) => $"Device: {marketModel} {model} {Did}, "; +} \ No newline at end of file diff --git a/MiHomeLib/DevicesV3/XiaomiMotionSensor.cs b/MiHomeLib/DevicesV3/XiaomiMotionSensor.cs new file mode 100644 index 0000000..320aa29 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiMotionSensor.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.Extensions.Logging; +using MiHomeLib.Utils; + +namespace MiHomeLib.DevicesV3; +public class XiaomiMotionSensor : ZigBeeBatteryDevice +{ + public const string MARKET_MODEL = "RTCGQ01LM"; + public const string MODEL = "lumi.sensor_motion"; + // I use these exact intervals because only these events are sent to miio/report mqtt topic + public enum NoMotionInterval + { + NoMotionForOneMinute, + NoMotionForTwoMinutes, + } + private const string MOTION_RES_NAME = "3.1.85"; + private readonly ITimer _oneMinuteTimer; + private readonly ITimer _twoMinutesTimer; + public bool MotionDetected { get; set; } + public event Action OnMotionDetected; + public event Action OnNoMotionDetected; + public XiaomiMotionSensor(string did, ILoggerFactory loggerFactory): + this( + did, + loggerFactory, + new SimpleTimer(TimeSpan.FromMinutes(1)), + new SimpleTimer(TimeSpan.FromMinutes(2)) + ) {} + internal XiaomiMotionSensor(string did, ILoggerFactory loggerFactory, ITimer oneMinuteTimer, ITimer twoMinutesTimer) + : base(did, loggerFactory) + { + _oneMinuteTimer = oneMinuteTimer; + _twoMinutesTimer = twoMinutesTimer; + + _oneMinuteTimer.Elapsed += (_, __) => + { + OnNoMotionDetected?.Invoke(NoMotionInterval.NoMotionForOneMinute); + _oneMinuteTimer.Stop(); + }; + + _twoMinutesTimer.Elapsed += (_, __) => + { + OnNoMotionDetected?.Invoke(NoMotionInterval.NoMotionForTwoMinutes); + _twoMinutesTimer.Stop(); + }; + + ResNamesToActions.Add(MOTION_RES_NAME, x => + { + _oneMinuteTimer.Stop(); + _twoMinutesTimer.Stop(); + MotionDetected = x.GetInt32() == 1; + OnMotionDetected?.Invoke(); + _oneMinuteTimer.Start(); + _twoMinutesTimer.Start(); + }); + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Motion: {MotionDetected}, " + base.ToString(); + } +} diff --git a/MiHomeLib/DevicesV3/XiaomiMotionSensor2.cs b/MiHomeLib/DevicesV3/XiaomiMotionSensor2.cs new file mode 100644 index 0000000..fe67b29 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiMotionSensor2.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class XiaomiMotionSensor2 : BleBatteryDevice +{ + public enum MotionState + { + Unknown = -1, + MotionWithoutLight = 0, + MotionWithLight = 100, + } + public enum NoMotionState + { // Only these intervals are exposed by the sensor + Idle120Seconds = 120, + Idle300Seconds = 300, + } + public enum LightState + { + Unknown = -1, + LightOff = 0, + LightOn = 1, + } + public const string MARKET_MODEL = "RTCGQ02LM"; + public const string MODEL = "lumi.motion.bmgl01"; + public const int PDID = 2701; + private const int MOTION_EID = 15; + private const int LIGHT_EID = 4120; + private const int IDLE_TIME_EID = 4119; + public MotionState Motion { get; set; } = MotionState.Unknown; + public event Action OnMotionDetected; + public LightState Light { get; set; } = LightState.Unknown; + public event Action OnLightChange; + public event Action OnNoMotionDetected; + public XiaomiMotionSensor2(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + EidToActions.Add(MOTION_EID, x => + { + var value = (MotionState)int.Parse(x); + + if(value == Motion) return; // prevent event duplication when state is actual + + (var oldValue, Motion) = (Motion, value); + OnMotionDetected?.Invoke(oldValue); + }); + + EidToActions.Add(LIGHT_EID, x => + { + var value = (LightState)int.Parse(x); + + if(value == Light) return; // prevent event duplication when state is actual + + (var oldValue, Light) = (Light, value); + OnLightChange?.Invoke(oldValue); + }); + + EidToActions.Add(IDLE_TIME_EID, x => + { + OnNoMotionDetected?.Invoke((NoMotionState)x.ToBleInt256()); + }); + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + $"Motion: {Motion}, Light: {Light}"; + } +} diff --git a/MiHomeLib/DevicesV3/XiaomiPlugCN.cs b/MiHomeLib/DevicesV3/XiaomiPlugCN.cs new file mode 100644 index 0000000..8929df8 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiPlugCN.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.Transport; + +namespace MiHomeLib.DevicesV3; + +public class XiaomiPlugCN: ZigBeeManageableDevice +{ + public enum PlugState + { + Off = 0, + On = 1, + } + public enum PowerMemoryState + { + PowerOff = 0, + Previous = 1, + } + public enum ChargeProtect + { + Off = 0, + On = 1, // when load is less then 2w for half an hour, plug will turn off automatically + } + public enum LedState + { + TurnOffAtNightTime = 0, // from mihome app night time is from 21:00 till 09:00 + AlwaysOn = 1, + } + public const string MARKET_MODEL = "ZNCZ02LM"; + public const string MODEL = "lumi.plug"; + private const string STATE_RES_NAME = "4.1.85"; + private const string LOAD_POWER_RES_NAME = "0.12.85"; + private const string POWER_ON_STATE_RES_NAME = "8.0.2030"; + private const string CHARGE_PROTECTION_RES_NAME = "8.0.2031"; + private const string LED_STATE_RES_NAME = "8.0.2032"; + public PlugState State { get; set; } + public float LoadPower { get; set; } + public int MaxPower { get; set; } + public event Action OnStateChange; + public event Action OnLoadPowerChange; + public XiaomiPlugCN(string did, IMqttTransport mqttTransport, ILoggerFactory loggerFactory) : base(did, mqttTransport, loggerFactory) + { + ResNamesToActions.Add(STATE_RES_NAME, x => + { + var state = x.GetInt32() == 1 ? PlugState.On : PlugState.Off; + + if(state == State) return; // No need to emit event when state is already actual + + State = state; + OnStateChange?.Invoke(); + }); + + ResNamesToActions.Add(LOAD_POWER_RES_NAME, x => + { + var oldValue = LoadPower; + LoadPower = (float)x.GetDouble(); + OnLoadPowerChange?.Invoke(oldValue); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["neutral_0", "load_power", "max_power", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + State = props[0].GetValue() == "on" ? PlugState.On : PlugState.Off; + LoadPower = props[1].GetValue(); + MaxPower = props[2].GetValue(); + base.SetProps(props.Skip(3).ToArray()); + } + public void PowerOn() => SendWriteCommand(STATE_RES_NAME, 1); + public void PowerOff() => SendWriteCommand(STATE_RES_NAME, 0); + public void ToggleState() + { + switch (State) + { + case PlugState.Off: + SendWriteCommand(STATE_RES_NAME, 1); + break; + case PlugState.On: + SendWriteCommand(STATE_RES_NAME, 0); + break; + }; + } + public void SetPowerMemoryState(PowerMemoryState state) + { + SendWriteCommand(POWER_ON_STATE_RES_NAME, (int)state); + } + public void SetChargeProtection(ChargeProtect state) + { + SendWriteCommand(CHARGE_PROTECTION_RES_NAME, (int)state); + } + public void SetLedState(LedState state) + { + SendWriteCommand(LED_STATE_RES_NAME, (int)state); + } + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"State: {State}, " + + $"Load Power: {LoadPower}W, " + + $"Max Power: {MaxPower}W, " + base.ToString(); + } +} + diff --git a/MiHomeLib/DevicesV3/XiaomiThSensor.cs b/MiHomeLib/DevicesV3/XiaomiThSensor.cs new file mode 100644 index 0000000..ca12500 --- /dev/null +++ b/MiHomeLib/DevicesV3/XiaomiThSensor.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public class XiaomiThSensor : ZigBeeBatteryDevice +{ + public const string MARKET_MODEL = "WSDCGQ01LM"; + public const string MODEL = "lumi.sensor_ht"; + private const string TEMPERATURE_RES_NAME = "0.1.85"; + private const string HUMIDITY_RES_NAME = "0.2.85"; + public float Temperature { get; internal set; } + public float Humidity { get; internal set; } + /// + /// Old value temperature -40-125°C (0.1 step) passed as an argument + /// + public event Action OnTemperatureChange; + /// + /// Old value relative humidity 0-100% (1 step) passed as an argument + /// + public event Action OnHumidityChange; + public XiaomiThSensor(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions.Add(TEMPERATURE_RES_NAME, x => + { + var oldVal = Temperature; + Temperature = (float)x.GetDouble()/100; + OnTemperatureChange?.Invoke(oldVal); + }); + + ResNamesToActions.Add(HUMIDITY_RES_NAME, x => + { + var oldVal = Humidity; + Humidity = (float)x.GetDouble()/100; + OnHumidityChange?.Invoke(oldVal); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["temperature", "humidity", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + Temperature = props[0].GetValue()/100f; + Humidity = props[1].GetValue()/100f; + base.SetProps(props.Skip(2).ToArray()); + } + protected string GetBaseToString() => base.ToString(); + + public override string ToString() + { + return GetBaseInfo(MARKET_MODEL, MODEL) + + $"Temperature: {Temperature}°C, " + + $"Humidity: {Humidity}%, " + base.ToString(); + } +} diff --git a/MiHomeLib/DevicesV3/ZigBeeBatteryDevice.cs b/MiHomeLib/DevicesV3/ZigBeeBatteryDevice.cs new file mode 100644 index 0000000..d8b1f53 --- /dev/null +++ b/MiHomeLib/DevicesV3/ZigBeeBatteryDevice.cs @@ -0,0 +1,52 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; +using MiHomeLib.DevicesV3; + +namespace MiHomeLib; + +public abstract class ZigBeeBatteryDevice : ZigBeeDevice +{ + private const string BATTERY_RES_NAME = "8.0.2001"; + private const string VOLTAGE_RES_NAME = "8.0.2008"; + public float Voltage { get; set; } + public byte BatteryPercent { get; set; } + /// + /// Old value voltage 0-3.5 (0.01 step) passed as an argument + /// + public event Action OnVoltageChange; + /// + /// Old value battery percent 0-100% (1 step) passed as an argument + /// + public event Action OnBatteryPercentChange; + public ZigBeeBatteryDevice(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions.Add(VOLTAGE_RES_NAME, x => + { + var oldVal = Voltage; + Voltage = x.GetInt32()/1000f; + OnVoltageChange?.Invoke(oldVal); + }); + + ResNamesToActions.Add(BATTERY_RES_NAME, x => + { + var oldVal = BatteryPercent; + BatteryPercent = (byte)x.GetInt32(); + OnBatteryPercentChange?.Invoke(oldVal); + }); + } + protected internal override string[] GetProps() + { + // order of properties does matter ! don't change it + return ["voltage", "battery", .. base.GetProps()]; + } + protected internal override void SetProps(JsonNode[] props) + { + Voltage = props[0].GetValue()/1000f; + BatteryPercent = (byte)props[1].GetValue(); + base.SetProps(props.Skip(2).ToArray()); + } + + public override string ToString() => $"Voltage: {Voltage}V, "+ $"BatteryPercent: {BatteryPercent}%, "+ base.ToString(); +} diff --git a/MiHomeLib/DevicesV3/ZigBeeDevice.cs b/MiHomeLib/DevicesV3/ZigBeeDevice.cs new file mode 100644 index 0000000..34f9c37 --- /dev/null +++ b/MiHomeLib/DevicesV3/ZigBeeDevice.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; + +namespace MiHomeLib.DevicesV3; + +public abstract class ZigBeeDevice : XiaomiGateway3SubDevice +{ + protected const string RES_NAME = "res_name"; + protected const string VALUE = "value"; + private const string LQI_RES_NAME = "8.0.2007"; + private const string CHIP_TEMPERATURE_RES_NAME = "8.0.2006"; + public byte LinqQuality { get; set; } + public byte ChipTemperature { get; set; } + /// + /// Catching event when linq quality is changed (0-255) + /// + public event Action OnLinkQualityChange; + /// + /// Old value chip temperature percent 0-100°C (1 step) passed as an argument + /// + public event Action OnChipTemperatureChange; + protected Dictionary> ResNamesToActions = []; + public ZigBeeDevice(string did, ILoggerFactory loggerFactory) : base(did, loggerFactory) + { + ResNamesToActions = new() { + {LQI_RES_NAME, x => + { + var oldVal = LinqQuality; + LinqQuality = (byte)x.GetInt32(); + OnLinkQualityChange?.Invoke(oldVal); + } + }, + {CHIP_TEMPERATURE_RES_NAME, x => + { + var oldVal = ChipTemperature; + ChipTemperature = (byte)x.GetInt32(); + OnChipTemperatureChange?.Invoke(oldVal); + } + }, + }; + } + protected internal virtual string[] GetProps() + { + // order of properties does matter ! don't change it + return ["lqi", "chip_temperature"]; + } + protected internal virtual void SetProps(JsonNode[] props) + { + LinqQuality = (byte)props[0].GetValue(); + ChipTemperature = (byte)props[1].GetValue(); + } + protected internal override void ParseData(string data) + { + var listProps = JsonSerializer.Deserialize>>(data); + + foreach (var prop in listProps) + { + if (prop.ContainsKey(RES_NAME) && ResNamesToActions.ContainsKey(prop[RES_NAME].GetString())) + { + ResNamesToActions[prop[RES_NAME].ToString()](prop[VALUE]); + } + else + { + _logger.LogWarning($"Property '{prop[RES_NAME].GetString()}' is not supported for this device yet. Please contribute to support."); + } + } + } + public override string ToString() + { + return + $"ChipTemperature {ChipTemperature}°C, " + + $"LinqQuality: {LinqQuality}dBm, " + + $"Last seen: {LastTimeMessageReceived}"; + } +} diff --git a/MiHomeLib/DevicesV3/ZigBeeManageableDevice.cs b/MiHomeLib/DevicesV3/ZigBeeManageableDevice.cs new file mode 100644 index 0000000..d65c968 --- /dev/null +++ b/MiHomeLib/DevicesV3/ZigBeeManageableDevice.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using MiHomeLib.Transport; + +namespace MiHomeLib.DevicesV3; + +public abstract class ZigBeeManageableDevice(string did, IMqttTransport mqttTransport, ILoggerFactory loggerFactory) : ZigBeeDevice(did, loggerFactory) +{ + private readonly JsonSerializerOptions _options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + protected readonly IMqttTransport _mqttTransport = mqttTransport; + protected void SendWriteCommand(string resName, int value) + { + var cmd = JsonSerializer.Serialize(new ZigbeeWriteCommand + { + Did = Did, + Params = [ + new() { ResName = resName, Value = value } + ] + }, _options); + + _mqttTransport.SendMessage(cmd); + } + + protected void SendWriteCommand((int siid, int piid) res, int value) + { + var cmd = JsonSerializer.Serialize(new ZigbeeWriteCommand + { + Did = Did, + MiSpec = [ + new() { Siid = res.siid, Piid = res.piid, Value = value } + ] + }, _options); + + _mqttTransport.SendMessage(cmd); + } + + public class ZigbeeWriteCommand + { + public string Cmd { get; } = "write"; + public string Did { get; internal set; } + public List Params { get; internal set; } + public List MiSpec { get; internal set; } + + public class ZigbeeWriteItem + { + public string ResName { get; set; } + public int Value { get; set; } + } + + public class ZigbeeMiSpecWriteItem + { + public int Siid { get; set; } + public int Piid { get; set; } + public int Value { get; set; } + } + } +} diff --git a/MiHomeLib/Exceptions/ModelNotSupportedException.cs b/MiHomeLib/Exceptions/ModelNotSupportedException.cs index e333f49..68ab6dd 100644 --- a/MiHomeLib/Exceptions/ModelNotSupportedException.cs +++ b/MiHomeLib/Exceptions/ModelNotSupportedException.cs @@ -1,25 +1,24 @@ using System; using System.Runtime.Serialization; -namespace MiHomeLib +namespace MiHomeLib; + +[Serializable] +public class ModelNotSupportedException : Exception { - [Serializable] - public class ModelNotSupportedException : Exception + public ModelNotSupportedException() { - public ModelNotSupportedException() - { - } + } - public ModelNotSupportedException(string message) : base(message) - { - } + public ModelNotSupportedException(string message) : base(message) + { + } - public ModelNotSupportedException(string message, Exception innerException) : base(message, innerException) - { - } + public ModelNotSupportedException(string message, Exception innerException) : base(message, innerException) + { + } - protected ModelNotSupportedException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } + protected ModelNotSupportedException(SerializationInfo info, StreamingContext context) : base(info, context) + { } } \ No newline at end of file diff --git a/MiHomeLib/JsonResponses/BleAsyncEventResponse.cs b/MiHomeLib/JsonResponses/BleAsyncEventResponse.cs new file mode 100644 index 0000000..b639fdb --- /dev/null +++ b/MiHomeLib/JsonResponses/BleAsyncEventResponse.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; + +namespace MiHomeLib.JsonResponses; + +public class BleAsyncEventResponse : BleResponse +{ + public string Method { get; } = "_async.ble_event"; + + public BleAsyncEventParams @Params { get; set; } + + public class BleAsyncEventParams: BleResponse + { + public BleAsyncEventDevice Dev { get; set; } + public List Evt { get; set; } + public int FromCnt { get; set; } + + public long Gwts { get; set; } + + public class BleAsyncEventDevice + { + public string Did { get; set; } + public string Mac { get; set; } + public int Pdid { get; set; } + } + + public class BleAsyncEventEvt + { + public int Eid { get; set; } + public string Edata { get; set; } + } + } +} diff --git a/MiHomeLib/JsonResponses/BleResponse.cs b/MiHomeLib/JsonResponses/BleResponse.cs new file mode 100644 index 0000000..0c2bfc6 --- /dev/null +++ b/MiHomeLib/JsonResponses/BleResponse.cs @@ -0,0 +1,17 @@ +using System; +using System.Text.Json; + +namespace MiHomeLib.JsonResponses; + +public abstract class BleResponse +{ + private readonly JsonSerializerOptions _options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public override string ToString() + { + return JsonSerializer.Serialize(Convert.ChangeType(this, GetType()), _options); + } +} diff --git a/MiHomeLib/JsonResponses/GetDeviceListResponse.cs b/MiHomeLib/JsonResponses/GetDeviceListResponse.cs new file mode 100644 index 0000000..4334d60 --- /dev/null +++ b/MiHomeLib/JsonResponses/GetDeviceListResponse.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace MiHomeLib.JsonResponses; + +public class GetDeviceListResponse : MiioResponse +{ + public int Code { get; set; } + public List Result { get; set; } + + public int ExeTime { get; set; } + + public class GetDeviceListItem : MiioResponse + { + public string Did { get; set; } + public string Model { get; set; } + public int Num { get; set; } + public int Total { get; set; } + } +} diff --git a/MiHomeLib/JsonResponses/GetDevicePropResponse.cs b/MiHomeLib/JsonResponses/GetDevicePropResponse.cs new file mode 100644 index 0000000..737b4b7 --- /dev/null +++ b/MiHomeLib/JsonResponses/GetDevicePropResponse.cs @@ -0,0 +1,16 @@ +namespace MiHomeLib.JsonResponses; + +public class GetDevicePropResponse : MiioResponse +{ + public int Code { get; set; } + public int Id { get; set; } + public int[] Result { get; set; } + public int ExeTime { get; set; } + public MiioError Error { get; set; } + + public class MiioError + { + public int Code { get; set; } + public string Message { get; set; } + } +} diff --git a/MiHomeLib/JsonResponses/MiioResponse.cs b/MiHomeLib/JsonResponses/MiioResponse.cs new file mode 100644 index 0000000..813e52d --- /dev/null +++ b/MiHomeLib/JsonResponses/MiioResponse.cs @@ -0,0 +1,19 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MiHomeLib.JsonResponses; + +public abstract class MiioResponse +{ + private readonly JsonSerializerOptions _options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public override string ToString() + { + return JsonSerializer.Serialize(Convert.ChangeType(this, GetType()), _options); + } +} diff --git a/MiHomeLib/JsonResponses/ZigbeeHearbeatResponse.cs b/MiHomeLib/JsonResponses/ZigbeeHearbeatResponse.cs new file mode 100644 index 0000000..34cc772 --- /dev/null +++ b/MiHomeLib/JsonResponses/ZigbeeHearbeatResponse.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace MiHomeLib.JsonResponses; + +public class ZigbeeHearbeatResponse : MiioResponse +{ + public string Cmd { get; } = "heartbeat"; + public long Id { get; set; } + public double Time { get; set; } + public int Rssi { get; set; } + public List @Params { get; set; } + + public class ZigbeeHearbeatItem + { + public string Did { get; set; } + public double Time { get; set; } + public int Zseq { get; set; } + public List ResList { get; set; } + + public class ZigbeeHearbeatItemResource + { + public string ResName { get; set; } + public int Value { get; set; } + } + } +} diff --git a/MiHomeLib/JsonResponses/ZigbeeReportResponse.cs b/MiHomeLib/JsonResponses/ZigbeeReportResponse.cs new file mode 100644 index 0000000..5806574 --- /dev/null +++ b/MiHomeLib/JsonResponses/ZigbeeReportResponse.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace MiHomeLib.JsonResponses; + +public class ZigbeeReportResponse : MiioResponse +{ + public string Cmd { get; } = "report"; + public string Did { get; set; } + public double Time { get; set; } + public int Rssi { get; set; } + public int Zseq { get; set; } + public List @Params { get; set; } + public List @MiSpec { get; set; } + + public string DevSrc { get; set; } + + public class ZigbeeReportResource: MiioResponse + { + public string ResName { get; set; } + public int Value { get; set; } + } + + public class ZigbeeMiSpecItem + { + public int Siid { get; set; } + public int Piid { get; set; } + public object Value { get; set; } + } +} diff --git a/MiHomeLib/MiHome.cs b/MiHomeLib/MiHome.cs index c62d867..0b8fe19 100644 --- a/MiHomeLib/MiHome.cs +++ b/MiHomeLib/MiHome.cs @@ -10,273 +10,273 @@ using MiHomeLib.Devices; using Newtonsoft.Json.Linq; -namespace MiHomeLib +namespace MiHomeLib; + +[Obsolete("Please use XiaomiGateway2 instead")] +public class MiHome : IDisposable { - public class MiHome : IDisposable - { - private static ILogger _logger; - private static ILoggerFactory _loggerFactory; + private static ILogger _logger; + private static ILoggerFactory _loggerFactory; - private Gateway _gateway; - private readonly string _gatewaySid; - private static IMessageTransport _transport; - private readonly MiHomeDeviceFactory _miHomeDeviceFactory; - private readonly CancellationTokenSource _cts = new CancellationTokenSource(); + private Gateway _gateway; + private readonly string _gatewaySid; + private static IMessageTransport _transport; + private readonly MiHomeDeviceFactory _miHomeDeviceFactory; + private readonly CancellationTokenSource _cts = new CancellationTokenSource(); - private readonly Dictionary> _deviceEvents = new Dictionary>(); - - public static bool LogRawCommands { set; private get; } + private readonly Dictionary> _deviceEvents = new Dictionary>(); + + public static bool LogRawCommands { set; private get; } - public static ILoggerFactory LoggerFactory + public static ILoggerFactory LoggerFactory + { + set { - set - { - _loggerFactory = value; - _logger = _loggerFactory.CreateLogger(); - } - get - { - return _loggerFactory; - } + _loggerFactory = value; + _logger = _loggerFactory.CreateLogger(); } + get + { + return _loggerFactory; + } + } - private readonly ConcurrentDictionary _devicesList = - new ConcurrentDictionary(); - - private readonly Dictionary _namesMap; + private readonly ConcurrentDictionary _devicesList = + new ConcurrentDictionary(); - private const int ReadDeviceInterval = 100; + private readonly Dictionary _namesMap; - private readonly Dictionary> _commandsToActions; - private readonly Task _receiveTask; + private const int ReadDeviceInterval = 100; - public event EventHandler OnAnyDevice; - public event EventHandler OnGateway; + private readonly Dictionary> _commandsToActions; + private readonly Task _receiveTask; - public event EventHandler OnAqaraCubeSensor; - public event EventHandler OnAqaraMotionSensor; - public event EventHandler OnAqaraOpenCloseSensor; - public event EventHandler OnDoorWindowSensor; - public event EventHandler OnMotionSensor; - public event EventHandler OnSmokeSensor; - public event EventHandler OnSocketPlug; - public event EventHandler OnSwitch; - public event EventHandler OnThSensor; - public event EventHandler OnWaterLeakSensor; - public event EventHandler OnWeatherSensor; - public event EventHandler OnWiredDualWallSwitch; - public event EventHandler OnWirelessDualWallSwitch; + public event EventHandler OnAnyDevice; + public event EventHandler OnGateway; - public MiHome(string gatewayPassword = null, string gatewaySid = null) - { - _gatewaySid = gatewaySid; + public event EventHandler OnAqaraCubeSensor; + public event EventHandler OnAqaraMotionSensor; + public event EventHandler OnAqaraOpenCloseSensor; + public event EventHandler OnDoorWindowSensor; + public event EventHandler OnMotionSensor; + public event EventHandler OnSmokeSensor; + public event EventHandler OnSocketPlug; + public event EventHandler OnSwitch; + public event EventHandler OnThSensor; + public event EventHandler OnWaterLeakSensor; + public event EventHandler OnWeatherSensor; + public event EventHandler OnWiredDualWallSwitch; + public event EventHandler OnWirelessDualWallSwitch; - _commandsToActions = new Dictionary> - { - {ResponseCommandType.GetIdListAck, DiscoverGatewayAndDevices}, - {ResponseCommandType.ReadAck, ProcessReadAck}, - {ResponseCommandType.Report, ProcessReport}, - {ResponseCommandType.Hearbeat, ProcessHeartbeat}, - }; - - _deviceEvents = new Dictionary> - { - { typeof(AqaraCubeSensor), x => OnAqaraCubeSensor?.Invoke(this, x as AqaraCubeSensor)}, - { typeof(AqaraMotionSensor), x => OnAqaraMotionSensor?.Invoke(this, x as AqaraMotionSensor)}, - { typeof(AqaraOpenCloseSensor), x => OnAqaraOpenCloseSensor?.Invoke(this, x as AqaraOpenCloseSensor)}, - { typeof(DoorWindowSensor), x => OnDoorWindowSensor?.Invoke(this, x as DoorWindowSensor)}, - { typeof(MotionSensor), x => OnMotionSensor?.Invoke(this, x as MotionSensor)}, - { typeof(SmokeSensor), x => OnSmokeSensor?.Invoke(this, x as SmokeSensor)}, - { typeof(SocketPlug), x => OnSocketPlug?.Invoke(this, x as SocketPlug)}, - { typeof(Switch), x => OnSwitch?.Invoke(this, x as Switch)}, - { typeof(ThSensor), x => OnThSensor?.Invoke(this, x as ThSensor)}, - { typeof(WaterLeakSensor), x => OnWaterLeakSensor?.Invoke(this, x as WaterLeakSensor)}, - { typeof(WeatherSensor), x => OnWeatherSensor?.Invoke(this, x as WeatherSensor)}, - { typeof(WiredDualWallSwitch), x => OnWiredDualWallSwitch?.Invoke(this, x as WiredDualWallSwitch)}, - { typeof(WirelessDualWallSwitch), x => OnWirelessDualWallSwitch?.Invoke(this, x as WirelessDualWallSwitch)}, - }; - - _transport = new UdpTransport(gatewayPassword); - - _miHomeDeviceFactory = new MiHomeDeviceFactory(_transport); - - _receiveTask = Task.Run(() => StartReceivingMessagesAsync(_cts.Token), _cts.Token); - - _transport.SendCommand(new DiscoverGatewayCommand()); - } + public MiHome(string gatewayPassword = null, string gatewaySid = null) + { + _gatewaySid = gatewaySid; - public MiHome(Dictionary namesMap, string gatewayPassword = null, string gatewaySid = null) - : this(gatewayPassword, gatewaySid) + _commandsToActions = new Dictionary> { - if (namesMap.GroupBy(x => x.Value).Any(x => x.Count() > 1)) - { - throw new ArgumentException("Values in the dictionary must be unique"); - } + {ResponseCommandType.GetIdListAck, DiscoverGatewayAndDevices}, + {ResponseCommandType.ReadAck, ProcessReadAck}, + {ResponseCommandType.Report, ProcessReport}, + {ResponseCommandType.Hearbeat, ProcessHeartbeat}, + }; - _namesMap = namesMap; - } - - [Obsolete("Use OnGateway event instead")] - public Gateway GetGateway() + _deviceEvents = new Dictionary> { - return _gateway; - } + { typeof(AqaraCubeSensor), x => OnAqaraCubeSensor?.Invoke(this, x as AqaraCubeSensor)}, + { typeof(AqaraMotionSensor), x => OnAqaraMotionSensor?.Invoke(this, x as AqaraMotionSensor)}, + { typeof(AqaraOpenCloseSensor), x => OnAqaraOpenCloseSensor?.Invoke(this, x as AqaraOpenCloseSensor)}, + { typeof(DoorWindowSensor), x => OnDoorWindowSensor?.Invoke(this, x as DoorWindowSensor)}, + { typeof(MotionSensor), x => OnMotionSensor?.Invoke(this, x as MotionSensor)}, + { typeof(SmokeSensor), x => OnSmokeSensor?.Invoke(this, x as SmokeSensor)}, + { typeof(SocketPlug), x => OnSocketPlug?.Invoke(this, x as SocketPlug)}, + { typeof(Switch), x => OnSwitch?.Invoke(this, x as Switch)}, + { typeof(ThSensor), x => OnThSensor?.Invoke(this, x as ThSensor)}, + { typeof(WaterLeakSensor), x => OnWaterLeakSensor?.Invoke(this, x as WaterLeakSensor)}, + { typeof(WeatherSensor), x => OnWeatherSensor?.Invoke(this, x as WeatherSensor)}, + { typeof(WiredDualWallSwitch), x => OnWiredDualWallSwitch?.Invoke(this, x as WiredDualWallSwitch)}, + { typeof(WirelessDualWallSwitch), x => OnWirelessDualWallSwitch?.Invoke(this, x as WirelessDualWallSwitch)}, + }; + + _transport = new UdpTransport(gatewayPassword); + + _miHomeDeviceFactory = new MiHomeDeviceFactory(_transport); + + _receiveTask = Task.Run(() => StartReceivingMessagesAsync(_cts.Token), _cts.Token); + + _transport.SendCommand(new DiscoverGatewayCommand()); + } - [Obsolete("Use OnAnyDevice event instead")] - public IReadOnlyCollection GetDevices() + public MiHome(Dictionary namesMap, string gatewayPassword = null, string gatewaySid = null) + : this(gatewayPassword, gatewaySid) + { + if (namesMap.GroupBy(x => x.Value).Any(x => x.Count() > 1)) { - return (IReadOnlyCollection) _devicesList.Values; + throw new ArgumentException("Values in the dictionary must be unique"); } - [Obsolete("Use specific event, for example OnThSensor event")] - public T GetDeviceByName(string name) where T : MiHomeDevice - { - var device = _devicesList.Values.FirstOrDefault(x => x.Name == name); + _namesMap = namesMap; + } - if (device == null) return null; + [Obsolete("Use OnGateway event instead")] + public Gateway GetGateway() + { + return _gateway; + } - if (device is T d) return d; + [Obsolete("Use OnAnyDevice event instead")] + public IReadOnlyCollection GetDevices() + { + return (IReadOnlyCollection) _devicesList.Values; + } - throw new InvalidCastException($"Device with name '{name}' cannot be converted to {nameof(T)}"); - } + [Obsolete("Use specific event, for example OnThSensor event")] + public T GetDeviceByName(string name) where T : MiHomeDevice + { + var device = _devicesList.Values.FirstOrDefault(x => x.Name == name); - [Obsolete("Use specific event, for example OnThSensor event")] - public T GetDeviceBySid(string sid) where T : MiHomeDevice - { - if (!_devicesList.TryGetValue(sid, out var miHomeDevice)) - { - throw new ArgumentException($"There is no device with sid '{sid}'"); - } + if (device == null) return null; - if (!(miHomeDevice is T device)) - { - throw new InvalidCastException($"Device with sid '{sid}' cannot be converted to {nameof(T)}"); - } + if (device is T d) return d; - return device; - } + throw new InvalidCastException($"Device with name '{name}' cannot be converted to {nameof(T)}"); + } - [Obsolete("Use specific event, for example OnThSensor event")] - public IEnumerable GetDevicesByType() where T : MiHomeDevice + [Obsolete("Use specific event, for example OnThSensor event")] + public T GetDeviceBySid(string sid) where T : MiHomeDevice + { + if (!_devicesList.TryGetValue(sid, out var miHomeDevice)) { - return _devicesList.Values.OfType(); + throw new ArgumentException($"There is no device with sid '{sid}'"); } - public void Dispose() + if (!(miHomeDevice is T device)) { - _cts?.Cancel(); - _receiveTask?.Wait(); - _transport?.Dispose(); + throw new InvalidCastException($"Device with sid '{sid}' cannot be converted to {nameof(T)}"); } - private async Task StartReceivingMessagesAsync(CancellationToken ct) + return device; + } + + [Obsolete("Use specific event, for example OnThSensor event")] + public IEnumerable GetDevicesByType() where T : MiHomeDevice + { + return _devicesList.Values.OfType(); + } + + public void Dispose() + { + _cts?.Cancel(); + _receiveTask?.Wait(); + _transport?.Dispose(); + } + + private async Task StartReceivingMessagesAsync(CancellationToken ct) + { + // Receive messages + while (!ct.IsCancellationRequested) { - // Receive messages - while (!ct.IsCancellationRequested) + try { - try - { - var str = await _transport.ReceiveAsync().ConfigureAwait(false); - var respCmd = ResponseCommand.FromString(str); + var str = await _transport.ReceiveAsync().ConfigureAwait(false); + var respCmd = ResponseCommand.FromString(str); - if (LogRawCommands) _logger?.LogInformation(str); + if (LogRawCommands) _logger?.LogInformation(str); - if (!_commandsToActions.TryGetValue(respCmd.Command, out var actionCommand)) - { - _logger?.LogInformation($"Command '{respCmd.RawCommand}' is not a response command, skipping it"); - continue; - } - - actionCommand(respCmd); - } - catch (Exception e) + if (!_commandsToActions.TryGetValue(respCmd.Command, out var actionCommand)) { - _logger?.LogError(e, "Unexpected error"); + _logger?.LogInformation($"Command '{respCmd.RawCommand}' is not a response command, skipping it"); + continue; } + + actionCommand(respCmd); + } + catch (Exception e) + { + _logger?.LogError(e, "Unexpected error"); } } + } + + private void ProcessReport(ResponseCommand cmd) + { + GetOrAddDeviceByCommand(cmd)?.ParseData(cmd.Data); + } - private void ProcessReport(ResponseCommand cmd) + private void ProcessHeartbeat(ResponseCommand cmd) + { + if (cmd.Model != Gateway.TypeKey) { GetOrAddDeviceByCommand(cmd)?.ParseData(cmd.Data); } - - private void ProcessHeartbeat(ResponseCommand cmd) + else { - if (cmd.Model != Gateway.TypeKey) - { - GetOrAddDeviceByCommand(cmd)?.ParseData(cmd.Data); - } - else - { - _transport.Token = cmd.Token; - _gateway.ParseData(cmd.Data); - } + _transport.Token = cmd.Token; + _gateway.ParseData(cmd.Data); } + } + + private void ProcessReadAck(ResponseCommand cmd) + { + if (cmd.Model != Gateway.TypeKey) GetOrAddDeviceByCommand(cmd); + } - private void ProcessReadAck(ResponseCommand cmd) + private MiHomeDevice GetOrAddDeviceByCommand(ResponseCommand cmd) + { + if (_devicesList.TryGetValue(cmd.Sid, out var miHomeDevice)) { - if (cmd.Model != Gateway.TypeKey) GetOrAddDeviceByCommand(cmd); + return miHomeDevice; } - private MiHomeDevice GetOrAddDeviceByCommand(ResponseCommand cmd) + try { - if (_devicesList.TryGetValue(cmd.Sid, out var miHomeDevice)) - { - return miHomeDevice; - } + var device = _miHomeDeviceFactory.CreateByModel(cmd.Model, cmd.Sid); - try + if (_namesMap != null && _namesMap.TryGetValue(cmd.Sid, out var deviceName)) { - var device = _miHomeDeviceFactory.CreateByModel(cmd.Model, cmd.Sid); + device.Name = deviceName; + } - if (_namesMap != null && _namesMap.TryGetValue(cmd.Sid, out var deviceName)) - { - device.Name = deviceName; - } + if (cmd.Data != null) device.ParseData(cmd.Data); - if (cmd.Data != null) device.ParseData(cmd.Data); + if (_devicesList.TryAdd(cmd.Sid, device)) + { + OnAnyDevice?.Invoke(this, device); - if (_devicesList.TryAdd(cmd.Sid, device)) + if(_deviceEvents.ContainsKey(device.GetType())) { - OnAnyDevice?.Invoke(this, device); - - if(_deviceEvents.ContainsKey(device.GetType())) - { - _deviceEvents[device.GetType()](device); - } + _deviceEvents[device.GetType()](device); } - - return device; } - catch (ModelNotSupportedException e) - { - _logger?.LogWarning(e, "Model is unknown"); - return null; - } + return device; } + catch (ModelNotSupportedException e) + { + _logger?.LogWarning(e, "Model is unknown"); + + return null; + } + } - private void DiscoverGatewayAndDevices(ResponseCommand cmd) + private void DiscoverGatewayAndDevices(ResponseCommand cmd) + { + if (_gatewaySid != null && _gatewaySid != cmd.Sid) { - if (_gatewaySid != null && _gatewaySid != cmd.Sid) - { - throw new Exception("Gateway is not discovered, make sure that it is powered on"); - } + throw new Exception("Gateway is not discovered, make sure that it is powered on"); + } - _transport.Token = cmd.Token; + _transport.Token = cmd.Token; - _gateway = new Gateway(cmd.Sid, _transport); - OnGateway?.Invoke(this, _gateway); + _gateway = new Gateway(cmd.Sid, _transport); + OnGateway?.Invoke(this, _gateway); - _transport.SendCommand(new ReadDeviceCommand(cmd.Sid)); + _transport.SendCommand(new ReadDeviceCommand(cmd.Sid)); - foreach (var sid in JArray.Parse(cmd.Data)) - { - _transport.SendCommand(new ReadDeviceCommand(sid.ToString())); + foreach (var sid in JArray.Parse(cmd.Data)) + { + _transport.SendCommand(new ReadDeviceCommand(sid.ToString())); - Task.Delay(ReadDeviceInterval).Wait(); // need some time in order not to loose message - } + Task.Delay(ReadDeviceInterval).Wait(); // need some time in order not to loose message } } } \ No newline at end of file diff --git a/MiHomeLib/MiHomeLib.csproj b/MiHomeLib/MiHomeLib.csproj index 6d9d701..bccf0cd 100644 --- a/MiHomeLib/MiHomeLib.csproj +++ b/MiHomeLib/MiHomeLib.csproj @@ -1,20 +1,19 @@ - MiHomeLib netstandard2.0;net481 MiHomeLib - 1.0.15 + 2.0.0 2.6 Sergey Brutsky C# library for using xiaomi smart gateway in your scenarious false -Added support for Mi Robot Vacuum (rockrobo.vacuum.v1). -Check the details in the documentation. +Added support for Xiaomi Gateway 3 (ZNDMWG03LM) and several sensors +Check details in the documentation. Copyright 2017-2024 (c) Sergey Brutsky. All rights reserved. - xiaomi xiaomi-smart-home smarthome csharp netstandard13 netcore2 mihome + xiaomi xiaomi-smart-home gateway3 gateway2 smarthome csharp netstandard2 netcore2 mihome true https://github.com/sergey-brutsky/mi-home gateway.jpeg @@ -25,7 +24,6 @@ Check the details in the documentation. 1.0.10 - default @@ -35,14 +33,10 @@ Check the details in the documentation. - - - - + + - - diff --git a/MiHomeLib/Miio/AirHumidifier.cs b/MiHomeLib/Miio/AirHumidifier.cs deleted file mode 100644 index 4d4e23b..0000000 --- a/MiHomeLib/Miio/AirHumidifier.cs +++ /dev/null @@ -1,243 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using MiHomeLib.Events; - -[assembly: InternalsVisibleTo("MiHomeUnitTests")] - -namespace MiHomeLib.Devices -{ - public class AirHumidifier: MiioDevice - { - public enum Mode - { - Silent, - Medium, - High - }; - - public enum Brightness - { - Bright = 0, - Dim = 1, - Off = 2, - }; - - private readonly Dictionary _mapMode = new Dictionary - { - { "high", Mode.High}, - { "medium", Mode.Medium}, - { "silent", Mode.Silent}, - }; - - public const string version = "zhimi.humidifier.v1"; - - public static event EventHandler OnDiscovered; - - public AirHumidifier(string ip, string token) : base(new MiioTransport(ip, token)) { } - - internal AirHumidifier(IMiioTransport transport) : base(transport) { } - - public override string ToString() - { - var values = GetProps("power", "mode", "temp_dec", "humidity", "led_b", "buzzer", "child_lock", "limit_hum"); - var temp = $"{values[2].Substring(0, 2)}.{values[2].Substring(2, 1)} °C"; - var brightness = ((Brightness)Enum.Parse(typeof(Brightness), values[4])).ToString().ToLower(); - - return $"Power: {values[0]}\nMode: {values[1]}\nTemperature: {temp}\n" + - $"Humidity: {values[3]}%\nLED brightness: {brightness}\n" + - $"Buzzer: {values[5]}\nChild lock: {values[6]}\nTarget humidity: {values[7]}%\n" + - $"Model: {version}\nIP Address:{_miioTransport.Ip}\nToken: {_miioTransport.Token}"; - } - - public static void DiscoverDevices() - { - var discoveredHumidifiers = MiioTransport - .SendDiscoverMessage() - .Where(x => x.type == "0404"); // magic number identifying air humidifier - - foreach(var (ip, type, serial, token) in discoveredHumidifiers) - { - OnDiscovered?.Invoke(null, new DiscoverEventArgs(ip, type, serial, token)); - } - } - - public bool IsTurnedOn() - { - return GetProps("power")[0] == "on"; - } - - public async Task IsTurnedOnAsync() - { - return (await GetPropsAsync("power").ConfigureAwait(false))[0] == "on"; - } - - public void PowerOn() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_power", "on")); - CheckMessage(response, "Unable to power on air humidifier"); - } - - public async Task PowerOnAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_power", "on")).ConfigureAwait(false); - CheckMessage(response, "Unable to power on air humidifier"); - } - - public void PowerOff() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_power", "off")); - CheckMessage(response, "Unable to power off air humidifier"); - } - - public async Task PowerOffAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_power", "off")).ConfigureAwait(false); - CheckMessage(response, "Unable to power off air humidifier"); - } - - public Mode GetDeviceMode() - { - return _mapMode[GetProps("mode")[0]]; - } - - public async Task GetDeviceModeAsync() - { - return _mapMode[(await GetPropsAsync("mode").ConfigureAwait(false))[0]]; - } - - public void SetMode(Mode mode) - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_mode", mode.ToString().ToLower())); - CheckMessage(response, "Unable to set fan mode of air humidifier"); - } - - public async Task SetModeAsync(Mode mode) - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_mode", mode.ToString().ToLower())).ConfigureAwait(false); - CheckMessage(response, "Unable to set fan mode of air humidifier"); - } - - public float GetTemperature() - { - return int.Parse(GetProps("temp_dec")[0]) / 10f; - } - - public async Task GetTemperatureAsync() - { - return int.Parse((await GetPropsAsync("temp_dec").ConfigureAwait(false))[0]) / 10f; - } - - public int GetHumidity() - { - return int.Parse(GetProps("humidity")[0]); - } - - public async Task GetHumidityAsync() - { - return int.Parse((await GetPropsAsync("humidity").ConfigureAwait(false))[0]); - } - - public Brightness GetBrightness() - { - return (Brightness)Enum.Parse(typeof(Brightness), GetProps("led_b")[0]); - } - - public async Task GetBrightnessAsync() - { - return (Brightness)Enum.Parse(typeof(Brightness), (await GetPropsAsync("led_b").ConfigureAwait(false))[0]); - } - - public void SetBrightness(Brightness brightness) - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_led_b", brightness)); - CheckMessage(response, "Unable to set brightness of air humidifier"); - } - - public async Task SetBrightnessAsync(Brightness brightness) - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_led_b", brightness)).ConfigureAwait(false); - CheckMessage(response, "Unable to set brightness of air humidifier"); - } - - public int GetTargetHumidity() - { - return int.Parse(GetProps("limit_hum")[0]); - } - - public async Task GetTargetHumidityAsync() - { - return int.Parse((await GetPropsAsync("limit_hum").ConfigureAwait(false))[0]); - } - - public bool IsBuzzerOn() - { - return GetProps("buzzer")[0] == "on"; - } - - public async Task IsBuzzerOnAsync() - { - return (await GetPropsAsync("buzzer").ConfigureAwait(false))[0] == "on"; - } - - public void BuzzerOn() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_buzzer", "on")); - CheckMessage(response, "Unable to enable buzzer on air humidifier"); - } - - public async Task BuzzerOnAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_buzzer", "on")).ConfigureAwait(false); - CheckMessage(response, "Unable to enable buzzer on air humidifier"); - } - - public void BuzzerOff() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_buzzer", "off")); - CheckMessage(response, "Unable to disable buzzer on air humidifier"); - } - - public async Task BuzzerOffAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_buzzer", "off")).ConfigureAwait(false); - CheckMessage(response, "Unable to disable buzzer on air humidifier"); - } - - public bool IsChildLockOn() - { - return GetProps("child_lock")[0] == "on"; - } - - public async Task IsChildLockOnAsync() - { - return (await GetPropsAsync("child_lock").ConfigureAwait(false))[0] == "on"; - } - - public void ChildLockOn() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_child_lock", "on")); - CheckMessage(response, "Unable to enable child lock on air humidifier"); - } - - public async Task ChildLockOnAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_child_lock", "on")).ConfigureAwait(false); - CheckMessage(response, "Unable to enable child lock on air humidifier"); - } - - public void ChildLockOff() - { - var response = _miioTransport.SendMessage(BuildParamsArray("set_child_lock", "off")); - CheckMessage(response, "Unable to disable child lock on air humidifier"); - } - - public async Task ChildLockOffAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_child_lock", "off")).ConfigureAwait(false); - CheckMessage(response, "Unable to disable child lock on air humidifier"); - } - } -} diff --git a/MiHomeLib/Miio/MiRobotV1.cs b/MiHomeLib/Miio/MiRobotV1.cs deleted file mode 100644 index da1e7c0..0000000 --- a/MiHomeLib/Miio/MiRobotV1.cs +++ /dev/null @@ -1,183 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using MiHomeLib.Events; -using Newtonsoft.Json.Linq; - -[assembly: InternalsVisibleTo("MiHomeUnitTests")] - -namespace MiHomeLib.Devices -{ - public class MiRobotV1: MiioDevice - { - public enum Status - { - Unknown1 = 1, - ChargerDisconnected = 2, - Idle = 3, - Unknown2 = 4, - Cleaning = 5, - ReturningHome = 6, - ManualMode = 7, - Charging = 8, - Unknown3 = 9, - Paused = 10, - SpotCleaning = 11, - Error = 12, - Unknown4 = 13, - Updating = 14, - }; - - public const string version = "rockrobo.vacuum.v1"; - - public static event EventHandler OnDiscovered; - - public MiRobotV1(string ip, string token, int clientId = 0) : - base(new MiioTransport(ip, token), clientId) { } - - internal MiRobotV1(IMiioTransport transport) : base(transport) { } - - public override string ToString() - { - var response = _miioTransport.SendMessage(BuildParamsArray("get_status", "")); - - var values = (JObject.Parse(response)["result"] as JArray)[0] - .ToObject>(); - - return $"Model: {version}\nState: {(Status)int.Parse(values["state"])}\n" + - $"Battery: {values["battery"]} %\nFanspeed: {values["fan_power"]} %\n" + - $"Cleaning since: {values["clean_time"]} seconds\n" + - $"Cleaned area: {(float)int.Parse(values["clean_area"])/1_000_000} m²\n" + - $"IP Address: {_miioTransport.Ip}\nToken: {_miioTransport.Token}"; - } - - /// - /// Find all available mi robot vacuums in the LAN - /// - public static void DiscoverDevices() - { - var discoveredRobots = MiioTransport - .SendDiscoverMessage() - .Where(x => x.type == "05c5"); // magic number identifying rockrobo.vacuum.v1 - - foreach (var (ip, type, serial, token) in discoveredRobots) - { - OnDiscovered?.Invoke(null, new DiscoverEventArgs(ip, type, serial, token)); - } - } - - /// - /// This will make a robot to give a voice - /// - public void FindMe() - { - var response = _miioTransport.SendMessage(BuildParamsArray("find_me", "")); - CheckMessage(response, "Unable to find mi robot"); - } - - /// - /// This will make a robot to give a voice - /// - public async Task FindMeAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("find_me", "")); - CheckMessage(response, "Unable to find mi robot"); - } - - /// - /// Tell the robot to go to the base station - /// - public void Home() - { - var resp1 = _miioTransport.SendMessage(BuildParamsArray("app_pause", "")); - CheckMessage(resp1, "Unable to tell the robot to go to the base station"); - var resp2 = _miioTransport.SendMessage(BuildParamsArray("app_charge", "")); - CheckMessage(resp2, "Unable to tell the robot to go to the base station"); - } - - /// - /// Tell the robot to go to the base station - /// - public async Task HomeAsync() - { - var resp1 = await _miioTransport.SendMessageAsync(BuildParamsArray("app_pause", "")); - CheckMessage(resp1, "Unable to tell the robot to go to the base station"); - var resp2 = await _miioTransport.SendMessageAsync(BuildParamsArray("app_charge", "")); - CheckMessage(resp2, "Unable to tell the robot to go to the base station"); - } - - /// - /// Tell the robot to start cleaning - /// - public void Start() - { - var response = _miioTransport.SendMessage(BuildParamsArray("app_start", "")); - CheckMessage(response, "Unable to tell the robot to start cleaning"); - } - - /// - /// Tell the robot to start cleaning - /// - public async Task StartAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_start", "")); - CheckMessage(response, "Unable to tell the robot to start cleaning"); - } - - /// - /// Tell the robot to stop cleaning - /// - public void Stop() - { - var response = _miioTransport.SendMessage(BuildParamsArray("app_stop", "")); - CheckMessage(response, "Unable to tell the robot to stop cleaning"); - } - - /// - /// Tell the robot to stop cleaning - /// - public async Task StopAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_stop", "")); - CheckMessage(response, "Unable to tell the robot to stop cleaning"); - } - - /// - /// Tell the robot to pause cleaning - /// - public void Pause() - { - var response = _miioTransport.SendMessage(BuildParamsArray("app_pause", "")); - CheckMessage(response, "Unable to tell the robot to pause cleaning"); - } - - /// - /// Tell the robot to pause cleaning - /// - public async Task PauseAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_pause", "")); - CheckMessage(response, "Unable to tell the robot to pause cleaning"); - } - - /// - /// Tell the robot to start spot cleaning - /// - public void Spot() - { - var response = _miioTransport.SendMessage(BuildParamsArray("app_spot", "")); - CheckMessage(response, "Unable to tell the robot to start spot cleaning"); - } - - /// - /// Tell the robot to start spot cleaning - /// - public async Task SpotAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_spot", "")); - CheckMessage(response, "Unable to tell the robot to start spot cleaning"); - } - } -} diff --git a/MiHomeLib/Miio/MiioDevice.cs b/MiHomeLib/Miio/MiioDevice.cs deleted file mode 100644 index bc3e0e4..0000000 --- a/MiHomeLib/Miio/MiioDevice.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; - -namespace MiHomeLib.Devices -{ - public class MiioDevice - { - protected int _clientId; - protected readonly IMiioTransport _miioTransport; - - private readonly JsonSerializerSettings _serializerSettings = new JsonSerializerSettings(); - - public MiioDevice(IMiioTransport miioTransport, int initialClientId = 0) - { - _miioTransport = miioTransport; - _clientId = initialClientId; - _serializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); - - } - - protected void CheckMessage(string response, string errorMessage) - { - if (response.TrimEnd('\0') != $"{{\"result\":[\"ok\"],\"id\":{_clientId}}}") - { - throw new Exception($"{errorMessage}, miio protocol error --> {response}"); - } - } - - protected string GetString(string response) - { - return (string)GetResult(response); - } - - protected int GetInteger(string response, string msg) - { - var result = GetString(response); - - if (!int.TryParse(result, out int number)) - { - throw new Exception($"{msg}, value '{result}' seems to be corrupted"); - } - - return number; - } - - protected static JToken GetResult(string response) - { - return (JObject.Parse(response)["result"] as JArray)[0]; - } - - protected string BuildParamsArray(string method, params object[] methodParams) - { - return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {JsonConvert.SerializeObject(methodParams, _serializerSettings)}}}"; - } - - protected string BuildParamsObject(string method, object methodParams) - { - return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {JsonConvert.SerializeObject(methodParams, _serializerSettings)}}}"; - } - - protected string BuildSidProp(string method, string sid, string prop, int value) - { - return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {{\"sid\":\"{sid}\", \"{prop}\":{value}}}}}"; - } - - protected string[] GetProps(params string[] props) - { - var response = _miioTransport.SendMessage(BuildParamsArray("get_prop", props)); - var values = JObject.Parse(response)["result"] as JArray; - return values.Select(x => x.ToString()).ToArray(); - } - - protected async Task GetPropsAsync(params string[] props) - { - var response = await _miioTransport.SendMessageAsync(BuildParamsArray("get_prop", props)); - var values = JObject.Parse(response)["result"] as JArray; - return values.Select(x => x.ToString()).ToArray(); - } - - public void Dispose() - { - _miioTransport?.Dispose(); - } - } -} \ No newline at end of file diff --git a/MiHomeLib/Miio/MiioGateway.cs b/MiHomeLib/Miio/MiioGateway.cs deleted file mode 100644 index 961d53e..0000000 --- a/MiHomeLib/Miio/MiioGateway.cs +++ /dev/null @@ -1,402 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using MiHomeLib.Events; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -[assembly: InternalsVisibleTo("MiHomeUnitTests")] - -namespace MiHomeLib.Devices -{ - public class MiioGateway : MiioDevice - { - public const string version = "lumi.gateway.v3"; - - private readonly string[] _armingStates = new[] { "on", "off" }; - - public static event EventHandler OnDiscovered; - - public MiioGateway(string ip, string token) : base(new MiioTransport(ip, token)) { } - - internal MiioGateway(IMiioTransport transport) : base(transport) { } - - public bool IsArmingOn() - { - var msg = _miioTransport.SendMessage(BuildParamsArray("get_arming", new string[0])); - - return CheckArmingState(msg); - } - - public async Task IsArmingOnAsync() - { - var msg = await _miioTransport.SendMessageAsync(BuildParamsArray("get_arming", new string[0])).ConfigureAwait(false); - - return CheckArmingState(msg); - } - - private bool CheckArmingState(string msg) - { - var result = GetString(msg); - - if (!_armingStates.Contains(result)) - { - throw new Exception($"Arming state is unknown, looks like miio protocol error --> '{msg}'"); - }; - - return result == "on"; - } - - public void SetArmingOff() - { - var msg = BuildParamsArray("set_arming", "off"); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming off"); - } - - public async Task SetArmingOffAsync() - { - var msg = BuildParamsArray("set_arming", "off"); - var result = await _miioTransport.SendMessageAsync(msg).ConfigureAwait(false); - CheckMessage(result, "Unable to set arming off"); - } - - public void SetArmingOn() - { - var msg = BuildParamsArray("set_arming", "on"); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming on"); - } - - public async Task SetArmingOnAsync() - { - var msg = BuildParamsArray("set_arming", "on"); - var result = await _miioTransport.SendMessageAsync(msg).ConfigureAwait(false); - CheckMessage(result, "Unable to set arming on"); - } - - public int GetArmingWaitTime() - { - var msg = BuildParamsArray("get_arm_wait_time", new string[0]); - var result = _miioTransport.SendMessage(msg); - return CheckArmingWaitResult(result); - } - - public async Task GetArmingWaitTimeAsync() - { - var msg = BuildParamsArray("get_arm_wait_time", new string[0]); - var result = await _miioTransport.SendMessageAsync(msg); - return CheckArmingWaitResult(result); - } - - private int CheckArmingWaitResult(string result) - { - return GetInteger(result, "Unable to get arming wait time"); - } - - public void SetArmingWaitTime(int seconds) - { - var msg = BuildParamsArray("set_arm_wait_time", seconds); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming wait time"); - } - - public async Task SetArmingWaitTimeAsync(int seconds) - { - var msg = BuildParamsArray("set_arm_wait_time", seconds); - var result = await _miioTransport.SendMessageAsync(msg); - CheckMessage(result, "Unable to set arming wait time"); - } - - public int GetArmingOffTime() - { - var msg = BuildParamsArray("get_device_prop", "lumi.0", "alarm_time_len"); - var result = _miioTransport.SendMessage(msg); - return CheckArmingOffTimeResult(result); - } - - public async Task GetArmingOffTimeAsync() - { - var msg = BuildParamsArray("get_device_prop", "lumi.0", "alarm_time_len"); - var result = await _miioTransport.SendMessageAsync(msg); - return CheckArmingOffTimeResult(result); - } - - private int CheckArmingOffTimeResult(string result) - { - return GetInteger(result, "Unable to get arming off time"); - } - - public void SetArmingOffTime(int seconds) - { - var msg = BuildSidProp("set_device_prop", "lumi.0", "alarm_time_len", seconds); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming off time"); - } - - public async Task SetArmingOffTimeAsync(int seconds) - { - var msg = BuildSidProp("set_device_prop", "lumi.0", "alarm_time_len", seconds); - var result = await _miioTransport.SendMessageAsync(msg); - CheckMessage(result, "Unable to set arming off time"); - } - - public int GetArmingBlinkingTime() - { - var msg = BuildParamsArray("get_device_prop", "lumi.0", "en_alarm_light"); - var result = _miioTransport.SendMessage(msg); - return CheckArmingBlinkingResult(result); - } - - public async Task GetArmingBlinkingTimeAsync() - { - var msg = BuildParamsArray("get_device_prop", "lumi.0", "en_alarm_light"); - var result = await _miioTransport.SendMessageAsync(msg); - return CheckArmingBlinkingResult(result); - } - - private int CheckArmingBlinkingResult(string result) - { - return GetInteger(result, "Unable to get arming blinking time"); - } - - public void SetArmingBlinkingTime(int seconds) - { - var msg = BuildSidProp("set_device_prop", "lumi.0", "en_alarm_light", seconds); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming blinking time"); - } - - public async Task SetArmingBlinkingTimeAsync(int seconds) - { - var msg = BuildSidProp("set_device_prop", "lumi.0", "en_alarm_light", seconds); - var result = await _miioTransport.SendMessageAsync(msg); - CheckMessage(result, "Unable to set arming blinking time"); - } - - public int GetArmingVolume() - { - var msg = BuildParamsArray("get_alarming_volume", new string[0]); - var result = _miioTransport.SendMessage(msg); - return CheckArmingVolumeResult(result); - } - - public async Task GetArmingVolumeAsync() - { - var msg = BuildParamsArray("get_alarming_volume", new string[0]); - var result = await _miioTransport.SendMessageAsync(msg); - return CheckArmingVolumeResult(result); - } - - private int CheckArmingVolumeResult(string result) - { - return GetInteger(result, "Unable to get arming volume level"); - } - - public void SetArmingVolume(int volume) - { - var msg = BuildParamsArray("set_alarming_volume", volume); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to set arming volume level"); - } - - public async Task SetArmingVolumeAsync(int volume) - { - var msg = BuildParamsArray("set_alarming_volume", volume); - var result = await _miioTransport.SendMessageAsync(msg); - CheckMessage(result, "Unable to set arming volume level"); - } - - /// - /// Get last time when alarm was triggered unix timestamp - /// - /// unix timestamp seconds - public int GetArmingLastTimeTriggeredTimestamp() - { - var msg = BuildParamsArray("get_arming_time", new string[0]); - var result = _miioTransport.SendMessage(msg); - return CheckArmingLastTimeTriggeredResult(result); - } - - /// - /// Get last time when alarm was triggered unix timestamp - /// - /// unix timestamp seconds - public async Task GetArmingLastTimeTriggeredTimestampAsync() - { - var msg = BuildParamsArray("get_arming_time", new string[0]); - var result = await _miioTransport.SendMessageAsync(msg); - return CheckArmingLastTimeTriggeredResult(result); - } - - private int CheckArmingLastTimeTriggeredResult(string result) - { - return GetInteger(result, "Unable to get timestamp when arming was triggered"); - } - - /// - /// Get list or radio channels from gateway in format {id: , url: } - /// - /// List of RadioChannel - public List GetRadioChannels() - { - var response = _miioTransport.SendMessage(BuildParamsObject("get_channels", new { start = 0 })); - var channelsJson = JObject.Parse(response)["result"]["chs"].ToString(); - var radioChannels = JsonConvert.DeserializeObject>(channelsJson); - - return radioChannels.Skip(1).ToList(); - } - - /// - /// Get list or radio channels from gateway in format {id: , url: } - /// - /// List of RadioChannel - public async Task> GetRadioChannelsAsync() - { - var response = await _miioTransport.SendMessageAsync(BuildParamsObject("get_channels", new { start = 0 })); - var channelsJson = JObject.Parse(response)["result"]["chs"].ToString(); - - return JsonConvert.DeserializeObject>(channelsJson); - } - - /// - /// Add custom radio channel to the gateway - /// - public void AddRadioChannel(int channelId, string channelUrl) - { - if (channelId < 1024) throw new ArgumentException($"Radio channel id must be > 1024"); - - if(GetRadioChannels().Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} already exists, choose another id"); - - var msg = BuildParamsObject("add_channels", new { chs = new List{ new RadioChannel() { Id = channelId, Url = channelUrl, Type = 0} } }); - var result = _miioTransport.SendMessage(msg); - - CheckMessage(result, "Unable to add radio channel"); - } - - /// - /// Add custom radio channel to the gateway - /// - public async Task AddRadioChannelAsync(int channelId, string channelUrl) - { - if (channelId < 1024) throw new ArgumentException($"Radio channel id must be > 1024"); - - if ((await GetRadioChannelsAsync()).Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} already exists, choose another id"); - - var msg = BuildParamsObject("add_channels", new { chs = new List { new RadioChannel() { Id = channelId, Url = channelUrl, Type = 0 } } }); - var result = await _miioTransport.SendMessageAsync(msg); - - CheckMessage(result, "Unable to add radio channel"); - } - - /// - /// Remove custom radio channel from gateway stations list - /// - public void RemoveRadioChannel(int channelId) - { - var radioChannels = GetRadioChannels(); - - if (!radioChannels.Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); - - radioChannels.RemoveAll(x => x.Id != channelId); - - var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); - var result = _miioTransport.SendMessage(msg); - - CheckMessage(result, $"Unable to remove radio channel with id {channelId}"); - } - - /// - /// Remove custom radio channel from gateway stations list - /// - public async Task RemoveRadioChannelAsync(int channelId) - { - var radioChannels = await GetRadioChannelsAsync(); - - if (!radioChannels.Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); - - radioChannels.RemoveAll(x => x.Id != channelId); - - var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); - var result = await _miioTransport.SendMessageAsync(msg); - - CheckMessage(result, $"Unable to remove radio channel with id {channelId}"); - } - - /// - /// Clear all custom radio channels from the gateway - /// - public void RemoveAllRadioChannels() - { - var radioChannels = GetRadioChannels(); - var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); - var result = _miioTransport.SendMessage(msg); - CheckMessage(result, "Unable to remove all radio channels"); - } - - /// - /// Clear all custom radio channels from the gateway - /// - public async Task RemoveAllRadioChannelsAsync() - { - var radioChannels = await GetRadioChannelsAsync(); - var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); - var result = await _miioTransport.SendMessageAsync(msg); - CheckMessage(result, "Unable to remove all radio channels"); - } - - /// - /// Start playing custom channel - /// - public void PlayRadio(int channelId, int volume) - { - if (volume < 0 || volume > 100) - throw new ArgumentException($"Volume must be within range 0-100"); - - if (!GetRadioChannels().Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); - - var result = _miioTransport.SendMessage(BuildParamsArray("play_specify_fm", channelId, volume)); - CheckMessage(result, $"Unable to play channelId: {channelId} with volume {volume}"); - } - - /// - /// Start playing custom channel - /// - public async Task PlayRadioAsync(int channelId, int volume) - { - if (volume < 0 || volume > 100) - throw new ArgumentException($"Volume must be within range 0-100"); - - if (!(await GetRadioChannelsAsync()).Any(x => x.Id == channelId)) - throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); - - var result = await _miioTransport.SendMessageAsync(BuildParamsArray("play_specify_fm", channelId, volume)); - CheckMessage(result, $"Unable to play channelId: {channelId} with volume {volume}"); - } - - /// - /// Stop playing radio - /// - public void StopRadio() - { - var result = _miioTransport.SendMessage(BuildParamsArray("play_fm", "off")); - CheckMessage(result, $"Unable to stop playing radio"); - } - - /// - /// Stop playing radio - /// - public async Task StopRadioAsync() - { - var result = await _miioTransport.SendMessageAsync(BuildParamsArray("play_fm", "off")); - CheckMessage(result, $"Unable to stop playing radio"); - } - } -} diff --git a/MiHomeLib/Miio/MiioPacket.cs b/MiHomeLib/Miio/MiioPacket.cs deleted file mode 100644 index f7f4bcb..0000000 --- a/MiHomeLib/Miio/MiioPacket.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Security.Cryptography; -using System.Text; - -namespace MiHomeLib.Devices -{ - public class MiioPacket - { - private readonly string _magic; - private readonly string _length; - private readonly string _unknown1; - private readonly string _deviceType; - private readonly string _serial; - private readonly string _time; - private readonly string _checksum; - private readonly string _data; - - public MiioPacket(string hex) - { - _magic = hex.Substring(0, 4); - _length = hex.Substring(4, 5); - _unknown1 = hex.Substring(8, 8); - _deviceType = hex.Substring(16, 4); - _serial = hex.Substring(20, 4); - _time = hex.Substring(24, 8); - _checksum = hex.Substring(32, 32); - - if (hex.Length > 64) _data = hex.Substring(64, hex.Length - 64); - } - - public string BuildMessage(string msg, string token) - { - var key = Md5(token); - var iv = Md5($"{key}{token}"); - - var encryptedData = CryptoProvider.EncryptData(iv.ToByteArray(), key.ToByteArray(), Encoding.UTF8.GetBytes(msg)).ToHex(); - var dataLength = (encryptedData.Length/2+32).ToString("x").PadLeft(4, '0'); - var checksum = Md5($"{_magic}{dataLength}{_unknown1}{_deviceType}{_serial}{_time}{token}{encryptedData}"); - - return $"{_magic}{dataLength}{_unknown1}{_deviceType}{_serial}{_time}{checksum}{encryptedData}"; - } - - public string GetResponseData(string token) - { - var key = Md5(token); - var iv = Md5($"{key}{token}"); - var data = CryptoProvider.DecryptData(iv.ToByteArray(), key.ToByteArray(), _data.ToByteArray()); - - return Encoding.UTF8.GetString(data); - } - - public string GetDeviceType() => _deviceType; - - public string GetChecksum() => _checksum; - - public string GetSerial() => _serial; - - private string Md5(string data) => MD5.Create().ComputeHash(data.ToByteArray()).ToHex(); - } -} \ No newline at end of file diff --git a/MiHomeLib/Miio/RadioChannel.cs b/MiHomeLib/Miio/RadioChannel.cs deleted file mode 100644 index dff9e65..0000000 --- a/MiHomeLib/Miio/RadioChannel.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace MiHomeLib.Devices -{ - public class RadioChannel - { - public int Id { get; set; } - public string Url { get; set; } - public int Type { get; set; } - - public override string ToString() - { - return $"Radio channel -> Id: {Id}, Url: {Url}"; - } - - public override bool Equals(object obj) - { - if (obj is null) return false; - if (ReferenceEquals(this, obj)) return true; - if (obj.GetType() != GetType()) return false; - - RadioChannel other = (RadioChannel)obj; - - return Id == other.Id && Url == other.Url; - } - - public override int GetHashCode() - { - return new { Id, Url }.GetHashCode(); - } - } -} \ No newline at end of file diff --git a/MiHomeLib/MiioDevices/AirHumidifier.cs b/MiHomeLib/MiioDevices/AirHumidifier.cs new file mode 100644 index 0000000..7421b92 --- /dev/null +++ b/MiHomeLib/MiioDevices/AirHumidifier.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using MiHomeLib.Events; +using MiHomeLib.Transport; + +[assembly: InternalsVisibleTo("MiHomeUnitTests")] + +namespace MiHomeLib.MiioDevices; + +public class AirHumidifier : MiioDevice +{ + public enum Mode + { + Silent, + Medium, + High + }; + + public enum Brightness + { + Bright = 0, + Dim = 1, + Off = 2, + }; + + private readonly Dictionary _mapMode = new Dictionary + { + { "high", Mode.High}, + { "medium", Mode.Medium}, + { "silent", Mode.Silent}, + }; + + public const string version = "zhimi.humidifier.v1"; + + public static event EventHandler OnDiscovered; + + public AirHumidifier(string ip, string token) : base(new MiioTransport(ip, token)) { } + + internal AirHumidifier(IMiioTransport transport) : base(transport, 0) { } + + public override string ToString() + { + var values = GetProps("power", "mode", "temp_dec", "humidity", "led_b", "buzzer", "child_lock", "limit_hum"); + var temp = $"{values[2].Substring(0, 2)}.{values[2].Substring(2, 1)} °C"; + var brightness = ((Brightness)Enum.Parse(typeof(Brightness), values[4])).ToString().ToLower(); + + return $"Power: {values[0]}\nMode: {values[1]}\nTemperature: {temp}\n" + + $"Humidity: {values[3]}%\nLED brightness: {brightness}\n" + + $"Buzzer: {values[5]}\nChild lock: {values[6]}\nTarget humidity: {values[7]}%\n" + + $"Model: {version}\nIP Address:{_miioTransport.Ip}\nToken: {_miioTransport.Token}"; + } + + public static void DiscoverDevices() + { + var discoveredHumidifiers = MiioTransport + .SendDiscoverMessage() + .Where(x => x.type == "0404"); // magic number identifying air humidifier + + foreach (var (ip, type, serial, token) in discoveredHumidifiers) + { + OnDiscovered?.Invoke(null, new DiscoverEventArgs(ip, type, serial, token)); + } + } + + public bool IsTurnedOn() + { + return GetProps("power")[0] == "on"; + } + + public async Task IsTurnedOnAsync() + { + return (await GetPropsAsync("power").ConfigureAwait(false))[0] == "on"; + } + + public void PowerOn() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_power", "on")); + CheckMessage(response, "Unable to power on air humidifier"); + } + + public async Task PowerOnAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_power", "on")).ConfigureAwait(false); + CheckMessage(response, "Unable to power on air humidifier"); + } + + public void PowerOff() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_power", "off")); + CheckMessage(response, "Unable to power off air humidifier"); + } + + public async Task PowerOffAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_power", "off")).ConfigureAwait(false); + CheckMessage(response, "Unable to power off air humidifier"); + } + + public Mode GetDeviceMode() + { + return _mapMode[GetProps("mode")[0]]; + } + + public async Task GetDeviceModeAsync() + { + return _mapMode[(await GetPropsAsync("mode").ConfigureAwait(false))[0]]; + } + + public void SetMode(Mode mode) + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_mode", mode.ToString().ToLower())); + CheckMessage(response, "Unable to set fan mode of air humidifier"); + } + + public async Task SetModeAsync(Mode mode) + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_mode", mode.ToString().ToLower())).ConfigureAwait(false); + CheckMessage(response, "Unable to set fan mode of air humidifier"); + } + + public float GetTemperature() + { + return int.Parse(GetProps("temp_dec")[0]) / 10f; + } + + public async Task GetTemperatureAsync() + { + return int.Parse((await GetPropsAsync("temp_dec").ConfigureAwait(false))[0]) / 10f; + } + + public int GetHumidity() + { + return int.Parse(GetProps("humidity")[0]); + } + + public async Task GetHumidityAsync() + { + return int.Parse((await GetPropsAsync("humidity").ConfigureAwait(false))[0]); + } + + public Brightness GetBrightness() + { + return (Brightness)Enum.Parse(typeof(Brightness), GetProps("led_b")[0]); + } + + public async Task GetBrightnessAsync() + { + return (Brightness)Enum.Parse(typeof(Brightness), (await GetPropsAsync("led_b").ConfigureAwait(false))[0]); + } + + public void SetBrightness(Brightness brightness) + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_led_b", brightness)); + CheckMessage(response, "Unable to set brightness of air humidifier"); + } + + public async Task SetBrightnessAsync(Brightness brightness) + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_led_b", brightness)).ConfigureAwait(false); + CheckMessage(response, "Unable to set brightness of air humidifier"); + } + + public int GetTargetHumidity() + { + return int.Parse(GetProps("limit_hum")[0]); + } + + public async Task GetTargetHumidityAsync() + { + return int.Parse((await GetPropsAsync("limit_hum").ConfigureAwait(false))[0]); + } + + public bool IsBuzzerOn() + { + return GetProps("buzzer")[0] == "on"; + } + + public async Task IsBuzzerOnAsync() + { + return (await GetPropsAsync("buzzer").ConfigureAwait(false))[0] == "on"; + } + + public void BuzzerOn() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_buzzer", "on")); + CheckMessage(response, "Unable to enable buzzer on air humidifier"); + } + + public async Task BuzzerOnAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_buzzer", "on")).ConfigureAwait(false); + CheckMessage(response, "Unable to enable buzzer on air humidifier"); + } + + public void BuzzerOff() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_buzzer", "off")); + CheckMessage(response, "Unable to disable buzzer on air humidifier"); + } + + public async Task BuzzerOffAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_buzzer", "off")).ConfigureAwait(false); + CheckMessage(response, "Unable to disable buzzer on air humidifier"); + } + + public bool IsChildLockOn() + { + return GetProps("child_lock")[0] == "on"; + } + + public async Task IsChildLockOnAsync() + { + return (await GetPropsAsync("child_lock").ConfigureAwait(false))[0] == "on"; + } + + public void ChildLockOn() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_child_lock", "on")); + CheckMessage(response, "Unable to enable child lock on air humidifier"); + } + + public async Task ChildLockOnAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_child_lock", "on")).ConfigureAwait(false); + CheckMessage(response, "Unable to enable child lock on air humidifier"); + } + + public void ChildLockOff() + { + var response = _miioTransport.SendMessage(BuildParamsArray("set_child_lock", "off")); + CheckMessage(response, "Unable to disable child lock on air humidifier"); + } + + public async Task ChildLockOffAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("set_child_lock", "off")).ConfigureAwait(false); + CheckMessage(response, "Unable to disable child lock on air humidifier"); + } +} diff --git a/MiHomeLib/MiioDevices/MiRobotV1.cs b/MiHomeLib/MiioDevices/MiRobotV1.cs new file mode 100644 index 0000000..2371823 --- /dev/null +++ b/MiHomeLib/MiioDevices/MiRobotV1.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using MiHomeLib.Events; +using MiHomeLib.Transport; +using Newtonsoft.Json.Linq; + +[assembly: InternalsVisibleTo("MiHomeUnitTests")] + +namespace MiHomeLib.MiioDevices; + +public class MiRobotV1 : MiioDevice +{ + public enum Status + { + Unknown1 = 1, + ChargerDisconnected = 2, + Idle = 3, + Unknown2 = 4, + Cleaning = 5, + ReturningHome = 6, + ManualMode = 7, + Charging = 8, + Unknown3 = 9, + Paused = 10, + SpotCleaning = 11, + Error = 12, + Unknown4 = 13, + Updating = 14, + }; + + public const string version = "rockrobo.vacuum.v1"; + + public static event EventHandler OnDiscovered; + + public MiRobotV1(string ip, string token, int clientId = 0) : + base(new MiioTransport(ip, token), clientId) + { } + + internal MiRobotV1(IMiioTransport transport) : base(transport, 0) { } + + public override string ToString() + { + var response = _miioTransport.SendMessage(BuildParamsArray("get_status", "")); + + var values = (JObject.Parse(response)["result"] as JArray)[0] + .ToObject>(); + + return $"Model: {version}\nState: {(Status)int.Parse(values["state"])}\n" + + $"Battery: {values["battery"]} %\nFanspeed: {values["fan_power"]} %\n" + + $"Cleaning since: {values["clean_time"]} seconds\n" + + $"Cleaned area: {(float)int.Parse(values["clean_area"]) / 1_000_000} m²\n" + + $"IP Address: {_miioTransport.Ip}\nToken: {_miioTransport.Token}"; + } + + /// + /// Find all available mi robot vacuums in the LAN + /// + public static void DiscoverDevices() + { + var discoveredRobots = MiioTransport + .SendDiscoverMessage() + .Where(x => x.type == "05c5"); // magic number identifying rockrobo.vacuum.v1 + + foreach (var (ip, type, serial, token) in discoveredRobots) + { + OnDiscovered?.Invoke(null, new DiscoverEventArgs(ip, type, serial, token)); + } + } + + /// + /// This will make a robot to give a voice + /// + public void FindMe() + { + var response = _miioTransport.SendMessage(BuildParamsArray("find_me", "")); + CheckMessage(response, "Unable to find mi robot"); + } + + /// + /// This will make a robot to give a voice + /// + public async Task FindMeAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("find_me", "")); + CheckMessage(response, "Unable to find mi robot"); + } + + /// + /// Tell the robot to go to the base station + /// + public void Home() + { + var resp1 = _miioTransport.SendMessage(BuildParamsArray("app_pause", "")); + CheckMessage(resp1, "Unable to tell the robot to go to the base station"); + var resp2 = _miioTransport.SendMessage(BuildParamsArray("app_charge", "")); + CheckMessage(resp2, "Unable to tell the robot to go to the base station"); + } + + /// + /// Tell the robot to go to the base station + /// + public async Task HomeAsync() + { + var resp1 = await _miioTransport.SendMessageAsync(BuildParamsArray("app_pause", "")); + CheckMessage(resp1, "Unable to tell the robot to go to the base station"); + var resp2 = await _miioTransport.SendMessageAsync(BuildParamsArray("app_charge", "")); + CheckMessage(resp2, "Unable to tell the robot to go to the base station"); + } + + /// + /// Tell the robot to start cleaning + /// + public void Start() + { + var response = _miioTransport.SendMessage(BuildParamsArray("app_start", "")); + CheckMessage(response, "Unable to tell the robot to start cleaning"); + } + + /// + /// Tell the robot to start cleaning + /// + public async Task StartAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_start", "")); + CheckMessage(response, "Unable to tell the robot to start cleaning"); + } + + /// + /// Tell the robot to stop cleaning + /// + public void Stop() + { + var response = _miioTransport.SendMessage(BuildParamsArray("app_stop", "")); + CheckMessage(response, "Unable to tell the robot to stop cleaning"); + } + + /// + /// Tell the robot to stop cleaning + /// + public async Task StopAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_stop", "")); + CheckMessage(response, "Unable to tell the robot to stop cleaning"); + } + + /// + /// Tell the robot to pause cleaning + /// + public void Pause() + { + var response = _miioTransport.SendMessage(BuildParamsArray("app_pause", "")); + CheckMessage(response, "Unable to tell the robot to pause cleaning"); + } + + /// + /// Tell the robot to pause cleaning + /// + public async Task PauseAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_pause", "")); + CheckMessage(response, "Unable to tell the robot to pause cleaning"); + } + + /// + /// Tell the robot to start spot cleaning + /// + public void Spot() + { + var response = _miioTransport.SendMessage(BuildParamsArray("app_spot", "")); + CheckMessage(response, "Unable to tell the robot to start spot cleaning"); + } + + /// + /// Tell the robot to start spot cleaning + /// + public async Task SpotAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("app_spot", "")); + CheckMessage(response, "Unable to tell the robot to start spot cleaning"); + } +} diff --git a/MiHomeLib/MiioDevices/MiioDevice.cs b/MiHomeLib/MiioDevices/MiioDevice.cs new file mode 100644 index 0000000..6992486 --- /dev/null +++ b/MiHomeLib/MiioDevices/MiioDevice.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace MiHomeLib.MiioDevices; + +public class MiioDevice +{ + protected int _clientId; + protected readonly IMiioTransport _miioTransport; + private readonly JsonSerializerSettings _serializerSettings = new(); + + public MiioDevice(IMiioTransport miioTransport, int? initialClientId = null) + { + _miioTransport = miioTransport; + _clientId = initialClientId is null ? new Random().Next(1, 255) : initialClientId.Value; + _serializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + + } + + protected void CheckMessage(string response, string errorMessage) + { + if (response.TrimEnd('\0') != $"{{\"result\":[\"ok\"],\"id\":{_clientId}}}") + { + throw new Exception($"{errorMessage}, miio protocol error --> {response}"); + } + } + protected string GetString(string response) + { + return (string)GetResult(response); + } + protected int GetInteger(string response, string msg) + { + var result = GetString(response); + + if (!int.TryParse(result, out int number)) + { + throw new Exception($"{msg}, value '{result}' seems to be corrupted"); + } + + return number; + } + protected static JToken GetResult(string response) + { + return (JObject.Parse(response)["result"] as JArray)[0]; + } + + protected string BuildParamsArray(string method, params object[] methodParams) + { + return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {JsonConvert.SerializeObject(methodParams, _serializerSettings)}}}"; + } + + protected string BuildParamsObject(string method, object methodParams) + { + return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {JsonConvert.SerializeObject(methodParams, _serializerSettings)}}}"; + } + + protected string BuildSidProp(string method, string sid, string prop, int value) + { + return $"{{\"id\": {Interlocked.Increment(ref _clientId)}, \"method\": \"{method}\", \"params\": {{\"sid\":\"{sid}\", \"{prop}\":{value}}}}}"; + } + + protected string[] GetProps(params string[] props) + { + var response = _miioTransport.SendMessage(BuildParamsArray("get_prop", props)); + var values = JObject.Parse(response)["result"] as JArray; + return values.Select(x => x.ToString()).ToArray(); + } + + protected async Task GetPropsAsync(params string[] props) + { + var response = await _miioTransport.SendMessageAsync(BuildParamsArray("get_prop", props)); + var values = JObject.Parse(response)["result"] as JArray; + return values.Select(x => x.ToString()).ToArray(); + } + + public int GetClientId() => _clientId; + public void Dispose() + { + _miioTransport?.Dispose(); + } +} \ No newline at end of file diff --git a/MiHomeLib/MiioDevices/MiioGateway.cs b/MiHomeLib/MiioDevices/MiioGateway.cs new file mode 100644 index 0000000..f57049d --- /dev/null +++ b/MiHomeLib/MiioDevices/MiioGateway.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using MiHomeLib.Transport; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +[assembly: InternalsVisibleTo("MiHomeUnitTests")] + +namespace MiHomeLib.MiioDevices; + +public class MiioGateway : MiioDevice +{ + public const string version = "lumi.gateway.v3"; + + private readonly string[] _armingStates = ["on", "off"]; + + public MiioGateway(string ip, string token) : base(new MiioTransport(ip, token)) { } + + internal MiioGateway(IMiioTransport transport) : base(transport, 0) { } + + public bool IsArmingOn() + { + var msg = _miioTransport.SendMessage(BuildParamsArray("get_arming", new string[0])); + + return CheckArmingState(msg); + } + + public async Task IsArmingOnAsync() + { + var msg = await _miioTransport.SendMessageAsync(BuildParamsArray("get_arming", new string[0])).ConfigureAwait(false); + + return CheckArmingState(msg); + } + + private bool CheckArmingState(string msg) + { + var result = GetString(msg); + + if (!_armingStates.Contains(result)) + { + throw new Exception($"Arming state is unknown, looks like miio protocol error --> '{msg}'"); + }; + + return result == "on"; + } + + public void SetArmingOff() + { + var msg = BuildParamsArray("set_arming", "off"); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming off"); + } + + public async Task SetArmingOffAsync() + { + var msg = BuildParamsArray("set_arming", "off"); + var result = await _miioTransport.SendMessageAsync(msg).ConfigureAwait(false); + CheckMessage(result, "Unable to set arming off"); + } + + public void SetArmingOn() + { + var msg = BuildParamsArray("set_arming", "on"); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming on"); + } + + public async Task SetArmingOnAsync() + { + var msg = BuildParamsArray("set_arming", "on"); + var result = await _miioTransport.SendMessageAsync(msg).ConfigureAwait(false); + CheckMessage(result, "Unable to set arming on"); + } + + public int GetArmingWaitTime() + { + var msg = BuildParamsArray("get_arm_wait_time", new string[0]); + var result = _miioTransport.SendMessage(msg); + return CheckArmingWaitResult(result); + } + + public async Task GetArmingWaitTimeAsync() + { + var msg = BuildParamsArray("get_arm_wait_time", new string[0]); + var result = await _miioTransport.SendMessageAsync(msg); + return CheckArmingWaitResult(result); + } + + private int CheckArmingWaitResult(string result) + { + return GetInteger(result, "Unable to get arming wait time"); + } + + public void SetArmingWaitTime(int seconds) + { + var msg = BuildParamsArray("set_arm_wait_time", seconds); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming wait time"); + } + + public async Task SetArmingWaitTimeAsync(int seconds) + { + var msg = BuildParamsArray("set_arm_wait_time", seconds); + var result = await _miioTransport.SendMessageAsync(msg); + CheckMessage(result, "Unable to set arming wait time"); + } + + public int GetArmingOffTime() + { + var msg = BuildParamsArray("get_device_prop", "lumi.0", "alarm_time_len"); + var result = _miioTransport.SendMessage(msg); + return CheckArmingOffTimeResult(result); + } + + public async Task GetArmingOffTimeAsync() + { + var msg = BuildParamsArray("get_device_prop", "lumi.0", "alarm_time_len"); + var result = await _miioTransport.SendMessageAsync(msg); + return CheckArmingOffTimeResult(result); + } + + private int CheckArmingOffTimeResult(string result) + { + return GetInteger(result, "Unable to get arming off time"); + } + + public void SetArmingOffTime(int seconds) + { + var msg = BuildSidProp("set_device_prop", "lumi.0", "alarm_time_len", seconds); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming off time"); + } + + public async Task SetArmingOffTimeAsync(int seconds) + { + var msg = BuildSidProp("set_device_prop", "lumi.0", "alarm_time_len", seconds); + var result = await _miioTransport.SendMessageAsync(msg); + CheckMessage(result, "Unable to set arming off time"); + } + + public int GetArmingBlinkingTime() + { + var msg = BuildParamsArray("get_device_prop", "lumi.0", "en_alarm_light"); + var result = _miioTransport.SendMessage(msg); + return CheckArmingBlinkingResult(result); + } + + public async Task GetArmingBlinkingTimeAsync() + { + var msg = BuildParamsArray("get_device_prop", "lumi.0", "en_alarm_light"); + var result = await _miioTransport.SendMessageAsync(msg); + return CheckArmingBlinkingResult(result); + } + + private int CheckArmingBlinkingResult(string result) + { + return GetInteger(result, "Unable to get arming blinking time"); + } + + public void SetArmingBlinkingTime(int seconds) + { + var msg = BuildSidProp("set_device_prop", "lumi.0", "en_alarm_light", seconds); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming blinking time"); + } + + public async Task SetArmingBlinkingTimeAsync(int seconds) + { + var msg = BuildSidProp("set_device_prop", "lumi.0", "en_alarm_light", seconds); + var result = await _miioTransport.SendMessageAsync(msg); + CheckMessage(result, "Unable to set arming blinking time"); + } + + public int GetArmingVolume() + { + var msg = BuildParamsArray("get_alarming_volume", new string[0]); + var result = _miioTransport.SendMessage(msg); + return CheckArmingVolumeResult(result); + } + + public async Task GetArmingVolumeAsync() + { + var msg = BuildParamsArray("get_alarming_volume", new string[0]); + var result = await _miioTransport.SendMessageAsync(msg); + return CheckArmingVolumeResult(result); + } + + private int CheckArmingVolumeResult(string result) + { + return GetInteger(result, "Unable to get arming volume level"); + } + + public void SetArmingVolume(int volume) + { + var msg = BuildParamsArray("set_alarming_volume", volume); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to set arming volume level"); + } + + public async Task SetArmingVolumeAsync(int volume) + { + var msg = BuildParamsArray("set_alarming_volume", volume); + var result = await _miioTransport.SendMessageAsync(msg); + CheckMessage(result, "Unable to set arming volume level"); + } + + /// + /// Get last time when alarm was triggered unix timestamp + /// + /// unix timestamp seconds + public int GetArmingLastTimeTriggeredTimestamp() + { + var msg = BuildParamsArray("get_arming_time", new string[0]); + var result = _miioTransport.SendMessage(msg); + return CheckArmingLastTimeTriggeredResult(result); + } + + /// + /// Get last time when alarm was triggered unix timestamp + /// + /// unix timestamp seconds + public async Task GetArmingLastTimeTriggeredTimestampAsync() + { + var msg = BuildParamsArray("get_arming_time", new string[0]); + var result = await _miioTransport.SendMessageAsync(msg); + return CheckArmingLastTimeTriggeredResult(result); + } + + private int CheckArmingLastTimeTriggeredResult(string result) + { + return GetInteger(result, "Unable to get timestamp when arming was triggered"); + } + + /// + /// Get list or radio channels from gateway in format {id: , url: } + /// + /// List of RadioChannel + public List GetRadioChannels() + { + var response = _miioTransport.SendMessage(BuildParamsObject("get_channels", new { start = 0 })); + var channelsJson = JObject.Parse(response)["result"]["chs"].ToString(); + var radioChannels = JsonConvert.DeserializeObject>(channelsJson); + + return radioChannels.Skip(1).ToList(); + } + + /// + /// Get list or radio channels from gateway in format {id: , url: } + /// + /// List of RadioChannel + public async Task> GetRadioChannelsAsync() + { + var response = await _miioTransport.SendMessageAsync(BuildParamsObject("get_channels", new { start = 0 })); + var channelsJson = JObject.Parse(response)["result"]["chs"].ToString(); + + return JsonConvert.DeserializeObject>(channelsJson); + } + + /// + /// Add custom radio channel to the gateway + /// + public void AddRadioChannel(int channelId, string channelUrl) + { + if (channelId < 1024) throw new ArgumentException($"Radio channel id must be > 1024"); + + if (GetRadioChannels().Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} already exists, choose another id"); + + var msg = BuildParamsObject("add_channels", new { chs = new List { new RadioChannel() { Id = channelId, Url = channelUrl, Type = 0 } } }); + var result = _miioTransport.SendMessage(msg); + + CheckMessage(result, "Unable to add radio channel"); + } + + /// + /// Add custom radio channel to the gateway + /// + public async Task AddRadioChannelAsync(int channelId, string channelUrl) + { + if (channelId < 1024) throw new ArgumentException($"Radio channel id must be > 1024"); + + if ((await GetRadioChannelsAsync()).Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} already exists, choose another id"); + + var msg = BuildParamsObject("add_channels", new { chs = new List { new RadioChannel() { Id = channelId, Url = channelUrl, Type = 0 } } }); + var result = await _miioTransport.SendMessageAsync(msg); + + CheckMessage(result, "Unable to add radio channel"); + } + + /// + /// Remove custom radio channel from gateway stations list + /// + public void RemoveRadioChannel(int channelId) + { + var radioChannels = GetRadioChannels(); + + if (!radioChannels.Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); + + radioChannels.RemoveAll(x => x.Id != channelId); + + var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); + var result = _miioTransport.SendMessage(msg); + + CheckMessage(result, $"Unable to remove radio channel with id {channelId}"); + } + + /// + /// Remove custom radio channel from gateway stations list + /// + public async Task RemoveRadioChannelAsync(int channelId) + { + var radioChannels = await GetRadioChannelsAsync(); + + if (!radioChannels.Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); + + radioChannels.RemoveAll(x => x.Id != channelId); + + var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); + var result = await _miioTransport.SendMessageAsync(msg); + + CheckMessage(result, $"Unable to remove radio channel with id {channelId}"); + } + + /// + /// Clear all custom radio channels from the gateway + /// + public void RemoveAllRadioChannels() + { + var radioChannels = GetRadioChannels(); + var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); + var result = _miioTransport.SendMessage(msg); + CheckMessage(result, "Unable to remove all radio channels"); + } + + /// + /// Clear all custom radio channels from the gateway + /// + public async Task RemoveAllRadioChannelsAsync() + { + var radioChannels = await GetRadioChannelsAsync(); + var msg = BuildParamsObject("remove_channels", new { chs = radioChannels }); + var result = await _miioTransport.SendMessageAsync(msg); + CheckMessage(result, "Unable to remove all radio channels"); + } + + /// + /// Start playing custom channel + /// + public void PlayRadio(int channelId, int volume) + { + if (volume < 0 || volume > 100) + throw new ArgumentException($"Volume must be within range 0-100"); + + if (!GetRadioChannels().Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); + + var result = _miioTransport.SendMessage(BuildParamsArray("play_specify_fm", channelId, volume)); + CheckMessage(result, $"Unable to play channelId: {channelId} with volume {volume}"); + } + + /// + /// Start playing custom channel + /// + public async Task PlayRadioAsync(int channelId, int volume) + { + if (volume < 0 || volume > 100) + throw new ArgumentException($"Volume must be within range 0-100"); + + if (!(await GetRadioChannelsAsync()).Any(x => x.Id == channelId)) + throw new ArgumentException($"Radio channel with id {channelId} doesn't exist"); + + var result = await _miioTransport.SendMessageAsync(BuildParamsArray("play_specify_fm", channelId, volume)); + CheckMessage(result, $"Unable to play channelId: {channelId} with volume {volume}"); + } + + /// + /// Stop playing radio + /// + public void StopRadio() + { + var result = _miioTransport.SendMessage(BuildParamsArray("play_fm", "off")); + CheckMessage(result, $"Unable to stop playing radio"); + } + + /// + /// Stop playing radio + /// + public async Task StopRadioAsync() + { + var result = await _miioTransport.SendMessageAsync(BuildParamsArray("play_fm", "off")); + CheckMessage(result, $"Unable to stop playing radio"); + } +} diff --git a/MiHomeLib/MiioDevices/MiioPacket.cs b/MiHomeLib/MiioDevices/MiioPacket.cs new file mode 100644 index 0000000..d705b5b --- /dev/null +++ b/MiHomeLib/MiioDevices/MiioPacket.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MiHomeLib.MiioDevices; + +public class MiioPacket +{ + private readonly string _magic; + private readonly string _length; + private readonly string _unknown1; + private readonly string _deviceType; + private readonly string _serial; + private readonly string _time; + private readonly string _checksum; + private readonly string _data; + + public MiioPacket(string hex) + { + _magic = hex.Substring(0, 4); + _length = hex.Substring(4, 5); + _unknown1 = hex.Substring(8, 8); + _deviceType = hex.Substring(16, 4); + _serial = hex.Substring(20, 4); + _time = hex.Substring(24, 8); + _checksum = hex.Substring(32, 32); + + if (hex.Length > 64) _data = hex.Substring(64, hex.Length - 64); + } + + public string BuildMessage(string msg, string token) + { + var key = Md5(token); + var iv = Md5($"{key}{token}"); + + var encryptedData = CryptoProvider.EncryptData(iv.ToByteArray(), key.ToByteArray(), Encoding.UTF8.GetBytes(msg)).ToHex(); + var dataLength = (encryptedData.Length / 2 + 32).ToString("x").PadLeft(4, '0'); + var checksum = Md5($"{_magic}{dataLength}{_unknown1}{_deviceType}{_serial}{_time}{token}{encryptedData}"); + + return $"{_magic}{dataLength}{_unknown1}{_deviceType}{_serial}{_time}{checksum}{encryptedData}"; + } + + public string GetResponseData(string token) + { + var key = Md5(token); + var iv = Md5($"{key}{token}"); + var data = CryptoProvider.DecryptData(iv.ToByteArray(), key.ToByteArray(), _data.ToByteArray()); + + return Encoding.UTF8.GetString(data); + } + + public string GetDeviceType() => _deviceType; + + public string GetChecksum() => _checksum; + + public string GetSerial() => _serial; + + private string Md5(string data) => MD5.Create().ComputeHash(data.ToByteArray()).ToHex(); +} \ No newline at end of file diff --git a/MiHomeLib/MiioDevices/RadioChannel.cs b/MiHomeLib/MiioDevices/RadioChannel.cs new file mode 100644 index 0000000..554c9b2 --- /dev/null +++ b/MiHomeLib/MiioDevices/RadioChannel.cs @@ -0,0 +1,29 @@ +namespace MiHomeLib.MiioDevices; + +public class RadioChannel +{ + public int Id { get; set; } + public string Url { get; set; } + public int Type { get; set; } + + public override string ToString() + { + return $"Radio channel -> Id: {Id}, Url: {Url}"; + } + + public override bool Equals(object obj) + { + if (obj is null) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + RadioChannel other = (RadioChannel)obj; + + return Id == other.Id && Url == other.Url; + } + + public override int GetHashCode() + { + return new { Id, Url }.GetHashCode(); + } +} \ No newline at end of file diff --git a/MiHomeLib/Transport/IDevicesDiscoverer.cs b/MiHomeLib/Transport/IDevicesDiscoverer.cs new file mode 100644 index 0000000..46927fd --- /dev/null +++ b/MiHomeLib/Transport/IDevicesDiscoverer.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace MiHomeLib; + +public interface IDevicesDiscoverer +{ + List<(string did, int pdid, string mac)> DiscoverBleDevices(); + List<(string did, string model)> DiscoverZigBeeDevices(); +} \ No newline at end of file diff --git a/MiHomeLib/Transport/IMiioTransport.cs b/MiHomeLib/Transport/IMiioTransport.cs index 2f7ca0f..64b2492 100644 --- a/MiHomeLib/Transport/IMiioTransport.cs +++ b/MiHomeLib/Transport/IMiioTransport.cs @@ -1,13 +1,13 @@ using System; using System.Threading.Tasks; -namespace MiHomeLib.Devices +namespace MiHomeLib; + +public interface IMiioTransport: IDisposable { - public interface IMiioTransport: IDisposable - { - public string Ip { get;} - public string Token { get;} - string SendMessage(string msg); - Task SendMessageAsync(string msg); - } + public string Ip { get;} + public string Token { get;} + string SendMessageRepeated(string msg, int times = 3); + string SendMessage(string msg); + Task SendMessageAsync(string msg); } \ No newline at end of file diff --git a/MiHomeLib/Transport/IMqttTransport.cs b/MiHomeLib/Transport/IMqttTransport.cs new file mode 100644 index 0000000..68c4799 --- /dev/null +++ b/MiHomeLib/Transport/IMqttTransport.cs @@ -0,0 +1,9 @@ +using System; + +namespace MiHomeLib.Transport; + +public interface IMqttTransport : IDisposable +{ + void SendMessage(string message); + event Action OnMessageReceived; +} diff --git a/MiHomeLib/Transport/MiioTransport.cs b/MiHomeLib/Transport/MiioTransport.cs index 2d9a8a9..7ab5d4c 100644 --- a/MiHomeLib/Transport/MiioTransport.cs +++ b/MiHomeLib/Transport/MiioTransport.cs @@ -5,10 +5,11 @@ using System.Net.Sockets; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using MiHomeLib.MiioDevices; [assembly: InternalsVisibleTo("MiHomeUnitTests")] -namespace MiHomeLib.Devices +namespace MiHomeLib.Transport { public class MiioTransport : IMiioTransport { @@ -16,13 +17,15 @@ public class MiioTransport : IMiioTransport private readonly string _token; private readonly UdpClient _udpClient; private IPEndPoint _endpoint; - - private const int MESSAGES_TIMEOUT = 5000; // 5 seconds receive timeout + + private const int MESSAGES_TIMEOUT_MS = 5000; private const string HELLO_REQUEST = "21310020ffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; - - private readonly Exception _helloException = new Exception("Reponse hello package is corrupted, looks like miio protocol implemenation is broken"); - - private readonly Exception _timeoutException = new Exception($"Response has not been received in {MESSAGES_TIMEOUT / 1000} seconds." + + + private readonly Exception _helloException = + new("Reponse hello package is corrupted, looks like miio protocol implemenation is broken"); + + private readonly Exception _timeoutException = + new($"Response has not been received in {MESSAGES_TIMEOUT_MS / 1000} seconds." + $"Looks like miio protocol implementation is broken"); private MiioPacket initialPacket = null; @@ -35,9 +38,33 @@ public MiioTransport(string ip, string token, int port = 54321) _ip = ip ?? throw new Exception("IP of device must be provided"); _token = token ?? throw new Exception("Token for device communication must be provided"); - _endpoint = new IPEndPoint(IPAddress.Parse(ip), port); + _endpoint = new IPEndPoint(IPAddress.Parse(ip), port); _udpClient = new UdpClient(); - _udpClient.Client.ReceiveTimeout = MESSAGES_TIMEOUT; + _udpClient.Client.ReceiveTimeout = MESSAGES_TIMEOUT_MS; + } + + public string SendMessageRepeated(string msg, int times) + { + string result = string.Empty; + Exception ex = null; + + for (int i = 0; i < times; i++) + { + bool exceptionRaised = false; + try + { + result = SendMessage(msg); + } + catch (Exception e) + { + ex = e; + exceptionRaised = true; + } + + if (!exceptionRaised) return result; + } + + throw ex; } public string SendMessage(string msg) @@ -46,7 +73,7 @@ public string SendMessage(string msg) { SendHelloPacketIfNeeded(); - var requestHex = initialPacket.BuildMessage(msg, _token); + var requestHex = initialPacket.BuildMessage(msg, _token); _udpClient.SendTo(requestHex.ToByteArray(), _endpoint); var responseHex = _udpClient.Receive(ref _endpoint).ToHex(); @@ -118,12 +145,12 @@ private async Task SendHelloPacketIfNeededAsync() { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); - socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, MESSAGES_TIMEOUT); - socket.Bind(new IPEndPoint(IPAddress.Any, 0)); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, MESSAGES_TIMEOUT_MS); + socket.Bind(new IPEndPoint(IPAddress.Any, 0)); socket.SendTo(HELLO_REQUEST.ToByteArray(), new IPEndPoint(IPAddress.Broadcast, port)); var discoveredDevices = new List<(string ip, string type, string serial, string token)>(); - + try { var buffer = new byte[4096]; @@ -147,5 +174,7 @@ public void Dispose() { _udpClient?.Close(); } + + } } diff --git a/MiHomeLib/Transport/MqttDotNetTransport.cs b/MiHomeLib/Transport/MqttDotNetTransport.cs new file mode 100644 index 0000000..dc9cd9d --- /dev/null +++ b/MiHomeLib/Transport/MqttDotNetTransport.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.Extensions.Logging; +using MQTTnet; +using MQTTnet.Client; + +namespace MiHomeLib.Transport; + +public class MqttDotNetTransport : IMqttTransport, IDisposable +{ + private const double TIMEOUT_MS = 5_000; // timeout in milliseconds + private readonly ILogger _logger; + private readonly IMqttClient _mqttClient; + private readonly string _commandsTopic; + + public MqttDotNetTransport(string ip, int port, string[] listenTopics, string commandsTopic, ILoggerFactory loggerFactory) + { + _commandsTopic = commandsTopic; + _logger = loggerFactory.CreateLogger(GetType()); + _mqttClient = new MqttFactory().CreateMqttClient(); + + _mqttClient.ApplicationMessageReceivedAsync += x => + { + OnMessageReceived? + .Invoke( + x.ApplicationMessage.Topic, + System.Text.Encoding.UTF8.GetString([.. x.ApplicationMessage.PayloadSegment]) + ); + + return System.Threading.Tasks.Task.CompletedTask; + }; + + var mqttClientOptions = new MqttClientOptionsBuilder() + .WithTcpServer(ip, port) + .Build(); + + var subscriptionBuilder = new MqttFactory().CreateSubscribeOptionsBuilder(); + + Array.ForEach(listenTopics, topic => subscriptionBuilder.WithTopicFilter(topic)); + + _mqttClient.ConnectAsync(mqttClientOptions).Wait(TimeSpan.FromMilliseconds(TIMEOUT_MS)); + _logger.LogInformation($"MQTT client connected to the broker --> {ip}:{port}"); + + _mqttClient.SubscribeAsync(subscriptionBuilder.Build()).Wait(TimeSpan.FromMilliseconds(TIMEOUT_MS)); + _logger.LogInformation($"MQTT client subscribed to the topics --> {string.Join(",", listenTopics)}"); + + } + public void SendMessage(string message) + { + _mqttClient + .PublishStringAsync(_commandsTopic, message) + .Wait(TimeSpan.FromMilliseconds(TIMEOUT_MS)); + } + public event Action OnMessageReceived; + public void Dispose() => _mqttClient?.Dispose(); +} diff --git a/MiHomeLib/Transport/TelnetDevicesDiscoverer.cs b/MiHomeLib/Transport/TelnetDevicesDiscoverer.cs new file mode 100644 index 0000000..c4fe4e8 --- /dev/null +++ b/MiHomeLib/Transport/TelnetDevicesDiscoverer.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Data.SQLite; +using System.IO; +using System.Linq; +using System.Net.Sockets; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading; + +namespace MiHomeLib.Transport; + +internal class TelnetDevicesDiscoverer(string host, int port) : IDevicesDiscoverer +{ + private const int READ_DELAY_MS = 100; + private const int MAX_READ_ATTEMPTS = 100; + private const string END_MARKER = "# "; + private const string END_LINE = "\r\n"; + private const string BLE_DEVICES_PATH = "/data/miio/mible_local.db"; + private const string ZIGBEE_DEVICES_PATH = "/data/zigbee/device.info"; + private string ReadUntil(NetworkStream stream, string endMarker) + { + var sb = new StringBuilder(); + var attempt = 0; + + do + { + while (stream.DataAvailable) + { + var resp = Encoding.ASCII.GetString(stream.ReceiveBytes()); + sb.Append(resp); + if (resp.EndsWith(endMarker)) + return sb + .ToString() + .TrimEnd(END_MARKER.ToCharArray()) + .TrimEnd(END_LINE.ToCharArray()); + } + Thread.Sleep(READ_DELAY_MS); + } while (attempt < MAX_READ_ATTEMPTS); + + throw new Exception($"No data ending with marker '{endMarker}' detected"); + } + private void WriteStringToStream(NetworkStream stream, string s) + { + var bytes = Encoding.ASCII.GetBytes(s + END_LINE); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(); + } + public byte[] ReadFileByPath(string path) + { + using var stream = new TcpClient(host, port).GetStream(); + + ReadUntil(stream, "rlxlinux login: "); + WriteStringToStream(stream, "admin"); + ReadUntil(stream, END_MARKER); + WriteStringToStream(stream, $"ls {path}"); + + if (ReadUntil(stream, END_MARKER).EndsWith("No such file or directory")) + { + throw new Exception($"Looks like file '{path}' is not found on your device"); + } + + var cmd = $"cat {path} | base64"; + + WriteStringToStream(stream, cmd); + + var data = ReadUntil(stream, END_MARKER) + .Replace(cmd + END_LINE, string.Empty) + .Replace(END_LINE, string.Empty); + + stream.Close(); + + return Convert.FromBase64String(data); + } + public List<(string did, int pdid, string mac)> DiscoverBleDevices() + { + var bleDevicesList = new List<(string did, int pdid, string mac)>(); + + var tmpName = Path.GetTempFileName(); + File.WriteAllBytes(tmpName, ReadFileByPath(BLE_DEVICES_PATH)); + + using var conn = new SQLiteConnection($"Data Source={tmpName}"); + conn.Open(); + + var command = conn.CreateCommand(); + command.CommandText = "SELECT mac, pid, did FROM gateway_authed_table"; + + using var reader = command.ExecuteReader(); + + while (reader.Read()) + { + var mac = reader.GetString(0); + var pdid = reader.GetInt32(1); + var did = reader.GetString(2); + bleDevicesList.Add((did, pdid, mac)); + } + + File.Delete(tmpName); + + return bleDevicesList; + } + public List<(string did, string model)> DiscoverZigBeeDevices() + { + var json = JsonNode.Parse(Encoding.ASCII.GetString(ReadFileByPath(ZIGBEE_DEVICES_PATH))); + + return json["devInfo"] + .AsArray() + .Select(x => (x["did"].ToString(), x["model"].ToString())) + .ToList(); + } +} diff --git a/MiHomeLib/Utils/Helpers.cs b/MiHomeLib/Utils/Helpers.cs index 2a042f0..8a26f8c 100644 --- a/MiHomeLib/Utils/Helpers.cs +++ b/MiHomeLib/Utils/Helpers.cs @@ -1,130 +1,127 @@ -using System; -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; +using MiHomeLib.DevicesV3; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace MiHomeLib +namespace MiHomeLib; +public static class Helpers { - public static class Helpers + public static byte[] ToByteArray(this string hex) { - public static void SendTo(this UdpClient udpClient, byte[] bytes, IPEndPoint endpoint) - { - udpClient.Send(bytes, bytes.Length, endpoint); - } - - public static Task SendAsync(this UdpClient udpClient, byte[] bytes, IPEndPoint endpoint) - { - return udpClient.SendAsync(bytes, bytes.Length, endpoint); - } - - public static async Task ReceiveBytesAsync(this Socket socket, int bufferSize = 4096) - { - var buffer = new ArraySegment(new byte[bufferSize]); - var received = await socket.ReceiveAsync(buffer, SocketFlags.None).ConfigureAwait(false); - - if (received > bufferSize) - { - throw new Exception("Data received is greater than buffer size"); - } + return Enumerable + .Range(0, hex.Length) + .Where(x => x % 2 == 0) + .Select(x => Convert.ToByte(hex.Substring(x, 2), 16)) + .ToArray(); + } - return buffer.Take(received).ToArray(); - } + public static string ToHex(this byte[] byteArray) + { + return BitConverter.ToString(byteArray).Replace("-", string.Empty).ToLower(); + } + public static float ToBleFloat(this string hex) + { + // hex string is little endian ! + var arr = ToByteArray(hex); + arr.Reverse(); + return BitConverter.ToInt16(arr, 0)/10f; + } + public static byte ToBleByte(this string hex) + { + return ToByteArray(hex)[0]; + } - public static byte[] ReceiveBytes(this Socket socket, int bufferSize = 4096) - { - var buffer = new byte[bufferSize]; - - var recevied = socket.Receive(buffer); - - if(recevied > bufferSize) - { - throw new Exception("Data received is greater than buffer size"); - } + public static int ToBleInt256(this string hex) + { + var res = 0; + var start = 0; + + foreach (var val in ToByteArray(hex)) res += val*(int)Math.Pow(256, start++); + + return res; + } - return new ArraySegment(buffer, 0, recevied).ToArray(); - } + public static DateTime UnixSecondsToDateTime(this double unixTimeStamp) + { + return UnixMilliSecondsToDateTime(unixTimeStamp * 1000); + } - public static ArraySegment ToArraySegment(this string hex) - { - return new ArraySegment(hex.ToByteArray()); - } + public static DateTime UnixMilliSecondsToDateTime(this double unixTimeStamp) + { + DateTime dateTime = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + dateTime = dateTime.AddMilliseconds(unixTimeStamp).ToLocalTime(); + return dateTime; + } - public static byte[] ToByteArray(this string hex) + public static string CreateCommand(string cmd, string model, string sid, int short_id, Dictionary data) + { + var dict = new Dictionary() { - return Enumerable - .Range(0, hex.Length) - .Where(x => x % 2 == 0) - .Select(x => Convert.ToByte(hex.Substring(x, 2), 16)) - .ToArray(); - } - - public static string ToHex(this byte[] byteArray) + { "cmd", cmd}, + { "model", model}, + { "sid", sid}, + { "short_id", short_id}, + { "data", JsonConvert.SerializeObject(data) }, + }; + + return System.Text.Json.JsonSerializer.Serialize(dict); + } + public static bool ParseString(this JObject jObject, string key, out string s) + { + if (jObject[key] != null) { - return BitConverter.ToString(byteArray).Replace("-", string.Empty).ToLower(); + s = jObject[key].ToString(); + return true; } - public static string CreateCommand(string cmd, string model, string sid, int short_id, Dictionary data) + s = null; + return false; + } + public static bool ParseInt(this JObject jObject, string key, out int i) + { + if (jObject[key] != null && int.TryParse(jObject[key].ToString(), out int f1)) { - var dict = new Dictionary() - { - { "cmd", cmd}, - { "model", model}, - { "sid", sid}, - { "short_id", short_id}, - { "data", JsonConvert.SerializeObject(data) }, - }; - - return JsonConvert.SerializeObject(dict); + i = f1; + return true; } - public static bool ParseString(this JObject jObject, string key, out string s) + i = 0; + return false; + } + public static bool ParseFloat(this JObject jObject, string key, out float f) + { + if (jObject[key] != null && float.TryParse(jObject[key].ToString(), out float f1)) { - if (jObject[key] != null) - { - s = jObject[key].ToString(); - return true; - } - - s = null; - return false; + f = f1; + return true; } - public static bool ParseInt(this JObject jObject, string key, out int f) + f = 0; + return false; + } + public static float? ParseVoltage(this JObject jObject) + { + if (jObject.ParseFloat("voltage", out float v)) { - if (jObject[key] != null && int.TryParse(jObject[key].ToString(), out int f1)) - { - f = f1; - return true; - } - - f = 0; - return false; + return v / 1000; } - public static bool ParseFloat(this JObject jObject, string key, out float f) - { - if (jObject[key] != null && float.TryParse(jObject[key].ToString(), out float f1)) - { - f = f1; - return true; - } - - f = 0; - return false; - } + return null; + } + public static string DecodeMacAddress(this string mac) + { + var chunks = Enumerable + .Range(0, mac.Length / 2) + .Select(i => mac.Substring(i * 2, 2)) + .Reverse(); - public static float? ParseVoltage(this JObject jObject) - { - if(jObject.ParseFloat("voltage", out float v)) - { - return v / 1000; - } + return string.Join(":", chunks); + } - return null; - } + public static IEnumerable EnumToIntegers() + { + return ((T[])Enum.GetValues(typeof(T))).Select(x => Convert.ToInt32(x)); } } diff --git a/MiHomeLib/Utils/ITimer.cs b/MiHomeLib/Utils/ITimer.cs new file mode 100644 index 0000000..f4d956a --- /dev/null +++ b/MiHomeLib/Utils/ITimer.cs @@ -0,0 +1,10 @@ +using System.Timers; + +namespace MiHomeLib.Utils; + +public interface ITimer +{ + void Start(); + void Stop(); + event ElapsedEventHandler Elapsed; +} diff --git a/MiHomeLib/Utils/NetworkHelpers.cs b/MiHomeLib/Utils/NetworkHelpers.cs new file mode 100644 index 0000000..171dc2c --- /dev/null +++ b/MiHomeLib/Utils/NetworkHelpers.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace MiHomeLib; + +public static class NetworkHelpers +{ + public static void SendTo(this UdpClient udpClient, byte[] bytes, IPEndPoint endpoint) + { + udpClient.Send(bytes, bytes.Length, endpoint); + } + + public static Task SendAsync(this UdpClient udpClient, byte[] bytes, IPEndPoint endpoint) + { + return udpClient.SendAsync(bytes, bytes.Length, endpoint); + } + + public static async Task ReceiveBytesAsync(this Socket socket, int bufferSize = 4096) + { + var buffer = new ArraySegment(new byte[bufferSize]); + var received = await socket.ReceiveAsync(buffer, SocketFlags.None).ConfigureAwait(false); + + if (received > bufferSize) + { + throw new Exception("Data received is greater than buffer size"); + } + + return buffer.Take(received).ToArray(); + } + + public static byte[] ReceiveBytes(this Socket socket, int bufferSize = 4096) + { + var buffer = new byte[bufferSize]; + + var recevied = socket.Receive(buffer); + + if (recevied > bufferSize) + { + throw new Exception("Data received is greater than buffer size"); + } + + return new ArraySegment(buffer, 0, recevied).ToArray(); + } + + public static byte[] ReceiveBytes(this NetworkStream stream, int bufferSize = 4096) + { + var buffer = new byte[bufferSize]; + + var recevied = stream.Read(buffer, 0, buffer.Length); + + if (recevied > bufferSize) + { + throw new Exception("Data received is greater than buffer size"); + } + + return new ArraySegment(buffer, 0, recevied).ToArray(); + } + + public static void Write(this NetworkStream stream, string s) + { + var bytes = System.Text.Encoding.ASCII.GetBytes(s); + + stream.Write(bytes, 0, bytes.Length); + } +} diff --git a/MiHomeLib/Utils/SimpleTimer.cs b/MiHomeLib/Utils/SimpleTimer.cs new file mode 100644 index 0000000..b692fc7 --- /dev/null +++ b/MiHomeLib/Utils/SimpleTimer.cs @@ -0,0 +1,21 @@ +using System; +using System.Timers; + +namespace MiHomeLib.Utils; + +public class SimpleTimer : ITimer +{ + private readonly Timer _timer = new(); + + public SimpleTimer(TimeSpan timeSpan) => _timer.Interval = timeSpan.TotalMilliseconds; + + public void Start() => _timer.Start(); + + public void Stop() => _timer.Stop(); + + public event ElapsedEventHandler Elapsed + { + add => _timer.Elapsed += value; + remove => _timer.Elapsed -= value; + } +} diff --git a/MiHomeLib/XiaomiGateway2.cs b/MiHomeLib/XiaomiGateway2.cs new file mode 100644 index 0000000..e3edbd0 --- /dev/null +++ b/MiHomeLib/XiaomiGateway2.cs @@ -0,0 +1,7 @@ +namespace MiHomeLib; + +#pragma warning disable CS0618 // Type or member is obsolete +public class XiaomiGateway2(string gatewayPassword = null, string gatewaySid = null) : MiHome(gatewayPassword, gatewaySid) +#pragma warning restore CS0618 // Type or member is obsolete +{ +} diff --git a/MiHomeLib/XiaomiGateway3.cs b/MiHomeLib/XiaomiGateway3.cs new file mode 100644 index 0000000..7f53969 --- /dev/null +++ b/MiHomeLib/XiaomiGateway3.cs @@ -0,0 +1,222 @@ +using System; +using Microsoft.Extensions.Logging; +using System.Text.Json.Nodes; +using System.Collections.Generic; +using Microsoft.Extensions.Logging.Abstractions; +using MiHomeLib.DevicesV3; +using System.Linq; +using System.Reflection; +using MiHomeLib.ActionProcessors; +using MiHomeLib.MiioDevices; +using MiHomeLib.Transport; + +namespace MiHomeLib; + +public class XiaomiGateway3 : MiioDevice, IDisposable +{ + private static ILoggerFactory _loggerFactory = new NullLoggerFactory(); + private static ILogger _logger = _loggerFactory.CreateLogger(); + private readonly IDevicesDiscoverer _devicesDiscoverer; + private readonly IMqttTransport _mqttTransport; + private readonly Dictionary _supportedActionProcessors; + private readonly Dictionary> _supportedModels = []; + private readonly Dictionary _pdidToModel = []; + private readonly Dictionary _devices = []; + public event Action OnDeviceDiscovered; + private static readonly string _zigbeeCommandsTopic = "zigbee/recv"; + private static readonly string[] _zigbeeTopics = [ "zigbee/send" ]; + private static readonly string[] _bleTopics = [ "miio/report", "central/report" ]; + /// + /// Xiaomi Multimode Gateway (CN) ZNDMWG03LM + /// + public XiaomiGateway3(string ip, string token, int port = 1883): + this( + new MiioTransport(ip, token), + new MqttDotNetTransport(ip, port, [.. _zigbeeTopics, .. _bleTopics], _zigbeeCommandsTopic, _loggerFactory), + new TelnetDevicesDiscoverer(ip, 23) + ) {} + internal XiaomiGateway3(IMiioTransport miioTransport, IMqttTransport mqttTransport, IDevicesDiscoverer devicesDiscoverer) : base(miioTransport) + { + _supportedActionProcessors = new() + { + { ZigbeeReportCommandProcessor.ACTION, new ZigbeeReportCommandProcessor(_devices, _loggerFactory) }, + { ZigbeeHeartBeatCommandProcessor.ACTION, new ZigbeeHeartBeatCommandProcessor(_devices, _loggerFactory) }, + { AsyncBleEventMethodProcessor.ACTION, new AsyncBleEventMethodProcessor(_devices, _loggerFactory) } + }; + + _mqttTransport = mqttTransport; + _devicesDiscoverer = devicesDiscoverer; + + // Building map of the supported devices via reflection props + foreach (Type type in Assembly + .GetExecutingAssembly() + .GetTypes() + .Where(x => x.IsClass && !x.IsAbstract && (x.IsSubclassOf(typeof(ZigBeeDevice)) || x.IsSubclassOf(typeof(BleDevice))))) + { + var bindFlags = BindingFlags.Public | BindingFlags.Static; + var model = type.GetField("MODEL", bindFlags).GetValue(type).ToString(); + + XiaomiGateway3SubDevice addDevice(string did) => + type.IsSubclassOf(typeof(ZigBeeManageableDevice)) ? + Activator.CreateInstance(type, did, _mqttTransport, _loggerFactory) as XiaomiGateway3SubDevice: + Activator.CreateInstance(type, did, _loggerFactory) as XiaomiGateway3SubDevice; + + _supportedModels.Add(model, addDevice); + + if(!type.IsSubclassOf(typeof(BleDevice))) continue; + + _pdidToModel.Add((int)type.GetField("PDID", bindFlags).GetValue(type), model); + } + } + public void DiscoverDevices() + { + var action = string.Empty; + + _mqttTransport.OnMessageReceived += (topic, msg) => + { + _logger.LogInformation($"{topic} --> {msg}"); + + var json = JsonNode.Parse(msg); + + switch(topic) + { + case var _ when _zigbeeTopics.Contains(topic): + action = json["cmd"].ToString(); + break; + case var _ when _bleTopics.Contains(topic): + action = json["method"].ToString(); + break; + default: + _logger.LogWarning($"Topic '{topic}' is not supported. Please contribute to support."); + break; + } + + if(!_supportedActionProcessors.ContainsKey(action)) + { + _logger.LogWarning($"Command/Method '{action}' is unknown. Please contribute to support."); + return; + } + + _supportedActionProcessors[action].ProcessMessage(json); + }; + + DiscoverZigbeeDevices(); + DiscoverBleDevices(); + } + public static void SetLoggerFactory(ILoggerFactory value) + { + if(value is null) return; + + _loggerFactory = value; + _logger = _loggerFactory.CreateLogger(); + } + public List GetDevices() => [.. _devices.Values]; + public T GetDeviceByDid(string did) where T : XiaomiGateway3SubDevice + { + if(!_devices.ContainsKey(did)) return null; + + var device = _devices[did]; + + if(device is not T) return null; + + return device as T; + } + public new void Dispose() + { + _mqttTransport?.Dispose(); + base.Dispose(); + } + private void DiscoverZigbeeDevices() + { + var zigbeeDeviceList = _devicesDiscoverer.DiscoverZigBeeDevices(); + + if (zigbeeDeviceList.Count == 0) + { + _logger.LogWarning($"Gateway '{_miioTransport.Ip}' has no connected zigbee devices"); + return; + } + + foreach (var device in zigbeeDeviceList) + { + var model = device.model; + + if (!_supportedModels.ContainsKey(model)) + { + _logger.LogWarning($"Device '{model}' is not supported yet. Please contribute to support"); + continue; + } + + var did = device.did; + var mihomeDevice = _supportedModels[model](did) as ZigBeeDevice; + + _logger.LogInformation($"Device '{model}' with did '{did}' has been discovered"); + + var props = mihomeDevice.GetProps(); + var sendMsg = BuildParamsArray("get_device_prop", [did, .. props]); + var json = JsonNode.Parse(_miioTransport.SendMessageRepeated(sendMsg)); + mihomeDevice.LastTimeMessageReceived = DateTime.Now; + + if (json.AsObject().ContainsKey("error") || json["code"].GetValue() != 0) + { + _logger.LogError($"Device '{did}' is not responding on '{sendMsg}' or doesn't support miio protocol"); + } + else + { + var propsJson = json["result"] as JsonArray; + + // Device supports getting multiple props simultaneously + if(propsJson.Count == props.Length) + { + mihomeDevice.SetProps([.. propsJson]); + } + else // Need to get properties one by one + { + var propsArr = new JsonNode[props.Length]; + var propsCounter = 0; + + for (int i = 0; i < props.Length; i++) + { + sendMsg = BuildParamsArray("get_device_prop", [did, props[i]]); + json = JsonNode.Parse(_miioTransport.SendMessageRepeated(sendMsg)); + + if(json["code"].GetValue() == 0) + { + propsCounter++; + propsArr[i] = (json["result"] as JsonArray)[0]; + } + else + { + _logger.LogWarning($"Cannot get property '{props[i]}' for device did '{did}'"); + } + } + + if(propsCounter == props.Length) mihomeDevice.SetProps(propsArr); + } + } + + _devices.Add(did, mihomeDevice); + + OnDeviceDiscovered?.Invoke(mihomeDevice); + } + } + private void DiscoverBleDevices() + { + foreach (var (did, pdid, mac) in _devicesDiscoverer.DiscoverBleDevices()) + { + if(!_pdidToModel.ContainsKey(pdid)) + { + _logger.LogWarning($"Device with pdid '{pdid}' is not supported yet. Please contribute to support."); + continue; + } + + var model = _pdidToModel[pdid]; + var mihomeDevice = _supportedModels[model](did) as BleDevice; + + mihomeDevice.Mac = mac.DecodeMacAddress(); + + _devices.Add(mihomeDevice.Did, mihomeDevice); + _logger.LogInformation($"Device '{model}' with did '{mihomeDevice.Did}' has been successfully discovered"); + OnDeviceDiscovered?.Invoke(mihomeDevice); + } + } +} diff --git a/MiHomeUnitTests/Devices/DoorWindowSensorTests.cs b/MiHomeUnitTests/Devices/DoorWindowSensorTests.cs index ab95777..887e62e 100644 --- a/MiHomeUnitTests/Devices/DoorWindowSensorTests.cs +++ b/MiHomeUnitTests/Devices/DoorWindowSensorTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; diff --git a/MiHomeUnitTests/Devices/GatewayTests.cs b/MiHomeUnitTests/Devices/GatewayTests.cs index 861cab5..088471a 100644 --- a/MiHomeUnitTests/Devices/GatewayTests.cs +++ b/MiHomeUnitTests/Devices/GatewayTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Contracts; using MiHomeLib.Devices; diff --git a/MiHomeUnitTests/Devices/MotionSensorTests.cs b/MiHomeUnitTests/Devices/MotionSensorTests.cs index a7b2669..4401437 100644 --- a/MiHomeUnitTests/Devices/MotionSensorTests.cs +++ b/MiHomeUnitTests/Devices/MotionSensorTests.cs @@ -1,6 +1,6 @@ -using System; +using System; using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; diff --git a/MiHomeUnitTests/Devices/SmokeSensorTests.cs b/MiHomeUnitTests/Devices/SmokeSensorTests.cs index 7a2acfd..c154140 100644 --- a/MiHomeUnitTests/Devices/SmokeSensorTests.cs +++ b/MiHomeUnitTests/Devices/SmokeSensorTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; diff --git a/MiHomeUnitTests/Devices/SockerPlugTests.cs b/MiHomeUnitTests/Devices/SockerPlugTests.cs index 75f0bc2..87f688a 100644 --- a/MiHomeUnitTests/Devices/SockerPlugTests.cs +++ b/MiHomeUnitTests/Devices/SockerPlugTests.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Globalization; using System.Threading; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Contracts; using MiHomeLib.Devices; diff --git a/MiHomeUnitTests/Devices/SwitchTests.cs b/MiHomeUnitTests/Devices/SwitchTests.cs index f292481..db672e5 100644 --- a/MiHomeUnitTests/Devices/SwitchTests.cs +++ b/MiHomeUnitTests/Devices/SwitchTests.cs @@ -1,19 +1,14 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; -namespace MiHomeUnitTests +namespace MiHomeUnitTests.Devices { - public class SwitchTests: IClassFixture + public class SwitchTests(MiHomeDeviceFactoryFixture fixture) : IClassFixture { - private readonly MiHomeDeviceFactoryFixture _fixture; - - public SwitchTests(MiHomeDeviceFactoryFixture fixture) - { - _fixture = fixture; - } + private readonly MiHomeDeviceFactoryFixture _fixture = fixture; [Fact] public void Check_Switch_Click_Raised() diff --git a/MiHomeUnitTests/Devices/ThSensorTests.cs b/MiHomeUnitTests/Devices/ThSensorTests.cs index edcd04c..fe4b6e9 100644 --- a/MiHomeUnitTests/Devices/ThSensorTests.cs +++ b/MiHomeUnitTests/Devices/ThSensorTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; diff --git a/MiHomeUnitTests/Devices/WaterLeakSensorTests.cs b/MiHomeUnitTests/Devices/WaterLeakSensorTests.cs index e8bab13..71eec2d 100644 --- a/MiHomeUnitTests/Devices/WaterLeakSensorTests.cs +++ b/MiHomeUnitTests/Devices/WaterLeakSensorTests.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using MiHomeLib; +using MiHomeLib; using MiHomeLib.Commands; using MiHomeLib.Devices; using Xunit; diff --git a/MiHomeUnitTests/DevicesV3/AqaraDoorWindowSensorTests.cs b/MiHomeUnitTests/DevicesV3/AqaraDoorWindowSensorTests.cs new file mode 100644 index 0000000..ab06071 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraDoorWindowSensorTests.cs @@ -0,0 +1,35 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using static MiHomeLib.DevicesV3.XiaomiDoorWindowSensor; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraDoorWindowSensorTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":0}]")] + public void Check_OnContactChange_Event(string data) + { + // Arrange + var dwSensor = _fixture + .Build() + .Create(); + + var eventRaised = false; + + dwSensor.OnContactChanged += () => + { + eventRaised = true; + dwSensor.Contact.Should().Be((DoorWindowContactState)DataToZigbeeResource(data)[0].Value); + }; + + // Act + dwSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraOneChannelRelayEuTests.cs b/MiHomeUnitTests/DevicesV3/AqaraOneChannelRelayEuTests.cs new file mode 100644 index 0000000..58bc52c --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraOneChannelRelayEuTests.cs @@ -0,0 +1,206 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using Moq; +using System; + +namespace MiHomeUnitTests.DevicesV3; +public class AqaraOneChannelRelayEuTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"siid\":2,\"piid\":1,\"value\":true}]")] + public void Check_OnChannelStateChange_Event(string data) + { + // Arrange + var relay = new AqaraOneChannelRelayEu(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + State = AqaraOneChannelRelayEu.RelayState.Off + }; + + var eventRaised = false; + var counter = 0; + + relay.OnStateChange += () => + { + counter++; + eventRaised = true; + }; + + // Act + relay.ParseData(data); + relay.ParseData(data); // this is intentionally + + // Assert + eventRaised.Should().BeTrue(); + counter.Should().Be(1); + } + + [Theory, InlineData("[{\"siid\":3,\"piid\":2,\"value\":42.20}]")] + public void Check_OnLoadPowerChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + + var relay = new AqaraOneChannelRelayEu(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + LoadPower = oldValue + }; + + var eventRaised = false; + + relay.OnLoadPowerChange += (x) => + { + eventRaised = x == oldValue; + relay.LoadPower.Should().Be(GetMiSpecValue(data)); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"siid\":3,\"piid\":1,\"value\":1.30}]")] + public void Check_OnPowerConsumptionChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var relay = new AqaraOneChannelRelayEu(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + PowerConsumption = oldValue + }; + + var eventRaised = false; + + relay.OnPowerConsumptionChange += (x) => + { + eventRaised = x == oldValue; + relay.PowerConsumption.Should().Be(GetMiSpecValue(data)); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Fact] + public void Check_PowerOn_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":2,\"piid\":1,\"value\":1}}]}}"; + var relay = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.PowerOn(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Fact] + public void Check_PowerOff_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":2,\"piid\":1,\"value\":0}}]}}"; + var relay = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.PowerOff(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + [Theory] + [InlineData(AqaraOneChannelRelayEu.RelayState.Off, 1)] + [InlineData(AqaraOneChannelRelayEu.RelayState.On, 0)] + public void Check_Toggle_Works(AqaraOneChannelRelayEu.RelayState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":2,\"piid\":1,\"value\":{value}}}]}}"; + var plug = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory) + { + State = state + }; + + // Act + plug.ToggleState(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraOneChannelRelayEu.PowerMemoryState.PowerOff, 0)] + [InlineData(AqaraOneChannelRelayEu.PowerMemoryState.Previous, 1)] + public void Check_SetPowerMemoryState_Works(AqaraOneChannelRelayEu.PowerMemoryState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":5,\"piid\":1,\"value\":{value}}}]}}"; + var plug = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetPowerMemoryState(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraOneChannelRelayEu.PowerMode.Momentary, 1)] + [InlineData(AqaraOneChannelRelayEu.PowerMode.Toggle, 2)] + public void Check_SetPowerMode_Works(AqaraOneChannelRelayEu.PowerMode mode, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":7,\"piid\":2,\"value\":{value}}}]}}"; + var plug = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetPowerMode(mode); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Fact] + public void SetPowerOverloadThreshold_When_ValidArgument_Works() + { + // Arrange + var did = _fixture.Create(); + var value = 100; + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"mi_spec\":[{{\"siid\":5,\"piid\":6,\"value\":{value}}}]}}"; + var plug = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetPowerOverloadThreshold(value); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Fact] + public void SetPowerOverloadThreshold_When_InvalidArgument_ThrowsException() + { + // Arrange + var did = _fixture.Create(); + var value = 5000; // invalid argument + var plug = new AqaraOneChannelRelayEu(did, _mqttTransport.Object, _loggerFactory); + + // Act + Action code = () => { plug.SetPowerOverloadThreshold(value); }; + + // Assert + code + .Should() + .ThrowExactly() + .WithParameterName("threshold") + .Which.ActualValue.Should().Be(value); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraOppleFourButtonsWirelesSwitchTests.cs b/MiHomeUnitTests/DevicesV3/AqaraOppleFourButtonsWirelesSwitchTests.cs new file mode 100644 index 0000000..8e1565c --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraOppleFourButtonsWirelesSwitchTests.cs @@ -0,0 +1,52 @@ +using AutoFixture; +using MiHomeLib.DevicesV3; +using Xunit; +using FluentAssertions; +using static MiHomeLib.DevicesV3.AqaraOppleWirelesSwitch; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraOppleFourButtonsWirelesSwitchTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"13.3.85\",\"value\":1}]", ClickArg.SingleClick)] + [InlineData("[{\"res_name\":\"13.3.85\",\"value\":2}]", ClickArg.DoubleClick)] + [InlineData("[{\"res_name\":\"13.3.85\",\"value\":3}]", ClickArg.TripleClick)] + [InlineData("[{\"res_name\":\"13.3.85\",\"value\":16}]", ClickArg.LongPressHold)] + [InlineData("[{\"res_name\":\"13.3.85\",\"value\":17}]", ClickArg.LongPressRelease)] + public void Check_Switch_OnButton1Click_Event(string data, ClickArg evt) + { + // Arrange + var sw = _fixture.Create(); + var eventRaised = false; + + sw.OnButton3Click += (clickArgs) => { eventRaised = clickArgs == evt; }; + + // Act + sw.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"13.4.85\",\"value\":1}]", ClickArg.SingleClick)] + [InlineData("[{\"res_name\":\"13.4.85\",\"value\":2}]", ClickArg.DoubleClick)] + [InlineData("[{\"res_name\":\"13.4.85\",\"value\":3}]", ClickArg.TripleClick)] + [InlineData("[{\"res_name\":\"13.4.85\",\"value\":16}]", ClickArg.LongPressHold)] + [InlineData("[{\"res_name\":\"13.4.85\",\"value\":17}]", ClickArg.LongPressRelease)] + public void Check_Switch_OnButton2Click_Event(string data, ClickArg evt) + { + // Arrange + var sw = _fixture.Create(); + var eventRaised = false; + + sw.OnButton4Click += (clickArgs) => { eventRaised = clickArgs == evt; }; + + // Act + sw.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraOppleTwoButtonsWirelesSwitchTests.cs b/MiHomeUnitTests/DevicesV3/AqaraOppleTwoButtonsWirelesSwitchTests.cs new file mode 100644 index 0000000..768d3b3 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraOppleTwoButtonsWirelesSwitchTests.cs @@ -0,0 +1,52 @@ +using AutoFixture; +using MiHomeLib.DevicesV3; +using Xunit; +using FluentAssertions; +using static MiHomeLib.DevicesV3.AqaraOppleWirelesSwitch; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraOppleTwoButtonsWirelesSwitchTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":1}]", ClickArg.SingleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":2}]", ClickArg.DoubleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":3}]", ClickArg.TripleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":16}]", ClickArg.LongPressHold)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":17}]", ClickArg.LongPressRelease)] + public void Check_Switch_OnButton1Click_Event(string data, ClickArg evt) + { + // Arrange + var sw = _fixture.Create(); + var eventRaised = false; + + sw.OnButton1Click += (clickArgs) => { eventRaised = clickArgs == evt; }; + + // Act + sw.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"13.2.85\",\"value\":1}]", ClickArg.SingleClick)] + [InlineData("[{\"res_name\":\"13.2.85\",\"value\":2}]", ClickArg.DoubleClick)] + [InlineData("[{\"res_name\":\"13.2.85\",\"value\":3}]", ClickArg.TripleClick)] + [InlineData("[{\"res_name\":\"13.2.85\",\"value\":16}]", ClickArg.LongPressHold)] + [InlineData("[{\"res_name\":\"13.2.85\",\"value\":17}]", ClickArg.LongPressRelease)] + public void Check_Switch_OnButton2Click_Event(string data, ClickArg evt) + { + // Arrange + var sw = _fixture.Create(); + var eventRaised = false; + + sw.OnButton2Click += (clickArgs) => { eventRaised = clickArgs == evt; }; + + // Act + sw.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraThSensorTests.cs b/MiHomeUnitTests/DevicesV3/AqaraThSensorTests.cs new file mode 100644 index 0000000..44c869e --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraThSensorTests.cs @@ -0,0 +1,78 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraThSensorTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"0.1.85\",\"value\":2515}]")] + public void Check_OnTemperatureChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture.Create(); + th.Temperature = oldValue; + + var eventRaised = false; + + th.OnTemperatureChange += (x) => + { + eventRaised = x == oldValue; + th.Temperature.Should().Be(DataToZigbeeResource(data)[0].Value/100f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.2.85\",\"value\":4343}]")] + public void Check_OnHumidityChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture.Create(); + th.Humidity = oldValue; + + var eventRaised = false; + + th.OnHumidityChange += (x) => + { + eventRaised = x == oldValue; + th.Humidity.Should().Be(DataToZigbeeResource(data)[0].Value/100f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.3.85\",\"value\":99120}]")] + public void Check_OnPressureChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture.Create(); + th.Pressure = oldValue; + + var eventRaised = false; + + th.OnPressureChange += (x) => + { + eventRaised = x == oldValue; + th.Pressure.Should().Be(DataToZigbeeResource(data)[0].Value/100f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraTwoChannelsRelayTests.cs b/MiHomeUnitTests/DevicesV3/AqaraTwoChannelsRelayTests.cs new file mode 100644 index 0000000..5369442 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraTwoChannelsRelayTests.cs @@ -0,0 +1,268 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using Moq; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraTwoChannelsRelayTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"4.1.85\",\"value\":1}]")] + public void Check_OnChannel1StateChange_Event(string data) + { + // Arrange + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + Channel1 = AqaraTwoChannelsRelay.ChannelState.Off + }; + + var eventRaised = false; + var counter = 0; + + relay.OnChannel1StateChange += () => + { + counter++; + eventRaised = true; + }; + + // Act + relay.ParseData(data); + relay.ParseData(data); // this is intentionally + + // Assert + eventRaised.Should().BeTrue(); + counter.Should().Be(1); + } + + [Theory, InlineData("[{\"res_name\":\"4.2.85\",\"value\":1}]")] + public void Check_OnChannel2StateChange_Event(string data) + { + // Arrange + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + Channel2 = AqaraTwoChannelsRelay.ChannelState.Off + }; + + var eventRaised = false; + var counter = 0; + + relay.OnChannel2StateChange += () => + { + counter++; + eventRaised = true; + }; + + // Act + relay.ParseData(data); + relay.ParseData(data); // this is intentionally + + // Assert + eventRaised.Should().BeTrue(); + counter.Should().Be(1); + } + + [Theory, InlineData("[{\"res_name\":\"0.11.85\",\"value\":212512}]")] + public void Check_OnVoltageChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + Voltage = oldValue + }; + + var eventRaised = false; + + relay.OnVoltageChange += (x) => + { + eventRaised = x == oldValue; + relay.Voltage.Should().Be(GetMiSpecValue(data)/1000f); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.12.85\",\"value\":42.2}]")] + public void Check_OnLoadPowerChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + LoadPower = oldValue + }; + + var eventRaised = false; + + relay.OnLoadPowerChange += (x) => + { + eventRaised = x == oldValue; + relay.LoadPower.Should().Be(GetMiSpecValue(data)); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.13.85\",\"value\":3163.13}]")] + public void Check_OnEnergyChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + Energy = oldValue + }; + + var eventRaised = false; + + relay.OnEnergyChange += (x) => + { + eventRaised = x == oldValue; + relay.Energy.Should().Be(GetMiSpecValue(data)); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.14.85\",\"value\":153.24}]")] + public void Check_OnCurrentChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var relay = new AqaraTwoChannelsRelay(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + Current = oldValue + }; + + var eventRaised = false; + + relay.OnCurrentChange += (x) => + { + eventRaised = x == oldValue; + relay.Current.Should().Be(GetMiSpecValue(data)); + }; + + // Act + relay.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Fact] + public void Check_Channel1PowerOn_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.1.85\",\"value\":1}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.Channel1PowerOn(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Fact] + public void Check_Channel2PowerOn_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.2.85\",\"value\":1}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.Channel2PowerOn(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraTwoChannelsRelay.ChannelState.Off, 1)] + [InlineData(AqaraTwoChannelsRelay.ChannelState.On, 0)] + public void Check_Channel1ToggleState_Works(AqaraTwoChannelsRelay.ChannelState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.1.85\",\"value\":{value}}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory) + { + Channel1 = state + }; + + // Act + relay.Channel1ToggleState(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraTwoChannelsRelay.ChannelState.Off, 1)] + [InlineData(AqaraTwoChannelsRelay.ChannelState.On, 0)] + public void Check_Channel2ToggleState_Works(AqaraTwoChannelsRelay.ChannelState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.2.85\",\"value\":{value}}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory) + { + Channel2 = state + }; + + // Act + relay.Channel2ToggleState(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraTwoChannelsRelay.PowerMemoryState.PowerOff, 0)] + [InlineData(AqaraTwoChannelsRelay.PowerMemoryState.Previous, 1)] + public void Check_SetPowerMemoryState_Works(AqaraTwoChannelsRelay.PowerMemoryState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"8.0.2030\",\"value\":{value}}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.SetPowerMemoryState(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(AqaraTwoChannelsRelay.InterlockState.Disabled, 0)] + [InlineData(AqaraTwoChannelsRelay.InterlockState.Enabled, 1)] + public void Check_SetInterlock_Works(AqaraTwoChannelsRelay.InterlockState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.9.85\",\"value\":{value}}}]}}"; + var relay = new AqaraTwoChannelsRelay(did, _mqttTransport.Object, _loggerFactory); + + // Act + relay.SetInterlock(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } +} diff --git a/MiHomeUnitTests/DevicesV3/AqaraWaterLeakSensorTests.cs b/MiHomeUnitTests/DevicesV3/AqaraWaterLeakSensorTests.cs new file mode 100644 index 0000000..2509568 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/AqaraWaterLeakSensorTests.cs @@ -0,0 +1,34 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class AqaraWaterLeakSensorTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":0}]")] + public void Check_OnMoistureChange_Event(string data) + { + // Arrange + var wl = _fixture + .Build() + .Create(); + + var eventRaised = false; + + wl.OnMoistureChange += () => + { + eventRaised = true; + wl.Moisture.Should().Be(DataToZigbeeResource(data)[0].Value == 1); + }; + + // Act + wl.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/BleBatteryDeviceTests.cs b/MiHomeUnitTests/DevicesV3/BleBatteryDeviceTests.cs new file mode 100644 index 0000000..1db386e --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/BleBatteryDeviceTests.cs @@ -0,0 +1,37 @@ +using MiHomeLib; +using Xunit; +using AutoFixture; +using FluentAssertions; +using System; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class BleBatteryDeviceTests: MiHome3DeviceTests +{ + [Theory] + [InlineData(4106, "63", 95)] + [InlineData(4106, "62", 41)] + public void Check_OnBatteryPercentChange_Event(int eid, string edata, byte oldBatteryPercent) + { + // Arrange + var thMonitor2 = _fixture.Build().Create(); + var eventRaised = false; + double time = DateTimeOffset.Now.ToUnixTimeSeconds(); + + thMonitor2.BatteryPercent = oldBatteryPercent; + + thMonitor2.OnBatteryPercentChange += (oldValue) => + { + eventRaised = oldValue == oldBatteryPercent; + thMonitor2.BatteryPercent.Should().Be(edata.ToBleByte()); + thMonitor2.LastTimeMessageReceived = time.UnixSecondsToDateTime(); + }; + + // Act + thMonitor2.ParseData(SetupBleAsyncEventParams(eid, edata, time).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/HoneywellSmokeAlarmTests.cs b/MiHomeUnitTests/DevicesV3/HoneywellSmokeAlarmTests.cs new file mode 100644 index 0000000..c045bc7 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/HoneywellSmokeAlarmTests.cs @@ -0,0 +1,36 @@ +using Xunit; +using AutoFixture; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class HoneywellSmokeAlarmTests: MiHome3DeviceTests +{ + private readonly HoneywellSmokeAlarm _smokeAlarm; + + public HoneywellSmokeAlarmTests() => _smokeAlarm = _fixture.Build().Create(); + + [Theory] + [InlineData(4117, "00", HoneywellSmokeAlarm.SmokeState.Unknown)] + [InlineData(4117, "01", HoneywellSmokeAlarm.SmokeState.NoSmokeDetected)] + public void Check_OnSmokeChange_Event(int eid, string edata, HoneywellSmokeAlarm.SmokeState oldState) + { + // Arrange + var eventRaised = false; + + _smokeAlarm.Smoke = oldState; + + _smokeAlarm.OnSmokeChange += (oldValue) => + { + eventRaised = oldValue == oldState; + _smokeAlarm.Smoke.Should().Be((HoneywellSmokeAlarm.SmokeState)int.Parse(edata)); + }; + + // Act + _smokeAlarm.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/HoneywellSmokeSensorTests.cs b/MiHomeUnitTests/DevicesV3/HoneywellSmokeSensorTests.cs new file mode 100644 index 0000000..d151403 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/HoneywellSmokeSensorTests.cs @@ -0,0 +1,114 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using Moq; + +namespace MiHomeUnitTests.DevicesV3; + +public class HoneywellSmokeSensorTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"0.1.85\",\"value\":15}]")] + public void Check_OnSmokeDensityChanged_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + + var smokeSensor = new HoneywellSmokeSensor(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + SmokeDensity = oldValue + }; + + var eventRaised = false; + + smokeSensor.OnSmokeDensityChanged += (x) => + { + eventRaised = x == oldValue; + smokeSensor.SmokeDensity.Should().Be((byte)DataToZigbeeResource(data)[0].Value); + }; + + // Act + smokeSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":1}]")] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":0}]")] + public void Check_OnSmokeDetected_Event(string data) + { + // Arrange + var eventRaised = false; + var smokeSensor = new HoneywellSmokeSensor(_fixture.Create(), _mqttTransport.Object, _loggerFactory); + + smokeSensor.OnSmokeDetected += () => + { + eventRaised = true; + smokeSensor.SmokeDetected.Should().Be(DataToZigbeeResource(data)[0].Value == 1); + }; + + // Act + smokeSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"14.1.85\",\"value\":67174400}]")] + [InlineData("[{\"res_name\":\"14.1.85\",\"value\":67239936}]")] + [InlineData("[{\"res_name\":\"14.1.85\",\"value\":67305472}]")] + public void Check_OnSmokeSensivityModeChanged_Event(string data) + { + // Arrange + var eventRaised = false; + var smokeSensor = new HoneywellSmokeSensor(_fixture.Create(), _mqttTransport.Object, _loggerFactory); + + smokeSensor.OnSmokeSensivityModeChanged += (mode) => + { + eventRaised = true; + mode.Should().Be((HoneywellSmokeSensor.SensivityMode)DataToZigbeeResource(data)[0].Value); + }; + + // Act + smokeSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData(HoneywellSmokeSensor.SensivityMode.NoSmoke, 67174400)] + [InlineData(HoneywellSmokeSensor.SensivityMode.LowSmoke, 67239936)] + [InlineData(HoneywellSmokeSensor.SensivityMode.MiddleSmoke, 67305472)] + public void Check_SetSensivity_Works(HoneywellSmokeSensor.SensivityMode mode, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"14.1.85\",\"value\":{value}}}]}}"; + var plug = new HoneywellSmokeSensor(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetSensivity(mode); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Fact] + public void Check_RunSelfTest_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"14.1.85\",\"value\":{HoneywellSmokeSensor.SELF_TEST_MAGIC_NUMBER}}}]}}"; + var plug = new HoneywellSmokeSensor(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.RunSelfTest(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } +} diff --git a/MiHomeUnitTests/DevicesV3/MiHome3DeviceTests.cs b/MiHomeUnitTests/DevicesV3/MiHome3DeviceTests.cs new file mode 100644 index 0000000..a7f9fc3 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/MiHome3DeviceTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using AutoFixture; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using MiHomeLib.JsonResponses; +using MiHomeLib.Transport; +using Moq; +using static MiHomeLib.JsonResponses.BleAsyncEventResponse; +using static MiHomeLib.JsonResponses.BleAsyncEventResponse.BleAsyncEventParams; + +namespace MiHomeUnitTests.DevicesV3; + +public class MiHome3DeviceTests +{ + protected readonly Mock _mqttTransport; + protected readonly NullLoggerFactory _loggerFactory; + protected readonly Fixture _fixture = new(); + private readonly JsonSerializerOptions _opts = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + public MiHome3DeviceTests() + { + _fixture.Customize(x => x.FromFactory(() => new NullLoggerFactory())); + _mqttTransport = new Mock(); + _loggerFactory = new NullLoggerFactory(); + } + + protected List DataToZigbeeResource(string data) + { + return JsonSerializer.Deserialize>(data, _opts); + } + protected static T GetMiSpecValue(string data) => (JsonNode.Parse(data) as JsonArray)[0]["value"].GetValue(); + + protected BleAsyncEventParams SetupBleAsyncEventParams(int eid, string edata, double time = -1) + { + return _fixture + .Build() + .With(x => x.Dev, + _fixture + .Build() + .Create() + ) + .With(x => x.Evt, [new BleAsyncEventEvt() { Eid = eid, Edata = edata }]) + .With(x => x.Gwts, time == -1 ? DateTimeOffset.Now.ToUnixTimeSeconds() : time) + .Create(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/MiThMonitor2Tests.cs b/MiHomeUnitTests/DevicesV3/MiThMonitor2Tests.cs new file mode 100644 index 0000000..c5388a7 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/MiThMonitor2Tests.cs @@ -0,0 +1,59 @@ +using MiHomeLib; +using Xunit; +using AutoFixture; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; +public class MiThMonitor2Tests: MiHome3DeviceTests +{ + private readonly MiThMonitor2 _thMonitor2; + + public MiThMonitor2Tests() => _thMonitor2 = _fixture.Build().Create(); + + [Theory] + [InlineData(4100, "E500", 24.1f)] + [InlineData(4100, "fb00", 22f)] + public void Check_OnTemperatureChange_Event(int eid, string edata, float oldTemperature) + { + // Arrange + var eventRaised = false; + + _thMonitor2.Temperature = oldTemperature; + + _thMonitor2.OnTemperatureChange += (oldValue) => + { + eventRaised = oldValue == oldTemperature; + _thMonitor2.Temperature.Should().Be(edata.ToBleFloat()); + }; + + // Act + _thMonitor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData(4102, "E601", 44.1f)] + [InlineData(4102, "e301", 33.2f)] + public void Check_OnHumidityChange_Event(int eid, string edata, float oldHumidity) + { + // Arrange + var eventRaised = false; + + _thMonitor2.Humidity = oldHumidity; + + _thMonitor2.OnHumidityChange += (oldValue) => + { + eventRaised = oldValue == oldHumidity; + _thMonitor2.Humidity.Should().Be(edata.ToBleFloat()); + }; + + // Act + _thMonitor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/MiWirelessSwitchTests.cs b/MiHomeUnitTests/DevicesV3/MiWirelessSwitchTests.cs new file mode 100644 index 0000000..bb3c2d7 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/MiWirelessSwitchTests.cs @@ -0,0 +1,33 @@ +using AutoFixture; +using MiHomeLib.DevicesV3; +using Xunit; +using FluentAssertions; +using static MiHomeLib.DevicesV3.MiWirelesSwitch; + +namespace MiHomeUnitTests.DevicesV3; + +public class MiWirelessSwitchTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":1}]", ClickArg.SingleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":2}]", ClickArg.DoubleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":3}]", ClickArg.TripleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":4}]", ClickArg.QuadrupleClick)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":128}]", ClickArg.ManyClicks)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":16}]", ClickArg.LongPressHold)] + [InlineData("[{\"res_name\":\"13.1.85\",\"value\":17}]", ClickArg.LongPressRelease)] + public void Check_Switch_OnClick_Event(string data, ClickArg evt) + { + // Arrange + var sw = _fixture.Create(); + var eventRaised = false; + + sw.OnClick += (clickArgs) => { eventRaised = clickArgs == evt; }; + + // Act + sw.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensor2Tests.cs b/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensor2Tests.cs new file mode 100644 index 0000000..32ac02b --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensor2Tests.cs @@ -0,0 +1,64 @@ +using Xunit; +using AutoFixture; +using FluentAssertions; +using System; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class XiaomiDoorWindowSensor2Tests: MiHome3DeviceTests +{ + private readonly XiaomiDoorWindowSensor2 _dwSensor2; + + public XiaomiDoorWindowSensor2Tests() + { + _dwSensor2 = _fixture.Build().Create(); + } + + [Theory] + [InlineData(4121, "00", XiaomiDoorWindowSensor2.ContactState.Unknown)] + [InlineData(4121, "01", XiaomiDoorWindowSensor2.ContactState.Open)] + [InlineData(4121, "02", XiaomiDoorWindowSensor2.ContactState.Unknown)] + public void Check_OnContactChange_Event(int eid, string edata, XiaomiDoorWindowSensor2.ContactState oldState) + { + // Arrange + var eventRaised = false; + + _dwSensor2.Contact = oldState; + + _dwSensor2.OnContactChange += (oldValue) => + { + eventRaised = oldValue == oldState; + _dwSensor2.Contact.Should().Be((XiaomiDoorWindowSensor2.ContactState)int.Parse(edata)); + }; + + // Act + _dwSensor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData(4120, "00", XiaomiDoorWindowSensor2.LightState.LightDiscovered)] + [InlineData(4120, "01", XiaomiDoorWindowSensor2.LightState.NoLight)] + public void Check_OnLightChange_Event(int eid, string edata, XiaomiDoorWindowSensor2.LightState oldState) + { + // Arrange + var eventRaised = false; + + _dwSensor2.Light = oldState; + + _dwSensor2.OnLightChange += (oldValue) => + { + eventRaised = oldValue == oldState; + _dwSensor2.Light.Should().Be((XiaomiDoorWindowSensor2.LightState)int.Parse(edata)); + }; + + // Act + _dwSensor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensorTests.cs b/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensorTests.cs new file mode 100644 index 0000000..25aedc7 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiDoorWindowSensorTests.cs @@ -0,0 +1,35 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using static MiHomeLib.DevicesV3.XiaomiDoorWindowSensor; + +namespace MiHomeUnitTests.DevicesV3; + +public class XiaomiDoorWindowSensorTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":0}]")] + public void Check_OnContactChange_Event(string data) + { + // Arrange + var dwSensor = _fixture + .Build() + .Create(); + + var eventRaised = false; + + dwSensor.OnContactChanged += () => + { + eventRaised = true; + dwSensor.Contact.Should().Be((DoorWindowContactState)DataToZigbeeResource(data)[0].Value); + }; + + // Act + dwSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiMotionSensor2Tests.cs b/MiHomeUnitTests/DevicesV3/XiaomiMotionSensor2Tests.cs new file mode 100644 index 0000000..3ed7ff6 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiMotionSensor2Tests.cs @@ -0,0 +1,78 @@ +using Xunit; +using AutoFixture; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; +public class XiaomiMotionSensor2Tests: MiHome3DeviceTests +{ + private readonly XiaomiMotionSensor2 _motionSensor2; + + public XiaomiMotionSensor2Tests() => _motionSensor2 = _fixture.Build().Create(); + + [Theory] + [InlineData(15, "000000", XiaomiMotionSensor2.MotionState.Unknown)] + [InlineData(15, "000100", XiaomiMotionSensor2.MotionState.Unknown)] + public void Check_OnMotionDetected_Event(int eid, string edata, XiaomiMotionSensor2.MotionState oldState) + { + // Arrange + var eventRaised = false; + + _motionSensor2.Motion = oldState; + + _motionSensor2.OnMotionDetected += (oldValue) => + { + eventRaised = oldValue == oldState; + _motionSensor2.Motion.Should().Be((XiaomiMotionSensor2.MotionState)int.Parse(edata)); + }; + + // Act + _motionSensor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData(4120, "01", XiaomiMotionSensor2.LightState.Unknown)] + [InlineData(4120, "00", XiaomiMotionSensor2.LightState.Unknown)] + public void Check_OnLightChange_Event(int eid, string edata, XiaomiMotionSensor2.LightState oldState) + { + // Arrange + var eventRaised = false; + + _motionSensor2.Light = oldState; + + _motionSensor2.OnLightChange += (oldValue) => + { + eventRaised = oldValue == oldState; + _motionSensor2.Light.Should().Be((XiaomiMotionSensor2.LightState)int.Parse(edata)); + }; + + // Act + _motionSensor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData(4119, "78000000", XiaomiMotionSensor2.NoMotionState.Idle120Seconds)] + [InlineData(4119, "2C010000", XiaomiMotionSensor2.NoMotionState.Idle300Seconds)] + public void Check_OnNoMotionDetected_Event(int eid, string edata, XiaomiMotionSensor2.NoMotionState state) + { + // Arrange + var eventRaised = false; + + _motionSensor2.OnNoMotionDetected += (x) => + { + eventRaised = x == state; + }; + + // Act + _motionSensor2.ParseData(SetupBleAsyncEventParams(eid, edata).ToString()); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiMotionSensorTests.cs b/MiHomeUnitTests/DevicesV3/XiaomiMotionSensorTests.cs new file mode 100644 index 0000000..70859ee --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiMotionSensorTests.cs @@ -0,0 +1,96 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using Moq; +using Microsoft.Extensions.Logging.Abstractions; + +namespace MiHomeUnitTests.DevicesV3; + +public class XiaomiMotionSensorTests: MiHome3DeviceTests +{ + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":0}]")] + public void Check_OnMotionDetected_Event(string data) + { + // Arrange + var motionSensor = _fixture + .Build() + .Create(); + + var eventRaised = false; + + motionSensor.OnMotionDetected += () => + { + eventRaised = true; + motionSensor.MotionDetected.Should().Be(DataToZigbeeResource(data)[0].Value == 1); + }; + + // Act + motionSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + public void Check_OnNoMotionDetected_For_One_Minute_Event(string data) + { + // Arrange + var did = _fixture.Create(); + var timer1 = new Mock(); + var timer2 = new Mock(); + + timer1 + .Setup(timer => timer.Start()) + .Raises(x => x.Elapsed += null, null, null); + + var motionSensor = new XiaomiMotionSensor(did, new NullLoggerFactory(), timer1.Object, timer2.Object); + + var eventRaised = false; + + motionSensor.OnNoMotionDetected += (noMotionInterval) => + { + eventRaised = true; + noMotionInterval.Should().Be(XiaomiMotionSensor.NoMotionInterval.NoMotionForOneMinute); + }; + + // Act + motionSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory] + [InlineData("[{\"res_name\":\"3.1.85\",\"value\":1}]")] + public void Check_OnNoMotionDetected_For_Two_Minutes_Event(string data) + { + // Arrange + var did = _fixture.Create(); + var timer1 = new Mock(); + var timer2 = new Mock(); + + timer2 + .Setup(timer => timer.Start()) + .Raises(x => x.Elapsed += null, null, null); + + var motionSensor = new XiaomiMotionSensor(did, new NullLoggerFactory(), timer1.Object, timer2.Object); + + var eventRaised = false; + + motionSensor.OnNoMotionDetected += (noMotionInterval) => + { + eventRaised = true; + noMotionInterval.Should().Be(XiaomiMotionSensor.NoMotionInterval.NoMotionForTwoMinutes); + }; + + // Act + motionSensor.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiPlugCnTests.cs b/MiHomeUnitTests/DevicesV3/XiaomiPlugCnTests.cs new file mode 100644 index 0000000..baf9911 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiPlugCnTests.cs @@ -0,0 +1,160 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; +using Moq; + +namespace MiHomeUnitTests.DevicesV3; +public class XiaomiPlugCnTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"4.1.85\",\"value\":1}]")] + public void Check_OnStateChange_Event(string data) + { + // Arrange + var plug = new XiaomiPlugCN(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + State = XiaomiPlugCN.PlugState.Off + }; + + var eventRaised = false; + var counter = 0; + + plug.OnStateChange += () => + { + counter++; + eventRaised = true; + }; + + // Act + plug.ParseData(data); + plug.ParseData(data); // this is intentionally + + // Assert + eventRaised.Should().BeTrue(); + counter.Should().Be(1); + } + + [Theory, InlineData("[{\"res_name\":\"0.12.85\",\"value\":42.2}]")] + public void Check_OnLoadPowerChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var plug = new XiaomiPlugCN(_fixture.Create(), _mqttTransport.Object, _loggerFactory) + { + LoadPower = oldValue + }; + + var eventRaised = false; + + plug.OnLoadPowerChange += (x) => + { + eventRaised = x == oldValue; + plug.LoadPower.Should().Be(GetMiSpecValue(data)); + }; + + // Act + plug.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + [Fact] + public void Check_PowerOn_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.1.85\",\"value\":1}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.PowerOn(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + [Fact] + public void Check_PowerOff_Works() + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.1.85\",\"value\":0}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.PowerOff(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(XiaomiPlugCN.PlugState.Off, 1)] + [InlineData(XiaomiPlugCN.PlugState.On, 0)] + public void Check_Toggle_Works(XiaomiPlugCN.PlugState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"4.1.85\",\"value\":{value}}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory) + { + State = state + }; + + // Act + plug.ToggleState(); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(XiaomiPlugCN.PowerMemoryState.PowerOff, 0)] + [InlineData(XiaomiPlugCN.PowerMemoryState.Previous, 1)] + public void Check_PowerOnState_Works(XiaomiPlugCN.PowerMemoryState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"8.0.2030\",\"value\":{value}}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetPowerMemoryState(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(XiaomiPlugCN.ChargeProtect.Off, 0)] + [InlineData(XiaomiPlugCN.ChargeProtect.On, 1)] + public void Check_ChargeProtection_Works(XiaomiPlugCN.ChargeProtect state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"8.0.2031\",\"value\":{value}}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetChargeProtection(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } + + [Theory] + [InlineData(XiaomiPlugCN.LedState.TurnOffAtNightTime, 0)] + [InlineData(XiaomiPlugCN.LedState.AlwaysOn, 1)] + public void Check_SetLedState_Works(XiaomiPlugCN.LedState state, int value) + { + // Arrange + var did = _fixture.Create(); + var cmd = $"{{\"cmd\":\"write\",\"did\":\"{did}\",\"params\":[{{\"res_name\":\"8.0.2032\",\"value\":{value}}}]}}"; + var plug = new XiaomiPlugCN(did, _mqttTransport.Object, _loggerFactory); + + // Act + plug.SetLedState(state); + + // Assert + _mqttTransport.Verify(x => x.SendMessage(cmd), Times.Once); + } +} diff --git a/MiHomeUnitTests/DevicesV3/XiaomiThSensorTests.cs b/MiHomeUnitTests/DevicesV3/XiaomiThSensorTests.cs new file mode 100644 index 0000000..ed90bc4 --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/XiaomiThSensorTests.cs @@ -0,0 +1,54 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; +public class XiaomiThSensorTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"0.1.85\",\"value\":2515}]")] + public void Check_OnTemperatureChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture.Create(); + th.Temperature = oldValue; + + var eventRaised = false; + + th.OnTemperatureChange += (x) => + { + eventRaised = x == oldValue; + th.Temperature.Should().Be(DataToZigbeeResource(data)[0].Value/100f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"0.2.85\",\"value\":4343}]")] + public void Check_OnHumidityChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture.Create(); + th.Humidity = oldValue; + + var eventRaised = false; + + th.OnHumidityChange += (x) => + { + eventRaised = x == oldValue; + th.Humidity.Should().Be(DataToZigbeeResource(data)[0].Value/100f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/DevicesV3/ZigbeeDeviceTests.cs b/MiHomeUnitTests/DevicesV3/ZigbeeDeviceTests.cs new file mode 100644 index 0000000..9deaabb --- /dev/null +++ b/MiHomeUnitTests/DevicesV3/ZigbeeDeviceTests.cs @@ -0,0 +1,110 @@ +using AutoFixture; +using Xunit; +using FluentAssertions; +using MiHomeLib.DevicesV3; + +namespace MiHomeUnitTests.DevicesV3; + +public class ZigbeeDeviceTests: MiHome3DeviceTests +{ + [Theory, InlineData("[{\"res_name\":\"8.0.2008\",\"value\":3025}]")] + public void Check_OnVoltageChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture + .Build() + .With(x => x.Voltage, oldValue) + .Create(); + + var eventRaised = false; + + th.OnVoltageChange += (x) => + { + eventRaised = x == oldValue; + th.Voltage.Should().Be(DataToZigbeeResource(data)[0].Value/1000f); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"8.0.2001\",\"value\":95}]")] + public void Check_OnBatteryPercentChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture + .Build() + .With(x => x.BatteryPercent, oldValue) + .Create(); + + var eventRaised = false; + + th.OnBatteryPercentChange += (x) => + { + eventRaised = x == oldValue; + th.BatteryPercent.Should().Be((byte)DataToZigbeeResource(data)[0].Value); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"8.0.2007\",\"value\":181}]")] + public void Check_OnLinqQualityChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + + var th = _fixture + .Build() + .With(x => x.LinqQuality, oldValue) + .Create(); + + var eventRaised = false; + + th.OnLinkQualityChange += (x) => + { + eventRaised = x == oldValue; + th.LinqQuality.Should().Be((byte)DataToZigbeeResource(data)[0].Value); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } + + [Theory, InlineData("[{\"res_name\":\"8.0.2006\",\"value\":81}]")] + public void Check_OnChipTemperatureChange_Event(string data) + { + // Arrange + var oldValue = _fixture.Create(); + var th = _fixture + .Build() + .With(x => x.ChipTemperature, oldValue) + .Create(); + + var eventRaised = false; + + th.OnChipTemperatureChange += (x) => + { + eventRaised = x == oldValue; + th.ChipTemperature.Should().Be((byte)DataToZigbeeResource(data)[0].Value); + }; + + // Act + th.ParseData(data); + + // Assert + eventRaised.Should().BeTrue(); + } +} diff --git a/MiHomeUnitTests/MiHomeUnitTests.csproj b/MiHomeUnitTests/MiHomeUnitTests.csproj index 7857132..106504a 100644 --- a/MiHomeUnitTests/MiHomeUnitTests.csproj +++ b/MiHomeUnitTests/MiHomeUnitTests.csproj @@ -11,16 +11,19 @@ - - + + all runtime; build; native; contentfiles; analyzers - + all runtime; build; native; contentfiles; analyzers - + + + + diff --git a/MiHomeUnitTests/Miio/AirHumidifierTests.cs b/MiHomeUnitTests/Miio/AirHumidifierTests.cs index 0d5edbd..512f610 100644 --- a/MiHomeUnitTests/Miio/AirHumidifierTests.cs +++ b/MiHomeUnitTests/Miio/AirHumidifierTests.cs @@ -1,5 +1,6 @@ -using System.Threading.Tasks; -using MiHomeLib.Devices; +using System.Threading.Tasks; +using MiHomeLib; +using MiHomeLib.MiioDevices; using Moq; using Xunit; @@ -114,7 +115,7 @@ public void IsTurnedOn_Returns_State_Power() } [Fact] - public async void IsTurnedOnAsync_Returns_State_Power() + public async Task IsTurnedOnAsync_Returns_State_Power() { // Arrange var miioDevice = new Mock(); @@ -146,7 +147,7 @@ public void GetDeviceMode_Returns_Valid_Mode() } [Fact] - public async void GetDeviceModeAsync_Returns_Valid_Mode() + public async Task GetDeviceModeAsync_Returns_Valid_Mode() { // Arrange var miioDevice = new Mock(); @@ -242,7 +243,7 @@ public void GetBrightness_Returns_Valid_Brightness() } [Fact] - public async void GetBrightnessAsync_Returns_Valid_Brightness() + public async Task GetBrightnessAsync_Returns_Valid_Brightness() { // Arrange var miioDevice = new Mock(); @@ -304,7 +305,7 @@ public void GetTargetHumidity_Returns_Valid_TargetHumidity() } [Fact] - public async void GetTargetHumidityAsync_Returns_Valid_TargetHumidity() + public async Task GetTargetHumidityAsync_Returns_Valid_TargetHumidity() { // Arrange var miioDevice = new Mock(); @@ -336,7 +337,7 @@ public void IsBuzzerOn_Returns_Valid_BuzzerState() } [Fact] - public async void IsBuzzerOnAsync_Returns_Valid_BuzzerState() + public async Task IsBuzzerOnAsync_Returns_Valid_BuzzerState() { // Arrange var miioDevice = new Mock(); @@ -398,7 +399,7 @@ public void IsChildLockOn_Returns_Valid_ChildLockState() } [Fact] - public async void IsChildLockOnAsync_Returns_Valid_ChildLockState() + public async Task IsChildLockOnAsync_Returns_Valid_ChildLockState() { // Arrange var miioDevice = new Mock(); diff --git a/MiHomeUnitTests/Miio/MiRobotV1Tests.cs b/MiHomeUnitTests/Miio/MiRobotV1Tests.cs index 2333efb..6f04c43 100644 --- a/MiHomeUnitTests/Miio/MiRobotV1Tests.cs +++ b/MiHomeUnitTests/Miio/MiRobotV1Tests.cs @@ -1,9 +1,9 @@ using System.Threading.Tasks; -using MiHomeLib.Devices; using Moq; using Xunit; using System.Threading; using System.Globalization; +using MiHomeLib.MiioDevices; namespace MiHomeUnitTests { diff --git a/MiHomeUnitTests/Miio/MiioDeviceTest.cs b/MiHomeUnitTests/Miio/MiioDeviceTest.cs index b643ef8..dceb0fe 100644 --- a/MiHomeUnitTests/Miio/MiioDeviceTest.cs +++ b/MiHomeUnitTests/Miio/MiioDeviceTest.cs @@ -1,12 +1,12 @@ using System.Threading.Tasks; -using MiHomeLib.Devices; +using MiHomeLib; using Moq; namespace MiHomeUnitTests { public class MiioDeviceTest { - protected Mock GetMiioDevice(string method, string response) + protected static Mock GetMiioDevice(string method, string response) { var miioDevice = new Mock(); @@ -17,7 +17,7 @@ protected Mock GetMiioDevice(string method, string response) return miioDevice; } - protected Mock GetMiioDevice(string method, int id = 1) + protected static Mock GetMiioDevice(string method, int id = 1) { var miioDevice = new Mock(); @@ -28,7 +28,7 @@ protected Mock GetMiioDevice(string method, int id = 1) return miioDevice; } - protected Mock GetMiioDeviceAsync(string method, int id = 1) + protected static Mock GetMiioDeviceAsync(string method, int id = 1) { var miioDevice = new Mock(); diff --git a/MiHomeUnitTests/Miio/MiioGatewayTests.cs b/MiHomeUnitTests/Miio/MiioGatewayTests.cs index d9a642b..7f1c178 100644 --- a/MiHomeUnitTests/Miio/MiioGatewayTests.cs +++ b/MiHomeUnitTests/Miio/MiioGatewayTests.cs @@ -1,7 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using MiHomeLib.Devices; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MiHomeLib; +using MiHomeLib.MiioDevices; using Moq; using Xunit; @@ -392,9 +393,9 @@ public void GetRadioChannels_Returns_List_of_RadioChannel() }; var miioDevice = new Mock(); - - var msg = "{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + + var msg = "{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"; @@ -424,9 +425,9 @@ public async Task GetRadioChannels_Returns_List_of_RadioChannelAsync() }; var miioDevice = new Mock(); - - var msg = "{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + + var msg = "{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"; @@ -461,8 +462,8 @@ public void AddRadioChannel_with_existing_Id_throws_exception() var miioDevice = new Mock(); var miioGateway = new MiioGateway(miioDevice.Object); - var msg = "{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + var msg = "{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1045,\"type\":0,\"url\":\"http://192.168.1.1/radio.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"; @@ -482,8 +483,8 @@ public void AddRadioChannel_Should_Not_Throw_Exceptions() var msg = "{\"id\": 2, \"method\": \"add_channels\", \"params\": {\"chs\":[{\"id\":1045,\"url\":\"http://192.168.1.1/radio4.m3u8\",\"type\":0}]}}"; miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -507,8 +508,8 @@ public async Task AddRadioChannelAsync_Should_Not_Throw_ExceptionsAsync() var msg = "{\"id\": 2, \"method\": \"add_channels\", \"params\": {\"chs\":[{\"id\":1045,\"url\":\"http://192.168.1.1/radio4.m3u8\",\"type\":0}]}}"; miioDevice.Setup(x => x.SendMessageAsync(It.Is(s => s.Contains("get_channels")))) - .Returns(Task.FromResult("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns(Task.FromResult("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}")); @@ -531,8 +532,8 @@ public void RemoveRadioChannel_Should_Throw_Exception_When_Non_Existing_Id() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -549,8 +550,8 @@ public void RemoveRadioChannel_Should_Not_Throw_Exceptions() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -574,8 +575,8 @@ public async Task RemoveRadioChannelAsync_Should_Not_Throw_ExceptionsAsync() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessageAsync(It.Is(s => s.Contains("get_channels")))) - .Returns(Task.FromResult("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns(Task.FromResult("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}")); @@ -599,8 +600,8 @@ public void RemoveAllRadioChannels_Should_Not_Throw_Exceptions() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -612,7 +613,7 @@ public void RemoveAllRadioChannels_Should_Not_Throw_Exceptions() miioGateway.RemoveAllRadioChannels(); // Assert - var msg = "{\"id\": 2, \"method\": \"remove_channels\", \"params\": {\"chs\":[" + + var msg = "{\"id\": 2, \"method\": \"remove_channels\", \"params\": {\"chs\":[" + "{\"id\":1026,\"url\":\"http://192.168.1.1/radio2.m3u8\",\"type\":0}," + "{\"id\":1027,\"url\":\"http://192.168.1.1/radio3.m3u8\",\"type\":0}" + "]}}"; @@ -628,8 +629,8 @@ public async Task RemoveAllRadioChannelsAsync_Should_Not_Throw_ExceptionsAsync() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessageAsync(It.Is(s => s.Contains("get_channels")))) - .Returns(Task.FromResult("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns(Task.FromResult("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}")); @@ -641,8 +642,8 @@ public async Task RemoveAllRadioChannelsAsync_Should_Not_Throw_ExceptionsAsync() await miioGateway.RemoveAllRadioChannelsAsync(); // Assert - var msg = "{\"id\": 2, \"method\": \"remove_channels\", \"params\": {\"chs\":[" + - "{\"id\":1025,\"url\":\"http://192.168.1.1/radio1.m3u8\",\"type\":0}," + + var msg = "{\"id\": 2, \"method\": \"remove_channels\", \"params\": {\"chs\":[" + + "{\"id\":1025,\"url\":\"http://192.168.1.1/radio1.m3u8\",\"type\":0}," + "{\"id\":1026,\"url\":\"http://192.168.1.1/radio2.m3u8\",\"type\":0}," + "{\"id\":1027,\"url\":\"http://192.168.1.1/radio3.m3u8\",\"type\":0}" + "]}}"; @@ -668,8 +669,8 @@ public void PlayRadio_Should_Throw_Exception_When_Wrong_ChannelId() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -686,8 +687,8 @@ public void PlayRadio_Should_Not_Throw_Exceptions() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessage(It.Is(s => s.Contains("get_channels")))) - .Returns("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}"); @@ -711,8 +712,8 @@ public async Task PlayRadioAsync_Should_Not_Throw_ExceptionsAsync() var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessageAsync(It.Is(s => s.Contains("get_channels")))) - .Returns(Task.FromResult("{\"result\":{\"chs\":[" + - "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + + .Returns(Task.FromResult("{\"result\":{\"chs\":[" + + "{\"id\":1025,\"type\":0,\"url\":\"http://192.168.1.1/radio1.m3u8\"}," + "{\"id\":1026,\"type\":0,\"url\":\"http://192.168.1.1/radio2.m3u8\"}," + "{\"id\":1027,\"type\":0,\"url\":\"http://192.168.1.1/radio3.m3u8\"}," + "]}}")); @@ -751,7 +752,7 @@ public async Task StopRadioAsync_Should_Not_Throw_ExceptionsAsync() { // Arrange var miioDevice = new Mock(); - var miioGateway = new MiioGateway(miioDevice.Object); + var miioGateway = new MiioGateway(miioDevice.Object); miioDevice.Setup(x => x.SendMessageAsync(It.Is(s => s.Contains("play_fm")))) .Returns(Task.FromResult("{\"result\":[\"ok\"],\"id\":1}")); diff --git a/MiHomeUnitTests/Miio/MiioPacketTests.cs b/MiHomeUnitTests/Miio/MiioPacketTests.cs index 0753ecf..5266356 100644 --- a/MiHomeUnitTests/Miio/MiioPacketTests.cs +++ b/MiHomeUnitTests/Miio/MiioPacketTests.cs @@ -1,4 +1,4 @@ -using MiHomeLib.Devices; +using MiHomeLib.MiioDevices; using Xunit; namespace MiHomeUnitTests diff --git a/MiHomeUnitTests/XiaomiGateway3Tests.cs b/MiHomeUnitTests/XiaomiGateway3Tests.cs new file mode 100644 index 0000000..2f50776 --- /dev/null +++ b/MiHomeUnitTests/XiaomiGateway3Tests.cs @@ -0,0 +1,451 @@ +using FluentAssertions; +using MiHomeLib; +using Moq; +using Xunit; +using AutoFixture; +using System; +using System.Collections.Generic; +using MiHomeLib.DevicesV3; +using System.Linq; +using static MiHomeLib.JsonResponses.ZigbeeHearbeatResponse; +using static MiHomeLib.JsonResponses.ZigbeeHearbeatResponse.ZigbeeHearbeatItem; +using MiHomeLib.JsonResponses; +using static MiHomeLib.JsonResponses.BleAsyncEventResponse; +using static MiHomeLib.JsonResponses.BleAsyncEventResponse.BleAsyncEventParams; +using MiHomeLib.Transport; +using MiHomeUnitTests.DevicesV3; + +namespace MiHomeUnitTests; + +public class XiaomiGateway3Tests: MiHome3DeviceTests +{ + private readonly Mock _miioTransport; + private readonly Mock _devicesDiscoverer; + private readonly XiaomiGateway3 _gateway; + public XiaomiGateway3Tests() + { + _miioTransport = new Mock(); + _devicesDiscoverer = new Mock(); + + _devicesDiscoverer + .Setup(x => x.DiscoverZigBeeDevices()) + .Returns([]); + + _devicesDiscoverer + .Setup(x => x.DiscoverBleDevices()) + .Returns([]); + + _gateway = new XiaomiGateway3(_miioTransport.Object, _mqttTransport.Object, _devicesDiscoverer.Object); + } + private void SetupZigBeeDevices(List<(string did, string model, int[] props)> devices) + { + foreach (var (did, model, props) in devices) + { + var getDevicePropResponse = _fixture + .Build() + .With(x => x.Code, 0) + .With(x => x.Result, props) + .Without(x => x.Error) + .Create(); + + _miioTransport + .Setup(x => x.SendMessageRepeated(It.Is(s => s.Contains("get_device_prop") && s.Contains(did)), It.IsAny())) + .Returns(getDevicePropResponse.ToString()); + } + + _devicesDiscoverer + .Setup(x => x.DiscoverZigBeeDevices()) + .Returns(devices.Select(x => (x.did, x.model)).ToList()); + } + private void SetupZigBeeMiSpecDevices(List<(string did, string model)> devices) + { + foreach (var (did, model) in devices) + { + var getDevicePropResponse = _fixture + .Build() + .Without(x => x.Code) + .Without(x => x.Result) + .With(x => x.Error, new GetDevicePropResponse.MiioError { Code = -5015, Message = "device not found" }) + .Create(); + + _miioTransport + .Setup(x => x.SendMessageRepeated(It.Is(s => s.Contains("get_device_prop") && s.Contains(did)), It.IsAny())) + .Returns(getDevicePropResponse.ToString()); + } + + _devicesDiscoverer + .Setup(x => x.DiscoverZigBeeDevices()) + .Returns(devices.Select(x => (x.did, x.model)).ToList()); + } + private void SetupBleDevices(List<(string did, int pdid, string mac)> devices) + { + _devicesDiscoverer + .Setup(x => x.DiscoverBleDevices()) + .Returns(devices); + } + private void RaiseZigbeeReportEvent(string did, double time, List<(string res, int value)> props) + { + var zigbeeReport = _fixture + .Build() + .With(x => x.Did, did) + .With(x => x.Time, time) + .Without(x => x.MiSpec) + .With(x => x.Params, + props.Select(x => new ZigbeeReportResponse.ZigbeeReportResource + { + ResName = x.res, + Value = x.value, + }).ToList()) + .Create(); + + _mqttTransport.Raise(x => x.OnMessageReceived += null, "zigbee/send", zigbeeReport.ToString()); + } + private void RaiseZigbeeMiSpecReportEvent(string did, double time, List<(int siid, int piid, object val)> miSpec) + { + var zigbeeReport = _fixture + .Build() + .With(x => x.Did, did) + .With(x => x.Time, time) + .Without(x => x.Params) + .With(x => x.MiSpec, + miSpec.Select(x => new ZigbeeReportResponse.ZigbeeMiSpecItem + { + Siid = x.siid, + Piid = x.piid, + Value = x.val, + }).ToList()) + .Create(); + + _mqttTransport.Raise(x => x.OnMessageReceived += null, "zigbee/send", zigbeeReport.ToString()); + } + private void RaiseZigbeeHeartbeatEvent(string did, double time, List<(string res, int value)> props) + { + var @params = new List() + { + new() { + Did = did, + Time = time, + Zseq = _fixture.Create(), + ResList = props.Select(x => new ZigbeeHearbeatItemResource() { ResName = x.res, Value = x.value }).ToList(), + } + }; + + var zigbeeHeartBeat = _fixture + .Build() + .With(x => x.Time, time) + .With(x => x.Params, @params) + .Create(); + + _mqttTransport.Raise(x => x.OnMessageReceived += null, "zigbee/send", zigbeeHeartBeat.ToString()); + } + private void RaiseBleAsyncEvent(int pdid, string did, string mac, double time, List<(int eid, string edata)> data) + { + var asyncEventResponse = _fixture + .Build() + .With(x => x.Params, + _fixture + .Build() + .With(x => x.Dev, + _fixture + .Build() + .With(x => x.Did, did) + .With(x => x.Pdid, pdid) + .With(x => x.Mac, mac) + .Create() + ) + .With(x => x.Evt, data.Select(x => new BleAsyncEventEvt(){ Eid = x.eid, Edata = x.edata }).ToList()) + .With(x => x.Gwts, time) + .Create() + ) + .Create(); + + _mqttTransport.Raise(x => x.OnMessageReceived += null, "miio/report", asyncEventResponse.ToString()); + } + [Fact] + public void OnDeviceDiscovered_Works() + { + // Arrange + var eventRaised = false; + var switchDid = _fixture.Create(); + var thDid = _fixture.Create(); + var mac = _fixture.Create()[..12]; + + SetupZigBeeDevices([ + (switchDid, MiWirelesSwitch.MODEL, [3032, 100, 192, 88]), + ]); + + SetupBleDevices([ + (thDid, MiThMonitor2.PDID, mac), + ]); + + var counter = 0; + + _gateway.OnDeviceDiscovered += device => + { + counter++; + eventRaised = true; + + if(device is MiWirelesSwitch sw) sw.Did.Should().Be(switchDid); + if(device is MiThMonitor2 th) th.Did.Should().Be(thDid); + }; + + // Act + _gateway.DiscoverDevices(); + + // Assert + eventRaised.Should().BeTrue(); + counter.Should().Be(2); + } + [Fact] + public void ZigbeeReportCommand_Works() + { + // Arrange + var eventRaised = false; + var did = _fixture.Create(); + var time = (double)DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + SetupZigBeeDevices([ + (did, MiWirelesSwitch.MODEL, [3032, 100, 192, 88]), + ]); + + _gateway.OnDeviceDiscovered += device => + { + device.Did.Should().Be(did); + var sw = device as MiWirelesSwitch; + + sw.OnClick += clickArgs => + { + eventRaised = clickArgs == MiWirelesSwitch.ClickArg.SingleClick; + sw.LastTimeMessageReceived.Should().Be(time.UnixMilliSecondsToDateTime()); + }; + }; + + // Act + _gateway.DiscoverDevices(); + + RaiseZigbeeReportEvent(did, time,[("13.1.85", 1)]); + + // Assert + eventRaised.Should().BeTrue(); + } + [Fact] + public void ZigbeeReportCommandWithMiSpec_Works() + { + // Arrange + var eventRaised = false; + var did = _fixture.Create(); + var time = (double)DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + SetupZigBeeMiSpecDevices([ + (did, AqaraOneChannelRelayEu.MODEL), + ]); + + _gateway.OnDeviceDiscovered += device => + { + device.Did.Should().Be(did); + + var aqaraRelay = device as AqaraOneChannelRelayEu; + + aqaraRelay.OnLoadPowerChange += x => + { + eventRaised = true; + aqaraRelay.LastTimeMessageReceived.Should().Be(time.UnixMilliSecondsToDateTime()); + }; + }; + + // Act + _gateway.DiscoverDevices(); + + RaiseZigbeeMiSpecReportEvent(did, time,[(3, 2, 42.2)]); + + // Assert + eventRaised.Should().BeTrue(); + } + [Fact] + public void ZigbeeHeartbeatCommand_Works() + { + // Arrange + var voltageEventRaised = false; + var batteryEventRaised = false; + var oldVoltage = 3032; + var newVoltage = 3012; + var oldBatteryPerent = 100; + var newBatteryPerent = 99; + var did = _fixture.Create(); + var time = (double)DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + SetupZigBeeDevices([ + (did, MiWirelesSwitch.MODEL, [oldVoltage, oldBatteryPerent, 192, 88]), + ]); + + _gateway.OnDeviceDiscovered += device => + { + device.Did.Should().Be(did); + + var sw = device as MiWirelesSwitch; + + sw.OnVoltageChange += oldValue => + { + voltageEventRaised = true; + (oldVoltage/1000f).Should().Be(oldValue); + sw.Voltage.Should().Be(newVoltage/1000f); + sw.LastTimeMessageReceived.Should().Be(time.UnixMilliSecondsToDateTime()); + }; + + sw.OnBatteryPercentChange += oldValue => + { + batteryEventRaised = true; + oldBatteryPerent.Should().Be(oldBatteryPerent); + sw.BatteryPercent.Should().Be((byte)newBatteryPerent); + sw.LastTimeMessageReceived.Should().Be(time.UnixMilliSecondsToDateTime()); + }; + }; + + // Act + _gateway.DiscoverDevices(); + + RaiseZigbeeHeartbeatEvent(did, time,[("8.0.2008", newVoltage), ("8.0.2001", newBatteryPerent)]); + + // Assert + voltageEventRaised.Should().BeTrue(); + batteryEventRaised.Should().BeTrue(); + } + [Fact] + public void AsyncBleEventMethod_Works() + { + // Arrange + var temperatureEventRaised = false; + var did = _fixture.Create(); + var mac = Helpers.DecodeMacAddress(_fixture.Create()[..12]); + + double time = DateTimeOffset.Now.ToUnixTimeSeconds(); + + SetupBleDevices([ + (did, MiThMonitor2.PDID, mac), + ]); + + _gateway.OnDeviceDiscovered += device => + { + device.Did.Should().Be(did); + + var th = device as MiThMonitor2; + + th.OnTemperatureChange += oldValue => + { + temperatureEventRaised = true; + th.LastTimeMessageReceived.Should().Be(time.UnixSecondsToDateTime()); + }; + }; + + // Act + _gateway.DiscoverDevices(); + + RaiseBleAsyncEvent(MiThMonitor2.PDID, did, mac, time, [(4100, "e500")]); + + // Assert + temperatureEventRaised.Should().BeTrue(); + } + + [Fact] + public void GetDevices_When_Any_Returns_DiscoveredDevicesList() + { + // Arrange + var switchDid = _fixture.Create(); + var thSensorDid = _fixture.Create(); + var thMonitorDid = _fixture.Create(); + var mac = _fixture.Create()[..12]; + + SetupZigBeeDevices([ + (switchDid, MiWirelesSwitch.MODEL, [3032, 100, 192, 88]), + (thSensorDid, XiaomiThSensor.MODEL, [3032, 100, 192, 88]), + ]); + + SetupBleDevices([ + (thMonitorDid, MiThMonitor2.PDID, mac), + ]); + + // Act + _gateway.DiscoverDevices(); + var devices = _gateway.GetDevices(); + + // Assert + devices.Count.Should().Be(3); + devices.Any(x => x.Did == switchDid && x.GetType() == typeof(MiWirelesSwitch)).Should().BeTrue(); + devices.Any(x => x.Did == thSensorDid && x.GetType() == typeof(XiaomiThSensor)).Should().BeTrue(); + devices.Any(x => x.Did == thMonitorDid && x.GetType() == typeof(MiThMonitor2)).Should().BeTrue(); + } + + [Fact] + public void GetDevices_When_NoOne_Returns_EmptyList() + { + // Arrange + + // Act + _gateway.DiscoverDevices(); + + // Assert + _gateway.GetDevices().Count.Should().Be(0); + } + + [Fact] + public void GetDeviceByDid_When_NotFound_Returns_Null() + { + // Arrange + var did = _fixture.Create(); + + // Act + _gateway.DiscoverDevices(); + + // Assert + _gateway.GetDeviceByDid(did).Should().BeNull(); + } + + [Fact] + public void GetDeviceByDid_WhenExistsAndHasCorrectType_Returns_Device() + { + // Arrange + var switchDid = _fixture.Create(); + + SetupZigBeeDevices([ + (switchDid, MiWirelesSwitch.MODEL, [3032, 100, 192, 88]), + (_fixture.Create(), XiaomiThSensor.MODEL, [3032, 100, 192, 88]), + ]); + + SetupBleDevices([ + (_fixture.Create(), MiThMonitor2.PDID, _fixture.Create()), + ]); + + // Act + _gateway.DiscoverDevices(); + + // Assert + _gateway + .GetDeviceByDid(switchDid) + .Should() + .NotBeNull() + .And + .Match(x => x.Did == switchDid); + } + + [Fact] + public void GetDeviceByDid_WhenHasWrongType_Returns_Null() + { + // Arrange + var switchDid = _fixture.Create(); + + SetupZigBeeDevices([ + (switchDid, MiWirelesSwitch.MODEL, [3032, 100, 192, 88]), + (_fixture.Create(), XiaomiThSensor.MODEL, [3032, 100, 192, 88]), + ]); + + SetupBleDevices([ + (_fixture.Create(), MiThMonitor2.PDID, _fixture.Create()), + ]); + + // Act + _gateway.DiscoverDevices(); + + // Assert + _gateway.GetDeviceByDid(switchDid).Should().BeNull(); + } +} diff --git a/README.md b/README.md index 95701c6..ec9371c 100644 --- a/README.md +++ b/README.md @@ -5,48 +5,35 @@ [![Nuget](https://buildstats.info/nuget/mihomelib)](https://www.nuget.org/packages/MiHomeLib) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/sergey-brutsky/mi-home/blob/master/LICENSE.md) -This library provides simple and flexible C# API for Xiaomi Mi Home devices. - -Currently supports **only Gateway version 2 (DGNWG02LM)**, Air Humidifier (zhimi.humidifier.v1), Mi Robot vacuum (rockrobo.vacuum.v1) and several sensors. See the pictures below. - -![smart-home](https://user-images.githubusercontent.com/5664637/118375593-46751980-b5cb-11eb-81f9-93b095401737.jpeg) - -![humidifier](https://user-images.githubusercontent.com/5664637/102880937-25b1f900-445d-11eb-83e4-1f96830510d6.jpg) -![mirobot](https://user-images.githubusercontent.com/5664637/118375624-7de3c600-b5cb-11eb-8887-772795f7fbf5.jpeg) - -![gateway](https://user-images.githubusercontent.com/5664637/32080159-d2fbd29a-bab6-11e7-9ef8-e18c048fd5fe.jpg) -![temperature_sensor](https://user-images.githubusercontent.com/5664637/32080111-88c9a058-bab6-11e7-9d73-82dd77e362ae.jpg) -![socket_plug](https://user-images.githubusercontent.com/5664637/32080247-4b007520-bab7-11e7-9e0a-83e01ee37b8e.jpg) -![motion_sensor](https://user-images.githubusercontent.com/5664637/32079992-db2366d2-bab5-11e7-9f5f-d9bf711f261f.jpg) -![motion_sensor_2](./images/MotionSensor2.jpg) -![door_window_sensor](https://user-images.githubusercontent.com/5664637/32079914-83947b22-bab5-11e7-8f5c-43d07ca82022.jpg) -![aqara_door_window_sensor](./images/ContactSensor2.jpg) -![water_sensor](https://user-images.githubusercontent.com/5664637/32079774-d6bdd9d4-bab4-11e7-8a48-5c2b7ea978c9.jpg) -![smoke_sensor](https://user-images.githubusercontent.com/5664637/32079813-05bfab9a-bab5-11e7-9416-2227e167f0ab.jpg) -![switch](https://user-images.githubusercontent.com/5664637/37819616-233b087e-2e8f-11e8-8558-7e47137705d4.jpg) -![wired wall switch](https://user-images.githubusercontent.com/5664637/37880344-6dc7b066-308f-11e8-80b1-1b39ef973acf.jpg) -![sensor_weather](https://user-images.githubusercontent.com/5664637/37911004-9687dafc-3117-11e8-9e82-a6823da8da0b.jpg) -![wireless dual wall switch](https://user-images.githubusercontent.com/5664637/63649478-eaa79480-c746-11e9-94ff-092814f62c6f.jpg) -![aqara_cube_sensor](./images/MagicSquare.jpg) - - -## Table of Contents -1. [Installation](#installation) -2. [Setup Gateway](#setup-gateway) -3. [Basic scenario](#basic-scenario) -4. [Supported devices](#supported-devices) - - 4.1 [Gateway](#gateway) - - 4.1.1 [Gateway radio](#gateway-radio) - - 4.2 [Temparature & humidity sensor](#th-sensor) - - 4.3 [Socket plug](#socket-plug) - - 4.4 [Motion sensor](#motion-sensor) - - 4.5 [Door/Window sensor](#door-window-sensor) - - 4.6 [Water leak sensor](#water-sensor) - - 4.7 [Smoke sensor](#smoke-sensor) - - 4.8 [Wireless dual wall switch](#dual-wall-sensor) - - 4.9 [Aqara cube](#aqara-cube) - - 4.10 [Air humidifier](#air-humidifier) - - 4.11 [Mi Robot Vacuum](#mi-robot-v1) +This library provides simple and flexible C# API for Xiaomi smart devices. + +Currently supports **only Gateway version 2 (DGNWG02LM), Gateway version 3 (ZNDMWG03LM)**, Air Humidifier (zhimi.humidifier.v1), Mi Robot vacuum (rockrobo.vacuum.v1) and several sensors. See table below. + +![xiaomi-gateway-2](https://user-images.githubusercontent.com/5664637/118375593-46751980-b5cb-11eb-81f9-93b095401737.jpeg) + +## Supported gateway devices/sensors +| Device| Gateway 2 support| Gateway 3 support | +|:---: |:---: |:---: | +| [Xiaomi Door/Window Sensor](#link-to-wiki-here)

MCCGQ01LM | yes | yes | +| [Xiaomi Door/Window Sensor 2](#link-to-wiki-here)

MCCGQ02HL | yes | yes | +| [Aqara Door/Window Sensor](#link-to-wiki-here)

MCCGQ11LM | yes | yes | +| [Xiaomi TH Sensor](#link-to-wiki-here)

WSDCGQ01LM | yes | yes | +| [Xiaomi TH Sensor 2](#link-to-wiki-here)

LYWSD03MMC | no | yes | +| [Aqara TH Sensor](#link-to-wiki-here)

WSDCGQ11LM | yes | yes | +| [Aqara Water Leak Sensor](#link-to-wiki-here)

SJCGQ11LM | yes | yes | +| [Xiaomi Motion Sensor](#link-to-wiki-here)

RTCGQ01LM | yes | yes | +| [Xiaomi Motion Sensor 2](#link-to-wiki-here)

RTCGQ02LM | no | yes | +| [Aqara Relay T1 EU (with N)](#link-to-wiki-here)

SSM-U01 | no | yes | +| [Aqara Relay CN](#link-to-wiki-here)

LLKZMK11LM | no | yes | +| [Aqara Opple Switch (2 buttons)](#link-to-wiki-here)

WXCJKG11LM | no | yes | +| [Aqara Opple Switch (4 buttons)](#link-to-wiki-here)

WXCJKG12LM | no | yes | +| [Honeywell Smoke Sensor](#link-to-wiki-here)

JTYJ-GD-01LM/BW | yes | yes | +| [Honeywell Smoke Alarm](#link-to-wiki-here)

JTYJ-GD-03MI | no | yes | +| [Xiaomi Wireless Button](#link-to-wiki-here)

WXKG01LM | yes | yes | +| [Xiaomi Plug CN](#link-to-wiki-here)

ZNCZ02LM | yes | yes | +| [Aqara Double Wall Switch (no N)](#link-to-wiki-here)

QBKG03LM | yes | no | +| [Aqara Double Wall Button CN](#link-to-wiki-here)

WXKG02LM | yes | no | +| [Aqara Cube EU](#link-to-wiki-here)

MFKZQ01LM | yes | no | ## Installation via nuget package manager @@ -57,9 +44,9 @@ or ```nuget dotnet add package MiHomeLib ``` -or install via [GitHub packages](https://github.com/sergey-brutsky/mi-home/packages/540443) +or install via [GitHub packages](https://github.com/sergey-brutsky/mi-home/pkgs/nuget/MiHomeLib) -## Setup Gateway +## Setup Xiaomi Gateway 2 Before using this library you should setup **development mode** on your gateway, [instructions how to do this](https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)).\ This mode allows to work with the gateway via UDP multicast protocol. @@ -78,18 +65,36 @@ If it is not you **have to** use multicast routers like [udproxy](https://github **Warning 3**: If your app is running on windows machine, make sure that you disabled virtual network adapters like VirtualBox, Hyper-V, Npcap, pcap etc. Because these adapters may prevent proper work of multicast traffic between your machine and gateway -## Basic scenario -Get all devices in the network +## Setup Xiaomi Gateway 3 + +Before using this library: + +1. Open telnet on your gateway +2. Expose MQTT broker to the world +3. Extract token to work with your gateway + +The easisest way is to setup/configure [this HA integration](https://github.com/AlexxIT/XiaomiGateway3/) (it does all aforementioned things automatically). + +The way of warrior: +1. [Enable telnet on your gateway](https://gist.github.com/zvldz/1bd6b21539f84339c218f9427e022709) +2. Download this [openmiio_agent](http://github.com/AlexxIT/openmiio_agent/releases/download/v1.2.1/openmiio_agent_mips) and upload it to your gateway (for example to /data/openmiio_agent) via telnet +3. Login to your gateway via telnet `telnet 23` (login: admin, pwd: empty) +4. Kill embedded mosquitto mqtt broker and run openmiio_agent (it will expose mqtt port 1883 to the world) `kill -9 && /data/openmiio_agent mqtt &` +5. Check that mosquitto is binded to `0.0.0.0 1883` `netstat -ntlp | grep mosquitto` +6. [Extract token instructions](https://github.com/jghaanstra/com.xiaomi-miio/blob/master/docs/obtain_token.md) + +## Basic scenarios +Get all devices in the network from the **Xiaomi Gateway 2** ```csharp public static void Main(string[] args) { // gateway password is optional, needed only to send commands to your devices // gateway sid is optional, use only when you have 2 gateways in your LAN - // using var miHome = new MiHome("gateway password", "gateway sid"); - using var miHome = new MiHome(); + // using var gw2 = new XiaomiGateway2("gateway password", "gateway sid"); + using var gw2 = new XiaomiGateway2(); - miHome.OnAnyDevice += (_, device) => + gw2.OnAnyDevice += (_, device) => { Console.WriteLine($"{device.Sid}, {device.GetType()}, {device}"); // all discovered devices }; @@ -97,386 +102,27 @@ public static void Main(string[] args) Console.ReadLine(); } ``` -## Supported devices - -### 1. Gateway - -![gateway](https://user-images.githubusercontent.com/5664637/32080159-d2fbd29a-bab6-11e7-9ef8-e18c048fd5fe.jpg) - -```csharp -using var miHome = new MiHome("gateway password here"); // here we using developers api - -miHome.OnGateway += (_, gateway) => -{ - gateway.EnableLight(); // by default this is "white" light - Task.Delay(3000).Wait(); - gateway.DisableLight(); // light off - Task.Delay(3000).Wait(); - gateway.EnableLight(255, 0, 0, 100); // turn on "red" light with full brightness - Task.Delay(3000).Wait(); - gateway.DisableLight(); // light off - Task.Delay(3000).Wait(); - gateway.PlaySound(Gateway.Sound.IceWorldPiano, 50); // play ice world piano sound on gateway with volume 50% - Task.Delay(3000).Wait(); - gateway.SoundsOff(); - gateway.PlayCustomSound(10_002, 50); // play custom sound with volume 50% - Task.Delay(3000).Wait(); - gateway.SoundsOff(); - -}; -``` -Yes, it is possible to upload custom sounds to your gateway and use them in various scenarios. [Check this instruction](https://smarthomehobby.com/using-the-xiaomi-door-window-sensor/). - - -#### 1.1 Gateway Radio -It is possible to add/remove/play custom radio channels in this version of gateway. - -Bellow is a simple code snippet explaining how to use this feature. - -```csharp -var gw = new MiioGateway("192.168.1.12", ""); - -var radioChannels = gw.GetRadioChannels(); // get list of available custom radio channels - -foreach (var channel in radioChannels) -{ - Console.WriteLine(channel); -} - -gw.AddRadioChannel(1025, "http://192.168.1.1/my-playlist.m3u8"); // add custom radio channel -Task.Delay(1000).Wait(); -gw.PlayRadio(1024, 50); // play newly-added channel with volume 50% -Task.Delay(1000).Wait(); -gw.StopRadio(); // stop playing radio -Task.Delay(1000).Wait(); -gw.RemoveRadioChannel(1024); // remove newly-added channel -Task.Delay(1000).Wait(); -gw.RemoveAllRadioChannels(); // remove all custom radio channels -``` -Async methods also supported. - -**Warning 1**: Added radio channels are not persistant. Gateway may remove them from time to time. -**Warning 2**: My gateway recognizes only songs in aac format (mp3 is not supported). - -Here is minimal working sample of m3u8 file that gateway recognizes and respects. -``` -#EXTM3U -#EXT-X-VERSION:3 -#EXT-X-MEDIA-SEQUENCE:1 -#EXTINF:240, -http://192.168.1.2/test.aac -``` -EXT-X-MEDIA-SEQUENCE - number of songs in your playlist. - -EXTINF - track length in seconds. - -http://192.168.1.2/test.aac - url to your song -### 2. Temperature and humidity sensor - -![temperature_sensor](https://user-images.githubusercontent.com/5664637/32080111-88c9a058-bab6-11e7-9d73-82dd77e362ae.jpg) - -```csharp -using var miHome = new MiHome(); - -miHome.OnThSensor += (_, thSensor) => -{ - if (thSensor.Sid == "158d000182dfbc") // sid of specific device - { - Console.WriteLine(thSensor); // Sample output --> Temperature: 22,19°C, Humidity: 74,66%, Voltage: 3,035V - - thSensor.OnTemperatureChange += (_, e) => - { - Console.WriteLine($"New temperature: {e.Temperature}"); - }; - - thSensor.OnHumidityChange += (_, e) => - { - Console.WriteLine($"New humidity: {e.Humidity}"); - }; - } -}; -``` - -### 3. Socket Plug (zigbee version) - -![socket_plug](https://user-images.githubusercontent.com/5664637/32080247-4b007520-bab7-11e7-9e0a-83e01ee37b8e.jpg) - -```csharp -using var miHome = new MiHome(); - -miHome.OnSocketPlug += (_, socketPlug) => -{ - if (socketPlug.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(socketPlug); // sample output Status: on, Inuse: 1, Load Power: 2.91V, Power Consumed: 37049W, Voltage: 3.6V - - socketPlug.TurnOff(); - Task.Delay(5000).Wait(); - socketPlug.TurnOn(); - } -}; -``` - -### 4. Motion sensor or Aqara motion sensor - -![motion_sensor](https://user-images.githubusercontent.com/5664637/32079992-db2366d2-bab5-11e7-9f5f-d9bf711f261f.jpg) -![motion_sensor_2](./images/MotionSensor2.jpg) - -```csharp -using var miHome = new MiHome(); - -//miHome.OnAqaraMotionSensor += (_, motionSensor) => -miHome.OnMotionSensor += (_, motionSensor) => -{ - if (motionSensor.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(motionSensor); // sample output Status: motion, Voltage: 3.035V, NoMotion: 0s - - motionSensor.OnMotion += (_, __) => - { - Console.WriteLine($"{DateTime.Now}: Motion detected !"); - }; - - motionSensor.OnNoMotion += (_, e) => - { - Console.WriteLine($"{DateTime.Now}: No motion for {e.Seconds}s !"); - }; - } -}; -``` - -### 5. Door/Window sensor or Aqara open/close sensor - -![door_window_sensor](https://user-images.githubusercontent.com/5664637/32079914-83947b22-bab5-11e7-8f5c-43d07ca82022.jpg) -![aqara_door_window_sensor](./images/ContactSensor2.jpg) - -```csharp -using var miHome = new MiHome(); - -//miHome.OnAqaraOpenCloseSensor += (_, windowSensor) => -miHome.OnDoorWindowSensor += (_, windowSensor) => -{ - if (windowSensor.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(windowSensor); // sample output Status: close, Voltage: 3.025V - - windowSensor.OnOpen += (_, __) => - { - Console.WriteLine($"{DateTime.Now}: Window opened !"); - }; - - windowSensor.OnClose += (_, __) => - { - Console.WriteLine($"{DateTime.Now}: Window closed !"); - }; - - } -}; -``` - -### 6. Water leak sensor - -![water_sensor](https://user-images.githubusercontent.com/5664637/31301235-2d6403ee-ab01-11e7-914a-80641e3ba2bf.jpg) - -```csharp -using var miHome = new MiHome(); - -miHome.OnWaterLeakSensor += (_, waterLeakSensor) => -{ - if (waterLeakSensor.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(waterLeakSensor); // Status: no_leak, Voltage: 3.015V - - waterLeakSensor.OnLeak += (_, __) => - { - Console.WriteLine("Water leak detected !"); - }; - - waterLeakSensor.OnNoLeak += (_, __) => - { - Console.WriteLine("NO leak detected !"); - }; - - } -}; -``` -### 7. Smoke sensor - -![smoke_sensor](https://user-images.githubusercontent.com/5664637/32071412-e3db3e76-ba97-11e7-840c-1d901df4b84f.jpg) +Get all devices in the network from the **Xiaomi Gateway 3** ```csharp -using var miHome = new MiHome(); - -miHome.OnSmokeSensor += (_, smokeSensor) => -{ - if (smokeSensor.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(smokeSensor); // sample output Alarm: off, Density: 0, Voltage: 3.075V - - smokeSensor.OnAlarm += (_, __) => - { - Console.WriteLine("Smoke detected !"); - }; - - smokeSensor.OnAlarmStopped += (_, __) => - { - Console.WriteLine("Smoke alarm stopped"); - }; - - smokeSensor.OnDensityChange += (_, e) => - { - Console.WriteLine($"Density changed {e.Density}"); - }; - } -}; -``` - -### 8. Wireless dual wall switch - -![wireless dual wall switch](https://user-images.githubusercontent.com/5664637/63649478-eaa79480-c746-11e9-94ff-092814f62c6f.jpg) - -```csharp -using var miHome = new MiHome(); - -miHome.OnWirelessDualWallSwitch += (_, wirelessDualSwitch) => +public static void Main(string[] args) { - if (wirelessDualSwitch.Sid == "158d00015dc6cc") // sid of specific device - { - Console.WriteLine(wirelessDualSwitch); - - wirelessDualSwitch.OnLeftClick += (_) => - { - Console.WriteLine("Left button clicked !"); - }; - - wirelessDualSwitch.OnRightDoubleClick += (_) => - { - Console.WriteLine("Right button double clicked !"); - }; - - wirelessDualSwitch.OnLeftLongClick += (_) => - { - Console.WriteLine("Left button long clicked !"); - }; - - } -}; -``` - -### 9. Aqara cube - -![aqara_cube_sensor](./images/MagicSquare.jpg) - -```csharp -using var miHome = new MiHome(); + using var gw3 = new XiaomiGateway3("", ""); -miHome.OnAqaraCubeSensor += (_, aqaraQube) => -{ - if (aqaraQube.Sid == "158d00015dc6cc") // sid of specific device + gw3.OnDeviceDiscovered += gw3SubDevice => { - Console.WriteLine(aqaraQube); - - aqaraQube.OnStatusChanged += (sender, eventArgs) => - { - Console.WriteLine($"{sender} | {eventArgs.Status}"); - }; - - } -}; -``` - -### 10. Air Humidifier -![humidifier](https://user-images.githubusercontent.com/5664637/102878695-b71f6c00-4459-11eb-92c1-518c57b34683.jpg) - -Before using the library you need to know IP and TOKEN of your air humidifier. -If you don't know these parameters try to use the following code in order to discover air humidifiers in your LAN -```csharp -AirHumidifier.OnDiscovered += (_, humidifier) => -{ - Console.WriteLine($"ip: {humidifier.Ip}, token: {humidifier.Token}"); - // sample output ip: 192.168.1.5, token: 4a3a2f017b70097a850558c35c953b55 -}; - -AirHumidifier.DiscoverDevices(); -``` -If your device hides his token follow [these instructions](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) in order to extract it. - -Basic scenario - -```csharp -var airHumidifier = new AirHumidifier("", ""); -Console.WriteLine(airHumidifier); -/* sample output -Power: on -Mode: high -Temperature: 32.6 °C -Humidity: 34% -LED brightness: bright -Buzzer: on -Child lock: off -Target humidity: 50% -Model: zhimi.humidifier.v1 -IP Address:192.168.1.5 -Token: 4a3a2f017b70097a850558c35c953b55 -*/ -``` -Functions -```csharp -var airHumidifier = new AirHumidifier("", ""); -airHumidifier.PowerOn(); // power on -airHumidifier.PowerOff(); // power off -airHumidifier.SetMode(AirHumidifier.Mode.High); // set fan mode high/medium/low -airHumidifier.GetTemperature(); // get temperature -airHumidifier.GetHumidity(); // get humidity -airHumidifier.SetBrightness(AirHumidifier.Brightness.Bright); // set brighness bright/dim/off -airHumidifier.BuzzerOn(); // set buzzer sound on -airHumidifier.BuzzerOff(); // set buzzer sound off -airHumidifier.ChildLockOn(); // set child lock on -airHumidifier.ChildLockOff(); // set child lock oаа -airHumidifier.GetTargetHumidity(); // get humidity limit 20/30/40/50/60/70/80 % -``` -Async versions of the operations above also supported. - -### 11. Mi Robot Vacuum -![mirobot](https://user-images.githubusercontent.com/5664637/118375492-a6b78b80-b5ca-11eb-86d3-3b9065ac3892.jpeg) - -Before using the library you need to know IP and TOKEN of your Mi Robot. + Console.WriteLine(gw3SubDevice.ToString()); // all discovered devices + }; -If you don't know these parameters try to use the following code in order to discover **mi robots** in your LAN -```csharp -MiRobotV1.OnDiscovered += (_, e) => -{ - Console.WriteLine($"{e.Ip}, {e.Serial}, {e.Type}, {e.Token}"); -}; + gw3.DiscoverDevices(); -MiRobotV1.DiscoverDevices() -``` -If your device hides his token (you get 'ffffffffffffffffffffffffffffffff' instead of token) follow [these instructions](https://github.com/Maxmudjon/com.xiaomi-miio/blob/master/docs/obtain_token.md) in order to extract it. - -Supported methods -```csharp -var miRobot = new MiRobotV1("", ""); -miRobot.Start(); // start the clean up -miRobot.Stop(); // stop the clean up -miRobot.Pause(); // pause the clean up -miRobot.Spot(); // start spot clean up -miRobot.Home(); // go back to the base station -miRobot.FindMe(); // tell the robot to give a voice + Console.ReadLine(); +} ``` -Async versions of the operations above also supported. -**Warning**: -Mi Robot stores client requests in memory and doesn't allow to send request with the same client id twice. +## Documentation +Check detailed documentation on how to work with different devices in the [project's WIKI](https://github.com/sergey-brutsky/mi-home/wiki) -It means that if you run the code snippet bellow twice. -```csharp -var miRobot = new MiRobotV1("", ""); -miRobot.Start(); // start the clean up -``` -The second attempt will fail. -Work around is to set client id manually (usually increasing to 1 works) -```csharp -var miRobot = new MiRobotV1("", "", 2); // client request id is set to 2 -miRobot.Start(); // start the clean up -``` +## Contribution +Your pull requests are welcome to replenish the database of supported devices \ No newline at end of file