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..b0dabe4 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -116,4 +116,13 @@ > + + + + + + diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 9ff1d3c..bf5cede 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,7 @@ 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. + Wifi/LTE execution mode. + Enable executing commands over Wifi/LTE. + Allows the app to start without phone connection (when menu is cached), and prompt to execute command over Wifi/LTE. diff --git a/source/HomeAssistantApp.mc b/source/HomeAssistantApp.mc index bda9906..8677420 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; @@ -25,6 +26,7 @@ using Toybox.Timer; (:glance, :background) class HomeAssistantApp extends Application.AppBase { private var mApiStatus as Lang.String or Null; + private var mHasToast as Lang.Boolean = false; private var mMenuStatus as Lang.String or Null; private var mHaMenu as HomeAssistantView or Null; private var mGlanceTemplate as Lang.String or Null = null; @@ -38,6 +40,9 @@ class HomeAssistantApp extends Application.AppBase { 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 = {}; + private var mNotifiedNoBle as Lang.Boolean = false; + + private const wifiPollDelayMs = 2000; //! Class Constructor // @@ -105,6 +110,7 @@ class HomeAssistantApp extends Application.AppBase { 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) { @@ -122,11 +128,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 +236,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 +269,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(), @@ -301,11 +324,11 @@ class HomeAssistantApp extends Application.AppBase { //! Start the periodic menu updates for as long as the application is running. // - function startUpdates() { + function startUpdates() as Void { if (mHaMenu != null and !mUpdating) { // Start the continuous update process that continues for as long as the application is running. - updateMenuItems(); mUpdating = true; + updateMenuItems(); } } @@ -410,15 +433,50 @@ class HomeAssistantApp extends Application.AppBase { //! Construct the GET request to update all menu items. // function updateMenuItems() as Void { - if (! System.getDeviceSettings().phoneConnected) { + var phoneConnected = System.getDeviceSettings().phoneConnected; + var connectionAvailable = System.getDeviceSettings().connectionAvailable; + + // In Wifi/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.scAlertTimeout, + :font => Graphics.FONT_MEDIUM, + :text => toast, + :fgcolor => Graphics.COLOR_WHITE, + :bgcolor => Graphics.COLOR_BLACK + }).pushView(WatchUi.SLIDE_IMMEDIATE); + } + } + + mNotifiedNoBle = true; + setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); + mUpdateTimer.start(method(:startUpdates), wifiPollDelayMs, false); + + mUpdating = 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 (! System.getDeviceSettings().connectionAvailable) { + } 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 = {}; @@ -531,20 +589,27 @@ class HomeAssistantApp extends Application.AppBase { // (:glance) 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 (! System.getDeviceSettings().phoneConnected) { + if (! mIsGlance && 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 (mIsGlance) { WatchUi.requestUpdate(); } else { + System.println("we here"); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); } - } else if (! System.getDeviceSettings().connectionAvailable) { + } else if (! connectionAvailable) { // System.println("HomeAssistantApp fetchApiStatus(): No Internet connection, skipping API call."); mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; if (mIsGlance) { @@ -785,6 +850,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/HomeAssistantConfirmation.mc b/source/HomeAssistantConfirmation.mc index 9b48714..dab35e2 100644 --- a/source/HomeAssistantConfirmation.mc +++ b/source/HomeAssistantConfirmation.mc @@ -35,19 +35,43 @@ class HomeAssistantConfirmation extends WatchUi.Confirmation { //! Delegate to respond to the confirmation request. // class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate { - private var mConfirmMethod as Method(state as Lang.Boolean) as Void; - private var mTimer as Timer.Timer or Null; - private var mState as Lang.Boolean; + private static var mTimer as Timer.Timer or Null; + + private var mConfirmMethod as Method(state as Lang.Boolean) as Void; + private var mState as Lang.Boolean; + private var mToggleMethod as Method(state as Lang.Boolean) as Void or Null; + private var mConfirmationView as WatchUi.Confirmation; //! Class Constructor + //! + //! @param options A dictionary describing the following options: + //! - callback Method to call on confirmation. + //! - confirmationView Confirmation the delegate is active for + //! - state Wanted state of a toggle button. + //! - toggle Optional setEnabled method to untoggle ToggleItem. // - function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean) { + function initialize(options as { + :callback as Method(state as Lang.Boolean) as Void, + :confirmationView as WatchUi.Confirmation, + :state as Lang.Boolean, + :toggleMethod as Method(state as Lang.Boolean) or Null, + }) { + if (mTimer != null) { + mTimer.stop(); + } + WatchUi.ConfirmationDelegate.initialize(); - mConfirmMethod = callback; - mState = state; + mConfirmMethod = options[:callback]; + mConfirmationView = options[:confirmationView]; + mState = options[:state]; + mToggleMethod = options[:toggleMethod]; + var timeout = Settings.getConfirmTimeout(); // ms if (timeout > 0) { - mTimer = new Timer.Timer(); + if (mTimer == null) { + mTimer = new Timer.Timer(); + } + mTimer.start(method(:onTimeout), timeout, true); } } @@ -64,6 +88,11 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate { } if (response == WatchUi.CONFIRM_YES) { mConfirmMethod.invoke(mState); + } else { + // Undo the toggle, if we have one + if (mToggleMethod != null) { + mToggleMethod.invoke(!mState); + } } return true; } @@ -71,6 +100,14 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate { //! 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); + // Undo the toggle, if we have one + if (mToggleMethod != null) { + mToggleMethod.invoke(!mState); + } + + var getCurrentView = WatchUi.getCurrentView(); + if (getCurrentView[0] == mConfirmationView) { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } } } diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index 436dd39..5ccf475 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -185,21 +185,25 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { private var mTimer as Timer.Timer or Null; private var mState as Lang.Boolean; private var mFailures as PinFailures; + private var mToggleMethod as Method(state as Lang.Boolean) as Void or Null; private var mView as HomeAssistantPinConfirmationView; //! Class Constructor //! - //! @param callback Method to call on confirmation. - //! @param state Current state of a toggle button. - //! @param pin PIN to be matched. - //! @param view PIN confirmation view. + //! @param options A dictionary describing the following options: + //! - callback Method to call on confirmation. + //! - pin PIN to be matched. + //! - state Wanted state of a toggle button. + //! - toggle Optional setEnabled method to untoggle ToggleItem. + //! - view PIN confirmation view. // - function initialize( - callback as Method(state as Lang.Boolean) as Void, - state as Lang.Boolean, - pin as Lang.String, - view as HomeAssistantPinConfirmationView - ) { + function initialize(options as { + :callback as Method(state as Lang.Boolean) as Void, + :pin as Lang.String, + :state as Lang.Boolean, + :view as HomeAssistantPinConfirmationView, + :toggleMethod as (Method(state as Lang.Boolean) as Void) or Null, + }) { BehaviorDelegate.initialize(); mFailures = new PinFailures(); if (mFailures.isLocked()) { @@ -208,11 +212,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { WatchUi.loadResource($.Rez.Strings.Seconds); WatchUi.showToast(msg, {}); } - mPin = pin; + mPin = options[:pin]; mEnteredPin = ""; - mConfirmMethod = callback; - mState = state; - mView = view; + mConfirmMethod = options[:callback]; + mState = options[:state]; + mToggleMethod = options[:toggleMethod]; + mView = options[:view]; + resetTimer(); } @@ -237,8 +243,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { if (mTimer != null) { mTimer.stop(); } - mConfirmMethod.invoke(mState); WatchUi.popView(WatchUi.SLIDE_RIGHT); + + // Set the toggle, if we have one + if (mToggleMethod != null) { + mToggleMethod.invoke(!mState); + } + mConfirmMethod.invoke(mState); } else { error(); } @@ -279,6 +290,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { if (mTimer != null) { mTimer.stop(); } + WatchUi.popView(WatchUi.SLIDE_RIGHT); } @@ -304,6 +316,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { goBack(); } + //! Handle the back button (ESC) + // + function onBack() as Lang.Boolean { + goBack(); + return true; + } + } diff --git a/source/HomeAssistantService.mc b/source/HomeAssistantService.mc index 104f800..56f3f9f 100644 --- a/source/HomeAssistantService.mc +++ b/source/HomeAssistantService.mc @@ -27,9 +27,8 @@ class HomeAssistantService { //! Class Constructor // function initialize() { - if (WatchUi has :showToast) { - mHasToast = true; - } + mHasToast = WatchUi has :showToast; + if (Attention has :vibrate) { mHasVibrate = true; } @@ -129,10 +128,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, + }, dialog), + 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/HomeAssistantSyncDelegate.mc b/source/HomeAssistantSyncDelegate.mc new file mode 100644 index 0000000..92161d8 --- /dev/null +++ b/source/HomeAssistantSyncDelegate.mc @@ -0,0 +1,111 @@ +using Toybox.Communications; +using Toybox.Lang; + +// SyncDelegate to execute single command via POST request to Home Assistant +// +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 { + Communications.notifySyncProgress(100); + 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); + } +} diff --git a/source/HomeAssistantTapMenuItem.mc b/source/HomeAssistantTapMenuItem.mc index 88aabaa..8d22d81 100644 --- a/source/HomeAssistantTapMenuItem.mc +++ b/source/HomeAssistantTapMenuItem.mc @@ -82,16 +82,43 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem { var pinConfirmationView = new HomeAssistantPinConfirmationView(); WatchUi.pushView( pinConfirmationView, - new HomeAssistantPinConfirmationDelegate(method(:onConfirm), false, pin, pinConfirmationView), + new HomeAssistantPinConfirmationDelegate({ + :callback => method(:onConfirm), + :pin => pin, + :state => false, + :view => pinConfirmationView, + }), WatchUi.SLIDE_IMMEDIATE ); } } else if (mConfirm) { - WatchUi.pushView( - new HomeAssistantConfirmation(), - new HomeAssistantConfirmationDelegate(method(:onConfirm), false), - WatchUi.SLIDE_IMMEDIATE - ); + var phoneConnected = System.getDeviceSettings().phoneConnected; + var internetAvailable = System.getDeviceSettings().connectionAvailable; + 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 => "service", + :service => mService, + :data => mData, + :exit => mExit, + }, dialog), + WatchUi.SLIDE_LEFT + ); + } else { + var view = new HomeAssistantConfirmation(); + WatchUi.pushView( + view, + new HomeAssistantConfirmationDelegate({ + :callback => method(:onConfirm), + :confirmationView => view, + :state => false, + }), + WatchUi.SLIDE_IMMEDIATE + ); + } } else { onConfirm(false); } diff --git a/source/HomeAssistantToggleMenuItem.mc b/source/HomeAssistantToggleMenuItem.mc index e902916..2c47de2 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,30 +213,54 @@ 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) { + // if there's no response body, let's assume that what we did, happened, and flip the toggle + if (data.size() == 0) { + setEnabled(!isEnabled()); + } + + else { + 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 - 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) { - url = url + id.substring(0, id.find(".")) + "/turn_on"; - } else { - url = url + id.substring(0, id.find(".")) + "/turn_off"; + var url = getUrl(id, s); + + if ((! phoneConnected || ! internetAvailable) && Settings.getWifiLteExecutionEnabled()) { + // Undo the toggle + setEnabled(!isEnabled()); + wifiPrompt(s); + return; } + // System.println("HomeAssistantToggleMenuItem setState() URL = " + url); // System.println("HomeAssistantToggleMenuItem setState() entity_id = " + id); Communications.makeWebRequest( @@ -275,21 +291,45 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { function callService(b as Lang.Boolean) as Void { var hasTouchScreen = System.getDeviceSettings().isTouchScreen; if (mPin && hasTouchScreen) { + // Undo the toggle + setEnabled(!isEnabled()); + var pin = Settings.getPin(); if (pin != null) { var pinConfirmationView = new HomeAssistantPinConfirmationView(); WatchUi.pushView( pinConfirmationView, - new HomeAssistantPinConfirmationDelegate(method(:onConfirm), b, pin, pinConfirmationView), + new HomeAssistantPinConfirmationDelegate({ + :callback => method(:onConfirm), + :pin => pin, + :state => b, + :toggleMethod => method(:setEnabled), + :view => pinConfirmationView, + }), WatchUi.SLIDE_IMMEDIATE ); } } else if (mConfirm) { - WatchUi.pushView( - new HomeAssistantConfirmation(), - new HomeAssistantConfirmationDelegate(method(:onConfirm), b), - WatchUi.SLIDE_IMMEDIATE - ); + // Undo the toggle + setEnabled(!isEnabled()); + + var phoneConnected = System.getDeviceSettings().phoneConnected; + var internetAvailable = System.getDeviceSettings().connectionAvailable; + if ((! phoneConnected || ! internetAvailable) && Settings.getWifiLteExecutionEnabled()) { + wifiPrompt(b); + } else { + var confirmationView = new HomeAssistantConfirmation(); + WatchUi.pushView( + confirmationView, + new HomeAssistantConfirmationDelegate({ + :callback => method(:onConfirm), + :confirmationView => confirmationView, + :state => b, + :toggleMethod => method(:setEnabled), + }), + WatchUi.SLIDE_IMMEDIATE + ); + } } else { onConfirm(b); } @@ -303,4 +343,45 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { setState(b); } + //! Displays a confirmation dialog before executing a service call via Wi-Fi/LTE. + //! + //! @param s Desired state: `true` to turn on, `false` to turn off. + // + private function wifiPrompt(s as Lang.Boolean) as Void { + var id = mData.get("entity_id") as Lang.String; + var url = getUrl(id, s); + + 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, + }, dialog), + WatchUi.SLIDE_LEFT + ); + } + + //! Constructs a Home Assistant API URL for the given entity and desired state. + //! + //! @param id The entity ID, e.g., `"switch.kitchen"`. + //! @param s Desired state: `true` for "turn_on", `false` for "turn_off". + //! + //! @return Full service URL string. + // + private function getUrl(id as Lang.String, s as Lang.Boolean) as Lang.String { + var url = Settings.getApiUrl() + "/services/"; + if (s) { + url = url + id.substring(0, id.find(".")) + "/turn_on"; + } else { + url = url + id.substring(0, id.find(".")) + "/turn_off"; + } + + return url; + } } diff --git a/source/Settings.mc b/source/Settings.mc index 658c9a8..9075198 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,16 @@ class Settings { } } + //! Get the value of the WiFi/LTE toggle in settings. + //! + //! @return The state of the toggle. + // + static function getWifiLteExecutionEnabled() as Lang.Boolean { + // Wifi/LTE sync execution on a cached menu + if (!mCacheConfig) { + return false; + } + return mWifiLteExecution; + } + } diff --git a/source/WifiLteExecutionConfirmDelegate.mc b/source/WifiLteExecutionConfirmDelegate.mc new file mode 100644 index 0000000..563baf6 --- /dev/null +++ b/source/WifiLteExecutionConfirmDelegate.mc @@ -0,0 +1,135 @@ +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 static var mTimer as Timer.Timer or Null; + private var mHasToast as Lang.Boolean = false; + private var mConfirmationView as WatchUi.Confirmation; + + //! 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: if set to true: exit after running command. + //! @param view The Confirmation view the delegate is active for + 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, + }, view as WatchUi.Confirmation) { + ConfirmationDelegate.initialize(); + + if (mTimer != null) { + mTimer.stop(); + } + + if (WatchUi has :showToast) { + mHasToast = true; + } + + mConfirmationView = view; + mCommandData = { + :type => cOptions[:type], + :service => cOptions[:service], + :data => cOptions[:data], + :url => cOptions[:url], + :callback => cOptions[:callback], + :exit => cOptions[:exit] + }; + + var timeout = Settings.getConfirmTimeout(); // ms + if (timeout > 0) { + if (mTimer == null) { + mTimer = new Timer.Timer(); + } + + mTimer.start(method(:onTimeout), timeout, true); + } + } + + //! 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 { + getApp().getQuitTimer().reset(); + if (mTimer != null) { + mTimer.stop(); + } + + if (response == WatchUi.CONFIRM_YES) { + trySync(); + } + return true; + } + + //! Initiates a bulk sync process to execute a command, if connections are available + private function trySync() as Void { + 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) { + if (Communications has :startSync2) { + var syncString = WatchUi.loadResource($.Rez.Strings.WifiLteExecutionTitle) as Lang.String; + Communications.startSync2({:message => syncString}); + } else { + Communications.startSync(); + } + } 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(); + var getCurrentView = WatchUi.getCurrentView(); + + if (getCurrentView[0] == mConfirmationView) { + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + } +} \ No newline at end of file