Skip to content

Commit

Permalink
Merge pull request #3558 from flaparoo/hadash
Browse files Browse the repository at this point in the history
hadash: initial release
  • Loading branch information
bobrippling authored Sep 23, 2024
2 parents f908f56 + a6bf396 commit cc79ecc
Show file tree
Hide file tree
Showing 11 changed files with 39,654 additions and 0 deletions.
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
68 changes: 68 additions & 0 deletions apps/hadash/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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).
- After showing a state or call a service, return to the same point in the menu.
- Config option for service calls to not show a "successful" prompt


## 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:
CBs[`${key}_format`] = ((key) => function(v) { return entry.input[key].options[v]; })(key);
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

0 comments on commit cc79ecc

Please sign in to comment.