239 lines
8.3 KiB
JavaScript
239 lines
8.3 KiB
JavaScript
/**
|
|
* zigbee_card -- six rows, drawn like every other dashboard card's list. No build step,
|
|
* no framework.
|
|
*
|
|
* On load the card asks the bot's door for "state" and draws every row from it: a
|
|
* toggle's title is the ACTION a press takes and the line under it says what is; the
|
|
* climate row is a reading (press = refresh); the feeder is one action behind a second
|
|
* tap. After a press the card waits for the bot's reply (the devices' read-back) and
|
|
* redraws from a fresh state. The reply's first line shows under the list for a moment.
|
|
*/
|
|
(function () {
|
|
'use strict'
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (!window.OCA || !OCA.Dashboard) {
|
|
return
|
|
}
|
|
OCA.Dashboard.register('zigbee_card', function (el) {
|
|
var buttons = OCP.InitialState.loadState('zigbee_card', 'buttons') || []
|
|
var url = OC.generateUrl('/apps/zigbee_card/cmd')
|
|
|
|
el.classList.add('zc')
|
|
var list = document.createElement('ul')
|
|
list.className = 'zc-list'
|
|
el.appendChild(list)
|
|
var note = document.createElement('div')
|
|
note.className = 'zc-note'
|
|
el.appendChild(note)
|
|
// the footer link, the other cards' "show more" shape; none without a configured url
|
|
var link = OCP.InitialState.loadState('zigbee_card', 'link') || {}
|
|
if (link.url) {
|
|
var more = document.createElement('a')
|
|
more.className = 'zc-more'
|
|
more.href = link.url
|
|
more.target = '_blank'
|
|
more.rel = 'noopener'
|
|
more.textContent = link.text || link.url
|
|
el.appendChild(more)
|
|
}
|
|
|
|
var state = null
|
|
var rows = {}
|
|
buttons.forEach(function (b) {
|
|
// a plain list item, NOT a <button>: the theme styles every button as a bold pill
|
|
// sized to its text, which is what the other cards' rows are not
|
|
var li = document.createElement('li')
|
|
var btn = document.createElement('div')
|
|
btn.className = 'zc-row'
|
|
btn.setAttribute('role', 'button')
|
|
btn.tabIndex = 0
|
|
var icon = document.createElement('span')
|
|
icon.className = 'zc-icon'
|
|
var text = document.createElement('span')
|
|
text.className = 'zc-text'
|
|
var title = document.createElement('span')
|
|
title.className = 'zc-title'
|
|
var sub = document.createElement('span')
|
|
sub.className = 'zc-sub'
|
|
text.appendChild(title)
|
|
text.appendChild(sub)
|
|
btn.appendChild(icon)
|
|
btn.appendChild(text)
|
|
var held = false, holdTimer = null
|
|
btn.addEventListener('click', function () { if (held) { held = false; return } onPress(b) })
|
|
btn.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPress(b) } })
|
|
if (b.hold) {
|
|
btn.addEventListener('pointerdown', function () {
|
|
held = false
|
|
holdTimer = setTimeout(function () {
|
|
held = true
|
|
if (el.classList.contains('zc-busy')) { return }
|
|
busy(true); say(b.hold + ' …')
|
|
post(b.hold).then(function (d) { say((d.reply || 'no answer').split('\n')[0], !d.ok); return refresh() }).catch(function (e) { say('no answer: ' + e, true) }).finally(function () { busy(false) })
|
|
}, 500)
|
|
})
|
|
var clr = function () { if (holdTimer) { clearTimeout(holdTimer); holdTimer = null } }
|
|
btn.addEventListener('pointerup', clr)
|
|
btn.addEventListener('pointerleave', clr)
|
|
btn.addEventListener('pointercancel', clr)
|
|
}
|
|
li.appendChild(btn)
|
|
list.appendChild(li)
|
|
rows[b.key] = { btn: btn, icon: icon, title: title, sub: sub }
|
|
})
|
|
|
|
function post(line) {
|
|
return fetch(url, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'requesttoken': OC.requestToken,
|
|
'OCS-APIREQUEST': 'true',
|
|
},
|
|
body: JSON.stringify({ line: line }),
|
|
}).then(function (r) {
|
|
return r.json().catch(function () { return { ok: false, reply: 'http ' + r.status } })
|
|
})
|
|
}
|
|
|
|
function side(b) {
|
|
// which side of a toggle applies to the current state
|
|
if (!state) {
|
|
return null
|
|
}
|
|
var v = b.key === 'ac' || b.key === 'heater' || b.key === 'tv' ? (state.plugs || {})[b.key] : state[b.key]
|
|
if (v === undefined) {
|
|
v = null
|
|
}
|
|
return v === b.on.when ? b.on : b.off
|
|
}
|
|
|
|
function has(v) {
|
|
return v !== null && v !== undefined
|
|
}
|
|
|
|
// a time from the bot (ISO with offset), rendered in Pacific. Node policy:
|
|
// human-facing is ALWAYS Pacific, never the viewer's clock (TIMEZONE.md).
|
|
// "10:59" today, "yesterday 10:59", else "Mon 10:59"
|
|
function when(iso) {
|
|
var TZ = 'America/Los_Angeles'
|
|
var d = new Date(iso)
|
|
var hm = d.toLocaleTimeString('en-GB', { timeZone: TZ, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) // the node is 24-hour
|
|
var day = function (x) { return x.toLocaleDateString('en-CA', { timeZone: TZ }) } // YYYY-MM-DD in Pacific
|
|
var now = new Date()
|
|
var yest = new Date(now.getTime() - 86400000)
|
|
if (day(d) === day(now)) {
|
|
return hm
|
|
}
|
|
if (day(d) === day(yest)) {
|
|
return 'yesterday ' + hm
|
|
}
|
|
return d.toLocaleDateString('en-US', { timeZone: TZ, weekday: 'short' }) + ' ' + hm
|
|
}
|
|
|
|
function draw() {
|
|
buttons.forEach(function (b) {
|
|
var r = rows[b.key]
|
|
if (b.kind === 'reading') {
|
|
// title: temp · humidity; the sensor reports Celsius, the card shows Fahrenheit.
|
|
// No feeding count: Bella is on the feeder's schedule.
|
|
var t = state && has(state.temp_c) ? Math.round(Number(state.temp_c) * 9 / 5 + 32) + ' °F' : '?'
|
|
var h = state && has(state.humidity) ? ' · ' + Math.round(state.humidity) + ' %' : ''
|
|
r.icon.textContent = b.icon
|
|
r.title.textContent = t + h
|
|
// under it: the door's last change, nothing else
|
|
var dr = state && state.door
|
|
r.sub.textContent = dr && has(dr.closed)
|
|
? (dr.at ? (dr.closed ? 'door closed ' : 'door open since ') + when(dr.at) : (dr.closed ? 'door closed' : 'door open'))
|
|
: b.now
|
|
} else if (b.kind === 'toggle') {
|
|
var s = side(b)
|
|
r.icon.textContent = (s && s.icon) || b.icon
|
|
r.title.textContent = s ? s.label : '…'
|
|
r.sub.textContent = s ? s.now : 'asking the house'
|
|
} else if (b.kind === 'cycle') {
|
|
r.icon.textContent = b.icon
|
|
var cs = state && state.scenes && state.scene_idx != null ? state.scenes[state.scene_idx] : null
|
|
r.title.textContent = cs || b.label
|
|
r.sub.textContent = b.now
|
|
} else if (b.kind === 'dimmer') {
|
|
r.icon.textContent = b.icon
|
|
r.title.textContent = b.label
|
|
r.sub.textContent = state && state.lights_on === false ? 'off · tap = on' : b.now
|
|
} else {
|
|
r.icon.textContent = b.icon
|
|
r.title.textContent = b.label
|
|
// an action row: the feeder showed when Bella was last fed; the tv row (2026-10-27) just says what it does
|
|
r.sub.textContent = b.key === 'feed' && state && state.last_fed ? 'last fed ' + when(state.last_fed) : b.now
|
|
}
|
|
})
|
|
}
|
|
|
|
function busy(on) {
|
|
list.querySelectorAll('.zc-row').forEach(function (x) { x.classList.toggle('zc-disabled', on); x.setAttribute('aria-disabled', on ? 'true' : 'false') })
|
|
el.classList.toggle('zc-busy', on)
|
|
}
|
|
|
|
function refresh() {
|
|
return post('state').then(function (d) {
|
|
state = d.ok && d.state ? d.state : null
|
|
if (!state) {
|
|
say(d.reply || 'no answer', true)
|
|
}
|
|
draw()
|
|
}).catch(function (e) {
|
|
state = null
|
|
say('no answer: ' + e, true)
|
|
draw()
|
|
})
|
|
}
|
|
|
|
var noteTimer = null
|
|
function say(text, err) {
|
|
note.textContent = text
|
|
note.classList.toggle('zc-err', !!err)
|
|
clearTimeout(noteTimer)
|
|
noteTimer = setTimeout(function () { note.textContent = '' }, 8000)
|
|
}
|
|
|
|
function onPress(b) {
|
|
if (el.classList.contains('zc-busy')) {
|
|
return
|
|
}
|
|
var line
|
|
if (b.kind === 'reading') {
|
|
busy(true)
|
|
refresh().finally(function () { busy(false) })
|
|
return
|
|
}
|
|
if (b.kind === 'toggle') {
|
|
var s = side(b)
|
|
if (!s) {
|
|
return
|
|
}
|
|
line = s.send
|
|
} else {
|
|
if (b.confirm && !window.confirm(b.icon + ' ' + b.label + ' — sure?')) {
|
|
return
|
|
}
|
|
line = b.send
|
|
}
|
|
busy(true)
|
|
say(line + ' …')
|
|
post(line).then(function (d) {
|
|
say((d.reply || 'no answer').split('\n')[0], !d.ok)
|
|
return refresh()
|
|
}).catch(function (e) {
|
|
say('no answer: ' + e, true)
|
|
}).finally(function () { busy(false) })
|
|
}
|
|
|
|
draw()
|
|
busy(true)
|
|
refresh().finally(function () { busy(false) })
|
|
})
|
|
})
|
|
})()
|