From b48102f9a6b1dc50ef345f5e51498500f170efd8 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Tue, 15 Oct 2024 19:59:04 +0200 Subject: [PATCH 01/19] add pin confirmation --- README.md | 3 +- config.schema.json | 9 ++ examples/Templates.md | 24 ++++ source/HomeAssistantMenuItemFactory.mc | 5 +- source/HomeAssistantPinConfirmation.mc | 161 +++++++++++++++++++++++++ source/HomeAssistantTapMenuItem.mc | 12 +- source/HomeAssistantView.mc | 4 +- 7 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 source/HomeAssistantPinConfirmation.mc diff --git a/README.md b/README.md index c639263..1c75178 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,8 @@ Example schema: "name": "TV Lights Scene", "type": "tap", "tap_action": { - "service": "scene.turn_on" + "service": "scene.turn_on", + "pin": "1234" } } ] diff --git a/config.schema.json b/config.schema.json index 0f4d6ec..a107054 100644 --- a/config.schema.json +++ b/config.schema.json @@ -217,6 +217,9 @@ "type": "object", "title": "Your services's parameters", "description": "The object containing the parameters and their values to be passed to the entity. No schema checking can be done here, you are on your own! On application crash, remove the parameters." + }, + "pin": { + "$ref": "#/$defs/pin" } }, "required": ["service"] @@ -230,6 +233,12 @@ "default": false, "title": "Confirmation", "description": "Optional confirmation of the action before execution as a precaution." + }, + "pin": { + "title": "Confirmation PIN", + "type": "string", + "pattern": "^[1-4]+$", + "description": "Optional confirmation PIN to be entered before execution as a simple security measure." } } } diff --git a/examples/Templates.md b/examples/Templates.md index c6ec771..3d75342 100644 --- a/examples/Templates.md +++ b/examples/Templates.md @@ -118,6 +118,30 @@ Note: Only when you use the `tap_action` field do you also need to include the ` } ``` +In order to provide at least some kind of additional security, users with touch devices can now choose to use a PIN confirmation when using the `tap_action` field. Please be aware that the dashboard configuration is hosted on your Home assistant server without authentication, making the PIN publicly available for everyone accessing your configuration. +The PIN can be a string of arbitrary length consisting only of the digits 1-4. + +```json +{ + "entity": "cover.garage_door", + "name": "Garage Door", + "type": "template", + "content": "{% if is_state('binary_sensor.garage_connected', 'on') %}{{state_translated('cover.garage_door')}} - {{state_attr('cover.garage_door', 'current_position')}}%{%else%}Unconnected{% endif %}", + "tap_action": { + "service": "cover.toggle", + "pin": "1234" + } +} +``` + +In order to further strengthen the PIN, you can add a PIN mask to your app settings (in Garmin IQ). This PIN mask will transcode each digit of your PIN on the Garmin device, effectively scrambling the publicly available PIN. Think of it like turning the discs of a combination lock n times for each digit at the same position as the mask digit. If no mask is provided or if the mask is shorter than the pin, the corresponding digits won't be transcoded. + +``` +PIN: 1234 + |||| -> PIN to be entered: 3311 +Mask: 2121 +``` + ## Group and Toggle Menu Items Both `group` and `toggle` menu items accept an optional `content` field as of v2.19. This allows the use of templates to present status information. diff --git a/source/HomeAssistantMenuItemFactory.mc b/source/HomeAssistantMenuItemFactory.mc index e522820..a0c3e44 100644 --- a/source/HomeAssistantMenuItemFactory.mc +++ b/source/HomeAssistantMenuItemFactory.mc @@ -85,7 +85,8 @@ class HomeAssistantMenuItemFactory { template as Lang.String or Null, service as Lang.String or Null, confirm as Lang.Boolean, - data as Lang.Dictionary or Null + data as Lang.Dictionary or Null, + pin as Lang.String or Null ) as WatchUi.MenuItem { if (entity != null) { if (data == null) { @@ -100,6 +101,7 @@ class HomeAssistantMenuItemFactory { template, service, confirm, + pin, data, mTapTypeIcon, mMenuItemOptions, @@ -111,6 +113,7 @@ class HomeAssistantMenuItemFactory { template, service, confirm, + pin, data, mInfoTypeIcon, mMenuItemOptions, diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc new file mode 100644 index 0000000..b2a5327 --- /dev/null +++ b/source/HomeAssistantPinConfirmation.mc @@ -0,0 +1,161 @@ +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.WatchUi; +import Toybox.Timer; +import Toybox.Attention; + +class PinDigit extends WatchUi.Selectable { + + private var mDigit as Number; + + function initialize(digit as Number, halfX as Number, halfY as Number) { + var margin = 40; + var x = (digit % 2 == 1) ? 0 + margin : halfX + margin; // place even numbers in right half, odd in left half + var y = (digit < 3) ? 0 + margin : halfY + margin; // place 1&2 on top half, 3&4 on bottom half + var width = halfX - 2 * margin; + var height = halfY - 2 * margin; + + // build text area + var textArea = new WatchUi.TextArea({ + :text=>digit.format("%d"), + :color=>Graphics.COLOR_WHITE, + :font=>[Graphics.FONT_NUMBER_THAI_HOT, Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_MILD], + :width=>width, + :height=>height, + :justification=>Graphics.TEXT_JUSTIFY_CENTER + }); + + // initialize selectable + Selectable.initialize({ + :stateDefault=>textArea, + :locX =>x, + :locY=>y, + :width=>width, + :height=>height + }); + + mDigit = digit; + + } + + function getDigit() as Number { + return mDigit; + } + +} + +class HomeAssistantPinConfirmationView extends WatchUi.View { + + function initialize() { + View.initialize(); + } + + function onLayout(dc as Dc) as Void { + var halfX = dc.getWidth()/2; + var halfY = dc.getHeight()/2; + // draw digits + setLayout([ + new PinDigit(1, halfX, halfY), + new PinDigit(2, halfX, halfY), + new PinDigit(3, halfX, halfY), + new PinDigit(4, halfX, halfY) + ]); + } + + function onUpdate(dc as Dc) as Void { + View.onUpdate(dc); + // draw cross + var halfX = dc.getWidth()/2; + var halfY = dc.getHeight()/2; + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); + dc.drawRectangle(halfX, dc.getHeight() * 0.1, 2, dc.getHeight() * 0.8); + dc.drawRectangle(dc.getWidth() * 0.1, halfY, dc.getWidth() * 0.8, 2); + } + +} + + +class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { + + private var mPin as Array; + private var mCurrentIndex as Number; + 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; + + function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean, pin as String) { + BehaviorDelegate.initialize(); + mPin = pin.toCharArray(); + mCurrentIndex = 0; + mConfirmMethod = callback; + mState = state; + resetTimer(); + } + + function onSelectable(event as SelectableEvent) as Boolean { + var instance = event.getInstance(); + if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) { + var currentDigit = getTranscodedCurrentDigit(); + if (currentDigit != null && currentDigit == instance.getDigit()) { + // System.println("Pin digit " + (mCurrentIndex+1) + " matches"); + if (mCurrentIndex == mPin.size()-1) { + getApp().getQuitTimer().reset(); + if (mTimer != null) { + mTimer.stop(); + } + mConfirmMethod.invoke(mState); + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } else { + mCurrentIndex++; + resetTimer(); + } + } else { + // System.println("Pin digit " + (mCurrentIndex+1) + " doesn't match"); + // TODO: add maxFailures counter & protection + error(); + } + } + return true; + } + + function getTranscodedCurrentDigit() as Number { + var currentDigit = mPin[mCurrentIndex].toString().toNumber(); // this is ugly, but apparently the only way for char<->number comparisons + // TODO: Transcode digit using a pin mask for additional security + return currentDigit; + } + + function resetTimer() { + var timeout = Settings.getConfirmTimeout(); // ms + if (timeout > 0) { + if (mTimer != null) { + mTimer.stop(); + } else { + mTimer = new Timer.Timer(); + } + mTimer.start(method(:goBack), timeout, true); + } + } + + function goBack() as Void { + if (mTimer != null) { + mTimer.stop(); + } + WatchUi.popView(WatchUi.SLIDE_RIGHT); + } + + function error() as Void { + if (Attention has :vibrate && Settings.getVibrate()) { + Attention.vibrate([ + new Attention.VibeProfile(100, 100), + new Attention.VibeProfile(0, 200), + new Attention.VibeProfile(75, 100), + new Attention.VibeProfile(0, 200), + new Attention.VibeProfile(50, 100), + new Attention.VibeProfile(0, 200), + new Attention.VibeProfile(25, 100) + ]); + } + goBack(); + } + +} \ No newline at end of file diff --git a/source/HomeAssistantTapMenuItem.mc b/source/HomeAssistantTapMenuItem.mc index 17f861b..3445cef 100644 --- a/source/HomeAssistantTapMenuItem.mc +++ b/source/HomeAssistantTapMenuItem.mc @@ -28,12 +28,14 @@ class HomeAssistantTapMenuItem extends WatchUi.IconMenuItem { private var mService as Lang.String or Null; private var mConfirm as Lang.Boolean; private var mData as Lang.Dictionary or Null; + private var mPin as Lang.String or Null; function initialize( label as Lang.String or Lang.Symbol, template as Lang.String, service as Lang.String or Null, confirm as Lang.Boolean, + pin as Lang.String or Null, data as Lang.Dictionary or Null, icon as Graphics.BitmapType or WatchUi.Drawable, options as { @@ -54,6 +56,7 @@ class HomeAssistantTapMenuItem extends WatchUi.IconMenuItem { mService = service; mConfirm = confirm; mData = data; + mPin = pin; } function hasTemplate() as Lang.Boolean { @@ -84,7 +87,14 @@ class HomeAssistantTapMenuItem extends WatchUi.IconMenuItem { } function callService() as Void { - if (mConfirm) { + var hasTouchScreen = System.getDeviceSettings().isTouchScreen; + if (mPin != null && hasTouchScreen) { + WatchUi.pushView( + new HomeAssistantPinConfirmationView(), + new HomeAssistantPinConfirmationDelegate(method(:onConfirm), false, mPin), + WatchUi.SLIDE_IMMEDIATE + ); + } else if (mConfirm || (mPin!=null && !hasTouchScreen)) { WatchUi.pushView( new HomeAssistantConfirmation(), new HomeAssistantConfirmationDelegate(method(:onConfirm), false), diff --git a/source/HomeAssistantView.mc b/source/HomeAssistantView.mc index 1f75f32..f6ceb63 100644 --- a/source/HomeAssistantView.mc +++ b/source/HomeAssistantView.mc @@ -52,10 +52,12 @@ class HomeAssistantView extends WatchUi.Menu2 { var service = items[i].get("service") as Lang.String or Null; // Deprecated schema var confirm = false as Lang.Boolean or Null; var data = null as Lang.Dictionary or Null; + var pin = null as Lang.String or Null; if (tap_action != null) { service = tap_action.get("service"); confirm = tap_action.get("confirm"); // Optional data = tap_action.get("data"); // Optional + pin = tap_action.get("pin"); // Optional if (confirm == null) { confirm = false; } @@ -64,7 +66,7 @@ class HomeAssistantView extends WatchUi.Menu2 { if (type.equals("toggle") && entity != null) { addItem(HomeAssistantMenuItemFactory.create().toggle(name, entity, content, confirm)); } else if ((type.equals("tap") && service != null) || (type.equals("template") && content != null)) { - addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, confirm, data)); + addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, confirm, data, pin)); } else if (type.equals("group")) { addItem(HomeAssistantMenuItemFactory.create().group(items[i], content)); } From c592726bd4f6f8a13793f87cedeea40f2d75e046 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Tue, 15 Oct 2024 20:27:52 +0200 Subject: [PATCH 02/19] add PIN transcoding --- resources-deu/strings/strings.xml | 1 + resources/settings/properties.xml | 8 ++++++++ resources/settings/settings.xml | 7 +++++++ resources/strings/strings.xml | 1 + source/HomeAssistantPinConfirmation.mc | 15 +++++++++++---- source/Settings.mc | 6 ++++++ 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/resources-deu/strings/strings.xml b/resources-deu/strings/strings.xml index f6a21d3..d0d9620 100644 --- a/resources-deu/strings/strings.xml +++ b/resources-deu/strings/strings.xml @@ -60,6 +60,7 @@ Zusätzliche Abfrageverzögerung (in Sekunden). Fügt eine Verzögerung zwischen der Statusaktualisierung aller Menüelemente hinzu. Nach dieser Zeit (in Sekunden) wird der Bestätigungsdialog einer Aktion geschlossen und die Aktion abgebrochen. Auf 0 setzen, um den Timeout zu deaktivieren. + Schablone um die PIN aus der Dashboard Config zu transkodieren. Menüausrichtung links (aus) oder rechts (ein). Links nach rechts Rechts nach links diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index 06b7fbb..d57cd39 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -65,6 +65,14 @@ --> 3 + + + diff --git a/resources/settings/settings.xml b/resources/settings/settings.xml index 346ad84..ac03988 100644 --- a/resources/settings/settings.xml +++ b/resources/settings/settings.xml @@ -79,6 +79,13 @@ + + + + Timeout in seconds. Exit the application after this period of inactivity to save the device battery. Additional poll delay (in seconds). Adds a delay between the status update of all menu items. After this time (in seconds), a confirmation dialog for an action is automatically closed and the action is cancelled. Set to 0 to disable the timeout. + Integer Mask to transcode the plain text PIN from the public dashboard config. Left (off) or Right (on) Menu Alignment. Left to right Right to Left diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index b2a5327..b818dd3 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -118,10 +118,17 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { return true; } - function getTranscodedCurrentDigit() as Number { - var currentDigit = mPin[mCurrentIndex].toString().toNumber(); // this is ugly, but apparently the only way for char<->number comparisons - // TODO: Transcode digit using a pin mask for additional security - return currentDigit; + function getTranscodedCurrentDigit() as Number or Null { + var currentDigit = mPin[mCurrentIndex].toString().toNumber(); // this is ugly, but apparently the only way for char<->number conversions + if (currentDigit == null) { + return null; + } + var pinMask = Settings.getPinMask(); + var maskDigit = pinMask.substring(mCurrentIndex, mCurrentIndex+1).toNumber(); + if (maskDigit == null) { + return currentDigit; + } + return ((currentDigit + maskDigit - 1) % 4) + 1; } function resetTimer() { diff --git a/source/Settings.mc b/source/Settings.mc index 1bb7520..3a4d9b7 100644 --- a/source/Settings.mc +++ b/source/Settings.mc @@ -38,6 +38,7 @@ class Settings { private static var mAppTimeout as Lang.Number = 0; // seconds private static var mPollDelay as Lang.Number = 0; // seconds private static var mConfirmTimeout as Lang.Number = 3; // seconds + private static var mPinMask as Lang.String = ""; 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 mBatteryRefreshRate as Lang.Number = 15; // minutes @@ -59,6 +60,7 @@ class Settings { mAppTimeout = Properties.getValue("app_timeout"); mPollDelay = Properties.getValue("poll_delay_combined"); mConfirmTimeout = Properties.getValue("confirm_timeout"); + mPinMask = Properties.getValue("pin_mask"); mMenuAlignment = Properties.getValue("menu_alignment"); mIsSensorsLevelEnabled = Properties.getValue("enable_battery_level"); mBatteryRefreshRate = Properties.getValue("battery_level_refresh_rate"); @@ -164,6 +166,10 @@ class Settings { return mConfirmTimeout * 1000; // Convert to milliseconds } + static function getPinMask() as Lang.String { + return mPinMask; + } + static function getMenuAlignment() as Lang.Number { return mMenuAlignment; // Either WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_RIGHT or WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT } From e21ab79d5d1ca3ace12ca730effb54791667d197 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Wed, 16 Oct 2024 15:58:37 +0200 Subject: [PATCH 03/19] add PIN lock after exceeding failure count --- examples/Templates.md | 1 + source/HomeAssistantPinConfirmation.mc | 99 +++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/examples/Templates.md b/examples/Templates.md index 3d75342..168c8af 100644 --- a/examples/Templates.md +++ b/examples/Templates.md @@ -120,6 +120,7 @@ Note: Only when you use the `tap_action` field do you also need to include the ` In order to provide at least some kind of additional security, users with touch devices can now choose to use a PIN confirmation when using the `tap_action` field. Please be aware that the dashboard configuration is hosted on your Home assistant server without authentication, making the PIN publicly available for everyone accessing your configuration. The PIN can be a string of arbitrary length consisting only of the digits 1-4. +The user has 5 attempts to provide a valid PIN within 2 minutes. If too many failures have been detected in this time, the PIN dialog will be locked for 10 minutes. ```json { diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index b818dd3..26d18f4 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -1,8 +1,29 @@ +//----------------------------------------------------------------------------------- +// +// 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 & Someone0nEarth, 31 October 2023 +// +// +// Description: +// +// Pin Confirmation dialog and logic. +// +//----------------------------------------------------------------------------------- + import Toybox.Graphics; import Toybox.Lang; import Toybox.WatchUi; import Toybox.Timer; import Toybox.Attention; +import Toybox.Time; class PinDigit extends WatchUi.Selectable { @@ -82,23 +103,32 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { 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 mFailures as PinFailures; function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean, pin as String) { BehaviorDelegate.initialize(); + mFailures = new PinFailures(); + if (mFailures.isLocked()) { + WatchUi.showToast("PIN input locked for " + mFailures.getLockedUntilSeconds() + " seconds", {}); + } mPin = pin.toCharArray(); - mCurrentIndex = 0; + mCurrentIndex = 0; mConfirmMethod = callback; mState = state; resetTimer(); } function onSelectable(event as SelectableEvent) as Boolean { + if (mFailures.isLocked()) { + goBack(); + } var instance = event.getInstance(); if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) { var currentDigit = getTranscodedCurrentDigit(); if (currentDigit != null && currentDigit == instance.getDigit()) { // System.println("Pin digit " + (mCurrentIndex+1) + " matches"); if (mCurrentIndex == mPin.size()-1) { + mFailures.reset(); getApp().getQuitTimer().reset(); if (mTimer != null) { mTimer.stop(); @@ -111,7 +141,6 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { } } else { // System.println("Pin digit " + (mCurrentIndex+1) + " doesn't match"); - // TODO: add maxFailures counter & protection error(); } } @@ -151,6 +180,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { } function error() as Void { + mFailures.addFailure(); if (Attention has :vibrate && Settings.getVibrate()) { Attention.vibrate([ new Attention.VibeProfile(100, 100), @@ -165,4 +195,69 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { goBack(); } +} + +class PinFailures { + + const MAX_FAILURES as Number = 5; // maximum number of failed pin confirmation attemps allwed in ... + const MAX_FAILURE_MINUTES as Number = 2; // ... this number of minutes before pin confirmation is locked for ... + const LOCK_TIME_MINUTES as Number = 10; // ... this number of minutes + + const STORAGE_KEY_FAILURES as String = "pin_failures"; + const STORAGE_KEY_LOCKED as String = "pin_locked"; + + private var mFailures as Array; + private var mLockedUntil as Number or Null; + + function initialize() { + // System.println("Initializing PIN failures from storage"); + var failures = Application.Storage.getValue(PinFailures.STORAGE_KEY_FAILURES); + mFailures = (failures == null) ? [] : failures; + mLockedUntil = Application.Storage.getValue(PinFailures.STORAGE_KEY_LOCKED); + } + + function addFailure() { + mFailures.add(Time.now().value()); + // System.println(mFailures.size() + " PIN confirmation failures recorded"); + if (mFailures.size() >= MAX_FAILURES) { + // System.println("Too many failures detected"); + var oldestFailureOutdate = new Time.Moment(mFailures[0]).add(new Time.Duration(MAX_FAILURE_MINUTES * 60)); + // System.println("Oldest failure: " + oldestFailureOutdate.value() + " Now:" + Time.now().value()); + if (new Time.Moment(Time.now().value()).greaterThan(oldestFailureOutdate)) { + // System.println("Pruning oldest outdated failure"); + mFailures = mFailures.slice(1, null); + } else { + mFailures = []; + mLockedUntil = Time.now().add(new Time.Duration(LOCK_TIME_MINUTES * Gregorian.SECONDS_PER_MINUTE)).value(); + Application.Storage.setValue(STORAGE_KEY_LOCKED, mLockedUntil); + // System.println("Locked until " + mLockedUntil); + } + } + Application.Storage.setValue(STORAGE_KEY_FAILURES, mFailures); + } + + function reset() { + // System.println("Resetting failures"); + mFailures = []; + mLockedUntil = null; + Application.Storage.deleteValue(STORAGE_KEY_FAILURES); + Application.Storage.deleteValue(STORAGE_KEY_LOCKED); + } + + function getLockedUntilSeconds() as Number { + return new Time.Moment(mLockedUntil).subtract(Time.now()).value(); + } + + function isLocked() as Boolean { + if (mLockedUntil == null) { + return false; + } + var isLocked = new Time.Moment(Time.now().value()).lessThan(new Time.Moment(mLockedUntil)); + if (!isLocked) { + mLockedUntil = null; + Application.Storage.deleteValue(STORAGE_KEY_LOCKED); + } + return isLocked; + } + } \ No newline at end of file From 86943aa66417045fa476deccee0a59b38da23171 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Wed, 16 Oct 2024 15:59:44 +0200 Subject: [PATCH 04/19] add mini vibration feedback when tapping pin digits --- source/HomeAssistantPinConfirmation.mc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index 26d18f4..3003708 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -124,6 +124,9 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { } var instance = event.getInstance(); if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) { + if (Attention has :vibrate && Settings.getVibrate()) { + Attention.vibrate([new Attention.VibeProfile(25, 25)]); + } var currentDigit = getTranscodedCurrentDigit(); if (currentDigit != null && currentDigit == instance.getDigit()) { // System.println("Pin digit " + (mCurrentIndex+1) + " matches"); From cd5ed317c51f1aabf5f7ab4da72f047316019f95 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Wed, 16 Oct 2024 16:45:55 +0200 Subject: [PATCH 05/19] move pin mask translation to corrections --- resources-deu/strings/corrections.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/resources-deu/strings/corrections.xml b/resources-deu/strings/corrections.xml index bea1d18..902af07 100644 --- a/resources-deu/strings/corrections.xml +++ b/resources-deu/strings/corrections.xml @@ -39,6 +39,7 @@ des Geräts zu schonen. Nach dieser Zeit (in Sekunden) wird der Bestätigungsdialog einer Aktion geschlossen und die Aktion abgebrochen. Auf 0 setzen, um den Timeout zu deaktivieren. + Schablone um die PIN aus der Dashboard Config zu transkodieren. (Nur Widget) Anwendung automatisch über das Widget starten ohne drauftippen zu müssen. Hintergrunddienst aktivieren, um den Ladezustand der Batterie an HomeAssistant zu senden. Die Aktualisierungsrate (in Minuten) mit der der Ladezustand der Batterie From fb2bb7f566d54b49e0cc6329d136763369ccdea6 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Mon, 11 Nov 2024 20:06:35 +0100 Subject: [PATCH 06/19] remove transcoding, move pin to settings, remove pin from schema --- config.schema.json | 9 ------- resources-deu/strings/corrections.xml | 2 +- resources-deu/strings/strings.xml | 1 - resources/settings/properties.xml | 8 +++---- resources/settings/settings.xml | 4 ++-- resources/strings/strings.xml | 2 +- source/HomeAssistantMenuItemFactory.mc | 5 +--- source/HomeAssistantPinConfirmation.mc | 17 ++----------- source/HomeAssistantTapMenuItem.mc | 33 +++++++++++++------------- source/HomeAssistantView.mc | 4 +--- source/Settings.mc | 8 +++---- 11 files changed, 32 insertions(+), 61 deletions(-) diff --git a/config.schema.json b/config.schema.json index a107054..0f4d6ec 100644 --- a/config.schema.json +++ b/config.schema.json @@ -217,9 +217,6 @@ "type": "object", "title": "Your services's parameters", "description": "The object containing the parameters and their values to be passed to the entity. No schema checking can be done here, you are on your own! On application crash, remove the parameters." - }, - "pin": { - "$ref": "#/$defs/pin" } }, "required": ["service"] @@ -233,12 +230,6 @@ "default": false, "title": "Confirmation", "description": "Optional confirmation of the action before execution as a precaution." - }, - "pin": { - "title": "Confirmation PIN", - "type": "string", - "pattern": "^[1-4]+$", - "description": "Optional confirmation PIN to be entered before execution as a simple security measure." } } } diff --git a/resources-deu/strings/corrections.xml b/resources-deu/strings/corrections.xml index 902af07..60d2253 100644 --- a/resources-deu/strings/corrections.xml +++ b/resources-deu/strings/corrections.xml @@ -39,7 +39,7 @@ des Geräts zu schonen. Nach dieser Zeit (in Sekunden) wird der Bestätigungsdialog einer Aktion geschlossen und die Aktion abgebrochen. Auf 0 setzen, um den Timeout zu deaktivieren. - Schablone um die PIN aus der Dashboard Config zu transkodieren. + PIN für alle Actions mit 'confirm': true. (Nur Widget) Anwendung automatisch über das Widget starten ohne drauftippen zu müssen. Hintergrunddienst aktivieren, um den Ladezustand der Batterie an HomeAssistant zu senden. Die Aktualisierungsrate (in Minuten) mit der der Ladezustand der Batterie diff --git a/resources-deu/strings/strings.xml b/resources-deu/strings/strings.xml index d0d9620..f6a21d3 100644 --- a/resources-deu/strings/strings.xml +++ b/resources-deu/strings/strings.xml @@ -60,7 +60,6 @@ Zusätzliche Abfrageverzögerung (in Sekunden). Fügt eine Verzögerung zwischen der Statusaktualisierung aller Menüelemente hinzu. Nach dieser Zeit (in Sekunden) wird der Bestätigungsdialog einer Aktion geschlossen und die Aktion abgebrochen. Auf 0 setzen, um den Timeout zu deaktivieren. - Schablone um die PIN aus der Dashboard Config zu transkodieren. Menüausrichtung links (aus) oder rechts (ein). Links nach rechts Rechts nach links diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index d57cd39..356dc24 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -66,12 +66,10 @@ 3 - + Select... diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index 634ff85..db0f37d 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -109,7 +109,10 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { BehaviorDelegate.initialize(); mFailures = new PinFailures(); if (mFailures.isLocked()) { - WatchUi.showToast("PIN input locked for " + mFailures.getLockedUntilSeconds() + " seconds", {}); + var msg = WatchUi.loadResource($.Rez.Strings.PinInputLocked) + " " + + mFailures.getLockedUntilSeconds() + " " + + WatchUi.loadResource($.Rez.Strings.Seconds); + WatchUi.showToast(msg, {}); } mPin = pin.toCharArray(); mCurrentIndex = 0; From b13fb7e953aec7f1c6c3d5c0349d92b99c5fc837 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Mon, 11 Nov 2024 20:58:09 +0100 Subject: [PATCH 08/19] validate pin after full length of pin has been entered --- source/HomeAssistantPinConfirmation.mc | 23 +++++++++++------------ source/HomeAssistantTapMenuItem.mc | 1 - 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index db0f37d..902d0ed 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -98,8 +98,8 @@ class HomeAssistantPinConfirmationView extends WatchUi.View { class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { - private var mPin as Array; - private var mCurrentIndex as Number; + private var mPin as String; + private var mEnteredPin as String; 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; @@ -114,8 +114,8 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { WatchUi.loadResource($.Rez.Strings.Seconds); WatchUi.showToast(msg, {}); } - mPin = pin.toCharArray(); - mCurrentIndex = 0; + mPin = pin; + mEnteredPin = ""; mConfirmMethod = callback; mState = state; resetTimer(); @@ -130,10 +130,10 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { if (Attention has :vibrate && Settings.getVibrate()) { Attention.vibrate([new Attention.VibeProfile(25, 25)]); } - var currentDigit = mPin[mCurrentIndex].toString().toNumber(); // this is ugly, but apparently the only way for char<->number conversions - if (currentDigit != null && currentDigit == instance.getDigit()) { - // System.println("Pin digit " + (mCurrentIndex+1) + " matches"); - if (mCurrentIndex == mPin.size()-1) { + mEnteredPin += instance.getDigit(); + // System.println("HomeAssitantPinConfirmationDelegate onSelectable() mEnteredPin = " + mEnteredPin); + if (mEnteredPin.length() == mPin.length()) { + if (mEnteredPin.equals(mPin)) { mFailures.reset(); getApp().getQuitTimer().reset(); if (mTimer != null) { @@ -142,12 +142,10 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { mConfirmMethod.invoke(mState); WatchUi.popView(WatchUi.SLIDE_RIGHT); } else { - mCurrentIndex++; - resetTimer(); + error(); } } else { - // System.println("Pin digit " + (mCurrentIndex+1) + " doesn't match"); - error(); + resetTimer(); } } return true; @@ -173,6 +171,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { } function error() as Void { + // System.println("HomeAssistantPinConfirmationDelegate error()"); mFailures.addFailure(); if (Attention has :vibrate && Settings.getVibrate()) { Attention.vibrate([ diff --git a/source/HomeAssistantTapMenuItem.mc b/source/HomeAssistantTapMenuItem.mc index 5689657..677823b 100644 --- a/source/HomeAssistantTapMenuItem.mc +++ b/source/HomeAssistantTapMenuItem.mc @@ -87,7 +87,6 @@ class HomeAssistantTapMenuItem extends WatchUi.IconMenuItem { if (mConfirm) { var hasTouchScreen = System.getDeviceSettings().isTouchScreen; var pin = Settings.getPin(); - System.println("HomeAsistantTemplateMenuItem callService() pin = '" + pin + "'"); if (!hasTouchScreen || "".equals(pin)) { WatchUi.pushView( new HomeAssistantConfirmation(), From 73219c686481d466786db0dc8d0ab802d6978700 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Mon, 11 Nov 2024 21:06:38 +0100 Subject: [PATCH 09/19] add class/method to debug messages --- source/HomeAssistantPinConfirmation.mc | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index 902d0ed..f363ebe 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -171,7 +171,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { } function error() as Void { - // System.println("HomeAssistantPinConfirmationDelegate error()"); + // System.println("HomeAssistantPinConfirmationDelegate error() Wrong PIN entered"); mFailures.addFailure(); if (Attention has :vibrate && Settings.getVibrate()) { Attention.vibrate([ @@ -202,7 +202,7 @@ class PinFailures { private var mLockedUntil as Number or Null; function initialize() { - // System.println("Initializing PIN failures from storage"); + // System.println("PinFailures initialize() Initializing PIN failures from storage"); var failures = Application.Storage.getValue(PinFailures.STORAGE_KEY_FAILURES); mFailures = (failures == null) ? [] : failures; mLockedUntil = Application.Storage.getValue(PinFailures.STORAGE_KEY_LOCKED); @@ -210,26 +210,26 @@ class PinFailures { function addFailure() { mFailures.add(Time.now().value()); - // System.println(mFailures.size() + " PIN confirmation failures recorded"); + // System.println("PinFailures addFailure() " + mFailures.size() + " PIN confirmation failures recorded"); if (mFailures.size() >= MAX_FAILURES) { - // System.println("Too many failures detected"); + // System.println("PinFailures addFailure() Too many failures detected"); var oldestFailureOutdate = new Time.Moment(mFailures[0]).add(new Time.Duration(MAX_FAILURE_MINUTES * 60)); - // System.println("Oldest failure: " + oldestFailureOutdate.value() + " Now:" + Time.now().value()); + // System.println("PinFailures addFailure() Oldest failure: " + oldestFailureOutdate.value() + " Now:" + Time.now().value()); if (new Time.Moment(Time.now().value()).greaterThan(oldestFailureOutdate)) { - // System.println("Pruning oldest outdated failure"); + // System.println("PinFailures addFailure() Pruning oldest outdated failure"); mFailures = mFailures.slice(1, null); } else { mFailures = []; mLockedUntil = Time.now().add(new Time.Duration(LOCK_TIME_MINUTES * Gregorian.SECONDS_PER_MINUTE)).value(); Application.Storage.setValue(STORAGE_KEY_LOCKED, mLockedUntil); - // System.println("Locked until " + mLockedUntil); + // System.println("PinFailures addFailure() Locked until " + mLockedUntil); } } Application.Storage.setValue(STORAGE_KEY_FAILURES, mFailures); } function reset() { - // System.println("Resetting failures"); + // System.println("PinFailures reset() Resetting failures"); mFailures = []; mLockedUntil = null; Application.Storage.deleteValue(STORAGE_KEY_FAILURES); From 0ac8e11287978c9dd36fb35a26d0394a546e8d46 Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Tue, 12 Nov 2024 19:24:15 +0100 Subject: [PATCH 10/19] use full numpad, validate 4-digit pin, add visual feedback on click, show toast on error --- resources-deu/strings/corrections.xml | 3 +- resources/strings/strings.xml | 4 +- source/HomeAssistantPinConfirmation.mc | 116 ++++++++++++++++++------- source/HomeAssistantTapMenuItem.mc | 9 +- 4 files changed, 96 insertions(+), 36 deletions(-) diff --git a/resources-deu/strings/corrections.xml b/resources-deu/strings/corrections.xml index 60d2253..330a996 100644 --- a/resources-deu/strings/corrections.xml +++ b/resources-deu/strings/corrections.xml @@ -39,7 +39,8 @@ des Geräts zu schonen. Nach dieser Zeit (in Sekunden) wird der Bestätigungsdialog einer Aktion geschlossen und die Aktion abgebrochen. Auf 0 setzen, um den Timeout zu deaktivieren. - PIN für alle Actions mit 'confirm': true. + 4-stellige PIN für alle Actions mit 'confirm': true (0000-9999). + Bitte eine gültige 4-stellige numerische PIN in den App Einstellungen eingeben (0000-4444). (Nur Widget) Anwendung automatisch über das Widget starten ohne drauftippen zu müssen. Hintergrunddienst aktivieren, um den Ladezustand der Batterie an HomeAssistant zu senden. Die Aktualisierungsrate (in Minuten) mit der der Ladezustand der Batterie diff --git a/resources/strings/strings.xml b/resources/strings/strings.xml index 68d52b2..f558a53 100644 --- a/resources/strings/strings.xml +++ b/resources/strings/strings.xml @@ -42,6 +42,7 @@ Potential Error PIN input locked for seconds + Wrong PIN Select... @@ -55,7 +56,8 @@ Timeout in seconds. Exit the application after this period of inactivity to save the device battery. Additional poll delay (in seconds). Adds a delay between the status update of all menu items. After this time (in seconds), a confirmation dialog for an action is automatically closed and the action is cancelled. Set to 0 to disable the timeout. - PIN to be used for all actions that require confirmation. + 4-digit PIN to be used for all actions that require confirmation (0000-9999). + Please configure a valid 4-digit numeric PIN between 0000 and 9999 in the application settings. Left (off) or Right (on) Menu Alignment. Left to right Right to Left diff --git a/source/HomeAssistantPinConfirmation.mc b/source/HomeAssistantPinConfirmation.mc index f363ebe..34cbfcc 100644 --- a/source/HomeAssistantPinConfirmation.mc +++ b/source/HomeAssistantPinConfirmation.mc @@ -29,26 +29,33 @@ class PinDigit extends WatchUi.Selectable { private var mDigit as Number; - function initialize(digit as Number, halfX as Number, halfY as Number) { - var margin = 40; - var x = (digit % 2 == 1) ? 0 + margin : halfX + margin; // place even numbers in right half, odd in left half - var y = (digit < 3) ? 0 + margin : halfY + margin; // place 1&2 on top half, 3&4 on bottom half - var width = halfX - 2 * margin; - var height = halfY - 2 * margin; + function initialize(digit as Number, stepX as Number, stepY as Number) { + var marginX = stepX * 0.05; // 5% margin on all sides + var marginY = stepY * 0.05; + var x = (digit == 0) ? stepX : stepX * ((digit+2) % 3); // layout '0' in 2nd col, others ltr in 3 columns + x += marginX + HomeAssistantPinConfirmationView.MARGIN_X; + var y = (digit == 0) ? stepY * 4 : (digit <= 3) ? stepY : (digit <=6) ? stepY * 2 : stepY * 3; // layout '0' in bottom row (5), others top to bottom in 3 rows (2-4) (row 1 is reserved for masked pin) + y += marginY; + var width = stepX - (marginX * 2); + var height = stepY - (marginY * 2); - // build text area - var textArea = new WatchUi.TextArea({ - :text=>digit.format("%d"), - :color=>Graphics.COLOR_WHITE, - :font=>[Graphics.FONT_NUMBER_THAI_HOT, Graphics.FONT_NUMBER_HOT, Graphics.FONT_NUMBER_MEDIUM, Graphics.FONT_NUMBER_MILD], + var button = new PinDigitButton({ :width=>width, :height=>height, - :justification=>Graphics.TEXT_JUSTIFY_CENTER + :label=>digit + }); + + var buttonTouched = new PinDigitButton({ + :width=>width, + :height=>height, + :label=>digit, + :touched=>true }); // initialize selectable Selectable.initialize({ - :stateDefault=>textArea, + :stateDefault=>button, + :stateHighlighted=>buttonTouched, :locX =>x, :locY=>y, :width=>width, @@ -63,34 +70,69 @@ class PinDigit extends WatchUi.Selectable { return mDigit; } + class PinDigitButton extends WatchUi.Drawable { + private var mText as Number; + private var mTouched as Boolean = false; + + function initialize(options) { + Drawable.initialize(options); + mText = options.get(:label); + mTouched = options.get(:touched); + } + + function draw(dc) { + if (mTouched) { + dc.setColor(Graphics.COLOR_ORANGE, Graphics.COLOR_ORANGE); + } else { + dc.setColor(Graphics.COLOR_DK_GRAY, Graphics.COLOR_DK_GRAY); + } + dc.fillCircle(locX + width / 2, locY + height / 2, height / 2); // circle fill + dc.setColor(Graphics.COLOR_LT_GRAY, Graphics.COLOR_LT_GRAY); + dc.setPenWidth(3); + dc.drawCircle(locX + width / 2, locY + height / 2, height / 2); // circle outline + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT); + dc.drawText(locX+width / 2, locY+height / 2, Graphics.FONT_TINY, mText, Graphics.TEXT_JUSTIFY_CENTER|Graphics.TEXT_JUSTIFY_VCENTER); // center text in circle + } + + } + } class HomeAssistantPinConfirmationView extends WatchUi.View { + + static const MARGIN_X = 20; // margin on left & right side of screen (overall prettier and works better on round displays) + + var mPinMask as String = ""; function initialize() { View.initialize(); } function onLayout(dc as Dc) as Void { - var halfX = dc.getWidth()/2; - var halfY = dc.getHeight()/2; + var stepX = (dc.getWidth() - MARGIN_X * 2) / 3; // three columns + var stepY = dc.getHeight() / 5; // five rows (first row for masked pin entry) + var digits = []; + for (var i=0; i<=9; i++) { + digits.add(new PinDigit(i, stepX, stepY)); + } // draw digits - setLayout([ - new PinDigit(1, halfX, halfY), - new PinDigit(2, halfX, halfY), - new PinDigit(3, halfX, halfY), - new PinDigit(4, halfX, halfY) - ]); + setLayout(digits); } function onUpdate(dc as Dc) as Void { View.onUpdate(dc); - // draw cross - var halfX = dc.getWidth()/2; - var halfY = dc.getHeight()/2; - dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); - dc.drawRectangle(halfX, dc.getHeight() * 0.1, 2, dc.getHeight() * 0.8); - dc.drawRectangle(dc.getWidth() * 0.1, halfY, dc.getWidth() * 0.8, 2); + if (mPinMask.length() != 0) { + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); + dc.drawText(dc.getWidth()/2, dc.getHeight()/10, Graphics.FONT_SYSTEM_SMALL, mPinMask, Graphics.TEXT_JUSTIFY_CENTER|Graphics.TEXT_JUSTIFY_VCENTER); + } + } + + function updatePinMask(length as Number) { + mPinMask = ""; + for (var i=0; i PIN to be entered: 3311 -Mask: 2121 -``` - ## Group and Toggle Menu Items Both `group` and `toggle` menu items accept an optional `content` field as of v2.19. This allows the use of templates to present status information. From 97b5371a0ec07855d712aa51fc1e470cbc8714bd Mon Sep 17 00:00:00 2001 From: Matthias Oesterheld Date: Wed, 13 Nov 2024 21:30:36 +0100 Subject: [PATCH 12/19] add `"pin": true` JSON config property, add default pin, add pin confirmation to toggle, adjust JSON schema --- config.schema.json | 12 ++++++++ resources/settings/properties.xml | 6 ++-- source/HomeAssistantMenuItemFactory.mc | 7 ++++- source/HomeAssistantTapMenuItem.mc | 39 +++++++++++++------------- source/HomeAssistantToggleMenuItem.mc | 21 ++++++++++++-- source/HomeAssistantView.mc | 6 ++-- source/Settings.mc | 2 +- 7 files changed, 65 insertions(+), 28 deletions(-) diff --git a/config.schema.json b/config.schema.json index 0f4d6ec..1e70bba 100644 --- a/config.schema.json +++ b/config.schema.json @@ -37,6 +37,9 @@ "properties": { "confirm": { "$ref": "#/$defs/confirm" + }, + "pin": { + "$ref": "#/$defs/pin" } }, "additionalProperties": false @@ -213,6 +216,9 @@ "confirm": { "$ref": "#/$defs/confirm" }, + "pin": { + "$ref": "#/$defs/pin" + }, "data": { "type": "object", "title": "Your services's parameters", @@ -230,6 +236,12 @@ "default": false, "title": "Confirmation", "description": "Optional confirmation of the action before execution as a precaution." + }, + "pin": { + "type": "boolean", + "default": false, + "title": "PIN Confirmation", + "description": "Optional PIN confirmation of the action before execution as a precaution. Has precedence over 'confirm': true if both are set." } } } diff --git a/resources/settings/properties.xml b/resources/settings/properties.xml index 356dc24..a108e2c 100644 --- a/resources/settings/properties.xml +++ b/resources/settings/properties.xml @@ -66,10 +66,10 @@ 3 - + 0000