Compare commits

...

13 Commits

Author SHA1 Message Date
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
17 changed files with 713 additions and 108 deletions

View File

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

View File

@@ -96,4 +96,9 @@
-->
<property id="webhook_id" type="string"></property>
<!--
Enables the SyncDelegate and prompt to send a command over Wifi/LTE.
This will only show when not connected to the user's phone.
-->
<property id="wifi_lte_execution" type="boolean">false</property>
</properties>

View File

@@ -116,4 +116,13 @@
>
<settingConfig type="alphaNumeric" readonly="true" />
</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>
</settings>

View File

@@ -31,7 +31,9 @@
<string id="NoInternet">No Internet connection.</string>
<string id="NoJson">No JSON returned from HTTP request.</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="TimedOut">Request timed out</string>
<string id="PinInputLocked">PIN input locked for</string>
<string id="PotentialError">Potential Error</string>
<string id="Seconds">seconds</string>
@@ -42,6 +44,10 @@
<string id="UnhandledHttpErr">HTTP request returned error code = </string>
<string id="WebhookFailed">Failed to register Webhook</string>
<string id="WrongPin">Wrong PIN</string>
<string id="WifiLteNotAvailable">No Wifi or LTE available</string>
<string id="WifiLtePrompt">Execute over Wifi/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. -->
<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="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="WifiLteExecution">Wifi/LTE execution mode.</string>
<string id="WifiLteExecutionEnable">Enable executing commands over Wifi/LTE.</string>
<string id="WifiLteExecutionDescription">Allows the app to start without phone connection (when menu is cached), and prompt to execute command over Wifi/LTE.</string>
</strings>

View File

