Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added option to turn device children on as in EP40. #10

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Kasa/Data/Child.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using Newtonsoft.Json;

namespace Kasa;

/// <summary>
/// Child element for outlet device with more than one plug
/// </summary>
public struct Child {
[JsonProperty("id")] public string id { get; set; } // "800671BEB946D3C691ECBD2940991375202E165600",
[JsonProperty("state")] public string state { get; set; } // 1,
[JsonProperty("alias")] public string alias { get; set; } // "Outside Plug 1",
[JsonProperty("on_time")] public string on_time { get; set; } // 1882,

/// <exception cref="ArgumentOutOfRangeException"></exception>
public static Feature FromJsonString(string jsonString) => jsonString.ToUpperInvariant() switch {
"TIM" => Feature.Timer,
"ENE" => Feature.EnergyMeter,
_ => throw new ArgumentOutOfRangeException(nameof(jsonString), jsonString, "Unknown feature")
};
}
4 changes: 4 additions & 0 deletions Kasa/Data/SystemInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,8 @@ public struct SystemInfo {
/// </summary>
[JsonProperty("feature")] public ISet<Feature> Features { get; internal set; }

/// <summary>
/// Children switches in a single device
/// </summary>
[JsonProperty("children")] public ISet<Child> Children { get; internal set; }
}
1 change: 1 addition & 0 deletions Kasa/Data/Timer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public Timer(TimeSpan duration, bool willSetOutletOn): this() {
IsEnabled = true;
TotalDuration = duration;
WillSetOutletOn = willSetOutletOn;
RemainingDuration = duration; // Necissary to build.
}

}
2 changes: 1 addition & 1 deletion Kasa/IKasaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ internal interface IKasaClient: IDisposable {
/// <exception cref="FeatureUnavailable">If the device is missing a feature that is required to run the given method, such as running <c>EnergyMeter.GetInstantaneousPowerUsage()</c> on an EP10, which does not have the EnergyMeter Feature.</exception>
/// <exception cref="NetworkException">if the TCP connection to the outlet failed and could not automatically reconnect</exception>
/// <exception cref="ResponseParsingException">if the JSON received from the outlet contained unexpected data</exception>
Task<T> Send<T>(CommandFamily commandFamily, string methodName, object? parameters = null);
Task<T> Send<T>(CommandFamily commandFamily, string methodName, object? parameters = null, string[]? childIds = null);

}
12 changes: 12 additions & 0 deletions Kasa/IKasaOutlet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ public interface ISystemCommands {
/// <exception cref="ResponseParsingException">if the JSON received from the outlet contained unexpected data</exception>
Task SetOutletOn(bool turnOn);

/// <summary>
/// <para>Turn on or off the device's children outlets so it can supply power to any connected electrical consumers or not.</para>
/// <para>You can also toggle the outlet by pressing the physical button on the device.</para>
/// <para>This call is idempotent: if you try to turn the outlet on and it's already on, the call will have no effect.</para>
/// <para>The state is persisted across restarts. If the device loses power, it will restore the previous outlet power state when it turns on again.</para>
/// <para>This call is unrelated to turning the entire Kasa device on or off. To reboot the device, use <see cref="Reboot"/>.</para>
/// </summary>
/// <param name="turnOnChild"><c>true</c> to supply power to the outlet, or <c>false</c> to switch if off.</param>
/// <exception cref="NetworkException">if the TCP connection to the outlet failed and could not automatically reconnect</exception>
/// <exception cref="ResponseParsingException">if the JSON received from the outlet contained unexpected data</exception>
Task SetAllChildOutletOn(bool turnOn);

/// <summary>
/// <para>Get data about the device, including hardware, software, configuration, and current state.</para>
/// </summary>
Expand Down
79 changes: 73 additions & 6 deletions Kasa/KasaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,11 @@ public async Task Connect() {

/// <inheritdoc />
// ExceptionAdjustment: M:System.Threading.SemaphoreSlim.Release -T:System.Threading.SemaphoreFullException
public async Task<T> Send<T>(CommandFamily commandFamily, string methodName, object? parameters = null) {
public async Task<T> Send<T>(CommandFamily commandFamily, string methodName, object? parameters = null, string[]? childIds = null)
{
await TcpMutex.WaitAsync().ConfigureAwait(false); //only one TCP write operation may occur in parallel, which is a requirement of TcpClient
try {
Task<T> Attempt() => SendWithoutRetry<T>(commandFamily, methodName, parameters);
Task<T> Attempt() => SendWithoutRetry<T>(commandFamily, methodName, parameters, childIds);

#pragma warning disable Ex0100 // Member may throw undocumented exception
return await Retrier.InvokeWithRetry(Attempt, Options.MaxAttempts, _ => Options.RetryDelay, IsRetryAllowed).ConfigureAwait(false);
Expand All @@ -132,15 +133,16 @@ public async Task<T> Send<T>(CommandFamily commandFamily, string methodName, obj
// ExceptionAdjustment: M:System.Threading.Interlocked.Increment(System.Int64@) -T:System.NullReferenceException
// ExceptionAdjustment: M:System.IO.Stream.WriteAsync(System.Byte[],System.Int32,System.Int32,System.Threading.CancellationToken) -T:System.NotSupportedException
// ExceptionAdjustment: M:System.IO.Stream.ReadAsync(System.Byte[],System.Int32,System.Int32,System.Threading.CancellationToken) -T:System.NotSupportedException
private async Task<T> SendWithoutRetry<T>(CommandFamily commandFamily, string methodName, object? parameters) {
private async Task<T> SendWithoutRetry<T>(CommandFamily commandFamily, string methodName, object? parameters, string[]? childIds = null)
{
/*
* Send request
*/

Stream tcpStream = await GetNetworkStream().ConfigureAwait(false);
long requestId = Interlocked.Increment(ref _requestId);
JObject request = new(new JProperty(commandFamily.ToJsonString(), new JObject(
new JProperty(methodName, parameters is null ? null : JObject.FromObject(parameters, JsonSerializer)))));
long requestId = Interlocked.Increment(ref _requestId);
JObject request = BuildRequest(commandFamily, methodName, parameters, childIds);

byte[] requestBytes = Serialize(request, requestId);
byte[] headerBuffer = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(requestBytes.Length));

Expand Down Expand Up @@ -170,6 +172,71 @@ private async Task<T> SendWithoutRetry<T>(CommandFamily commandFamily, string me
return Deserialize<T>(payloadBuffer, requestId, request, commandFamily, methodName);
}

private static JObject BuildRequest(CommandFamily commandFamily, string methodName, object? parameters, string[]? childIds = null)
{
JObject request = new JObject();

try
{
if (childIds is null)
{
request = new
(
new JProperty(
commandFamily.ToJsonString(),
new JObject
(
new JProperty(methodName, parameters is null ? null : JObject.FromObject(parameters, JsonSerializer))
)
)
);
}
else
{
request = new
(
new JProperty
(
"context", new JObject
(
new JProperty
(
"child_ids", JToken.FromObject(childIds, JsonSerializer)
)
)
),
new JProperty(
commandFamily.ToJsonString(),
new JObject
(
new JProperty(methodName, parameters is null ? null : JObject.FromObject(parameters, JsonSerializer))
)
)
);
}
// request.Add(
// new JProperty
// (
// "context", new JObject
// (
// new JProperty
// (
// "child_ids", JToken.FromObject(childIds, JsonSerializer)
// )
// )
// )
// );
}
catch (Exception ex)
{
var serializedObject1 = Newtonsoft.Json.JsonConvert.SerializeObject(request);
}

var serializedObject = Newtonsoft.Json.JsonConvert.SerializeObject(request);

return request;
}

/// <exception cref="SocketException">The TCP socket failed to connect.</exception>
internal virtual async Task<Stream> GetNetworkStream() {
await EnsureConnected().ConfigureAwait(false);
Expand Down
24 changes: 24 additions & 0 deletions Kasa/KasaOutlet.System.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;

Expand All @@ -20,6 +21,29 @@ Task IKasaOutlet.ISystemCommands.SetOutletOn(bool turnOn) {
return _client.Send<JObject>(CommandFamily.System, "set_relay_state", new { state = Convert.ToInt32(turnOn) });
}

/// <inheritdoc />
Task IKasaOutlet.ISystemCommands.SetAllChildOutletOn(bool turnOn)
{
var systemInfo = System.GetInfo().Result;

if (systemInfo.Children == null)
{
// No children are found, return.
return Task.FromResult(false);
}

// In order to support multiple outlets, a 'context' node must be added:
// {"system":{"set_relay_state":{"state":0}},"context":{"child_ids":["etc00", "etc01]}}
// https://community.home-assistant.io/t/tp-link-kasa-kp400-2-outlet-support/113135/9

string[] children = systemInfo.Children.Select(child => child.id).ToArray();
var commandParameters = new { state = Convert.ToInt32(turnOn) };

//var serializedObject = Newtonsoft.Json.JsonConvert.SerializeObject(commandParameters); // Debug
// Include array of children IDs to Send<JObject>().
return _client.Send<JObject>(CommandFamily.System, "set_relay_state", commandParameters, children);
}

/// <inheritdoc />
Task<SystemInfo> IKasaOutlet.ISystemCommands.GetInfo() {
return _client.Send<SystemInfo>(CommandFamily.System, "get_sysinfo");
Expand Down
14 changes: 7 additions & 7 deletions Test/KasaOutletEnergyMeterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class KasaOutletEnergyMeterTest: AbstractKasaOutletTest {
[Fact]
public async Task GetInstantaneousPowerUsage() {
PowerUsage expected = new() { Current = 18, Voltage = 121995, Power = 967, CumulativeEnergySinceBoot = 0 };
A.CallTo(() => Client.Send<PowerUsage>(CommandFamily.EnergyMeter, "get_realtime", null)).Returns(expected);
A.CallTo(() => Client.Send<PowerUsage>(CommandFamily.EnergyMeter, "get_realtime", null, null)).Returns(expected);
PowerUsage actual = await Outlet.EnergyMeter.GetInstantaneousPowerUsage();
actual.Current.Should().Be(18);
actual.Voltage.Should().Be(121995);
Expand All @@ -21,7 +21,7 @@ public async Task GetInstantaneousPowerUsage() {
[Fact]
public async Task GetDailyEnergyUsageOneDay() {
JObject json = JObject.Parse(@"{""day_list"": [{""year"": 2022, ""month"": 5, ""day"": 31, ""energy_wh"": 6}]}");
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 5 }, "")))).Returns(json);
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 5 }, "")), null)).Returns(json);
IList<int>? actual = await Outlet.EnergyMeter.GetDailyEnergyUsage(2022, 5);
actual.Should().HaveCount(31);
actual.Should().BeEquivalentTo(new List<int> { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6 });
Expand All @@ -30,7 +30,7 @@ public async Task GetDailyEnergyUsageOneDay() {
[Fact]
public async Task GetDailyEnergyUsageTwoDays() {
JObject json = JObject.Parse(@"{""day_list"": [{""year"": 2022, ""month"": 6, ""day"": 1, ""energy_wh"": 22}, {""year"": 2022, ""month"": 6, ""day"": 2, ""energy_wh"": 1}]}");
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 6 }, "")))).Returns(json);
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 6 }, "")), null)).Returns(json);
IList<int>? actual = await Outlet.EnergyMeter.GetDailyEnergyUsage(2022, 6);
actual.Should().HaveCount(30);
actual.Should().BeEquivalentTo(new List<int> { 22, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
Expand All @@ -39,31 +39,31 @@ public async Task GetDailyEnergyUsageTwoDays() {
[Fact]
public async Task GetDailyEnergyUsageNotFound() {
JObject json = JObject.Parse(@"{""day_list"": []}");
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 4 }, "")))).Returns(json);
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_daystat", An<object>.That.Matches(o => o.Should().BeEquivalentTo(new { year = 2022, month = 4 }, "")), null)).Returns(json);
IList<int>? actual = await Outlet.EnergyMeter.GetDailyEnergyUsage(2022, 4);
actual.Should().BeNull();
}

