mirror of
https://github.com/house-of-abbey/GarminHomeAssistant.git
synced 2025-07-11 23:38:38 +00:00
The newer SDK support tooltips to show the function prototype and help text, so best to make good use of it.
368 lines
19 KiB
MonkeyC
368 lines
19 KiB
MonkeyC
//-----------------------------------------------------------------------------------
|
|
//
|
|
// Distributed under MIT Licence
|
|
// See https://github.com/house-of-abbey/GarminHomeAssistant/blob/main/LICENSE.
|
|
//
|
|
//-----------------------------------------------------------------------------------
|
|
//
|
|
// GarminHomeAssistant is a Garmin IQ application written in Monkey C and routinely
|
|
// tested on a Venu 2 device. The source code is provided at:
|
|
// https://github.com/house-of-abbey/GarminHomeAssistant.
|
|
//
|
|
// P A Abbey & J D Abbey, 10 January 2024
|
|
//
|
|
//-----------------------------------------------------------------------------------
|
|
|
|
using Toybox.Lang;
|
|
using Toybox.Communications;
|
|
using Toybox.System;
|
|
using Toybox.WatchUi;
|
|
|
|
//! 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 {
|
|
|
|
//! Callback for requesting a Webhoo 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) {
|
|
case Communications.BLE_HOST_TIMEOUT:
|
|
case Communications.BLE_CONNECTION_UNAVAILABLE:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
|
|
break;
|
|
|
|
case Communications.BLE_QUEUE_FULL:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
|
|
break;
|
|
|
|
case Communications.NETWORK_REQUEST_TIMED_OUT:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
|
|
break;
|
|
|
|
case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?");
|
|
// Ignore and see if we can carry on
|
|
break;
|
|
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.NoJson) as Lang.String);
|
|
break;
|
|
|
|
case 404:
|
|
// System.println("WebhookManager onReturnRequestWebhookId() Response Code: 404, page not found. Check API URL setting.");
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.ApiUrlNotFound) as Lang.String);
|
|
break;
|
|
|
|
case 200:
|
|
case 201:
|
|
var id = data.get("webhook_id") as Lang.String or Null;
|
|
if (id != null) {
|
|
Settings.setWebhookId(id);
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Registering first sensor: Battery Level");
|
|
registerWebhookSensors();
|
|
} else {
|
|
// System.println("WebhookManager onReturnRequestWebhookId(): No webhook id in response data.");
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + ".");
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// System.println("WebhookManager onReturnRequestWebhookId(): Unhandled HTTP response code = " + responseCode);
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + responseCode);
|
|
}
|
|
}
|
|
|
|
//! Request a Webhook ID from Home Assistant for use in this application.
|
|
//
|
|
function requestWebhookId() {
|
|
var deviceSettings = System.getDeviceSettings();
|
|
// System.println("WebhookManager requestWebhookId(): Requesting webhook id for device = " + deviceSettings.uniqueIdentifier);
|
|
Communications.makeWebRequest(
|
|
Settings.getApiUrl() + "/mobile_app/registrations",
|
|
{
|
|
"device_id" => deviceSettings.uniqueIdentifier,
|
|
"app_id" => "garmin_home_assistant",
|
|
"app_name" => WatchUi.loadResource($.Rez.Strings.AppName) as Lang.String,
|
|
"app_version" => "",
|
|
"device_name" => "Garmin Device",
|
|
"manufacturer" => "Garmin",
|
|
// An unhelpful part number that can be translated to a familiar model name.
|
|
"model" => deviceSettings.partNumber,
|
|
"os_name" => "",
|
|
"os_version" => Lang.format("$1$.$2$", deviceSettings.firmwareVersion),
|
|
"supports_encryption" => false,
|
|
"app_data" => {}
|
|
},
|
|
{
|
|
:method => Communications.HTTP_REQUEST_METHOD_POST,
|
|
:headers => {
|
|
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON,
|
|
"Authorization" => "Bearer " + Settings.getApiKey()
|
|
},
|
|
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
|
|
},
|
|
method(:onReturnRequestWebhookId)
|
|
);
|
|
}
|
|
|
|
//! 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) {
|
|
case Communications.BLE_HOST_TIMEOUT:
|
|
case Communications.BLE_CONNECTION_UNAVAILABLE:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: BLE_HOST_TIMEOUT or BLE_CONNECTION_UNAVAILABLE, Bluetooth connection severed.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.NoPhone) as Lang.String);
|
|
break;
|
|
|
|
case Communications.BLE_QUEUE_FULL:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: BLE_QUEUE_FULL, API calls too rapid.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.ApiFlood) as Lang.String);
|
|
break;
|
|
|
|
case Communications.NETWORK_REQUEST_TIMED_OUT:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: NETWORK_REQUEST_TIMED_OUT, check Internet connection.");
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.NoResponse) as Lang.String);
|
|
break;
|
|
|
|
case Communications.NETWORK_RESPONSE_OUT_OF_MEMORY:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: NETWORK_RESPONSE_OUT_OF_MEMORY, are we going too fast?");
|
|
// Ignore and see if we can carry on
|
|
break;
|
|
|
|
case Communications.INVALID_HTTP_BODY_IN_NETWORK_RESPONSE:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: INVALID_HTTP_BODY_IN_NETWORK_RESPONSE, check JSON is returned.");
|
|
// Webhook ID might have been deleted on Home Assistant server
|
|
Settings.unsetWebhookId();
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Webhook ID invalid, going full chain.");
|
|
requestWebhookId();
|
|
break;
|
|
|
|
case 404:
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor() Response Code: 404, page not found. Check API URL setting.");
|
|
// Webhook ID might have been deleted on Home Assistant server
|
|
Settings.unsetWebhookId();
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Webhook ID invalid, going full chain.");
|
|
requestWebhookId();
|
|
break;
|
|
|
|
case 200:
|
|
case 201:
|
|
if (data instanceof Lang.Dictionary) {
|
|
var d = data as Lang.Dictionary;
|
|
var b = d.get("success") as Lang.Boolean or Null;
|
|
if (b != null and b != false) {
|
|
if (sensors.size() == 0) {
|
|
getApp().startUpdates();
|
|
} else {
|
|
registerWebhookSensor(sensors);
|
|
}
|
|
} else {
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Failure, no 'success'.");
|
|
Settings.unsetWebhookId();
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + ".");
|
|
}
|
|
} else {
|
|
// !! Speculative code for an application crash !!
|
|
// System.println("WebhookManager onReturnRegisterWebhookSensor(): Failure, not a Lang.Dict");
|
|
// Webhook ID might have been deleted on Home Assistant server and a Lang.String is trying to tell us an error message
|
|
Settings.unsetWebhookId();
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
if (data == null) {
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\nNull data");
|
|
} else {
|
|
// All objects have toString()
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + data.toString());
|
|
}
|
|
}
|
|
break;
|
|
|
|
default:
|
|
// System.println("WebhookManager onReturnRequestWebhookId(): Unhandled HTTP response code = " + responseCode);
|
|
Settings.unsetWebhookId();
|
|
Settings.unsetIsSensorsLevelEnabled();
|
|
ErrorView.show(WatchUi.loadResource($.Rez.Strings.WebhookFailed) as Lang.String + "\n" + WatchUi.loadResource($.Rez.Strings.UnhandledHttpErr) as Lang.String + " " + responseCode);
|
|
}
|
|
}
|
|
|
|
//! 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();
|
|
// System.println("WebhookManager registerWebhookSensor(): Registering webhook sensor: " + sensor.toString());
|
|
// System.println("WebhookManager registerWebhookSensor(): URL=" + url);
|
|
// https://developers.home-assistant.io/docs/api/native-app-integration/sensors/#registering-a-sensor
|
|
Communications.makeWebRequest(
|
|
url,
|
|
{
|
|
"type" => "register_sensor",
|
|
"data" => sensors[0]
|
|
},
|
|
{
|
|
:method => Communications.HTTP_REQUEST_METHOD_POST,
|
|
:headers => {
|
|
"Content-Type" => Communications.REQUEST_CONTENT_TYPE_JSON
|
|
},
|
|
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON,
|
|
:context => sensors.slice(1, null)
|
|
},
|
|
method(:onReturnRegisterWebhookSensor)
|
|
);
|
|
}
|
|
|
|
//! Request the creation of all the supported watch sensors on the Home Assistant instance.
|
|
//
|
|
function registerWebhookSensors() {
|
|
var heartRate = Activity.getActivityInfo().currentHeartRate;
|
|
|
|
var sensors = [
|
|
{
|
|
"device_class" => "battery",
|
|
"name" => "Battery Level",
|
|
"state" => System.getSystemStats().battery,
|
|
"type" => "sensor",
|
|
"unique_id" => "battery_level",
|
|
"icon" => "mdi:battery",
|
|
"unit_of_measurement" => "%",
|
|
"state_class" => "measurement",
|
|
"entity_category" => "diagnostic",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
},
|
|
{
|
|
"device_class" => "battery_charging",
|
|
"name" => "Battery is Charging",
|
|
"state" => System.getSystemStats().charging,
|
|
"type" => "binary_sensor",
|
|
"unique_id" => "battery_is_charging",
|
|
"icon" => System.getSystemStats().charging ? "mdi:battery-plus" : "mdi:battery-minus",
|
|
"entity_category" => "diagnostic",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
},
|
|
{
|
|
"name" => "Heart rate",
|
|
"state" => heartRate == null ? "unknown" : heartRate,
|
|
"type" => "sensor",
|
|
"unique_id" => "heart_rate",
|
|
"icon" => "mdi:heart-pulse",
|
|
"unit_of_measurement" => "bpm",
|
|
"state_class" => "measurement",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
}
|
|
];
|
|
|
|
if (Toybox has :ActivityMonitor) {
|
|
// System.println("WebhookManager registerWebhookSensors(): has ActivityMonitor class");
|
|
var activityInfo = ActivityMonitor.getInfo();
|
|
sensors.add({
|
|
"name" => "Steps today",
|
|
"state" => activityInfo.steps == null ? "unknown" : activityInfo.steps,
|
|
"type" => "sensor",
|
|
"unique_id" => "steps_today",
|
|
"icon" => "mdi:walk",
|
|
"state_class" => "total",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
|
|
if (ActivityMonitor.Info has :floorsClimbed) {
|
|
sensors.add({
|
|
"name" => "Floors climbed today",
|
|
"state" => activityInfo.floorsClimbed == null ? "unknown" : activityInfo.floorsClimbed,
|
|
"type" => "sensor",
|
|
"unique_id" => "floors_climbed_today",
|
|
"icon" => "mdi:stairs-up",
|
|
"state_class" => "total",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
}
|
|
|
|
if (ActivityMonitor.Info has :floorsDescended) {
|
|
sensors.add({
|
|
"name" => "Floors descended today",
|
|
"state" => activityInfo.floorsDescended == null ? "unknown" : activityInfo.floorsDescended,
|
|
"type" => "sensor",
|
|
"unique_id" => "floors_descended_today",
|
|
"icon" => "mdi:stairs-down",
|
|
"state_class" => "total",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
}
|
|
|
|
if (ActivityMonitor.Info has :respirationRate) {
|
|
sensors.add({
|
|
"name" => "Respiration rate",
|
|
"state" => activityInfo.respirationRate == null ? "unknown" : activityInfo.respirationRate,
|
|
"type" => "sensor",
|
|
"unique_id" => "respiration_rate",
|
|
"icon" => "mdi:lungs",
|
|
"unit_of_measurement" => "bpm",
|
|
"state_class" => "measurement",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
}
|
|
} else {
|
|
//System.println("WebhookManager registerWebhookSensors(): has no ActivityMonitor class");
|
|
}
|
|
|
|
if (Activity has :getProfileInfo) {
|
|
var activity = Activity.getProfileInfo().sport;
|
|
var sub_activity = Activity.getProfileInfo().subSport;
|
|
|
|
if ((Activity.getActivityInfo() != null) and
|
|
((Activity.getActivityInfo().elapsedTime == null) or
|
|
(Activity.getActivityInfo().elapsedTime == 0))) {
|
|
// Indicate no activity with -1, not part of Garmin's activity codes.
|
|
// https://developer.garmin.com/connect-iq/api-docs/Toybox/Activity.html#Sport-module
|
|
activity = -1;
|
|
sub_activity = -1;
|
|
}
|
|
sensors.add({
|
|
"name" => "Activity",
|
|
"state" => activity,
|
|
"type" => "sensor",
|
|
"unique_id" => "activity",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
sensors.add({
|
|
"name" => "Sub-activity",
|
|
"state" => sub_activity,
|
|
"type" => "sensor",
|
|
"unique_id" => "sub_activity",
|
|
"disabled" => !Settings.isSensorsLevelEnabled()
|
|
});
|
|
}
|
|
|
|
registerWebhookSensor(sensors);
|
|
}
|
|
|
|
}
|