165 templating content for all types (#170)

Submitting as an initial draft in order to guage feedback on the style.
In particular the solution to adding the code to the `toggle` menu item,
which has incurred duplication of code as I can't inherit from two
classes.
This commit is contained in:
Joseph Abbey
2024-08-26 19:42:51 +01:00
committed by GitHub
10 changed files with 406 additions and 174 deletions

View File

@ -31,3 +31,4 @@
| 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.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.17 | Bug fix for reporting activity metrics that are not found on some devices. |
| 2.18 | Bug fix for reporting activity metrics that might be `null` sometimes. This is unsimulatable situation, so this version is a change based on an informed guess. | | 2.18 | Bug fix for reporting activity metrics that might be `null` sometimes. This is unsimulatable situation, so this version is a change based on an informed guess. |
| 2.19 | A template to evaluate is now optionally allowed on both `group` and `toggle` menu items. The template to evaluate is non-optional on a `template` menu item. |

View File

@ -22,14 +22,16 @@
"$ref": "#/$defs/entity" "$ref": "#/$defs/entity"
}, },
"name": { "name": {
"title": "Your familiar name", "$ref": "#/$defs/name"
"type": "string"
}, },
"type": { "type": {
"title": "Menu item type", "$ref": "#/$defs/type",
"description": "One of 'tap', 'template', 'toggle' or 'group'.",
"const": "toggle" "const": "toggle"
}, },
"content": {
"$ref": "#/$defs/content",
"description": "Optional in a toggle."
},
"tap_action": { "tap_action": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -55,16 +57,13 @@
"description": "Use 'tap_action' instead to mirror Home Assistant." "description": "Use 'tap_action' instead to mirror Home Assistant."
}, },
"name": { "name": {
"title": "Your familiar name", "$ref": "#/$defs/name"
"type": "string"
}, },
"content": { "content": {
"title": "What to display (template)", "$ref": "#/$defs/content"
"type": "string"
}, },
"type": { "type": {
"title": "Menu item type", "$ref": "#/$defs/type",
"description": "One of 'tap', 'template', 'toggle' or 'group'.",
"const": "template" "const": "template"
} }
}, },
@ -78,16 +77,13 @@
"$ref": "#/$defs/entity" "$ref": "#/$defs/entity"
}, },
"name": { "name": {
"title": "Your familiar name", "$ref": "#/$defs/name"
"type": "string"
}, },
"content": { "content": {
"title": "What to display (template)", "$ref": "#/$defs/content"
"type": "string"
}, },
"type": { "type": {
"title": "Menu item type", "$ref": "#/$defs/type",
"description": "One of 'tap', 'template', 'toggle' or 'group'.",
"const": "template" "const": "template"
}, },
"tap_action": { "tap_action": {
@ -106,12 +102,10 @@
"$ref": "#/$defs/entity" "$ref": "#/$defs/entity"
}, },
"name": { "name": {
"title": "Your familiar name", "$ref": "#/$defs/name"
"type": "string"
}, },
"type": { "type": {
"title": "Menu item type", "$ref": "#/$defs/type",
"description": "One of 'tap', 'template', 'toggle' or 'group'.",
"const": "tap" "const": "tap"
}, },
"service": { "service": {
@ -153,10 +147,13 @@
"type": "string" "type": "string"
}, },
"type": { "type": {
"title": "Menu item type", "$ref": "#/$defs/type",
"description": "One of 'tap', 'template', 'toggle' or 'group'.",
"const": "group" "const": "group"
}, },
"content": {
"$ref": "#/$defs/content",
"description": "Optional in a group."
},
"items": { "items": {
"$ref": "#/$defs/items" "$ref": "#/$defs/items"
} }
@ -164,6 +161,10 @@
"required": ["name", "title", "type", "items"], "required": ["name", "title", "type", "items"],
"additionalProperties": false "additionalProperties": false
}, },
"type": {
"title": "Menu item type",
"description": "One of 'tap', 'template', 'toggle' or 'group'."
},
"items": { "items": {
"type": "array", "type": "array",
"maxItems": 16, "maxItems": 16,
@ -184,6 +185,10 @@
] ]
} }
}, },
"name": {
"title": "Your familiar name",
"type": "string"
},
"entity": { "entity": {
"type": "string", "type": "string",
"title": "Home Assistant entity name", "title": "Home Assistant entity name",
@ -213,6 +218,10 @@
}, },
"required": ["service"] "required": ["service"]
}, },
"content": {
"title": "Jinja2 template defining the text to display.",
"type": "string"
},
"confirm": { "confirm": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,

View File

@ -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 Expressions to print to the template output
- `{#` ... `#}` for Comments not included in 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 ## States
In this example we get the battery level of the device and add the percent sign. *Very simple* In this example we get the battery level of the device and add the percent sign. *Very simple*

View File

@ -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 // 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. // (-101) error. This function is called by a timer every Globals.menuItemUpdateInterval ms.
function updateNextMenuItemInternal() as Void { function updateNextMenuItemInternal() as Void {
var itu = mItemsToUpdate as Lang.Array<HomeAssistantToggleMenuItem>; if (mItemsToUpdate != null) {
if (itu != null) {
// System.println("HomeAssistantApp updateNextMenuItemInternal(): Doing update for item " + mNextItemToUpdate + ", mIsInitUpdateCompl=" + mIsInitUpdateCompl); // System.println("HomeAssistantApp updateNextMenuItemInternal(): Doing update for item " + mNextItemToUpdate + ", mIsInitUpdateCompl=" + mIsInitUpdateCompl);
itu[mNextItemToUpdate].getState(); mItemsToUpdate[mNextItemToUpdate].getState();
// mNextItemToUpdate = (mNextItemToUpdate + 1) % itu.size() - But with roll-over detection // mNextItemToUpdate = (mNextItemToUpdate + 1) % mItemsToUpdate.size() - But with roll-over detection
if (mNextItemToUpdate == itu.size()-1) { if (mNextItemToUpdate == mItemsToUpdate.size()-1) {
// Last item completed return to the start of the list // Last item completed return to the start of the list
mNextItemToUpdate = 0; mNextItemToUpdate = 0;
mIsInitUpdateCompl = true; mIsInitUpdateCompl = true;

View File

@ -14,27 +14,31 @@
// //
// Description: // 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.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
class HomeAssistantGroupMenuItem extends WatchUi.IconMenuItem { class HomeAssistantGroupMenuItem extends TemplateMenuItem {
private var mMenu as HomeAssistantView; private var mMenu as HomeAssistantView;
function initialize( function initialize(
definition as Lang.Dictionary, definition as Lang.Dictionary,
template as Lang.String,
icon as WatchUi.Drawable, icon as WatchUi.Drawable,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment :alignment as WatchUi.MenuItem.Alignment
} or Null) { } or Null
) {
WatchUi.IconMenuItem.initialize( TemplateMenuItem.initialize(
definition.get("name") as Lang.String, definition.get("name") as Lang.String,
null, template,
null, // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer.
getApp().method(:updateNextMenuItem),
icon, icon,
options options
); );

View File

@ -67,10 +67,12 @@ class HomeAssistantMenuItemFactory {
function toggle( function toggle(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
entity_id as Lang.String or Null, entity_id as Lang.String or Null,
template as Lang.String or Null,
confirm as Lang.Boolean confirm as Lang.Boolean
) as WatchUi.MenuItem { ) as WatchUi.MenuItem {
return new HomeAssistantToggleMenuItem( return new HomeAssistantToggleMenuItem(
label, label,
template,
confirm, confirm,
{ "entity_id" => entity_id }, { "entity_id" => entity_id },
mMenuItemOptions mMenuItemOptions
@ -145,7 +147,15 @@ class HomeAssistantMenuItemFactory {
); );
} }
function group(definition as Lang.Dictionary) as WatchUi.MenuItem { function group(
return new HomeAssistantGroupMenuItem(definition, mGroupTypeIcon, mMenuItemOptions); definition as Lang.Dictionary,
template as Lang.String or Null
) as WatchUi.MenuItem {
return new HomeAssistantGroupMenuItem(
definition,
template,
mGroupTypeIcon,
mMenuItemOptions
);
} }
} }

View File

@ -26,9 +26,8 @@ using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Graphics; using Toybox.Graphics;
class HomeAssistantTemplateMenuItem extends WatchUi.IconMenuItem { class HomeAssistantTemplateMenuItem extends TemplateMenuItem {
private var mHomeAssistantService as HomeAssistantService; private var mHomeAssistantService as HomeAssistantService;
private var mTemplate as Lang.String;
private var mService as Lang.String or Null; private var mService as Lang.String or Null;
private var mConfirm as Lang.Boolean; private var mConfirm as Lang.Boolean;
private var mData as Lang.Dictionary or Null; private var mData as Lang.Dictionary or Null;
@ -45,16 +44,16 @@ class HomeAssistantTemplateMenuItem extends WatchUi.IconMenuItem {
} or Null, } or Null,
haService as HomeAssistantService haService as HomeAssistantService
) { ) {
WatchUi.IconMenuItem.initialize( TemplateMenuItem.initialize(
label, label,
null, template,
null, // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer.
getApp().method(:updateNextMenuItem),
icon, icon,
options options
); );
mHomeAssistantService = haService; mHomeAssistantService = haService;
mTemplate = template;
mService = service; mService = service;
mConfirm = confirm; mConfirm = confirm;
mData = data; mData = data;
@ -79,116 +78,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)
);
}
}
} }

View File

@ -27,9 +27,11 @@ using Toybox.Timer;
class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
private var mConfirm as Lang.Boolean; private var mConfirm as Lang.Boolean;
private var mData as Lang.Dictionary; private var mData as Lang.Dictionary;
private var mTemplate as Lang.String;
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String,
confirm as Lang.Boolean, confirm as Lang.Boolean,
data as Lang.Dictionary or Null, data as Lang.Dictionary or Null,
options as { options as {
@ -40,6 +42,7 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
WatchUi.ToggleMenuItem.initialize(label, null, null, false, options); WatchUi.ToggleMenuItem.initialize(label, null, null, false, options);
mConfirm = confirm; mConfirm = confirm;
mData = data; mData = data;
mTemplate = template;
} }
private function setUiToggle(state as Null or Lang.String) as Void { private function setUiToggle(state as Null or Lang.String) as Void {
@ -88,6 +91,7 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY: case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY:
// System.println("HomeAssistantToggleMenuItem onReturnGetState() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?"); // System.println("HomeAssistantToggleMenuItem onReturnGetState() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?");
var myTimer = new Timer.Timer(); var myTimer = new Timer.Timer();
// Abandon the update to this menu item, and any template, and move on to the next with a back-off delay.
// Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a 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); myTimer.start(getApp().method(:updateNextMenuItem), Globals.scApiBackoff, false);
// Revert status // Revert status
@ -123,8 +127,13 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
setLabel((data.get("attributes") as Lang.Dictionary).get("friendly_name") as Lang.String); setLabel((data.get("attributes") as Lang.Dictionary).get("friendly_name") as Lang.String);
} }
setUiToggle(state); setUiToggle(state);
if (mTemplate == null) {
// Nothing more to do
// Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer. // Now this feels very "closely coupled" to the application, but it is the most reliable method instead of using a timer.
getApp().updateNextMenuItem(); getApp().updateNextMenuItem();
} else {
updateTemplate();
}
break; break;
default: default:
@ -272,4 +281,127 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
setState(b); setState(b);
} }
// 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 onReturnUpdateTemplate(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);
}
// Massive code duplication from TemplateMenuItem, but cannot inherit from two classes.
//
function updateTemplate() 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(:onReturnUpdateTemplate)
);
}
}
}
} }

