Skip to content

Commit 9870829

Browse files
committed
autoupdate=false items follow what the device reports back
a tile on a vetoed item now clears as soon as the device answers, even when the answer changes nothing, instead of showing what you sent until a reload. a notice says when the device reports the opposite of the command, and sliders and color controls on those items no longer hide changes made elsewhere. e2e-floorplan waits for a real bulb to finish fading before it measures the glow.
1 parent aedf1e5 commit 9870829

20 files changed

Lines changed: 402 additions & 83 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ Anything not listed here yet is on `main` and ships with the next release.
66

77
## Unreleased
88

9+
## 1.37.0
10+
11+
- Tiles on `autoupdate=false` items show what the device reports back, not what you sent, without a
12+
reload.
13+
- A notice says when such a device reports the opposite of the command it was sent.
14+
- Sliders and color controls on those items no longer hide changes made elsewhere.
15+
916
## 1.36.0
1017

1118
- A dashboard no longer flickers between two sizes when its height lands close to the window's. The

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# neohab
77

88
A modern UI for [openHAB](https://www.openhab.org/).
9-
Modern web dashboards with mobile devices and tablets as first class citizens, built and configured entirely in the browser. **Status:** stable, latest release **1.36.0**. A community project, not an official openHAB UI.
9+
Modern web dashboards with mobile devices and tablets as first class citizens, built and configured entirely in the browser. **Status:** stable, latest release **1.37.0**. A community project, not an official openHAB UI.
1010

1111
<p float="center">
1212
<img src="imgs/1.png" width="400" />

‎e2e/e2e-button.mjs‎

Lines changed: 100 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
// exactly, so every state check here is written to tell those two rules apart.
44
// SAFE with a live config. Creates and deletes exactly: dashboard:nh-e2e-button,
55
// dashboard:nh-e2e-btnlegacy, dashboard:nh-e2e-btnface and dashboard:nh-e2e-noauto (neohab:config),
6-
// managed items nh_e2e_btn, nh_e2e_btnstr and nh_e2e_noauto - the last one carrying autoupdate
7-
// metadata of its own, which is deleted with the item.
6+
// managed items nh_e2e_btn, nh_e2e_btnstr, nh_e2e_noauto and nh_e2e_noautodim - the last two carrying
7+
// autoupdate metadata of their own, which is deleted with the item.
88
// It SAVES through the app once, on purpose - the migration has to be proved to write back - so it mints
99
// one version-history restore point, like any real edit.
1010
import { launchChromium } from './lib/browser.mjs'
@@ -18,6 +18,7 @@ const ACCENT = '#e0459a'
1818
const DIM = 'nh_e2e_btn'
1919
const STR = 'nh_e2e_btnstr'
2020
const NOAUTO = 'nh_e2e_noauto'
21+
const NOAUTO_DIM = 'nh_e2e_noautodim'
2122
const NOAUTO_UID = 'dashboard:nh-e2e-noauto'
2223
const HOLD_MS = 800 // comfortably past the 500ms threshold
2324
// The legacy dashboard below carries no version field at all, which reads as 1, and a save has to
@@ -879,26 +880,32 @@ try {
879880

880881
// ---- an item openHAB has promised not to update --------------------------------------------
881882
//
882-
// `autoupdate=false` means core's AutoUpdateManager takes the DONT branch: the command goes to
883-
// the binding and NOTHING is posted - no ItemStatePredictedEvent, no ItemStateChangedEvent.
884-
// Measured on a live 4.3.11 server as five OFF commands in a row with nothing behind any of them,
885-
// which leaves a toggle reading the state alone stuck on whatever the device last reported and
886-
// sending the same command for ever. The tile shows what was asked for instead, marked as
887-
// unconfirmed, and a real state update still wins.
883+
// `autoupdate=false` means core's AutoUpdateManager takes the DONT branch: the command goes to the
884+
// binding and openHAB posts no state for it. The device's own answer is the only thing that moves
885+
// the item, and a device can answer by confirming the state the item already had - an update that
886+
// is not a change, which the states tracker never carries. These items have no binding at all, so
887+
// a PUT to /state plays the device: it is the same ItemStateEvent a binding posts.
888888
{
889-
await fetch(itemUrl(NOAUTO), {
890-
method: 'PUT',
891-
headers: { ...AUTH, 'Content-Type': 'application/json' },
892-
body: JSON.stringify({ type: 'Switch', name: NOAUTO, label: 'NH E2E No Autoupdate' })
893-
})
894-
const meta = await fetch(itemUrl(NOAUTO) + '/metadata/autoupdate', {
895-
method: 'PUT',
896-
headers: { ...AUTH, 'Content-Type': 'application/json' },
897-
body: JSON.stringify({ value: 'false' })
898-
})
899-
ok('seed: an item that vetoes autoupdate', meta.status === 200 || meta.status === 201, 'metadata ' + meta.status)
900-
await sleep(600)
901-
await putState(NOAUTO, 'ON')
889+
const vetoed = []
890+
for (const [name, type, label, rest] of [
891+
[NOAUTO, 'Switch', 'NH E2E No Autoupdate', 'ON'],
892+
[NOAUTO_DIM, 'Dimmer', 'NH E2E No Autoupdate Dim', '40']
893+
]) {
894+
await fetch(itemUrl(name), {
895+
method: 'PUT',
896+
headers: { ...AUTH, 'Content-Type': 'application/json' },
897+
body: JSON.stringify({ type, name, label })
898+
})
899+
const meta = await fetch(itemUrl(name) + '/metadata/autoupdate', {
900+
method: 'PUT',
901+
headers: { ...AUTH, 'Content-Type': 'application/json' },
902+
body: JSON.stringify({ value: 'false' })
903+
})
904+
vetoed.push(meta.status)
905+
await sleep(300)
906+
await putState(name, rest)
907+
}
908+
ok('seed: two items that veto autoupdate', vetoed.every((s) => s === 200 || s === 201), 'metadata ' + vetoed.join(','))
902909
await sleep(400)
903910

904911
await fetch(NS, {
@@ -919,13 +926,20 @@ try {
919926
type: 'button',
920927
config: { item: NOAUTO, label: 'Veto', command: 'ON', commandAlt: 'OFF', toggle: true, style: 'switch' },
921928
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }
929+
},
930+
{
931+
id: 'w-nad',
932+
type: 'slider',
933+
config: { item: NOAUTO_DIM, label: 'Veto dim', style: 'plain', min: 0, max: 100, step: 1 },
934+
layout: { lg: { x: 3, y: 0, w: 4, h: 2 } }
922935
}
923936
]
924937
}
925938
})
926939
})
927940

