Files

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) })
})
})
})()