hadash: initial release

pull/3558/head
Flaparoo 2024-01-13 14:12:01 +08:00
parent 932d2c0d9d
commit 1bc60185dd
11 changed files with 39653 additions and 0 deletions

4
apps/hadash/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
hadash.json
node_modules
package-lock.json
package.json

1
apps/hadash/ChangeLog Normal file
View File

@ -0,0 +1 @@
1.00: initial release

67
apps/hadash/README.md Normal file
View File

@ -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)

View File

@ -0,0 +1 @@
require("heatshrink").decompress(atob("mEwwgmjhGACyuIxAYUCwIABFyowUCwYwSFwgwSCwowQFwwwQCw4wOFxAwOCxIwMFwuDnAwPCwv//4YFFx0/C4PzGBpXFCwIABMJiMGC5IwGQ4wXJGAq7HC5QwEW4+PCwP4YZTqJFxAwEdBIXKGAQXWIxIXMwAXXBRIXFwYFBnATKC5E/AoPzC6bdKC/4XTx4WCO5CbGC4YuDU5CbGC4QuCma+JKYwECXhoXIFwTsLC5DrONgxzMO4woDFx6nDFIYuPJQoqBFx4ADRIYuSGAiUEGCQXUYg4wUC6YwDBpUIGBYWJwA"))

227
apps/hadash/hadash.app.js Normal file
View File

@ -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]; }');
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]);
}

BIN
apps/hadash/hadash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

258
apps/hadash/interface.html Normal file
View File

