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

View File

@@ -32,6 +32,9 @@ importers:
date-fns:
specifier: ^3.6.0
version: 3.6.0
home-assistant-js-websocket:
specifier: ^9.4.0
version: 9.4.0
pinia:
specifier: ^2.3.0
version: 2.3.0(typescript@5.6.2)(vue@3.5.13(typescript@5.6.2))
@@ -114,9 +117,6 @@ importers:
vue-tsc:
specifier: ^2.1.10
version: 2.1.10(typescript@5.6.2)
websocketstream-polyfill:
specifier: ^1.0.1
version: 1.0.1
packages:
@@ -1310,6 +1310,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
home-assistant-js-websocket@9.4.0:
resolution: {integrity: sha512-312TuI63IfKf8G+iWvKmPYIdxWMNojwVk03o9OSpQFFDjSCNAYdCUfuPCFs73SuJ1Xpd4D1Eo11CB33MGMqZ+Q==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -2044,9 +2047,6 @@ packages:
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
websocketstream-polyfill@1.0.1:
resolution: {integrity: sha512-SV0AG8yN3YDwCVCex/oBQli7vrezGQ0stgo5suKMTthjPQv2wuMsNvwUWDAvNmDGGb9VIm5WsW99x57GW06gLQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@@ -3406,6 +3406,8 @@ snapshots:
he@1.2.0: {}
home-assistant-js-websocket@9.4.0: {}
ignore@5.3.2: {}
immutable@5.0.3: {}
@@ -4167,8 +4169,6 @@ snapshots:
webpack-virtual-modules@0.6.2: {}
websocketstream-polyfill@1.0.1: {}
whatwg-url@5.0.0:
dependencies:
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 { usePowerStore } from '@/stores/powerStore';
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();
weatherStore.start();
@@ -18,12 +24,61 @@
const homeAssistantStore = useHomeAssistantStore();
homeAssistantStore.start();
const calendarStore = useCalendarStore();
const currentTime = ref(new Date());
const calendarDays = ref([] as CalendarDay[]);
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' });
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>
<template>
@@ -108,19 +163,38 @@
class="kiosk-device-icon"
icon="mdi-shield-home" />
<div class="kiosk-device-text">
{{ capitalize(homeAssistantStore.houseAlarmState) }}
{{ alarmState(homeAssistantStore.houseAlarmState) }}
</div>
</div>
</div>
<div class="kiosk-content" v-if="Environment.getCalendarEmbedUrl()">
<div class="kiosk-calendar">
<iframe
:src="Environment.getCalendarEmbedUrl()"
style="border-width: 0"
width="100%"
height="100%"
frameborder="0"
scrolling="no"></iframe>
<div class="kiosk-content">
<div
class="kiosk-calendar"
v-if="calendarReady">
<div class="kiosk-calendar-header">
{{ 'Next ' + calendarDayCount + ' Days' }}
</div>
<ul class="kiosk-calendar-day-list">
<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>
</v-container>
@@ -131,7 +205,7 @@
height: 100%;
padding: 0;
background-color: #212428;
color: #1f1f1f;
color: #ebebeb;
display: grid;
grid-template-columns: 250px 1fr;
grid-template-rows: 1fr;
@@ -169,12 +243,15 @@
.kiosk-content {
height: 100%;
max-height: 100vh;
padding: 0;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(2, 1fr);
grid-auto-flow: row;
grid-template-areas: 'kiosk-calendar .';
grid-template-areas:
'kiosk-calendar . .'
'kiosk-calendar . .';
}
.kiosk-time {
@@ -231,6 +308,48 @@
.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 {

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 { WebSocketStream } from 'websocketstream-polyfill';
import { MessageType, AuthMessage, IncomingMessage, SubscribeEntitiesMessage, EventMessage } from '@/models/home-assistant/home-assistant';
import { createConnection, subscribeEntities, createLongLivedTokenAuth, Connection } from 'home-assistant-js-websocket';
import Environment from '@/environment';
export const useHomeAssistantStore = defineStore('home-assistant', {
@@ -9,7 +7,7 @@ export const useHomeAssistantStore = defineStore('home-assistant', {
return {
garageState: null as string | null,
houseAlarmState: null as string | null,
_wss: null as WebSocketStream | null
_connection: null as Connection | null
};
},
actions: {
@@ -21,72 +19,27 @@ export const useHomeAssistantStore = defineStore('home-assistant', {
const garageDevice = Environment.getGarageDevice();
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();
const writer = writable.getWriter();
while (true) {
const { value, done } = await reader.read();
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];
subscribeEntities(this._connection as Connection, entities => {
const garageEntity = entities[garageDevice];
if (garageEntity) {
this.$patch({ garageState: garageEntity.s });
this.$patch({ garageState: garageEntity.state });
}
const houseAlarmEntity = eventMessage.event.a[alarmDevice];
const houseAlarmEntity = entities[alarmDevice];
if (houseAlarmEntity) {
this.$patch({ houseAlarmState: houseAlarmEntity.s });
}
break;
case MessageType.result:
// Handle result type
break;
default:
// Handle unknown message type
break;
}
if (done) {
break;
}
this.$patch({ houseAlarmState: houseAlarmEntity.state });
}
});
},
async stop() {
if (!this._wss) {
return;
}
this._wss.close();
this._wss = null;
this._connection?.close();
this._connection = null;
}
}
});