Migrated DateTimeWeather and Calendar widgets

This commit is contained in:
Fabio Manganiello 2020-11-22 12:57:28 +01:00
parent ba8e5ef6a0
commit 62b651789a
14 changed files with 711 additions and 200 deletions

View File

@ -4,11 +4,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<script type="text/javascript">
window.config = JSON.parse(`{
"websocketPort": "{{ websocket_port }}"
}`)
</script>
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title> <title><%= htmlWebpackPlugin.options.title %></title>
</head> </head>

View File

@ -1,43 +1,43 @@
<template> <template>
<router-view /> <Events ref="events" :ws-port="config['backend.http'].websocket_port" v-if="config['backend.http']" />
<Notifications ref="notifications" /> <Notifications ref="notifications" />
<Events ref="events" /> <router-view />
</template> </template>
<script> <script>
import Notifications from "@/components/Notifications"; import Notifications from "@/components/Notifications";
import Utils from "@/Utils"; import Utils from "@/Utils";
import { bus } from '@/bus';
import Events from "@/Events"; import Events from "@/Events";
import { bus } from "@/bus";
export default { export default {
name: 'App', name: 'App',
components: {Events, Notifications}, components: {Events, Notifications},
mixins: [Utils], mixins: [Utils],
computed: { data() {
config() { return {
const cfg = { config: {},
websocketPort: 8009,
}
if (this._config.websocketPort && !this._config.websocketPort.startsWith('{{')) {
cfg.websocketPort = parseInt(this._config.websocketPort)
}
return cfg;
} }
}, },
methods: { methods: {
onNotification(notification) { onNotification(notification) {
this.$refs.notifications.create(notification) this.$refs.notifications.create(notification)
},
async initConfig() {
this.config = await this.request('config.get')
} }
}, },
created() {
this.initConfig()
},
mounted() { mounted() {
bus.on('notification-create', this.onNotification) bus.on('notification-create', this.onNotification)
} },
} }
</script> </script>

View File

@ -1,10 +1,15 @@
<template>
<div id="__platypush_events"/>
</template>
<script> <script>
import { bus } from "@/bus";
export default { export default {
name: "Events", name: "Events",
props: {
wsPort: {
type: Number,
default: 8009,
}
},
data() { data() {
return { return {
ws: null, ws: null,
@ -12,108 +17,144 @@ export default {
opened: false, opened: false,
timeout: null, timeout: null,
reconnectMsecs: 30000, reconnectMsecs: 30000,
handlers: [], handlers: {},
} }
}, },
methods: { methods: {
onWebsocketTimeout() {
return function() {
console.log('Websocket reconnection timed out, retrying')
this.pending = false
this.close()
this.onclose()
}
},
onMessage(event) {
const handlers = []
event = event.data
if (typeof event === 'string') {
try {
event = JSON.parse(event)
} catch (e) {
console.warn('Received invalid non-JSON event')
console.warn(event)
}
}
console.debug(event)
if (event.type !== 'event') {
// Discard non-event messages
return
}
if (null in this.handlers) {
handlers.push(this.handlers[null])
}
if (event.args.type in this.handlers) {
handlers.push(...this.handlers[event.args.type])
}
for (const [handler] of handlers) {
handler(event.args)
}
},
onOpen() {
if (this.opened) {
console.log("There's already an opened websocket connection, closing the newly opened one")
this.onclose = () => {}
this.close()
}
console.log('Websocket connection successful')
this.opened = true
if (this.pending) {
this.pending = false
}
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = undefined
}
},
onError(error) {
console.error('Websocket error')
console.error(error)
},
onClose(event) {
if (event) {
console.log('Websocket closed - code: ' + event.code + ' - reason: ' + event.reason)
}
this.opened = false
if (!this.pending) {
this.pending = true
this.init()
}
},
init() { init() {
try { try {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws' const protocol = location.protocol === 'https:' ? 'wss:' : 'ws'
const url = `${protocol}://${location.hostname}:${this.$root.config.websocketPort}` const url = `${protocol}://${location.hostname}:${this.wsPort}`
this.ws = new WebSocket(url) this.ws = new WebSocket(url)
} catch (err) { } catch (err) {
console.error('Websocket initialization error') console.error('Websocket initialization error')
console.log(err) console.error(err)
return return
} }
this.pending = true this.pending = true
this.timeout = setTimeout(this.onWebsocketTimeout, this.reconnectMsecs)
this.ws.onmessage = this.onMessage
this.ws.onopen = this.onOpen
this.ws.onerror = this.onError
this.ws.onclose = this.onClose
},
const onWebsocketTimeout = function(self) { subscribe(msg) {
return function() { const handler = msg.handler
console.log('Websocket reconnection timed out, retrying') const events = msg.events.length ? msg.events : [null]
this.pending = false
self.close() for (const event of events) {
self.onclose() if (!(event in this.handlers)) {
this.handlers[event] = []
} }
this.handlers[event].push(handler)
} }
},
this.timeout = setTimeout( unsubscribe(msg) {
onWebsocketTimeout(this.ws), this.reconnectMsecs) const handler = msg.handler
const events = msg.events.length ? msg.events : [null]
this.ws.onmessage = (event) => { for (const event of events) {
const handlers = [] if (!(event in this.handlers))
event = event.data continue
if (typeof event === 'string') { const idx = this.handlers[event].indexOf(handler)
try { if (idx < 0)
event = JSON.parse(event) continue
} catch (e) {
console.warn('Received invalid non-JSON event')
console.warn(event)
}
}
console.debug(event) this.handlers[event] = this.handlers[event].splice(idx, 1)[1]
if (event.type !== 'event') { if (!this.handlers[event].length)
// Discard non-event messages delete this.handlers[event]
return
}
if (null in this.handlers) {
handlers.push(this.handlers[null])
}
if (event.args.type in this.handlers) {
handlers.push(...this.handlers[event.args.type])
}
for (const handler of handlers) {
handler(event.args)
}
} }
},
this.ws.onopen = () => {
if (this.opened) {
console.log("There's already an opened websocket connection, closing the newly opened one")
this.onclose = () => {}
this.close()
}
console.log('Websocket connection successful')
this.opened = true
if (this.pending) {
this.pending = false
}
if (this.timeout) {
clearTimeout(this.timeout)
this.timeout = undefined
}
}
this.ws.onerror = (event) => {
console.error(event)
}
this.ws.onclose = (event) => {
if (event) {
console.log('Websocket closed - code: ' + event.code + ' - reason: ' + event.reason)
}
this.opened = false
if (!this.pending) {
this.pending = true
this.init()
}
}
}
}, },
mounted() { created() {
bus.on('subscribe', this.subscribe)
bus.on('unsubscribe', this.unsubscribe)
this.init() this.init()
}, },
} }

