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;