From 7311a1cdce36b4b31d19ddb388c27d64a16171a0 Mon Sep 17 00:00:00 2001 From: Max Gramser Date: Thu, 17 Sep 2026 08:32:22 +0200 Subject: [PATCH] Show binary sensors in Home Assistant's words and grey out what is off (0.2.62) A binary sensor's tile and card now say what Home Assistant says for its device class, such as Open or Closed for a door and Wet or Dry for a leak sensor. The screen maps the class it already receives, with the same words as the top bar and the history card; a test keeps both tables equal. A history card still waiting for its answer no longer shows the previous card's word. Lights and binary sensors that are off turn grey, as in Home Assistant, and a light without an icon of its own shows a crossed-out bulb while it is off, on the tile and in the top bar. A chosen icon stays and turns grey. Co-Authored-By: Claude Opus 5 --- components/smart_display/runtime_tiles.h | 12 +++- components/smart_display/tile_controls.h | 21 ++++++ components/smart_display/tile_icon_names.h | 1 + docs/RELEASING.md | 24 +++++++ docs/TEST_RESULTS_0262.md | 56 ++++++++++++++++ guition-4848s040.yaml | 3 +- home-like-2432s028.yaml | 3 +- packages/cyd.yaml | 3 +- packages/guition.yaml | 3 +- screen_manager/CHANGELOG.md | 9 +++ screen_manager/app/core.py | 2 +- screen_manager/app/header_bar.py | 5 +- screen_manager/app/static/tile-icons.woff | Bin 15792 -> 15864 bytes screen_manager/app/tile_icons.py | 2 + screen_manager/config.yaml | 2 +- tests/test_binary_words.py | 74 +++++++++++++++++++++ tests/test_off_look.py | 38 +++++++++++ tests/test_tile_controls.cpp | 8 +++ 18 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 docs/TEST_RESULTS_0262.md create mode 100644 tests/test_binary_words.py create mode 100644 tests/test_off_look.py diff --git a/components/smart_display/runtime_tiles.h b/components/smart_display/runtime_tiles.h index 7e8c0bd..9dad9c9 100644 --- a/components/smart_display/runtime_tiles.h +++ b/components/smart_display/runtime_tiles.h @@ -632,6 +632,7 @@ inline std::string detail_state(const Tile &t){ if(t.domain()=="sun")return t.state=="above_horizon"?"Above the horizon":"Below the horizon"; if(t.domain()=="timer")return timer_text(t); if(t.domain()=="script"||t.domain()=="scene"||t.domain()=="button"||t.domain()=="input_button")return t.state=="on"?"Running...":last_run_text(t.last_run); + if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))return tile_controls::binary_state_text(t.device_class,t.state=="on"); if(t.state=="on")return "On"; if(t.state=="off")return "Off"; if(t.state=="docked")return "Docked"; @@ -1235,10 +1236,11 @@ inline bool history_fits(const Tile &t){ return history.entity==t.entity&&history.hours==history_hours&& (history_answered||esphome::millis()-history_received_at<60000); } -// Home Assistant's word for the state now: from the history's words, else the card's own. +// Home Assistant's word for the state now: from this entity's history words, else the card's own. The history can +// still be the previous card's while this one waits for its answer. inline std::string history_words(const Tile &t){ if(!t.available())return "Unavailable"; - for(const auto &pair:history.words)if(pair.first==t.state)return pair.second; + if(history.entity==t.entity)for(const auto &pair:history.words)if(pair.first==t.state)return pair.second; return detail_state(t); } inline std::string history_clock(uint32_t epoch,bool weekday){ @@ -1651,6 +1653,8 @@ inline const char *weather_text(const std::string &condition) { inline const char *icon_for(const Tile &tile) { if (!tile.icon.empty()) return tile.icon.c_str(); auto d = tile.domain(); + // Home Assistant's own icon: a bulb, crossed out while off. A chosen icon stays, as in Home Assistant. + if (d == "light" && tile.state == "off") return "\U000F0E4F"; if (d == "light") return "\U000F0335"; if (d == "climate") return "\U000F001B"; if (d == "vacuum") return "\U000F070D"; @@ -2371,6 +2375,7 @@ inline void render_slot(size_t slot) { else if (d == "sun") value = !t.extra().sunrise.empty() && !t.extra().sunset.empty() ? t.extra().sunrise+" - "+t.extra().sunset : t.state=="above_horizon"?"Above the horizon":"Below the horizon"; else if (d == "timer") value = timer_text(t); else if (d == "script" || d == "scene" || d == "button" || d == "input_button") value = t.state == "on" ? "Running..." : last_run_text(t.last_run); + else if (d == "binary_sensor" && (value == "on" || value == "off")) value = tile_controls::binary_state_text(t.device_class, value == "on"); else if (value == "on") value = "On"; else if (value == "off") value = "Off"; else if (value == "cleaning") value = "Cleaning"; @@ -2469,7 +2474,8 @@ inline void render_slot(size_t slot) { if (w.cached_active == palette_state && !w.panel_dirty) { lap(swipe_profile::GEOMETRY); return; } w.cached_active = palette_state;w.panel_dirty=false; uint32_t accent = domain_accent(t); - auto color=lv_color_hex((t.is_switch()||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent); + // Off is grey, as in Home Assistant, so a light or a door sensor shows its state at a glance. + auto color=lv_color_hex((t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent); if(d=="light" && on && t.has_hs_color) color=lv_color_hsv_to_rgb(t.hue%360,t.saturation,100); auto circle_color=available?lv_color_mix(color,lv_color_hex(0xFFFFFF),38):lv_color_hex(0xF0F0F0); diff --git a/components/smart_display/tile_controls.h b/components/smart_display/tile_controls.h index 6cb2a36..dd0d97e 100644 --- a/components/smart_display/tile_controls.h +++ b/components/smart_display/tile_controls.h @@ -135,6 +135,27 @@ inline const char *media_state_text(const std::string &state) { if (state == "off") return "Off"; return state.c_str(); } +// A binary sensor's state in Home Assistant's words for its device class: a door is Open or Closed, a leak sensor +// Wet or Dry. The same words as the add-on's header_bar.BINARY_STATES, which the top bar and the history card show; +// tests/test_binary_words.py keeps the two tables equal. Without a class, or with one this table lacks: On and Off. +struct BinaryWords { const char *device_class, *on, *off; }; +constexpr BinaryWords BINARY_WORDS[] = { + {"battery", "Low", "Normal"}, {"battery_charging", "Charging", "Not charging"}, + {"carbon_monoxide", "Danger", "Safe"}, {"cold", "Cold", "Normal"}, {"connectivity", "Connected", "Disconnected"}, + {"door", "Open", "Closed"}, {"garage_door", "Open", "Closed"}, {"gas", "Danger", "Safe"}, + {"heat", "Hot", "Normal"}, {"light", "Light", "Dark"}, {"lock", "Open", "Locked"}, {"moisture", "Wet", "Dry"}, + {"motion", "Motion", "No motion"}, {"moving", "Moving", "Still"}, {"occupancy", "Occupied", "Clear"}, + {"opening", "Open", "Closed"}, {"plug", "Plugged in", "Unplugged"}, {"power", "On", "Off"}, + {"presence", "Home", "Away"}, {"problem", "Problem", "OK"}, {"running", "Active", "Inactive"}, + {"safety", "Unsafe", "Safe"}, {"smoke", "Smoke", "No smoke"}, {"sound", "Sound", "Silent"}, + {"tamper", "Tampering", "OK"}, {"update", "Update", "Up to date"}, {"vibration", "Vibration", "Still"}, + {"window", "Open", "Closed"}, +}; +inline const char *binary_state_text(const std::string &device_class, bool on) { + for (const auto &words : BINARY_WORDS) + if (device_class == words.device_class) return on ? words.on : words.off; + return on ? "On" : "Off"; +} // Status line beside a control panel: what Home Assistant shows under the name. inline std::string status_text(const Tile &t) { auto d = t.domain(); char b[48]; diff --git a/components/smart_display/tile_icon_names.h b/components/smart_display/tile_icon_names.h index e7bb406..9639855 100644 --- a/components/smart_display/tile_icon_names.h +++ b/components/smart_display/tile_icon_names.h @@ -190,6 +190,7 @@ inline constexpr Named NAMES[] = { {"monitor", 0xF0379}, {"information-outline", 0xF02FD}, {"restart", 0xF0709}, + {"lightbulb-off", 0xF0E4F}, }; inline constexpr size_t NAME_COUNT = sizeof(NAMES) / sizeof(NAMES[0]); // Codepoint of a name the fonts carry; 0 for any other name. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3820ccd..c32e7aa 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -176,6 +176,30 @@ icons sit off-center in the browser); run `generate_packages.py` afterward. The `MDI_GLYPH_*` substitutions are retired: `TILEn_ICON` in manual profiles must come from the set. No changed preferences or keys. +### Compatibility 0.2.62 / firmware 0.2.53 + +Firmware (`components/smart_display`), the icon fonts and the top bar's icon. Storage, tile protocol, preferences and +keys are unchanged; nothing new on the wire. + +- A binary sensor's tile (`render_slot`) and card (`detail_state`) turn `on`/`off` into words with + `tile_controls::binary_state_text(device_class, on)`, from the `device_class` attribute every state message has + carried since app 0.2.23. `BINARY_WORDS` is a copy of the app's `header_bar.BINARY_STATES` (top bar, history card + `states` and `words`); `tests/test_binary_words.py` keeps them equal, so change a word in both in one release. +- The screen maps the class itself instead of the app sending words: it works with every app since 0.2.23 (screens + build from `ref: main`, so new firmware can meet an older app), costs the CYD no RAM per tile (the table lives in + flash, the class was already kept), and adds no field for older firmware to ignore. +- `history_words` reads `history.words` only for the card's own entity. A card waiting for its history used to take + the previous card's words (a motion sensor opened after a door read "Open", a switch too). +- Off looks off, as Home Assistant's frontend does it (checked on the `dev` branch and on a live dashboard): + `light/icons.json` gives `mdi:lightbulb`, and `mdi:lightbulb-off` while off; anything off takes + `--state-inactive-color` (grey); an entity's own icon never changes. `icon_for` draws F0E4F for a light without + `tile.icon` while it is off (its own `if`, so `test_tile_icons` still reads the domain default), `render_slot` greys + lights and binary sensors that are not active, as it did for switches, people and timers, and + `header_bar.auto_icon` gives an off light the same bulb. `lightbulb-off` joins `tile_icons.FIXED`: one glyph more in + the three icon fonts of both boards and in the editor font. +- App 0.2.62 with firmware 0.2.52 or older: tiles unchanged; an off light's top bar item shows without an icon, since + that firmware drops a glyph its font lacks (`has_icon_glyph`). + ### Compatibility 0.2.60 / firmware 0.2.52 `components/smart_display` and both board profiles; the app only raises `FIRMWARE_VERSION`. Storage, tile protocol, diff --git a/docs/TEST_RESULTS_0262.md b/docs/TEST_RESULTS_0262.md new file mode 100644 index 0000000..f3fc6db --- /dev/null +++ b/docs/TEST_RESULTS_0262.md @@ -0,0 +1,56 @@ +# Test results app 0.2.62 / firmware 0.2.53 (2026-09-17) + +A binary sensor tile read "On" or "Off" where Home Assistant says "Open" or "Closed", and a user (Remco) asked for a +different icon for lights that are off. Max asked to do it the way Home Assistant's web interface does. See CHANGELOG +0.2.62 and the compatibility note in docs/RELEASING.md. + +## Home Assistant's own behaviour + +- Source, `dev` branch: `homeassistant/components/light/icons.json` gives `mdi:lightbulb`, and `mdi:lightbulb-off` + for state off (switch, fan and input_boolean have the same pattern); the frontend colours active lights + `--state-light-active-color` (amber) and anything off `--state-inactive-color` (grey). +- Live on Max's dashboard: lights that are on have an amber icon on a pastel amber circle; lights that are off are grey, + with a crossed-out icon where the icon has one; a light with its own icon (a mirror) keeps it and turns grey. + +## Automated + +- Python (`.venv-portal`): all tests OK. New `tests/test_binary_words.py` (5): the firmware's `BINARY_WORDS` equals + `header_bar.BINARY_STATES`; the top bar and the history card give the same words for all 28 classes, and On/Off + without a class or with an unknown one; every state message already carries `device_class` (no `x`); the tile, the + card and the history heading use the words for binary sensors only, and the heading only takes its own entity's + history words; all five text fonts of both profiles and packages carry every letter. New `tests/test_off_look.py` + (3): `lightbulb-off` (F0E4F) in the fonts of both profiles and packages; the firmware's off bulb and grey rule; the + top bar's off bulb, a light's own icon kept, no accent while off. +- C++ (`clang++ -std=c++17 -Wall -Wextra -Werror -I. -I components/smart_display`): 15/15 PASS; + `test_tile_controls` checks door, motion, moisture, battery charging, battery, no class, an unknown class and a + wrongly cased class. +- `generate_packages.py --check` and `generate_icons.py --check` (150 pickable icons, 184 glyphs) green. + +## Builds (ESPHome 2026.6.2) + +Check profiles with the generated packages and local components, as for 0.2.59. No warnings from `runtime_tiles.h` +or `tile_controls.h`. + +| Profile | RAM | Flash | vs 0.2.59 | +|---|---|---|---| +| CYD | 23.5% (77,060 B) | 77.8% (1,428,127 B) | +0 B RAM, +1,112 B flash (0.2.60 and 0.2.61 included) | +| Guition | 24.5% (80,436 B) | 18.9% (1,531,911 B) | +0 B RAM, +1,580 B flash (0.2.60 and 0.2.61 included) | + +## Firmware on the Mac (host builds, driven over the ESPHome API) + +A demo home with fifteen binary sensors of every kind, lights on and off, and the front door and a light in the top +bar (`.esphome/readme-render/binary/`), with a probe that logs each card's text and colours: + +- Before (0.2.52 code): every binary sensor read On or Off in amber, while the top bar said Open and Motion; the door + card said "On" until its history arrived and then "Open"; the motion card opened after the door card said "Open". +- After, Guition and CYD: Open, Closed, Motion, Dry, Smoke, Disconnected, Not charging, Low, Problem, Up to date, + Plugged in (also as a large value); a sensor without a class and an unknown class still say On. The door card says + "Open" while loading and after the answer, and "Closed" once the door closes with the card open; the motion card says + "Motion" while loading. The longest words fit a single CYD tile. +- Off look (Guition): an off light without an icon shows a grey crossed-out bulb, an off light with a chosen lamp icon + a grey lamp, a closed door and a dry leak sensor grey circles; the open door, motion and a light at 50 % stay amber; + the top bar shows the crossed-out bulb for the off light. + +## Not tested + +- Physical screens: not flashed in this round. diff --git a/guition-4848s040.yaml b/guition-4848s040.yaml index fe6f7d5..5fa91f0 100644 --- a/guition-4848s040.yaml +++ b/guition-4848s040.yaml @@ -2,7 +2,7 @@ # Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md. # A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.52" + SCREEN_FIRMWARE_VERSION: "0.2.53" # 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not # (like iOS). The CYD keeps a limit because its resistive panel jumps. TOUCH_MOVE_LIMIT_PX: "0" @@ -984,6 +984,7 @@ font: - "\U000F0379" # monitor - "\U000F02FD" # information-outline - "\U000F0709" # restart + - "\U000F0E4F" # lightbulb-off - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini diff --git a/home-like-2432s028.yaml b/home-like-2432s028.yaml index f779ade..0768916 100644 --- a/home-like-2432s028.yaml +++ b/home-like-2432s028.yaml @@ -17,7 +17,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.52" + SCREEN_FIRMWARE_VERSION: "0.2.53" # A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe. TOUCH_MOVE_LIMIT_PX: "56" # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. @@ -1102,6 +1102,7 @@ font: - "\U000F0379" # monitor - "\U000F02FD" # information-outline - "\U000F0709" # restart + - "\U000F0E4F" # lightbulb-off - file: 'fonts/materialdesignicons-webfont.ttf' id: materialdesign_icons_mini diff --git a/packages/cyd.yaml b/packages/cyd.yaml index d8f7f2e..ec6eabc 100644 --- a/packages/cyd.yaml +++ b/packages/cyd.yaml @@ -18,7 +18,7 @@ ############################################################ substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.52" + SCREEN_FIRMWARE_VERSION: "0.2.53" # A tap survives a finger that drifts up to this far (320 px over 58 mm: 1 cm); further is a swipe. TOUCH_MOVE_LIMIT_PX: "56" # Shortest contact that counts as a tap; a resistive panel bounces, a capacitive one does not. @@ -1091,6 +1091,7 @@ font: - "\U000F0379" # monitor - "\U000F02FD" # information-outline - "\U000F0709" # restart + - "\U000F0E4F" # lightbulb-off - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini diff --git a/packages/guition.yaml b/packages/guition.yaml index f866a41..c636a58 100644 --- a/packages/guition.yaml +++ b/packages/guition.yaml @@ -3,7 +3,7 @@ # Source profile for packages/guition.yaml (tools/generate_packages.py); see docs/GUITION.md. # A screen gets its tiles from the ESP Screen Manager add-on while it runs. The CYD is its own profile. substitutions: - SCREEN_FIRMWARE_VERSION: "0.2.52" + SCREEN_FIRMWARE_VERSION: "0.2.53" # 0: no drift limit; LVGL decides, releasing inside the tile is a tap, leaving it is not # (like iOS). The CYD keeps a limit because its resistive panel jumps. TOUCH_MOVE_LIMIT_PX: "0" @@ -973,6 +973,7 @@ font: - "\U000F0379" # monitor - "\U000F02FD" # information-outline - "\U000F0709" # restart + - "\U000F0E4F" # lightbulb-off - file: "https://raw.githubusercontent.com/MaxGramser/homeassistant_espscreen/main/fonts/materialdesignicons-webfont.ttf" id: materialdesign_icons_mini diff --git a/screen_manager/CHANGELOG.md b/screen_manager/CHANGELOG.md index d850de0..538a31c 100644 --- a/screen_manager/CHANGELOG.md +++ b/screen_manager/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.2.62 (firmware 0.2.53) + +On or off at a glance, in Home Assistant's words and colours. + +- **A door says Open or Closed.** A binary sensor's tile and card use the words of its kind, the same ones the top bar and the history card show: a door or window Open or Closed, motion Motion or No motion, a leak sensor Wet or Dry, and so on for smoke, battery, connectivity, plugs and the rest. A sensor without a kind still says On or Off. +- **Off looks off.** A light or binary sensor that is off turns grey, as in Home Assistant, and a light without an icon of its own shows a crossed-out bulb while it is off, in the top bar too. An icon you chose stays the same and turns grey, as Home Assistant does with an icon of its own. +- **The history card's heading is right while it loads.** It said On until the history arrived, and a card opened after another one could show that card's word for a moment, such as Open on a motion sensor. +- Needs firmware 0.2.53: press **Update** on the screen. Older firmware keeps On, Off and its colours, and shows an off light in the top bar without an icon. + ## 0.2.61 (firmware 0.2.52) Your own YAML for one screen, kept through updates. diff --git a/screen_manager/app/core.py b/screen_manager/app/core.py index 5585606..f19c988 100644 --- a/screen_manager/app/core.py +++ b/screen_manager/app/core.py @@ -17,7 +17,7 @@ WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] REPO = 'https://github.com/MaxGramser/homeassistant_espscreen' REFS = {'cyd': 'main', 'guition': 'main'} # Firmware shipped with this app release; screens below it get an update offer. -FIRMWARE_VERSION = '0.2.52' +FIRMWARE_VERSION = '0.2.53' # The Auto standby switch a screen offers Home Assistant automations. AUTO_STANDBY_MIN_FIRMWARE = '0.2.41' # The settings page the screen opens itself, and the screen.settings tile that opens it. diff --git a/screen_manager/app/header_bar.py b/screen_manager/app/header_bar.py index fc7a2ee..91545cd 100644 --- a/screen_manager/app/header_bar.py +++ b/screen_manager/app/header_bar.py @@ -38,7 +38,8 @@ STATES = { 'armed_vacation': 'Armed vacation', 'armed_custom_bypass': 'Armed', 'arming': 'Arming', 'pending': 'Pending', 'triggered': 'Triggered', 'active': 'Active', 'above_horizon': 'Up', 'below_horizon': 'Down', } -# device_class: (on, off) +# device_class: (on, off). Tiles and cards show the same words from the screen's own copy, BINARY_WORDS in +# components/smart_display/tile_controls.h (firmware 0.2.53+); tests/test_binary_words.py keeps the two equal. BINARY_STATES = { 'battery': ('Low', 'Normal'), 'battery_charging': ('Charging', 'Not charging'), 'carbon_monoxide': ('Danger', 'Safe'), 'cold': ('Cold', 'Normal'), 'connectivity': ('Connected', 'Disconnected'), 'door': ('Open', 'Closed'), @@ -245,6 +246,8 @@ def auto_icon(entity, state): domain, device_class = entity.split('.')[0], attrs.get('device_class') if domain == 'weather': name = tile_icons.WEATHER.get(raw, 'weather-partly-cloudy') + elif domain == 'light' and raw == 'off': + name = 'lightbulb-off' elif domain == 'sun': name = 'weather-sunset-down' if raw == 'above_horizon' else 'weather-sunset-up' elif domain == 'binary_sensor' and device_class in BINARY_ICONS: diff --git a/screen_manager/app/static/tile-icons.woff b/screen_manager/app/static/tile-icons.woff index 05b2e52be11837b4221ca6ef4240659ffa5b29bd..0f2bb1e187bdd10cb7754c2159920065eafc63d6 100644 GIT binary patch delta 15225 zcmY*=Q;;Q06J^`BZQHhOP209_+rDkvn6^D_+nTm*%zppw(`Lk}%&a&WSrt(a8C6-o z{oedIyp*M+KtMtM6TvqS`2T4;|NrOz&;I{KT3u7@Ki+gQ2R*Ak2nh5tx>{4bmA#MU zf3^Y$2$wYoh|xMKNpy>~g^4)`2q?^d;ava2A1ut+Ca zivO+tKfHNha{odWcm{z7`)>jS#Gf*6;D7pLfm;>?WWQ-8t9l39fDoH`MxFFiZ>+bS9N;=8O^A^>>H->27 z@;zlVF)8r;a{?ZOA&4k~Kb6vZe}YR1ru6s<0PUW6N2yk+-NS;$Y@T|1rRkf?7>pc@ zzeg+Rb{(A;g|rNVJCfY|DSSYa{C3*QrE}sQk63@v%;~)L+ITtI2iVW;DXMz4**@J% zR*?1puE{(4M-JOYXj;h@>~+fZmFk&JF?Eo-mYv8_>FnFRU_F5U(ql0CZ;#%&lq)uI zhGrm<_f|L44*cwSpS!r(S)S!(Ws>q$jEs%$>SZw~@p{=ha?D2cy-e5Hr(xtz|^-Q3UoS3&4XP=`kDI`{PjAB&@kt9QSw zl-Jm?meTKPA#EJyhl!!SAv0?Di*t>^DEGdH<8zx3)pN*@7x&olPK(c(H9;_b}4qC>%kee3edEg8DiyX4n^QZjORVMHrkokll>g1bH_>o{3(wM5J z&cc*OTkTUlgN0C95oJ4Tn+Q);ngj7!sxQHr(dq<&zH|!#TqK74EbE7RQT~U%vfZwP zrXZV$Mvz3r%)?^Hz(dn(q_I}*5pS&L+fU-Q)Wr(LE<~|I$-}Y3$P>4?N}8FRfJ%_B zghi07gieqJP{KYU*kW^XXl4`QGo+~eX%}OmC&VBC_7QHe3vmS~I~#mNlk~SIShyJp3jAJrur(fFf$hG^*;v=%AQLMhPX%UA3b>v4m~M})&ssx2CL3X zLcUggSH%O|MmNx(i`KL6Q`{?zZ9X#gd!C2~-_QF2#hc>fQZP&kPO%=i%<{w-2C02y z9X#JE&axk&`-@M?gRYa~o`V)PFI(eMqZt*wA@qw?A@oaSA!+AugPP~9AvH^55NoAd zkTr`tAs6d=kTtX=Y^NnqY<*05WF4f2G##vl6dkmN3?01RvPm90yYwBDgv=eBhEzH^ zLZst>uU042Bi}Y*W}S>a%8t-QR_&zA?M69g?JhlMuP!NP<&U8KUH6=QA-PjI(HDbL zy}^$sc2Oa2-)53EwCoDt&+Ci9uD6+xO*8clEK0Q z4;s?xzr*5GE*?@FzH6c?nDOxe(JB0VOtjEWa#(xc$Qa^9b9e%`tC#`fij417zyFQz zUbY|V9#8Vw+OK`5+GTXdYGE8|JoZl+?Y^aX4w4QOkVXYXB|{ZOHA?kKEkvD0y+(sV zqe$aTlS6!l^i1 z`PvQzKD4oO6S`Bs)47^;?NOiX@@k4L)Xp#lco0~3Hpqp)QJ6^mqH7=A8QYmN+|S?V zaWKx-TElCBPPfStU_J}sWq~x>-*TupxTCsl$$K&0cY1f|yGAbVW$IbH*}4md!|CnY zL$~j@T8Hw-zZNq1ZriquRhBQ;;dm&ga%c@3k`lzL*|*T``*fYwe9XT*)_lEOovphD zK;KoIJRVVVbgrR2P@f1R6j)U)YOx0k$7#9lP!*&j_6U4l_we2KzzV?qyu9|33OHAu z6g78HcT%)`faiizmoven@9(eOGZOcQGQHcV`kVGX{6P=d*lqk!_6Hnm=cC9UO{Ih% zq4%r3|4_Hr=C!4He@#q&pp!indz~f$2$F7(DVXLod2`%&>Q}6Xoe_+A-18H;yp6bL zZj67gIKsD?yyK$|+78OYws$o*H9hh;YPIL@Zu)*$Y}c+DO(XTUyon`$En&wn++GdgLsB3BD3Ywn z2^Z*-Z#KrYsKt0xQ#54JwA3S2|iFT;IOZYnuJ8-VM z#l7_*Hkn=7awRWS>u!eM;O{86Njp8>n63Kn>gq33b~Z90nupJ=A;d$-?=Ui`g~h5e zmV9H7^KSJcZ=mUfAIQ^?lkN!tHy#4Mgz`eex+13y#uMqP6_Aik!ptrxKM{gUK~{Yd z@|$psXDH+h#z`aW)_^u2lvdLy@& zDCkeANBAC~zekz(!iM+7@a%|b*$>8;+jibCjIdQREmiLzjGGoJPz`v*O_-esYM z@6GDdJVX6ZH0MVQEI%fIoi68+!ohL#ZpF~vAY;R~FIry41rim zVBUi8Mw*0js;6Wp`SP=Ajpp*Dm^9YL@kU?iWE0(jFG#Y9;DZ4Gw+icW0iIsniuw9E z2^@@C0EhM+HDTQzxxM5|p1dhXI|zJdbRjgp;wX{@lg!|C_8+ORybyvL3{13+2tf!t z3nS>q?ik}cb{aBm;}f?{=oj(|!O!vSFDD@hA%l07z`_7^zVa{E>!Op7*M5Ru2SMNv zcVV;}ktJkzr1hGB+cm<`Ec~9LGs}iu!l$&lHiy?>_-A9iT{6etr?{!52HSX}-d8() zung=CvyL+jevxLSt}kDt0>8lMwje$p{8mGH*B`-&t>JfRV4!-Mpc}5%u%!<-uk{pH zTZ*fHZdDnY&IM8qqU4nThTC$n|op7 z1)hNlBN+*DyB0}|c+Hro*e8=TKn-4{k9|O1{Dv|(eI5639LPIla=8u0gC!wyNX;M- zc7c9?lv;cMh-Q0!i_(~>MDWcH5bxIqho6x3a0{dOiXiB05MAqA0mle)4VrIu7#@u+L=wOaCPtMfmzFRNC}1vg_FQh2uIrHfJr*78E08OT4KOuB0vp}K_Y z${%duAky;H9%(rdOGM*jWNS*96A-Nbw-krJ4 zO<5X}^m!I)p#dSTa;xT?%#=ViqwryjyEFP+Xbg8;b0}$;7w{}HdcZ|LO5%0Vv=DaE z6vY4=|0VuahF*D^v=zSLD?{lCdNEOsjPR_FG>$?6?oT!5 zsgBw1^SsVbQ!^_;BbJKWB=a0!#KtJ8iw9v!7=8|_5F>%Pv8dF z45SQ!L^uO^&}=kMRklL!G;dSUW3)>~ASR__#*|BzVMtd~TFv(0|E0>lkl6xE!QdpX z(qfpDHiH$V`-843rZzG62e`m94RIFox{tXyex<&dITM|=FOQt zUqcAN>F7{RAR~F&U1_eIF;lg%!$1K*B6vIG9dFVj5@QFF&AGLQ=$Ig4zMYTenCNOm z6P#?uo)NPOqi*h)hK>QE!=O^C zkOOjJB zD$6cMa1to`05-}u*k&Yj?>p#00=N%0j$WC0em!JPncV3nyFZ;?dMXc~pnfe(N1^f$ z1nY-V?p~9B)^(t|!~l558PCZWbnDzH!qQZp)9`&Yi9v#Sikza7m3p~1B`6#D(16;2 zr`wT45IkeE zA*NHmVFzr+-NbHQe~1tu4`lb?Zyfi&xJe6RSD69Q53F{t%9DEyU*(|lko78*ru=IR zcG%*^qTaB}zCgt#ZwK5Wa2voYIi}r8=KW7?DUf{@|dfu>#fS(z(zB4frG> z*r~Exp&Gi}FdPZu8)(oMZy;5--zSj>d;|aI90=RzyvFT`I~O7##HN{l@G%gc#!HD@ zwJ?QmO>FXdYtzNhHQKn?`E}kSzFn@t`Dk`f;h<;Z+7ITpGk1Mc!TY@IB9YV%T$E4)4oeqyzNh0`{juTkR& zV=K#GT4i~EDUT|zc@fG3bZS1X9cR%;y4F-0LrrRp&FUmzw&MOl@0nZ6lW0e8$9{dJ zcxT;U$JgB|yu{j+I3EDQSVogi`W%2=g}56aLt7g4iqU}|;%Dm;tc&?qf0=#)0@cB2 z$K%!DPm7r`vJWIaqCEmDL&ed7159-x!^+>PBx5y*R_~pr1#1oppm6$pp!psikqsWY zhz@1xVmAsP9roG8tZ)EF2Oeb@l)p@%$>n3uv^6sXwJ_~s&=F-f7_s<@{Mc;rN=Lqf z3D)74xI0YC(HjPoyQub1j6Uh!6ZTSAQyP9=&vKQ{uPqP0^664(Z%P=ODcb zAQ?)Pvs5CCeldjM%=Zz)-v{&LOpBN!+w>v}iR9Gt7=V9VKK`C|uRPTbslscJKZFEr|x-Wd%<4DxdjcbFNM&W7_HJg)Y;TLbQ@l}}MfZ0H?)4_$v9Qof6w0l^i|H>yk0lkUeJR^B4WBw7E~n*rR&)N zVkgR@rqn*~oY8wRBW>PQ8nzs}4$$##r+A=sv`-tLg^8H+NuNQhv*bqPh=WMFHco@^ z83|&ZcJ1Nh&vU=A*9D2P)hB*yzWbOfP;n|NoUQ(Ww{0C!-~b>Dpq6{=&YoKRGlKvC zC_vmH><0?Bfesttc6^zrWZB#2 zuI<-^eZG5fZHfyuop$-PH0+K>=sRZv0+h-g@vkqZ7-pPAhec)nYD6(7t^F9VSW=K# z0`BHSv|NeCcflq-ZFxIF4~tQl97cC9ue`-YfJH}%t~FjrPlNU@ z{w2^m_SuYoI79})X#RkJL90S9bM7@V{Gs65qbfM3hQn>x6K=k*1(GxmyKpfBaO$pK z+vxD;TEmBqgGJCRFQ=5+=x%*^)W7zd&pGbve|Zt#7G%@_O2!!v-=jL8z5jvgW(8dF zcG9&XJTST0DJ+UCxCLf_)i%R*;Vg1W`mnAe)rW%qU7(JBBo32HEKxWPDYc zepCdgFYr-wu_;!hX~hjFbnOlThM=C?^IwxLjX+!6je@yyVyde>`EtTjMlXj+TmLT2 z;4pqh@hA;ge{&rpYG?4U!Wc)Mhd79WthmJLl_3O$D&dQA3g90@o+Zrp_aRpt*h`Qj z7CMA&f)JRf)zvA&=q9E_NuxFPRa3pK(~MGUv=YmsFpCvmzIN5oK5jJt4#{Wh5rfpO zD^dJt@Zxc7O!%=9$J)YMP@|=YV_;Q3joQ&4zPOH$LGK(!CLn3ny;LBM6>dFOQN$t*BU7PTXG#8r|kT2G0?sdqn}$MxAn+m%k7p^}07)#B2! zump9NjK`t!e3%@v!Pz6h%rKcb*30*++voXjdQvoOG+WH0%pj$ppto;3f_pU{t?XJdYos z6bSUEmL!C`zz`aT`$9v(xmKYm8FQ%aEzf9qwP_BGl**u_W#$CXJqqGJzh63EHyp^P zWJn{+Dt6psI)6_-V${u^=luNzhJi>&b&yV}jnRRjXHLA&6Gv)WG(>-a+Y+Pt4XP68e=sI2wpyhP#^|z-nu`0FzRCb5A^R zBH!ibCoS!Bj(FS@CF&uiR}PHuH>}KAhp@)u780lHw4fplg^0&JDiM>9M1O6{gvNu_ z#9740@gLMHcp->U$P#29hmUeGT!X?Paa-v9{k6W4ABP$Mp=(Da37 zj9~9Pb}PP{gu|=S$xZ{Gm(Bxkga5v>E#4*O(>55~hKBxzx4-6J3trVjdQAwBF~_Dm z0WZm-XtvAYzV`MbJbc=SnKe!%b!{}jvh2pW+SbDMdkyO4W=g^tW?$MGZY6x@vcFva z6&nWiYDNwnX`?pydhCF7s3#+FV)}zBms5aDzw;)ND<*&zFN6?0jo=|&Pi$7 zlrD#}d4qK;nVZ7qo~v?jzcczE=!fou?6*c4 zajO++VQ)_d22_1`W*CQ!s)Czd*1+R@r-eNqlY0{kvhE8& zF7EPzkAX@!= zXZ@+h-X-rO4u*EBlGOW!mbOZ8wdyem|LnfjPfqX@ zVDjH~sMtww@|P$Dyd8Dsjsie0e$$}6Sp|t}ixynCRdjy3w4qcIWP@krq@nsXtT}#K zk!#`4TrE4>4?;fmgHNOR$F>JGz=U?#@Cp&PXSo|>uTyN^&EohjFna+5$v)6prLxQc7Or z8gb4doGYCB^c>;zn{+2Pamfo(GABN&arFnX<^aZQpZpzF^bX8K8ubYZQ&X;FVWrXZe3d`mVyO=o94U%D6jY|T z_mticxnC@~1L=7TH?^~{9hDkrteohvWrO@X|Q{tz3%@h z-3*1{L=?OgX3vGIX2cY?HNSi6D#(FNzUvrN4h?9QY0%}Vp##vArX|#O(lE0kMtJaF zXpM8QyPZKZ;T#?yXF{!cEODn$5euHqop? zTGYM8Ct|Ly2>aH~PXubItilV!uVo1)j}V&J>xMUjDgp{5s)+fx(%*FQ*r))iW_lYm zAEf&sp`L0}Y~6!Nef~0#90}N{MQDS?n@4o3Z;g*3wJ^Q;h2uj$r2H0UlN20HouNKu z#KRx6fw!PWX>WCmMr!ZS-j)q{-@?luQEhk%`d|N4FZcKo({7%BFP=h592UJ;gGw5} zl$@@|Sp$B@unhN&)Xqv{XJSOJC8}tID&fe9woHCnQzQkEgbPF*v3;`l7+jW&cZ3&b z;jrT!O9*I)aa8_SZA8YcTGW~mg+p*bZB~M943nc6fWoU34K8nVU5R3dJldEP;9HM2 z23$nzdn08$H;QBbAYk1#9dRS&MWElV9(m&2umQaAloAMHI1<9i3&1okKofZ($8uN# zm#>t`(Y!BW%@o4%0I5W+{_65`V(921-{9A`=xeplaax?}vi2R52rKQjE3LK#RWpDi zM8U;A%lb#jk=oHdVjL4V)NI#pQ10@8ulBDQJflJJnB|_)R5`g7sRhtPiJ-!R$4D53y=Q zTYN-j;&s^0&!9E9aR{6ub^VQ=5U5CcdH@$j3*C9GmO7`h>MQvW`M|cmC_L*xF5oRF z*s~)em`q89kzv{JdY;wST};$q&U(HB909@g_b3Z{75q@4>0FFi787FUIhk?C8nS?C zjYW+i%f(PYF-Lh(nw5mE8-whexx0<{qFuJT*kThWbvr2Di+8RT(2~|Ia%-Ho6~NG| zU2?pu=P$-4M)xyeI<;uNz_R}HT5QWk%T=+1L_3z5Y0Ar04Hh3d`PFI*+IJcyYsK3KMg3H*2j-CEi8unu^ zUvRk~?NH{XA*f?Q70}nEx~5Q6NS+^hl~i|AvXU_qb3kv=WN7Qt&6k)qzD!&DnLFly zw-$TUDluM+d+Waq`E(N&HW##Q9g)|5sOhCz^`oMWptq6O^)N~|!7wXD0XBGy#K%ph z7UJX@EQsE^KcU~*3sk%085OCUP&crG#KnVZB($ZDqdd53Ntbs)Nw|s^3ITzl? z{ISZFjQbGEI8vA#9=-$STNZ6doJo9qi*w&mp)<3T?^HmCR&;+3#^1j|!Ab$u8;ley z8v1-~hZuccS(QuYu5x*%1Z>KovNt1d#%Hh^wmZ}$+V7loruzaZVd{JG8C^-Gw<;be z!WU@tc0%MxKDYPqXI>n;4*9n;uXYdO8i8^Yc)0a`Q#(7oz0&{U&Yzrl5Cp(~1ScDl zekFDS11RtSSv2qeJ-{f5kSXmf92xUan4N8i&yyN+^JkJlWqLDx#1S29Jd_+!gXXo+ zyuh20`;3i=(8o2|mV7e8m0I18-$9_j$2V*3G+jtm98<`HlULC2K1(G3h*46x3^))80sO3IhIt`$+nk_?vmUJ%8^2vcGkB>3tv2+LJ*-KbE!D%3StD zhG&gI&^Y3TDo~NmWxyl+r;y425ER~&z%-W3H^@-}JWv0t);eEiRtPZXI;#ocuEASc zs?xqf-(#x5pJxvD?9$}wRf6`G>$SDAG=9}Rh5F#wqlj#L!Iq`WX{Vz5c+1cjB=gOJ zrQM_exJ_F0<#*pI=Um|wn%A6+=cy11;v4;gvLV&f;m#0v9cuk9n_Svdd6T&oEjcg) z3f3HZQb@EjSIl3C3m&PQd`0_?((58nH@shur>PZfw*(;tUx^D(RccQ4LY68-R}zAx zIbK##a#_IEV8mVSqo9t&boDZK$%NOQdmO6)dF~eNd)29OX{!Ti+Q7Qy@>7R$y|MZV z?UcIy^Wi;3UN0`_vdt>Tdf#GQS}gh*^c4ajtLNQzF5vZ%wnmLrjV4oHrmFQ!4T#!$ z1eb~@W*xB?yS`KOO*7v4_|WD?bP_uV>?1!%adbEv@ATGwvmrU4xJngRu_|!S{~in2 z5%@Bd{py&KYENF>reWE#GAXHQgyFFi9_`?=Aj(Ss#gHB|ms=7`ofhq&25$UXR6sef zTLlD5QNRcfx=6p)c(!rFmfiGy4R z;SwR>Eo?*=p4PW>pt9`ee~C2QBgq0JSNaV$8FG{;bLx03DHdqiNnSOx*j2P|39B(e z$?_y1d;&-A3yn8!*wuGRRG=JWIdK~27JA@7JB*=tY)}L6@SuK!vx@`W2*`;GJzWTf zKUP78r>p!P?aiBvbpI88%Ky#_fJ)LwnHGv9=h~VAP$$e0B0)vg4qso2-r)eQ`qmzM zxE~A0fFP0PPKij;6h|?);d&e*WCQYt--AuDpX13hf9d;o2?ZoVTIf1sXte%Vz5sDm zYop_*<;3P{xC8Ht@|?W?(e@cvxJ_8LWhlllQ{m|f12Gr{UF2r`>>Q zXW~ol6mkB%>*DltWz!Yc>^hM{vp%3?g~MMPZo>eD@8R!;sG<1K*^TFA(v^scGj|a*bY9PUzBMK3SjO_@^x92(uGOmj7jf;yk-GNN3TP)9lvdP9cJ>O(bot(D z(?vGz4|Rz}He-n)`*nw=Q~RaXHdiN#KEa}lHmEPYG~(!ZgcD#HEy?&0iPG4#wztdE zjROvBK>au8$*%qosG5t@zU9K2SvF`DiPGhvItupy`sH1oQiS;1RhRh@5yTz++wQ>$ za;UCg;&;Vcj82>KJ*HUpKxncDD-7bO?=982D&SF&%*jzba)z zS%L4->Y3rhx>BuyzxI9KcK5XgDIsWH>pohi8QTlZBsfp6Wp|U6 zabp%dQbFvGg7niBaFgu2@ms5{U7MlA0B~gB2P4UpN9WO&a|i!s;?}Ls=K-7FJO`GT z_&^~rph{hp5&xJ)=$$ONQS*8Uw)Gn4h*Q;{3+NnQ7J;-NM^$ErD&w0~k3{bTtq!ID zOGlv$HDZ0^vECkUMM$K;^)ZYF2GZk!P6D&&NdU>=GUY2`n~I9+j$JB{Qq)#uQLHxD zrX1iLnLDJofiP;dP9tk~nyk-`NvCJzBZy#NI z;{%a{iRy?f>1OMkd*6e@r5bk&BVPz(se!Gg_X>?2T1?ql?%lb*7w$6(y~8P9AJ<$szeB!T7(!@nw8dv%j$yxrJ8 zYV#GxN+{4`shdBV7iKKCQ{i*A>Sk}H4$C~v__3;x^^z@=A7ZxfmdP8hrMfY!cy}I? z2d)hGHQZSrgW_Q-^F&JEHY<@>g)5fMxcP44Mf){=(}juqY_7*CR3y)g-Z%piuK*Jd zaP8yQRz5edNwd61azY{s(5Y}+EV>x&Qgs;smuLAlqVH?1PeZ@zRU|!PL`RU@zQw>( zt>k{+=e5^2t@%|;X616=yk*P+MHIoqn?j~utO|RW5{CSs7ay06K^AYmH;$z=RYat! zm^Il`+`T(rcttN#>le(3B$w1EGoS$)v{`e2ReCFG;}Nk)^sqSNd34^b@p-wefl5Bd zWsViW$>Ot$5o=S*Q#bYE#`ikj(y@eWOhGc*9-VT{zohMk)gxYi(LU4N_F%hbE2Is^ z$w)%W#-ofW(^-aGTmj*US~oXY3K}*d80XUf>)V$(}=~|Eqw7u2Lp!ma{weCkH&z8w31d z!T0Ot^(LVs2X69CDFr39U+TLmRtL9%ROaF3npJ!6fLW`AtsnYFg9Ld69|k;AoG%H| zDQy7BAkDV9gzGXy3S9#=24LofOa2c{=toI)v}*pb|x}7FE!6^xa-`_;<6l08QJ1;`|hg?IDik*l~y^1O=*52 z4TS7;RVPLi>K=Deu}FCI+7NdT4RjW;k3wz);8uk6yWmZoew^ieI#;{>AM4po(0Dcp zhLEX%b^~7!95WvLa`!Y8$d7Z1&7oFED}8TkKQ}!ASEdzI zNMbMf^-;sqNRGy;E%RhS=AD*qBcsl$BPYLRo7YEMK2?Q6Q-E(G;la8bAL(iLh65hd z@XT&f$IOl5h5B73Fu0R!ohy|p^60dZf_CW4X=wpjv}^Xtf@7qBXu$wrED6?{C+SzD zJ7aq3MPRPjY^mGOks+X<&gdDFwu1I_#Qf*|wSBF?dT-6)wRv?4+3%j=o!20$#FBQG zvK@hiU@~2s9>7BNDxH1Xou2kz(kZNa9JYKcFKY`8jz>0et7J&*9 zWo^?`xe_A7?$QCX9$PIB$I>@QgHenF|MQ#<%N_-X1`t5yf>tWnm7Ib}jJ^@#&5R!> z3fv*7xg0^4G@OGfkcWZg)vP}X^QMrah)$%ENyWC{q=aouwTz)i!63WW36mh3kPygY zG58BvIDUQa9~WrM^`pYKm6Rf;kqp6_R~J=f%7zv#RdOOF6*rk?{sO8R6KyJxj-lQ| zQ$}|04S=ZfCy<+=m>$E6^;q&&neZ+0^=uFq9c!f&@e{$W*@Fj8s$_2TZ9LZnCwA_^ zn|wCB`KNQ6_M}9wB_Lfo(_}u~?G@yDCm~jCxZ$cm z)2NJNr4^T-+>0=VL2ne-pHN?BtTc>SNsL6K1MHky-8R3&mRG{k1EzUl2KlGzOs;2^ zw(-&>LOcURG^Hns({Bl2IcNLk-V_@D>r;IH`wh`7Da=7fw5Xukq)!_-+A(Leqw5m; zItFC0a@Uy-OR=w~?}0P;LfdznzQHMk{liwgd>TaoZkbW${5P&7L8aiY4ZFbo zIlz?g?p9Mf_xiLwFYN+=2`{xWiJuI^ETda8A5~*2HYXz{fafAh!6bvrPD!a&(+b9d zA|&@@P;XRuo_;_Uqy&20DM3%`Dj-D`1G-O|kqSXgJ)&l+l~VTKH7dOgc^zDJ6?SW8 zcGU@fXAi6~FDG_LvZB}j!;JP4P z!p136?J<%~`sW`H8(y3h*kuzikIJR1o|5)CV7DK9jfqZyS+ry7C%!e2`+|~n7_Ly1 z&?9-5Cz8D041GRy1G2jI94pkYyj6j& zV!aVuf5y)8#g*IR)J<8LezUOeob;YJm9oIA1lu{)T$vWo3g`&eH=%vwa{ZikGTCHF zLuNQ?Q}D!M(Q(6~$)k~31pUdSn0p6Z$?3Hq1BaqCq5L4M+#^d?g;U|73BZt?xDzNT zFMroy9>KRKjOn#%u&JgkTS}?29RQRESDhTCmX|NCiEK7+mmC-hMsU%Q1Wu1jW+m(e z(#YR;tTg)yP2CkBM5?bL&RZ*adGU~5mxYtwi(pgap5uCR-g)kzE<0>E#XcsJlhk6z z=2>)%aZ`LG@w()y*Rc`~y?#lb z=%2;w>>yyncDs@a@@8@7mZDZ$r0$a%^+rlPW zbJIWJe52gze7SAX;4ZG8hP3V2I@pD$0-L*= z0pxan*<)Wz1Vyh=<>_U6%eqA{5i(Z1gO2r= zW_C%@ll`XFO}I$OZU2b@?(mmC!m;=tX{gU=RL6@m0cB3a42xb&xotLW^9~|}UKLlr zON*{2j?%{%C6h~OX0PUDQi-hLIReM6URclz5)!IFRT@A)z_ue}J0!3UkpBCn((ibI zpXiRH(Cap%5aZ`G{@O3D*HHV*u*UbCaf=RxmJw(7;~itq=Lp=t!okOTwf#YDy!=VIzUE%I);~A?9*2U6}0)w$DWX0=tvex zrl7rEgJPOc?faXAHonx{c>wFr_BJP{hNEug3?)2AV`IIO7ToQ+Dh+nEU z6vviwNLE0|L4Ww%+Tw7f1l97wk1Q+N?yD{jVxq#w(FArM8uxW>{#Hao$HP~QcfgWE zJwR9L>YxGsXdN_S zJ?^)aGiNnBj=q2U%qh{-KgRl=vP<9B-_V3ab++K$zm!yLzQ0)UzgASjz|D(tU zXg)Kird}BV>4HqKafe1aCVvC)3=*JZV*z|?G}z6mq)zP6FkEc*)Lb6iVJk|_H5l(5 zseFtQG4~1&{F05+b}3qL4>1gA`%gjP;*ayY)Q@dyaWEC?YPrm#>O<5%jkDD9zF5nc zrDF5?SgJsrvZ5tx3IpznNEjzK7VX`uE=v&Fc99b_w}PEPm;5{~TS+;mw=Q$Qo_ zM$V{dw1(7FT25sNA}S^%JtJe5-I~^Ao!B4?T)0+1>+0-8j1is7*ewugiYF5orUVMedN$3XnwJoc2S)tU1m#{^w7_x~Ez8wLe7ig`c0^v{ay2!?TC(&Ob-+UnX4nOBoX zdQ|t$O;U5iW6>C~9NzMa`&75)cV^#puKcO_!^Mp&ENFrG&F#t6$G#Stc!qU~B&4RCEaoP7~&EJjuFXPT_w-$)xmk0PF ziySxp>Hhit1U$1)@e|0EQgkB38&TAEGzhUJxc(i3;aulrW#Db|1oaZ~4#dkVBm54< z?GKB%IUP!KvyQd+h`pNYGMc4VSsIj-gYRB5LR$WmCFx(7qA151%z)CRWFR&$JKgU? znV#RGSwzUO)~hK#|8TS7SWMmy$BymmsfA3l6e+jn?lVKZQNps!+@nRCh|@Pb4>>b} z&SFwIpTZ^BmlA2bU~ql8Lvdf+3M-eW&e*+G93lP6gR#ag6RVk)P#+7n!6N{+^ z&GlP7OF~eV*eNiuPa$Rfg$yReahds`OiJt5PEUivi z;_8{fA&s_|3zZJ2S?)EjyL4ZvoJ3X-y#3p1XMJE7?EWR5qy%Z3C!!Jm^$TU5VdEK_ zxJ!jH3Jkv2B*E+zqKm2G_H~E?uCh&bEhvC9FE$%iu;>-h44}^q0%>p8lgwhKm93*X zqJZvmGRZ2A_2+DTMlb8K9%G^$sQb6-{Pa4c5Wgdi-uNlx+h5P4Kp8*lNS^r^SoP#~ zo8Dwg=lkueHzUWfg=+>eyZC&@(@)JK2sMLX3mL19xM{)*8sYE`H0FU@+^}ulqtPk! r^OhcSw({bs#pTv|T`%{DyQea@o?&56ae__fdnM6L$Kra#!vK203a1b z|G)$zBY*b#zyfH_aUh<&18d_BVkXUQT*B4y=~GA7AmLc|0E?dgPXLd@xLLe3~a{TkrGn;C` zI@-Vt5O!mA`RBrQ(Zwo#{p3$WLj#bmv6w2=p3Zs(8M-u{r28dUdGU zrs9G@Op+X3P6OX3e+P^ip(06slie-n_w<9~DLcz$>#5@i5<6e+ZC2KH^^#$t{rM9V z-S-qlG-v!)Bv@nYyqy8_2FK^_CAu()oNkO8M(QvHpAKX}2L7f9SW>^X6U`z<>Szlj zh9GwL5G{S@yKdZm@+Xjz&=XC&Ksj!Dhj?3-DEj*$)$haPxDGa(LM~f#ff}1y#$=5s zR7X0lcL{RNrN4uMJ+~I)vq~9;Dp{_Acw0%x&mJFJg3!2VhL}4?gMeE$f<$fjggsd2 zQASEPi9_HW$;ET5H6U>e@BB);RPJQZ!}uoZ?=sgS)gOyi?+uCC>hm}1hoT`2)qlM=(Y2k*xRq)NKtt= zgbB7is=P!roB7;TG-Vn$ZV=lAjEmnnwte0_dSD*&^1>++n8dLnhsZID7+D9D z2k-cy3zmk|pGrM~;$G%~y8H<=z}x0;u108Ct43)Vu10EEazJdEx`c1ZE3~KW5n5^z zHZFSS?73TQhH1&YAcYe&??$@*I87(K4mZHs?p*xM*VB51hjQI@mQB3MZL)Yb0|1m5Bs5h7pf+DyR+O3ZM5sohXU z7~2Y{z5&EtyfXA~o#f(P2MP&nO9|k-yr23pZBxD3`oHCuVq8a7!F0&0k)mFAye|4L zT>yIs-35ER3z~Uo!D(K;%=gSr=XciX5lA3?~^@utQuJu;sV^uMn0>X%^XA;6Q=U#L~7W2yUTU}+3!@@ST6v1vtV^=X4> zTWRm;Oz6_+Ug>G+zta~opfRX0WHSO7)ff{PD;YPLgqYlz8km-tC75%W|FUrY7pjdu zNXe8--wikXG~L|3xp{QHy_a8BXOw5;=3JgsuY|cPRD8dvb}wG2kV#}3wKe%Z3vP;s zk#?^|=A^{qg(XL>=jHv4-6K5A5%FW?D=N3EXST7=_Hqy7*8w6-XXQRrnqU_)8o}AD z&+g{7QmaPp!0bP+OLy)IzJH%SyFcAp;d%Cv>tv+$1Lt^SU6pb`4Lq>gOPnZ0n_GCu z@Kzq}PwwsRNAgx}Ypvqa8+QiJ>)=|V@jK&wCnx3>uCJK4w(A5QxE?$nvEIRZ4FT4= z2DZ~AIwcmj9r;LyJy@Orpyr&={;ZBU_tl_)?w#hBCXw*RwrlxzP(Loc@%^ixO6_~D z3E{PU!<*v+obwrzFjI!<%t%&d!wd$Z?E|nfFwNrSah&bjgm-n?w~Z?uK5%^H_V(87=?VM68Qk6#K9ATOB*;M5cUnArKu7nQX$QNBK8}X*MU1;>O&$`(+H$S35<%gi<{TBFRzk&F@czv?1iGU4q}FRxJkeyJmZa4_5C1PVGYX9`;1+Vq1#1?qtw(% zp@v4qrh);?&P_~JJ8z%z>Gzz~ey(T1KB<1)bAS}BK9nus>b#E-Cdmr{34AglO(V{$ zmZ&L*k;ctKy1uUOT$5eCplt9X`Qq^=mA*5unPi>vyeQJzMHb zt?IJ>8R?p89ir9gnx(Y+`MvrqG3O*4dM;@6ksBEyw#Q6A*C$b(rSt)dlz*!kb^S?G z_+@m=d*m4B36Z2TkScGR5uAUATWdaEHWvuFl9jTI2JX+b?%~KQqOuRcaScbuVIBFK zfu8^Bi}y*K&iCVWz6*^r(&(powkYIeiExJE@aAZv4@bBAErFfToa^uggSFV`5%yQ# zVgXpMv5E$s?FhIX%(L~|CGT?T9w$%{@l~%u*bxXko<=_yxs)xu4esrl`Acv`c~yfo zC14xp+rwv6IZwLh8QbpmWoLvY{{i;MS%`U?**RN;(90?T^4)ODebv7GD||OQu_=2hb60hH`K2azBV)zxEdn--a&^{N$gZ09O>Gz$(sln3q!g1JN z_jEanAGBr%Sw=3gd z&B?cXa#Gqp7rK6A7e3QOMY z8ME3l#)h;hXRxUHW;Q++DweJ-)hyP0iTZCy9F#Ddy}@CEKxE(2{<2C@-)QD6efzJ1 zr;^ZuG;YTb`FCxQ7nV83c7ii^lO1>fDeu&?Z-F>J>?3yrqdna7ci>2__qGQ))|iva zo~!wgBbhMv7s%2#uXVabiM9RD{xY|Yp|o(U!(8U5Qa}t8#oS`-(_D&n^~mZ9%g$Ac zz*``Xse*^`pP|;dYNr*0pDf|T(ZV4Z){1=3D(_l?I696otIe^q`B^0QW`O+k(tGB7 zn~{r2S_>{l?m~^3l`?ua78qgPC$z~J&utpDx#s4dzIGlf6QWP03z~*X0r|5UHp0av zCY<5ubk-@F)V2p>2Q7@07QNdXZ6%E;20W{oC15L0iTI>IB9=54ZSJIUVDX%yO|6Qm z@@Qt|VX?XTScPLcB#Cdtoqr&6;Gb>yUOzHS^Mkl|+}+KM)`+x$!ehBsQ&J zB=^ZWSW$ijh3x{q4&A=_n7GQ{gLyBdKc2n)p+rh(=Tm2IZHRwG&K4?!&21?EbP zzCE!cPy2%-KW@R4xI-a%P%KV|5izJfcB5PGaKvCkO_0>2o>*u}xezJ!0%ZIu8{$bW zFH8S&;K@!9_T40>;@Wagmj_45pD|9SH1aUj{wLNKs^6aECfHY?!K5*`?HC{Z$RqZ| zjLDOg1{e-{oU|x}pGU-aHR#c}vS23^(R#}_f!+uEFZet(!E43YxbkFHxGjtxneDj8 zB(nG~*&;_cBRD=#VQpKbc4kn7XnOH?zF51VGtzwdSvXU+tW>Y8Um+v;y09}JfCg;;W?nR}rPQcbNW(rNwd9+FP))LykneQ6nS`RS#N_qG z{}eJ$lmU2+;6W695zSyBCb0}{ekCpWm9LQft1Va_gqeN!j1APr6 zDH8U$BL;F8Z7CAngbCHA%b>CC_4+m2dJCNwnMy@9S$f9x6$g`^O$;2}g?-e_Xk^~L zg@t|%VCIi}xe>dl=j*?Bwpw%SV1&n|$oQ$AG`EsWXDILgC!Dnwt9ApK4}Du4ws;e< zDGg3sl5<+d%}Mk@i#jPlw%}FNpvsi>CqKIUdd%=BN&MP_xc8KvI1_Mm%@()1pUS7s z+~q59ZEKiyB;uVq z@j|@};xweoDVYk@-K$SxU3*PdCMtlNzSOPlCdJ#k*)*>U6CCq*hUDiM`Bp;bNRJ4nv&XMfz#IdEXwmo@hhQ%9vIDmv4-c|bjG2Q1ti$gIH1P) z8*XYVEL9zAD*iP$Y997VI3G)Wz#DNLrB|r2rKb|{hUqlLJUX1a=CY-o*>n}XHmoBs z29`S%FID6Anj2b6HPz-uQmSc7RcT8k$AHdgh_u*WlhmS2-jT#1 zEH|P3ojcPM%(K{o>k6VXXcRQlSl}z_AD&)jN3`YkYLl>%GZGU{=dnBWVTAjne+c+2 z!EJ2lw55gmNK8bq95iHPVpVq+3(h&HE}hVrx$MQoY5pn()$}xzUtzKZ3N&4Cg~sOj zHwVYxqGmZDdI0wV~H}JxLFOb=!FV{{y#f&nQA(eNc=CUU)33)&W%J!pfW>MF<5g3|GHxCpTb48Z#1G?!O^1KHSVU}v z4|03{(zM~+G=YoZup)q{qPt%a&X1#-j(_uCoFq4-K&J=;%hM(gH9o@}+{zd^)0$ic(dh*wk!1gO4 z1gRpO>QK+-duW3FtbE5i-;I#;VfV$5USE+tGQ1&yP!m5j_xRgrmd_qm@SZCRD&;zP zJnHnSysG2}-#lABq1W2yRQ9p4_%20rJoy@Sqn8#ZZLYQY-p>U#Hl>8? z&f3}=YBmQ#vs^NTl#A~OFV3PE!4n{{elgiy&G4y&rAMEdZ5D3y;-Ny-*s6v-xWLhgEIxSU zb!e|@Z0&LiYwOLNU#X+w+eX-%)xqf&t@F4izo~=Zdbf;r3F!~{YI&xhj)`@=1Wwia z=&fSF8DkK0sOtneDSnr6fe%-oM7lMHOA>l!ma!eoHi7mtl}K^=huoHC!{qNq#X0L@ zYzo$->SmE*c$|`i(!m`sQBa490%L6^GAl~@Hqykd`$s*@W5r1G=;S8a`fQd zZ>6vHl7%yGrsDHma~Hva?Mgp>06GK#HailAmjcjbz)y}m1bB}qiL(*=VxQvD=Pvd~ zn|ku*_%3`);sBD8ufq;2zNE0jU%dK=V@Gp!4M} zh@nYJz>P4eOneIun?smrVqjoc>NlfMSG^PmCm^nVorOD&@bXi8Qgr0&Sv->wqMXOW zvWb|%M|!k!pTqyT3ur^rts;jvE#AdL#%Je!vFGSW&ofRC1KXlG;44cNEGOD==$}mH zbKEw-bi|Qlxy54v_kcyEDBBcV!2Hu;3YdCZ&j`cxG`2V|w=UoJ+ZU9tn3x!)BzO*p zPHYTA=9}K2Z}EkK;M=^)VY3tQ);=HaD1Pg!>XD*&e}DC#6qE`-oc^zhSh?l zYksve>4N4Q*Y_6iCh7f-FT_j~Qu#i84?q?cE&_NfBbH%;Ar^Dj^cX$2mytTqWp@i` z+u7|hC{Z1tYbi=&!z->NCL(+#_#V}sZv#{bY2kqrC3n|@Bv5X={&!!ga0@e7{bJ2Dh#3IpcVeH^o<*0ruIB0JlhVU2z zf#6@Q(AL@tZnQpCiA-d;xF{SpU*h$Cnw{OSda_`Hm;BLdNELv(3{B}ePg?$BANKFT z&ZWk}$ZvawN5d5?+|n5}r+=AqJH+!XwE9RqLml3e0+;D)?Uy^S)g14=zZz_@cU`i9 zCce=2nlX{@RL~wuU-c+T$Iqag*3gHwXV@58k}xiV&wsBd{$`b7V?Orj6b^Y3E+?Q( z_vR?7Gb1vuSI9aTI8@(E+2gCJE_Y)|o9If}@@XkMRme(3#pQPtdSot_87#;%gz@`T z1e1=pED{Le(z?+EU?B_=8$bRYDcUjlOM}>B+^I?ZK)+h>{nU|@yr%kRO~;=XMelAqBYY^=7wz3RMO)aUkej%)27DplCIIk$sxxLKqya z{cTIj$6e6tqN=<$kx?SRv{lC=&&N1En82-o#FmgPc2W!K@U%jfY!}6Xs>wMsOmh7e= z{XwJfP-D1Y1?v9NqL6Csvn;dv^fAi`JNE(ieJ#1nZWJ3Ac^VC-_KX583_EaMYyT1_7)cOmhiSZ21zI7$> zB1MruRdx3!x#91u0$Q5kiExgzs%G=Cn{JqOe&#A1d6B)`BgJd zV00sckvW`$}RMp3&hQ=%#aXKdT?oJyOMq;lDC;7=2~;-6!P+ zO}|qO*Ku@TkxTdZqvkS%z5A$H&w?_os`)X$A+z@ zFIGQE&Wr~;{>#`-V=G5E8Hc{ts{qynw?dG5G=a8jvSeysPcoi4=e#xL8RSX6P^v>1 zkDvrOXKf+rz21u?N4=u%QZ?A_?c+E`n(06}s}CbYgxXMoSxUU7GAG9Ax8e(B$!~SZ z2{AD}S{^-aSL}cFR~oX|1TQ@N!Sn zHhk&rP{b7*{;)v=)IIaRb@9@mI*hlGVpkpzaEByJf+RL-)3!#y+X$UsK#jXFIs0q1 zzJHx~KX(kfzUc&~k_4p+;7a{wrezqs@++$?|JacTQGR3ytkZplX_-8aV7!Dk8>i1k z0ltEH<=>}&|Lz$+O=f$!BbXbLo*e)q7^p;$YX3G}e7XG3>&|Gv1_vr+)WRgS) zthB%Kzj9jT>gU>^`kQzG+oc+{OBcaC(;z!y{+TDJUD&snMke+g}^eB&Pcz0-v4)wt2tj@ktaq{Zx)`1K1wdAZC zGs}NOw&9|a01h`1b;BVn|MtTnHw+4)VL3(Hm=s*vgvHMEVf^fG@ScsmY7<_ z*UB|rD^t{v$*GW`x2`++%L07TNx<|pv}=}#u70C}t(Qt2725mQO{kT`){AK>t(Z^9 zXQHe>6@Ja87As;xX)bIz(!<*Etb2D;KTD#~Ade^F3Qp@&(rCaPNk5>DoMuLzGxMY_?Jc$Kl>(Dm*b`#-0^wo1@{YCMOQM;!3juG z@+O^Wga9<2z}OdMW(wwxUHam@?95p-Yn$Qyarq(k@y9CpQe0s!!cMXYX8Y!m4P7+M zQ!z5qCe&arYljO!UX#+T4Ts8UnD89U5vR7bmzXX0!xPVMc8-xV3qD7-8@vs>>3I$_ z^z-f6_}bHhrG=ZofTH*Dc_1on?qr-L7taN0)AtYN35Nj9q_be=uYi)rg7yo`sI+PW z`a(TVUPeo!RxIc=!%>{p_@WE7ABlZHtSSdFs(9ba3kMaCn)V{S_3-oc-ROiQRZ0SI ztaty$N^7gaXY}a4F)y+p)Q8a6?|7rQ7S4uFNxmkf{!nqSx`0i6nO7A5PkUDeH31TQ zhL_pbsTeVG<@Jl34@X()>3X<)i5`bG5iw-eNBw(zp^;zvr6W@i{MKscxI@ynv2g*~ zcoqxNPkLCgQ@aT}2xK?}rB!Vf3sK78ak*4jX=OF%)8FgB`!0)CeT@V8#Y9_k&DzsA z5yOfR_4Ozlua8Ub+sMa1$E)|Rx9#m*Zw1cU_xmso6cDfvzpAPg_PPQ>b9w=kK)etY z3ZmHrSmc*bQt5C$(Ty;4)35@g6#0+)#SHyo=L7EwKKfi2Edi|6H=wl!!;3fg4qFTE zG;6SXi9SWY0MPx*po)>>SC{??Ts`YDnSb4jnLJfq4ejvlb-Zc|xmz|Y{l?dteqb|B z$BkOv0am(Y%~nso5}q)j*$aRR>1PSnG`{n!&V#nao@EIWa^W@Vx3PuL*QD zdg$&hI?6~$T?1>UlMw_CugdRqI1CeJ3q-WvYy~%WPNodB3=W4aHq#v7!ktG_h)Up-hcvJ`AaRSbW!ke?d5Jm@UN-Z-^Sdwot=T^iq$bG{1UNPm>N-ia!GW_HX2# zSa02iRY@CM0rG014Xde9@egcB$6jQ|S@Ks-PGn;^%TVegUTI;l=*2E?=tB%dXokkq z;mL?+W8JIaQ<{muJ3Co3S-Ky#j9pvS=a<+iCREt4%D#&VvFk7A!skCW&wfnr$@xpv zwJU^CU^ow^<>R2l!(Y#Of@_a=g+x5OpRmmuX zL;gOwc>hQRKhS!8y;?ve%W;!83iU3-&?@LN%kJshew{X${&Y^I|8a|$UeL&*Xi7Mb z&U#MeY%o!p`Ao_~?4rO6rf7f3M6zL3(JW2R@(GtF(XGWs>Sn~Cwl>n%3mI*jlY5=y;h)Be` z%xsB(Ds7B9qEW@q4&TGujK}*!h9HX^pTop1L9#S(Uk~DpzFIv&=*s4p17xH$Z>UsKH;zVrS##3E}U>X!tGgksQ;^vyMEopCEPW`^X9H4TF`f)sQ1$6 zNS!9I$_=)7W=~Lp8yh^zkk=Dz4}%}G06S-B!FbN$C6deThp@B6@z+9hJ4wEezMJO> zxkTKwLZ7+}o!H(-x;CY01{t@k8XEmqX*vtEXmyO1!E^W_*!m!0%prR6yaUesbza9i z9M~%e34C;qSGX$qtazLDTS8|1*kO^m>IMez^-LgH^q+UN<2-h0dy@v;GL-L}oT-CR z;@)Z}P0*Oeo8WpEJvFMDVp8wzp~tnv-&H!T&rQ1qtscw2#QBxoUstT|Y=$CzOV7Hf zAwWJ&ysBW;gE=cW@pVSg9NXp(4D0myIsd-Tt3lbF$$YTCUjWEns*zy2M$kZ=XL56J4 zyukI5M&vk3{2vC9cRqr2Q313+sHHhG6M>!>*+oHc4lsC_3A!Umc?@LW+z?sn1@ToS zC3S~3RTya+tKx7r2V7H5D9-dvAht(T6>-t5(p&XeX*+iRT0l9xP3!bxrM8nPnOWvA z-?{21|HNC97v7FAnoAuU+4`@bK3L6}lDQc9kq`5!lWDT1cip{vY<2XF+3EAMA>2@j z>A=@PnI&u99m6Su--f@ymE#kZ4TbpNIvEcA-Mi;1Et|R4Ja2{=R(l?3OV0vMGo?=plOM>*ukV5`QM)9I{I|D6N+sH z%0ljSmed+_dXl@j)Stk_ zX!GZ@2JR)KI($&IOx}F`iD$B;>5aKIn7wT?I?LI_w|?Oz2iP(1U^hhmAjVL4ENAGf z7vx3Rv;s`qWi0jP#z@bx4U1&9B&kJ*Xv73+iV-eA93Ht6yh{&vav!H5YKJ(|st}$f zj8fKZF5_QbBpQIf8}1&qzB5h5syNpZKer5K>3&E|xvqvM_i1?)9=Xu7cLI z8r`S8m|9>v-qhc!2&F!54_MA`tMaRq@<>&N3MR0ML!nf{}Sjg(Rs_j1kfHfImb2pJn<&TDBGau!xACf0SOVlyH!Rw(7GUu^6b%uWfK z^H*3;=KETIw8PkwYzoFz&YsE}u2I{||oxCn?ljWgnEQ<&3*is4terE3dW-p=;vhPPNLbp58t$+ql+{*EwOd{<6K z$Z&M!c_&9geMUsu%lmj*omgdZ@a|A@PfScEx>7%s`M!PWh;9;FZJ4up4;i=7LgF&(o~LB%a%d?oVX#h2NOs^>5D+kvsL!O57*Ak3M?TLw382j6QJDBj<;Z^q zM1BXypXyTKToe(b(8DT?mo*=TV;b_~7WB6wXp8X!$`SDXadw2EqxSv%e@UK(P^3A4 z3R&N|O*yUX^Q7^~ae6Hzwtb&T!$w#5GpR7iOzN&SNp95X2yRhE(4YTIZ@kgnt2H4d zfmO$MLFE>6-OKHd51FuFlQ~P)=EOnidmPD^YJH7$g; zem>}FO1+M|zdM<|~Jb<0OD7s>jX<$5m>U(Ml-uXIx? z!?{GNlOSquHDd_Wu3vs7e)~RG`F#=T5(aHfGb+c_q7_=`CCcez<6=Q()x&M=z4GDj zyP=?ha2S%)JmX;db}B@u#LBHR%n*40QufZ^+w9!b$C-Dv@73CsP4g5@A`*KIULnEN zNE=}%t7a3AM1&3l@>X=e1@aCNJ0dX*1#Xw8C-KJ!_r|5_*!s|YfxYz4wKX1ArGufz zzY}KsXN-6^Zvn`FAXB@F{AFKBX4l`))3H@?Gt7)ub>6T${*CB1dH@31TvB@!9CV zC9Zf&(osj==%_2+6edRw?4g5y-m1}ZC9#s)GA=6ZiBX+}oUY`c!VoW|0xlp7D*ad# z=_O1o-z=A!VTBL_JhH8)_B-v2z(AcEVT~XMlkjC z9mmqPY7*Q(EvGC*iStFj>S;!1W)4F&ejzcBYRe?8G1TnF%tOZ})U5`0&P-Z+V5)z^ z)P4MFB?5iLasEN>xPauJ#bu+>cXVcusvg1N&rj_e37WVbD2=k} z>HcQm%$>LCnp*y;#fYcH0For{AhU=Or>KrZ5TZc10S-_lw2)c+Nlun!T0&$kc%hwf zY4s{7(F=|8=Y~n%#;5}S9J#@ZpoN9M(^tpH&c;%R_%4^4`y;CIXG{ZqS&8AisZB{H zX#bYlf9a?B{tQ3++)3lh-|uGnegR>tydlWtXZ0Oz_?uaKUDJrE-`KukCXETvldK$Z zpYNesFTO0hS@Wffn3eV8ovN0uIER7qsPvu=wL5BsWiX~_dAO&+`bUKulkgl4Ur-L4 zPjtB%BRFe3NjiiTsf7CRc5!WYXrT=1|LUhc`0DA#6MH!UwPn4T?chT>m30cSs$h&Y zYC1m7qE#>zE3R?sT-4C-SY@7YhSeF3rM2in-c@PawW61)149%df{i#+Ui&(j_wV9z z9l?DddHWo#Q*!aTJeG&rue}wIccQ(Y=K>^FPI9)da`nE5rj?y$+l`7pG+4@~y z{8-w49h`i#O4~p}YAyjkPcCiB4aEsQ$g-bMPyI|yDot_#BX*7AUOHZ+6d%qOmQa%$ z3|Z&hvxYQZvMz9H8W+I5aVwc$!<7xX&j1nP-s`a6SZ%(ySHXC~xRo8~O+zP z^Ng$RV$}uGSU#`GKZ8OXR#tGsXI(%on?Rnw6h#+c{4OrR$GD=F>vj-;u6-&T3oH2>fpxyGEFx>S$>PyEu>GbSpb!5`{0SL~LS=Vs{gkzn?8mu}_v( z0x|H0dGt=qx(eZd$M(Zy>k(GUl{aq9lidvUfg^4M_git@*<+WAu_7*gbBqdem9wuJ zidS4t~7GaK$Ke2t*sCHS0AMuegO2&2yka$B`)!ui9 zO1v$Z@`KPj2+Oyek&^+2AHKjm@dxebknX&d_H_D12aQTpb#TciTaipn7};0ijMl)_ ziCbv^uW+mGtT{<<= zS$-@Aa6`ptem%0q+e2cFhWN)lou?wi9*@ADfyL3$w>vW0?0m?#%4usUjGRgY6$kvR`VrzDd+>PR6qbnOH-3eSb2vR_$c*RW|gD8u{{oD>naXj2= zO3Io`x)+#?-2BIYkd&OwX@mUCka7L>{@c70%O`(u#K?x!IV*9Jy-l3>=Xu3EK?n}# zzhFLMbs8>AF9uyr%~?<8G*~}QDv1&|mrmDG=1$x^hYmAIy5)1*Q(S={o&opDdp~?E z^Vn}rj%f)14+Q>OJHq+{R2A^epTZ}XS{g>)`SqaF90p7EbziNnH>Oz|i-%kJ1qINu zb^ut`y7yXps^obR>S{;8e!RclePf9{=S^Vz=v=JCYbGH?GwHTaq@V+S39HOmfzvzP zX`}jNI$JZIpOeC^Yg+kTRl_}g_`0^AC`l41+WYb7uOWvmlH?Rff!T7D zA@Mi|rBdQH$w3OlL|2lrC8^mn=rXABo>e{`!L30?JsKSR zkc!^^92>>fuWKDhqD=_>u3JTlY$GN( z-fzU+8-(~2SDc)qbfD2HO##sj5952JLoWtOE`w`Vt=&_|Nz>G6LS)%;<@{u^#yhC3i^*z6$RU)`ba*+J z&i&{?41?j0>{Np#;1vdbH?!EMc*f_Gc?I@vf?-77*k~}8uVKu_*cKEsnyQUCbqiUw{Fs7 zAs49v7t(AmvMY~^F+BF)As?z{pBS#?Ek05agnx*WhHmews&AL8V7Emw%TjFBIbA-_ zk8=e*hn1l9T|mFl)ax>{Hi3V=U={mXtSx8VX93n?bd{8ewjY9m&tV`0f25|hhR=U9 z&b6D)epzEWoaTz%L53b5qWZd~y>@eS)@DjldUelgGtnm%co;?d7pP`bQFY>QWXI^< zltkQTovN18Q=NNhZpP?i{FpUtx-$8lcFm9>SN|V@lrc7PG4nD=jtiC^RXKiDZU{SM z5PW#GT(a84nLp}-5qCs^=(88>a{6eYNrt|9eu)`26RXk*1_7=eOP>aY47T#ow8;}v z0zObY>;-3_dM7SL{(Z1L1gpI;!U9oSVqx89(_yLFM}-+NckeykvS_T`W7{Uct_u<@KRdPZNtio%FQRxv{(aGDg@`Ehmwn4Van zzhbdls-P|OvLjqm5B zovgfe)mgJ2r1Mo)yl1w0N?KD{6fY)6m|IGaLU&?FaZ9xhm?FOaVQ!+$|z+qV&fvxq2GoiGah{IegI(EG>rS()k!Hz$JDmT+_ zl^MP*V7wesnq^s~RO{)aAwf>~NnbcaUgQCluA-#yTTRJArDT2Fj1@Su8g2MRsP}{o zXsODelg(>0jT$w>V>eC;*MC8lQ^oxj6A055=HZF#;U3!WWyWU|>KD2uc1ABgJl6 diff --git a/screen_manager/app/tile_icons.py b/screen_manager/app/tile_icons.py index deba148..f20f260 100644 --- a/screen_manager/app/tile_icons.py +++ b/screen_manager/app/tile_icons.py @@ -224,6 +224,8 @@ FIXED = ( ('monitor', 'F0379'), ('information-outline', 'F02FD'), ('restart', 'F0709'), + # A light that is off, as Home Assistant shows one without an icon of its own (firmware 0.2.53+). + ('lightbulb-off', 'F0E4F'), ) # Pickable icons by name, and every glyph the firmware fonts contain. diff --git a/screen_manager/config.yaml b/screen_manager/config.yaml index 20c7fbf..0a1be11 100644 --- a/screen_manager/config.yaml +++ b/screen_manager/config.yaml @@ -1,5 +1,5 @@ name: ESP Screen Manager -version: "0.2.61" +version: "0.2.62" slug: esp_screen_manager description: Choose tiles for your CYD and Guition touchscreen, without reflashing. url: https://github.com/MaxGramser/homeassistant_espscreen diff --git a/tests/test_binary_words.py b/tests/test_binary_words.py new file mode 100644 index 0000000..4b96ed5 --- /dev/null +++ b/tests/test_binary_words.py @@ -0,0 +1,74 @@ +"""Binary sensors in Home Assistant's words (app 0.2.62 / firmware 0.2.53): a door tile says Open or Closed. + +The screen maps the device class itself (`tile_controls::binary_state_text`), from the `device_class` attribute every +state message has carried since app 0.2.23: nothing new on the wire, so it works with older apps too. These tests keep +its table equal to the add-on's `header_bar.BINARY_STATES`, which the top bar and the history card use. +""" +import re +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'screen_manager/app')) +import header_bar # noqa: E402 +import history_card # noqa: E402 +from core import state_message # noqa: E402 + +CONTROLS = (ROOT / 'components/smart_display/tile_controls.h').read_text() +TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() +PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml') + + +def firmware_words(): + block = re.search(r'constexpr BinaryWords BINARY_WORDS\[\] = \{(.*?)\n\};', CONTROLS, re.S) + return {device_class: (on, off) for device_class, on, off in re.findall(r'\{"(\w+)", "([^"]*)", "([^"]*)"\}', block.group(1))} + + +class BinaryWords(unittest.TestCase): + def test_the_screen_carries_the_add_on_s_words(self): + self.assertEqual(firmware_words(), header_bar.BINARY_STATES) + + def test_top_bar_and_history_card_say_the_same(self): + for device_class, (on, off) in header_bar.BINARY_STATES.items(): + attrs = {'device_class': device_class} + for raw, words in (('on', on), ('off', off)): + self.assertEqual(header_bar.value('binary_sensor.x', {'state': raw, 'attributes': attrs}), (words, None)) + self.assertEqual(history_card.state_label('binary_sensor', raw, attrs), words) + # Without a class, or with one Home Assistant adds later, all of them say On and Off. + for attrs in ({}, {'device_class': 'future_class'}): + self.assertEqual(header_bar.value('binary_sensor.x', {'state': 'on', 'attributes': attrs}), ('On', None)) + self.assertEqual(history_card.state_label('binary_sensor', 'off', attrs), 'Off') + self.assertIn('return on ? "On" : "Off";', CONTROLS) + + def test_the_state_message_already_carries_the_class(self): + tile = {'entity': 'binary_sensor.front_door', 'name': ''} + states = {'binary_sensor.front_door': {'state': 'on', 'attributes': {'device_class': 'door', 'friendly_name': 'Front door'}}} + message = state_message(0, tile, states) + self.assertEqual((message['state'], message['a']['device_class']), ('on', 'door')) + self.assertNotIn('x', message) + self.assertIn('tile.device_class=string(a["device_class"],24);', TILES) + self.assertLessEqual(max(map(len, header_bar.BINARY_STATES)), 24) + + def test_tile_card_and_heading_use_them_for_binary_sensors_only(self): + self.assertIn('else if (d == "binary_sensor" && (value == "on" || value == "off")) ' + 'value = tile_controls::binary_state_text(t.device_class, value == "on");', TILES) + self.assertIn('if(t.domain()=="binary_sensor"&&(t.state=="on"||t.state=="off"))' + 'return tile_controls::binary_state_text(t.device_class,t.state=="on");', TILES) + # A card still waiting for its history must not take the words of the card opened before it. + self.assertIn('if(history.entity==t.entity)for(const auto &pair:history.words)', TILES) + + def test_the_fonts_carry_every_word(self): + letters = set(''.join(on + off for on, off in header_bar.BINARY_STATES.values())) + self.assertLessEqual(letters, set(header_bar.GLYPHS)) + for name in PROFILES: + text = (ROOT / name).read_text() + for font in ('headline', 'watch_value', 'label', 'sublabel', 'sublabel_big'): + block = re.search(r'id: ' + font + r'\n size: \d+\n bpp: 4\n glyphs: \[(.*?)\]\n', text, re.S) + self.assertIsNotNone(block, (name, font)) + glyphs = {a or b for a, b in re.findall(r"'([^'])'|\"(')\"", block.group(1))} + self.assertLessEqual(letters, glyphs, (name, font)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_off_look.py b/tests/test_off_look.py new file mode 100644 index 0000000..99a65b1 --- /dev/null +++ b/tests/test_off_look.py @@ -0,0 +1,38 @@ +"""An off light and an off binary sensor look off, as in Home Assistant (app 0.2.62 / firmware 0.2.53). + +Home Assistant's own light icon is a bulb that is crossed out while off (`mdi:lightbulb-off`), and every entity that is +off is grey. An icon of its own, or one chosen in ESP Screens, stays the same and turns grey, as in Home Assistant. +""" +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'screen_manager/app')) +import header_bar # noqa: E402 +import tile_icons # noqa: E402 + +TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text() + + +class OffLook(unittest.TestCase): + def test_the_fonts_carry_the_bulb_that_is_off(self): + self.assertEqual(tile_icons.GLYPHS['lightbulb-off'], 'F0E4F') + for name in ('guition-4848s040.yaml', 'home-like-2432s028.yaml', 'packages/cyd.yaml', 'packages/guition.yaml'): + self.assertIn('"\\U000F0E4F" # lightbulb-off', (ROOT / name).read_text(), name) + + def test_the_screen_crosses_out_its_own_bulb_and_greys_what_is_off(self): + self.assertIn('if (d == "light" && tile.state == "off") return "\\U000F0E4F";', TILES) + self.assertIn('(t.is_switch()||d=="light"||d=="binary_sensor"||d=="person"||d=="timer") && !on ? 0x9E9E9E : accent', TILES) + + def test_the_top_bar_does_the_same(self): + light = lambda state, **attrs: {'state': state, 'attributes': attrs} + self.assertEqual(header_bar.auto_icon('light.hall', light('off')), tile_icons.GLYPHS['lightbulb-off']) + self.assertEqual(header_bar.auto_icon('light.hall', light('on')), tile_icons.GLYPHS['lightbulb']) + self.assertEqual(header_bar.auto_icon('light.hall', light('unavailable')), tile_icons.GLYPHS['lightbulb']) + self.assertEqual(header_bar.auto_icon('light.lamp', light('off', icon='mdi:lamp')), tile_icons.GLYPHS['lamp']) + self.assertIsNone(header_bar.accent('light.hall', light('off'))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tile_controls.cpp b/tests/test_tile_controls.cpp index 7d0ec3f..56559ff 100644 --- a/tests/test_tile_controls.cpp +++ b/tests/test_tile_controls.cpp @@ -170,5 +170,13 @@ int main() { assert(old_robot2.choice('s') && old_robot2.choice('s')->labels[1] == "Normal" && old_robot2.choice('s')->current == "balanced"); rows = vacuum_rows(old_robot2, 0); assert(rows.suction && !rows.water && !choice_action(old_robot2, 'm', "mop").valid()); + + // Binary sensors (firmware 0.2.53+) in the words of their device class; no class or an unknown one says On and Off. + assert(!strcmp(binary_state_text("door", true), "Open") && !strcmp(binary_state_text("door", false), "Closed")); + assert(!strcmp(binary_state_text("motion", true), "Motion") && !strcmp(binary_state_text("motion", false), "No motion")); + assert(!strcmp(binary_state_text("moisture", true), "Wet") && !strcmp(binary_state_text("moisture", false), "Dry")); + assert(!strcmp(binary_state_text("battery_charging", false), "Not charging") && !strcmp(binary_state_text("battery", true), "Low")); + assert(!strcmp(binary_state_text("", true), "On") && !strcmp(binary_state_text("", false), "Off")); + assert(!strcmp(binary_state_text("future_class", true), "On") && !strcmp(binary_state_text("Door", true), "On")); return 0; }