mirror of
https://github.com/house-of-abbey/GarminHomeAssistant.git
synced 2025-07-30 16:38:31 +00:00
Reformatted comments to work in VSCode and converted `dict.get(:key)` to `dict[:key]` syntax as its nicer.
404 lines
14 KiB
MonkeyC
404 lines
14 KiB
MonkeyC
//-----------------------------------------------------------------------------------
|
|
//
|
|
// 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 & moesterheld & vincentezw, 31 October 2023
|
|
//
|
|
//-----------------------------------------------------------------------------------
|
|
|
|
using Toybox.Graphics;
|
|
using Toybox.Lang;
|
|
using Toybox.WatchUi;
|
|
using Toybox.Timer;
|
|
using Toybox.Attention;
|
|
using Toybox.Time;
|
|
|
|
//! Pin digit used for number 0..9
|
|
//
|
|
class PinDigit extends WatchUi.Selectable {
|
|
|
|
private var mDigit as Lang.Number;
|
|
|
|
//! Class Constructor
|
|
//!
|
|
//! @param digit The digit this instance of the class represents and to display.
|
|
//! @param stepX Horizontal spacing.
|
|
//! @param stepY Vertical spacing.
|
|
//
|
|
function initialize(digit as Lang.Number, stepX as Lang.Number, stepY as Lang.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);
|
|
|
|
var button = new PinDigitButton({
|
|
:width => width,
|
|
:height => height,
|
|
:label => digit
|
|
});
|
|
|
|
var buttonTouched = new PinDigitButton({
|
|
:width => width,
|
|
:height => height,
|
|
:label => digit,
|
|
:touched => true
|
|
});
|
|
|
|
// initialize selectable
|
|
Selectable.initialize({
|
|
:stateDefault => button,
|
|
:stateHighlighted => buttonTouched,
|
|
:locX => x,
|
|
:locY => y,
|
|
:width => width,
|
|
:height => height
|
|
});
|
|
|
|
mDigit = digit;
|
|
}
|
|
|
|
//! Return the digit 0..9 represented by this button
|
|
//
|
|
function getDigit() as Lang.Number {
|
|
return mDigit;
|
|
}
|
|
|
|
//! Customised drawing of a PIN digit's button.
|
|
//
|
|
class PinDigitButton extends WatchUi.Drawable {
|
|
private var mText as Lang.Number;
|
|
private var mTouched as Lang.Boolean = false;
|
|
|
|
//! Class Constructor
|
|
//!
|
|
//! @param options See `Drawable.initialize()`, but with `:label` and `:touched` added.<br>
|
|
//! `{`<br>
|
|
//!   `:label as Lang.Number,` // The digit 0..9 to display<br>
|
|
//!   `:touched as Lang.Boolean,` // Should the digit be filled to indicate it has been pressed?<br>
|
|
//!   + those required by `Drawable.initialize()`<br>
|
|
//! ``}`
|
|
//
|
|
function initialize(options) {
|
|
Drawable.initialize(options);
|
|
mText = options[:label];
|
|
mTouched = options[:touched];
|
|
}
|
|
|
|
//! Draw the PIN digit button.
|
|
//!
|
|
//! @param dc Device context
|
|
//
|
|
function draw(dc as Graphics.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
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
//! Pin Confirmation dialog and logic.
|
|
//
|
|
class HomeAssistantPinConfirmationView extends WatchUi.View {
|
|
|
|
//! Margin on left & right side of screen (overall prettier and works better on round displays)
|
|
static const MARGIN_X = 20;
|
|
//! Indicates how many digits have been entered so far.
|
|
var mPinMask as Lang.String = "";
|
|
|
|
//! Class Constructor
|
|
//
|
|
function initialize() {
|
|
View.initialize();
|
|
}
|
|
|
|
//! Construct the view.
|
|
//!
|
|
//! @param dc Device context
|
|
//
|
|
function onLayout(dc as Graphics.Dc) as Void {
|
|
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(digits);
|
|
}
|
|
|
|
//! Update the view.
|
|
//!
|
|
//! @param dc Device context
|
|
//
|
|
function onUpdate(dc as Graphics.Dc) as Void {
|
|
View.onUpdate(dc);
|
|
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);
|
|
}
|
|
}
|
|
|
|
//! Update the PIN mask displayed.
|
|
//!
|
|
//! @param length Number of `*` characters to use for the mask string.
|
|
//
|
|
function updatePinMask(length as Lang.Number) {
|
|
mPinMask = "";
|
|
for (var i=0; i<length; i++) {
|
|
mPinMask += "*";
|
|
}
|
|
requestUpdate();
|
|
}
|
|
|
|
}
|
|
|
|
|
|
//! Delegate for the HomeAssistantPinConfirmationView.
|
|
//
|
|
class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
|
|
|
|
private var mPin as Lang.String;
|
|
private var mEnteredPin as Lang.String;
|
|
private var mConfirmMethod as Method(state as Lang.Boolean) as Void;
|
|
private var mTimer as Timer.Timer?;
|
|
private var mState as Lang.Boolean;
|
|
private var mFailures as PinFailures;
|
|
private var mToggleMethod as Method(state as Lang.Boolean) as Void?;
|
|
private var mView as HomeAssistantPinConfirmationView;
|
|
|
|
//! Class Constructor
|
|
//!
|
|
//! @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(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)?,
|
|
}) {
|
|
BehaviorDelegate.initialize();
|
|
mFailures = new PinFailures();
|
|
if (mFailures.isLocked()) {
|
|
var msg = WatchUi.loadResource($.Rez.Strings.PinInputLocked) + " " +
|
|
mFailures.getLockedUntilSeconds() + " " +
|
|
WatchUi.loadResource($.Rez.Strings.Seconds);
|
|
WatchUi.showToast(msg, {});
|
|
}
|
|
mPin = options[:pin];
|
|
mEnteredPin = "";
|
|
mConfirmMethod = options[:callback];
|
|
mState = options[:state];
|
|
mToggleMethod = options[:toggleMethod];
|
|
mView = options[:view];
|
|
|
|
resetTimer();
|
|
}
|
|
|
|
//! Add another entered digit to the "PIN so far". When it is long enough verify the PIN is correct and the
|
|
//! invoke the supplied call back function.
|
|
//!
|
|
//! @param event The digit pressed by the user tapping the screen.
|
|
//
|
|
function onSelectable(event as WatchUi.SelectableEvent) as Lang.Boolean {
|
|
if (mFailures.isLocked()) {
|
|
goBack();
|
|
}
|
|
var instance = event.getInstance();
|
|
if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) {
|
|
mEnteredPin += instance.getDigit();
|
|
createUserFeedback();
|
|
// System.println("HomeAssistantPinConfirmationDelegate onSelectable() mEnteredPin = " + mEnteredPin);
|
|
if (mEnteredPin.length() == mPin.length()) {
|
|
if (mEnteredPin.equals(mPin)) {
|
|
mFailures.reset();
|
|
getApp().getQuitTimer().reset();
|
|
if (mTimer != null) {
|
|
mTimer.stop();
|
|
}
|
|
WatchUi.popView(WatchUi.SLIDE_RIGHT);
|
|
|
|
// Set the toggle, if we have one
|
|
if (mToggleMethod != null) {
|
|
mToggleMethod.invoke(!mState);
|
|
}
|
|
mConfirmMethod.invoke(mState);
|
|
} else {
|
|
error();
|
|
}
|
|
} else {
|
|
resetTimer();
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
//! Hepatic feedback.
|
|
//
|
|
function createUserFeedback() {
|
|
if (Attention has :vibrate && Settings.getVibrate()) {
|
|
Attention.vibrate([new Attention.VibeProfile(25, 25)]);
|
|
}
|
|
mView.updatePinMask(mEnteredPin.length());
|
|
}
|
|
|
|
//! A timer is used to clear the PIN entry view if digits are not pressed. So each time a digit is pressed the
|
|
//! timer is reset.
|
|
//
|
|
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, false);
|
|
}
|
|
}
|
|
|
|
//! Cancel PIN entry.
|
|
//
|
|
function goBack() as Void {
|
|
if (mTimer != null) {
|
|
mTimer.stop();
|
|
}
|
|
|
|
WatchUi.popView(WatchUi.SLIDE_RIGHT);
|
|
}
|
|
|
|
//! Hepatic feedback for a wrong PIN and cancel entry.
|
|
//
|
|
function error() as Void {
|
|
// System.println("HomeAssistantPinConfirmationDelegate error() Wrong PIN entered");
|
|
mFailures.addFailure();
|
|
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)
|
|
]);
|
|
}
|
|
if (WatchUi has :showToast) {
|
|
showToast($.Rez.Strings.WrongPin, null);
|
|
}
|
|
goBack();
|
|
}
|
|
|
|
//! Handle the back button (ESC)
|
|
//
|
|
function onBack() as Lang.Boolean {
|
|
goBack();
|
|
return true;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
//! Manage PIN entry failures to try and prevent brute force exhaustion by inserting delays in retries.
|
|
//
|
|
class PinFailures {
|
|
|
|
const STORAGE_KEY_FAILURES as Lang.String = "pin_failures";
|
|
const STORAGE_KEY_LOCKED as Lang.String = "pin_locked";
|
|
|
|
private var mFailures as Lang.Array<Lang.Number>;
|
|
private var mLockedUntil as Lang.Number?;
|
|
|
|
//! Class Constructor
|
|
//
|
|
function initialize() {
|
|
// 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);
|
|
}
|
|
|
|
//! Record a PIN entry failure. If too many have occurred lock the application.
|
|
//
|
|
function addFailure() {
|
|
mFailures.add(Time.now().value());
|
|
// System.println("PinFailures addFailure() " + mFailures.size() + " PIN confirmation failures recorded");
|
|
if (mFailures.size() >= Globals.scPinMaxFailures) {
|
|
// System.println("PinFailures addFailure() Too many failures detected");
|
|
var oldestFailureOutdate = new Time.Moment(mFailures[0]).add(new Time.Duration(Globals.scPinMaxFailureMinutes * 60));
|
|
// System.println("PinFailures addFailure() Oldest failure: " + oldestFailureOutdate.value() + " Now:" + Time.now().value());
|
|
if (new Time.Moment(Time.now().value()).greaterThan(oldestFailureOutdate)) {
|
|
// System.println("PinFailures addFailure() Pruning oldest outdated failure");
|
|
mFailures = mFailures.slice(1, null);
|
|
} else {
|
|
mFailures = [];
|
|
mLockedUntil = Time.now().add(new Time.Duration(Globals.scPinLockTimeMinutes * Time.Gregorian.SECONDS_PER_MINUTE)).value();
|
|
Application.Storage.setValue(STORAGE_KEY_LOCKED, mLockedUntil);
|
|
// System.println("PinFailures addFailure() Locked until " + mLockedUntil);
|
|
}
|
|
}
|
|
Application.Storage.setValue(STORAGE_KEY_FAILURES, mFailures);
|
|
}
|
|
|
|
//! Clear the record of previous PIN entry failures, e.g. because the correct PIN has now been entered
|
|
//! within tolerance.
|
|
//
|
|
function reset() {
|
|
// System.println("PinFailures reset() Resetting failures");
|
|
mFailures = [];
|
|
mLockedUntil = null;
|
|
Application.Storage.deleteValue(STORAGE_KEY_FAILURES);
|
|
Application.Storage.deleteValue(STORAGE_KEY_LOCKED);
|
|
}
|
|
|
|
//! Retrieve the remaining time the application must be locked out for.
|
|
//
|
|
function getLockedUntilSeconds() as Lang.Number {
|
|
return new Time.Moment(mLockedUntil).subtract(Time.now()).value();
|
|
}
|
|
|
|
//! Is the application currently locked out? If the application is no longer locked out, then clear the
|
|
//! stored values used to determine this state.
|
|
//!
|
|
//! @return Boolean indicating if the application is currently locked out.
|
|
//
|
|
function isLocked() as Lang.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;
|
|
}
|
|
|
|
} |