Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

hadash: initial release #3558

Merged
merged 3 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/hadash/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
hadash.json
node_modules
package-lock.json
package.json
1 change: 1 addition & 0 deletions apps/hadash/ChangeLog
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.00: initial release
67 changes: 67 additions & 0 deletions apps/hadash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Home-Assistant Dashboard

This app interacts with a Home-Assistant (HA) instance. You can query entity
states and call services. This allows you access to up-to-date information of
any home automation system integrated into HA, and you can also control your
automations from your wrist.

![](screenshot.png)


## How It Works

This app uses the REST API to directly interact with HA (which requires a
"long-lived access token" - refer to "Configuration").

You can define a menu structure to be displayed on your Bangle, with the states
to be queried and services to be called. Menu entries can be:

* entry to show the state of a HA entity
* entry to call a HA service
* sub-menus, including nested sub-menus

Calls to a service can also have optional input for data fields on the Bangle
itself.


## Configuration

After installing the app, use the "interface" page (floppy disk icon) in the
App Loader to configure it.

Make sure to set the "Home-Assistant API Base URL" (which must include the
"/api" path, as well - but no slash at the end).

Also create a "long-lived access token" in HA (under the Profile section, at
the bottom) and enter it as the "Long-lived access token".

The tricky bit will be to configure your menu structure. You need to have a
basic understanding of the JSON format. The configuration page uses a JSON
Editor which will check the syntax and highlight any errors for you. Follow the
instructions on the page regarding how to configure menus, menu entries and the
required attributes. It also contains examples.

Once you're happy with the menu structure (and you've entered the base URL and
access token), click the "Configure / Upload to Bangle" button.


## Security

The "long-lived access token" will be stored unencrypted on your Bangle. This
would - in theory - mean that if your Bangle gets stolen, the new "owner" would
have unrestricted access to your Home-Assistant instance (the thief would have
to be fairly tech-savvy, though). However, I suggest you create a separate
token exclusively for your Bangle - that way, it's very easy to simply delete
that token in case your watch is stolen or lost.


## To-Do

A better way to configure the menu structure would be useful, something like a
custom editor (replacing the jsoneditor).


## Author

