Kiosk improvements

This commit is contained in:
2024-12-18 20:38:41 +00:00
parent 9ccc4ec9de
commit 1ff4e1580e
8 changed files with 193 additions and 151 deletions

View File

@@ -17,6 +17,7 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"core-js": "^3.39.0", "core-js": "^3.39.0",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"home-assistant-js-websocket": "^9.4.0",
"pinia": "^2.3.0", "pinia": "^2.3.0",
"roboto-fontface": "^0.10.0", "roboto-fontface": "^0.10.0",
"suncalc": "^1.9.0", "suncalc": "^1.9.0",
@@ -45,7 +46,6 @@
"vite": "^5.4.11", "vite": "^5.4.11",
"vite-plugin-vuetify": "^2.0.4", "vite-plugin-vuetify": "^2.0.4",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"vue-tsc": "^2.1.10", "vue-tsc": "^2.1.10"
"websocketstream-polyfill": "^1.0.1"
} }
} }

View File

@@ -32,6 +32,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
home-assistant-js-websocket:
specifier: ^9.4.0
version: 9.4.0
pinia: pinia:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0(typescript@5.6.2)(vue@3.5.13(typescript@5.6.2)) version: 2.3.0(typescript@5.6.2)(vue@3.5.13(typescript@5.6.2))
@@ -114,9 +117,6 @@ importers:
vue-tsc: vue-tsc:
specifier: ^2.1.10 specifier: ^2.1.10
version: 2.1.10(typescript@5.6.2) version: 2.1.10(typescript@5.6.2)
websocketstream-polyfill:
specifier: ^1.0.1
version: 1.0.1
packages: packages:
@@ -1310,6 +1310,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true hasBin: true
home-assistant-js-websocket@9.4.0:
resolution: {integrity: sha512-312TuI63IfKf8G+iWvKmPYIdxWMNojwVk03o9OSpQFFDjSCNAYdCUfuPCFs73SuJ1Xpd4D1Eo11CB33MGMqZ+Q==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -2044,9 +2047,6 @@ packages:
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
websocketstream-polyfill@1.0.1:
resolution: {integrity: sha512-SV0AG8yN3YDwCVCex/oBQli7vrezGQ0stgo5suKMTthjPQv2wuMsNvwUWDAvNmDGGb9VIm5WsW99x57GW06gLQ==}
whatwg-url@5.0.0: whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -3406,6 +3406,8 @@ snapshots:
he@1.2.0: {} he@1.2.0: {}
home-assistant-js-websocket@9.4.0: {}
ignore@5.3.2: {} ignore@5.3.2: {}
immutable@5.0.3: {} immutable@5.0.3: {}
@@ -4167,8 +4169,6 @@ snapshots:
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}
websocketstream-polyfill@1.0.1: {}
whatwg-url@5.0.0: whatwg-url@5.0.0:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3

View File

@@ -0,0 +1,11 @@
import CalendarEntry from './calendar-entry';
export default class CalendarDay {
date: Date;
entries: CalendarEntry[];
constructor(date: Date, entries: CalendarEntry[]) {
this.date = date;
this.entries = entries;
}
}

View File

@@ -0,0 +1,6 @@
export default interface CalendarEntry {
summary: string;
isAllDay: boolean;
start: Date;
end: Date;
}

View File

@@ -1,64 +0,0 @@
let messageId: number = 1;
export enum MessageType {
auth_invalid = 'auth_invalid',
auth_ok = 'auth_ok',
auth_required = 'auth_required',
event = 'event',
pong = 'pong',
result = 'result'
}
export interface OutgoingMessage {
id: number | undefined;
type: string;
}
export interface IncomingMessage {
id: number | undefined;
type: string;
success: boolean | undefined;
result: any;
}
export class AuthMessage implements OutgoingMessage {
id = undefined;
type: string = 'auth';
access_token: string | null = null;
constructor(access_token: string) {
this.access_token = access_token;
}
}
export class SubscribeEntitiesMessage implements OutgoingMessage {
id = messageId++;
type = 'subscribe_entities';
entity_ids: string[] = [];
constructor(entity_ids: string[]) {
this.entity_ids = entity_ids;
}
}
export interface EventMessage extends IncomingMessage {
event: StatesUpdates;
}
export interface EntityState {
/** state */
s: string;
/** attributes */
a: { [key: string]: any };
/** last_changed; if set, also applies to lu */
lc: number;
/** last_updated */
lu: number;
}
export interface StatesUpdates {
/** add */
a?: Record<string, EntityState>;
/** remove */
r?: string[]; // remove
}

View File