@ -0,0 +1,258 @@
<html>
<head>
<link rel="stylesheet" href="../../css/spectre.min.css">
</head>
<body>
<div class="form-group">
<label class="form-label" for="HAbaseUrl">Home-Assistant API Base URL:</label>
<input class="form-input" type="text" id="HAbaseUrl" placeholder="https://ha.example:8123/api" />
<div>
<small class="text-muted">Make sure to include "/api" as the URL path, but no slash at the end.</small>
</div>
<div class="form-group">
<label class="form-label" for="HAtoken">Long-lived access token:</label>
<input class="form-input" type="text" id="HAtoken" placeholder="Your Long-lived Access Token" />
<div>
<small class="text-muted">It's recommended to create a dedicated token for your Bangle.</small>
</div>
</div>
<div class="divider"></div>
<h4>Menu structure</h4>
<p>Use the editor below to configure the menu structure displayed in the
Bangle app. It is in the JSON format.</p>
<p>The main menu, and any sub-menus, are arrays. They can contain 3
different types of entries (objects) defined by the "type" attribute:</p>
<ul>
<li>
<b>Query an Entity State</b>
<pre class="code" data-lang="JSON">
{
"type": "state",
"title": "Menu entry title",
"id": "HA Entity ID"
}
</pre>
The required Entity ID can be looked up in HA under Settings -&gt;
Devices &amp; Services -&gt; Entities. For example:
<pre class="code" data-lang="JSON">
{
"type": "state",
"title": "Check for updates",
"id": "update.home_assistant_core_update"
}
</pre>
</li>
<li>
<b>Call a HA service</b>
<pre class="code" data-lang="JSON">
{
"type": "service",
"title": "Menu entry title",
"domain": "HA Domain",
"service": "HA Service",
"data": {
"key": "value"
}
}
</pre>
<p>The required information to call a HA service can be found in HA
under the Developer tools -&gt; Services. Use the "Go to YAML Mode"
function to see the actual names and values. The domain and service
parts are the 2 parts of the service name which are separated by a dot.
Any (optional) data key/value pairs can be added under the "data"
field. For example, here's a service call YAML:
<pre class="code" data-lang="YAML">
service: persistent_notification.create
data:
message: test Notification
title: Test
</pre>
The resulting menu entry (JSON object) should be:
<pre class="code" data-lang="JSON">
{
"type": "service",
"title": "Create Notification",
"domain": "persistent_notification",
"service": "create",
"data": {
"message": "test Notification",
"title": "Test"
}
}
</pre>
If the service requires a target, include the
"entity_id"/"device_id"/etc. (listed under "target:") also as "data"
key/value pairs. For example, if the YAML also includes:
<pre class="code" data-lang="YAML">
target:
device_id: abcd1234
</pre>
... add another "data" key/value pair: <code>"device_id": "abcd1234"</code>.
If that doesn't work, list the device (or entity) ID in an array:
<code>"device_id": [ "abcd1234" ]</code></p>
<p>Data fields can also have variable input on the Bangle. In that
case, don't add the key/value pair under "data", but create an "input"
object with entries per "data" key:
<pre class="code" data-lang="JSON">
{
"key": {
"options": [],
"value": "",
"label": ""
}
}
</pre>
If "options" is left empty, the preferred text-input (pick your
favourite app - I like the dragboard) is called to allow entering a
free-form value. Otherwise, list the allowed values in the "options"
array. The "value" is the pre-defined/default value. The "label" is
optional and can be used to override the label for this key (as
displayed on the Bangle). For example:
<pre class="code" data-lang="JSON">
{
"type": "service",
"title": "Custom Notification",
"domain": "persistent_notification",
"service": "create",
"data": {
"title": "Fixed"
},
"input": {
"message": {
"options": [],
"value": "Pre-filled text"
},
"notification_id": {
"options": [
"123",
"456",
"136"
],
"value": "999",
"label": "ID"
}
}
}
</pre>
In the above example, the "data" will have 3 key/value pairs when the
service is called: the "title" is always the same, "message" can be
entered via the (free-form) text-input and "notification_id" can be
selected from a list of numbers (however, the prompt will be for "ID"
and not "notification_id"). If the default value is not listed in
"options" (like "999"), it will be added to that list.</p>
</li>
<li>
<b>Sub-menu</b>
<pre class="code" data-lang="JSON">
{
"type": "menu",
"title": "Menu entry / sub-menu title",
"data": []
}
</pre>
The "data" needs to be another array of menu entries. For example:
<pre class="code" data-lang="JSON">
{
"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": {}
}
]
}
</pre>
Sub-menus can contain other sub-menus, so you can have multiple levels
of nested menus.
</li>
</ul>
<div id="jsoneditor"></div>
<div class="divider"></div>
<p><div id="status"></div></p>
<button id="upload" class="btn btn-primary">Configure / Upload to Bangle</button>
<script>
var JSONEditorInstance;
var JSONEditor_target = document.getElementById('jsoneditor');
</script>
<script src="jsoneditor.bundlejs"></script>
<script src="../../core/lib/interface.js"></script>
<script>
var settings = {
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" } } },
]
};
function onInit() {
// read in existing settings from the Bangle
try {
Util.readStorageJSON('hadash.json', currentSettings => {
if (currentSettings) {
settings = currentSettings;
if ('HAbaseUrl' in settings)
document.getElementById('HAbaseUrl').value = settings.HAbaseUrl;
if ('HAtoken' in settings)
document.getElementById('HAtoken').value = settings.HAtoken;
if ('menu' in settings)
JSONEditorInstance.update({ text: undefined, json: settings.menu });
}
});
} catch (e) {
console.log("Failed to read existing settings: "+e);
}
}
document.getElementById("upload").addEventListener("click", function() {
if (JSONEditorInstance.get().json) {
settings.menu = JSONEditorInstance.get().json;
} else {
settings.menu = JSON.parse(JSONEditorInstance.get().text);
}
if (! settings.menu) {
document.getElementById("status").innerHTML = 'Generating the menu failed (or menu is empty) - upload aborted!';
return;
}
settings.HAbaseUrl = document.getElementById('HAbaseUrl').value;
settings.HAtoken = document.getElementById('HAtoken').value;
Util.writeStorage('hadash.json', JSON.stringify(settings), () => {
document.getElementById("status").innerHTML = 'HA-Dash configuration successfully uploaded to Bangle!';
});
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
/*
* JSONEditor wrapper
*
* This script is bundled together with the actual JSONEditor (https://github.com/josdejong/svelte-jsoneditor)
* using ESBuild (see below).
*
* The following global variables need to be defined before including the jsoneditor-bundle.js:
*
* JSONEditorInstance will contain the new JSONEditor instance
* JSONEditor_target element ID of container (<div>) for the JSONEditor
*
* To build the bundle, run the following commands:
* npm install esbuild
* npm install vanilla-jsoneditor
* ./node_modules/.bin/esbuild jsoneditor.wrapperjs --bundle --outfile=jsoneditor.bundlejs
*
*/
import { JSONEditor } from 'vanilla-jsoneditor/standalone.js'
JSONEditorInstance = new JSONEditor({ target: JSONEditor_target, props: {} });

20
apps/hadash/metadata.json Normal file
View File

@ -0,0 +1,20 @@
{
"id": "hadash",
"name": "Home-Assistant Dashboard",
"shortName":"HA-Dash",
"version":"1.00",
"description": "Interact with Home-Assistant (query states, call services)",
"icon": "hadash.png",
"screenshots": [{ "url": "screenshot.png" }],
"type": "app",
"tags": "tool,online",
"supports": ["BANGLEJS2"],
"dependencies": { "textinput": "type" },
"readme": "README.md",
"interface": "interface.html",
"storage": [
{ "name":"hadash.app.js", "url":"hadash.app.js" },
{ "name":"hadash.img", "url":"hadash-icon.js", "evaluate":true }
],
"data": [{ "name":"hadash.json" }]
}

BIN
apps/hadash/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB