Compare commits

..

22 Commits

Author SHA1 Message Date
Philip Abbey
0d73de494e Tidy 2025-07-24 20:56:08 +01:00
Philip Abbey
a686e1a104 Reordered settings 2025-07-24 19:49:44 +01:00
Philip Abbey
8868f2152c Comments & Dictionaries
Reformatted comments to work in VSCode and converted `dict.get(:key)` to `dict[:key]` syntax as its nicer.
2025-07-24 18:54:27 +01:00
Philip Abbey
70f05e8912 Added missing code headers
And included vincentezw in headers for the code he touched.
2025-07-22 22:14:46 +01:00
Philip Abbey
c138fad6ca Update properties.xml
Moved Wi-Fi option up to be more prominent.
2025-07-22 22:05:04 +01:00
Philip Abbey
9641313492 Wifi -> Wi-Fi
Amended in presentational aspects only, not in the actual code. "Wi-Fi" is the proper noun and registered trademark.
2025-07-22 22:03:10 +01:00
Philip Abbey
e2722319a6 add Wifi LTE command execution (#255)
Firstly, thank you to all the maintainers for your work on this very
useful application.

This PR adds the ability to execute Home Assistant commands over Wi-Fi
or LTE, behind an opt-in setting. While not a full "Wi-Fi mode," this
feature allows the app to function in limited scenarios where the phone
is unavailable but the device still has a direct network connection
(e.g. Wi-Fi or LTE).

When enabled:
- The app can launch with a cached menu without phone connection.
- On command execution, the user is prompted to confirm.
- Upon confirmation, a bulk sync is triggered to send the request to
Home Assistant.

This enables basic control even without Bluetooth connectivity — so I
can switch off that pesky bathroom light late at night when I don't have
my phone nearby.

I’ve seen a few issues suggesting similar functionality, and I believe
this strikes a useful balance between functionality and simplicity. That
said, I understand if this doesn't align with the intended featureset —
feel free to close if so.

This PR also adds a few string resources without translations. I'm not
sure if those are autogenerated or user-provided — happy to adjust if
needed.
2025-07-22 20:52:07 +01:00
Vincent Elger Zwanenburg
0b84983eaf use fixed poll delay from const 2025-07-21 21:02:51 +01:00
Vincent Elger Zwanenburg
d32135af63 fix setting toggleItem, periodic pulling 2025-07-21 19:45:14 +01:00
Vincent Elger Zwanenburg
db3fbd9886 timers as statics, defensive popviews, no double confirmation, add pin
screen onBack, toggle state tweaks
2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
be7eed1ae1 early return from fetchApiStatus for in-app wifi, fix typo in docstrings 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
576f8c4a64 HomeAssistantConfirmationDelegate and HomeAssistantPinConfirmationDelegate undo toggles on timeout and reject 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
979d85fce5 show toast, not error, in updateMenuItems 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
ac899ff784 simplify delegate, toggle switch for empty response body 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
b45f02ef7b move popview up so it does not close wifi dialog 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
62f0e711c9 make setting conditional by using group 2025-07-19 10:35:35 +01:00
Vincent Elger Zwanenburg
b2b8ffb332 separate syncdelegate class file, check for startsync2 2025-07-19 10:35:34 +01:00
Vincent Elger Zwanenburg
172d4ad1e4 add Wifi LTE command execution 2025-07-19 10:35:34 +01:00
Joseph Abbey
460f247728 Improve web: dim disabled, icons, pin 2025-07-19 07:37:05 +01:00
__JosephAbbey
a6b4925ff7 257 pin dialog does not popup when using is servicetap (#258)
Need this urgently as it affects the use of the PIN for tap menu items.
2025-07-18 20:51:10 +01:00
Philip Abbey
3672a598fb Update HISTORY.md
Added v2.32
2025-07-18 20:49:10 +01:00
Philip Abbey
f6d0916315 Update HomeAssistantTapMenuItem.mc
Requirement for use of the PIN was not being correctly extracted from the options. Goes to show why the security features should not be relied on! A recent compilation fix created this breaking change.
2025-07-18 20:46:49 +01:00
27 changed files with 898 additions and 230 deletions

View File

@@ -2,5 +2,8 @@
"cSpell.words": [ "cSpell.words": [
"usbs", "usbs",
"Venu" "Venu"
] ],
"files.exclude": {
"resources-*": true
}
} }

View File

