diff --git a/.eslintrc.json b/.eslintrc.json index 93e7ba4..ff98996 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -29,7 +29,8 @@ } ], "@typescript-eslint/explicit-function-return-type": "error", // Enforce explicit return type - "@typescript-eslint/no-floating-promises": "error", // Enforce awaiting async functions + "@typescript-eslint/no-floating-promises": "error", // Enforce awaiting async functions, + "@typescript-eslint/no-explicit-any": "warn", // Warn on explicit any "indent": [ "error", "tab" diff --git a/.homeycompose/app.json b/.homeycompose/app.json index 4bcc4ef..cf1aee6 100644 --- a/.homeycompose/app.json +++ b/.homeycompose/app.json @@ -7,7 +7,7 @@ "url": "https://github.com/Erikvl87/nl.erikvl87.zoneactivity/issues" }, "source": "https://github.com/Erikvl87/nl.erikvl87.zoneactivity", - "compatibility": ">=5.0.0", + "compatibility": ">=12.1.2", "sdk": 3, "platforms": [ "local" diff --git a/app.json b/app.json index db01fed..43a1650 100644 --- a/app.json +++ b/app.json @@ -8,7 +8,7 @@ "url": "https://github.com/Erikvl87/nl.erikvl87.zoneactivity/issues" }, "source": "https://github.com/Erikvl87/nl.erikvl87.zoneactivity", - "compatibility": ">=5.0.0", + "compatibility": ">=12.1.2", "sdk": 3, "platforms": [ "local" @@ -628,5 +628,34 @@ } ] } - ] + ], + "widgets": { + "zone-activity-state": { + "name": { + "en": "Zone activity state", + "nl": "Zone activiteit status" + }, + "height": 75, + "settings": [ + { + "id": "zone", + "type": "autocomplete", + "title": { + "en": "Zone" + } + } + ], + "api": { + "getZone": { + "method": "GET", + "path": "/getZone" + }, + "log": { + "method": "POST", + "path": "/log" + } + }, + "id": "zone-activity-state" + } + } } \ No newline at end of file diff --git a/app.ts b/app.ts index b4ec133..e819655 100644 --- a/app.ts +++ b/app.ts @@ -7,6 +7,7 @@ import ConditionCardEvaluateSensorCapabilities from './lib/ConditionCardEvaluate import ConditionCardZoneActiveForMinutes from './lib/ConditionCardZoneActiveForMinutes'; import TriggerCardAnyDeviceTurnedOn from './lib/TriggerCardAnyDeviceOnOff'; import ZonesDb from './lib/ZonesDb'; +import WidgetZoneActivityState from './lib/WidgetZoneActivityState'; class ZoneActivity extends Homey.App { /** @@ -16,6 +17,8 @@ class ZoneActivity extends Homey.App { homeyApi!: ExtendedHomeyAPIV3Local; homeyLog?: Log; + private widget!: WidgetZoneActivityState; + /** * Initialize the Zone Activity app. * @returns {Promise} A promise that resolves when the app has been initialized. @@ -34,6 +37,7 @@ class ZoneActivity extends Homey.App { await ConditionCardZoneActiveForMinutes.initialize(this.homey, this.homeyApi, zonesDb, this.log); await ConditionCardEvaluateSensorCapabilities.initialize(this.homey, this.homeyApi, zonesDb, this.log); await TriggerCardAnyDeviceTurnedOn.initialize(this.homey, this.homeyApi, zonesDb, this.log, this.error); + this.widget = await WidgetZoneActivityState.initialize(this.homey, this.homeyApi, zonesDb, this.log, this.error); } } diff --git a/lib/WidgetZoneActivityState.ts b/lib/WidgetZoneActivityState.ts new file mode 100644 index 0000000..e406423 --- /dev/null +++ b/lib/WidgetZoneActivityState.ts @@ -0,0 +1,65 @@ +import { ExtendedHomeyAPIV3Local, ExtendedZone } from "homey-api"; +import Homey from "homey/lib/Homey"; +import handleZoneAutocomplete from "../utils/handleZoneAutocomplete"; +import ZonesDb from "./ZonesDb"; +import getIconForZone, { getZoneImageSource } from "./../utils/getIconForZone"; + +export default class WidgetZoneActivityState { + + private static instance: WidgetZoneActivityState | null = null; + + widget: unknown; + + private constructor(private homey: Homey, private homeyApi: ExtendedHomeyAPIV3Local, private zonesDb: ZonesDb, private log: (...args: unknown[]) => void, private error: (...args: unknown[]) => void) { + // @ts-expect-error Ignore the error for the non-existing method / property + this.widget = this.homey.dashboards.getWidget('zone-activity-state'); + } + + public static async initialize(homey: Homey, homeyApi: ExtendedHomeyAPIV3Local, zonesDb: ZonesDb, log: (...args: unknown[]) => void, error: (...args: unknown[]) => void): Promise { + if (WidgetZoneActivityState.instance === null) { + WidgetZoneActivityState.instance = new WidgetZoneActivityState(homey, homeyApi, zonesDb, log, error); + await WidgetZoneActivityState.instance.setup(); + } + return WidgetZoneActivityState.instance; + } + + private async setup(): Promise { + // @ts-expect-error Ignore the error for the non-existing method / property + this.widget.registerSettingAutocompleteListener('zone', async (query: string) => { + const result = (await handleZoneAutocomplete(query, this.zonesDb)) + // Remap icon to image for all results + .map((zone) => { + zone.image = zone.icon; + return zone; + }); + return result; + }); + + this.homeyApi.zones.on('zone.update', async (zone: ExtendedZone) => { + const data = await this.transformZone(zone); + this.homey.api.realtime(`zone-update-${zone.id}`, data); + }); + } + + public async getZone(zoneId: string): Promise { + const zone = await this.zonesDb.getZone(zoneId); + if (zone === null) + return null; + + return await this.transformZone(zone); + } + + private async transformZone(zone: ExtendedZone): Promise { + const result = { + id: zone.id, + iconSrc: await getZoneImageSource(zone.icon), + icon: getIconForZone(zone.icon), + name: zone.name, + active: zone.active, + activeLastUpdated: zone.activeLastUpdated, + } + + return result; + } +} + diff --git a/locales/en.json b/locales/en.json index a597a7e..01595f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,4 +1,14 @@ { "any_type": "Any type", - "any_sensor": "Any sensor" + "any_sensor": "Any sensor", + "active": "Active", + "last_active": "Last active", + "hour_ago": "hour ago", + "hours_ago": "hours ago", + "minute_ago": "minute ago", + "minutes_ago": "minutes ago", + "second_ago": "second ago", + "seconds_ago": "seconds ago", + "widget_error": "An error occurred", + "contact_dev": "Please contact the developer" } \ No newline at end of file diff --git a/locales/nl.json b/locales/nl.json index a322cef..149a565 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -1,4 +1,14 @@ { "any_type": "Elk type", - "any_sensor": "Elke sensor" + "any_sensor": "Elke sensor", + "active": "Actief", + "last_active": "Laatst actief", + "hours_ago": "uur geleden", + "hour_ago": "uur geleden", + "minutes_ago": "minuten geleden", + "minute_ago": "minuut geleden", + "second_ago": "seconde geleden", + "seconds_ago": "seconden geleden", + "widget_error": "Er is een fout is opgetreden", + "contact_dev": "Neem contact op met de ontwikkelaar" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 188b671..1e92725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "devDependencies": { "@eslint-recommended/eslint-config": "^26.1.1", "@tsconfig/node16": "^16.1.3", - "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.7", + "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.9", "@types/node": "^22.5.1", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", diff --git a/package.json b/package.json index 39fb068..429dbf9 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "devDependencies": { "@eslint-recommended/eslint-config": "^26.1.1", "@tsconfig/node16": "^16.1.3", - "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.7", + "@types/homey": "npm:homey-apps-sdk-v3-types@^0.3.9", "@types/node": "^22.5.1", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", diff --git a/tsconfig.json b/tsconfig.json index 712faca..0c4882a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "sourceMap": true }, "include": [ - "./**/*.ts" + "./**/*.ts", + "./widgets/zone-activity-state/api.js" ], "exclude": [ "node_modules", diff --git a/utils/getIconForZone.ts b/utils/getIconForZone.ts index 4111912..85c68d5 100644 --- a/utils/getIconForZone.ts +++ b/utils/getIconForZone.ts @@ -1,7 +1,27 @@ +const cache: { [key: string]: string } = {}; + function getZoneImagePath(t: string): string { return `https://my.homey.app/img/zones/${t}`; } +async function getZoneImageSource(iconName: string): Promise { + if (cache[iconName]) { + return cache[iconName]; + } + const url = getIconForZone(iconName); + try { + const response = await fetch(url); + if (!response.ok || !response.headers.get('content-type')?.includes('image/svg+xml')) { + throw new Error('Invalid response while fetching icon'); + } + const svgSource = await response.text(); + cache[iconName] = svgSource; + return svgSource; + } catch (_error) { + return null; + } +} + export default function getIconForZone(iconName: string): string { switch (iconName) { case 'firstFloor': @@ -108,3 +128,5 @@ export default function getIconForZone(iconName: string): string { return getZoneImagePath('hallway_door.svg'); } } + +export { getZoneImageSource }; diff --git a/widgets/zone-activity-state/api.ts b/widgets/zone-activity-state/api.ts new file mode 100644 index 0000000..292999b --- /dev/null +++ b/widgets/zone-activity-state/api.ts @@ -0,0 +1,21 @@ + +import Homey from "homey/lib/Homey"; + +class ZoneActivityStateWidget { + public async getZone({ homey, query }: ApiRequest) : Promise { + //@ts-expect-error: Ignore the error for the non-existing method + const result = await homey.app.widget.getZone(query.zoneId); + homey.app.log('Widget initialized,', { widgetInstanceId: query.widgetInstanceId, zoneId: query.zoneId, result }); + return result; + } + + public async log({ homey, body }: ApiRequest) : Promise { + const message = `[${this.constructor.name}]: ${body[0]}`; + const args = body.slice(1); + homey.app.log(message, ...args); + } +} + +module.exports = new ZoneActivityStateWidget(); + +type ApiRequest = { homey: Homey; query: any, body: any, params: unknown }; \ No newline at end of file diff --git a/widgets/zone-activity-state/preview-dark.png b/widgets/zone-activity-state/preview-dark.png new file mode 100644 index 0000000..4d85c9d Binary files /dev/null and b/widgets/zone-activity-state/preview-dark.png differ diff --git a/widgets/zone-activity-state/preview-light.png b/widgets/zone-activity-state/preview-light.png new file mode 100644 index 0000000..43a3db2 Binary files /dev/null and b/widgets/zone-activity-state/preview-light.png differ diff --git a/widgets/zone-activity-state/public/error.svg b/widgets/zone-activity-state/public/error.svg new file mode 100644 index 0000000..e2f410f --- /dev/null +++ b/widgets/zone-activity-state/public/error.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/widgets/zone-activity-state/public/index.html b/widgets/zone-activity-state/public/index.html new file mode 100644 index 0000000..8895bbf --- /dev/null +++ b/widgets/zone-activity-state/public/index.html @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/widgets/zone-activity-state/widget.compose.json b/widgets/zone-activity-state/widget.compose.json new file mode 100644 index 0000000..dcf81fc --- /dev/null +++ b/widgets/zone-activity-state/widget.compose.json @@ -0,0 +1,26 @@ +{ + "name": { + "en": "Zone activity state", + "nl": "Zone activiteit status" + }, + "height": 75, + "settings": [ + { + "id": "zone", + "type": "autocomplete", + "title": { + "en": "Zone" + } + } + ], + "api": { + "getZone": { + "method": "GET", + "path": "/getZone" + }, + "log": { + "method": "POST", + "path": "/log" + } + } +} \ No newline at end of file