View File

@ -1,9 +1,12 @@
<script> <script>
import Api from "@/utils/Api"; import Api from "@/utils/Api";
import DateTime from "@/utils/DateTime";
import Events from "@/utils/Events";
import Notification from "@/utils/Notification"; import Notification from "@/utils/Notification";
import Types from "@/utils/Types";
export default { export default {
name: "Utils", name: "Utils",
mixins: [Api, Notification], mixins: [Api, Notification, Events, DateTime, Types],
} }
</script> </script>

View File

@ -0,0 +1,84 @@
<template>
<div class="sensor">
<div class="label-container col-6" v-if="iconClass || name">
<i :class="iconClass" v-if="iconClass" />
<span v-text="name" v-else-if="name" />
</div>
<div class="value-container col-6">
<span class="value" v-text="_value" />
</div>
</div>
</template>
<script>
export default {
name: "Sensor",
props: {
// The FontAwesome icon class to be used.
iconClass: {
type: String,
required: false,
},
// The name of the sensor metric.
name: {
type: String,
required: false,
},
// Sensor value.
value: {
required: false,
},
// Sensor unit.
unit: {
type: String,
required: false,
},
// Number of decimal units.
decimals: {
type: Number,
required: false,
default: 1,
},
// Set to true if the sensor holds a binary value.
isBoolean: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
_value() {
if (this.value == null)
return 'N/A'
if (this.isBoolean)
return this.parseBoolean(this.value)
let value = parseFloat(this.value)
if (this.decimals != null)
value = value.toFixed(this.decimals)
if (this.unit)
value = `${value}${this.unit}`
return value
},
}
}
</script>
<style lang="scss" scoped>
.label-container {
text-align: left;
}
.value-container {
text-align: right;
}
</style>

View File

@ -0,0 +1,14 @@
<script>
export default {
name: "DateTime",
methods: {
formatDate(date) {
return date.toDateString().substring(0, 10)
},
formatTime(date, seconds=true) {
return date.toTimeString().substring(0, seconds ? 8 : 5)
},
},
}
</script>

View File

@ -0,0 +1,15 @@
<script>
import { bus } from "@/bus";
export default {
name: "Events",
methods: {
subscribe(handler, ...events) {
bus.emit('subscribe', {
events: events,
handler: handler,
})
},
}
}
</script>

