add pin confirmation

This commit is contained in:
Matthias Oesterheld
2024-10-15 19:59:04 +02:00
parent 04c61981ea
commit b48102f9a6
7 changed files with 214 additions and 4 deletions

View File

@ -131,7 +131,8 @@ Example schema:
"name": "TV Lights Scene",
"type": "tap",
"tap_action": {
"service": "scene.turn_on"
"service": "scene.turn_on",
"pin": "1234"
}
}
]

View File

@ -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."
}
}
}

View File

@ -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.

View File

@ -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,

View File

@ -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<Char>;
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();
}
}

View File

@ -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),

View File

@ -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));
}