Compare commits

...

29 Commits

Author SHA1 Message Date
Philip Abbey
906cdf7371 Code documentation comments were not updated 2025-07-10 23:54:05 +01:00
__JosephAbbey
7dd85937fa 243 option to close application after command (#252)
Also closes #250.
2025-07-10 22:02:12 +01:00
Philip Abbey
d141c03104 Review comment
Moved point of application exit for the menu item option.
2025-07-10 19:46:04 +01:00
Philip Abbey
842a31a1cc Review comment
enable => enabled
2025-07-10 18:24:30 +01:00
Philip Abbey
5639ff5c42 Amended heading levels in docs 2025-07-09 21:51:18 +01:00
Philip Abbey
8b3e86a00f Amended documentation for two new menu item options
The options are 'enable' and 'exit'.
2025-07-09 21:48:58 +01:00
Philip Abbey
6dbcea94cf Update HomeAssistantView.mc
Refined more precisely where the exit option can be used and enforced in the code.
2025-07-09 21:09:33 +01:00
Philip Abbey
b28daacafd Update config.schema.json
Removed exit from non-actionable menu items.
2025-07-09 20:54:28 +01:00
Philip Abbey
029a9f373e Update config.schema.json
Added to boolean options for disable and exit.
2025-07-09 19:57:11 +01:00
Philip Abbey
ec044c5408 Added two new options to menu items
1. The ability to disable a menu item without deleting it.
2. The option to quit the application on item selection.
2025-07-08 22:48:42 +01:00
Philip Abbey
659a060c76 Forgotten image used on App home page 2025-07-08 22:45:39 +01:00
__JosephAbbey
3acef26fea 247 improve the glance view (#249)
2s seems to be a good compromise between the status flickering and the
update delay. When the menu is cached there's no flickering.
2025-07-07 13:49:53 +01:00
__JosephAbbey
e898fc1fe5 Update config.schema.json
Signed-off-by: __JosephAbbey <me@josephabbey.dev>
2025-07-07 13:49:33 +01:00
Philip Abbey
4df1fd69bc Update Globals.mc
Increased the API back off time from 1s to 2s to remove the glance's status flickering. The assumption is that the GET requests are too fast for the Bluetooth stack.
2025-07-06 21:31:33 +01:00
Philip Abbey
fc19599586 Update export.cmd
Removed a commented out command line option no longer used and unlikely to be replaced.
2025-07-06 21:29:19 +01:00
Philip Abbey
13f70af45a Update Glance.md
Explained to turn on menu caching.
2025-07-06 21:28:40 +01:00
Philip Abbey
90ed1f4bea Update HomeAssistantGlanceView.mc
Improved layout.
2025-07-06 19:02:59 +01:00
Philip Abbey
2117b27210 Update HISTORY.md
Added two images.
2025-07-06 19:01:27 +01:00
Philip Abbey
df7874e825 Update HISTORY.md
Added link to Glance view for 2.30 text.
2025-07-06 17:32:12 +01:00
Philip Abbey
f3c5947b82 Merge branch 'main' into 247-improve-the-glance-view 2025-07-06 14:49:49 +01:00
Philip Abbey
47a930828a Revised glance view
Refreshed default view and new customisable view.
2025-07-06 14:44:53 +01:00
Philip Abbey
ef299bcaf6 Render correct icon in web editor for info types (#248) 2025-07-06 13:53:24 +01:00
__JosephAbbey
f0eb9c26b1 Render correct icon in web editor for info types 2025-07-06 13:07:04 +01:00
__JosephAbbey
f1c592179d Added correctly formatted code comments (#246)
The newer SDK support tooltips to show the function prototype and help
text, so best to make good use of it.

I'm not expecting this to be 100% on the first iteration.
2025-07-05 13:58:35 +01:00
__JosephAbbey
4ed81df60a Fix spelling of Bluetooth
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: __JosephAbbey <me@josephabbey.dev>
2025-07-05 13:57:35 +01:00
__JosephAbbey
b4f5f34760 Fix spelling of Webhook
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: __JosephAbbey <me@josephabbey.dev>
2025-07-05 13:57:02 +01:00
Philip Abbey
4cf5a0ae26 Update HomeAssistantApp.mc
Fix the 'fixed' comment string.
2025-07-04 17:26:33 +01:00
Philip Abbey
b44d4c6155 Update HomeAssistantApp.mc
Amended 4 comments
2025-07-04 17:20:48 +01:00
Philip Abbey
f2d65aa6e3 Added correctly formatted code comments
The newer SDK support tooltips to show the function prototype and help text, so best to make good use of it.
2025-07-04 16:57:25 +01:00
40 changed files with 1417 additions and 402 deletions

View File

@@ -1,4 +1,4 @@
[Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | Battery Reporting | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md) [Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Glance](examples/Glance.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md)
# Background Service # Background Service

View File

@@ -1,4 +1,4 @@
[Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | Version History [Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Glance](examples/Glance.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md)
# Version History # Version History
@@ -34,7 +34,7 @@
| 2.19 | A template to evaluate is now optionally allowed on both `group` and `toggle` menu items. The template to evaluate is non-optional on a `template` menu item. All updates are performed in a single HTTP GET request for efficiency. Bug fix for negative heading values. Vibration now (optionally) confirms toggle menu items being tapped. | | 2.19 | A template to evaluate is now optionally allowed on both `group` and `toggle` menu items. The template to evaluate is non-optional on a `template` menu item. All updates are performed in a single HTTP GET request for efficiency. Bug fix for negative heading values. Vibration now (optionally) confirms toggle menu items being tapped. |
| 2.20 | Simplified the code base now that templates have been requested in all menu items. This means the `template` menu item became a superset of `tap`. Therefore the `tap` code has been has been upgraded to include `template` and the latter deprecated. JSON menu definitions continue to support `template` items by instantiating a `tap` menu item, but the schema marks them as deprecated and users should migrate their menu definitions now. Use the [web editor](https://house-of-abbey.github.io/GarminHomeAssistant/web/) for assistance with changes. | | 2.20 | Simplified the code base now that templates have been requested in all menu items. This means the `template` menu item became a superset of `tap`. Therefore the `tap` code has been has been upgraded to include `template` and the latter deprecated. JSON menu definitions continue to support `template` items by instantiating a `tap` menu item, but the schema marks them as deprecated and users should migrate their menu definitions now. Use the [web editor](https://house-of-abbey.github.io/GarminHomeAssistant/web/) for assistance with changes. |
| 2.21 | Added 7 new devices (`edge1050`, `enduro3`, `fenix843mm`, `fenix847mm`, `fenix8solar47mm`, `fenix8solar51mm`, `fenixe`) and upgraded the SDK to 7.3.0. Fix for a bug on Edge devices introduced by v2.16 activity reporting improvements. | | 2.21 | Added 7 new devices (`edge1050`, `enduro3`, `fenix843mm`, `fenix847mm`, `fenix8solar47mm`, `fenix8solar51mm`, `fenixe`) and upgraded the SDK to 7.3.0. Fix for a bug on Edge devices introduced by v2.16 activity reporting improvements. |
| 2.22 | Major feature release adding an optional PIN to menu items. This significant new feature has been provided by [moesterheld](https://github.com/moesterheld). Please do not rely on this application for security. Use at your own risk! | | 2.22 | <img src="images/pin_view.png" width="200" title="PIN Entry View"/><br/>Major feature release adding an optional PIN to menu items. This significant new feature has been provided by [moesterheld](https://github.com/moesterheld). Please do not rely on this application for security. Use at your own risk! |
| 2.23 | Added "info" menu item for displaying information via a template without a tap or toggle. Essentially like the old 'template' type that was deprecated when all items were amended to display evaluated templates. That action removed the display only items too hastily. Added 5 new devices in the model range Instinct 3 and Instinct E. | | 2.23 | Added "info" menu item for displaying information via a template without a tap or toggle. Essentially like the old 'template' type that was deprecated when all items were amended to display evaluated templates. That action removed the display only items too hastily. Added 5 new devices in the model range Instinct 3 and Instinct E. |
| 2.24 | Experiment to prevent new Webhook IDs being created unnecessarily. Reduced the latency for the first menu update. Added 4 new devices: approachs50, descentg2, descentmk1, and gpsmap66. | | 2.24 | Experiment to prevent new Webhook IDs being created unnecessarily. Reduced the latency for the first menu update. Added 4 new devices: approachs50, descentg2, descentmk1, and gpsmap66. |
| 2.25 | 2 Bug fixes. First time startup issues caused by v2.24 change and a fix for pure numbers in returned templates. | | 2.25 | 2 Bug fixes. First time startup issues caused by v2.24 change and a fix for pure numbers in returned templates. |
@@ -42,3 +42,4 @@
| 2.27 | Trivial bug fix for the glance view to prevent the "Unconfigured" result being erroneously displayed because the settings were not yet pulled from persistent storage. | | 2.27 | Trivial bug fix for the glance view to prevent the "Unconfigured" result being erroneously displayed because the settings were not yet pulled from persistent storage. |
| 2.28 | Added support for Vivoactive 6 device which also required an SDK update to 8.1.0. | | 2.28 | Added support for Vivoactive 6 device which also required an SDK update to 8.1.0. |
| 2.29 | Added support for three new devices, Forerunners 570 42mm & 47mm and 970. | | 2.29 | Added support for three new devices, Forerunners 570 42mm & 47mm and 970. |
| 2.30 | <img src="images/Venu2_glance_default.png" width="200" title="Default Glance"/><br/>Extensive re-work of the [Glance](examples/Glance.md) view, including the ability to customise it with a user supplied template. |

View File

@@ -4,5 +4,9 @@
"path": "." "path": "."
} }
], ],
"settings": {} "settings": {
"cSpell.words": [
"Initialiser"
]
}
} }

View File

@@ -1,4 +1,4 @@
Home | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md) [Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Glance](examples/Glance.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md)
# GarminHomeAssistant # GarminHomeAssistant
@@ -24,7 +24,7 @@ As of version 2.0, there are now two installable versions. For older devices bef
| Version | Explanation | | Version | Explanation |
|------------------------|-------------| |------------------------|-------------|
| Application (original) | For newer devices that allow glance views in their applications, e.g. Venu 2, the GarminHomeAssistant application can be started either from a glance or from the list of applications and activities. Head over to the [GarminHomeAssistant](https://apps.garmin.com/en-US/apps/61c91d28-ec5e-438d-9f83-39e9f45b199d) application page on the [Connect IQ application store](https://apps.garmin.com/en-US/) to download the application. The application can be started two different ways, either from the glance in the carousel, or as an application from the list of applications & activities. With the latter, it is worth marking the application as a favourite.<br/><img src="images/Venu2_app_start.png" width="200" title="Venu 2" style="margin:5px"/><img src="images/Vivoactive3_app_start.jpg" width="200" title="Venu 2" style="margin:5px"/><br/>If you place the application on your list of favourites, and rearrange it to appear near the top, then the item is just one button press away from the watch face. This second picture here shows the application menu on a Vivoactive 3 watch.<br/><img src="images/Venu2_glance_start.png" width="200" title="Venu 2" style="margin:5px"/><br/>On newer watches, you can also start the application from the glance carousel. The glance view here typically displays some trackable status, so ours provides some early indication of availability. Older watches will still allow you to start this application from the list of applications and activities. | | Application (original) | For newer devices that allow glance views in their applications, e.g. Venu 2, the GarminHomeAssistant application can be started either from a glance or from the list of applications and activities. Head over to the [GarminHomeAssistant](https://apps.garmin.com/en-US/apps/61c91d28-ec5e-438d-9f83-39e9f45b199d) application page on the [Connect IQ application store](https://apps.garmin.com/en-US/) to download the application. The application can be started two different ways, either from the glance in the carousel, or as an application from the list of applications & activities. With the latter, it is worth marking the application as a favourite.<br/><img src="images/Venu2_app_start.png" width="200" title="Venu 2" style="margin:5px"/><img src="images/Vivoactive3_app_start.jpg" width="200" title="Vivoactive 3" style="margin:5px"/><br/>If you place the application on your list of favourites, and rearrange it to appear near the top, then the item is just one button press away from the watch face. This second picture here shows the application menu on a Vivoactive 3 watch.<br/><img src="images/Venu2_glance_default.png" width="200" title="Venu 2" style="margin:5px"/><br/>On newer watches, you can also start the application from the glance carousel. The glance view here typically displays some trackable status, so ours provides some early indication of availability. Older watches will still allow you to start this application from the list of applications and activities. |
| Widget | **"Maintenance only mode"** so no new features will be added to this version.<br>For older devices that use widgets, e.g. Venu (1) as opposed to applications with "glances", the GarminHomeAssistant application can instead be started from the widget carousel. This is a separate item in the Connect IQ AppStore and with this installation, the application will no longer appear in the list of applications and activities. Head over to the [GarminHomeAssistant](https://apps.garmin.com/en-US/apps/) widget page on the [Connect IQ application store](https://apps.garmin.com/en-US/) to download the widget.<br/><img src="images/Venu_Widget_sim.png" width="200" title="Venu 2" style="margin:5px"/><br/>Typically the widget view implements something similar to the glance view, e.g. status, and exists in a widget carousel to allow you to select an application to launch.<br>**Please note that memory in widgets is more limited than applications. This means a large menu definition can crash the widget without the code catching the error.**<br> This version was born out of the application version and from Ver 2.0 shared the same source code repository until Ver 2.8 when they were [separated](https://github.com/house-of-abbey/GarminHomeAssistantWidget) to allow the application version to take advantage of its increase memory availability. | | Widget | **"Maintenance only mode"** so no new features will be added to this version.<br>For older devices that use widgets, e.g. Venu (1) as opposed to applications with "glances", the GarminHomeAssistant application can instead be started from the widget carousel. This is a separate item in the Connect IQ AppStore and with this installation, the application will no longer appear in the list of applications and activities. Head over to the [GarminHomeAssistant](https://apps.garmin.com/en-US/apps/) widget page on the [Connect IQ application store](https://apps.garmin.com/en-US/) to download the widget.<br/><img src="images/Venu_Widget_sim.png" width="200" title="Venu 2" style="margin:5px"/><br/>Typically the widget view implements something similar to the glance view, e.g. status, and exists in a widget carousel to allow you to select an application to launch.<br>**Please note that memory in widgets is more limited than applications. This means a large menu definition can crash the widget without the code catching the error.**<br> This version was born out of the application version and from Ver 2.0 shared the same source code repository until Ver 2.8 when they were [separated](https://github.com/house-of-abbey/GarminHomeAssistantWidget) to allow the application version to take advantage of its increase memory availability. |
### Features ### Features

View File

@@ -1,4 +1,4 @@
[Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Background Service](BackgroundService.md) | Trouble Shooting | [Version History](HISTORY.md) [Home](README.md) | [Switches](examples/Switches.md) | [Actions](examples/Actions.md) | [Templates](examples/Templates.md) | [Glance](examples/Glance.md) | [Background Service](BackgroundService.md) | [Trouble Shooting](TroubleShooting.md) | [Version History](HISTORY.md)
# Troubleshooting Guides # Troubleshooting Guides

View File

@@ -2,14 +2,19 @@
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object", "type": "object",
"properties": { "properties": {
"$schema": {
"type": "string",
"description": "The schema will prevent JSON file errors."
},
"title": { "title": {
"type": "string" "type": "string",
"description": "Top level menu title"
},
"glance": {
"$ref": "#/$defs/glance"
}, },
"items": { "items": {
"$ref": "#/$defs/items" "$ref": "#/$defs/items"
},
"$schema": {
"type": "string"
} }
}, },
"required": ["title", "items"], "required": ["title", "items"],
@@ -29,8 +34,7 @@
"const": "toggle" "const": "toggle"
}, },
"content": { "content": {
"$ref": "#/$defs/content", "$ref": "#/$defs/content"
"description": "Optional in a toggle."
}, },
"tap_action": { "tap_action": {
"type": "object", "type": "object",
@@ -43,6 +47,12 @@
} }
}, },
"additionalProperties": false "additionalProperties": false
},
"enabled": {
"$ref": "#/$defs/enabled"
},
"exit": {
"$ref": "#/$defs/exit"
} }
}, },
"required": ["entity", "name", "type"], "required": ["entity", "name", "type"],
@@ -71,6 +81,9 @@
"deprecated": true, "deprecated": true,
"title": "Schema change:", "title": "Schema change:",
"description": "Use 'info' or 'tap' instead." "description": "Use 'info' or 'tap' instead."
},
"enabled": {
"$ref": "#/$defs/enabled"
} }
}, },
"required": ["name", "content", "type"], "required": ["name", "content", "type"],
@@ -97,6 +110,12 @@
}, },
"tap_action": { "tap_action": {
"$ref": "#/$defs/tap_action" "$ref": "#/$defs/tap_action"
},
"enabled": {
"$ref": "#/$defs/enabled"
},
"exit": {
"$ref": "#/$defs/exit"
} }
}, },
"required": ["name", "content", "type", "tap_action"], "required": ["name", "content", "type", "tap_action"],
@@ -116,6 +135,9 @@
"type": { "type": {
"$ref": "#/$defs/type", "$ref": "#/$defs/type",
"const": "info" "const": "info"
},
"enabled": {
"$ref": "#/$defs/enabled"
} }
}, },
"required": ["name", "content", "type"], "required": ["name", "content", "type"],
@@ -135,8 +157,7 @@
"const": "tap" "const": "tap"
}, },
"content": { "content": {
"$ref": "#/$defs/content", "$ref": "#/$defs/content"
"description": "Optional in a tap."
}, },
"service": { "service": {
"$ref": "#/$defs/entity", "$ref": "#/$defs/entity",
@@ -146,6 +167,12 @@
}, },
"tap_action": { "tap_action": {
"$ref": "#/$defs/tap_action" "$ref": "#/$defs/tap_action"
},
"enabled": {
"$ref": "#/$defs/enabled"
},
"exit": {
"$ref": "#/$defs/exit"
} }
}, },
"required": ["name", "type"], "required": ["name", "type"],
@@ -174,11 +201,13 @@
"const": "group" "const": "group"
}, },
"content": { "content": {
"$ref": "#/$defs/content", "$ref": "#/$defs/content"
"description": "Optional in a group."
}, },
"items": { "items": {
"$ref": "#/$defs/items" "$ref": "#/$defs/items"
},
"enabled": {
"$ref": "#/$defs/enabled"
} }
}, },
"required": ["name", "title", "type", "items"], "required": ["name", "title", "type", "items"],
@@ -190,7 +219,6 @@
}, },
"items": { "items": {
"type": "array", "type": "array",
"maxItems": 16,
"items": { "items": {
"oneOf": [ "oneOf": [
{ {
@@ -248,7 +276,8 @@
"required": ["service"] "required": ["service"]
}, },
"content": { "content": {
"title": "Jinja2 template defining the text to display.", "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.",
"type": "string" "type": "string"
}, },
"confirm": { "confirm": {
@@ -262,6 +291,47 @@
"default": false, "default": false,
"title": "PIN Confirmation", "title": "PIN Confirmation",
"description": "Optional PIN confirmation of the action before execution as a precaution. Has precedence over 'confirm': true if both are set." "description": "Optional PIN confirmation of the action before execution as a precaution. Has precedence over 'confirm': true if both are set."
},
"glance": {
"type": "object",
"title": "Glance customisation",
"oneOf": [
{
"properties": {
"type": {
"title": "Glance type",
"description": "One of 'info' or 'status'. 'info' renders the template specified in the 'content' field inside the glance view. 'status' reverts to the default glance view and ignores the 'content' field. This allows for disabling the template temporarily.",
"const": "info"
},
"content": {
"$ref": "#/$defs/content"
}
},
"required": ["type", "content"]
},
{
"properties": {
"type": {
"title": "Glance type",
"description": "One of 'info' or 'status'.",
"const": "status"
}
},
"required": ["type"]
}
]
},
"enabled": {
"type": "boolean",
"default": true,
"title": "Enable the menu item",
"description": "Typically used to temporarily disable a menu item, e.g. for seasonal variations. Enabled (true) by default."
},
"exit": {
"type": "boolean",
"default": false,
"title": "Exit on selection",
"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."
} }
} }
} }

View File

@@ -1,4 +1,5 @@
[Home](../README.md) | [Switches](Switches.md) | Actions | [Templates](Templates.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md) [Home](../README.md) | [Switches](Switches.md) | [Actions](Actions.md) | [Templates](Templates.md) | [Glance](Glance.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md)
# Actions # Actions
@@ -75,3 +76,35 @@ Note that for notify events, you _must_ not supply an `entity_id` or the API cal
> Be careful with the value of the `service` field. > Be careful with the value of the `service` field.
Note that the `service` field will need to be a locally custom `script.<something>` as soon as any `data` fields are populated and not something more generic like `script.turn_on`. If the `service` field is wrong, the application will fail with a [`Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE`](https://developer.garmin.com/connect-iq/api-docs/Toybox/Communications.html) error in the response from your Home Assistant and show the error message as _"No JSON returned from HTTP request"_ on your device. In the [web-based editor](https://house-of-abbey.github.io/GarminHomeAssistant/web/) you can use the standard developer tools to observe an `HTTP 400` error which the application does not see. Here we are limited by the [Garmin Connect IQ](https://developer.garmin.com/connect-iq/overview/) software development kit (SDK). We do not have enough information at the point of execution in the application to determine the cause of the error. Nor is there an immediately obvious way of identifying this issue using the JSON schema checks. Note that the `service` field will need to be a locally custom `script.<something>` as soon as any `data` fields are populated and not something more generic like `script.turn_on`. If the `service` field is wrong, the application will fail with a [`Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE`](https://developer.garmin.com/connect-iq/api-docs/Toybox/Communications.html) error in the response from your Home Assistant and show the error message as _"No JSON returned from HTTP request"_ on your device. In the [web-based editor](https://house-of-abbey.github.io/GarminHomeAssistant/web/) you can use the standard developer tools to observe an `HTTP 400` error which the application does not see. Here we are limited by the [Garmin Connect IQ](https://developer.garmin.com/connect-iq/overview/) software development kit (SDK). We do not have enough information at the point of execution in the application to determine the cause of the error. Nor is there an immediately obvious way of identifying this issue using the JSON schema checks.
## Exit on Tap
You can choose individual items that will quit after they have completed their action.
```json
{
"entity": "automation.turn_off_stuff",
"name": "Turn off Stuff",
"type": "tap",
"tap_action": {
"service": "automation.trigger"
},
"exit": true
}
```
## Disable Menu Item
If you would like to temporarily disable an item in your menu, e.g. for seasonal reasons like not needing to turn on the heating in summer, then rather than swapping menu definition files or deleting a section of the menu you can mark the item as 'disabled'. This field applies to all menu items.
```json
{
"entity": "automation.turn_off_stuff",
"name": "Turn off Stuff",
"type": "tap",
"tap_action": {
"service": "automation.trigger"
},
"enabled": false
}
```

80
examples/Glance.md Normal file
View File

@@ -0,0 +1,80 @@
[Home](../README.md) | [Switches](Switches.md) | [Actions](Actions.md) | [Templates](Templates.md) | [Glance](Glance.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md)
# Glance
Since [version 2.30](../History.md), it is possible to ovverride the text displayed on the Glance view. This page explains how to customise the text.
## Default View
The default view has always been to display the status of the menu and API availability to indicate if there's a problem. This view has now been updated to be more colourful.
<img src="../images/Venu2_glance_default.png" width="200" title="Venu 2 Default Glance"/>
When either the API or the menu file is inaccessible, the fields will turn red.
## Customised View
In order to customise the Glance view you need to add a `glance` field to the top level of the JSON menu file as illustrated here:
```json
{
"$schema": "https://raw.githubusercontent.com/house-of-abbey/GarminHomeAssistant/main/config.schema.json",
"title": "Home",
"glance": {
"type": "info",
"content": "Text: {% .. %}"
},
"items": [...]
}
```
For example:
<img src="../images/Venu2_glance_custom.png" width="200" title="Venu 2 Customised Glance"/>
```json
{
"$schema": "https://raw.githubusercontent.com/house-of-abbey/GarminHomeAssistant/main/config.schema.json",
"glance": {
"type": "info",
"content": "Solar Battery: {{ states('sensor.battery_capacity_charge') }}%"
},
:
}
```
You may make this as complicated as you like! But you have limited space and only ASCII text characters. **It is best to turn on menu caching in order to speed up the display of the template**. The display is then nearly instantaneous.
The default view will persist showing until the errors are resolved. In order to extract the custom glance template both the menu and the API are required. So it is logical that the two tests must pass first. The exception here is if the menu is cached, in which case only the API needs to pass.
> [!IMPORTANT]
> Sadly what you cannot do is use special characters like: 🌞🔋⛅🪫. Whilst these do display in menu items, they do not seem to work on the Glance view. We really like them, so have tried but failed. Only ASCII text appears to be supported by the Garmin Connect IQ SDK's Glance View. This is not something we have any control over, please do not request this to be "fixed".
It is possible to revert to the default glance content without deleting the template by changing the `type` to `status`.
```json
{
"$schema": "https://raw.githubusercontent.com/house-of-abbey/GarminHomeAssistant/main/config.schema.json",
"title": "Home",
"glance": {
"type": "status",
"content": "Text: {% .. %}"
},
"items": [...]
}
```
So the glance view object has a `type` field with two possible values: `info` and `status`. When the type is `status` the `content` field is not required.
## Displayed Errors
The following shows the default glance when the menu file is not available at the specified URL.
<img src="../images/Venu2_glance_no_menu.png" width="200" title="Venu 2 Glance showing errors"/>
Once the custom glance template has been retrieved and evaluated the display will change. Should the connectivity to your Home Assistant then be lost, e.g. you move out of range of your phone, the glance reflects this in the colour of the residual two rectangles. The top one remains an indicator for the API, and the bottom rectangle remains an indicator for the menu availability, reflecting the original placement in the default glance view that has now been replaced.
<img src="../images/Venu2_glance_no_bt.png" width="200" title="Venu 2 Glance showing lost connectivity"/>

View File

@@ -1,4 +1,5 @@
[Home](../README.md) | Switches | [Actions](Actions.md) | [Templates](Templates.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md) [Home](../README.md) | [Switches](Switches.md) | [Actions](Actions.md) | [Templates](Templates.md) | [Glance](Glance.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md)
# Switches # Switches
@@ -108,3 +109,29 @@ Then you can use the following in your config:
"type": "toggle" "type": "toggle"
} }
``` ```
## Exit On Toggle
You can choose individual items that will quit after they have completed their action.
```json
{
"entity": "light.hall_light",
"name": "Hall Light & Quit",
"type": "toggle",
"exit": true
}
```
## Disable Menu Item
If you would like to temporarily disable an item in your menu, e.g. for seasonal reasons like not needing to turn on Christmas tree lights outside the festive season, then rather than swapping menu definition files or deleting a section of the menu you can mark the item as 'disabled'. This field applies to all menu items.
```json
{
"entity": "light.chrissmas_tree",
"name": "Christmas Lights",
"type": "toggle",
"enabled": false
}
```

View File

@@ -1,4 +1,4 @@
[Home](../README.md) | [Switches](Switches.md) | [Actions](Actions.md) | Templates | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md) [Home](../README.md) | [Switches](Switches.md) | [Actions](Actions.md) | [Templates](Templates.md) | [Glance](Glance.md) | [Background Service](../BackgroundService.md) | [Trouble Shooting](../TroubleShooting.md) | [Version History](../HISTORY.md)
# Templates # Templates
@@ -218,6 +218,19 @@ An example of a dimmer light with 4 brightness settings 0..3. Here our light wor
} }
``` ```
## Disable Menu Item
If you would like to temporarily disable an item in your menu, then rather than swapping menu definition files or deleting a section of the menu you can mark the item as 'disabled'. This field applies to all menu items.
```json
{
"name": "Phone",
"type": "info",
"content": "{{ ... }}",
"enabled": false
}
```
## Warnings ## Warnings
Just remember, on older smaller memory devices **you have the ability to crash the application by creating an excessive menu definition**. Templates can require significant definition for highly customised text. Don't be silly. Just remember, on older smaller memory devices **you have the ability to crash the application by creating an excessive menu definition**. Templates can require significant definition for highly customised text. Don't be silly.

