From 439923d6d45a5091e25902093da23276e767a4eb Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Sun, 16 Aug 2026 16:53:46 +0200 Subject: [PATCH 1/6] Add optional local network URL with automatic fallback Lets you set a LAN address for your Home Assistant instance alongside the existing URL. The bridge tries the local address first on every connection attempt and falls back to the primary URL when it isn't reachable (e.g. away from home). The local URL shares the primary URL's stored token rather than needing its own, since it's the same instance reached by a different address. --- AGENTS.md | 8 ++- ConfigStore.js | 6 +- Connection.js | 12 +++- README.md | 6 ++ Service.qml | 29 ++++++++-- Settings.qml | 56 ++++++++++++++++++- bin/hass-bridge | 116 +++++++++++++++++++++++++++++---------- tests/test_bridge.py | 65 ++++++++++++++++++++++ tests/test_config.js | 10 ++++ tests/test_connection.js | 13 +++++ 10 files changed, 281 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2bc620c..a8ecd65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,13 @@ to `Service.qml`. - Scope credentials to a normalized server origin. Changing origin must never silently reuse a credential. Credential deletion must target an explicit origin and must not remove a saved live credential as a side effect of demo - mode. + mode. The optional local-network URL (`localUrl`) is a deliberate, narrow + exception: it is an alternate address for the same instance the primary URL + already names, not a second server, so it intentionally shares the primary + origin's stored token rather than getting its own keyring entry. Do not add + separate credential storage for it, and do not let it participate in + `currentOrigin()`/`requiresTokenFor()` — those stay scoped to the primary + URL only. - Treat `http://` and `ws://` as plaintext transport. Any UI path that permits them must make the token-exposure risk explicit; never downgrade an invalid or unknown scheme to plaintext. diff --git a/ConfigStore.js b/ConfigStore.js index e1d2d2d..2c457c9 100644 --- a/ConfigStore.js +++ b/ConfigStore.js @@ -1,7 +1,7 @@ .pragma library var KEYS = [ - "baseUrl", "demoMode", "favorites", "demoFavorites", "groupByArea", + "baseUrl", "localUrl", "demoMode", "favorites", "demoFavorites", "groupByArea", "showEntityIcons", "selectedTab", "displayNameOverrides", "iconOverrides" ] @@ -48,6 +48,10 @@ function parse(text, demoDefaults) { error: error, config: { baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : "", + // Optional. An alternate address for the same Home Assistant instance + // — a LAN address, say — tried first when reachable. It shares baseUrl's + // credential; it is never a separate keyring origin. + localUrl: typeof raw.localUrl === "string" ? raw.localUrl : "", demoMode: raw.demoMode === true, favorites: stringList(raw.favorites, []), demoFavorites: stringList(raw.demoFavorites, diff --git a/Connection.js b/Connection.js index fcd02aa..d18f2f9 100644 --- a/Connection.js +++ b/Connection.js @@ -76,10 +76,18 @@ function normalizeOrigin(value) { // // Empty when the URL cannot be normalized; callers treat that as invalid // rather than as a connection worth starting. -function signature(demoMode, value) { +// +// localValue is optional: an alternate address for the same instance (a LAN +// address, tried first when reachable). It shares value's credential origin, +// so it is folded into the signature only to restart the bridge when it +// changes — it never contributes an origin of its own. +function signature(demoMode, value, localValue) { if (demoMode) return "demo" var origin = normalizeOrigin(value) - return origin ? origin + "|" + String(value || "").trim() : "" + if (!origin) return "" + var text = origin + "|" + String(value || "").trim() + var local = String(localValue || "").trim() + return local ? text + "|" + local : text } function acceptsGeneration(activeGeneration, eventGeneration) { diff --git a/README.md b/README.md index 0ca1485..68ea3a0 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,12 @@ your profile → Security), or flip on **Demo mode** to try the panel against a built-in fake house with no instance at all. Then switch to **Devices** and star the ones you want in the panel. +Optionally, add a **Local network URL** — your instance's LAN address. It's +tried first on every connection attempt and used when it answers, with the +URL above as fallback (for example when a laptop is away from home). It's the +same Home Assistant instance reached by a different address, so it reuses the +one access token above rather than needing its own. + ## Debugging ```bash diff --git a/Service.qml b/Service.qml index e311835..6326daa 100644 --- a/Service.qml +++ b/Service.qml @@ -27,6 +27,12 @@ QtObject { property bool configured: false property bool demoMode: false property string baseUrl: "" + // Optional alternate address for the same instance — a LAN address, say — + // the bridge tries first when reachable. Shares baseUrl's credential; never + // its own keyring origin. See Connection.signature and CredentialManager. + property string localUrl: "" + // True only while connected through localUrl rather than baseUrl. + property bool usingLocal: false property int connectionGeneration: 0 property bool connectionSuppressed: false @@ -82,6 +88,7 @@ QtObject { function currentConfig() { return { baseUrl: root.baseUrl, + localUrl: root.localUrl, demoMode: root.demoMode, favorites: root.liveFavorites.slice(), demoFavorites: root.demoFavorites.slice(), @@ -197,7 +204,7 @@ QtObject { function finishRemoveConnection() { root.connectionSuppressed = false root.saveConfig({ - baseUrl: "", demoMode: false, favorites: [], + baseUrl: "", localUrl: "", demoMode: false, favorites: [], displayNameOverrides: {}, iconOverrides: {}, selectedTab: "favorites" }) // demoFavorites untouched: not part of the connection } @@ -230,13 +237,21 @@ QtObject { root.reconcileConnection() } - function applyConnection(url, token, demo) { + function applyConnection(url, localUrl, token, demo) { var origin = demo ? "demo" : Connection.normalizeOrigin(url) if (!origin) { root.phase = "error" root.lastError = "Enter a valid http(s) or ws(s) Home Assistant URL." return false } + // Optional, and validated the same way, but blank is always fine — it + // just means no local fallback. + var trimmedLocal = String(localUrl || "").trim() + if (!demo && trimmedLocal && !Connection.normalizeOrigin(trimmedLocal)) { + root.phase = "error" + root.lastError = "Enter a valid http(s) or ws(s) local network URL, or leave it blank." + return false + } if (!demo && !token && root.requiresTokenFor(url)) { root.phase = "error" root.lastError = "A new Home Assistant origin requires a new token." @@ -244,13 +259,14 @@ QtObject { } root.connectionSuppressed = false // Start the serialized write before applyConfig runs so reconciliation - // cannot race a lookup of the previous credential. + // cannot race a lookup of the previous credential. The local URL is never + // its own keyring origin: it shares whatever is stored for `origin`. if (!demo && token.length > 0 && !credentials.store(token, origin)) { root.phase = "error" root.lastError = "Could not start token storage while the keyring is busy." return false } - root.saveConfig({ baseUrl: url, demoMode: demo }) + root.saveConfig({ baseUrl: url, localUrl: demo ? "" : trimmedLocal, demoMode: demo }) return true } @@ -275,6 +291,7 @@ QtObject { root.demoMode = config.demoMode root.baseUrl = config.baseUrl + root.localUrl = config.localUrl root.liveFavorites = config.favorites root.demoFavorites = config.demoFavorites root.displayNameOverrides = config.displayNameOverrides @@ -328,7 +345,7 @@ QtObject { // Connection.js owns this rule, so the definition of "same connection" // cannot drift from the one the tests pin. - var signature = Connection.signature(root.demoMode, root.baseUrl) + var signature = Connection.signature(root.demoMode, root.baseUrl, root.localUrl) if (!signature) { root.phase = "error" root.lastError = "Home Assistant URL is invalid." @@ -406,6 +423,7 @@ QtObject { root.send({ op: "config", url: root.baseUrl, + localUrl: root.localUrl, token: token, generation: root.connectionGeneration }) @@ -654,6 +672,7 @@ QtObject { root.phase = transition.state.phase root.lastError = transition.state.error root.lastErrorKind = transition.state.errorKind + root.usingLocal = transition.state.phase === "connected" && event.usingLocal === true break case "states": root.applyStates(event.entities || []) diff --git a/Settings.qml b/Settings.qml index db1ca44..36a7864 100644 --- a/Settings.qml +++ b/Settings.qml @@ -25,6 +25,7 @@ Item { // Local until Connect, so a half-typed URL never reaches the bridge. property string urlDraft: "" + property string localUrlDraft: "" property string tokenDraft: "" property string query: "" @@ -88,6 +89,7 @@ Item { function resetDrafts() { if (!service) return root.urlDraft = service.baseUrl + root.localUrlDraft = service.localUrl // The stored token never comes back to screen; blank means "keep it". root.tokenDraft = "" root.query = "" @@ -100,7 +102,8 @@ Item { function applyConnection() { if (!service) return - if (service.applyConnection(root.urlDraft.trim(), root.tokenDraft, false)) { + if (service.applyConnection(root.urlDraft.trim(), root.localUrlDraft.trim(), + root.tokenDraft, false)) { root.tokenDraft = "" } } @@ -248,7 +251,10 @@ Item { readonly property bool needsToken: root.service ? root.service.requiresTokenFor(root.urlDraft.trim()) : true readonly property bool validUrl: Connection.normalizeOrigin(root.urlDraft) !== "" - readonly property bool canConnect: validUrl && !keyringBusy + // Blank is fine — it just means no local fallback. + readonly property bool validLocalUrl: root.localUrlDraft.trim().length === 0 + || Connection.normalizeOrigin(root.localUrlDraft) !== "" + readonly property bool canConnect: validUrl && validLocalUrl && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) Column { @@ -288,6 +294,48 @@ Item { } } + Column { + width: connectionColumn.width + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "Local network URL (optional)" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + TextField { + width: connectionColumn.width + text: root.localUrlDraft + placeholderText: "https://192.168.1.50:8123" + onTextChanged: root.localUrlDraft = text + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + text: "Tried first when reachable — e.g. your instance's LAN address. Falls back to the URL above otherwise. Uses the same access token." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0 + text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } + Column { width: connectionColumn.width spacing: Style.spacing.sm @@ -404,7 +452,9 @@ Item { if (!root.service.configured) return "Not connected" switch (root.service.phase) { case "connected": - return (root.service.demoMode ? "Demo running · " : "Connected · ") + return (root.service.demoMode ? "Demo running · " + : root.service.usingLocal ? "Connected (local network) · " + : "Connected · ") + Object.keys(root.service.states).length + " devices" case "connecting": return root.service.lastError ? "Connecting… · " + root.service.lastError diff --git a/bin/hass-bridge b/bin/hass-bridge index ccfeb7a..f84de2b 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -12,10 +12,14 @@ writes commands on our stdin and reads events from our stdout. Commands (stdin) {"protocolVersion":1,"op":"config","generation":7, - "url":"https://ha.local:8123","token":"..."} + "url":"https://ha.local:8123","localUrl":"https://192.168.1.50:8123", + "token":"..."} Connect, or reconnect with new credentials. The token arrives here rather than in argv because /proc//cmdline is readable by every - process running as this user. + process running as this user. localUrl is optional: an alternate + address for the same instance (a LAN address, say). Every connection + attempt tries it first with a short timeout and falls back to url on + failure — it shares url's token rather than needing its own. {"op":"call_service","domain":"light","service":"turn_on", "entity_id":"light.x","data":{"brightness_pct":40},"tag":"..."} {"op":"refresh"} re-request the full state snapshot @@ -25,7 +29,9 @@ Commands (stdin) Events (stdout) Every event includes protocolVersion and generation. {"ev":"phase","phase":"idle|connecting|connected|error", - "error":"...","errorKind":"connection|synchronization|credential|protocol"} + "error":"...","errorKind":"connection|synchronization|credential|protocol", + "usingLocal":true} usingLocal is present and true only when phase is + "connected" and the connection went through localUrl {"ev":"states","entities":[...]} full snapshot, replaces the model {"ev":"state_changed","entity":{...}} single entity changed {"ev":"removed","entity_id":"..."} entity disappeared @@ -104,6 +110,13 @@ PROTOCOL_VERSION = 1 BACKOFF_START = 1.0 BACKOFF_MAX = 30.0 +# How long a local-network candidate gets before falling back to the primary +# URL. Short: the whole point is that a laptop off the home network should not +# sit there waiting out the normal OPEN_TIMEOUT before trying the URL that can +# actually answer. A closed port refuses well inside this anyway; the timeout +# only matters for an address nothing answers at all. +LOCAL_CONNECT_TIMEOUT = 2.0 + def now(): return time.monotonic() @@ -126,9 +139,10 @@ class WebSocketError(Exception): class WsTransport: kind = "live" - def __init__(self, url, verify_tls=True): + def __init__(self, url, verify_tls=True, open_timeout=OPEN_TIMEOUT): self.url = url self.verify_tls = verify_tls + self.open_timeout = open_timeout self.ws = None def connect(self): @@ -143,7 +157,7 @@ class WsTransport: ssl=ssl_context, proxy=None, compression=None, - open_timeout=OPEN_TIMEOUT, + open_timeout=self.open_timeout, close_timeout=CLOSE_TIMEOUT, ping_interval=PING_INTERVAL, ping_timeout=PING_TIMEOUT, @@ -508,6 +522,8 @@ class Bridge: self.demo = demo self.verify_tls = verify_tls self.url = "" + self.local_url = "" + self.using_local = False self.token = "" self.generation = 0 self.transport_epoch = 0 @@ -569,6 +585,8 @@ class Bridge: return self.phase = phase payload = {"ev": "phase", "phase": phase} + if phase == "connected": + payload["usingLocal"] = self.using_local if error: payload["error"] = self.safe_error(error) payload["errorKind"] = error_kind @@ -640,6 +658,8 @@ class Bridge: def apply_config(self, command): raw_url = command.get("url") or "" url = raw_url.strip() if isinstance(raw_url, str) else "" + raw_local_url = command.get("localUrl") or "" + local_url = raw_local_url.strip() if isinstance(raw_local_url, str) else "" token = command.get("token") or "" if not isinstance(token, str): token = "" @@ -651,6 +671,7 @@ class Bridge: next_generation = self.generation + 1 if (next_generation == self.generation and url == self.url + and local_url == self.local_url and token == self.token and self.transport is not None): return @@ -659,6 +680,7 @@ class Bridge: self.disconnect() self.generation = next_generation self.url = url + self.local_url = local_url self.token = token self.want_connect = self.demo or bool(url and token) self.backoff = BACKOFF_START @@ -676,6 +698,7 @@ class Bridge: self.generation += 1 self.want_connect = False self.url = "" + self.local_url = "" self.token = "" self.set_phase("idle", force=True) @@ -834,35 +857,71 @@ class Bridge: def connect(self): self.set_phase("connecting") - try: - if self.demo: + if self.demo: + try: self.transport = DemoTransport() - else: - self.transport = WsTransport( - self.websocket_url(), verify_tls=self.verify_tls) - except (WebSocketError, ValueError) as exc: - # A URL we cannot even parse will not start working on its own, so - # retrying it just spins. Wait for the shell to send a better one. - # ValueError is belt and braces: websocket_url already converts the - # ones urllib raises, and this loop must not be able to die on a - # parse error the shell cannot see and cannot restart us from. - self.transport = None - self.set_phase("error", self.safe_error(exc)) - self.want_connect = False + self.transport.connect() + except Exception as exc: + self.transport = None + self.schedule_retry(self.explain(exc)) + return + self.using_local = False + self._finish_connect() return - try: - self.transport.connect() - except Exception as exc: - self.transport = None - self.schedule_retry(self.explain(exc)) + # The local URL, if any, is always tried first — the network can have + # changed since the last attempt (a laptop rejoining home wifi) — and + # falls back to the primary URL on any failure. A local candidate that + # does not even parse is skipped rather than fatal: it is optional, so + # a typo there should not stop the primary URL from being tried. + candidates = [] + if self.local_url: + candidates.append((self.local_url, True, LOCAL_CONNECT_TIMEOUT)) + candidates.append((self.url, False, OPEN_TIMEOUT)) + + last_exc = None + for raw_url, is_local, open_timeout in candidates: + try: + ws_url = self.websocket_url(raw_url) + except (WebSocketError, ValueError) as exc: + # ValueError is belt and braces: websocket_url already converts + # the ones urllib raises, and this loop must not be able to die + # on a parse error the shell cannot see and cannot restart us + # from. + last_exc = exc + if is_local: + continue + # The primary URL we cannot even parse will not start working + # on its own, so retrying it just spins. Wait for the shell to + # send a better one. + self.transport = None + self.set_phase("error", self.safe_error(exc)) + self.want_connect = False + return + + transport = WsTransport( + ws_url, verify_tls=self.verify_tls, open_timeout=open_timeout) + try: + transport.connect() + except Exception as exc: + last_exc = exc + continue + + self.transport = transport + self.using_local = is_local + self._finish_connect() return + + self.transport = None + self.schedule_retry(self.explain(last_exc)) + + def _finish_connect(self): self.transport_epoch += 1 self.authenticated = False self.subscription_ready = False self.snapshot_ready = False - def websocket_url(self): + def websocket_url(self, raw_url): # Every parse step below can raise ValueError on input the settings # overlay was willing to accept: urlsplit rejects an unbalanced bracket # ("https://ha.local:8123]") with "Invalid IPv6 URL", and .hostname and @@ -870,14 +929,14 @@ class Bridge: # any of those kills the whole bridge process for what is really just a # typo, so the entire parse is funnelled into WebSocketError. try: - parts = urllib.parse.urlsplit(self.url) + parts = urllib.parse.urlsplit(raw_url) # "homeassistant.local:8123" parses as scheme "homeassistant.local" # with no netloc, and "//homeassistant.local:8123" as a netloc with # no scheme. Both are natural things to type, and both need the # same repair. Assume TLS: guessing http would quietly put the # token on the wire in the clear. if not parts.scheme or not parts.netloc: - parts = urllib.parse.urlsplit("https://" + self.url.lstrip("/")) + parts = urllib.parse.urlsplit("https://" + raw_url.lstrip("/")) scheme = WS_SCHEMES.get(parts.scheme) if scheme is None: # Never fall back to ws:// here. A typo like "htps://" would then @@ -885,7 +944,7 @@ class Bridge: raise WebSocketError( "unsupported scheme %r — use http:// or https://" % parts.scheme) if not parts.hostname: - raise WebSocketError("no host in URL: %s" % self.url) + raise WebSocketError("no host in URL: %s" % raw_url) if parts.username or parts.password: raise WebSocketError( "credentials aren't allowed in the Home Assistant URL") @@ -905,6 +964,7 @@ class Bridge: except Exception: pass self.transport = None + self.using_local = False self.authenticated = False self.subscription_ready = False self.snapshot_ready = False diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 3572be8..3bec9b0 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -629,6 +629,68 @@ def test_oversized_frame_is_refused(): server.stop() +def test_local_url_is_preferred_when_reachable(): + print("local: a reachable local URL is used ahead of the primary URL") + local = FakeHA() + bridge = BridgeProc() + try: + # Port 1 refuses instantly, so a successful connection can only have + # gone through localUrl. + bridge.send({"op": "config", "url": "http://127.0.0.1:1", + "localUrl": local.url, "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected", connected is not None) + check("flags the connection as local", + connected is not None and connected.get("usingLocal") is True, + connected) + check("the local server saw the connection", local.connections >= 1, + local.connections) + finally: + bridge.stop() + local.stop() + + +def test_falls_back_to_primary_when_local_is_unreachable(): + print("local: an unreachable local URL falls back to the primary URL") + server = FakeHA() + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, + "localUrl": "http://127.0.0.1:1", "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected", connected is not None) + check("does not flag the connection as local", + connected is not None and connected.get("usingLocal") is False, + connected) + check("the primary server saw the connection", server.connections >= 1, + server.connections) + finally: + bridge.stop() + server.stop() + + +def test_unparseable_local_url_does_not_block_the_primary(): + print("local: a bad local URL is skipped, not fatal") + # The local URL is optional; a typo there must not stop the primary URL + # — the one the user actually confirmed — from being tried at all. + server = FakeHA() + bridge = BridgeProc() + try: + bridge.send({"op": "config", "url": server.url, + "localUrl": "https://192.168.1.50:8123]", "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("still reaches connected via the primary URL", connected is not None) + check("does not flag the connection as local", + connected is not None and connected.get("usingLocal") is False, + connected) + finally: + bridge.stop() + server.stop() + + def test_demo_needs_no_server(): print("demo: runs standalone and drifts on its own") bridge = BridgeProc("--demo") @@ -702,6 +764,9 @@ def main(): test_wss_certificate_policy, test_invalid_websocket_message_is_controlled, test_fragment_flood_hits_size_limit, + test_local_url_is_preferred_when_reachable, + test_falls_back_to_primary_when_local_is_unreachable, + test_unparseable_local_url_does_not_block_the_primary, test_demo_needs_no_server): test() print() diff --git a/tests/test_config.js b/tests/test_config.js index 0970c49..912f310 100644 --- a/tests/test_config.js +++ b/tests/test_config.js @@ -38,6 +38,7 @@ const parsed = Config.parse(JSON.stringify({ }), ["light.demo"]); eq("typed values are normalized", parsed.config, { baseUrl: "", + localUrl: "", demoMode: true, favorites: ["light.a"], demoFavorites: [], @@ -48,12 +49,21 @@ eq("typed values are normalized", parsed.config, { iconOverrides: {} }); +const withLocal = Config.parse(JSON.stringify({ + baseUrl: "https://ha.example.com", + localUrl: 7 +}), []); +eq("a non-string localUrl falls back to empty", withLocal.config.localUrl, ""); + const merged = Config.merge(parsed.config, { groupByArea: true, + localUrl: "https://192.168.1.50:8123", token: "must-not-be-serialized", unknown: "ignored" }); eq("known keys merge", merged.groupByArea, true); +eq("localUrl merges like any other known key", + merged.localUrl, "https://192.168.1.50:8123"); eq("unknown and secret keys are dropped", merged.token, undefined); eq("serialized config has one trailing newline", Config.serialize(merged).endsWith("}\n"), true); diff --git a/tests/test_connection.js b/tests/test_connection.js index 7301fef..3d7669c 100644 --- a/tests/test_connection.js +++ b/tests/test_connection.js @@ -75,6 +75,19 @@ eq("an unusable URL has no signature", Connection.signature(false, "ftp://ha.local"), ""); eq("a bracket typo has no signature", Connection.signature(false, "https://ha.local:8123]"), ""); +// The local URL shares the primary URL's credential origin — it must never +// appear as, or affect, the credential scope — but it does have to change the +// signature so reconcileConnection restarts the bridge when it is edited. +eq("a blank local URL does not change the signature", + Connection.signature(false, "https://ha.local:8123", ""), + Connection.signature(false, "https://ha.local:8123")); +eq("a local URL is folded into the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123"), + "https://ha.local:8123|https://ha.local:8123|https://192.168.1.50:8123"); +eq("changing only the local URL changes the signature", + Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123") + === Connection.signature(false, "https://ha.local:8123", "https://192.168.1.51:8123"), + false); eq("matching generation is accepted", Connection.acceptsGeneration(4, 4), true); eq("old generation is rejected", Connection.acceptsGeneration(5, 4), false); eq("missing generation is rejected", Connection.acceptsGeneration(5, undefined), false); From c172fd08e9e23d0e0db955fef04ff5ba406805c1 Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Sun, 16 Aug 2026 17:05:58 +0200 Subject: [PATCH 2/6] Collapse the local network URL field behind a toggle Two always-visible URL fields, each with its own plaintext warning, was more chrome than most users need for an optional field. Reuses the Demo mode toggle's visual pattern: collapsed by default, expands to reveal the field, starts expanded only if a local URL is already saved. Collapsing clears the draft so "off" means no local URL rather than a hidden stale value. --- Settings.qml | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/Settings.qml b/Settings.qml index 36a7864..0505e3b 100644 --- a/Settings.qml +++ b/Settings.qml @@ -26,6 +26,10 @@ Item { // Local until Connect, so a half-typed URL never reaches the bridge. property string urlDraft: "" property string localUrlDraft: "" + // Collapsed unless a local URL is already saved: most people never need + // this field, and a second always-visible URL box with its own warning text + // outweighs the value of surfacing it up front. + property bool localUrlExpanded: false property string tokenDraft: "" property string query: "" @@ -90,6 +94,7 @@ Item { if (!service) return root.urlDraft = service.baseUrl root.localUrlDraft = service.localUrl + root.localUrlExpanded = service.localUrl.length > 0 // The stored token never comes back to screen; blank means "keep it". root.tokenDraft = "" root.query = "" @@ -298,15 +303,24 @@ Item { width: connectionColumn.width spacing: Style.spacing.sm - Text { - textFormat: Text.PlainText - text: "Local network URL (optional)" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall + // Ui/Toggle: label + description + switch, row owns the click. + Toggle { + width: connectionColumn.width + label: "Local network URL" + description: "Try a LAN address first, e.g. your instance's local IP, before falling back to the URL above. Uses the same access token." + checked: root.localUrlExpanded + foreground: root.foreground + fontFamily: root.family + onClicked: { + root.localUrlExpanded = !root.localUrlExpanded + // Collapsing means "no local URL" — a hidden stale draft would + // otherwise still reach applyConnection. + if (!root.localUrlExpanded) root.localUrlDraft = "" + } } TextField { + visible: root.localUrlExpanded width: connectionColumn.width text: root.localUrlDraft placeholderText: "https://192.168.1.50:8123" @@ -316,18 +330,9 @@ Item { Text { textFormat: Text.PlainText width: connectionColumn.width - text: "Tried first when reachable — e.g. your instance's LAN address. Falls back to the URL above otherwise. Uses the same access token." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - - Text { - textFormat: Text.PlainText - width: connectionColumn.width - visible: root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 - || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0 + visible: root.localUrlExpanded + && (root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0) text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." color: Color.muted font.family: root.family From 57e798280b512d3976cc4bd2bdc2d739e42270de Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Sun, 16 Aug 2026 17:11:20 +0200 Subject: [PATCH 3/6] Add screenshot of the local network URL toggle for the PR --- .../screenshots/settings-local-network-url.png | Bin 0 -> 112251 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/settings-local-network-url.png diff --git a/docs/screenshots/settings-local-network-url.png b/docs/screenshots/settings-local-network-url.png new file mode 100644 index 0000000000000000000000000000000000000000..a89584f35759a05c9ad625a8d3d289fcbb347edf GIT binary patch literal 112251 zcmeFZbx>U0(?3W;0widHJ3)hMa1Aa&g9mpV++h+(kl^la!Gqht1b4UK?mGA|%*^sU z-}kq>zpdKZzjn83_pO<#d#9(*IrrY~)2B~&e{O`jsyy~Nc+*b@8Vd!@PbesUj3_8yzoVc$JdwU0prCkip`aX@qM!(;p`Z}EWVLCC zKK+4at}HKug8b)`*HN1IL_zmZ{3MHhgn@^Pi5>0lsPRN%@Q~H>ur%|q61H%&dOD!+ zaPjc7a|yC@^MB_4Ak4)l%)`aX#U;$eWlQMA^FK9k0$AEv`~IIdAXONTd1~dc>VYMgZ6!&6B2H}RY;&${O6mX>H-ju{I4Gk4p=@6|B~sM6-h6Y{zKMG z;Mqw24|fs0)gRXXFeoYxf7SgTog_W0`40m<12d}ee=#U&qJjR4A*txUJO1xS@vn;f zw^sardxE?^KSi^h@Y|@>z4gNXFRM}Hd8-(9a?0qKO89?e5!|rWaMJ?=NAdn+MC<@c zO+rb&+Gp;7YZewchi^t8ai2@luQB5vP-Dl=>$nP zv=Vd;&4#r9=$_K^&5l(R#UX z4`1{Xbb_rwcF@3s)#l)}z=wp~&SsGvF$K;dklM=*n2vhREO{OIcenRvGqG!dtOwd@ zL)8=--$oNW2V~b@E4g{Wm+WpYEYd#5OQB#CVekB*lTYkuhROejx{j@=PC4s&le9WOC22ie(Z#B9ZuaWDyZ9l`NQJ9`Ojv!=aO)bz_^OhE+tGf`|eSWrs3q;_jQhbif z8t6Go_>gl23}OPd+?)p-Uwt@k>$Qcs=v_(ohLcIgbN2}%Qs+8BvgNz(kCCum8 z(wfrdJ60wBx#Xh23~4?EYLC_)Qjz!Q=E~{9bkPv}5mG+0_lpysXF$!%P4#a*LD2RI zNV2@VI#spVVQ?{@SI2}C2HJwQZZ#6QLr8;%AxWb!sT}wsa*uk^Ednc zf?Qh;)VsPSX?<~B#q{&z7E|Tc6yIbL=nq3UB<-buEI&hLtMon^g}wYl&Hv4XNQl(c zAagn5^tAsy^ON^P?UIg)Yib*Wl99^oV2#$pcw1n4A9Vc*_(O=&zumK)X03b47f|a3 zXqH}*Hvcq|F5n4Wa}K3foaTQq^qxvmcHPZQOW5v9dz4l32M<4OztkUoxug5w8&|Xv zsfA1A1?1o0qlNB5GG$lD}w0s}*)6?wsdm(H*6-6eo4+^wPit;wI5nJ?%4=5OW zvAac>oSSKz(ADwuw=%~zmpiAxtukmd^+S8?bXacB%hDKnvB|E2Fu+!PtJC>IhN(TH z-LVK?T8g4Q2G_=FRj^g*JlJ&%37suATbv_A$Xce%wp?A-*| zfB7neyEZIhmAe<57#2@?Vmao}>;iARn-V>A>%7{XAxXn8s&E!Ywr?^$9*-U60CF)b zCmk*apgT+x=18f&qOj+2;cx5=x=!}bH>*VuBcT4f&HhMrQo80~L432>+ZkDY%!a z_;b=gp^XzxHv}_2^TfzIF$M*iH%v>5D)2f1pOakRR(!M0^tO@0CL-&;BiH>bys|1( z8l#u4((i~Y$Q3qANm9rsbGxxEcvnyC)_dXR#xsL~{_f3jL1FRFKF-x4O7F*5Ax}d` zg(a8qM}(T7lLF(nn^Vl9{rG2bAL7-FfYkfWu4v>KeBAK8kyc3G5!D0#kKlMF_cPht zo!@4=mZhb5So8`q%VmZSXlm=8n7ANNxf?-<#*oME7EVxj4&jh*)U% zE5fq$#VdN+hR>Zt+s*J8e`8}Q%PVZNjYZw7X!p=sGvP)_uIv+)uWWM07wFE2gA_nU zp50OOm#gE_vPJx%FI-^=`G5>H;1+#o@?Ly;6XNfYeQB}vGl1v?(^-%QOXIRwEq0>B z5uf-6hg@MWnoz9GmV zseC`EYr;7&F=Y&=R_&_eJWG0=YA;%)fpaI`1o0J}$Z`LgV>TAQ6Q(RK%$Qgr;JlUE zJZ^IUHQ7&OQq5yy{ej|2iyi56^-aQMIS`;;-$5W@ff5Eha>xR1$qd(PVbE{MknKT+ z!N7_2+@EgUchwu;pS>%m0{^UV&K#bX(0*Q&{Ap_^l4Cmw{*aiPEv58cl9q~L+F)S# z5Z86$)cdOcr%un4laQdH1dM6DAEA(Vj~b3|SDjzWn+@vzqKVn>c3EsfdzpFcX>r4hKx6EPQuO%H-~kWV(Hav$_)bSHF#bo0zrRB8k}hEHeB$$4Y%8r5AZ=K- zpD0t{)g3`;V7!TVUBEd($E@fuwu#Befw&P{HbnIjyZBdN@Ut3m$>)}_9ckytgO751 zXafGCNhxaQt~M?Z&e*MscHo~6&EQWn)G?7sK%1HQ0Lk2;gYVxLw!AM4OJ)k+E4?`T zm_%_ySB=?8FJBn>=jZh4rbYdM9j?}H8LvxsgKp>bd$jv`YaEe(3OcDS;`_{+x1{xwxU_ zdzC+%q`fM)tdL=|;0GJs67uC|U79$Ehx69Q`%Xh@L+13I8?p(A2`GLD9U0GB0}7En z6|15Tk)m$Rpboe-ecgld$~+#YAfIoVXN1ISwm1NA$t@amDgeEz(9YCOc)|0NnVCJm zv~2QY`VRstXSh#MCybkfrZF!np*w-fngu%{#c*`q*C|U+6&L6xy-1LhqCPugvTcj6 zpYILRz@shv9aRvFr8$h|)=SK}S15nSkQM$Wk_qEwNYNhd?&QRmiOFvZE`2w#wN0h< z^HK3x?z@2doxdy2#9Ggb2%>g=DOVQ*YJjy3c}d;Dc=1^-vD<1kF%J|HokzS_BL+xE zS}9SgFv+xrEKM{@FYfhh55CyP6Nks$=q21h&sciJA;!RqDGELsR9hQ}G##4Nd(EJ$?mfbkW5m7nbg3;V7=DDCrkU9N z)xCPZT=~{w5+eWmJ~q{LXhu2WMmQVrc+{@Kk@lxH40k8JoyemC17`+SbPg$=2}^im zTYA}=3EeiKG7EANiJ1NJKymSk13nFn;>a3>u0Mz>gCPzjr(GlN`XM-`F2*QJDO?U3 zsPrG%0&HGpqen4%|3zF+_{ETlA)aBXd)%kC9t;|PKg_#alyDna&o$LB5}A^Tn_c;) z^6*!n^}ZpVK&35eWIUsv(3|sb0grGdcP=PP3(ZQh@@JX1@x(e+j-0mgcOR}Fqn8m` z0gKIo-r%>P0Xxe9=1avZJjHfb*7QedH-7-o?Ziyg=(o&PD&lv~Z#h?M)4QI_%DZkd z3}}&sjuWa~POKx-iHSbXtR>T=Wyr6GaBS3ZX7x-y4h3vgP+QG618g9nmkWOlN>yMb6Y`@;U<*?j{ zC%*a-sg@74eupmswTkh*$`oybY)L0AH6wXs62(FVM!cr-vEq)d^-z+_9)D@lkrv`#tl_sqd_T(yfE*LsHwlVsk-97) zUB~}^dERT6bBEHr8bA!-tYAu+ORX(68Xg#kE~a;?_g?<;U_1WU%|wzyNMkIKjE?j{ zMP_O$y4yra3MOt%mAY@Ha_;@P?8>^{<>cP3(7p8QIg@C5+C}8NrKzYSUX;qS;0AzO zE#XEsua;6fyE8Fy3T-9Hx*p&1pt`rM#uL$3tXtwp9N~rDu+wMb9I@J5-ny&tILRtE zV|GgYycLPXXCioeRK61}zeXc?$QF|`nM*;Y&%OJGDsYeTsIa~5v1GyOR_LpAtWdB9 z{HH=`cIYrE2r{arQsa0CdG28_B*;kZNwAHUnwXuD*J1sp-q<%QwX~)v5bkA-Ba%FW zb12UuXwy*x~0UfBu`syt(-@;_id@)yNj z*MdVg35{+<)zsR*!*NN+e%Eo$5CPVh?K*Jwc4Bizy6F7ksQ~7uE5tsJo#;OP+RZFU z4Q5#`5NOm;<=)&fa6UEAZauqq@IBWjlN^E$@C*ywAfISU;~0pEkK) zV0CEHhh-ZGM{G}sg-Ofb(Fqhsh7xM|Lc4&F*{CBb`J2D_w*WkaQb%O0%e>;#RtOY+ zEyH?Kv41%aJl`1dE~orpNp16|B2XpmWLiAQOO7A;5%W7Uo67px@dL$;D-H{;v?Dg> z;}PcL8MKi+Oii!eoP)P?d>a$o7P{@1%6(XgU*>a@%XYj;&Qj-xhG}mu8MRvj9;{P4 z9XL3!xBg@ZJbpY{@O3iAPsANA&I6DhdSLSN%DJ5!!V-zU?lB8MB9G54EUIUx{#>Ra z1{<>%A0G#7rrNjkRC5B*XL1eN!<=e~s)z2`L6a6lSWuI

sxsRT!C^}G=alWV>Pz};^qq`&5?W{lwo7vi-2Ag~-C^xbL9Ii=8FPGU-u^jovpqXyy&vvF+GOzg!j;-UBf|l9y89 z+K5!cAb(=swrKrHZIN7JKu5xC!EuY}M;uaAc}=36p3Ad+{Vq2g(c>2n#gYq`=q;P& z5a!JJNB_fH5#K`t4&O0u-pdC@U_`JYF zWzn0#N^epiYxh!F>}Bulr`RGK&h3_1DztAH+FI1R5M45_UjhYlGd`PBc9~)+{ueLC zg&mmMJOZ~ejEt3bZ%np1Yv!wwsu92ajwl~*GYEcGcO;pSGDL~F(Z1vqUp%eHD8kv> zpp%b#Cc{d-6UrJimW0i)<8*h%+lf>;M& zP{-O`-i+uv2kMT-f&-s3TwCA&p)k%p8na2DXNaX?J}f&vGyd5$wHByTeotoHdFW=m zcJ#sSTI?3ta(%+R<54koFb8;F$Vff%4fo4{Nv?5|t8J}%7gs+uS5i`_ceo|xP?2l# z@c{Hx*|$$26uTvv91s|>bYIPm%X}?-R1tv4g6w!@dgO!7@#H9c*xb*-F<|j}kYPAs zk;`;(z@}E1`U;%C+IEyNN14jOBc$Q0kk$yaNZ&oBmN zhw(qs#mV=543#WSD81*~0MW@{!M6`T;TT_DTzL7KMAVrkUD9%>@bRv((J(riyat$!6yuKV z;UxRy2adpE2ZdNUG*fx=%`dh>8Y8N`Xt@=7g(DmVBNSy0Pokbb)102+&x~XKA|B7( zq4mwcTJ)BUwYiP0r-4Qjb25a*}QeFwv)gOxSAweSHb74a}$1_*yUjaM*0g(7?6K~qu!l?j!UgwUq ziN_C5o6r1;Figza^7PTl6I0C~w8h=Pp;uDwu|0fDwmHxjfXb!Ijh}T#bMon)T~ZWD zxl)RwPPzOi=tAg&-1nH!h{-H)*WpX98IFf=E5UVZeKUimw3fwdyK zD-dwC4PIQ%e#+LaF>Q7-xs2N#cMn!)*nu8bmMe<|EMkRIH2PYxBu+o#5-@{ewSt_wX{ zzoLn`j~bK(VR>s&g76s%@jGJCdW5Kmx(k<0ZZpS9^au8hXqy}h4K02+u5d(17-5_Q z!XjX2V{zrO+u(UNYx>o^L(%hZ4Qp8BhEhaTl{rF*bE`oZo>k80Ue)vCON%u%U<(dO z>)cu_&Iwb)wHm~%Upl_veqDW;t$&0>(6PK|tJ~@;5BqiA`+m>$&3k3eojHAh3srJS zbY)||_~CjE%lmgQjf)+NS*I@=&**ST<&8ar)WB`FTJIG1u@I1A{8l;?`j1IDm&(=f z>0eIGOo3Q7#$Hs?-O9cv!z0k+3Wvkzno2QSTe-+2{BalyQ^&RJnF-nIk6TpVM4{82 zH(W^mpz96^-e%9$c(OsEuM=PV*_pDvsJ5xwGZr!@#q9^qTvKj6^F@_jFTlASYYgO! zVt2ib_&!(b6Z;esY9zG728ch6ancFA4Cnt{Vox4&=P_4T(uR!@XRQ8lp6Y=B?WY`X z2`#FvCFEK*labaTV`8+JU6LQp6t%L&a)svfxyG61brG&$M&TVRlji_>oIz=Q=-dZN z$?$2onwcB&q~r3(jB#T^|NA)pw2;_b6}zQz=fSf$nzcDa8yocaYM%*+Q9*P`i6^=< z*N5y`Codgh?27s6?cWVf+LcZQK*&2@3+j&G1-Y?u;kK`{Kk0-XzOJ2|aJ;fkR*$1X zS^8zH-5ec(XHaImDv5=$pPZ1|qy>mi61HdYN1R0pXc(=2e2!6K6;HMFcqnPWpU#YO zI$K>`SUM`UR`;{<)3WLy-A(eW2g*0|ekveVi2Fx{gd*H}yuQM_LN&5U?$j{A10}Bb zm1vEaI}@v#^vEs4as&cf0$ zpT&nlCaP%u+VH?||6bG4)m_W3eR=cFW>zDL{ivzn5keta9bR2LakaE{X66^{!=}VX z#y}Q{ceoV9?Afw_P&r7In=x#@I=|WEkkq}w6uqD1%Xr>FD&Anb?P4#oj4(9@DDmVw z?TYF&IzRMf*al@TBhNo`fKGH(?4)47pQ2x2anO>D+k_f$v`~3Tpa$j1H5EQ^3%Uo& zYy6!#4lXMyM$^~*wp=@w&S{Z-DuXZiK02yl6YGH%peu zr2k?xKFf2rBWtB4rTjy3<(JOyR*}SYmTP?0oO+7x3;gBS7OXzo5zRt2CvSG{#3O1D z5E602=FrB9lzKO)c?yT|pYFw(G%zS-T+GK%eW07kCkV0lg^MMzi@G&MB4^22A}rTmE{RakEca3pq9BpV+PczAJSb&EG!M%98hMfe~Q6;~A;*Hf7*$6Q&hg zf1exvHo61Y*wS7S!Z0D$|2PxIBkb_L)W{W=iBDUju`MB)HRZSC(8rX~%<&UnR@Jdb3Ip>rn{{T2{(B@KPl~{YLZc z2Hjk=DSFrZjd1&0M~Ct_nYjwln>_a5 zzAhKZQ&S3`y`HU_kn^&IdY(5{6WL&ob$xMjLzP4cuA2SeDP{J0@uhE(x?&Wb{txJJ z|Fn}y-(|V;er9~JC@c(c<)`#jrZSrysC1g{zrFU4QZxI?TK=G|EOVgC%8}}Sz~6iE zZt3VK`~^%SEf8Key&3jrUG`6vu)2t_5ekXkBFVv+l*0 zmOr-nYV>>diY;Ar;CBvRuE8ku{Dk<(5iGF6^oC3mcm9ZwQ6M7NCp;qAIj{cw58nf*~#F0KJ&d_8h33*Jop z%cUj>3KXh&+zj&l#-2SO`b5he%3w(z39@ldVDYGQjjWhbvwc#4(+S@iiseQ$G zX9Q%pQ?0OwU6G>J|APz)r0fW~_!L8$Cd;T^%_cXuWteAS_n-xFM~VOSQk-v(+- z7?pY|MWAp_!0xGiscqa-I+I> zIG5U`=z3_kpDhBz+7 zfqHxVoZD+qMbn49uwC$Nf-E#a*gNq^|=0u^qe~rYwiQ73Gk5UA$BcS zqbF<%xM9lja8q2_@O@>JAefYp;!V*X&IiDE9`DRZYob))jZoK5wYeALBlCQ zennifaF%j$CMKDp1;(^wQHL(ykI%(wHn!t;MNMU0odR4{kSgJod#5l&Z%EB(X_J#y z4O6i1i8rM2i>MGV_PVQ+q5Eh*XlS=)O{wn}oJ7o=1dfiAUtnS~tgJjZroxc6mKws@ zo--Sxd=Ir?^~XWte;5ro2wE#&LbxU#e3A8>jsmyWymowyu4jjYGOxhlu@^2i8d0PI zDP19GGr$c=zq=z@S1BC3d@iU3$A+Oq%+)qvwOe!qV_f9~Q1y(RCF+K!B1^Nm|bJ&O)* z%(eqQC7fzD9cqvfozn7LE`LnXu*io0Y;KeKZpa3}23&)##|-s<+XU4)2yksAdlkMQ z-#YY4>GZ+H9;Fb3!h@qrKD*1X@ObBL4r%XH5xD;PhcHf%%5W4_MuD3LAmVW$Kg)vOMkVnTKu$v<#A zV)H1p&$R_z=dLg$NmFoafv`)B&7%8UH1>CNFe|cm2Jt8=FqG0{;yE;TZX_BjxT=PG zcc7$(PuJO_)l_A4IjmCQl%(<^EF|_WV`G{82h+0xFW`?`=|>$MhY9p{VGQA(ee>C` zUT7`grZyVqB`Fer>CU$@+nSqz)GX(gGQMFVGV*pK$0$vh4uq<+lx_Zb@RVCGmHeRE zz^e2dLq?YeUKZw;hUZVdOul69uUP6h8}xbSg`!8b`Pp^fa_hF^T_bS)XaYCkv)d)_ zt9~+Dr*#kY-a?J72o%Utt4g5zV$6-h!rL`JC+__qzvt+&?B+THPrl4q!DVf#K7^|9 zn@qqeAkXxDgLbl)U=3#n=5)9~O;KHw#lrGL+d6V&-8W+Q9MpS-vcr2w4&3&ar|v5H z39dJldb_F*e|IHCm8?$9SnIVUY%ck=0rZi_PqYrTXrK7W1vPi#Yd%FRV-`5E%WO$1 zal8~__OB9g3pTS`xeLI+k{|^w4xm?gQV8X zbvhMe*_Xajc~0*DogJV7`)5z(4_c2(jBkpqhPGE=8(0S4T%0+ZRu~}TM8XAVsfUd*QrrvTWOou)Pmu`KHCrZ%d~uzW%>9RJGeF;$dj9W$7eh+ zHA{74l!LsWPn73siYmmP=8hQ?v58|%T_7a}&5~V$G2*a*anbz%)uoz+hd6pwLnn4h zlyp>5kpiFXTQoO$*VzG%O?+bGVN!Gg{J*p-(Vh*}nHKbMpA>5zDJ#mCPjt$1&a##k z9O!RK%EuvyOi zaQ3|O0bX(6UDneis0Jvu{)9TE&M2gx({(;l`BXU}T^X~>N~RFS#?%oY7+7a^s#Bb| zcuh7PhD9?43MTYyN2-|=`Effe=XtAA1wN$W<#ak8E48@1>S2s7q$k`Oi%jFTIwT4y z0_!eb=W+#$(inV5B#kV$T`Q+)jOf#{(ST1)U9(g4{Y9jMpsLFyr89CaOS&9p;ODlv zp6CK_2PIsmC_vT;E8aMbnANKGHg@nXlN*Fstw@x_V(uyBnYv%$_X{5R=J1O*gIH2d zt21roszF~yf8GqZj|qo`c2YbM9dM7y>z4-QU7PD_6Z+L%WK)@Go3|f%@oH-dI~mii zsPxBI?{@XyXCGX@s*~9Qww)4Hs4<7|y9@oe6{vg})|dNq=+pqGV$ zSI4?sw!V?h+gITJ0I)q5ggV^&tmXdR(g zg>0-ZAokWB{Qed0i?*kjp1{ z29;Tt#F?2vPswfpYtH@;q1+vJd6V(f6n^)So_f2eVy&P*F9V>LYFsT4@#=?RI>Rck z!y+cA?m#QR{N7B(p5tBJz1dp%%0s0 z|B>EN1ASG?<@(C?%6{E9$Jc+O`poC%vvKZvZ!g@P)Ekj16x2^B+SJgoOua~;gxeZb z69^;l`*GyE{V?I|MSkTRuhW$l&+tM~z1dW_qKSJ+a=EUI%~>$EQ+79sWF{=ho5=CC zA+2YFb*%Xo!dJ$B0kVZZ<6C7N`G?VyCJ~2Pl?boOu)DluOpRe{%<~0|axTf3`qkP;`h18E zLZ=+tV52b@`8nKwnnloOXWWsHa{tWI8fW*<5UOvRTUUEjAw{9tq9)lkEIyt8dyiV$ zAAF!+;@(-!D46y;MQ@2Xb(~xsi$DKHZWa!rnPV&KFShhy%T`_aS{x~}S}O`gKHM z=Lz)8Lk$y~@!(Y{1j9aWCw)l4x9vqdX$E0k-raQp$lrobHYgGO3$1rG9fOD!Qmmn| z;)UN4*9OQhm3BC4h*Pf;fli>SWdQ{H4CWxDpDPl%`J~gpov+aoeTKvrCnnCDH%rc^ zcA0%hgd4h0YR<&>1QjK;bNQjr{Q}QX=sC5Ii#6^X9s7*W&ROYu0^WGnR@D7gao9L5{(SXdVn*%E)61Bb-6aoi5C>7j=0tszyIc_sdo6=e%H5yM^AiAoZpy=U9{qF?Iv`8@SS!?FcjIpQoo3`lo=xt?sV}RY8mq>CtG(E zsRAqPn@!BHlVdDC*L=>4FP98AK;OG}iErB0`h_T{14P{KESAfl97Mb zY?j+YtVn`1lTO^5uk*fY7ncdA6OXgPNV_$?Af8g}LF z>$rQriH%Li>+F}d$}CfHEU&u;c=Bm8QL5Mi&J>9NjmjD6zUS*co|m{nl^!HEU!b`C zpi7b(6%!dT=ry@NpFEXlLh$U9doJMnxCmWd54B|gNJu2s%Tr0Jg59B05;0~sKUtRe z_l92?*dU_U*Dl}QEU#4Bfds9v6JwZ}kdxFGFcI;drzowAz^H9j`0uUw+$-2Xiqq=? z=?=k1%=P{2P5sQ+)FfjiV-$?Ly>MiEgsahNC&SyPNpozo@-Tb`KRYl8k^m!I$xIOA zeH4FLvquxUqTZ8Y_1fxRpfo?ivE_irCr+K67DMi!x(zlKIcwe{UIEu0#Kh(BEVR<; zC*JV;f`xEt!IQlBol;4UIaX1Tv7@*D)CLw^+BvshdJc!FS@!-B-zoK+{_Q4Szn-qF zy_`Zkh~L2B^EI>DCnu<|pSQu}6={Tyz3!;l_z44_hs3};DOfNPQe&t^GbN>cC&||I zbK$%*{_q|v^08GRLL8Q!~EKE43h4q5S*P)v$!esA%u-K63H}U5o*{ z^ryJ+<4F7J<(lki|FfVwOJL9iLx%DGnL}z~+dHmUdD|+(PrC^WvrTuRADujI5?Ai~ z1bl6gg@P_B5>F9Gy$&BTK!;G+gA);xrv~&I`_a!+h`F~cvl%oX!eCKBwaqEv|6{|8 zCFN}dhC@3%E<#&kfZZf0Gi-ozya*C9ge(8c1i=pgiT=>8G6eLDH%fHZp@X}Kjj4uO zTRMLbY}n#W&lXJZXtz_7^S zG;#%URpA@ZH*caE!Hz(#(kooIxWHDHTW&M>I-ylpr0ENpu1rR~Es1TMZwh@kLskNI z&(oWhYks4>C+Vby^L&jV9MxBEK0S@lt#{F3h=Z}Q_vGwO#y16w@WYT@htgZTz9RJ* z6v7g$0tZ$vIuPH5hOAHj%@$o33akmsLU)5sm6*)sP#(#+V z%|v6}BNhbffQ-ek?2#X^?Dj+GAWsfr8_u=@Ado-pHeRokXnu%wa>h7+JSJXQgq3Ci z)d8BkN{0eP4Y1HSArU9sC_rW&BAL7uGS1TpfxwwZDM!DRVp~_x(XGktVhUV_`OimR zfr|ew4YVFI>AwiNC&W3-p2+Kw7|-hw0OpQ2azbh!+6V-Iwy!`<$rNTM77~ z3CRWu=j=F@B}d+*#{wE-4#3tF^hn?z((RuZ``6Zzs^`v_9(evjAgJ(LGdET_9*={j z{=o4(+dmiV)kgxF&2w8KmtqM%sl{&_M&ZQZmU}6suqccBKRnZPHGnZA(~hbrr_ z8u_Q!k}$S2@TT0u<3$C<5@%u^q}1j1UaQG3wmO4(1=w@$Q#F4m@dUCls3n>o4T}Fz z@9hYYDT>*h$+3bqoN~xsU6!esto3M}R-_4d&iQ}oTxM-2)8mHg>!_?lPK`wF80N=U zTb+M;&(>PIQaUu%cCe3>a(P0>tK27U_R&p1&NzThL7ROO5+~EeW2D*Qu?QkTZ;Wlt z$86p!)vhKWQkT`+Oxk?xvufb?;WGacczlKs=w_5+ZRtme-8s(_5GLQ@Mp-mq;bS+% zK*!^jf|j1F6f!I3@TzZFk7}`>=V_g~FMnDpCVK?8e)W)w{mRO6As*9?OuKrR0bMMy z9r=y=3I7U!a3I7uWd;hCKI#4yPPsgSH`mfMMKzLBpIDwA-?^P1eWp!j^(T?$ky|ws zfb>i}ra}g?y5iBJivb#Fkdnryti_+GeX6Y==?;r??aj@cnV?=3s&;?wZ}WXBWlL*m z=x=m7`~;Ayq%v{^>84Udv)<%(0#kpYmOFc`aAUV83vr($^z53MlU@ujzqKSmdt{Gy zyLiCILGh}|X6Gbr09h@XOB_lu9v`M^X^MObRt_@~I1~qsxv>yWY}}OQS|Rv3fO7Mo z{%vp0<+4SZ!bZ%$kIx;0OAdis^2G44c=@b9Me))H%QXTo51-MF%{^Umcf#Ss<%t8P z-D=lpnbo)i6v`0G>$3796qia;!@7BcW3szDv`Uvi|5hRhR=nV|mBuR+(2enM$I4jT z5juxyycvN`ZRMq%CKV67I!`9;wcmFX!v_>42vD^|TQO_c6E|G1{Pv;4%;^mAug`af z9(hx*H&_uNp+ihQW`iiw0Cpt%#U z!~+*#ZbDJCQpJtqwj+AC1wVI&W)vRWHe>M{Bk{uU-E6GE>@tLH|Li-~H5zoTG(+`5z* zL=>J7-~SOpAO`7${W<3TjEy=qk6oel(A%T-HZkJCBFhbwORY9rz0SC!TU^Dc7T5M~ zKD-V@HF-i!&E#{R{De?Lv1lZ)B34kUwDza*R#8{+u-;%j6>8CSPMkiVbg9RoyxOY0 zcgAfASqvnZ6v$PVCmc!Do>_!rcl%yb{Y|ZuB%f1Gnf$%UTfbRP@E7zU!j|cDtZ{ap zfBMaFe(^F4>))4LJmE;6)!aUNH+?no{*{gv*kfcrxM(*;Ghyx{>}SX7_??EmID^&h zPS`geqSFeU=@qi|<*Ft6B{@U+sK*nUsU~aTJB51+M!+P!TuRul%sa-ba*?}R9RNpF zlo`IfCZ5sIN!gX%%4#5<0#kEKR6~hellXOyz_5}fxWjV@%BVx29klg&oS_nhUjY+Oj(96 z4|oIU<4Jk-R-15*xcMZ3lxgA~ccNiPoem--*tJZ^Aq(~)&USigW}vEV!6Y_sAIkIoU^VwLm|}WH6NA3M z!?R)Xw>4>ngJFd8n-#Qr7}xItmx?^0`7c8Wgg1`<7;7|7ukKfO54BYzyF#FLp#tWD+E)o z8wi3UID|7D^bA{rGg7MAS@LGylW--~jgov~O;x8plLdDcTbBM%@Mu&A+rLWI9$S5Q zr(g|Le;_QWj?L5>#P~>)q?WsBaX9BR7GaCZN?e_1mCKTFTGZMUQJwHsvz`ZIM!^yw z@SmyIVS@%zGVWp8+^Fh8E%zqRCS5gl4L$jVX_f+Cbp zl3n}x(hu9h<#&dpD%n&uiqK0!EYdf{7X6z1*uLa6RUY((VJV4J;rP)F_z_Q`v{PBx zAkyMF_jEA;Hf4KR!iTsFWoNlKTP;+l?5Y=r?FZUP^2Ty;p?g*MJ*L(sa#7?;K~E(l zY#)nQ(Kc1we!35_a+LZUdhAu&E{T4}A?7k&5%jb9q0rlY@KYuNeP(cUg0VC&DWP0w zGz08)715zU*Pi#il3huHu6AyDF@sA8&cszto2pLxl*9bUM@7$2@ik%P<4sC;Blx|4eG|RGvRNEoY3+yMKk?74c+Tm_>rgl_FWOF zuP({b#>uJHm|;=seLUQF$HzBO;8oAl$8{LNVhyTKQBZm%%@mU_DA1o}2bjVe$#cA> zN82aVimQ#9pTA+&GY~>WPfnx)6uFIyRN98ep*$~pc}*^q(#arAs<-}UrIE7TDrzMu z>m!e}t8ivUIDMO~og;%zma?j|@VC7t_BFa6k3sMuwTLND0GfNMz+|%Cg2d)Y5EeP_ z_s33D=JE{HxqhMH=axy>MQp4JMbSn4zxa%RjTfQGp}$N7qh%X2)#`|1y=q8t5*L8)i_>2j=#H75C6EbkA5%@R zQFjf#D<;4tk@SRrA!}m!fD;%0#$ql?S8sVmHkjtHPnwh)gqyw_^4@Sp(#nA%e8xa1 znz>?ux~z-=i&P{ERINRl+xUG?uURZH9DlX6-20m(S*z^-!rfa2#Tjhf!XYFOf(Li^ z0KuIJ?hqijySsY=!QCB#hhV|o-Q8huXMn+BnBkI~_tf|OyMN!hT{TtnOm$6FckkZ4 z_j=Y|-CT%KQc+sj?k3R6KaUqTVYz+Xv~0fd?@AB{m~f06|FN@ z!V}47)}*EKzm2u&s;D>E`Rn+RM~_8ehjA(E%r^_qVbJY5Zb?Y!J zOhSz@V|PEoZcn-sEIsMI!w6eVdsXR-Ft-okwichie@^qz}m#EdYoRULPbNYGrC(V($j= zB*!o;<77;el+?yB2G!L$Xhkkb_}CzV6KrSLRgf^2^N-yi-MjmfA=2a0RN_e!3lkLs zE75KZGnhfwb1(jS3aqbMYq-$znN3?v^`r$9(a1A1lTOaj`mOvTV|;K+L3|N)7-NL8 z^Hb(;we`WM=;>u$r2}<`4+Uklk#Dq2@%9td4c3`46fo>;3addv_JiKwO2vF9=}|^X z)`FCk+oj@ff-90Pz4qP(;)n`rWmd=FW)^-bry-ONXg6P}fD%$Z2e^>-$cYA)4-!W| zdh((lT-e{eDokN_eV(bvjpDAhJ|q6^zUWE$Z}?V!srbSaa8qjIJx8^rM>*06>_B z{!{fsnL?%ZYWRdrA!Z6E8kF*WaxUvNyC_mV7)ACGb*X6rLwWnVtMe86JI;FBTYbIy zt2DmW?1&DtU9+OZgm?vON$%!gv!pvRQMo<&{g2C>%J`r2?f6_aj`AHO$lkkf$S90c z3NS~WK7Ejdpyb!OQ@9+cyJuS3}vw~&TY}hYt{ny z1xDcLFSzb3|F21A9}aa#1OJ-iX-AFg$N=&E)_c#^NbW!83RpDoSM(s&zT;v>%5%Sp z|Ew9=%bM*bg<(a~k!OkWHti+C^)MI(Qin z>yf-zt@hEo{Wy-cw5U^8Q8k@dfhS-KhhlkIP(vl!$_fRO$43bQzQWL-R4pfE3d185 zLhs@q=Q&;Nxx%jO350k2Qo|CZKa*qA*6s09eV=Lp|7c_rAKu@tQ>*ydp+wsM7D_+- z+i1^+Wjy6yG!BW_^f3so!|PkIvWuvL)Sq$s>GsJ@?C1R!V;V+c4r3fOT#*a!Jq2(t4PlR#7+!9Q59&DdXDy zJV6tX;vDBFJK~jRJX*8Xd&wsXVwI=!F1phe7uWl~T zxv>wsZhKv$!u>Y-``AsXIdA0CcGm#85T;#0XvuTnRX@!(h;y<2 zA}6LpT4?@;1Um&+h0T|~x<8rIy-#MCrNmg8bq1a-!{)cuYM${LK69DI%va&8r+k0x zieDCXu_nephK88({tUX&hBYiq&l%nPM{E)D0bZY`qO#@fm`=t>7@R~BTFz&y#)EB; zjh3JTlF;~i{VbntU5uFvUIMrEr?>5rY?T^!$r4t2T$yjnRyRzF92u7%mVbmz%`6Qp zV6KF0@&v5&wtX+YFK}j_F zLfb?CBs=ho=b&~9i?+fbw|7ykImKvX4Zf!C=PHr6wx^HcOiU$<(`_B$;SI?jwK67$ zL};*4Go+_~Dy+^ln6uS~+g{eXyXR6jbSmvImus~}Jx#+8Go4Ou+WTSHq)lEw;+CR}b&DOWv5yu*C2J>L{s?L|aACrYc_uJGI?41ZC9+nHIB&2jWqU+T-8_q+Nt$RzUk5m6NQApazO}gcRZ|lo7mqcXA zWqDDEM#Qj4Y|@_9Ntlo$heszUsf2<**Coa@;AR|``Jn9@N0+C;-p_mWWABY#3 znwGHeVPd|S=d5$L!?dS8EY}*@-uEDDqnMr-a*di+;fro8yJGk_M#aRGIA<7WX+dPi zd^2}bm&?6+$h|P(mEBS<=v>1gv(5_>&59XsVNhH>_DEa#BJHFMx2$FLre(#dQcpoa zHEJv$KYry+JhOpneVjx+5~a>Ke+v1E=5RtV+_v2+DKYOtTauPTC4~S_>e#h?uUMb9 zyP@qG2}Y^O(X~b?aS8BBN6j*tuUCi>I6sz^KL_cppb-*{FizTK^DC9H|?FBjgcMQoS+-eP{ zl<*C|fZqRp_}`~d`WG6W3SC7i$*TYVu+778DOhb|X58HWANYM+b&E6!xLSHq-}}#K zfKpPVE;B&;9>l=^tGMF-W*ygm5aO-f_pEqg=lgfguSv>%uNZ)($1D3aR(|OF7fyRW z3e?8DkIdBTK%fP=e+%jNN2{C3N+1wh;Sn28?}cFKWgL({uD7bi9hpZLkTa?KXf~0N zrf35J+PD6jtr^5zoVLeT$S+D?G{iqVHRz`Af%Zxt-rpE+W10--CDK$04d#P_8~99k zT~)|L!wJoWpSj(_Ni+jK$y>`>t_KXZgq?0GzArvd{i-W!Z%wCAKR?ISfVG+j8h#P+ z$_hNi-nP5>T2+z9+(L!w$I49-`UiQ?_*3v)5;@!laVkvX^Odz%Tji7yk;udE-KR8L zQ(B4@Zx6S~-X&@+MJ5D(4AdLv{92LM;TL*4REb<1ucIqzq|aZyuWfB}?CxuTAX-@X z1L@5-&!oyqe4lIjcZG3knv%*QxZiKRkAK@(#++`FBK>$*@&gIsB|)&HZV`j9$0aBj zSrK@pi{vxcm+R?B9W?0Uze`LY^j>7@09(@O`FJ+|13}$$UMPJ{fqs18!GO&`WlUhp z*45KvaE6|y%uDU2a7KKv$O>*}U!;q*2k*O)U%W|T11TS@b^3HBCx5aa!S6ffit)L6 za=VUg*K6QnE>F~WnZ#I#%EWojpS1DTZs{#%uN?5QJPl~@SsT)dY|nMvbNJt%94rHw z#NJ}@g0@**j(5NBhQObYMNc;z+vT)D8`mI@*52i8XgKW0V^x>UE@(v_>S3r=Q7;_n zg#sRzlWf*M?mk3~Wq}V-f(Na>4+V+z?`VFy2;Uii-Olu;nuC~z%%1u?_lW53^7bk= zT*m9sm7B9EkCSG^cn^>bcKe*#hc$CE6T_yO?zPS!I|_o$f4q1Q(;1S8Jt^=4Zfyrr zENtJuGpQ)|J|VS-%w~`MiarAhqwapX-(__I55|~()*mVMTW*N(AZ-rUIrk4f23;%v zx_CA5u;RgMVoO1>T{i*a(16{OrkwAaS0+k6OZyE+Q z7aBl9ABKoWzy?p4xq@}*rPPPo4;YJn2Oe#+E!mCopk9&OCxl#nE8>1DqK$aXQ59g@ zxyKVI?}*bLbEX)rGpwviFxQ`9Re)dH^I)I#GICG@v>f^N??x%iHt#A!uQQ~V|AbG4 zzcjDO6zwnNml8=^!hTn!>S;yy8ki(3z z7AV#FnFN+Pml7c#8R5w>_aD7o$W+WN=oniR4q%7QP|Ofeq@_q&TU+|DQhxRSV9@^P zOp6yPLGi9VI>VjXCXehV;Yq$S@4mf)cn?(+!hpE(}IsEq_Pj2SP!Ra@bqnu zI%`~IW}zDake(!xE5FM7nRx8rZPx{6v!ly;SF@YE=WiX?IZp%~YSWQK0iHq*b7z%7 zfKX*T-N8lxdz5Q_U3pG#K2CIDGMbU$GcXEpSMpSNSJia97+Y;#*2OKq)u(ioT$MdJ z)P^xVfwpaR^s;T;izo!hll@DclF0O($-Tbwl5inw)zxK)!(Jhvo%T;*b@azVa9qZd zpAEOvlfiH`AzA%J8*Nd;d!>B>0*A5+bXgyO_?CC{MS#J86AfQ;6^d?6qWV!>Wi2e4 zl1YrmRP^1S$N?f7Gr5lQx%wq+)6jKe@f97b$ZJR27sp|4n(zSyYeiM>pTxJf5{hS$ z2j;sshLGwJMAgSHXndv^rJQUh!Hb%$**djBW{G^ZUxi(nccbwRs>kDrXBc_66UeTX zO%M|lndC@YgP$9+x{3~Bmi=`^{rL8;3=Ex}(i$G_E<#V9M^@ENyI8PITpL^Nu3V}?S5wcY>5RquMFaifXN!)BykLRa40g1~B`N$2%!1?eRZr8i zTk~=z!_HWPzQTNrY`L=?9*y;$x)wJi3vv?-I$eKeJO#pu@La)U4S!#rQ4g^=5_P5Oi^^K|x~g(a&ZgcwtJ9-cK}6<~@txIp6kaJnUpfXX@iKnm;`NcIC(jr0hS5&X)yj z$E2G)jM0R~S7QkXe)auC2AL@$o&n3kO~a^M|*6CW<_RU@pRJ zjvq&s$+g5L_G|zhQPotFJ0?;>qpYMAYn*-3> zAmLRZ{-7jMCx!zBM(QcS=+>$S2J~?&-A3SNS{yq6)N@^Le81S%q*;TEVSt4lBm7ww zgvZ}=Yoh@el4Lv(aBSV-zW-@^l=L8=s`QAEKLlR8NWp0SrGPhrh=h;rR3Hb#B+;8vP&#_X76He-lA&}S~psOWt zz4)FKyj|{hW`4GefA-o{`RWn0_OMO(;X7rB5~<9D{ZX^%K&}_s&U>u6HaBDqeV7=j zcS*>QA}Rdx&cwkf{QI9^RMIAktBBDGJko(aWr;T*Y&;*%8y!$Q;68vNy^e2N(-+|g z;ORJOJnwu#2hoBF%E2O(k))fqK0H5KUm@{p2oGm_w2NRF$qtf;OAMTO4r0*kpcPV* ztXM!J0|Xl=*4~Dx+}&<8U-j2h2hv0d8cH{syn5Xq>iEd8GZbC9e*<_X+i1Pr7^~P= zW+SMz!TKfHTx^RR9-!rZQ7c|!Mi@({+X zA@JmtXT@lg!gotO+^J z(&Qnr@gkRCJZ>fy3{i_~fnOEaJte!`;|RgjhVnK(oyN*n@Bsfx`xpAfzr;n3(&L4m zeMVW((%3%+&!RK%pg9)L_|Rvs7ws$SV|6^d;z_5}t8^0xhW@jmq!ukk{+-jtu2f=c zi7k?`8>`dc`I(v18_~VNt7Xzr@2^|cN0Ri1XE=H#|gmI(6w~~kzCR3!67T`Tdcyd z#Lru1*ww0ymuo9Wft?&_Z>(Tg&6li}BPBK$ACBT8w9T&Gka!WMT7BPeoK=ERZVby3 z|J#73J9M6_m5g^7#!|B8wU1~;)Torm8-^Zj)^wZK+GImn%Cg%!=yyP!l*+N5oz(zWQFj6=ZJjXNk+~MD9bU5y$^y zN7!GmT@*C-ykXbYwy$U2AUjS$$Mr6IzPudh9WY49RBA2riIO#QZS2#9J|eLqMHP8= z%p>D5-b8_SnLI#ocBF_?9G!xvH7U^5Py?-Z{KxRvIIVc#em#>F@VDGbsg{=55B3>n zXPLL${rOHSjJaD5jcZ+4k=oL)n!~dz=3}s;UG4aFca&Qi_)2xY)nyT@e4kjsnX{h< zU><8!bt>#x( z=l+lzC+04LX=Qs(FKy99pm8xC8%tGiO9OkX-MEmQCnYG<{2N--5#e7Pi&0c;X!#!X za1f-dDC$_eWGu#yih`M=lF=m_TpOF`vpnk)BrNz{f8wbcemjnWctX~gNl&!d{dNNA zM7^#Fgi|n&bUOY78Wl8ne(VVB&%{wd7&_z%jPI=-*7c^&6^00+F`!Q2$iyY&C@hEV z-1p(^;6Fh|AJ%Kjmeki$g?GOy0({>+P>`RzQv`ry^q)XX0)9TeKMg5EmFQ{kb;joo z%%|$I&1Mlv`bFSp@*Yt2{c96I67k{nO3t%tWu+UXzHHLoif9a#_DYlhN>*CDG_QYC!L4x;Z00B;WOa*Mp8f!l(S;KyK6M7wr!wQW=Jg_U%77`gf#^D@5`&nm7ZkGO~etACFH3D)+ zk@!ZTEM;~IDwe1@veWs5Sw`f}{solP3$LNu1;46GE^qDq9&Y;u0JA?3Rc7>yc_!Ux zas0ri;BXOJR<(6BBE$xt_cxES@?hJX4;A&7;6pxx5%LJ~M-EtiyVeiAoF45QXAnbl zT6=2&p2=F(B8CgA**`M4DBxL4-lE;sih3AJ_VqSqygG+61o%{EcsAzYeW<4Wy%?$i z&ailx^(c<3fjx{(=kD3063Y+d``oW9D19M0@h5W+Y{+0{QeG6woeto&a?l@bJoo;t z214rb>gBtv-Pr4)dSpAo!uzxZGPu0u{;+82v(xg4`OTDsjTnc3fd^ip<+f840}6&7 zn4NXf1~^fpg(Cd)>9LU7(ib9AtY!!J964773|^V$1w}`@!o3g{=%s@zT4FwVwT}$F z;wQ4PT%V3cyE9w!$0?kdnBhZiATm8~Qn&sM>>YKvJs7*9O-%i@A1#$rkY`Cv^(bNW zTfcv0Wg!RtdZJnIYUpdsLysvMY&W>A`8u|(9@d3oj=pn`JYi(%O*cx`oNIN{YDTpo zfdEBMlXk+;P8m0pIPo!{qWhRE&XPI3S=OT*o8`j!tJ+;_Uu?06cU^*p_(`4G#a$>S^;mbFE; z^^}N^IKvieL6+WQp8IEks~jjKVC14Dw(Q`_21lsL?eoBm{JVBQe{7M3FDw5uDu;n* z;=rkKjc9V+fcx?KUmK7)r-Ik_d$*e~dy8@hb=ZG&lE-o__mB61u?J3TWGg-ULpXk4 z=YFizO*MQ8tSz+GLl$wT1H`_q)-6GgrHCCgGz-KcO_HT#;YpJ_YP&CNEKd* z7P(%bF(h8AXQJ(Co_)N|VSje#Etb#Tp}%DEw*NADpx2q*F%W`cCS;xSj1Z|4V<_5b z!guryQS<&1H!*MTY%+Whl`0x7rJchEaTT-ha1)u=$`c`cpT}#feyPXsgrd~$8kC@5Oy1$rwSnmXSom0`AeskQw;yYM^`>``W=DDw6?Y`(V%^w{AJtgef zcml*coSr{G2}U&=euvL(!(D@t<$TBG*1(xIUPd6{PwKX=1hIN|V(c9-p$WkqLVMnt zggqu}ncbTLhYiY}XpV<-hpPSWZv~)8>u;@4c(-PW7nsCt$87`w1@4)02ppF~vT(2a zqxg2@UOthom0s}zRjnz5xS%f0u77FcUG@ti@P_~8eV6c!&JTH#vi&d1Sw92xNrRgi zx;nn(bfiisrM4l@k<{op9l_8=N2kNsr61PS2-$4kAsHdBpsJL@O4T z$D@%AI!>8@+cyKLZ^A45vQy2uPn-2iy~9Iu1yZL=P&eaP^~Ee6Z`Q6ZjWb$AO>g+p zVttQnh-U>*DrUWo>Tv|>=u{!O{Y7|WBFcY)wVpfD4>UkfC>gzA+N=_x2>)jzxOj{6 z{RXeHaJ*>4&H^RVo}X9`Yx90X4`2^Z!!2B8yZmS_)!faa4zDcNIec$a=AU?<^OHvw zN%xLY&1-8tpyvr=(^=5@NP<+X5?H-X^GW^6UB`rd{*8<0P52=F7;g|1f@UlZejKzZ zzUtYDDW7wm^ z_jS8i^hfVA^)qifG_GpF$7n$MtfFx4q>^xY@7X;?XNLotPdOS#QJ_hO_gf3pQReurL!A~|Q2fpXB69kD;i!b@$IkOVGix*XC5mmbDiqs1axs3N|N$P$_jpt$o zn1lVXD6pXO)jNZGmv}DjNyYR8c{NOd~h3hdrb(B(0;1~vu<^KEzjI1-je|%tMYFRaBr+-m+5Ix{YW+3a%Ofkow?~Zv59#ijER8XfQS1#YL zAD`FDo_1C;(`Nn%*jRDkJMX9Rn#c>szYRFZAln#tLPcCJFKxT&sU3S7aCqpUpmXYe z>%e}#IO(8P{|rNVQMx_gdydn8vnV*=5OBc+{I&i^s+bK_RN{G3+qtz`Le|%uD-iAB zf9arp`ke`7OP|hh8NH*cFYpZ)>vIYbj4gOS`!rE}c2)54UKj28R8v+es*sm6GTEnd zN$;<7np60DaoJ1+sdD$v# zb$@LT-eE35nylx0B4R>I>nbU|X^%za{?*_$%^YWNYvluhQsvUa(RITH06v}53&Sd$ z5cqTlpV_mHYFy@ZlM|}>0ZE1P3}15Z{49Dc9rm(qjZB76>)7r(pSLSkm~W-?!_(2% zhOcRdh`q5PcR=W(c2IvPIGxNlTTC{Hv&|1{5q9RZa5@tlg+FeyFqQJn(VWF9hwJa1 zZ!^M!xz-5_qbB$9h|0JTsyt8zb9;y4nz`@9o=cAGmS)p(tjf(A13c128{pinCAiYQ zvfKk;sJUIh>+0+7tf%N)=ZRm$l%r(312*<%=Cd8_+DsVlFwx$=T<$=)Dp^MwJAlb!mrdSE9=Q z^osV>gPaOtIy*~CV_fYvzx$oM+s}-X=-&>OL};54rU_n~>8W7)D8`4qTl`v?U*LAi z^Ysfx?JLUAy+l|n3Yq6N;`Q z9y*iT2z=posi-zmOvFqtEQw+d(KFJO3{qrpFr4X0r}1VpSFDmb@FpoL`m>Y@TrW}t zeYeo<-aCkTLsNpW!@?I-$V*%)vdP)B@5H}C!Xs%p&Ib|X`*ih#Em7Wu*1Lb8;X0k z2>u;jxh7pqb{aqSi`_pIDhvK()e-=YRPIZz?8K=Eh5`8m2$2wm$o$?ET=EzQ7ft#{ zfwB>*`5861?F2p}<8r{Bn40(R8o^g&`UAU{B<%!%&)_~Rd_8>fmSZ#hoYwYu+gg0@ zd!uU^nW_7UMESCn>{?6EFWrG07dQycoA8ehWOu@?fp!DHJQZ4lTMl6lOM-ANrK^K7 zht2xqY=zPqe6Q~F=9gaJk{2iHp-3Vl0z`#0Lko{58Z`8&B7;T}j-l`>M{#&SIWZ(c`evx-b=xB*E?K4u zE0r&$^ZMem)n?daU_1Xf7_|el)tY8Cp-oP|T&z+-$utVT`nahTH2s>9KWh?tEi2i^ z9~*M`_;$zcqyG^_u>*Ic)@w@(P=?CH{lp?e+kug}n#2%+BX5bmqj14XT~~K)TT3?B zel{e#_w50(w9Lj^%s)@ZnJG{KO z1A6(tcN2&|EV>r{v4ci3S0VdJdVf<;^mEhhXGOs(9wOt>iM`3Ixf7;CE9%8-ql1g; z0?3D$s=fZ!GZPs1YUaeBSiNDqm=h`46Zsr}a{`G_zo=RtZBAfcn+2J%yB_+aS?tyj zRcIp48+zk0q`{fzVI8fdolmuVC<13Fl6bDjH$6qyim0E-Bfp0RMAg!jqxYNKjCtK+ z?k~`Nqfb_Pj;4!!@;fH?L>W@&tuMuC5`Trjk3{kBs6EyNLt_ zS)#9o4g)v`<(q;^Cl_y$4^?pm_bNQ?!H}EU_)<9o#YNppk zB1-7Y@v#u`+gDg_I|MCpm;c=7x#D{%j;6TM ztkzV0Tuw04sv$PjNilt^67~9X+#9%rS7BMWOJUx>K0& zWG(t9A+Grn#rA`_(nh7+-)$h1HSMbplPr(eRctZ7J2Jvc&q_=6(2{$efVyCd+$Yk( zmI1K3?ZHAU6kfQl9-m{r+eXPhw$h6rpumz9UAQgt;z_f-vKu+=?P~MgEI`qfHfB}~ z(etP%QCDjPzoY|}doOx%wYL5)yXwgxhp+ytPLvC*)nGpx;-BbqEIiS9zay#dc}V~i z;0e=P8_5Y39;JfVTxe6Z-9?x1@^yYk;BmU^_4vYfj`c_~<;y|DG z4-2>?3*_{DkO4a6_UTn8`uHD`IS1&x@;UbFxO;M17)_X`{MVerKeRb#>zomm;RaPb zF$A=h#pFIXVvT!1F~&Xg9_@CafZgZ=bY|ED73)<$U_%PT>YG$N(CSqPK9lQ=BHORp zctQxRx@QWyf579LdYXOe>x3=^B4iNwrB*?!s+?eqiO^Hmz`3;_+L5m83fxyV>!^2* zlMAoI0=je@a(1k*kRm<&e)uJ`u=_b&tsoY}SkJyY-|r51;b-7!Np{WNep0H_WAxc* zj0MlEW=GR8s4l>?@9v4Iy>~%X1!}p2>1VFVEC!oG*RWRVWF)Y$x z2W;1RKQwu|O?5SjFVuSnyWRlzi|ruCE|c!J1D<)F$Y40R0hU-gqz_&Vg6si7*uCda z)Muy?EGLF0*Uuc2h=j^MXz~?%VpI$>2~vZ=w+&SMqSMDEr5e-m6+hqSF^(5`9o9to z7{&NJ3_L?ADg0;_Aqh^Wt&^ulzyS;MaoLj#MGNpTbTxwB zXEyDG=NfA`#%C|A_YML)Cf8yU&#$i@<>>JI0(>rcC!w(h2Yi*|gudZ`=XUZ1!1D_@ zu{KHts7-xaLcKiNYH+UyTl8Sf^s`T?g_r<_Lr3t^jdg26V}0Exi}bRfa%lc{uf#Y2 zK2NYC;etyv;oD|`l#e>aCpKC46OpOmAx3b84a6-%@}}#8&QfM@g81xvUP_$Jd+{laZ_nzrlg#0)jvPh z68l@ztv1xD5ZsRW4GVMl$L}UAOGM`JJf{VCou1eN8?py7UT%fS&{oxLqx3MBe~!%E zTHb(Y#jHb-gzEx0_P$fY(;4g)owrLucIP|EvIQY(uxq7u-{VTjs2raAg++7=ww(#7 zooupvnpe|Qtj?R(GQvtRC;qk?gF669?w*3h_nAa};1~gN-MvIqH=+C_hcE@jR z_H(~@R$_S$*F!Jw$`)#;jA^l#Tc@H;!5C$8Q!|hVvIC-kYs8Cmbmdh3;Yb6&6kw5J zaD4G|1?y$7iB>tB;UGbmAq)}osGH}$r}K$;^1Lb6=X^=bM2)TT@Rc5&{!X$6`b_Ir zbH3>brstglzKdN_`_^YtcTROIcL{l^Hw9u<$5(>bX5a7)@A&3xN%AL zd{kR^8t|>v9w_Q&6?W~3PD0i;LF7Q1=MevAZZebl#lnW524PCqZFen0q#Rez9+JJ|o;At-~S7`*{6aJn9C5u9n7lajYe}wmvkH!rJ@=H^wm>P)+Pnm~B=p&*;Z|bXMr^9c3Rzol<*LK}HEHgs zfdQeDL$=GBnH=x4=(hj>_)w4Ui~&IE|D^h=?a7$iWxLX%e+^a^&|S5LwY`S-%?>?>SMV%sK}&hHG&WDwEa8UaJa zuQQjLQ$0@t6I%cK4>k&NoMCI5#&od2jmP2V$n0>eNPRoefRK)J7;fivK3plis&|0`t~vrIp&vcATDyFtGMYMAQb@2mqL583x#Gmd3T#XL@35p zzS1qPYGSYdAUy~F9PCqhuH>aBG3Q`ckY==^RL{}eCp~zv_{YnD3D`|V%r7LqGVN3D z_fm=E-Ee02qT4XGRZDe+;L7rz@b*u^O8&t`Yn_s!fj6oFSEju+mG8qH)hS9in1v;) zqf6R&%dWY^m7|phkIieJB<8Mvag%-#o@Z;o&$dc>#_7eEW1^a=XZ-ELoyy#Hv5MJ( z)+sl*d!w;2Dhza+dj6;YGSeHqd^}7miP~>pMN-Pyy{IPrVB?onHZ0?#8x5AxkL6MV z*J?vj%T%FN6}ra(da;P=A+vv{^cFVCdnTvjp6eu3Hg}y|#7- zirZ}-Tg=3`Uv%fy%5OSC#)?klx^XsA8;AR(4uyOYCZ_v(`YKA&9xDW_=s{}0(Il;c?@^;2W3PI6Qd4+?)B_cCiaegCTgsJWMmA{j=~K00Pg^49k!0{sj)pgFa$6 z2Q@Xs`eb`6#L4r@2P-NsvqF3vu>N9bo%Y<`@+|89y-lZT0^OQ}GWoTv zMEVgYIt;atqeD^}u2e_qiKot45UWf~R*A_cVLyOn6BtijMNLZ*8spa_?3s&fQp@v0 zc!UR3Sly@Ky2**xDkVcGB1^=hYS58xm(DAth#oJxdz`eH%$2N2JA`l4Mv52Ly?3?K zpeoKG?Avj)C?AU+8&6v!(h-yq6V3iX=R`#pz-{YSOh-V_#7SlOr!lp<0cu-SQCwM* zSbcW*_}nF0$y7QFhp^xAW~-QYt!+U<`);t+2tpQr61P$iR*rK6Sj1!+Sn6s^w8F(I zN0escTN>@dVL&WdolMNpb_7TV@IVU$mf9;KA|Bs+514mE@30osEJ=Bva+WB1VAjG> ziYw8w-K~lHSuytmDG(BE^t@_-f49_#?0}3Gi=Z$Mf{d;(z?@N%5{vfqO1^XdW~(NV z8kgD=Rkz#O`oxC*{Uf-^Hep&hq(uNft;x5zDtBgIeWaZ&gdZ}L#HFgCA2unEMRbx^ zRuEh2b%I3DZJj|l;wa&BMCz`(tf?e31V1bz>CG}FM6AE?0-{OBx=YQPr?<);uUWd*{p%2o0yQ95r9QN!Pq&j~!$zq&FoZjhFbOIpciL(Lp@_vk6(?bmuvm0iV8YyLS~_hxoOI*KMPIIuB# zJv{HQ2_LFi16egalA@P?o1vx+Jv=`%|7fpwi|bZ5hi4-1?vm$Y+pPiYamy@a_>+Dp zEVeOsy1{o+r7qVn*qQ;=(dRu182hBgj5-T;0TcJ81{eT!B*k2~;B2DKuGYnI`;^>4 zHkJOw^>`&&x=I$ZyG%vDJ7Q|`$w_-DaY#&l9WX+}XF(+Mv9mA=CFn4FW$bc8RT&%d zv1KLefPB={?6e#wN5J#L>16E=gI!VcOt1I>!6OxJQ^YU3vXXBy-ji;h(&4DSD7dH+ zq<3?;qR?g656ab^?II@D`hLN_44SHJY#gITVD)J@{~RqM~E2fOp-y92aix=?Uh!BwUl^uQI6a1NgFPV9t4o}?-` zm&aC%(Kd{8bTKK8eH%>;T%V6X}CW5|qIwJ}+e%-27r8ah&@qCxet@2e}Vt6?y z>o}4{Q+J)H@~0rD$N+swU7r|ZeD`%QL&d71lT+M|)C3z_rX9x?V3@1I>U z@qgrBOU!d(rz(5G$mv>2SoG>_UA&(~T+W~f zEp2WPqoF0Eyo5}k<$TG z{}@y9Cr3d*423zPZ*=FOK3|>|zHTmMC;il{&|<6H+QEr=UahEabu}Hpa+hRpba3k( zSgb&sOs)|3rRhh_F`;)X*>2C%4G#)n>ylCT2*_mIjgs_5-6;N0>%OG`W{txu1e`zG zxa^a8HjxO|HqvGqb&PgnTkHJPd&2NLujO~vR|~r82k=gzAD=y|B42w>VC%1F5`j#5 z$7R!mm$8R_(Si@&d)VOf~2a8JrEiiSCTj!6@A2%UnkgSxG0Z!P$*P6aNl?I z;5_rpTJp3!Wc93AC4~mT8U*kEfo5<($P{)ee-}8{WoI`e?Y-a*9BRfo>c7`4{H}Y}u3+;5_*c%O=;l4j1q(269AD+T0*!a;pL zvs@Mo_7q|UGoNA1O2jt+0!pzUahpt})Z|Uj`OZv5cxq1#rJ#G#_pKL${o}`H$I6#w zAhoEffmv^dij9x?7(=$d?MRIL$q%RQ6ey$COZz)?qr~Sn&!LjiS|{7~yv?8Z2L0hw z$+=m$QaWHe|EE84y-|?mqE*YlLAzE5#09b3^oQq4@Z@vym(yxTJ!$x<&llti4{U7&RD|?5hxQx$%^wdnK zf0EG>D4?sDH%ZRHC*gbRnb>=c+D~6;Zv0SM-nZ0b z%Sn+HAHBhByxo4%m(fe&req+67dTaOJbQ5uZl=J>q!tdqNFptQPT90UfJYJu(6TO7 zaWgxi7c-rkYpk}KWVWq9Gn9fHl4`c!cw{fc5-^rf8)6Wh8d?JD#F!S8CJnJ)nUn#G-Qhi7xcG}?X2XLV!z z^TOw9O0lSck88m=*j@+F9#wIzcyuQAp^LUbQUN;y#ns5GAUAg*t|uC7M0u40yrYcf zX2d@q#ICQS;we8jbu~Id3~yHjD#%KpV*U`UgT=w*LnYlUTJ4kfY{mS18*B}mI{Xi3 zlO!_zVMQoyaAdRyIiFu~5YVJ>f)U&Bn=TN1zFKE*0su09{=!`jXPt0&4a zJ6EIdDA^1ua5|Yi5IYM_ ztG&w9feH|y?+WUV)W8-q=UX}*M|3(8p&sj#AeZMeW4UIJWypB?5sfAW?N`c`VKkQMV|L427yuIe>xwtv6@+-r64P1I!-lqpjr%qen7l6QKR54y! zI}Hv%+DO)krvj#CNYJzg=r~+oueP1e$fPyuv3nS#!I)7mT1}pHV>++t=Dke(nLQE6| z67}(veDt0?XO}|4tD_$4RF#coG99gunNew>CaQfqPAW$zDal&3MFed$3Y@HIhN%?7 z&hf|=tmW1{%T~}dQdX8;%A7I7CfzY2<3t(S>Zr{zF-*LMBHNv1$7Z(ZBhLn0-0K>* z1{bDI7o>s5P?;nRRc$Tl#oZTy+b1(M>?YMHbQF#N2Hx47UOP={6YwZdK#cPLu=k!p zQLWv&sEUFLh~%W8WF_YeB9fB?36itq3{7kWLCKOcEm>ldbIuvbp~b{TA7yEK!4j9EfkT>2s0 zV)_Ps!SpW+t7(^!yhxnufjrY3JXGgAzoHX0-pxq*RuJ31f83C4&mhwy*PcPRl9-K79Dl_rw zb}OU>!c`nR#}8k?$TCAo3G{g4H>0&s$+8)86Yc#%4o5$$b;GI zT=j>tQ(k@Dw#AgPV!NqwZDLDQS?%*^?`vST(7?9aaav2AQA z^<2je(5y75%snyN(tn4k%A^!BZA$o@hE99st*JM6X2)F(Kz@>aIUOFJacjWxU=27y zZ_szEtU#D_FYNI73?_FlZ879@wKqPBWj|cK({KWfDc|Ll2tA3LpevX1&DuKLv6ZVJGQX`0%o^SW|5Gl zAQZ6h#)XeY-^sBWWty>UtgC65E-Kugm+(<6-m%GHgs6F7&8?bX)GvIdR-W1XU4C|M zVmhH?Tom58m+ALbTXuzfxRVs_?BiS830CL3B#UZ0}!|VLDhuOa_tZL&$0lpBrxcU0$ zc`k;LB(9cm6k;7&YiIX6P2XnS3(UIrxU=W0QrdiD*Y?~Ru5#&(&FGJ?J_TaypVrB# zhY4CUiNC$9QV1IL6K)2$=KaIUDyFh;mB4$eW|$Xd71+`7a9U+cD-li+t^MA}B$i^w z+;3#oG1Hiox~a|8wh%RfIWJwxfTiJrU@>VtISleqlKWeu_bN1+>9*brMBTp&RuN0BU*2vr0+BV)Y_&6cFrUY`LGP)Na zSd4m_?J-zO^sbNiUEen=BiQ@OE$vzz?e|pn)~{y?(FYOt&j!_&@w?jFTeowAr#>-b zX_8JQ3BF$zerxP^VNGIfC|S#oeC1{qSP|n_LTW!Yy|jhrwyVZ=>KSmi{QHwj?hkD# z*@KFT^rZJADUev^I#2qo_WHOujPZw(g_)@B0^S%f1`M-pSgQz&8x=IQ(cB2KcJB0( zT&VQKlU^tHYrWsB3cbWP7S5S-xg9KRv0(nTUpS&H#x_CgbMJmL$AMPF+`%bfRNYu9 z8guq-)=9d&=)1Hm=%qZ^d--MHmtlL^%;VHl)?^|6a_1*n%eQXU6BR74s*+8ll0Q>g zzsP*)AS<7m@bxhfeE7b3#YOqADI(0SSc#dY28lYhg zI2#oWf@7zMZAeHoXz|EvW(?UQYo+nNDR|*+ocys(V;{M!uLupjLmI%(NZD-GvQ=6; zYQ`>+KS_Z>exnz9Wx{O6z}~JHEB{51qT77f*zR|707ZUFzl|0WkM>p2hoNgRj^huN&>ox7J2FK-e0#VXs|C`769{ITma?lRQ3A z#==Mv*E$bJTZ*eD9%)cI-v$A;X$#iqDV(L56nOk2l^m>Mzbtc49GMl_J%*Nw)}Z#7 zrAYT7vF0K8&Av|G`jNf$NICeDW1f;ui?ixE?sE_8YhraONKX9VDf;FkuT8 zHIR~Vd72%Br(!hU$`iU{fCP{sdZk}%c2yH~Lrb6Ax?o{1e9Xy}a<( z0^_>dE%xZ^!?BD(208{d&a7;%W%u|`gT1P<-}M6C2ET09j@VITYfmeH4HR9VuW5|8 zEYa7rAm~EC66(eB1I{McjW~sN>Qr-&+L?b#T3j7`_zXT`3d}sawGgIBOE|o4Imh15 z0qT-@z5KuqnV-^7>Ug|#Bhs>>=X6C*ou;zzwj?~EL(MhuGh!E-NOP~iQhn<)i$e!=I{M6$4m zY2${G^AMJY@n+M`il@d(aYTxYBF%ro(Khxdx=RyrKKmii98mb#)|$H6~X z2d0wnEe|h?`_9!u1v~}F%It&c*1Oj!@qiCJENIpVnZ)_~=lGc^ z7rMWRP)1STXQgH_S~o4jjdodX3w+w3T{wADkrRZcYIX9#(Nxo)+bAj2)aPM*O}Wj! z=g0jn-64vfA1erD&B-z+$Wn_s0R~L@qHk=>u}pm{zS?Gv9j6QBAn6&zd*Z+1XGe0G>D8=F8By7~-$Em{{4`GmV#9G3o4I2yBmy>MlBGyZjsn%@yWVpo&li=q zIn&;IY?#}7OBoJ+1A9L76cqMD;E!!JwD}~LAD=Y3iW&9w^w$7~#$ekEApMw^Aq8yB zYp#l&5+(gFa>Cgj9#sEHHb;fpG8bSkv6(gM!}aGEKg#s)_imjM70tcVH>BVd?mYp} zg&#u5R+dfw03_O_k^`qV^>l|rsoBSVqH&3!utaUeZA-G#C6H)h7?D~E;GV>HH5H&a zD=~8-*U5usiqKbPXJK(?@YiY^aUb^4XId!4ktBNK%mCsv_*fJ2K5E6mEZOMoblRua z7iK-O6lt?+P^3@N?9CX^*J}X;(4FfMzCTszy8!ku6}Gcq>5L3 zB=d9T6c(Mzq|Dj4 ziMVpDXi0xwnoxaIoZFLmm0-o%(SKcq#{u>E)JGxYm6&a)|H^4`NF_2Uz_JIY-nlHP z}NS9UeOdOGG*Zdp0n z)K+kF;gxIVxMzxt0cJM9GV8*;s$&hRVP1Ud6CxxFGgN^|ifY*mP%A5_Pt z4B84cUV&Lz{Y1v<(Gy_JeLfgfzR&Usk&~xV_Wxc4?q1C>_b2=bMYh^T7keo(6?2Y5V+sKVC!jyn?qZu0?}IOK~q;a zH!<3RX;*lqBQP{exTeqGqfF~M@7Q0y(+&CO{4B0z8v}RUcrBt!Isv;A-~ymzi+$(g zNXiV%I^9L~Oxtom$ld0wgdxUC+0kyy(tuiijd4$6Qin-vigwlLN&g#?AiI;_&hvmj z>V&ZP{W;CCmWovCEC|lJM^Crix4`zdC>ygti|5R5FH6R|ubJ~2{!VV&I-AyW`Nd_tz7A>`kjgZciT>fH_Y z6DkH~H}F_P%k|?|uOAcR!Cm5*2*bJfB|4`R{g&Kr@L5<&f7ZYF43Q@WJ+rc~FzXm$ z`MmZAY;A8|2i#S=%P1pdz>q>kX?{u4ByCCZpzN|Hmv4ML<^gY7I{Atzl7c3XF(IDp zfwN_7H@$g86o+fGaBtNbdu~Z~`aLj)dr8h(dsvpO@%Cs4a{M~Oxc#9(x!a0WAThJ> zsnpms@fGh7h~yhqeM;1~aTtq|0C$)4uj<%X=R1P0%!sA&qGieEm-T=(_v9iW#&5WG z`gpGld-y6UVuFt|wqRwp=)`XuBQ@wRDXnGw(T^CIaZ>6(-m#o--9CmW_`38CT)5Z1 zal9u(8c+7^go3Pahl0KR--$MjOQtY$MMauqVvs%3KyZ0P`Pd8M=azgt$e+*E>I3Q8zK~LzyT2Cp zT4_$dOjbdPJ_iX&7@zG)HlZZ@-~-eASD>K_mRl6VEMTY>NtI>gd2H7s3iv8*&2*CV zL7nK*l!2Lo@|l_Kz!O2pS<~>oN9(9lE5vsO>3wt&Hv(XTo~2nLp_t{}3m72Ht!_-c z$3$^J{c#4_k#G^agdEKqIdcQ9O(AZ zE6tAA)EVF0#P+l*4Y9m_xGpfkK%WzV7fn3IVp*px@J_;kh7kaEqh^1>=onh9)L&q^8Y@9>rlO|ik86|ncB~8r2x7LlyW$hbO_*>f zpM7xk`cMcY#wa^}1C-xcP{yy4&OgOl>@W)px6(PS>u2cOZ384;EbyI(&)J&f3(~RZD(4R&9H3_;n~nYJ;Y=IL z?dA2N``iO$-jQ+5Hs1M9i3(jT_5W|u91CweL7F;T^U|RTqapf#ZV@YYRKxLEULpk} z-?&OBY*pq_q;mj!z-Dm%O9diSMCwIZdh4nAygbzX*}CqnP)dzh;)OPip4@*4J%(R$ z3HSgj$A^H1TrKwzGg#sDMH$B?#=k=u{3Cp2rncVA9KEq8^F@gimJM6Xm`R$0yJd!PE0 z%xA24*i8=|GcDFfACfAFJbC_;b-fwsbdIvfNdkU}q(W!%q+U5Pi+RU(_4&w`czFLM z?%8a_5P=4>CGwo)Y6Qawi&b_EPaD543KQ(ZAU$-$7M^`+N_h-E!fq_8rtNe*5=U&p z=>+?wV%}RPh%!-XFoL>8a(-va_|ryD^xsmmR+bLd*_x@%J6Lg5k`1q(2!cIg%0zeP zSm-NiIC^f_qcL3mUsmD#+uq@tfjTqkdhg|w{JDEA1j!rwwo6m?=nU#oQtzdO9`T;5 zsDQOU-7mJWW$7Y4%=4X7u{0NFd`ZB-Ou5_e=E~V#W`>^+X{jYRrAc)9uC({7l!I~e$}ILJX_`rHUCq@QvfQ#Iv@8X#B+vMO*L(P}y7CkNdB5Prl2hb^m@KQZ{Fu8SN#WXr0et_`jg_ zS&GZ$&)F7R%bOn`>td^Yc<{h8;;SVQ zOHqDSeMILOj_|m6O$kHBe@~n$j6xP<>UWtu`N+)jH@eLp@r0zA*ogO#PRAPyb%&M> z1c&m-K|~9c2(l(#hgs{_nwq?qw~<+Ej+}^yG_F5&GR)(10RQ|fq%c(D?!Uma?EGM> zV?Ltjiz!>hm>=!24;ukCYVi5NqDJtTYRvZ@g^ib53-aoEq>R=Cu0@=w`V;I0XLic7 zYM%ll)KzUlziH70?c!>Ftjkzmf6*76Zf@o&j$3dzuHe5-0UBM%Q7HE$5QQkh=4^;6 zgfcsQ1P<}INet6Gti<#5=Neivf7-+;Fv?@NC626;#*pO=oCdDckL4}@1NDmdhO^DR z>>4ADPQSSJppZ<7m-^Lw&wK~r9ym*!U(XUjK=4wHIgXNgQsRI<*clM1s`jM=g z;pgcO%7%8&vxc8Sqn_nR3S@+FVb$2rS49>jGKKVwaY^`M9lgrQZ+yzc>3T5d&1AhY zYxwpoQ(S9yMso@n7^4*h!Bk>wY(kKe7aJVeHpdc0hh;KEc;UHiNE$Qf%Gx>U#k{-= z+C|n1y-by}vC8T`JpAo^_P|t4e{I5Y5DF{nJFWj$=9Qs9)6S|qs?@huW8$~`_-}Z zq|*1FEE>91OkD9cYZh?TGKm|@s#@pOi*c?P&aU!cw~@QOwxnUU0Bv}kR5mq7bV1pe zE#ct1c59##5bgCmu`e_Cb|>wbm|w{FW?rT4_{$I`4T|NbQch*Cl-uhbOKyHHu?)X^ z@)<}Bfjs039jL|yX67$KE1DcSN^Fc53w zn=6CcN`**|-~1$f592kSrO$%kN-R&3b7eUv_~RBI{0lKA$jNX@=`!8_w;l_Qf7JQTJw0$8Rk4p) z`|+1wh$X<#PSn~=l-6q#A(R@ScoaXWNBFjFTx1cW@{z>;gbxU`qHjWbzc#|NRny% zI$qGsD)S5pa68~~anG7@&nXAKvt1iDVoiAA@Wm!s7_63deB-`AgP%k_S+2kwhYRI~ zydoNyb~4QG`vxhO?_E!tmI-;s7HSDU@qs3+9CkR&t*@WJv*s)&42$Bke*Sgc#wnSsEzTf{it@&MOG{`bv4O_Z?tjdhUB}q zTO^*>_7#`UX6sS*^2b7FZ;&-l%r?3q6Mbzw&@07PL=_}fdQZVaN?bd1{1h)S^M;dx zPwF-9BbW-;=JMdi9cLajms=e3ovosE!;T8M`*?=42EYE+U0b*)o`kWz?y|Ug)!0j> z|9zV2c?}sN{eL6}^co!7#!Krk+^x4k4~7vc?xa3kJDB(EU7sv`4)Ii<^5NAVbb~kr z6m`v(NvIH&-!dC}Gh0 z(T>q+gpU*p7C>2NyHq22=cc9a#i=)aHY_FO1|Q~(j7w*cU=fsO0$X@Ltoi?762FM*mX~96VKWD2VvE#gTYidbCqQs>4>J&~Q zihP#l%=F!Cl3ATkjaX&*Oh+Kg^Vc6G{tZ<&^wIoJ!1s$pCi!21k2eO>tx2LV{X`dB zTps$rgde-9Ex)VD$Wz}Pq^5p_nZEKu>q0q_vQPU z&iqn{O(}u-NK?lc!vr9VwXnn~o9Cd*GBr7$+NnOZvFNFlh$2JgsP}EU9;W^iFQhH7 z@#b|{XHt~l>PX9O+Vbx_A6#;x6XoNfll#yXnqH_;#)hHsmcoAnFOw?c{kw@v=N{&8 z9n2kPju*|W{bIS271$~)-ln?oO)DNS*iC#Q z5zp@~r>t3+?sny=kdQHJ=fC2oV&M*(|AuPHV+s8+K4D6Z@QcdJ1EC@J!hFaasPmRcMl%= z7pM^~G(-+2rZwbw=Zj&_6{?GbZ3Ftk#}r1jmy3F6(rc-lvCR1hK}w zm%>V1?l3yb2M+Ok2f4r2Cibytn@8qj7OiB`9z@zh^t!grhQm^?Dy%QR4YNR3B;}1o z(0xtIgK-o>O1?ICOSei7noA!*QkQ|Y2)dSvw_(x^$G4SUuFe6^8FJHW`_lJ$hfYNu zqElRaMm;o|b3((|>oh9ndwZGqXQYff^K(t|_9P)F2E~<>xcgp?1w}3dw^UG4{)w&z z-lgts5Y4~gfOb$Mhq%IViYDHO=v~YPC>dBFra-_02J>b&uV>45mT{-7@^|AQx zbDD#jiu!7YV@>38%FT%lejO@z{BfU-edOfn>(X3?XYDFsbKOt7atJ4qfM;_$@(Od& zF_~ueY$C_In@p%|p0_kXQ&UfgVR=N!*p9%v9Y1Nu7QK8YyL)B7=aVNg>uM<2sNd0H zQV`?m-P!5xv}hO02mi><=IZ!ND~O4;A8KyUD>Igm&P!P5C%wTCd$as?`PNJ#!@BN` z+jved8G|P;ghj|Q>Kvi&{$v1rOdI;WNm7<9q0NTgw2ur-A<3zA$ri0qUM&gMwMYLg zJeXjRRdz)MCCA7{l}^$q&CI)S%@MYh_R&LSV|IyX4e#`7xh{QqJsIH#MrqxfT+&^H z1~PeH7g-I^dRNRCRBP)GaE951&#c_V8ZG=)os6YyTpmW7#)L(2^N1WP^ZT?7VxV{#dJA(Qt2 zfR~0rCF7DLok#u!g5o;U{mVsZ&x8yXWZggZaLRd*`&W2?`RM<+@CO50!@x8D{8UO| zNd5n~z|{Es->bik@qel%8Zda6tGO>6rdH}f_@6~u`2JV(1+$G0sZ*^^{>~HstO)z} zm^gaBbBI%-Zbs&>93OMu#(Z!`V?~#F5SHQod-d1mLu(-jXI_GIEnD!V-_ zpTwi}&zv1oE%PWm?VIgqCs>jxbqZZ!z7;;5P%?-p@sP5#B(2=)=$qHR|HJh3oB*&j z_abE)U{@89K@jkDCUEyEQ|Q{?NSHfJ+=*CiGKv;wL zSG@9&)-m+WQro(gU+GW+xC{S1a107xRC(|w_RLtM->x`emH+Ylc?_^S+7WiwMHRVH z=Y3ImA6$G+MuYwa%J03;WMq9Fsr1O`Ug_dl7aB9hJ8z{^^~p&`9ZD1p9RiPonoqBm zQ+=lZ{HIQ{%=lxrl%r7ZsaHjZLEWyM^`eZ{%#_9lLnX@!V5w4^ z*Q{GQ^|GR;{|$O$1-Whg>DpzDc82$@DdMy~5s=ibYpC=Z@74N~P(*WvQ)@+Ee)2`URZ)`ey$#Et5PJCPi3u3m|dH*Z3W2CzVl=B0K| zxSSi7O!5YVL;zTmhaEDhS_^|WP7&9S93Z+mp&8iuL&2t&{hLHwRhaLYWQrR#SUIaY zWNDEz-WnM_AI9Z8S+4_a(YIY2!wb{GxgY+%T>EC@dkJ(s^)Ymm{`*NcsW91~%b2wc#2|J~mq3iJYY(cQKy~?SMxJhwl9XA{T z;I3!}&{4NtHoz+gk~Y+ymK=7(sMA`m*Or&nfy@Go69v^XBUeFTi^Kw^#h_hFWqm z{i_y;MpcjSza2H30;Y-5*j~9}J7q0&VxGs2<(X+|aMTf(E3j0e>B7Krsbx zyQ5=g#c2@u8St#BdCKkji|;Sg3HWqh@`})wyrgcA{{2LEW?w+e(v{?(7ka1-)2D*c zLg0ffoVC!&G5b@+Tkr5fTb9-X8Ce%2BiT*Q5CBq{`f050CjLj_hAh zntgFt46wtWv5;KKSc>T3O)9EG&;-EUyfj_sCmx|jBnKst^YfdIAp^Kz*b*2)3A~!N z#&Cs?sks8vyy4^lkTJ zt%GgzT(=o+hxS=u_X=M&-qKgey}OVLhy;O>7HcNedvR_lLK&b$Y zMvY-eT2p>_&sI%npZ>xL`BLI4W_OcA*?AMwH9$W|&-&YJ za$0N%({`$QE~uk0Qp58fPn1wV*-WQ*>U7N#eZY~fdbD8v>^f~_y-o7$E-@w7J(b0? zXy7;C>6*_X{8PZmj@wU-m0^~zJe1;AF$ zt|3F*k1DUTqALzXfx|KL2BsaJNA(R>jU6BTn=j?sI?;Zmpk2RQsg5JiMjh&cU~&`7 z_{@KC`P6bW{d=iLf??KA58f7Iv`4_M(lKx1UHbaaZ@{f9IHJ^&f+GG1^%N2gY^Tnt z0;>04%F*PpKQ!UJ=>=}CAfU&*LTUBLg-J1#KCn~m;E_4W(N-2t9qiQjIpY)MXK*lL zdkis_V$qy-dK-|i0w2(4xx=%xve|~XZCe|*w6oC-ePniLK&lZZ`?s!qd1WS@pt5ey z)C{R96DJR!(K~5r`Wn+Xf^6)y)Q@km=f;hEWUcZYZ|h*~8-m1{Pbiq4poP)b(QUnj z6?_zTZI5qd0uIS(^Lp`@6Xm?^(Q)a19spRF8-IDjsU>O;gnr3cEmy()YVMRCaoUE` z^CYKQEi#>TNPGB9q@RDRu`V(i(M?=EIvf&3gXRHVnKAF$A(v9QxJHyf(o|MI_VgwC zBvCL(vZ}Q{9<6T>uBM%#>OU{HCg(m_UVBmNU`}OAIb0zFjct50Rqq%;_^mZF88aa&&J_Im16=LmHG{IQrn|?d!pN&WBnnMOEL7 zHVU1TnZ52F?C2TY3|9F8RwQ%hvF;cB;<#+z8Ro%nmGyKiPR^`J-`zg6Ua5~?d?0VQ zS##Vx)aNDnCUh;!^;Y%uqTH@>P)53ipJNIh)@*lk9_;}IF@0AA`-KUIZ!GGFo2QOs zoxLz;m>@$NAOFK$9!=yqo=&CH+487FAshSh#y)YZdr0mYLDP1@jOGnRfz!%zTIcqYy@?;Sto?6?Dkf`Lx z=uibyLvhoM)eo~kZ1N9RXcF2Lb*?JNq!?Oo?02Sx|Ebf~WZSK!gw@?-K;Q$o`1ax{ z%jFbz>uu>3h4H@YKN&#DOAJ|#X~n6f)qXJ8Fy4CE@NAP!bVB-Am#Wi|3-Pr}B3WhD zJM0x_ilN}kUX#8%N#Od_U7s*CJQ~y|&bz@&nju)g!>xQ3oHC!059+M+*~7)i)p-j! zbcAW_1gfmpUDcR7W*|ivIhbR}MtIxI!CeE1d&}OeeHfMie`LzX>^rK0B!nA?j}tfb ziI&)g1@^h%R~Q|qJBP1`ZD361-E}^q*T1qFe+LhJ%5UwLIem+Bxa5&LEW5WC&K;d! z_=}_aem=GcMOrAXmlSu_JdMDD($$Hjo|bPIDpP%eWUaSdNB28BdZoUCE)NoHD~jmy7@Q(ft?i9vQ`yP`D{*-WeKn?kNI!h72!;GjSv}tRCaLIWE7{ zwu<4GJ|w9aC?1wMpK;!gMi5Ef%mvuKb;J{(fee}$CVJE!vKptE`#6tTO$YhHE6@mh z3A-^zdy`ab3P&+srDLjmyI?Z1y5zk>tHH+LAK<{f$~#iVtfT>H@@N?mQT@=0;IMpK z#4^RSWx$J(cgB*0zO`(Q+o}!xk^B=`60yEAH`E|x690zy<7Tbeuv|_#bV7&EO z2OfH)5?j@3-nC|1AFaRbT5$@B9Nq+O?Yl8rYNyLkj;&~S_JuVTj5!{hCtYu>pp~K` zO2&d2{|TeDReHnTUB6B)eqF>tpX7yDnR_@VeY0 zbPlU|Ml*{%KfpRXL)PDuvgH<4riaC1cghBi9?@X?ic%`2X-(B)Mi2|Ml-wuA^L$e7 zQ~X>PulWLs0N1(R?sq%mbd6sK^xg8nHvBFG10tP_l!=$o;fTvN#C?8_hZv0RmA$it zMJX=JKS|mpCivC6!W%q-i>WR0Wh~b8Ew6>QUD3iv3qzGwdan*w-O#&jC!xLj^$K#) zC(|pp(nh-~CY=1h`7qvww@L*1#D?dp9}n=bSxOypvx**r#ASf9Trn}KA=$s*&&#@< zJtV#Rf>+k*c(YNT42w-xt-1Y6xH_IOz3)*|sG{KgI`l&j@^>*}&8tbTp&CG=2pTYn zGhN@Q18gmJ`FV!Z-h*79&^LL*#+HrE6mb7JF{%4J((#G(6vztS*>ZS8AKix;pgeqZ ze*ARu{PLM^pt~6yt*;O}Fn_$$1pDTvQx>xiw!U%@edR&@CcTv&>}JdI;Hyoe4YdsR zn~>Jo)?wVUxTZVFlS8BiP7^Ncuc*1&x0rax`+om^?awZ`taf!yV)U3b&E^+JyL`>$ z5^YD+`2=sK6;O|T#KBatxm`=rP&#QQs7Av@gUsY!<{hcyE$c;<7n>k`4Z1kxAEQd1 zLL)m_;4?vEVd+7Z4bNF1H0c=J$rm<%+lBtF{{%tf6kW1OU7(O>ZN3!DxzqOmH<8cf z)_Q^9bAnX`sZ!Ywv9aP6Z%|+DqRdN2+hY`8otXdkB%I1?^M4GoKRd{#^U8HeiAREq z=gOqoq{}E3mz+tf?!PLZ^8qqp+AH99tTK{wqJP(}& zLgyJnp}@fUL?gcH)gve9WZXi4CzdoC=z^LcAoKT8s=|S&>hwB zS#J5gBO36!Ux8toCYY15uhpjV51%l~tor5dX5m@zcADrH%<)xg)}_=&-`~Cd^GL+!eS4buSOq8EH{#Z)d$%nVPkOk9Owj5Ar)Jj<~$UH zn3F_X9)Yj!5~`x6InSD)*knr|s=*oKBqYA=EDhm6criNgrf!r=Lhz-~$W+R#_x{10 zviLj7oo5UR4>I3Vnzn_Xzrn#$Z_hOQ>GW)fG{4@ykzOUrol{=xqco1NvuBvNFK&Cu z`Vr`sy;ls_dnHNng4l8ElJ=TWqQ--%A@gXv{|l}g?Tby#{Hq78mm_lXm*%36R12I4Es`hL1_riqvYg`EJ~Tt1^L?y6iN7$lJZ*2%G+Auhmy zVPmELej$DH$pqyQa#6q!6Ys8S9t2!o2|FiP6R($99?MC{OlnN#Od_B8Zn67l=bZl2 zlj`&hMlJS*^!Me-*hKB-v3WXTsH-`^m4dq89hd|bzv6NL%?Hww_cQ_HftTMTeYQY( zzTO*3#v4mPoL{z1{@i>LIC}8m&1z@suEYnN2|2C>kRA!Mj+oKyN89#I@hDc zV>U* z-nyh`mE9%MQrF(-iR{sJA}2o6!R>d4EhM#dU}V)1wRh?jFJYm^NKFYBnSlwsR@AKD zE!?)0i5iNi0uB%yA^mCW&OZaGqfv14=4(;4@fNyb<2gJ`U9#WRI41X>c}nr1S{#*z zINAue7ruP5g4ia++23LMgzn(=JE?G3G%+=O0r2LC@#+(P zz&A;N=6`g?ny;^Z7qsZ?YV6?t2dvOX3~2Tx>#i4Sc_QdIF-~cdbD3g2EJ13+8h%NO z(r-OAl2qDKDec!Yf;*wZ+I+m+rG(=SS60EOZOfauDsXB&@?~HEfmMFASEgTPlya?H zV*b@FI#lv{0)od>g@_~FXudqGsDne$Cpdn`i<`$tm-hbs=vojZYdU8U@_!p|J+h>0&vZEY!yraDR+kf3Ai8=cv2 z_Nsy*V+<|O<_RY|LZ2+zL#E_ISv%j~N*MuWDA$6( zC?!;n7xgFGC1pD|_AKv~FBy9lw!xWAq-}8j%$rW%dc6J!5S`C8cA?|4|0$=?@!bQ8 z49)Oo{$t^Y9&lS%Rnrl4x#@CJLq9}PToE`ctlEM!T!#Myy(8OFxoYcOK@0`?6aqJ>{A=~>&+@S{t4&E{*(bU*0e;4Uo2AN1_Z2+ zH_bH4F8Vyy$`pJ!5)MH)xJl-Po?26o?zVSzy9z2|KaMtdIoMbUQ)MR z9M7I3foLz(^hcd927MSD`akHIcNRe!|AB5}{`SAc)lB9PruW^rjDn+b7?3cAI_?ID z(UrgaBY*BM;`0CcSQ-H4_Ngpo7>B)zz5GZHaHtRLA-OTMFf&)~=FvlEtg-+6)&A{^ z%4?oMtCRLU1o`CE#)eDe*3DO9eUl!mwLctw8O!nO%kPWW8R||GQoG1 zMOEz0(gBxAt)$$(ONRZJUQ&t{Bj9iQxd=CNA#>P@z5*R_97AaeNc?43R{)v4gS9+! z_PcBaHtl(3Z+R7N?iAhND=%kc3rEQ!Oe7L&+1G}-fh1DB)+rYSoZh&mvbsHSj}Hx{ zC|(Q(1NRU8LgkGxl$aoPRfJ)*c*oItu{EG{JV@;J3OrtSB&Tz|=F}J+4g8h*h1M?= zs;X>-^y-))ID-)gZqQ535&`YVFim%v9kaTX$FF*I0GB5_!!NpI2Fm*2a}q&0YW^u+ zI5R$W`+1%OYb&L9y;C0RP+=-XQ|^OUCV6hs!b8Q#Lx7rIU7tP z*ZF*}4VUZD*PwcnLq}(^=>y*r$2}Wp204at2)cYw8MY|ZgZ_9rRf*n+9`B?yLhQL2 zd@ntMs2O{uos0s8n3md8WYSaT!Xnos$>aH4`z*XoS589$&OZ~K@CBtLuZb2 zxlAHLmxH*^4kAiEW}7#P)e#HbI%N96?uGibrcCZ1%hq-F?ek?OJx3qC$Z8kaMORf9 z!P4=KdA8YOHV2&i+qGJkty>IftJuaGMUClVbuiLZd^*M1FXWUSBN3>GI~RgW zQgI$3S5LeMK_dh6F-mAoy;DtX{V40^ILAGf(&BEQSu8vovY?w7UOA&37Twy{f3TEb zYJv7F?zkhz=-P<%7?GLJBydx5y9>)SPZyh2;}btC-kDmjvY{)0cW7!(kyujf?i$j0 zYKMom<)$j$a8Hx>+kQ{q#8FdujhYQ}Fr7EyLat0FQc8^Kb7o{*54@Oj$GuGx9Obw4 z7?gCvv%YUrf9;tnGRO?`~o-ZdE3Kdm5 z)tog~Z?JM!#R+NLh~p}C!*XH3mcxUr^ZNQ8qqSUpfs z!Z~pgt4h+lu(;8()XoB#07OxYYN}nURBhA|pBq>rtQfw1k`#C9T^Pto-}6gW3$VaV zyQ+anUEwH~g})n79N4;PiYZ0>*+E-Jd>dr-o6GI*sJL^%H7Zu2?st$#(sT z!BKzFU+nnA&ej~L0M^j<8PU<>;Zl9vVj21 z*uqba?9iR}k~V$E^|2Ql`Q=+ym&Z0a!%l?HMf%3Q)C?`rPih4_`IbE8JYl@(=a19o zTqccDi#q`a_v4UIAs0AB#zvvc5V7N8SLPvpJQ+4@P9d8($o-iwNK;%hyhM82{r1^d zpv`B87zN|4(Rt00(WcAy6|1i;xIIt~PYiS|ynm$c@eXa^*I?!Vq`l(0WAJt9vwGJk zf%ve%t;ReZbY*)9cb?O*k|@0=XrqPLu`v|s3t_>1YX+yIeI}%u?JcRVIO*Pk7O|Go z$w9;6c^!hf;HARZ1L0J6TKwn6QPB7J|E|l5G5+a*BcSzA5THp8zTFiDBEm*iO+GV0|o_cMn+wS zOZyZyA8Lx#wmsIt^k;xQe1*Ws)$&^%SlONYgP;UYhN-KCmzE!q)3lM-nVLW(taRRD z1jcZzGh3#N7n!NTShWhj8WU#LQ)Gznu_omwrq3>8E4E7{o<9Sx6_nWUdhmwtQ09k9 zk*YW(oGnW#cwK2&4iQ+xRuSo>hkohNE0C`%s)QL7ePwe4EwrrA&i4<}_6jJxfeBCe z_EBdo50KVBVF5Hi3j#)ta7=gISRzH%eVh5#ID$;yFhNo1bhO@^4-m4&0pcWokC}GE z#W!~PsfF~HfwLL4#&YSsZJ1t`we0ts75pHA>M@keu`ZfgEeS{um8oVJ$rvq7s@G)N zG2ClzMTA2VZhO35(RZnNR3k4_@IBu@p8qhX(Nfd_Cv)u@%YPV*N{56y#8lXGl@=RF zp3MWdd({z~>YzNO8K}=Qr;?|ly#B3*y+xcsroPk-7ZoZ~^=93?Mvsf?D%8?$WqJHy(m}fZ!G&KyVAzjk|Yn zw?-QG#$`I+cm7l7Ox4WQ+|1Ox7hU!C#ap%aUVE>-*0a|0^ts{87-#mz1)Wr_pNb?6 zomA9*w@7e0DG3ms&EC!6fAWWo?e70I#s394)~1%O`l4Th8Wsxj7UG z;wVzC`#_r&eJr-{EZ;)Bwi7QWoF~L>p)Ha#B^B8;Kbl)9otjg!a|Pw{qjI}(O6*Es zP*NFND2bv@P!&CXq$$4Dw<#BGe-FjLF3y@(`4Q1_OMc8a@3p=I5E4&EQ~Ws6;M&KA z9YtBvbpW>@keJw9@tJXySA%;U?7#*25(iv~jy3{S{8N$K1%n~2+S^%FV`$`abuAcZ z%bN=;zEKZpGuQ#B4FNE7`<13JFu_WZSoZpba${-Zb;J$2)?rH$Y z_9B;YhkE&bKF0+GCGj{m?sUMXv<6hmx;HU!qMWti6X554bGfYN=mR6LZ-_fxoJe_l zysw#=59g)MJmsr`7fRn1Q{(ua=~Gh6nwaC;B295+2IbmEae$T`k&_gK$jPv}g?b4@ zFMD4s*CpbY#&xY+^B;LgRdc?aT(%6aaw~VR&E(dxYa}p6uCwR)N;1ZmIm}i zW#O@6HETAUZudDASs{xll>KsI4d#8=Pjc1~Onax0(mB6Nqd`NuOwb|OGMG~i&6()8 z3%rlUbV2E|=KgNQ4RpaWH)O-%8H%he%&+Jk=RxvF1#ETN+`&KR>^U`8f7H|-lXnP| z^EL^e$_lsoBq)1cUU|A1m8kOtVu6e6w=2GTaZvOye97Jumk1i&yvcBTOe;ExCJ>@J z5{`oVUOtw(`sGBx8)~BfGKiz!ZF=9sBKAYTE9mcl#}A;E5Fop~N+HK%}fu+FyD)#4w6VdIt|l5qd338rOVW13{09i`3d; z5&cE?b7i26ONvu&-Q75VQMxg$o?IfLHSu>G8(mr&*~T$Xz|c&#e@twD!M%kv)UARe z5RWyVw{cKu9x1LU^<~3a;1gF>KI(Z4{fXwJ`mW)f3KlDai1I`RQ(kTzeq}Atp!&uo zr1Tvoxui&}i=(^ea+1`~d%_mEOClbhOh*a7vbnxE$nUgJv!ICDFhL2YbTg}cUKwbt zIibL~jxa~XT?irQ4WD@bRr7~rs-ul8#>Y$!-_6bE0)KmJx@9??VJPUh&0QU+N3LLr zCp)I2G`@hXI-nqE6{YCb4AV+@AvwfSS4S(|eQVyB(L*g~GI%RvWOYaN(Ti=jvE!=6%k~D4a*yfx(Pv=%z0mg>3CvS-gSX#Q-t~Oq;85 z)`fEJA9oE_YU2P!h@-|fz2G8S3E)xr*W9jVmmjzDwSFn$>amq^*Hk$h;zo#Wh|mO; zA-%smNw-RCUk+`8v27Nuw&f@0P7B{P#@roHuw-xVL3++?)E|$Utm5QyUZ;w!&jBRw z6frUc)OTXW+`xWbxM%&{Y3b4Cjd#KFkuO13?7j7v4M}KSA$Cbe4F*yk8bnB-_`rM+ zE%j%c6FvkSnvO4Eh!p-dor?8zt6-5=pRi(H8G+epdHtf9SnolSywQ$@7?;+qD=Hc4 z(iSbC&f>uOHgREmJc^QusgAhWP3_!rQh659W?!EO3^zllC@fj6RL4Lfz~z0I#>^{% zMD=c%9QV#7kuje;xP`9Rl%0}%>nX~I`9aU>0$%0E6X2DJ*=9{1vZ<}I4}y`|8>AlE z7qw6&XNU)l*a3}NaU&x2njcwhmlb=0KU;mE2c>*}8 z*Hep^!^ce5NU)}laA7~wy>Hj6}%YwDlA;1 zxR*S|tTHE=p8oeE<&VQFpx$`O*h}vSxcx133f#&;4pl#4fz6#> zzlvQR<}Z?N9G9ZI8q2?vDdiF$kg7jPJlZP`-$+5?i9-EYEkgp2yJG!6i4B?w#26bn z>q87j^ndv`M2%{2#gr#C(vb*GG%Z$2dND0squ?$!rI|r#Z9)_$uh~<2e*6F@SK+O} z+6fyC8ml6(u<&^VRMqvv=Ok*nW1POXx-E4J@J(pBeIb~w+SeG6N76S3|2%uPm3LyE z`Xy{vf4E9ZuGnmAv4x z|MRlx;2EjCGrhjsK@oT5@^#+I2~Z;r>vRA=>r?>w446WI^Ypu)VacPh_9^wFotIcm zWpe`*=%ed&KM>jbNQ-DX?4@XmFaQMsM-@#dz9cc=Q2{Khzv~d=zSdV~c?H!L>m-WK zAtHm8eJRZu+oQ`Cs7NXc@EIs@Z$@06q~(B}Jd2_!eM7oRvz9{msB+2-uARzGivGo> z4RmbOqGHI@{4zyEhA;H>XUAXV0e&Y0^*QarPF(oA>ZL}+WNl9s1EW~}XJmIe7Q0S>n zmxYH(p*?-n%(V+=PL8Be!=m($2r`Uf+pta~&3d$H>e-khxw?&VQZ>+()3p&Qu(bTH zIiUtx{+;C){NE8UxRsE@EoXQ^uWq=iO- zD#HC+Sn>RAgyqhQ1A}Q2VNhmF|L*JH-XaEjp|Wm=m2? zgN1>Cc{1*^6Xj_Yo_8Es9)0j<^B%D0{^;c$|KL{Wk2SM-9~!vB{UmDmPHNkp!sif( z*#JyKGb!ICA1Zw-angn{lb`mi=bWIE#PxY36INq20YS8s)fU!PQ_Dt!xZ5}NJAi)9 zo^!KHR%6Wrwqlow)rVAtQ!1$PI%D5G93o}~?~Q{0yU7a&Izgkk4_OzFwJ^U-q4Ou| zjoZHmudWO;8WE$l=dI4k+qP(?tqU{ENdbPpjqzM;QOgABYpQjSqB~0jUB*ojBZwYO z*JQW*p{zs8VPCf1PVk*6men^{=afo+qrz@6*1RM**7d3dD8zetBo+%)HbXD{#fC7j z#C?f2TE|27tDv>pV2eC-bgvJ|wy($V;0#&|S~hr2NWvE2Q>3PDF3H3G(X!`?WSX}p zW?>HOCxZPJ*&3n`$+@NR|L)_dWspp+f}fUZggSEMvDthtopN?E&!21=wB}jGVEhH# za_M}V1+0eTnn+DsdXVFuk+m|{FC$)Et~4cLtiTGY8NfSa&{d~JsFFiZBX;|-YPC6u zrqxXe>W%$HPN~!_G?3*MIyp%?_B-mc?#sYyDbA;aDIv}UbkQn?;Okl#QWkZUjh&A7 zu^EiP#CBr-Gf%N+<|Kg%XH($eEI@3qn<2aa+xM*{tM5g86f7(5v>582zOc*Q0(G5N zFxs2o%1~tHX<^vZ6n3Oo>{C5?#2RXg>iEt2wOtbC+4gBcZTVHA^X|^DK zLTqvBd!_f0;p!9~=WlEL=Mvqs_vv>_3v$OTHWqb5Q|^_L2?>yVUeUCAmq!Kl?J=gQ zji{Tym%S+JwgxMn**uf~^{WhgYAp5cgTNQL#=_92JcA}j+m=K7MPx?@jPj`^^RQA+ z?tup$_vl-ua%R1)r`V%=j6Czoa2@i135P{+dBT^zkRg4=_43t@%kS*YMd3FI zg!ZjDK9^JYr;SKX`$&C9cxxE-#-U%5#nyo* z)qq?c3zs!MDl8rDW>SvXnY~@jou6GMJ}tHYMKUh^!fzcbjP6Q>i>YHSVJG3>!ttFyx5@HoGm@V{r!LpkpBe77NLR<^FHTAm^P3z9CB0^=XTv#+_RU1XahA(JUIPYvcLfX~JjBNBl`H+6z>o593Na!UtM-)X(mN!o5iNygtW+bK9R-fUBWU9m$qoCWA2s5E9Y@VL>^ z3`(fgQ4Q>A%=?zr6zzmmevjt*k0T2a4mdsHmWH=*1CmE*YO3rrbS`f*Gi!cT>TF#o z>(Ws^cHlDk-94#aye1<*s;aVTZMEqEgC2VXcG6O0>KT=fr$yLAJ!h;AAI=#LYYN-G zI;NsYTSZ>X7_aX)M*_iqtxYVBTe*D=1mJzd{DQ;)Bfy+vs)xeZFMVAOzBzagDr!8w zfc#Egk?Ih0m4O+*<+VR+u2+i6Ar08*)v;A*Z2d>f9;vGRfGVqwWac6hE z;9GWN+RSEaI5D|*Nb$!L8^JX8%Eo->8va;11z)v9G&P?{Yi$AtSxs+cvQ2;|cdUo( z)K|k5I};L~-vDhFWzSrAOfD#U;rk8|@uXy+nCmeRYy@yVg`l#g<3+OvR z9qsM9W^bb_rGU=a=NvuIB)j+lX-oflEGmSyf&wlTY$S-y_q_gK(SJ_}&B%t)f20kZ z&e|8&^t;kG8BniR88hNdOtr7y_2LKh%uI;=q^&zKy3 z<@aZql|iyp(WAE0-`;}mcrMk(FKJZ*Zc(+^owf>pfYUyL%zVK`T1145N50sJsVgJxM`eD__NJ2!+B>ITZ};W7r>8os z@rS?Kl9ym`(%!qDD~+-Ztg@How{nL^ASHI5`VkDtg^5(s@aRaL6%sRlM6$w@2nCoD zswmM}`ezikMm0G(J!7)_mH)@)F7=5##k_UdyWWggPVq%0Kp)E3+marllDvwFgajBT zlJM8UH@M>v2aPU`Xl_}E<}Efhp${8_4+rP(?oL293~hJF*LkXm-Uj)X@)2F%&G0p7 z=|kiY3lokPz$$lvURrpYy2Vvvuc9fAQM@U|1Ue*&7@<=v+t8dc6;;%Be5oW-ulhj( z5Ss&7RE?#5Kh%}8-cjhX{BX14=00h(b`NmrSH4l1kWqEuK_vBDh4XOz`2c`2MHGNP z@F2-0DPoX4F8hM(EhLS9_YJs(RhCx)clMc*t zqv-&o`64sjZfp{Taj$z zjDE>7DTA1svI@rrFd~{VPo$GKpmDoRiOuJlf~Ef|?(6iou`Jp7SrtWS&lA!omOM?16P+pu!ya~vP{2A8sc*6Dm z;pncE&o7|pu<7v)iV^5R-^&kqKLQbc`A6-I=Je^FSdORAUn;UavDY)K9U0o}gBJmz zaVuJ~mzc&|K1D^-;PIyv3w?fkM{5W7-i1nS>EG@_`ghg70xg+3AgyOMDHHhENbWGh4+{E175ze^NEWv@ zu6}2)*jYM1MX&h-czUKYRb}@KffHKRPtig}WL>15hqPh^p~wEIB^C(Sm5{O+v0r^K zuxLeq{7CTPoBwA2L%d4)$4Ki_1>F*sXZ2E~B~J&B*JJA4PIWR5bBP|P1D4bN(m|D` z87BA8676hGE>xb4p7>A0@W}u9AZZ}>Voua>!$ZXpEp0*o645HFvbfw6MWj>)IaF>> zq)1s%)iBxw99hoKfGLHN{G!)a9x#;+yx%m0;g`Qg(kF-AR zhwhii0&J~JG*x8ZZ5c$eoB`tD4G6fEW4``RAp$LJlj{Gf5Ew^J(tka! zhE4E?uxaoM+EQtr|0PT~vP$%CH6UL6UMD6r0Lh&Ax@PzPuJ8c)S=TPk=I4Y&SFeS7 zt~8$BjyoL_e@BW&+1h?D-WYZgeG}RF@I2@1tX6^~uRLp-?zxyjhZ)8_J9jk~gBjWM zcScEZpNr_CHK7FUoE!Q(W-OD!d_~1>o%Lnoe@Y)5IsYQQU98I^ zK`Q%welQAAggoOw(+cQUPEQ3s^BC<|yL*#6!&;zsqZ zAHg(q1st9krpodDRuV|!b*fLpSvTGH?w^?zAVjVX3KpvQ*vy;p2mYfRD#oC|H5|3+ z--+cT0%xl-V!n?Q54}L%60R*S_>cdt z6h#m+#W7<-kJbdb8OQ47>67?zwoJ1XZ>T>9x#nDwkwzdq{%gJPbP13)CYWrpYJi?7 za;c`H`Za7!^X>*5*zhwc>(=wLJ~_kc=qrkYt;P6Dp(PSS-ZA?HwCp^q`lA&YH>nqN z;{82cPHyKg!5yi3APIks2bBFEJd-{V6)xm;g$ibbI~-J)EMy6&u4loJnjWq$r!~dS z9Sn}G<`h!JjbVTU`Ey>E}2r5Y`xGaA25|q^d)c%0F;5h%u&Vx%_JM_w003Jte!0n&u zn6m;h3OLEp3;S8N{?Q?B zkHs1HPrCTGo=5-NWhi8dUdv zyh^H#aBxCx5C=z)1EWE`3Lg+!7c;!7_ctqNOZjw|2@HX~w@5Hg?0X}t+6j}KJX3jD zk>#zjIOy=*a;9M%t=OP!NhZXTZ*51ovqv3MI}>Yi-{f|h*zAbL%GYSRW5t;xPNF6b zpRRNE&kNO$8ul$2z2zc;4|j>pW7XL+kYhEkg(8rB08vHvGxK#kwX1hFvr2voT6)Vj zXiyqToo^N{p3S3|Iec9zx)WBr%{&t63}~9Xb(*6KOh{yJA&S!Tzac5te{i%J#o`hIQde}QF2-xfVI{&MWi*S|VuIB{5! zl(Nc{wJH>w=2JcERTm8a$>IY4(o^C!{Ut4c>3m6N8zCf>y9w>pt@A2c7QR@lk7)1a zYP}pD`YltaE>e-OBzit@Ytv{$QwKN7vqMI46rCUPoC124#1Vp9!BE=ftTN`HtwGHUMzmJ zv=hr!XN(vw%_*5Cf11XnY)lFM{_SMC`m$o_`_Iv{`3b0tt*QWMnFkNoB;* zuNKnPci}EKr5u0Km;w9h62nT*KZ%~5`$U5>nvTLGq}4z11=7(?RA(Qpd+7NC`5;Hj z3Qcyw1W>UqeG-NNl!~wXhj&+W_K+Q;`kY4}oH(5(hnypjk<-c1FA0Ol{{<;ReP}>2#!Rj!zjJ{tJev$lMv5$lMM$d&csM)qTWahu!99 zMZd-Foe1Sl`u$CVRN3w8uu&Blv+a-8jd8%oo%l0%WD%)$ohm`Pv2oq+$!BtLnd`9T zIl1~D@58e;$h2V}ljF~7_j~^>i!01`#~*Edznyr=k%%l0h^RaW z8D9+!VJ7S$x$bu!vZ|}gklHhE5(i9BNr|^1ls6en!$NiDb{{--#UHlB^fcLzjF?O}06mD7TF`DTHUP zT>8L*OGLUq#gf^qes<$@0dn%ZJxWnyFr4y_wBV7l?+Wa4+5B;Mb=|Ua)710enturLX&pGtpq8MX zD6INsh4`V8F*#6qDxvbE-fw6H$t-v6X!j`KC)Pad3_ERf$>7`f*+-Exk2L3KYw`Yh z%r*&6YFRM}ApCob#p9AZ2T3h|+PV|Cu5IXLBKWztqv8^9KnY(K9sUEmrE+z~Te;^| zk(m56SvMNv`_{)NLtvR@-qNIO-XWyvL3|5W{JkN7t~H9Jf32WC|7Cz>@|j=hSuMN6 ziqw6O@oH3<7A_GV7HZ?j>59$ulV!hk);g8LpL>7Y+^O_#X4wI0G(tW45+FN|jK;b3 zmbrr!hr{!~L-mrl8OpMUQt0~oSe9K(w+0j~oGnd1INf+Rb}!ut=G?QyaXglH?n5M2 zXRn04DC=(!pZ~ew9WrAaM>eAe;WxRbKeZXn*#=y?iP^7A45yKxgCx+8!5P$Bba9S! zoWOLNyHy8iV)@xG4iIb5h71UNc3+t>XwPxabLs5#i3IV0d9I87Tj2Sx;mn@_o-I56 z@`}{A;zu}@DNN&qoIqggvY}ag>y=^aNA!=RjQN)nHIkS>j5EMZT4n4JyVJ_3BDbjEWwwW3-;$Y6)eza1^ld7|f&N3c z%-%!}d@fsA&)kWJPqV3fy=4u#a?3?$KIRCwz5C5W!DW4>ww6hwnG2xv6>YGe@_y&j zv$XMYzehh?C{XFPEotw~d;d&K8I|hrpf@ZXxMGVV_qc`@`w)_WdAo*y83WvZ8ci$| zCZZ`%b4`he2a-CdB5Q0N-}1cYg>^6i1~yjKiB?aa!Ifh^AM;kb0mLdU%g=jH?I6Tj z1?9q`NyxYe7MSKuREvK+1N%C*+gE@n_X#1`(n|INutOIeX_0A zs6$|!p|%Vs`%Tt2jL*)$|H1t!V1;$cG<6&%Iu{xAo;DIVh0i!PorNqJNlc!-;j;zp z&ORLTh@Sj$v=yaD1e{te1VK)Wvahz!M8S~IsW&;aX(0ufy+)T)Zv}js1t9nORtq}G z*r8|ju79*3S!X_UjurlnEp3ZbeLiQKl^ewUN!X&*-MbgjLEDG$>vMQOFnao8!`;2e zQua~(MS$H*mnI;%DN$l|TPMQgl5TP3&=_(rGHa{_CYV8namc3$!qm<}L+LT|10TXQnvy z*T05wIh)NcJnatgNfQkUp4quE581@=Z ze9%|=a;nG?bY>?rr|Z^YK+b``@Ab70hZo;jh%?J5k17mwmlk*2AE?KihKTKw8|eUm zEFP7;aYP5!FDIS)OVI@usO6)c-?53=i#T&E?0#*`#GzCcc$m-HUm)&N%>;O_*M6kGk5Mu6Y_H}-bdL{=0F^FJbz9c#p9F#(|UavbpsXWY0uSpi)A zp%nq_=s{Z4%GS=FB-|H{rywWq+XnmiRsr0JG~d)@pX~gT?9v=#&>B)$^~Y|~HO12A zLFNjDkfDI4M8mvsgI46dmgAn^PH64W5yjDpGSw5?d10H1m{qPwRz)mMPR-{P`yxh>i)Uq|D@kPQeOF$|)>D!j(3dS*I_4E14sd>-rrrbAMPF^O- zi6(v`Y%zSnnQw_1ymzSN*Bz|iu2t(~lA18rx}!Q>+1c3Wus`a=>&W1;)x8-Weuhfo zlQw8Xmr{TvRi9OMb0Ea^B1W>}lV{~SXElBy`&CpR9*ml?Z^5y?v*$M_(_9cW9m;g* z8ag@r;!{wfMP-WK1W~@j432H;YUhrT8Ud?!TsUQ&26?JOF6k0$mT z;x2n{Z>LT{;ryQC^WWQ@k_Sd(IRA(!mVjb%ZZaMovK*j5gGDHqJ=t)E&g=!AhJ5?P>g*waOS$Il+Fn`#QOh%HTd&zRfGpCBG|r?E2s@B(;o z^H2W7H6t5z2^SfBN4oHZ?nht4pMV|C57G>RtQ) zV5Iy1;G0DOK^zLo7Zf?k4;uf^j8#Ygfr7;37^1{aHPg?FMEIbH3~4w? zoNbPay8I zHFHwU3r)7xdnG-Fay(-q{hdA(`}rhO|}?GNmC0O=bq2IqZM{_qm;>Sf@hq0W))@Jp7BTwynmcTFFRzP35wCI z=D%y$KQzJrbyUa&`@y-?x2ou0i%I|2Zv*k3gEkLg7>U118F$e3Sqo0yY^|^e(=`lZ z{jLTCh%BE=#paOGHjDv#3d26v;1~;!99z-s6PtPG(c1>u8C5eWD{@xNg)CrTp||Q# zRlA0~TSsek@_xyt3v1mm z!tAAvg-o2AJM2`5%ee>FW~PHy+&b}`j6#LP2xXY5HY{B#CXFpHEw|502VX#FQ>qUs%-BsGK7%-uCG zq2)msCm^9W*$QS#qs%&ZO~_*&6s-iPh$ooprn31_T%G&_8Z=Z**f>C>N|-^tBP=z9j;heCN|n~P&Y zB}wBZ+Lf2Ep4mY=A>5lQhQEO$#mr1CJN^9QHW>c>b*o_6a{HF zU*;7)8}CftOOTjg^rksEm5DurzK0^(a=PQZZOeD78#3iv8*95a)W;@zUc^OVq?Fkk zTi_eS?Kn1MG0q@GV6UsYoy-pKx5ujb-3VerMC;x&1l71XqBoTMqc};ZCPPlPnHeZ} zZt*r=ap_Z8ONMaU2UE{49Vh1{U;%?S2}%x=j2|1ROo(ccGFW6(8E=rHsa+%I1w#kR z>Sq4G;=EMo%11n&iQ(*sP=!w)YBh)AAb^)JGx@2t+(`ShP$@IC6za|AndtPnBZ zSXyJmL^6)k__dm3i|x(5KS~Cvn(-kNGyPJQ(>fiV7g9cuo&*CBtD;W9jX=cZpj)k` z5WCV{jL+;;TDJp==kp={lk1>h0_@3{EZBJuMb3FY(NSJ$9kq&S<{3%pSI9D&_k|f>u;Vag4E(iAZ=-#mc*}g0AD`PBrn2x(JHuE0Sd`GY5uuR}V)n3l>a7 z!hh5JXdR6*Ecr4K!npiVn^$@D9M2oh|5~+ZE#I^6131A53-Yl^qnBIsDj)kT2~s#d=J9>xo$uR8^b-(mT0q>4k8qs?6LjB~ z)=EI0zJvuP<>;`oUuaJSrEJs(xzF6_q^FdBb+|ft-q~$Sk=E+`9W${>WcCqDlyAwM zZ)R;6+s)J4!+8dax7JSum+SKM#dTiH1Wi#Z&j*M57jWfF^NJgo$AsTNJC*z^+|76J zhKb44cHMX8WXhSj5m#}pwy!<(KNxip(WlF5+@p@l2(Ibh_vH0V3hymgeYrU} zm_*CfsBvjT4Yq^4=*c^$lIXbFrFoA9#Ln^os=|S+#&JWb*)RCYxe}13lP%gK>4{36 zBV_*jXhFujJ+*MJl*zh_mPk8piCZH#5@Bxzv@Kd0z>%UrRg4 ze3w1OVuZ#RzoS8keZl5)F^2DHsvSv)@z(iy1fK(=mNvA%*c?ksCYwrC@A9BXXd7iA z&w%^nt?(>026y^=KeffGvOVB3R@&D}Q>=UUSB4O@xR!21D_gHifit^1hfQ^HTF!9x zr$))D^XnE`*#0O50pq?doZtD&!r z#*r`B_Pijls6OmMk4D-*vegZqs>5Vv3X82Dugy=CUH8=6!kG{OBDMzbZ1ZdRB8TL8 z{MQIkCv=(X1G|hBZeyt?dqj5ZQ`aYDw`^Z`s;kHV#Ee*aj?)ORMo6lkbW!D7!j01H zyth*`;QNvfyTXUhMn+UKH-*rYvn_aAMD_kSs%o_4Je=2hfWjH48Qxp4U#rgZ6gv%g(J zp}tU2h6BFI*tS-H)g$#Mg{2-XLb(|Zoe8oQAmIY``3{bOm^=cnU>`mpYOO#k1}p_?xk*YF36;^=qMnT zn!~6LxXj8OBmtvUcw8-zlql<4Px7w}bJYZy2U&X5Y1{OF8l$od<@CV%sYtG>EqMy= zm%NVoLdctRp4{^K8K(dzVd5hrvCm+bnU)34qt-2CqWOz>64> zi!oxQKZ^+_TZ@yC-p&L^i_%#%#9?tR^#P%IBT069qZj1!U~*kSA>WKdck-)U;a`}k zghf#2-No6Y_PjN@@oFDj1RoV|tja`62@3!92|=@u(ANXSKh;3SmkdqLu&2eEpH#}H z5UbH+i%TEdRq6Rap$c8K8!Twb0SUs+<)d7NTruJSIt*8M@IGz`iW3v;ZcVkfoSqjtb z#w&84{(P`>2|p|1FuZ2NMCL&<7uy5sAql@d_*Qu^h7nn?aqGxDht>0$@p?ZIj|g_* zJtVg=cr-{g4EOR*DmE)kN0>q88`$A?TT$A^zSMGeayD`WzOemYK7M#%CAeSOc{NVj zS)IPN>RjH<#wFXJr<)>zHHOr%=hJV7$f&10esEHKCiazZT|zkeHifb&nNi-!pU5#W zoVx+?yFw|e4a$3UU1t6-N^eCv-apbm&wPf@CCYLEkC?*qoU>qL=dPjr_OvR>E&4nr zq1G_ijsT?fee^u|SSU9$+eEkprs)ik^ghP|#I_Axj}7lqrYK81sf;j8wX|h#fc1{& zar#8m_N{CV7rc{iKtyhh_ph2&%U0|B&@lp+@>VgCa0IlT7ipA~H5T(6_t?D@a&TRT zZ)fQIv|ljMW-3J6ImjstPLWl5Hsh1rq12oorbk1_!0|_-MOe`eL_kl+UN+2$+I6OF zoK%N7jB8}U4OowyBtVqa+JwoOnpy9JA}dHPxznmS7fn0G(J2Pb1^(0>J;9-zmHKqd z4l>$`{1)xS6{(Jetym5^XjZh^r_C?Mu&PRyG}$azc`}FR&5^mL_ITy$Op-jm^ve9~ ztuxxoTWxCNz)AN=eC3Zweh!t86j|En#HYnO*P3+NP6ZrGI9=E~Yq|Zor^DPcp zjP{=QMSz?x{Qv|qHj#mtfW}#o8cL*I)w?1YvaEri9XdkS%EZy>ZdPS3ZZAaAy}V~; zZag88!4=(yWk{V-8^@Apl&eh-->R$rukTuh6p=l&1*e0u!;yO~M_#?e7&`p3TJb$6 zXXi>=RVfQwRHh&~PtU~TS&Kd>j}C&c?|9}LmQXP(7PqrdBc{n_8rilb7$qAdww6Rv zslTg`qEeoCI^2#N^(E2vdS)w zx2Z5

)95<*M6>B5QWJcVg8E5GL?mGEzO9L-uk>?+se>DB@9PDwmami?@ueKXB2d z)DkC3rDNtp$k3pzL&b^kRCc4KI73~RvVtL8N_%Fz-P~s8&wE&GNe!YieFYt4r$iK0 zOaMl*M#1j9&GsBwn7|ANV-#I)eJqCClQR5oxZX2t?Tq_LC(ah7S=UT)wGllMs+7Jz}568>1eOvk7K$^1K@l(-}tD%ru zeoiWqSWwL0V5af5`L0@Iu7!7;LqOU1k#GtCb-z7H{X77e+2SeN7}#Q2b>_%=w4FXZ zonnkI$bA}$T4YW~Knjki^H3#u3B#S5m{;v{=)-hMTsvR$AlC`OvQx{RmvX(vCuJ8s zOnMT6pO(hO(ASp+E#Dy`j3R39UNbOU>A998j$Fl>dSyfwkLZ>SHkb|3wIbHaUee!F zXeh{}(=HQcD1F~*9k|o&v2f5qDr<}U zy>9Zy=U5X1gN~+PKM3lMx_{YRaofClGv1B;=L41W3FiC z;pDmg;bplXAQQdl! zs35h&-fg&T)JFY;7p0|T(P@hn%f9WIv`C+?=fsB46t=46>GUT; zt#k}hO+CFs-A!v4Q4H%S&qMmJU^Z+hxSP=mHI(^W= zhs%}e&uC(vKIH*`#Weg`)KTb%8xgH?Wjg9`l-jw(riIW(SOiOUcTH}ng}KStzG(U( z$kp>vb8=PdX8fRP1|mL7jMN5m7n==lA#Lo8;S~^gh27{v$`TapIBXYco_sSt57V8& zUR~5M_HHBHQp>1eW4@Sw0BADLk`BQG%u`#@gn@6XxlTgO8_T4mIS}!B9EK1ZGZg8n zhzcr%CcBQC*>x8t-Qd`$34s2 z`HQLf@YCk@g z=&(j{WAE820JyEx<#o?jVWAFGcEM@<5KU2|u7Q3pBm!xCC29uezu{tXXobP>^NwB-KMIj{pIsy!zg}~ zTCoNZy~`_mq4tW47s{owo-Xmjkuyh1PT#`=T3QHoQ?rY{m%aTS4$IE`5gsPX9i1pz zJF%y7VY%pT49-BNVm)h%iiia)Por$O3rcZKv7qDh@f6@b{cQyr$wGmT!;m~``5`() zW!D-ufoJ6mcyQ>z@nE7jV^4v`pio4X$vbAxrCPG5^?o%oi)v#(0)M1jsk_U6@et*M z$`_O0q4R~x7(66GKAu+lbL@ z{O)6VBn7!GfjjF#7TOL~-LNaK(dZm>EX5+shCUU}IcXn-uOK^$>a>Yr$LfgAGItI} z6`X{+%;0&Ps#+m=Hvqv|QE21z#$G`h52XksqB3g*1;RPhAEGPG4|%Q0yd$ohO$yF^ zFazybJH)QCLV28FI#Y^U%6VeAuaG4KyY?>rUp1#-d$<6DtOr+u{HObg$kbR}f zpLt!C-C@$1^uBUVugG;0KvSCY6*41;TXTB%JT`|~pAQdCH3x-}-WT<41Kt@1BQ;G( z%5tmovp{)S$s2?K#-n5z8-dtBRM0DV zCkgm1_BAs0__G>^=Zc#E@ihkMD^zljk8U=Xlpc|0t}&3W8!?S?^9wgn(*I5|&hS_@ zfNPp|8GllDAe|WHm@V9DmYhWdZ7_trsechTBt zp)IsPp_CSvA_ZC?xJycLO)13*R@~hQr4%a;#RC*5?ocFHaCdiy;O>O*rSE&r`Odlb z?>l$qyElJiW=}G|+49?Kuf3jUJ!{-6fzhZ>kNX>phgQ2umm$4p?TP4n4IbIyFI+mF z@WYqvxu*83&O*Q5?O6)wqG64KA|Oszb>3%I)_PgXEzGs@J09sdi_(;p{^=dEy8YhK z!gN5cf?TUz3|OnccWm1z)wUcE#9qNeyF4#27CJ%yI(!$-rz6i9`!1>|BqrEhE|y`v z;_aYfv>uVv+^;5QqJq;9fX`O5A$jwD?l^}CEvD^5Vg3%|g4)dq-_wgF9sOO#o6+0N z6RUlV6Y^5l0Kc}4_V&f;k(q>DKg`NIlBBS`Y`}<|&HyEX>9nx)t@>kJb%c!1kVM^e zlk>#JQexcHc42AzTuNHsM(0c%*DDH<6noL?VyyAhNv-u1xa_!kX0&-m+ zi3Pg!o{ya&K;jlv7qg8gykk{g2o`L5)cSNo_V;|33GuB9vppdKVdqZ1g8i!7M!G9WvYk-Dsk4!Za4O7rhTb(`&qO> zH#;d|GqJa;E~Zq{Kf3NY@#-LKc)BvhW=<&BLyWSt3R5%t#j9rb($@-aC92K8xMxXv zt1a;$8HC*`eY^;&{0iwE_vOi~t~ckj8XaV!?1x=asQ}p)%&Uxvvd_Z<9A-Q=r<8{5 z(SA>L?KjDSzC)bnN68=G#=s~2UldAu2pViR=mqHXiN6l=+P}o3$q2q2M0GdG=+}wz z#aB^2ajGu?3MR*yajIgDff!bGsJ?PFe9wd^${Uk7xiMDBTO_wMIp96kvVs zVN)XA!0Xf+gMuBBYxWIwKorq&eYP3wVDJIs^O74dm&l`U)Ohb+o>K6t5Nn)9P?Ti% z!1xlux(pf>oIaeb6P2>b;yZP&=)sYM%-MSBS-MfKHc4EL5NnkxhwozZ?e~5XO$5vz za(WmER?4>M^%{cj9KW%U(l39PheXe%4HZLaV{14>2e;1A!;ob7}5Jpaqz$84zB z*3BS=1SxTff*=D6z9EX1(OiIUBg;QtOGJ-fz4VxqtF>VlWqXQAO<-#mEV*mzNsdVx z$1|q7{R-o8==?Y-uE|5t0KTg;|ML?MdoSeo-J0@7Zog2>{GJ&#*k=W&tp0Ut-4Gk= z!vdx)hZX0#V=0}j*+nI=5+&$ur6u2iBcDRYc8mU)oYxn;^tphs?biL!Pg5i45ZFg8 zd}BRRlFFA3YaxGz7u6MpcRRP8S56*<@Y(c8!4&l*v>X@^<9bfNp& zp2ePO^S=bz+7nV_b;&!yk69gIuOdZH-V%rM zndZesRFfkC*g0(>!kP-ZgRYnxyG%XI*^F}Np0+$H|4JbY>{Il-Q@2Nl*j%Rr=ngt< zcsh`^P{6z&1KXUZZ0)n&ZMjcVo@4bfCbX(p8Zp&2;PI)VyhF*DVaI^E4xVk@$89H9Mi5?ArBS-VQu?e$xBOI5(WFfG=`6ZPCty#f09!xo=3v2@XOi$dzI(&2 zW-Pw@Rr5B!>ePtbzzGBjU`J`PP!&5se|G1b?nW*auXnGrC=ex|JCA*=T&-cH);|AO zQ6Vt+xh{;%+VFM|5VP_?Pd`f5hd{WRcbWIc?@mV8zC;CaQd(lJb)fltQ_801gPO6B zJ|eoArj(LR&llqU$O74ujUZD%=Dx6nT;P#FYHb52dEMM`LMX+B{jUa&&hg9u`uUa% zs(pqJkcOt`Bc1!!hb1jGh$M=1*xmd|&Dg&#Xy98Ze`_u#Kl3LfCZqZNbuBHUMn81l z$i1d)u^I&Te9=g&?}mvH2+zhc%wEQt>zBvGne42-l_u5*(%3^XlMG$Yls6_Bv?*Wk z73pdk86NQ@NXi4jdpcFhN&Q|NM3)x@+u@@Z6Q5Dk}1sUZSu(Zgt!8&kBK zp4N1SK{KanB1`Iogf1pW%2(^vA>S6ceST}WhORO^B>D2lm50!FvCofXe=w6{tk1kf zLBKneS;ZnP>2q}QEQ5pfNmlId(pMYsfF@@zM^@Hai}~}{HZ1rp07cGWVaPt8F(PrN z@qK%JYO*FpYI$5=MHn?uvF^d=Phq;0um9Bh%P!zwOzvx=t(t0D@+fgZ@23xn23OHNVl5V{<$FCW#gqsYU%wKujKWD7 zhoB8_upW3x{?gG1Fc8;;J4u&?7+jsH^1sofm@{IT^#`M0t(OUWzX@j6I|uDYY9iA* z8H&AP{*>ryCuZFD&q7q7O|tirQ3rLT1Hy;79v(W`*W%KSdYOb%;ba4v0~OP|D61{xuKRHs!T3YCPQS=^&5 z-aH~w=sZe%RZ&UATEy=kO8>ry@kdu)RBDnzhNXKt8Xhdor+sm>{7cc$A%45Zf@`Yh zm4U#WWQ+^?W&#bAdYfS6YWXc--o;TeS+BK6=*7rufc$!;#V79A-zD5T?ao0VZ;T!x z#YFI#Nu1V+5s9hbU+%>zWI^e&bmGj*UAuhc3LcJ0k2lRsd*Tlj-FS5&-!h&q#lSoh*W9VuL8R%9t%Zo4`e)>}u zq!gp&lC|4Rl%LH=)^YgllbC`jBqr&4ewIrRvpq&-UOJ?|pCPmmlNs=>ha3~Y%%;eZ zGE&3HUiOUu}ltE%om*DxmJ zx$>I!epfqHNd_Ys{W9NM1h?@gRmCAQJD|q?4tE%T3Zu3E{)+(Jzz*2kO>X|E z+19h?PlBFa&9jt5Yi%+jCY7I?)gDP%$x2GNL-{u5F`dR#S&O_PppRmz9)UmVB^qz$ z2qpS)N4?gHeZB5t@XATS#+=b7^`|s`L%F9V5uu1_lCI^qo_rqD-w}CJzhjb&EDimo z+bIB;<=U$wuWiRNf9e?6dL}y?+K7@alJ=l=)OtBIe5f2B1y4yjs2DkTHu5>2{!5KW z*)EC9Ra5zT`OvnISN1PY5v|lBoJSk-mp4JDGpQb$`xg7ZsjL%cKlmRlxygUv*#s~h zuXhA)1kQD4h5p~WWpMYc48~vjWAvOl9XGeKX=NzwKaZplk{|isiPaVVFY`t^B@$40U#v zXlhnB&UD-^b1G8igp}(ds%;F_SU-Y9D z*dSp@jzjOYdT2^%QRP7Pt}^eP^50H%WBXw?oV1uoPG9U1IM}6H?Q_+C;NOMqYC&<6|v zvgUciI2MMG&oB9>!i_e>x2e`Z~pnG7-3~v8$ zXC4^`ZHrFNAWddF*N}q`=%9@kzJtBYU-$jA7bF+m)25H8&qYiTwAN*fzqwN={p}Gu z-XRK287e_V5au9eKo0tMv%UnH=#+)yT{{lF`8}L}N>IaGJVQQ-4fk)1{8rC@Pn)iP zqssiLn#%n{mGk^9D~%V5+HxTUJQ^2(KT>h{{Bi-F;<>bNUzbm|vyx#(oskRp-esuz zHjbI?9#$wr(VN4mNYutr5_VRiT{mv05%%bQmW7>$2H?HT3eI=%wyoBOe1W!&|U!11t&@7qnnl+gS*wRd0_ zK{eqyjH9R!PM__#ag$|rngtb|8Jb_EJC(>45RL)gd&Rw)V~t~uW8;WlA{_?B{oSmnk zqo4h9U^rPZ&?@pAnR^$r;}tr!NS6IoF0U#@d&rc;F{%6ncz9xHA#faYCs5v_jK`g| zT+$ur&_yBVo{^YrU<3#?3CzpKT|CzaxC|{nfgha6bE@l4Xa88iC$y*d2sO0kaUp71 z&@sv4LH;$!C*qle4JWjw(bP1C8S|3i{2$uwxO6qiu=wl)Qf8Wb*=?!}z4b_P)EI>M zrSJ2zpj6t++7>UVTBNJ@mt=Ho62DG`%Ox4{4eSl0`A&OZnrc@EN1qJeG-p{yvb)H% z*)Ly`Dx}kRMywaKc=C0O&^NeRLw6F=pV`Gkh+nnalrF)A4^9N%-+ z%PMg3?2%g|&*Cj<{Vvt-A$U(c5pDI7SJYtLbM*NZMV@^*Eexeru0)YaF}37Sp(#yL z5+Yyw%9uAkDPE+lj?d5%7i3~aOylEI?X-$I`7J6w^t#|9D*JQ>zk3)Fb~AIm)n;9K z`YCO9ZMx8I1a4nXPfykED$?7SDjudtV+VIkhPm)rbh;i5XV_v&C!?nHSG*^_zt7>9 zn=5HN`-2ud6ZrMXz`-lUpfw<05KT+fpPS_j%h?P%3SdGA1a5{F&SjA0oVEmQ)Zj?Sxi0*|0P;%;wlMwVf@YmKK&-lO7l?JQNKH=w+q_a@#?cy@5`VZHf} zpd+tTQuPpT#t!EtIb|r7se@8S;&(6V6~8q536=vsSJ$ufrD;YNo-osA=mlEXn_Ez( zqio*gD(ib(v278E&Qa=L-cf-{=Y?WZ&x%i%Bi%Hf@Ec$W!|t<`3Ww@bznpdVL6eJ` zJ_(`A3mOb9+V0R{G}vOhzU^x@2AV~t8rYZAZVGLd0Gs`>QykdzR<~9Ny@e0I*lCE; zM#{ZOH$t}7Q;PB`nwoTYrcB>=Lo62*NMzeqy6nEK*cji-h_xKVbxDOJ#g-lo4)YG} zJmb!q6F6Ju`c&tu%O>`GyF-MbRaxf-{q-skHvP%L%H7aP)T5nM9|Uw z(bC23bWO2eRF@xEmeXOIR0f0>)zm52m=A&JIdMElq41R5vhn#ETlOy}npg?YOWFGwLQ`9-lPsLmtk~&GXO#r|5n|xb z{O5BlDoJfm^ODjQ>iTv~OBcyzvZaA9LyMp+yX(iebDew*?hgrL*cO}{Zf*2L#Hu!R z>)Lq7uQn4k`9)TnDGIYY4LLO%r|pU4Csn>QD_&+%PLKVVdZ*gr*GH9;vn!TX6nc8Y zub1iTa2nL$L4JtXX1aW?j}M)x4qrK5#qG?q*)?1~w>z2bh_mTxrzp>6*qVHuD+=yNuV$$?W|%ymBpK+K$Y*m^Z_|?6F+k0KXRkeC z_u1tjQoH39C45m;WrgB28~`~ab%}Oi}#d~`#;tm?a7B!m@J=3$yOgCM&exo zO=r&*$@_~Aqz0dd)M+;?dL$pt4Gfk%a4r9CQg;++X%0td?O5s|>eJrAPk!UUF;%;@m#xd!9&(3w~$EqxUpl*a-lSgZ3%g>>#ORT_@q?EInyV z)ENT_tF3+PP8mDOvjDadYQrtrveT7Yi;Fo{UC#iG#fE0x5v(?;+I=x<4`U|YTk4JG z^>jz~GuKKyitp9HHtx$w2M>T_+jeX7>p%NsVq;a7n&gv4?4>?yywX#!6&I{ z*|s|ZWscK@)6ZSB3o4{nH|e7Z4pAakPt#_WWGi=Xz1Jdddtf^-#M)3f_oTGKo&dLM z4S!=}D)sv46~>NE)*sgiN6&(HHI?-8PF8qKkEH?6iUEc6EfGNn86##N6NLtZ?a~a~ z0R#se=fVUI_i56W1R|YHJ*CHk(x7Wwui#D+*wq4Xy?=|&g!0PL?WB3_a)i>_?6AZs ztbw<>h2UvCHO{=fNat9;Uyv)2+njOW(#FwBr6OxQXCr6wC9vOT?!_O`qbI7WOY6G5 z+As?HE_;06aJw}9=HSCMdYbykWMYC+-+hHM6G{er2-NJ7$=nv?XtcWHAY^rUx-6>X z&f=u&eJ2%hHmFk3V&7rzyDUe&WnsKTkDUlv;fSTs5=4>jWC8Y&ctrUxTrFaH0=UkP z$#*i9r5PuyRo&Jw@c&?Q;(npLMJYh3^{HWYwNKo{x`_xbKUN|zJhAka&*p#JI82n^ zF4}%Djow~cLTr4=GG^g3SjMYyILhyuiJ$IhtiQS1tRG{MexUQ4y|JHPrbNuyabgcK z5+rog)TPC{CM;0P9Z|{E6_5b$i}rim%lGx6?-~ZRk0X$GfuJncF03ZLRL3SS zYBHWvh@z$z9{*vr$xzzh>K9hUC2y`;K(4%P(UdMBye5D{1oghghEgl3R^Taupw+k3 zh>xJKl3M~Up5xxSDb~5L=S~-=vk8%$e?GSIH88k}^k-ex5TV%zO_Sn!2RhUNI^>9} z8hGz^Ul+wpi$vt%Yv}IAKtcqkjyBZ@D}s;jo%x(t=E}?5rfbnbCFA^My77ht34+61 z04((3oagmm5A6J5$sn@>;&rLdhCo|B9onu<`UK11yAat<_Z@7C=}~ENN?vH+jS^*D zH}l?P7e;%DT`+*fo!(Yk3E0;@Ag?_Pf_IA+o-qAPgj}Y0-}fWq)YVA6QVt>KTN4*A z_cn)Py@Kt`*-DIG%p5@w3-E~q4h|+Sh`8TB^1bMN{p0v= z?e6YdB@Df5dyYx07-+OD{^FEeb6Jnl87J%n!n^f`-!Y_Vo`VS#9u00{s$$W~8Eopv zkH?}m$5YjyX@zP##O{SWClJgK>Bx@t(fC&XK;0JV6tvWC`)sWYd`0y!@4@G8_Wo!% zQmV#RaM(~)7iwaAaC7J_6bp7{fNE|#HJna*Yy!IO_FmxL*_|p^q~%^pS{&FeUu`vG z&ATTHKmpK%HKNW9ji^+C!oWSP2&?cNmsJy}YFA*b$Z~Z|%4+M1n?;^4bn`vcxe8NO zg1@G8R8E>HQaS*}4O~7aN|2#X=uu4DC{ECQ1cU&q&PD%xXLn?*60$bjFijeod7pIF zFCs-}Y>G(OY4~Ymn(>GeeS#C!Hav!c zp{2XP_;(Px(=hRfhBHQ=ge*8rFfgLw`~E#G^Oe6-OT^+5$geNfJH62f zP0G9sCvm_6_S}V6Iahbp+FR$mJ9JIEhHZRBtm2(MPH{Jk{|w0ovnmg%z77#y?t0p{ z->0OcfvpnM(VE2XemOcH*IWut&N@6wVXy-BR+|e`Rdpp&&eU?RhVZ{QeQ#kL2E#PS zO-dd#WO_J08NkV8TKLV05#^3oY8Xo;)Dv+0%0dghK#el)bB(g$*zfcR?9acMU9C#` zV696FIGKLmi&HgH)!Ra>r-+@L>xE@vN4885R=t0dUf9?;n+BB;&|$$Q99-pyh89%3<(|#GfNnf2{{|)TohdBmN_hbGdutO zXq*yv7s?tm)Y6}yg_naqn%{AFp*Xt8$vukNKoy*UUa#FYZkz@nNsSOV52dnhNg)At zJcLD&Qu)jImfc)u(G6|+32uLBZAtRW18bC<477TXT-P-aRkO5=JljZ}`#I)M>3jOJ zYKVqmWM0YNHwra3RQ*pd(NPth*F}KAR8AFnBH6VuCy#ybK2eq-c0Y#?-C@|5qNwl}wmXRb;r|NM3_!C2IW;v3{M@&cTAn|U9 zcSh}=fOVj{(t(KgRVO=F!*IuiED_Z+NFaOUPMcIiV_a|>qp)Ylv6=^6X@lCjQQtJKjir|eRa3qnyAJd7* z$?D-whae*&)H>AtX+R?>JwNfhw;h1)8SfP{H#eu!AQm&=e1mHL`;O%iS*3Bo$)a`~ z4l!1V!J|^aKXs*ZWSxTzebb)xmA#*>n_n^PxqYy8vFGN81QM-LI2vfUX=&jM3Ly!R z0NK)lc}#ASNY2U(FIH~1gJ*i8Zs8prQll`OD_!>m{7J9jF9_2_?Yx`$K`!CAoYB>e z?emg>r}bL$PGKNK!R(iS{ymY$^~7-Y?^77KyCOpsPGgFtS|1Gz`6RO2BKr_J4$zuu zc7-nuw@ml3S$+E45_29%B7#qLk@i;shYoKWZ&popQbhc6ndC*bzB>NsnW=)aZv;Fw zb>a(opBTK<@+0>jYAM|z?q8>{S?*U($oumy*O0Sri--ou&y|jIIuoqH_c1p&3>jY$ zJHcvr4YaY@Uk%hG%LKZnKgX9C1efGO61#_AuZ=nVWyod|%d&5(hkc%Vf~8!8OT?@W z{T?glt=30Hol@~kF9Cb1!2FC2&9T%t04F$+J`7aL(9NGWKn`X8WG`X+0nX56{mA^Y zOkG+>i3E^~p&spRm@4`iXC3ENv)`N#$#+e8)wA=Vkw&06ScAw9g{#mafCl{IpX8*P zu8+D;nC}!KDn56Y3Ws_N){H}R0Yjta+u?Xe+EO^Xl41MuA)m_Mmm8N_9Mt4Cujjkf zYKr0}kY(;KVFeE3ROO@tI}^14yREI5N#57^kIyKtR-cVeNIrE>)sXo?PHoh8Psdd< zGQ9sOtxC;Y74S1&)i9CbJZ$SC4)^8cb$PjbK=yD|Te<1yz23v3#+TEdtTgYEOzQs*LWlU%@%ieD`g=y3P1rfe&&o(hBNa+{@&N(FAZn>@)8djDG`b zjuL;3lb^^J8NJPRO^Bd@f$Cf0ZZqz6`KjQjBysHn-(+kz1_%Aq2%p9yiTD+_#pMrI zoSrW*V)ljQs`bH^aifY*NcdD_c+_0?GUan~-^9p2`d-&qhX;dYn*w*?_dajXWdzks zOAcRf*S{E_bRDCtYll6?byLC)HzZ==eZMrm-?*1&q;yo^IR?tQ9S9n3mz@+pL2yo%A`fXW3Jvk-?uL@~jP#EsLm5b@>Bkd)^U1IM{H( zmD_nrplw(RL(vk|PzzJc`KVU7SeF(W2ceTU4UT8m0^i|kI6m!5 zHOs!)PMrqXI6d3nR{>BDMeUi>nSV~C*!tr7#f&9ZxC3jCM?wa~;W&VAtSV=t`+79i znqd>GZK5+{$U9nsC7vwsX<&Nt4M#-zb^%JycyqkwdMDudah=eMV}WwbFj<}t0xz3W zwpNBK@q6{XqWuc`3_ULJ*nO|*THHl>c^xv#3#ZuNOwzahLo2NJPl2_ywc=e`vGZ7AeMtlwZnHK9p zS*bRvq`#7BU`5YdfE}ICs+o?4-*r`)$|ICl8)N_QVsZAW;++kZPz2Z5cx?-mM&nP{ zdPdE&1zn@cOQCb;`Wf(Ku8wN{9l5~pMrav7zek9|mqVG)i%LbbA=oL70Htcr?xHq@ z*ITrjI{r(EjE%~|Wj`!(f<_eyiu0)Si7rKM30>QDwk)1;ySp0N{F;1ME+E>zTJTDA zoEpgt+Aqw2cww(B68+{o5|0KCa`Dgh&6^)i5P%(AMb0@7Y>KiAg?j?ph-&UQ`k&N{ zOf0uCgPd-#fy-ykE|$+P1I6rbkEX*QFtQ-nyiVBPOf$0nrNzy^_^2dbGFo$I0*diL z3|kYozu$OL&StrN&8e8i(z*87LzndD+f5v`YuCQtfu2cX(;q6CWQT3uAG>Bb6K{No z##uhWo{myl41Xu};|(s78WY0Q|2aU0N(@tsX<7>>pq}Z{5VD8a7P@~XIk&Y)ow;cD zksRJWjP&%2$ab>8P+l5+^tEuzowODcLl;XkyMVG$J;aENqw&!lpx_jky!aZz^bt^! zlHg>8j-M+`qf3WC)D>IYu9=Uq62#bF;!woy&c0*Yc*7@hb!WTm^W;dVb)4aj^n%W( z6#3LHyaZzWCD83;oQWn=Y*Mz>E$}QZx)w{hl%S@q`Q2u>Ms|9+6A@n6?-}CARoq*L zGW%>4UM@*`SNYF1Aq{Pxfl6G(OM>WGu*5t`=i&EpEDGrM^xz48DNPNAE384>BhK!( z{taKXzAa|Gu4XTM?}X?mF>0x`#qu9x@&4w)!a%Tf)F)F5K9?~NYc-!n7MW#ERcGZv zK_EOd2zciqcDkLrg;%n#m&5dR3dW-aI?(Q6EpGi}-+4wDeznMLWoT;}M)Mc5Qsx^lEo~svt8f(pb9u5G}rS zj(t{c)lg0MynPEMZBgI!dh{Ln+C%I%D}A+zafod{Gnp_in{V$c5-;}DiauZ6G^WS- zvB=Y%HI~b5I*;WwvyhGpN> z+VGFE(^Jf`^UJR> z(f^>mLDAAjKRy@!CZ$L+lMxYL^PyRZTH`Q7r@XDuYEE^Oh*m}Q8r@DUcFe#maz;&d zh{7q|cT_05207DuzmNgE%S}Bm4(Yf5^hAd1d%e^Z95_=X7ADR-_CvU5=0tAyPvpsa zR41IaZL?sITOEj)Vbz?<%CLE*d*B~NXPqenJ~;&B?Jf>2?z_0}Kb8(+oUXHr+aweA ziAaj~lC2i&uN>+Wbb%y(9+Aq+NC6A$JWYPJ$)08`;B@OXC-BDf)-!3rgI%r^+wtx! zTvs?KpV!x~QbO4MH~rvXq&Mb$e(V_t@Qq#WbgZhDx13hTc;JKbpko^35=rSI zE6DX3vel-?FB*S5c*bu1F>RWQ8==fKd&fL*etCMv4T+#WG!as5d1dk2>mCCw)KGK{ z3C!VRblxbmVA%OKdM3aPb7}f0$1R%Lq5Md8QpO%11gHt1!zJQ=SsnD$&g*AauZ-Xr zP%6iG?dW^n_WaP|;DeuLNoj)>H`mRNxMxHqzb#OD7PKD|H7c?bms3$=_TalxO-m2w zm2vROnVQ@lGi+~J(3NrVZQ`?TkZ^wB*`Ed7#XUx+JfG@9`yUd|Y$c4xjp)WakdGG`@ps> zM|@mi?!j)w=(-#23$g1*4L)HlNtD$rl9@jI^(kQ1oWAc_6prLmWtH5D?CN>p6BU>A zG;iklJ6Gd^yk8Lq4sr_AIMBE6caY%>5S-@S@|fM2F(-;!)=(MiyZud@fLMH|7?8i$ zTfBs9KN_1(=Y~{Xfn75MQItOWGiT}KLBPje+yV6crxMfQ*`sPL&U+k8(9QBlo zg}PyOMg^b?)t2`%&fNmebX_Fdv|oy~jiu2;YdHpudc=Q~q%2^&enJHGProLjP%HoI zoYL`&qhna_>#Ibvr5GdOojvU`(^I6mr)V)+y`+u2*47Sw0q~53M4J!=JIG;Yq9(+5 z9g@7dFn{{l%_W3+ARTc;K~j4m!s;Qt=;7})(lwJ7HvH%Afs*dReSft0AVKN8alGFY z9MpWrbDM%z2kmyU)`!95lUr(<`sUi)T=&KY)XxVXa1kG%o8{(6&?C)1GZGqxC^;b1n?X>vz1!COH;8 ze%mmPOG zZBMCaJNXOuvcwc^@>ekR=<8!LoH>a{mb*_9V>KLCO>dW$2WB}U_hg655Gv#spNlO` z$b@g+brr_?BCrKdcI4+r1k#3R}nsyrM zl}^k)^g_UDku{_3^$iX@gGAUAa%UplM4}U~^o=j}tSArQgG4FTz{4hwh$H$rG*5E| z^6Px3VpPD601CUg>}5qPd8e$81(`pqaa!JX#n%;Vz>_#^kf>{n=-f?9zqG8K($XVG zjPG{Iinx5OGk6I0qEIi2U)^ih!kT$+8WFPiLNq9CdFGB~2$Xc~WM^-@ETqz%a<|gp z2e}@UzEGHxE82Z<8@n6!-Ib@}E-lWiyuCfe2ihnN8-gDbALmm479EwmT+C4m#q|$8 z(}UKXTq1{48oa0b)-J1J=20A=VpPT(;WIvhXqM zL-o#Kx(8}y05JNF=I$yN;~)0bRd@=JEhp4<1EYJ74l`YTG}>wveNST|%TeWg7Kdrx>r zMqadOrI}o)r=rOF~GZelUv)XseZd>EM zxBb?%xXnv|gQpQ(7eTE}@!}EG#BtlhROWVzA}RNeirQwx*zAmKvtcv^T=KsEW{Q=2 zSk}yol0?eXlbr*$10}1a3;$C+P8ceERR8SVwG{DYE*TA48PsW?c0Q0>68%zE9W$@| zBB_j_k4-jn^^QU-WzFtF(S30vYN3$7_)N>~=`g^oCi5#IeiCc7oLRi|VPO<)Dq7yN zHog3M^%~!rKLP(eYfKMW5&X)^@!IlsbHRo&Zi#MC%CbA>G1BY%+T3E zS0me$y{pGqW~K3CRCc+IV!>L;=)9!w9R)v*rfjm_YzHDAJxs_cp;+eD6<@8YYNvzf zY5;csntARD``fnNfh&x(+jh}*+ywXSLZHF?g6!0VvL&5fKaw@85wS&~qw5yam2LIq zK_yS0{oz*fnm34ljJ;6f8GmK*n;&vXFqBi6S&7r82C^E?pR(#qlTgB6G04n~*LdStO@*{l$7?I&jXYW^SPyT6pB95lJHo66|Bwt4CK zdyharlvec-()6Em1|0`U7-g_RD++#>%PS$)aE(ZsImH`b5zY#pw=i+Ub?Q&x7f7i6 z3%Ul+PyCg?gweAS0~*@-0oJa9lIv})$)Y`>`l7Izj(_-R(U(b|c>ekKKVQ}=Xo>Ux zYW|R=ivMS30%1z>UTj|M<``t>N@yHEzf*BY7U$kTYp;G_*6tt4;|8cEe*v#YS9&pkU zj*uHqBMj`0ae*}=r3!rO z?0!;u_Yi`d5pADOcxc79aqZNAtiOlepR@{A{3u!=mtm_<^ZbeL8-PaER|Zi1rU_dm zo91^SaO5s^={>k7FjJss}@_Tje04FHbfjptE}V$OSL!N*O| z9arf9JK_l^^pSSjGA{5Q*V|}L9CBuNFTiwvVCM*c&%F6h+g+b3ebkGs8I~qdA42{r z5PnT_b+Un0>S>fcOh;k)IsynFa+_!wa#T-rU z9&3j>usf-8W19MT%q3%t9qD_(bk&~al^5w=hWnOr2;N$AzwCyl9Bf;a1g+#=udpA% zhP;0^+B|JgMg!)|BuhxuCgiPeYkpGPwSoU0suj47d)dWl^T7XOG47M{-JyF`$oUwH zRXgL-Z4h$P7b&LN>)+dFRLu-)8m!YUm^@{q_;IkmXU*n+m6g zP$Y`xD4QML{ZWm}!tFNqngV!E>4(dHbhqtwJu)OJTfx=bcYmF5y`NR(eS<(zr%w~T znYsi+4yRC5?2*J%#JzFGQx+EVv{>&6ubGekk3&Q3`(FS9(B?O0 z@xTPES=qLoc@OjLCIR{czlTIO?6;FxnOr*grraMk?IUm4k?%^Cixr<5u3F7^ zl%$Fy?}m;vfcIIHKjL~e*eIKr&+F3=?03E5o{&4clTx^71>f@plokY~f#SPlN}b+d z&4=+^#r^>v38H|PqFrn7OI+m6y})4d+hNLZk&DXvWcalruI+<*#eD1K;?XefLmc4NHS_6B}+{@bid? zkqU%d8f10qdc*go3r{a(h?q=hBIvSIM8Yk%M|40x8(u2Zy88>Yh!Fha%ig(7b3}u! zf$e4oV+;Lv`+3Wv z{o}#3JFOOJy}9#4;^HLGP+Mk*xj{)D&Q+xs8*7umU7=q;?Ui_ z*{Ru(^9B8NI~1x=@ntjFTo)7M;(oJ>CU30;enm8YI$3(8ZrOozu#Y@ix_)~A(hl>! zOg||_t>K=rsW%^uoK1t!8+*`f<~ra$7H)r2dYm5dTcc>}7b*+b1ri(kCJb!ugP#sv zA1;Y>SdTPo_|l}`=-fvE-3)-~cS-Qp{Pf!zX#azCfqS@bTKcVO*QjCgxyW_R{nPY= z9{VdGk`C_XcuqJIK(@*2c^L>xJ}?CNQxrYz>>6XCmM4{@xt>Fg=hm#qQLPZ5Hh?RU zi)p{U`*Bw%fKYqDl~4ljpXtb?Slz1*c24a$Wua;OQskcgTDBbj9BR~eIl*@ia&UP^ z2S18s=RycK>!Ygl5kbOf>n#^c8?7^~8&%V{9q^&ZJ)AE?4Ffc&AbmstJ^ebRVKU8C zD3Zo^O=kLH7<8(dF4zJ@-Qnuuuf=2OZJsZ(k2Y3@-X2=5fNnfBfKm256^AX<-s=Ll zH@jgax>tHO*Ji-JL3SiPs&zZP$g=N0JZu_0w19s_#B>YIsvj11c1G#R;eChQ>EN)) zwNYBf_L%n}KP2S-+Lj#C%VV`%S$Ef_IlWm7spGx9|5TpOfFw!q26$b}PQ?`JpDcPg zwgGrc<R`=hulMTER&99>fM)Ya1c z$l5xf4Ka9yw{gUa3hFUkhihK& zccFAdDGCWH6Mg`z^zC&UG%f}3@6=wxBpAZ*%uvhv$3b==)~P}zj{^bU6N>u~`nOLG zJkx@-r>eV8_N1`PSl&k*pFT8Z=JK)l`k0~{-_b$f;ouYAr}qn1{;`J%W{ohM652?Xg~AsCvKrKfPnnnIz|)J^iX zCC&$rucv06lfSphOb6{v|1{qT@0#adO6V8tP+Y`0ndzs^4^)>xsz1N$e|5SJv-C87 zN)aX2HR9x?hG=R%OQH{3r#8{3NQn<_f%Wt)F>{_w_MP2NQ&+o*4z_dxjm?i7LzK{j zPieCUj!aEmD;aUv9!@rb)fNAH?*1CHz=w9tp&?SPfR0&<++Nnf9A*rF@AJz~KNctC zEk1=>Wr5=MFg>>sgLsCDK*V@zM7oB@om;xnh>3@a6@AnJAh~sFJ!yi0_p>7)y&^o&j z*N=xQMInNLsYg_sOp?iShEuA4qhP(XFmQ)WaecGAJo>59&xhcU;r;(&?>(cM+`6_= zm96fLlqO_=pbdVZ)RClO=ND&FWNC}Vx5_*TINN)lOq4$~q zp(Q{_Inn*R-}%NF_b*9A{Sdgp`X>jp8?_49%+AM`Ci0FgIVuI>X(!GM{h)yHx2AF=f^RysJlDkCsnZ! zU=}a%{zeXutm7*~U*4#ih<^f;6nJ?JEu5_Wmz_~y@96x<`&-6ZZFm$g+}}OxY)4PD zXs>yOjedQJQMWPRI3hoE*R3On<~2PK7Y|L(nxXmY+mBd$J+-rPNF|5A_l+TFCmTL1 zul=$tAx9h0*$ejuHa3dbK^Z9m7ppaegI;I=&NijiK{{E}Bo z`+Q-thNo@BU<&JHt!kjOL9KGnbrCu^lfL<#c%G{!!h>T`;2!fQeH0R+TC