View File

@ -1,5 +1,5 @@
<script> <script>
import { bus } from '@/bus'; import { bus } from "@/bus";
export default { export default {
name: "Notification", name: "Notification",

View File

@ -0,0 +1,20 @@
<script>
export default {
name: "Types",
methods: {
parseBoolean(value) {
if (typeof value === 'string') {
value = value.toLowerCase()
if (value === 'true')
return true
if (value === 'false')
return false
return !!parseInt(value)
}
return !!value
},
},
}
</script>

View File

@ -0,0 +1,120 @@
<template>
<div class="calendar">
<Loading v-if="loading" />
<div class="no-events" v-else-if="!events.length">
No events found
</div>
<div class="event upcoming-event" v-else-if="events.length > 0">
<div class="date" v-text="formatDate(events[0].start)"></div>
<div class="summary" v-text="events[0].summary"></div>
<div class="time">
{{ formatTime(events[0].start, false) }} -
{{ formatTime(events[0].end, false) }}
</div>
</div>
<div class="event-list" v-if="events.length > 1">
<div class="event" v-for="event in events.slice(1, maxEvents)" :key="event.id">
<div class="date col-2" v-text="formatDate(event.start)"></div>
<div class="time col-2" v-text="formatTime(event.start, false)"></div>
<div class="summary col-8" v-text="event.summary"></div>
</div>
</div>
</div>
</template>
<script>
import Utils from "@/Utils";
import Loading from "@/components/Loading";
export default {
name: "Calendar",
components: {Loading},
mixins: [Utils],
props: {
// Maximum number of events to be rendered.
maxEvents: {
type: Number,
required: false,
default: 10,
},
// Refresh interval in seconds.
refreshSeconds: {
type: Number,
required: false,
default: 600,
},
},
data: function() {
return {
events: [],
loading: false,
}
},
methods: {
refresh: async function() {
this.loading = true
try {
this.events = (await this.request('calendar.get_upcoming_events')).map(event => {
if (event.start)
event.start = new Date(event.start.dateTime || event.start.date)
if (event.end)
event.end = new Date(event.end.dateTime || event.end.date)
return event
})
} finally {
this.loading = false
}
},
},
mounted: function() {
this.refresh()
setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))
},
}
</script>
<style lang="scss" scoped>
.calendar {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
.no-events {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75em;
}
.event {
font-size: .95em;
}
.event-list {
margin-top: 2em;
}
.upcoming-event {
text-align: center;
margin-bottom: .15em;
font-size: 1.2em;
.summary {
text-transform: uppercase;
font-size: 1.3em;
}
}
}
</style>

View File

@ -0,0 +1,78 @@
<template>
<div class="date-time">
<div class="date" v-text="formatDate(now)" v-if="_showDate" />
<div class="time" v-text="formatTime(now, _showSeconds)" v-if="_showTime" />
</div>
</template>
<script>
import Utils from "@/Utils";
// Widget to show date and time
export default {
name: 'DateTime',
mixins: [Utils],
props: {
// If false then don't display the date.
showDate: {
required: false,
default: true,
},
// If false then don't display the time.
showTime: {
required: false,
default: true,
},
// If false then don't display the seconds.
showSeconds: {
required: false,
default: true,
},
},
computed: {
_showTime() {
return this.parseBoolean(this.showTime)
},
_showDate() {
return this.parseBoolean(this.showDate)
},
_showSeconds() {
return this.parseBoolean(this.showSeconds)
},
},
data: function() {
return {
now: new Date(),
};
},
methods: {
refreshTime() {
this.now = new Date()
},
},
mounted: function() {
this.refreshTime()
setInterval(this.refreshTime, 1000)
},
}
</script>
<style lang="scss" scoped>
.date-time {
.date {
font-size: 1.3em;
}
.time {
font-size: 2em;
}
}
</style>

View File