View File

@@ -105,7 +105,6 @@ echo.
--private-key %SRC%\..\developer_key ^ --private-key %SRC%\..\developer_key ^
--package-app ^ --package-app ^
--release --release
rem --warn
echo. echo.
echo Finished exporting HomeAssistant echo Finished exporting HomeAssistant

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -11,15 +11,6 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// Alert provides a means to present application notifications to the user
// briefly. Credit to travis.vitek on forums.garmin.com.
//
// Reference:
// * https://forums.garmin.com/developer/connect-iq/f/discussion/106/how-to-show-alert-messages
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -27,6 +18,12 @@ using Toybox.Graphics;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Timer; using Toybox.Timer;
//! The Alert class provides a means to present application notifications to the user
//! briefly. Credit to travis.vitek on forums.garmin.com.
//!
//! Reference:
//! @url https://forums.garmin.com/developer/connect-iq/f/discussion/106/how-to-show-alert-messages
//
class Alert extends WatchUi.View { class Alert extends WatchUi.View {
private static const scRadius = 10; private static const scRadius = 10;
private var mTimer as Timer.Timer; private var mTimer as Timer.Timer;
@@ -36,6 +33,16 @@ class Alert extends WatchUi.View {
private var mFgcolor as Graphics.ColorType; private var mFgcolor as Graphics.ColorType;
private var mBgcolor as Graphics.ColorType; private var mBgcolor as Graphics.ColorType;
//! Class Constructor
//! @param params A dictionary object as follows:<br>
//! &lbrace;<br>
//! &emsp; :timeout as Lang.Number, // Timeout in millseconds<br>
//! &emsp; :font as Graphics.FontType, // Text font size<br>
//! &emsp; :text as Lang.String, // Text to display<br>
//! &emsp; :fgcolor as Graphics.ColorType, // Foreground Colour<br>
//! &emsp; :bgcolor as Graphics.ColorType // Background Colour<br>
//! &rbrace;
//
function initialize(params as Lang.Dictionary) { function initialize(params as Lang.Dictionary) {
View.initialize(); View.initialize();
@@ -67,14 +74,22 @@ class Alert extends WatchUi.View {
mTimer = new Timer.Timer(); mTimer = new Timer.Timer();
} }
//! Setup a timer to dismiss the alert.
//
function onShow() { function onShow() {
mTimer.start(method(:dismiss), mTimeout, false); mTimer.start(method(:dismiss), mTimeout, false);
} }
//! Prematurely stop the timer.
//
function onHide() { function onHide() {
mTimer.stop(); mTimer.stop();
} }
//! Draw the Alert view.
//!
//! @param dc Device context
//
function onUpdate(dc as Graphics.Dc) { function onUpdate(dc as Graphics.Dc) {
var tWidth = dc.getTextWidthInPixels(mText, mFont); var tWidth = dc.getTextWidthInPixels(mText, mFont);
var tHeight = dc.getFontHeight(mFont); var tHeight = dc.getFontHeight(mFont);
@@ -110,32 +125,49 @@ class Alert extends WatchUi.View {
dc.drawText(tX, tY, mFont, mText, Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER); dc.drawText(tX, tY, mFont, mText, Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
} }
// Remove the alert from view, usually on user input, but that is defined by the calling function. //! Remove the alert from view, usually on user input, but that is defined by the calling function.
// //
function dismiss() as Void { function dismiss() as Void {
WatchUi.popView(SLIDE_IMMEDIATE); WatchUi.popView(SLIDE_IMMEDIATE);
} }
function pushView(transition) as Void { //! Push this view onto the view stack.
//!
//! @param transition Slide Type
function pushView(transition as WatchUi.SlideType) as Void {
WatchUi.pushView(self, new AlertDelegate(self), transition); WatchUi.pushView(self, new AlertDelegate(self), transition);
} }
} }
//! Input Delegate for the Alert view.
//
class AlertDelegate extends WatchUi.InputDelegate { class AlertDelegate extends WatchUi.InputDelegate {
private var mView; private var mView as Alert;
function initialize(view) { //! Class Constructor
//!
//! @param view The Alert view for which this class is a delegate.
//!
function initialize(view as Alert) {
InputDelegate.initialize(); InputDelegate.initialize();
mView = view; mView = view;
} }
function onKey(evt) as Lang.Boolean { //! Handle key events.
//!
//! @param evt The key event whose value is ignored, just fact of key event matters.
//!
function onKey(evt as WatchUi.KeyEvent) as Lang.Boolean {
mView.dismiss(); mView.dismiss();
getApp().getQuitTimer().reset(); getApp().getQuitTimer().reset();
return true; return true;
} }
function onTap(evt) as Lang.Boolean { //! Handle click events.
//!
//! @param evt The click event whose value is ignored, just fact of key event matters.
//!
function onTap(evt as WatchUi.ClickEvent) as Lang.Boolean {
mView.dismiss(); mView.dismiss();
getApp().getQuitTimer().reset(); getApp().getQuitTimer().reset();
return true; return true;

View File

@@ -11,12 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023
// //
//
// Description:
//
// The background service delegate currently just reports the Garmin watch's battery
// level.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -25,20 +19,43 @@ using Toybox.Background;
using Toybox.System; using Toybox.System;
using Toybox.Activity; using Toybox.Activity;
//! The background service delegate reports the Garmin watch's various status values
//! back to the Home Assistant instance.
//
(:background) (:background)
class BackgroundServiceDelegate extends System.ServiceDelegate { class BackgroundServiceDelegate extends System.ServiceDelegate {
//! Class Constructor
//
function initialize() { function initialize() {
ServiceDelegate.initialize(); ServiceDelegate.initialize();
} }
function onReturnBatteryUpdate(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { //! Callback function for doUpdate().
// System.println("BackgroundServiceDelegate onReturnBatteryUpdate() Response Code: " + responseCode); //!
// System.println("BackgroundServiceDelegate onReturnBatteryUpdate() Response Data: " + data); //! @param responseCode Response code
//! @param data Return data
//
function onReturnDoUpdate(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void {
// System.println("BackgroundServiceDelegate onReturnDoUpdate() Response Code: " + responseCode);
// System.println("BackgroundServiceDelegate onReturnDoUpdate() Response Data: " + data);
Background.exit(null); Background.exit(null);
} }
function onActivityCompleted(activity as { :sport as Activity.Sport, :subSport as Activity.SubSport }) as Void { //! Called on completion of an activity.
//!
//! @param activity Specified as a Dictionary with two items.<br>
//! &lbrace;<br>
//! &emsp; :sport as Activity.Sport<br>
//! &emsp; :subSport as Activity.SubSport<br>
//! &rbrace;
//
function onActivityCompleted(
activity as {
:sport as Activity.Sport,
:subSport as Activity.SubSport
}
) as Void {
if (!System.getDeviceSettings().phoneConnected) { if (!System.getDeviceSettings().phoneConnected) {
// System.println("BackgroundServiceDelegate onActivityCompleted(): No Phone connection, skipping API call."); // System.println("BackgroundServiceDelegate onActivityCompleted(): No Phone connection, skipping API call.");
} else if (!System.getDeviceSettings().connectionAvailable) { } else if (!System.getDeviceSettings().connectionAvailable) {
@@ -50,6 +67,8 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
} }
} }
//! Called periodically to send status updates to the Home Assistant instance.
//
function onTemporalEvent() as Void { function onTemporalEvent() as Void {
if (!System.getDeviceSettings().phoneConnected) { if (!System.getDeviceSettings().phoneConnected) {
// System.println("BackgroundServiceDelegate onTemporalEvent(): No Phone connection, skipping API call."); // System.println("BackgroundServiceDelegate onTemporalEvent(): No Phone connection, skipping API call.");
@@ -76,7 +95,15 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
} }
} }
private function doUpdate(activity as Lang.Number or Null, sub_activity as Lang.Number or Null) { //! Combined update function to collect the data to be sent as updates to the Home Assistant instance.
//!
//! @param activity Activity.Sport
//! @param sub_activity Activity.SubSport
//
private function doUpdate(
activity as Lang.Number or Null,
sub_activity as Lang.Number or Null
) {
// System.println("BackgroundServiceDelegate onTemporalEvent(): Making API call."); // System.println("BackgroundServiceDelegate onTemporalEvent(): Making API call.");
var position = Position.getInfo(); var position = Position.getInfo();
// System.println("BackgroundServiceDelegate onTemporalEvent(): GPS : " + position.position.toDegrees()); // System.println("BackgroundServiceDelegate onTemporalEvent(): GPS : " + position.position.toDegrees());
@@ -136,7 +163,7 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
}, },
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
}, },
method(:onReturnBatteryUpdate) method(:onReturnDoUpdate)
); );
} }
var activityInfo = ActivityMonitor.getInfo(); var activityInfo = ActivityMonitor.getInfo();
@@ -222,7 +249,7 @@ class BackgroundServiceDelegate extends System.ServiceDelegate {
}, },
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
}, },
method(:onReturnBatteryUpdate) method(:onReturnDoUpdate)
); );
} }