928941
const sent = []
942+
const sentDim = []
929943
const na = await browser.newPage({ viewport: { width: 1200, height: 800 } })
930944
await na.addInitScript((t) => {
931945
try {
@@ -937,6 +951,10 @@ try {
937951
if (r.request().method() === 'POST') sent.push(r.request().postData())
938952
return r.continue()
939953
})
954+
await na.route('**/rest/items/' + NOAUTO_DIM, (r) => {
955+
if (r.request().method() === 'POST') sentDim.push(r.request().postData())
956+
return r.continue()
957+
})
940958
await na.goto(APP + '#/d/nh-e2e-noauto', { waitUntil: 'domcontentloaded' })
941959
await na.waitForSelector('.nh-switch', { timeout: 20000 })
942960
await sleep(1200)
@@ -951,31 +969,85 @@ try {
951969
})
952970
.catch(() => null)
953971

972+
// every notice as it appears, so one that has already faded is still counted
973+
await na.evaluate(() => {
974+
window.__nhToasts = []
975+
new MutationObserver(() => {
976+
for (const t of document.querySelectorAll('.nh-toast__text')) {
977+
if (!window.__nhToasts.includes(t.textContent)) window.__nhToasts.push(t.textContent)
978+
}
979+
}).observe(document.body, { childList: true, subtree: true, characterData: true })
980+
})
981+
const toasts = () => na.evaluate(() => window.__nhToasts ?? []).catch(() => [])
982+
const lastSent = () => sent.filter(Boolean).slice(-1)[0]
983+
954984
const start = await read()
955985
ok('it starts on the state the server reports', start?.on === true && start.asking === false, JSON.stringify(start))
956986

957987
await na.click('.nh-switch')
958988
await sleep(700)
959989
const afterOff = await read()
960-
ok('pressing it sends the alternate command', sent.filter(Boolean).slice(-1)[0] === 'OFF', JSON.stringify(sent))
990+
ok('pressing it sends the alternate command', lastSent() === 'OFF', JSON.stringify(sent))
961991
ok('the server posts no state for it at all', (await readState(NOAUTO)) === 'ON', await readState(NOAUTO))
962992
ok('but the tile shows what was asked for', afterOff?.on === false, JSON.stringify(afterOff))
963993
ok('and says it is unconfirmed rather than claiming it', afterOff?.asking === true, JSON.stringify(afterOff))
964994

965-
// past SETTLE_MS, which is what the old hold would have expired at
995+
// a device that never answers: past SETTLE_MS, and past the time an answer is given to settle
966996
await sleep(5000)
967997
const held = await read()
968-
ok('it still shows it after the settle window, because nothing can arrive to settle to', held?.on === false && held.asking === true, JSON.stringify(held))
998+
ok('with no answer at all it still shows what was asked for, because nothing can arrive to settle to', held?.on === false && held.asking === true, JSON.stringify(held))
969999

9701000
await na.click('.nh-switch')
9711001
await sleep(700)
972-
ok('so pressing again sends the other command instead of repeating', sent.filter(Boolean).slice(-1)[0] === 'ON', JSON.stringify(sent.filter(Boolean).slice(-3)))
1002+
ok('so pressing again sends the other command instead of repeating', lastSent() === 'ON', JSON.stringify(sent.filter(Boolean).slice(-3)))
9731003

974-
// a real state update is the one thing that wins
1004+
// told ON, the device reports the ON the item already had: no change, so only an update says so
1005+
await putState(NOAUTO, 'ON')
1006+
await sleep(1200)
1007+
const agreed = await read()
1008+
ok('an answer that changes nothing still ends the wait', agreed?.on === true && agreed.asking === false, JSON.stringify(agreed))
1009+
1010+
// what CHATAIGNE did before its link was fixed: told OFF, it reported ON
1011+
await na.click('.nh-switch')
1012+
await sleep(700)
1013+
ok('pressing it again sends OFF', lastSent() === 'OFF', JSON.stringify(sent.filter(Boolean).slice(-3)))
1014+
await putState(NOAUTO, 'ON')
1015+
await sleep(1200)
1016+
const refused = await read()
1017+
ok('when the device answers the opposite, the tile shows the answer, not the request', refused?.on === true && refused.asking === false, JSON.stringify(refused))
1018+
const refusal = `Sent OFF to ${NOAUTO}, but it reported ON`
1019+
let seen = false
1020+
for (let i = 0; i < 40 && !seen; i++) {
1021+
seen = (await toasts()).includes(refusal)
1022+
if (!seen) await sleep(250)
1023+
}
1024+
ok('and a notice says the device answered the opposite', seen, JSON.stringify(await toasts()))
1025+
1026+
// a real change still wins, and an answer that agrees is not reported
1027+
await na.click('.nh-switch')
1028+
await sleep(700)
1029+
ok('from ON, a press sends OFF', lastSent() === 'OFF', JSON.stringify(sent.filter(Boolean).slice(-3)))
9751030
await putState(NOAUTO, 'OFF')
9761031
await sleep(1500)
9771032
const confirmed = await read()
9781033
ok('a state the server does post wins and clears the mark', confirmed?.on === false && confirmed.asking === false, JSON.stringify(confirmed))
1034+
await sleep(4500)
1035+
const told = (await toasts()).filter((t) => t.includes(NOAUTO))
1036+
ok('only the answer that contradicted its command was reported', told.length === 1 && told[0] === refusal, JSON.stringify(told))
1037+
1038+
// a slider on a vetoed item used to hold what it sent for good, even over a change made elsewhere
1039+
const dim = na.locator('.nh-widget:has(.nh-widget__labeltext:text-is("Veto dim")) input[type="range"]')
1040+
const before = Number(await dim.inputValue().catch(() => NaN))
1041+
await dim.focus().catch(() => {})
1042+
await na.keyboard.press('ArrowUp')
1043+
await sleep(1200)
1044+
const heldDim = Number(await dim.inputValue().catch(() => NaN))
1045+
ok('a slider on a vetoed item sends its value', sentDim.filter(Boolean).slice(-1)[0] === String(before + 1), `${before} ${JSON.stringify(sentDim)}`)
1046+
ok('and shows it while nothing has answered', heldDim === before + 1, `${before} -> ${heldDim}`)
1047+
await putState(NOAUTO_DIM, '20')
1048+
await sleep(5000)
1049+
const movedDim = Number(await dim.inputValue().catch(() => NaN))
1050+
ok('a change made elsewhere still moves it', movedDim === 20, `${before} -> ${heldDim} -> ${movedDim}`)
9791051
await na.close().catch(() => {})
9801052
}
9811053

