diff --git a/HISTORY.md b/HISTORY.md index f77a024..8d9a77c 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -30,3 +30,4 @@ | 2.15 | Better support for templates by isolating erroneous returns and marking the menu item. | | 2.16 | Bug fix for lack of phone connection when starting the application. Includes new activity reporting features from [KPWhiver](https://github.com/KPWhiver) covering steps, heart rate, floors climbed and descended, and respiration rate. | | 2.17 | Bug fix for reporting activity metrics that are not found on some devices. | +| 2.18 | A template to evaluate is now optionally allowed on a group menu item. Only toggle items cannot include a template at this time. | diff --git a/config.schema.json b/config.schema.json index ae78b37..02e58dd 100644 --- a/config.schema.json +++ b/config.schema.json @@ -22,14 +22,16 @@ "$ref": "#/$defs/entity" }, "name": { - "title": "Your familiar name", - "type": "string" + "$ref": "#/$defs/name" }, "type": { - "title": "Menu item type", - "description": "One of 'tap', 'template', 'toggle' or 'group'.", + "$ref": "#/$defs/type", "const": "toggle" }, + "content": { + "$ref": "#/$defs/content", + "description": "Optional in a toggle." + }, "tap_action": { "type": "object", "properties": { @@ -55,16 +57,13 @@ "description": "Use 'tap_action' instead to mirror Home Assistant." }, "name": { - "title": "Your familiar name", - "type": "string" + "$ref": "#/$defs/name" }, "content": { - "title": "What to display (template)", - "type": "string" + "$ref": "#/$defs/content" }, "type": { - "title": "Menu item type", - "description": "One of 'tap', 'template', 'toggle' or 'group'.", + "$ref": "#/$defs/type", "const": "template" } }, @@ -78,16 +77,13 @@ "$ref": "#/$defs/entity" }, "name": { - "title": "Your familiar name", - "type": "string" + "$ref": "#/$defs/name" }, "content": { - "title": "What to display (template)", - "type": "string" + "$ref": "#/$defs/content" }, "type": { - "title": "Menu item type", - "description": "One of 'tap', 'template', 'toggle' or 'group'.", + "$ref": "#/$defs/type", "const": "template" }, "tap_action": { @@ -106,12 +102,10 @@ "$ref": "#/$defs/entity" }, "name": { - "title": "Your familiar name", - "type": "string" + "$ref": "#/$defs/name" }, "type": { - "title": "Menu item type", - "description": "One of 'tap', 'template', 'toggle' or 'group'.", + "$ref": "#/$defs/type", "const": "tap" }, "service": { @@ -153,10 +147,13 @@ "type": "string" }, "type": { - "title": "Menu item type", - "description": "One of 'tap', 'template', 'toggle' or 'group'.", + "$ref": "#/$defs/type", "const": "group" }, + "content": { + "$ref": "#/$defs/content", + "description": "Optional in a group." + }, "items": { "$ref": "#/$defs/items" } @@ -164,6 +161,10 @@ "required": ["name", "title", "type", "items"], "additionalProperties": false }, + "type": { + "title": "Menu item type", + "description": "One of 'tap', 'template', 'toggle' or 'group'." + }, "items": { "type": "array", "maxItems": 16, @@ -184,6 +185,10 @@ ] } }, + "name": { + "title": "Your familiar name", + "type": "string" + }, "entity": { "type": "string", "title": "Home Assistant entity name", @@ -213,6 +218,10 @@ }, "required": ["service"] }, + "content": { + "title": "Jinja2 template defining the text to display.", + "type": "string" + }, "confirm": { "type": "boolean", "default": false, diff --git a/examples/Templates.md b/examples/Templates.md index 97641dd..c1ff923 100644 --- a/examples/Templates.md +++ b/examples/Templates.md @@ -10,6 +10,9 @@ In order to provide the most functionality possible the content of the menu item - `{{` ... `}}` for Expressions to print to the template output - `{#` ... `#}` for Comments not included in theĀ templateĀ output +> [!IMPORTANT] +> In order to avoid "Template Error" being displayed as the return value, make sure your Jinja2 template returns a `string`, not a number of some variety. _All numbers must be formatted to strings_ so the application does not need to distinguish an `integer` from a `float`. + ## States In this example we get the battery level of the device and add the percent sign. *Very simple* diff --git a/source/HomeAssistantApp.mc b/source/HomeAssistantApp.mc index de7a700..18d17e3 100644 --- a/source/HomeAssistantApp.mc +++ b/source/HomeAssistantApp.mc @@ -429,12 +429,11 @@ class HomeAssistantApp extends Application.AppBase { // We need to spread out the API calls so as not to overload the results queue and cause Communications.BLE_QUEUE_FULL // (-101) error. This function is called by a timer every Globals.menuItemUpdateInterval ms. function updateNextMenuItemInternal() as Void { - var itu = mItemsToUpdate as Lang.Array; - if (itu != null) { + if (mItemsToUpdate != null) { // System.println("HomeAssistantApp updateNextMenuItemInternal(): Doing update for item " + mNextItemToUpdate + ", mIsInitUpdateCompl=" + mIsInitUpdateCompl); - itu[mNextItemToUpdate].getState(); - // mNextItemToUpdate = (mNextItemToUpdate + 1) % itu.size() - But with roll-over detection - if (mNextItemToUpdate == itu.size()-1) { + mItemsToUpdate[mNextItemToUpdate].getState(); + // mNextItemToUpdate = (mNextItemToUpdate + 1) % mItemsToUpdate.size() - But with roll-over detection + if (mNextItemToUpdate == mItemsToUpdate.size()-1) { // Last item completed return to the start of the list mNextItemToUpdate = 0; mIsInitUpdateCompl = true; diff --git a/source/HomeAssistantGroupMenuItem.mc b/source/HomeAssistantGroupMenuItem.mc index bf52204..b8860fe 100644 --- a/source/HomeAssistantGroupMenuItem.mc +++ b/source/HomeAssistantGroupMenuItem.mc @@ -14,27 +14,29 @@ // // Description: // -// Menu button with an icon that opens a sub-menu, i.e. group. +// Menu button with an icon that opens a sub-menu, i.e. group, and optionally renders +// a Home Assistant Template. // //----------------------------------------------------------------------------------- using Toybox.Lang; using Toybox.WatchUi; -class HomeAssistantGroupMenuItem extends WatchUi.IconMenuItem { +class HomeAssistantGroupMenuItem extends TemplateMenuItem { private var mMenu as HomeAssistantView; function initialize( definition as Lang.Dictionary, + template as Lang.String, icon as WatchUi.Drawable, options as { :alignment as WatchUi.MenuItem.Alignment - } or Null) { + } or Null + ) { - WatchUi.IconMenuItem.initialize( + TemplateMenuItem.initialize( definition.get("name") as Lang.String, - null, - null, + template, icon, options ); diff --git a/source/HomeAssistantMenuItemFactory.mc b/source/HomeAssistantMenuItemFactory.mc index 67003ec..a9be246 100644 --- a/source/HomeAssistantMenuItemFactory.mc +++ b/source/HomeAssistantMenuItemFactory.mc @@ -145,7 +145,15 @@ class HomeAssistantMenuItemFactory { ); } - function group(definition as Lang.Dictionary) as WatchUi.MenuItem { - return new HomeAssistantGroupMenuItem(definition, mGroupTypeIcon, mMenuItemOptions); + function group( + definition as Lang.Dictionary, + template as Lang.String or Null + ) as WatchUi.MenuItem { + return new HomeAssistantGroupMenuItem( + definition, + template, + mGroupTypeIcon, + mMenuItemOptions + ); } } diff --git a/source/HomeAssistantTemplateMenuItem.mc b/source/HomeAssistantTemplateMenuItem.mc index be667f1..6e68840 100644 --- a/source/HomeAssistantTemplateMenuItem.mc +++ b/source/HomeAssistantTemplateMenuItem.mc @@ -26,35 +26,32 @@ using Toybox.Lang; using Toybox.WatchUi; using Toybox.Graphics; -class HomeAssistantTemplateMenuItem extends WatchUi.IconMenuItem { +class HomeAssistantTemplateMenuItem extends TemplateMenuItem { 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; private var mData as Lang.Dictionary or Null; function initialize( - label as Lang.String or Lang.Symbol, - template as Lang.String, - service as Lang.String or Null, - confirm as Lang.Boolean, - data as Lang.Dictionary or Null, - icon as Graphics.BitmapType or WatchUi.Drawable, - options as { + label as Lang.String or Lang.Symbol, + template as Lang.String, + service as Lang.String or Null, + confirm as Lang.Boolean, + data as Lang.Dictionary or Null, + icon as Graphics.BitmapType or WatchUi.Drawable, + options as { :alignment as WatchUi.MenuItem.Alignment } or Null, - haService as HomeAssistantService + haService as HomeAssistantService ) { - WatchUi.IconMenuItem.initialize( + TemplateMenuItem.initialize( label, - null, - null, + template, icon, options ); mHomeAssistantService = haService; - mTemplate = template; mService = service; mConfirm = confirm; mData = data; @@ -79,116 +76,4 @@ class HomeAssistantTemplateMenuItem extends WatchUi.IconMenuItem { } } - // 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 Null or Lang.Dictionary) as Void { - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: " + responseCode); - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Data: " + data); - - var status = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; - switch (responseCode) { - case Communications.BLE_HOST_TIMEOUT: - case Communications.BLE_CONNECTION_UNAVAILABLE: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String + "."); - break; - - case Communications.BLE_QUEUE_FULL: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_QUEUE_FULL, API calls too rapid."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String); - break; - - case Communications.NETWORK_REQUEST_TIMED_OUT: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String); - break; - - case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String); - break; - - case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY: - // 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: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 404, page not found. Check API URL setting."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiUrlNotFound) as Lang.String); - break; - - case 400: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 400, bad request. Template error."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.TemplateError) as Lang.String); - break; - - case 200: - status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String; - var label = data.get("request"); - if (label == null) { - setSubLabel($.Rez.Strings.Empty); - } else if(label instanceof Lang.String) { - setSubLabel(label); - } else if(label instanceof Lang.Dictionary) { - // System.println("HomeAssistantTemplateMenuItem onReturnGetState() label = " + label); - if (label.get("error") != null) { - setSubLabel($.Rez.Strings.TemplateError); - } else { - setSubLabel($.Rez.Strings.PotentialError); - } - } - requestUpdate(); - // 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: - // System.println("HomeAssistantTemplateMenuItem onReturnGetState(): Unhandled HTTP response code = " + responseCode); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode); - } - getApp().setApiStatus(status); - } - - function getState() as Void { - if (! System.getDeviceSettings().phoneConnected) { - // System.println("HomeAssistantTemplateMenuItem getState(): No Phone connection, skipping API call."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String + "."); - getApp().setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); - } else if (! System.getDeviceSettings().connectionAvailable) { - // System.println("HomeAssistantTemplateMenuItem getState(): No Internet connection, skipping API call."); - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String + "."); - getApp().setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); - } else { - // https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates - var url = Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(); - // System.println("HomeAssistantTemplateMenuItem getState() URL=" + url + ", Template='" + mTemplate + "'"); - Communications.makeWebRequest( - url, - { - "type" => "render_template", - "data" => { - "request" => { - "template" => mTemplate - } - } - }, - { - :method => Communications.HTTP_REQUEST_METHOD_POST, - :headers => { - "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON - }, - :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON - }, - method(:onReturnGetState) - ); - } - } - } diff --git a/source/HomeAssistantView.mc b/source/HomeAssistantView.mc index ac93663..6a82046 100644 --- a/source/HomeAssistantView.mc +++ b/source/HomeAssistantView.mc @@ -73,7 +73,7 @@ class HomeAssistantView extends WatchUi.Menu2 { } else if (type.equals("tap") && service != null) { addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, service, confirm, data)); } else if (type.equals("group")) { - addItem(HomeAssistantMenuItemFactory.create().group(items[i])); + addItem(HomeAssistantMenuItemFactory.create().group(items[i], content)); } } } @@ -82,11 +82,16 @@ class HomeAssistantView extends WatchUi.Menu2 { function getItemsToUpdate() as Lang.Array { var fullList = []; - var lmi = mItems as Lang.Array; + for(var i = 0; i < mItems.size(); i++) { var item = lmi[i]; if (item instanceof HomeAssistantGroupMenuItem) { + // Group menu items can now have an optional template to evaluate + var gmi = item as HomeAssistantGroupMenuItem; + if (gmi.hasTemplate()) { + fullList.add(item); + } fullList.addAll(item.getMenuView().getItemsToUpdate()); } else if (item instanceof HomeAssistantToggleMenuItem) { fullList.add(item); diff --git a/source/TemplateMenuItem.mc b/source/TemplateMenuItem.mc new file mode 100644 index 0000000..927618b --- /dev/null +++ b/source/TemplateMenuItem.mc @@ -0,0 +1,176 @@ +//----------------------------------------------------------------------------------- +// +// 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: +// +// Menu button that renders 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 TemplateMenuItem extends WatchUi.IconMenuItem { + private var mTemplate as Lang.String; + + function initialize( + label as Lang.String or Lang.Symbol, + template as Lang.String, + icon as Graphics.BitmapType or WatchUi.Drawable, + options as { + :alignment as WatchUi.MenuItem.Alignment + } or Null + ) { + WatchUi.IconMenuItem.initialize( + label, + null, + null, + icon, + options + ); + + mTemplate = template; + } + + // 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 Null or Lang.Dictionary) as Void { + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: " + responseCode); + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Data: " + data); + + var status = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; + switch (responseCode) { + case Communications.BLE_HOST_TIMEOUT: + case Communications.BLE_CONNECTION_UNAVAILABLE: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String + "."); + break; + + case Communications.BLE_QUEUE_FULL: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: BLE_QUEUE_FULL, API calls too rapid."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String); + break; + + case Communications.NETWORK_REQUEST_TIMED_OUT: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String); + break; + + case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String); + break; + + case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY: + // 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: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 404, page not found. Check API URL setting."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiUrlNotFound) as Lang.String); + break; + + case 400: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() Response Code: 400, bad request. Template error."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.TemplateError) as Lang.String); + break; + + case 200: + status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String; + var label = data.get("request"); + if (label == null) { + setSubLabel($.Rez.Strings.Empty); + } else if(label instanceof Lang.String) { + setSubLabel(label); + } else if(label instanceof Lang.Dictionary) { + // System.println("HomeAssistantTemplateMenuItem onReturnGetState() label = " + label); + if (label.get("error") != null) { + setSubLabel($.Rez.Strings.TemplateError); + } else { + setSubLabel($.Rez.Strings.PotentialError); + } + } else { + // The template must return a Lang.String, a number can be either integer or float and hence cannot be formatted locally without error. + setSubLabel(WatchUi.loadResource($.Rez.Strings.TemplateError) as Lang.String); + } + requestUpdate(); + // 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: + // System.println("HomeAssistantTemplateMenuItem onReturnGetState(): Unhandled HTTP response code = " + responseCode); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode); + } + getApp().setApiStatus(status); + } + + function getState() as Void { + if (mTemplate == null) { + // Nothing to do here. + // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer. + getApp().updateNextMenuItem(); + } else { + if (! System.getDeviceSettings().phoneConnected) { + // System.println("HomeAssistantTemplateMenuItem getState(): No Phone connection, skipping API call."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String + "."); + getApp().setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); + } else if (! System.getDeviceSettings().connectionAvailable) { + // System.println("HomeAssistantTemplateMenuItem getState(): No Internet connection, skipping API call."); + ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String + "."); + getApp().setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); + } else { + // https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates + var url = Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(); + // System.println("HomeAssistantTemplateMenuItem getState() URL=" + url + ", Template='" + mTemplate + "'"); + Communications.makeWebRequest( + url, + { + "type" => "render_template", + "data" => { + "request" => { + "template" => mTemplate + } + } + }, + { + :method => Communications.HTTP_REQUEST_METHOD_POST, + :headers => { + "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON + }, + :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON + }, + method(:onReturnGetState) + ); + } + } + } + + function hasTemplate() as Lang.Boolean { + return (mTemplate != null); + } + +} \ No newline at end of file