diff --git a/config.schema.json b/config.schema.json index d87334b1..c7d3bf9d 100644 --- a/config.schema.json +++ b/config.schema.json @@ -25,15 +25,17 @@ "template": { "type": "object", "properties": { + "entity": { "$ref": "#/$defs/entity" }, "name": { "title": "Your familiar name", "type": "string" }, "content": { "title": "What to display (template)", "type": "string" }, "type": { "title": "Menu item type", "description": "One of 'tap', 'template', 'toggle' or 'group'.", "const": "template" - } + }, + "tap_action": { "$ref": "#/$defs/action" } }, - "required": ["name", "content", "type"], + "required": ["name", "entity", "content", "type"], "additionalProperties": false }, "tap": { @@ -80,9 +82,9 @@ "type": "array", "items": { "oneOf": [ - { "$ref": "#/$defs/tap" }, - { "$ref": "#/$defs/template" }, { "$ref": "#/$defs/toggle" }, + { "$ref": "#/$defs/template" }, + { "$ref": "#/$defs/tap" }, { "$ref": "#/$defs/menu" } ] } diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 57bd3bdb..8be67ce5 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -33,6 +33,7 @@ HTTP request returned error code = API URL must not have a trailing slash '/' Failed to register Webhook + Failed to render template Available Checking... Unavailable diff --git a/source/Globals.mc b/source/Globals.mc index 8fbf0343..342f0144 100644 --- a/source/Globals.mc +++ b/source/Globals.mc @@ -24,7 +24,7 @@ using Toybox.Lang; class Globals { // Enable printing of messages to the debug console (don't make this a Property // as the messages can't be read from a watch!) - static const scDebug = false; + static const scDebug = true; static const scAlertTimeout = 2000; // ms static const scTapTimeout = 1000; // ms // Time to let the existing HTTP responses get serviced after a diff --git a/source/HomeAssistantMenuItemFactory.mc b/source/HomeAssistantMenuItemFactory.mc index a90ce383..a3a141a5 100644 --- a/source/HomeAssistantMenuItemFactory.mc +++ b/source/HomeAssistantMenuItemFactory.mc @@ -46,6 +46,7 @@ class HomeAssistantMenuItemFactory { :locX => WatchUi.LAYOUT_HALIGN_CENTER, :locY => WatchUi.LAYOUT_VALIGN_CENTER }); + mHomeAssistantService = new HomeAssistantService(); } @@ -66,6 +67,24 @@ class HomeAssistantMenuItemFactory { ); } + function template( + label as Lang.String or Lang.Symbol, + identifier as Lang.Object or Null, + template as Lang.String or Null, + service as Lang.String or Null, + confirm as Lang.Boolean + ) as WatchUi.MenuItem { + return new HomeAssistantTemplateMenuItem( + label, + identifier, + template, + service, + confirm, + mMenuItemOptions, + mHomeAssistantService + ); + } + function tap( label as Lang.String or Lang.Symbol, identifier as Lang.Object or Null, diff --git a/source/HomeAssistantTemplateMenuItem.mc b/source/HomeAssistantTemplateMenuItem.mc new file mode 100644 index 00000000..8f7edc3c --- /dev/null +++ b/source/HomeAssistantTemplateMenuItem.mc @@ -0,0 +1,198 @@ +//----------------------------------------------------------------------------------- +// +// Distributed under MIT Licence +// See https://github.com/house-of-abbey/GarminHomeAssistant/blob/main/LICENSE. +// +//----------------------------------------------------------------------------------- +// +// GarminHomeAssistant is a Garmin IQ application written in Monkey C and routinely +// tested on a Venu 2 device. The source code is provided at: +// https://github.com/house-of-abbey/GarminHomeAssistant. +// +// P A Abbey & J D Abbey, 12 January 2024 +// +// +// Description: +// +// Rendering a Home Assistant Template. +// +// Reference: +// * https://developers.home-assistant.io/docs/api/rest/ +// * https://www.home-assistant.io/docs/configuration/templating +// +//----------------------------------------------------------------------------------- + +using Toybox.Lang; +using Toybox.WatchUi; +using Toybox.Graphics; + +class HomeAssistantTemplateMenuItem extends WatchUi.MenuItem { + private var mHomeAssistantService as HomeAssistantService; + private var mTemplate as Lang.String; + private var mService as Lang.String or Null; + private var mConfirm as Lang.Boolean; + + function initialize( + label as Lang.String or Lang.Symbol, + identifier as Lang.Object or Null, + template as Lang.String, + service as Lang.String or Null, + confirm as Lang.Boolean, + options as { + :alignment as WatchUi.MenuItem.Alignment, + :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol + } or Null, + haService as HomeAssistantService + ) { + WatchUi.MenuItem.initialize( + label, + null, + identifier, + options + ); + + mHomeAssistantService = haService; + mTemplate = template; + mService = service; + mConfirm = confirm; + } + + function callService() as Void { + if (mConfirm) { + WatchUi.pushView( + new HomeAssistantConfirmation(), + new HomeAssistantConfirmationDelegate(method(:onConfirm)), + WatchUi.SLIDE_IMMEDIATE + ); + } else { + onConfirm(); + } + } + + function onConfirm() as Void { + if (mService != null) { + mHomeAssistantService.call(mIdentifier as Lang.String, mService); + } + } + + // Callback function after completing the GET request to fetch the status. + // Terminate updating the toggle menu items via the chain of calls for a permanent network + // error. The ErrorView cancellation will resume the call chain. + // + function onReturnGetState(responseCode as Lang.Number, data as Lang.String) as Void { + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: " + responseCode); + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Data: " + data); + } + + var status = RezStrings.getUnavailable(); + switch (responseCode) { + case Communications.BLE_HOST_TIMEOUT: + case Communications.BLE_CONNECTION_UNAVAILABLE: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed."); + } + ErrorView.show(RezStrings.getNoPhone() + "."); + break; + + case Communications.BLE_QUEUE_FULL: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_QUEUE_FULL, API calls too rapid."); + } + ErrorView.show(RezStrings.getApiFlood()); + break; + + case Communications.NETWORK_REQUEST_TIMED_OUT: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection."); + } + ErrorView.show(RezStrings.getNoResponse()); + break; + + case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned."); + } + ErrorView.show(RezStrings.getNoJson()); + break; + + case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?"); + } + var myTimer = new Timer.Timer(); + // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer. + myTimer.start(getApp().method(:updateNextMenuItem), Globals.scApiBackoff, false); + // Revert status + status = getApp().getApiStatus(); + break; + + case 404: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 404, page not found. Check API URL setting."); + } + ErrorView.show(RezStrings.getApiUrlNotFound()); + break; + + case 400: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 400, bad request. Template error."); + } + ErrorView.show(RezStrings.getTemplateError()); + break; + + case 200: + status = RezStrings.getAvailable(); + setSubLabel(data); + requestUpdate(); + ErrorView.unShow(); + // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer. + getApp().updateNextMenuItem(); + break; + + default: + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem onReturnGetState(): Unhandled HTTP response code = " + responseCode); + } + ErrorView.show(RezStrings.getUnhandledHttpErr() + responseCode); + } + getApp().setApiStatus(status); + } + + function getState() as Void { + if (! System.getDeviceSettings().phoneConnected) { + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem getState(): No Phone connection, skipping API call."); + } + ErrorView.show(RezStrings.getNoPhone() + "."); + getApp().setApiStatus(RezStrings.getUnavailable()); + } else if (! System.getDeviceSettings().connectionAvailable) { + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem getState(): No Internet connection, skipping API call."); + } + ErrorView.show(RezStrings.getNoInternet() + "."); + getApp().setApiStatus(RezStrings.getUnavailable()); + } else { + var url = Settings.getApiUrl() + "/template"; + if (Globals.scDebug) { + System.println("HomeAssistantTemplateMenuItem getState() URL=" + url + ", Template='" + mTemplate + "'"); + } + Communications.makeWebRequest( + url, + { + "template" => mTemplate + }, + { + :method => Communications.HTTP_REQUEST_METHOD_POST, + :headers => { + "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON, + "Authorization" => "Bearer " + Settings.getApiKey() + }, + :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_TEXT_PLAIN + }, + method(:onReturnGetState) + ); + } + } + +} diff --git a/source/HomeAssistantView.mc b/source/HomeAssistantView.mc index 9c301a4d..86d98fe4 100644 --- a/source/HomeAssistantView.mc +++ b/source/HomeAssistantView.mc @@ -51,6 +51,7 @@ class HomeAssistantView extends WatchUi.Menu2 { for(var i = 0; i < items.size(); i++) { var type = items[i].get("type") as Lang.String or Null; var name = items[i].get("name") as Lang.String or Null; + var content = items[i].get("content") as Lang.String or Null; var entity = items[i].get("entity") as Lang.String or Null; var tap_action = items[i].get("tap_action") as Lang.Dictionary or Null; var service = items[i].get("service") as Lang.String or Null; @@ -62,12 +63,16 @@ class HomeAssistantView extends WatchUi.Menu2 { confirm = false; } } - if (type != null && name != null && entity != null) { - if (type.equals("toggle")) { + if (type != null && name != null) { + if (type.equals("toggle") && entity != null) { var item = HomeAssistantMenuItemFactory.create().toggle(name, entity); addItem(item); mListToggleItems.add(item); - } else if (type.equals("tap") && service != null) { + } else if (type.equals("template") && content != null) { + var item = HomeAssistantMenuItemFactory.create().template(name, entity, content, service, confirm); + addItem(item); + mListToggleItems.add(item); + } else if (type.equals("tap") && entity != null && service != null) { addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, service, confirm)); } else if (type.equals("group")) { var item = HomeAssistantMenuItemFactory.create().group(items[i]); @@ -155,6 +160,12 @@ class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate { System.println(haItem.getLabel() + " " + haItem.getId()); } haItem.callService(); + } else if (item instanceof HomeAssistantTemplateMenuItem) { + var haItem = item as HomeAssistantTemplateMenuItem; + if (Globals.scDebug) { + System.println(haItem.getLabel() + " " + haItem.getId()); + } + haItem.callService(); } else if (item instanceof HomeAssistantViewMenuItem) { var haMenuItem = item as HomeAssistantViewMenuItem; if (Globals.scDebug) { diff --git a/source/RezStrings.mc b/source/RezStrings.mc index 2344bbd7..05d55cfc 100644 --- a/source/RezStrings.mc +++ b/source/RezStrings.mc @@ -47,7 +47,8 @@ class RezStrings { private static var strNoJson as Lang.String or Null; private static var strUnhandledHttpErr as Lang.String or Null; private static var strTrailingSlashErr as Lang.String or Null; - private static var strWebhookFailed as Lang.String or Null; + private static var strWebhookFailed as Lang.String or Null; + private static var strTemplateError as Lang.String or Null; (:glance) private static var strAvailable as Lang.String or Null; (:glance) @@ -100,6 +101,7 @@ class RezStrings { strUnhandledHttpErr = WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr); strTrailingSlashErr = WatchUi.loadResource($.Rez.Strings.TrailingSlashErr); strWebhookFailed = WatchUi.loadResource($.Rez.Strings.WebhookFailed); + strTemplateError = WatchUi.loadResource($.Rez.Strings.TemplateError); strAvailable = WatchUi.loadResource($.Rez.Strings.Available); strChecking = WatchUi.loadResource($.Rez.Strings.Checking); strUnavailable = WatchUi.loadResource($.Rez.Strings.Unavailable); @@ -184,6 +186,10 @@ class RezStrings { return strWebhookFailed; } + static function getTemplateError() as Lang.String { + return strTemplateError; + } + static function getAvailable() as Lang.String { return strAvailable; } diff --git a/source/WebhookManager.mc b/source/WebhookManager.mc index c2933cbf..a2649dfd 100644 --- a/source/WebhookManager.mc +++ b/source/WebhookManager.mc @@ -1,3 +1,26 @@ +//----------------------------------------------------------------------------------- +// +// Distributed under MIT Licence +// See https://github.com/house-of-abbey/GarminHomeAssistant/blob/main/LICENSE. +// +//----------------------------------------------------------------------------------- +// +// GarminHomeAssistant is a Garmin IQ application written in Monkey C and routinely +// tested on a Venu 2 device. The source code is provided at: +// https://github.com/house-of-abbey/GarminHomeAssistant. +// +// P A Abbey & J D Abbey, 10 January 2024 +// +// +// Description: +// +// Home Assistant Webhook creation. +// +// Reference: +// * https://developers.home-assistant.io/docs/api/native-app-integration +// +//----------------------------------------------------------------------------------- + using Toybox.Lang; using Toybox.Communications; using Toybox.System;