@@ -44,3 +44,4 @@
| 2.29 | Added support for three new devices, Forerunners 570 42mm & 47mm and 970. | | 2.29 | Added support for three new devices, Forerunners 570 42mm & 47mm and 970. |
| 2.30 | <img src="images/Venu2_glance_default.png" width="200" title="Default Glance"/><br/>Extensive re-work of the [Glance](examples/Glance.md) view, including the ability to customise it with a user supplied template. | | 2.30 | <img src="images/Venu2_glance_default.png" width="200" title="Default Glance"/><br/>Extensive re-work of the [Glance](examples/Glance.md) view, including the ability to customise it with a user supplied template. |
| 2.31 | Adding [two new options](./examples/Actions.md#exit-on-tap) to the menu items: 1) The ability to disable a menu item, e.g. temporarily for seasonal changes, 2) The option to exit after a menu item has been select. | | 2.31 | Adding [two new options](./examples/Actions.md#exit-on-tap) to the menu items: 1) The ability to disable a menu item, e.g. temporarily for seasonal changes, 2) The option to exit after a menu item has been select. |
| 2.32 | Bug fix for a breaking change extracting options caused by the need to rearrange function parameters for an [annoying compiler error](https://github.com/house-of-abbey/GarminHomeAssistant/issues/253). |

View File

@@ -8,7 +8,7 @@
tested on a Venu 2 device. The source code is provided at: tested on a Venu 2 device. The source code is provided at:
https://github.com/house-of-abbey/GarminHomeAssistant. https://github.com/house-of-abbey/GarminHomeAssistant.
P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 P A Abbey & J D Abbey & Someone0nEarth & vincentezw, 31 October 2023
--> -->
@@ -38,6 +38,12 @@
--> -->
<property id="clear_cache" type="boolean">false</property> <property id="clear_cache" type="boolean">false</property>
<!--
Enables the SyncDelegate and prompt to send a command over Wi-Fi/LTE.
This will only show when not connected to the user's phone.
-->
<property id="wifi_lte_execution" type="boolean">false</property>
<!-- <!--
Enable notification via vibrations, typically for confirmation of actions. Enable notification via vibrations, typically for confirmation of actions.
--> -->
@@ -53,7 +59,7 @@
Poll delay adds a user configurable delay (in seconds) to each round of Poll delay adds a user configurable delay (in seconds) to each round of
status updates of all item in the device's menu that might be amended status updates of all item in the device's menu that might be amended
externally from the watch. A user has requested that it is possible to add externally from the watch. A user has requested that it is possible to add
this delayfor an "always open" mode of operation, which then drains the this delay for an "always open" mode of operation, which then drains the
watch battery from the additional API access activity. watch battery from the additional API access activity.
--> -->
<property id="poll_delay_combined" type="number">5</property> <property id="poll_delay_combined" type="number">5</property>
@@ -95,5 +101,4 @@
for trouble shooting. for trouble shooting.
--> -->
<property id="webhook_id" type="string"></property> <property id="webhook_id" type="string"></property>
</properties> </properties>

View File

@@ -8,7 +8,7 @@
tested on a Venu 2 device. The source code is provided at: tested on a Venu 2 device. The source code is provided at:
https://github.com/house-of-abbey/GarminHomeAssistant. https://github.com/house-of-abbey/GarminHomeAssistant.
P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 P A Abbey & J D Abbey & Someone0nEarth & vincentezw, 31 October 2023
--> -->
@@ -51,6 +51,15 @@
<settingConfig type="boolean" /> <settingConfig type="boolean" />
</setting> </setting>
<group enableIfTrue="@Properties.cache_config" id="wifiLteExection" title="@Strings.WifiLteExecution" description="@Strings.WifiLteExecutionDescription">
<setting
propertyKey="@Properties.wifi_lte_execution"
title="@Strings.WifiLteExecutionEnable"
>
<settingConfig type="boolean" />
</setting>
</group>
<setting <setting
propertyKey="@Properties.enable_vibration" propertyKey="@Properties.enable_vibration"
title="@Strings.SettingsVibration" title="@Strings.SettingsVibration"
@@ -116,4 +125,5 @@
> >
<settingConfig type="alphaNumeric" readonly="true" /> <settingConfig type="alphaNumeric" readonly="true" />
</setting> </setting>
</settings> </settings>

View File

@@ -8,7 +8,7 @@
tested on a Venu 2 device. The source code is provided at: tested on a Venu 2 device. The source code is provided at:
https://github.com/house-of-abbey/GarminHomeAssistant. https://github.com/house-of-abbey/GarminHomeAssistant.
P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 P A Abbey & J D Abbey & Someone0nEarth & vincentezw, 31 October 2023
--> -->
@@ -31,7 +31,9 @@
<string id="NoInternet">No Internet connection.</string> <string id="NoInternet">No Internet connection.</string>
<string id="NoJson">No JSON returned from HTTP request.</string> <string id="NoJson">No JSON returned from HTTP request.</string>
<string id="NoPhone" scope="glance">No Phone connection.</string> <string id="NoPhone" scope="glance">No Phone connection.</string>
<string id="NoPhoneNoCache" scope="glance">No phone connection, no cached menu.</string>
<string id="NoResponse">No Response, check Internet connection</string> <string id="NoResponse">No Response, check Internet connection</string>
<string id="TimedOut">Request timed out</string>
<string id="PinInputLocked">PIN input locked for</string> <string id="PinInputLocked">PIN input locked for</string>
<string id="PotentialError">Potential Error</string> <string id="PotentialError">Potential Error</string>
<string id="Seconds">seconds</string> <string id="Seconds">seconds</string>
@@ -42,6 +44,10 @@
<string id="UnhandledHttpErr">HTTP request returned error code = </string> <string id="UnhandledHttpErr">HTTP request returned error code = </string>
<string id="WebhookFailed">Failed to register Webhook</string> <string id="WebhookFailed">Failed to register Webhook</string>
<string id="WrongPin">Wrong PIN</string> <string id="WrongPin">Wrong PIN</string>
<string id="WifiLteNotAvailable">No Wi-Fi or LTE available</string>
<string id="WifiLtePrompt">Execute over Wi-Fi/LTE?</string>
<string id="WifiLteExecutionTitle">Sending to Home Assistant.</string>
<string id="WifiLteExecutionDataError">No data received.</string>
<!-- For the settings GUI, strings should be in the order they are used. --> <!-- For the settings GUI, strings should be in the order they are used. -->
<string id="SettingsSelect">Select...</string> <string id="SettingsSelect">Select...</string>
@@ -64,4 +70,7 @@
<string id="SettingsEnableBatteryLevel">Enable the background service to send the device battery level, location and (if supported) activity data to Home Assistant.</string> <string id="SettingsEnableBatteryLevel">Enable the background service to send the device battery level, location and (if supported) activity data to Home Assistant.</string>
<string id="SettingsBatteryLevelRefreshRate">The refresh rate (in minutes) at which the background service should repeat sending data.</string> <string id="SettingsBatteryLevelRefreshRate">The refresh rate (in minutes) at which the background service should repeat sending data.</string>
<string id="WebhookId">(Read only) The Webhook ID created by the device for background service updates. You might require this for debugging.</string> <string id="WebhookId">(Read only) The Webhook ID created by the device for background service updates. You might require this for debugging.</string>
<string id="WifiLteExecution">Wi-Fi/LTE execution mode.</string>
<string id="WifiLteExecutionEnable">Enable executing commands over Wi-Fi/LTE.</string>
<string id="WifiLteExecutionDescription">Allows the app to start without phone connection (when menu is cached), and prompt to execute command over Wi-Fi/LTE.</string>
</strings> </strings>

View File

@@ -35,38 +35,38 @@ class Alert extends WatchUi.View {
//! Class Constructor //! Class Constructor
//! @param params A dictionary object as follows:<br> //! @param params A dictionary object as follows:<br>
//! &lbrace;<br> //! `{`<br>
//! &emsp; :timeout as Lang.Number, // Timeout in millseconds<br> //! &emsp; `:timeout as Lang.Number,` // Timeout in millseconds<br>
//! &emsp; :font as Graphics.FontType, // Text font size<br> //! &emsp; `:font as Graphics.FontType,` // Text font size<br>
//! &emsp; :text as Lang.String, // Text to display<br> //! &emsp; `:text as Lang.String,` // Text to display<br>
//! &emsp; :fgcolor as Graphics.ColorType, // Foreground Colour<br> //! &emsp; `:fgcolor as Graphics.ColorType,` // Foreground Colour<br>
//! &emsp; :bgcolor as Graphics.ColorType // Background Colour<br> //! &emsp; `:bgcolor as Graphics.ColorType` // Background Colour<br>
//! &rbrace; //! `}`
// //
function initialize(params as Lang.Dictionary) { function initialize(params as Lang.Dictionary) {
View.initialize(); View.initialize();
mText = params.get(:text) as Lang.String; mText = params[:text] as Lang.String;
if (mText == null) { if (mText == null) {
mText = "Alert"; mText = "Alert";
} }
mFont = params.get(:font) as Graphics.FontType; mFont = params[:font] as Graphics.FontType;
if (mFont == null) { if (mFont == null) {
mFont = Graphics.FONT_MEDIUM; mFont = Graphics.FONT_MEDIUM;
} }
mFgcolor = params.get(:fgcolor) as Graphics.ColorType; mFgcolor = params[:fgcolor] as Graphics.ColorType;
if (mFgcolor == null) { if (mFgcolor == null) {
mFgcolor = Graphics.COLOR_BLACK; mFgcolor = Graphics.COLOR_BLACK;
} }
mBgcolor = params.get(:bgcolor) as Graphics.ColorType; mBgcolor = params[:bgcolor] as Graphics.ColorType;
if (mBgcolor == null) { if (mBgcolor == null) {
mBgcolor = Graphics.COLOR_WHITE; mBgcolor = Graphics.COLOR_WHITE;
} }
mTimeout = params.get(:timeout) as Lang.Number; mTimeout = params[:timeout] as Lang.Number;
if (mTimeout == null) { if (mTimeout == null) {
mTimeout = 2000; mTimeout = 2000;
} }

View File

@@ -45,10 +45,10 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
//! Called on completion of an activity. //! Called on completion of an activity.
//! //!
//! @param activity Specified as a Dictionary with two items.<br> //! @param activity Specified as a Dictionary with two items.<br>
//! &lbrace;<br> //! `{`<br>
//! &emsp; :sport as Activity.Sport<br> //! &emsp; `:sport as Activity.Sport`<br>
//! &emsp; :subSport as Activity.SubSport<br> //! &emsp; `:subSport as Activity.SubSport`<br>
//! &rbrace; //! `}`
// //
function onActivityCompleted( function onActivityCompleted(
activity as { activity as {
@@ -101,8 +101,8 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
//! @param sub_activity Activity.SubSport //! @param sub_activity Activity.SubSport
// //
private function doUpdate( private function doUpdate(
activity as Lang.Number or Null, activity as Lang.Number?,
sub_activity as Lang.Number or Null sub_activity as Lang.Number?
) { ) {
// System.println("BackgroundServiceDelegate onTemporalEvent(): Making API call."); // System.println("BackgroundServiceDelegate onTemporalEvent(): Making API call.");
var position = Position.getInfo(); var position = Position.getInfo();
@@ -154,7 +154,7 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
(Properties.getValue("api_url") as Lang.String) + "/webhook/" + (Properties.getValue("webhook_id") as Lang.String), (Properties.getValue("api_url") as Lang.String) + "/webhook/" + (Properties.getValue("webhook_id") as Lang.String),
{ {
"type" => "update_location", "type" => "update_location",
"data" => data, "data" => data
}, },
{ {
:method => Communications.HTTP_REQUEST_METHOD_POST, :method => Communications.HTTP_REQUEST_METHOD_POST,

View File

@@ -39,7 +39,7 @@ class ErrorView extends ScalableView {
// Vertical spacing between the top of the face and the error icon // Vertical spacing between the top of the face and the error icon
private var mErrorIconMargin as Lang.Number; private var mErrorIconMargin as Lang.Number;
private var mErrorIcon; private var mErrorIcon;
private var mTextArea as WatchUi.TextArea or Null; private var mTextArea as WatchUi.TextArea?;
private var mAntiAlias as Lang.Boolean = false; private var mAntiAlias as Lang.Boolean = false;
private static var instance; private static var instance;
@@ -169,7 +169,7 @@ class ErrorDelegate extends WatchUi.BehaviorDelegate {
WatchUi.BehaviorDelegate.initialize(); WatchUi.BehaviorDelegate.initialize();
} }
//! Process the event to clear the ErrorView. //! Handle the back button (ESC) to clear the ErrorView.
// //
function onBack() as Lang.Boolean { function onBack() as Lang.Boolean {
getApp().getQuitTimer().reset(); getApp().getQuitTimer().reset();

View File

@@ -9,11 +9,12 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld & vincentezw, 31 October 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Application; using Toybox.Application;
using Toybox.Communications;
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.System; using Toybox.System;
@@ -24,20 +25,24 @@ using Toybox.Timer;
// //
(:glance, :background) (:glance, :background)
class HomeAssistantApp extends Application.AppBase { class HomeAssistantApp extends Application.AppBase {
private var mApiStatus as Lang.String or Null; private var mApiStatus as Lang.String?;
private var mMenuStatus as Lang.String or Null; private var mHasToast as Lang.Boolean = false;
private var mHaMenu as HomeAssistantView or Null; private var mMenuStatus as Lang.String?;
private var mGlanceTemplate as Lang.String or Null = null; private var mHaMenu as HomeAssistantView?;
private var mGlanceText as Lang.String or Null = null; private var mGlanceTemplate as Lang.String? = null;
private var mQuitTimer as QuitTimer or Null; private var mGlanceText as Lang.String? = null;
private var mGlanceTimer as Timer.Timer or Null; private var mQuitTimer as QuitTimer?;
private var mUpdateTimer as Timer.Timer or Null; private var mGlanceTimer as Timer.Timer?;
private var mUpdateTimer as Timer.Timer?;
// Array initialised by onReturnFetchMenuConfig() // Array initialised by onReturnFetchMenuConfig()
private var mItemsToUpdate as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTapMenuItem or HomeAssistantGroupMenuItem> or Null; private var mItemsToUpdate as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTapMenuItem or HomeAssistantGroupMenuItem>?;
private var mIsGlance as Lang.Boolean = false; private var mIsGlance as Lang.Boolean = false;
private var mIsApp as Lang.Boolean = false; // Or Widget 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 mUpdating as Lang.Boolean = false; // Don't start a second chain of updates
private var mTemplates as Lang.Dictionary = {}; private var mTemplates as Lang.Dictionary = {};
private var mNotifiedNoBle as Lang.Boolean = false;
private const wifiPollDelayMs = 2000;
//! Class Constructor //! Class Constructor
// //
@@ -105,6 +110,7 @@ class HomeAssistantApp extends Application.AppBase {
mUpdateTimer = new Timer.Timer(); mUpdateTimer = new Timer.Timer();
mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String; mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String; mMenuStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mHasToast = WatchUi has :showToast;
Settings.update(); Settings.update();
if (Settings.getApiKey().length() == 0) { if (Settings.getApiKey().length() == 0) {
@@ -122,11 +128,14 @@ class HomeAssistantApp extends Application.AppBase {
} else if (Settings.getPin() == null) { } else if (Settings.getPin() == null) {
// System.println("HomeAssistantApp getInitialView(): Invalid PIN in application settings."); // System.println("HomeAssistantApp getInitialView(): Invalid PIN in application settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.SettingsPinError) as Lang.String); return ErrorView.create(WatchUi.loadResource($.Rez.Strings.SettingsPinError) as Lang.String);
} else if (! System.getDeviceSettings().phoneConnected) { } else if (! System.getDeviceSettings().phoneConnected and Settings.getWifiLteExecutionEnabled() and ! hasCachedMenu()) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection, skipping API call."); // 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); return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable) { } else if (! System.getDeviceSettings().connectionAvailable and ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantApp getInitialView(): No Internet connection, skipping API call."); // 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); return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else { } else {
var isCached = fetchMenuConfig(); var isCached = fetchMenuConfig();
@@ -227,6 +236,18 @@ class HomeAssistantApp extends Application.AppBase {
WatchUi.requestUpdate(); 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. //! 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 //! @return Return true if the menu came from the cache, otherwise false. This is because fetching
@@ -246,22 +267,22 @@ class HomeAssistantApp extends Application.AppBase {
Settings.unsetClearCache(); Settings.unsetClearCache();
} }
if (menu == null) { 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."); // System.println("HomeAssistantApp fetchMenuConfig(): No Phone connection, skipping API call.");
if (mIsGlance) { if (mIsGlance) {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} else { } 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; mMenuStatus = WatchUi.loadResource(errorRez) 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;
} else { } else {
Communications.makeWebRequest( Communications.makeWebRequest(
Settings.getConfigUrl(), Settings.getConfigUrl(),
@@ -301,11 +322,11 @@ class HomeAssistantApp extends Application.AppBase {
//! Start the periodic menu updates for as long as the application is running. //! Start the periodic menu updates for as long as the application is running.
// //
function startUpdates() { function startUpdates() as Void {
if (mHaMenu != null and !mUpdating) { if (mHaMenu != null and !mUpdating) {
// Start the continuous update process that continues for as long as the application is running. // Start the continuous update process that continues for as long as the application is running.
updateMenuItems();
mUpdating = true; mUpdating = true;
updateMenuItems();
} }
} }
@@ -410,15 +431,50 @@ class HomeAssistantApp extends Application.AppBase {
//! Construct the GET request to update all menu items. //! Construct the GET request to update all menu items.
// //
function updateMenuItems() as Void { function updateMenuItems() as Void {
if (! System.getDeviceSettings().phoneConnected) { 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.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."); // System.println("HomeAssistantApp updateMenuItems(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) 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."); // System.println("HomeAssistantApp updateMenuItems(): No Internet connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String); setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
} else { } else {
mNotifiedNoBle = false;
if (mItemsToUpdate == null or mTemplates == null) { if (mItemsToUpdate == null or mTemplates == null) {
mItemsToUpdate = mHaMenu.getItemsToUpdate(); mItemsToUpdate = mHaMenu.getItemsToUpdate();
mTemplates = {}; mTemplates = {};
@@ -531,20 +587,27 @@ class HomeAssistantApp extends Application.AppBase {
// //
(:glance) (:glance)
function fetchApiStatus() as Void { function fetchApiStatus() as Void {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var connectionAvailable = System.getDeviceSettings().connectionAvailable;
// System.println("API URL = " + Settings.getApiUrl()); // System.println("API URL = " + Settings.getApiUrl());
if (Settings.getApiUrl().equals("")) { if (Settings.getApiUrl().equals("")) {
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String; mApiStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String;
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} else { } 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."); // System.println("HomeAssistantApp fetchApiStatus(): No Phone connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (mIsGlance) { if (mIsGlance) {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} else { } else {
System.println("we here");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); 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."); // System.println("HomeAssistantApp fetchApiStatus(): No Internet connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String; mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (mIsGlance) { if (mIsGlance) {
@@ -692,10 +755,10 @@ class HomeAssistantApp extends Application.AppBase {
//! Return the optional glance text that overrides the default glance content. This //! Return the optional glance text that overrides the default glance content. This
//! is derived from the glance template. //! is derived from the glance template.
//! //!
//! @return A string derived from the glance template //! @return A string derived from the glance template (or null)
// //
(:glance) (:glance)
function getGlanceText() as Lang.String or Null { function getGlanceText() as Lang.String? {
return mGlanceText; return mGlanceText;
} }
@@ -785,6 +848,13 @@ class HomeAssistantApp extends Application.AppBase {
return mIsApp; 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. //! Global function to return the application object.

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023 // P A Abbey & J D Abbey & Someone0nEarth & vincentezw, 19 November 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -35,26 +35,53 @@ class HomeAssistantConfirmation extends WatchUi.Confirmation {
//! Delegate to respond to the confirmation request. //! Delegate to respond to the confirmation request.
// //
class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate { class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
private static var mTimer as Timer.Timer?;
private var mConfirmMethod as Method(state as Lang.Boolean) as Void; 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 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 //! Class Constructor
//!
//! @param options A dictionary describing the following options:<br>
//! `{`<br>
//! &emsp; `:callback as Method(state as Lang.Boolean) as Void,` // Method to call on confirmation.<br>
//! &emsp; `:confirmationView as WatchUi.Confirmation,` // Confirmation the delegate is active for<br>
//! &emsp; `:state as Lang.Boolean,` // Wanted state of a toggle button.<br>
//! &emsp; `:toggle as Method(state as Lang.Boolean)?` // Optional setEnabled method to untoggle ToggleItem.<br>
//! `}`
// //
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)?
}
) {
if (mTimer != null) {
mTimer.stop();
}
WatchUi.ConfirmationDelegate.initialize(); WatchUi.ConfirmationDelegate.initialize();
mConfirmMethod = callback; mConfirmMethod = options[:callback];
mState = state; mConfirmationView = options[:confirmationView];
mState = options[:state];
mToggleMethod = options[:toggleMethod];
var timeout = Settings.getConfirmTimeout(); // ms var timeout = Settings.getConfirmTimeout(); // ms
if (timeout > 0) { if (timeout > 0) {
if (mTimer == null) {
mTimer = new Timer.Timer(); mTimer = new Timer.Timer();
}
mTimer.start(method(:onTimeout), timeout, true); mTimer.start(method(:onTimeout), timeout, true);
} }
} }
//! Respond to the confirmation event. //! Respond to the confirmation event.
//! //!
//! @param response code //! @param response response code
//! @return Required to meet the function prototype, but the base class does not indicate a definition. //! @return Required to meet the function prototype, but the base class does not indicate a definition.
// //
function onResponse(response as WatchUi.Confirm) as Lang.Boolean { function onResponse(response as WatchUi.Confirm) as Lang.Boolean {
@@ -64,13 +91,27 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
} }
if (response == WatchUi.CONFIRM_YES) { if (response == WatchUi.CONFIRM_YES) {
mConfirmMethod.invoke(mState); mConfirmMethod.invoke(mState);
} else {
// Undo the toggle, if we have one
if (mToggleMethod != null) {
mToggleMethod.invoke(!mState);
}
} }
return true; return true;
} }
//! Function supplied to a timer in order to limit the time for which the confirmation can be provided. //! Function supplied to a timer in order to limit the time for which the confirmation can be provided.
//
function onTimeout() as Void { function onTimeout() as Void {
mTimer.stop(); mTimer.stop();
// 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); WatchUi.popView(WatchUi.SLIDE_RIGHT);
} }
}
} }

View File

@@ -44,12 +44,12 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
private var mTextWidth as Lang.Number = 0; private var mTextWidth as Lang.Number = 0;
// Re-usable text items for drawing // Re-usable text items for drawing
private var mApp as HomeAssistantApp; private var mApp as HomeAssistantApp;
private var mTitle as WatchUi.Text or Null; private var mTitle as WatchUi.Text?;
private var mApiText as WatchUi.Text or Null; private var mApiText as WatchUi.Text?;
private var mApiStatus as WatchUi.Text or Null; private var mApiStatus as WatchUi.Text?;
private var mMenuText as WatchUi.Text or Null; private var mMenuText as WatchUi.Text?;
private var mMenuStatus as WatchUi.Text or Null; private var mMenuStatus as WatchUi.Text?;
private var mGlanceContent as WatchUi.TextArea or Null; private var mGlanceContent as WatchUi.TextArea?;
private var mAntiAlias as Lang.Boolean = false; private var mAntiAlias as Lang.Boolean = false;
//! Class Constructor //! Class Constructor

View File

@@ -30,7 +30,7 @@ class HomeAssistantGroupMenuItem extends HomeAssistantMenuItem {
icon as WatchUi.Drawable, icon as WatchUi.Drawable,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment :alignment as WatchUi.MenuItem.Alignment
} or Null }?
) { ) {
if (options != null) { if (options != null) {
options.put(:icon, icon); options.put(:icon, icon);

View File

@@ -20,7 +20,7 @@ using Toybox.Graphics;
//! Generic menu button with an icon that optionally renders a Home Assistant Template. //! Generic menu button with an icon that optionally renders a Home Assistant Template.
// //
class HomeAssistantMenuItem extends WatchUi.IconMenuItem { class HomeAssistantMenuItem extends WatchUi.IconMenuItem {
private var mTemplate as Lang.String or Null; private var mTemplate as Lang.String?;
//! Class Constructor //! Class Constructor
//! //!
@@ -34,13 +34,13 @@ class HomeAssistantMenuItem extends WatchUi.IconMenuItem {
options as { options as {
:alignment as WatchUi.MenuItem.Alignment, :alignment as WatchUi.MenuItem.Alignment,
:icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol
} or Null }?
) { ) {
WatchUi.IconMenuItem.initialize( WatchUi.IconMenuItem.initialize(
label, label,
null, null,
null, null,
options.get(:icon), options[:icon],
options options
); );
mTemplate = template; mTemplate = template;
@@ -56,9 +56,9 @@ class HomeAssistantMenuItem extends WatchUi.IconMenuItem {
//! Return the menu item's template. //! Return the menu item's template.
//! //!
//! @return A string with the menu item's template definition. //! @return A string with the menu item's template definition (or null).
// //
function getTemplate() as Lang.String or Null { function getTemplate() as Lang.String? {
return mTemplate; return mTemplate;
} }

View File

@@ -74,8 +74,8 @@ class HomeAssistantMenuItemFactory {
// //
function toggle( function toggle(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
entity_id as Lang.String or Null, entity_id as Lang.String?,
template as Lang.String or Null, template as Lang.String?,
options as { options as {
:exit as Lang.Boolean, :exit as Lang.Boolean,
:confirm as Lang.Boolean, :confirm as Lang.Boolean,
@@ -105,10 +105,10 @@ class HomeAssistantMenuItemFactory {
// //
function tap( function tap(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
entity_id as Lang.String or Null, entity_id as Lang.String?,
template as Lang.String or Null, template as Lang.String?,
service as Lang.String or Null, service as Lang.String?,
data as Lang.Dictionary or Null, data as Lang.Dictionary?,
options as { options as {
:exit as Lang.Boolean, :exit as Lang.Boolean,
:confirm as Lang.Boolean, :confirm as Lang.Boolean,
@@ -156,7 +156,7 @@ class HomeAssistantMenuItemFactory {
// //
function group( function group(
definition as Lang.Dictionary, definition as Lang.Dictionary,
template as Lang.String or Null template as Lang.String?
) as WatchUi.MenuItem { ) as WatchUi.MenuItem {
return new HomeAssistantGroupMenuItem( return new HomeAssistantGroupMenuItem(
definition, definition,

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld & vincentezw, 31 October 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -45,7 +45,8 @@ class PinDigit extends WatchUi.Selectable {
var button = new PinDigitButton({ var button = new PinDigitButton({
:width => width, :width => width,
:height => height, :height => height,
:label => digit :label => digit,
:touched => false
}); });
var buttonTouched = new PinDigitButton({ var buttonTouched = new PinDigitButton({
@@ -83,16 +84,23 @@ class PinDigit extends WatchUi.Selectable {
//! Class Constructor //! Class Constructor
//! //!
//! @param options See `Drawable.initialize()`, but with `:label` and `:touched` added.<br> //! @param options See `Drawable.initialize()`, but with `:label` and `:touched` added.<br>
//! &lbrace;<br> //! `{`<br>
//! &emsp; :label as Lang.Number, // The digit 0..9 to display<br> //! &emsp; `:label as Lang.Number,` // The digit 0..9 to display<br>
//! &emsp; :touched as Lang.Boolean, // Should the digit be filled to indicate it has been pressed?<br> //! &emsp; `:touched as Lang.Boolean,` // Should the digit be filled to indicate it has been pressed?<br>
//! &emsp; + those required by `Drawable.initialize()`<br> //! &emsp; + those required by `Drawable.initialize()`<br>
//! &rbrace; //! ``}`
// //
function initialize(options) { function initialize(
options as {
:width as Lang.Float,
:height as Lang.Float,
:label as Lang.Number,
:touched as Lang.Boolean
}
) {
Drawable.initialize(options); Drawable.initialize(options);
mText = options.get(:label); mText = options[:label];
mTouched = options.get(:touched); mTouched = options[:touched];
} }
//! Draw the PIN digit button. //! Draw the PIN digit button.
@@ -182,24 +190,28 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
private var mPin as Lang.String; private var mPin as Lang.String;
private var mEnteredPin as Lang.String; private var mEnteredPin as Lang.String;
private var mConfirmMethod as Method(state as Lang.Boolean) as Void; private var mConfirmMethod as Method(state as Lang.Boolean) as Void;
private var mTimer as Timer.Timer or Null; private var mTimer as Timer.Timer?;
private var mState as Lang.Boolean; private var mState as Lang.Boolean;
private var mFailures as PinFailures; private var mFailures as PinFailures;
private var mToggleMethod as Method(state as Lang.Boolean) as Void?;
private var mView as HomeAssistantPinConfirmationView; private var mView as HomeAssistantPinConfirmationView;
//! Class Constructor //! Class Constructor
//! //!
//! @param callback Method to call on confirmation. //! @param options A dictionary describing the following options:
//! @param state Current state of a toggle button. //! - callback Method to call on confirmation.
//! @param pin PIN to be matched. //! - pin PIN to be matched.
//! @param view PIN confirmation view. //! - state Wanted state of a toggle button.
//! - toggle Optional setEnabled method to untoggle ToggleItem.
//! - view PIN confirmation view.
// //
function initialize( function initialize(options as {
callback as Method(state as Lang.Boolean) as Void, :callback as Method(state as Lang.Boolean) as Void,
state as Lang.Boolean, :pin as Lang.String,
pin as Lang.String, :state as Lang.Boolean,
view as HomeAssistantPinConfirmationView :view as HomeAssistantPinConfirmationView,
) { :toggleMethod as (Method(state as Lang.Boolean) as Void)?,
}) {
BehaviorDelegate.initialize(); BehaviorDelegate.initialize();
mFailures = new PinFailures(); mFailures = new PinFailures();
if (mFailures.isLocked()) { if (mFailures.isLocked()) {
@@ -208,11 +220,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
WatchUi.loadResource($.Rez.Strings.Seconds); WatchUi.loadResource($.Rez.Strings.Seconds);
WatchUi.showToast(msg, {}); WatchUi.showToast(msg, {});
} }
mPin = pin; mPin = options[:pin];
mEnteredPin = ""; mEnteredPin = "";
mConfirmMethod = callback; mConfirmMethod = options[:callback];
mState = state; mState = options[:state];
mView = view; mToggleMethod = options[:toggleMethod];
mView = options[:view];
resetTimer(); resetTimer();
} }
@@ -237,8 +251,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
if (mTimer != null) { if (mTimer != null) {
mTimer.stop(); mTimer.stop();
} }
mConfirmMethod.invoke(mState);
WatchUi.popView(WatchUi.SLIDE_RIGHT); WatchUi.popView(WatchUi.SLIDE_RIGHT);
// Set the toggle, if we have one
if (mToggleMethod != null) {
mToggleMethod.invoke(!mState);
}
mConfirmMethod.invoke(mState);
} else { } else {
error(); error();
} }
@@ -279,6 +298,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
if (mTimer != null) { if (mTimer != null) {
mTimer.stop(); mTimer.stop();
} }
WatchUi.popView(WatchUi.SLIDE_RIGHT); WatchUi.popView(WatchUi.SLIDE_RIGHT);
} }
@@ -304,6 +324,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
goBack(); goBack();
} }
//! Handle the back button (ESC)
//
function onBack() as Lang.Boolean {
goBack();
return true;
}
} }
@@ -315,7 +342,7 @@ class PinFailures {
const STORAGE_KEY_LOCKED as Lang.String = "pin_locked"; const STORAGE_KEY_LOCKED as Lang.String = "pin_locked";
private var mFailures as Lang.Array<Lang.Number>; private var mFailures as Lang.Array<Lang.Number>;
private var mLockedUntil as Lang.Number or Null; private var mLockedUntil as Lang.Number?;
//! Class Constructor //! Class Constructor
// //

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023 // P A Abbey & J D Abbey & Someone0nEarth & vincentezw, 19 November 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -27,9 +27,8 @@ class HomeAssistantService {
//! Class Constructor //! Class Constructor
// //
function initialize() { function initialize() {
if (WatchUi has :showToast) { mHasToast = WatchUi has :showToast;
mHasToast = true;
}
if (Attention has :vibrate) { if (Attention has :vibrate) {
mHasVibrate = true; mHasVibrate = true;
} }
@@ -50,8 +49,8 @@ class HomeAssistantService {
var entity_id; var entity_id;
var exit = false; var exit = false;
if (c != null) { if (c != null) {
entity_id = c.get(:entity_id) as Lang.String; entity_id = c[:entity_id] as Lang.String;
exit = c.get(:exit) as Lang.Boolean; exit = c[:exit] as Lang.Boolean;
} }
// System.println("HomeAssistantService onReturnCall() Response Code: " + responseCode); // System.println("HomeAssistantService onReturnCall() Response Code: " + responseCode);
// System.println("HomeAssistantService onReturnCall() Response Data: " + data); // System.println("HomeAssistantService onReturnCall() Response Data: " + data);
@@ -126,13 +125,28 @@ class HomeAssistantService {
// //
function call( function call(
service as Lang.String, service as Lang.String,
data as Lang.Dictionary or Null, data as Lang.Dictionary?,
exit as Lang.Boolean exit as Lang.Boolean
) as Void { ) 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."); // System.println("HomeAssistantService call(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); 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."); // System.println("HomeAssistantService call(): No Internet connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else { } else {

View File

@@ -0,0 +1,139 @@
//-----------------------------------------------------------------------------------
//
// 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 & vincentezw, 22 July 2025
//
//-----------------------------------------------------------------------------------
using Toybox.Communications;
using Toybox.Lang;
//! SyncDelegate to execute single command via POST request to the Home Assistant
//! server.
//
class HomeAssistantSyncDelegate extends Communications.SyncDelegate {
//! Retain the last synchronisation error.
private static var syncError as Lang.String?;
//! Class Constructor
//
public function initialize() {
SyncDelegate.initialize();
}
//! Called by the system to determine if a synchronisation is needed
//
public function isSyncNeeded() as Lang.Boolean {
return true;
}
//! Called by the system when starting a bulk synchronisation.
//
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 the Home Assistant server with a given payload and URL, and calls
//! haCallback.
//!
//! @param url URL for the API call.
//! @param data Data to be supplied to the API call.
//
private function performRequest(url as Lang.String, data as Lang.Dictionary?) {
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
//!
//! @param responseCode Response code.
//! @param data Response data.
//
public function haCallback(code as Lang.Number, data as 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);
}
}

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld & vincentezw, 31 October 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -21,11 +21,11 @@ using Toybox.Graphics;
// //
class HomeAssistantTapMenuItem extends HomeAssistantMenuItem { class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
private var mHomeAssistantService as HomeAssistantService; private var mHomeAssistantService as HomeAssistantService;
private var mService as Lang.String or Null; private var mService as Lang.String?;
private var mConfirm as Lang.Boolean; private var mConfirm as Lang.Boolean;
private var mExit as Lang.Boolean; private var mExit as Lang.Boolean;
private var mPin as Lang.Boolean; private var mPin as Lang.Boolean;
private var mData as Lang.Dictionary or Null; private var mData as Lang.Dictionary?;
//! Class Constructor //! Class Constructor
//! //!
@@ -44,32 +44,32 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String, template as Lang.String,
service as Lang.String or Null, service as Lang.String?,
data as Lang.Dictionary or Null, data as Lang.Dictionary?,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment, :alignment as WatchUi.MenuItem.Alignment,
:icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol, :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol,
:exit as Lang.Boolean, :exit as Lang.Boolean,
:confirm as Lang.Boolean, :confirm as Lang.Boolean,
:pin as Lang.Boolean :pin as Lang.Boolean
} or Null, }?,
haService as HomeAssistantService haService as HomeAssistantService
) { ) {
HomeAssistantMenuItem.initialize( HomeAssistantMenuItem.initialize(
label, label,
template, template,
{ {
:alignment => options.get(:alignment), :alignment => options[:alignment],
:icon => options.get(:icon) :icon => options[:icon]
} }
); );
mHomeAssistantService = haService; mHomeAssistantService = haService;
mService = service; mService = service;
mData = data; mData = data;
mExit = options.get(:exit); mExit = options[:exit];
mConfirm = options.get(:confirm); mConfirm = options[:confirm];
mPin = options.get(:acospin); mPin = options[:pin];
} }
//! Call a Home Assistant service only after checks have been done for confirmation or PIN entry. //! Call a Home Assistant service only after checks have been done for confirmation or PIN entry.
@@ -82,16 +82,43 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
var pinConfirmationView = new HomeAssistantPinConfirmationView(); var pinConfirmationView = new HomeAssistantPinConfirmationView();
WatchUi.pushView( WatchUi.pushView(
pinConfirmationView, pinConfirmationView,
new HomeAssistantPinConfirmationDelegate(method(:onConfirm), false, pin, pinConfirmationView), new HomeAssistantPinConfirmationDelegate({
:callback => method(:onConfirm),
:pin => pin,
:state => false,
:view => pinConfirmationView,
}),
WatchUi.SLIDE_IMMEDIATE WatchUi.SLIDE_IMMEDIATE
); );
} }
} else if (mConfirm) { } else if (mConfirm) {
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( WatchUi.pushView(
new HomeAssistantConfirmation(), dialog,
new HomeAssistantConfirmationDelegate(method(:onConfirm), false), 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 WatchUi.SLIDE_IMMEDIATE
); );
}
} else { } else {
onConfirm(false); onConfirm(false);
} }

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld & vincentezw, 31 October 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -39,14 +39,14 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String, template as Lang.String,
data as Lang.Dictionary or Null, data as Lang.Dictionary?,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment, :alignment as WatchUi.MenuItem.Alignment,
:icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol, :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol,
:exit as Lang.Boolean, :exit as Lang.Boolean,
:confirm as Lang.Boolean, :confirm as Lang.Boolean,
:pin as Lang.Boolean :pin as Lang.Boolean
} or Null }?
) { ) {
WatchUi.ToggleMenuItem.initialize( WatchUi.ToggleMenuItem.initialize(
label, label,
@@ -54,8 +54,8 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
null, null,
false, false,
{ {
:alignment => options.get(:alignment), :alignment => options[:alignment],
:icon => options.get(:icon) :icon => options[:icon]
} }
); );
if (Attention has :vibrate) { if (Attention has :vibrate) {
@@ -63,9 +63,9 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
} }
mData = data; mData = data;
mTemplate = template; mTemplate = template;
mExit = options.get(:exit); mExit = options[:exit];
mConfirm = options.get(:confirm); mConfirm = options[:confirm];
mPin = options.get(:pin); mPin = options[:pin];
} }
//! Set the state of a toggle menu item. //! Set the state of a toggle menu item.
@@ -82,17 +82,17 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
//! Return the menu item's template. //! Return the menu item's template.
//! //!
//! @return A string with the menu item's template definition. //! @return A string with the menu item's template definition (or null).
// //
function getTemplate() as Lang.String or Null { function getTemplate() as Lang.String? {
return mTemplate; return mTemplate;
} }
//! Return a toggle menu item's state template. //! Return a toggle menu item's state template.
//! //!
//! @return A string with the menu item's template definition. //! @return A string with the menu item's template definition (or null).
// //
function getToggleTemplate() as Lang.String or Null { function getToggleTemplate() as Lang.String? {
return "{{states('" + mData.get("entity_id") + "')}}"; return "{{states('" + mData.get("entity_id") + "')}}";
} }
@@ -198,16 +198,8 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
case 200: case 200:
// System.println("HomeAssistantToggleMenuItem onReturnSetState(): Service executed."); // System.println("HomeAssistantToggleMenuItem onReturnSetState(): Service executed.");
getApp().forceStatusUpdates(); getApp().forceStatusUpdates();
var state;
var d = data as Lang.Array; var d = data as Lang.Array;
for(var i = 0; i < d.size(); i++) { setToggleStateWithData(d);
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();
}
}
status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String; status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String;
break; 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 actually 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. //! Set the state of the toggle menu item.
//! //!
//! @param s Boolean indicating the desired state of the toggle switch. //! @param s Boolean indicating the desired state of the toggle switch.
// //
function setState(s as Lang.Boolean) as Void { function setState(s as Lang.Boolean) as Void {
// Toggle the UI back, we'll wait for confirmation from the Home Assistant var phoneConnected = System.getDeviceSettings().phoneConnected;
setEnabled(!isEnabled()); var internetAvailable = System.getDeviceSettings().connectionAvailable;
if (! System.getDeviceSettings().phoneConnected) {
if (! phoneConnected && ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantToggleMenuItem getState(): No Phone connection, skipping API call."); // System.println("HomeAssistantToggleMenuItem getState(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String); 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."); // System.println("HomeAssistantToggleMenuItem getState(): No Internet connection, skipping API call.");
// Toggle the UI back // Toggle the UI back
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else { } else {
// Updated SDK and got a new error
// ERROR: venu: Cannot find symbol ':substring' on type 'PolyType<Null or $.Toybox.Lang.Object>'.
var id = mData.get("entity_id") as Lang.String; var id = mData.get("entity_id") as Lang.String;
var url = Settings.getApiUrl() + "/services/"; var url = getUrl(id, s);
if (s) {
url = url + id.substring(0, id.find(".")) + "/turn_on"; if ((! phoneConnected || ! internetAvailable) && Settings.getWifiLteExecutionEnabled()) {
} else { // Undo the toggle
url = url + id.substring(0, id.find(".")) + "/turn_off"; setEnabled(!isEnabled());
wifiPrompt(s);
return;
} }
// System.println("HomeAssistantToggleMenuItem setState() URL = " + url); // System.println("HomeAssistantToggleMenuItem setState() URL = " + url);
// System.println("HomeAssistantToggleMenuItem setState() entity_id = " + id); // System.println("HomeAssistantToggleMenuItem setState() entity_id = " + id);
Communications.makeWebRequest( Communications.makeWebRequest(
@@ -275,21 +291,45 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
function callService(b as Lang.Boolean) as Void { function callService(b as Lang.Boolean) as Void {
var hasTouchScreen = System.getDeviceSettings().isTouchScreen; var hasTouchScreen = System.getDeviceSettings().isTouchScreen;
if (mPin && hasTouchScreen) { if (mPin && hasTouchScreen) {
// Undo the toggle
setEnabled(!isEnabled());
var pin = Settings.getPin(); var pin = Settings.getPin();
if (pin != null) { if (pin != null) {
var pinConfirmationView = new HomeAssistantPinConfirmationView(); var pinConfirmationView = new HomeAssistantPinConfirmationView();
WatchUi.pushView( WatchUi.pushView(
pinConfirmationView, 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 WatchUi.SLIDE_IMMEDIATE
); );
} }
} else if (mConfirm) { } else if (mConfirm) {
// 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( WatchUi.pushView(
new HomeAssistantConfirmation(), confirmationView,
new HomeAssistantConfirmationDelegate(method(:onConfirm), b), new HomeAssistantConfirmationDelegate({
:callback => method(:onConfirm),
:confirmationView => confirmationView,
:state => b,
:toggleMethod => method(:setEnabled),
}),
WatchUi.SLIDE_IMMEDIATE WatchUi.SLIDE_IMMEDIATE
); );
}
} else { } else {
onConfirm(b); onConfirm(b);
} }
@@ -303,4 +343,44 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
setState(b); 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 static 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;
}
} }

View File

@@ -30,7 +30,7 @@ class HomeAssistantView extends WatchUi.Menu2 {
options as { options as {
:focus as Lang.Number, :focus as Lang.Number,
:icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol
} or Null }?
) { ) {
if (options == null) { if (options == null) {
options = { :title => definition.get("title") as Lang.String }; options = { :title => definition.get("title") as Lang.String };
@@ -42,17 +42,17 @@ class HomeAssistantView extends WatchUi.Menu2 {
var items = definition.get("items") as Lang.Array<Lang.Dictionary>; var items = definition.get("items") as Lang.Array<Lang.Dictionary>;
for (var i = 0; i < items.size(); i++) { for (var i = 0; i < items.size(); i++) {
if (items[i] instanceof(Lang.Dictionary)) { if (items[i] instanceof(Lang.Dictionary)) {
var type = items[i].get("type") as Lang.String or Null; var type = items[i].get("type") as Lang.String?;
var name = items[i].get("name") as Lang.String or Null; var name = items[i].get("name") as Lang.String?;
var content = items[i].get("content") as Lang.String or Null; var content = items[i].get("content") as Lang.String?;
var entity = items[i].get("entity") as Lang.String or Null; var entity = items[i].get("entity") as Lang.String?;
var tap_action = items[i].get("tap_action") as Lang.Dictionary or Null; var tap_action = items[i].get("tap_action") as Lang.Dictionary?;
var service = items[i].get("service") as Lang.String or Null; // Deprecated schema var service = items[i].get("service") as Lang.String?; // Deprecated schema
var confirm = false as Lang.Boolean or Null; var confirm = false as Lang.Boolean?;
var pin = false as Lang.Boolean or Null; var pin = false as Lang.Boolean?;
var data = null as Lang.Dictionary or Null; var data = null as Lang.Dictionary?;
var enabled = true as Lang.Boolean or Null; var enabled = true as Lang.Boolean?;
var exit = false as Lang.Boolean or Null; var exit = false as Lang.Boolean?;
if (items[i].get("enabled") != null) { if (items[i].get("enabled") != null) {
enabled = items[i].get("enabled"); // Optional enabled = items[i].get("enabled"); // Optional
} }
@@ -207,7 +207,7 @@ class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate {
mTimer = getApp().getQuitTimer(); mTimer = getApp().getQuitTimer();
} }
//! Back button event //! Handle the back button (ESC)
// //
function onBack() { function onBack() {
mTimer.reset(); mTimer.reset();

View File

@@ -9,7 +9,7 @@
// tested on a Venu 2 device. The source code is provided at: // tested on a Venu 2 device. The source code is provided at:
// https://github.com/house-of-abbey/GarminHomeAssistant. // https://github.com/house-of-abbey/GarminHomeAssistant.
// //
// P A Abbey & J D Abbey, SomeoneOnEarth & moesterheld, 23 November 2023 // P A Abbey & J D Abbey, SomeoneOnEarth & moesterheld & vincentezw, 23 November 2023
// //
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
@@ -35,13 +35,14 @@ class Settings {
private static var mCacheConfig as Lang.Boolean = false; private static var mCacheConfig as Lang.Boolean = false;
private static var mClearCache as Lang.Boolean = false; private static var mClearCache as Lang.Boolean = false;
private static var mVibrate as Lang.Boolean = false; private static var mVibrate as Lang.Boolean = false;
private static var mWifiLteExecution as Lang.Boolean = false;
//! seconds //! seconds
private static var mAppTimeout as Lang.Number = 0; private static var mAppTimeout as Lang.Number = 0;
//! seconds //! seconds
private static var mPollDelay as Lang.Number = 0; private static var mPollDelay as Lang.Number = 0;
//! seconds //! seconds
private static var mConfirmTimeout as Lang.Number = 3; private static var mConfirmTimeout as Lang.Number = 3;
private static var mPin as Lang.String or Null = "0000"; private static var mPin as Lang.String? = "0000";
private static var mMenuAlignment as Lang.Number = WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT; private static var mMenuAlignment as Lang.Number = WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT;
private static var mIsSensorsLevelEnabled as Lang.Boolean = false; private static var mIsSensorsLevelEnabled as Lang.Boolean = false;
//! minutes //! minutes
@@ -49,7 +50,7 @@ class Settings {
private static var mIsApp as Lang.Boolean = false; private static var mIsApp as Lang.Boolean = false;
private static var mHasService as Lang.Boolean = false; private static var mHasService as Lang.Boolean = false;
//! Must keep the object so it doesn't get garbage collected. //! Must keep the object so it doesn't get garbage collected.
private static var mWebhookManager as WebhookManager or Null; private static var mWebhookManager as WebhookManager?;
//! Called on application start and then whenever the settings are changed. //! Called on application start and then whenever the settings are changed.
// //
@@ -61,6 +62,7 @@ class Settings {
mConfigUrl = Properties.getValue("config_url"); mConfigUrl = Properties.getValue("config_url");
mCacheConfig = Properties.getValue("cache_config"); mCacheConfig = Properties.getValue("cache_config");
mClearCache = Properties.getValue("clear_cache"); mClearCache = Properties.getValue("clear_cache");
mWifiLteExecution = Properties.getValue("wifi_lte_execution");
mVibrate = Properties.getValue("enable_vibration"); mVibrate = Properties.getValue("enable_vibration");
mAppTimeout = Properties.getValue("app_timeout"); mAppTimeout = Properties.getValue("app_timeout");
mPollDelay = Properties.getValue("poll_delay_combined"); mPollDelay = Properties.getValue("poll_delay_combined");
@@ -191,6 +193,18 @@ class Settings {
Properties.setValue("clear_cache", mClearCache); Properties.setValue("clear_cache", mClearCache);
} }
//! Get the value of the Wi-Fi/LTE toggle in settings.
//!
//! @return The state of the toggle.
//
static function getWifiLteExecutionEnabled() as Lang.Boolean {
// Wi-Fi/LTE sync execution on a cached menu
if (!mCacheConfig) {
return false;
}
return mWifiLteExecution;
}
//! Get the vibration Boolean option supplied as part of the Settings. //! Get the vibration Boolean option supplied as part of the Settings.
//! //!
//! @return Boolean for whether vibration is enabled. //! @return Boolean for whether vibration is enabled.
@@ -227,7 +241,7 @@ class Settings {
//! //!
//! @return The menu item security PIN. //! @return The menu item security PIN.
// //
static function getPin() as Lang.String or Null { static function getPin() as Lang.String? {
return mPin; return mPin;
} }
@@ -235,7 +249,7 @@ class Settings {
//! //!
//! @return The validated 4 digit string. //! @return The validated 4 digit string.
// //
private static function validatePin() as Lang.String or Null { private static function validatePin() as Lang.String? {
var pin = Properties.getValue("pin"); var pin = Properties.getValue("pin");
if (pin.toNumber() == null || pin.length() != 4) { if (pin.toNumber() == null || pin.length() != 4) {
return null; return null;

View File

@@ -71,7 +71,7 @@ class WebhookManager {
case 200: case 200:
case 201: case 201:
var id = data.get("webhook_id") as Lang.String or Null; var id = data.get("webhook_id") as Lang.String?;
if (id != null) { if (id != null) {
Settings.setWebhookId(id); Settings.setWebhookId(id);
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Registering first sensor: Battery Level"); // System.println("WebhookManager onReturnRegisterWebhookSensor(): Registering first sensor: Battery Level");
@@ -177,7 +177,7 @@ class WebhookManager {
case 201: case 201:
if (data instanceof Lang.Dictionary) { if (data instanceof Lang.Dictionary) {
var d = data as Lang.Dictionary; var d = data as Lang.Dictionary;
var b = d.get("success") as Lang.Boolean or Null; var b = d.get("success") as Lang.Boolean?;
if (b != null and b != false) { if (b != null and b != false) {
if (sensors.size() == 0) { if (sensors.size() == 0) {
getApp().startUpdates(); getApp().startUpdates();

View File

@@ -0,0 +1,159 @@
//-----------------------------------------------------------------------------------
//
// 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 & vincentezw, 22 July 2025
//
//-----------------------------------------------------------------------------------
using Toybox.WatchUi;
using Toybox.System;
using Toybox.Communications;
using Toybox.Lang;
using Toybox.Timer;
//! Delegate to respond to a confirmation to execute an API request via bulk
//! synchronisation.
//
class WifiLteExecutionConfirmDelegate extends WatchUi.ConfirmationDelegate {
public static var mCommandData as {
:type as Lang.String,
:service as Lang.String?,
:data as Lang.Dictionary?,
:url as Lang.String?,
:id as Lang.Number?,
:exit as Lang.Boolean
};
private static var mTimer as Timer.Timer?;
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 execution
//!
//! @param options A dictionary describing the command to be executed:<br>
//! `{`<br>
//! &emsp; `:type: as Lang.String,` // The command type, either `"service"` or `"entity"`.<br>
//! &emsp; `:service: as Lang.String?,` // (For type `"service"`) The Home Assistant service to call (e.g., "light.turn_on").<br>
//! &emsp; `:url: as Lang.Dictionary?,` // (For type `"entity"`) The full Home Assistant entity API URL.<br>
//! &emsp; `:callback: as Lang.String?,` // (For type `"entity"`) A callback method (Method<data as Dictionary>) to handle the response.<br>
//! &emsp; `:data: as Lang.Method?,` // (Optional) A dictionary of data to send with the request.<br>
//! &emsp; `:exit: as Lang.Boolean,` // Boolean: if set to true: exit after running command.<br>
//! &rbrace;<br>
//! @param view The Confirmation view the delegate is active for
//
function initialize(
cOptions as {
:type as Lang.String,
:service as Lang.String?,
:data as Lang.Dictionary?,
:url as Lang.String?,
:callback as Lang.Method?,
: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) {
if (connectionInfo[keys[i]].state != System.CONNECTION_STATE_NOT_INITIALIZED) {
possibleConnection = true;
break;
}
}
}
if (possibleConnection) {
if (Communications has :startSync2) {
Communications.startSync2({
:message => WatchUi.loadResource($.Rez.Strings.WifiLteExecutionTitle) as Lang.String
});
} 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);
}
}
}

View File

@@ -37,7 +37,8 @@
margin-left: 0.5em; margin-left: 0.5em;
filter: grayscale() invert(); filter: grayscale() invert();
} }
.template, .info { .template,
.info {
background-image: url(../resources-icons-48/info_type.svg); background-image: url(../resources-icons-48/info_type.svg);
background-size: contain; background-size: contain;
margin-left: 0.5em; margin-left: 0.5em;
@@ -63,6 +64,13 @@
margin-left: 0.5em; margin-left: 0.5em;
filter: grayscale() invert(); filter: grayscale() invert();
} }
.mdi {
margin-left: 0.5em;
}
.disabled {
opacity: 0.5;
}
:root { :root {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
@@ -285,6 +293,10 @@
background-color: var(--ctp-mocha-overlay1); background-color: var(--ctp-mocha-overlay1);
} }
</style> </style>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css"
defer />
</head> </head>
<body> <body>
<div id="settings"> <div id="settings">
@@ -441,9 +453,10 @@ http:
</div> </div>
</dialog> </dialog>
<script src="https://www.unpkg.com/monaco-editor@0.45.0/min/vs/loader.js"></script> <script src="https://www.unpkg.com/monaco-editor@0.52.2/min/vs/loader.js"></script>
<script src="https://www.unpkg.com/json-ast-comments@1.1.1/lib/json.js"></script> <script src="https://www.unpkg.com/json-ast-comments@1.1.1/lib/json.js"></script>
<script src="https://www.unpkg.com/toastify-js@1.12.0/src/toastify.js"></script> <script src="https://www.unpkg.com/toastify-js@1.12.0/src/toastify.js"></script>
<script src="https://code.iconify.design/1/1.0.6/iconify.min.js"></script>
<script type="module" src="./main.js"></script> <script type="module" src="./main.js"></script>
</body> </body>
</html> </html>

View File

@@ -11,7 +11,7 @@ let api_token = localStorage.getItem('api_token') ?? '';
/** /**
* Get all entities in HomeAssistant. * Get all entities in HomeAssistant.
* @returns {Promise<Record<string, string>>} [id, name] * @returns {Promise<Record<string, { name: string, icon?: string }>>} [id, name]
*/ */
async function get_entities() { async function get_entities() {
try { try {
@@ -21,7 +21,7 @@ async function get_entities() {
Authorization: `Bearer ${api_token}`, Authorization: `Bearer ${api_token}`,
}, },
mode: 'cors', mode: 'cors',
body: `{"template":"[{% for entity in states %}[\\"{{ entity.entity_id }}\\",\\"{{ entity.name }}\\"]{% if not loop.last %},{% endif %}{% endfor %}]"}`, body: `{"template":"[{% for entity in states %}[\\"{{ entity.entity_id }}\\",\\"{{ entity.name }}\\",\\"{{ entity.attributes.icon }}\\"]{% if not loop.last %},{% endif %}{% endfor %}]"}`,
}); });
if (res.status == 401 || res.status == 403) { if (res.status == 401 || res.status == 403) {
document.querySelector('#api_token').classList.add('invalid'); document.querySelector('#api_token').classList.add('invalid');
@@ -29,8 +29,16 @@ async function get_entities() {
} }
document.querySelector('#api_url').classList.remove('invalid'); document.querySelector('#api_url').classList.remove('invalid');
document.querySelector('#api_token').classList.remove('invalid'); document.querySelector('#api_token').classList.remove('invalid');
return Object.fromEntries(await res.json()); const data = {};
} catch { for (const [id, name, icon] of await res.json()) {
data[id] = { name };
if (icon !== '') {
data[id].icon = icon;
}
}
return data;
} catch (e) {
console.error('Error fetching entities:', e);
document.querySelector('#api_url').classList.add('invalid'); document.querySelector('#api_url').classList.add('invalid');
return {}; return {};
} }
@@ -57,7 +65,8 @@ async function get_devices() {
document.querySelector('#api_url').classList.remove('invalid'); document.querySelector('#api_url').classList.remove('invalid');
document.querySelector('#api_token').classList.remove('invalid'); document.querySelector('#api_token').classList.remove('invalid');
return Object.fromEntries(await res.json()); return Object.fromEntries(await res.json());
} catch { } catch (e) {
console.error('Error fetching devices:', e);
document.querySelector('#api_url').classList.add('invalid'); document.querySelector('#api_url').classList.add('invalid');
return {}; return {};
} }
@@ -84,7 +93,8 @@ async function get_areas() {
document.querySelector('#api_url').classList.remove('invalid'); document.querySelector('#api_url').classList.remove('invalid');
document.querySelector('#api_token').classList.remove('invalid'); document.querySelector('#api_token').classList.remove('invalid');
return Object.fromEntries(await res.json()); return Object.fromEntries(await res.json());
} catch { } catch (e) {
console.error('Error fetching areas:', e);
document.querySelector('#api_url').classList.add('invalid'); document.querySelector('#api_url').classList.add('invalid');
return {}; return {};
} }
@@ -119,7 +129,8 @@ async function get_services() {
} }
} }
return services; return services;
} catch { } catch (e) {
console.error('Error fetching services:', e);
document.querySelector('#api_url').classList.add('invalid'); document.querySelector('#api_url').classList.add('invalid');
return []; return [];
} }
@@ -370,6 +381,9 @@ async function generate_schema(entities, devices, areas, services, schema) {
confirm: { confirm: {
$ref: '#/$defs/confirm', $ref: '#/$defs/confirm',
}, },
pin: {
$ref: '#/$defs/pin',
},
data: { data: {
type: 'object', type: 'object',
properties: {}, properties: {},
@@ -724,6 +738,7 @@ require(['vs/editor/editor.main'], async () => {
var decorations = editor.createDecorationsCollection([]); var decorations = editor.createDecorationsCollection([]);
/** @type {monaco.editor.IMarkerData[]} */
let markers = []; let markers = [];
const renderTemplate = editor.addCommand( const renderTemplate = editor.addCommand(
@@ -905,6 +920,7 @@ require(['vs/editor/editor.main'], async () => {
const ast = json.parse(model.getValue()); const ast = json.parse(model.getValue());
const data = JSON.parse(model.getValue()); const data = JSON.parse(model.getValue());
markers = []; markers = [];
/** @type {monaco.editor.IModelDeltaDecoration[]} */
const glyphs = []; const glyphs = [];
async function testToggle(range, entity) { async function testToggle(range, entity) {
const res = await fetch(api_url + '/states/' + entity, { const res = await fetch(api_url + '/states/' + entity, {
@@ -983,8 +999,10 @@ require(['vs/editor/editor.main'], async () => {
* @param {import('json-ast-comments').JsonAst | * @param {import('json-ast-comments').JsonAst |
* import('json-ast-comments').JsonProperty} node * import('json-ast-comments').JsonProperty} node
* @param {string[]} path * @param {string[]} path
* @param {import('json-ast-comments').JsonAst |
* import('json-ast-comments').JsonProperty | null} parent
*/ */
function recurse(node, path) { function recurse(node, path, parent = null) {
if (node.type === 'property') { if (node.type === 'property') {
if (node.key[0].value === 'content') { if (node.key[0].value === 'content') {
templates.push([ templates.push([
@@ -1010,11 +1028,39 @@ require(['vs/editor/editor.main'], async () => {
} }
trim++; trim++;
markers.push({ markers.push({
message: entities[node.value[0].value] ?? 'Entity not found', message: entities[node.value[0].value].name ?? 'Entity not found',
severity: monaco.MarkerSeverity.Hint, severity: monaco.MarkerSeverity.Hint,
...range, ...range,
startColumn: trim, startColumn: trim,
}); });
glyphs.push({
range,
options: {
isWholeLine: true,
glyphMarginClassName:
'mdi ' +
entities[node.value[0].value]?.icon?.replace(':', '-'),
},
});
} else if (
node.key[0].value === 'enabled' &&
node.value[0].type === 'boolean' &&
!node.value[0].value
) {
glyphs.push({
range: {
startLineNumber: parent.members[0].key[0].range.start.line + 1,
startColumn: 0,
endLineNumber:
parent.members[parent.members.length - 1].value[0].range.end
.line + 1,
endColumn: 10000,
},
options: {
isWholeLine: true,
inlineClassName: 'disabled',
},
});
} else if (node.key[0].value === 'type') { } else if (node.key[0].value === 'type') {
if (node.value[0].value === 'toggle') { if (node.value[0].value === 'toggle') {
toggles.push([ toggles.push([
@@ -1041,15 +1087,15 @@ require(['vs/editor/editor.main'], async () => {
}); });
} }
} else { } else {
recurse(node.value[0], [...path, node.key[0].value]); recurse(node.value[0], [...path, node.key[0].value], node);
} }
} else if (node.type === 'array') { } else if (node.type === 'array') {
for (let i = 0; i < node.members.length; i++) { for (let i = 0; i < node.members.length; i++) {
recurse(node.members[i], [...path, i]); recurse(node.members[i], [...path, i], node);
} }
} else if (node.type === 'object') { } else if (node.type === 'object') {
for (let member of node.members) { for (let member of node.members) {
recurse(member, path); recurse(member, path, node);
} }
} }
} }

View File

@@ -10,10 +10,11 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@types/toastify-js": "^1.12.3", "@types/toastify-js": "^1.12.4",
"@vscode/webview-ui-toolkit": "1.4.0", "@vscode/webview-ui-toolkit": "1.4.0",
"json-ast-comments": "1.1.1", "json-ast-comments": "1.1.1",
"monaco-editor": "0.45.0", "monaco-editor": "0.52.2",
"prettier": "^3.6.2",
"serve": "^14.2.1" "serve": "^14.2.1"
} }
} }

25
web/pnpm-lock.yaml generated
View File

@@ -6,8 +6,8 @@ settings:
devDependencies: devDependencies:
'@types/toastify-js': '@types/toastify-js':
specifier: ^1.12.3 specifier: ^1.12.4
version: 1.12.3 version: 1.12.4
'@vscode/webview-ui-toolkit': '@vscode/webview-ui-toolkit':
specifier: 1.4.0 specifier: 1.4.0
version: 1.4.0(react@18.2.0) version: 1.4.0(react@18.2.0)
@@ -15,8 +15,11 @@ devDependencies:
specifier: 1.1.1 specifier: 1.1.1
version: 1.1.1 version: 1.1.1
monaco-editor: monaco-editor:
specifier: 0.45.0 specifier: 0.52.2
version: 0.45.0 version: 0.52.2
prettier:
specifier: ^3.6.2
version: 3.6.2
serve: serve:
specifier: ^14.2.1 specifier: ^14.2.1
version: 14.2.1 version: 14.2.1
@@ -52,8 +55,8 @@ packages:
exenv-es6: 1.1.1 exenv-es6: 1.1.1
dev: true dev: true
/@types/toastify-js@1.12.3: /@types/toastify-js@1.12.4:
resolution: {integrity: sha512-9RjLlbAHMSaae/KZNHGv19VG4gcLIm3YjvacCXBtfMfYn26h76YP5oxXI8k26q4iKXCB9LNfv18lsoS0JnFPTg==} resolution: {integrity: sha512-zfZHU4tKffPCnZRe7pjv/eFKzTVHozKewFCKaCjZ4gFinKgJRz/t0bkZiMCXJxPhv/ZoeDGNOeRD09R0kQZ/nw==}
dev: true dev: true
/@vscode/webview-ui-toolkit@1.4.0(react@18.2.0): /@vscode/webview-ui-toolkit@1.4.0(react@18.2.0):
@@ -415,8 +418,8 @@ packages:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
dev: true dev: true
/monaco-editor@0.45.0: /monaco-editor@0.52.2:
resolution: {integrity: sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==} resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
dev: true dev: true
/ms@2.0.0: /ms@2.0.0:
@@ -460,6 +463,12 @@ packages:
resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==} resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==}
dev: true dev: true
/prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
dev: true
/punycode@1.4.1: /punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
dev: true dev: true