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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FunctionDescription
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
+ +## Communication + +It uses the [Fetch API](https://dev.fitbit.com/build/reference/companion-api/fetch/). +The asynchronous answer will be packed as JSON in a socket message. + + + + + + + + + + + + + + + + + + +
FunctionResponse
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" +} + +
+ +```mermaid +flowchart TD; + Companion -- function --> HomeAssistantAPI; + HomeAssistantAPI -- request --> HomeAssistant; + HomeAssistant -- respond --> HomeAssistantAPI; + HomeAssistantAPI -- message --> App; +``` + +## Example + +Companion +```js +import { HomeAssistantAPI } from "./HomeAssistantAPI"; + +var HA = new HomeAssistantAPI(); +HA.setup("127.0.0.1", "8123", "my_secret_access_token", false); +HA.fetchApiStatus(); +HA.fetchEntity("switch.myswitch"); +``` + +App +```js +import * as messaging from "messaging"; + +messaging.peerSocket.onmessage = (evt) => { + if (evt.data.key === "api") { + if (evt.data.value === "ok") { + console.log("HomeAssistant available"); + } + else { + console.log("HomeAssistant not available"); + console.log(`Error: ${evt.data.value}`); + } + } + else if (evt.data.key === "add") { + console.log(`New entry: ${evt.data.name} = ${evt.data.state}`); + } +} +``` diff --git a/companion/index.js b/companion/index.js index 916224c..3ab40cf 100644 --- a/companion/index.js +++ b/companion/index.js @@ -1,40 +1,15 @@ import * as messaging from "messaging"; import { gettext } from "i18n"; +import { me as companion } from "companion"; import { settingsStorage } from "settings"; import { sendData, isEmpty } from "../common/utils"; -let URL = ""; -let Port = "8123"; -let Token = ""; -let Force = false; +import { HomeAssistantAPI } from "./HomeAssistantAPI"; -const groups = { - switch: "switch", - light: "light", - group: "homeassistant", - script: "script", - automation: "automation", - cover: "cover", -} - -const nextStateOverrides = { - script: "turn_on", - automation: "trigger", -} - -const forcedStates = { - turn_on: "on", - turn_off: "off", - close_cover: "closed", - open_cover: "open", -} - -// Return address as URL + Port -function address() { - return URL + ':' + Port; -} +// Create HomeAssistantAPI object +var HA = new HomeAssistantAPI(); // Settings have been changed settingsStorage.onchange = function(evt) { @@ -53,8 +28,8 @@ settingsStorage.onchange = function(evt) { else if (evt.key === "entities") { sendData({key: "clear"}); JSON.parse(evt.newValue).forEach(element => { - fetchEntity(address(), Token, element["name"]); - }) + HA.fetchEntity(element["name"]); + }); } else if (evt.key === "force") { let data = JSON.parse(evt.newValue); @@ -62,122 +37,16 @@ settingsStorage.onchange = function(evt) { } } -// Get entity info -function fetchEntity(url, token, entity) { - fetch(`${url}/api/states/${entity}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${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 (data["entity_id"].startsWith("script")) { - msgData.state = 'exe' - } - else if (data["entity_id"].startsWith("automation")) { - msgData.state = 'exe' - } - sendData(msgData); - } - else { - console.log(`[fetchEntity] ${gettext("error")} ${response.status}`); - } - }) - .catch(err => console.log('[fetchEntity]: ' + err)); -} - -// Get Availability of HA -function fetchApiStatus(url, token) { - fetch(`${url}/api/config`, { - method: "GET", - headers: { - "Authorization": `Bearer ${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 state -function changeEntity(url, token, entity, state) { - const json = JSON.stringify({ - entity_id: `${entity}` +// Settings changed while companion was not running +if (companion.launchReasons.settingsChanged) { + const keys = ["url", "port", "token", "force"]; + keys.forEach(function(keyName, index, array) { + sendData({key: keyName, value: settingsStorage.getItem(keyName)}); + }); + sendData({key: "clear"}); + JSON.parse(settingsStorage?.getItem("entities"))?.forEach((element) => { + HA.fetchEntity(element["name"]); }); - const domain = entity.split('.')[0] - const group = groups[domain] - state = nextStateOverrides[domain] || state - //DEBUG console.log(`SENT ${url}/api/services/${group}/${state} FOR ${entity}`); - fetch(`${url}/api/services/${group}/${state}`, { - method: "POST", - body: json, - headers: { - "Authorization": `Bearer ${token}`, - "content-type": "application/json", - } - }) - .then(async(response) => { - if (response.ok) { - let data = await response.json(); - //DEBUG console.log('RECEIVED ' + JSON.stringify(data)); - if (Force) { - let msgData = { - key: "change", - id: entity, - state: forcedStates[state] || state, - }; - if (!entity.startsWith("script") && !entity.startsWith("automation")) { - //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 (!element["entity_id"].startsWith("script") && !element["entity_id"].startsWith("automation")) { - sendData(msgData); - } - } - }) - } - } - else { - console.log(`[changeEntity] ${gettext("error")} ${response.status}`); - } - }) - .catch(err => console.log('[changeEntity]: ' + err)); } // Message socket opens @@ -190,39 +59,34 @@ messaging.peerSocket.onclose = () => { console.log('Socket closed'); }; -// Received message +// Received message from App messaging.peerSocket.onmessage = evt => { console.log('Received', JSON.stringify(evt.data)); if (evt.data.key === "change") { - changeEntity(address(), Token, evt.data.entity, evt.data.state); + HA.changeEntity(evt.data.entity, evt.data.state); } else if (evt.data.key === "url") { - URL = evt.data.value; - if (URL && Port && Token) { - fetchApiStatus(address(), Token); - } + HA.changeUrl(evt.data.value); + HA.fetchApiStatus(); } else if (evt.data.key === "port") { - Port = evt.data.value; - if (URL && Port && Token) { - fetchApiStatus(address(), Token); - } + HA.changePort(evt.data.value); + HA.fetchApiStatus(); } else if (evt.data.key === "token") { - Token = evt.data.value; - if (URL && Port && Token) { - fetchApiStatus(address(), Token); - } + HA.changeToken(evt.data.value); + HA.fetchApiStatus(); } else if (evt.data.key === "entities") { if (evt.data.value) { sendData({key: "clear"}); evt.data.value.forEach(element => { - fetchEntity(address(), Token, element["name"]); + HA.fetchEntity(element["name"]); }) } } else if (evt.data.key === "force") { - Force = evt.data.value; + HA.changeForce(evt.data.value); + HA.fetchApiStatus(); } }; diff --git a/package.sdk4.json b/package.sdk4.json index 8c17a84..e3b114d 100644 --- a/package.sdk4.json +++ b/package.sdk4.json @@ -4,7 +4,7 @@ "@fitbit/sdk-cli": "^1.7.3" }, "name": "homeassistant", - "version": "0.6.0", + "version": "1.0.0", "private": true, "license": "MIT", "fitbit": { diff --git a/package.sdk5.json b/package.sdk5.json deleted file mode 100644 index 6f92cbf..0000000 --- a/package.sdk5.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "devDependencies": { - "@fitbit/sdk": "~5.0.0", - "@fitbit/sdk-cli": "^1.7.3" - }, - "name": "homeassistant", - "version": "0.6.0", - "private": true, - "license": "MIT", - "fitbit": { - "appUUID": "158edb1c-f748-4dbf-a682-b9dae2b74457", - "appType": "app", - "appDisplayName": "HA Remote", - "iconFile": "resources/icon.png", - "wipeColor": "#ffffff", - "requestedPermissions": [ - "access_internet" - ], - "buildTargets": [ - "atlas", - "vulcan" - ], - "i18n": { - "en-US": { - "name": "HA Remote" - } - }, - "defaultLanguage": "en-US" - }, - "scripts": { - "build": "fitbit-build", - "debug": "fitbit" - } -} diff --git a/package.sdk6.json b/package.sdk6.json index 697de3e..c9fa394 100644 --- a/package.sdk6.json +++ b/package.sdk6.json @@ -1,10 +1,10 @@ { "devDependencies": { - "@fitbit/sdk": "~6.0.0", + "@fitbit/sdk": "~6.1.0", "@fitbit/sdk-cli": "^1.7.3" }, "name": "homeassistant", - "version": "0.6.0", + "version": "1.0.0", "private": true, "license": "MIT", "fitbit": {