From 76834ea539252ccffac9435c4e748df800ef0eef Mon Sep 17 00:00:00 2001 From: 916BGAI Date: Wed, 6 May 2026 14:57:20 +0800 Subject: [PATCH 1/2] add picoclaw app --- projects/app_picoclaw/app.yaml | 12 + projects/app_picoclaw/asr/__init__.py | 51 ++ projects/app_picoclaw/asr/config.py | 110 ++++ projects/app_picoclaw/asr/elevenlabs.py | 71 +++ projects/app_picoclaw/asr/qwen.py | 83 +++ projects/app_picoclaw/asr/qwen_realtime.py | 93 ++++ projects/app_picoclaw/asr/whisper.py | 87 ++++ projects/app_picoclaw/config.py | 74 +++ projects/app_picoclaw/img/icon.png | Bin 0 -> 21458 bytes projects/app_picoclaw/img/logo.png | Bin 0 -> 54532 bytes projects/app_picoclaw/install_picoclaw.py | 317 ++++++++++++ projects/app_picoclaw/key.py | 27 + projects/app_picoclaw/main.py | 347 +++++++++++++ projects/app_picoclaw/picoclaw.py | 359 +++++++++++++ projects/app_picoclaw/touch.py | 50 ++ projects/app_picoclaw/ui.py | 569 +++++++++++++++++++++ 16 files changed, 2250 insertions(+) create mode 100644 projects/app_picoclaw/app.yaml create mode 100644 projects/app_picoclaw/asr/__init__.py create mode 100644 projects/app_picoclaw/asr/config.py create mode 100644 projects/app_picoclaw/asr/elevenlabs.py create mode 100644 projects/app_picoclaw/asr/qwen.py create mode 100644 projects/app_picoclaw/asr/qwen_realtime.py create mode 100644 projects/app_picoclaw/asr/whisper.py create mode 100644 projects/app_picoclaw/config.py create mode 100644 projects/app_picoclaw/img/icon.png create mode 100644 projects/app_picoclaw/img/logo.png create mode 100644 projects/app_picoclaw/install_picoclaw.py create mode 100644 projects/app_picoclaw/key.py create mode 100644 projects/app_picoclaw/main.py create mode 100644 projects/app_picoclaw/picoclaw.py create mode 100644 projects/app_picoclaw/touch.py create mode 100644 projects/app_picoclaw/ui.py diff --git a/projects/app_picoclaw/app.yaml b/projects/app_picoclaw/app.yaml new file mode 100644 index 00000000..4589d63f --- /dev/null +++ b/projects/app_picoclaw/app.yaml @@ -0,0 +1,12 @@ +id: picoclaw +name: PicoClaw +name[zh]: PicoClaw +version: 1.0.0 +icon: img/icon.png +author: Sipeed Ltd +desc: PicoClaw +desc[zh]: PicoClaw +files: + app.yaml: app.yaml + asr: asr + img: img diff --git a/projects/app_picoclaw/asr/__init__.py b/projects/app_picoclaw/asr/__init__.py new file mode 100644 index 00000000..47ec47c5 --- /dev/null +++ b/projects/app_picoclaw/asr/__init__.py @@ -0,0 +1,51 @@ +import importlib +import logging + +from .config import load_asr_config + +logger = logging.getLogger(__name__) + +_BACKEND_REGISTRY: list[tuple[str, str]] = [ + ("qwen3-asr-flash-realtime", ".qwen_realtime"), + ("qwen3-asr-flash", ".qwen"), + ("whisper", ".whisper"), + ("scribe_v1", ".elevenlabs"), +] + + +class ASRNotConfiguredError(Exception): + """Raised when no ASR model is configured.""" + + +def _resolve_backend(model: str) -> str: + """Return the module path for the given model name.""" + for prefix, module_path in _BACKEND_REGISTRY: + if model.startswith(prefix): + return module_path + raise ValueError( + f"No ASR backend registered for model '{model}'. " + f"Known prefixes: {[p for p, _ in _BACKEND_REGISTRY]}" + ) + + +def get_asr_backend(use_cache: bool = True): + prefixes = [p for p, _ in _BACKEND_REGISTRY] + model, api_key = load_asr_config(use_cache=use_cache, prefixes=prefixes) + if not model: + raise ASRNotConfiguredError( + "No ASR model configured." + ) + + module_path = _resolve_backend(model) + logger.info("ASR routing: model=%s → %s", model, module_path) + + mod = importlib.import_module(module_path, package=__name__) + return mod.asr_session + + +try: + asr_session = get_asr_backend() +except ASRNotConfiguredError: + asr_session = None + +__all__ = ["asr_session", "get_asr_backend", "ASRNotConfiguredError"] diff --git a/projects/app_picoclaw/asr/config.py b/projects/app_picoclaw/asr/config.py new file mode 100644 index 00000000..4edcf29c --- /dev/null +++ b/projects/app_picoclaw/asr/config.py @@ -0,0 +1,110 @@ +import logging +import os +from pathlib import Path + +logger = logging.getLogger(__name__) + +SECURITY_YML_PATH = Path( + os.environ.get("PICOCLAW_SECURITY_YML", "/root/.picoclaw/.security.yml") +) + +_cached_config: tuple[str, str] | None = None + + +def load_asr_config(prefixes: list[str] | None = None, use_cache: bool = True) -> tuple[str, str]: + global _cached_config + if use_cache and _cached_config is not None: + return _cached_config + env_model = os.environ.get("ASR_MODEL", "").strip() + env_key = os.environ.get("DASHSCOPE_API_KEY", "").strip() + + if env_model and env_key: + _cached_config = (env_model, env_key) + return _cached_config + + # Try .security.yml + yml_result = _load_from_yml(prefixes) + if yml_result is not None: + yml_model, yml_key = yml_result + model = env_model or yml_model + key = env_key or yml_key + if model and key: + _cached_config = (model, key) + return _cached_config + + # Fallback + model = env_model or "" + key = env_key or "" + result = (model, key) + if model and key: + _cached_config = result + return result + + +def _load_from_yml(prefixes: list[str] | None = None) -> tuple[str, str] | None: + try: + if not SECURITY_YML_PATH.exists(): + return None + text = SECURITY_YML_PATH.read_text(encoding="utf-8") + return _parse_yml(text, prefixes) + except Exception as exc: + logger.debug("Failed to read %s: %s", SECURITY_YML_PATH, exc) + return None + + +def _parse_yml(text: str, prefixes: list[str] | None = None) -> tuple[str, str] | None: + """Extract first model block (matching prefixes) with an api_key. + + Expected structure (indent = 2 spaces per level): + :0: + api_keys: + - + """ + lines = text.splitlines() + found_model = "" + in_model = False + in_api_keys = False + + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(line) - len(line.lstrip(" ")) + + if not in_model: + if indent == 2 and stripped.endswith(":0:"): + candidate = stripped[:-3] + if prefixes is None or any(candidate.startswith(p) for p in prefixes): + found_model = candidate + in_model = True + continue + + # Moved to another top-level model block + if indent <= 2 and stripped.endswith(":0:"): + # Check if this new block also matches + candidate = stripped[:-3] + in_model = False + in_api_keys = False + if prefixes is None or any(candidate.startswith(p) for p in prefixes): + found_model = candidate + in_model = True + continue + if indent == 0: + break + + if indent == 4 and stripped == "api_keys:": + in_api_keys = True + continue + + if in_api_keys: + if indent <= 4: + in_api_keys = False + continue + if stripped.startswith("- "): + key = stripped[2:].strip().strip('"').strip("'") + if key: + return found_model, key + + return None diff --git a/projects/app_picoclaw/asr/elevenlabs.py b/projects/app_picoclaw/asr/elevenlabs.py new file mode 100644 index 00000000..fea35e51 --- /dev/null +++ b/projects/app_picoclaw/asr/elevenlabs.py @@ -0,0 +1,71 @@ +import asyncio +import io +import logging +import wave + +import numpy as np +import requests + +from .config import load_asr_config + +logger = logging.getLogger(__name__) + +API_URL = "https://api.elevenlabs.io/v1/speech-to-text" + + +def _pcm_to_wav_bytes(pcm_int16: np.ndarray, sample_rate: int = 16000) -> bytes: + """Convert int16 PCM samples to WAV file bytes.""" + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # 16-bit + wf.setframerate(sample_rate) + wf.writeframes(pcm_int16.tobytes()) + return buf.getvalue() + + +async def asr_session(pcm_data: np.ndarray) -> str: + if len(pcm_data) < 3200: + logger.info("Audio too short (%d samples), skipping recognition", len(pcm_data)) + return "" + + # Convert normalized float32 PCM to int16 PCM + pcm_int16 = (pcm_data * 32768).clip(-32768, 32767).astype(np.int16) + + model, api_key = load_asr_config() + if not api_key: + logger.error("API key not found") + return "" + + logger.debug("ASR model: %s (ElevenLabs)", model) + + wav_bytes = _pcm_to_wav_bytes(pcm_int16) + + headers = { + "Xi-Api-Key": api_key, + } + files = { + "file": ("audio.wav", wav_bytes, "audio/wav"), + } + data = { + "model_id": model, + } + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, + lambda: requests.post(API_URL, headers=headers, files=files, data=data, timeout=120), + ) + + if resp.status_code != 200: + logger.error("ElevenLabs API error %d: %s", resp.status_code, resp.text) + return "" + + try: + result = resp.json() + transcript = result["text"] + logger.info("Recognized: %s", transcript) + return transcript.strip() + except (KeyError, TypeError) as exc: + logger.error("Failed to parse ElevenLabs response: %s — %s", exc, resp.text) + return "" diff --git a/projects/app_picoclaw/asr/qwen.py b/projects/app_picoclaw/asr/qwen.py new file mode 100644 index 00000000..dac1ca56 --- /dev/null +++ b/projects/app_picoclaw/asr/qwen.py @@ -0,0 +1,83 @@ +import base64 +import io +import logging +import wave + +import numpy as np +import requests + +from .config import load_asr_config + +logger = logging.getLogger(__name__) + +API_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" + + +def _pcm_to_base64_wav(pcm_int16: np.ndarray, sample_rate: int = 16000) -> str: + """Convert int16 PCM samples to a base64-encoded WAV data URI.""" + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # 16-bit + wf.setframerate(sample_rate) + wf.writeframes(pcm_int16.tobytes()) + wav_bytes = buf.getvalue() + b64 = base64.b64encode(wav_bytes).decode("utf-8") + return f"data:audio/wav;base64,{b64}" + + +async def asr_session(pcm_data: np.ndarray) -> str: + if len(pcm_data) < 3200: + logger.info("Audio too short (%d samples), skipping recognition", len(pcm_data)) + return "" + + # Convert normalized float32 PCM to int16 PCM + pcm_int16 = (pcm_data * 32768).clip(-32768, 32767).astype(np.int16) + + model, api_key = load_asr_config() + if not api_key: + logger.error("API key not found (DASHSCOPE_API_KEY / .security.yml)") + return "" + + logger.debug("ASR model: %s (non-realtime)", model) + + data_uri = _pcm_to_base64_wav(pcm_int16) + + payload = { + "model": model, + "messages": [ + { + "role": "user", + "content": [ + {"type": "input_audio", "input_audio": {"data": data_uri, "format": "wav"}} + ], + } + ], + "stream": False, + "asr_options": {"enable_itn": False}, + } + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + import asyncio + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, + lambda: requests.post(API_URL, json=payload, headers=headers, timeout=120), + ) + + if resp.status_code != 200: + logger.error("ASR API error %d: %s", resp.status_code, resp.text) + return "" + + try: + data = resp.json() + transcript = data["choices"][0]["message"]["content"] + logger.info("Recognized: %s", transcript) + return transcript.strip() + except (KeyError, IndexError, TypeError) as exc: + logger.error("Failed to parse ASR response: %s — %s", exc, resp.text) + return "" diff --git a/projects/app_picoclaw/asr/qwen_realtime.py b/projects/app_picoclaw/asr/qwen_realtime.py new file mode 100644 index 00000000..cd671ef3 --- /dev/null +++ b/projects/app_picoclaw/asr/qwen_realtime.py @@ -0,0 +1,93 @@ +import asyncio +import base64 +import json +import logging + +import numpy as np +import websockets + +from .config import load_asr_config + +logger = logging.getLogger(__name__) + +DASHSCOPE_URL_TPL = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime?model={model}" + + +async def asr_session(pcm_data: np.ndarray) -> str: + if len(pcm_data) < 3200: + logger.info("Audio too short (%d samples), skipping recognition", len(pcm_data)) + return "" + # Convert normalized float32 PCM to int16 PCM + pcm_int16 = (pcm_data * 32768).clip(-32768, 32767).astype(np.int16) + audio_bytes = pcm_int16.tobytes() + + model, api_key = load_asr_config() + if not api_key: + logger.error("API key not found (DASHSCOPE_API_KEY / .security.yml)") + return "" + + url = DASHSCOPE_URL_TPL.format(model=model) + logger.debug("ASR model: %s", model) + + conn_headers = { + "Authorization": f"Bearer {api_key}", + "OpenAI-Beta": "realtime=v1", + } + + transcript = "" + + async with websockets.connect(url, extra_headers=conn_headers) as ws: + await ws.send(json.dumps({ + "event_id": "event_001", + "type": "session.update", + "session": { + "modalities": ["text"], + "input_audio_format": "pcm", + "sample_rate": 16000, + "input_audio_transcription": {}, + "turn_detection": None + } + })) + + chunk_size = 3200 # 3200 bytes = 1600 samples = 0.1s @ 16kHz PCM16 + start = 0 + total = len(audio_bytes) + while start < total: + end = min(start + chunk_size, total) + encoded = base64.b64encode(audio_bytes[start:end]).decode("utf-8") + await ws.send(json.dumps({ + "event_id": f"event_audio_{start}", + "type": "input_audio_buffer.append", + "audio": encoded + })) + start += chunk_size + await asyncio.sleep(0.01) + + await ws.send(json.dumps({"event_id": "event_commit", "type": "input_audio_buffer.commit"})) + await ws.send(json.dumps({"event_id": "event_finish", "type": "session.finish"})) + + async for msg in ws: + try: + data = json.loads(msg) + ev_type = data.get("type", "") + logger.debug("%s", ev_type) + if ev_type == "error": + err_code = data.get("error", {}).get("code", "unknown") + err_msg = data.get("error", {}).get("message", "") + logger.error("Server error %s: %s", err_code, err_msg) + break + elif ev_type == "conversation.item.input_audio_transcription.completed": + transcript = ( + data.get("transcript") + or data.get("text") + or (data.get("transcription") or {}).get("text", "") + ) + logger.info("Recognized: %s", transcript) + elif ev_type == "session.finished": + if not transcript: + transcript = data.get("transcript", "") + break + except Exception: + pass + + return transcript.strip() diff --git a/projects/app_picoclaw/asr/whisper.py b/projects/app_picoclaw/asr/whisper.py new file mode 100644 index 00000000..ee0bd715 --- /dev/null +++ b/projects/app_picoclaw/asr/whisper.py @@ -0,0 +1,87 @@ +import asyncio +import io +import logging +import wave + +import numpy as np +import requests + +from .config import load_asr_config + +logger = logging.getLogger(__name__) + +# model name -> (api_base, model_id) +_MODEL_MAP = { + "whisper-1": ("https://api.openai.com/v1", "whisper-1"), + "whisper-large-v3": ("https://api.groq.com/openai/v1", "whisper-large-v3"), + "whisper-large-v3-turbo":("https://api.groq.com/openai/v1", "whisper-large-v3-turbo"), +} + + +def _resolve_model(model: str) -> tuple[str, str]: + """Return (api_base_url, model_id) for the given model name.""" + if model in _MODEL_MAP: + return _MODEL_MAP[model] + raise ValueError(f"Unknown whisper model: '{model}'. Known: {list(_MODEL_MAP)}") + + +def _pcm_to_wav_bytes(pcm_int16: np.ndarray, sample_rate: int = 16000) -> bytes: + """Convert int16 PCM samples to WAV file bytes.""" + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) # 16-bit + wf.setframerate(sample_rate) + wf.writeframes(pcm_int16.tobytes()) + return buf.getvalue() + + +async def asr_session(pcm_data: np.ndarray) -> str: + if len(pcm_data) < 3200: + logger.info("Audio too short (%d samples), skipping recognition", len(pcm_data)) + return "" + + # Convert normalized float32 PCM to int16 PCM + pcm_int16 = (pcm_data * 32768).clip(-32768, 32767).astype(np.int16) + + model, api_key = load_asr_config() + if not api_key: + logger.error("API key not found") + return "" + + api_base, model_id = _resolve_model(model) + url = f"{api_base}/audio/transcriptions" + + logger.debug("ASR model: %s -> %s %s", model, api_base, model_id) + + wav_bytes = _pcm_to_wav_bytes(pcm_int16) + + headers = { + "Authorization": f"Bearer {api_key}", + } + files = { + "file": ("audio.wav", wav_bytes, "audio/wav"), + } + data = { + "model": model_id, + "response_format": "json", + } + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, + lambda: requests.post(url, headers=headers, files=files, data=data, timeout=120), + ) + + if resp.status_code != 200: + logger.error("Whisper API error %d: %s", resp.status_code, resp.text) + return "" + + try: + result = resp.json() + transcript = result["text"] + logger.info("Recognized: %s", transcript) + return transcript.strip() + except (KeyError, TypeError) as exc: + logger.error("Failed to parse Whisper response: %s — %s", exc, resp.text) + return "" diff --git a/projects/app_picoclaw/config.py b/projects/app_picoclaw/config.py new file mode 100644 index 00000000..9d7ad170 --- /dev/null +++ b/projects/app_picoclaw/config.py @@ -0,0 +1,74 @@ +import logging +import os + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +LOG_LEVEL = os.environ.get("LOG_LEVEL", "ERROR").upper() + + +def setup_logging() -> None: + level = getattr(logging, LOG_LEVEL, logging.DEBUG) + logging.basicConfig( + level=level, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + force=True, # override any handlers installed by maix at import time + ) + logging.getLogger().setLevel(level) + + +# --------------------------------------------------------------------------- +# Default config +# --------------------------------------------------------------------------- +DISP_W = 0 # set at runtime via init_display() +DISP_H = 0 # set at runtime via init_display() +ICON_PATH = "img/logo.png" + +# Baseline display width used when picking the default font sizes below. +# UI elements scale relative to this in init_display(). +BASE_DISP_W = 240 +# Extra multiplier on top of resolution-based scaling. Lower this to shrink fonts. +FONT_SCALE = 0.75 + + +def init_display(disp) -> None: + """Populate display-dependent module globals from a maix display.Display.""" + global DISP_W, DISP_H, MAX_TEXT_W + global FONT_SIZE, FONT_SIZE_LARGE, LINE_H + DISP_W = disp.width() + DISP_H = disp.height() + MAX_TEXT_W = DISP_W - TEXT_MARGIN * 2 + + scale = max(1.0, DISP_W / BASE_DISP_W) * FONT_SCALE + FONT_SIZE = int(round(FONT_SIZE * scale)) + FONT_SIZE_LARGE = int(round(FONT_SIZE_LARGE * scale)) + LINE_H = int(round(LINE_H * scale)) + +# --------------------------------------------------------------------------- +# Fonts +# --------------------------------------------------------------------------- +FONT_PATH = "/maixapp/share/font/SourceHanSansCN-Regular.otf" +FONT_NAME = "sourcehansans" +FONT_SIZE = 17 # scaled at runtime by init_display() +FONT_NAME_LARGE = "sourcehansans20" +FONT_SIZE_LARGE = 20 # scaled at runtime by init_display() + +# --------------------------------------------------------------------------- +# Audio +# --------------------------------------------------------------------------- +SAMPLE_RATE = 16000 +AUDIO_CHANNELS = 1 +RECORDER_VOLUME = 100 + +# --------------------------------------------------------------------------- +# Picoclaw release +# --------------------------------------------------------------------------- +PICOCLAW_VERSION = "v0.2.8" + +# --------------------------------------------------------------------------- +# UI layout +# --------------------------------------------------------------------------- +LINE_H = 24 # Line height +TEXT_MARGIN = 8 # Left/right text margin +MAX_TEXT_W = 0 # set at runtime via init_display() diff --git a/projects/app_picoclaw/img/icon.png b/projects/app_picoclaw/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1c625886d938a6b194cab5eea74a1764645d5068 GIT binary patch literal 21458 zcmZ5{1xzJP)Fm*;gTdWhANFu}cXxMpcNpB=ZLkk_AN-*+*x>HY;O;DccC*QDI@LG1 zm6J-RyK?V2eJWa6Q3?qG9{~aa0!c<%T=gpk|IYxxe%%KJO~$_hshPB@0tAFF^;bR& z0^;R==8qvDJU|c-r^XNvyjc(sIL>*$RQSIpU`^$u#34Ta=la=Gp8A!6cb3+1gMdID z{GS0i>QrI=m4tJbQILQ;g@OIXfLJQB#{QL}a+lC{H#c#&;5Bo#_zDoLAXZLB5CmEdOT_+uO@22nf}D8F3MH@741jt2hgtz~x82qY)f>EiDg^f{W~e6L>y? zP7%2aRvA_Wxnh70tN0rFKIt+xDei}*uo2QAXqy?zemN!7UX7CuxXdoY+_|5!-am=a zX65VqpPI*k@BNL!5n)ir&PHZcm6aDA!Igu$)Y$7!>N`qgLxw`e(!g>g-`P-Uci&fa-#L{*C*Mh04W{E&9E4ls6!3QVg=1|uiQqWwz|9QrSdWiBGJI; zqBW-c@53ECZtBOvli)3n;@bV)?H(oDue0pbgLyMcA@+Ou5rfny9zPQcUH*Vui(dkl zPio(y(+J{NPP|5VEbQq_7bDpgEhca!uv66H+*YNV@0(W89*a4$h|C*D6!g z!#8fXo;2JBi5`-Z(rzGz%6!&hA@KcFA z;Ec|U+n`8ZkRR?@EtqxKymKhGt6t~K{=Z0u@xP5dX$ww{)oDvMu2ETdQ3b8pGXLeK z44AHy13y?tU}1uffd8Fkv&j8wNsx*4%llKFZ5ekn%E_w~5jPD=eO8*|DmN&1dA3=q zT->DepL{#|^GIMRn~>RHY20GS+Uz%!YDOqqN>b=IguaGlXe;zmY)6Dv7i#tB38* z8%FlBi{@wAs&|^8`Ie7wlGeZ?*Xa#%)ABY`()m!&i|F*EKuzQ9Cp$#rU1^bLn z`;h>*QV||&Q%kbIKiqjH{rRvoE}T?~Ro&NRiljlJ^V68T&81rZagOXl>|&C5tX~QQ zL6|yrn8HGhl=bt7N;ebkWh*I0?az+xVs5>$yD(3=`8|Z7HcRut9fk2_B?>kEmwm@u zd35_OFPK@~P&qsafS&9^=o!#i7bMOC-751~;3-*)u{_F*fLlA`a?MC{DoG^ya#EZ` z$r%VvI!GDfG;y)aQgBN+DmysKr`dQyNiA?~$Vy>EE%&I6@5btYi9BX&ZjUOS14>=g z@%3j-N1e~Z#jIvzTN6Iq&4h`U5f}jKP`+xPjPWnSQKO{h@C!B5t?7ol8uXT6CrKW16~@L>!u&o))oII8sd zM*exhD%FyZgJbv}*DwBU{#xxNF-Z9GCH{|a#dG}o^U3}phj+$d*wum-c5MJR9;saWSg2y|89CHA^*FC~<*uHsU zMNcBaH-@EA9qmEGj$roy^~;%BP5^Gt%HvvP$4I=ujw;8S9> z$1YsN46VT`W|L0-y$b6>c#M7pEIP$kI?_b3{TbbztmY$>!anPCa&D%x zbo)5H$8iudJf#xEyNrR;Vt?mC>eWbDe!0n37w$fOC%ek2W?28gym;Z5|1H=9=kzzo zx6pfe*g8B7+p|p3;vLvJ&b(XeIOD&QN=jM#*c%@cu}5ZMf-3!1_MKt#3-||H<~4Zp z3%C^^M1^mbln~kHpFGl$la#zIkIKz zGcbm-Hs(6DK`BcHX3fPVlgkB%*%(Zw@(SWWmh)@vMu?pbiaRY^8_E~cb^0L7hjT(l zSv}?kQ?dZ@(LIo(SK&I+c{l%1E4C{7H%ZOgV0d;5O89n4I*Ui}}W1Z^xb#Phk zI8zNwqo@L-jLf3MBbb%TcXK)C>jZCZ*R6rzFBl(}j!*rwwpZ&)kmv$WiVIQQQHttmnG+ z9cB2sIIp+vU1s>HyS0~O1Es-v7(1HUG;V52IG)%IBAQ@lU$l@Sf!|TIG9XX(TYF9NGW-597v>=DihdcBT}+59=m58p$- ziQmyj7Y?@IlU^I34M&I!*F+0OfI}?Ul#&nw;uMzn+*6gFo5oo-d?tMia*5EtP5x8Q z;2L=_UUBrTUp%HIB%8(!v)E&*-E-O~cDd)wFp?NX8<^n)5}I;kxa3x^XimHhXRZiQ zgk`rYy`=KYrGqG>BrwVa1gs##pRls;1r<%y?pzSk3vBh|`RPYmR)&5yr@;N7(QtKXRCZ{IkYK<`*Z(lk6CQ(~WEdf%i#16A znWw2Kppsh@W)KrO1Bt3MET7_q?-CL!Xph&?tkSr+V~zZT*o058b@E?jZzi^=wsu{j zK_yvfe+6YM(+@>cd33Tcab98oj~)FSU$(fjByDln6Qz_}nu|JSZb;=txNd^rO3wu15%zM|KC19gzJ!O*Y= z@L#3~VAt_G7P6FCW!AQJt|g%{bIne@J|#a71ft1f)<>$BwsnniS6)@Z$A_6a`W~~< z&tLcal61v;2I_xFYA3Bh_&O0Mm34;MHj?PYH7!=K>(s^;h11r+&;ZPHUbYzS)!*}< zR))KAT}Q4TD`RlVGSWvgC=EDE74|*CdsBr2ozD5k`w(P|K)X!mOfG3(Mqi~(38{gK zpO|8^cBzQG-v2UPGC5Hf^>DF@c9W(`+8h6USFh_9y&sXfuIv4=2(PMbj2^Le`Pb)) znpe>Q+N8_}st8L`B0QGs`-@p$yv4(ziG4v=V1oJIiL2_dNPkW+4_lnRy|4HRTS-EB zHZ>d7(i*kUomL>vE9vM3jkuXg5!*XL zefg>pH|g!{ClUb6{7UN%p@q)VYS6VFt;-(uvb1*E?;JL>PkMv;Idi9EY zed+dn2}w{`$Wbr(jrvfcyXrVe>9D-fQE(fo#kRA*=(W@*>w@@|kW1q_JJF4aNpFRx zx?c?0Byr}Ve_n?xl4{7mgT4*k3SxH}u;pC&vyc^Ez`I=fxf6dt*sS21{RK+WxWKZp zv>Mi?vZrQ>YdgY>*T#M1WlmGEm>fk_xUqZD&3ZaxbD<_Ao)V%zt`6VO$aRypr%%}b zEby2#u3>=!9fd5Lr~mcgX6jSOYEDjR_AMafonC$pD*q2<+?iIU&K}te5s0jkZAfhN z8b{g8IV3`Y6$uy>`}MJIrMMP;pmCkSB?2gZfc)LSCt$_X!biVjz%(rMO_`c&cf4r3 z5x@DjwyX~Y8x&)?RE&a6-0j#UJcQu)@Yq>m#ayxdA{#eHs9dhXghBEQQX;R_4MB@{ zn|_5|4lNh#C1sWVt8E!s2tg_!P^*%dW{=s5jqpgYCj4<}1p;u}nEjZ+lDyXR3T;AM z#90kXX~ftE3z*B56c~SOojb5uLe2@&)kf}^&AFviX6g}#uj7dupY==u%GoteQzg?B zar~|<^O4;3Zj;6r+P@NW-j3{Xg;3zrkX{?Ct2gFaLUrU|+!Qw6MO_dewYe-ARNaYa z5!SUa6#JJPVBRHNAJZa$3PluoY#kLn2nOTK7B{&juP!o%sUmu9(wq|E0GKp6rVn|} zNhcXPrc}s0c?+|O++=tC7-ZF1MMpF>WG@StDflu8rr*|d%nIlL|MC2(V%o0T>euc3 zan5l?^nD4P5^Rb}S8Rf_vP!+u6?sZz*3{kGU=XKTSx!pn8GvVXkFW@a&DbM@$Pc6J zG20!K8p{8s%%WH(a=SX-5sy_|;_$Neh)|Mhr4n>h!77ME`*0@yBm4c##w|Op-16Y$ z=nL~++l}q^;zluJ*px?uC|Uh0xc#1W>W{@@2EPBzL9YMV1Ck~-fjb=8R?s6^&|Qz0 z54l098kO%2Y*kR$!4hglEdIk^nYiYLgmw=&CzsHLbO|OVu13b#9?i)75{!rii~T7+ z%5#HL94FOIZw!pcJL1paZF8aKd)C8ToU7dRT@*Tr^SSu{*0*W@z5Ujh6x}3*%Hfe9 z*5^5roI*{Ulf&z*HS=Y8Wg;T+I>(>-Lse&}f8g}7d-NR6>>o31nMygc+_ujQBNll1 z&*{qObM^9i!;6Yi><+TyJH7GRPqVyLDA#-5`vp1whlSj?gThaEN5d6y*@3-$ijR}a zfY_~%Qv&zWAwB&vmvgGGjqm+JyfNxEBa;ROPiVDIyIc4qy+tk%COpN5bjV7wb*2nVUMg$NFC7 zgeLo*aRM{m_c;4r^n%8mKCjz#Dtx6e>5N;KH)ob}3GPlO}YvsXmG zRteMU5v9O8n2)`nR~&5v!(G2iL2nHnp*M=*~`o4bZE9(2e{}@? zb5$t-(E(A`PVjbS&3|XF{h8z;v8WTO)dIq_I!1Td^f$>B21$>YHN(gF=L6T%#oFWZ zzt2BMPrsFV!DW+JAAbs85w`*#$u0#1D9;F4p0@q&9!OFmvaKkB?Cq@bd2?Xa)C$mw z(~#akOX>@M*ch&K0j8$Y$?q_cX3^#=lwt^~Jhv27{k%fmrR0aV-wQ#wo%3 z9pO||%!XU09+d<2K_~zGpY^=pF$PJad|p2=$jeUL;ru`Lm~T(d3Dg5G^`Kqi>XE=H zVU3bzdyCjlHyZ{YPd!8*$CGfBCg9KwY@?T#$I54>B_>Dbe}7;v1=owZ9tC@(yrd$w zhKLKW1o!TdEn&STde6|jk5C5??Sd>(pAcoo|0xd*S-A-W!;5+UNyBA}gU+xpd*yg5 z^RWGwS!vWEYH`l`0LG7IrIh_@-^y=o(>2d1S#)hmEmUeXlfgwoQPI2Z21+PvZ#bXF z?G;)9f*-RUg73+h73%&BvKY36WWjQMrw8RKC+0n=Z&lYB{kw>ufzJ*Pq6ZV3P2X>)>09%4H&D zj6s{-Djj;L@}9&x&0Vs#-zaTzA!_eT(Rh}2PeCO*GxozWM=xn5mqFj&Q{8Fa(Bu4KpmTY6E~>IJ_lB|gFRT~mEr)W1P}jxhTd zB=&g6o`+&j{u<5C%}fICl6*8X-?nmXNv$HcY0;rKnQ0(G{YAh+?C`V*@B6?B%!#?Y zw(Gm)_?Y>;##$YP8-hdmD-tvz#*?~s)L%&p&&N!WbCUQ;HDp$Jp3)}gaZetrWoUjq zGOo^~KUl?%rfNxbh@VeM=^{=TF0K+5L$03k*Qp(}s%&H^CI|oD{0Q|oS=M6~{?$vG8n0aGhe)FVXokz&+#cx12>_#n8M@otoOo^i_Z1DV4Bn?r=48 zMF!Sez zC=J^9B)%4xrjD&TZUh+#zku;$#kV#&3lsSAMQ9reRO#d@d@*2fOHA~tl=wz!%aEL1 zOAN~(=k)-b_^3_5PgHc1HSL7R1C8XmM3LfFNr((gWA&V3Dsu@`mKdeQ^puCX zgOH37u7rJT{(T(7{)8(u4coCcq{X0EBrz4Vd-b6frwN$F81f82Yu8m8A9S0VhGiAC zf+af!k>Wj&;iH}h3C{`*RNxYMvdo~G@N~3Nbsw@`fbkXC1UMZ#vA`g5eAtdanEG8v zSS*+dk<3yrad%hYc^-0N_t~Ko#SyYcveKx0iXfmmZz3o#Eu|<=X!cRM3@F1I7Nc?>hl?h%iLLn$x(vg9hfKbbNdlLW_gzO< zI}m2;78ET?#UfeRBD>_7)b>3?!}`7D7Y|Gr=gB&60^rpkZ8nRWk~dI%T|kty;T`xf z;?ezmdWXSisE#8!0VM+oEINuN;~u9`z0FupMg(Wp4<>CLQDI5;>E301CYb+GtP-}}D#uKm*G?wHun4Su{H z{k{t^^4kyNsF>6gySxOb?bB$R+Hm3a==$or@TZEo!PyaN@hqRRFv8 z=9W(xwt!xA7wa2Z82!rxEh#$aIwm#k$s=yCCoMTNMm@R zQe~O@LNsi4YYdBh52)PjGQaBMv0$&l#<#gUz*Z<%_kr<$2WHz*z{Nuuc*iZYZ4lhyG;~!|aB`hW+CkxJ8oRtQC*{9+3 zxHJp5 zxQTo?#|7#6yn9z*8*z}2xDM$xj@F9zdn`lW9bU}~Yjgt3Dk;PF1I-gDcnDSLQl?P= z>zrIUjZccPFhAjcc}B)^KUgRSg3XslD>+9J4Y39=*@_*Kp?IX+@jqyn_H1vK|5HRS zcFe6af=>7K={6kE3R7VAI}LWQ4tAVv`Lmu8KP7PKIn*wTP{n& zC7U}+2`+V$?rDP~3TPSEdPzaVLrZCC6p5C~+f zWTLD8b^uv6z=`eIhG%gWuOvh*I!7Ko}WI4Uc3Y-*5 zfb=eH>$ZaIgT!Pc%TEtqYiMPaiS2#BT3d2LDlv`Q`#kMMJAB&~mH!Rr{Rf%XDYB4D zK>Cta&U%G+hxMl5$qj*0;`8jB7;@xJx9HE^CaFAcSyL-n1l$6u5Xv#ie?y=TFc3%R zqq%1DnQePP)e-;U*egLseX&$T8oG&c+&y_uQ^nF926EQr3J0h04PmxP%>Y@uZ5V`W zK9EgpvJ`Ph-lq0@gt*>Do+#RdhocIc9R{t6mh#jyDpcgZ5mpw1Qb#I4G#V-m!fu6j z`|tTgRH$s2<=K-t@8SgVVrS`1yRmD1@nwsrE2OtY48b+$r|?Xs>y=jOr7B_$IdU}G z5*>2ONkfYbm#2HFF8&jgayt&Wj^^evel{8*wIN_(346SrUDI`Pq3OZFG(|VBE>+kd z@%sad3n9e@P*gXIQnAxeo0JvWT7nP}&_&cT+tCVZXa;Z6RkDGbkFTo>iJyXs@gUtK zahx;q00hvnL8pCJkhkQ|xMMMn6QyC10g;|QN*AA<^);yO(jDzJPmj~NIXK>S##38Sph0$S8o<#o}%g{U}|C2UmLEV{@4b7Hq`xmHz z)d?I6B)-JVswRkLLHm78F+WFgr7L#hW%z4g+JT`7U|p-WryO^~Y8@WlZ#_^<^>mAa zy{v@!`5H>(tD=wU)j7hJ4soE{NwSx&mfzxlPq5^CT5W9xhzRh_$56*6`xdI#h~tUyvC|bx>y|rVQo-a#QzDkuML`bXd}w} zuiPzULS_|m_HEaQb?72OzW6csl<7Ac(gA=Ddr(?iyurP8pG!1lG>@-;@HsbwkpT^% zc-~oyb3kT#E!Imd@_9MjPj=l(_J;I(_=$}jc9i0o{BQG9eBFi^Z==kHJ4Z|SPr6ns zqscTH#uy{deeHC(f+936f`-Z(|`(h+?{SRkUZif5ftR7-HPSKI|r z`7Lg!jA@EUV#6FcHxIcr5k^}Aysi{9jgX&vW zOV&1Y>nAZ+URK}x8&#lXYqXj0Ny=|srpNK2E(;y$>`o?Kw}r;1H`&zx943cG+04HM zX-|Y&@Tlj+ikk!{Q0&tFTyj8@8lWvz+kbIbPam&%fb?A+;pPA5Dq_m$Eu3;IF(a49ang>jHi{33f-YiE`>;!B}zt&e!Ng4 zI=hZt(tO&rB{KRW%JW6M8SMAV03j_5rKlAXFz$%mi&s8%hC6z-7XXr#MM;SwA;-7x zB70yerk)3#2%1Rgr9s_ZXaX@e>#AR@)uz%5}8R-&g6C9iCO!sA6tdvY6vXGJS&2nP8C;rqVCgS``s5@GOFc_OY+v=<-N!0Sext`q z9_(}N^wMG;8c0mCMidhoa}i1>lk~%Tt)%Y+^UNE?ulYixHYBD-+PLxb^4M21Ltpi^ zT?&1)bW(@mEGF?>i|xHUSyBYOzd>%@0qs5^x{F3U)NwI)x-`IR9YK_LsCGleY}B>B zgrQ8XfCjYZ5l6iiiCGy-%^0m!?{c4>tNstG_cbUezvTQ|X$vbFLvw*B`J}Ne4DWYF zyU^qu0+OD|1Dv|1_XiE%SBJP-G?%#3i&S@%l`8LU-Fjm>T-nfB*l{3Mp}hEo{Posx z==!E>*|zCI$;mPuWLn)bG$@@|kl^|D54g-iWve5h&liu;JO|CLS7Lc}(6EaWJ4+cz zedZAj7w{LKel7sqc76Jt&h!Ve%w^WTH=IY)_jj|r3Z-ungFmVqcE;FuiR}iBy~SxX z-FJhFpcgi&GLVZKbFed(nW*h1C~y9hqg~>7{%D}QZ-qG}_&2YWXZQb&V=!xmy+zvBg%JUf@#b@h|C$NVK0YDB4~WI zPc%P+fxQq+ppluWmD}CmpDo8xsIgR>al#o-3j3n##*OH+OmH(LQ`ki#Kuo-Gus=po zQThu%KuGM|S| zp)#w9HRxNd=lnlu+X|N;a(>Bm>z0>m`rwarui#z9hjIqFdXIG}70y0lH)=7NG1`-^ zzj)UPACC4>(R4}i`|KI4OClNm3a5hB4K}#%cw2Q~;tz_almSJ9r%I#Ip~{LH8~=C( zy;ehSK;p`v6Cis&@c1?;c+b+{!v zfwv+K<&-Xw6M&TBZl*!wiRe~a?N=o8RSXoC4(aPiSkoW0R=`9A$v`D2-Hudu`#YqM z-8>(n5Pwmp?7E5F(+Kv9&6V=L+i$i#27xc4sYbtCV+*xw8Xd zUW}|^BO8nMl z^#*W#D*Ijxa!nnbzjqJhI$D8-4$<85YRFL0iqIxB``2DuX0Nps2U{KhsDR8FFx#T%I)))-M~ER?6(ydl|2ZbSTTiP{noUv|yL;s1jqI8h?^W>)`FWY#C$) zr}X3Ba#*8i)&xEdESTxPu4yR(!62KmDGrk)*)qDEVlPEHgWTLP9=#}is<;b$ACXEH z_0))^YGf`a$D7{=u%C0Ud~Xk%es^HOi^bTn7u_md${PJ&W37onP~+Fmb{a3WX7VW! zN3f#-5u*k#kp>`EDjA2r$)e%-Sny%V(k4Z%;xwG!bC)U@9Z?BMX5RCRwpXs!|(K)V%Be~-FT|0BlouQ$N zOny%O;o&$!p&5onl3^?rp#6@GffUxs&sYX(+NN!VzXMui%v|{MsaX%C#zm)Alkt-4 zh2?n?(R*}TYU)h~mlXIG@2-WNOxxe96_^(MIc>Y%>QTISHW$4!jU+f1LD7~+n*p%z zWPxrW>`W%s%8gTsm$K?<4St^n$QK)8 z_b-UQ0>buO*sX7kaa|VNI`{xjMg@eoG_}M+E=AKchFu~hyNW&#P||7C((Js`H0D>z z{f?+JO0rDG8JI7Pa6i^{L;vjZo=aUCGbH|*R0{rAKBi$~tC5xFE1^lIo)CBPQ_8@$ zZoxk+WDzvvW)D=Xqfk4;UKcRz7_Jb}AfsX1w*zxiYvPYKx}32`IGT7D9o~338Wo=` zzi~YlIKOEtcV5HC8Ic{k%cP8|`}1Vo3a z>6NbWQgfeJRc^5>v)ku8zdgbG;wF97N=PZaY<7))A!WoT zn5q-v9Bsu?5OkuIAn_ts$ZFpchM__??waH7u5z|vVob8jS7PL6AGeswCU2;VN&a!3 z|BjkbdfGY9PcWUqEzQK65_CHPqiZrGT(1_Fjs<#UV*2q%>!v(^l~5RJj#B+mN$nJqVt5T{W;)&Ud(zEY^a&eQ-at} z(H54lMlw~FQ1QlX`kdauSdT)ZPM>72-d9LIrA?F1NvFBhm4QmG72o$}?|WWR?d7_} zgGB%wniL6t>HUD8bWO5d)hrlA$GL_Se}u62ku&mflNt{ZmzXGctzV~GdS2j*s9P)@ z)a$S9<8WeM%U|rKx8=bE!J15fRABZ@R7YFNFt>P5T+xTwRPOCNA!KhJYQ~$ca_QBV z>MdzJkrZ1a3_HVaY`r;hahPiV+9P^5ESYl4%c6|4ch1>;F=h1R2>0)LM#2^732=~} z3cJ{|196NTK{x|hYwG6SFd4xyW;5`t1e6pxv@?W{VrjDsOZ5J>Wm8s5xo>+L0 zE-eGZ4=05!4X5pC>{Q30?TL`;{k%~$GYwr^aaw5PQ zJ*FLzCa}9EU;KDoR*7V`uf5i)H!zk!XyH8gY1CLrtKvQgh^Jefl!N@!zY#e0sFzO% z_ThvYYrW;)|DH@OYLBya>td?_9M5I1@0ET(m9utGmWkS1jv9d~a&So#fBW52J`ZFn zus{Q7gmJ(a!_tH)ZIWLQvsC!Ho52`TLpND#Gsn5qB&Am+=3B_hBAfj2lL)c8K#zx@ zfiRNgrvt=h&&k<|(MX@C9^{Nq+cR7TT?&;3w8R@2Q8d~s689x`HHbLagf_T|V&Vai zWNq?ZpvIX$p*_nI{<@s{0HReYd$D!reEkcj9>h{;#c6Cjl&d%~6EOYV1EPaIAFvAQ zeRL+wSpR0U)tw-IgOyP|6QVCU0LeVf4!|qHLf+P?!T(%0Wn5Yi)wN*>+cSAxxI2(* zg>`hpEAI`{G6GjJlLHmf&M|C5GKWqGI?Z#YTJnp!?l~7V|+|#R#$tR0b{jWjqM^3=IagTGIN%>E9F_w1j%5&%_&`2=N_U z=hFTuMPe^FvKYb+Jm^rP~=7X+pe~Z$QW}ab3Y{XX&6% z$S-SRz&rlTcGTv>tN6LuOBV`2FY=gHvbx65*;%yZl~{{#c+39-Sg2z|nod0YIWAv; z6iQl*A)ft@i+op@pm!DTDm@g#l*7n=Ep@EGL&j!rF-KjCv$D?qKi{6-U^RoGD)ywh zL}&h%24xpxz#6Zv?-UY za+$|}fkC>@uB?cSWyS-K4NYPsJRjq#vhujo)CP!ry~9GjuHxkjCMzBt+SeV&T{SBy z5_+bh&h#lB(*t*}6|};%;dpl^f<7ptx6b(VL&=gWKh^Oe*rLh#R}Y>h>vx$9`)g}i zxR-a1&z+JGUu@uJxJ76X;v~Zib~n5#kUXCc^m5;xClAywaVYg~Oj}HFM+hIkk=Lpn zPMC+mUci#|HM)%%qD~1cCLcm6(!*k**EbH$rsHV$KI5m~S8r>`uvgS@*w$dmV5TCj z1`m)g2&n>EW-7S9q$|H|oQQ1BlYyxTR-EvIf`VTxF2M+X`*gL!HpEC2P~S9IfG-oV zrKV*(XXQ>Gx-~DA#0>s}*TbwJqwWw;C7MliRkwxi}U8luXVspEmHYKaURdElB@G55lJbU%=@KW z;+5+-nzUt6skq}!sz)9AGoU{{%A1aXQa^SKg^X!b*K4%RA_#-AQRxR&gcdv|V=|+i zV|)&0xg+>AGQX%n$kuuW{{_T zyi{d51ELI&@VN51NrH;vTR|!u?nmhVPma<=n3b?pzn)!`AS`BPR7u)%Yi|!Jv(LjV z^YcB6sEDtjU`*5Pqfd%!n0LqkqLT8vCJ+l+{Eu_x>8=0dDA3zS93WZ>WRIJ( z8zcx9X!s+lQHr@!>yYDH+$ z&@((f^z~0ARzmZ@>kiBDnbZt}m5RXc809b~MT?XGL!F9da~a7byqQFZG?uWHYnP>r zYz5MoSGSg<6?L(yrc+htvfZo3VP2um#vv60|@o_tWn72YsBsWS0gwBLh56@QU!C$uPbhq&~Ba8F0>1{** z0+6gKkdeT8CV2ZqAR=*d_yu3AnU{?6|mam5C{Ev9&W z18t9xpBw^7m2?9E`$y^4m$7<8m@h`>f!~n}$o>3o>KnQ$oJEUn(ZKk9EhT(1soXR6 z%8kwYQP*u-BUm`P;m6)@++ayzQK8zj+jz zXs|{3Mn`H?#}?RHq5@IM1$>|zK8^(!JCQQo$u~_C#SVi{ZG#q`1~r;;5)y4MR06fH zdaOMLR^|w%qC{FV9dGZPtGGF7l@M%h1lx^&uURve2uQg=6Y)#z@CrkQTk(U00s))< zjDJeHC{~)Llt@*Sdp)03O>YISE20k{_@*N*N#?BNPW1$96a1)&OrnCx;GodZFP)YoIgGDV@Z1zO ztpE%}n7Nf-+^(d<2)da>FKOo5KVvU+NVzx3eR}5}o1+NWhsGqUl9w9XxC>i{I|_YS z?5Mdmvie?cI0W1Er6l0rLH`K=Y*55tRYZ%`uu8&7S$}+U@ODGJ$%UhNJKg&h==GWP z4gz|zjBXKpcb%kx=*N{1kiQE_NZGRD1 zsOK>zRJIVKSkmYw&S0MfU0b8Z5;7B?OXEvRGo9B}RFl zRlFifX5TW1>V64%Rd#yX38UNFbk*&K7SB^wc)+N=`9JIsx_d~8PxG-_Q8}5o3ClS>>9)@Gnm+JyKgV zQ~ckwqy;;h`uhSc@|D=D)aBR(57}Z>zH`M5B>%{oKxg<+o86z$Oa^wHCR|r1BC#({wXJoVTo_lav7yjJkmb1hJW6^Ejyg+Si zCJkz$$RWHPhkb2DTSHl;dvf8?p{~oBpgRF~c8GrLA2>LtZ#U^4y&cQObcs+2Y!14* zNfc~*xKapJbqbSSl^w{QzpJK5@R{i*0}G-=vQ(zpX(dvj8mPqNuH3V(MpPoVHhB)0 zwzhU?oBv9M)9=AB`24gz+XH8k;OtM|q#&^zeLRQ!e8Wwd0ocqe|BAlBA5sa^$cP70 zult~2AUQAAu|#30r(lkDBSvt?fLv109Yz>C*DTYDd)mdK4DK#YBH?FtnCc7$Z5{su z4&JzE*6n&c;9dkl;F{L@P{?^)IMm&Il^>Ud=0;XUtd+yq(=1h9?!^dC)VWx6|Iy;z zRqup&I~jYrh9TO1)dccGX#$x>|I89wiZUeXSpcLRY#SlLTB(h_nY#>FevLWRg@DDf z9J9GFJDsTdlA(QF;EU-K_fXk3_5e3$d1X@K-Zt&?WsahDrr>Jyd%mbm(HcRVrsM4G zJupU+SfV$&W-Su_mGG=5vBwurM@JTksUAVKm={ia^80>#!4=Tkjw^}}ZfH;uGHx_9 z`15)P`SLFEZMas$&`#YNGa5~e-5M8?CAPt$H-Jm_SOQ)Zz`q7=rz*(*m6(Alo$cze zLxKr8!A}RHTNb61MlFlksOrwqEfDpzMSOnk=O>jXUZtMQMhAK5c8MF3r*>B8W8vL) z^xlYyC%UbU5BL%=(>xTRfzNA}jYm{I{#qeFFw_Gb}HcP|D-7onu7!bMfng9y4M@sK8S&x={14eub?83@8=X zL`e;pk~`Z~rLoeG0&SLwD3KWUWwYpYo8JJSSnh9h4xN9+@}~G{EN3)1Le?lxI7%o! zdmUmG9+mNb^JGs`j)l0$r+;~6n53!B9ve%qKoyj>tpAN36y!F{=#i5s#A|_nBvv-) z4DNCdQN*<%XFAY!V3*0#Tr>V{kESE57!qaXE_6N}+cMi1eHTyCG%Ma-P#lUpTYUfp zqfcVx^S$~W6YFk-H=An2xqi;7OK87q)FAKyj9#c;<1*}Z#Y!<|&4!$+iX>8WA1<71 zi7z7`a)$iM;%y&aQz z95^5?zwR`R?{(JGJ_52ocf0Fh-eY$9Qc$!Fsm`nY-qJe!=V0_^I@ z6=~elsY<5MGT&joqR_#M%m}`f0d;ka;=v&gH0yu5G|pVlKs+{=J^`P-7HiqHLNDTt zvaWaHS?J0yc5e}Zlx-t?xJIfIV51kFPSy`CF6%v-WFz{N8IT^6}0ersvDHKZFGf!sW( z3ox$Wxb%cuzeIZ82*u9DDwM-2tc@9!9JW~Tu;#`Nw0tKZUCbd>x!VgXq1~}TZ6*q{ z|N8X^ht?xr)Pm>2zBj~$XHEC^hId8iq{enXdM&+xn_S3D46>kc-YovBb5lbJE}kNL zhua#hWiHC+c#D~K21(mpiF-z=mB$mrFJ@7a^924#fa;Dv8mP?BubBvzCy$eDVr{DV zXW6TgUJ9Q{XB@lIKj!|w0v;3L?0jy}Dh%x?rX9xIZTE0Qnk@39#JAGs5_zYsL>nTl zh_ohBnn-Js{4`1igS?@{hzRl(nX?#wCYVou`Ew2@G3Rfy@RX~Q=@NnSp9?VaJ`@<0 z(0}?KwY5t)^#zQPs3=Co38=Ub`KTCFh-&W>?%l_#HnE#)#UalknPC(nx{T8~t zgRCwgWqKS;4~|fSb|Lh~tlW7nRw03=i6b^?P-%bw5kuMPJ`K1<5U2!iF=w7Lu4j6M zZcat#Xx@=oeZ?=6w}1G9A-Q_WeuxKMdXL}1U%SdsR*1T7OfiJl&_gF7 zMyX7vr(;Xo#arIOyKoDuv7QlvAUS?S|Nb|!#81Ald_m_x^o)SYpGG6hG2lN7>;&VI z>^yTkBW}(Cs#E@Y&dG!jNZUnNc3L~A({ksj1&fivgAmx&1-zBZILliIVKaFACgIVe z)cs?|Gn^+RN=Zun>0IZ2Y10gN&b42P%}m+{=RnFCx8&*a+(Ddb^aLrFT3(GJj#(P9 zSe8g#I~9lMILO{?mA!1qZy;5T$=F z!M@=+Ec-6T7>+;b0ID>(LxS2<$08wOl|TB(=@wXx#Wn z8mZGdjf{y=#Z2xqYZVi9j~HyAW&~*@D5K!yfH-zQN$|2}ldKxk~NgExe_T^iWFK-!Am9Mu$U!<6Qbp<)L918TcdHejzj5apuddX2 z%Bu_|<#c^x~v&5;m(o|IRBncyX!0_l`RJ z{qNuBa2SrQYwpRQw5_|uTU^KAyoHe#S|#bZCqY(^E=xpUaMrHjE?j^&f1km@Bc$Wv zEN^13Uri4#Ivxzc@_d@tU!!*OW5`M)3l6DqA4%k`&;=&wVL!f$5CXTc#L`Qj#^1V~ z+6*R*Eu=@vO9YTT?`=MZx425{&94x4TL=NS7Ha%QcdqcWn+w!!i8cb2<@3k7j0pi! zGK?dtQnKROe0I6Q1y{1ut>8#OIC_D}6#i$agqmgXXRoaCD>qhYX7_(=#iJL7Jm?J5 zGk&D?|C_$ePhDtmrE2qs+Xrm-h6S{5E!6m>D=WOdSOK9~_23)3Exv!$2GV-Q8;vSI zztLpNx7qE=bdM!fYA9s`XN3w2mK311BHVvObi9+Uz{(meo3y;jSy;hY*u-AmqI&Up zf~+g2HtD%Go#)I2cuO19F29KM8t7r4xV48GcF6nNjoIGCYL@Lj4Bes*hxpIc8`uix*4ymd096~$?{ zuY7)Eie5->o($;5nffs*Yr8gmRC8XFtnzieS>a%FZzf22TiHUT$!$VxyEo+HkGr^6F}nH;>Yjz>b9H znpGApK{twd%_utv$|9Z5?FpJ-h zvehG-A=YV$R*@1GB5P$=gs-Nr3FxT49z&%Fwc)MfF8jlnOOD{hg&H--rX43NcrLH4E>LxBcET8`Bx}CQtE&tAix2iV z2_u@0%}Wb)d?9&%81v?FkEDKj4NO1TB74(WcJta$8ClSHn zQ|iG0;n%RdDni;+9K(|5Vp)QnFyR}AZT5%ZM2KuGV*_MODD!00GNS{^Am!-&o*_msj}x#|M1(-~^@N{>gwJv<6&Rt8=^Q zvsLqHxBFabRC#5!ffj-fvNFd(wJ zekSS)PPvRo`xpPzr8PdX+MsU)U*7HTZmY}V-jLsY+MWNgT_jcIh_W(&os?&I~spCHBHPR;3O)A?df!ahUb=&)-xr%PF7meT(GR zJV{gv7mxj^oDLo>Jar*Q2T`Z2o07*v#SYxM{MoMaAshnc{XHQ&dRQbZz5_L=P zRzKvMdnd)5-#=*cerL$XmMVOHbAjJ_P~&@tZGuGc)^V3(b%E8Y!=1GzUpr{=>PnNU zG`uqidHbk?6oQ9?h>t8+`RHncuk9Z3!eWgD&t`iNarb0U5c)F`K&dv~vT;@~;jUeV z@_|m1`|Cg)5})ic*uIBOvf|P5R}QwaN)YGeqYU_FN?XjoUs%~I=2J5?m1b#&4r9{# ze^CSg#Mro{`k>sXe({s|J z;<4sd)#3A-%iKNb5h~5Y?tlk_n5|}&7nbVWS#0oP-ABcmw+_2(_lMY)WC#Rj(;6>KyQ`yuyp@n0hR;;mt)gD^M4(!-DO(Kbo$N()n^ZDadY;%=5D6ythZ2#H;7q^FzE zX<|t!B4HS2uGWHMv*x*cP&yk4BU`i}+3a^^KpBBC5;r?;CX!r|%#dUmQCx0R`NGx; z4NIW3;YQu%Z@+Sx;)N9m9Ai+4qG4J5^raQP^z@jwkJ{`HBi?TH_~eBJu2&p>eruI0 zzD+AseCMD`FG{d2$#)Lg{Nm*muGTz$`r-=Lt1dyLc=Nc&?nH~2v4Y?8u$_?-;+Vmc zcj!I%KC`$rMUKi|X9^C`e#fVB@fIo$iTfv|V`39#m{G+2Hadxj zhaD`PApIJRt9KB#iwXxAT_(7+Ib-$mi^xhnJ()DmIikD}4Ua8t=DzbmN%sA9gt&#$0kOetx6LqHFWL zcAqy-x@coawBhZeE)TmyZr2@tZgY_Z3r?bh?;Up1qvUh0GgUyU1~SV$iAci!+&ibO zMGj}TrXutIb^!GlXzxO`w+GRv# zk?OVA@Gm}(j>5FgAa~OcVB1)JJ#Ea?KPEhSictw217ADr@RM6hZ2A_zd~K00tk>wr z3RUbOlY2rUEy*7qboej7b)W4)$Xo3`*Xu5K7JdHHk6x!8#}qAi3OiMhNXf4~IOMC_ zNBp^~Ypi;bH;()KXWx6wTgRQ!sh#XO3@r>(9kfzx_X6HI>CR0ajjDoABLSlj zNrY5+TkPpV#+>Y@UO{UNLS@gYh+K0<-6MMMe}{%;VJ}=j*bdmWk;o{*3t1sZ*he@n zS}O)S53v0P^{aQ%mp?f^mQ%rGp{BAf*D6GJ4;egqo58^&zz}Qr^7bJYY99aUl~pz> zHjBQ)vhR$!Y{f}sDR{r{;aZaYAmY~^?6cszyu9R7cP$oNS%~^VbqGN$1Q+WbAG^51 ztapDqn3okQY67RRo`qqwVz~bhDP{U< z*OT3;fhN!S=S7SHP~m{Vqc=&8pJvCWhpFwLVtP;CBkrBxEnP@ksY$DlI0bO>db~Iw z>Kq`1Kt+Ap_rF7U^b~(_4e3>~piGGc$&yn+pRj#MaI%k%!-97A1|k3O2aoyMUYnZ> zHI_XG&yt1okOc@*o8+C7KAkurF`BRJ9I@RSa%ZK7Q zZ;wHo@TG?b^b=L${_*5nH&*~{Y(fQ;37XQ`T4%ZhNw1SY|f4omWR#_Ht4uel(bX+u*#4u#mv?=MjY0EcHiiqOz zOxNY5;9~+JCzk2pN(q)3F-{kLq+^eVGDZy_OfhGE?|5cA{a-TUByi5n*`|6} zB0o67{40-ys zRmF!}SLW>CINof>+EFE4|$DVPH^7i3!~?LoS-&&mh;2x<^OA!IQ>umAKlge zfBh(~AD;veC4G|zRi5F@`-h$LU*e{?FKNTHeui|@y@ig_EK5uz^pA!B-cDz99o z)QL%E9r!2)9uE-Boa2Xu@`uIL8PEOD?+g+XfGRq5mVq{7F=MXFhdWDEj5!hh89xut zXe|@9D5G(TIyo^Ge3`knIRrLU+%oI_e67zEt0}Tvi=;#l#>T_}e7&||}NFtoNhy4E;nY44Q|5@NP{m=*m#d^*k3pvla z?6Me}yTnuC$+G}9iZ4@?a!tL#<2=_4!kHPlD`_1udzezqAVrSjwB3|X9RJXoa>lv* zvoyoABJ;!8Ruk#qIkjyDCW^^tQf9H4XYk+0fjqgoXNz%*IXD`(NycjiXRc{#4J9>D zsuh?Tf9|4Y6Xht`#*}I_Gw+{`gM1%nNwRtHpAEKo1YZ)zlM|lRU1aQF=gb1p)1Nc5 z?1_Ag3F$TU`q&bn&XRazh$%*yOcgScI>pZuC1Eo%jO9Jg%UhhLTFP$q*myH|_q=w( z?6-%PS30?PF@EovlNn~jpR;+&FTk5%;fX4{(mm5_n}qY|tlk8n~Gt=9ea*<{*>$p`v%kLWh zIlq4l#AisbQvp3D0Y*;bB;#?`JX2gbA8h9ucLJs}^DCzr7lkWPs_Ectv=$SOiP4FH z=Iv~Xlh5nIKnLkfy&r`%c eNgZc^`~Lw!ZApwR5akyD0000tV&+-Ms~hySqCrE_X!0!WjM^f=*|~Ao)*-V6G%5 z4fXjyt*ECm^*;%shk}X>;wc;~5-ODI1}@QmB03KlT@On$4=X_nH>>{`6el|;FDpAY zD?67qJHOz6os*B5on4Tf-6fX|>Hh&ZI$PRV`~Lp`-v2QF3#c`p{~x6c|5t;Douiez zhnb_x|A*mV7vvH6U(B|P!GB9%np2RL(DpGte@D!+TJt22Zm1z)K3VP{AqzzyPKH7X zqm&O3h@nn*R3P6{Q*9ax3VK-b5uG~7{#C#_age-o;-IJ6Tmi=STnk079_R*1@5jK@MO)Z3lCBGSCR z2`cwqOuw3U$-dcOszgsGbxcV55_f#`loM?+zdO#bMuuFW-27=6tnk^Ai@ypd=|KZhx+Bi#F$yVu}r{iLg zw~}KtRdefN#DTK>FEfI)(n1fc=nH0Ylm6nOD&HtXwS5oJccbe2q~hBmRyl{#63%Ln z!rX> z(CY9?Rdav*X~Jfq9Q@iMRcWPOV7)O6&yGeA^8oAmV!b{%u1_S#b>EWe$C$tR)d1Z* zW{Vt;^cmk_CPl~r0(E`k49}_*m*>actM?b4L$T~R!adPX|0@Q6zPamOMR=!6s@{+! zA$Hyl^b$CH#7%uJmN2NG(U%T6 z#F_z;HGfWAQp z&weBq;|Wxi#OPdfUTF?VXh05#NLoW>z5y7qFqw(-dqokhol^>PD{KTi{)5sZ7uA zx_Q0LoHEHbpG5acOC77UAisRUIDIVh+kXoUcX{DAE)aIZ)DVzXM;N!&As@VZY&TTH z$1!w_f`}J=%k&Tm&55xtGYp~OtgM2=bxcvlPnf2XZ7?G^NplOBHdx{oOgjvS#E@fk z$WrB=ZviPd%2+JM(NrZ~ZxMbI)0Z=#DbggLL>+@z;~0|8%@Xxj`pTN?4<0R`O^#ud zQ{iH4{*{4Im7NbYpsXvM#U77z?BUVl20fF>8vv;}d90s1V=ePnb9Y(xgog+4kT9QgjF$(`k}Kp{`SM*L>P1x*XIoiK=*KN`V$GyFRdB zafJ3et~&A#(8s`4pAzTCBf=VZ4q^l77=J6FR0-!z{*__4R{{&Vm*9Lk@C@_gLvSc@ z^?(>lde^Uk&YrnBT$Js`^S;Qos(oKchAV97WIiwDzTWy*2c=XQ?07r9;_)=k|sSh<4CE| zx(H)K`sJ597myNx)No3+1>6bQz8(PFPr?FNhQu*xb|kq?1V79S7V*5+FJh=lH&G3D?r4Xm|rmzo0Meiwoc0-Mg9@%=T9! zF>8G~sH2x4=f$<}oR(_%S*EDj9Fpv9`bh-98Mj`SK>-Kyd7SUT8n_NQ745Aqt|1n` zU4><3o?NOG&roEBqw|TS88}AO#3PC2(Ok)++VgbO$xkb#dwDe+=;$#jW9JU0u9j9P zJW$+A=;cvEuxK0(sxucHu`2D@t>KJ%a`3pS2u<)b1kCtVPuje@XGjZ!S4(Ama2)x| zIPO5m%%LwZKah%#?k2$3n}WiEI!bEn1CIvkGz4TgSOsPB9jj$JgVmse+|WnoYZv>;N4CnTS)nw>+~R zWaGcs(_C1>$Bqu8H~>5~YzuXeXj=#O9EP05Di|}AsTy?jJ}-5WijPxa_Cs^S4YWcD zS}_tNNRLM*q6KScDsIL$VEBIi=e_^ikA71m`9*d*ziUM`(Qh!Q1XosYLGFNDUpmW{et`PMD;q z@+HXa4D=0OdsVqu#dl8zQZL3eHdKLTlSJvNKiH)yLBUH3`a_v`>GIN2Gadj*CfrL) zY+(J@b|N$t1|B@ZqaRZ)mI*tJxMJHfPG9*zt2~e%UZk^~qIA5}_!*W-7&yFt!ZJGeo>fV5nSlwe`Fi{<7?Pj_HvyKYo_9lxQ79|9>k`rG7nD50!9 z1~AvQ(jm$gqqs`k5|@yilr?#ZnE@18Vr$KS7Lh(d7~T#iw}mXph>$SnY3$`8x1?+> z-00Iz)LLQ(;7ZWxw1p9VD6`GXOjHF28tE@YaEtQv7jY7)j$NL7e}Oa8x+waG$xn>S z!8A(PH+i+lt(=&S`;gDxLq=MDgaI~MH)9rXdlFVdlo;Cz0cNWeXLHhVheQ zMJwI=#{B2m_}LZ`dcVYxl8cmUt1qXK8QoG1_Rn)m*@Wj*%D@2|vO(6@kvo}-;~UBu zQo^5D04pIY*Q=q0k7eVWgYVmjWI0nv0(op@HxMIo>AWU0EGY2py=%v-TeMFWQpaF= ze6r0hIQJUo0^AxzJmGqljmSSWlUi7ONl&aB%0EK_pSgw(4Xf3%)ZeEvV3rT!auyms zV>?W&cs3QblJht!+T=nV05ebKez>@8Y4s>E&IT%|#eKPeJfq z6RQ@PDt}I8cqkspJx(#^3`edCJ5Vt9?0lvf-GG>$E)IDP6wCKtQ2N^8?dfTOfKjb% z3>7{aTN-~$js7$5D#8&FxTN}w?upbqU5p5n|9SI=Gs=#mong!#i%y#{ZI7uGK0t3P z^hWR9+c+E{ltog8$d^osj%uB8;5thc!C|3S6cam#>p(>@uYyd{DO{YQUDZ@W4oZ%nM|eqh2R50WV;RvYFF zrr4k~x#=r5TD{s1kf6DZ1DG^6*kbC&t&9PQF9oS9qC#`PaY8Wzh&$l*j}}>a%Vnq4 zZV!u-e{cjjfCdo+H3Wbg*M!g4+Tp5)f-NQSV|;TH^YAUM6*_}$;#0=Nu}&4KDWE98 z34?z6REV$#`IoTKkUfoL9ltWP?LLB!Bq*8ScOH zl9z`S$MI{jGF%*uJXLcGd)CFTTU;E4I3AT+vYnwoVJLKLxiIl>DTkn$%``3XRl~DQ`W|Qllo?eu zwJ|FYg@Fn*$_c<31tcTG6bWhag}8CKSP$7TtLIMXn{}9CQql?1Ln6hPD(oq7MCpSC z?!wjQZ1Ut#BsI{>`%lap0lDGl{gbMli1EudDHR#ECMI)RD8Zv4F z&yeFCse>dd!znWYHZy42%~@#s;S66G#Jp_{iE~FfEXoTCwC})n0th)UR17v|_g|h) zN=XCqP#LXo{ct?=YtcDb8qX+*kB<7jprIzl3mYi7-k-F%-VwJ7@~%W^WH5riV-)dC z)IZi>n2G)NKpQ$+lcV=EE7R2j-4e^7!Eh9JI{`TVTJT(D4RXVoPjX*DFC-j;iMHbW zNYpj*y_y-`@*tIG+Nya$LfsgLRO2U8F{tJiZ7DNp@-_Fef=KVY=XZdBU^rU3ShhBF z1}R1!3crALev?9cmlu$rjIVawHA3`RW9ND|X&?x}&h-F!@nA5Axj9e#K*wGl^?t0oFiIQ(Wago56cC9jyaRzRa3IEYs%rKOLVV?k>&bfB~~>GCEV5G)-XCC+zB z>g@`k<0S>ID#cUof=HJ{J$U(ihTg&$_(`C+9ejt@kFS|&Ff^8V4zSzcpXjL>W1{U=TQooQnW?Cu$vzgL zgTuKNgwEE~=!j^d-w54o(y`_@bY+Pb$R#XPy1ECUr~9AtkYGt};C-SO^=-?Pe%Y|u zO7D&H=MhF4UC)e@mlR{EszWKpEiTHdD7SmFul!sQmeSD4AVz(QoFRkCC6|cho+4R* z{*)Cf#D`3;#b~gb!bz8~D*p&gJzVPOj`j)U_k)cb6n^7g5x?iGCI-l8hd|?eZLRO4 zoYUQEf%nNHmsZk~O@_*8@Y_VnJO=OEAQ}LplKx`7I)KbPVM;o2a&W-59Yk>`w&L>? zhJLgb1eAG{{f*xmNy#Iq&*zS@6uF2%X0QZaZh6LzTe!pWd)K51KU-VpCIU-e^7vpUU#mL|3}GHa;M8ow@KIq&#mdrZz9?~bl2{rMDudF0pi{GbNo45Q2=qNX^`X*xg+657 zr~H;^if6t%&oW~2r|U*PdmPxidFMH#s5Z?`>A^=nSSzWb)TIz>WJNVPLkVAQPVTty z7|eyDNE$DYuyc9s*e;*0)`qYzx5^%&g@-E;9kJyYZ)SMG1=bR+wYUxLX)9yk zM@&n>)VkQ&gk~sxlHJns*@;vKa7Jcq&Xrx10sltRx8i&hk$^20!PNy0x{6kn_wTzz z$S6;}s2JzfV#uJ+7n?*^_En8Yyqxb)Cnpr#5O~!pMno%sa;ywmC79#J|z+S{$Ief0Cdh$KTvi6|e9J z+6%68f019bt%*l~Q9}b@RuEInU5yNL_d`hMoV#3z+QQW=n&Ux`#Id&UMg7r`wwMsF zrZC=HD=1g;;oKl`U46tGhDHmF5v)xNQA2j>)M!-nJ;_ZrmcqzAumyQ2%RC&HGZfol zcM(S)tdOn=tOfTVyM7~}T2mIi6a)LfeF0_2ImN;QUmwaq7nW^KUKqQK;p?!Euu%-c z5Vbf~IVX}W&qUc+wWJ;35-BU1K}@c2@Px;<0$wv0axNapJFyiu)txPDkZ5M(WSjyO zl$JvLB95^1d+upo-XGx5$r{Y=;&&lgugtLe&}*xfml0;>zgUXSH{KMaqWyFY;LRZ> z57(xfH&j8nm+Bv;B&3BS#U{>$1Bt?iGkvo$MNb|}(KE+&+lWCv_pH#r0y+oqISUh!P5H{Zhz5K>2; zTk`icc9l%*qCP<^b;wN`Vq7OOO0YxoLe}O-s_V0m&=3g&(JhzM!`~#p<{&QIc{D)qVi((tB5qPzZl-Vvf@K?%W&Z^)q5UJYvF|JHtojXM zg=~jWj%&hXfmDTT78=@}Lri`jnKRcg#3+kw+LIHW)5Z6@F>60}lbz6#1(Aas`YxhT z&Jjldz&RD>zpE5l?MOfCT7&BG)5Z8d8=k-hig6uaFTFK$Wx>|=}O52V=Po}YQ=tlq=a0qK$%%>SRP-F z$lOtJGp3zsv`Vv==s)=i653HIk3`;B=riQu3-c}wm&4lvK>>@gYY{7A2;`6c}DH(FZ&Uyr(1rh4S z0-o7gX7`&@Lf3}FIOHb>KU+DB;<llBF%@91Q|EFFX9)|`zr|H!68m>p-Wmq11`S>t>8(_;^;j@I$?q@ z5}<+7h@nhQUOb1JG_X`gcHpVX?dU9bOqblIrz2rl8?*#Bb7zZ~em?chtMOo~`SQCq z?=;t8EJg#gQa8);+Q`7!?XUz>g&=ZoA<__41e?kh>~jZyxxH3f)ztb|u+A{kLUg<( z(n*v771-n9kocEY5Oqm*<%yRN+}@32ZlBT>X99RvQU|HFr(PISpQ4w&60I0U-#ay= zUe7=PK5ydt?dRfNM%;nZ?T-kKG}qO!Zm+Dch6NzcI5#&0mA4Ah#-GR#xz?je+S?OVhQ|327Y$h7 z+w0}gBk3p6ke|?O2Mb9|i5|;nk$;pR)Zxwe^i0yVYRW)18D`VGIH?g31=9A27s{Sz zI(U>%%Ev&+^zO`hk%PYkp+)7^J4hY5q@U4SgpDm^PB${PsH#${q7j}r!KH5v(wd8~ zrHfo0YMi*Qkn=Axnt*s2@9kH6a?bPXm`cm8VrrNFo3*XJt2!d9Xsq@pP z=d;S9DrtH60Wk$Z@r_P(@^;rbX_+?Vc?QsdBFjcHDU%VUFIk49h9CT|$chQD5qrxu z>11tEWsSCyuOAD@`@Su(b}I0?Pn}N-RhVzZ83FqMa2eGBk|0U*Pthp$a-Ot`Y-7{Y zcER6NX!Xvtle4C&ZXH?jvnuyUztuHHw?br9hq(19QzM1YYH?tkWQ6mn$nO|p>;2!p6Gj0N8FPIMEBmt2TFhIAQT{Bj?Nm7c(77~WbY@#Lk{ZInYfR6fKe zqgUabU+;p4w;K3&#eMucA`SvcvpWB?Xk+a6C~NLnudtIJTZgd$cab9*a40iWn@I*# zcqajoV#a#)SYSM@-lZR|wwsXjj^-OeC(>MS5!nQ<8?*FHVrnPtuoekIu7bO-Cma}e z5}XqHSI+W$S&Ev53+W~W5iNFV)$a?2O$NM@J@K-2ezkmNO&YL|DV8lj$u0I9i3g`6 zcxY}RO+ZMP3U{2Z=d232k(`ZzuP@sbXPtbyY}O1w25XsQr;lWkr>-zJ61@vcyLou- zo%ZUHA=0{DkYKyW9fI!1*Xzr51=5O#Ib;Z1Zvyl8o6zAC5*$&*m4slsl#QqYs` zJ{3UH`?24)b$A!M#CBTTXnjjVQT5tmTWFyJ{Mjl;S7J;98XT?3L!(!gzC5F+#4S-{ zglOU+!~xQ56zJIG!lBYE>-g+R#qWG12Nz_}-`mPu{umafzWsLB)XhoPuZB8Mlt-A_ zESTs?A>LuT*Tz&vUpAa=n3;IvG6|K)4;8n@Fgm!0tRaTposXxTcV3pJgme=RD%{|$ zl{L8;Db^}!Hl~#`XQwRpN7Mci1fLSAzcbW!X(pu;^wGsO3M1=Tt2HzoJh#HsE~T_` zZjTo!wg*`qq00xC|1eW%5ky_|w-;q>Jl$Z+8nt>=Y-QAI3C zXBlmCinb<|UM~XeJy^D2onU!j9Bv8y`c|=hYnR-s0Xb&LG)ex=r8coSlIhvkVaG;} zorjGJiMva<%-EyxR`k1}w6-!n46D2M&Gx@}w0ht4#fn79`Uqt&&8k;XGz%7T-x;I* z{mQ>jMWA%JxWd&G@WMF3YM4PBx3;3h_pqB?j7%izQF})wOn0v=M*YhM=sf<1FW5kb ztq@~VX`w`gW?qm|LN^tFN4>mNNh!%xD2hQpcaKPdf{Oiw_ zs~iRqd;!idlEK>}HSCojA!ZmgC@FKeu`e`(gim*3in!Q2$YH|NGLi07FRTH9;QMfS z3wC2)^q*hIi?0qj>BbitpF&1sX$UVC?+zD|(;52yoZ%6r<=|I^_VD0e!TU z7*r_n-MvM!?E(Mvs?#~n!s<{X2V0!@3(j<#GFCIJ4o>x^!(^sQQmM1(a5sV=WVtDe zgI}xhR>K3d`-;%;+1IZvyp?wLgo<9@_e-wxCrpy1{tMy@o%emW_LNL&yvc`dD|VGB|7!jsk&%>_S`u?qtfp~)tv3m7 z#LhNohF%;i!F9{0AOM-w?HZh0M)Tv^zCVW)4+dCKxQ7`5M@vp76k1S!yN5^Fp>(i; z$-MvNK#F-u{g&`0r%0xSdY+4RF?2kFgm|pd1IYrs)MU`XT0D^#>AV zH8!~@=m5Jdagm1?O%>WMtvBxfsZBn`y#A!W_!SxFF&(F5Q<%E)t#J6Jiz$z9>A(F_ zbjdx#=zb-80X!-^_7l2|m;|Zl%nGyK*W^pEf>#1?>=eqH0Fn2wG{%Cjaffqa%tpV7 zZE_h0Nw|WkElzl>#_8q&W2Wc zB6E-aN|v?CQjQlG9IL_bXBOixS|9Jj*nH~K=qt-@#f5ti&j}+R+JRMc=>}>!ct0zk zD6F2JiTfxlu-IZ_wb;fPCwMwznmEl%vhcwcYSQAEa0`*^PX}Rw(lC*^Wce9_==D0u zZ|Q4g`kOx&2M4%U_^6k>#i-w-F4GpYE>o+uE=NFrNx+IU#=^I8X$|>bjqHYD`Thb# zhO}}TV7{85Q~jY6Iq0I~XU^3v$keg^MQhR$L&YG*#SiSd`~;o*?_f^tZsSH<8Ql!J z>*1S(FPvmj;v0kxAfHcBEe#STOwISVV2TGW?}Y@f>BPAy*6h$8m}*j+YGYkwNeO0> zJ|Mwr*-(+~*^(_&Nb~=zDq}xXa)wb;WzUn<&AH}_iDj;imL^t%MV}`elZ_K3pa?P6 z*1;+j)Egm$B=b^c$O6BWA+-9yAsLN5>`ftQ@+?MDyI~;>nl%$qIjkq27b%aH)R;O$`KjnkQd*bODjAp=mV)YT>9CK)^z6&AC*c+;3 zpU4%e4)CA>a)p$0Ep?`9{?!ea(BYXYio=)kPn4evO-9L(QUpEvAgkwqzi0#i%gdf*whD~~0S*wf4 zV#m{vRnM4&gfIZNCchd*vuj*NV1&y%W%yURglR`=u%l2OZuQCD`NWOT)k-M8`qSSt0RY|@z<^@2rmwDXeM}y z>XKB%=DgGmMaLA)^7JS%v2SmT1*=VFnhbdTuIPh*q-cki<*&JevpOLFP9a#!Y#(Yg zIxAjoTP$*g=@FKcJiCjH{qCg<>>dNDdE8p5%q6=n5_h35eFBhRwOYOrQ|#P1uf` z#5m+N%Ehfo^i5+8YHj*n2yAPRrisYA4I67Shlrcd;}nS?-d=y@Svh1vmAlX4$K|!T zwb`9jO+byhVj#g8ijD^MYKj^LYG`+L!b(U9hP!r%A}^+uDIm@h{Wb>@Uq2{()JgGM zxGx{F0ZrC+e9xZ0s1~OjUJh6+MfOAcFQvQBDL8s&Ktl#ugyCp-anPznGynWH$7mLH zo?cI*y}1aRhN9gUBV|)g&upOKM5g%{mJk>@T9RV0k}#2>bcc4TW+-hTlKqq1Q4=(X z$$h1NQe;Lj&kaNC#5Pg!QOmv+kF=JZoFh2$7_bUwg_b`V&-7Lf;+p5&8qV=we06^lk+6 zrHp?to~)G{PY+>QD3z5n&!8%ii)(G_rrayBN%^RpCT%po{w(uH6zLH-Sp9XG9}fME zBX*TGTu(5zr$Obx&8ipd^F~yg>m8QOd#6z@>UA`quQ)^mo3Nx&ky?9i{w~_mU>I0k zXoFsbdbXJN_gAqDHw|kk0Sosa0NC>O4FnyGID2>HYjq;!0C!{3Tq5VxDp}+ijmEE@8&jydh9%ZEPAMPN?`FIOgrnt8?&x`d{->IV zJcX8dVyL~B@^4nn?U6{+%QReze1ZL%Al)uMW9K*dkn&n*-BBn1M9ZgnxJyVxoC?dp zbMwml2W|>+zAR}OO-*;gmyjqygdZx~5fKuh&HT1omX4qs_-g-y+qW#+XKbI_;qr->?uPs8NZ}p> z;yjU;9)-CBq#3(BVl}YOOYcB!x?4P$uO-zss|c2l#Moxa{mTfEe0%9Q@th-Hj`0Ku z@D8qgUAOWANXSNX6YuNi|>?fvE>Q;i`Zgyr> z?Q9WB(W{!>f1o1A&qtV;F=vi+6QH1NXzc#W!T@Q;P0ohY?2J-O%?KDwv8~z~I!GsN zzkNe7=qtas>Ot)E#)|}H*CDKla6?Op(u(*eD9tqt2}&*cbVwb)PrA_{m0H`~i8OF5=O-c}#vZdM2F(Iiz_v>J5+ zQ0D^Rr~WnPoR&HVw4u2mjQ(uOHc3ujD@fMwu)Vhj?dhN|6F~d!w$|~3mg z0g8p|X|_Ij>c)<*+6S92+=IyGZP5Np!r}I(B_$YUa43RNZ?ThfBVd_!D2hDOtMcTF zOPdV)hxsxWg;)E*m+b~xoFi$-a(-yJLb@#(KUY%L5IyYya&!(;jp?WCv4r6YlSZ<+TtXwjI*n?u1}YvAKq8}Gn#5v_vEMh zzuo=zn2Pp@f`8lVxvjA&`0jZ^n4INNWb?%hy6~u57hn% z^iRgLs-KzQv^M=XFH_`}VHjE#2j{Vg(_*>Vd(^&srHbSj-Ns>>#8p)>d~HcJdzzjc zo6{I_QGR^Ju0%aN&W}rjM#ON zqS*T;XM!$mbMg^Rwm2@H&a+$p8o1*X5`rMo23uP?HHSd=myZ@!SMpKKLTpnE4TdF) z*($%Buc09Ie&hC6xeMiwAL~yl3f3={pEP1XPFJV}9=-Q%it)kQ9m=o$BrrW8ogNl_ zdBf+8b8yb~qHoo;UphN+njS;d6hC9U&)_@$d{-Mka()N$?j1uI*H&c(#GBD?y+qsg zIUN72V;XI6Z%)|5r_ChGl!IJzC`Nw$tKTX9JwE_-*=NWE&AarB^8vJm~KU@+qX!_R*G&#lhQLJC|?G6Fe@}n9ibOxb7u?K zO0~l};-jjkxhFB&7Z#;{YlxeH!mc;q11UyNQRUV&{{~gOfmp*266dK{-z0;%*ab6& zi`e469FH#x==t#mku14XP>RitHwEip%s~f|5CWAh1xT|9=i$ zEv)<0OD7siA5`(OlV!+cFk63sw1G9^ZG7#-CP~-E;O}lE3G!T}>q+e@k5yx&YUu1O zYct~P=r|F9lg(qLpLazWVr=vLDFo5KVOfdT*eH#Mn zcm1O_9OmKp#k|Ms>S^V2@NVDIemKPU*|Rh)qg$j7kA_`yHbY$`&t(Y!yrfh@_ohTe zx*ruVT{yd2y(~jmgl53jW{0nG9}rj6N@B)?r#KpBo}b)S)RD%dtV1FoU?`_|*8_rX zQnV)yy2-Po7j=WC#lo`QkT%PIhfjrBt=EW}yDaNh;MY}Pm>=1O%5RX>8E(%P6Qp4s zpP%hqo}bQKYZcbt+;8=IyA{$#0;tHRP6l)8j08)kg0$#Bf! z#Lk<(h}qHT5>5g48^%J*%c8QENV*L!hQFO2BU#%O=K)_ZyWRoE3b+Fj*&7G_Xm#$6 zDdz}O3eI7!93YLhpLY}F2=RGoZ~ItonEe>cy8!=I8Lj<wOsLD{d7_{saBI-V;r4BIzAo>!QSBEpw6~z zOGBY!%v8*(!c75FEUo=pc4t0*HkZjn`>z~a?-lFqujT-b_r)OUcoz^7iVW$3I4R7M z5=>wyIpQK14}AQN)N2%C9i)312P!gB2G zw7}YIZ^O1JnZNLtbd?$ZNz#&g7^nEWqq*tNZyR4;kEX@>+tgZ+dSW6%|G9^{leY7@ z>YmbQ8oaW^+~g;NQrU+dZm`x>?IrRAyV(4xT1`_J|E-sGjpDQ0G7>=Ne+Aq)CrefA zpncCS`_p10`Jk3aGv?I6-9=g1IIYkC>JUv&`Zw5wHz!6{epLGcTAKD34gdZ8lU6-4 z-d$~CWvnk`qs?-1M3TGOnWxF8TPME05Fl(oMa`v?NCy1|y@hd;csSv;t%z`23Y|KQ z-}OrqNu_1cVB(I89){dhNoIGyo3}3AzFStcxuyOPR>e+FA?|OE(KA`j>Vl5Tn|`Ej zuW>e>;t#3VfIQ%`)Kobq4-*q2cp!z>6YQj%MFiPqnK~gCqzism`}|3|*@swtoy*SD z)cFh8_t9mN`8n73WTbaEoZFxAv>BTs_9+5UwXwSVFH|zJKur2u!4miw+vEzdf{6Jw zE_2dQuhjf;cW8Ty5PQ(*xO~X9^6-Iz;kEMhl^dtBZs< zR$;LelU=ro1NiZcgwii`;f8oO#Fy&DFyw5lo^HZu`Q5KfmuS?@acen+hlhRuG@$EP z#>V^{<;X+FYJt(Gx{@^G->vLO3^a&mLe{Rhk$PEx&6j1%OYeRsD~ZA{z~_;^2QE$# z0jhL+8Dl|zX!lcVg#oHRbT#Q;5z3rh>SwG$lw7=4q=M4lAPusI*KoBOou_)JI@G%)GpWn(@g;p7mdZle&R zecbkwF7UnP>FA8!5ZOHUfU#9eQ0T|=<57&!D+%7ijgzpDy1#hh@F~OxEtNh=-u%YA0scjQ)_Fqvty3cb7 zlY+0@gEdu2x9Lp+_u-#AKF@Z>cV1U^G6>WPGat9T6<=QctgHjHG0g4WkH&sVvsnbA zF+!AeG}nV03!e+7{dc77BZ|nwUVh$p$7eT7M2g^U<%dq3wQ&bJT7k+=y}O0aP6w9C zS-DN(0&7ChuWSPxvDA3uRQA#qV16k#7>f2B|CNL?r2}<4&evT98Pdlu)nMek)E#EH zja#2JiS#E1&oDM{g>90r!Y9MsSZ#Z}iSzX_a|bOsx`9L0CRq!gx!leY-}?^1m>)9P zzTl{`&-ETnA?AM7ht(${Fzrr}o`(&dZ5lwhCo?4<+LlmF_!9bN7k)cH{UdBiBiGVjSmG9~F zm*gL^hcm@BRRjW{di zKiYifNS;SX=KX!5x}d&;pSGm%NlCmlcGW3HyS{Gy(~e&v>>3xX?I?EOxUvs+i$P{f zvg%D2fL1fR*E{s+9Dz{onDmz1La4{ZT^pi$uO70K`IFT+svXT=r|`eRb22;OIhRc$ zQsn*Tim_hE`wLw}wU8)fX(f3E%R`YNQBp)c|jV+{DIJ1S3!`A ziAjRKYmttorebeHE5FzK1@VZ}mHKbVzNm-hz0@Z*1c$YAc^Q|&w@j866{DK|kLyW) zf?u%({GV&O-!|bp>@J;HYTa)}oJDK<=$w!7Ew7&&6$IL9%yYv$QEa*wcoQN+bp1%t z13{h1Wc*-8N35yb?9;-px}bC!OGX5JxSo1J<-t4-RVTcLJ_+eqWeJUL7x4>tbz zOVEd&&wI4+dYRHu{^hxzO$7LLenqE2ft<^w`}6s}VMS86PgAn>JKq?joR`veivR5i zFd>?UW}+%>J@0h9j8}m6-u%&Oo9g6bX*D|C<+~x}15w22jXt2T@f@UG17q!x#VHfJTYKp{)&NwbTyMQ4y|vWWkPW% zg~$YpIJ&U=oJmHQ5qfQ>P@$8BT#z9awbpbOXt*-4Cp=Bw_{uuaJ% zk!k5D1SfPcbdD+CoPSAN z90R^Y>I~Fn8knT>5I*j|l@J3d_!-j9+%UM|?Uis#WeZ*FVYnGX%Z0jF z7uVVLf1D|*n|fPp>K5&*nR|0X_&1o6%Vu5)8~ZQb*IJ-s%t|~8i_ncab=6z!RtYtC z{OmX;Xu7v8tO<~4(b}lpp}3xAAM_Uq0!79xgJ&AuRxS7%6+#DBCB@9s5|Xjzz7=+` z;DF9%9u|T)% zl}#Lp(m?_I0$4p_>@TB%R+sC*jlem?qv6WWZqnCjlQf8Tu)b!OX-l-@=h)T7m7MG=Q^8IXpswZPqAi0XV*SQ*(F=UmX&)Bfl@2_ObJNtd+uiSVo_l z-1me_k#6vqn+x=5)BdfK$!R@_7k8!>c-nKbYs9?^d>(x?PKs@Q6DL9wPEVHRd+6&9 zm$I>dk&Uj-%t2w@;e+HmCYnc}R092<*FC`=l4>c5dtTInE3C_^xJ7t*YSt zoAyg!Wd;$5#Plq7OkTtbCGpSoD+XfjCW7iO!p56RB+@rwfz>Q;gl)xYf?t$y>9eyZ z1b%X0@xr3!OQ;0z$~Sb=U(Y9&nsx45AxmuJttb6 zZns@@$Wq&f@YJh)UKkcwhB-I&sChH%($BxY=yiIjr1S))FypW2POYEZ41T%v+vi+8 z8qsUkWElW|=j>`>@~1~UYAt=W^fTA}*ZrLI;bjnY{qSgcco@p~yg}is{c(sHsP5O? z<95B7_cjxJN8+}(S%SeW5?-1z`2c1LL5NfKKtPXT9=%r1KT9#uPs98hs2y|^{2n~6 z7W_}j?QU1J!{?~;?XB@m=y_I&piC$`ZF3Nw(O3*K2%b%f_d)l5WxhH}TU=m%r>B+f(7m4RB zvDYz6J5QC~=f4Dpg>PeQlc|k{!{aGw4Sz}wXN55~!7Dajmg>lzu~fDYUG*(yZro*P zw|zXlFe;nBqFfvq$41}=I!hh}nhY*4ChQ3cIM&#>7y=Uh04>HYGtTcM-cNgGGjpQ+ zn=-fv!aaMSMvfebnr*7JlbFz^iB3-6>NaP_r^#=*;;_x!X(G^V;RGGaS)QNIUaVky z(cSDF)IWwRDN-n&uly}P|JmKTV?uaq*^fP+M^o9x#Xi)6X?FskBO_-Q<2Txz12hW> zDz0m^#}VX`I6UV$O{-@sGjC$oJbnAT&E+4|Fs4YYKb72-QpSu8*;fUO@bahIlWTa$LOR138n<>;PNTcOJRlq1# z=dNODoRs0|y|Rh3ZjV7kd5WMp8N+=Ra>O8z!@il{J>tWI+)E#v9+SII9FzmQCM1(i z%EHQ;JoUy7{TfEuBrjVP2b+qU2^T>y>A{ zq{4Sicc+jK_UJI!^5PXKFI|>;CD+i+c<(eH{!e4DNDU0>_muc;q;jdECF6Uj)2XB+ zF>>%L4Scy?8LNsamg}{0L02t4g1)HQA*|X{-&n>i@Q&-tRHun5Is@xhd*2h>P)CPY26cKE*I5@%aWwLb(v|K}gRWrZu(q%SjxNdc z^+?aa0QT-CKMsa<3>bm|sk$~V#f2;SNtfdKqJG>>2PV)NWel_&v37b>VG8@E0s{^2 z#X&A_U`CPAZwtAOmS|=0q-iSaEvO&`@(In?>It2KQX<$lG9`m!V={a06*ZK;kx9Ap zC;pmLhj!`I#w*F}bVbYNSmeQ=YUmn+`NGdTop^!j$cKMSmyPphU)1E$T?b_H_J{PtVhJvPOGQ~-UX*oo zu!_AXrN+y>rRHTaG{$TEe%a{d%@@4+U->Xorh3hHef823(mCt$Y)?jCmBC2nrxExE zKW1@$`97KH(R-PbSiW-h#nfkL;zu1JOxbl10C+0~7t~zR7O?NtH8^h`!Hip(Jp-d& zRyu+@_f77X@ly}U;K}z&YUZe5jgEahXL)0^%t?nbl?wqa6o$ZpOF6Po3IkfNUgv(fVj7cSeRmkX9rVBjqy8$lh+}F zzR#2Toofzldt#gUGzVmuX7FGInsKX**_TVw?k;oX*Hu4_R{wnY zT2DCWY>MR~_LZEm)j-f$1v5CN&`?Y2tWE>UYq~bhHi;Ln)cDEo%BVc({5Ugrjn)*G z#uFo*i4l}({Pik~cj>xb?dHmWd+V-h0ee% z^f{S0dbbWz3Ni<099$Tnx8n&!p&Z87jIYDK+yJsEFI+1b$jd{!1cyk5oyuFTM<~R?6v{DfyMbfm0ul^pX3df_kdfz=y2_w*=JAEVan2xc3=R+tB^%UE19 zH0u{Th04Nz!{pUY52OVKyluo3rUI@&kvtjYlQJ=`lqzqX215q=wNSnc2g*UZ%iEE- zu=BSBkIUfA59pg6tLQW}PgFOSd-<-6tmX3H)7cgVjOBI-%k7Oh82`c?W^pV5d6iZ5HIDieu^iV6WL&xiI#Pz2 z)ktcJ$ar&73dFHYOfsfTIuhGJ=rK^s49%a}@ku6LwBm=iyJ2J|#pGSbQa?F3B?BiO zl+3}qq~0^4bE8!gG~6fSj698gIKT)wFfquzxh9|x)U#48=}*usUzfdd&N}1JL>b*M zVj44z+O!10A)|Gj@9SHAZpNe_<6?)U$gRQtx!;atZz zom?g3u!$Lns&F6Y>a&i$ad~}BO1X6yW)+T6G90KA|-Y0+T!4fe^&v0=G&WKa(DW@WLEmp{6` zEZ>}6lw75%WiSOa(0nZdJuF)F4<9~#E4&eMPzO;128Z=D!eyvU*@VnjsM7+NZlO(~ ze&aKU$UwBFY1UEM%_@StD!&6g;%Jn|jwdoWy-yBf=1tEWHO53bB-CM4Wh`f}ef>Yl z+_PVm%f%Tf~03rn65%5}3ug^h;)Y^y)BY zsCu<7rL|?fRgtrhmNCIQrKUmGucCP?q{^gf8pDv$!PKo$AgmR?#u3l%fPYsu<|9rkIzqeSBfBEtSxv+{oyHVGidC>AVhbz15XU@MJMEK}+?2PYF7V5|u zaP-s?enuYRjcG`5Y$wku+RzbzZB+*N8zqF;N|qK$4~^v@$UiW(SB`z;k4t9vEtn)| z5L{Lw4I}*e^1Ku=d*)vLhRl8M5A{_yGmri;+02fDz%2JvHV*tFOfHzcc?EZ6S?bu| z)-WrlvVGDsFf83LT<-6vj_Jtk)V!LV!^|=(T`&gz09Z%!CDOgpGddwjc4(y|J#(OA zekM8GBkCxX47KMIB}O2PK=>l-!x8WckeZq>BCb zWKUW?vVXVy=)ozuYq&>-P$j$=X3B$nuwp7;>oL2sDg~@=9^a_TKX~c9JiicLHP`xM z#vWSr4aW0KsNW=LxD|HX=N%hK;b#xAuhgN!#@7ja1$=);N_g0DL3W5{`7uuaaCQyi zz$Wh*nUbR){b}jha|`+pXpqd##@T3f@w((W8=)iA*5)OL(VsUc3?4kC_dL5grAX5$ z;WVx;6)ctWOH2Cb9Un~S8JU2a)IBr}fO|T_undi^5CQ#&vZ$KMvqxn`;_Zf*1MX^V*stno@BS27#op`xsqJX7a-i^ z8$k0kagn|mBU6ypdYi%TTib=S#h@=IQl4` zd8YQr2YyHfj=l?>mucLaan)OqmBl%|lZqWye?F-cBzNg$3X#DBxA4^(N=unsh6Z&5 z!zigiETM1Juw2ea-`J#H*=9#4q3P5CgBlorcH!@t)m#oF=;Y-MeIzeAIIj8gs`MuI zkliVMHITe)7-z}`8l#<%j-YHF!>}7@_<#fNpDrvfVywWjon^tGqR=v-`@kSoXT&p4 zO~<}nu(TKKSGi~Hg3~bQ^op@HL{*ICP{HoRKk95~Ox+pXOdS0nL>V?$uSxE;C#0}+ zS@sO{%3pogt@3lX-z2wZVYp}?y=X>SZeKJ%Q>S|C5)#)6WhukHDSiSf9~vK#7uNE! zSmHK@ydG3HiZME=JdHPDjcW&G=6{raOFj<)j|wxe25cibl+`p-7iH?YH9+SSaHI^m zM%t49O@u3#8)X?jaPNm?{O%9w%i>HNj2Fx0Wz4)~%zEa^DLWbaXqObPp3y6_!+VcP zH@`Hm>q^6?CBY*_xc%6}rm_RJ=#*w_2nQ3#`+T_+=d8_pe@kjGe8F6G z?yS_y1xXA|sou3>Q7U>XAr^}?9%mp#aB?}(P;_Vk;I}g~f99zcVYn+RQiPthDwgZW zkAsAnIiVkoflo52p+YbmpFi&Ai}K)Tq>S5SSdAF8%%3Xs8lY-)XW=mMUHwtVGyM!iUDl-@q+(NysgRvp4 zvwsoV7I1~;(G95EE>tWVC-aR2m!k*X^9f0e?$ZWhXXDQ{%L~SM^}>rSJ6kq^4qaQF zll=T;?3wpVYIL_=*)?s;4!bEuKA)?`%h|nG`o|`)1YrfCC2@$fBWZLA4gOta>30A)2c3*?g%3%bhpR5?As|a%5yse(AnjOIWBx2t z&BHjcSLfB^in<(FH>9!wnUuS}W@dMO+>Cd8sV>G*4?cU$nN$szufxG$)tydBvL`F= z|A~*uJs*3I%wD>V^6@8`ZrOYL5qb1yJ_p(^nY(Zub(xjy$bdZh)1Q*#_nws3U%iNx z1GW#8%L3bS^T>hDz{ViRYOF(UaufUU3(rVl@pakX-zUFt@6Ga&;Vj}=OqKZy4IiwRPh+Qdl08tzMDUrR$Q%3}tG} z(qWj^knI4evE7(?CZ(vi7xK<5cB?wJt82P!)~mMZjPzp9+nwV5y)Y78M$+lO^3bu9 zsXFMvwR%}fdFJiXaYTM*2`^GvndQyW+SzrFM*| zq<`Zftep#zi7kisb^oc=JRk-Py# z2MKGqd^eXasZowi%*0$W8#Td&^cw0)uk|H4Xs|q>hh5gXxEu$i#$mAFbG@MBcb4if z-c8Q#G+td+L$(?+2q;{Y1J_)cN~?yw;Yg64`xv4HnDp` z_TIWrcAeZOgA*g@Zbl2DrS(*cUu`J&fK!5E9A)=uuoWiY0SSTj85%Eq08Y4z zx%uNiDbM}(Kb6^+o|L_JJuLlqeH62AE*!1h3FV}79iC?<)oLZ_$uG-Ebl7jacuC6k zw+h~PFfh3rozs4vgdjhKsK-PZ4?7lSDJ*aE3-eOP@|XKbW8BOKxiNkA6HnEg6%fEzO zmKvvR3WF$O=B?Dwg*YRDhi}EeBnUB>89`IsSPt?~6WIiRpjSJv!!kQ21+e@i;SH%L270j9wm*OQgtou52Gb}!BE2b$ z?&{I-c7Zm+3>@e}#jN-32FnxEg@x6qww1RH*viw+i~q@^x6AR*`~|6Jhom%jR$ln` zzbKVrUiLoxqp~@+&*ZCl)7gq=PoM{~NLyH5#_WNP=^6x`xsIi={$w;h1v+*J@T!4; z2Kz()eylsmauG|!RjI6F|Gdtho0jy!2F{YYPQsu;V@hD3-Q6>!v(@UE$H23qw^I(@ z_J~ODBx22D7F5g_CjP0ijboGokO0neUyESJ!#pH`ow ze&9PSXXtygpbf~{*b2>PcBo#<{1~r|iFU;dv+vy>m+Z`8ozc3|S!}x&WcBhH=^dSs zo|TQW z-;?DRzavYpJSI!8JtgaxUy;JX>rz>pgPWJiD%|=4bmD7uu1In5btx@ek{T~wK^}jz z&W=FOR2FuZ(5tdkk}9aK!NEkRYT9vma6QEEm|G#~%vE7t#6uxu;=r_=xc8*q$nw4a z_AOa>ZB9x{1t~0UK%WihyCJ#7oD>*Gw}1f+afSJHIGh*xO((tp0P#y3vbwM=8|&+G z=+trP>C4LErE7BS%w;uteNjCOKT*Km_tNty=elH}%TFAflG}PP++pD4AjyGh0JAcS z!(y0$ON(JjWr7}DdKXp(by>}qOf}H384I~=H!h*;XtyuTt;*Fx$+9&FYl?>cW<+ld z(D=Nr|2QfQgv>cQA1f z3%NAXz4w0#vkf1C6D(UXL!dhpX3wGXOWfb9Tb=RQHrvN|u(dNsaGX=ABtEK7B;ClY)2FdQC}JYTOo=@|f99(4 zT&oCn!aAGjiwRI(EUdA#W;$>5;O&$gz>qW2vRI}bz3Zeta#+q6Fw-YxeBTV>`!FCb zOKtJ86t14e3|^MqFw{SBa9T#ej9OGj-3PK6yT@i&o6iD1&BzAnchE&ZO;!sXr^PQ z@H6n%<1IbEJ@+gOR{$1 z1$53?^q`9LVwC5tfOk&AP^ZV_C=7Hao03r&Xm+zEDP*TNWgt(^s#SEV62j{+*z1@X z&a4&Wc`O5;onMic7guB+M!*i)je#gVI3bzg8A87%C_$F#3_n5&7t!{6R^>V5yllFT~VxdHMZ+_-k_Z z3%@BF7oL$#Y%d<28kN6$%RWhCInE19TtcJ5W$$pmjPNHP&dORQ#6pJdbE%TbMViuU z1j@UK{did}FReosQrV_RCkbcaBs^sd3V-#*i}HnQ3!0{V$FM70-!TDm3er^;^#L4B zGf*(7Nb3;qqwRnewYO5LhU)7WY8WJ?2SA9wF>^GC9bPmIXz{rr4FiUMfv=JB*h2dkA7ir<-HbW4P6NhALz_+B zc|qguiB_QTnVn`2W9h*{eC4^X$nwkIlIr@r?8EZ-Cr=!b|MkwJ`pDbe16divQZfO< zM6br6!HYd$rlSl5bzeq-{8#HU@^v2w`feChKMeE$jOo6y0r|-OX?YI}X(XGKB^ccj zUt_yE8yGTQ7QmOjx%#6KXy;`Z0}+2Z;_trIa!oG3bXG1t`;wf0>IEsRLZWAFCmBTJb^mwr}}yox`HKk)m@Q>;qmHprOv%{w+v5?%KGB6EM1*f-Lt(t za`4p6STSVe#cw?+-~P{Elyl#GQO-SgT1qgS^7XUA2Ns~Q{3r&gBQR9{_UkSZvWmDW z7|f<^o0|HkSIc8Oh|wfeIm`e4{HnaRVcz-W1e<(p zP6#Si5 zf2*}Tri=`uLD9K;)tI~GJyXN-0E~H}zh9P$Wtm@Hk;2k7*~DI(uad>|XY*#R0A!fE zuFOwIyv5P%=aC9Udq+oP->oNg20s7X%P?pxg_GTK6AgH<4>M(6p8Wh*#212CgsdgVN3)w*P|J+l9{n+2O-x%TQAnLB+!HevAk zume^drE6!fH(r)e=<#EF$7BlD$}jt~KG;6gpEtYoVacA-EsSc*!sx#ZXnksw4fDjG zzc$J+Q*f&#om!8x6cE?gl`5X7&F28dBE%H!5W^~N9`eFcU zkR~q!<71l1o6^*fZHhuO<-aMvF?wFCLdT}#B#vc57E|Kg*@XPu$-VNg-+!n4?5&4& z>0VsAEbFg)OKQ9=vBEc7LN@hKN3ZF?G|u5Ydu05`5gEJbunbP^#*EsH8J6-vr$yxx zS_oG*rJ5_t>?^O!{3};w;ngcLe;VgA^k0?hufEPrFN`0y%GnZw1eIsgP#(rh=^37o z$-_rv^7wHX*|QtVc+!|9M;hCL9xUlOVA2?wHB)%8iA$ma_SkH5ttBl4&u}C}n0sD= zC(f|Um@5QLXV7|7g5@nCYMnQe1YknIqVW4$(C0cpoNqh4KG;rLKy{r8A)Pv-2oWDm z8qg-$beyQ`1fW#0KF@aeolfhsm!z&A+p3%2UD5Hm7c|0^1bf_sTv{#3uYLYoGT4)n z@gdG$tUh!mt%9)g@F$6`@`jXGW|iUx_fE=BP4~-qr6`*i@%6?FFsYisOd18#*y+@i zZCyJujnfDl&jj_l6yp?_$ugyq+?LvuzXe17=MUX3AI4reS;=F6`K(@~>n^PtT>!I~ zM2EuaVt8s?9{8cp%6*^toZSD}AC_bHKL`Wlivy4sIzQ`(^|D##v>}+_~ev2E_YjJiHU=U=-j6gl##3v`Ka&kncJ{oOWCMT2@vd8tLje zS$N@_(uL)58M80rncyndv^fr#CazpxlE+@SAbh$e$%{SO;Z1ika|L-erM|JC55-Sp z((<9vKFq|+=0jan&8nwrQeQ@R@yHo;#u<5@qfYcP zoih`rVd`4M-uT?(&&lKeWh{x$pO>p= zUX|Bheo;z!3`qO-O9h?!%1bX{$;>x@h|FKShV=82r}07&W?8o?>$P0e-N?Q@GIr=N zmiJwp^e)cKJEkG~q*Ne+n^{KGE zeLeE7(SF^YETTUD$%O^^N2f2zOUr9=er`eT9_*DnhOsizg4zNTtOQedQ%`VttQJ;K z4)ds_P18SfQH;MUe`hr>zkhAP49q0aR65FIq=oO_DV(m`1bZn?mYIcn6L6!W%o~lM z<3p`X@1XPyjZ1NLLC$~uH?eM7!6?rg=|}?t)qPWHr6Ok3B1UF5IAY-3%&Q#+XV<{I z_)^WAPm3HH=#?HA3?GkM%9kX^2gng`YQ)I)eXgp)6jFyU>eyM*>Zb7^Q`12lAIPWi z@uwQ28V=}?u9NUe_YgLtKec~U{@%N9m&1MiQd)dn@~6L}GcmKIJpQgtBhAC$^7Fh4 zjN1#WF7(+9D+?Rg{37ZsnUFoV9hF-jeNb-y$ou5Zk31}Qe)I!!`$r#@J3jSMIrOf3 zRqZPF%Iiz(lEAEL>JqarR|#-+tbPj1D{zZaTwfRdCeN1?@H@-Q^7me!lNa-4$)i61 z@%iiW?=M`F6_l0J@lZM?yZZQ6PHN@qMt!D+cwjyjc?`rfOeM#`u{i@TqitVa-H?iv z!*t^_#|WB4Z5uY9?Lu#*+!3?w4Bxk~>QCHsQg+|-Az8bA1s(N@eig58>Y(0$p?6lX zZr}=QtNP+~?)lj1SbS%|Y(br=t*;CF%fhu+(F^kOi9O?TXD>ToLOTPOZUcP$P7TU% zn6n-NuSxRSg=*l$e3+{b(yE^7VL#oJxs?rB$rZ8BgGMkE-cVtd$ObG!0v2@?v(}#B zA^FzT1zBESk#4MI(__13GnLhc>@!2dk{ub7O#hG!Ois)A-UHYJS7i3gD^kg0Sq&Nd z3Fzb_?~^0<-7Q1AcFDlRlzvT+U-s`Cn~=WIN$E=Ur~wz(H{|?dk4pthWh$HO>5-`; zNA>sV^`#ZbZM*SzV3ZwG;vQo%~5np=`p%$nDVH91?_l;4E$7Ev7T!;>iQPaZ!g zKYe7kq?m~VU7&zC=dY zc)E7zgl2~@2J95<1aFceZy8ut4--f4l!-eZk$R#Bon%!uX3t@Es7TMm9?)Vj#YRJ? zF0PvymCH*r`_i%5*{E>*SJ5!=N=}N`&qxw-eqhgp9M7gOqMP(G1_Athch0%h*rQJb*)s@a}Ki&Z`@}tN#w2fYHN=0qr=}c zkdbmCE#Jm+u~=BgvUX6qhbE*S26+1?J}di9-7R}goRrbs`=C-1d)SIxJ$ptCiIb(B+!p8|%-&e)E(Oez8XL?-z&wEeEF56tJ z1D=8!vUKLSDY;@anZaHoL2eu2Lq zp;w4)88~{+-1=_m-+Mx8-5IH*hV*R=>la^?8kW-h_;eR$B3{_i`*e9hMLUD(9;Ue& z0XfZ;-ErKJIH@FEdrPtL?CEO~WCMvqUx!1!{wE5)k3fF*3L zl$YM2Danpa%JlJ5SpN4)rOKHrf!Q4c6b7`}^EBWb#<-*$xcl9b#QwR8{4PHCv|M`O zd6_$ZPUg;@m)Ub?;XfqFXYpY>@-lqWt1}?~uFtQo?T@+vo_Gm&wr#CX1MT3pks; zVAH|G3qovBUe*5ftBdl)Ja5!#2Xel>2o0E}-ibbMe;Kv~(_}cK@w!tP*?s53k{REt z%gj}b?%h43df#&H@~cw1ei5_LkQ6IsM$tR2P*F4xHn@{Tm`;N*lD`#<@|# zK3KzspW^s)4EN#u2_=}S?k`R7c=Ew zyZeNEc4k<*u@`6gS(c_i0`o4Q?DHE1)TyZ#BM*H65ggBWb^9_Z^Qn2D#>0U#|;Wu&|ywJnO3fVPZj%0+AHuRa zBSRaPU{kV9cExGohgfpnDQEo{>mlfcTVyPS9M0^btk?zD>Hc*+hQ29 zDzMVx>jG8Q2meT9>3C+na*+Mr@`hZ{J-USx z_6~&o9oQ?#Hoyvb%dRj}KdneM5AyW$jp-iI2|Nu3ujySfyzhh>Fgp_Otm4F$?eEjs zxSko3%%QubGIBtMhx+A5KKL#4vCCrdS435WLlL=WSZqA zRTS-A{aI?N5h+=CM z7;qV#A4beq@76Xek{BA1)XW}9PwiHho!TSW$=xu-J(8K;4|h;{cO8+D!>2G%W~IQ} z8W;KL7CxTHJRyhH3&Yg&@U%WzrArPJ;-0B7`JYejml1AVkRG%N%E!`akxkG5o;=!k zou9E`B`81%I?iHtCKK$q<%?J6rO4&HqjSPK0A+{r-+W-Y;Sw4OcCO>w2CW==V!Cdp z()gA3-mzUWee&J1`~Htgci*r!P$9R9eQr)?lq6;@8X$jeS;w~P$(Jw5*((du&--n$ zKgt!&XFFi7kVu`FH$t_7l+Hhm{qJdn*^NXV93PUS*(By}XEuhG>T=R3Vu_f+emD(d zW4t;&@k~z$HD>&qT*FH6`xoO)x?$%98zyr#pck*`_^0|4SXNf}e&#O8V5vWnPRNyV zUA{e!jR_vZ^UOY;R&7j)-Ror>(4(9UaU$mVDKRb{v5_M zUJppXuq*TDrCMB*1A~3?_wGL__YLsgbypKmkOlJUA_QiA7$mRE7rC_1jR~KdNDZg8^a2A zTH^y9d?a=e7Dj?On*E%Co~>}+X2>UjO3;J%6|)?sQyKYE?&%pAryzZVJ z`MrzRC5HhmJ2WAiS@SXit%o(tQIM5NreuBgBI>Ll!@LLx1Lm7L)-Ipc_f7ZjIxMNd z39UcI^Wg*zdc4TT%q8X5-(Cev@g8yV_SMrZ8H zq+vwJhvjrK^cWDUmmwyz>$xg%6s}WhUIse*lT zQu+q^(aRoTZ78r0~QQ22?ws2uCM0wO_}cI5&k(I zu8qoxg#7C33-Sd%=BVl0z;@~1K=2}HI0N5+7FsFJdErhF;k7)zpFqg^L}%5JJvYhZ z(K}=Y%e0x>ACj3nACc+XAC}3J?~&mncfweYNpfVG@3Yb6P<~@Wax2TSvBsIU7>w@R zYw?k`DzDgbAIxr#tk{UGmeR&Lwt;2n!gxMI%UuUM0nLLHkOTUzG6sa#Ule{=n#St1 z4G!5)&c1;jhOl%hYC`^g2L!**u_%^ zYM7rGmw&calV5xK75Vb)vV3|uLg@L#;TEN55O zCDk`1-D7(|!3PgaMmlF>i^20|cN%-y6H-m|>5VSAYc${j2GX0b+@6&3(yXkV`L1jh z_y{5raK@$K@+0Q@Of>T1Z`576IoUY(q*U`u^3bjc`GtFr z%M3?2Go9N4hjpsOr%^KkXTJGzb{R`&_&EqOZymHqOLm;OI{DICLH_aSOR}L4X;4UO zn6~g-EPgwP$k_qEF<^pxt`>FxW^6`78?hBUI>*~gc~yyW0e+`<&m9lR!S{YrcHQ$~ z89aKIzJ#uuGX(awyh)Ku!5n7R-0HHdEib@eSERu2|L`*+RsPg~j$tc`P07!D8b`l< z^2Diuc=s_eH3v0ZCK>{8=#j>@xjv`gC?XHxMDYCR%(x7}f_XhTNYVVGbR~2izHXHU z#IFXJZbrB>pt|FXY)!3bl=avX&h&C%m_vR1t*<^QPh4M=vsgyHu)Z#jFRjQ|W*6lT zt}e(o7FOlOjiM|=SfZ~_64;A#cK*`*y1cTqss@}KKcEI|>Vd*pb2zbGFe~@$x&=$< zUOwd@xvQ@TzY;OL>yTu}rqxK-F#GcTU0ul@>ECyY?#I{8zbNIkWmK2B8m;ev#u<$O z!-cWIB}+?^JNtx`R^^`&9#ntM=wcP2OvS><$9GrPPlIh^&mZLK@# zujQZw&d6QzBKG`$_r>#aF=v*}MtUOx{ua>w{}#hu!~(QkS}WHMj!z57zfRaAqjx))6*?i!)-Q82v$SfdS7; zaqfbl#nByj1@-QUVL6n=JO@&(ADgUCzNlOy1p&SKSwV;6Ll0c)GSSoxouP5LI%rCG z2)txVLdO>{>pr`>66Ndct*(G^uIWP0*7B8>mZkA6?4XRw#K@|w= z0wL2J3?Wb~SLHg5w}L@|+aegS)(J^KLt5^8FC*rcU%ezxqWm_|2y6njL#v5~Xch0n z(R4!F3O;XmBgZxcsu0J6v*XrfR*MRVz{`%Z;uWJJ5Z6DoSB`z?CuHWXM==Fs|BBgl zZD~=~F?ttZTvdKROdHiQ!BHFD<&(vvabn+u+<)sqIlOO5@3&oAFX+qa^gYEO=AJZ_ zhH*hg8J(F1&+qDP@_G_FR(T_b#->r?PmLF#zBV(Rg<1Y`PD=9^bs*5%;6EJa{1M2% zZyZZeot+U$Ru+|x`jSw?nv0a6Lm9@E<#L+KJ|y`tF{bmHG!>#g!ugoa_e~$?d$Lj~ zeSHjX;^1=KFhdeAZo%<8MJt%C&#mX>+t;xFMY&l+iOirRCl113_*mIp zzs-)^bh{)mFeWxDvU=e;spl}5t}e*pxtI0#T;7Vx`sT&0wM(aE^%9raFkUrW}GI%vsoW?{kIeN{BH!z3{08Ke;cx? z2+7@<*zOWSd2NB36>Mbh5jpn3ACoke$t&2qt}bDw#a@2>Lwn2}yAm%Fiq=1co(Fkt;w6ZW@) zJj^W13r;X%)+-E`1E&6di!(FSI6<2lBbHnMK+^hVlN+g=^-<4#c(Uzow5hLi29J=R|5p^Uq)Km5m}+BYtXiwjc7=g~su4ksED`!WMsI5bkC z8x6mKJ@1+C$tLz;AN|Bf<(0XD{Lxdd$&2T%%JQ073WF4@xtjD~IXyf!Ar%Y;MHnuT zz)Z<~GQXv(u1I5N!}HHU70@cvSkB5UM24L^z&cPK3NtcQtR7f3#*>1vOkx{xYOqH>xNA&4 zzJEsEJu@NG(2KWq>H`l>H{0n6zK?km+mh_yxV|}(Goo6xU3qX$Twk~*Y4G%87M?%% zf|PSBI=j}fd}V#o&pPL9t1lQJKkJOfYvHaCq>4+jarQ~6&_!fSEb6owC$AA9siq&hGqE6dBef8zejR0Cw-Pp=Zg zak<-F$w}e-b29(bm!-P4Afw0bl-Fy6a{BV3EMZ1`{i!7R&itMMD1sGAOuaNJ?1Y6Z08_oT3VS;kP-zx}Qg^5OA*tw6K* zum!T9JU->Lgk}F6X5D-hc|m{eV;tzTu1qWOqF4FVNH$ zUzdfa_`c<5WqjWeIsUPql)jrz$}FM_DVcL_=7i(fQ7O!l1r^5`Tx(I~adAGH zOz4Na-nVO1K6-GMJhXR8cK7w_oLGc`&{bf>#bqovm#)ZWeg&n6D1P|?`$+Cw^@88> zg4D}cwb1xsyfj?5G`3pkS^caswhh`u-vEM$^3|6lhrMqGtEoS8>ZtswBQuhrQ7Vto z+3@q?U`^KZMVVdQkd-3p7H!Njvu<>-2O%#~rQqS)D%TQ8`K|di`TH+jke9JP2BDSK z0>CD^+d`C90`t>?Fn^p7OlA9J;?#pu z?9Q6y7Lz!dqT{i{QP(bho}^lk+}WpO_IrP*&+#6-_fa|c{?AG+J0c77^HPKns^R00 z7n*pl^Bo5#%&{V?}{N^fu}SCCN{#x0ony0H`uz7cG)28_Ox?V@ySL6`#&-y*@E znbIlV(rIa^N8nIHJO}XoBtcy`S`&kREh!w#5JuMzgW~(8AD9@H4a6cFjCez2XQ*KKd%j}qJ0)H!kZP(wmo_IFF?Leis;0yEC;0+_3f!`?Oy-`4qOi#b`j_=Y} zs%T@Vakx5xo>-=?JpWCZf8kqtmG=1ie?WG>>%+2+)?ApM(|dL~vk)A=`Td^5)BW<< zN8c@vpP85Iia*$WtZ?RSat;lc9goJcDQPVA7^TZe zrr_L-r8Gxq@Hfj?%-3-igy?myZ7v9p?8#{ww zT0lm4JnHaI-E?4$8-8#^mAM6ENmM-H(^CY|iD^q_8$8 z<)znQ%vWV|V^Lpb!0Qoa;N!yBXsELy{+K_ZK6rWV-ng#&-fCe*ZW|epUwGi898ZH< z_v9SBL35jdBKXh@weXRgZ{==k*j zof+6WU|7tTcWC977PQN8(9oBsLoZFma&Qwf5NC6vDJl-0KJ1x?`m%Cu5zBGT*i2=G zqJx(&J}v84UevIbkBmsVWoSTt?zY2nX9n`oNp*`zHY+&_>c_ln*9Qq_W9m4BL13)E zS2Fq_1jtB0M=?6sz~k!Gie_1?7iTjg>Z?}mKtnInDT@+_F8<8J5T>^p3}d}3DQ7YJ zJwCUrui|+c=kqWw{do$5Zn6h^>46DJ^Q(@7Q?l7Jj6C==6mqxu=*?94OBPoocjj^I zd#}i3CN2N)p;PjiJtJ6(V=%??Ifr~Uuv9Kpc*Wdkr247P)TO?e3FH-}Djaibu|Ftbg;_}H*Ip{PF0%+Oi|$fRkS z&LlXd6fg+!0vF3_9?o}sgr!wjKbS~M9Vtuq(sYQYi3aisB{Y;EG%pcAy^tr1*gffl z+%wiMAI5(91N&#>(C`5A@7AZQHu4)%+Tiz%u^L*vjy>}#23|fW0ug*+V0Rkz16j3_ zlrB9h`ML8d_a|;WB0sW!Tq@XVtYbjV5O6FaWR|u1JI#1`)^d`H0a)QscC7#FI%M@Mz_}X<$iE zrZ+K%8vz%MH3`=XGM<+R=)N)KT!k(mL} zT8o_ZZ4~n)xk_TB^9%N<1iZX`Wd(9BVA=ZA(z<;6`m%g?Zb>fk>M+;4G{S73WQL|B zHMU!leIrs?x+?4Zy%&beYZ1KRWM4KZqcG5+bW-|JrYs*+;qT|jL!aC#SM_T1ItJQ# zzT~b@lv%iCY%BCpLDdJ^QB~^dm(7Fr6)(*^Ke9G#cSo$=+GhRix;Kv(%0m^PyKOO z>D?!b%ge^t`DfRZyy(K^V}1ksVmQX?GSc)|lRxT*4N|L?<B;FQaz5k?op zYQ9_+^K4Remc>k2OeN(!jP0?xHTlYgS-ltgb?jgDWpv&Acv?nRT6}#Zrg#&&y;%G{B9uFcD_3VuM)kbzXNXram2aNF>YMYg&nM_T*NjLPYT85FXVG@(p#Bom@0d+zY7PvB%?v^`-vCQ5zCLcR6 zEr%w?^jpSj*lw^r^|n*eTRyg-D;MjKXRd^J)j@M3A?aDaq_yQTaXeE{XE1fv8e63| z0OZ@|c>{nx$QwDBv1(vqx55@=t{^<`v`~&WK9WrzyGQzu+$F2)Fd&2}-sRwE`SV5b z;&ZZi=4m-}?+0b@rc<)Ek&^;n!iGQ6L%N>1_bp*oujKODA++OYrwkXWBvjcj!o=FV zzWgpFFcu45_pcucF9Umw$;JZBOguNMBVapHAPv+Z2Sk87vyoQT=*&{mn=kB@d zVX5>COARwCJF;Ggahh9yS8h?}zxO3c_w>u5_x^xX5?NVWTSfmgGpt!knug}PiAx(B z=Iw_x43)usCC>PFqds{P73{ZOd`ya%&AYSxb|_GBI!hI=pVJFD3GDN@Of>o!s!iz5 zd#}sb_i-=pgL!gZKC_;aXI9oF)7vZi`?7*XruIYA7a@i6%zqG1>8|r=oYELaKi@*h zpI}xCCHOo@e6INEU9JX-sXu$ND*%?mdGJ=VO%^PJVrQTm%sYwobt>%gt zG~r4$c{X2_UwHn!{KmN}=5?;rEzEAmXx3M@vveiD(S`M4&RfFrGzFuznp&i{LC5*W zU~lA5y>1vzah{!jyA|rrzH>-p7Ms5PAt~}vA$A0f#S#BBMs^}pgA~p^Ei30=kbSp5 zC?kjOkPYmU3!GsoPl-vzn&!T?Dis*8c?$-TIC}szT+NyeX}qa~UmPr9md%Xq)|>m; z=}q~N#tT|2SEb4qqfZ_{&GL0Jr4xXQIKGnP`xJZ8-AF09A`EP7&i zK!*8AP4IHA(>G$0-kbnaV&gMT;p&WHxuSr4Qt~=kp}Hv>n3*?&T%vqYwkwN1mPk?NZ%;q;~PkhKnjUCWnbB?rfWf^x9}SWBkm8@aOl;tOZx z8`uN$tst&F{q}XP0ZroC(szJn{La4}nZBIB@egOyw=7{B_`Gfq@m(h{GcfWC8vhLd zGvCQ*LMSiKCK!>^J2EAsC+@?N({)@p^KZZzw6H3R&wNE^mzy5?H1?l^va-4&e6u2X zf{L+n`64?hm(5HL$EBviW~&1OIyZZN*V>$v7A{J5@{nw%dLWH@7()KcCCHl};^G(m zyRuj^>nOl<>-ZaKDlO&3%duJ5WNK9Ck|K=cg_SjVa&b+{px@KigS{~uE1n#e(V5+EOaT$pQ1L(H7s%A3Ye++x*-_gYM}}TKiq1*A}i`j`mn$% z9q2YdUw{Ggrykz3jb_1$04~ujS1_Rc%d3m>PhLDLXR)&2YCzR;j-c*5G=XU+q&Zq3 zXuB9(=Whq5Z^iEnPw9Npcu;A>I4yzGp?Psy!s(0`W3 zf-8`V=c{x$iZhn)?`5YTiJLjPyXsP&eN76>v---N%;=PUX!e{bfTXc=HUydI}{ zS;rK5%o&u-htNy_&6~4xLRSwQT)1(lZ}QQ~ZoSiqw<(t3FCngs!H8d%s9}Vo@l!C% zrSF)cgZKpUtDqjgjhXoWeBqq@yO++%0>5vB`qJDjpHT`xq-)zawC#P`CLoRLBP^*c zZ3DD@Jla;Emi!Cy)<_el1=OHD>(PLI7vD+;AEb58N3*!CfI+?M=b5FlZJl>hxJ=^} z6e!a|Cso+`3FmL9=s-@FWP17}li_cJ37s|Bfpp)AaH^V@mFK@9#q|~0|E>>8@61h7 zC>CMF_B%L`%T8~kZ|d{Ed~$}@n|QTYJ9(&t@p7qNtw^d;kldA*(czPlnLdoOeKN?6 zfr#&}SEPh}Fgt8|d@p42Zd}4E4`yn4C|cXwxX{V^Rj;BH5k%aDbHPy+VU0IqoZiti_crZ@A~z_%UTdG9#D+&Rk~N?AiHv zaf@FptgT;{jjN~i<>IOF{gA<5;7IRy5Uydcm^-I3GZXt!yZnF|g_)xsFx&B~f91v3 zrG`O)DJ-w1TbTS!jHa^;ka-?U(eKQy$g9{_)-Z_mXH(JxvIJ*fr@x*;d7Na`AM^|> z7xGkfjRC(_faAlNGF&>1RRtMpzO#zt4CXPAyoBlcH!d&8KYsZF2AUZAIt>C;GXs!xAuoWpVNR_b_<8Btu8;ljN=wNH<37z>8nq#T8k7 z=8KrLt1^5W29TanR2SHdcQEc zBwxL@AXoDR439`70b$ERjkS#|{BkN(o;3&yzdtn855kNw24v-Wym2(tCD5*)IgroM zwUnXpIBy}&FV7ndT|zgi_tt;~a~0+MI|BHFJ*f@%PGAYEds5N2Jo2UwLYp7ym5p`G zfK^18-%ZVhAetMF@UkE3TP3JlIWM_OXB`%dbsYWW2{?Mcc-4|uVJ=1)nSS$U;e z_<%oTDOqqZ<*Y7Wh7L>cBX7nT0t42C^=hL#M=%YpjDlUw7v-_}W%t{PboNrrFc9vyFcvF7| z-Y&M&7!Df8Xq(0pUscME+rRs;J|UCkjS-0F+Z#yFw@_AiOC@F&?4FTkx-XAva0u{c zBYxh4X|;t5`pVdz@ja5BJ_=*!gB9WI#onbeaE*K5v-&W9W^Au?_l_WqmIS9A7=rl^ znU$r>Qe9ur%$PU9cvD8(TibM6{ll4$$Ssu0^6c`Od=2~LZ!fLO*qZ>Eky}h8WC1INm&-Nz5(bH1zdSF$cK(`t z{_>pearxaR16u;s-9mLVy0%4&g!@gZ+=i@>>%8$CT2DCVUP?Wueb#CL=RFS=rOyBYS$YGSQQk5zNs2scy;g z7E91^xy>i4^f_a!IQVX?l~P5n7R&N_z9d&L%g^bZR2B37D9sT0!ynNyuCwYD>KUX{ zcPobywrWeJ*djKkv-vpru0E9(UX^cR^yp4aLDpD$v-TmjY9rO%(`hUEodW&Pbl*V! zo0QQF!LL|WXfqwPo=t}#NHI8bSSIiL1eTV)GBh|KnVue5U0;*6r4_03%?zNTDC5j4 zV0hJ;5A^Gj9Kz}_vX#@{mh9*r=|Aya{gR%^gE_#6RUde9?#j>0GWNi=YDtD~{eW~2 zV;LW4WP!{Qbr{5pUy%Z4VZVbIlQ3oIsKJ?MOgy2Sj@`;39Y2(v#>~n`@=|avZ*CK~ zQsG@&bo^P4E`X9nGh)b4I69MV;;msa%@flpf)3B}A-wI*uc{bchmK`fCdX~n&|(Dg z5ZO+et5=ujn`ywoG!iKHPK00fCKcf-%r67|>$B&iaPAp>ch*L}pwAFz(miUUq~NPg z`Pv)amRZ9xlozb(72afD*NZV-`4!1sehGelec*^}CNoB`W@H-&XFA65lOlD@+-&^h zX4NiPd7&RM@RLTTd+d>`rJR*xpbc!qD6~wrT&;nQgxNFh^s1H0qi&9pzvFI79y9GS z2Cun7QLdv-=;krQuAtmG3|wWSnP$ z*&q*wk(-6+eGV;xlKm#M7du4u8 z7fa+?aYL8NN!Tq%N!})rz$~ArW1v{NB>A}us$aa~sk`Ygl>WxHimMkr!mnW#nz+HN z;%%@UIK2Y6;2T@~?Sb{9XAQk$p-X5tKv36KqlN2OZyQuO~u-MRUZ#*^SOCH=sr! z*OsMSU+JhHsk}y=Z0s9?j&-(lNHphn7|b3Rx=}r?pf^}itr&WCDt9}r)`FG~K^%DC zlTzuQmfZ3p40c0m8!%SB@?~QYhHDIpw?W3?^iTJX%D}-pq-$h1LiS0cF1}Kgsdd@J z?7Q;v*I}Q=FkJ^xh0K2wbWk{tB$R3JMz_^Y5$XJ1v3Ckr&h3^j!nXrGBIW1=%n$U< z__i&}!j*yOgg>~5#e&}GNE}B{p?cV%V2W_8w!)gir}LpYY)W=uOj0nya(*4NYfd&{ zXf#;8aHVwi;LAra+=*>UcdAEvrw>VH>M)vl9Af*F{2BMo?~0(Oc}4*5-o4wUpoM#=Q->(Ee{yb4PX;+bNO zh;elAHZeUo^Rl|^)IrIN@0V^Yk#pytll<(fV67=hLs@O3#Ia?akO?HF7$&e4O9RME zCM!cvfXdOa+sL&9HlcHL)F}z4hljT9gTJ6-wgUJ=2?n7Yq%d&{y{H3u^sF&FTB!{> znN-5jyPw1yoGjHlgy%;z*SZ z^@^~`-tzgP2&Lmc6A&=}hECHc!vyq-b{tH{Nwvp+yj}^$#oLp7PABCtY=$mIs<3i- zY>*X$(;*J!0zMsB6v@7IkzUorE5}NTL86p z)fegg69=Sk<_P>~GAyZAFTH@(@O-Qs%s-91$0R#6A<0aSq1U`@DG@er#WXiJSVj>@9Snf zvt$17l2oxNgXxqU8X1Hekcpm*-qc}o2Pt{1pI|*2lN4D;kNur9mt;0q(0i>vaqNIT zi_C}mUt8Ib-+%3rltAP2n;yd$qfa{o^=hwtv%ejIo)7Mu*0)fy5DSHpeDn3|a(}cuihhSfOxB5rWT8-@AKK?%joUgK`_ys(ksvth~Ck zW^`bsSw&G0qUjgl*Oz4dwWnkWPJgEb zE$^I~y7dDxbn^qqp-cF2>bb}Nt1O*++Ngmz>XaTFmr>AUci)8BHcN%<&`iz3*@#A| z&()HT*~7<|3T(XiO__cAO9&ZsC(|-?=yn-8dbcF{M=TFu>&YAetAqZ#x~LSTym(Dk zUVTEJjMDwPQ8wVeb81Zf>>Wqs-thq$<^v9hhj&SviylJJf}Hyhl#54D}j{xha1%w;_M&%g@LHZ{$%z9;lDVR5m65^wD?8y`z143+=^H zP5#&y9+y{E*1|Th5=@?GdR<_a%9QMhnr=7wt4EZXRPZW z+Q~5s@{JF4e3wN9R#Js4GhFAnTgoVoo)KR`Jip>kn$5m38NB&H89i_-rKwDonNQ>L z<_3Q9gfjWwDhKM7>K%}&JKir@@YT};pi4>>V|lg9oNJQ6xf?D4e&h>#;*Ah|(*^aS zE|SRDn>kp@KIy`sR7II8Dua*c8(DnwWfeMB!AsqFF0*`)T}@^sIekbb z@A;tgV-HT)n%3j(h ze2Zsxv#cLpBiJ#L)uJRoOP*|18spm06qi)VFF3a_eJe4Pf!j&r=ykck(-3rlq z**;90I3ewXwMc5Fz*ZwkJe^_Z9O60zf%@IA(C;5?RX z`Bjk|&Tw5D%fh!#Zmut4DqEB;EZMuDLxo{mM?GV^W$?(|Qt!@UC4d|`gYs%Pzo!d2 z>RjhrKTwX%)w#fR<(80tMak(ldq-s$Gk7=3WMp^gGWn;D?~yzCfN;rtP`lQhl*O); zyats|!@XMWl9$zqyo|~1<}^HM?=dpgpGFd09vXB=fRI+4ZYaTLq>0Y1oq-oURN zJ~h84YkUBPiSZ(GVgR2X&2U3`V{mCcUA*sa2asWRzFYA#vmIvA30N3c2;Wa+M`mR2 z`+q{}J%i}{n5h{(ek{ap;sFxrL+YaZt3 zHuW9I@K4_Qko4d3puQANA5&bLljRq`g?;U;Sq_uRtn1(n&)vzC4DP>0`i|bqAEeC_ zMVMW$ef8I+uzX$X>gZ6f{OgBL%1vq14@BkB0sq~lW%+OCuIirF{laa;I@xfx%6Q@n z>-hCP-Vc2c%iDkZ!8_$>c2km|d%D;yKmNrh^-Bn>JB8CR4z`E&HUrcC(p|^oubh~~ zfLO)gnvs9`%B&<{JU?{|`_*z8`e)>CJatL_?Mvru+ge7rpSkrW`T4sJV0D1HMce%C z7p}?w{mdD(8`=r9d|AV(v0?euM^4EUO0Js*7<9UuPnU^&XSFUr{l)LfLXmGarNLsW z1e*W-zLWA}98~HRS?f;8pZ~_o@}(p$2G%N>79a;O3_3y) zej}6M3`|F_bp)FziNPWyM9sk zW~`nr3@~Zzp`HHwu>70K@S8*EYM9BMT&v38fBLjM&d;0Vi~3X%-BlRi)j~<1C00Mr zHtjm!A#Lm4PFQ+y_2T-iMjLMnn6`5581Ms(J2coQAKs5y5N*ow>a|=&{?#k5%fEf) zvYgMOJgBSQuDbl-(OoiySvcqqIMH~XyuKoHg_`cC5-`@gCI+Ps`(dTBirqIeiuzz( zcgqSy{^8X%DZwbY)H>Xsm7`-^iCKVg8q4rgqXYVOQr7Xg^^&}d<+@9EH7|mZn#)nl zJS8lZ39Wky0ljhf5BzXQ=f$@Ywg%?)1B^=OV5A|C3}w@jf#K>xH<6H6*YbK5Rm-gO zN)|XCm~X_nG=+=vVolcW)Bw^eJOZ?;ax;pKAKd;F_P0m-`POTZBI@_QUYOU{1)brQ z?u(03Nx&FEcl$)YJbD0oVe+CPRV)s#Ew9USOF6A0eq`>J{=H`_)1nTIaOy@nSnDLdWNBNXRtrgoj#M%39!F%omFM} z4CJ>^L$^yZ?z{CVB|c_o(|IOuyxueq)x$bN1$mGKXI^cCj)xhTZ~O>g#VEh=DnIo! z+M{c^`=<%W(pTA9Wb`F}q=ihJ`NWMuo@gB4}5f zbPtO3!$bY@1DIKPv8)R^U%(jiyJxS#5I1${{(1N>*FXy+?Cq+_kKepU_VpR#q%_K0 z#S;5F*RcA*AXY~kPxbJ^6knd81lSV~$jQ-u(4)PQ33+O11N+%!d2V4tjWdZ}f8XSg zjHGxc9roOad)M@+45XODrfh)oiR(+jw&R@)?b^|5f9nx%_y%a(n~u#Fj?=>>ZmM@g zzjBxA8`Zm`cwL{1~oBSb% zQ3PonIn%6b0HbU)J0E4J-y>DYR=#GbFa##vK))=(JX9^}MctWzNta-Yo>JVZ^HA+UH|8?Uus>8I7r7U;3phD{_8y zgTmCkw74qYytX2BE{U;9IW^KFA3n58^K-dT#V4;X%QBbXsDhq!m)x^wT%U7h#on`P zT&DR$F|dJI`iZNHvQeqZ*RL!}0Y*>U0=dUW22l1o3^^%xOb!bcFuD)FQmSM5>^?;I z&W4uKYnOK>Y$q_lzGrw+X5RA|+5P?>3*4^v|G4aaAN=q8QMeybH+Ij5MY4nDw@>uK zI`)($ULDo~^MJ;5Mxd}Y5w8#~V=Y|&_B-=-plr50fh@*SphnVJ0tZKkv#vtyAf~s2 zQlK>?$Hsvoshv5dq-KNMG%pexzLr3_oe>Ay0fij^wqREsnI9#lBt9h{KKER0@hKnuLKye8-JCF#ZtOZ`qw z4a;P2M$*Xdo+%9KTxo&+;zn6sUgV7)q8}n(;O~q0AIIeTo;|!V2zp^vaG;+*Y#||; zk{6fxoODss0)SuZZ3oyXUsJ?H>6Qs`u(ab||eKbQ;IuU^UqW*dg^&8vcx2 z*RDoZ8;O=MSTg&o1*;jCpcJ8MBg_jifzqr$x*!5@aZ2aY+LIHH2-X+P#zh5^JpNFQ z^#kTbf$Gz;f9&vXIf8vA-!(>IS66a+v&p+K``tA$EO+C4-}Hzin6{k9iV3rHxhA*v zC*|S2yr3BBuv#k1(~CK&r&GFL-h(~xVJw}eVd%FF^~q+rDpf4SpPXHmIlUpITVBuQ zK7N2L_|~w4%Xph$UHGe-8%9i&zT!9R4DVDx zKdlJ*%%A>#Sj7m;GaSF1(7nmKYjDQso2WCcEL|1sdsZ&X>a$;##nVqhK&)Zd6?GP- zGaZl6CzZHIMMLUC3fQmdKGwo^VbyDEfQAMJS>$8Crh&N&YDPk9IWC>Iaq>IJjG#{3 z6PuRnl2Vl)ErZUCKwcTpXkN^R0%FdOqu&@H|IJxXy7J(@9Va?N7&xAk&O3*G&0YOm zqVE~t>^mt*UPnL^RAFEz2Q%`2ec)F4=O4UX{>2AxlYjA{Q}WN?|Hb#6ltVoUsbbkn zJL-ju&m5VNv20q)#w@r${ob`D$<@sO(~te`F&OS`W5cp5$)D*&HekSyUBzD3baL#S zE7&_PN|p5vxkq61#|8)Fwvm3ln$7mUUZ}{kSe~1LM6nY?d_#@53D6tf+zj3@XzyU> zP3lb#OW*yXEI#(TvhlX(cARPgnissyPqCRotZYaa@si3QBJ+eWf{&e zG=8BWJ2Z+g(*jn*vGFf$a0An`nqH+)g_{y^llvhP&z+qwgXZqwJK>O=Ia^RkE;l^+r-u3q=tT*Ps|Tgk1#5c!@e zHXobSp-XzE4oPZaA39r?UZg2rJtL*%YiL_T*Ee-QKh90?H{^6rpT0MX3t>Ls(8b#% zac<|>ad;mtyHC%=9!ZSOV5CPRdS(WL!Y1Ft#hDoci1Ku&3?F49Px|?D5x=t7dk}S? zZ}#ZIKC&PyXTB?Z113aE76$O~L(?*p!i)*^$NRF9LVYb`FV_u&%3yHnK}Qc944rpx z^;&;F!*Ki^m{-bqWB()jresf+g8+2JvU3Id((4-q!8{nqAir^4a0%%L-5>^z9t<>; z#iH)->y^KB_Ypam=FdJg*+ALPRj^Ek?aiWUuEX&>Tj`QnlNpExh%WV2|3t@cH$TZ_xUTU^1J7*>hfO!J^6Xn zcg>8+{+^`XPr#3=b3e@YrtwzVe|LUfzI}ZWeE>>Ae;=-2DCqUWZxf&sb|*1K-)YE> z?2_Fd_;IOb1`tX}eeS$m{o23B5|S@#4=d4?!o)DtqJk04B9Gnn0qMW_L3CIwPch6d zJ@I*2J^NkOin34M@__W7;KL65T@umguvq)aX0f1GNHH?NIL-c8^QKVElEjdSj=XX9 zX<2yXaipU&?mi+D_kA2j$h|qKFmPbWUn}LMUV==n7oFPLIVcCGWJ(g8{bA7jqzm8U zym|esT>jdBf;y-J@C?9!f93sm$R~I8WABGq9=hAol^TDnQXy8V)tdt$WWm_h zP-p+gw_e0@bW#57sUz}N?mQs9Ffx=@{=?-p`TO5JqxX$lZJZQqGdSR%gTepy?wjR< zlbDZTkgF*F&wuNT{Q4^w0{S01x=;S@y+@=6?ZS04eu=GH^D&!tUPdNA`L&ni$@yii6Rsp$!5Q=0Z9BY#_s*Yzdk19j z;3?Te1G3X^ZY;{$g%?o4yzL+17L}-tz)oT-JAei;V@w{9%%pksD`()smD4%{JMhgN z{S(vD)r*-DGbD{?6Eh;FPU*_h4d{oyr3ViFP3S2-gJ#z59hR=XVd%On8&}RyOU#xV zlEywZJu;=YglhFe-e#&-Wp!nd4`sku`B~I%IF-jYX`1fFLP?^W(_1hLi?(y3JY^XF za6WYJ-Pr1B9rJ=h~(yRs|=>~;U2_Z~+& z$AFq#Dc9uheDA!xvb-)i%%sNEtgGtm$~R}y@kSJWXZXRr6FS3Zp~J*rzkKcDoPG_0 zppAWS--L{CYr=NMK*al`Pv>j$Po6oe``!>pM;Ul4>mz%{We_rz2WJjg&#aa7jxhav zt@e@DXy{WO+NE`Z?eJq6;1a#xDkN0_2bI7-SJ=(mEagXvk4oBL{=L%1oeh zAPa@o1zZVx?fIv^B(>|WNqV!0rP-#WK%463^F!ttqAv20pK{Q7IFs?5pq@y>@UF8r z<#As<|Khi08$4;qO)(B<-oMKwVFr0;Hp`M+xhyMB|Dmj1 zdI>U3M^m+N_$KyuKlhzifbzQJ$+<=OJ5QaHrwdrFVimBNN`n^Kk%#gSR+*4h#kqpKs&tHp9P0Jq>|qW2bUJH zKjyl{qD|+DOd6KK+0c#7%G=>b{c-VsV;K_u0C1xPaE+%mi2o#JxQXNUN{&b<^p95vX1w~i>y5ugOmbI&|F`&`ZgZtg=;JEaS@4~(gGc`Iq)&R!P zodA{36H0*DwT@+badiRuU6XQd71A(AS{jAbmEc4CH;)g?(a`~!KquwH`J7<_naqtr z25f%xuVA+Owdc>u#q|y7Xl8u=OtHJKM{b`SkwZiMGR_wSa3lo-Yu529_jfE}NeYwEyTI+;idzk^W*5|z#odx%7l&vtVc&uq-W0tv`8Dv5E{ z3ury40s&J{R>n9|O$`F%F?1Ye7_GEH0ZqCOUhHwVdMX*5$u^|bpTCT^C0t&V$Gmlz zqb!`v-Wi}s%_|HO)X#bW`V0ZK)kY4YxOmpVjvzhcVDSPPOg)v|Q#xvyZ zU^728nfwV2dAvTtI&2O<&#j>4r7rfoU4V}UOyIbE{s!zs5ub;Chn>iYLuD{}8=@j| zz-Nqqdk95Db=ppuoDAh?#@0!>B4ycrIS5xD0e2^Iz(NyhC@*F0P!9tYtTOz5(-Evr z4qNiJ(6wbh+aZGb-KGrk!8rp4cBsTo6>xC;4m*((m*LQfEb=-!U(QY{v=vm+R`QhH z$nYgaXY3APb#Zcb+BU?EwvXc{-*)S_U7Zpg=>ie0)(w%mB}p4`ZQFN;b>d^YG6=}Q zCJWe6C;7eyM=+jIdRul^u{&uH$Ky8+Tr?NWL`TGI!_HbxgM~rGx zY+nZPPN^H{b>lPejiPBqc8GYzI&>l{dvXCdx!yJ4m zJE>EmnJ(LvSA@z&cnfh(2gmE{v>jcVm4)b3#*Om(@XHi0-%bD-)_`w7$oDN= z!?5h|yJqUBLdomVy%CLZ!|=;dETk@;jYZTZFtLPEHArA-thaHY`eKEGfy{eLN@|v zZVh>-V7;J+Vm zF#I0_;aiGsTJV2w`<<=e_IN?LaSFmgT>Cl+c;5`XiK5>Og!T6J8gHP-o7pz+Fz6om z|F3|yWjBj=H5X>nt1+8k6kg=E_lV5|A}%rzc>XO1h!)8KA5L4!@IlgmSDB5!6`H0u z;bYLbw*U>z@ne{WcscLLX!koC58 zu=*2knC`BQ==Wu^JqZ^V5HZQ_y}U5dppzA$o;OP0Hw#eF^^NZr@E&kmw{L*btz@{4 z@K(WPrwwA^sh}MX;ihHw=o&zI+bK&cQkN}_fI5&a;#x2wJ}_PlBGimFkVA(<;B1_O zMl-@(2Jw>=T0c%%3rl2TDL_5V5lM^5*)Ce;Llv6U!A<}b-_{F4J17RqZf~W0BMkT* z0MnMOYa%FRyTQgwW9MLq5;e9>Jt^%D>d|$;fYZtd;#-89!jOc*z!Qza$|F`k8c&&& z-$r&&K2IjWxQ=uqKo$%_ThX?Il|zGTD9??^^$?VIo7ff~>+J^gMPC2E@~%M9a@$lk z?f?Js>tldHBaCdHvp3tMo$Rp@Fawe$-;+(U`3(mc>-(j=F9yhWeATyntcl}>qw}o= zcV27!%=j{vpJC0+$GPx0P#-@0W1Fd-fJXytjw(HZKKwkUkDs4qczs_ZpYRp;mEo<0xk; zeC+vzo_^q0O~411cT-CHuodt-=WyF&hMwl}cAm(qPx=(Db0ZbJNWfK9sQVUw{DD8s z&DOw|;TPTn-oqz!%{O~qihw?(@#_&LY&p}!J@w_hP7Al!cZZKRfST^HkGmc$f1?~y zFLP&2sokNyL`wYR+WN5+_aTFB=fxlt;(9v|g{Rv^)(*f3mg}&$)=Tw}@O8JGXg!_T_l!Bkuu>_WbY!+|~h+<@RS|7a6;% z`wz&}xaGhB(hAlFx6dTE_-)=cD2^HERS(<=m*>;~jgyu$lh3>fr#{wtB5w9TcU|0L zh*{$R&&jvQ8V*;p%fsYfL$xQcUS4&fxHwM_qQF|l3ucxEpRtDH!;$>QKDJ_p%%>ah z7it(4512z`5xUV)cp7_ZN$m&(M0+byJ~Lp84+&elg-l|e!OfdzrJEgVd0P9*>vc0# zxq4^pmypxyIQ9C7gC5|TFtUID3hhOn>U2?%($J|6qA3;kyHnP>$b70oH#a;H|&+CI>7b7ZDAryMxQWnVRqa*1z2;zAny~3JgUrGd5*A{qIOxh?STcCiJ;G- zh=Bu^QA}}cdFeI`Y%+J?QY4seyQ|!WCRFD1tCxAX)mX5E`ut@k%HT}E6 zhi4OCo7eCw^VbbohNGHQnjOaJQwpF zB#t$cgE+8hWcUcipYjxM=lLffpKkQ<1TM&He3<@A_@q|O16RJOH*lUBqOEHHvpzC7 zt&@ik%ZCkHKkkzc;CJiN;;3N|0JUUuT@xH}5OO21&IIj)u;KL}`LBC0fmDp$ z7mhl3%o9QM;92a)xv?6!YLD*CJhz|V+!uL?_nBWn<}tzM6hDEH7thd_<$nVHknsw` z|EDA8T^rF{c-Yg%bkNPQXZtN+@r~Pg#K}*TC1F4iL=S?dJfR8KkfX>r&8 zrf*`PLcxb} zYV&Tw&SgRfQ4R=N`$~NWu9b<@#vv@UAK;9`$5e(r20{!K&|Pj=ma7^-x;K6sQzzVJ z_1udfVg8zD*sCX8gNN(>6H|DA4f7SZxSkunHI7jxZHP7YDj2!FI7!@YnFD01L*|p| zj__*YUmx+WQW|QlbQCxBZ_-d$oQ&!>eLi|Kha2d6IM`0y@PA_6>v)_=&G(DF{7$Uj!~$UG zWON>V{L`4~CF;$M-ahTasUnsL83?UdN#`1Dht;4zLC8`K@=GVL3$lq3W0+3B1-P*_ zLGzUWXv{vaE2ZZWk)-J%v5yLLJ9f>;KJXH2?HJ!R3a7*4=@VytV`8-13pP5)zXe|u z$CoDJr`Qu*y7B8-$2{GVc>bGM!FBhy#Sid9@QtQ7ZoHptrzUB5vCwsZr#xBM*F8EV zc{jD6HpzOi7~ZU{)-8a?Es;~WtEaJ&GUH8s!#F3nFhuO7j>GbunjFd$N;mI6h^J@s z@mv%x-S}A#6S+OWg>Q>1R!z6->&qR$Vbahva9p$oE;3LY^F@i)jMi0T{)Ck-TB^hP zsBPw?+dBoC6u2@0mg@#5(|X?XZDNrMlSv;V|bY?ZYSX!bE~em!HGOZB~cr#?F3 z;pG5^%q<+G7jton&ZQ&CcfAyQ(lOfGc>tkcSCs-C-d-fO|vn6=^)Y3d`9$ z2|T_$8fj8_tWD%x?p{W(wG7@%lZwNZg~Xf?8?pUuh+piQ>s8|cKQ#~Hr1zvn9DySGSh)4_0?iG6Z0c6OlH zFD7)m(ei$)V|2{HTMEyH%iFdRISBqOEF3L})`a%J6&D%=x8yZ==w@l@x;V)$=DS-{ zz*%=|N|?;u+Pu{{9)7>P=_B~K{?{B}BiaGR9}T@oUf@n6tD$V9Wj-fX6NMdS#5^hQ zdFzwQyJwvy(g{ri*mS})crt`zXwXfO(F;5q9L|CC;s!ILj;<3KxC>t)b6B}CTI&oj zn()5DyyPKdx@7PtapjO(4ufljV9IXc1@q2<9TX9@=;48{Yn|&Hp78m+?)Jwa6m7Hw zno%aP4IK1%500jDz&4ajTB)=e=^bY&xG4K#N-f0kb&-tXm)`|OT&a^KXVrse+e$G_|VgOt4c33X(3QnZ;4YG^J2 zUypwRt?N4KFsP>1wZVm&a39x#nz}Zb%*}xataX=MCTo_PW<%4%krUC*&3qzK1N7%Z zWvDiI%k@pmScm5>1=_fJdiJ{J+(cv;bqv#fB1-rLKFOUh@c*_4Sda%8rd{ys69Fy$ zDYnM*{;8HP4oDwNycRTgH8Or}ZpV%0F5#Eu5>BUUZN`M((?5EcrcHIA4(TX-K=qex z8G@Qx6Ff1Cysh&a;hklB6L`Q74r`$(pFD>L=z#`$U9a?dFGKHNf2=)c_tys)Rgvlq zHIDnkgk1-tr7aB~UE>p&TQGE#$Uvck}i|x45Q&Kez`+U{3b+&`vyzUg(;% zPDoB2)Hl!4IjaLOV84EmE1Vdn!{=;x($eBUAOAG6FP3?NxADn(i15D4?oky!sAVsN z_1^;nAN_M+_C^XsPQ`of9gwSe9J>aE0CT?8+mM3fQh&n6UQ3JoK8OfA|%El0_2xo z4xTs)H*)>^smt1t!H8b>MZ9K z+7sfJu{lb(*$VUfMoQa(lr#$$t|cI+xh`ns%Au2XEWa*zNKzR7UUb5cQ*)cR7Sj^R zWoCp_Q|-Vha@rJqQz6fvL*_HS*w>mz-@&c=4&-<)lNrl^q*m?(r&FWjO41kH3C=pK z-6Kr?O%0-|ysd}zs^=ts4-A|h_*cLjPI7m#KMTI5S$L|=o@zq@E%&5BEnEYzsjP=)%{kOyS6<$C zZ->QJ;uY-4>DEJ+Njlsvqy3X(*UFK(RA%Wnci4%i8m7MU+ONxC5Gp_3Z z17>|AJa-Kzwaorjx6{1VLC<3b4Kd%GadAGlp!gf9m);3a-#J_1mVQCj!?jXT zBPF>)`(9~iq3v+>e($g~@j12w>~%jaj+;IJqfAv@8Z3*h0%sZ1Cxl}fhOF{e{JOTA(RRpL2VHjopo(uGaf-a2JqE4bO&i zfgwj3j;rb(4MpC!-MJ?#89! z!m8jEoXg$=?5*mC$|namALiV19JtJ2!{QX-9X?EVc$pw>|0<>X-ZtQK*(c|C;+WWA}qe>=J8!*gAruQe~6KH)ZatWjNn;zD4&Hj7^y z)-?P^`dHWW`$o|_ZCE>hpU0jX=;Jk)yz^t1PYz#!6xUh&-V4W$~LHNJ>l@Gi-3}VSPLBH?WMOB zI#;R{vCEc~1Kc(#`2uu%0z-3T1}FswC*@kUL1dM1eXmm8^o({+BPv1eZq*>>H8f>kZR@OWn9hKso` z{j~Mw3lFC_6btf+3l1axD8s7UAioPWS=zT;bYZYXr>u4Cr}*Dfcc0WE{7f z6#YSS0YX)VBAO?@EidMm2RK!jqlKIwA|ge`0gR9l2+sKcFXX5fNZRSaD7aW}H^nPl zq~MSyVXCWzH>ehR><`y{FvJg&n#yseO$Gym{Wc3P%&~E(T;Ax0{qdKx}$ zK~um!HJ=E^zOktF=HVBZs)cAr5#CmKs^LQs0O_AOwtPyK@Nz7u4>oSB<@K1*$xOBn z!+@u#LtrkZD?D?09-J>?sY`BT+f``#?4~#nIq5fhPXHk$N-h%WRopxR4!wgG=>nB!b^fiIX1H(X{j+Q$<- z$LeewLj~N%#Re2KFtiq2_!G8p>Jeh@d4xH0EpWZ0P|gDxb)O0w(4)Il#cUlqoSeBk z=-Sc^-M>>Y+Mk*pRG{m1gu;%CkAsv3;EH|j#u@eeg!~DFUP#}xH}ntKnBwuzferg1 zFgu4NjXymGluF+eglRmhgbwm}pTM5R18mTKD~CRE;R??qRO7;dE}clrTj+ za;H8u7YJbbqYjPMl6OYS#yKZf_0l~}NY4fMD+1|9TGDH}#ulQQ01 zpW==J=~KLy)$upbI_D`JD&;dCQR$1fDR3}I(1n!o04Et&+nf9|u`=BT)cz^Bewe=v zJixzF_wxg6;AE%!y8TjL8(c^VO`A&UZ{UHYhTjdTk&0<#95&+k`*hqZ$pHHUc)Hi8 z@cz6?E^{>YcL3-2xPLhP2JF8luEp&q@Lf)<>ngz9K30&^lgR|>y5QO7|+KskiP zdiXRZ4l<5OL-`XIK#s=xctnb8?r5L#H~<)wJ4|5Xz)(ltwwx9(*h{UxLX(7K92(}5 zyP*r(7%m@D=p7#@GySSnR6P?>)9yXt+ z*ubB$2MuKyRO1BA5lyvom<|{-JdZs01RrodkU`It@d%6gKgP%*!2P@g4NRb~j};nI z9r}UAW7ZK|{}|*w##inh_c2`d?Z-W4U;iE$AugiA8P^hf4aR+7*n$ClK$=>P3BoUE z?j0BC{GLnxNk{q2Bc6DZA7JcG+n^E*oyLwFaFf)tv~kzkc!~1&VCv=VUIz{aNB}O0 z7Pk1Pr5cLvQ++}O73%N01UVVUt+^ai*+Lw!NGIu3^CYA^K*k~^U!l7Y+~MnGnF+tz zyC*FFjgS;zG=~n37b|eeouD}#SN&Rq)ru2zYpy)y~Njh z7X}jom2QUCaSm+Jq3t#3C(Ku=Y52|j7?%xy#$f|F+@Lks+q7p0)lh#Foa2+m#vQ=l vHSY-hJ$A|k-qh+KCu$iekd8I{$3OlbOiv(b4K_x800000NkvXXu0mjf_+~k( literal 0 HcmV?d00001 diff --git a/projects/app_picoclaw/install_picoclaw.py b/projects/app_picoclaw/install_picoclaw.py new file mode 100644 index 00000000..586b2633 --- /dev/null +++ b/projects/app_picoclaw/install_picoclaw.py @@ -0,0 +1,317 @@ +import argparse +import os +import platform +import signal +import shutil +import subprocess +import sys +import tarfile +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from config import PICOCLAW_VERSION + + +DEFAULT_ACTION = "install" +START_PICOCLAW = True +DOWNLOAD_BASE = "https://picoclaw-downloads.tos-cn-beijing.volces.com" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Install or uninstall picoclaw.") + parser.add_argument( + "--action", + choices=["install", "uninstall"], + default=DEFAULT_ACTION, + help=f"Action to perform, defaults to '{DEFAULT_ACTION}'.", + ) + return parser.parse_args() + + +def detect_arch() -> str: + machine = platform.machine().lower() + + if machine in {"aarch64", "arm64"}: + return "arm64" + if machine in {"riscv64"}: + return "riscv64" + + return f"unknown ({machine})" + + +def ensure_picoclaw_dir() -> Path: + target_dir = Path("/root/picoclaw") + target_dir.mkdir(parents=True, exist_ok=True) + return target_dir + + +def get_download_url(arch: str, version: str = PICOCLAW_VERSION) -> str: + if arch == "riscv64": + return f"{DOWNLOAD_BASE}/{version}/picoclaw_Linux_riscv64.tar.gz" + if arch == "arm64": + return f"{DOWNLOAD_BASE}/{version}/picoclaw_aarch64.deb" + + raise ValueError(f"Unsupported architecture: {arch}") + + +def download_package(url: str, target_dir: Path) -> Path: + file_name = Path(url).name + target_path = target_dir / file_name + urllib.request.urlretrieve(url, target_path) + return target_path + + +def install_arm64(deb_file: Path) -> None: + subprocess.run(["dpkg", "-i", str(deb_file)], check=True) + + +def find_file_recursive(root: Path, file_name: str) -> Path: + matches = list(root.rglob(file_name)) + if not matches: + raise FileNotFoundError(f"Required file not found: {file_name}") + return matches[0] + + +def install_riscv64(tar_file: Path, work_dir: Path) -> None: + install_dir = Path("/opt/picoclaw") + if install_dir.exists(): + shutil.rmtree(install_dir) + install_dir.mkdir(parents=True, exist_ok=True) + + with tarfile.open(tar_file, "r:gz") as tar: + tar.extractall(path=install_dir) + + for binary_name in ["picoclaw", "picoclaw-launcher"]: + installed_binary = find_file_recursive(install_dir, binary_name) + installed_binary.chmod(0o755) + + link_path = Path("/usr/bin") / binary_name + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + link_path.symlink_to(installed_binary) + + +def cleanup_dir(target_dir: Path) -> None: + if target_dir.exists(): + shutil.rmtree(target_dir) + + +def stop_picoclaw() -> None: + process_names = {"picoclaw", "picoclaw-launcher"} + stopped_any = False + self_pid = os.getpid() + + try: + ps_result = subprocess.run(["ps", "-eo", "pid=,args="], capture_output=True, text=True, check=False) + except FileNotFoundError: + print("ps command not found, skip stopping picoclaw processes.") + return + + if ps_result.returncode != 0: + print("Failed to list processes with ps, skip stopping picoclaw processes.") + return + + for line in ps_result.stdout.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split(maxsplit=1) + if not parts: + continue + + try: + pid = int(parts[0]) + except ValueError: + continue + + if pid == self_pid: + continue + + cmdline = parts[1] if len(parts) > 1 else "" + executable = os.path.basename(cmdline.split(maxsplit=1)[0]) if cmdline else "" + if executable not in process_names and not any(f"/{name}" in cmdline for name in process_names): + continue + + try: + os.kill(pid, signal.SIGTERM) + stopped_any = True + except (ProcessLookupError, PermissionError): + continue + + if stopped_any: + print("Stopped picoclaw related processes.") + else: + print("No running picoclaw related process found.") + + +def uninstall_arm64() -> None: + result = subprocess.run(["dpkg", "-l"], capture_output=True, text=True, check=True) + package_names = [] + + for line in result.stdout.splitlines(): + if line.startswith("ii") and "picoclaw" in line: + package_names.append(line.split()[1]) + + if not package_names: + print("No installed picoclaw package found for arm64.") + return + + for package_name in package_names: + print(f"Removing package: {package_name}") + subprocess.run(["dpkg", "-r", package_name], check=True) + + +def uninstall_riscv64() -> None: + for binary_name in ["picoclaw", "picoclaw-launcher"]: + link_path = Path("/usr/bin") / binary_name + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + print(f"Removed link: {link_path}") + + install_dir = Path("/opt/picoclaw") + if install_dir.exists(): + shutil.rmtree(install_dir) + print(f"Removed directory: {install_dir}") + + +def remove_picoclaw_user_dir() -> None: + user_dir = Path("/root/.picoclaw") + if user_dir.exists(): + shutil.rmtree(user_dir) + print(f"Removed directory: {user_dir}") + + +def start_picoclaw_launcher() -> None: + launcher_path = shutil.which("picoclaw-launcher") + if not launcher_path: + print("picoclaw-launcher not found in PATH, skip start.") + return + + env = dict(os.environ) + env["HOME"] = "/root" + env.setdefault("NO_COLOR", "1") + + subprocess.Popen( + [launcher_path, "-no-browser", "-public"], + env=env, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + close_fds=True, + ) + print("Started picoclaw-launcher in background.") + + +def is_picoclaw_installed() -> bool: + """Return True if the picoclaw-launcher binary is available.""" + return shutil.which("picoclaw-launcher") is not None + + +def is_picoclaw_launcher_running() -> bool: + """Check if a picoclaw-launcher process is already running.""" + try: + ps_result = subprocess.run( + ["ps", "-eo", "args="], capture_output=True, text=True, check=False + ) + except FileNotFoundError: + return False + if ps_result.returncode != 0: + return False + for line in ps_result.stdout.splitlines(): + line = line.strip() + if not line: + continue + exe = os.path.basename(line.split(maxsplit=1)[0]) + if exe == "picoclaw-launcher" or "/picoclaw-launcher" in line: + return True + return False + + +def ensure_picoclaw_launcher_running() -> bool: + """Start picoclaw-launcher if not already running. Returns True if running.""" + if not is_picoclaw_installed(): + return False + if is_picoclaw_launcher_running(): + print("picoclaw-launcher already running.") + return True + start_picoclaw_launcher() + return True + + +def install_picoclaw(start_after: bool = True) -> None: + """Download and install picoclaw for the current architecture. + + Raises on failure. + """ + arch = detect_arch() + print(f"Arch: {arch}") + print(f"Picoclaw version: {PICOCLAW_VERSION}") + if arch.startswith("unknown"): + raise RuntimeError(f"Unsupported architecture: {arch}") + + picoclaw_dir = ensure_picoclaw_dir() + download_url = get_download_url(arch) + print(f"Downloading from: {download_url}") + downloaded_file = download_package(download_url, picoclaw_dir) + print(f"Downloaded file: {downloaded_file}") + + if arch == "arm64": + install_arm64(downloaded_file) + else: + install_riscv64(downloaded_file, picoclaw_dir) + + if start_after: + start_picoclaw_launcher() + + cleanup_dir(picoclaw_dir) + + +if __name__ == "__main__": + args = parse_args() + action = args.action + + arch = detect_arch() + print(f"Arch: {arch}") + print(f"Action: {action}") + print(f"Picoclaw version: {PICOCLAW_VERSION}") + + if action == "install": + if arch.startswith("unknown"): + print("Unsupported arch, exiting.") + raise SystemExit(1) + + picoclaw_dir = ensure_picoclaw_dir() + print(f"Ensured directory exists: {picoclaw_dir}") + + download_url = get_download_url(arch) + print(f"Downloading from: {download_url}") + + downloaded_file = download_package(download_url, picoclaw_dir) + print(f"Downloaded file: {downloaded_file}") + + if arch == "arm64": + print("Installing with dpkg...") + install_arm64(downloaded_file) + else: + print("Extracting all files to /opt/picoclaw and creating symlinks...") + install_riscv64(downloaded_file, picoclaw_dir) + + if START_PICOCLAW: + start_picoclaw_launcher() + + cleanup_dir(picoclaw_dir) + print(f"Cleaned up directory: {picoclaw_dir}") + else: + stop_picoclaw() + + if arch == "arm64": + uninstall_arm64() + elif arch == "riscv64": + uninstall_riscv64() + else: + print("Unknown architecture, skipping uninstallation.") + + remove_picoclaw_user_dir() diff --git a/projects/app_picoclaw/key.py b/projects/app_picoclaw/key.py new file mode 100644 index 00000000..3ac2a38b --- /dev/null +++ b/projects/app_picoclaw/key.py @@ -0,0 +1,27 @@ +from maix import key as _key + + +class Key: + def __init__(self, *args, **kwargs): + self._pressed = False + self._key_obj = _key.Key(self._on_key) + + def _on_key(self, key_id, state): + if state == _key.State.KEY_RELEASED: + self._pressed = False + else: # KEY_PRESSED or KEY_LONG_PRESSED + self._pressed = True + + def is_pressed(self) -> bool: + return self._pressed + + def close(self) -> None: + if self._key_obj is not None: + del self._key_obj + self._key_obj = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() diff --git a/projects/app_picoclaw/main.py b/projects/app_picoclaw/main.py new file mode 100644 index 00000000..5a892ec8 --- /dev/null +++ b/projects/app_picoclaw/main.py @@ -0,0 +1,347 @@ +import asyncio +import logging + +import numpy as np +from maix import audio, app, image, display +from key import Key +from touch import Touch +from picoclaw import PicoclawAgent +from asr import asr_session, get_asr_backend, ASRNotConfiguredError +from install_picoclaw import ( + is_picoclaw_installed, install_picoclaw, ensure_picoclaw_launcher_running, +) +import config +from config import ( + setup_logging, + FONT_PATH, FONT_NAME, FONT_NAME_LARGE, + SAMPLE_RATE, AUDIO_CHANNELS, RECORDER_VOLUME, +) + +logger = logging.getLogger(__name__) +from ui import ( + start_anim, stop_anim, + show_no_speech, show_error, + animate_speak_now, animate_transcribing, animate_thinking, + StreamingRenderer, show_home_icon, + show_install_prompt, animate_installing, + get_exit_btn_rect, get_install_btn_rect, +) + + +# ----------------------------------------------------------------------- +# Main application +# ----------------------------------------------------------------------- +async def main(): + setup_logging() + + disp = display.Display() + config.init_display(disp) + + image.load_font(FONT_NAME, FONT_PATH, size=config.FONT_SIZE) + image.load_font(FONT_NAME_LARGE, FONT_PATH, size=config.FONT_SIZE_LARGE) + image.set_default_font(FONT_NAME) + + key = Key() + touch = Touch() + + disp.set_backlight(100) + + logger.debug("exit btn rect = %s, disp = %dx%d", + get_exit_btn_rect(), config.DISP_W, config.DISP_H) + + on_home_screen = False + voice_touch_active = False + + def exit_button_tapped() -> bool: + nonlocal voice_touch_active + p = touch.consume_press() + if p is None: + if voice_touch_active and not touch.is_pressing(): + voice_touch_active = False + return False + if Touch.in_rect(p, get_exit_btn_rect()): + return True + if on_home_screen: + ex, ey, ew, eh = get_exit_btn_rect() + pad = max(16, int(min(config.DISP_W, config.DISP_H) * 0.06)) + exit_zone = (ex - pad, ey - pad, ew + pad * 2, eh + pad * 2) + if not Touch.in_rect(p, exit_zone): + voice_touch_active = True + return False + + def is_voice_held() -> bool: + nonlocal voice_touch_active + if not voice_touch_active: + return False + _, _, pressed = touch.update() + if not pressed: + voice_touch_active = False + return voice_touch_active + + async def wait_install_decision() -> str: + """Wait for user to tap install or tap exit. Returns 'install'/'exit'.""" + while not app.need_exit(): + p = touch.consume_press() + if p is not None: + if Touch.in_rect(p, get_exit_btn_rect()): + logger.debug("install screen: exit tapped at %s", p) + return "exit" + btn = get_install_btn_rect() + if btn is not None and Touch.in_rect(p, btn): + logger.debug("install screen: install tapped at %s", p) + # Brief pressed-state feedback before kicking off install. + show_install_prompt(disp, pressed=True) + await asyncio.sleep(0.12) + return "install" + await asyncio.sleep(0.03) + return "exit" + + # If picoclaw isn't installed, show install screen and wait for confirmation. + if not is_picoclaw_installed(): + logger.debug("picoclaw not installed; prompting user") + show_install_prompt(disp) + if (await wait_install_decision()) != "install": + disp.set_backlight(0) + key.close() + touch.close() + return + + start_anim(animate_installing(disp)) + try: + await asyncio.to_thread(install_picoclaw, True) + except Exception as exc: + stop_anim() + logger.exception("install failed: %s", exc) + await show_error(disp, "Install failed") + disp.set_backlight(0) + key.close() + touch.close() + return + stop_anim() + else: + # Already installed: ensure launcher is running on every startup. + ensure_picoclaw_launcher_running() + + show_home_icon(disp) + on_home_screen = True + + recorder = audio.Recorder(sample_rate=SAMPLE_RATE, channel=AUDIO_CHANNELS) + recorder.volume(RECORDER_VOLUME) + recorder.reset(True) + + agent = PicoclawAgent() + _asr_fn = asr_session + + async def record_audio_until_release() -> np.ndarray | None: + """Record while key is pressed, stop on release, return float32 PCM or None.""" + start_anim(animate_speak_now(disp)) + + record_ms = 50 + sr = recorder.sample_rate() + pcm_chunks: list = [] + loop = asyncio.get_running_loop() + + def _blocking_record() -> bytes: + return recorder.record(record_ms) or b"" + + await loop.run_in_executor(None, lambda: recorder.reset(True)) + + retried_first = False + while (key.is_pressed() or is_voice_held()) and not app.need_exit(): + raw = await loop.run_in_executor(None, _blocking_record) + if len(raw) >= 2: + samples = ( + np.frombuffer(raw, dtype=np.int16) + .astype(np.float32) / 32768.0 + ) + pcm_chunks.append(samples) + elif not retried_first and not pcm_chunks: + retried_first = True + await loop.run_in_executor(None, lambda: recorder.reset(True)) + + if not pcm_chunks: + return None + pcm_all = np.concatenate(pcm_chunks) + logger.debug("record done: %.2fs", pcm_all.size / max(1, sr)) + return pcm_all + + async def transcribe_audio(pcm_all: np.ndarray) -> str | None: + nonlocal _asr_fn + if _asr_fn is None: + try: + _asr_fn = get_asr_backend(use_cache=False) + except (ASRNotConfiguredError, Exception): + pass + if _asr_fn is None: + stop_anim() + logger.warning("ASR not configured, cannot transcribe") + await show_error(disp, "ASR not configured") + return None + + logger.debug("Uploading for transcription...") + start_anim(animate_transcribing(disp)) + try: + result = await _asr_fn(pcm_all) + logger.info("Transcription: %s", result) if result else logger.info("No speech recognized") + return result or "" + except asyncio.CancelledError: + raise + except Exception as e: + logger.error("Transcription failed: %s", e) + return "" + + async def stream_agent_until_interrupt(text: str) -> tuple[str, list[str], bool]: + logger.debug("Asking PicoClaw...") + tool_names: list[str] = [] + fragments: list[str] = [] + answer_started = False + interrupted = False + + start_anim(animate_thinking(disp, tool_names)) + + renderer = StreamingRenderer(disp, text) + + def current_answer() -> str: + return "\n\n".join(s for s in (f.strip() for f in fragments) if s) + + async def render(): + await renderer.update(current_answer(), tool_names) + + async def consume_stream(): + nonlocal answer_started + async for ev in agent.astream(text): + if ev.kind == "answer_start": + if not answer_started: + # Switch from thinking animation to live render. + stop_anim() + answer_started = True + fragments.append(ev.content) + await render() + elif ev.kind == "answer_delta" and fragments: + fragments[-1] = ev.content + if answer_started: + await render() + elif ev.kind == "tool_call" and ev.tool: + tool_names.append(ev.tool.name) + if answer_started: + await render() + elif ev.kind == "error": + logger.error("PicoClaw error: %s – %s", + ev.error_code, ev.error_message) + + async def wait_key_interrupt(): + while not key.is_pressed() and not app.need_exit(): + await asyncio.sleep(0.05) + + async def wait_exit_interrupt(): + while not app.need_exit(): + if exit_button_tapped(): + logger.debug("streaming: exit tapped") + return True + await asyncio.sleep(0.03) + return False + + stream_task = asyncio.create_task(consume_stream()) + interrupt_task = asyncio.create_task(wait_key_interrupt()) + exit_task = asyncio.create_task(wait_exit_interrupt()) + try: + done, pending = await asyncio.wait( + [stream_task, interrupt_task, exit_task], + return_when=asyncio.FIRST_COMPLETED, + ) + for t in pending: + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + + if exit_task in done: + interrupted = True + logger.debug("PicoClaw exited via touch, returning home") + try: + await agent.close() + except Exception as e: + logger.debug("agent.close on exit: %s", e) + elif interrupt_task in done and not stream_task.done(): + interrupted = True + logger.debug("PicoClaw interrupted, ready for next input") + try: + await agent.close() + except Exception as e: + logger.debug("agent.close on interrupt: %s", e) + except Exception as e: + logger.error("PicoClaw streaming error: %s", e) + finally: + if not stream_task.done(): + stream_task.cancel() + if not interrupt_task.done(): + interrupt_task.cancel() + if not exit_task.done(): + exit_task.cancel() + if not answer_started: + stop_anim() + + return current_answer(), tool_names, interrupted + + async def _active_cycle(): + """Run one complete voice interaction cycle.""" + try: + pcm_all = await record_audio_until_release() + if pcm_all is None: + return + + result = await transcribe_audio(pcm_all) + stop_anim() + if result is None: + return + if not result: + await show_no_speech(disp) + return + + answer, _tool_names, interrupted = await stream_agent_until_interrupt(result) + if interrupted: + return + + if answer: + logger.debug("PicoClaw response: %s", answer) + else: + await show_error(disp, "No response") + return + + while not key.is_pressed() and not app.need_exit(): + if exit_button_tapped(): + return + await asyncio.sleep(0.05) + + finally: + try: + stop_anim() + except Exception: + pass + + try: + while not app.need_exit(): + if exit_button_tapped(): + break + if not key.is_pressed() and not voice_touch_active: + await asyncio.sleep(0.05) + continue + + on_home_screen = False + await _active_cycle() + show_home_icon(disp) + on_home_screen = True + + except KeyboardInterrupt: + logger.info("Exit") + finally: + stop_anim() + disp.set_backlight(0) + key.close() + touch.close() + await agent.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/projects/app_picoclaw/picoclaw.py b/projects/app_picoclaw/picoclaw.py new file mode 100644 index 00000000..07fa9838 --- /dev/null +++ b/projects/app_picoclaw/picoclaw.py @@ -0,0 +1,359 @@ +import asyncio +import json +import logging +import os +import re +import socket +import subprocess +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import AsyncIterator + +import websockets + +logger = logging.getLogger(__name__) + +GATEWAY_HOST = "127.0.0.1" +GATEWAY_PORT = 18790 +SECURITY_YML_PATH = Path(os.environ.get("PICOCLAW_SECURITY_YML", "/root/.picoclaw/.security.yml")) + + +def _load_pico_token() -> str: + env_token = os.environ.get("PICO_TOKEN", "").strip() + if env_token: + return env_token + + try: + if not SECURITY_YML_PATH.exists(): + return "" + + lines = SECURITY_YML_PATH.read_text(encoding="utf-8").splitlines() + in_channels = False + in_pico = False + + for raw in lines: + line = raw.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = len(line) - len(line.lstrip(" ")) + + if not in_channels: + if indent == 0 and stripped in ("channels:", "channel_list:"): + in_channels = True + continue + + if in_channels and indent == 0: + break + + if not in_pico: + if indent == 2 and stripped == "pico:": + in_pico = True + continue + + if in_pico and indent <= 2: + in_pico = False + continue + + if in_pico and indent >= 4 and stripped.startswith("token:"): + token = stripped.split(":", 1)[1].strip().strip('"').strip("'") + return token + except Exception: + pass + + return "" + + +@dataclass +class ToolCall: + name: str + args: str + + +@dataclass +class PicoEvent: + kind: str + content: str = "" + delta: str = "" + message_id: str = "" + tool: ToolCall | None = None + error_code: str = "" + error_message: str = "" + raw: dict | None = None + + +_TOOL_RE = re.compile(r'^🔧\s*`([^`]+)`\s*\n```\n(.*?)\n```\s*$', re.DOTALL) + + +def _parse_message(content: str) -> ToolCall | None: + m = _TOOL_RE.match(content.strip()) + if m: + return ToolCall(name=m.group(1), args=m.group(2).strip()) + return None + + +def _parse_tool_calls_payload(raw) -> ToolCall | None: + if not isinstance(raw, list): + return None + for item in raw: + if not isinstance(item, dict): + continue + fn = item.get("function") if isinstance(item.get("function"), dict) else None + if not fn: + continue + name = fn.get("name") if isinstance(fn.get("name"), str) else "" + args = fn.get("arguments") if isinstance(fn.get("arguments"), str) else "" + if name: + return ToolCall(name=name, args=args or "") + return None + + +class PicoclawAgent: + def __init__( + self, + host: str = GATEWAY_HOST, + port: int = GATEWAY_PORT, + token: str | None = None, + idle_timeout: float = 0.0, + ): + self.ws_base = f"ws://{host}:{port}/pico/ws" + self._token = token + self._idle_timeout = idle_timeout + self._ws = None + self._session_id = None + self._lock = asyncio.Lock() + + @property + def token(self) -> str: + if self._token is not None: + return self._token + return _load_pico_token() + + @staticmethod + def _ws_open(ws) -> bool: + if ws is None: + return False + closed = getattr(ws, "closed", None) + if closed is not None: + return not closed + state = getattr(ws, "state", None) + if state is not None: + return getattr(state, "name", "") == "OPEN" + return True + + async def _ensure_connected(self): + if self._ws_open(self._ws): + return + self._session_id = str(uuid.uuid4()) + url = f"{self.ws_base}?session_id={self._session_id}" + headers = {"Authorization": f"Bearer {self.token}"} + try: + self._ws = await websockets.connect(url, additional_headers=headers) + except TypeError: + self._ws = await websockets.connect(url, extra_headers=headers) + logger.debug("Connected session=%s", self._session_id) + + async def close(self): + if self._ws_open(self._ws): + await self._ws.close() + self._ws = None + + async def astream( + self, + question: str, + idle_timeout: float | None = None, + ) -> AsyncIterator[PicoEvent]: + if idle_timeout is None: + idle_timeout = self._idle_timeout + + async with self._lock: + await self._ensure_connected() + ws = self._ws + session_id = self._session_id + + logger.debug("Send: %s", question) + await ws.send(json.dumps({ + "type": "message.send", + "id": str(uuid.uuid4()), + "session_id": session_id, + "timestamp": int(time.time() * 1000), + "payload": {"content": question}, + }, ensure_ascii=False)) + + last_answer_id: str | None = None + last_answer_content: str = "" + + while True: + try: + if idle_timeout and idle_timeout > 0: + try: + raw = await asyncio.wait_for(ws.recv(), timeout=idle_timeout) + except asyncio.TimeoutError: + logger.debug("Stream idle for %ss, terminating turn.", idle_timeout) + return + else: + raw = await ws.recv() + except websockets.ConnectionClosed as e: + logger.debug("Pico WS closed (code=%s): %s", + getattr(e, "code", "?"), e) + self._ws = None + return + + try: + msg = json.loads(raw) + except Exception: + continue + + ev_type = msg.get("type", "") + payload = msg.get("payload") or {} + + if ev_type == "typing.start": + yield PicoEvent(kind="typing_start", raw=msg) + continue + + if ev_type == "typing.stop": + yield PicoEvent(kind="typing_stop", raw=msg) + continue + + if ev_type == "message.update": + msg_id = payload.get("message_id") or "" + if msg_id and msg_id == last_answer_id: + new_content = payload.get("content", "") or "" + delta = ( + new_content[len(last_answer_content):] + if new_content.startswith(last_answer_content) + else new_content + ) + last_answer_content = new_content + yield PicoEvent( + kind="answer_delta", + content=new_content, + delta=delta, + message_id=msg_id, + raw=msg, + ) + continue + + if ev_type == "message.delete": + if payload.get("message_id") == last_answer_id: + last_answer_id = None + last_answer_content = "" + yield PicoEvent(kind="raw", raw=msg) + continue + + if ev_type == "message.create": + content = payload.get("content", "") or "" + kind = (payload.get("kind") or "").strip().lower() + is_thought = bool(payload.get("thought")) or kind == "thought" + is_tool_calls = kind == "tool_calls" + + tc = _parse_tool_calls_payload(payload.get("tool_calls")) + if tc is None: + tc = _parse_message(content) + if tc or is_tool_calls: + if tc is None: + yield PicoEvent(kind="raw", raw=msg) + continue + yield PicoEvent(kind="tool_call", tool=tc, raw=msg) + continue + + if is_thought: + yield PicoEvent(kind="thought", content=content, raw=msg) + continue + + last_answer_id = payload.get("message_id") or "" + last_answer_content = content + yield PicoEvent( + kind="answer_start", + content=content, + delta=content, + message_id=last_answer_id, + raw=msg, + ) + continue + + if ev_type == "error": + yield PicoEvent( + kind="error", + error_code=str(payload.get("code", "")), + error_message=str(payload.get("message", "")), + raw=msg, + ) + return + + yield PicoEvent(kind="raw", raw=msg) + + + +def gateway_running(host: str = GATEWAY_HOST, port: int = GATEWAY_PORT) -> bool: + try: + with socket.create_connection((host, port), timeout=0.5): + return True + except OSError: + return False + + +def get_picoclaw_model() -> str: + try: + env = dict(os.environ) + env["HOME"] = "/root" + result = subprocess.run( + ["picoclaw", "status"], + capture_output=True, text=True, timeout=5, + env=env, cwd="/root", + ) + for line in result.stdout.splitlines(): + if line.startswith("Model:"): + return line.split(":", 1)[1].strip() + except Exception: + pass + return "" + + +if __name__ == "__main__": + import sys + from config import setup_logging + setup_logging() + + async def _test(): + agent = PicoclawAgent() + + for q in ["Hello, introduce yourself.", "What's the weather in Shenzhen today?"]: + logger.debug("=" * 60) + logger.debug("Q: %s", q) + sys.stdout.write("A (streaming): ") + sys.stdout.flush() + + tool_calls: list[ToolCall] = [] + fragments: list[str] = [] + + async for ev in agent.astream(q): + if ev.kind == "answer_start": + if fragments: + sys.stdout.write("\n\n") + fragments.append(ev.content) + sys.stdout.write(ev.delta) + elif ev.kind == "answer_delta" and fragments: + fragments[-1] = ev.content + sys.stdout.write(ev.delta) + elif ev.kind == "tool_call" and ev.tool: + tool_calls.append(ev.tool) + logger.debug("Tool: %s args=%s", ev.tool.name, ev.tool.args) + elif ev.kind == "error": + logger.error("Error: %s – %s", ev.error_code, ev.error_message) + sys.stdout.flush() + + sys.stdout.write("\n") + sys.stdout.flush() + full = "\n\n".join(s for s in (f.strip() for f in fragments) if s) + if tool_calls: + logger.debug("Tool calls: %s", ", ".join(tc.name for tc in tool_calls)) + logger.debug("Final answer (%d chars):\n%s", len(full), full) + logger.debug("=" * 60) + await asyncio.sleep(1) + await agent.close() + + asyncio.run(_test()) diff --git a/projects/app_picoclaw/touch.py b/projects/app_picoclaw/touch.py new file mode 100644 index 00000000..650e9f7b --- /dev/null +++ b/projects/app_picoclaw/touch.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import logging + +from maix import touchscreen + +logger = logging.getLogger(__name__) + + +class Touch: + def __init__(self) -> None: + self._ts = touchscreen.TouchScreen() + self._was_pressed = False + self._last_x = 0 + self._last_y = 0 + + def update(self) -> tuple[int, int, bool]: + """Poll the touchscreen once. Returns (x, y, pressed).""" + x, y, pressed = self._ts.read() + if pressed: + self._last_x, self._last_y = x, y + self._was_pressed = pressed + return x, y, pressed + + def consume_press(self) -> tuple[int, int] | None: + """Poll once. On rising edge (released -> pressed), return (x, y).""" + was = self._was_pressed + x, y, pressed = self.update() + if pressed and not was: + return (x, y) + return None + + def is_pressing(self) -> bool: + """Return last-known pressed state without polling the device.""" + return self._was_pressed + + def position(self) -> tuple[int, int]: + return self._last_x, self._last_y + + @staticmethod + def in_rect(point: tuple[int, int], rect: tuple[int, int, int, int]) -> bool: + px, py = point + rx, ry, rw, rh = rect + return rx <= px < rx + rw and ry <= py < ry + rh + + def close(self) -> None: + try: + self._ts.close() + except Exception: + pass diff --git a/projects/app_picoclaw/ui.py b/projects/app_picoclaw/ui.py new file mode 100644 index 00000000..5410505f --- /dev/null +++ b/projects/app_picoclaw/ui.py @@ -0,0 +1,569 @@ +import asyncio +import math +import psutil + +from maix import image + +import config +from config import ( + ICON_PATH, TEXT_MARGIN, + FONT_NAME, FONT_NAME_LARGE, +) + + +# --------------------------------------------------------------------------- +# Animation task management +# --------------------------------------------------------------------------- +_anim_task: asyncio.Task | None = None + + +def start_anim(coro) -> None: + global _anim_task + if _anim_task and not _anim_task.done(): + _anim_task.cancel() + _anim_task = asyncio.create_task(coro) + + +def stop_anim() -> None: + global _anim_task + if _anim_task and not _anim_task.done(): + _anim_task.cancel() + _anim_task = None + + +# Cached splash canvases keyed by prompt text/color tuple. +_home_cache: dict = {} + + +def get_exit_btn_rect() -> tuple[int, int, int, int]: + """Return (x, y, w, h) of the top-left exit button hit area.""" + W, H = config.DISP_W, config.DISP_H + size = max(40, int(min(W, H) * 0.11)) + margin = max(6, int(min(W, H) * 0.02)) + return (margin, margin, size, size) + + +def _draw_exit_button(canvas) -> None: + x, y, w, h = get_exit_btn_rect() + cx, cy = x + w // 2, y + h // 2 + # Simple "<" arrow icon, no background. + arm = max(8, int(min(w, h) * 0.32)) + thick = max(3, int(min(w, h) * 0.10)) + col = image.Color.from_rgb(235, 235, 240) + # Two strokes forming '<' + canvas.draw_line(cx + arm // 2, cy - arm, cx - arm // 2, cy, col, thickness=thick) + canvas.draw_line(cx - arm // 2, cy, cx + arm // 2, cy + arm, col, thickness=thick) + + +# Cached install button hit rect, computed in _build_splash when button=True. +_install_btn_rect: tuple[int, int, int, int] | None = None + + +def get_install_btn_rect() -> tuple[int, int, int, int] | None: + """Return (x, y, w, h) of the install button, or None if not yet built.""" + return _install_btn_rect + + +def _build_splash(text: str, glow_rgb: tuple, core_rgb: tuple, + button: bool = False, pressed: bool = False): + global _install_btn_rect + W, H = config.DISP_W, config.DISP_H + icon = image.load(ICON_PATH) + iw, ih = icon.width(), icon.height() + + bottom_reserve = max(72, int(H * 0.22)) + top_pad = max(8, int(H * 0.04)) + avail_h = H - bottom_reserve - top_pad + avail_w = W - 2 * max(8, int(W * 0.05)) + + scale = min(avail_w / iw, avail_h / ih) + scale = max(0.1, min(scale, 4.0)) + new_w = max(1, int(iw * scale)) + new_h = max(1, int(ih * scale)) + if (new_w, new_h) != (iw, ih): + icon = icon.resize(new_w, new_h, image.Fit.FIT_CONTAIN, image.ResizeMethod.BILINEAR) + iw, ih = new_w, new_h + + canvas = image.Image(W, H, image.Format.FMT_RGB888) + canvas.draw_rect(0, 0, W, H, image.Color.from_rgb(0, 0, 0), thickness=-1) + ix = (W - iw) // 2 + iy = top_pad + (avail_h - ih) // 2 + canvas.draw_image(ix, iy, icon) + + image.set_default_font(FONT_NAME_LARGE) + tw, th = image.string_size(text) + x = (W - tw) // 2 + y = H - bottom_reserve + (bottom_reserve - th) // 3 + + if button: + # Render text inside a clearly visible button. + pad_x = max(14, int(W * 0.05)) + pad_y = max(8, int(H * 0.018)) + bw = tw + pad_x * 2 + bh = th + pad_y * 2 + bx = (W - bw) // 2 + # Sit near the top of the bottom band so it feels closer to the icon. + by = H - bottom_reserve + max(0, int(bottom_reserve * 0.12)) + + # Color scheme: fill = darker glow, border = core, text = core. + fill_rgb = _mix((0, 0, 0), glow_rgb, 0.55 if not pressed else 0.85) + border_rgb = core_rgb + text_rgb = core_rgb if not pressed else _mix(core_rgb, (255, 255, 255), 0.2) + + # Drop shadow (skip when pressed for an "inset" feel). + if not pressed: + shadow = image.Color.from_rgb(0, 0, 0) + canvas.draw_rect(bx + 2, by + 3, bw, bh, shadow, thickness=-1) + + canvas.draw_rect(bx, by, bw, bh, _rgb(fill_rgb), thickness=-1) + border_thick = 2 if not pressed else 3 + canvas.draw_rect(bx, by, bw, bh, _rgb(border_rgb), thickness=border_thick) + + tx = bx + (bw - tw) // 2 + ty = by + (bh - th) // 2 + if pressed: + tx += 1 + ty += 1 + canvas.draw_string(tx, ty, text, _rgb(text_rgb)) + + _install_btn_rect = (bx, by, bw, bh) + else: + glow = image.Color.from_rgb(*glow_rgb) + core = image.Color.from_rgb(*core_rgb) + canvas.draw_string(x - 1, y, text, glow) + canvas.draw_string(x + 1, y, text, glow) + canvas.draw_string(x, y - 1, text, glow) + canvas.draw_string(x, y + 1, text, glow) + canvas.draw_string(x, y, text, core) + + image.set_default_font(FONT_NAME) + _draw_exit_button(canvas) + return canvas + + +def _get_wifi_ip() -> str | None: + try: + addrs = psutil.net_if_addrs().get('wlan0', []) + for addr in addrs: + if addr.family == 2: + return addr.address + except Exception: + pass + return None + + +def show_home_icon(disp) -> None: + ip = _get_wifi_ip() + ip_label = f"http://{ip}:18800" if ip else "" + key = ("PRESS TO START", "home", ip_label) + canvas = _home_cache.get(key) + if canvas is None: + canvas = _build_splash("PRESS TO START", (40, 140, 220), (125, 225, 255)) + if ip_label: + W, H = config.DISP_W, config.DISP_H + bottom_reserve = max(72, int(H * 0.22)) + image.set_default_font(FONT_NAME_LARGE) + _, th_title = image.string_size("PRESS TO START") + title_y = H - bottom_reserve + (bottom_reserve - th_title) // 3 + image.set_default_font(FONT_NAME) + tw, th = image.string_size(ip_label) + x = (W - tw) // 2 + gap = max(4, int(H * 0.1)) + y = title_y - th - gap + canvas.draw_string(x, y, ip_label, image.Color.from_rgb(100, 160, 255)) + _home_cache[key] = canvas + disp.show(canvas) + + +def show_install_prompt(disp, pressed: bool = False) -> None: + key = ("INSTALL PicoClaw", "install_pressed" if pressed else "install") + canvas = _home_cache.get(key) + if canvas is None: + canvas = _build_splash("INSTALL PicoClaw", (40, 110, 200), (130, 200, 255), + button=True, pressed=pressed) + _home_cache[key] = canvas + disp.show(canvas) + + +async def animate_installing(disp, status_text: str = "Installing PicoClaw..."): + """Indeterminate progress animation while picoclaw is being installed.""" + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.42) + bar_w = int(W * 0.6) + bar_h = max(8, int(H * 0.018)) + bar_x = (W - bar_w) // 2 + bar_y = int(H * 0.55) + seg_w = max(40, int(bar_w * 0.28)) + y_title = int(H * 0.66) + y_sub = int(H * 0.74) + + BG = (8, 12, 22) + TRACK = (28, 36, 60) + HEAD = (110, 200, 255) + TITLE = (220, 230, 255) + SUB = (120, 130, 160) + + PERIOD = 70 + frame = 0 + while True: + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, _rgb(BG), thickness=-1) + + # Pulsing dot above the bar + breath = (math.sin(frame * 0.18) + 1) * 0.5 + img.draw_circle(cx, cy, max(8, int(min(W, H) * 0.045)), + _rgb(_mix(BG, HEAD, 0.25 + 0.5 * breath)), thickness=-1) + + # Track + sliding segment + img.draw_rect(bar_x, bar_y, bar_w, bar_h, _rgb(TRACK), thickness=-1) + phase = (frame % PERIOD) / PERIOD + eased = _smoothstep(phase) + seg_x = bar_x - seg_w + int((bar_w + seg_w) * eased) + # clip into bar bounds + sx = max(bar_x, seg_x) + sw = min(bar_x + bar_w, seg_x + seg_w) - sx + if sw > 0: + img.draw_rect(sx, bar_y, sw, bar_h, _rgb(HEAD), thickness=-1) + + _draw_centered_text(img, status_text, y_title, TITLE, W) + _draw_centered_text(img, "Please wait", y_sub, SUB, W) + + disp.show(img) + frame += 1 + await asyncio.sleep(0.02) + + +async def show_no_speech(disp, duration: float = 2.0): + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.375) + r = max(8, int(min(W, H) * 0.117)) + y_title = int(H * 0.583) + y_sub = int(H * 0.687) + + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, image.Color.from_rgb(20, 10, 10), thickness=-1) + img.draw_circle(cx, cy, r, image.Color.from_rgb(60, 30, 0), thickness=-1) + img.draw_circle(cx, cy, r, image.Color.from_rgb(255, 160, 30), thickness=3) + tw, _ = image.string_size("No speech detected") + img.draw_string((W - tw) // 2, y_title, + "No speech detected", image.Color.from_rgb(255, 160, 30)) + tw2, _ = image.string_size("Please try again") + img.draw_string((W - tw2) // 2, y_sub, + "Please try again", image.Color.from_rgb(120, 120, 150)) + disp.show(img) + await asyncio.sleep(duration) + + +async def show_error(disp, message: str = "No response", duration: float = 2.0): + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.375) + r = max(8, int(min(W, H) * 0.117)) + d = max(4, int(r * 0.43)) + y_title = int(H * 0.583) + y_sub = int(H * 0.687) + + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, image.Color.from_rgb(15, 5, 5), thickness=-1) + img.draw_circle(cx, cy, r, image.Color.from_rgb(60, 10, 10), thickness=-1) + img.draw_circle(cx, cy, r, image.Color.from_rgb(220, 60, 60), thickness=3) + img.draw_line(cx - d, cy - d, cx + d, cy + d, image.Color.from_rgb(220, 60, 60), thickness=3) + img.draw_line(cx + d, cy - d, cx - d, cy + d, image.Color.from_rgb(220, 60, 60), thickness=3) + tw, _ = image.string_size(message) + img.draw_string((W - tw) // 2, y_title, + message, image.Color.from_rgb(220, 60, 60)) + tw2, _ = image.string_size("Please try again") + img.draw_string((W - tw2) // 2, y_sub, + "Please try again", image.Color.from_rgb(120, 120, 150)) + disp.show(img) + await asyncio.sleep(duration) + + +# --------------------------------------------------------------------------- +# Animation helpers +# --------------------------------------------------------------------------- +def _clamp(x, lo=0, hi=255): + return max(lo, min(hi, int(x))) + + +def _mix(c1, c2, t): + """Linear blend between two RGB tuples.""" + t = max(0.0, min(1.0, t)) + return (_clamp(c1[0] + (c2[0] - c1[0]) * t), + _clamp(c1[1] + (c2[1] - c1[1]) * t), + _clamp(c1[2] + (c2[2] - c1[2]) * t)) + + +def _rgb(c): + return image.Color.from_rgb(c[0], c[1], c[2]) + + +def _smoothstep(t): + t = max(0.0, min(1.0, t)) + return t * t * (3 - 2 * t) + + +def _draw_centered_text(img, text, y, color, W): + tw, _ = image.string_size(text) + img.draw_string((W - tw) // 2, y, text, _rgb(color)) + + +def _draw_comet(img, cx, cy, orbit, angle, head_r, head_color, bg, + tail_len=12, step=0.16): + """Rotating comet with a fading tail.""" + for i in range(tail_len): + t = 1.0 - i / tail_len # head=1.0, tail→0 + a = angle - i * step + dx = int(orbit * math.cos(a)) + dy = int(orbit * math.sin(a)) + r = max(2, int(head_r * (0.45 + 0.55 * t))) + col = _mix(bg, head_color, t ** 1.4) + img.draw_circle(cx + dx, cy + dy, r, _rgb(col), thickness=-1) + + +async def animate_speak_now(disp): + """Sonar-like ripples expanding from a pulsing core.""" + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.375) + core_r = max(6, int(min(W, H) * 0.06)) + max_r = max(core_r * 4, int(min(W, H) * 0.22)) + y_title = int(H * 0.583) + y_sub = int(H * 0.687) + + BG = (8, 10, 26) + ACCENT = (255, 90, 110) + ACCENT_DIM = (90, 30, 50) + CORE = (255, 200, 200) + TITLE = (240, 240, 240) + SUB = (120, 120, 150) + + NUM_RIPPLES = 3 + PERIOD = 50 # frames per ripple lifecycle + frame = 0 + while True: + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, _rgb(BG), thickness=-1) + + # Expanding sonar rings, staggered in phase, fading as they grow + for i in range(NUM_RIPPLES): + phase = ((frame / PERIOD) + i / NUM_RIPPLES) % 1.0 + eased = _smoothstep(phase) + r = int(core_r + (max_r - core_r) * eased) + alpha = (1.0 - phase) ** 1.4 + ring_color = _mix(BG, ACCENT, alpha * 0.85) + img.draw_circle(cx, cy, r, _rgb(ring_color), thickness=2) + + # Pulsing core + breath = (math.sin(frame * 0.18) + 1) * 0.5 # 0..1 + rr = int(core_r + 3 * breath) + img.draw_circle(cx, cy, rr + 4, _rgb(_mix(BG, ACCENT_DIM, 0.9)), thickness=-1) + img.draw_circle(cx, cy, rr, _rgb(_mix(ACCENT, CORE, breath)), thickness=-1) + + _draw_centered_text(img, "Listening...", y_title, TITLE, W) + _draw_centered_text(img, "Please speak now", y_sub, SUB, W) + + disp.show(img) + frame += 1 + await asyncio.sleep(0.02) + + +async def animate_transcribing(disp): + """Single rotating comet on a faint guide ring (green).""" + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.375) + orbit = max(12, int(min(W, H) * 0.13)) + head_r = max(4, int(orbit * 0.32)) + y_title = int(H * 0.583) + y_sub = int(H * 0.687) + + BG = (8, 18, 14) + RING = (24, 64, 38) + HEAD = (110, 255, 170) + TITLE = (90, 230, 140) + SUB = (110, 130, 120) + + frame = 0 + while True: + angle = frame * 0.18 + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, _rgb(BG), thickness=-1) + img.draw_circle(cx, cy, orbit, _rgb(RING), thickness=2) + _draw_comet(img, cx, cy, orbit, angle, head_r, HEAD, BG, + tail_len=14, step=0.16) + + _draw_centered_text(img, "Transcribing...", y_title, TITLE, W) + _draw_centered_text(img, "Recognizing speech", y_sub, SUB, W) + + disp.show(img) + frame += 1 + await asyncio.sleep(0.02) + + +async def animate_thinking(disp, tool_names: list | None = None): + """Two counter-rotating comets on concentric rings (blue/purple).""" + W, H = config.DISP_W, config.DISP_H + cx, cy = W // 2, int(H * 0.375) + orbit_o = max(14, int(min(W, H) * 0.15)) + orbit_i = max(8, int(orbit_o * 0.62)) + head_o = max(4, int(orbit_o * 0.28)) + head_i = max(3, int(orbit_i * 0.40)) + y_title = int(H * 0.583) + y_sub = int(H * 0.687) + + BG = (10, 12, 30) + RING_O = (40, 50, 100) + RING_I = (60, 40, 110) + HEAD_O = (110, 180, 255) + HEAD_I = (190, 140, 255) + TITLE = (130, 180, 255) + SUB = (120, 120, 150) + TOOL = (180, 160, 255) + + frame = 0 + while True: + angle_o = frame * 0.14 + angle_i = -frame * 0.22 + math.pi / 3 + img = image.Image(W, H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, W, H, _rgb(BG), thickness=-1) + img.draw_circle(cx, cy, orbit_o, _rgb(RING_O), thickness=2) + img.draw_circle(cx, cy, orbit_i, _rgb(RING_I), thickness=2) + _draw_comet(img, cx, cy, orbit_o, angle_o, head_o, HEAD_O, BG, + tail_len=14, step=0.13) + _draw_comet(img, cx, cy, orbit_i, angle_i, head_i, HEAD_I, BG, + tail_len=10, step=0.18) + + _draw_centered_text(img, "Thinking...", y_title, TITLE, W) + if tool_names: + _draw_centered_text(img, f"> {tool_names[-1]}", y_sub, TOOL, W) + else: + _draw_centered_text(img, "Please wait a moment", y_sub, SUB, W) + + disp.show(img) + frame += 1 + await asyncio.sleep(0.02) + + +def _strip_emoji(text: str) -> str: + return "".join(c for c in text if ord(c) <= 0xFFFF and not (0x2600 <= ord(c) <= 0x27BF)) + + +def _wrap(text: str, max_lines: int = 0, max_w: int | None = None) -> list: + max_w = config.MAX_TEXT_W if max_w is None else max_w + lines = [] + for para in text.split("\n"): + if not para: + continue # Skip empty lines + while para and (max_lines == 0 or len(lines) < max_lines): + lo, hi = 1, len(para) + while lo < hi: + mid = (lo + hi + 1) // 2 + w, _ = image.string_size(para[:mid]) + if w <= max_w: + lo = mid + else: + hi = mid - 1 + lines.append(para[:lo]) + para = para[lo:] + if max_lines != 0 and len(lines) >= max_lines: + break + return lines, bool(text) + + +def _draw_line_h(img, x: int, y: int, text: str, color) -> int: + img.draw_string(x, y, text, color) + _, h = image.string_size(text) + return max(h + 6, config.LINE_H) + + +def _render_frame(question: str, window: list, tool_names: list | None = None) -> image.Image: + img = image.Image(config.DISP_W, config.DISP_H, image.Format.FMT_RGB888) + img.draw_rect(0, 0, config.DISP_W, config.DISP_H, image.Color.from_rgb(8, 8, 24), thickness=-1) + + bx, by, bw, bh = get_exit_btn_rect() + content_x = max(TEXT_MARGIN, bx + bw + max(6, int(config.DISP_W * 0.02))) + content_max_w = max(1, config.DISP_W - TEXT_MARGIN - content_x) + + y = 6 + y += _draw_line_h(img, content_x, y, "You:", image.Color.from_rgb(120, 180, 255)) + + q_lines, _ = _wrap(question, 2, content_max_w) + for line in q_lines: + y += _draw_line_h(img, content_x, y, line, image.Color.from_rgb(200, 200, 200)) + + y += 3 + img.draw_line(content_x, y, config.DISP_W - TEXT_MARGIN, y, image.Color.from_rgb(50, 50, 80), thickness=1) + y += 8 + + y += _draw_line_h(img, content_x, y, "PicoClaw:", image.Color.from_rgb(80, 200, 100)) + + for line in window: + if y + config.LINE_H > config.DISP_H: + break + y += _draw_line_h(img, content_x, y, line, image.Color.from_rgb(220, 220, 190)) + + _draw_exit_button(img) + + return img + + +def render_streaming_frame(disp, question: str, answer: str, tool_names: list | None = None): + ans = _strip_emoji(answer) if answer else "" + bx, by, bw, bh = get_exit_btn_rect() + content_x = max(TEXT_MARGIN, bx + bw + max(6, int(config.DISP_W * 0.02))) + content_max_w = max(1, config.DISP_W - TEXT_MARGIN - content_x) + q_lines, _ = _wrap(question, 2, content_max_w) + y_est = 6 + config.LINE_H + len(q_lines) * config.LINE_H + 3 + 1 + 8 + config.LINE_H + max_visible = max(1, (config.DISP_H - y_est - 4) // config.LINE_H) + all_lines, _ = _wrap(ans, max_w=content_max_w) if ans else ([], False) + window = all_lines[-max_visible:] if all_lines else [] + disp.show(_render_frame(question, window, tool_names)) + + +class StreamingRenderer: + def __init__(self, disp, question: str, line_delay: float = 0.8): + self.disp = disp + self.question = question + self.line_delay = line_delay + self._revealed = 0 # number of answer lines already shown + self._last_lines: list[str] = [] + self._last_tools: list[str] | None = None + bx, by, bw, bh = get_exit_btn_rect() + self._content_x = max(TEXT_MARGIN, bx + bw + max(6, int(config.DISP_W * 0.02))) + self._content_max_w = max(1, config.DISP_W - TEXT_MARGIN - self._content_x) + q_lines, _ = _wrap(question, 2, self._content_max_w) + y_est = 6 + config.LINE_H + len(q_lines) * config.LINE_H + 3 + 1 + 8 + config.LINE_H + self._max_visible = max(1, (config.DISP_H - y_est - 4) // config.LINE_H) + + def _window(self, count: int) -> list[str]: + end = min(count, len(self._last_lines)) + start = max(0, end - self._max_visible) + return self._last_lines[start:end] + + def _draw(self, count: int, tool_names: list | None): + frame = _render_frame(self.question, self._window(count), tool_names) + self.disp.show(frame) + + async def update(self, answer: str, tool_names: list | None = None): + ans = _strip_emoji(answer) if answer else "" + all_lines, _ = _wrap(ans, max_w=self._content_max_w) if ans else ([], False) + self._last_lines = all_lines + self._last_tools = tool_names + + total = len(all_lines) + if total == 0: + self._revealed = 0 + self._draw(0, tool_names) + return + + complete = total - 1 + + while self._revealed < complete: + self._revealed += 1 + self._draw(self._revealed + 1, tool_names) + await asyncio.sleep(self.line_delay) + + self._draw(self._revealed + 1, tool_names) + + async def finalize(self, tool_names: list | None = None): + if self._last_lines: + self._revealed = len(self._last_lines) + self._draw(self._revealed, tool_names if tool_names is not None else self._last_tools) From c3a76730482e674e3b6e4b76e24bf93fc60bc941 Mon Sep 17 00:00:00 2001 From: 916BGAI Date: Wed, 6 May 2026 16:19:09 +0800 Subject: [PATCH 2/2] optimize picoclaw record interface --- projects/app_picoclaw/main.py | 81 ++++++++++++++++++++++--------- projects/app_picoclaw/ui.py | 91 ++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 31 deletions(-) diff --git a/projects/app_picoclaw/main.py b/projects/app_picoclaw/main.py index 5a892ec8..8ba6269e 100644 --- a/projects/app_picoclaw/main.py +++ b/projects/app_picoclaw/main.py @@ -25,6 +25,7 @@ StreamingRenderer, show_home_icon, show_install_prompt, animate_installing, get_exit_btn_rect, get_install_btn_rect, + get_home_speak_btn_rect, show_record_screen, get_mic_btn_rect, ) @@ -50,22 +51,36 @@ async def main(): get_exit_btn_rect(), config.DISP_W, config.DISP_H) on_home_screen = False + on_record_screen = False voice_touch_active = False def exit_button_tapped() -> bool: - nonlocal voice_touch_active + nonlocal voice_touch_active, on_home_screen, on_record_screen p = touch.consume_press() if p is None: if voice_touch_active and not touch.is_pressing(): voice_touch_active = False return False if Touch.in_rect(p, get_exit_btn_rect()): + if on_record_screen: + logger.debug("record screen: back tapped at %s, return home", p) + voice_touch_active = False + show_home_icon(disp) + on_record_screen = False + on_home_screen = True + return False return True if on_home_screen: - ex, ey, ew, eh = get_exit_btn_rect() - pad = max(16, int(min(config.DISP_W, config.DISP_H) * 0.06)) - exit_zone = (ex - pad, ey - pad, ew + pad * 2, eh + pad * 2) - if not Touch.in_rect(p, exit_zone): + speak_btn = get_home_speak_btn_rect() + if speak_btn is not None and Touch.in_rect(p, speak_btn): + show_record_screen(disp, pressed=True) + on_record_screen = True + on_home_screen = False + logger.debug("home screen: speak tapped at %s", p) + return False + if on_record_screen: + mic_btn = get_mic_btn_rect() + if mic_btn is not None and Touch.in_rect(p, mic_btn): voice_touch_active = True return False @@ -124,6 +139,7 @@ async def wait_install_decision() -> str: show_home_icon(disp) on_home_screen = True + on_record_screen = False recorder = audio.Recorder(sample_rate=SAMPLE_RATE, channel=AUDIO_CHANNELS) recorder.volume(RECORDER_VOLUME) @@ -190,12 +206,13 @@ async def transcribe_audio(pcm_all: np.ndarray) -> str | None: logger.error("Transcription failed: %s", e) return "" - async def stream_agent_until_interrupt(text: str) -> tuple[str, list[str], bool]: + async def stream_agent_until_interrupt(text: str) -> tuple[str, list[str], bool, str | None]: logger.debug("Asking PicoClaw...") tool_names: list[str] = [] fragments: list[str] = [] answer_started = False interrupted = False + interrupt_reason: str | None = None start_anim(animate_thinking(disp, tool_names)) @@ -235,8 +252,9 @@ async def wait_key_interrupt(): async def wait_exit_interrupt(): while not app.need_exit(): - if exit_button_tapped(): - logger.debug("streaming: exit tapped") + p = touch.consume_press() + if p is not None and Touch.in_rect(p, get_exit_btn_rect()): + logger.debug("streaming: back tapped at %s", p) return True await asyncio.sleep(0.03) return False @@ -258,13 +276,15 @@ async def wait_exit_interrupt(): if exit_task in done: interrupted = True - logger.debug("PicoClaw exited via touch, returning home") + interrupt_reason = "back_to_record" + logger.debug("PicoClaw exited via touch, returning record screen") try: await agent.close() except Exception as e: logger.debug("agent.close on exit: %s", e) elif interrupt_task in done and not stream_task.done(): interrupted = True + interrupt_reason = "key_interrupt" logger.debug("PicoClaw interrupted, ready for next input") try: await agent.close() @@ -282,38 +302,42 @@ async def wait_exit_interrupt(): if not answer_started: stop_anim() - return current_answer(), tool_names, interrupted + return current_answer(), tool_names, interrupted, interrupt_reason - async def _active_cycle(): + async def _active_cycle() -> str: """Run one complete voice interaction cycle.""" try: pcm_all = await record_audio_until_release() if pcm_all is None: - return + return "record" result = await transcribe_audio(pcm_all) stop_anim() if result is None: - return + return "record" if not result: await show_no_speech(disp) - return + return "record" - answer, _tool_names, interrupted = await stream_agent_until_interrupt(result) + answer, _tool_names, interrupted, interrupt_reason = await stream_agent_until_interrupt(result) if interrupted: - return + if interrupt_reason == "back_to_record": + return "record" + return "home" if answer: logger.debug("PicoClaw response: %s", answer) else: await show_error(disp, "No response") - return + return "record" while not key.is_pressed() and not app.need_exit(): if exit_button_tapped(): - return + return "home" await asyncio.sleep(0.05) + return "record" + finally: try: stop_anim() @@ -324,14 +348,25 @@ async def _active_cycle(): while not app.need_exit(): if exit_button_tapped(): break - if not key.is_pressed() and not voice_touch_active: + + if on_home_screen: + await asyncio.sleep(0.03) + continue + + if on_record_screen and not key.is_pressed() and not voice_touch_active: await asyncio.sleep(0.05) continue - on_home_screen = False - await _active_cycle() - show_home_icon(disp) - on_home_screen = True + on_record_screen = False + next_screen = await _active_cycle() + if next_screen == "record": + show_record_screen(disp) + on_record_screen = True + on_home_screen = False + else: + show_home_icon(disp) + on_home_screen = True + on_record_screen = False except KeyboardInterrupt: logger.info("Exit") diff --git a/projects/app_picoclaw/ui.py b/projects/app_picoclaw/ui.py index 5410505f..2a38e776 100644 --- a/projects/app_picoclaw/ui.py +++ b/projects/app_picoclaw/ui.py @@ -57,6 +57,8 @@ def _draw_exit_button(canvas) -> None: # Cached install button hit rect, computed in _build_splash when button=True. _install_btn_rect: tuple[int, int, int, int] | None = None +_home_speak_btn_rect: tuple[int, int, int, int] | None = None +_mic_btn_rect: tuple[int, int, int, int] | None = None def get_install_btn_rect() -> tuple[int, int, int, int] | None: @@ -64,6 +66,16 @@ def get_install_btn_rect() -> tuple[int, int, int, int] | None: return _install_btn_rect +def get_home_speak_btn_rect() -> tuple[int, int, int, int] | None: + """Return (x, y, w, h) of home 'PRESS TO SPEAK' button.""" + return _home_speak_btn_rect + + +def get_mic_btn_rect() -> tuple[int, int, int, int] | None: + """Return (x, y, w, h) of microphone hold-to-talk button.""" + return _mic_btn_rect + + def _build_splash(text: str, glow_rgb: tuple, core_rgb: tuple, button: bool = False, pressed: bool = False): global _install_btn_rect @@ -153,28 +165,91 @@ def _get_wifi_ip() -> str | None: def show_home_icon(disp) -> None: + global _home_speak_btn_rect ip = _get_wifi_ip() ip_label = f"http://{ip}:18800" if ip else "" - key = ("PRESS TO START", "home", ip_label) + key = ("PRESS TO SPEAK", "home_btn", ip_label) canvas = _home_cache.get(key) if canvas is None: - canvas = _build_splash("PRESS TO START", (40, 140, 220), (125, 225, 255)) + canvas = _build_splash("PRESS TO SPEAK", (40, 140, 220), (125, 225, 255), + button=True, pressed=False) + _home_speak_btn_rect = _install_btn_rect if ip_label: W, H = config.DISP_W, config.DISP_H - bottom_reserve = max(72, int(H * 0.22)) - image.set_default_font(FONT_NAME_LARGE) - _, th_title = image.string_size("PRESS TO START") - title_y = H - bottom_reserve + (bottom_reserve - th_title) // 3 + btn = _home_speak_btn_rect image.set_default_font(FONT_NAME) tw, th = image.string_size(ip_label) x = (W - tw) // 2 - gap = max(4, int(H * 0.1)) - y = title_y - th - gap + if btn is not None: + bx, by, bw, bh = btn + gap = max(10, int(H * 0.05)) + y = by - th - gap + else: + bottom_reserve = max(72, int(H * 0.22)) + y = H - bottom_reserve - th - max(6, int(H * 0.02)) canvas.draw_string(x, y, ip_label, image.Color.from_rgb(100, 160, 255)) _home_cache[key] = canvas disp.show(canvas) +def show_record_screen(disp, pressed: bool = False) -> None: + """Record page with a centered microphone button.""" + global _mic_btn_rect + key = ("record_screen", "pressed" if pressed else "idle") + canvas = _home_cache.get(key) + if canvas is None: + W, H = config.DISP_W, config.DISP_H + canvas = image.Image(W, H, image.Format.FMT_RGB888) + bg = image.Color.from_rgb(8, 12, 24) + canvas.draw_rect(0, 0, W, H, bg, thickness=-1) + + cx, cy = W // 2, int(H * 0.40) + r_outer = max(32, int(min(W, H) * 0.15)) + r_inner = max(24, int(r_outer * 0.72)) + ring = image.Color.from_rgb(90, 165, 255) + fill = image.Color.from_rgb(40, 95, 180) + + canvas.draw_circle(cx, cy, r_outer, ring, thickness=3) + canvas.draw_circle(cx, cy, r_inner, fill, thickness=-1) + + # Simple mic glyph (capsule + stem + base) + cap_w = max(12, int(r_inner * 0.52)) + cap_h = max(16, int(r_inner * 0.78)) + cap_x = cx - cap_w // 2 + cap_y = cy - int(cap_h * 0.62) + mic_col = image.Color.from_rgb(225, 240, 255) + canvas.draw_rect(cap_x, cap_y, cap_w, cap_h, mic_col, thickness=-1) + canvas.draw_circle(cx, cap_y, cap_w // 2, mic_col, thickness=-1) + stem_top = cap_y + cap_h + 2 + stem_h = max(8, int(r_inner * 0.35)) + canvas.draw_line(cx, stem_top, cx, stem_top + stem_h, mic_col, thickness=3) + base_w = max(16, int(r_inner * 0.8)) + by = stem_top + stem_h + 2 + canvas.draw_line(cx - base_w // 2, by, cx + base_w // 2, by, mic_col, thickness=3) + + image.set_default_font(FONT_NAME_LARGE) + title = "HOLD TO RECORD" + tw, _ = image.string_size(title) + canvas.draw_string((W - tw) // 2, int(H * 0.63), title, image.Color.from_rgb(190, 220, 255)) + + image.set_default_font(FONT_NAME) + hint1 = "Please configure asr first" + tw1, _ = image.string_size(hint1) + canvas.draw_string((W - tw1) // 2, int(H * 0.76), hint1, image.Color.from_rgb(140, 165, 200)) + + url = "https://wiki.sipeed.com/rvclaw" + url_lines, _ = _wrap(url, max_lines=2, max_w=max(1, W - 2 * max(6, int(W * 0.03)))) + for i, line in enumerate(url_lines): + tw2, _ = image.string_size(line) + canvas.draw_string((W - tw2) // 2, int(H * 0.87) + i * max(14, int(H * 0.05)), + line, image.Color.from_rgb(105, 130, 170)) + + _draw_exit_button(canvas) + _mic_btn_rect = (cx - r_outer, cy - r_outer, r_outer * 2, r_outer * 2) + _home_cache[key] = canvas + disp.show(canvas) + + def show_install_prompt(disp, pressed: bool = False) -> None: key = ("INSTALL PicoClaw", "install_pressed" if pressed else "install") canvas = _home_cache.get(key)