@ -1,30 +1,28 @@
<template> <template>
<div class="date-time-weather"> <div class="date-time-weather">
<div class="date" v-text="formatDate(now)"></div> <div class="row date-time-container">
<div class="time" v-text="formatTime(now)"></div> <DateTime :show-date="_showDate" :show-time="_showTime" :animate="animate"
v-if="_showDate || _showTime" />
</div>
<h1 class="weather"> <div class="row weather-container">
<skycons :condition="weatherIcon" :paused="animPaused" :size="iconSize" v-if="weatherIcon" /> <Weather :show-summary="_showSummary" :animate="_animate" :icon-size="iconSize"
<span class="temperature" v-if="weather"> :refresh-seconds="weatherRefreshSeconds" v-if="showWeather"/>
{{ Math.round(parseFloat(weather.temperature)) + '&deg;' }} </div>
</span>
</h1>
<div class="summary" v-if="weather && weather.summary" v-text="weather.summary"></div> <div class="row sensors-container">
<div class="row" v-if="_showSensors && Object.keys(sensors).length">
<div class="col-3">
<Sensor icon-class="fas fa-thermometer-half" :value="sensors.temperature" unit="°"
v-if="sensors.temperature != null" />
</div>
<div class="sensors" v-if="Object.keys(sensors).length"> <div class="col-6">&nbsp;</div>
<div class="sensor temperature col-6" v-if="sensors.temperature">
<i class="fas fa-thermometer-half"></i> &nbsp;
<span class="temperature">
{{ parseFloat(sensors.temperature).toFixed(1) + '&deg;' }}
</span>
</div>
<div class="sensor humidity col-6" v-if="sensors.humidity"> <div class="col-3">
<i class="fa fa-tint"></i> &nbsp; <Sensor icon-class="fas fa-tint" :value="sensors.humidity" unit="%"
<span class="humidity"> v-if="sensors.humidity != null" />
{{ parseFloat(sensors.humidity).toFixed(1) + '%' }} </div>
</span>
</div> </div>
</div> </div>
</div> </div>
@ -32,88 +30,128 @@
<script> <script>
import Utils from "@/Utils"; import Utils from "@/Utils";
import Skycons from "vue-skycons" import DateTime from "@/widgets/DateTime/Index";
import Weather from "@/widgets/Weather/Index";
import Sensor from "@/components/Sensor";
// Widget to show date, time, weather and temperature information // Widget to show date, time, weather and temperature information
export default { export default {
name: 'DateTimeWeather', name: 'DateTimeWeather',
mixins: [Utils], mixins: [Utils],
components: {Skycons}, components: {Sensor, DateTime, Weather},
props: { props: {
// If false then the weather icon will be animated. // If false then the weather icon will be animated.
// Otherwise, it will be a static image. // Otherwise, it will be a static image.
paused: { animate: {
type: Boolean,
required: false, required: false,
default: false, default: false,
}, },
// Size of the weather icon in pixels // Size of the weather icon in pixels.
iconSize: { iconSize: {
type: Number, type: Number,
required: false, required: false,
default: 50, default: 50,
}, },
// If false then don't display the date.
showDate: {
required: false,
default: true,
},
// If false then don't display the time.
showTime: {
required: false,
default: true,
},
// If false then don't display the weather.
showWeather: {
required: false,
default: true,
},
// If false then the weather summary won't be displayed.
showSummary: {
required: false,
default: true,
},
// If false then temperature/humidity sensor data won't be shown.
showSensors: {
required: false,
default: true,
},
// Name of the attribute on a received SensorDataChangeEvent that
// represents the temperature value to be rendered.
sensorTemperatureAttr: {
type: String,
required: false,
default: 'temperature',
},
// Name of the attribute on a received SensorDataChangeEvent that
// represents the humidity value to be rendered.
sensorHumidityAttr: {
type: String,
required: false,
default: 'humidity',
},
// Weather refresh interval in seconds.
weatherRefreshSeconds: {
type: Number,
required: false,
default: 900,
},
}, },
computed: { computed: {
animPaused() { _showDate() {
return !!parseInt(this.paused) return this.parseBoolean(this.showDate)
},
_showTime() {
return this.parseBoolean(this.showTime)
},
_showWeather() {
return this.parseBoolean(this.showWeather)
},
_showSummary() {
return this.parseBoolean(this.showSummary)
},
_showSensors() {
return this.parseBoolean(this.showSensors)
},
_animate() {
return this.parseBoolean(this.animate)
}, },
}, },
data: function() { data: function() {
return { return {
weather: undefined,
sensors: {}, sensors: {},
now: new Date(),
weatherIcon: undefined,
}; };
}, },
methods: { methods: {
async refresh() {
const weather = (await this.request('weather.darksky.get_hourly_forecast')).data[0]
this.onWeatherChange(weather)
},
refreshTime() {
this.now = new Date()
},
formatDate(date) {
return date.toDateString().substring(0, 10)
},
formatTime(date) {
return date.toTimeString().substring(0, 8)
},
onWeatherChange(event) {
if (!this.weather)
this.weather = {}
this.weather = {...this.weather, ...event}
this.weatherIcon = this.weather.icon
},
onSensorData(event) { onSensorData(event) {
if ('temperature' in event.data) if (this.sensorTemperatureAttr in event.data)
this.sensors.temperature = event.data.temperature this.sensors.temperature = event.data.temperature
if ('humidity' in event.data) if (this.sensorHumidityAttr in event.data)
this.sensors.temperature = event.data.humidity this.sensors.humidity = event.data.humidity
}, },
}, },
mounted: function() { mounted() {
this.refresh() this.subscribe(this.onSensorData, 'platypush.message.event.sensor.SensorDataChangeEvent');
setInterval(this.refresh, 900000)
setInterval(this.refreshTime, 1000)
// TODO
// registerEventHandler(this.onWeatherChange, 'platypush.message.event.weather.NewWeatherConditionEvent')
// registerEventHandler(this.onSensorData, 'platypush.message.event.sensor.SensorDataChangeEvent')
}, },
} }
</script> </script>
@ -126,42 +164,27 @@ export default {
align-items: center; align-items: center;
padding-top: 0.1em; padding-top: 0.1em;
.date { .row {
font-size: 1.3em; text-align: center;
height: 10%;
} }
.time { .date-time-container {
font-size: 2em; height: 40%;
height: 14%;
} }
.weather { .weather-container {
height: 25%; height: 40%;
display: flex;
align-items: center;
margin-top: 15%;
.temperature {
font-size: 3.1em;
margin-left: 0.4em;
}
} }
.summary { .sensors-container {
height: 28%;
}
.sensors {
width: 100%; width: 100%;
height: 13%; height: 20%;
position: relative;
.sensor { .row {
padding: 0 0.1em; width: 100%;
} position: absolute;
bottom: 0;
.humidity {
text-align: right;
} }
} }
} }