[Fact]
public async Task GetMonthlyEnergyUsage() {
JObject json = JObject.Parse(@"{""month_list"": [{""year"": 2022, ""month"": 5, ""energy_wh"": 6}, {""year"": 2022, ""month"": 6, ""energy_wh"": 18}]}");
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_monthstat", An<object>.That.HasProperty("year", 2022))).Returns(json);
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_monthstat", An<object>.That.HasProperty("year", 2022), null)).Returns(json);
IList<int>? actual = await Outlet.EnergyMeter.GetMonthlyEnergyUsage(2022);
actual.Should().BeEquivalentTo(new List<int> { 0, 0, 0, 0, 6, 18, 0, 0, 0, 0, 0, 0 });
}

[Fact]
public async Task GetMonthlyEnergyUsageNotFound() {
JObject json = JObject.Parse(@"{""month_list"": []}");
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_monthstat", An<object>.That.HasProperty("year", 2021))).Returns(json);
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "get_monthstat", An<object>.That.HasProperty("year", 2021), null)).Returns(json);
IList<int>? actual = await Outlet.EnergyMeter.GetMonthlyEnergyUsage(2021);
actual.Should().BeNull();
}

[Fact]
public async Task ClearHistoricalUsage() {
await Outlet.EnergyMeter.DeleteHistoricalUsage();
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "erase_emeter_stat", null)).MustHaveHappened();
A.CallTo(() => Client.Send<JObject>(CommandFamily.EnergyMeter, "erase_emeter_stat", null, null)).MustHaveHappened();
}

}
Loading