View File

@@ -11,15 +11,12 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// ClientId is somewhere to store personal credentials that should not be shared in
// a separate file that is locally customised to the source code and not commited
// back to GitHub.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
//! ClientId is somewhere to store personal credentials that should not be shared in
//! a separate file that is locally customised to the source code and not committed
//! back to GitHub.
//
(:glance) (:glance)
class ClientId { class ClientId {
static const webLogUrl = "https://..."; static const webLogUrl = "https://...";

View File

@@ -11,22 +11,6 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// ErrorView provides a means to present application errors to the user. These
// should not happen of course... but they do, so best make sure errors can be
// reported.
//
// Designed so that a single ErrorView is used for all errors and hence can ensure
// that only the first call to display is honoured until the view is dismissed.
// This compensates for older devices not being able to call WatchUi.getCurrentView()
// due to not supporting API level 3.4.0.
//
// Usage:
// 1) ErrorView.show("Error message");
// 2) return ErrorView.create("Error message"); // as Lang.Array<WatchUi.Views or WatchUi.InputDelegates>
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Graphics; using Toybox.Graphics;
@@ -35,6 +19,19 @@ using Toybox.WatchUi;
using Toybox.Communications; using Toybox.Communications;
using Toybox.Timer; using Toybox.Timer;
//! ErrorView provides a means to present application errors to the user. These
//! should not happen of course... but they do, so best make sure errors can be
//! reported.
//!
//! Designed so that a single ErrorView is used for all errors and hence can ensure
//! that only the first call to display is honoured until the view is dismissed.
//! This compensates for older devices not being able to call WatchUi.getCurrentView()
//! due to not supporting API level 3.4.0.
//!
//! Usage:
//! 1) `ErrorView.show("Error message");`
//! 2) `return ErrorView.create("Error message"); // as Lang.Array<WatchUi.Views or WatchUi.InputDelegates>`
//
class ErrorView extends ScalableView { class ErrorView extends ScalableView {
private static const scErrorIconMargin as Lang.Float = 7f; private static const scErrorIconMargin as Lang.Float = 7f;
private var mText as Lang.String = ""; private var mText as Lang.String = "";
@@ -48,9 +45,11 @@ class ErrorView extends ScalableView {
private static var instance; private static var instance;
private static var mShown as Lang.Boolean = false; private static var mShown as Lang.Boolean = false;
//! Class Constructor
//
function initialize() { function initialize() {
ScalableView.initialize(); ScalableView.initialize();
mDelegate = new ErrorDelegate(self); mDelegate = new ErrorDelegate();
// Convert the settings from % of screen size to pixels // Convert the settings from % of screen size to pixels
mErrorIconMargin = pixelsForScreen(scErrorIconMargin); mErrorIconMargin = pixelsForScreen(scErrorIconMargin);
mErrorIcon = Application.loadResource(Rez.Drawables.ErrorIcon) as Graphics.BitmapResource; mErrorIcon = Application.loadResource(Rez.Drawables.ErrorIcon) as Graphics.BitmapResource;
@@ -59,7 +58,10 @@ class ErrorView extends ScalableView {
} }
} }
// Load your resources here //! Construct the view.
//!
//! @param dc Device context
//
function onLayout(dc as Graphics.Dc) as Void { function onLayout(dc as Graphics.Dc) as Void {
var w = dc.getWidth(); var w = dc.getWidth();
@@ -75,7 +77,10 @@ class ErrorView extends ScalableView {
}); });
} }
// Update the view //! Update the view
//!
//! @param dc Device context
//
function onUpdate(dc as Graphics.Dc) as Void { function onUpdate(dc as Graphics.Dc) as Void {
var w = dc.getWidth(); var w = dc.getWidth();
if (mAntiAlias) { if (mAntiAlias) {
@@ -87,10 +92,18 @@ class ErrorView extends ScalableView {
mTextArea.draw(dc); mTextArea.draw(dc);
} }
//! Get this view's delegate for processing events.
//
function getDelegate() as ErrorDelegate { function getDelegate() as ErrorDelegate {
return mDelegate; return mDelegate;
} }
//! 'Create' (get) the ErrorView instance, intended to make short work of using this class. E.g.
//!
//! `return ErrorView.create("Went wrong!");`
//!
//! @param text The string to display in the ErrorView.
//
static function create(text as Lang.String) as [ WatchUi.Views ] or [ WatchUi.Views, WatchUi.InputDelegates ] { static function create(text as Lang.String) as [ WatchUi.Views ] or [ WatchUi.Views, WatchUi.InputDelegates ] {
if (instance == null) { if (instance == null) {
instance = new ErrorView(); instance = new ErrorView();
@@ -102,7 +115,10 @@ class ErrorView extends ScalableView {
return [instance, instance.getDelegate()]; return [instance, instance.getDelegate()];
} }
// Create or reuse an existing ErrorView, and pass on the text. //! Create or reuse an existing ErrorView, and pass on the text.
//!
//! @param text The string to display in the ErrorView.
//
static function show(text as Lang.String) as Void { static function show(text as Lang.String) as Void {
if (!mShown) { if (!mShown) {
create(text); // Ignore returned values create(text); // Ignore returned values
@@ -113,6 +129,8 @@ class ErrorView extends ScalableView {
} }
} }
//! Pop the view and clean up timers.
//
static function unShow() as Void { static function unShow() as Void {
if (mShown) { if (mShown) {
WatchUi.popView(WatchUi.SLIDE_DOWN); WatchUi.popView(WatchUi.SLIDE_DOWN);
@@ -126,7 +144,10 @@ class ErrorView extends ScalableView {
} }
} }
// Internal show now we're not a static method like 'show()'. //! Internal show now we're not a static method like 'show()'.
//!
//! @param text Change the string tio display in the ErrorView.
//
function setText(text as Lang.String) as Void { function setText(text as Lang.String) as Void {
mText = text; mText = text;
if (mTextArea != null) { if (mTextArea != null) {
@@ -137,12 +158,19 @@ class ErrorView extends ScalableView {
} }
//! Delegate for the ErrorView.
//
class ErrorDelegate extends WatchUi.BehaviorDelegate { class ErrorDelegate extends WatchUi.BehaviorDelegate {
function initialize(view as ErrorView) { //! Class Constructor
//!
function initialize() {
WatchUi.BehaviorDelegate.initialize(); WatchUi.BehaviorDelegate.initialize();
} }
//! Process the event to clear the ErrorView.
//
function onBack() as Lang.Boolean { function onBack() as Lang.Boolean {
getApp().getQuitTimer().reset(); getApp().getQuitTimer().reset();
ErrorView.unShow(); ErrorView.unShow();

View File

@@ -11,30 +11,38 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Home Assistant centralised constants.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
//! Home Assistant centralised constants.
//
(:glance) (:glance)
class Globals { class Globals {
//! Alert is a toast at the top of the watch screen, it stays present until tapped
//! or this timeout has expired.
static const scAlertTimeout = 2000; // ms static const scAlertTimeout = 2000; // ms
static const scTapTimeout = 1000; // ms
// Time to let the existing HTTP responses get serviced after a //! Time to let the existing HTTP responses get serviced after a
// Communications.NETWORK_RESPONSE_OUT_OF_MEMORY response code. //! `Communications.NETWORK_RESPONSE_OUT_OF_MEMORY` response code.
static const scApiBackoff = 1000; // ms static const scApiBackoff = 2000; // ms
// Needs to be long enough to enable a "double ESC" to quit the application from
// an ErrorView. //! Needs to be long enough to enable a "double ESC" to quit the application from
//! an ErrorView.
static const scApiResume = 200; // ms static const scApiResume = 200; // ms
// Warn the user after fetching the menu if their watch is low on memory before the device crashes.
//! Warn the user after fetching the menu if their watch is low on memory before the device crashes.
static const scLowMem = 0.90; // percent as a fraction. static const scLowMem = 0.90; // percent as a fraction.
// Constants for PIN confirmation dialog //! Constant for PIN confirmation dialog.<br>
static const scPinMaxFailures = 5; // Maximum number of failed PIN confirmation attemps allwed in ... //! Maximum number of failed PIN confirmation attempts allowed in `scPinMaxFailureMinutes`.
static const scPinMaxFailureMinutes = 2; // ... this number of minutes before PIN confirmation is locked for ... static const scPinMaxFailures = 5;
static const scPinLockTimeMinutes = 10; // ... this number of minutes
//! Constant for PIN confirmation dialog.<br>
//! Period in minutes during which no more than `scPinMaxFailures` PIN attempts are tolerated.
static const scPinMaxFailureMinutes = 2;
//! Constant for PIN confirmation dialog.<br>
//! Lock out time in minutes after a failed PIN entry.
static const scPinLockTimeMinutes = 10;
} }

View File

@@ -11,11 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Application root for GarminHomeAssistant
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Application; using Toybox.Application;
@@ -25,11 +20,15 @@ using Toybox.System;
using Toybox.Application.Properties; using Toybox.Application.Properties;
using Toybox.Timer; using Toybox.Timer;
//! Application root for GarminHomeAssistant
//
(:glance, :background) (:glance, :background)
class HomeAssistantApp extends Application.AppBase { class HomeAssistantApp extends Application.AppBase {
private var mApiStatus as Lang.String or Null; private var mApiStatus as Lang.String or Null;
private var mMenuStatus as Lang.String or Null; private var mMenuStatus as Lang.String or Null;
private var mHaMenu as HomeAssistantView or Null; private var mHaMenu as HomeAssistantView or Null;
private var mGlanceTemplate as Lang.String or Null = null;
private var mGlanceText as Lang.String or Null = null;
private var mQuitTimer as QuitTimer or Null; private var mQuitTimer as QuitTimer or Null;
private var mGlanceTimer as Timer.Timer or Null; private var mGlanceTimer as Timer.Timer or Null;
private var mUpdateTimer as Timer.Timer or Null; private var mUpdateTimer as Timer.Timer or Null;
@@ -40,6 +39,8 @@ class HomeAssistantApp extends Application.AppBase {
private var mUpdating as Lang.Boolean = false; // Don't start a second chain of updates private var mUpdating as Lang.Boolean = false; // Don't start a second chain of updates
private var mTemplates as Lang.Dictionary = {}; private var mTemplates as Lang.Dictionary = {};
//! Class Constructor
//
function initialize() { function initialize() {
AppBase.initialize(); AppBase.initialize();
// ATTENTION when adding stuff into this block: // ATTENTION when adding stuff into this block:
@@ -55,7 +56,10 @@ class HomeAssistantApp extends Application.AppBase {
// with "(:glance)". // with "(:glance)".
} }
// onStart() is called on application start up //! Called on application start up
//!
//! @param state see `AppBase.onStart()`
//
function onStart(state as Lang.Dictionary?) as Void { function onStart(state as Lang.Dictionary?) as Void {
AppBase.onStart(state); AppBase.onStart(state);
// ATTENTION when adding stuff into this block: // ATTENTION when adding stuff into this block:
@@ -71,7 +75,11 @@ class HomeAssistantApp extends Application.AppBase {
// with "(:glance)". // with "(:glance)".
} }
// onStop() is called when your application is exiting //! Called when your application is exiting
//
//!
//! @param state see `AppBase.onStop()`
//
function onStop(state as Lang.Dictionary?) as Void { function onStop(state as Lang.Dictionary?) as Void {
AppBase.onStop(state); AppBase.onStop(state);
// ATTENTION when adding stuff into this block: // ATTENTION when adding stuff into this block:
@@ -87,7 +95,10 @@ class HomeAssistantApp extends Application.AppBase {
// with "(:glance)". // with "(:glance)".
} }
// Return the initial view of your application here //! Returns the initial view of the application.
//!
//! @return The initial view.
//
function getInitialView() as [ WatchUi.Views ] or [ WatchUi.Views, WatchUi.InputDelegates ] { function getInitialView() as [ WatchUi.Views ] or [ WatchUi.Views, WatchUi.InputDelegates ] {
mIsApp = true; mIsApp = true;
mQuitTimer = new QuitTimer(); mQuitTimer = new QuitTimer();
@@ -132,10 +143,16 @@ class HomeAssistantApp extends Application.AppBase {
} }
} }
// Callback function after completing the GET request to fetch the configuration menu. //! Callback function after completing the GET request to fetch the configuration menu.
//!
//! @param responseCode Response code.
//! @param data Response data.
// //
(:glance) (:glance)
function onReturnFetchMenuConfig(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { function onReturnFetchMenuConfig(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: " + responseCode); // System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Data: " + data); // System.println("HomeAssistantApp onReturnFetchMenuConfig() Response Data: " + data);
@@ -188,7 +205,9 @@ class HomeAssistantApp extends Application.AppBase {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String; mMenuStatus = WatchUi.loadResource($.Rez.Strings.Available) as Lang.String;
} }
} }
if (!mIsGlance) { if (mIsGlance) {
glanceTemplate(data);
} else {
if (data == null) { if (data == null) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String); ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
} else { } else {
@@ -208,8 +227,11 @@ class HomeAssistantApp extends Application.AppBase {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// Return true if the menu came from the cache, otherwise false. This is because fetching the menu when not in the cache is //! Fetch the menu configuration over HTTPS, which might be locally cached.
// asynchronous and affects how the views are managed. //!
//! @return Return true if the menu came from the cache, otherwise false. This is because fetching
//! the menu when not in the cache is asynchronous and affects how the views are managed.
//
(:glance) (:glance)
function fetchMenuConfig() as Lang.Boolean { function fetchMenuConfig() as Lang.Boolean {
// System.println("Menu URL = " + Settings.getConfigUrl()); // System.println("Menu URL = " + Settings.getConfigUrl());
@@ -254,7 +276,9 @@ class HomeAssistantApp extends Application.AppBase {
} else { } else {
mMenuStatus = WatchUi.loadResource($.Rez.Strings.Cached) as Lang.String; mMenuStatus = WatchUi.loadResource($.Rez.Strings.Cached) as Lang.String;
WatchUi.requestUpdate(); WatchUi.requestUpdate();
if (!mIsGlance) { if (mIsGlance) {
glanceTemplate(menu);
} else {
buildMenu(menu); buildMenu(menu);
} }
return true; return true;
@@ -263,6 +287,10 @@ class HomeAssistantApp extends Application.AppBase {
return false; return false;
} }
//! Build the menu and store in `mHaMenu`. Then start updates if necessary.
//!
//! @param menu The dictionary derived from the JSON menu fetched by `fetchMenuConfig()`.
//
private function buildMenu(menu as Lang.Dictionary) { private function buildMenu(menu as Lang.Dictionary) {
mHaMenu = new HomeAssistantView(menu, null); mHaMenu = new HomeAssistantView(menu, null);
mQuitTimer.begin(); mQuitTimer.begin();
@@ -271,6 +299,8 @@ class HomeAssistantApp extends Application.AppBase {
} // If not, this will be done via a chain in Settings.webhook() and mWebhookManager.requestWebhookId() that registers the sensors. } // If not, this will be done via a chain in Settings.webhook() and mWebhookManager.requestWebhookId() that registers the sensors.
} }
//! Start the periodic menu updates for as long as the application is running.
//
function startUpdates() { function startUpdates() {
if (mHaMenu != null and !mUpdating) { if (mHaMenu != null and !mUpdating) {
// Start the continuous update process that continues for as long as the application is running. // Start the continuous update process that continues for as long as the application is running.
@@ -279,7 +309,31 @@ class HomeAssistantApp extends Application.AppBase {
} }
} }
function onReturnUpdateMenuItems(responseCode as Lang.Number, data as Null or Lang.Dictionary) as Void { //! Extract the optional template to override the default glance view.
//
function glanceTemplate(menu as Lang.Dictionary) {
if (menu != null) {
if (menu.get("glance") != null) {
var glance = menu.get("glance") as Lang.Dictionary;
if (glance.get("type").equals("info")) {
mGlanceTemplate = glance.get("content") as Lang.String;
// System.println("HomeAssistantApp glanceTemplate() " + mGlanceTemplate);
} else { // if glance.get("type").equals("status")
mGlanceTemplate = null;
}
}
}
}
//! Callback function for each menu update GET request.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
function onReturnUpdateMenuItems(
responseCode as Lang.Number,
data as Null or Lang.Dictionary
) as Void {
// System.println("HomeAssistantApp onReturnUpdateMenuItems() Response Code: " + responseCode); // System.println("HomeAssistantApp onReturnUpdateMenuItems() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnUpdateMenuItems() Response Data: " + data); // System.println("HomeAssistantApp onReturnUpdateMenuItems() Response Data: " + data);
@@ -353,6 +407,8 @@ class HomeAssistantApp extends Application.AppBase {
setApiStatus(status); setApiStatus(status);
} }
//! Construct the GET request to update all menu items.
//
function updateMenuItems() as Void { function updateMenuItems() as Void {
if (! System.getDeviceSettings().phoneConnected) { if (! System.getDeviceSettings().phoneConnected) {
// System.println("HomeAssistantApp updateMenuItems(): No Phone connection, skipping API call."); // System.println("HomeAssistantApp updateMenuItems(): No Phone connection, skipping API call.");
@@ -368,7 +424,7 @@ class HomeAssistantApp extends Application.AppBase {
mTemplates = {}; mTemplates = {};
for (var i = 0; i < mItemsToUpdate.size(); i++) { for (var i = 0; i < mItemsToUpdate.size(); i++) {
var item = mItemsToUpdate[i]; var item = mItemsToUpdate[i];
var template = item.buildTemplate(); var template = item.getTemplate();
if (template != null) { if (template != null) {
mTemplates.put(i.toString(), { mTemplates.put(i.toString(), {
"template" => template "template" => template
@@ -376,16 +432,15 @@ class HomeAssistantApp extends Application.AppBase {
} }
if (item instanceof HomeAssistantToggleMenuItem) { if (item instanceof HomeAssistantToggleMenuItem) {
mTemplates.put(i.toString() + "t", { mTemplates.put(i.toString() + "t", {
"template" => (item as HomeAssistantToggleMenuItem).buildToggleTemplate() "template" => (item as HomeAssistantToggleMenuItem).getToggleTemplate()
}); });
} }
} }
} }
// https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates // https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates
var url = Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId();
// System.println("HomeAssistantApp updateMenuItems() URL=" + url + ", Template='" + mTemplate + "'"); // System.println("HomeAssistantApp updateMenuItems() URL=" + url + ", Template='" + mTemplate + "'");
Communications.makeWebRequest( Communications.makeWebRequest(
url, Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(),
{ {
"type" => "render_template", "type" => "render_template",
"data" => mTemplates "data" => mTemplates
@@ -402,10 +457,16 @@ class HomeAssistantApp extends Application.AppBase {
} }
} }
// Callback function after completing the GET request to fetch the API status. //! Callback function after completing the GET request to fetch the API status.
//!
//! @param responseCode Response code.
//! @param data Response data.
// //
(:glance) (:glance)
function onReturnFetchApiStatus(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { function onReturnFetchApiStatus(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: " + responseCode); // System.println("HomeAssistantApp onReturnFetchApiStatus() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchApiStatus() Response Data: " + data); // System.println("HomeAssistantApp onReturnFetchApiStatus() Response Data: " + data);
@@ -466,6 +527,8 @@ class HomeAssistantApp extends Application.AppBase {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
//! Construct the GET request to test the API status, is it accessible?
//
(:glance) (:glance)
function fetchApiStatus() as Void { function fetchApiStatus() as Void {
// System.println("API URL = " + Settings.getApiUrl()); // System.println("API URL = " + Settings.getApiUrl());
@@ -506,30 +569,153 @@ class HomeAssistantApp extends Application.AppBase {
} }
} }
//! Callback function after completing the GET request to render the glance template.
//!
//! @param responseCode Response code.
//! @param data Response data.
//
(:glance)
function onReturnFetchGlanceContent(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: " + responseCode);
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Data: " + data);
switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
}
break;
case Communications.BLE_QUEUE_FULL:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
}
break;
case Communications.NETWORK_REQUEST_TIMED_OUT:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
}
break;
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
}
break;
case 404:
// System.println("HomeAssistantApp onReturnFetchGlanceContent() Response Code: 404, page not found. Check Configuration URL setting.");
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.ConfigUrlNotFound) as Lang.String);
}
break;
case 200:
if (data != null) {
mGlanceText = data.get("glanceTemplate");
}
break;
default:
// System.println("HomeAssistantApp onReturnFetchGlanceContent(): Unhandled HTTP response code = " + responseCode);
if (!mIsGlance) {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
}
}
WatchUi.requestUpdate();
}
//! Construct the GET request to convert the optional glance template to text for display.
//
(:glance)
function fetchGlanceContent() as Void {
if (mGlanceTemplate != null) {
// https://developers.home-assistant.io/docs/api/native-app-integration/sending-data/#render-templates
Communications.makeWebRequest(
Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(),
{
"type" => "render_template",
"data" => {
"glanceTemplate" => {
"template" => mGlanceTemplate
}
}
},
{
:method => Communications.HTTP_REQUEST_METHOD_POST,
:headers => {
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON
},
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
},
method(:onReturnFetchGlanceContent)
);
}
}
//! Record the API status result.
//!
//! @param s A string describing the API status
//
function setApiStatus(s as Lang.String) { function setApiStatus(s as Lang.String) {
mApiStatus = s; mApiStatus = s;
} }
//! Return the API status result.
//!
//! @return A string describing the API status
//
(:glance) (:glance)
function getApiStatus() as Lang.String { function getApiStatus() as Lang.String {
return mApiStatus; return mApiStatus;
} }
//! Return the Menu status result.
//!
//! @return A string describing the Menu status
//
(:glance) (:glance)
function getMenuStatus() as Lang.String { function getMenuStatus() as Lang.String {
return mMenuStatus; return mMenuStatus;
} }
//! Return the optional glance text that overrides the default glance content. This
//! is derived from the glance template.
//!
//! @return A string derived from the glance template
//
(:glance)
function getGlanceText() as Lang.String or Null {
return mGlanceText;
}
//! Return the Menu construction status.
//!
//! @return A Boolean indicating if the menu is loaded into the application.
//
function isHomeAssistantMenuLoaded() as Lang.Boolean { function isHomeAssistantMenuLoaded() as Lang.Boolean {
return mHaMenu != null; return mHaMenu != null;
} }
//! Make the menu visible on the watch face.
//
function pushHomeAssistantMenuView() as Void { function pushHomeAssistantMenuView() as Void {
WatchUi.pushView(mHaMenu, new HomeAssistantViewDelegate(true), WatchUi.SLIDE_IMMEDIATE); WatchUi.pushView(mHaMenu, new HomeAssistantViewDelegate(true), WatchUi.SLIDE_IMMEDIATE);
} }
// Only call this function if Settings.getPollDelay() > 0. This must be tested locally as it is then efficient to take //! Force status updates. Only take action if `Settings.getPollDelay() > 0`. This must be tested
// alternative action if the test fails. //! locally as it is then efficient to take alternative action if the test fails.
//
function forceStatusUpdates() as Void { function forceStatusUpdates() as Void {
// Don't mess with updates unless we are using a timer. // Don't mess with updates unless we are using a timer.
if (Settings.getPollDelay() > 0) { if (Settings.getPollDelay() > 0) {
@@ -539,10 +725,18 @@ class HomeAssistantApp extends Application.AppBase {
} }
} }
//! Return the timer used to quit the application.
//!
//! @return Timer object
//
function getQuitTimer() as QuitTimer { function getQuitTimer() as QuitTimer {
return mQuitTimer; return mQuitTimer;
} }
//! Return the glance view.
//!
//! @return The glance view
//
function getGlanceView() as [ WatchUi.GlanceView ] or [ WatchUi.GlanceView, WatchUi.GlanceViewDelegate ] or Null { function getGlanceView() as [ WatchUi.GlanceView ] or [ WatchUi.GlanceView, WatchUi.GlanceViewDelegate ] or Null {
mIsGlance = true; mIsGlance = true;
mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String; mApiStatus = WatchUi.loadResource($.Rez.Strings.Checking) as Lang.String;
@@ -554,30 +748,47 @@ class HomeAssistantApp extends Application.AppBase {
return [new HomeAssistantGlanceView(self)]; return [new HomeAssistantGlanceView(self)];
} }
// Required for the Glance update timer. //! Return the glance theme.
//!
//! @return The glance colour
//
function getGlanceTheme() as Application.AppBase.GlanceTheme {
return Application.AppBase.GLANCE_THEME_LIGHT_BLUE;
}
//! Update the menu and API statuses. Required for the Glance update timer.
//
function updateStatus() as Void { function updateStatus() as Void {
mGlanceTimer = null; mGlanceTimer = null;
fetchMenuConfig(); fetchMenuConfig();
fetchApiStatus(); fetchApiStatus();
fetchGlanceContent();
} }
//! Code for when the application settings are updated.
//
function onSettingsChanged() as Void { function onSettingsChanged() as Void {
// System.println("HomeAssistantApp onSettingsChanged()"); // System.println("HomeAssistantApp onSettingsChanged()");
Settings.update(); Settings.update();
} }
// Called each time the Registered Temporal Event is to be invoked. So the object is created each time on request and //! Called each time the Registered Temporal Event is to be invoked. So the object is created each time
// then destroyed on completion (to save resources). //! on request and then destroyed on completion (to save resources).
//
function getServiceDelegate() as [ System.ServiceDelegate ] { function getServiceDelegate() as [ System.ServiceDelegate ] {
return [new BackgroundServiceDelegate()]; return [new BackgroundServiceDelegate()];
} }
//! Determine is we are a glance or the full application. Glances should be considered to be separate applications.
//
function getIsApp() as Lang.Boolean { function getIsApp() as Lang.Boolean {
return mIsApp; return mIsApp;
} }
} }
//! Global function to return the application object.
//
(:glance, :background) (:glance, :background)
function getApp() as HomeAssistantApp { function getApp() as HomeAssistantApp {
return Application.getApp() as HomeAssistantApp; return Application.getApp() as HomeAssistantApp;

View File

@@ -11,11 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023 // P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023
// //
//
// Description:
//
// Calling a Home Assistant confirmation dialogue view.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -25,19 +20,27 @@ using Toybox.WatchUi;
using Toybox.Timer; using Toybox.Timer;
using Toybox.Application.Properties; using Toybox.Application.Properties;
//! Calling a Home Assistant confirmation dialogue view.
//
class HomeAssistantConfirmation extends WatchUi.Confirmation { class HomeAssistantConfirmation extends WatchUi.Confirmation {
//! Class Constructor
//
function initialize() { function initialize() {
WatchUi.Confirmation.initialize(WatchUi.loadResource($.Rez.Strings.Confirm) as Lang.String); WatchUi.Confirmation.initialize(WatchUi.loadResource($.Rez.Strings.Confirm) as Lang.String);
} }
} }
//! Delegate to respond to the confirmation request.
//
class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate { class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
private var mConfirmMethod as Method(state as Lang.Boolean) as Void; private var mConfirmMethod as Method(state as Lang.Boolean) as Void;
private var mTimer as Timer.Timer or Null; private var mTimer as Timer.Timer or Null;
private var mState as Lang.Boolean; private var mState as Lang.Boolean;
//! Class Constructor
//
function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean) { function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean) {
WatchUi.ConfirmationDelegate.initialize(); WatchUi.ConfirmationDelegate.initialize();
mConfirmMethod = callback; mConfirmMethod = callback;
@@ -49,7 +52,12 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
} }
} }
function onResponse(response) as Lang.Boolean { //! Respond to the confirmation event.
//!
//! @param response code
//! @return Required to meet the function prototype, but the base class does not indicate a definition.
//
function onResponse(response as WatchUi.Confirm) as Lang.Boolean {
getApp().getQuitTimer().reset(); getApp().getQuitTimer().reset();
if (mTimer != null) { if (mTimer != null) {
mTimer.stop(); mTimer.stop();
@@ -60,6 +68,7 @@ class HomeAssistantConfirmationDelegate extends WatchUi.ConfirmationDelegate {
return true; return true;
} }
//! Function supplied to a timer in order to limit the time for which the confirmation can be provided.
function onTimeout() as Void { function onTimeout() as Void {
mTimer.stop(); mTimer.stop();
WatchUi.popView(WatchUi.SLIDE_RIGHT); WatchUi.popView(WatchUi.SLIDE_RIGHT);

View File

@@ -11,29 +11,49 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 23 November 2023 // P A Abbey & J D Abbey & Someone0nEarth, 23 November 2023
// //
//
// Description:
//
// Glance view for GarminHomeAssistant
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Graphics; using Toybox.Graphics;
//! Glance view for GarminHomeAssistant
//
(:glance) (:glance)
class HomeAssistantGlanceView extends WatchUi.GlanceView { class HomeAssistantGlanceView extends WatchUi.GlanceView {
private static const scLeftMargin = 5; // in pixels //! Margin left of the filled rectangle in pixels.
private static const scMidSep = 10; // Middle Separator "text:_text" in pixels private static const scLeftRectMargin = 5;
//! Filled rectangle width in pixels.
private static const scRectWidth = 20;
//! Margin right of the filled rectangle in pixels.
private static const scRightRectMargin = 5;
//! Separator between the first column of text and the second in pixels.
//! i.e. Middle Separator "text:_text"
private static const scMidSep = 10;
//! Margin on the right side of the glance in pixels.
private static const scRightGlanceMargin = 15;
//! Internal margin for the custom template between the border and the text in pixels.
private static const scIntCustMargin = 5;
//! Margin top and bottom of the rectangles in pixels.
private static const scVertMargin = 5;
//! Size of the rounded rectangle corners in pixels.
private static const scRectRadius = 5;
//! Dynamically scale the width of the first column of text based on the
//! language selection for the word "Menu".
private var mTextWidth as Lang.Number = 0;
// Re-usable text items for drawing
private var mApp as HomeAssistantApp; private var mApp as HomeAssistantApp;
private var mTitle as WatchUi.Text or Null; private var mTitle as WatchUi.Text or Null;
private var mApiText as WatchUi.Text or Null; private var mApiText as WatchUi.Text or Null;
private var mApiStatus as WatchUi.Text or Null; private var mApiStatus as WatchUi.Text or Null;
private var mMenuText as WatchUi.Text or Null; private var mMenuText as WatchUi.Text or Null;
private var mMenuStatus as WatchUi.Text or Null; private var mMenuStatus as WatchUi.Text or Null;
private var mGlanceContent as WatchUi.TextArea or Null;
private var mAntiAlias as Lang.Boolean = false; private var mAntiAlias as Lang.Boolean = false;
//! Class Constructor
//
function initialize(app as HomeAssistantApp) { function initialize(app as HomeAssistantApp) {
GlanceView.initialize(); GlanceView.initialize();
mApp = app; mApp = app;
@@ -42,16 +62,21 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
} }
} }
//! Construct the view.
//!
//! @param dc Device context
//
function onLayout(dc as Graphics.Dc) as Void { function onLayout(dc as Graphics.Dc) as Void {
var h = dc.getHeight(); var h = dc.getHeight();
var tw = dc.getTextWidthInPixels(WatchUi.loadResource($.Rez.Strings.GlanceMenu) as Lang.String, Graphics.FONT_XTINY);
mTextWidth = dc.getTextWidthInPixels(WatchUi.loadResource($.Rez.Strings.GlanceMenu) as Lang.String + ":", Graphics.FONT_XTINY);
mTitle = new WatchUi.Text({ mTitle = new WatchUi.Text({
:text => WatchUi.loadResource($.Rez.Strings.AppName) as Lang.String, :text => WatchUi.loadResource($.Rez.Strings.AppName) as Lang.String,
:color => Graphics.COLOR_WHITE, :color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_TINY, :font => Graphics.FONT_TINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER, :justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftMargin, :locX => scLeftRectMargin,
:locY => 1 * h / 6 :locY => 1 * h / 6
}); });
@@ -60,7 +85,7 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
:color => Graphics.COLOR_WHITE, :color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_XTINY, :font => Graphics.FONT_XTINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER, :justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftMargin, :locX => scLeftRectMargin + scRectWidth + scRightRectMargin,
:locY => 3 * h / 6 :locY => 3 * h / 6
}); });
mApiStatus = new WatchUi.Text({ mApiStatus = new WatchUi.Text({
@@ -68,15 +93,16 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
:color => Graphics.COLOR_WHITE, :color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_XTINY, :font => Graphics.FONT_XTINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER, :justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftMargin + scMidSep + tw, :locX => scLeftRectMargin + scRectWidth + scRightRectMargin + scMidSep + mTextWidth,
:locY => 3 * h / 6 :locY => 3 * h / 6
}); });
mMenuText = new WatchUi.Text({ mMenuText = new WatchUi.Text({
:text => WatchUi.loadResource($.Rez.Strings.GlanceMenu) as Lang.String + ":", :text => WatchUi.loadResource($.Rez.Strings.GlanceMenu) as Lang.String + ":",
:color => Graphics.COLOR_WHITE, :color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_XTINY, :font => Graphics.FONT_XTINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER, :justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftMargin, :locX => scLeftRectMargin + scRectWidth + scRightRectMargin,
:locY => 5 * h / 6 :locY => 5 * h / 6
}); });
mMenuStatus = new WatchUi.Text({ mMenuStatus = new WatchUi.Text({
@@ -84,14 +110,38 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
:color => Graphics.COLOR_WHITE, :color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_XTINY, :font => Graphics.FONT_XTINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER, :justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftMargin + scMidSep + tw, :locX => scLeftRectMargin + scRectWidth + scRightRectMargin + scMidSep + mTextWidth,
:locY => 5 * h / 6 :locY => 5 * h / 6
}); });
mGlanceContent = new WatchUi.TextArea({
:text => "A longer piece of text to wrap.",
:color => Graphics.COLOR_WHITE,
:font => Graphics.FONT_XTINY,
:justification => Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER,
:locX => scLeftRectMargin + scRectWidth + scRightRectMargin + scIntCustMargin,
:locY => (2 * h / 6) + scVertMargin,
:width => dc.getWidth() - scLeftRectMargin - scRectWidth - scRightRectMargin - (2 * scIntCustMargin) - scRightGlanceMargin,
:height => (4 * h / 6) - (2 * scVertMargin)
});
} }
//! Update the view with the latest status text.
//!
//! @param dc Device context
//
function onUpdate(dc as Graphics.Dc) as Void { function onUpdate(dc as Graphics.Dc) as Void {
var h = dc.getHeight();
var w = dc.getWidth() - scLeftRectMargin - scRightGlanceMargin;
var apiStatus = mApp.getApiStatus();
var menuStatus = mApp.getMenuStatus();
var glanceText = mApp.getGlanceText();
var apiCol;
var menuCol;
// System.println("HomeAssistantGlanceView onUpdate() glanceText=" + glanceText);
GlanceView.onUpdate(dc); GlanceView.onUpdate(dc);
if(mAntiAlias) { if (mAntiAlias) {
dc.setAntiAlias(true); dc.setAntiAlias(true);
} }
dc.setColor( dc.setColor(
@@ -99,12 +149,60 @@ class HomeAssistantGlanceView extends WatchUi.GlanceView {
Graphics.COLOR_TRANSPARENT Graphics.COLOR_TRANSPARENT
); );
dc.clear(); dc.clear();
mTitle.setColor(Graphics.COLOR_BLUE);
mTitle.draw(dc); mTitle.draw(dc);
if (apiStatus.equals(WatchUi.loadResource($.Rez.Strings.Checking))) {
apiCol = Graphics.COLOR_YELLOW;
} else if (apiStatus.equals(WatchUi.loadResource($.Rez.Strings.Available))) {
apiCol = Graphics.COLOR_GREEN;
} else {
apiCol = Graphics.COLOR_RED;
}
if (menuStatus.equals(WatchUi.loadResource($.Rez.Strings.Checking))) {
menuCol = Graphics.COLOR_YELLOW;
} else if (menuStatus.equals(WatchUi.loadResource($.Rez.Strings.Available))) {
menuCol = Graphics.COLOR_GREEN;
} else if (menuStatus.equals(WatchUi.loadResource($.Rez.Strings.Cached))) {
menuCol = Graphics.COLOR_GREEN;
} else {
menuCol = Graphics.COLOR_RED;
}
if (glanceText == null) {
// Default Glance View
mApiText.draw(dc); mApiText.draw(dc);
mApiStatus.setText(mApp.getApiStatus()); mApiStatus.setText(apiStatus);
mApiStatus.setColor(apiCol);
dc.setColor(apiCol, apiCol);
dc.drawRoundedRectangle(scLeftRectMargin, 2 * h / 6 + scVertMargin, w, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
dc.fillRoundedRectangle(scLeftRectMargin, 2 * h / 6 + scVertMargin, scRectWidth, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
mApiStatus.draw(dc); mApiStatus.draw(dc);
mMenuText.draw(dc); mMenuText.draw(dc);
mMenuStatus.setText(mApp.getMenuStatus()); mMenuStatus.setText(menuStatus);
mMenuStatus.setColor(menuCol);
dc.setColor(menuCol, menuCol);
dc.drawRoundedRectangle(scLeftRectMargin, 4 * h / 6 + scVertMargin, w, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
dc.fillRoundedRectangle(scLeftRectMargin, 4 * h / 6 + scVertMargin, scRectWidth, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
mMenuStatus.draw(dc); mMenuStatus.draw(dc);
} else {
// Customised Glance View
dc.setColor(Graphics.COLOR_BLUE, Graphics.COLOR_BLUE);
dc.drawRoundedRectangle(
scLeftRectMargin + scRectWidth + scRightRectMargin,
2 * h / 6 + scVertMargin,
w - scRectWidth - scRightRectMargin,
4 * h / 6 - (2 * scVertMargin),
scRectRadius
);
dc.setColor(apiCol, apiCol);
dc.fillRoundedRectangle(scLeftRectMargin, 2 * h / 6 + scVertMargin, scRectWidth, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
dc.setColor(menuCol, menuCol);
dc.fillRoundedRectangle(scLeftRectMargin, 4 * h / 6 + scVertMargin, scRectWidth, 2 * h / 6 - (2 * scVertMargin), scRectRadius);
mGlanceContent.setText(glanceText);
mGlanceContent.draw(dc);
}
} }
} }

View File

@@ -11,20 +11,19 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023
// //
//
// Description:
//
// Menu button with an icon that opens a sub-menu, i.e. group, and optionally renders
// a Home Assistant Template.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
//! Menu button with an icon that opens a sub-menu, i.e. group, and optionally renders
//! a Home Assistant Template.
//
class HomeAssistantGroupMenuItem extends HomeAssistantMenuItem { class HomeAssistantGroupMenuItem extends HomeAssistantMenuItem {
private var mMenu as HomeAssistantView; private var mMenu as HomeAssistantView;
//! Class Constructor
//
function initialize( function initialize(
definition as Lang.Dictionary, definition as Lang.Dictionary,
template as Lang.String, template as Lang.String,
@@ -48,6 +47,8 @@ class HomeAssistantGroupMenuItem extends HomeAssistantMenuItem {
mMenu = new HomeAssistantView(definition, null); mMenu = new HomeAssistantView(definition, null);
} }
//! Return the submenu for this group menu item.
//
function getMenuView() as HomeAssistantView { function getMenuView() as HomeAssistantView {
return mMenu; return mMenu;
} }

View File

@@ -11,20 +11,23 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth, 31 October 2023
// //
//
// Description:
//
// Generic menu button with an icon that optionally renders a Home Assistant Template.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Graphics; using Toybox.Graphics;
//! Generic menu button with an icon that optionally renders a Home Assistant Template.
//
class HomeAssistantMenuItem extends WatchUi.IconMenuItem { class HomeAssistantMenuItem extends WatchUi.IconMenuItem {
private var mTemplate as Lang.String or Null; private var mTemplate as Lang.String or Null;
//! Class Constructor
//!
//! @param label Menu item label
//! @param template Menu item template
//! @param options Menu item options to be passed on.
//
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String, template as Lang.String,
@@ -43,14 +46,27 @@ class HomeAssistantMenuItem extends WatchUi.IconMenuItem {
mTemplate = template; mTemplate = template;
} }
//! Does this menu item use a template?
//!
//! @return True if the menu has a defined template else false.
//
function hasTemplate() as Lang.Boolean { function hasTemplate() as Lang.Boolean {
return mTemplate != null; return mTemplate != null;
} }
function buildTemplate() as Lang.String or Null { //! Return the menu item's template.
//!
//! @return A string with the menu item's template definition.
//
function getTemplate() as Lang.String or Null {
return mTemplate; return mTemplate;
} }
//! 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 { function updateState(data as Lang.String or Lang.Dictionary or Lang.Number or Lang.Float or Null) as Void {
if (data == null) { if (data == null) {
setSubLabel($.Rez.Strings.Empty); setSubLabel($.Rez.Strings.Empty);

View File

@@ -11,17 +11,14 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 17 November 2023 // P A Abbey & J D Abbey & Someone0nEarth, 17 November 2023
// //
//
// Description:
//
// MenuItems Factory.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Application; using Toybox.Application;
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
//! MenuItems Factory class.
//
class HomeAssistantMenuItemFactory { class HomeAssistantMenuItemFactory {
private var mMenuItemOptions as Lang.Dictionary; private var mMenuItemOptions as Lang.Dictionary;
private var mTapTypeIcon as WatchUi.Bitmap; private var mTapTypeIcon as WatchUi.Bitmap;
@@ -31,6 +28,8 @@ class HomeAssistantMenuItemFactory {
private static var instance; private static var instance;
//! Class Constructor
//
private function initialize() { private function initialize() {
mMenuItemOptions = { mMenuItemOptions = {
:alignment => Settings.getMenuAlignment() :alignment => Settings.getMenuAlignment()
@@ -57,6 +56,8 @@ class HomeAssistantMenuItemFactory {
mHomeAssistantService = new HomeAssistantService(); mHomeAssistantService = new HomeAssistantService();
} }
//! Create the one and only instance of this class.
//
static function create() as HomeAssistantMenuItemFactory { static function create() as HomeAssistantMenuItemFactory {
if (instance == null) { if (instance == null) {
instance = new HomeAssistantMenuItemFactory(); instance = new HomeAssistantMenuItemFactory();
@@ -64,37 +65,60 @@ class HomeAssistantMenuItemFactory {
return instance; return instance;
} }
//! Toggle menu item.
//!
//! @param label Menu item label.
//! @param entity_id Home Assistant Entity ID (optional)
//! @param template Template for Home Assistant to render (optional)
//! @param exit Should the service call complete and then exit?
//! @param confirm Should this menu item selection be confirmed?
//! @param pin Should this menu item selection request the security PIN?
//
function toggle( function toggle(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
entity_id as Lang.String or Null, entity_id as Lang.String or Null,
template as Lang.String or Null, template as Lang.String or Null,
exit as Lang.Boolean,
confirm as Lang.Boolean, confirm as Lang.Boolean,
pin as Lang.Boolean pin as Lang.Boolean
) as WatchUi.MenuItem { ) as WatchUi.MenuItem {
return new HomeAssistantToggleMenuItem( return new HomeAssistantToggleMenuItem(
label, label,
template, template,
{ "entity_id" => entity_id },
exit,
confirm, confirm,
pin, pin,
{ "entity_id" => entity_id },
mMenuItemOptions mMenuItemOptions
); );
} }
//! Tap menu item.
//!
//! @param label Menu item label.
//! @param entity_id Home Assistant Entity ID (optional)
//! @param template Template for Home Assistant to render (optional)
//! @param service Template for Home Assistant to render (optional)
//! @param data Sourced from the menu JSON, this is the `data` field from the `tap_action` field.
//! @param exit Should the service call complete and then exit?
//! @param confirm Should this menu item selection be confirmed?
//! @param pin Should this menu item selection request the security PIN?
//
function tap( function tap(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
entity as Lang.String or Null, entity_id as Lang.String or Null,
template as Lang.String or Null, template as Lang.String or Null,
service as Lang.String or Null, service as Lang.String or Null,
data as Lang.Dictionary or Null,
exit as Lang.Boolean,
confirm as Lang.Boolean, confirm as Lang.Boolean,
pin as Lang.Boolean, pin as Lang.Boolean
data as Lang.Dictionary or Null
) as WatchUi.MenuItem { ) as WatchUi.MenuItem {
if (entity != null) { if (entity_id != null) {
if (data == null) { if (data == null) {
data = { "entity_id" => entity }; data = { "entity_id" => entity_id };
} else { } else {
data.put("entity_id", entity); data.put("entity_id", entity_id);
} }
} }
if (service != null) { if (service != null) {
@@ -102,9 +126,10 @@ class HomeAssistantMenuItemFactory {
label, label,
template, template,
service, service,
data,
exit,
confirm, confirm,
pin, pin,
data,
mTapTypeIcon, mTapTypeIcon,
mMenuItemOptions, mMenuItemOptions,
mHomeAssistantService mHomeAssistantService
@@ -114,9 +139,10 @@ class HomeAssistantMenuItemFactory {
label, label,
template, template,
service, service,
data,
exit,
confirm, confirm,
pin, pin,
data,
mInfoTypeIcon, mInfoTypeIcon,
mMenuItemOptions, mMenuItemOptions,
mHomeAssistantService mHomeAssistantService
@@ -124,6 +150,11 @@ class HomeAssistantMenuItemFactory {
} }
} }
//! Group menu item.
//!
//! @param definition Items array from the JSON that defines this sub menu.
//! @param template Template for Home Assistant to render (optional)
//
function group( function group(
definition as Lang.Dictionary, definition as Lang.Dictionary,
template as Lang.String or Null template as Lang.String or Null

View File

@@ -11,25 +11,28 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Pin Confirmation dialog and logic.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
import Toybox.Graphics; using Toybox.Graphics;
import Toybox.Lang; using Toybox.Lang;
import Toybox.WatchUi; using Toybox.WatchUi;
import Toybox.Timer; using Toybox.Timer;
import Toybox.Attention; using Toybox.Attention;
import Toybox.Time; using Toybox.Time;
//! Pin digit used for number 0..9
//
class PinDigit extends WatchUi.Selectable { class PinDigit extends WatchUi.Selectable {
private var mDigit as Number; private var mDigit as Lang.Number;
function initialize(digit as Number, stepX as Number, stepY as 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 marginX = stepX * 0.05; // 5% margin on all sides
var marginY = stepY * 0.05; var marginY = stepY * 0.05;
var x = (digit == 0) ? stepX : stepX * ((digit+2) % 3); // layout '0' in 2nd col, others ltr in 3 columns var x = (digit == 0) ? stepX : stepX * ((digit+2) % 3); // layout '0' in 2nd col, others ltr in 3 columns
@@ -63,24 +66,40 @@ class PinDigit extends WatchUi.Selectable {
}); });
mDigit = digit; mDigit = digit;
} }
function getDigit() as Number { //! Return the digit 0..9 represented by this button
//
function getDigit() as Lang.Number {
return mDigit; return mDigit;
} }
//! Customised drawing of a PIN digit's button.
//
class PinDigitButton extends WatchUi.Drawable { class PinDigitButton extends WatchUi.Drawable {
private var mText as Number; private var mText as Lang.Number;
private var mTouched as Boolean = false; private var mTouched as Lang.Boolean = false;
//! Class Constructor
//!
//! @param options See `Drawable.initialize()`, but with `:label` and `:touched` added.<br>
//! &lbrace;<br>
//! &emsp; :label as Lang.Number, // The digit 0..9 to display<br>
//! &emsp; :touched as Lang.Boolean, // Should the digit be filled to indicate it has been pressed?<br>
//! &emsp; + those required by `Drawable.initialize()`<br>
//! &rbrace;
//
function initialize(options) { function initialize(options) {
Drawable.initialize(options); Drawable.initialize(options);
mText = options.get(:label); mText = options.get(:label);
mTouched = options.get(:touched); mTouched = options.get(:touched);
} }
function draw(dc) { //! Draw the PIN digit button.
//!
//! @param dc Device context
//
function draw(dc as Graphics.Dc) {
if (mTouched) { if (mTouched) {
dc.setColor(Graphics.COLOR_ORANGE, Graphics.COLOR_ORANGE); dc.setColor(Graphics.COLOR_ORANGE, Graphics.COLOR_ORANGE);
} else { } else {
@@ -98,17 +117,27 @@ class PinDigit extends WatchUi.Selectable {
} }
//! Pin Confirmation dialog and logic.
//
class HomeAssistantPinConfirmationView extends WatchUi.View { 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) //! Margin on left & right side of screen (overall prettier and works better on round displays)
static const MARGIN_X = 20;
var mPinMask as String = ""; //! Indicates how many digits have been entered so far.
var mPinMask as Lang.String = "";
//! Class Constructor
//
function initialize() { function initialize() {
View.initialize(); View.initialize();
} }
function onLayout(dc as Dc) as Void { //! 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 stepX = (dc.getWidth() - MARGIN_X * 2) / 3; // three columns
var stepY = dc.getHeight() / 5; // five rows (first row for masked pin entry) var stepY = dc.getHeight() / 5; // five rows (first row for masked pin entry)
var digits = []; var digits = [];
@@ -119,7 +148,11 @@ class HomeAssistantPinConfirmationView extends WatchUi.View {
setLayout(digits); setLayout(digits);
} }
function onUpdate(dc as Dc) as Void { //! Update the view.
//!
//! @param dc Device context
//
function onUpdate(dc as Graphics.Dc) as Void {
View.onUpdate(dc); View.onUpdate(dc);
if (mPinMask.length() != 0) { if (mPinMask.length() != 0) {
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK);
@@ -127,7 +160,11 @@ class HomeAssistantPinConfirmationView extends WatchUi.View {
} }
} }
function updatePinMask(length as Number) { //! Update the PIN mask displayed.
//!
//! @param length Number of `*` characters to use for the mask string.
//
function updatePinMask(length as Lang.Number) {
mPinMask = ""; mPinMask = "";
for (var i=0; i<length; i++) { for (var i=0; i<length; i++) {
mPinMask += "*"; mPinMask += "*";
@@ -138,17 +175,31 @@ class HomeAssistantPinConfirmationView extends WatchUi.View {
} }
//! Delegate for the HomeAssistantPinConfirmationView.
//
class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate { class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
private var mPin as String; private var mPin as Lang.String;
private var mEnteredPin as String; private var mEnteredPin as Lang.String;
private var mConfirmMethod as Method(state as Lang.Boolean) as Void; private var mConfirmMethod as Method(state as Lang.Boolean) as Void;
private var mTimer as Timer.Timer or Null; private var mTimer as Timer.Timer or Null;
private var mState as Lang.Boolean; private var mState as Lang.Boolean;
private var mFailures as PinFailures; private var mFailures as PinFailures;
private var mView as HomeAssistantPinConfirmationView; private var mView as HomeAssistantPinConfirmationView;
function initialize(callback as Method(state as Lang.Boolean) as Void, state as Lang.Boolean, pin as String, view 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.
//
function initialize(
callback as Method(state as Lang.Boolean) as Void,
state as Lang.Boolean,
pin as Lang.String,
view as HomeAssistantPinConfirmationView
) {
BehaviorDelegate.initialize(); BehaviorDelegate.initialize();
mFailures = new PinFailures(); mFailures = new PinFailures();
if (mFailures.isLocked()) { if (mFailures.isLocked()) {
@@ -165,7 +216,12 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
resetTimer(); resetTimer();
} }
function onSelectable(event as SelectableEvent) as Boolean { //! 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()) { if (mFailures.isLocked()) {
goBack(); goBack();
} }
@@ -173,7 +229,7 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) { if (instance instanceof PinDigit && event.getPreviousState() == :stateSelected) {
mEnteredPin += instance.getDigit(); mEnteredPin += instance.getDigit();
createUserFeedback(); createUserFeedback();
// System.println("HomeAssitantPinConfirmationDelegate onSelectable() mEnteredPin = " + mEnteredPin); // System.println("HomeAssistantPinConfirmationDelegate onSelectable() mEnteredPin = " + mEnteredPin);
if (mEnteredPin.length() == mPin.length()) { if (mEnteredPin.length() == mPin.length()) {
if (mEnteredPin.equals(mPin)) { if (mEnteredPin.equals(mPin)) {
mFailures.reset(); mFailures.reset();
@@ -193,6 +249,8 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
return true; return true;
} }
//! Hepatic feedback.
//
function createUserFeedback() { function createUserFeedback() {
if (Attention has :vibrate && Settings.getVibrate()) { if (Attention has :vibrate && Settings.getVibrate()) {
Attention.vibrate([new Attention.VibeProfile(25, 25)]); Attention.vibrate([new Attention.VibeProfile(25, 25)]);
@@ -200,6 +258,9 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
mView.updatePinMask(mEnteredPin.length()); 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() { function resetTimer() {
var timeout = Settings.getConfirmTimeout(); // ms var timeout = Settings.getConfirmTimeout(); // ms
if (timeout > 0) { if (timeout > 0) {
@@ -212,6 +273,8 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
} }
} }
//! Cancel PIN entry.
//
function goBack() as Void { function goBack() as Void {
if (mTimer != null) { if (mTimer != null) {
mTimer.stop(); mTimer.stop();
@@ -219,18 +282,20 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
WatchUi.popView(WatchUi.SLIDE_RIGHT); WatchUi.popView(WatchUi.SLIDE_RIGHT);
} }
//! Hepatic feedback for a wrong PIN and cancel entry.
//
function error() as Void { function error() as Void {
// System.println("HomeAssistantPinConfirmationDelegate error() Wrong PIN entered"); // System.println("HomeAssistantPinConfirmationDelegate error() Wrong PIN entered");
mFailures.addFailure(); mFailures.addFailure();
if (Attention has :vibrate && Settings.getVibrate()) { if (Attention has :vibrate && Settings.getVibrate()) {
Attention.vibrate([ Attention.vibrate([
new Attention.VibeProfile(100, 100), new Attention.VibeProfile(100, 100),
new Attention.VibeProfile(0, 200), new Attention.VibeProfile( 0, 200),
new Attention.VibeProfile(75, 100), new Attention.VibeProfile( 75, 100),
new Attention.VibeProfile(0, 200), new Attention.VibeProfile( 0, 200),
new Attention.VibeProfile(50, 100), new Attention.VibeProfile( 50, 100),
new Attention.VibeProfile(0, 200), new Attention.VibeProfile( 0, 200),
new Attention.VibeProfile(25, 100) new Attention.VibeProfile( 25, 100)
]); ]);
} }
if (WatchUi has :showToast) { if (WatchUi has :showToast) {
@@ -241,14 +306,19 @@ class HomeAssistantPinConfirmationDelegate extends WatchUi.BehaviorDelegate {
} }
//! Manage PIN entry failures to try and prevent brute force exhaustion by inserting delays in retries.
//
class PinFailures { class PinFailures {
const STORAGE_KEY_FAILURES as String = "pin_failures"; const STORAGE_KEY_FAILURES as Lang.String = "pin_failures";
const STORAGE_KEY_LOCKED as String = "pin_locked"; const STORAGE_KEY_LOCKED as Lang.String = "pin_locked";
private var mFailures as Array<Number>; private var mFailures as Lang.Array<Lang.Number>;
private var mLockedUntil as Number or Null; private var mLockedUntil as Lang.Number or Null;
//! Class Constructor
//
function initialize() { function initialize() {
// System.println("PinFailures initialize() Initializing PIN failures from storage"); // System.println("PinFailures initialize() Initializing PIN failures from storage");
var failures = Application.Storage.getValue(PinFailures.STORAGE_KEY_FAILURES); var failures = Application.Storage.getValue(PinFailures.STORAGE_KEY_FAILURES);
@@ -256,6 +326,8 @@ class PinFailures {
mLockedUntil = Application.Storage.getValue(PinFailures.STORAGE_KEY_LOCKED); mLockedUntil = Application.Storage.getValue(PinFailures.STORAGE_KEY_LOCKED);
} }
//! Record a PIN entry failure. If too many have occurred lock the application.
//
function addFailure() { function addFailure() {
mFailures.add(Time.now().value()); mFailures.add(Time.now().value());
// System.println("PinFailures addFailure() " + mFailures.size() + " PIN confirmation failures recorded"); // System.println("PinFailures addFailure() " + mFailures.size() + " PIN confirmation failures recorded");
@@ -268,7 +340,7 @@ class PinFailures {
mFailures = mFailures.slice(1, null); mFailures = mFailures.slice(1, null);
} else { } else {
mFailures = []; mFailures = [];
mLockedUntil = Time.now().add(new Time.Duration(Globals.scPinLockTimeMinutes * Gregorian.SECONDS_PER_MINUTE)).value(); mLockedUntil = Time.now().add(new Time.Duration(Globals.scPinLockTimeMinutes * Time.Gregorian.SECONDS_PER_MINUTE)).value();
Application.Storage.setValue(STORAGE_KEY_LOCKED, mLockedUntil); Application.Storage.setValue(STORAGE_KEY_LOCKED, mLockedUntil);
// System.println("PinFailures addFailure() Locked until " + mLockedUntil); // System.println("PinFailures addFailure() Locked until " + mLockedUntil);
} }
@@ -276,6 +348,9 @@ class PinFailures {
Application.Storage.setValue(STORAGE_KEY_FAILURES, mFailures); 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() { function reset() {
// System.println("PinFailures reset() Resetting failures"); // System.println("PinFailures reset() Resetting failures");
mFailures = []; mFailures = [];
@@ -284,11 +359,18 @@ class PinFailures {
Application.Storage.deleteValue(STORAGE_KEY_LOCKED); Application.Storage.deleteValue(STORAGE_KEY_LOCKED);
} }
function getLockedUntilSeconds() as Number { //! 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(); return new Time.Moment(mLockedUntil).subtract(Time.now()).value();
} }
function isLocked() as Boolean { //! 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) { if (mLockedUntil == null) {
return false; return false;
} }

View File

@@ -11,11 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023 // P A Abbey & J D Abbey & Someone0nEarth, 19 November 2023
// //
//
// Description:
//
// Calling a Home Assistant Service.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -23,10 +18,14 @@ using Toybox.WatchUi;
using Toybox.Graphics; using Toybox.Graphics;
using Toybox.Application.Properties; using Toybox.Application.Properties;
//! Calling a Home Assistant Service.
//
class HomeAssistantService { class HomeAssistantService {
private var mHasToast as Lang.Boolean = false; private var mHasToast as Lang.Boolean = false;
private var mHasVibrate as Lang.Boolean = false; private var mHasVibrate as Lang.Boolean = false;
//! Class Constructor
//
function initialize() { function initialize() {
if (WatchUi has :showToast) { if (WatchUi has :showToast) {
mHasToast = true; mHasToast = true;
@@ -36,14 +35,24 @@ class HomeAssistantService {
} }
} }
// Callback function after completing the POST request to call a service. //! Callback function after completing the POST request to call a service.
//!
//! @param responseCode Response code.
//! @param data Response data.
//! @param context An `entity_id` supplied in the GET request `options` `Lang.Dictionary` `context` field.
// //
function onReturnCall( function onReturnCall(
responseCode as Lang.Number, responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String, data as Null or Lang.Dictionary or Lang.String,
context as Lang.Object context as Lang.Object
) as Void { ) as Void {
var entity_id = context as Lang.String or Null; var c = context as Lang.Dictionary;
var entity_id;
var exit = false;
if (c != null) {
entity_id = c.get(:entity_id) as Lang.String;
exit = c.get(:exit) as Lang.Boolean;
}
// System.println("HomeAssistantService onReturnCall() Response Code: " + responseCode); // System.println("HomeAssistantService onReturnCall() Response Code: " + responseCode);
// System.println("HomeAssistantService onReturnCall() Response Data: " + data); // System.println("HomeAssistantService onReturnCall() Response Data: " + data);
@@ -99,6 +108,9 @@ class HomeAssistantService {
:bgcolor => Graphics.COLOR_BLACK :bgcolor => Graphics.COLOR_BLACK
}).pushView(WatchUi.SLIDE_IMMEDIATE); }).pushView(WatchUi.SLIDE_IMMEDIATE);
} }
if (exit) {
System.exit();
}
break; break;
default: default:
@@ -107,9 +119,15 @@ class HomeAssistantService {
} }
} }
//! Invoke a service call for a menu item.
//!
//! @param service The Home Assistant service to be run, e.g. from the JSON `service` field.
//! @param data Data to be supplied to the service call.
//
function call( function call(
service as Lang.String, service as Lang.String,
data as Lang.Dictionary or Null data as Lang.Dictionary or Null,
exit as Lang.Boolean
) as Void { ) as Void {
if (! System.getDeviceSettings().phoneConnected) { if (! System.getDeviceSettings().phoneConnected) {
// System.println("HomeAssistantService call(): No Phone connection, skipping API call."); // System.println("HomeAssistantService call(): No Phone connection, skipping API call.");
@@ -141,7 +159,10 @@ class HomeAssistantService {
"Authorization" => "Bearer " + Settings.getApiKey() "Authorization" => "Bearer " + Settings.getApiKey()
}, },
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON, :responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON,
:context => entity_id :context => {
:entity_id => entity_id,
:exit => exit
}
}, },
method(:onReturnCall) method(:onReturnCall)
); );

View File

@@ -11,31 +11,44 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Menu button that triggers a service.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Graphics; using Toybox.Graphics;
//! Menu button that triggers a service.
//
class HomeAssistantTapMenuItem extends HomeAssistantMenuItem { class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
private var mHomeAssistantService as HomeAssistantService; private var mHomeAssistantService as HomeAssistantService;
private var mService as Lang.String or Null; private var mService as Lang.String or Null;
private var mConfirm as Lang.Boolean; private var mConfirm as Lang.Boolean;
private var mExit as Lang.Boolean;
private var mPin as Lang.Boolean; private var mPin as Lang.Boolean;
private var mData as Lang.Dictionary or Null; private var mData as Lang.Dictionary or Null;
//! Class Constructor
//!
//! @param label Menu item label.
//! @param template Menu item template.
//! @param service Menu item service.
//! @param data Data to supply to the service call.
//! @param exit Should the service call complete and then exit?
//! @param confirm Should the service call be confirmed to avoid accidental invocation?
//! @param pin Should the service call be protected with a PIN for some low level of security?
//! @param icon Icon to use for the menu item.
//! @param options Menu item options to be passed on.
//! @param haService Shared Home Assistant service object that will perform the required call. Only
//! one of these objects is created for all menu items to re-use.
//
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String, template as Lang.String,
service as Lang.String or Null, service as Lang.String or Null,
data as Lang.Dictionary or Null,
exit as Lang.Boolean,
confirm as Lang.Boolean, confirm as Lang.Boolean,
pin as Lang.Boolean, pin as Lang.Boolean,
data as Lang.Dictionary or Null,
icon as Graphics.BitmapType or WatchUi.Drawable, icon as Graphics.BitmapType or WatchUi.Drawable,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment, :alignment as WatchUi.MenuItem.Alignment,
@@ -57,11 +70,14 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
mHomeAssistantService = haService; mHomeAssistantService = haService;
mService = service; mService = service;
mData = data;
mExit = exit;
mConfirm = confirm; mConfirm = confirm;
mPin = pin; mPin = pin;
mData = data;
} }
//! Call a Home Assistant service only after checks have been done for confirmation or PIN entry.
//
function callService() as Void { function callService() as Void {
var hasTouchScreen = System.getDeviceSettings().isTouchScreen; var hasTouchScreen = System.getDeviceSettings().isTouchScreen;
if (mPin && hasTouchScreen) { if (mPin && hasTouchScreen) {
@@ -85,10 +101,13 @@ class HomeAssistantTapMenuItem extends HomeAssistantMenuItem {
} }
} }
// NB. Parameter 'b' is ignored //! 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 { function onConfirm(b as Lang.Boolean) as Void {
if (mService != null) { if (mService != null) {
mHomeAssistantService.call(mService, mData); mHomeAssistantService.call(mService, mData, mExit);
} }
} }

View File

@@ -11,11 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Light or switch toggle button that calls the API to maintain the up to date state.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -24,19 +19,33 @@ using Toybox.Graphics;
using Toybox.Application.Properties; using Toybox.Application.Properties;
using Toybox.Timer; using Toybox.Timer;
//! Light or switch toggle menu button that calls the API to maintain the up to date state.
//
class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem { class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
private var mConfirm as Lang.Boolean;
private var mPin as Lang.Boolean;
private var mData as Lang.Dictionary; private var mData as Lang.Dictionary;
private var mTemplate as Lang.String; private var mTemplate as Lang.String;
private var mExit as Lang.Boolean;
private var mConfirm as Lang.Boolean;
private var mPin as Lang.Boolean;
private var mHasVibrate as Lang.Boolean = false; private var mHasVibrate as Lang.Boolean = false;
//! Class Constructor
//!
//! @param label Menu item label.
//! @param template Menu item template.
//! @param data Data to supply to the service call.
//! @param exit Should the service call complete and then exit?
//! @param confirm Should the service call be confirmed to avoid accidental invocation?
//! @param pin Should the service call be protected with a PIN for some low level of security?
//! @param options Menu item options to be passed on.
//
function initialize( function initialize(
label as Lang.String or Lang.Symbol, label as Lang.String or Lang.Symbol,
template as Lang.String, template as Lang.String,
data as Lang.Dictionary or Null,
exit as Lang.Boolean,
confirm as Lang.Boolean, confirm as Lang.Boolean,
pin as Lang.Boolean, pin as Lang.Boolean,
data as Lang.Dictionary or Null,
options as { options as {
:alignment as WatchUi.MenuItem.Alignment, :alignment as WatchUi.MenuItem.Alignment,
:icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol :icon as Graphics.BitmapType or WatchUi.Drawable or Lang.Symbol
@@ -52,12 +61,15 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
if (Attention has :vibrate) { if (Attention has :vibrate) {
mHasVibrate = true; mHasVibrate = true;
} }
mConfirm = confirm;
mPin = pin;
mData = data; mData = data;
mTemplate = template; mTemplate = template;
mExit = exit;
mConfirm = confirm;
mPin = pin;
} }
//! Set the state of a toggle menu item.
//
private function setUiToggle(state as Null or Lang.String) as Void { private function setUiToggle(state as Null or Lang.String) as Void {
if (state != null) { if (state != null) {
if (state.equals("on") && !isEnabled()) { if (state.equals("on") && !isEnabled()) {
@@ -68,13 +80,26 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
} }
} }
function buildTemplate() as Lang.String or Null { //! Return the menu item's template.
//!
//! @return A string with the menu item's template definition.
//
function getTemplate() as Lang.String or Null {
return mTemplate; return mTemplate;
} }
function buildToggleTemplate() as Lang.String or Null {
//! Return a toggle menu item's state template.
//!
//! @return A string with the menu item's template definition.
//
function getToggleTemplate() as Lang.String or Null {
return "{{states('" + mData.get("entity_id") + "')}}"; return "{{states('" + mData.get("entity_id") + "')}}";
} }
//! Update the menu item's label from a recent GET request.
//!
//! @param data This should be a string, but the way the GET response is parsed, it can also be a number.
//
function updateState(data as Lang.String or Lang.Dictionary or Lang.Number or Lang.Float or Null) as Void { function updateState(data as Lang.String or Lang.Dictionary or Lang.Number or Lang.Float or Null) as Void {
if (data == null) { if (data == null) {
setSubLabel(null); setSubLabel(null);
@@ -100,6 +125,10 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
//! Update the menu item's toggle state from a recent GET request.
//!
//! @param data This should be a string of either "on" or "off".
//
function updateToggleState(data as Lang.String or Lang.Dictionary or Null) as Void { function updateToggleState(data as Lang.String or Lang.Dictionary or Null) as Void {
if (data == null) { if (data == null) {
setUiToggle("off"); setUiToggle("off");
@@ -126,9 +155,15 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
WatchUi.requestUpdate(); WatchUi.requestUpdate();
} }
// Callback function after completing the POST request to set the status. //! Callback function after completing the POST request to set the status.
//!
//! @param responseCode Response code.
//! @param data Response data.
// //
function onReturnSetState(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { function onReturnSetState(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
// System.println("HomeAssistantToggleMenuItem onReturnSetState() Response Code: " + responseCode); // System.println("HomeAssistantToggleMenuItem onReturnSetState() Response Code: " + responseCode);
// System.println("HomeAssistantToggleMenuItem onReturnSetState() Response Data: " + data); // System.println("HomeAssistantToggleMenuItem onReturnSetState() Response Data: " + data);
@@ -181,8 +216,15 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode); ErrorView.show(WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
} }
getApp().setApiStatus(status); getApp().setApiStatus(status);
if (mExit) {
System.exit();
}
} }
//! 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 { function setState(s as Lang.Boolean) as Void {
// Toggle the UI back, we'll wait for confirmation from the Home Assistant // Toggle the UI back, we'll wait for confirmation from the Home Assistant
setEnabled(!isEnabled()); setEnabled(!isEnabled());
@@ -228,6 +270,8 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
} }
} }
//! Call a Home Assistant service only after checks have been done for confirmation or PIN entry.
//
function callService(b as Lang.Boolean) as Void { function callService(b as Lang.Boolean) as Void {
var hasTouchScreen = System.getDeviceSettings().isTouchScreen; var hasTouchScreen = System.getDeviceSettings().isTouchScreen;
if (mPin && hasTouchScreen) { if (mPin && hasTouchScreen) {
@@ -251,6 +295,10 @@ class HomeAssistantToggleMenuItem extends WatchUi.ToggleMenuItem {
} }
} }
//! Callback function to toggle state of this item after (optional) confirmation.
//!
//! @param b Desired toggle button state.
//
function onConfirm(b as Lang.Boolean) as Void { function onConfirm(b as Lang.Boolean) as Void {
setState(b); setState(b);
} }

View File

@@ -11,11 +11,6 @@
// //
// P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023 // P A Abbey & J D Abbey & Someone0nEarth & moesterheld, 31 October 2023
// //
//
// Description:
//
// Home Assistant menu construction.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Application; using Toybox.Application;
@@ -24,8 +19,12 @@ using Toybox.Graphics;
using Toybox.System; using Toybox.System;
using Toybox.WatchUi; using Toybox.WatchUi;
//! Home Assistant menu construction.
//
class HomeAssistantView extends WatchUi.Menu2 { class HomeAssistantView extends WatchUi.Menu2 {
//! Class Constructor
//
function initialize( function initialize(
definition as Lang.Dictionary, definition as Lang.Dictionary,
options as { options as {
@@ -52,21 +51,42 @@ class HomeAssistantView extends WatchUi.Menu2 {
var confirm = false as Lang.Boolean or Null; var confirm = false as Lang.Boolean or Null;
var pin = false as Lang.Boolean or Null; var pin = false as Lang.Boolean or Null;
var data = null as Lang.Dictionary or Null; var data = null as Lang.Dictionary or Null;
var enabled = true as Lang.Boolean or Null;
var exit = false as Lang.Boolean or Null;
if (items[i].get("enabled") != null) {
enabled = items[i].get("enabled"); // Optional
}
if (items[i].get("exit") != null) {
exit = items[i].get("exit"); // Optional
}
if (tap_action != null) { if (tap_action != null) {
service = tap_action.get("service"); service = tap_action.get("service");
confirm = tap_action.get("confirm"); // Optional
pin = tap_action.get("pin"); // Optional
data = tap_action.get("data"); // Optional data = tap_action.get("data"); // Optional
if (confirm == null) { if (tap_action.get("confirm") != null) {
confirm = false; confirm = tap_action.get("confirm"); // Optional
}
if (tap_action.get("pin") != null) {
pin = tap_action.get("pin"); // Optional
} }
} }
if (type != null && name != null) { if (type != null && name != null && enabled) {
if (type.equals("toggle") && entity != null) { if (type.equals("toggle") && entity != null) {
addItem(HomeAssistantMenuItemFactory.create().toggle(name, entity, content, confirm, pin)); addItem(HomeAssistantMenuItemFactory.create().toggle(name, entity, content, exit, confirm, pin));
} else if ((type.equals("tap") && service != null) || (type.equals("info") && content != null) || (type.equals("template") && content != null)) { } else if (type.equals("tap") && service != null) {
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, data, exit, confirm, pin));
} else if (type.equals("template") && content != null) {
// NB. "template" is deprecated in the schema and remains only for backward compatibility. All menu items can now use templates, so the replacement is "info". // NB. "template" is deprecated in the schema and remains only for backward compatibility. All menu items can now use templates, so the replacement is "info".
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, confirm, pin, data)); // The exit option is dependent on the type of template.
if (tap_action == null) {
// No exit from an information only item
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, data, false, confirm, pin));
} else {
// You may exit from template item with a 'tap_action'.
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, data, exit, confirm, pin));
}
} else if (type.equals("info") && content != null) {
// Cannot exit from a non-actionable information only menu item.
addItem(HomeAssistantMenuItemFactory.create().tap(name, entity, content, service, data, false, confirm, pin));
} else if (type.equals("group")) { } else if (type.equals("group")) {
addItem(HomeAssistantMenuItemFactory.create().group(items[i], content)); addItem(HomeAssistantMenuItemFactory.create().group(items[i], content));
} }
@@ -75,7 +95,12 @@ class HomeAssistantView extends WatchUi.Menu2 {
} }
} }
// Lang.Array.addAll() fails structural type checking without including "Null" in the return type //! Return a list of items that need to be updated within this menu structure.
//!
//! MN. Lang.Array.addAll() fails structural type checking without including "Null" in the return type
//!
//! @return An array of menu items that need to be updated periodically to reflect the latest Home Assistant state.
//
function getItemsToUpdate() as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTapMenuItem or HomeAssistantGroupMenuItem or Null> { function getItemsToUpdate() as Lang.Array<HomeAssistantToggleMenuItem or HomeAssistantTapMenuItem or HomeAssistantGroupMenuItem or Null> {
var fullList = []; var fullList = [];
var lmi = mItems as Lang.Array<WatchUi.MenuItem>; var lmi = mItems as Lang.Array<WatchUi.MenuItem>;
@@ -102,26 +127,35 @@ class HomeAssistantView extends WatchUi.Menu2 {
return fullList; return fullList;
} }
// Called when this View is brought to the foreground. Restore //! Called when this View is brought to the foreground. Restore
// the state of this View and prepare it to be shown. This includes //! the state of this View and prepare it to be shown. This includes
// loading resources into memory. //! loading resources into memory.
function onShow() as Void {} function onShow() as Void {}
} }
// //! Delegate for the HomeAssistantView.
// Reference: https://developer.garmin.com/connect-iq/core-topics/input-handling/ //!
//! Reference: https://developer.garmin.com/connect-iq/core-topics/input-handling/
// //
class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate { class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate {
private var mIsRootMenuView as Lang.Boolean = false; private var mIsRootMenuView as Lang.Boolean = false;
private var mTimer as QuitTimer; private var mTimer as QuitTimer;
//! Class Constructor
//!
//! @param isRootMenuView As menus can be nested, this state marks the top level menu so that the
//! back event can exit the application completely rather than just popping
//! a menu view.
//
function initialize(isRootMenuView as Lang.Boolean) { function initialize(isRootMenuView as Lang.Boolean) {
Menu2InputDelegate.initialize(); Menu2InputDelegate.initialize();
mIsRootMenuView = isRootMenuView; mIsRootMenuView = isRootMenuView;
mTimer = getApp().getQuitTimer(); mTimer = getApp().getQuitTimer();
} }
//! Back button event
//
function onBack() { function onBack() {
mTimer.reset(); mTimer.reset();
@@ -135,16 +169,22 @@ class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate {
WatchUi.popView(WatchUi.SLIDE_RIGHT); WatchUi.popView(WatchUi.SLIDE_RIGHT);
} }
// Only for CheckboxMenu //! Only for CheckboxMenu
//
function onDone() { function onDone() {
mTimer.reset(); mTimer.reset();
} }
// Only for CustomMenu //! Only for CustomMenu
//
function onFooter() { function onFooter() {
mTimer.reset(); mTimer.reset();
} }
//! Select event
//!
//! @param item Selected menu item.
//
function onSelect(item as WatchUi.MenuItem) as Void { function onSelect(item as WatchUi.MenuItem) as Void {
mTimer.reset(); mTimer.reset();
if (item instanceof HomeAssistantToggleMenuItem) { if (item instanceof HomeAssistantToggleMenuItem) {
@@ -164,7 +204,8 @@ class HomeAssistantViewDelegate extends WatchUi.Menu2InputDelegate {
} }
} }
// Only for CustomMenu //! Only for CustomMenu
//
function onTitle() { function onTitle() {
mTimer.reset(); mTimer.reset();
} }

View File

@@ -11,11 +11,6 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// Quit the application after a period of inactivity in order to save the battery.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -23,18 +18,27 @@ using Toybox.Timer;
using Toybox.Application.Properties; using Toybox.Application.Properties;
using Toybox.WatchUi; using Toybox.WatchUi;
//! Quit the application after a period of inactivity in order to save the battery.
//!
class QuitTimer extends Timer.Timer { class QuitTimer extends Timer.Timer {
//! Class Constructor
//
function initialize() { function initialize() {
Timer.Timer.initialize(); Timer.Timer.initialize();
} }
//! Can't see how to make a method object from `System.exit()` without this layer of
//! indirection. I assume this is because `System` is a static class.
//
function exitApp() as Void { function exitApp() as Void {
// System.println("QuitTimer exitApp(): Exiting"); // System.println("QuitTimer exitApp(): Exiting");
// This will exit the system cleanly from any point within an app. // This will exit the system cleanly from any point within an app.
System.exit(); System.exit();
} }
//! Kick off the quit timer.
//
function begin() { function begin() {
var api_timeout = Settings.getAppTimeout(); // ms var api_timeout = Settings.getAppTimeout(); // ms
if (api_timeout > 0) { if (api_timeout > 0) {
@@ -42,6 +46,8 @@ class QuitTimer extends Timer.Timer {
} }
} }
//! Reset the quit timer.
//
function reset() { function reset() {
// System.println("QuitTimer reset(): Restarted quit timer"); // System.println("QuitTimer reset(): Restarted quit timer");
stop(); stop();

View File

@@ -11,35 +11,36 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// A view with added methods to scale from percentages of scrren size to pixels.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
using Toybox.WatchUi; using Toybox.WatchUi;
using Toybox.Math; using Toybox.Math;
//! A view that provides a common method 'pixelsForScreen' to make Views easier to layout on different
//! sized watch screens.
//
class ScalableView extends WatchUi.View { class ScalableView extends WatchUi.View {
//! Retain the local screen width for efficiency
private var mScreenWidth; private var mScreenWidth;
//! Class Constructor
//
function initialize() { function initialize() {
View.initialize(); View.initialize();
mScreenWidth = System.getDeviceSettings().screenWidth; mScreenWidth = System.getDeviceSettings().screenWidth;
} }
// Convert a fraction expressed as a percentage (%) to a number of pixels for the //! Convert a fraction expressed as a percentage (%) to a number of pixels for the
// screen's dimensions. //! screen's dimensions.
// //!
// Parameters: //! Uses screen width rather than screen height as rectangular screens tend to have
// * dc - Device context //! height > width.
// * pc - Percentage (%) expressed as a number in the range 0.0..100.0 //!
// //! @param pc Percentage (%) expressed as a number in the range 0.0..100.0
// Uses screen width rather than screen height as rectangular screens tend to have //!
// height > width. //! @return Number of pixels for the screen's dimensions for a fraction expressed as a percentage (%).
// //!
function pixelsForScreen(pc as Lang.Float) as Lang.Number { function pixelsForScreen(pc as Lang.Float) as Lang.Number {
return Math.round(pc * mScreenWidth) / 100; return Math.round(pc * mScreenWidth) / 100;
} }

View File

@@ -11,16 +11,6 @@
// //
// P A Abbey & J D Abbey, SomeoneOnEarth & moesterheld, 23 November 2023 // P A Abbey & J D Abbey, SomeoneOnEarth & moesterheld, 23 November 2023
// //
//
// Description:
//
// Home Assistant settings.
//
// WARNING!
//
// Careful putting ErrorView.show() calls in here. They need to be guarded so that
// they do not get called when only displaying the glance view.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -31,6 +21,11 @@ using Toybox.System;
using Toybox.Background; using Toybox.Background;
using Toybox.Time; using Toybox.Time;
//! Home Assistant settings.
//!
//! <em>WARNING!</em> Careful putting ErrorView.show() calls in here. They need to be
//! guarded so that they do not get called when only displaying the glance view.
//
(:glance, :background) (:glance, :background)
class Settings { class Settings {
private static var mApiKey as Lang.String = ""; private static var mApiKey as Lang.String = "";
@@ -40,19 +35,24 @@ class Settings {
private static var mCacheConfig as Lang.Boolean = false; private static var mCacheConfig as Lang.Boolean = false;
private static var mClearCache as Lang.Boolean = false; private static var mClearCache as Lang.Boolean = false;
private static var mVibrate as Lang.Boolean = false; private static var mVibrate as Lang.Boolean = false;
private static var mAppTimeout as Lang.Number = 0; // seconds //! seconds
private static var mPollDelay as Lang.Number = 0; // seconds private static var mAppTimeout as Lang.Number = 0;
private static var mConfirmTimeout as Lang.Number = 3; // seconds //! seconds
private static var mPollDelay as Lang.Number = 0;
//! seconds
private static var mConfirmTimeout as Lang.Number = 3;
private static var mPin as Lang.String or Null = "0000"; private static var mPin as Lang.String or Null = "0000";
private static var mMenuAlignment as Lang.Number = WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT; 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 mIsSensorsLevelEnabled as Lang.Boolean = false;
private static var mBatteryRefreshRate as Lang.Number = 15; // minutes //! minutes
private static var mBatteryRefreshRate as Lang.Number = 15;
private static var mIsApp as Lang.Boolean = false; private static var mIsApp as Lang.Boolean = false;
private static var mHasService as Lang.Boolean = false; private static var mHasService as Lang.Boolean = false;
// Must keep the object so it doesn't get garbage collected. //! Must keep the object so it doesn't get garbage collected.
private static var mWebhookManager as WebhookManager or Null; private static var mWebhookManager as WebhookManager or Null;
// Called on application start and then whenever the settings are changed. //! Called on application start and then whenever the settings are changed.
//
static function update() { static function update() {
mIsApp = getApp().getIsApp(); mIsApp = getApp().getIsApp();
mApiKey = Properties.getValue("api_key"); mApiKey = Properties.getValue("api_key");
@@ -71,6 +71,8 @@ class Settings {
mBatteryRefreshRate = Properties.getValue("battery_level_refresh_rate"); mBatteryRefreshRate = Properties.getValue("battery_level_refresh_rate");
} }
//! A webhook is required for non-privileged API calls.
//
static function webhook() { static function webhook() {
if (System has :ServiceDelegate) { if (System has :ServiceDelegate) {
mHasService = true; mHasService = true;
@@ -116,65 +118,123 @@ class Settings {
// } // }
} }
//! Get the API key supplied as part of the Settings.
//!
//! @return The API Key
//
static function getApiKey() as Lang.String { static function getApiKey() as Lang.String {
return mApiKey; return mApiKey;
} }
//! Get the Webhook ID supplied as part of the Settings.
//!
//! @return The Webhook ID
//
static function getWebhookId() as Lang.String { static function getWebhookId() as Lang.String {
return mWebhookId; return mWebhookId;
} }
//! Set the Webhook ID supplied as part of the Settings.
//!
//! @param webhookId The Webhook ID value to be saved.
//
static function setWebhookId(webhookId as Lang.String) { static function setWebhookId(webhookId as Lang.String) {
mWebhookId = webhookId; mWebhookId = webhookId;
Properties.setValue("webhook_id", mWebhookId); Properties.setValue("webhook_id", mWebhookId);
} }
//! Delete the Webhook ID saved as part of the Settings.
//
static function unsetWebhookId() { static function unsetWebhookId() {
mWebhookId = ""; mWebhookId = "";
Properties.setValue("webhook_id", mWebhookId); Properties.setValue("webhook_id", mWebhookId);
} }
//! Get the API URL supplied as part of the Settings.
//!
//! @return The API URL
//
static function getApiUrl() as Lang.String { static function getApiUrl() as Lang.String {
return mApiUrl; return mApiUrl;
} }
//! Get the menu configuration URL supplied as part of the Settings.
//!
//! @return The menu configuration URL
//
static function getConfigUrl() as Lang.String { static function getConfigUrl() as Lang.String {
return mConfigUrl; return mConfigUrl;
} }
//! Get the menu cache Boolean option supplied as part of the Settings.
//!
//! @return Boolean for whether the menu should be cached to save application
//! start up time.
//
static function getCacheConfig() as Lang.Boolean { static function getCacheConfig() as Lang.Boolean {
return mCacheConfig; return mCacheConfig;
} }
//! Get the clear cache Boolean option supplied as part of the Settings.
//!
//! @return Boolean for whether the cache should be cleared next time the
//! application is started, forcing a menu refresh.
//
static function getClearCache() as Lang.Boolean { static function getClearCache() as Lang.Boolean {
return mClearCache; return mClearCache;
} }
//! Unset the clear cache Boolean option supplied as part of the Settings.
//
static function unsetClearCache() { static function unsetClearCache() {
mClearCache = false; mClearCache = false;
Properties.setValue("clear_cache", mClearCache); Properties.setValue("clear_cache", mClearCache);
} }
//! Get the vibration Boolean option supplied as part of the Settings.
//!
//! @return Boolean for whether vibration is enabled.
//
static function getVibrate() as Lang.Boolean { static function getVibrate() as Lang.Boolean {
return mVibrate; return mVibrate;
} }
//! Get the application timeout value supplied as part of the Settings.
//!
//! @return The application timeout in milliseconds.
//
static function getAppTimeout() as Lang.Number { static function getAppTimeout() as Lang.Number {
return mAppTimeout * 1000; // Convert to milliseconds return mAppTimeout * 1000; // Convert to milliseconds
} }
//! Get the application API polling interval supplied as part of the Settings.
//!
//! @return The application API polling interval in milliseconds.
//
static function getPollDelay() as Lang.Number { static function getPollDelay() as Lang.Number {
return mPollDelay * 1000; // Convert to milliseconds return mPollDelay * 1000; // Convert to milliseconds
} }
//! Get the menu item confirmation delay supplied as part of the Settings.
//!
//! @return The menu item confirmation delay in milliseconds.
//
static function getConfirmTimeout() as Lang.Number { static function getConfirmTimeout() as Lang.Number {
return mConfirmTimeout * 1000; // Convert to milliseconds return mConfirmTimeout * 1000; // Convert to milliseconds
} }
//! Get the menu item security PIN supplied as part of the Settings.
//!
//! @return The menu item security PIN.
//
static function getPin() as Lang.String or Null { static function getPin() as Lang.String or Null {
return mPin; return mPin;
} }
//! Check the user selected PIN confirms to 4 digits as a string.
//!
//! @return The validated 4 digit string.
//
private static function validatePin() as Lang.String or Null { private static function validatePin() as Lang.String or Null {
var pin = Properties.getValue("pin"); var pin = Properties.getValue("pin");
if (pin.toNumber() == null || pin.length() != 4) { if (pin.toNumber() == null || pin.length() != 4) {
@@ -183,14 +243,24 @@ class Settings {
return pin; return pin;
} }
//! Get the menu item alignment as part of the Settings.
//!
//! @return The menu item alignment.
//
static function getMenuAlignment() as Lang.Number { static function getMenuAlignment() as Lang.Number {
return mMenuAlignment; // Either WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_RIGHT or WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT return mMenuAlignment; // Either WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_RIGHT or WatchUi.MenuItem.MENU_ITEM_LABEL_ALIGN_LEFT
} }
//! Is logging of the watch sensors enabled? E.g. battery, activity etc.
//!
//! @return Boolean for whether logging of the watch sensors is enabled.
//
static function isSensorsLevelEnabled() as Lang.Boolean { static function isSensorsLevelEnabled() as Lang.Boolean {
return mIsSensorsLevelEnabled; return mIsSensorsLevelEnabled;
} }
//! Disable logging of the watch's sensors.
//
static function unsetIsSensorsLevelEnabled() { static function unsetIsSensorsLevelEnabled() {
mIsSensorsLevelEnabled = false; mIsSensorsLevelEnabled = false;
Properties.setValue("enable_battery_level", mIsSensorsLevelEnabled); Properties.setValue("enable_battery_level", mIsSensorsLevelEnabled);

View File

@@ -11,88 +11,99 @@
// //
// J D Abbey & P A Abbey, 28 December 2022 // J D Abbey & P A Abbey, 28 December 2022
// //
//
// Description:
//
// WebLog provides a logging and hence debugging aid for when the application is
// deployed to the watch. This is only used for development and use of it must not
// persist into a deployed version. It uses a string buffer to group log entries into
// larger submissions in order to prevent overflow of the blue tooth stack.
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
//
// Usage:
// wl = new WebLog();
// wl.clear();
// wl.println("Debug Message");
// wl.flush();
//
// https://domain.name/path/log.php
//
// <?php
// $myfile = fopen("log", "a");
// $queries = array();
// parse_str($_SERVER['QUERY_STRING'], $queries);
// fwrite($myfile, $queries['log']);
// print "Success";
// ?>
//
// Logs published to: https://domain.name/path/log
//
// https://domain.name/path/log_clear.php
//
// <?php
// $myfile = fopen("log", "w");
// fwrite($myfile, "");
// print "Success";
// ?>
using Toybox.Communications; using Toybox.Communications;
using Toybox.Lang; using Toybox.Lang;
using Toybox.System; using Toybox.System;
//! WebLog provides a logging and hence debugging aid for when the application is
//! deployed to the watch. This is only used for development and use of it must not
//! persist into a deployed version. It uses a string buffer to group log entries into
//! larger submissions in order to prevent overflow of the Bluetooth stack.
//!
//! Usage:
//! <pre>
//! wl = new WebLog();
//! wl.clear();
//! wl.println("Debug Message");
//! wl.flush();
//! </pre>
//!
//! File: https://domain.name/path/log.php
//!
//! <pre>
//! &lt;?php
//! $myfile = fopen("log", "a");
//! $queries = array();
//! parse_str($_SERVER['QUERY_STRING'], $queries);
//! fwrite($myfile, $queries['log']);
//! print "Success";
//! ?&gt;
//! </pre>
//!
//! Logs published to https://domain.name/path/log.
//!
//! File: https://domain.name/path/log_clear.php
//!
//! <pre>
//! &lt;?php
//! $myfile = fopen("log", "w");
//! fwrite($myfile, "");
//! print "Success";
//! ?&gt;
//! </pre>
//
(:glance, :background) (:glance, :background)
class WebLog { class WebLog {
private var callsbuffer = 4 as Lang.Number; private var callsBuffer = 4 as Lang.Number;
private var numCalls = 0 as Lang.Number; private var numCalls = 0 as Lang.Number;
private var buffer = "" as Lang.String; private var buffer = "" as Lang.String;
// Set the number of calls to print() before sending the buffer to the online //! Set the number of calls to print() before sending the buffer to the online
// logger. //! logger.
//!
//! @param l The number of log calls to buffer before writing to the online service.
// //
function setCallsBuffer(l as Lang.Number) { function setCallsBuffer(l as Lang.Number) {
callsbuffer = l; callsBuffer = l;
} }
// Get the number of calls to print() before sending the buffer to the online //! Get the number of calls to print() before sending the buffer to the online
// logger. //! logger.
//!
//! @return The number of log calls to buffer before writing to the online service.
// //
function getCallsBuffer() as Lang.Number { function getCallsBuffer() as Lang.Number {
return callsbuffer; return callsBuffer;
} }
// Create a debug log over the Internet to keep track of the watch's runtime //! Create a debug log over the Internet to keep track of the watch's runtime
// execution. //! execution.
//!
//! @param str The string to log.
// //
function print(str as Lang.String) { function print(str as Lang.String) {
var myTime = System.getClockTime(); var myTime = System.getClockTime();
buffer += myTime.hour.format("%02d") + ":" + myTime.min.format("%02d") + ":" + myTime.sec.format("%02d") + " " + str; buffer += myTime.hour.format("%02d") + ":" + myTime.min.format("%02d") + ":" + myTime.sec.format("%02d") + " " + str;
numCalls++; numCalls++;
// System.println("WebLog print() str = " + str); // System.println("WebLog print() str = " + str);
if (numCalls >= callsbuffer) { if (numCalls >= callsBuffer) {
doPrint(); doPrint();
} }
} }
// Create a debug log over the Internet to keep track of the watch's runtime //! Create a debug log over the Internet to keep track of the watch's runtime
// execution. Add a new line character to the end. //! execution. Add a new line character to the end.
//!
//! @param str The string to log.
// //
function println(str as Lang.String) { function println(str as Lang.String) {
print(str + "\n"); print(str + "\n");
} }
// Flush the current buffer to the online logger even if it has not reach the //! Flush the current buffer to the online logger even if it has not reach the
// submission level set by 'callsbuffer'. //! submission level set by 'callsBuffer'.
// //
function flush() { function flush() {
// System.println("WebLog flush()"); // System.println("WebLog flush()");
@@ -101,7 +112,7 @@ class WebLog {
} }
} }
// Perform the submission to the online logger. //! Perform the submission to the online logger.
// //
function doPrint() { function doPrint() {
// System.println("WebLog doPrint()"); // System.println("WebLog doPrint()");
@@ -122,8 +133,8 @@ class WebLog {
buffer = ""; buffer = "";
} }
// Clear the debug log over the Internet to start a new track of the watch's runtime //! Clear the debug log over the Internet to start a new track of the watch's runtime
// execution. //! execution.
// //
function clear() { function clear() {
// System.println("WebLog clear()"); // System.println("WebLog clear()");
@@ -143,7 +154,10 @@ class WebLog {
buffer = ""; buffer = "";
} }
// Callback function to print the outcome of a doPrint() method. //! Callback function to print the outcome of a doPrint() method. Typically used for debugging this class.
//!
//! @param responseCode Response code.
//! @param data Response data.
// //
function onLog(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { function onLog(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void {
// if (responseCode != 200) { // if (responseCode != 200) {
@@ -153,7 +167,10 @@ class WebLog {
// } // }
} }
// Callback function to print the outcome of a clear() method. //! Callback function to print the outcome of a clear() method. Typically used for debugging this class.
//!
//! @param responseCode Response code.
//! @param data Response data.
// //
function onClear(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { function onClear(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void {
// if (responseCode != 200) { // if (responseCode != 200) {

View File

@@ -11,14 +11,6 @@
// //
// P A Abbey & J D Abbey, 10 January 2024 // P A Abbey & J D Abbey, 10 January 2024
// //
//
// Description:
//
// Home Assistant Webhook creation.
//
// Reference:
// * https://developers.home-assistant.io/docs/api/native-app-integration
//
//----------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------
using Toybox.Lang; using Toybox.Lang;
@@ -26,10 +18,24 @@ using Toybox.Communications;
using Toybox.System; using Toybox.System;
using Toybox.WatchUi; using Toybox.WatchUi;
// Can use push view so must never be run in a glance context //! Home Assistant Webhook creation.
//!
//! NB. Because we can use push view (E.g. `ErrorView.show()`) this class must never
//! be run in a glance context.
//!
//! Reference: https://developers.home-assistant.io/docs/api/native-app-integration
//
class WebhookManager { class WebhookManager {
function onReturnRequestWebhookId(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String) as Void { //! Callback for requesting a Webhook ID.
//!
//! @param responseCode Response code
//! @param data Return data
//
function onReturnRequestWebhookId(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String
) as Void {
switch (responseCode) { switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT: case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE: case Communications.BLE_CONNECTION_UNAVAILABLE:
@@ -84,6 +90,8 @@ class WebhookManager {
} }
} }
//! Request a Webhook ID from Home Assistant for use in this application.
//
function requestWebhookId() { function requestWebhookId() {
var deviceSettings = System.getDeviceSettings(); var deviceSettings = System.getDeviceSettings();
// System.println("WebhookManager requestWebhookId(): Requesting webhook id for device = " + deviceSettings.uniqueIdentifier); // System.println("WebhookManager requestWebhookId(): Requesting webhook id for device = " + deviceSettings.uniqueIdentifier);
@@ -115,7 +123,18 @@ class WebhookManager {
); );
} }
function onReturnRegisterWebhookSensor(responseCode as Lang.Number, data as Null or Lang.Dictionary or Lang.String, sensors as Lang.Array<Lang.Object>) as Void { //! Callback function for the POST request to register the watch's sensors on the Home Assistant instance.
//!
//! @param responseCode Response code.
//! @param data Response data.
//! @param sensors The remaining sensors to be processed. The list of sensors is iterated through
//! until empty. Each POST request creating one sensor on the local Home Assistant.
//
function onReturnRegisterWebhookSensor(
responseCode as Lang.Number,
data as Null or Lang.Dictionary or Lang.String,
sensors as Lang.Array<Lang.Object>
) as Void {
switch (responseCode) { switch (responseCode) {
case Communications.BLE_HOST_TIMEOUT: case Communications.BLE_HOST_TIMEOUT:
case Communications.BLE_CONNECTION_UNAVAILABLE: case Communications.BLE_CONNECTION_UNAVAILABLE:
@@ -194,7 +213,11 @@ class WebhookManager {
} }
} }
function registerWebhookSensor(sensors as Lang.Array<Lang.Object>) { //! Local method to send the POST request to register a number of sensors.
//!
//! @param sensors An array of sensors, e.g. As created by `registerWebhookSensors()`.
//
private function registerWebhookSensor(sensors as Lang.Array<Lang.Object>) {
var url = Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId(); var url = Settings.getApiUrl() + "/webhook/" + Settings.getWebhookId();
// System.println("WebhookManager registerWebhookSensor(): Registering webhook sensor: " + sensor.toString()); // System.println("WebhookManager registerWebhookSensor(): Registering webhook sensor: " + sensor.toString());
// System.println("WebhookManager registerWebhookSensor(): URL=" + url); // System.println("WebhookManager registerWebhookSensor(): URL=" + url);
@@ -217,6 +240,8 @@ class WebhookManager {
); );
} }
//! Request the creation of all the supported watch sensors on the Home Assistant instance.
//
function registerWebhookSensors() { function registerWebhookSensors() {
var heartRate = Activity.getActivityInfo().currentHeartRate; var heartRate = Activity.getActivityInfo().currentHeartRate;

View File

@@ -37,7 +37,7 @@
margin-left: 0.5em; margin-left: 0.5em;
filter: grayscale() invert(); filter: grayscale() invert();
} }
.template { .template, .info {
background-image: url(../resources-icons-48/info_type.svg); background-image: url(../resources-icons-48/info_type.svg);
background-size: contain; background-size: contain;
margin-left: 0.5em; margin-left: 0.5em;