Files
GarminHomeAssistant/source/HomeAssistantApp.mc
Philip Abbey 7ba0d76bf6 Added option to turn off automatic menu update checking
This is because it uses additional memory than can cause some older devices to crash unless they have smaller menus.
2025-09-13 11:06:29 +01:00

1097 lines
50 KiB
MonkeyC

//-----------------------------------------------------------------------------------
//
// 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 & Someone0nEarth & moesterheld & vincentezw, 31 October 2023
//
//-----------------------------------------------------------------------------------
using Toybox.Application;
using Toybox.Communications;
using Toybox.Lang;
// Required for callback method definition
typedef Method as Toybox.Lang.Method;
using Toybox.WatchUi;
using Toybox.System;
using Toybox.Application.Properties;
using Toybox.Timer;
//! Application root for GarminHomeAssistant
//
(:glance, :background)
class HomeAssistantApp extends Application.AppBase {
private var mHasToast as Lang.Boolean = false;
private var mApiStatus as Lang.String?;
private var mMenuStatus as Lang.String?;
private var mHaMenu as HomeAssistantView?;
private var mGlanceTemplate as Lang.String? = null;
private var mGlanceText as Lang.String? = null;
private var mQuitTimer as QuitTimer?;
private var mGlanceTimer as Timer.Timer?;
private var mUpdateTimer as Timer.Timer?;
// Array initialised by onReturnFetchMenuConfig()
private var mItemsToUpdate as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTapMenuItem or HomeAssistantGroupMenuItem>?;
private var mIsApp as Lang.Boolean = false; // Or Widget
private var mUpdating as Lang.Boolean = false; // Don't start a second chain of updates
private var mTemplates as Lang.Dictionary? = null; // Cache of compiled templates
private var mNotifiedNoBle as Lang.Boolean = false;
private var mIsCacheChecked as Lang.Boolean = false;
//! Class Constructor
//
function initialize() {
AppBase.initialize();
// ATTENTION when adding stuff into this block:
// Because of the >>GlanceView<<, it should contain only
// code, which is used as well for the glance:
// - https://developer.garmin.com/connect-iq/core-topics/glances/
//
// Also dealing with resources "Rez" needs attention, too. See
// "Resource Scopes":
// - https://developer.garmin.com/connect-iq/core-topics/resources/
//
// Classes which are used for the glance view, needed to be tagged
// with "(:glance)".
}
//! Called on application start up
//!
//! @param state see `AppBase.onStart()`
//
function onStart(state as Lang.Dictionary?) as Void {
AppBase.onStart(state);
// ATTENTION when adding stuff into this block:
// Because of the >>GlanceView<<, it should contain only
// code, which is used as well for the glance:
// - https://developer.garmin.com/connect-iq/core-topics/glances/
//
// Also dealing with resources "Rez" needs attention, too. See
// "Resource Scopes":
// - https://developer.garmin.com/connect-iq/core-topics/resources/
//
// Classes which are used for the glance view, needed to be tagged
// with "(:glance)".
}
//! Called when your application is exiting
//
//!
//! @param state see `AppBase.onStop()`
//
function onStop(state as Lang.Dictionary?) as Void {
AppBase.onStop(state);
// ATTENTION when adding stuff into this block:
// Because of the >>GlanceView<<, it should contain only
// code, which is used as well for the glance:
// - https://developer.garmin.com/connect-iq/core-topics/glances/
//
// Also dealing with resources "Rez" needs attention, too. See
// "Resource Scopes":
// - https://developer.garmin.com/connect-iq/core-topics/resources/
//
// Classes which are used for the glance view, needed to be tagged
// with "(:glance)".
}
//! Returns the initial view of the application.
//!
//! @return The initial view.
//
function getInitialView() as [ WatchUi.Views ] or [ WatchUi.Views, WatchUi.InputDelegates ] {
mIsApp = true;
mQuitTimer = new QuitTimer();
mUpdateTimer = new Timer.Timer();
mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mHasToast = WatchUi has :showToast;
Settings.update();
if (Settings.getApiKey().length() == 0) {
// System.println("HomeAssistantApp getInitialView(): No API key in the application Settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoAPIKey) as Lang.String);
} else if (Settings.getApiUrl().length() == 0) {
// System.println("HomeAssistantApp getInitialView(): No API URL in the application Settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoApiUrl) as Lang.String);
} else if (Settings.getApiUrl().substring(-1, Settings.getApiUrl().length()).equals("/")) {
// System.println("HomeAssistantApp getInitialView(): API URL must not have a trailing slash '/'.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.TrailingSlashErr) as Lang.String);
} else if (Settings.getConfigUrl().length() == 0) {
// System.println("HomeAssistantApp getInitialView(): No configuration URL in the application settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoConfigUrl) as Lang.String);
} else if (Settings.getPin() == null) {
// System.println("HomeAssistantApp getInitialView(): Invalid PIN in application settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.SettingsPinError) as Lang.String);
} else if (! System.getDeviceSettings().phoneConnected and Settings.getWifiLteExecutionEnabled() and ! hasCachedMenu()) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection, no cached menu, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhoneNoCache) as Lang.String);
} else if (! System.getDeviceSettings().phoneConnected and ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection and Wi-Fi disabled, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable and ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantApp getInitialView(): No Internet connection and Wi-Fi disabled, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else {
var isCached = fetchMenuConfig();
var ret = null;
fetchApiStatus();
if (isCached) {
ret = [mHaMenu, new HomeAssistantViewDelegate(true)];
} else {
ret = [new WatchUi.View(), new WatchUi.BehaviorDelegate()];
}
// Separated from Settings.update() in order to call after fetchMenuConfig() and not call it on changed settings.
Settings.webhook();
return ret;
}
}
//! Callback function after completing the GET request to fetch the configuration menu.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnFetchMenuConfig(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Data: " + data);
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE:
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
break;
case Communications.BLE_QUEUE_FULL:
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
}
break;
case Communications.NETWORK_REQUEST_TIMED_OUT:
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
}
break;
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
}
break;
case 404:
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: 404, page not found. Check Configuration URL setting.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ConfigUrlNotFound) as Lang.String);
}
break;
case 200:
if (data == null) {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
} else {
if (Settings.getCacheConfig()) {
Storage.setValue("menu", data as Lang.Dictionary);
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Cached) as Lang.String;
} else {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String;
}
}
if (!mIsApp) {
glanceTemplate(data);
} else {
if (data == null) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
} else {
buildMenu(data);
WatchUi.switchToView(mHaMenu, new HomeAssistantViewDelegate(false), WatchUi.SLIDE_IMMEDIATE);
}
}
break;
default:
// System.println("HomeAssistantApp onReturnFetchMenuConfig(): Unhandled HTTP response code = " + responseCode);
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
break;
}
WatchUi.requestUpdate();
}
//! Can we use the cached menu?
//!
//! @return Return true if there's a menu in cache, and if the user has enabled the cache and
//! has not requested to have the cache refreshed.
//
function hasCachedMenu() as Lang.Boolean {
if (Settings.getClearCache() || !Settings.getCacheConfig()) {
return false;
}
return (Storage.getValue("menu") as Lang.Dictionary) != null;
}
//! Fetch the menu configuration over HTTPS, which might be locally cached.
//!
//! @return Return true if the menu came from the cache, otherwise false. This is because fetching
//! the menu when not in the cache is asynchronous and affects how the views are managed.
//
function fetchMenuConfig() as Lang.Boolean {
// System.println("Menu URL = " + Settings.getConfigUrl());
if (Settings.getConfigUrl().equals("")) {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String;
WatchUi.requestUpdate();
} else {
var menu = Storage.getValue("menu") as Lang.Dictionary;
if (menu != null and (Settings.getClearCache() || !Settings.getCacheConfig())) {
// System.println("HomeAssistantApp fetchMenuConfig(): Clearing cached menu on user request.");
Storage.deleteValue("menu");
menu = null;
Settings.unsetClearCache();
}
if (menu == null) {
// System.println("HomeAssistantApp fetchMenuConfig(): Menu not cached, fetching.");
fetchMenuConfigBasic(method(:onReturnFetchMenuConfig));
} else {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Cached) as Lang.String;
WatchUi.requestUpdate();
if (!mIsApp) {
glanceTemplate(menu);
} else {
buildMenu(menu);
}
return true;
}
}
return false;
}
//! The basic API call to fetch the menu configuration, with cache management issues managed by external supporting
//! code. This is factored out separately so that it can be reused to check and validate the cached menu.
//!
//! @param responseCallback The method to call on completion of the GET request.
//
function fetchMenuConfigBasic(
responseCallback as (
Method(
responseCode as Lang.Number,
data as Lang.Dictionary or Lang.String or Null
) as Void
) or (
Method(
responseCode as Lang.Number,
data as Lang.Dictionary or Lang.String or Null,
context as Lang.Object
) as Void
)
) {
// System.println("HomeAssistantApp fetchMenuConfigBasic(): Fetching JSON menu.");
var internetAvailable = System.getDeviceSettings().connectionAvailable;
if (! System.getDeviceSettings().phoneConnected or ! internetAvailable) {
// System.println("HomeAssistantApp fetchMenuConfigBasic(): No Phone connection, skipping API call.");
var errorRez = $.Rez.Strings.NoPhone;
if (Settings.getWifiLteExecutionEnabled()) {
errorRez = $.Rez.Strings.NoPhoneNoCache;
} else if (! internetAvailable) {
errorRez = $.Rez.Strings.Unavailable;
}
if (!mIsApp) {
WatchUi.requestUpdate();
} else {
ErrorView.show(WatchUi.loadResource(errorRez) as Lang.String);
}
mMenuStatus = WatchUi.loadResource(errorRez) as Lang.String;
} else {
Communications.makeWebRequest(
Settings.getConfigUrl(),
null,
{
:method => Communications.HTTP_REQUEST_METHOD_GET,
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON,
:headers => Settings.augmentHttpHeaders({})
},
responseCallback
);
}
}
//! Build the menu and store in `mHaMenu`. Then start updates if necessary.
//!
//! @param menu The dictionary derived from the JSON menu fetched by `fetchMenuConfig()`.
//
private function buildMenu(menu as Lang.Dictionary) {
mHaMenu = new HomeAssistantView(menu, null);
mQuitTimer.begin();
if (!Settings.getWebhookId().equals("") && !Settings.getClearWebhookId()) {
startUpdates();
} // If not, this will be done via a chain in Settings.webhook() and mWebhookManager.requestWebhookId() that registers the sensors.
}
//! Start the periodic menu updates for as long as the application is running.
//
function startUpdates() as Void {
if (mHaMenu != null and !mUpdating) {
// Start the continuous update process that continues for as long as the application is running.
mUpdating = true;
updateMenuItems();
}
}
//! Extract the optional template to override the default glance view.
//
function glanceTemplate(menu as Lang.Dictionary) {
if (menu != null) {
if (menu["glance"] != null) {
var glance = menu["glance"] as Lang.Dictionary;
if (glance["type"].equals("info")) {
mGlanceTemplate = glance["content"] as Lang.String;
// System.println("HomeAssistantApp glanceTemplate() " + mGlanceTemplate);
} else { // if glance["type"].equals("status")
mGlanceTemplate = null;
}
}
}
}
//! Test if two dictionaries are structurally equal. Used to see if the JSON menu has been
//! amended but yet to be updated in the application cache.
//!
//! @param a First dictionary in the comparison.
//! @param b Second dictionary in the comparison.
//
function structuralEquals(
a as Lang.Dictionary,
b as Lang.Dictionary
) as Lang.Boolean {
if (a.size() != b.size()) {
return false;
}
var keys = a.keys();
for (var i = 0; i < keys.size(); i++) {
var key = keys[i];
// If the sizes are the same and b contains every item in a,
// then a contains every item in b, i.e. the items are the same.
if (!b.hasKey(key)) {
return false;
}
var valA = a[key];
var valB = b[key];
if (valA == null && valB == null) {
// both null, consider true
} else if (valA == null || valB == null) {
return false;
} else if (valA instanceof Lang.Dictionary and valB instanceof Lang.Dictionary) {
if (!structuralEquals(valA, valB)) {
return false;
}
} else if (valA instanceof Lang.Array and valB instanceof Lang.Array) {
if (!arrayEquals(valA, valB)) {
return false;
}
} else if (!valA.equals(valB)) {
return false;
}
}
return true;
}
//! Test if two arrays are structurally equal. Used to see if the JSON menu has been
//! amended but yet to be updated in the application cache.
//!
//! @param a First array in the comparison.
//! @param b Second array in the comparison.
//
function arrayEquals(
a as Lang.Array,
b as Lang.Array
) as Lang.Boolean {
if (a.size() != b.size()) {
return false;
}
for (var i = 0; i < a.size(); i++) {
var itemA = a[i];
var itemB = b[i];
if (itemA == null && itemB == null) {
// Both null, consider true
} else if (itemA == null || itemB == null) {
return false;
} else if (itemA instanceof Lang.Dictionary and itemB instanceof Lang.Dictionary) {
if (!structuralEquals(itemA, itemB)) {
return false;
}
} else if (itemA instanceof Lang.Array and itemB instanceof Lang.Array) {
if (!arrayEquals(itemA, itemB)) {
return false;
}
} else if (!itemA.equals(itemB)) {
return false;
}
}
return true;
}
//! Takes the actions required to disable the check for an updated menu and notify the user.
//
function disableMenuCheck() as Void {
// As we're out of memory, pretend we've checked and don't try again.
mIsCacheChecked = true;
// Prevent the menu check happening in future.
Settings.unsetMenuCheck();
// Tell the user
var toast = WatchUi.loadResource($.Rez.Strings.MenuCheckDisabled) as Lang.String;
if (mHasToast) {
WatchUi.showToast(toast, null);
} else {
new Alert({
:timeout => Globals.scAlertTimeoutMs,
:font => Graphics.FONT_MEDIUM,
:text => toast,
:fgcolor => Graphics.COLOR_WHITE,
:bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE);
}
}
//! Callback function for the menu check GET request.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnCheckMenuConfig(
responseCode as Lang.Number,
data as Null or Lang.Dictionary
) as Void {
// System.println("HomeAssistantApp onReturnCheckMenuConfig() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnCheckMenuConfig() Response Data: " + data);
switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE:
// System.println("HomeAssistantApp onReturnCheckMenuConfig() 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("HomeAssistantApp onReturnCheckMenuConfig() 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("HomeAssistantApp onReturnCheckMenuConfig() 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("HomeAssistantApp onReturnCheckMenuConfig() 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("HomeAssistantApp onReturnCheckMenuConfig() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?");
disableMenuCheck();
var myTimer = new Timer.Timer();
myTimer.start(method(:updateMenuItems), Globals.scApiBackoffMs, false);
break;
case 404:
// System.println("HomeAssistantApp onReturnCheckMenuConfig() 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("HomeAssistantApp onReturnCheckMenuConfig() Response Code: 400, bad request. Template error.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.TemplateError) as Lang.String);
break;
case 200:
if (data != null) {
// 'menu' will be null if caching has just been enabled, but not yet cached locally.
var menu = Storage.getValue("menu") as Lang.Dictionary;
if (menu == null || !structuralEquals(data, menu)) {
// System.println("HomeAssistantApp onReturnCheckMenuConfig() New menu found.");
Storage.setValue("menu", data as Lang.Dictionary);
if (menu != null) {
// Notify the the user we have just got a newer menu file
var toast = WatchUi.loadResource($.Rez.Strings.MenuUpdated) as Lang.String;
if (mHasToast) {
WatchUi.showToast(toast, null);
} else {
new Alert({
:timeout => Globals.scAlertTimeoutMs,
:font => Graphics.FONT_MEDIUM,
:text => toast,
:fgcolor => Graphics.COLOR_WHITE,
:bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE);
}
}
}
// Prevent checking the cache is up to date again
mIsCacheChecked = true;
var delay = Settings.getPollDelay();
if (delay > 0) {
mUpdateTimer.start(method(:updateMenuItems), delay, false);
} else {
updateMenuItems();
}
}
break;
default:
// System.println("HomeAssistantApp onReturnCheckMenuConfig(): Unhandled HTTP response code = " + responseCode);
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
}
//! Callback function for each menu update GET request.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnUpdateMenuItems(
responseCode as Lang.Number,
data as Null or Lang.Dictionary
) as Void {
// System.println("HomeAssistantApp onReturnUpdateMenuItems() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?");
var myTimer = new Timer.Timer();
myTimer.start(method(:updateMenuItems), Globals.scApiBackoffMs, false);
// Revert status
status = getApiStatus();
break;
case 404:
// System.println("HomeAssistantApp onReturnUpdateMenuItems() 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("HomeAssistantApp onReturnUpdateMenuItems() 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;
// System.println("mItemsToUpdate: " + mItemsToUpdate);
if (data == null) {
// Simulation and real device behave differently, hence 2nd NoJson error message for "data == null".
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
} else {
if (mItemsToUpdate != null) {
for (var i = 0; i < mItemsToUpdate.size(); i++) {
var item = mItemsToUpdate[i];
var state = data[i.toString()];
if (item.getTemplate() != null) {
item.updateState(state);
}
if (item instanceof HomeAssistantToggleMenuItem) {
(item as HomeAssistantToggleMenuItem).updateToggleState(data[i.toString() + "t"]);
}
}
if (Settings.getMenuCheck() && Settings.getCacheConfig() && !mIsCacheChecked) {
// We are caching the menu configuration, so let's fetch it and check if its been updated.
var stats = System.getSystemStats(); // stats.* values in bytes
// https://developer.garmin.com/connect-iq/core-topics/debugging/, see "Basic Debugging"
// Create a file on the device called /GARMIN/APPS/LOGS/HOMEASSISTANT.TXT in order to log the values here.
System.println("Memory: total=" + stats.totalMemory + ", used=" + stats.usedMemory + ", free=" + stats.freeMemory);
if (stats.usedMemory > (Globals.scLowMem * stats.totalMemory)) {
// Assume insufficient memory
disableMenuCheck();
} else {
// Assume sufficient memory, but the response code might still turn the automatic check off.
fetchMenuConfigBasic(method(:onReturnCheckMenuConfig));
}
} else {
var delay = Settings.getPollDelay();
if (delay > 0) {
mUpdateTimer.start(method(:updateMenuItems), delay, false);
} else {
updateMenuItems();
}
}
}
}
break;
default:
// System.println("HomeAssistantApp onReturnUpdateMenuItems(): Unhandled HTTP response code = " + responseCode);
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
setApiStatus(status);
}
//! Construct the GET request to update all menu items.
//
function updateMenuItems() as Void {
if (mUpdating) {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var connectionAvailable = System.getDeviceSettings().connectionAvailable;
// In Wi-Fi/LTE execution mode, we should not show an error page but use a toast instead.
if (Settings.getWifiLteExecutionEnabled() &&
(! phoneConnected || ! connectionAvailable)) {
// Notify only once per disconnection cycle
if (!mNotifiedNoBle) {
var toast = WatchUi.loadResource($.Rez.Strings.NoPhone);
if (!connectionAvailable) {
toast = WatchUi.loadResource($.Rez.Strings.NoInternet);
}
if (mHasToast) {
WatchUi.showToast(toast, null);
} else {
new Alert({
:timeout => Globals.scAlertTimeoutMs,
:font => Graphics.FONT_MEDIUM,
:text => toast,
:fgcolor => Graphics.COLOR_WHITE,
:bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE);
}
}
mNotifiedNoBle = true;
mUpdating = false;
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
mUpdateTimer.start(method(:startUpdates), Globals.wifiPollResumeDelayMs, false);
return;
}
if (! phoneConnected) {
// System.println("HomeAssistantApp updateMenuItems(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
} else if (! connectionAvailable) {
// System.println("HomeAssistantApp updateMenuItems(): No Internet connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
} else {
mNotifiedNoBle = false;
if (mItemsToUpdate == null or mTemplates == null) {
mItemsToUpdate = mHaMenu.getItemsToUpdate();
mTemplates = {};
for (var i = 0; i < mItemsToUpdate.size(); i++) {
var item = mItemsToUpdate[i];
var template = item.getTemplate();
if (template != null) {
mTemplates.put(i.toString(), {
"template" => template
});
}
if (item instanceof HomeAssistantToggleMenuItem) {
mTemplates.put(i.toString() + "t", {
"template" => (item as HomeAssistantToggleMenuItem).getToggleTemplate()
});
}
}
}
// https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates
// System.println("HomeAssistantApp updateMenuItems() URL=" + url + ", Template='" + mTemplate + "'");
Communications.makeWebRequest(
Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(),
{
"type" => "render_template",
"data" => mTemplates
},
{
:method => Communications.HTTP_REQUEST_METHOD_POST,
:headers => Settings.augmentHttpHeaders({
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON
}),
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
},
method(:onReturnUpdateMenuItems)
);
}
}
}
//! Callback function after completing the GET request to fetch the API status.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnFetchApiStatus(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Data: " + data);
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE:
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
break;
case Communications.BLE_QUEUE_FULL:
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
}
break;
case Communications.NETWORK_REQUEST_TIMED_OUT:
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
}
break;
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
}
break;
case 404:
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: 404, page not found. Check Configuration URL setting.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ConfigUrlNotFound) as Lang.String);
}
break;
case 200:
if ((data != null) && (data instanceof Lang.Dictionary) && data["message"].equals("API running.")) {
mApiStatus = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String;
} else {
if (mIsApp) {
ErrorView.show("API " + mApiStatus + ".");
}
}
break;
default:
// System.println("HomeAssistantApp onReturnFetchApiStatus(): Unhandled HTTP response code = " + responseCode);
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
}
WatchUi.requestUpdate();
}
//! Construct the GET request to test the API status, is it accessible?
//
function fetchApiStatus() as Void {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var connectionAvailable = System.getDeviceSettings().connectionAvailable;
// System.println("API URL = " + Settings.getApiUrl());
if (Settings.getApiUrl().equals("")) {
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String;
WatchUi.requestUpdate();
} else {
if (mIsApp && Settings.getWifiLteExecutionEnabled() && (! phoneConnected || ! connectionAvailable)) {
// System.println("HomeAssistantApp fetchApiStatus(): In-app Wifi mode (No Phone and Internet connection), early return.");
return;
} else if (! phoneConnected) {
// System.println("HomeAssistantApp fetchApiStatus(): No Phone connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (!mIsApp) {
WatchUi.requestUpdate();
} else {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
} else if (! connectionAvailable) {
// System.println("HomeAssistantApp fetchApiStatus(): No Internet connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (!mIsApp) {
WatchUi.requestUpdate();
} else {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
}
} else {
Communications.makeWebRequest(
Settings.getApiUrl() + "/",
null,
{
:method => Communications.HTTP_REQUEST_METHOD_GET,
:headers => Settings.augmentHttpHeaders({
"Authorization" => "Bearer " + Settings.getApiKey()
}),
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
},
method(:onReturnFetchApiStatus)
);
}
}
}
//! Callback function after completing the GET request to render the glance template.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnFetchGlanceContent(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Data: " + data);
switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
break;
case Communications.BLE_QUEUE_FULL:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
}
break;
case Communications.NETWORK_REQUEST_TIMED_OUT:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
}
break;
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
}
break;
case 404:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: 404, page not found. Check Configuration URL setting.");
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ConfigUrlNotFound) as Lang.String);
}
break;
case 200:
if ((data != null) && (data instanceof Lang.Dictionary)) {
mGlanceText = data["glanceTemplate"];
}
break;
default:
// System.println("HomeAssistantApp onReturnFetchGlanceContent(): Unhandled HTTP response code = " + responseCode);
if (mIsApp) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
}
WatchUi.requestUpdate();
}
//! Construct the GET request to convert the optional glance template to text for display.
//
function fetchGlanceContent() as Void {
if (mGlanceTemplate != null) {
// https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates
Communications.makeWebRequest(
Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(),
{
"type" => "render_template",
"data" => {
"glanceTemplate" => {
"template" => mGlanceTemplate
}
}
},
{
:method => Communications.HTTP_REQUEST_METHOD_POST,
:headers => Settings.augmentHttpHeaders({
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON
}),
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
},
method(:onReturnFetchGlanceContent)
);
}
}
//! Record the API status result.
//!
//! @param s A string describing the API status
//
function setApiStatus(s as Lang.String) {
mApiStatus = s;
}
//! Return the API status result.
//!
//! @return A string describing the API status
//
function getApiStatus() as Lang.String {
return mApiStatus;
}
//! Return the Menu status result.
//!
//! @return A string describing the Menu status
//
function getMenuStatus() as Lang.String {
return mMenuStatus;
}
//! Return the optional glance text that overrides the default glance content. This
//! is derived from the glance template.
//!
//! @return A string derived from the glance template (or null)
//
function getGlanceText() as Lang.String? {
return mGlanceText;
}
//! Return the Menu construction status.
//!
//! @return A Boolean indicating if the menu is loaded into the application.
//
function isHomeAssistantMenuLoaded() as Lang.Boolean {
return mHaMenu != null;
}
//! Make the menu visible on the watch face.
//
function pushHomeAssistantMenuView() as Void {
WatchUi.pushView(mHaMenu, new HomeAssistantViewDelegate(true), WatchUi.SLIDE_IMMEDIATE);
}
//! Force status updates. Only take action if `Settings.getPollDelay() > 0`. This must be tested
//! locally as it is then efficient to take alternative action if the test fails.
//
function forceStatusUpdates() as Void {
// Don't mess with updates unless we are using a timer.
if (Settings.getPollDelay() > 0) {
mUpdateTimer.stop();
// For immediate updates
updateMenuItems();
}
}
//! Return the timer used to quit the application.
//!
//! @return Timer object
//
function getQuitTimer() as QuitTimer {
return mQuitTimer;
}
//! Return the glance view.
//!
//! @return The glance view
//
function getGlanceView() as [ WatchUi.GlanceView ] or [ WatchUi.GlanceView, WatchUi.GlanceViewDelegate ] or Null {
mIsApp = false; // A bit unnecessary given the default
mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
Settings.update();
updateStatus();
mGlanceTimer = new Timer.Timer();
mGlanceTimer.start(method(:updateStatus), Globals.scApiBackoffMs, true);
return [new HomeAssistantGlanceView(self)];
}
//! Return the glance theme.
//!
//! @return The glance colour
//
function getGlanceTheme() as Application.AppBase.GlanceTheme {
return Application.AppBase.GLANCE_THEME_LIGHT_BLUE;
}
//! Update the menu and API statuses. Required for the Glance update timer.
//
function updateStatus() as Void {
mGlanceTimer = null;
fetchMenuConfig();
fetchApiStatus();
if (!Settings.getWebhookId().equals("") && !Settings.getClearWebhookId()) {
fetchGlanceContent();
}
}
//! Code for when the application settings are updated.
//
function onSettingsChanged() as Void {
// System.println("HomeAssistantApp onSettingsChanged()");
Settings.update();
}
//! Called each time the Registered Temporal Event is to be invoked. So the object is created each time
//! on request and then destroyed on completion (to save resources).
//
function getServiceDelegate() as [ System.ServiceDelegate ] {
return [new BackgroundServiceDelegate()];
}
//! Determine is we are a glance or the full application. Glances should be considered to be separate applications.
//!
//! @return We are an application (if not we're a glance)
//
function getIsApp() as Lang.Boolean {
return mIsApp;
}
//! Returns a SyncDelegate for this App
//!
//! @return a SyncDelegate or null
//
public function getSyncDelegate() as Communications.SyncDelegate? {
return new HomeAssistantSyncDelegate();
}
}
//! Global function to return the application object.
//!
//! @return The application object.
//
(:glance, :background)
function getApp() as HomeAssistantApp {
return Application.getApp() as HomeAssistantApp;
}