@@ -986,7 +1058,7 @@ try {
9861058
for (const uid of [UID, LEGACY_UID, FACE_UID, NOAUTO_UID]) {
9871059
await fetch(NS + '/' + encodeURIComponent(uid), { method: 'DELETE', headers: AUTH }).catch(() => {})
9881060
}
989-
for (const item of [DIM, STR, NOAUTO]) {
1061+
for (const item of [DIM, STR, NOAUTO, NOAUTO_DIM]) {
9901062
await fetch(itemUrl(item), { method: 'DELETE', headers: AUTH }).catch(() => {})
9911063
}
9921064
const left = await fetch(NS, { headers: AUTH })
@@ -995,7 +1067,7 @@ try {
9951067
.catch(() => ['<unreadable>'])
9961068
ok('cleanup: dashboards removed', left.length === 0, left.join(','))
9971069
const items = []
998-
for (const item of [DIM, STR, NOAUTO]) {
1070+
for (const item of [DIM, STR, NOAUTO, NOAUTO_DIM]) {
9991071
const there = await fetch(itemUrl(item), { headers: AUTH })
10001072
.then((r) => r.status === 200)
10011073
.catch(() => false)

‎e2e/e2e-floorplan.mjs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,22 @@ async function pollItem(name, want, tries = 20) {
4848
}
4949
return itemState(name)
5050
}
51-
// A hue on its own is not a settled colour: `pollItem(color, '0')` is satisfied by `0.000,92,100`,
52-
// which is a real DMX bulb part way through its fade, and the glow then reads 21 of green where the
53-
// section wants pure red. Wait for all three channels.
54-
async function pollColor(name, want, tries = 25) {
51+
// A colour is settled when all three channels match AND stay matched: openHAB's optimistic update
52+
// shows the target the instant the command lands, then a real DMX bulb reports its pre-fade colour
53+
// and fades back (321.4 -> 0 over 1.02s, measured). Returning on the first match loaded the page
54+
// mid-fade, inside the app's 1.5s hold, and the glow read the old colour.
55+
async function pollColor(name, want, stableMs = 2000, budgetMs = 15000) {
5556
const parts = want.split(',').map(Number)
56-
for (let i = 0; i < tries; i++) {
57+
const end = Date.now() + budgetMs
58+
let since = 0
59+
while (Date.now() < end) {
5760
const s = await itemState(name)
5861
const got = s.split(',').map(Number)
59-
if (got.length === parts.length && parts.every((v, j) => Math.abs(v - got[j]) <= 1)) return s
60-
await sleep(300)
62+
const match = got.length === parts.length && parts.every((v, j) => Math.abs(v - got[j]) <= 1)
63+
if (!match) since = 0
64+
else if (since === 0) since = Date.now()
65+
else if (Date.now() - since >= stableMs) return s
66+
await sleep(250)
6167
}
6268
return itemState(name)
6369
}

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>org.openhab.ui.bundles</groupId>
88
<artifactId>org.openhab.ui.neohab</artifactId>
9-
<version>1.36.0</version>
9+
<version>1.37.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>neohab</name>

‎web/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎web/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "neohab-web",
33
"private": true,
4-
"version": "1.36.0",
4+
"version": "1.37.0",
55
"license": "EPL-2.0",
66
"type": "module",
77
"engines": {

‎web/src/api/reports.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { ohUrl } from './base'
2+
3+
const OPEN_WAIT_MS = 1500
4+
// an item name is \w+ in openHAB, and anything else would make the server refuse the whole filter
5+
const ITEM_NAME = /^\w+$/
6+
7+
interface SseEnvelope {
8+
topic?: unknown
9+
payload?: unknown
10+
}
11+
12+
/**
13+
* Every state update for a few items, including the ones that change nothing. The states tracker
14+
* is fed from ItemStateChangedEvent alone, so a device that answers a command by confirming the
15+
* state it already had is only heard here.
16+
*/
17+
export class ReportStream {
18+
private source: EventSource | null = null
19+
private items = new Set<string>()
20+
private opened: Promise<void> = Promise.resolve()
21+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
22+
private reconnectDelay = 1000
23+
24+
constructor(private onReport: (item: string, value: string) => void) {}
25+
26+
/** listening for all of these, reopening if needed; settles once open or after a bound either way */
27+
listen(names: Iterable<string>): Promise<void> {
28+
const wanted = [...names].filter((n) => ITEM_NAME.test(n))
29+
if (this.source && wanted.every((n) => this.items.has(n))) return this.opened
30+
for (const n of wanted) this.items.add(n)
31+
this.connect()
32+
return this.opened
33+
}
34+
35+
close(): void {
36+
if (this.reconnectTimer) {
37+
clearTimeout(this.reconnectTimer)
38+
this.reconnectTimer = null
39+
}
40+
this.source?.close()
41+
this.source = null
42+
this.items.clear()
43+
}
44+
45+
private connect(): void {
46+
if (this.reconnectTimer) {
47+
clearTimeout(this.reconnectTimer)
48+
this.reconnectTimer = null
49+
}
50+
this.source?.close()
51+
this.source = null
52+
if (this.items.size === 0) return
53+
const topics = [...this.items].sort().map((n) => `openhab/items/${n}/state`)
54+
const source = new EventSource(ohUrl('/rest/events?topics=') + encodeURIComponent(topics.join(',')))
55+
this.source = source
56+
let settle = () => {}
57+
this.opened = new Promise<void>((resolve) => {
58+
const done = setTimeout(resolve, OPEN_WAIT_MS)
59+
settle = () => {
60+
clearTimeout(done)
61+
resolve()
62+
}
63+
})
64+
source.onopen = () => {
65+
this.reconnectDelay = 1000
66+
settle()
67+
}
68+
69+
source.onmessage = (e) => {
70+
if (!e.data) return
71+
try {
72+
const evt = JSON.parse(e.data as string) as SseEnvelope
73+
if (typeof evt.topic !== 'string' || typeof evt.payload !== 'string') return
74+
const parts = evt.topic.split('/')
75+
if (parts.length !== 4 || parts[0] !== 'openhab' || parts[1] !== 'items' || parts[3] !== 'state') return
76+
const payload = JSON.parse(evt.payload) as { value?: unknown }
77+
if (typeof payload.value === 'string') this.onReport(parts[2], payload.value)
78+
} catch {
79+
// ignore malformed frames
80+
}
81+
}
82+
83+
source.onerror = () => {
84+
// a stream the server refuses must not hold up the command that is waiting on it
85+
settle()
86+
source.close()
87+
if (this.source !== source) return
88+
this.source = null
89+
if (this.reconnectTimer || this.items.size === 0) return
90+
this.reconnectTimer = setTimeout(() => {
91+
this.reconnectTimer = null
92+
this.connect()
93+
}, this.reconnectDelay)
94+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30_000)
95+
}
96+
}
97+
}

0 commit comments

Comments
 (0)