diff --git a/config.schema.json b/config.schema.json index d73a0e3..c639405 100644 --- a/config.schema.json +++ b/config.schema.json @@ -283,50 +283,14 @@ "exit": { "$ref": "#/$defs/exit" }, - "min": { - "type": "number", - "title": "Minimum Value" - }, - "max": { - "type": "number", - "title": "Maximum Value" - }, - "step": { - "type": "number", - "title": "Step Size" - }, - "display_format": { - "type": "string", - "title": "Display Format", - "description": "A C-Style format string for displaying the value in the UI. https://developer.garmin.com/connect-iq/api-docs/Toybox/Lang/Number.html#format-instance_function", - "default": "%.1f" - }, "entity": { "$ref": "#/$defs/entity" - }, - "attribute": { - "type": "string", - "title": "Attribute on the entity", - "description": "Attribute on the entity with the current numeric value. To use the state of the entity, do not specify." - }, - "service": { - "$ref": "#/$defs/service" - }, - "data_attribute": { - "type": "string", - "title": "Attribute on the service data", - "description": "Attribute on the service data for the value to set." } }, "required": [ "name", "type", - "min", - "max", - "step", - "entity", - "service", - "data_attribute" + "entity" ], "additionalProperties": false }, @@ -816,6 +780,9 @@ "title": "Action", "description": "'confirm' field is optional.", "properties": { + "picker": { + "$ref": "#/$defs/picker" + }, "confirm": { "$ref": "#/$defs/confirm" }, @@ -824,6 +791,41 @@ } } }, + "picker": { + "type": "object", + "title": "Number picker configuration", + "description": "'attribute' field is optional.", + "properties": { + "min": { + "type": "number", + "title": "Minimum Value" + }, + "max": { + "type": "number", + "title": "Maximum Value" + }, + "step": { + "type": "number", + "title": "Step Size" + }, + "attribute": { + "type": "string", + "title": "Attribute on the entity", + "description": "Attribute on the entity with the current numeric value. To use the state of the entity, do not specify." + }, + "data_attribute": { + "type": "string", + "title": "Attribute on the service data", + "description": "Attribute on the service data for the value to set." + } + }, + "required": [ + "min", + "max", + "step", + "data_attribute" + ] + }, "content": { "title": "Home Assistant Template", "description": "Jinja2 template defining the text to display. Must be included in an 'info'. Optional in a 'toggle', 'tap' and 'group'. Special characters may not render in the glance context.", @@ -888,4 +890,4 @@ "description": "Choose to exit the application after this item has been selected. Disabled (false) by default. N.B. Only actionable menu items can have this field added." } } -} \ No newline at end of file +} diff --git a/source/HomeAssistantApp.mc b/source/HomeAssistantApp.mc index 273ec4c..87fa8a9 100644 --- a/source/HomeAssistantApp.mc +++ b/source/HomeAssistantApp.mc @@ -630,8 +630,10 @@ class HomeAssistantApp extends Application.AppBase { (item as HomeAssistantToggleMenuItem).updateToggleState(data[i.toString() + "t"]); } if (item instanceof HomeAssistantNumericMenuItem) { - // (item as HomeAssistantNumericMenuItem).updateNumericState("22"); - (item as HomeAssistantNumericMenuItem).updateNumericState(data[i.toString() + "n"].toString()); + var s = data[i.toString() + "n"]; + if ((s instanceof Lang.Number) or (s instanceof Lang.Float)) { + (item as HomeAssistantNumericMenuItem).setValue(s); + } } } if (Settings.getMenuCheck() && Settings.getCacheConfig() && !mIsCacheChecked) { @@ -831,7 +833,7 @@ class HomeAssistantApp extends Application.AppBase { var phoneConnected = System.getDeviceSettings().phoneConnected; var connectionAvailable = System.getDeviceSettings().connectionAvailable; - // System.println("API URL = " + Settings.getApiUrl()); + // System.println("HomeAssistantApp fetchApiStatus(): API URL = " + Settings.getApiUrl()); if (Settings.getApiUrl().equals("")) { mApiStatus = WatchUi.loadResource($.Rez.Strings.Unconfigured) as Lang.String; WatchUi.requestUpdate(); diff --git a/source/HomeAssistantMenuItemFactory.mc b/source/HomeAssistantMenuItemFactory.mc index 1136d23..a64b69d 100644 --- a/source/HomeAssistantMenuItemFactory.mc +++ b/source/HomeAssistantMenuItemFactory.mc @@ -158,7 +158,7 @@ class HomeAssistantMenuItemFactory { entity_id as Lang.String?, template as Lang.String?, service as Lang.String?, - data as Lang.Dictionary?, + picker as Lang.Dictionary, options as { :exit as Lang.Boolean, :confirm as Lang.Boolean, @@ -166,25 +166,21 @@ class HomeAssistantMenuItemFactory { :icon as WatchUi.Bitmap } ) as WatchUi.MenuItem { + var data = null; if (entity_id != null) { - if (data == null) { - data = { "entity_id" => entity_id }; - - } else { - data.put("entity_id", entity_id); - } - } + data = { "entity_id" => entity_id }; + } var keys = mMenuItemOptions.keys(); for (var i = 0; i < keys.size(); i++) { options.put(keys[i], mMenuItemOptions.get(keys[i])); } - options.put(:icon, mTapTypeIcon); - + options.put(:icon, mTapTypeIcon); return new HomeAssistantNumericMenuItem( label, template, service, data, + picker, options, mHomeAssistantService ); diff --git a/source/factory/HomeAssistantNumericFactory.mc b/source/HomeAssistantNumericFactory.mc similarity index 55% rename from source/factory/HomeAssistantNumericFactory.mc rename to source/HomeAssistantNumericFactory.mc index bb1bee5..8fdef90 100644 --- a/source/factory/HomeAssistantNumericFactory.mc +++ b/source/HomeAssistantNumericFactory.mc @@ -9,80 +9,92 @@ // tested on a Venu 2 device. The source code is provided at: // https://github.com/house-of-abbey/GarminHomeAssistant. // -// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 +// P A Abbey & J D Abbey & @thmichel, 13 October 2025 // //------------------------------------------------------------ -import Toybox.Graphics; -import Toybox.Lang; -import Toybox.WatchUi; +using Toybox.Graphics; +using Toybox.Lang; +using Toybox.WatchUi; //! Factory that controls which numbers can be picked class HomeAssistantNumericFactory extends WatchUi.PickerFactory { // define default values in case not contained in data - private var mStart as Lang.Float = 0.0; - private var mStop as Lang.Float = 100.0; - private var mStep as Lang.Float = 1.0; - private var mFormatString as Lang.String = "%.2f"; + private var mStart as Lang.Float = 0.0; + private var mStop as Lang.Float = 100.0; + private var mStep as Lang.Float = 1.0; + private var mFormatString as Lang.String = "%d"; //! Class Constructor - //! - public function initialize(data as Lang.Dictionary) { + // + public function initialize(picker as Lang.Dictionary) { PickerFactory.initialize(); // Get values from data - - var val = data.get("min"); + var val = picker["min"]; if (val != null) { mStart = val.toString().toFloat(); } - val = data.get("max"); + val = picker["max"]; if (val != null) { mStop = val.toString().toFloat(); } - val = data.get("step"); + val = picker["step"]; if (val != null) { mStep = val.toString().toFloat(); } - val = data.get("display_format"); - if (val != null) { - mFormatString = val.toString(); - } - - } - - //! Get the index of a number item - //! @param value The number to get the index of - //! @return The index of the number - public function getIndex(value as Float) as Number { - return ((value / mStep) - mStart).toNumber(); + if (mStep > 0.0) { + var s = mStep; + var dp = 0; + while (s < 1.0) { + s *= 10; + dp++; + // Assigned inside the loop and in each iteration to avoid clobbering the default '%d'. + mFormatString = "%." + dp.toString() + "f"; + } + } else { + // The JSON menu definition defined a step size of 0, revert to the default. + mStep = 1.0; + } } //! Generate a Drawable instance for an item + //! //! @param index The item index //! @param selected true if the current item is selected, false otherwise //! @return Drawable for the item - public function getDrawable(index as Number, selected as Boolean) as Drawable? { + // + public function getDrawable( + index as Lang.Number, + selected as Lang.Boolean + ) as WatchUi.Drawable? { var value = getValue(index); var text = "No item"; if (value instanceof Lang.Float) { text = value.format(mFormatString); } - return new WatchUi.Text({:text=>text, :color=>Graphics.COLOR_WHITE, - :locX=>WatchUi.LAYOUT_HALIGN_CENTER, :locY=>WatchUi.LAYOUT_VALIGN_CENTER}); + return new WatchUi.Text({ + :text => text, + :color => Graphics.COLOR_WHITE, + :locX => WatchUi.LAYOUT_HALIGN_CENTER, + :locY => WatchUi.LAYOUT_VALIGN_CENTER + }); } //! Get the value of the item at the given index + //! //! @param index Index of the item to get the value of //! @return Value of the item - public function getValue(index as Number) as Object? { + // + public function getValue(index as Lang.Number) as Lang.Object? { return mStart + (index * mStep); } //! Get the number of picker items + //! //! @return Number of items - public function getSize() as Number { + // + public function getSize() as Lang.Number { return ((mStop - mStart) / mStep).toNumber() + 1; } - } diff --git a/source/HomeAssistantNumericMenuItem.mc b/source/HomeAssistantNumericMenuItem.mc index 2cb2488..13da14d 100644 --- a/source/HomeAssistantNumericMenuItem.mc +++ b/source/HomeAssistantNumericMenuItem.mc @@ -9,7 +9,7 @@ // tested on a Venu 2 device. The source code is provided at: // https://github.com/house-of-abbey/GarminHomeAssistant. // -// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 +// P A Abbey & J D Abbey & @thmichel, 13 October 2025 // //----------------------------------------------------------------------------------- @@ -17,7 +17,6 @@ using Toybox.Lang; using Toybox.WatchUi; using Toybox.Graphics; - //! Menu button with an icon that opens a sub-menu, i.e. group, and optionally renders //! a Home Assistant Template. // @@ -28,9 +27,9 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { private var mExit as Lang.Boolean; private var mPin as Lang.Boolean; private var mData as Lang.Dictionary?; - private var mValue as Lang.String?; - private var mFormatString as Lang.String="%.1f"; - + private var mPicker as Lang.Dictionary?; + private var mValue as Lang.Number or Lang.Float = 0; + private var mFormatString as Lang.String = "%d"; //! Class Constructor //! @@ -51,6 +50,7 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { template as Lang.String, service as Lang.String?, data as Lang.Dictionary?, + picker as Lang.Dictionary, options as { :alignment as WatchUi.MenuItem.Alignment, :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol, @@ -62,33 +62,37 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { ) { mService = service; mData = data; + mPicker = picker; mExit = options[:exit]; mConfirm = options[:confirm]; mPin = options[:pin]; mLabel = label; mHomeAssistantService = haService; - var val = data.get("display_format"); - if (val != null) { - mFormatString = val.toString(); - } - else { - mFormatString = "%.1f"; - } - - HomeAssistantMenuItem.initialize( label, template, { :alignment => options[:alignment], :icon => options[:icon] - } - ); + } + ); + + if (picker != null) { + var s = picker["step"]; + if (s != null) { + var step = s.toFloat() as Lang.Float; + var dp = 0; + while (step < 1.0) { + step *= 10; + dp++; + // Assigned inside the loop and in each iteration to avoid clobbering the default '%d'. + mFormatString = "%." + dp.toString() + "f"; + } + } + } } - - function callService() as Void { var hasTouchScreen = System.getDeviceSettings().isTouchScreen; if (mPin && hasTouchScreen) { @@ -98,10 +102,10 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { WatchUi.pushView( pinConfirmationView, new HomeAssistantPinConfirmationDelegate({ - :callback => method(:onConfirm), - :pin => pin, - :state => false, - :view => pinConfirmationView, + :callback => method(:onConfirm), + :pin => pin, + :state => false, + :view => pinConfirmationView, }), WatchUi.SLIDE_IMMEDIATE ); @@ -139,62 +143,58 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { } } - - //! Callback function after the menu items selection has been (optionally) confirmed. //! //! @param b Ignored. It is included in order to match the expected function prototype of the callback method. // function onConfirm(b as Lang.Boolean) as Void { - //mHomeAssistantService.call(mService, {"entity_id" => mData.get("entity_id").toString(),mData.get("valueLabel").toString() => mValue}, mExit); - var dataAttribute = mData.get("data_attribute"); + var dataAttribute = mPicker["data_attribute"]; if (dataAttribute == null) { //return without call service if no data attribute is set to avoid crash WatchUi.popView(WatchUi.SLIDE_RIGHT); return; } - var entity_id = mData.get("entity_id"); + var entity_id = mData["entity_id"]; if (entity_id == null) { //return without call service if no entity_id is set to avoid crash WatchUi.popView(WatchUi.SLIDE_RIGHT); return; } - mHomeAssistantService.call(mService, {"entity_id" => entity_id.toString(),dataAttribute.toString() => mValue}, mExit); + mHomeAssistantService.call( + mService, + { + "entity_id" => entity_id.toString(), + dataAttribute.toString() => mValue + }, + mExit + ); WatchUi.popView(WatchUi.SLIDE_RIGHT); } - //! Return a toggle menu item's state template. + //! Return a numeric menu item's fetch state template. //! //! @return A string with the menu item's template definition (or null). // function getNumericTemplate() as Lang.String? { - var entity_id = mData.get("entity_id"); - if (entity_id != null) { - return "{{state_attr('" + entity_id.toString() + "','" + mData.get("attribute").toString() +"')}}"; - } - return null; - } - - function updateNumericState(data as Lang.String or Lang.Dictionary or Null) as Void { - if (data == null) { - mValue="0"; - return; - } else if(data instanceof Lang.String) { - mValue=data; - + var entity_id = mData["entity_id"]; + var attribute = (mPicker["attribute"] as Lang.String); + if (entity_id == null) { + return null; } else { - // Catch possible error - mValue="0"; + if (attribute == null) { + return "{{states('" + entity_id.toString() + "')}}"; + } else { + return "{{state_attr('" + entity_id.toString() + "','" + attribute + "')}}"; + } } } - //! Update the menu item's sub label to display the template rendered by Home Assistant. //! //! @param data The rendered template (typically a string) to be placed in the sub label. This may //! unusually be a number if the SDK interprets the JSON returned by Home Assistant as such. // - function updateState(data as Lang.String or Lang.Dictionary or Lang.Number or Lang.Float or Null) as Void { + public function updateState(data as Lang.String or Lang.Dictionary or Lang.Number or Lang.Float or Null) as Void { if (data == null) { setSubLabel($.Rez.Strings.Empty); } else if(data instanceof Lang.Float) { @@ -203,31 +203,45 @@ class HomeAssistantNumericMenuItem extends HomeAssistantMenuItem { } else if(data instanceof Lang.Number) { var f = data.toFloat() as Lang.Float; setSubLabel(f.format(mFormatString)); - } else if (data instanceof Lang.String){ + } else if (data instanceof Lang.String) { // This should not happen setSubLabel(data); - } - else { + } else { // The template must return a Float on Numeric value, or the item cannot be formatted locally without error. setSubLabel(WatchUi.loadResource($.Rez.Strings.TemplateError) as Lang.String); } WatchUi.requestUpdate(); } - //! Set the mValue value. + //! Set the Picker's value. Needed to set new value via the Service call //! - //! Needed to set new value via the Service call + //! @param value New value to set. // - function setValue(value as Lang.String) as Void { + public function setValue(value as Lang.Number or Lang.Float) as Void { mValue = value; } - function getValue() as Lang.String { + //! Get the Picker's value. + //! + //! Needed to set new value via the Service call + // + public function getValue() as Lang.Number or Lang.Float { return mValue; } - function getData() as Lang.Dictionary { + //! Get the original 'data' field supplied by the JSON menu. + //! + //! @return Dictionary containing the 'data' field. + // + public function getData() as Lang.Dictionary { return mData; } - + + // Get the original 'picker' field supplied by the JSON menu. + //! + //! @return Dictionary containing the 'picker' field. + // + public function getPicker() as Lang.Dictionary { + return mPicker; + } } diff --git a/source/picker/HomeAssistantNumericPicker.mc b/source/HomeAssistantNumericPicker.mc similarity index 55% rename from source/picker/HomeAssistantNumericPicker.mc rename to source/HomeAssistantNumericPicker.mc index 05da47c..0940b00 100644 --- a/source/picker/HomeAssistantNumericPicker.mc +++ b/source/HomeAssistantNumericPicker.mc @@ -9,7 +9,7 @@ // tested on a Venu 2 device. The source code is provided at: // https://github.com/house-of-abbey/GarminHomeAssistant. // -// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 +// P A Abbey & J D Abbey & @thmichel, 13 October 2025 // //------------------------------------------------------------ @@ -20,88 +20,79 @@ using Toybox.System; using Toybox.WatchUi; //! Picker that allows the user to choose a float value +// class HomeAssistantNumericPicker extends WatchUi.Picker { - - private var mFactory as HomeAssistantNumericFactory; private var mItem as HomeAssistantNumericMenuItem; //! Constructor - public function initialize(factory as HomeAssistantNumericFactory, haItem as HomeAssistantNumericMenuItem) { + // + public function initialize( + factory as HomeAssistantNumericFactory, + haItem as HomeAssistantNumericMenuItem + ) { + mItem = haItem; + var picker = mItem.getPicker(); + var min = (picker.get("min") as Lang.String).toFloat(); + var step = (picker.get("step") as Lang.String).toFloat(); + var val = haItem.getValue(); - mFactory = factory; - - - var pickerOptions = {:pattern=>[mFactory]}; - mItem=haItem; - - - var data = mItem.getData(); - - var min = 0.0; - var val = data.get("min"); - if (val != null) { - min = val.toString().toFloat(); - } - var step = 1.0; - val = data.get("step"); - if (val != null) { - step = val.toString().toFloat(); - } - val = haItem.getValue(); - if (val != null) { - val = val.toString().toFloat(); - } else { - // catch missing state to avoid crash - val = min; + if (min == null) { + min = 0.0; + } + if (step == null) { + step = 1.0; } - var index = ((val -min) / step).toNumber(); - - pickerOptions[:defaults] =[index]; - - var title = new WatchUi.Text({:text=>haItem.getLabel(), :locX=>WatchUi.LAYOUT_HALIGN_CENTER, - :locY=>WatchUi.LAYOUT_VALIGN_BOTTOM}); - pickerOptions[:title] = title; - - - Picker.initialize(pickerOptions); + WatchUi.Picker.initialize({ + :title => new WatchUi.Text({ + :text => haItem.getLabel(), + :locX => WatchUi.LAYOUT_HALIGN_CENTER, + :locY => WatchUi.LAYOUT_VALIGN_BOTTOM + }), + :pattern => [factory], + :defaults => [((val - min) / step).toNumber()] + }); } - - //! Get whether the user is done picking + //! Called when the user has completed picking. + //! //! @param value Value user selected //! @return true if user is done, false otherwise - public function onConfirm(value as Lang.String) as Void { + // + public function onConfirm(value as Lang.Number or Lang.Float) as Void { mItem.setValue(value); mItem.callService(); } - - } -//! Responds to a numeric picker selection or cancellation +//! Responds to a numeric picker selection or cancellation. +// class HomeAssistantNumericPickerDelegate extends WatchUi.PickerDelegate { private var mPicker as HomeAssistantNumericPicker; //! Constructor + // public function initialize(picker as HomeAssistantNumericPicker) { PickerDelegate.initialize(); mPicker = picker; } //! Handle a cancel event from the picker + //! //! @return true if handled, false otherwise + // public function onCancel() as Lang.Boolean { WatchUi.popView(WatchUi.SLIDE_RIGHT); return true; } //! Handle a confirm event from the picker + //! //! @param values The values chosen in the picker //! @return true if handled, false otherwise + // public function onAccept(values as Lang.Array) as Lang.Boolean { - var chosenValue = values[0].toString(); - mPicker.onConfirm(chosenValue); + mPicker.onConfirm(values[0]); return true; } } diff --git a/source/HomeAssistantTapMenuItem.mc b/source/HomeAssistantTapMenuItem.mc index b437ca1..fa84e31 100644 --- a/source/HomeAssistantTapMenuItem.mc +++ b/source/HomeAssistantTapMenuItem.mc @@ -128,7 +128,7 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem { //! //! @param b Ignored. It is included in order to match the expected function prototype of the callback method. // - function onConfirm(b as Lang.Boolean) as Void { + public function onConfirm(b as Lang.Boolean) as Void { if (mService != null) { mHomeAssistantService.call(mService, mData, mExit); } diff --git a/source/HomeAssistantView.mc b/source/HomeAssistantView.mc index eb33985..c5d7814 100644 --- a/source/HomeAssistantView.mc +++ b/source/HomeAssistantView.mc @@ -127,18 +127,23 @@ class HomeAssistantView extends WatchUi.Menu2 { )); } } else if (type.equals("numeric") && service != null) { - addItem(HomeAssistantMenuItemFactory.create().numeric( - name, - entity, - content, - service, - data, - { - :exit => exit, - :confirm => confirm, - :pin => pin + if (tap_action != null) { + var picker = tap_action.get("picker") as Lang.Dictionary?; + if (picker != null) { + addItem(HomeAssistantMenuItemFactory.create().numeric( + name, + entity, + content, + service, + picker, + { + :exit => exit, + :confirm => confirm, + :pin => pin + } + )); } - )); + } } else if (type.equals("info") && content != null) { // Cannot exit from a non-actionable information only menu item. addItem(HomeAssistantMenuItemFactory.create().tap( @@ -167,7 +172,6 @@ class HomeAssistantView extends WatchUi.Menu2 { //! //! @return An array of menu items that need to be updated periodically to reflect the latest Home Assistant state. // - function getItemsToUpdate() as Lang.Array { var fullList = []; var lmi = mItems as Lang.Array; @@ -203,8 +207,8 @@ class HomeAssistantView extends WatchUi.Menu2 { //! Called when this View is brought to the foreground. Restore //! the state of this View and prepare it to be shown. This includes //! loading resources into memory. + // function onShow() as Void {} - } //! Delegate for the HomeAssistantView. @@ -272,18 +276,13 @@ class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate { var haItem = item as HomeAssistantNumericMenuItem; // System.println(haItem.getLabel() + " " + haItem.getId()); // create new view to select new value - - var mPickerFactory = new HomeAssistantNumericFactory(haItem.getData()); - - var mPicker = new HomeAssistantNumericPicker(mPickerFactory,haItem); + var mPickerFactory = new HomeAssistantNumericFactory((haItem as HomeAssistantNumericMenuItem).getPicker()); + var mPicker = new HomeAssistantNumericPicker(mPickerFactory,haItem); var mPickerDelegate = new HomeAssistantNumericPickerDelegate(mPicker); WatchUi.pushView(mPicker,mPickerDelegate,WatchUi.SLIDE_LEFT); } else if (item instanceof HomeAssistantGroupMenuItem) { var haMenuItem = item as HomeAssistantGroupMenuItem; - // System.println("IconMenu: " + haMenuItem.getLabel() + " " + haMenuItem.getId()); WatchUi.pushView(haMenuItem.getMenuView(), new HomeAssistantViewDelegate(false), WatchUi.SLIDE_LEFT); - // } else { - // System.println(item.getLabel() + " " + item.getId()); } }