View File

@ -0,0 +1,114 @@
<template>
<div class="weather">
<Loading v-if="loading" />
<h1 v-else>
<skycons :condition="weatherIcon" :paused="animate" :size="iconSize" v-if="weatherIcon" />
<span class="temperature" v-if="weather">
{{ Math.round(parseFloat(weather.temperature)) + '&deg;' }}
</span>
</h1>
<div class="summary" v-if="_showSummary && weather && weather.summary" v-text="weather.summary"></div>
</div>
</template>
<script>
import Utils from "@/Utils";
import Skycons from "vue-skycons"
import Loading from "@/components/Loading";
// Widget to show date, time, weather and temperature information
export default {
name: 'Weather',
mixins: [Utils],
components: {Loading, Skycons},
props: {
// If false then the weather icon will be animated.
// Otherwise, it will be a static image.
animate: {
required: false,
default: false,
},
// Size of the weather icon in pixels.
iconSize: {
type: Number,
required: false,
default: 50,
},
// If false then the weather summary won't be displayed.
showSummary: {
required: false,
default: true,
},
// Refresh interval in seconds.
refreshSeconds: {
type: Number,
required: false,
default: 900,
},
},
data: function() {
return {
weather: undefined,
weatherIcon: undefined,
loading: false,
};
},
computed: {
_showSummary() {
return this.parseBoolean(this.showSummary)
},
},
methods: {
async refresh() {
this.loading = true
try {
const weather = (await this.request('weather.darksky.get_hourly_forecast')).data[0]
this.onWeatherChange(weather)
} finally {
this.loading = false
}
},
onWeatherChange(event) {
if (!this.weather)
this.weather = {}
this.weather = {...this.weather, ...event}
this.weatherIcon = this.weather.icon
},
},
mounted: function() {
this.refresh()
this.subscribe(this.onWeatherChange, 'platypush.message.event.weather.NewWeatherConditionEvent')
setInterval(this.refresh, parseInt((this.refreshSeconds*1000).toFixed(0)))
},
}
</script>
<style lang="scss" scoped>
.weather {
display: flex;
flex-direction: column;
h1 {
display: flex;
align-items: center;
justify-content: center;
}
.temperature {
font-size: 3.1em;
margin-left: 0.4em;
}
}
</style>

View File

@ -15,7 +15,11 @@ export default {
.widget { .widget {
background: $background-color; background: $background-color;
border-radius: 5px; border-radius: 5px;
margin-bottom: 1em; margin: 0 1em 1em 0;
display: flex;
justify-content: center;
align-content: center;
position: relative;
overflow: hidden; overflow: hidden;
box-shadow: 0 3px 3px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08); box-shadow: 0 3px 3px 0 rgba(0,0,0,0.16), 0 0 0 1px rgba(0,0,0,0.08);
} }