View File

@ -62,7 +62,7 @@ class HomeAssistantView extends WatchUi.Menu2 {
} }
if (type != null && name != null) { if (type != null && name != null) {
if (type.equals("toggle") && entity != null) { if (type.equals("toggle") && entity != null) {
addItem(HomeAssistantMenuItemFactory.create().toggle(name, entity, confirm)); addItem(HomeAssistantMenuItemFactory.create().toggle(name, entity, content, confirm));
} else if (type.equals("template") && content != null) { } else if (type.equals("template") && content != null) {
if (service == null) { if (service == null) {
addItem(HomeAssistantMenuItemFactory.create().template_notap(name, content)); addItem(HomeAssistantMenuItemFactory.create().template_notap(name, content));
@ -73,7 +73,7 @@ class HomeAssistantView extends WatchUi.Menu2 {
} else if (type.equals("tap") && service != null) { } else if (type.equals("tap") && service != null) {
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, service, confirm, data)); addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, service, confirm, data));
} else if (type.equals("group")) { } 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<HomeAssistantToggleMenuItem or HomeAssistantTemplateMenuItem> { function getItemsToUpdate() as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTemplateMenuItem> {
var fullList = []; var fullList = [];
var lmi = mItems as Lang.Array<WatchUi.MenuItem>; var lmi = mItems as Lang.Array<WatchUi.MenuItem>;
for(var i = 0; i < mItems.size(); i++) { for(var i = 0; i < mItems.size(); i++) {
var item = lmi[i]; var item = lmi[i];
if (item instanceof HomeAssistantGroupMenuItem) { 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()); fullList.addAll(item.getMenuView().getItemsToUpdate());
} else if (item instanceof HomeAssistantToggleMenuItem) { } else if (item instanceof HomeAssistantToggleMenuItem) {
fullList.add(item); fullList.add(item);

182
source/TemplateMenuItem.mc Normal file
View File

@ -0,0 +1,182 @@
//-----------------------------------------------------------------------------------
//
// 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, 24 August 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;
private var mCallback as Method() as Void;
function initialize(
label as Lang.String or Lang.Symbol,
template as Lang.String,
// Do not use Lang.Method as it does not compile!
callback as Method() as Void,
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;
mCallback = callback;
}
// 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("TemplateMenuItem onReturnGetState() Response Code: " + responseCode);
// System.println("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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();
if (mCallback != null) {
mCallback.invoke();
}
break;
default:
// System.println("TemplateMenuItem 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.
if (mCallback != null) {
mCallback.invoke();
}
} else {
if (! System.getDeviceSettings().phoneConnected) {
// System.println("TemplateMenuItem 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("TemplateMenuItem 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("TemplateMenuItem 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);
}
}