@@ -14,6 +14,7 @@
//-----------------------------------------------------------------------------------
using Toybox.Application;
using Toybox.Communications;
using Toybox.Lang;
using Toybox.WatchUi;
using Toybox.System;
@@ -25,6 +26,7 @@ using Toybox.Timer;
(:glance, :background)
class HomeAssistantApp extends Application.AppBase {
private var mApiStatus as Lang.String or Null;
private var mHasToast as Lang.Boolean = false;
private var mMenuStatus as Lang.String or Null;
private var mHaMenu as HomeAssistantView or Null;
private var mGlanceTemplate as Lang.String or Null = null;
@@ -38,6 +40,9 @@ class HomeAssistantApp extends Application.AppBase {
private var mIsApp as Lang.Boolean = false; // Or Widget
private var mUpdating as Lang.Boolean = false; // Don't start a second chain of updates
private var mTemplates as Lang.Dictionary = {};
private var mNotifiedNoBle as Lang.Boolean = false;
private const wifiPollDelayMs = 2000;
//! Class Constructor
//
@@ -105,6 +110,7 @@ class HomeAssistantApp extends Application.AppBase {
mUpdateTimer = new Timer.Timer();
mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
mHasToast = WatchUi has :showToast;
Settings.update();
if (Settings.getApiKey().length() == 0) {
@@ -122,11 +128,14 @@ class HomeAssistantApp extends Application.AppBase {
} else if (Settings.getPin() == null) {
// System.println("HomeAssistantApp getInitialView(): Invalid PIN in application settings.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.SettingsPinError) as Lang.String);
} else if (! System.getDeviceSettings().phoneConnected) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection, skipping API call.");
} else if (! System.getDeviceSettings().phoneConnected and Settings.getWifiLteExecutionEnabled() and ! hasCachedMenu()) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection, no cached menu, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhoneNoCache) as Lang.String);
} else if (! System.getDeviceSettings().phoneConnected and ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantApp getInitialView(): No Phone connection and wifi disabled, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable) {
// System.println("HomeAssistantApp getInitialView(): No Internet connection, skipping API call.");
} else if (! System.getDeviceSettings().connectionAvailable and ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantApp getInitialView(): No Internet connection and wifi disabled, skipping API call.");
return ErrorView.create(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else {
var isCached = fetchMenuConfig();
@@ -227,6 +236,20 @@ class HomeAssistantApp extends Application.AppBase {
WatchUi.requestUpdate();
}
//! Can we use the cached menu?
//!
//! @return Return true if there's a menu in cache, and if the user has enabled the cache and
//! has not requested to have the cache busted.
//
function hasCachedMenu() as Lang.Boolean {
if (Settings.getClearCache() || !Settings.getCacheConfig()) {
return false;
}
var menu = Storage.getValue("menu") as Lang.Dictionary;
return menu != null;
}
//! Fetch the menu configuration over HTTPS, which might be locally cached.
//!
//! @return Return true if the menu came from the cache, otherwise false. This is because fetching
@@ -246,22 +269,22 @@ class HomeAssistantApp extends Application.AppBase {
Settings.unsetClearCache();
}
if (menu == null) {
if (! System.getDeviceSettings().phoneConnected) {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var internetAvailable = System.getDeviceSettings().connectionAvailable;
if (! phoneConnected or ! internetAvailable) {
var errorRez = $.Rez.Strings.NoPhone;
if (Settings.getWifiLteExecutionEnabled()) {
errorRez = $.Rez.Strings.NoPhoneNoCache;
} else if (! internetAvailable) {
errorRez = $.Rez.Strings.Unavailable;
}
// System.println("HomeAssistantApp fetchMenuConfig(): No Phone connection, skipping API call.");
if (mIsGlance) {
WatchUi.requestUpdate();
} else {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
ErrorView.show(WatchUi.loadResource(errorRez) as Lang.String);
}
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
} else if (! System.getDeviceSettings().connectionAvailable) {
// System.println("HomeAssistantApp fetchMenuConfig(): No Internet connection, skipping API call.");
if (mIsGlance) {
WatchUi.requestUpdate();
} else {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
}
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
mMenuStatus = WatchUi.loadResource(errorRez) as Lang.String;
} else {
Communications.makeWebRequest(
Settings.getConfigUrl(),
@@ -301,11 +324,11 @@ class HomeAssistantApp extends Application.AppBase {
//! Start the periodic menu updates for as long as the application is running.
//
function startUpdates() {
function startUpdates() as Void {
if (mHaMenu != null and !mUpdating) {
// Start the continuous update process that continues for as long as the application is running.
updateMenuItems();
mUpdating = true;
updateMenuItems();
}
}
@@ -410,15 +433,50 @@ class HomeAssistantApp extends Application.AppBase {
//! Construct the GET request to update all menu items.
//
function updateMenuItems() as Void {
if (! System.getDeviceSettings().phoneConnected) {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var connectionAvailable = System.getDeviceSettings().connectionAvailable;
// In Wifi/LTE execution mode, we should not show an error page but use a toast instead.
if (Settings.getWifiLteExecutionEnabled() && (! phoneConnected || ! connectionAvailable)) {
// Notify only once per disconnection cycle
if (!mNotifiedNoBle) {
var toast = WatchUi.loadResource($.Rez.Strings.NoPhone);
if (!connectionAvailable) {
toast = WatchUi.loadResource($.Rez.Strings.NoInternet);
}
if (mHasToast) {
WatchUi.showToast(toast, null);
} else {
new Alert({
:timeout => Globals.scAlertTimeout,
:font => Graphics.FONT_MEDIUM,
:text => toast,
:fgcolor => Graphics.COLOR_WHITE,
:bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE);
}
}
mNotifiedNoBle = true;
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
mUpdateTimer.start(method(:startUpdates), wifiPollDelayMs, false);
mUpdating = false;
return;
}
if (! phoneConnected) {
// System.println("HomeAssistantApp updateMenuItems(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable) {
} else if (! connectionAvailable) {
// System.println("HomeAssistantApp updateMenuItems(): No Internet connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
setApiStatus(WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String);
} else {
mNotifiedNoBle = false;
if (mItemsToUpdate == null or mTemplates == null) {
mItemsToUpdate = mHaMenu.getItemsToUpdate();
mTemplates = {};
@@ -531,20 +589,27 @@ class HomeAssistantApp extends Application.AppBase {
//
(:glance)
function fetchApiStatus() as Void {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var connectionAvailable = System.getDeviceSettings().connectionAvailable;
// System.println("API URL = " + Settings.getApiUrl());
if (Settings.getApiUrl().equals("")) {
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String;
WatchUi.requestUpdate();
} else {
if (! System.getDeviceSettings().phoneConnected) {
if (! mIsGlance && Settings.getWifiLteExecutionEnabled() && (! phoneConnected || ! connectionAvailable)) {
// System.println("HomeAssistantApp fetchApiStatus(): In-app Wifi mode (No Phone and Internet connection), early return.");
return;
} else if (! phoneConnected) {
// System.println("HomeAssistantApp fetchApiStatus(): No Phone connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (mIsGlance) {
WatchUi.requestUpdate();
} else {
System.println("we here");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
} else if (! System.getDeviceSettings().connectionAvailable) {
} else if (! connectionAvailable) {
// System.println("HomeAssistantApp fetchApiStatus(): No Internet connection, skipping API call.");
mApiStatus = WatchUi.loadResource($.Rez.Strings.Unavailable) as Lang.String;
if (mIsGlance) {
@@ -785,6 +850,13 @@ class HomeAssistantApp extends Application.AppBase {
return mIsApp;
}
//! Returns a SyncDelegate for this App
//!
//! @return a SyncDelegate or null
//
public function getSyncDelegate() as Communications.SyncDelegate? {
return new HomeAssistantSyncDelegate();
}
}
//! Global function to return the application object.

View File

@@ -35,19 +35,43 @@ class HomeAssistantConfirmation extends WatchUi.Confirmation {
//! Delegate to respond to the confirmation request.
//
class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
private static var mTimer as Timer.Timer or Null;
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 mToggleMethod as Method(state as Lang.Boolean) as Void or Null;
private var mConfirmationView as WatchUi.Confirmation;
//! Class Constructor
//!
//! @param options A dictionary describing the following options:
//! - callback Method to call on confirmation.
//! - confirmationView Confirmation the delegate is active for
//! - state Wanted state of a toggle button.
//! - toggle Optional setEnabled method to untoggle ToggleItem.
//
function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean) {
function initialize(options as {
:callback as Method(state as Lang.Boolean) as Void,
:confirmationView as WatchUi.Confirmation,
:state as Lang.Boolean,
:toggleMethod as Method(state as Lang.Boolean) or Null,
}) {
if (mTimer != null) {
mTimer.stop();
}
WatchUi.ConfirmationDelegate.initialize();
mConfirmMethod = callback;
mState = state;
mConfirmMethod = options[:callback];
mConfirmationView = options[:confirmationView];
mState = options[:state];
mToggleMethod = options[:toggleMethod];
var timeout = Settings.getConfirmTimeout(); // ms
if (timeout > 0) {
if (mTimer == null) {
mTimer = new Timer.Timer();
}
mTimer.start(method(:onTimeout), timeout, true);
}
}
@@ -64,6 +88,11 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
}
if (response == WatchUi.CONFIRM_YES) {
mConfirmMethod.invoke(mState);
} else {
// Undo the toggle, if we have one
if (mToggleMethod != null) {
mToggleMethod.invoke(!mState);
}
}
return true;
}
@@ -71,6 +100,14 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
//! Function supplied to a timer in order to limit the time for which the confirmation can be provided.
function onTimeout() as Void {
mTimer.stop();
// 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);
}
}
}

View File

@@ -185,21 +185,25 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
private var mTimer as Timer.Timer or Null;
private var mState as Lang.Boolean;
private var mFailures as PinFailures;
private var mToggleMethod as Method(state as Lang.Boolean) as Void or Null;
private var mView as HomeAssistantPinConfirmationView;
//! Class Constructor
//!
//! @param callback Method to call on confirmation.
//! @param state Current state of a toggle button.
//! @param pin PIN to be matched.
//! @param view PIN confirmation view.
//! @param options A dictionary describing the following options:
//! - callback Method to call on confirmation.
//! - pin PIN to be matched.
//! - state Wanted state of a toggle button.
//! - toggle Optional setEnabled method to untoggle ToggleItem.
//! - view PIN confirmation view.
//
function initialize(
callback as Method(state as Lang.Boolean) as Void,
state as Lang.Boolean,
pin as Lang.String,
view as HomeAssistantPinConfirmationView
) {
function initialize(options as {
:callback as Method(state as Lang.Boolean) as Void,
:pin as Lang.String,
:state as Lang.Boolean,
:view as HomeAssistantPinConfirmationView,
:toggleMethod as (Method(state as Lang.Boolean) as Void) or Null,
}) {
BehaviorDelegate.initialize();
mFailures = new PinFailures();
if (mFailures.isLocked()) {
@@ -208,11 +212,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
WatchUi.loadResource($.Rez.Strings.Seconds);
WatchUi.showToast(msg, {});
}
mPin = pin;
mPin = options[:pin];
mEnteredPin = "";
mConfirmMethod = callback;
mState = state;
mView = view;
mConfirmMethod = options[:callback];
mState = options[:state];
mToggleMethod = options[:toggleMethod];
mView = options[:view];
resetTimer();
}
@@ -237,8 +243,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
if (mTimer != null) {
mTimer.stop();
}
mConfirmMethod.invoke(mState);
WatchUi.popView(WatchUi.SLIDE_RIGHT);
// Set the toggle, if we have one
if (mToggleMethod != null) {
mToggleMethod.invoke(!mState);
}
mConfirmMethod.invoke(mState);
} else {
error();
}
@@ -279,6 +290,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
if (mTimer != null) {
mTimer.stop();
}
WatchUi.popView(WatchUi.SLIDE_RIGHT);
}
@@ -304,6 +316,13 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
goBack();
}
//! Handle the back button (ESC)
//
function onBack() as Lang.Boolean {
goBack();
return true;
}
}

View File

@@ -27,9 +27,8 @@ class HomeAssistantService {
//! Class Constructor
//
function initialize() {
if (WatchUi has :showToast) {
mHasToast = true;
}
mHasToast = WatchUi has :showToast;
if (Attention has :vibrate) {
mHasVibrate = true;
}
@@ -129,10 +128,25 @@ class HomeAssistantService {
data as Lang.Dictionary or Null,
exit as Lang.Boolean
) as Void {
if (! System.getDeviceSettings().phoneConnected) {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var internetAvailable = System.getDeviceSettings().connectionAvailable;
if (Settings.getWifiLteExecutionEnabled() && (! phoneConnected || ! internetAvailable)) {
var dialogMsg = WatchUi.loadResource($.Rez.Strings.WifiLtePrompt) as Lang.String;
var dialog = new WatchUi.Confirmation(dialogMsg);
WatchUi.pushView(
dialog,
new WifiLteExecutionConfirmDelegate({
:type => "service",
:service => service,
:data => data,
:exit => exit,
}, dialog),
WatchUi.SLIDE_LEFT
);
} else if (! phoneConnected) {
// System.println("HomeAssistantService call(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable) {
} else if (! internetAvailable) {
// System.println("HomeAssistantService call(): No Internet connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else {

View File

@@ -0,0 +1,111 @@
using Toybox.Communications;
using Toybox.Lang;
// SyncDelegate to execute single command via POST request to Home Assistant
//
class HomeAssistantSyncDelegate extends Communications.SyncDelegate {
private static var syncError as Lang.String or Null;
// Initialize an instance of this delegate
public function initialize() {
SyncDelegate.initialize();
}
//! Called by the system to determine if a sync is needed
public function isSyncNeeded() as Lang.Boolean {
return true;
}
//! Called by the system when starting a bulk sync.
public function onStartSync() as Void {
syncError = null;
if (WifiLteExecutionConfirmDelegate.mCommandData == null) {
syncError = WatchUi.loadResource($.Rez.Strings.WifiLteExecutionDataError) as Lang.String;
onStopSync();
return;
}
var type = WifiLteExecutionConfirmDelegate.mCommandData[:type];
var data = WifiLteExecutionConfirmDelegate.mCommandData[:data];
var url;
switch (type) {
case "service":
var service = WifiLteExecutionConfirmDelegate.mCommandData[:service];
url = Settings.getApiUrl() + "/services/" + service.substring(0, service.find(".")) + "/" + service.substring(service.find(".")+1, service.length());
var entity_id = "";
if (data != null) {
entity_id = data.get("entity_id");
if (entity_id == null) {
entity_id = "";
}
}
performRequest(url, data);
break;
case "entity":
url = WifiLteExecutionConfirmDelegate.mCommandData[:url];
performRequest(url, data);
break;
}
}
// Performs a POST request to Hass with a given payload and URL, and calls haCallback
private function performRequest(url as Lang.String, data as Lang.Dictionary or Null) {
Communications.makeWebRequest(
url,
data, // May include {"entity_id": xxxx} for service calls
{
:method => Communications.HTTP_REQUEST_METHOD_POST,
:headers => {
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON,
"Authorization" => "Bearer " + Settings.getApiKey()
},
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON,
},
method(:haCallback)
);
}
//! Handle callback from request
public function haCallback(code as Lang.Number, data as Null or Lang.Dictionary) as Void {
Communications.notifySyncProgress(100);
if (code == 200) {
syncError = null;
if (WifiLteExecutionConfirmDelegate.mCommandData[:type].equals("entity")) {
var callbackMethod = WifiLteExecutionConfirmDelegate.mCommandData[:callback];
if (callbackMethod != null) {
var d = data as Lang.Array;
callbackMethod.invoke(d);
}
}
onStopSync();
return;
}
switch(code) {
case Communications.NETWORK_REQUEST_TIMED_OUT:
syncError = WatchUi.loadResource($.Rez.Strings.TimedOut) as Lang.String;
break;
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
syncError = WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String;
syncError = "";
default:
var codeMsg = WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String;
syncError = codeMsg + code;
break;
}
onStopSync();
}
//! Clean up
public function onStopSync() as Void {
if (WifiLteExecutionConfirmDelegate.mCommandData[:exit]) {
System.exit();
}
Communications.cancelAllRequests();
Communications.notifySyncComplete(syncError);
}
}

View File

@@ -82,16 +82,43 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
var pinConfirmationView = new HomeAssistantPinConfirmationView();
WatchUi.pushView(
pinConfirmationView,
new HomeAssistantPinConfirmationDelegate(method(:onConfirm), false, pin, pinConfirmationView),
new HomeAssistantPinConfirmationDelegate({
:callback => method(:onConfirm),
:pin => pin,
:state => false,
:view => pinConfirmationView,
}),
WatchUi.SLIDE_IMMEDIATE
);
}
} else if (mConfirm) {
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(
new HomeAssistantConfirmation(),
new HomeAssistantConfirmationDelegate(method(:onConfirm), false),
dialog,
new WifiLteExecutionConfirmDelegate({
:type => "service",
:service => mService,
:data => mData,
:exit => mExit,
}, dialog),
WatchUi.SLIDE_LEFT
);
} else {
var view = new HomeAssistantConfirmation();
WatchUi.pushView(
view,
new HomeAssistantConfirmationDelegate({
:callback => method(:onConfirm),
:confirmationView => view,
:state => false,
}),
WatchUi.SLIDE_IMMEDIATE
);
}
} else {
onConfirm(false);
}

View File

@@ -198,16 +198,8 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
case 200:
// System.println("HomeAssistantToggleMenuItem onReturnSetState(): Service executed.");
getApp().forceStatusUpdates();
var state;
var d = data as Lang.Array;
for(var i = 0; i < d.size(); i++) {
if ((d[i].get("entity_id") as Lang.String).equals(mData.get("entity_id"))) {
state = d[i].get("state") as Lang.String;
// System.println((d[i].get("attributes") as Lang.Dictionary).get("friendly_name") + " State=" + state);
setUiToggle(state);
WatchUi.requestUpdate();
}
}
setToggleStateWithData(d);
status = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String;
break;
@@ -221,30 +213,54 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
}
}
//! Handles the response from a Home Assistant service or state call and updates the toggle UI.
//!
//! @param data An array of dictionaries, each representing a Home Assistant entity state.
//
function setToggleStateWithData(data as Lang.Array) {
// if there's no response body, let's assume that what we did, happened, and flip the toggle
if (data.size() == 0) {
setEnabled(!isEnabled());
}
else {
for(var i = 0; i < data.size(); i++) {
if ((data[i].get("entity_id") as Lang.String).equals(mData.get("entity_id"))) {
var state = data[i].get("state") as Lang.String;
// System.println((d[i].get("attributes") as Lang.Dictionary).get("friendly_name") + " State=" + state);
setUiToggle(state);
WatchUi.requestUpdate();
}
}
}
}
//! Set the state of the toggle menu item.
//!
//! @param s Boolean indicating the desired state of the toggle switch.
//
function setState(s as Lang.Boolean) as Void {
// Toggle the UI back, we'll wait for confirmation from the Home Assistant
setEnabled(!isEnabled());
if (! System.getDeviceSettings().phoneConnected) {
var phoneConnected = System.getDeviceSettings().phoneConnected;
var internetAvailable = System.getDeviceSettings().connectionAvailable;
if (! phoneConnected && ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantToggleMenuItem getState(): No Phone connection, skipping API call.");
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
} else if (! System.getDeviceSettings().connectionAvailable) {
} else if (! internetAvailable && ! Settings.getWifiLteExecutionEnabled()) {
// System.println("HomeAssistantToggleMenuItem getState(): No Internet connection, skipping API call.");
// Toggle the UI back
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoInternet) as Lang.String);
} else {
// Updated SDK and got a new error
// ERROR: venu: Cannot find symbol ':substring' on type 'PolyType<Null or $.Toybox.Lang.Object>'.
var id = mData.get("entity_id") as Lang.String;
var url = Settings.getApiUrl() + "/services/";
if (s) {
url = url + id.substring(0, id.find(".")) + "/turn_on";
} else {
url = url + id.substring(0, id.find(".")) + "/turn_off";
var url = getUrl(id, s);
if ((! phoneConnected || ! internetAvailable) && Settings.getWifiLteExecutionEnabled()) {
// Undo the toggle
setEnabled(!isEnabled());
wifiPrompt(s);
return;
}
// System.println("HomeAssistantToggleMenuItem setState() URL = " + url);
// System.println("HomeAssistantToggleMenuItem setState() entity_id = " + id);
Communications.makeWebRequest(
@@ -275,21 +291,45 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
function callService(b as Lang.Boolean) as Void {
var hasTouchScreen = System.getDeviceSettings().isTouchScreen;
if (mPin && hasTouchScreen) {
// Undo the toggle
setEnabled(!isEnabled());
var pin = Settings.getPin();
if (pin != null) {
var pinConfirmationView = new HomeAssistantPinConfirmationView();
WatchUi.pushView(
pinConfirmationView,
new HomeAssistantPinConfirmationDelegate(method(:onConfirm), b, pin, pinConfirmationView),
new HomeAssistantPinConfirmationDelegate({
:callback => method(:onConfirm),
:pin => pin,
:state => b,
:toggleMethod => method(:setEnabled),
:view => pinConfirmationView,
}),
WatchUi.SLIDE_IMMEDIATE
);
}
} else if (mConfirm) {
// 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(
new HomeAssistantConfirmation(),
new HomeAssistantConfirmationDelegate(method(:onConfirm), b),
confirmationView,
new HomeAssistantConfirmationDelegate({
:callback => method(:onConfirm),
:confirmationView => confirmationView,
:state => b,
:toggleMethod => method(:setEnabled),
}),
WatchUi.SLIDE_IMMEDIATE
);
}
} else {
onConfirm(b);
}
@@ -303,4 +343,45 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
setState(b);
}
//! Displays a confirmation dialog before executing a service call via Wi-Fi/LTE.
//!
//! @param s Desired state: `true` to turn on, `false` to turn off.
//
private function wifiPrompt(s as Lang.Boolean) as Void {
var id = mData.get("entity_id") as Lang.String;
var url = getUrl(id, s);
var dialogMsg = WatchUi.loadResource($.Rez.Strings.WifiLtePrompt) as Lang.String;
var dialog = new WatchUi.Confirmation(dialogMsg);
WatchUi.pushView(
dialog,
new WifiLteExecutionConfirmDelegate({
:type => "entity",
:url => url,
:id => id,
:data => mData,
:callback => method(:setToggleStateWithData),
:exit => mExit,
}, dialog),
WatchUi.SLIDE_LEFT
);
}
//! Constructs a Home Assistant API URL for the given entity and desired state.
//!
//! @param id The entity ID, e.g., `"switch.kitchen"`.
//! @param s Desired state: `true` for "turn_on", `false` for "turn_off".
//!
//! @return Full service URL string.
//
private function getUrl(id as Lang.String, s as Lang.Boolean) as Lang.String {
var url = Settings.getApiUrl() + "/services/";
if (s) {
url = url + id.substring(0, id.find(".")) + "/turn_on";
} else {
url = url + id.substring(0, id.find(".")) + "/turn_off";
}
return url;
}
}

View File

@@ -35,6 +35,7 @@ class Settings {
private static var mCacheConfig as Lang.Boolean = false;
private static var mClearCache as Lang.Boolean = false;
private static var mVibrate as Lang.Boolean = false;
private static var mWifiLteExecution as Lang.Boolean = false;
//! seconds
private static var mAppTimeout as Lang.Number = 0;
//! seconds
@@ -69,6 +70,7 @@ class Settings {
mMenuAlignment = Properties.getValue("menu_alignment");
mIsSensorsLevelEnabled = Properties.getValue("enable_battery_level");
mBatteryRefreshRate = Properties.getValue("battery_level_refresh_rate");
mWifiLteExecution = Properties.getValue("wifi_lte_execution");
}
//! A webhook is required for non-privileged API calls.
@@ -270,4 +272,16 @@ class Settings {
}
}
//! Get the value of the WiFi/LTE toggle in settings.
//!
//! @return The state of the toggle.
//
static function getWifiLteExecutionEnabled() as Lang.Boolean {
// Wifi/LTE sync execution on a cached menu
if (!mCacheConfig) {
return false;
}
return mWifiLteExecution;
}
}

View File

@@ -0,0 +1,135 @@
using Toybox.WatchUi;
using Toybox.System;
using Toybox.Communications;
using Toybox.Lang;
using Toybox.Timer;
// Delegate to respond to a confirmation to execute command via bulk sync
//
class WifiLteExecutionConfirmDelegate extends WatchUi.ConfirmationDelegate {
public static var mCommandData as {
:type as Lang.String,
:service as Lang.String or Null,
:data as Lang.Dictionary or Null,
:url as Lang.String or Null,
:id as Lang.Number or Null,
:exit as Lang.Boolean
};
private static var mTimer as Timer.Timer or Null;
private var mHasToast as Lang.Boolean = false;
private var mConfirmationView as WatchUi.Confirmation;
//! Initializes a confirmation delegate to confirm a Wi-Fi or LTE command exection
//!
//! @param options A dictionary describing the command to be executed:
//! - type: The command type, either `"service"` or `"entity"`.
//! - service: (For type `"service"`) The Home Assistant service to call (e.g., "light.turn_on").
//! - url: (For type `"entity"`) The full Home Assistant entity API URL.
//! - callback: (For type `"entity"`) A callback method (Method<data as Dictionary>) to handle the response.
//! - data: (Optional) A dictionary of data to send with the request.
//! - exit: Boolean: if set to true: exit after running command.
//! @param view The Confirmation view the delegate is active for
function initialize(cOptions as {
:type as Lang.String,
:service as Lang.String or Null,
:data as Lang.Dictionary or Null,
:url as Lang.String or Null,
:callback as Lang.Method or Null,
:exit as Lang.Boolean,
}, view as WatchUi.Confirmation) {
ConfirmationDelegate.initialize();
if (mTimer != null) {
mTimer.stop();
}
if (WatchUi has :showToast) {
mHasToast = true;
}
mConfirmationView = view;
mCommandData = {
:type => cOptions[:type],
:service => cOptions[:service],
:data => cOptions[:data],
:url => cOptions[:url],
:callback => cOptions[:callback],
:exit => cOptions[:exit]
};
var timeout = Settings.getConfirmTimeout(); // ms
if (timeout > 0) {
if (mTimer == null) {
mTimer = new Timer.Timer();
}
mTimer.start(method(:onTimeout), timeout, true);
}
}
//! Handles the user's response to the confirmation dialog.
//!
//! @param response The user's confirmation response as `WatchUi.Confirm`
//! @return Always returns `true` to indicate the response was handled.
function onResponse(response) as Lang.Boolean {
getApp().getQuitTimer().reset();
if (mTimer != null) {
mTimer.stop();
}
if (response == WatchUi.CONFIRM_YES) {
trySync();
}
return true;
}
//! Initiates a bulk sync process to execute a command, if connections are available
private function trySync() as Void {
var connectionInfo = System.getDeviceSettings().connectionInfo;
var keys = connectionInfo.keys();
var possibleConnection = false;
for(var i = 0; i < keys.size(); i++) {
if (keys[i] != :bluetooth) {
var connection = connectionInfo[keys[i]];
if (connection.state != System.CONNECTION_STATE_NOT_INITIALIZED) {
possibleConnection = true;
break;
}
}
}
if (possibleConnection) {
if (Communications has :startSync2) {
var syncString = WatchUi.loadResource($.Rez.Strings.WifiLteExecutionTitle) as Lang.String;
Communications.startSync2({:message => syncString});
} else {
Communications.startSync();
}
} else {
var toast = WatchUi.loadResource($.Rez.Strings.WifiLteNotAvailable) as Lang.String;
if (mHasToast) {
WatchUi.showToast(toast, null);
} else {
new Alert({
:timeout => Globals.scAlertTimeout,
:font => Graphics.FONT_MEDIUM,
:text => toast,
:fgcolor => Graphics.COLOR_WHITE,
:bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE);
}
}
}
//! Function supplied to a timer in order to limit the time for which the confirmation can be provided.
function onTimeout() as Void {
mTimer.stop();
var getCurrentView = WatchUi.getCurrentView();
if (getCurrentView[0] == mConfirmationView) {
WatchUi.popView(WatchUi.SLIDE_RIGHT);
}
}
}

View File

@@ -37,7 +37,8 @@
margin-left: 0.5em;
filter: grayscale() invert();
}
.template, .info {
.template,
.info {
background-image: url(../resources-icons-48/info_type.svg);
background-size: contain;
margin-left: 0.5em;
@@ -63,6 +64,13 @@
margin-left: 0.5em;
filter: grayscale() invert();
}
.mdi {
margin-left: 0.5em;
}
.disabled {
opacity: 0.5;
}
:root {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
@@ -285,6 +293,10 @@
background-color: var(--ctp-mocha-overlay1);
}
</style>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css"
defer />
</head>
<body>
<div id="settings">
@@ -441,9 +453,10 @@ http:
</div>
</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/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>
</body>
</html>

View File

@@ -11,7 +11,7 @@ let api_token = localStorage.getItem('api_token') ?? '';
/**
* 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() {
try {
@@ -21,7 +21,7 @@ async function get_entities() {
Authorization: `Bearer ${api_token}`,
},
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) {
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_token').classList.remove('invalid');
return Object.fromEntries(await res.json());
} catch {
const data = {};
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');
return {};
}
@@ -57,7 +65,8 @@ async function get_devices() {
document.querySelector('#api_url').classList.remove('invalid');
document.querySelector('#api_token').classList.remove('invalid');
return Object.fromEntries(await res.json());
} catch {
} catch (e) {
console.error('Error fetching devices:', e);
document.querySelector('#api_url').classList.add('invalid');
return {};
}
@@ -84,7 +93,8 @@ async function get_areas() {
document.querySelector('#api_url').classList.remove('invalid');
document.querySelector('#api_token').classList.remove('invalid');
return Object.fromEntries(await res.json());
} catch {
} catch (e) {
console.error('Error fetching areas:', e);
document.querySelector('#api_url').classList.add('invalid');
return {};
}
@@ -119,7 +129,8 @@ async function get_services() {
}
}
return services;
} catch {
} catch (e) {
console.error('Error fetching services:', e);
document.querySelector('#api_url').classList.add('invalid');
return [];
}
@@ -370,6 +381,9 @@ async function generate_schema(entities, devices, areas, services, schema) {
confirm: {
$ref: '#/$defs/confirm',
},
pin: {
$ref: '#/$defs/pin',
},
data: {
type: 'object',
properties: {},
@@ -724,6 +738,7 @@ require(['vs/editor/editor.main'], async () => {
var decorations = editor.createDecorationsCollection([]);
/** @type {monaco.editor.IMarkerData[]} */
let markers = [];
const renderTemplate = editor.addCommand(
@@ -905,6 +920,7 @@ require(['vs/editor/editor.main'], async () => {
const ast = json.parse(model.getValue());
const data = JSON.parse(model.getValue());
markers = [];
/** @type {monaco.editor.IModelDeltaDecoration[]} */
const glyphs = [];
async function testToggle(range, 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 |
* import('json-ast-comments').JsonProperty} node
* @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.key[0].value === 'content') {
templates.push([
@@ -1010,11 +1028,39 @@ require(['vs/editor/editor.main'], async () => {
}
trim++;
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,
...range,
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') {
if (node.value[0].value === 'toggle') {
toggles.push([
@@ -1041,15 +1087,15 @@ require(['vs/editor/editor.main'], async () => {
});
}
} 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') {
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') {
for (let member of node.members) {
recurse(member, path);
recurse(member, path, node);
}
}
}

View File

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

25
web/pnpm-lock.yaml generated
View File

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