HMo;_eV31O(13pJ?qS5w+wnrD0DI(OaA2l8*cVL$+G9W zUmGOZCtN+t{7cwU1QO@*3Li5iWU%`^?oX4#wdT9kS7uG@={Ofh$#?5)8mD_espY`$ zQ|;#| z_*%TsvLcdONOYau2+h<8s7?4_sT*5lt`KL_m7vZ4>YrF4O5H~*m0|aj>CEut%%-FrYiy`>t&pBmAw;vKRNuqu_78D zXqGkc&qQ{3`W=PEsQ5u8tSzbdR``Lsl*2!xyT9*GL4i|e-}Kg#w>L*aL=!+&OF|+G z+nX8A|I6DxXK!E@d3uu8vg@E9I?rEMwA*n2Waaz@X103ygbZC7I=qoX-_3cJA|l(t zO7+UJ&D!(ZK1exeN5pTXP=)6)OduImsVX+@|hw;1)`O&*|w8 z9$RX`?7|4DeKTP`yg95TsNMmVaYs5^Fn`tU`9LK!jSYpX=235vo5C}3-FzE=Ow)I2 z4|~534DF2q7eQe*bS4O#3talAd{-cRu|u_u`cjY92@2kR9?r;`@ZHN2xCSNbD+2co z4&gK}U-!GdaDD9s%=QMqbi&l4;yCAem&jI;G~@1mC=zyBmBFPK#d`Nq!u!X)X~qJ^ zXZtHwd%zgmDXo-oC;|?KeNED;I+9U6DkJ5gr|tUX`2vlH?2Pxx{D%jEgFbC z-v=KC(tUQxJKs<%X@U64g>C7rRs+8|^oJb=kakLqkV+|$rJllMf$hn!gp{{dPsoV& zb%A*0f@Af4n`@em#<0CB82tarO}_ukO{%(Ab9TH~E*wyn2=in>fa(B0i@7V2Y-uk= zKh9bP)6c$vBl%+Z4~L;tok!u@ta)Q4-mP+M_)GRltIo!MAM#)+60B^|GZ!t!;Z4{{ z_wLh>Xb_75%)4{IS{RtA%`sqRfmc3B@bf^OLOzs;2LNp%-OCqWM#9(NPQgR{?oX({xtanSboDNqO6sihOmM>1fJS+wlVKOPx8 zJ$A*Zh052jx+}Lny=##@v+G1B*nvcp&-b$enRtxY!D;A9lna#ZwY4w8x+pPHnxJ|F zp^pO@`EIAIFg>=IL6|`BT*_g}Hh75~&O9~&q}Gly%lTs1Ovgho>nv-jI?YYN!tH|{ zCi}AQRyg&N?n9ctZ5U*IoLAkmSdzsNPAdD>_@59mh3#TRUR*%rW4ol8-_NW==JS>7 zuuW)}N0)Zt z{$>E)Z6fPHA@lZyy1M={Qzb~c7h?q5W}IgK-1HxG?{^fo)how0p}DT?&Qr&gRR$G0 zUkk2Iy=d$7c6naBg&q$aKtWW$eKJtKADERhH2HgL%Jdwb+|oW<&6BA7hCP$?4TbRd zqwLV+)6}Z7-d6TSQr`10-~udP&3?x1ozEs0nAwrD0`Cu7pB&L2f>J7sE@jP{&BuzE zJZ37-FuYiCg!OxlBlpCXz5*Aptka_cGG!M--U%snPr~40*TT1?4-WI8yyMu_cEQoCJN41xfxE(PNy-ClGcRc(5^i zOUc&1rsQ56Yd>ch19byN2}6@?HmVOwZhzvRS?)^#FMaEU#9lz(+8!rwO=K(leqv-^ zAWpYM;zb|^S4|W*-3BCWF@6)!D2Ob9dTseu#LrbgXrw?tMl`cG%TS+1-p8x?|*uZ}^JRF?QcA zwX<6%*uVe#4D0VS9)EdNg*s^95peG1$-h7S`tV==Nd0=|*XYe%EsRrqeznBIRRc1e*_!=-Z)1M0`Y(}xY~oaSH$XV^ zy`X!Twe1JoHmaDIsr>G-jHxF#a}Wp99eyAU&B%hG?~o8mn_T+%y5qk(>Hg;l+eyWy zSj8Pg&fwP{6x*i~z>tU1^ffXG1zZO`tDJ~&xWzZQeU_1htyL4&N;?NFu#?UjJbzi` z*9_IEKgl~@*PT%0gH_$>!4`oJA4N4~OkFB@?Y$fG?;O7P({XEl#6<81JNHAz86-KF zKM=E|lD48YpQ!yv`cOutFG>^b$gGBzaMi{!$&>%yg^qd2Uwcg{pBb1#*dJJN6gf!G zK3G-Ll+cOGBK5uu5HvWD@|4zq-u~QPmsyck)eRfb=NS!L!n#TdK4Q%P-Zs#6E-S zjHRdVzv8I@u*LK3LLmg6n?k{z$r;McnY>neQ$53yuYB4JS+(kc*4_x+y1rN5v5BXS zzYCR8gNJG_*m$kBUB{MS!*(kw&Rh`sK{a4CFl&$8mildRzTgmb`5b1G@c3D?T$X7V z1<1;^m6|TiVLAa(kaR{jcQfKT>tLUE8tp*4hRcEVx-HkGlTg03XOR%rn67Ietp47r);Oi)lQbbL)2ajvI~4T9P1o9l0>* zD^Tl+RtK>G)lwis8d@0G5lxWlu|hH8nJQs>UnlUELdgVKlWskiy&;+Aol@-3Ov_Yb z@yzlzZf&b5+zh(^Q4NWDfyfjB4Oq?m05Nx5^pc~2idXNvc5NbcP`2Y$^TH%yoI$!{ z5haB*gVm@RW^{J=c9>WU(Dm}a(`v6EntzF6yY~+sm)p}7=&PI4k@Nvx9X5$=M?(%xTx&adQv>GDEwa0$C(3z5W7%Yzcr8HR@ORr%~u1-FI;C90kHBU zv;uw2MM7r(U~KcVqNa6{tM6EI;;V{tzoHVGjrT)6F+6yhn3id6m>F>QuuKZCf}VnT zY8fp}=lmF84I473SXLoz`D7vcC|Y*YbQWJ&-5B=sOv!y+|3;A^gY_2Be)C=wZHl!Y z%zcxmSfw;%9mL8~g;?#m4!)2`cybiRdW4-ELV5{H6CU&x=76~aH*_)jP1I=p z{iXA3bvl6}W4UEZhJAxM!voy5Bm7uDqqMf3eG{bYeqzhc%wrtY4hV0|II&YLf@ki7 zht5Ioy`HWg+rSY3_N*Z|BX(gg@iJp|$RZ0*XGJ!2l^*wC%mUW>j-xF!h9|TB3&he%J#bVJ2TXe51t2 z*Uc2)49pkF;olsOaEaxO2apgZ{=-pzAlI!^wPvUv+1Py)4Iwe->8;Ce3)LdqUi#F? z{Jg0c@&vDH%-0xh#LgZ=*d@i;MnMad`=y^!ESpoMcP{+&7fIx>m9#$36Unb$@|#~X zAgP7C0J~q))x{P`vq&>IxfHbSFe5tq9t}n70|(vr9>$EERz<1|Wa6y4)X$U`iMTl{+0JCVWoDT1OBNA+hW znVHR;g@%I}7FHE`DOWHJo$7in+~v+Jg-k0Jo)*fq&B>PB6C?nklVNn_;G7kT-o)eLMozlT#Vu>4rudmk5 zM=^85*HVCzGnDa5SR3A%VL<#<7;Ue4=1`uoHnC7C7tXRYXxZAQK$*)cxaI2ezyt58 zse_8_b=w}n|3pG0wh1@CU<^~*M~%qsf_4s!LnuQ{f?NQX^^NP45LPoy^NpBjgIeQ7 zju447+q%D%^1Hf9++i@SYp`1QXP@-+-p=GwU7v^dtT+WCZ>ISBJGy?Re72NuWTl3x z%^d8w*GXu>|Huj>LWzwcw0PR17%Lxw+X~t>Z3R+Q_{nhhlXjs_BK!YnqONwCrkcr` zCfPf?x?76jJ8$b+pe1%arjTRJ8rx&Q-4!eX8}4~+5?0{Tpd-z~gZu~{*w0}Py54OJ zWnaU_Wu<2wt)q-}Je~$v7%Nh6Ab(&g$XCve!> z$>y!;gV(BE*(|HDeTD&YXPCzVW&@9_h8Jow{yx>#xZxGt+f^AAWPtN_2;xf}rcuEP{~+Z;UI;QEQ;Pi)oOd zrIY~`NIJm4&F^aUjZi%o`+pE{l;Phy8Vfu9RI7k0@TWHQqux&EPczuOfl^Rldri&>eiBo zwdQnjSE;Nx!6iyoA5} zhJ-{;7wTqLl6c@fc=^Y(4tEM{YP1h0@rsp&3U}ooLE(S71TA6};dLbsrLZ*$=2q1e zob|8WwIaFdIbMw^w(r)_cOZ^BqND0dzPkY8xEO3BEwJZB&x%E8w@(-Up3B<%`kLsp zb$obb@9S$Ck@D?VZGL^Tt>X{BYO$k|{+4Rw=3s~L=8s;<7VY%aP26{UN%`llT$!b- zT7_36C(eU2V+@nk7>GhY-{8TlAG|ZF^pLSrs%ma;oDCKo%FW__C4m|F z{=}#aR{A_^QU0DXnq5cyTF7u^ORgre*_CHtGv?dd$Jcy(4K5VQ!_s1rv1Nivu1jew z$H3AW4;^cT*}f0l67oj@?2cOa)BMffxe$&MEn{IdpgwPQ7wezb&pfkEz23B${9uY; zo>=k{kioke2O%37JKss_v%$H|C?RloLW(?Xx~Jh*nEC04iCzS!y?E7OHhbbbNl-V% z;S|w-i_AnayE*~^{~&(sz71cgod&T4tM5!u)UqzoQ;9^IM6X)aZ5?a@owe`KJg6cG zdY&UpJIWzUy&tP7>SrSs8ddVwkjc0Arb)!E&EbF?d%Q{^lpdqSSOTjQ?Jm@O+u&U_ zW~~mpruCVg3G-SD@(SPF7_-eHX%ow7bzH%C>NXg+@|YQu9f|^37-aE;?l@62V~!f! z2CPHXJ5CD|@+rx^#HhVtpsyhU2+_~#dc7EjSeTPuU15DS6ua4#9csf`P+-P3C^Yu` zD;u+yKcihx{w8f*nuvyHR~Gp#lb{7f0}lMk*1{o%GeOIS9;~jZor`?q%&r+6KhV%b z3Y4_8-R;od|4=fXg!7#lIouPhQD9_eAC99G_acr=U9_fYyd+=j_Or0faem^rVQ4Pl zRL$d8r?db8P%AK$`GB$0F~0awXL~{w1+Lqu)M+XMVM3R@{A3hZU%j*F{V~MGU1Qf5 zIy-@@Us((Ld$X{j9<>k>Y@e@={PKiOsdCuEf$$9nA?7y@WR)_qdF2ePG(QJ)IVEL- z+GS^x)|O>DpfiJ;P_ruf*Ww*Ze)AXi7tW8=^b!>CUu61rJG|F-gBbj+nznRfuLHH(6Cl=d8Kh2J+m^PFZw46fiZS=4KU5T-B zU0-^Tn&I4`XK*rm2*IMTRt>UM^Vdj=MFF(Dmi_B2OX%UqfxKM&@O73oi_+sI5!?sa z6%1O#P+Y-U1kg}t543oOB_drct<-4WnmDnNb0$#doFZ!x`h(1DlhMOG!~Q2Gic*=;K(dP)G2dFExOUZqB?LXEw&v_xWiH?eCBR&j&S(;@eSme;#-6Hm6%ZD znczy$tLDgTTK69oiQQ>TF;A%!v;9EkCni~hSxprR(+phqF!slx=*}3LRC5sl%c_w2 zFynrS_m|R_-TSb-%wd&pa($%j+(1jiEZG3Mxaz3j@~ZhXk5*!cl)qlJ8^>p@GYv*QEX5aptG#PkX{sqkShK8sIQ>}Y@W5_dpj@8XKTM-e zya7zF%L)LkI$kr5l3EzlAAMvnd$Q=7zctl~9a?STH8$OnHG(P4ba@qMK!^uu7Cp3i zT6pAZVKizEZSO{8IPKxwngJz_Y158V+uKK`@_TvNp_?C!GA1Hq)s*}Pu6w0mckihj zKCku3m=vDn=rYX@jF+e}vW2d;isNm;r zKa3SjvJO8PmVDK!CBY!sk8$kB+Cs-#2N_v&@0J&f=G6oxG)vY#WpNyPX?5YawC4lc zHWFYdeccpGJYTBQ85m$WCU4aOKbVI27vC#(XB{aH%9r%6M{z2BYB)?&kMyq8F;{C} z2OcA}CP-VBzKs+LDJU&quqF4PVdms*Ea5!fh6&l>$`*X zcmvng-y0LNtn+68f`!WE0UN4y8r(8DNdl$=UA2Blh)-9JLjypt+Jh`54>Ik9HyO32 z_))*RPzLwuxzNAqS{t#4(WD1$YMaA~D>6Yj; zpQ*oBhxdMg5quslF6Yy_jlKEmf>2Yw&1JR3n-SKD9|SjARvv8B(ef&cY`oS+;bS|p zk2Uq8$WPp!YG;wNO%aC))EbcZQ@-0{kjLa^h*|!I!VZ(?0k(XQB}C{Ot`~?I+Djhf^e8~PTph7u0d~y51;>S}6K@W`=euh|^!$-~+A6862qsm39x+xp;hJHxOZyZuw>bLx$q*sPBwn=&WQZzx zc&GIBS;L!#m}Ma)f7)Tu-eYppLjlJp_);OCr2*T2{m^!Pn)9HcqOEK*%ND{YlM+gx;@h5RyrQHYJA5uK_d0ZaEl-wud(-S7~^&eOJg*)Y~)nG z-tB4-#)9^}Y&SQAkTS;INt2wW12rpAF%y-{1t(M{X2-&~eB_P*TT3U+wIZd_4GnJ} z?f^EGa_3%4f{Gu0Viem>RV(~UC7ug^lFQHf++ff*yG2kk0!FxpXdU6$3q{$aHW4og?K%kj+ycw z4KwTo?HqsPF`<{eGVuJ8t z=LT{t@V@^Fwx|chxfM*TCU;7yZ(Pngpw1AdTB^EbmoQ2tr-kno2n7lm?bq54&$>=5 zxZ%)QrbC@FD*Njhg4r9KtrMRofV9+r^>I`~L*JOURMzMk-?H5@Bp52cMBrXqsq$(#n5D zLwJbF2y}BI)cQRS4LD;KtStB}ClM2-H$&C~3$b{iivjrcDNKX!OwL7?#7D&S;okuBkAxb1m2>1|5<*#zKjVK&n!#mGU+%zQ$X{>Ri*g>hqszVp_@ z5dWjVH{;m#-LA)3Gk_E6;R~dYdWfZi7JAfHW$Pfyq?Y`WC>`oGy zw=%_PcHnSB0(2Ms#QWi=$0p@vR_Wb6R%es10}=MF0Z;i(`>kg{k%8=N^r4anenA8| z-Lzeqy@JZP7oOrKk%cj9fP#jzVb&s`VHRmBN1|ESBX>dQ*s;4&!R+e6rSC!@k8efa zf^r_qC1weX%szTB-5t`VtWY_d`MAF&e}UXox$@p>pfY;$r4-e{v9`zT*};eYJtv0)yXey^`Z8t( zhW^it5&gCP73A&#p`PwN5dtJ@ShkfL8INvJiNrj7P-~(Y=j>{$F_s{Kw$W*N6*CJ{CG=FTMHXeR3f`VXmQqh5>7{$F}MK33NEBafs1qxB=cgv~TVA z8dJ?8rL_q9@pnkGx0ODQ1m-@9kl+92FATIIhQcyTWZgPNc)BIkn3|8wt|7Xr;L-2E zk=C4V-^dOxq-R2<8XL$lfTj;tAfr`ulhGWvGL^BKdy2gkngKPi}cf*`(4K~ z-pW%VH%wz6Wt6YD{l-qv^7g0Jy~8D$nDpL7hlWX?f$%%!4#f%m#rAKP_8SW_P)$V- zT`dn!>fOBYNvbnhtS9~GzWpHwJ79ocE8;ILFUHwF7LzM}Ib-P$S$8-3G*-G)?*f3bVb;gSs>T^rP z`nRVwNjehX8izD=a9|>Mj5&3qM{E=-k@Rl?6yBCeBT%12 zC`Vf5n%|FDEN+i=lyCp$gDQSHihAgyi>L;8K;@3y&Y?9(n;bqZmYxu^kg!QtM*R?J zFj;Z>__3FEe{RiRcz(m&2E{2_sv|1iRbk&{<{D%oP^pKn=97&A9e!FQ)p@JVo4FIh z8d&`S5mP#$WdKQ=Ti=FXnB*KBX<(W(7s$=Xb2{`m+$}CGPyFO|;9lCqMw@}QHpa?8 z+M!TIft60`rmh}bTuG|)iW>KXYI@WTD7*8^X;X7XCNM3f)40j$DOW~i0~KQj)$Zob z#KY~Q0u=mm%4{DU`{5$f`B|V-Qse~S)O{8xA z&Tm!$txcBgYFL&YmD{F=$TstTRz`utcW%-@3v0InK4e* zSN4N+4rjA5$yMJ-3*8=$P+N$~xMhdZvAYw4N5Yfq*N$C`WFt5UFaM&*)N|#Gi_kL1 zF?9awXPpU`18rb*a;#hab8dsFtOnL5&&fomy>SV0u@~}&Ria0ui7%nUA4(b`OMJ*2 zMD+gwJ8#tbsu}4ygNihML)4b!yUu^xXWl`GY=tb#E~S4z?LyVC7|xO=TB&#e5{r$L zFMw_fgPM?Lc)ch;R%=Z*geDm)HM&@e1EO45iV|FiN?&UwUU^i$>gQMXC`*8iwvBSp zE7NJL2g-6|d-eCFM9w9#!n%9mEYGZkK)dz(jOk*Fs1x8jn>JMT~+APU4`A$(j9tUP>rNj z`?^pWf0O0%x`9|qgGQ{4uG{n3#A~Od`c&tQ7s7HzqLeK0=fgDeMd6ZTK$IJcljdux zEr<=$7d=uf7~j}%74$IeO~=)r;jj!PV`Im$J!DO*nyal%RCC#|XAumkv3f1BVfCrr z?N#@l8LU;`V<9XdPGo`D+h6aGW*v;VuAu0PZ!6G>>gP;3Glm;(nLha{2<`UC`1ueF_%n`tNolo z62TIwQ}BuSOM}^wpGW2w_M#`S-jJ0a9)(CQlhMNmV`aN|S{0O(jT!0C#>D$36aEu&O!Xf2ZJD_oeLU^A*C}Nh0<3!m zJa(+^Cn|8Z@L_c|r+`2#)xg@<7{+cY?{MD8-y`S^Pjue3s1BIIH&>N9aP@-}uGXZb z7!;+y6)B%AP?ps0M~h-3nC7`PqJhPO3i~F(dB!$sCr?hq6V;Af`CeGbN@A`^){@jj zTEKxD2E|F^b)4^)>{Aq@c6MqfOCdbfHw3r3F1|yp{orV59>dVLf+<&5RISGlw*K6X zOUQb?Kp{`VQBq>hIm6+NeOIq)tFc#Sh#8>dyoBzanIGsl#_8%(z>Qjmt_o$&s{zgi z8oFlT5A#3yLYRfL4nDJ~xUlicB6a3-Pd!IM2|MXoA26*f!b3JIhFUD;uj=ag+2BV1 z!gAS=8IALf1_3DF8`ljOSq=M|QP0EUtv#{NH#GbR({Foqu?;i~PR2Ta!CU2g5iGO2 zr^FdQ&6qH;GKGI%$G)3sI9!tUWqDRpz1U(l8_{ znBP*_d9N9^SzRw)|Ay0Be8}ocOIMU>T5&pqAM_N4T}ZEAgNZ@!-q4G4ZTJUq3t``F z@{eG`M?u13(;rQRKx8-4Rm_p4PG81%oCAp%+^D*%V};qSG+aUXyRb%-z@;H9sbTiK zOORL;vH_&RD(j7ob^Lg8gD`tRZyL>i;ZUsbic+Lb<>#M99wM$CJ68KA`lwF*x1{3O z&Td|p7whAKT!-c1u`7YdhC<8Zc77b2!d?>obEiWZ#nGarwWPs;OJ>0V%bEeRsruJZ zru^XPL~VA2*$$Bm+1uio3a`^?#ZYft*IN1`EBMf%O}H5>q@kMIuG9nQNpSfa;6&To zjPY`Ps;7Eds-R*^@w(BM*)JS4-Fuib!Doiamstvr-%wBm*-ZkP}E zP})ij4`rHatB9uUUb8rMT!zY~I4=q9!6{>&wQ zKImTSc+1M=u+V{~q8(Ai-5m*HgXN1O$G8rPszm{gG9puLG7XQM5H^|e@Qnu_OkmdV z+4UBQR6s^W38V4&2G|hl*?HKPAfu9Jf>)t^U!lu#85dqg+{!b?h!D2$YEX{$0X=uG zhTYR4C>dmW5wlt6vm6 zznvtn@}qRHHcv){t&b7ci{)P3S$gK+xkIRo#AIybnwn;71oF)h~T6<7a({m|FKhDkmGbkT1L`h*=bZ>mPrU)c7ht7c^BAg6d zUAYJHB)nLhN4|W$o#869u0}qg5G@0hKw3NaWyvnnCU~Ja8dpa+cfgI>w(rJ6iOqy7e%!G_WLy&TsQRCl_R7RmIQ7RpWnz_JE$ z`D{{}@W@iXj-W*a{mZEjIi_A1af2I$<~MKVxKW)Z0>azu9OB?l^*$j_6moXiQsS1b z3hZ3BG_iSxRK{a^MAJ}niAqDC8|wDkm)zGA3r}Nw`>boXD<7<&MEvRK?vZ<7$v~Dq zcYxbXJ4}O$M2jlAn58g(VADm>L@7<%oR!}gM{9GF=GS!67j4~-9Xsvp>hkGomU6gz zWk$spvw`6YqO**b6GRT5))Tf*xlabFq75dEF*wM~(6AR**cbA7FY$n_b0PE>5wsST ze;D~8ahd0>4Dta5|Qr5xl{?;alK z4HT;f;Kv11(l+sJw%@S4mww{G?9^IVKIr&UF|HhC_F>NuU=g#Nm)WEmXqq7i=uU5V zq0cea=gB=Hc(fnlKDLyn?GV2Blj-`DBszQYC9JFnx7&{*j;#l{Cp=`4LWt6~Dej&l zI}g4yZ7hT7LMli6IFO@n>!7zsSR1%KtZ3tNIsq8Ryr<^FPD?ujH^a zzJ7{b9NX4C1Z3b7w!{^Dw8HWQU|zn)NXg3U&Zm@_LzYWd5uN{ zHbxa&`88MzNp?cab_eUkkwt%7uR{sb;UAy7U)=W`Hj`0;Z-4cq#OtE!rW<>=BnC&& zsY)38fs@>Dg?suG6NCe#5ipgQC*^R>z`5$hu+A?s?9*0u$uH49(xi6fv!$J|K;pWO zg)DvNv}iN|=Q6veM7-4FW@o44w0)JjT4Q4hpkPr8D;;<4*jJy3HYIfaEieM_+kD4& ziy8S~I>Xe2e=thRoA=a#_a^y-M*SiPy|{70)?IvB zu=Yfs?a7HS&W*jETikFdM_5CGDe<}47?ZA8f;TC9R+MfhJhpb+Mj2w;I(%}h5cDz9 zW_;ulsISH&{ceD^UGG+-YHNahFP+!ypQ4Yx9e9;;ldHC7bCFv0h^IDD#D~ez=lK0x zZZs~Y{27Tc+Ys{Kk;orBbKO4|YNS4qz*lUv+i!!6sYT^qMx2}aRI8u%dZ<831gg#< zWxVwK!A0e@UrfN4@@ijVB=d=L#YmBu?j6adeefPNA+I*dq&r!DZ}M$qPLBBmU&iq^ zfLg_R6-_!5KZ|qEGwXKETd8W0*u>QY>ArubVJAvDdkgx*{qZ-_y2)6-6RZzSpWqOE zf=O;KE-^Bi$_negDPSbH)9%I>FwxDEiH~UM8XLpcXs=ziQRIKJdcX1Ed%#J^b$Ne@ zKKp7Nzy(h@H(P6u9Si;Q?ACLg;*h(@^bR}ODFQE%&E;kf6RT=2W7c2I6nIanXa z9v;yUr~NdEt2&8iJ%?&kC}%EM=mwxF6%Y|B&%5~Do~WtCemfrD>+_&~?JT$L=3uAa zvo)W;Bdz$VH(ZgiJ>(kGUR~$nM&tbc{yhGHM4{RFl7O&5LeSQFQCO!G)B!LgqS2=e$7UhN?u@oD~i%c>K zYw&Y1IlOpBx&#^NXxbsM=$}y$V*zic;RzEM1l_-oWqG`fWMz`_u5;y%sg&bwmGPpM z>0FXXEY{Gz@*3xUAwSvcxM)})mjsA}2zt&E2>`VR(&wF#5e1#L{Q3(Wg zQeaeHFeJaGWigx*PdZqF3!XFSL%(sw?DdaqNOs*hQRxpM$p_JdY%cXl{tl3Y>TcJ; z?a}HVk%EWa!F&!~=iVV2B$3W-E?IY{u>X|FPF_}*umdE?RtO57uZ#vHao0CJYrD9A zL-5QRV_^kmoiu5|S2|u@t9Q261Yi%7(A>NaC)S9W*!XG{8CcgXtL&H{J!Vw#CndFN zy4w=n12qTJIn`0b+o9Cde%^uP<7Ilm001AR>_ULfKj@&ROwk(IvFtVpqmqTs0k|l8 zSWLH5B_Q24;;&N9>qJxhVVwTHzBFg0a3YhfdH1v8U#bY}C#o67V>=So6S>a*S&{wh%E8BaJ8@uEVtuoP zOzV$FluW^wLs%tPb7D@W%|_T>3Tgf?UNC6Mfz`l*W#9H7|axK z#-TYT`1~(cu}*_zM|S}~;94LU?Lf>J%z z@bT*M!B^muvyOai@{6c3bh0*CcWPR0<#r~Z^G*}8cp`Vh!xXtWTKFFENw5!mWE($& z+gc;wl(Iibs%>h<1gyJ&3Mx#w2hZC4jSXn*y??*0$BR_T6z3C5W4cyvjOC|4eUt8X z%szTyOOJRqaSQ~}ZKpxYt+Vc<;6WOVhWiN_D4 zXL-ge{r(bZY?Ykk#BU7sQvs&cUc*5kPEUblI8*!SBOaz6O-?VJ=baONWeHoS)NnnyDqi5x8|wG z=dM+#ydAJHjxRyRZKS7W3I!%6T^#PTw=M5}H*bhJb-LnA^6L9*=Y6yy%I8ihWL3(A zDef-M;y=1XC{;NHX6a776fm;U$jHmC7~*j9tMPrZmM7WDd1JZ(G(og;U3VV(zAe?q z4T_)NP%=4}P$-Q>4cp{*Po$^4nyxcwv_j#A3^85jQdVI27ZT&RyyC_J#O8wHluGVv zm0=+I$)|wzi9%<)3_#R0{)uPhzT8iD@l-CzYKFe%%?UC_`6)OO3y-A{qUQ)NBO{i)xczX3+7Q_3Rr{aCw#2JQ!`<`_Tw+G`m94G-H9ycZkb-yhj1*CU~CGXkI1f|YWL0p9G=z9vck^=Y6A zCQ$deWf5j1;IMSP0S^?jAjyOgw#27HGI*xFivr|3s$P>Xn|(lQLA`6kNOft!%Ob#7DJNF1KqqXuY5KG5IpE;q>@y6-gblZ@`L)WLQSH$;P< zBcuC()4tkqS4|Ui_+T=U%I6NwBe;6tnTew3EVoSG+oTI+RI;>zG^^usPvG4Na33Uy zUz+EJ%Eg8Wc-7;^ezzNTJ)GCh?+#qNlwF6+vmHx2ZbMAt4ixK>az+LgW=2X_=O57b z|F%6z_e9(-9n3922fT6TD}wmXEy`qtVy(obCVnc#OZC*RF?xLKTcynA4b@nzO!GZ% zh2|ZI6OpPfjh;ZgmPM3wn_doPWV?~5$O3D{%2lTkv85pk*iy<`MKZlBT;|iryirbs zuH{Lq9rMk!m%h~C9{z-$GSkek@VVDR+ABTLkgy%gA=vlP7r{&3S^=JxYi`p?!$B($ zMQ@9WT?LDiI5w-fx?rbjXv_`p4Up4ST>Q>jHD1@HM)%F-o`uBR#GNsE-VnFgl92`M zylqC9QvA;aZwIGGo_8Kc?;T_ivn^48Vh6aC2%-Gd$t=+x0)v7NyxQv>d zS3J@7LH4lXLg=~6T7_g{c~X7htv%Snl`HWCQDa|u=iPH^>AfICMV|IEFKi^H*Ok(4 zVlkJTMIx*&_6=+)sHoQKA(FC2-tvmTE#J?HAw)lY27TQaNs$py}w?c1P zLvw)ElGwH5$vO^Wvi6pR%S-4=cUVg5J?DuUG%)9fS$&fU0|%e6OD*IQsQA`L9L3kI zYN^?#$W(GK(?|MnPpda7(2_7$RD$j8i#~XC2>}&EvOUS~kvzxQooI5W@8pAhn0ED& znBt^V%1F^E^6R)V2aLDCJD>+|}31+a8&L=`Kb`t1TTE?8di?!}F?%lI03aeF@4 zdc)_B>j1%|iz9H~6&TN={G9|Ni`?poe_9h{j1y?%-YzMomc*wrj&4scJ}~u~C_2bX z`CAyvB_MmjV6Z>l%}*B7{pb7P6t?DK~lCLVRW@D z5%Kzx)%#!DbAOUh$`R{3#05?}$<5y>$5(bV&TmWMJ5KU(w)Jtx=*FLzOAXbjVz<~t zm3_z=z|;;cie)uEMapa%1{COo4ZF!h_BC!4-BwLDGOl}Qiw*Q#MoJVv`69MN!z*Ok z{(VUhNOSa%Q?WF1tg@QQujKWFf$3JN5mz?FI_wb%pzU;}m?impvbhiQEcsxD!I7t6 zDWH$vb%P_zKNj3+yj{Nb2jWTtE>#?nAYE)PJ!uy)D5vNR$X{+3RDZ2n&$t{+zZ|h3 zkMtNTTxv^b17{4Oj=OIrNfOQttP=fwue}ft80SftSlY;6VoJ*R@o>nJyj(OlL6Cr((mcGJ z1*EpLn! z$R&`>F0mWDa&Atyx_BN*ezLLkHslU5=2sH)Qq&z*Z&jy0rK_3IP-b@AD|p$7Rj7k@ zi~C(?ZbY@ea)k<-}EXLZHf9ppg6dMe*q{j0p~1xJva~f;b1ZLs$;jDE|;5YY$18P zlHqmpUVA7TRYk$8ZRB@1&7D)C0b>eEQXXUQ*E?zAdV4vbmHXN?YsRuKh0wEQ?PzSy@h>^3h6WQ!V+RY$~x5%+#<16(1m$p^c+rm=r1~DK=vZmPKv~ zYLnzB^3E5lq(!-A`9OsfFnpAiy1q%VbkNMjcK?ap-|tWNd(OS*obUI3Z-)^_c*>}; zw0BwpnE8%Vky&QH9*ZN^#^B4>#$1vfx(jEjk1mHqA;!H+a@3|!P8#yM{Qm9g6T zvr{?QrdW7@0@3Q%P)h2=Z`kgVEWx!UNDxOn3&-aNj;SScaK2vBltJTU+!Ly0HqE>* znw@x2GRUKws}sj;kjrsNPVEOT!%jMLN0v@Ft z$T*O&M;gp6N`!^eA!UEOR=<$F(0)3Nu2vH>M#A(oOY<}r=;0M9(V4d6ko9Qr)Tf1oe<=J#unNmgSd3|lE#)Ogt`kFhyV4(Zy-SVuz#~F zeyJk#rHNhrc>={-gYT_Hm}}6YpStB8gGLDtoz6$QdW!~Yk)1cI((Ni`ztv5oOofDK zbJ=!1Go*~ghz>u;@Z5oFS5ghw;jSP!rWDR%p|fewxkzDK;~K8q8Fp@7$T{Iu+z%}1 zue>t-!?d=i>=aZG92-&P7+yd7v;TH;ikK|mR0n6|ggd7MR`b{*B93jPQ_L^SyefnH zoUDax5!5>EFHYe#Op>QC2sID_DQ%4R>YSYZG&xvy@Mw>?`r(0;^e1G3IHYwle?;$8 zIQFVy=V$#63UIwC>shaj?4>V*fr|;>3cfVR6PXncI6snEk?;u&kt{kBnwE6Pz|=}3 z%h6W)$Jj(lW;R&YBh9}GjItZ)HvV9}`*>NjBHtb*412W~0^xN5`5ys#)L&zpho>6C zuFOLMRF$o=mZ90dS9Ijn9%%v;wYJZo#MF-gno}nFt1>+;UL#H8J3XbFwXI=vB0D!( z0Bi$rNEqnCC&4(u(faREu?-C z3C08C{ZINha!|2z2;{dgky^*jM5)X@Ja+qvDA0W>>d;yINM?B>uDS9VEPk5|ts#PScf`c6t|LBb(qd`u zYv17M?@Sg5oBr`i9{CJ?V-}kV{Rih zeg={B;%V=9eZ+0?s`>gg;_eq`pF&*SAqc4WeUkl1%Eu7zx`>_Yt!n~a3JGk;(JFz0 z5R_LCAZ&jWS$^{>X&|)H;4Ne7R0fqh{9r0F)O%FvD7jX!?Pfw>o>p4LAO~2macT%` z`P<{We|q^O&HYxMp0#(N%>DtD)HqoDwY960&`mDgIa%q`qL~u7eHIv+i~4GMFMFKf@IZi~4esR>g}5Cr?W%UfLL7T@JtE4hUI!BK zVI0+_JV*+R@nLrXbI0qMu*Hcf4JfC-m&mxy-#+kD=cg9djl~!y4}78sl`-pPUSLuU z0DjnNcqq~LGI`CWU{_XyXCY3$Q-s>H71KJJ3qQ`ar-Pwahcb+wab`4-m%dZHa1Ndm zcZ`zYg-d;&jX{IXy=R^6aLoJtm3BtZ{c^Hav8p*Hk-R)}%DwP@P1pCz(=4(vbupIv zz|h0o`{gcG)tl?RZ_<|8wq32Qe@g_(^)fSzy=7Hdx+L-!gntW|YG#kO%=v!gP`jL; z2iRrP6V0oWMnVfu(8LqW8>Z2$!@t{(tFCm13YifwkML2)jrpo zTFvSXxZUj`Wr_VL&(R|I3(HT1PO%|yU>5lB-O%yJ-UFNq(2_F_4h_l!oWc-S_wqL5 zNf)~@WtFzMu=kiFGH*kw$4rsyk5rP~>KvQ=r{$%EHs}7;g3CC^9@iBmUjG8M`IhD1 zO}1R50DyohZuHWee0~(XC?AeJ`d=Fht==f-`OmWN{{I-S{|O3Dy|M+r=3HD3>qp%x O5WcX0Qx#qpGXD*7% Date: Mon, 17 Aug 2026 07:06:20 +0200 Subject: [PATCH 4/6] Gate the local network URL behind a trusted Wi-Fi network match Trying the local URL unconditionally meant a laptop joining any Wi-Fi could send the access token to whatever answers at that address there, not just at home. Adds a trusted network name field next to the local URL. The bridge now checks the current Wi-Fi network name (via nmcli) before every attempt and only tries the local URL when it matches, exactly like the official Home Assistant apps do with their internal URL. Any failure to determine the current network fails closed to the primary URL. --- AGENTS.md | 14 ++++++- ConfigStore.js | 12 ++++-- Connection.js | 14 ++++--- README.md | 15 ++++--- Service.qml | 34 +++++++++++++--- Settings.qml | 47 +++++++++++++++++++-- bin/hass-bridge | 82 ++++++++++++++++++++++++++++++++----- tests/test_bridge.py | 88 +++++++++++++++++++++++++++++++++++++--- tests/test_config.js | 6 ++- tests/test_connection.js | 11 +++++ 10 files changed, 281 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a8ecd65..df2e3ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,10 @@ Canonical public repository: `https://github.com/konradk/hass`. The plugin must remain installable without npm, pip, a virtual environment, or first-run downloads. Python 3.11 or newer, `secret-tool`, and the vendored -`websockets` package are the runtime dependencies. +`websockets` package are the runtime dependencies. `nmcli` is an additional, +conditional one: only invoked when a local network URL is actually configured +(to check the current Wi-Fi network name), never otherwise. Its absence must +degrade to "never use the local URL", not an error. ## Architecture map @@ -52,6 +55,15 @@ to `Service.qml`. separate credential storage for it, and do not let it participate in `currentOrigin()`/`requiresTokenFor()` — those stay scoped to the primary URL only. +- `localUrl` must never be tried unless `trustedNetwork` is set and matches + the current Wi-Fi network name (`current_wifi_ssid()` in `bin/hass-bridge`, + checked fresh on every connection attempt). This is the only thing standing + between an alternate address and sending the token to whatever happens to + answer there on a network the user never trusted — fail closed on every + path (no NetworkManager, an nmcli error or timeout, no active Wi-Fi, no + match) rather than defaulting to "trusted". Enforce this in both the bridge + and `Service.applyConnection` — the settings UI check is a fast-fail + convenience, not the security boundary. - Treat `http://` and `ws://` as plaintext transport. Any UI path that permits them must make the token-exposure risk explicit; never downgrade an invalid or unknown scheme to plaintext. diff --git a/ConfigStore.js b/ConfigStore.js index 2c457c9..4b10e1b 100644 --- a/ConfigStore.js +++ b/ConfigStore.js @@ -1,8 +1,9 @@ .pragma library var KEYS = [ - "baseUrl", "localUrl", "demoMode", "favorites", "demoFavorites", "groupByArea", - "showEntityIcons", "selectedTab", "displayNameOverrides", "iconOverrides" + "baseUrl", "localUrl", "trustedNetwork", "demoMode", "favorites", + "demoFavorites", "groupByArea", "showEntityIcons", "selectedTab", + "displayNameOverrides", "iconOverrides" ] function stringList(value, fallback) { @@ -49,9 +50,12 @@ function parse(text, demoDefaults) { config: { baseUrl: typeof raw.baseUrl === "string" ? raw.baseUrl : "", // Optional. An alternate address for the same Home Assistant instance - // — a LAN address, say — tried first when reachable. It shares baseUrl's - // credential; it is never a separate keyring origin. + // — a LAN address, say — tried first, but only on trustedNetwork. It + // shares baseUrl's credential; it is never a separate keyring origin. localUrl: typeof raw.localUrl === "string" ? raw.localUrl : "", + // The Wi-Fi network name localUrl requires a match against before it is + // ever tried. See bin/hass-bridge's current_wifi_ssid. + trustedNetwork: typeof raw.trustedNetwork === "string" ? raw.trustedNetwork : "", demoMode: raw.demoMode === true, favorites: stringList(raw.favorites, []), demoFavorites: stringList(raw.demoFavorites, diff --git a/Connection.js b/Connection.js index d18f2f9..34424af 100644 --- a/Connection.js +++ b/Connection.js @@ -78,16 +78,20 @@ function normalizeOrigin(value) { // rather than as a connection worth starting. // // localValue is optional: an alternate address for the same instance (a LAN -// address, tried first when reachable). It shares value's credential origin, -// so it is folded into the signature only to restart the bridge when it -// changes — it never contributes an origin of its own. -function signature(demoMode, value, localValue) { +// address, only ever used on trustedNetwork — see bin/hass-bridge). It shares +// value's credential origin, so both are folded into the signature only to +// restart the bridge when either changes — neither contributes an origin of +// its own. +function signature(demoMode, value, localValue, trustedNetwork) { if (demoMode) return "demo" var origin = normalizeOrigin(value) if (!origin) return "" var text = origin + "|" + String(value || "").trim() var local = String(localValue || "").trim() - return local ? text + "|" + local : text + if (local) text += "|" + local + var trust = String(trustedNetwork || "").trim() + if (trust) text += "|" + trust + return text } function acceptsGeneration(activeGeneration, eventGeneration) { diff --git a/README.md b/README.md index 68ea3a0..9c897c2 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ omarchy-shell hass devices # device picker - Omarchy 4 (`schemaVersion: 1` plugin API) - Python 3.11 or newer - `secret-tool` (libsecret) with a running keyring daemon +- `nmcli` (NetworkManager), only if you use a local network URL The pure-Python runtime of `websockets` 17.0.1 is bundled with the plugin and loaded from `vendor/`. Users don't need `python-websockets`, `qt6-websockets`, @@ -86,11 +87,15 @@ your profile → Security), or flip on **Demo mode** to try the panel against a built-in fake house with no instance at all. Then switch to **Devices** and star the ones you want in the panel. -Optionally, add a **Local network URL** — your instance's LAN address. It's -tried first on every connection attempt and used when it answers, with the -URL above as fallback (for example when a laptop is away from home). It's the -same Home Assistant instance reached by a different address, so it reuses the -one access token above rather than needing its own. +Optionally, turn on **Local network URL** to add your instance's LAN address. +It's the same Home Assistant instance reached by a different address, so it +reuses the one access token above rather than needing its own. It also asks +for the name of your trusted Wi-Fi network: the local URL is only ever tried +while connected to that network, and the URL above is used everywhere else. +This matters because the local URL is plaintext-friendly on the assumption +that your home network is trustworthy — without the network-name check, a +laptop that later joins some other Wi-Fi with something answering on that +same address would send it your token. ## Debugging diff --git a/Service.qml b/Service.qml index 6326daa..d7ec8f3 100644 --- a/Service.qml +++ b/Service.qml @@ -28,9 +28,13 @@ QtObject { property bool demoMode: false property string baseUrl: "" // Optional alternate address for the same instance — a LAN address, say — - // the bridge tries first when reachable. Shares baseUrl's credential; never - // its own keyring origin. See Connection.signature and CredentialManager. + // the bridge tries first, but only on trustedNetwork. Shares baseUrl's + // credential; never its own keyring origin. See Connection.signature and + // CredentialManager. property string localUrl: "" + // The Wi-Fi network name localUrl requires a match against before the + // bridge will ever try it. See bin/hass-bridge's current_wifi_ssid. + property string trustedNetwork: "" // True only while connected through localUrl rather than baseUrl. property bool usingLocal: false property int connectionGeneration: 0 @@ -89,6 +93,7 @@ QtObject { return { baseUrl: root.baseUrl, localUrl: root.localUrl, + trustedNetwork: root.trustedNetwork, demoMode: root.demoMode, favorites: root.liveFavorites.slice(), demoFavorites: root.demoFavorites.slice(), @@ -204,7 +209,7 @@ QtObject { function finishRemoveConnection() { root.connectionSuppressed = false root.saveConfig({ - baseUrl: "", localUrl: "", demoMode: false, favorites: [], + baseUrl: "", localUrl: "", trustedNetwork: "", demoMode: false, favorites: [], displayNameOverrides: {}, iconOverrides: {}, selectedTab: "favorites" }) // demoFavorites untouched: not part of the connection } @@ -237,7 +242,7 @@ QtObject { root.reconcileConnection() } - function applyConnection(url, localUrl, token, demo) { + function applyConnection(url, localUrl, trustedNetwork, token, demo) { var origin = demo ? "demo" : Connection.normalizeOrigin(url) if (!origin) { root.phase = "error" @@ -252,6 +257,17 @@ QtObject { root.lastError = "Enter a valid http(s) or ws(s) local network URL, or leave it blank." return false } + // A local URL with no trusted network to gate it would otherwise be tried + // on every Wi-Fi the laptop ever joins, sending the token to whatever + // happens to answer at that address. The bridge enforces this too — this + // check exists to fail fast with a clear message instead of a silently + // inert field. + var trimmedTrust = String(trustedNetwork || "").trim() + if (!demo && trimmedLocal && !trimmedTrust) { + root.phase = "error" + root.lastError = "Enter the trusted Wi-Fi network name for the local URL, or leave the local URL blank." + return false + } if (!demo && !token && root.requiresTokenFor(url)) { root.phase = "error" root.lastError = "A new Home Assistant origin requires a new token." @@ -266,7 +282,10 @@ QtObject { root.lastError = "Could not start token storage while the keyring is busy." return false } - root.saveConfig({ baseUrl: url, localUrl: demo ? "" : trimmedLocal, demoMode: demo }) + root.saveConfig({ + baseUrl: url, localUrl: demo ? "" : trimmedLocal, + trustedNetwork: demo ? "" : trimmedTrust, demoMode: demo + }) return true } @@ -292,6 +311,7 @@ QtObject { root.demoMode = config.demoMode root.baseUrl = config.baseUrl root.localUrl = config.localUrl + root.trustedNetwork = config.trustedNetwork root.liveFavorites = config.favorites root.demoFavorites = config.demoFavorites root.displayNameOverrides = config.displayNameOverrides @@ -345,7 +365,8 @@ QtObject { // Connection.js owns this rule, so the definition of "same connection" // cannot drift from the one the tests pin. - var signature = Connection.signature(root.demoMode, root.baseUrl, root.localUrl) + var signature = Connection.signature( + root.demoMode, root.baseUrl, root.localUrl, root.trustedNetwork) if (!signature) { root.phase = "error" root.lastError = "Home Assistant URL is invalid." @@ -424,6 +445,7 @@ QtObject { op: "config", url: root.baseUrl, localUrl: root.localUrl, + trustedNetwork: root.trustedNetwork, token: token, generation: root.connectionGeneration }) diff --git a/Settings.qml b/Settings.qml index 0505e3b..ca8821b 100644 --- a/Settings.qml +++ b/Settings.qml @@ -26,6 +26,9 @@ Item { // Local until Connect, so a half-typed URL never reaches the bridge. property string urlDraft: "" property string localUrlDraft: "" + // Required whenever localUrlDraft is non-empty: the local URL is only ever + // tried on this Wi-Fi network. See bin/hass-bridge's current_wifi_ssid. + property string trustedNetworkDraft: "" // Collapsed unless a local URL is already saved: most people never need // this field, and a second always-visible URL box with its own warning text // outweighs the value of surfacing it up front. @@ -94,6 +97,7 @@ Item { if (!service) return root.urlDraft = service.baseUrl root.localUrlDraft = service.localUrl + root.trustedNetworkDraft = service.trustedNetwork root.localUrlExpanded = service.localUrl.length > 0 // The stored token never comes back to screen; blank means "keep it". root.tokenDraft = "" @@ -108,7 +112,7 @@ Item { function applyConnection() { if (!service) return if (service.applyConnection(root.urlDraft.trim(), root.localUrlDraft.trim(), - root.tokenDraft, false)) { + root.trustedNetworkDraft.trim(), root.tokenDraft, false)) { root.tokenDraft = "" } } @@ -259,8 +263,12 @@ Item { // Blank is fine — it just means no local fallback. readonly property bool validLocalUrl: root.localUrlDraft.trim().length === 0 || Connection.normalizeOrigin(root.localUrlDraft) !== "" - readonly property bool canConnect: validUrl && validLocalUrl && !keyringBusy - && (!needsToken || root.tokenDraft.length > 0) + // A local URL with no trusted network to gate it would be tried on + // every Wi-Fi the laptop joins. + readonly property bool localUrlNeedsTrust: root.localUrlDraft.trim().length > 0 + && root.trustedNetworkDraft.trim().length === 0 + readonly property bool canConnect: validUrl && validLocalUrl && !localUrlNeedsTrust + && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) Column { id: connectionColumn @@ -315,7 +323,10 @@ Item { root.localUrlExpanded = !root.localUrlExpanded // Collapsing means "no local URL" — a hidden stale draft would // otherwise still reach applyConnection. - if (!root.localUrlExpanded) root.localUrlDraft = "" + if (!root.localUrlExpanded) { + root.localUrlDraft = "" + root.trustedNetworkDraft = "" + } } } @@ -339,6 +350,34 @@ Item { font.pixelSize: Style.font.caption wrapMode: Text.WordWrap } + + Text { + textFormat: Text.PlainText + visible: root.localUrlExpanded + text: "Trusted Wi-Fi network name" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } + + TextField { + visible: root.localUrlExpanded + width: connectionColumn.width + text: root.trustedNetworkDraft + placeholderText: "Home" + onTextChanged: root.trustedNetworkDraft = text + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlExpanded + text: "Required. The local URL is only ever tried while connected to this Wi-Fi network — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } Column { diff --git a/bin/hass-bridge b/bin/hass-bridge index f84de2b..b8b2353 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -13,13 +13,18 @@ writes commands on our stdin and reads events from our stdout. Commands (stdin) {"protocolVersion":1,"op":"config","generation":7, "url":"https://ha.local:8123","localUrl":"https://192.168.1.50:8123", - "token":"..."} + "trustedNetwork":"Home Wifi","token":"..."} Connect, or reconnect with new credentials. The token arrives here rather than in argv because /proc//cmdline is readable by every process running as this user. localUrl is optional: an alternate - address for the same instance (a LAN address, say). Every connection - attempt tries it first with a short timeout and falls back to url on - failure — it shares url's token rather than needing its own. + address for the same instance (a LAN address, say). It is only ever + tried, ahead of url and with a short timeout, when trustedNetwork is + set and matches the current Wi-Fi network name (checked via nmcli). + Otherwise every attempt goes straight to url. This is what stops the + token from being sent to whatever happens to answer at that address + on an untrusted network — localUrl shares url's token rather than + needing its own, so this check is the only thing standing between it + and an unwanted disclosure. {"op":"call_service","domain":"light","service":"turn_on", "entity_id":"light.x","data":{"brightness_pct":40},"tag":"..."} {"op":"refresh"} re-request the full state snapshot @@ -53,8 +58,10 @@ import json import os import queue import random +import re import socket import ssl +import subprocess import sys import threading import time @@ -117,6 +124,50 @@ BACKOFF_MAX = 30.0 # only matters for an address nothing answers at all. LOCAL_CONNECT_TIMEOUT = 2.0 +# Bound on the nmcli call in current_wifi_ssid(), so a hung network stack +# cannot wedge the bridge's single-threaded run loop. +NETWORK_CHECK_TIMEOUT = 2.0 + + +def current_wifi_ssid(): + """Best-effort current Wi-Fi network name, or None. + + None whenever this cannot be determined with confidence — no + NetworkManager, no active Wi-Fi, a timeout, unparseable output — not just + when nothing matches. The local URL has to fail closed here: this is the + only thing standing between it and sending a token to whatever happens to + answer at that address on a network that was never trusted. + """ + # Test-only seam. Real installs never set this; nothing in this plugin + # sets it either. It exists because tests/test_bridge.py drives the + # bridge as a real subprocess with no way to patch this function + # directly, and CI has no controllable Wi-Fi to join. + override = os.environ.get("HASS_BRIDGE_TEST_SSID") + if override is not None: + return override or None + + try: + result = subprocess.run( + ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], + capture_output=True, text=True, timeout=NETWORK_CHECK_TIMEOUT) + except Exception: + return None + if result.returncode != 0: + return None + + for line in result.stdout.splitlines(): + # nmcli's terse output escapes a literal ':' inside a field as '\:', + # so only an unescaped colon is a field separator. + fields = re.split(r"(? Date: Mon, 17 Aug 2026 20:40:31 +0200 Subject: [PATCH 5/6] Allow more than one trusted network name A single name didn't cover routers that broadcast separate 2.4GHz and 5GHz SSIDs, or people who consider more than one location trusted. The field now takes a comma-separated list, matched against exactly, same as before just against more than one name. --- AGENTS.md | 8 +++++--- Connection.js | 11 +++++++++++ README.md | 6 ++++-- Service.qml | 4 ++-- Settings.qml | 8 ++++---- bin/hass-bridge | 32 +++++++++++++++++++++----------- tests/test_bridge.py | 22 ++++++++++++++++++++++ tests/test_connection.js | 9 +++++++++ 8 files changed, 78 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df2e3ac..9597dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,9 +55,11 @@ to `Service.qml`. separate credential storage for it, and do not let it participate in `currentOrigin()`/`requiresTokenFor()` — those stay scoped to the primary URL only. -- `localUrl` must never be tried unless `trustedNetwork` is set and matches - the current Wi-Fi network name (`current_wifi_ssid()` in `bin/hass-bridge`, - checked fresh on every connection attempt). This is the only thing standing +- `localUrl` must never be tried unless `trustedNetwork` is set and one of its + comma-separated names matches the current Wi-Fi network name + (`current_wifi_ssid()` and `trusted_network_list()` in `bin/hass-bridge`, + checked fresh on every connection attempt — the list exists because a + router commonly broadcasts more than one SSID). This is the only thing standing between an alternate address and sending the token to whatever happens to answer there on a network the user never trusted — fail closed on every path (no NetworkManager, an nmcli error or timeout, no active Wi-Fi, no diff --git a/Connection.js b/Connection.js index 34424af..3331fd5 100644 --- a/Connection.js +++ b/Connection.js @@ -4,6 +4,17 @@ // outside Service.qml makes the security boundary testable with Node as well // as usable by the QML service. +// Mirrors bin/hass-bridge's Bridge.trusted_network_list: a comma-separated +// list of Wi-Fi network names, since a router commonly broadcasts more than +// one (separate 2.4GHz/5GHz SSIDs). Kept here so the settings UI's notion of +// "is a trusted network actually configured" cannot drift from the bridge's +// — a field containing only commas or whitespace must count as empty in both. +function trustedNetworkList(value) { + return String(value || "").split(",") + .map(function(name) { return name.trim() }) + .filter(function(name) { return name.length > 0 }) +} + function preparedUrl(value) { var text = String(value || "").trim() if (!text) return "" diff --git a/README.md b/README.md index 9c897c2..b4d3ffe 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,10 @@ star the ones you want in the panel. Optionally, turn on **Local network URL** to add your instance's LAN address. It's the same Home Assistant instance reached by a different address, so it reuses the one access token above rather than needing its own. It also asks -for the name of your trusted Wi-Fi network: the local URL is only ever tried -while connected to that network, and the URL above is used everywhere else. +for the name of your trusted Wi-Fi network (comma-separate more than one, for +example if your router has separate 2.4GHz/5GHz names): the local URL is only +ever tried while connected to one of those, and the URL above is used +everywhere else. This matters because the local URL is plaintext-friendly on the assumption that your home network is trustworthy — without the network-name check, a laptop that later joins some other Wi-Fi with something answering on that diff --git a/Service.qml b/Service.qml index d7ec8f3..e525b55 100644 --- a/Service.qml +++ b/Service.qml @@ -263,9 +263,9 @@ QtObject { // check exists to fail fast with a clear message instead of a silently // inert field. var trimmedTrust = String(trustedNetwork || "").trim() - if (!demo && trimmedLocal && !trimmedTrust) { + if (!demo && trimmedLocal && Connection.trustedNetworkList(trimmedTrust).length === 0) { root.phase = "error" - root.lastError = "Enter the trusted Wi-Fi network name for the local URL, or leave the local URL blank." + root.lastError = "Enter at least one trusted Wi-Fi network name for the local URL, or leave the local URL blank." return false } if (!demo && !token && root.requiresTokenFor(url)) { diff --git a/Settings.qml b/Settings.qml index ca8821b..3e05b8f 100644 --- a/Settings.qml +++ b/Settings.qml @@ -266,7 +266,7 @@ Item { // A local URL with no trusted network to gate it would be tried on // every Wi-Fi the laptop joins. readonly property bool localUrlNeedsTrust: root.localUrlDraft.trim().length > 0 - && root.trustedNetworkDraft.trim().length === 0 + && Connection.trustedNetworkList(root.trustedNetworkDraft).length === 0 readonly property bool canConnect: validUrl && validLocalUrl && !localUrlNeedsTrust && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) @@ -354,7 +354,7 @@ Item { Text { textFormat: Text.PlainText visible: root.localUrlExpanded - text: "Trusted Wi-Fi network name" + text: "Trusted Wi-Fi network name(s)" color: Color.muted font.family: root.family font.pixelSize: Style.font.bodySmall @@ -364,7 +364,7 @@ Item { visible: root.localUrlExpanded width: connectionColumn.width text: root.trustedNetworkDraft - placeholderText: "Home" + placeholderText: "Home, Home 5G" onTextChanged: root.trustedNetworkDraft = text } @@ -372,7 +372,7 @@ Item { textFormat: Text.PlainText width: connectionColumn.width visible: root.localUrlExpanded - text: "Required. The local URL is only ever tried while connected to this Wi-Fi network — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." + text: "Required. Comma-separated if your router has more than one (e.g. separate 2.4GHz/5GHz names). The local URL is only ever tried while connected to one of these — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." color: Color.muted font.family: root.family font.pixelSize: Style.font.caption diff --git a/bin/hass-bridge b/bin/hass-bridge index b8b2353..d1429d9 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -13,13 +13,15 @@ writes commands on our stdin and reads events from our stdout. Commands (stdin) {"protocolVersion":1,"op":"config","generation":7, "url":"https://ha.local:8123","localUrl":"https://192.168.1.50:8123", - "trustedNetwork":"Home Wifi","token":"..."} + "trustedNetwork":"Home,Home 5G","token":"..."} Connect, or reconnect with new credentials. The token arrives here rather than in argv because /proc//cmdline is readable by every process running as this user. localUrl is optional: an alternate address for the same instance (a LAN address, say). It is only ever tried, ahead of url and with a short timeout, when trustedNetwork is - set and matches the current Wi-Fi network name (checked via nmcli). + set and one of its comma-separated names matches the current Wi-Fi + network name (checked via nmcli — a router commonly broadcasts more + than one SSID, e.g. separate 2.4GHz/5GHz names, hence the list). Otherwise every attempt goes straight to url. This is what stops the token from being sent to whatever happens to answer at that address on an untrusted network — localUrl shares url's token rather than @@ -928,16 +930,17 @@ class Bridge: return # The local URL is tried first, ahead of the primary URL, but only - # when the current Wi-Fi network name matches trustedNetwork — this is - # re-checked on every attempt since the network can have changed since - # the last one (a laptop rejoining home wifi, or leaving it). Without - # this, a local URL would silently send the token to whatever answers - # at that address on any untrusted network the laptop joins. A local - # candidate that does not even parse is skipped rather than fatal: it - # is optional, so a typo there should not stop the primary URL. + # when the current Wi-Fi network name is one of trustedNetwork's + # comma-separated entries — this is re-checked on every attempt since + # the network can have changed since the last one (a laptop rejoining + # home wifi, or leaving it). Without this, a local URL would silently + # send the token to whatever answers at that address on any untrusted + # network the laptop joins. A local candidate that does not even parse + # is skipped rather than fatal: it is optional, so a typo there should + # not stop the primary URL. candidates = [] - if (self.local_url and self.trusted_network - and current_wifi_ssid() == self.trusted_network): + trusted = self.trusted_network_list() + if self.local_url and trusted and current_wifi_ssid() in trusted: candidates.append((self.local_url, True, LOCAL_CONNECT_TIMEOUT)) candidates.append((self.url, False, OPEN_TIMEOUT)) @@ -983,6 +986,13 @@ class Bridge: self.subscription_ready = False self.snapshot_ready = False + def trusted_network_list(self): + # A router commonly broadcasts more than one name — separate 2.4GHz + # and 5GHz SSIDs are the usual case — so this is a comma-separated + # list, matched against exactly, not a single name. + return [name.strip() for name in self.trusted_network.split(",") + if name.strip()] + def websocket_url(self, raw_url): # Every parse step below can raise ValueError on input the settings # overlay was willing to accept: urlsplit rejects an unbalanced bracket diff --git a/tests/test_bridge.py b/tests/test_bridge.py index dfa61bf..ea10904 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -696,6 +696,27 @@ def test_unparseable_local_url_does_not_block_the_primary(): server.stop() +def test_local_url_matches_any_of_several_trusted_networks(): + print("local: trustedNetwork accepts a comma-separated list") + # A router commonly broadcasts more than one SSID (separate 2.4GHz/5GHz + # names); the current network only has to match one of the list. + local = FakeHA() + bridge = BridgeProc(env={"HASS_BRIDGE_TEST_SSID": "Home 5G"}) + try: + bridge.send({"op": "config", "url": "http://127.0.0.1:1", + "localUrl": local.url, "trustedNetwork": "Home, Home 5G", + "token": "tok"}) + connected = bridge.wait_for( + lambda e: e["ev"] == "phase" and e["phase"] == "connected") + check("reaches connected", connected is not None) + check("flags the connection as local", + connected is not None and connected.get("usingLocal") is True, + connected) + finally: + bridge.stop() + local.stop() + + def test_local_url_is_skipped_off_the_trusted_network(): print("local: a reachable local URL is not used off the trusted network") # This is the actual security property: a local URL must never be tried @@ -840,6 +861,7 @@ def main(): test_local_url_is_preferred_when_reachable, test_falls_back_to_primary_when_local_is_unreachable, test_unparseable_local_url_does_not_block_the_primary, + test_local_url_matches_any_of_several_trusted_networks, test_local_url_is_skipped_off_the_trusted_network, test_local_url_without_a_trusted_network_is_never_used, test_unknown_wifi_state_fails_closed, diff --git a/tests/test_connection.js b/tests/test_connection.js index e0e7fba..4730c9a 100644 --- a/tests/test_connection.js +++ b/tests/test_connection.js @@ -99,6 +99,15 @@ eq("changing only the trusted network changes the signature", === Connection.signature(false, "https://ha.local:8123", "https://192.168.1.50:8123", "Office"), false); +eq("a blank trusted network list is empty", + Connection.trustedNetworkList(""), []); +eq("a single trusted network is a one-item list", + Connection.trustedNetworkList("Home"), ["Home"]); +eq("comma-separated names are split and trimmed", + Connection.trustedNetworkList(" Home , Home 5G ,Office "), + ["Home", "Home 5G", "Office"]); +eq("only commas and whitespace is still empty", + Connection.trustedNetworkList(" , , "), []); eq("matching generation is accepted", Connection.acceptsGeneration(4, 4), true); eq("old generation is rejected", Connection.acceptsGeneration(5, 4), false); eq("missing generation is rejected", Connection.acceptsGeneration(5, undefined), false); From 7c41706f53f5b7c7c5678da530dcf0a31174e433 Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Mon, 17 Aug 2026 22:41:43 +0200 Subject: [PATCH 6/6] Fix modal overflow and suggest the current Wi-Fi network name The connection settings card has a fixed height, and the local URL fields pushed content past it with nothing to contain the overflow. Wrapped the form in a scrollable Flickable so this can't recur as more fields get added. Also suggests the currently connected Wi-Fi name as a one-click fill for the trusted network field, shown only while that field is empty. While building it, found that nmcli's default wifi list forces a multi-second radio scan, which would have blocked the bridge's single-threaded run loop on every reconnect attempt whenever a local URL is configured. Added --rescan no to both the bridge's check and the new suggestion, since neither needs a fresh scan of neighboring networks, only NetworkManager's already-cached state for the active connection. --- AGENTS.md | 9 +- Connection.js | 21 ++ README.md | 3 +- Settings.qml | 483 ++++++++++++++++++++++----------------- bin/hass-bridge | 8 +- tests/test_connection.js | 9 + 6 files changed, 322 insertions(+), 211 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9597dab..beacb59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,9 +13,12 @@ Canonical public repository: `https://github.com/konradk/hass`. The plugin must remain installable without npm, pip, a virtual environment, or first-run downloads. Python 3.11 or newer, `secret-tool`, and the vendored `websockets` package are the runtime dependencies. `nmcli` is an additional, -conditional one: only invoked when a local network URL is actually configured -(to check the current Wi-Fi network name), never otherwise. Its absence must -degrade to "never use the local URL", not an error. +conditional one, invoked from two places: the bridge (only when a local +network URL is actually configured, to gate every connection attempt — see +the security invariant below) and the settings UI (each time it opens, to +suggest a value for the trusted-network field; read-only, never a security +decision). Its absence must degrade to "never use the local URL" for the +bridge and "no suggestion" for the UI, not an error either way. ## Architecture map diff --git a/Connection.js b/Connection.js index 3331fd5..e1d7bf5 100644 --- a/Connection.js +++ b/Connection.js @@ -15,6 +15,27 @@ function trustedNetworkList(value) { .filter(function(name) { return name.length > 0 }) } +// Mirrors bin/hass-bridge's current_wifi_ssid line parsing: nmcli -t's terse +// output is "active:ssid" per line, with a literal ':' inside a field escaped +// as '\:'. Settings.qml uses this only to suggest a value for the trusted +// network field — the bridge is the actual security boundary and re-checks +// the current network independently in Python before ever using a local URL. +function parseNmcliActiveSsid(text) { + var lines = String(text || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var line = lines[i] + var splitAt = -1 + for (var c = 0; c < line.length; c++) { + if (line[c] === ":" && line[c - 1] !== "\\") { splitAt = c; break } + } + if (splitAt === -1) continue + if (line.slice(0, splitAt) !== "yes") continue + var ssid = line.slice(splitAt + 1).replace(/\\:/g, ":").replace(/\\\\/g, "\\") + if (ssid) return ssid + } + return "" +} + function preparedUrl(value) { var text = String(value || "").trim() if (!text) return "" diff --git a/README.md b/README.md index b4d3ffe..a790e0b 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ reuses the one access token above rather than needing its own. It also asks for the name of your trusted Wi-Fi network (comma-separate more than one, for example if your router has separate 2.4GHz/5GHz names): the local URL is only ever tried while connected to one of those, and the URL above is used -everywhere else. +everywhere else. If that field is empty, it'll offer the network you're +currently on as a one-click suggestion. This matters because the local URL is plaintext-friendly on the assumption that your home network is trustworthy — without the network-name check, a laptop that later joins some other Wi-Fi with something answering on that diff --git a/Settings.qml b/Settings.qml index 3e05b8f..8d209ef 100644 --- a/Settings.qml +++ b/Settings.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick import QtQuick.Controls @@ -33,8 +34,28 @@ Item { // this field, and a second always-visible URL box with its own warning text // outweighs the value of surfacing it up front. property bool localUrlExpanded: false + // Suggestion only, for the trusted-network field — read-only, not used for + // anything security-relevant. The bridge determines the actual network + // independently in Python before ever using a local URL. + property string detectedWifiSsid: "" property string tokenDraft: "" + Process { + id: wifiSsidProbe + // --rescan no: only what NetworkManager already knows about the active + // connection is needed here, not a fresh scan of every nearby network — + // that forces nmcli to block for several seconds instead of returning + // near-instantly. + command: ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi", "list", + "--rescan", "no"] + stdout: SplitParser { + onRead: function(value) { + var ssid = Connection.parseNmcliActiveSsid(value) + if (ssid) root.detectedWifiSsid = ssid + } + } + } + property string query: "" // Debounced: a burst of keystrokes costs one pass over the entities. property string appliedQuery: "" @@ -79,6 +100,11 @@ Item { } catch (e) { // Not worth refusing to open over. } + // Fresh on every open, not just the first: the answer can change between + // sessions, and a stale one from an old network would suggest the wrong + // name. + root.detectedWifiSsid = "" + if (!wifiSsidProbe.running) wifiSsidProbe.running = true Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -270,247 +296,292 @@ Item { readonly property bool canConnect: validUrl && validLocalUrl && !localUrlNeedsTrust && !keyringBusy && (!needsToken || root.tokenDraft.length > 0) - Column { - id: connectionColumn - anchors { top: parent.top; left: parent.left; right: parent.right } - spacing: Style.spacing.xxxl + // Flickable rather than anchoring the column straight to the card: the + // card's height is fixed (see card.preferredHeight above), and this + // form has grown past it before — the local URL toggle alone added + // enough fields to spill content out past the card's border with + // nothing to contain it. A Column that can outgrow its container has to + // scroll, not just hope it never does. + Flickable { + id: connectionFlick + anchors.fill: parent + clip: true + contentWidth: width + contentHeight: connectionColumn.height + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { - width: connectionColumn.width - spacing: Style.spacing.sm + id: connectionColumn + width: connectionFlick.width + spacing: Style.spacing.xxxl - Text { - textFormat: Text.PlainText - text: "Home Assistant URL" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - } - - TextField { + Column { width: connectionColumn.width - text: root.urlDraft - placeholderText: "https://homeassistant.local:8123" - onTextChanged: root.urlDraft = text - } + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: "Home Assistant URL" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } - Text { - textFormat: Text.PlainText - width: connectionColumn.width - visible: root.urlDraft.trim().toLowerCase().indexOf("http://") === 0 - || root.urlDraft.trim().toLowerCase().indexOf("ws://") === 0 - text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - } + TextField { + width: connectionColumn.width + text: root.urlDraft + placeholderText: "https://homeassistant.local:8123" + onTextChanged: root.urlDraft = text + } - Column { - width: connectionColumn.width - spacing: Style.spacing.sm + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.urlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.urlDraft.trim().toLowerCase().indexOf("ws://") === 0 + text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } + } - // Ui/Toggle: label + description + switch, row owns the click. - Toggle { + Column { width: connectionColumn.width - label: "Local network URL" - description: "Try a LAN address first, e.g. your instance's local IP, before falling back to the URL above. Uses the same access token." - checked: root.localUrlExpanded - foreground: root.foreground - fontFamily: root.family - onClicked: { - root.localUrlExpanded = !root.localUrlExpanded - // Collapsing means "no local URL" — a hidden stale draft would - // otherwise still reach applyConnection. - if (!root.localUrlExpanded) { - root.localUrlDraft = "" - root.trustedNetworkDraft = "" + spacing: Style.spacing.sm + + // Ui/Toggle: label + description + switch, row owns the click. + Toggle { + width: connectionColumn.width + label: "Local network URL" + description: "Try a LAN address first, e.g. your instance's local IP, before falling back to the URL above. Uses the same access token." + checked: root.localUrlExpanded + foreground: root.foreground + fontFamily: root.family + onClicked: { + root.localUrlExpanded = !root.localUrlExpanded + // Collapsing means "no local URL" — a hidden stale draft would + // otherwise still reach applyConnection. + if (!root.localUrlExpanded) { + root.localUrlDraft = "" + root.trustedNetworkDraft = "" + } } } - } - TextField { - visible: root.localUrlExpanded - width: connectionColumn.width - text: root.localUrlDraft - placeholderText: "https://192.168.1.50:8123" - onTextChanged: root.localUrlDraft = text - } + TextField { + visible: root.localUrlExpanded + width: connectionColumn.width + text: root.localUrlDraft + placeholderText: "https://192.168.1.50:8123" + onTextChanged: root.localUrlDraft = text + } - Text { - textFormat: Text.PlainText - width: connectionColumn.width - visible: root.localUrlExpanded - && (root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 - || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0) - text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlExpanded + && (root.localUrlDraft.trim().toLowerCase().indexOf("http://") === 0 + || root.localUrlDraft.trim().toLowerCase().indexOf("ws://") === 0) + text: "Warning: this URL sends your long-lived access token without transport encryption. Use HTTPS unless this is a trusted local network." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } - Text { - textFormat: Text.PlainText - visible: root.localUrlExpanded - text: "Trusted Wi-Fi network name(s)" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - } + Text { + textFormat: Text.PlainText + visible: root.localUrlExpanded + text: "Trusted Wi-Fi network name(s)" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } - TextField { - visible: root.localUrlExpanded - width: connectionColumn.width - text: root.trustedNetworkDraft - placeholderText: "Home, Home 5G" - onTextChanged: root.trustedNetworkDraft = text - } + TextField { + visible: root.localUrlExpanded + width: connectionColumn.width + text: root.trustedNetworkDraft + placeholderText: "Home, Home 5G" + onTextChanged: root.trustedNetworkDraft = text + } - Text { - textFormat: Text.PlainText - width: connectionColumn.width - visible: root.localUrlExpanded - text: "Required. Comma-separated if your router has more than one (e.g. separate 2.4GHz/5GHz names). The local URL is only ever tried while connected to one of these — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap - } - } + // A suggestion, not an autofill: it only offers the network this + // machine happens to be on right now, so it disappears the moment + // there's anything to lose by acting on it — a name typed in, or + // no detectable Wi-Fi at all. + Row { + visible: root.localUrlExpanded + && root.trustedNetworkDraft.trim().length === 0 + && root.detectedWifiSsid.length > 0 + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + text: "Currently on “" + root.detectedWifiSsid + "”." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + } - Column { - width: connectionColumn.width - spacing: Style.spacing.sm + Button { + anchors.verticalCenter: parent.verticalCenter + bordered: true + text: "Use this" + foreground: root.foreground + fontFamily: root.family + onClicked: root.trustedNetworkDraft = root.detectedWifiSsid + } + } - Text { - textFormat: Text.PlainText - text: root.service && root.service.configured && !root.service.demoMode - ? "Access token · leave blank to keep the stored one" - : "Long-lived access token" - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: root.localUrlExpanded + text: "Required. Comma-separated if your router has more than one (e.g. separate 2.4GHz/5GHz names). The local URL is only ever tried while connected to one of these — never on any other network, so the token can't be sent to whatever happens to answer at that address elsewhere." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } - TextField { + Column { width: connectionColumn.width - text: root.tokenDraft - password: true - placeholderText: "Paste from your Home Assistant profile" - onTextChanged: root.tokenDraft = text - } + spacing: Style.spacing.sm + + Text { + textFormat: Text.PlainText + text: root.service && root.service.configured && !root.service.demoMode + ? "Access token · leave blank to keep the stored one" + : "Long-lived access token" + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + } - Text { - textFormat: Text.PlainText - width: connectionColumn.width - visible: connectionPane.needsToken && root.urlDraft.trim().length > 0 - text: "Changing the server origin requires entering its token again." - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.caption - wrapMode: Text.WordWrap + TextField { + width: connectionColumn.width + text: root.tokenDraft + password: true + placeholderText: "Paste from your Home Assistant profile" + onTextChanged: root.tokenDraft = text + } + + Text { + textFormat: Text.PlainText + width: connectionColumn.width + visible: connectionPane.needsToken && root.urlDraft.trim().length > 0 + text: "Changing the server origin requires entering its token again." + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.caption + wrapMode: Text.WordWrap + } } - } - Row { - spacing: Style.spacing.xl + Row { + spacing: Style.spacing.xl - Button { - bordered: true // Ui/Button is flat otherwise, reading as a label - text: "Connect" - opacity: connectionPane.canConnect ? 1.0 : 0.45 - foreground: root.foreground - fontFamily: root.family - onClicked: if (connectionPane.canConnect) root.applyConnection() - } + Button { + bordered: true // Ui/Button is flat otherwise, reading as a label + text: "Connect" + opacity: connectionPane.canConnect ? 1.0 : 0.45 + foreground: root.foreground + fontFamily: root.family + onClicked: if (connectionPane.canConnect) root.applyConnection() + } - Button { - visible: connectionPane.trying - bordered: true - text: "Cancel" - foreground: root.foreground - fontFamily: root.family - onClicked: root.service.cancelConnection() - } + Button { + visible: connectionPane.trying + bordered: true + text: "Cancel" + foreground: root.foreground + fontFamily: root.family + onClicked: root.service.cancelConnection() + } - Button { - visible: connectionPane.paused - bordered: true - text: "Retry" - foreground: root.foreground - fontFamily: root.family - onClicked: root.service.retryConnection() - } + Button { + visible: connectionPane.paused + bordered: true + text: "Retry" + foreground: root.foreground + fontFamily: root.family + onClicked: root.service.retryConnection() + } - Button { - bordered: true - text: "Remove" - opacity: (root.service && root.service.configured - && !connectionPane.keyringBusy) ? 1.0 : 0.45 - foreground: root.foreground - fontFamily: root.family - onClicked: { - if (!root.service || !root.service.configured - || connectionPane.keyringBusy) return - root.service.removeConnection() - root.resetDrafts() + Button { + bordered: true + text: "Remove" + opacity: (root.service && root.service.configured + && !connectionPane.keyringBusy) ? 1.0 : 0.45 + foreground: root.foreground + fontFamily: root.family + onClicked: { + if (!root.service || !root.service.configured + || connectionPane.keyringBusy) return + root.service.removeConnection() + root.resetDrafts() + } } } - } - PanelSeparator { width: connectionColumn.width } - - // Ui/Toggle: label + description + switch, row owns the click. - Toggle { - width: connectionColumn.width - label: "Demo mode" - description: "A fake house, so you can try the panel without an instance." - checked: root.service ? root.service.demoMode : false - foreground: root.foreground - fontFamily: root.family - onClicked: if (root.service) root.service.setDemoMode(!root.service.demoMode) - } - - // From the live connection, not a probe: a probe on a different path - // can pass while the real connection fails. - Row { - spacing: Style.spacing.lg + PanelSeparator { width: connectionColumn.width } - Rectangle { - anchors.verticalCenter: parent.verticalCenter - width: Style.space(8); height: width; radius: width / 2 - color: !root.service ? Color.muted - : root.service.phase === "connected" ? "#4caf50" - : root.service.phase === "error" ? Color.urgent - : Color.muted + // Ui/Toggle: label + description + switch, row owns the click. + Toggle { + width: connectionColumn.width + label: "Demo mode" + description: "A fake house, so you can try the panel without an instance." + checked: root.service ? root.service.demoMode : false + foreground: root.foreground + fontFamily: root.family + onClicked: if (root.service) root.service.setDemoMode(!root.service.demoMode) } - Text { - textFormat: Text.PlainText - anchors.verticalCenter: parent.verticalCenter - width: connectionColumn.width - Style.space(24) - text: { - if (!root.service) return "Service unavailable" - if (!root.service.configured) return "Not connected" - switch (root.service.phase) { - case "connected": - return (root.service.demoMode ? "Demo running · " - : root.service.usingLocal ? "Connected (local network) · " - : "Connected · ") - + Object.keys(root.service.states).length + " devices" - case "connecting": return root.service.lastError - ? "Connecting… · " + root.service.lastError - : "Connecting…" - case "error": return root.service.lastError || "Connection failed" - default: return root.service.lastError || "Idle" + // From the live connection, not a probe: a probe on a different path + // can pass while the real connection fails. + Row { + spacing: Style.spacing.lg + + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Style.space(8); height: width; radius: width / 2 + color: !root.service ? Color.muted + : root.service.phase === "connected" ? "#4caf50" + : root.service.phase === "error" ? Color.urgent + : Color.muted + } + + Text { + textFormat: Text.PlainText + anchors.verticalCenter: parent.verticalCenter + width: connectionColumn.width - Style.space(24) + text: { + if (!root.service) return "Service unavailable" + if (!root.service.configured) return "Not connected" + switch (root.service.phase) { + case "connected": + return (root.service.demoMode ? "Demo running · " + : root.service.usingLocal ? "Connected (local network) · " + : "Connected · ") + + Object.keys(root.service.states).length + " devices" + case "connecting": return root.service.lastError + ? "Connecting… · " + root.service.lastError + : "Connecting…" + case "error": return root.service.lastError || "Connection failed" + default: return root.service.lastError || "Idle" + } } + color: Color.muted + font.family: root.family + font.pixelSize: Style.font.bodySmall + wrapMode: Text.WordWrap } - color: Color.muted - font.family: root.family - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WordWrap } } } diff --git a/bin/hass-bridge b/bin/hass-bridge index d1429d9..3563c3e 100755 --- a/bin/hass-bridge +++ b/bin/hass-bridge @@ -150,7 +150,13 @@ def current_wifi_ssid(): try: result = subprocess.run( - ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"], + # --rescan no: this only needs whatever NetworkManager already + # knows about the currently active connection, not a fresh scan + # of every nearby network. Without it, nmcli blocks on a full + # radio scan — several seconds, during which this call would + # freeze the bridge's single-threaded run loop. + ["nmcli", "-t", "-f", "active,ssid", "dev", "wifi", "list", + "--rescan", "no"], capture_output=True, text=True, timeout=NETWORK_CHECK_TIMEOUT) except Exception: return None diff --git a/tests/test_connection.js b/tests/test_connection.js index 4730c9a..0a774e7 100644 --- a/tests/test_connection.js +++ b/tests/test_connection.js @@ -108,6 +108,15 @@ eq("comma-separated names are split and trimmed", ["Home", "Home 5G", "Office"]); eq("only commas and whitespace is still empty", Connection.trustedNetworkList(" , , "), []); +eq("no active line means no detected ssid", + Connection.parseNmcliActiveSsid("no:Neighbor\nno:CoffeeShop"), ""); +eq("the active line's ssid is detected", + Connection.parseNmcliActiveSsid("no:Neighbor\nyes:Home\nno:CoffeeShop"), "Home"); +eq("an escaped colon in the ssid is unescaped", + Connection.parseNmcliActiveSsid("yes:Office\\:5G"), "Office:5G"); +eq("an escaped backslash in the ssid is unescaped", + Connection.parseNmcliActiveSsid("yes:Back\\\\slash"), "Back\\slash"); +eq("empty input has no detected ssid", Connection.parseNmcliActiveSsid(""), ""); eq("matching generation is accepted", Connection.acceptsGeneration(4, 4), true); eq("old generation is rejected", Connection.acceptsGeneration(5, 4), false); eq("missing generation is rejected", Connection.acceptsGeneration(5, undefined), false);