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.
748 lines
25 KiB
Java
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();
|
|
}
|
|
}
|
|
}
|