From 172d4ad1e4bd7eb8a316cb3318d99ae467e4a147 Mon Sep 17 00:00:00 2001 From: Vincent Elger Zwanenburg Date: Thu, 10 Jul 2025 16:50:15 +0100 Subject: [PATCH] add Wifi LTE command execution --- resources/settings/properties.xml | 5 + resources/settings/settings.xml | 7 + resources/strings/strings.xml | 7 + source/HomeAssistantApp.mc | 55 ++++-- source/HomeAssistantService.mc | 19 ++- source/HomeAssistantToggleMenuItem.mc | 60 +++++-- source/Settings.mc | 10 ++ source/WifiLteExecution.mc | 236 ++++++++++++++++++++++++++ 8 files changed, 368 insertions(+), 31 deletions(-) create mode 100644 source/WifiLteExecution.mc diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index 4dc885c..55bdd79 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -96,4 +96,9 @@ --> + + false diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml index 333b5ae..17552bb 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -116,4 +116,11 @@ > + + + + diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 9ff1d3c..ec32446 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -31,7 +31,9 @@ No Internet connection. No JSON returned from HTTP request. No Phone connection. + No phone connection, no cached menu. No Response, check Internet connection + Request timed out PIN input locked for Potential Error seconds @@ -42,6 +44,10 @@ HTTP request returned error code = Failed to register Webhook Wrong PIN + No Wifi or LTE available + Execute over Wifi/LTE? + Sending to Home Assistant. + No data received. Select... @@ -64,4 +70,5 @@ Enable the background service to send the device battery level, location and (if supported) activity data to Home Assistant. The refresh rate (in minutes) at which the background service should repeat sending data. (Read only) The Webhook ID created by the device for background service updates. You might require this for debugging. + Allows the app to start without phone connection, and prompt to execute command over Wifi/LTE. diff --git a/source/HomeAssistantApp.mc b/source/HomeAssistantApp.mc index bda9906..5f0324e 100644 --- a/source/HomeAssistantApp.mc +++ b/source/HomeAssistantApp.mc @@ -14,6 +14,7 @@ //----------------------------------------------------------------------------------- using Toybox.Application; +using Toybox.Communications; using Toybox.Lang; using Toybox.WatchUi; using Toybox.System; @@ -122,11 +123,14 @@ class HomeAssistantApp extends Application.AppBase { } 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) { - // System.println("HomeAssistantApp getInitialView(): No Phone connection, skipping API call."); + } 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 wifi disabled, skipping API call."); return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); - } else if (! System.getDeviceSettings().connectionAvailable) { - // System.println("HomeAssistantApp getInitialView(): No Internet connection, skipping API call."); + } else if (! System.getDeviceSettings().connectionAvailable and ! Settings.getWifiLteExecutionEnabled()) { + // System.println("HomeAssistantApp getInitialView(): No Internet connection and wifi disabled, skipping API call."); return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); } else { var isCached = fetchMenuConfig(); @@ -227,6 +231,20 @@ class HomeAssistantApp extends Application.AppBase { 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 busted. + // + function hasCachedMenu() as Lang.Boolean { + if (Settings.getClearCache() || !Settings.getCacheConfig()) { + return false; + } + + var menu = Storage.getValue("menu") as Lang.Dictionary; + return menu != 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 @@ -246,22 +264,22 @@ class HomeAssistantApp extends Application.AppBase { Settings.unsetClearCache(); } if (menu == null) { - if (! System.getDeviceSettings().phoneConnected) { + var phoneConnected = System.getDeviceSettings().phoneConnected; + var internetAvailable = System.getDeviceSettings().connectionAvailable; + if (! phoneConnected or ! internetAvailable) { + var errorRez = $.Rez.Strings.NoPhone; + if (Settings.getWifiLteExecutionEnabled()) { + errorRez = $.Rez.Strings.NoPhoneNoCache; + } else if (! internetAvailable) { + errorRez = $.Rez.Strings.Unavailable; + } // System.println("HomeAssistantApp fetchMenuConfig(): No Phone connection, skipping API call."); if (mIsGlance) { WatchUi.requestUpdate(); } else { - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); + ErrorView.show(WatchUi.loadResource(errorRez) as Lang.String); } - mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; - } else if (! System.getDeviceSettings().connectionAvailable) { - // System.println("HomeAssistantApp fetchMenuConfig(): No Internet connection, skipping API call."); - if (mIsGlance) { - WatchUi.requestUpdate(); - } else { - ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); - } - mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; + mMenuStatus = WatchUi.loadResource(errorRez) as Lang.String; } else { Communications.makeWebRequest( Settings.getConfigUrl(), @@ -785,6 +803,13 @@ class HomeAssistantApp extends Application.AppBase { 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. diff --git a/source/HomeAssistantService.mc b/source/HomeAssistantService.mc index 104f800..eca2509 100644 --- a/source/HomeAssistantService.mc +++ b/source/HomeAssistantService.mc @@ -129,10 +129,25 @@ class HomeAssistantService { data as Lang.Dictionary or Null, exit as Lang.Boolean ) as Void { - if (! System.getDeviceSettings().phoneConnected) { + var phoneConnected = System.getDeviceSettings().phoneConnected; + var internetAvailable = System.getDeviceSettings().connectionAvailable; + if (Settings.getWifiLteExecutionEnabled() && (! phoneConnected || ! internetAvailable)) { + var dialogMsg = WatchUi.loadResource($.Rez.Strings.WifiLtePrompt) as Lang.String; + var dialog = new WatchUi.Confirmation(dialogMsg); + WatchUi.pushView( + dialog, + new WifiLteExecutionConfirmDelegate({ + :type => "service", + :service => service, + :data => data, + :exit => exit, + }, null), + WatchUi.SLIDE_LEFT + ); + } else if (! phoneConnected) { // System.println("HomeAssistantService call(): No Phone connection, skipping API call."); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); - } else if (! System.getDeviceSettings().connectionAvailable) { + } else if (! internetAvailable) { // System.println("HomeAssistantService call(): No Internet connection, skipping API call."); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); } else { diff --git a/source/HomeAssistantToggleMenuItem.mc b/source/HomeAssistantToggleMenuItem.mc index e902916..e3bf4a9 100644 --- a/source/HomeAssistantToggleMenuItem.mc +++ b/source/HomeAssistantToggleMenuItem.mc @@ -198,16 +198,8 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { case 200: // System.println("HomeAssistantToggleMenuItem onReturnSetState(): Service executed."); getApp().forceStatusUpdates(); - var state; var d = data as Lang.Array; - for(var i = 0; i < d.size(); i++) { - if ((d[i].get("entity_id") as Lang.String).equals(mData.get("entity_id"))) { - state = d[i].get("state") as Lang.String; - // System.println((d[i].get("attributes") as Lang.Dictionary).get("friendly_name") + " State=" + state); - setUiToggle(state); - WatchUi.requestUpdate(); - } - } + setToggleStateWithData(d); status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String; break; @@ -221,23 +213,41 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { } } + //! Handles the response from a Home Assistant service or state call and updates the toggle UI. + //! + //! @param data An array of dictionaries, each representing a Home Assistant entity state. + // + function setToggleStateWithData(data as Lang.Array) { + for(var i = 0; i < data.size(); i++) { + if ((data[i].get("entity_id") as Lang.String).equals(mData.get("entity_id"))) { + var state = data[i].get("state") as Lang.String; + // System.println((d[i].get("attributes") as Lang.Dictionary).get("friendly_name") + " State=" + state); + setUiToggle(state); + WatchUi.requestUpdate(); + } + } + } + //! Set the state of the toggle menu item. //! //! @param s Boolean indicating the desired state of the toggle switch. // function setState(s as Lang.Boolean) as Void { // Toggle the UI back, we'll wait for confirmation from the Home Assistant + // Note: with Zigbee2MQTT a.o. we may not always get the state in the response. setEnabled(!isEnabled()); - if (! System.getDeviceSettings().phoneConnected) { + + var phoneConnected = System.getDeviceSettings().phoneConnected; + var internetAvailable = System.getDeviceSettings().connectionAvailable; + + if (! phoneConnected && ! Settings.getWifiLteExecutionEnabled()) { // System.println("HomeAssistantToggleMenuItem getState(): No Phone connection, skipping API call."); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); - } else if (! System.getDeviceSettings().connectionAvailable) { + } else if (! internetAvailable && ! Settings.getWifiLteExecutionEnabled()) { // System.println("HomeAssistantToggleMenuItem getState(): No Internet connection, skipping API call."); // Toggle the UI back ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); } else { - // Updated SDK and got a new error - // ERROR: venu: Cannot find symbol ':substring' on type 'PolyType'. var id = mData.get("entity_id") as Lang.String; var url = Settings.getApiUrl() + "/services/"; if (s) { @@ -245,6 +255,28 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { } else { url = url + id.substring(0, id.find(".")) + "/turn_off"; } + + if (! phoneConnected && ! internetAvailable && Settings.getWifiLteExecutionEnabled()) { + var dialogMsg = WatchUi.loadResource($.Rez.Strings.WifiLtePrompt) as Lang.String; + var dialog = new WatchUi.Confirmation(dialogMsg); + WatchUi.pushView( + dialog, + new WifiLteExecutionConfirmDelegate({ + :type => "entity", + :url => url, + :id => id, + :data => mData, + :callback => method(:setToggleStateWithData), + :exit => mExit, + }, { + :confirmMethod => method(:onConfirm), + :state => !isEnabled(), + }), + WatchUi.SLIDE_LEFT + ); + return; + } + // System.println("HomeAssistantToggleMenuItem setState() URL = " + url); // System.println("HomeAssistantToggleMenuItem setState() entity_id = " + id); Communications.makeWebRequest( @@ -302,5 +334,5 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { function onConfirm(b as Lang.Boolean) as Void { setState(b); } - + } diff --git a/source/Settings.mc b/source/Settings.mc index 658c9a8..19a732d 100644 --- a/source/Settings.mc +++ b/source/Settings.mc @@ -35,6 +35,7 @@ class Settings { private static var mCacheConfig as Lang.Boolean = false; private static var mClearCache as Lang.Boolean = false; private static var mVibrate as Lang.Boolean = false; + private static var mWifiLteExecution as Lang.Boolean = false; //! seconds private static var mAppTimeout as Lang.Number = 0; //! seconds @@ -69,6 +70,7 @@ class Settings { mMenuAlignment = Properties.getValue("menu_alignment"); mIsSensorsLevelEnabled = Properties.getValue("enable_battery_level"); mBatteryRefreshRate = Properties.getValue("battery_level_refresh_rate"); + mWifiLteExecution = Properties.getValue("wifi_lte_execution"); } //! A webhook is required for non-privileged API calls. @@ -270,4 +272,12 @@ class Settings { } } + //! Get the value of the WiFi/LTE toggle in settings. + //! + //! @return The state of the toggle. + // + static function getWifiLteExecutionEnabled() as Lang.Boolean { + return mWifiLteExecution; + } + } diff --git a/source/WifiLteExecution.mc b/source/WifiLteExecution.mc new file mode 100644 index 0000000..48722dc --- /dev/null +++ b/source/WifiLteExecution.mc @@ -0,0 +1,236 @@ +using Toybox.WatchUi; +using Toybox.System; +using Toybox.Communications; +using Toybox.Lang; +using Toybox.Timer; + +// Delegate to respond to a confirmation to execute command via bulk sync +// +class WifiLteExecutionConfirmDelegate extends WatchUi.ConfirmationDelegate { + public static var mCommandData as { + :type as Lang.String, + :service as Lang.String or Null, + :data as Lang.Dictionary or Null, + :url as Lang.String or Null, + :id as Lang.Number or Null, + :exit as Lang.Boolean + }; + + private var mToggleMethod as Method(b as Lang.Boolean) as Void or Null; + private var mToggleState as Lang.Boolean or Null; + private var mHasToast as Lang.Boolean = false; + private var mTimer as Timer.Timer or Null; + + + //! Initializes a confirmation delegate to confirm a Wi-Fi or LTE command exection + //! + //! @param options A dictionary describing the command to be executed: + //! - type: The command type, either `"service"` or `"entity"`. + //! - service: (For type `"service"`) The Home Assistant service to call (e.g., "light.turn_on"). + //! - url: (For type `"entity"`) The full Home Assistant entity API URL. + //! - callback: (For type `"entity"`) A callback method (Method) to handle the response. + //! - data: (Optional) A dictionary of data to send with the request. + //! = exit: Boolean: true to exit after running command. + //! + //! @param toggleItem Optional toggle state information: + //! - confirmMethod: A method to call after confirmation. + //! - state: The state (boolean) that will be passed to the confirmMethod. + function initialize(cOptions as { + :type as Lang.String, + :service as Lang.String or Null, + :data as Lang.Dictionary or Null, + :url as Lang.String or Null, + :callback as Lang.Method or Null, + :exit as Lang.Boolean, + }, toggleItem as { + :confirmMethod as Lang.Method, + :state as Lang.Boolean + } or Null) { + if (WatchUi has :showToast) { + mHasToast = true; + } + + mCommandData = { + :type => cOptions[:type], + :service => cOptions[:service], + :data => cOptions[:data], + :url => cOptions[:url], + :callback => cOptions[:callback], + :exit => cOptions[:exit] + }; + if (toggleItem != null) { + mToggleMethod = toggleItem[:confirmMethod]; + mToggleState = toggleItem[:state]; + } + + var timeout = Settings.getConfirmTimeout(); // ms + if (timeout > 0) { + mTimer = new Timer.Timer(); + mTimer.start(method(:onTimeout), timeout, true); + } + + ConfirmationDelegate.initialize(); + } + + //! Handles the user's response to the confirmation dialog. + //! + //! @param response The user's confirmation response as `WatchUi.Confirm` + //! @return Always returns `true` to indicate the response was handled. + function onResponse(response) as Lang.Boolean { + if (response == WatchUi.CONFIRM_YES) { + if (mToggleMethod != null) { + mToggleMethod.invoke(mToggleState); + } + trySync(); + } + return true; + } + + //! Initiates a bulk sync process to execute a command, if connections are available + private function trySync() as Void { + WatchUi.popView(WatchUi.SLIDE_IMMEDIATE); + var connectionInfo = System.getDeviceSettings().connectionInfo; + var keys = connectionInfo.keys(); + var possibleConnection = false; + + for(var i = 0; i < keys.size(); i++) { + if (keys[i] != :bluetooth) { + var connection = connectionInfo[keys[i]]; + if (connection.state != System.CONNECTION_STATE_NOT_INITIALIZED) { + possibleConnection = true; + break; + } + } + } + + if (possibleConnection) { + var syncString = WatchUi.loadResource($.Rez.Strings.WifiLteExecutionTitle) as Lang.String; + Communications.startSync2({:message => syncString}); + } else { + var toast = WatchUi.loadResource($.Rez.Strings.WifiLteNotAvailable) as Lang.String; + if (mHasToast) { + WatchUi.showToast(toast, null); + } else { + new Alert({ + :timeout => Globals.scAlertTimeout, + :font => Graphics.FONT_MEDIUM, + :text => toast, + :fgcolor => Graphics.COLOR_WHITE, + :bgcolor => Graphics.COLOR_BLACK + }).pushView(WatchUi.SLIDE_IMMEDIATE); + } + } + } + + //! Function supplied to a timer in order to limit the time for which the confirmation can be provided. + function onTimeout() as Void { + mTimer.stop(); + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } +} + +class HomeAssistantSyncDelegate extends Communications.SyncDelegate { + private static var syncError as Lang.String or Null; + + // Initialize an instance of this delegate + public function initialize() { + SyncDelegate.initialize(); + } + + //! Called by the system to determine if a sync is needed + public function isSyncNeeded() as Lang.Boolean { + return true; + } + + //! Called by the system when starting a bulk sync. + public function onStartSync() as Void { + syncError = null; + + if (WifiLteExecutionConfirmDelegate.mCommandData == null) { + syncError = WatchUi.loadResource($.Rez.Strings.WifiLteExecutionDataError) as Lang.String; + onStopSync(); + return; + } + + var type = WifiLteExecutionConfirmDelegate.mCommandData[:type]; + var data = WifiLteExecutionConfirmDelegate.mCommandData[:data]; + var url; + + switch (type) { + case "service": + var service = WifiLteExecutionConfirmDelegate.mCommandData[:service]; + url = Settings.getApiUrl() + "/services/" + service.substring(0, service.find(".")) + "/" + service.substring(service.find(".")+1, service.length()); + var entity_id = ""; + if (data != null) { + entity_id = data.get("entity_id"); + if (entity_id == null) { + entity_id = ""; + } + } + performRequest(url, data); + break; + case "entity": + url = WifiLteExecutionConfirmDelegate.mCommandData[:url]; + performRequest(url, data); + break; + } + } + + // Performs a POST request to Hass with a given payload and URL, and calls haCallback + private function performRequest(url as Lang.String, data as Lang.Dictionary or Null) { + Communications.makeWebRequest( + url, + data, // May include {"entity_id": xxxx} for service calls + { + :method => Communications.HTTP_REQUEST_METHOD_POST, + :headers => { + "Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON, + "Authorization" => "Bearer " + Settings.getApiKey() + }, + :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON + }, + method(:haCallback) + ); + } + + //! Handle callback from request + public function haCallback(code as Lang.Number, data as Null or Lang.Dictionary) as Void { + if (code == 200) { + syncError = null; + if (WifiLteExecutionConfirmDelegate.mCommandData[:type].equals("entity")) { + var callbackMethod = WifiLteExecutionConfirmDelegate.mCommandData[:callback]; + if (callbackMethod != null) { + var d = data as Lang.Array; + callbackMethod.invoke(d); + } + } + onStopSync(); + return; + } + + switch(code) { + case Communications.NETWORK_REQUEST_TIMED_OUT: + syncError = WatchUi.loadResource($.Rez.Strings.TimedOut) as Lang.String; + break; + case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE: + syncError = WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String; + syncError = ""; + default: + var codeMsg = WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String; + syncError = codeMsg + code; + break; + } + + onStopSync(); + } + + //! Clean up + public function onStopSync() as Void { + if (WifiLteExecutionConfirmDelegate.mCommandData[:exit]) { + System.exit(); + } + + Communications.cancelAllRequests(); + Communications.notifySyncComplete(syncError); + } +}