diff --git a/.github/workflows/FitbitBuild.yml b/.github/workflows/FitbitBuild.yml index 4062f3c..6a5ff2c 100644 --- a/.github/workflows/FitbitBuild.yml +++ b/.github/workflows/FitbitBuild.yml @@ -46,41 +46,6 @@ jobs: path: build/app.sdk4.fba if-no-files-found: error - build-sdk5: - name: Build SDK5 - runs-on: ubuntu-latest - - steps: - - name: Checkout branch - uses: actions/checkout@v2 - with: - lfs: true - - - name: Setup node - uses: actions/setup-node@v1 - - - name: Select SDK 5 - run: cp package.sdk5.json package.json - - - name: Checkout Fitbit SDK - run: npm add --also=dev @fitbit/sdk - - - name: Checkout Fitbit SDK-CLI - run: npm add --also=dev @fitbit/sdk-cli - - - name: Run Build - run: npx fitbit-build --if-present - - - name: Copy App - run: cp build/app.fba build/app.sdk5.fba - - - name: Upload artifact - uses: actions/upload-artifact@v2 - if: ${{ success() }} - with: - path: build/app.sdk5.fba - if-no-files-found: error - build-sdk6: name: Build SDK6 runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbf806..263858d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Version 1.0 + +### Added +- Support button entities + +### Changed +- Created HomeassistantAPI module + ## Version 0.6 ### Added diff --git a/README.md b/README.md index a0be2a5..3dce102 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ [![](https://img.shields.io/badge/Fitbit%20App%20Gallery-%2300B0B9?style=flat&logo=fitbit&logoColor=white)](https://gallery.fitbit.com/details/158edb1c-f748-4dbf-a682-b9dae2b74457) ![languages](https://img.shields.io/badge/languages-JavaScript%20|%20CSS-blue) ![platform](https://img.shields.io/badge/platforms-Ionic%20|%20Versa%20|%20Versa%202%20|%20Versa%20Lite%20|%20Versa%203%20|%20Sense-silver) -[![version](https://img.shields.io/badge/version-%200.6-blue)](https://github.com/smirko-dev/fitbit-homeassistant/blob/main/CHANGELOG.md) +[![version](https://img.shields.io/badge/version-%201.0-blue)](https://github.com/smirko-dev/fitbit-homeassistant/blob/main/CHANGELOG.md) [![](https://img.shields.io/badge/license-MIT-blue)](https://github.com/smirko-dev/fitbit-homeassistant/blob/main/LICENSE) [![FitbitBuild Actions Status](https://github.com/smirko-dev/fitbit-homeassistant/workflows/FitbitBuild/badge.svg)](https://github.com/smirko-dev/fitbit-homeassistant/actions) [![CodeQL Actions Status](https://github.com/smirko-dev/fitbit-homeassistant/workflows/CodeQL/badge.svg)](https://github.com/smirko-dev/fitbit-homeassistant/actions) ## Description -This app allows to control [Home Assistant](https://www.home-assistant.io/) entities from a [Fitbit watch](https://www.fitbit.com/global/eu/home). +This app allows to control [Home Assistant](https://www.home-assistant.io/) entities from a [Fitbit watch](https://www.fitbit.com/global/eu/home) via the [HomeAssistantAPI module](companion/) . Supported languages: de-DE, en-US, it-IT. @@ -21,6 +21,7 @@ Supported entity types: - automation (execute) - script (execute) - cover (open/close) +- button (execute) App icon is from https://icon-icons.com/de/symbol/home-assistant/138491 ([Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0)). @@ -53,7 +54,6 @@ Choose SDK version | SDK | Device | |-----|-----------------------------------| | 4 | Versa, Versa Lite, Versa 2, Ionic | -| 5 | Versa 3, Sense | | 6 | Versa 3, Sense | ``` diff --git a/app/index.js b/app/index.js index 22ceefc..64aeae4 100644 --- a/app/index.js +++ b/app/index.js @@ -22,7 +22,7 @@ me.onunload = saveSettings; // List of {id: "", name: "", state: ""} let Entities = []; -const nextStates = { +const NextStates = { on: "turn_off", off: "turn_on", open: "close_cover", @@ -47,7 +47,7 @@ function setupList(list, data) { tile.getElementById("itemText").text = `${info.name}`; tile.getElementById("itemState").text = `${gettext(info.state)}`; let touch = tile.getElementById("itemTouch"); - touch.onclick = () => sendData({key: "change", entity: Entities[info.index].id, state: nextStates[info.state]}); + touch.onclick = () => sendData({key: "change", entity: Entities[info.index].id, state: NextStates[info.state]}); } } }; @@ -69,6 +69,7 @@ messaging.peerSocket.onmessage = (evt) => { else if (evt.data.key === "change") { Entities.forEach((entity, index) => { if (entity.id === evt.data.id) { + //DEBUG console.log(`Updated: ${evt.data.id} to ${evt.data.state}`); Entities[index].state = evt.data.state; setupList(EntityList, Entities); } @@ -129,7 +130,7 @@ function loadSettings() { url: "localhost", port: "8123", token: "", - force: false + force: true }; } } diff --git a/common/utils.js b/common/utils.js index 3b26d78..d936054 100644 --- a/common/utils.js +++ b/common/utils.js @@ -4,7 +4,7 @@ import * as messaging from "messaging"; // Send data export function sendData(data) { if (messaging.peerSocket.readyState === messaging.peerSocket.OPEN) { - console.log('Sent', JSON.stringify(data)); + //DEBUG console.log('Sent', JSON.stringify(data)); messaging.peerSocket.send(data); } } @@ -14,4 +14,4 @@ export function isEmpty(obj) { //return obj && Object.keys(obj).length === 0 && obj.constructor === Object; let json = JSON.stringify(obj); return json === '{}' || json === '[]'; -} \ No newline at end of file +} diff --git a/companion/HomeAssistantAPI.js b/companion/HomeAssistantAPI.js new file mode 100644 index 0000000..693d6a8 --- /dev/null +++ b/companion/HomeAssistantAPI.js @@ -0,0 +1,273 @@ +/** + * @module HomeAssistantAPI + * @brief Provides interface for HomeAssistant communication + */ +import { gettext } from "i18n"; +import { sendData, isEmpty } from "../common/utils"; + +const Groups = { + switch: "switch", + light: "light", + group: "homeassistant", + script: "script", + automation: "automation", + button: "button", + cover: "cover", +} + +const NextStateOverrides = { + script: "turn_on", + automation: "trigger", + button: "press" +} + +const ForcedStates = { + turn_on: "on", + turn_off: "off", + close_cover: "closed", + open_cover: "open", +} + +/** + * Create HomeAssistantAPI class object + */ +export function HomeAssistantAPI() { + this.url = ""; + this.port = ""; + this.token = ""; + this.force = false; +} + +/** + * Configuration validity + * @return True if configuration contains valid data, otherwise false. + */ +HomeAssistantAPI.prototype.isValid = function() { + let self = this; + return self.url !== undefined && self.port !== undefined && self.token !== undefined; +} + +/** + * Configuration validity + * @param {string} url - HomeAssistant instance URL + * @param {string} port - HomeAssistant instance port + * @param {string} token - Access token + * @param {boolean} force - Force update flag + */ +HomeAssistantAPI.prototype.setup = function(url, port, token, force) { + let self = this; + self.changeUrl(url); + self.changePort(port); + self.changeToken(token); + self.changeForce(force); +} + +/** + * Change URL + * @param {string} url - HomeAssistant instance URL + */ +HomeAssistantAPI.prototype.changeUrl = function(url) { + let self = this; + if (url !== undefined) { + self.url = url; + } + else { + self.url = '127.0.0.1'; + } +} + +/** + * Change port number + * @param {string} port - HomeAssistant instance port + */ +HomeAssistantAPI.prototype.changePort = function(port) { + let self = this; + if (port !== undefined) { + self.port = port; + } + else { + self.port = '8123'; + } +} + +/** + * Change token + * @param {string} token - Access token + */ +HomeAssistantAPI.prototype.changeToken = function(token) { + let self = this; + if (token !== undefined) { + self.token = token; + } + else { + self.token = ''; + } +} + +/** + * Change force update flag + * @param {boolean} force - Force update flag + */ +HomeAssistantAPI.prototype.changeForce = function(force) { + let self = this; + if (force !== undefined) { + self.force = force; + } + else { + self.force = true; + } +} + +/** + * HomeAssistant address + * @return The complete HomeAssistant address including url and port + */ +HomeAssistantAPI.prototype.address = function() { + let self = this; + return self.url + ':' + self.port +} + +/** + * Fetch entity + * @param {string} entity - Entity name + */ +HomeAssistantAPI.prototype.fetchEntity = function(entity) { + let self = this; + if (self.isValid()) { + fetch(`${self.address()}/api/states/${entity}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${self.token}`, + "content-type": "application/json", + } + }) + .then(async(response) => { + if (response.ok) { + let data = await response.json(); + let msgData = { + key: "add", + id: data["entity_id"], + name: data["entity_id"], + state: data["state"], + }; + if (data["attributes"] && data["attributes"]["friendly_name"]) { + msgData.name = data["attributes"]["friendly_name"]; + } + if (self.isExecutable(data["entity_id"])) { + msgData.state = 'exe' + } + sendData(msgData); + } + else { + console.log(`[fetchEntity] ${gettext("error")} ${response.status}`); + } + }) + .catch(err => console.log('[fetchEntity]: ' + err)); + } +} + +/** + * Fetch HomeAssistant API status + */ +HomeAssistantAPI.prototype.fetchApiStatus = function() { + let self = this; + if (self.isValid()) { + fetch(`${self.address()}/api/config`, { + method: "GET", + headers: { + "Authorization": `Bearer ${self.token}`, + "content-type": "application/json", + } + }) + .then(async(response) => { + let data = await response.json(); + if (response.status === 200) { + sendData({key: "api", value: "ok", name: data["location_name"]}); + } + else { + const json = JSON.stringify({ + key: "api", + value: `${gettext("error")} ${response.status}` + }); + sendData(json); + } + }) + .catch(err => { + console.log('[fetchApiStatus]: ' + err); + sendData({key: "api", value: gettext("connection_error")}); + }) + } +} + +/** + * Change entity + * @param {string} entity - Entity name + * @param {string} state - New state value + */ +HomeAssistantAPI.prototype.changeEntity = function(entity, state) { + let self = this; + if (self.isValid()) { + const json = JSON.stringify({ + entity_id: `${entity}` + }); + const domain = entity.split('.')[0]; + const group = Groups[domain]; + state = NextStateOverrides[domain] || state; + //DEBUG console.log(`SENT ${self.url}/api/services/${group}/${state} FOR ${entity}`); + fetch(`${self.address()}/api/services/${group}/${state}`, { + method: "POST", + body: json, + headers: { + "Authorization": `Bearer ${self.token}`, + "content-type": "application/json", + } + }) + .then(async(response) => { + if (response.ok) { + let data = await response.json(); + //DEBUG console.log('RECEIVED ' + JSON.stringify(data)); + if (self.force) { + let msgData = { + key: "change", + id: entity, + state: ForcedStates[state] || state, + }; + if (!self.isExecutable(entity)) { + //DEBUG console.log('FORCED ' + JSON.stringify(msgData)); + sendData(msgData); + } + } + else if (!isEmpty(data)) { + data.forEach(element => { + if (element["entity_id"] === entity) { + let msgData = { + key: "change", + id: element["entity_id"], + state: element["state"], + }; + if (!self.isExecutable(element["entity_id"])) { + sendData(msgData); + } + } + }) + } + } + else { + console.log(`[changeEntity] ${gettext("error")} ${response.status}`); + } + }) + .catch(err => console.log('[changeEntity]: ' + err)); + } +} + +/** + * Returns if an entity is an executable + * @param {string} entity - Entity name + * @return True if entity is an executable, otherwise false + */ +HomeAssistantAPI.prototype.isExecutable = function(entity) { + if (!entity.startsWith("script") && !entity.startsWith("automation") && !entity.startsWith("button")) { + return false; + } + return true; +} diff --git a/companion/README.md b/companion/README.md new file mode 100644 index 0000000..e64bd39 --- /dev/null +++ b/companion/README.md @@ -0,0 +1,132 @@ +# HomeAssistantAPI + +The [HomeAssistantAPI](HomeAssistantAPI.js) encapsulates the communication to the HomeAssistant instance. + +## Setup + +
Function | +Description | +
setup(url, port, token, force) |
+ Setup HomeAssistant configuration | +
isValid() |
+ Return if HomeAssistantAPI has a valid configuration | +
changeUrl(url) |
+ Change URL | +
changePort(port) |
+ Change port number | +
changeToken(token) |
+ Change access token | +
changeForce(force) |
+ Change force update flag | +
Function | +Response | +
fetchApiStatus() |
+
+Ok:
+{
+ "key": "api",
+ "value": "ok",
+ "name": "location_name"
+}
+
++Error:
+{
+ "key": "api",
+ "value": "error_message"
+}
+
+ |
+
changeEntity(entity, state) |
+
+
+{
+ "key": "change",
+ "id": "entity_id"
+ "state": "entity_state"
+}
+
+ |
+
fetchEntity(entity) |
+
+
+{
+ "key": "add",
+ "id": "entity_id",
+ "name": "entity_name"
+ "state": "entity_state"
+}
+
+ |
+