diff --git a/README.md b/README.md index 3fb3c014..cf9cfd7c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ | LIFX | | Yeelight | | Philips Wiz | +| [WLED](https://kno.wled.ge/) (via serial or web API) | | Any light which can be controlled via a GET or POST call to a web API | ## Docs diff --git a/docs/configure-custom-api.md b/docs/configure-custom-api.md index bf216a8d..de82c527 100644 --- a/docs/configure-custom-api.md +++ b/docs/configure-custom-api.md @@ -1,3 +1,4 @@ +# Custom API The Custom API page lets you use any generic service which has a web API which accepts GET or POST requests. @@ -10,4 +11,81 @@ To connect PresenceLight to a custom API: Configure the web service (e.g. created the applets in IFTTT) Enter the corresponding API method and URI against each presence state. - ![Configured](../static/customApi.png) + ![Configured](../static/CustomAPI.png) + +The Custom API REST API calls also support providing a json formatted body to the endpoints (Uri) of Custom API. + +You can use the following variables in your JSON body: + +- {{availability}} +- {{activity}} + +If you use above variables in the JSON body they will be replaced with the availability and/or activity values of your Microsoft Teams status. + +## Home Assistant integration + +To use PresenceLight with Home Assistant you can use the Custom API functionality as follows: + +In Home Assistant you can use [Webhooks triggers](https://www.home-assistant.io/docs/automation/trigger/#webhook-trigger) to trigger an Automation Action, like turning on a light bulb. + +Example Automation for turning on a light bulb based on the Teams status send using the Custom API functionality of PresenceLight. + +```yaml +alias: Teams presence - IKEA Light Bulb Living Room +description: >- + Show the Microsoft Teams status via a color of the Light Bulb in the Living + Room +trigger: + - platform: webhook + allowed_methods: + - POST + local_only: true + webhook_id: "" +condition: [] +action: + - choose: + - conditions: + - condition: template + value_template: "{{ trigger.json.presence_status == 'Busy' }}" + sequence: + - service: light.turn_on + metadata: {} + data: + color_name: red + target: + entity_id: light.ikea_bulb + - conditions: + - condition: template + value_template: "{{ trigger.json.presence_status == 'Available' }}" + sequence: + - service: light.turn_on + metadata: {} + data: + color_name: green + target: + entity_id: light.ikea_bulb + - conditions: + - condition: template + value_template: "{{ trigger.json.presence_status == 'Away' }}" + sequence: + - service: light.turn_on + metadata: {} + data: + color_name: yellow + target: + entity_id: light.ikea_bulb + - conditions: null + sequence: + - service: light.turn_off + metadata: {} + target: + entity_id: light.ikea_bulb + data: {} +mode: single +``` + +In PresenceLight Custom API setting you need to enter the following information: + +| Method | Uri | Body | +|--------|----------------|------| +| POST | http://homeassistant.local:8123/api/webhook/webhook_id | { "presence_status":"Away" } | diff --git a/src/DesktopClient/PresenceLight/appsettings.json b/src/DesktopClient/PresenceLight/appsettings.json index 526c8977..61c77bce 100644 --- a/src/DesktopClient/PresenceLight/appsettings.json +++ b/src/DesktopClient/PresenceLight/appsettings.json @@ -379,87 +379,108 @@ "UseActivityStatus": false, "CustomApiAvailable": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiBusy": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiBeRightBack": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiAway": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiDoNotDisturb": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiOffline": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiOff": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityAvailable": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInACall": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInAConferenceCall": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInAMeeting": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityPresenting": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityBusy": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityAway": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiAvailableIdle": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityBeRightBack": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityDoNotDisturb": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityIdle": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOffline": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOff": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOffWork": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiTimeout": 100, "IgnoreCertificateErrors": false, diff --git a/src/PresenceLight.Core/Configuration/CustomApiSetting.cs b/src/PresenceLight.Core/Configuration/CustomApiSetting.cs index c9fab7ee..3494be59 100644 --- a/src/PresenceLight.Core/Configuration/CustomApiSetting.cs +++ b/src/PresenceLight.Core/Configuration/CustomApiSetting.cs @@ -14,5 +14,10 @@ public class CustomApiSetting /// Gets or sets the URI of the API. /// public string? Uri { get; set; } + + /// + /// Gets or sets the Body of the API. + /// + public string? Body { get; set; } } } diff --git a/src/PresenceLight.Core/Helpers.cs b/src/PresenceLight.Core/Helpers.cs index c2254117..43e845dc 100644 --- a/src/PresenceLight.Core/Helpers.cs +++ b/src/PresenceLight.Core/Helpers.cs @@ -104,5 +104,27 @@ public static string HoursPassedStatusString(HoursPassedStatus status) => HoursPassedStatus.Off => "Off", _ => throw new ArgumentException(message: "Invalid HoursPassedStatus Value", paramName: nameof(status)), }; + + /// + /// Replaces the variables in the given body with the provided availability and activity. + /// + /// The body in which to replace the variables. + /// The availability to replace the {{availability}} variable. + /// The activity to replace the {{activity}} variable. + /// The body with the variables replaced. + public static string ReplaceVariables(string body, string? availability, string? activity) + { + if (body.Contains("{{availability}}")) + { + body = body.Replace("{{availability}}", availability ?? string.Empty); + } + + if (body.Contains("{{activity}}")) + { + body = body.Replace("{{activity}}", activity ?? string.Empty); + } + return body; + } + } } diff --git a/src/PresenceLight.Core/Lights/CustomApiService/CustomApiService.cs b/src/PresenceLight.Core/Lights/CustomApiService/CustomApiService.cs index b0aeb65f..af7c4c2c 100644 --- a/src/PresenceLight.Core/Lights/CustomApiService/CustomApiService.cs +++ b/src/PresenceLight.Core/Lights/CustomApiService/CustomApiService.cs @@ -58,6 +58,7 @@ private async Task CallCustomApiForActivityChanged(object sender, string { string method = string.Empty; string uri = string.Empty; + string body = string.Empty; string result = string.Empty; switch (newActivity) @@ -65,62 +66,75 @@ private async Task CallCustomApiForActivityChanged(object sender, string case "Available": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityAvailable.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityAvailable.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityAvailable.Body; break; case "Presenting": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityPresenting.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityPresenting.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityPresenting.Body; break; case "InACall": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityInACall.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityInACall.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityInACall.Body; break; case "InAConferenceCall": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAConferenceCall.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAConferenceCall.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAConferenceCall.Body; break; case "InAMeeting": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAMeeting.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAMeeting.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityInAMeeting.Body; break; case "Busy": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityBusy.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityBusy.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityBusy.Body; break; case "Away": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityAway.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityAway.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityAway.Body; break; case "BeRightBack": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityBeRightBack.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityBeRightBack.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityBeRightBack.Body; break; case "DoNotDisturb": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityDoNotDisturb.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityDoNotDisturb.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityDoNotDisturb.Body; break; case "Idle": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityIdle.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityIdle.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityIdle.Body; break; case "Offline": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityOffline.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityOffline.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityOffline.Body; break; case "Off": method = _appState.Config.LightSettings.CustomApi.CustomApiActivityOff.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiActivityOff.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiActivityOff.Body; break; default: break; } - return await PerformWebRequest(method, uri, result, cancellationToken); + return await PerformWebRequest(method, uri, body, result, cancellationToken); } private async Task CallCustomApiForAvailabilityChanged(object sender, string newAvailability, CancellationToken cancellationToken) { string method = string.Empty; string uri = string.Empty; + string body = string.Empty; string result = string.Empty; switch (newAvailability) @@ -128,40 +142,48 @@ private async Task CallCustomApiForAvailabilityChanged(object sender, st case "Available": method = _appState.Config.LightSettings.CustomApi.CustomApiAvailable.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiAvailable.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiAvailable.Body; break; case "Busy": method = _appState.Config.LightSettings.CustomApi.CustomApiBusy.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiBusy.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiBusy.Body; break; case "BeRightBack": method = _appState.Config.LightSettings.CustomApi.CustomApiBeRightBack.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiBeRightBack.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiBeRightBack.Body; break; case "Away": method = _appState.Config.LightSettings.CustomApi.CustomApiAway.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiAway.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiAway.Body; break; case "DoNotDisturb": method = _appState.Config.LightSettings.CustomApi.CustomApiDoNotDisturb.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiDoNotDisturb.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiDoNotDisturb.Body; break; case "AvailableIdle": method = _appState.Config.LightSettings.CustomApi.CustomApiAvailableIdle.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiAvailableIdle.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiAvailableIdle.Body; break; case "Offline": method = _appState.Config.LightSettings.CustomApi.CustomApiOffline.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiOffline.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiOffline.Body; break; case "Off": method = _appState.Config.LightSettings.CustomApi.CustomApiOff.Method; uri = _appState.Config.LightSettings.CustomApi.CustomApiOff.Uri; + body = _appState.Config.LightSettings.CustomApi.CustomApiOff.Body; break; default: break; } - return await PerformWebRequest(method, uri, result, cancellationToken); + return await PerformWebRequest(method, uri, body, result, cancellationToken); } private async Task SetAvailability(string availability, CancellationToken cancellationToken) @@ -210,7 +232,7 @@ private async Task SetActivity(string activity, CancellationToken cancel } static Stack _lastUriCalled = new Stack(1); - private async Task PerformWebRequest(string method, string uri, string result, CancellationToken cancellationToken) + private async Task PerformWebRequest(string method, string uri, string body, string result, CancellationToken cancellationToken) { if (_lastUriCalled.Contains($"{method}|{uri}")) { @@ -221,6 +243,7 @@ private async Task PerformWebRequest(string method, string uri, string r using (Serilog.Context.LogContext.PushProperty("method", method)) using (Serilog.Context.LogContext.PushProperty("uri", uri)) + using (Serilog.Context.LogContext.PushProperty("body", body)) { if (Helpers.AreStringsNotEmpty(new string[] { method, uri })) { @@ -257,18 +280,36 @@ private async Task PerformWebRequest(string method, string uri, string r response = await _client.GetAsync(uri, cancellationToken); break; case "POST": - response = await _client.PostAsync(uri, null, cancellationToken); - break; + // check if body is empty + if (string.IsNullOrEmpty(body)) + { + response = await _client.PostAsync(uri, null, cancellationToken); + break; + } + else + { + // Replace any variables in the body + // The following variables are supported: + // {{availability}} - The current availability + // {{activity}} - The current activity + // Check if the body contains any variables using a regular expression and replace them + body = Helpers.ReplaceVariables(body, _appState.Presence.Availability, _appState.Presence.Activity); + + var content = new StringContent(body, Encoding.UTF8, "application/json"); + response = await _client.PostAsync(uri, content, cancellationToken); + break; + } + } string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); result = $"{(int)response.StatusCode} {response.StatusCode}: {responseBody}"; - string message = $"Sending {method} method to {uri}"; + string message = $"Sending {method} method to {uri} with body {body}"; _logger.LogInformation(message); _lastUriCalled.TryPop(out string res); - _lastUriCalled.Push($"{method}|{uri}"); + _lastUriCalled.Push($"{method}|{uri}|{body}"); using (Serilog.Context.LogContext.PushProperty("result", result)) _logger.LogDebug(message + " Results"); diff --git a/src/PresenceLight.Core/PresenceLight.Core.csproj b/src/PresenceLight.Core/PresenceLight.Core.csproj index 36746fef..a6651410 100644 --- a/src/PresenceLight.Core/PresenceLight.Core.csproj +++ b/src/PresenceLight.Core/PresenceLight.Core.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/PresenceLight.Razor/Components/Pages/CustomApiSetup.razor b/src/PresenceLight.Razor/Components/Pages/CustomApiSetup.razor index 0d394e05..6a0fe118 100644 --- a/src/PresenceLight.Razor/Components/Pages/CustomApiSetup.razor +++ b/src/PresenceLight.Razor/Components/Pages/CustomApiSetup.razor @@ -19,9 +19,10 @@ { object customApiSettingValue = customApiSetting.GetValue(appState.Config.LightSettings.CustomApi, null); var lab = $"{customApiSetting.Name}Uri"; + var body = $"{customApiSetting.Name}body"; - + @if (customApiSettingValue != null) @@ -33,17 +34,26 @@ if (setting.Name == "Method") { + + + } + else if (setting.Name == "Uri") + { + + + } - else + else if (setting.Name == "Body") { - - + + + } } @@ -127,6 +137,36 @@ try { SettingsService.SaveSettings(appState.Config); + // Check if non of the objects from appState.Config.LightSettings.CustomApi has a property method set without also having the property uri set. + foreach (var customApiSetting in appState.Config.LightSettings.CustomApi.GetType().GetProperties()) + { + if (customApiSetting.PropertyType.Name == "CustomApiSetting") + { + object customApiSettingValue = customApiSetting.GetValue(appState.Config.LightSettings.CustomApi, null); + foreach (var setting in customApiSettingValue.GetType().GetProperties()) + { + if (setting.Name == "Method") + { + object settingValue = setting.GetValue(customApiSettingValue, null); + if (settingValue != null && settingValue.ToString() != "" && customApiSettingValue.GetType().GetProperty("Uri").GetValue(customApiSettingValue, null).ToString() == "") + { + _logger.LogError("Uri is required when Method is set"); + throw new Exception("Uri is required when Method is set"); + } + } + // Check if the Uri is set without the Method being set. + if (setting.Name == "Uri") + { + object settingValue = setting.GetValue(customApiSettingValue, null); + if (settingValue != null && settingValue.ToString() != "" && customApiSettingValue.GetType().GetProperty("Method").GetValue(customApiSettingValue, null).ToString() == "") + { + _logger.LogError("Method is required when Uri is set"); + throw new Exception("Method is required when Uri is set"); + } + } + } + } + } _mediator.Send(new InitializeCommand { AppState = appState }); message = "Settings Saved"; diff --git a/src/PresenceLight.Razor/Components/Pages/LocalSerialHostSetup.razor b/src/PresenceLight.Razor/Components/Pages/LocalSerialHostSetup.razor index 2594e7b8..0149c6fb 100644 --- a/src/PresenceLight.Razor/Components/Pages/LocalSerialHostSetup.razor +++ b/src/PresenceLight.Razor/Components/Pages/LocalSerialHostSetup.razor @@ -54,7 +54,7 @@ @foreach(var port in appState.LocalSerialHosts) { - } + } } @@ -72,6 +72,7 @@ + } diff --git a/src/PresenceLight.Razor/PresenceLight.Razor.csproj b/src/PresenceLight.Razor/PresenceLight.Razor.csproj index caee92a5..9964a811 100644 --- a/src/PresenceLight.Razor/PresenceLight.Razor.csproj +++ b/src/PresenceLight.Razor/PresenceLight.Razor.csproj @@ -30,7 +30,7 @@ - + diff --git a/src/PresenceLight.Web/PresenceLightSettings.json b/src/PresenceLight.Web/PresenceLightSettings.json index d306f6b9..7d8e2508 100644 --- a/src/PresenceLight.Web/PresenceLightSettings.json +++ b/src/PresenceLight.Web/PresenceLightSettings.json @@ -364,87 +364,108 @@ "UseActivityStatus": false, "CustomApiAvailable": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiBusy": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiBeRightBack": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiAway": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiDoNotDisturb": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiOffline": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiOff": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityAvailable": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInACall": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInAConferenceCall": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityInAMeeting": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityPresenting": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityBusy": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityAway": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiAvailableIdle": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityBeRightBack": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityDoNotDisturb": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityIdle": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOffline": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOff": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiActivityOffWork": { "Method": "", - "Uri": "" + "Uri": "", + "Body": "" }, "CustomApiTimeout": 100, "IgnoreCertificateErrors": false, diff --git a/static/customApi.png b/static/customApi.png index 7bf51896..5306699c 100644 Binary files a/static/customApi.png and b/static/customApi.png differ diff --git a/version.json b/version.json index a17a19d7..5bcdc617 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "5.7", + "version": "5.8", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/develop$",