Flaparoo [github](https://github.com/flaparoo)

1 change: 1 addition & 0 deletions apps/hadash/hadash-icon.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

227 changes: 227 additions & 0 deletions apps/hadash/hadash.app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/*
* Home-Assistant Dashboard - Bangle.js
*/

const APP_NAME = 'hadash';

// Load settings
var settings = Object.assign({
menu: [
{ type: 'state', title: 'Check for updates', id: 'update.home_assistant_core_update' },
{ type: 'service', title: 'Create Notification', domain: 'persistent_notification', service: 'create',
data: { 'message': 'test notification', 'title': 'Test'} },
{ type: 'menu', title: 'Sub-menu', data:
[
{ type: 'state', title: 'Check for Supervisor updates', id: 'update.home_assistant_supervisor_update' },
{ type: 'service', title: 'Restart HA', domain: 'homeassistant', service: 'restart', data: {} }
]
},
{ type: 'service', title: 'Custom Notification', domain: 'persistent_notification', service: 'create',
data: { 'title': 'Not via input'},
input: { 'message': { options: [], value: 'Pre-filled text' },
'notification_id': { options: [ 123, 456, 136 ], value: 999, label: "ID" } } },
],
HAbaseUrl: '',
HAtoken: '',
}, require('Storage').readJSON(APP_NAME+'.json', true) || {});


// query an entity state
function queryState(title, id, level) {
E.showMessage('Fetching entity state from HA', { title: title });
Bangle.http(settings.HAbaseUrl+'/states/'+id, {
headers: {
'Authorization': 'Bearer '+settings.HAtoken,
'Content-Type': 'application/json'
},
}).then(data => {
//console.log(data);
let HAresp = JSON.parse(data.resp);
let title4prompt = title;
let msg = HAresp.state;
if ('attributes' in HAresp) {
if ('friendly_name' in HAresp.attributes)
title4prompt = HAresp.attributes.friendly_name;
if ('unit_of_measurement' in HAresp.attributes)
msg += HAresp.attributes.unit_of_measurement;
}
E.showPrompt(msg, { title: title4prompt, buttons: {OK: true} }).then((v) => { E.showMenu(menus[level]); });
}).catch( error => {
console.log(error);
E.showPrompt('Error querying state!', { title: title, buttons: {OK: true} }).then((v) => { E.showMenu(menus[level]); });
});
}


// call a service
function callService(title, domain, service, data, level) {
E.showMessage('Calling HA service', { title: title });
Bangle.http(settings.HAbaseUrl+'/services/'+domain+'/'+service, {
method: 'POST',
body: data,
headers: {
'Authorization': 'Bearer '+settings.HAtoken,
'Content-Type': 'application/json'
},
}).then(data => {
//console.log(data);
E.showPrompt('Service called successfully', { title: title, buttons: {OK: true} }).then((v) => { E.showMenu(menus[level]); });
}).catch( error => {
console.log(error);
E.showPrompt('Error calling service!', { title: title, buttons: {OK: true} }).then((v) => { E.showMenu(menus[level]); });
});
}


// callbacks for service input menu entries
function serviceInputChoiceChange(v, key, entry, level) {
entry.input[key].value = entry.input[key].options[v];
getServiceInputData(entry, level);
}

function serviceInputFreeform(key, entry, level) {
require("textinput").input({text: entry.input[key].value}).then(result => {
entry.input[key].value = result;
getServiceInputData(entry, level);
});
}

// get input data before calling a service
function getServiceInputData(entry, level) {
let serviceInputMenu = {
'': {
'title': entry.title,
'back': () => E.showMenu(menus[level])
},
};
let CBs = {};
for (let key in entry.input) {
// pre-fill data with default values
if ('value' in entry.input[key])
entry.data[key] = entry.input[key].value;

let label = ( ('label' in entry.input[key] && entry.input[key].label) ? entry.input[key].label : key );
let key4CB = key;

if ('options' in entry.input[key] && entry.input[key].options.length) {
// give choice from a selection of options
let idx = -1;
for (let i in entry.input[key].options) {
if (entry.input[key].value == entry.input[key].options[i]) {
idx = i;
}
}
if (idx == -1) {
idx = entry.input[key].options.push(entry.input[key].value) - 1;
}
// the setTimeout method can not be used for the "format" CB since it expects a return value - using eval instead:
eval('CBs["'+key+'_format"] = function(v) { return entry.input["'+key+'"].options[v]; }');
flaparoo marked this conversation as resolved.
Show resolved Hide resolved
serviceInputMenu[label] = {
value: parseInt(idx),
min: 0,
max: entry.input[key].options.length - 1,
format: CBs[key+'_format'],
onchange: (v) => setTimeout(serviceInputChoiceChange, 10, v, key4CB, entry, level)
};

} else {
// free-form text input
serviceInputMenu[label] = () => setTimeout(serviceInputFreeform, 10, key4CB, entry, level);
}
}
// menu entry to actually call the service:
serviceInputMenu['Call service'] = function() { callService(entry.title, entry.domain, entry.service, entry.data, level); };
E.showMenu(serviceInputMenu);
}


// menu hierarchy
var menus = [];


// add menu entries
function addMenuEntries(level, entries) {
for (let i in entries) {
let entry = entries[i];
let entryCB;

// is there a menu entry title?
if (! ('title' in entry) || ! entry.title)
entry.title = 'TBD';

switch (entry.type) {
case 'state':
/*
* query entity state
*/
if ('id' in entry && entry.id) {
entryCB = () => setTimeout(queryState, 10, entry.title, entry.id, level);
}
break;

case 'service':
/*
* call HA service
*/
if ('domain' in entry && entry.domain && 'service' in entry && entry.service) {
if (! ('data' in entry))
entry.data = {};
if ('input' in entry) {
// get input for some data fields first
entryCB = () => setTimeout(getServiceInputData, 10, entry, level);
} else {
// call service straight away
entryCB = () => setTimeout(callService, 10, entry.title, entry.domain, entry.service, entry.data, level);
}
}
break;

case 'menu':
/*
* sub-menu
*/
entryCB = () => setTimeout(showSubMenu, 10, level + 1, entry.title, entry.data);
break;
}

// only attach a call-back to menu entry if it's properly configured
if (! entryCB) {
menus[level][entry.title + ' - not correctly configured!'] = {};
} else {
menus[level][entry.title] = entryCB;
}
}
}


// create and show a sub menu
function showSubMenu(level, title, entries) {
menus[level] = {
'': {
'title': title,
'back': () => E.showMenu(menus[level - 1])
},
};
addMenuEntries(level, entries);
E.showMenu(menus[level]);
}


/*
* create the main menu
*/
menus[0] = {
'': {
'title': 'HA-Dash',
'back': () => load()
},
};
addMenuEntries(0, settings.menu);

// check required configuration
if (! settings.HAbaseUrl || ! settings.HAtoken) {
E.showAlert('The app is not yet configured!', 'HA-Dash').then(() => E.showMenu(menus[0]));
} else {
E.showMenu(menus[0]);
}

Binary file added apps/hadash/hadash.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading