Files
morns-keyboard-basic/srcs/juloo.keyboard2/LightsProxy.java
T
Nyra 2ab0cd16d8 Lights page: three Home Assistant lights, lit/unlit glyph, state colours
Opened from the free fourth corner slot on the Alt key (switch_lights), closed
by the full-width Keyboard key. One key per light, registry-driven labels, so
the page renders whatever the lights-proxy reports rather than knowing any
entity ids. ON is blue, OFF is pink, anything unreachable is stale grey, and
the glyph flips between a lit lamp (0xE025) and an unlit twin (0xE026) so an
unreachable light can never look lit. Toggles are sent once and never retried.
2026-09-28 03:18:50 +00:00

748 lines
25 KiB
Java

package juloo.keyboard2;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
/** The lights page's data: the whitelisted lights, their states, and one press at a time.
*
* The endpoint is Harley's lights-proxy on this host's wg0 address - the same
* 10.10.10.1 that RokuRemote uses, already allowed by res/xml/network_security_config.xml. It is
* the only thing that talks to Home Assistant, so the whitelist, the polarity rules and the
* honesty rules live there and this class only has to report what it was told.
*
* Three rules, and they are the whole design:
* - every fetch happens on a worker thread with a short timeout, and failure is quiet: no VPN,
* no proxy, a non-2xx or a junk payload all end as "no data", which the page draws as the
* stale look. Never a crash, and never a guessed state.
* - a light is 'on', 'off', 'unavailable' or 'unknown', and only the first two are states the
* page may draw as a state. The other two - and no data at all - draw as stale, because a
* light nobody can reach must never look like a light that is switched off.
* - a toggle is sent once and never retried, and the answer is believed rather than a
* prediction. The proxy reports what Home Assistant said after the light settled as 'state'
* (null when it could not be read back) and what it expects as 'optimistic_state'; the
* prediction is never displayed.
*
* A light changes rarely, so the page reads at most once per POLL_MS while it is open. That is
* deliberately nothing like the usage gauge's 1 Hz: polling a lamp harder buys no truth.
*/
public final class LightsProxy
{
/** Harley's lights-proxy, on the WireGuard address. */
static final String ENDPOINT = "http://10.10.10.1:8772";
/** A light changes rarely: how often the page re-reads while it is visible. */
static final long POLL_MS = 15000L;
/** Opening the page or pressing a light may ask sooner than the poll, but not twice in a moment. */
static final long FORCE_FLOOR_MS = 2000L;
/** A snapshot older than this is never drawn as a live state, however the fetch went. */
static final long MAX_AGE_MS = 120000L;
/** How long the answer to a press stays on its key: long enough to read, short enough that an
old sentence cannot pass for the current state. */
static final long NOTE_MS = 60000L;
private static final int CONNECT_TIMEOUT_MS = 4000;
private static final int READ_TIMEOUT_MS = 5000;
/** A toggle waits for Home Assistant to settle (the proxy sleeps ~2.5 s, then reads back with
its own 4 s timeout); timing out sooner would drop an answer that was still on its way. */
private static final int TOGGLE_READ_TIMEOUT_MS = 12000;
/** A burst of presses must not spawn unbounded threads. The same bound RokuRemote keeps. */
private static final int MAX_IN_FLIGHT = 4;
/** What the page may show for a light. 'unavailable', 'unknown', a state we do not recognise
and no data at all are all UNKNOWN: nobody can see this light. */
public enum State { ON, OFF, UNKNOWN }
/** One light as the proxy last described it, with the answer to the last press on it. All of it
is immutable, so the worker thread can publish a whole new array and the draw path needs no
lock: it reads one volatile reference and some final fields. */
public static final class Light
{
public final String id;
public final String name;
public final String entityId;
public final State state;
/** What the last press on this light said - a refusal code, or that the state could not be
read back - or null. Drawn instead of the name while it is fresh. */
public final String note;
public final long noteAt;
Light(String id_, String name_, String entityId_, State state_, String note_, long noteAt_)
{
id = id_;
name = name_;
entityId = entityId_;
state = state_;
note = note_;
noteAt = noteAt_;
}
/** The note, or null when there is nothing to say or it has gone stale. */
public String noteOrNull(long now)
{
if (note == null)
return null;
return (now - noteAt) < NOTE_MS ? note : null;
}
}
/** The object the proxy answers /lights with, reduced to what the page needs. */
private static final Object _lock = new Object();
private static volatile Light[] _lights = new Light[0];
private static volatile long _fetchedAt = 0L;
/** True only after a read that succeeded. A failed read says so at once instead of leaving the
last numbers wearing a live face. */
private static volatile boolean _fresh = false;
/** Bumped when the *list* changes (a light added, removed or renamed), so the open page knows
it has to be rebuilt. States changing never bump it: those are just a repaint. */
private static volatile int _version = 0;
private static boolean _inFlight = false;
private static long _lastAttempt = 0L;
/** Bit per slot with a toggle in flight: a second press before the answer would flip the light
back, which is the same trap as a retry, so it is refused. */
private static int _pendingSlots = 0;
private static final AtomicInteger _inFlightCount = new AtomicInteger();
private LightsProxy() {}
// ------------------------------------------------------------------ what the page reads
/** The lights as last read. Never null; possibly empty, which means nobody has answered yet. */
public static Light[] lights()
{
return _lights;
}
/** The slot's light (LIGHT_<slot>), or null when the page has fewer lights than that. */
public static Light lightAt(int slot)
{
Light[] lights = _lights;
int i = slot - 1;
return (i < 0 || i >= lights.length) ? null : lights[i];
}
/** The state to draw for a slot: UNKNOWN whenever the page has no *current* answer, which is
what makes 'unavailable' and a dead proxy look the same - not a state. */
public static State stateAt(int slot)
{
Light light = lightAt(slot);
if (light == null || isStaleNow())
return State.UNKNOWN;
return light.state;
}
/** True when there is nothing to draw a state from: no read has succeeded, it failed, or what
we have is too old to call current. */
public static boolean isStaleNow()
{
return !_fresh || (System.currentTimeMillis() - _fetchedAt) >= MAX_AGE_MS;
}
/** Changes whenever the light *list* does. The open page compares it to know when to rebuild. */
public static int version()
{
return _version;
}
/** THE colour rule, and the only place it is written: ON is blue, OFF is pink, and everything
else - unavailable, unknown, no data - is the stale grey. The three colours themselves come
from the theme (res/values/themes.xml), so swapping on and off later is a one-line change
there and no key holds a colour of its own. */
public static int colorFor(State state, int onColor, int offColor, int staleColor)
{
if (state == null)
return staleColor; // no light at all: the same look as one nobody can see
switch (state)
{
case ON: return onColor;
case OFF: return offColor;
default: return staleColor;
}
}
/** 'on' and 'off' are states; everything else - including 'unavailable', 'unknown', a missing
field and anything we do not recognise - is UNKNOWN, never OFF. Pure, so it is tested. */
public static State stateOf(String raw)
{
if (raw == null)
return State.UNKNOWN;
String text = raw.trim().toLowerCase(Locale.US);
if (text.equals("on"))
return State.ON;
if (text.equals("off"))
return State.OFF;
return State.UNKNOWN;
}
// ------------------------------------------------------------------ fetching
/** Read the lights, at most once per POLL_MS; [force] is for opening the page and for the press
that follows, which are allowed to ask sooner but not in a burst. Never blocks the caller and
never throws: when a read is actually made, [onDone] runs on that worker thread afterwards. */
public static void fetchIfNeeded(boolean force, Runnable onDone)
{
long now = System.currentTimeMillis();
synchronized (_lock)
{
long floor = force ? FORCE_FLOOR_MS : POLL_MS;
if (_inFlight || (now - _lastAttempt) < floor)
return;
_inFlight = true;
_lastAttempt = now;
}
Thread worker = new Thread(() -> {
try
{
read(ENDPOINT + "/lights");
}
catch (Throwable t)
{
// One answer for every way a read can go wrong.
fail();
}
finally
{
synchronized (_lock) { _inFlight = false; }
runQuietly(onDone);
}
}, "lights-fetch");
worker.setDaemon(true);
worker.start();
}
private static void read(String url)
{
HttpURLConnection connection = null;
try
{
connection = (HttpURLConnection)new URL(url).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestProperty("Accept", "application/json");
StringBuilder body = new StringBuilder();
try (InputStream input = connection.getInputStream();
InputStreamReader reader = new InputStreamReader(input, "UTF-8"))
{
char[] buf = new char[4096];
int n;
while ((n = reader.read(buf)) > 0)
body.append(buf, 0, n);
}
Light[] parsed = parseLights(body.toString());
if (parsed == null)
{
fail(); // a payload we cannot read is no data at all
return;
}
publish(parsed);
}
catch (Exception e)
{
// No VPN, no route, proxy down, refused, a non-2xx like the no-token 200-with-nothing and
// an unreadable stream: one answer for all of them.
fail();
}
finally
{
if (connection != null)
connection.disconnect();
}
}
private static void fail()
{
_fresh = false;
}
private static void publish(Light[] lights)
{
synchronized (_lock)
{
if (!sameList(_lights, lights))
_version++;
/* The notes travel with the lights so a rebuild keeps them, but a real state from the box
supersedes the answer to a press: once we know what the light is doing, the last
sentence about it is no longer the best thing to show. */
Light[] merged = new Light[lights.length];
for (int i = 0; i < lights.length; i++)
{
merged[i] = mergeNote(lights[i], _lights);
}
_lights = merged;
_fetchedAt = System.currentTimeMillis();
_fresh = true;
}
}
/** A fresh reading of the same light keeps a still-useful note; anything else replaces it. */
static Light mergeNote(Light fresh, Light[] previous)
{
if (fresh.note != null || fresh.state != State.UNKNOWN)
return fresh;
for (int i = 0; i < previous.length; i++)
{
Light old = previous[i];
if (old.id.equals(fresh.id) && old.note != null)
return new Light(fresh.id, fresh.name, fresh.entityId, fresh.state, old.note, old.noteAt);
}
return fresh;
}
private static boolean sameList(Light[] a, Light[] b)
{
if (a.length != b.length)
return false;
for (int i = 0; i < a.length; i++)
if (!a[i].id.equals(b[i].id) || !a[i].name.equals(b[i].name))
return false;
return true;
}
// ------------------------------------------------------------------ pressing a light
/** Toggle one light (1-based slot). Sent once, never retried: a retried toggle on a light that
did change flips it straight back. [onDone] runs on the worker thread, twice at most - once
with the answer to this press, once when the follow-up read lands. */
public static void toggle(int slot, Runnable onDone)
{
Light light = lightAt(slot);
if (light == null || !claimSlot(slot))
return;
final String id = light.id;
Thread worker = new Thread(() -> {
ToggleResult result = null;
try
{
result = sendToggle(id);
}
catch (Throwable t)
{
result = null;
}
finally
{
releaseSlot(slot);
/* What the press said, painted at once: a refusal or an unreadable read-back is the
honest thing to show, and waiting for a poll would hide it for seconds. */
reply(slot, result);
runQuietly(onDone);
/* And then a read: the answer above is about this one light and the page should not keep
a stale view of the rest. Floored like a page open, so a burst of presses cannot become
a burst of reads, and the proxy caches reads for 9 s anyway. */
fetchIfNeeded(true, onDone);
}
}, "lights-toggle");
worker.setDaemon(true);
worker.start();
}
/** Reserves the slot and one in-flight allowance, or refuses. Bounded like RokuRemote, and one
press per light at a time: a second press before the answer would flip it back. */
private static boolean claimSlot(int slot)
{
synchronized (_lock)
{
int bit = 1 << (slot - 1);
if ((_pendingSlots & bit) != 0)
return false;
if (_inFlightCount.incrementAndGet() > MAX_IN_FLIGHT)
{
_inFlightCount.decrementAndGet();
return false;
}
_pendingSlots |= bit;
return true;
}
}
private static void releaseSlot(int slot)
{
synchronized (_lock)
{
_pendingSlots &= ~(1 << (slot - 1));
}
_inFlightCount.decrementAndGet();
}
private static ToggleResult sendToggle(String id) throws Exception
{
HttpURLConnection connection = null;
try
{
String url = ENDPOINT + "/lights/" + URLEncoder.encode(id, "UTF-8") + "/toggle";
connection = (HttpURLConnection)new URL(url).openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(TOGGLE_READ_TIMEOUT_MS);
connection.setRequestProperty("Accept", "application/json");
connection.setDoOutput(true);
connection.setFixedLengthStreamingMode(0); // a POST with no body: the proxy ignores it
int code = connection.getResponseCode();
boolean ok = code >= 200 && code < 300;
String body = readAll(ok ? connection.getInputStream() : connection.getErrorStream());
/* Both halves matter: a 503 carries the reason (no token yet, a light Home Assistant cannot
read) and a 200 carries the read-back. An unreadable body is still an answer. */
ToggleResult result = parseToggle(body);
return result != null ? result : ToggleResult.unreadable();
}
finally
{
if (connection != null)
connection.disconnect();
}
}
private static String readAll(InputStream input) throws Exception
{
if (input == null)
return null;
StringBuilder body = new StringBuilder();
try (InputStreamReader reader = new InputStreamReader(input, "UTF-8"))
{
char[] buf = new char[2048];
int n;
while ((n = reader.read(buf)) > 0)
body.append(buf, 0, n);
}
return body.toString();
}
/** What the press did to the light: the read-back, which is the only thing shown, and what the
proxy said when it could not give one. */
static void reply(int slot, ToggleResult result)
{
if (result == null)
result = ToggleResult.unreadable();
synchronized (_lock)
{
Light[] current = _lights;
int i = slot - 1;
if (i < 0 || i >= current.length)
return;
Light old = current[i];
State state = old.state;
if (result.accepted)
state = result.state == null ? State.UNKNOWN : stateOf(result.state);
String note = result.note();
if (note != null)
note = LightsPage.ellipsize(note, LightsPage.MAX_NAME_CHARS);
Light[] next = current.clone();
next[i] = new Light(old.id, old.name, old.entityId, state, note,
System.currentTimeMillis());
_lights = next; // the list itself is unchanged: no rebuild, the view just repaints
}
}
private static void runQuietly(Runnable r)
{
if (r == null)
return;
try
{
r.run();
}
catch (Throwable t)
{
// A repaint request must never take the keyboard down.
}
}
// ------------------------------------------------------------------ parsing
/** The lights of a GET /lights payload, or null when it cannot be read: a page that invents an
empty list would be claiming there are no lights. Pure, so it is tested. */
public static Light[] parseLights(String json)
{
Object root = Json.parse(json);
if (!(root instanceof Map))
return null;
Object raw = ((Map<?, ?>)root).get("lights");
if (!(raw instanceof List))
return null;
ArrayList<Light> out = new ArrayList<Light>();
for (Object item : (List<?>)raw)
{
if (!(item instanceof Map))
continue;
Map<?, ?> m = (Map<?, ?>)item;
String id = asString(m.get("id"));
String name = asString(m.get("name"));
if (id == null || name == null || name.trim().isEmpty())
continue; // a light with no name has no key on the page
String entity = asString(m.get("entity_id"));
out.add(new Light(id, name, entity == null ? "" : entity,
stateOf(asString(m.get("state"))), null, 0L));
}
return out.toArray(new Light[out.size()]);
}
/** What one toggle said. [state] is Home Assistant's read-back after the light settled, and the
only field the page may display as a state; [optimisticState] is the proxy's prediction and
is deliberately not kept here. Pure, so it is tested. */
public static final class ToggleResult
{
public final boolean accepted;
public final String state;
public final boolean confirmed;
public final Boolean changed;
public final String warning;
public final String code;
ToggleResult(boolean accepted_, String state_, boolean confirmed_, Boolean changed_,
String warning_, String code_)
{
accepted = accepted_;
state = state_;
confirmed = confirmed_;
changed = changed_;
warning = warning_;
code = code_;
}
static ToggleResult unreadable()
{
return new ToggleResult(false, null, false, null, null, "no_answer");
}
/** The one short line the key shows instead of the name, or null when the answer was a state
the page can show. It is the proxy's own vocabulary - its error code - or the fact that
the state could not be read back; the long prose warning does not fit a key. */
public String note()
{
if (!accepted)
return code == null ? "refused" : code;
if (state == null || !confirmed)
return "unconfirmed";
return null;
}
}
/** What a POST /lights/<id>/toggle answered, or null when the body cannot be read. Pure. */
public static ToggleResult parseToggle(String json)
{
Object root = Json.parse(json);
if (!(root instanceof Map))
return null;
Map<?, ?> m = (Map<?, ?>)root;
Object accepted = m.get("accepted");
String state = asString(m.get("state"));
String code = error_code(m);
if (accepted == null && state == null && code == null)
return null; // a body that says neither yes, nor no, nor what it saw is no answer
return new ToggleResult(asBool(accepted), state, asBool(m.get("confirmed")),
asBoolean(m.get("changed")), asString(m.get("warning")), code);
}
/** Why a toggle was refused: the service's own error code - from the nested error object its
toggle answer documents, or the flat "code" a refusal carries - and only the prose when there
is no code at all. A key has room for the vocabulary, not for a sentence. Null when the
service did not refuse. */
private static String error_code(Map<?, ?> m)
{
Object err = m.get("error");
if (err instanceof Map)
{
String nested = asString(((Map<?, ?>)err).get("code"));
if (nested != null)
return nested;
}
String code = asString(m.get("code"));
return code != null ? code : asString(err);
}
static String asString(Object v)
{
return (v instanceof String) ? (String)v : null;
}
static boolean asBool(Object v)
{
return (v instanceof Boolean) && ((Boolean)v).booleanValue();
}
static Boolean asBoolean(Object v)
{
return (v instanceof Boolean) ? (Boolean)v : null;
}
/** A deliberately small JSON reader: objects, arrays, strings, numbers, booleans and null.
*
* Android's org.json is a stub under the local JVM test runner, which is why HostGraph and this
* class parse their own payloads: pure Java, so the tests can drive the real parser over the
* real payloads. Only what the lights endpoints answer with is supported, and anything it
* cannot read becomes "no data" rather than a guess. */
static final class Json
{
private final String _s;
private int _i = 0;
private Json(String s) { _s = s; }
/** Map/List/String/Double/Boolean/null, or null when the text cannot be read. */
static Object parse(String text)
{
if (text == null)
return null;
Json p = new Json(text);
try
{
Object v = p.value();
p.ws();
return p._i == p._s.length() ? v : null;
}
catch (Exception e)
{
return null;
}
}
private void ws()
{
while (_i < _s.length() && Character.isWhitespace(_s.charAt(_i)))
_i++;
}
private Object value() throws Exception
{
ws();
if (_i >= _s.length())
throw new Exception("eof");
char c = _s.charAt(_i);
switch (c)
{
case '{': return object();
case '[': return array();
case '"': return string();
case 't': expect("true"); return Boolean.TRUE;
case 'f': expect("false"); return Boolean.FALSE;
case 'n': expect("null"); return null;
default: return number();
}
}
private Map<String, Object> object() throws Exception
{
Map<String, Object> out = new java.util.LinkedHashMap<String, Object>();
_i++; // '{'
ws();
if (peek() == '}')
{
_i++;
return out;
}
while (true)
{
ws();
if (peek() != '"')
throw new Exception("object key");
String key = string();
ws();
if (peek() != ':')
throw new Exception("object colon");
_i++;
out.put(key, value());
ws();
char c = peek();
_i++;
if (c == '}')
return out;
if (c != ',')
throw new Exception("object comma");
}
}
private List<Object> array() throws Exception
{
List<Object> out = new ArrayList<Object>();
_i++; // '['
ws();
if (peek() == ']')
{
_i++;
return out;
}
while (true)
{
out.add(value());
ws();
char c = peek();
_i++;
if (c == ']')
return out;
if (c != ',')
throw new Exception("array comma");
}
}
private String string() throws Exception
{
StringBuilder b = new StringBuilder();
_i++; // '"'
while (true)
{
if (_i >= _s.length())
throw new Exception("string eof");
char c = _s.charAt(_i++);
if (c == '"')
return b.toString();
if (c != '\\')
{
b.append(c);
continue;
}
if (_i >= _s.length())
throw new Exception("escape eof");
char e = _s.charAt(_i++);
switch (e)
{
case '"': b.append('"'); break;
case '\\': b.append('\\'); break;
case '/': b.append('/'); break;
case 'b': b.append('\b'); break;
case 'f': b.append('\f'); break;
case 'n': b.append('\n'); break;
case 'r': b.append('\r'); break;
case 't': b.append('\t'); break;
case 'u':
if (_i + 4 > _s.length())
throw new Exception("unicode escape eof");
b.append((char)Integer.parseInt(_s.substring(_i, _i + 4), 16));
_i += 4;
break;
default: throw new Exception("escape");
}
}
}
private Double number() throws Exception
{
int start = _i;
while (_i < _s.length() && "+-.eE0123456789".indexOf(_s.charAt(_i)) >= 0)
_i++;
if (_i == start)
throw new Exception("number");
return Double.valueOf(_s.substring(start, _i));
}
private char peek() throws Exception
{
if (_i >= _s.length())
throw new Exception("eof");
return _s.charAt(_i);
}
private void expect(String word) throws Exception
{
if (!_s.startsWith(word, _i))
throw new Exception("expected " + word);
_i += word.length();
}
}
}