@@ -4,7 +4,13 @@
import { useLaundryStore } from '@/stores/laundryStore'; import { useLaundryStore } from '@/stores/laundryStore';
import { usePowerStore } from '@/stores/powerStore'; import { usePowerStore } from '@/stores/powerStore';
import { useHomeAssistantStore } from '@/stores/homeAssistantStore'; import { useHomeAssistantStore } from '@/stores/homeAssistantStore';
import Environment from '@/environment'; import { useCalendarStore } from '@/stores/calendarStore';
import { format, startOfDay, endOfDay } from 'date-fns';
import CalendarDay from '@/models/calendar/calendar-day';
const calendarDayCount = 7;
const calendarReady = ref(false);
const weatherStore = useWeatherStore(); const weatherStore = useWeatherStore();
weatherStore.start(); weatherStore.start();
@@ -18,12 +24,61 @@
const homeAssistantStore = useHomeAssistantStore(); const homeAssistantStore = useHomeAssistantStore();
homeAssistantStore.start(); homeAssistantStore.start();
const calendarStore = useCalendarStore();
const currentTime = ref(new Date()); const currentTime = ref(new Date());
const calendarDays = ref([] as CalendarDay[]);
const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' }); const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit' });
const dateFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); const dateFormatter = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
setInterval(() => (currentTime.value = new Date()), 1000); function alarmState(state: string): string {
switch (state) {
case 'armed_home':
return 'Armed';
case 'armed_away':
return 'Armed';
case 'disarmed':
return 'Disarmed';
default:
return 'Unknown';
}
}
function loadCalendar() {
const newCalendarDays = [] as CalendarDay[];
calendarStore.getUpcoming(calendarDayCount).then((upcoming) => {
const currentDay = startOfDay(currentTime.value);
for (let i = 0; i < calendarDayCount; i++) {
const day = new Date(currentDay);
day.setDate(day.getDate() + i);
const entries = upcoming.filter((entry) => {
const entryStart = startOfDay(entry.start);
const entryEnd = endOfDay(entry.end);
if (entry.isAllDay) {
return day > entryStart && day < entryEnd;
}
return day >= entryStart && day <= entryEnd;
});
newCalendarDays.push(new CalendarDay(day, entries));
}
calendarDays.value = newCalendarDays;
calendarReady.value = true;
});
}
loadCalendar();
setInterval(() => currentTime.value = new Date(), 1000);
setInterval(() => loadCalendar(), 60000);
</script> </script>
<template> <template>
@@ -108,19 +163,38 @@
class="kiosk-device-icon" class="kiosk-device-icon"
icon="mdi-shield-home" /> icon="mdi-shield-home" />
<div class="kiosk-device-text"> <div class="kiosk-device-text">
{{ capitalize(homeAssistantStore.houseAlarmState) }} {{ alarmState(homeAssistantStore.houseAlarmState) }}
</div> </div>
</div> </div>
</div> </div>
<div class="kiosk-content" v-if="Environment.getCalendarEmbedUrl()"> <div class="kiosk-content">
<div class="kiosk-calendar"> <div
<iframe class="kiosk-calendar"
:src="Environment.getCalendarEmbedUrl()" v-if="calendarReady">
style="border-width: 0" <div class="kiosk-calendar-header">
width="100%" {{ 'Next ' + calendarDayCount + ' Days' }}
height="100%" </div>
frameborder="0" <ul class="kiosk-calendar-day-list">
scrolling="no"></iframe> <li
class="kiosk-calendar-day-item"
v-for="calendarDay in calendarDays">
<div>
<span class="kiosk-calendar-day-item-number">
{{ format(calendarDay.date, 'dd') }}
</span>
<span class="kiosk-calendar-day-item-name">
{{ format(calendarDay.date, 'MMMM, EEEE') }}
</span>
<ul
class="kiosk-calendar-entry"
v-for="calendarEntry in calendarDay.entries">
<span>
{{ calendarEntry.summary }}
</span>
</ul>
</div>
</li>
</ul>
</div> </div>
</div> </div>
</v-container> </v-container>
@@ -131,7 +205,7 @@
height: 100%; height: 100%;
padding: 0; padding: 0;
background-color: #212428; background-color: #212428;
color: #1f1f1f; color: #ebebeb;
display: grid; display: grid;
grid-template-columns: 250px 1fr; grid-template-columns: 250px 1fr;
grid-template-rows: 1fr; grid-template-rows: 1fr;
@@ -169,12 +243,15 @@
.kiosk-content { .kiosk-content {
height: 100%; height: 100%;
max-height: 100vh;
padding: 0; padding: 0;
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr);
grid-auto-flow: row; grid-auto-flow: row;
grid-template-areas: 'kiosk-calendar .'; grid-template-areas:
'kiosk-calendar . .'
'kiosk-calendar . .';
} }
.kiosk-time { .kiosk-time {
@@ -231,6 +308,48 @@
.kiosk-calendar { .kiosk-calendar {
grid-area: kiosk-calendar; grid-area: kiosk-calendar;
background-color: #121212;
margin: 10px;
border-radius: 10px;
display: flex;
flex: 1;
flex-direction: column;
}
.kiosk-calendar-header {
font-size: 1.15em;
padding-top: 10px;
padding-bottom: 2px;
text-align: center;
}
.kiosk-calendar-day-item-number {
font-size: 1.25em;
padding-right: 0.5em;
}
.kiosk-calendar-day-list {
margin-left: 10px;
overflow: auto;
flex: 1;
}
.kiosk-calendar-day-item:not(:last-child) {
padding-bottom: 2px;
}
.kiosk-calendar-day-item:first-of-type {
color: #c75ec7;
}
.kiosk-calendar-day-item:not(:first-child) {
padding-top: 4px;
}
.kiosk-calendar-entry {
color: #ebebeb;
padding-left: 2em;
padding-bottom: 2px;
} }
.true { .true {

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia';
import axios from 'axios';
import Environment from '@/environment';
import CalendarEntry from '@/models/calendar/calendar-entry';
export const useCalendarStore = defineStore('calendar', {
state: () => {
return {};
},
actions: {
async getUpcoming(days: number): Promise<CalendarEntry[]> {
const response = await axios.get<CalendarEntry[]>(Environment.getUrlPrefix() + `:8081/api/calendar/calendar/upcoming?days=${days}`);
return response.data;
}
}
});

View File

@@ -1,7 +1,5 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { WebSocketStream } from 'websocketstream-polyfill'; import { createConnection, subscribeEntities, createLongLivedTokenAuth, Connection } from 'home-assistant-js-websocket';
import { MessageType, AuthMessage, IncomingMessage, SubscribeEntitiesMessage, EventMessage } from '@/models/home-assistant/home-assistant';
import Environment from '@/environment'; import Environment from '@/environment';
export const useHomeAssistantStore = defineStore('home-assistant', { export const useHomeAssistantStore = defineStore('home-assistant', {
@@ -9,7 +7,7 @@ export const useHomeAssistantStore = defineStore('home-assistant', {
return { return {
garageState: null as string | null, garageState: null as string | null,
houseAlarmState: null as string | null, houseAlarmState: null as string | null,
_wss: null as WebSocketStream | null _connection: null as Connection | null
}; };
}, },
actions: { actions: {
@@ -21,72 +19,27 @@ export const useHomeAssistantStore = defineStore('home-assistant', {
const garageDevice = Environment.getGarageDevice(); const garageDevice = Environment.getGarageDevice();
const alarmDevice = Environment.getAlarmDevice(); const alarmDevice = Environment.getAlarmDevice();
this._wss = new WebSocketStream(Environment.getHomeAssistantUrl()); const auth = createLongLivedTokenAuth(Environment.getHomeAssistantUrl(), Environment.getHomeAssistantToken());
const { readable, writable } = await this._wss.opened; this._connection = await createConnection({ auth });
const reader = readable.getReader(); subscribeEntities(this._connection as Connection, entities => {
const writer = writable.getWriter(); const garageEntity = entities[garageDevice];
while (true) { if (garageEntity) {
const { value, done } = await reader.read(); this.$patch({ garageState: garageEntity.state });
const message = JSON.parse(value as string) as IncomingMessage;
console.info(message);
switch (message.type) {
case MessageType.auth_required:
const authMessage = new AuthMessage(Environment.getHomeAssistantToken());
writer.write(JSON.stringify(authMessage));
break;
case MessageType.auth_ok:
const subscribeEntitiesMessage = new SubscribeEntitiesMessage([
garageDevice,
alarmDevice
]);
writer.write(JSON.stringify(subscribeEntitiesMessage));
break;
case MessageType.event:
const eventMessage = message as EventMessage;
if (!eventMessage?.event?.a) {
break;
}
const garageEntity = eventMessage.event.a[garageDevice];
if (garageEntity) {
this.$patch({ garageState: garageEntity.s });
}
const houseAlarmEntity = eventMessage.event.a[alarmDevice];
if (houseAlarmEntity) {
this.$patch({ houseAlarmState: houseAlarmEntity.s });
}
break;
case MessageType.result:
// Handle result type
break;
default:
// Handle unknown message type
break;
} }
if (done) { const houseAlarmEntity = entities[alarmDevice];
break;
if (houseAlarmEntity) {
this.$patch({ houseAlarmState: houseAlarmEntity.state });
} }
} });
}, },
async stop() { async stop() {
if (!this._wss) { this._connection?.close();
return; this._connection = null;
}
this._wss.close();
this._wss = null;
} }
} }
}); });