diff --git a/.gitignore b/.gitignore index b9a4bfe..80fb9e9 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,5 @@ selfdrive/ui/tests/test_ui/nav_demo_report/ # fetcher: closed-source precompiled component — ship only the obfuscated .so /iqpilot/models_private_src/fetcher.py + +/tools/iqmacvisiond/macos/dist/ diff --git a/SConstruct b/SConstruct index e044420..4304e8d 100644 --- a/SConstruct +++ b/SConstruct @@ -10,6 +10,13 @@ import SCons.Errors SCons.Warnings.warningAsException(True) +# scons only auto-loads a site dir named site_scons at the repo root; ours lives under tools/, +# so replicate what _load_site_scons_dir does (sys.path for site_tools imports + run site_init) +SITE_DIR = Dir('#tools/scons').abspath +if SITE_DIR not in sys.path: + sys.path.insert(0, SITE_DIR) +import site_init # noqa: F401 + # capnp's kj library warns when $PWD is stale (doesn't match the real cwd); keep them in sync os.environ.pop('PWD', None) @@ -49,6 +56,13 @@ assert arch in [ "Darwin", # macOS arm64 (x86 not supported) ] +# ffmpeg comes from the system (brew on macOS, distro packages elsewhere) rather than +# a vendored wheel, so it always needs the static-link deps. Exported so tools/ can +# take upstream's `ffmpeg_libs` form instead of hand-listing codecs per SConscript. +ffmpeg_libs = ['avformat', 'avcodec', 'avutil', 'x264', 'z'] +if arch != "Darwin": + ffmpeg_libs += ['va', 'va-drm', 'drm'] + env = Environment( ENV={ "PATH": os.environ['PATH'], @@ -100,7 +114,7 @@ env = Environment( COMPILATIONDB_USE_ABSPATH=True, REDNOSE_ROOT="#", tools=["default", "cython", "compilation_db", "rednose_filter"], - toolpath=["#site_scons/site_tools", "#rednose_repo/site_scons/site_tools"], + toolpath=["#tools/scons/site_tools", "#rednose_repo/site_scons/site_tools"], ) # Arch-specific flags and paths @@ -187,7 +201,7 @@ else: np_version = SCons.Script.Value(np.__version__) Export('envCython', 'np_version') -Export('env', 'arch') +Export('env', 'arch', 'ffmpeg_libs') # Setup cache dir default_cache_dir = os.environ.get('SCONS_CACHE_DIR') or ('/data/scons_cache' if arch == "larch64" else '/tmp/scons_cache') diff --git a/TOOLS_UPSTREAM_SYNC_PLAN.md b/TOOLS_UPSTREAM_SYNC_PLAN.md new file mode 100644 index 0000000..f701c70 --- /dev/null +++ b/TOOLS_UPSTREAM_SYNC_PLAN.md @@ -0,0 +1,433 @@ +# Upstream sync plan — `tools/cabana` and `tools/jotpluggler` + +Hand-port the current upstream (commaai/openpilot) versions of Cabana and jotpluggler into +iqpilot without losing konn3kt integration. iqpilot does not use comma connect: every route +list, route-file listing, JWT and live-CAN path goes through `api-iqlabs.konn3kt.com`, and the +DBC source is `iqdbc`, not `opendbc_repo`. + +Reference clone used for this analysis: `/tmp/openpilot-upstream` @ `e7b1ee3a5` +("jp: fix segment range parsing", 2026-08-05). + +--- + +## 1. Where we actually are + +Baselines were established by blob-matching every iqpilot file against upstream history, not by +reading commit messages. + +| Tool | iqpilot baseline | Upstream HEAD | Gap | +|---|---|---|---| +| `tools/cabana` | `bcdeec313` (2025-12-16, #36884) | `e7b1ee3a5` (2026-08-05) | **53 commits, ~8 months** | +| `tools/jotpluggler` | `61608db78` (2026-07-15, #38353) | `e7b1ee3a5` | **6 commits, ~3 weeks** | +| `tools/plotjuggler` | `a46ff01ca` (2026-01-19) | `e7b1ee3a5` | 14 commits (secondary scope) | + +Cabana is the real work. jotpluggler was hand-ported in May–July 2026 (`4b68073c4`, `b26218e85`, +`2cd89a198`) and is nearly current — most of its files are **byte-identical** to upstream HEAD. + +A critical structural fact: upstream moved the whole tree under a nested `openpilot/` package +(`#38219`/`#38220`/`#38223`, 2026-06-21) and moved the HAL to `common/hardware/` (`#38202`). +iqpilot has **not** adopted either. Every ported file needs a de-prefix pass: + +- `#include "openpilot/cereal/..."` → `"cereal/..."` +- `#include "common/hardware/hw.h"` → `"system/hardware/hw.h"` +- `#include "json11/json11.hpp"` → `"third_party/json11/json11.hpp"` +- `repo_root() / "openpilot" / "tools" / ...` → `repo_root() / "tools" / ...` +- SCons nodes `#openpilot/common`, `#openpilot/cereal` → `#common`, `#cereal` +- `opendbc_repo/opendbc/dbc` → `iqdbc/dbc`; `#opendbc_repo` → `#iqdbc_repo` + +--- + +## 2. konn3kt invariants — the contract the port must not break + +These are the only genuinely IQ-owned surfaces. Everything else in both trees is upstream code we +are behind on. **Anything not on this list should be taken from upstream verbatim** (modulo §1). + +### Cabana + +| File | What is IQ-owned | Disposition | +|---|---|---| +| `utils/api.{cc,h}` | `CommaApi::BASE_URL = API_HOST ?: https://api-iqlabs.konn3kt.com`; RSA-signed device JWT; Qt `HttpRequest` | **Delete** — fold into `tools/replay/api.{cc,h}` (§3.A) | +| `streams/routes.{cc,h}` | konn3kt device list + route listing | Rewrite on upstream HEAD shape, konn3kt endpoints | +| `konn3kt_canproxy.py` | `canlived → konn3kt relay → local ZMQ → Cabana` proxy | Keep; must stay wired to the Device/ZMQ stream (§3.C) | +| `SConscript` | iqdbc paths, non-nested paths, `PrettyAction`, extra libs | Rebase onto upstream, re-apply IQ deltas | +| `mainwin.cc` | "Load DBC from commaai/**iqdbc**" menu | Re-apply rename only | +| `dbc/generate_dbc_json.py` | `from iqdbc.car import ...` | Re-apply rename only | +| `assets/assets.cc` (tracked) | bootstrap-icons LFS bypass | See §3.K | +| `dbc/car_fingerprint_to_dbc.json` (+`.log`) | tracked build product | Keep tracked | +| `.gitignore`, `README.md` | binary name, iqdbc, konn3kt route examples | Re-apply | + +### jotpluggler + +All IQ deltas are already carrying `// IQ.Pilot patch:` comments — preserve that convention. + +| File | IQ patch | +|---|---| +| `SConscript` | `.venv` site-packages injection; `iqdbc.dbc.generator.generator.create_all()` instead of upstream `get_generated_dbcs()`; no `bootstrap_icons` module; explicit `avformat/avcodec/avutil/x264/yuv/z/bz2/zstd/curl/ssl/crypto` + `OpenCL`/`va`/`va-drm`/`drm`; `PrettyAction`; guards `imgui.MESA_DIR` existence | +| `icons.cc` | vendored `tools/jotpluggler/assets/bootstrap-icons.ttf` (third_party/bootstrap is LFS, TTF not checked in) | +| `common.cc` | iqpilot `common/util.h` has no `check_system`; inline `std::system` + stderr | +| `runtime.cc` | no `common/yuv.h`; vendored libyuv lacks `NV12ToABGR` → `NV12ToARGB` + `ARGBToABGR` | +| `sketch_layout.cc` | `PyDownloader::getRouteFiles` → `CommaApi2::httpGet(BASE_URL + "/v1/route//files")`; `androidLog` (cereal not renamed); iqdbc paths | +| `app.{cc,h}`, `layout.cc`, `layout_io.cc`, `map.cc`, `custom_series.cc` | path/include de-prefixing; `LogOrigin::Android`; iqdbc | +| `dbc.h` | empty-multiplex guard (`mux.empty() ? 0 : std::stoi(mux)`) — a genuine bugfix, **upstream this** | +| `generate_event_extractors.py` | `getBusTimeDEPRECATED()` (iqpilot cereal keeps a flat field, not upstream's `deprecated :group`) | +| `pluggle.py` | iqpilot-only launcher (upstream has none) | + +--- + +## 3. Conflict matrix — upstream change vs konn3kt + +### A. File downloading / API moved from C++ to Python (`#37497`, `#38430`) — **highest impact** + +Upstream cabana HEAD calls `PyDownloader::getDevices()`, `getDeviceRoutes()`, `getRouteFiles()` +(`tools/replay/py_downloader.h`), which shell into `openpilot/tools/lib/`. iqpilot's replay has +**no** `py_downloader`; it keeps a C++/libcurl path in `tools/replay/api.{cc,h}` +(`CommaApi2::BASE_URL`, `create_token(use_jwt)`, `httpGet`). jotpluggler already bridges this gap +in `sketch_layout.cc` — **reuse that pattern**, don't invent a second one. + +Resolution: extend `tools/replay/api.{cc,h}` with + +``` +std::string getDevices(); // GET /v1/me/devices/ +std::string getDeviceRoutes(dongle_id, start_ms, end_ms, preserved); + // GET /v1/devices//routes_segments?start=&end= + // GET /v1/devices//routes/preserved +std::string getRouteFiles(route); // GET /v1/route//files (already reachable via httpGet) +``` + +returning the same JSON shapes upstream's `PyDownloader` returns, then port upstream's `routes.cc` +verbatim with `PyDownloader::` → `CommaApi2::`. Upstream's HEAD `routes.cc` is already Qt-free +(json11 + `strptime`/`strftime` for the preserved-route ISO timestamps) — that code ports as-is. + +Auth semantics to preserve: `create_token(!Hardware::PC())` — device RSA-JWT on device, +`~/.comma/auth.json` `access_token` on a PC. Upstream's Python path only ever does the latter. + +Consequence: `tools/cabana/utils/api.{cc,h}` (Qt `QNetworkAccessManager` + duplicate JWT code) +**gets deleted**, which drops `Qt5Network` from the cabana link and converges with §3.B. + +### B. de-Qt: `#37519`, `#37521`, `#37522`, `#37523`, `#38357`, `#38359`, `#38360` + +The bulk of the 53-commit gap. `QString`→`std::string` through the DBC core, new Qt-free +`core/{can_data,color,message_id,settings}.h`, Qt/core split in `dbc/dbcqt.{cc,h}`, QtXml dropped +(bootstrap SVG symbol extraction hand-rolled in `utils/util.cc`), QtConcurrent dropped, QtSerialBus +dropped (SocketCAN now raw `linux/can` sockets, `#37553` excludes it on macOS). + +**Zero konn3kt content.** Pure upstream adoption. It is also why almost every cabana file shows a +large diff — those are not IQ changes, they are upstream changes we lack. + +Net effect on `SConscript`: `qt_modules` goes from +`["Widgets","Gui","Core","Network","Concurrent","DBus","Xml"]` → `["Widgets","Gui","Core"]`, +and `Qt5SerialBus` / `QtSerialBus` framework drop out. + +### C. `--zmq` bridge path (`#38484`) — **breaks konn3kt_canproxy if ported blind** + +Upstream HEAD `DeviceStream::start()` forks `cereal/messaging/bridge /"can/"` and +`streamThread()` then always subscribes on `127.0.0.1`. `bridge ` is +`zmq_to_msgq` (see `cereal/messaging/bridge.cc:60`) — it ZMQ-subscribes from `` and republishes +to local msgq. + +iqpilot's documented remote-CAN workflow is the opposite direction: +`konn3kt_canproxy.py` sets `ZMQ=1` and **publishes** capnp `Event` frames on a local ZMQ `can` +socket; the user then picks *Live → Device → ZMQ → 127.0.0.1*. Under upstream HEAD semantics that +forks a bridge which subscribes to the same loopback endpoint the proxy publishes on and +republishes into msgq, while cabana reads ZMQ — the proxy path stops being the thing cabana reads. + +Resolution: keep the pre-`#38484` direct-attach semantics as an explicit IQ escape hatch. Port +upstream's bridge-forking code, but subscribe to `zmq_address` directly (no bridge fork) when the +stream is started in "attach to an existing ZMQ publisher" mode — either by treating loopback +addresses as attach-mode, or by adding a third radio button next to MSGQ/ZMQ. Mark it +`// IQ.Pilot patch:` and state why (konn3kt_canproxy). **This must be re-verified end-to-end on a +real device before the sync is called done** (§5.3). + +### D. libyuv removed, `common/yuv.h` added (`#38306`) + +jotpluggler already patches around this. Cabana's `cameraview.cc` does NV12 conversion too. +Decision: port `common/yuv.h` into iqpilot once, then **delete** the `runtime.cc` IQ patch and take +upstream's `cameraview.cc` verbatim. One-time cost, removes a recurring patch site. + +### E. Vendored native deps via `comma-deps-*` wheels (`#37327`, `#37994`, `#37681`, `#38308`) + +Upstream `SConstruct` now exports `ffmpeg_libs` and pulls capnproto/ffmpeg/zstd/zeromq/json11/ +libusb/imgui/bootstrap-icons from pip wheels. iqpilot pins `imgui`/`libusb` from +`git.konn3kt.com/IQ.Lvbs/dependencies`, vendors json11 in `third_party/json11`, and has **no** +`bootstrap_icons` module and **no** `ffmpeg_libs` export. + +Resolution: add an `ffmpeg_libs` list + `Export` to iqpilot's `SConstruct` mirroring upstream's +(`avformat avcodec swresample avutil` [+ `x264 z` [+ `va va-drm drm`]]) so both tool SConscripts can +take upstream's form nearly verbatim instead of hand-listing libs. Keep `third_party/json11`. +Do **not** chase the wheels. + +### F. `androidLog` → `operatingSystemLog` (`#38209`) + +iqpilot cereal still has `androidLog`. Keep the existing `sketch_layout.cc` / `app.h` IQ patch. +Renaming cereal is out of scope (it is a schema change with device-side blast radius). + +### G. Tests: catch2 dropped (`#38408`), unittest conversion (`#38387`, `#38384`) + +Upstream deleted `tools/cabana/tests/test_runner.cc` and split a deliberately Qt-free +`tests/test_dbc_core` target (objects built from the **base** env, linking no Qt — it exists +specifically to stop Qt creeping back into the DBC core). Adopt that shape; drop `test_runner.cc`. +jotpluggler gains `test_jotpluggler.py`. + +### H. `cabana` launcher script + `_cabana` binary (`#37814`, `c02cf706a`, `05cf8023a`) + +Upstream builds `Program('_cabana')` and ships `tools/cabana/cabana` as a bash launcher that +installs Qt if missing and runs `scons -u openpilot/tools/cabana/_cabana openpilot/cereal/messaging/bridge`. +iqpilot builds `Program('cabana')` — so `tools/cabana/cabana` is currently a **committed binary**. +Adopting upstream's split means: `.gitignore` `cabana` → `_cabana`, and the launcher's scons +targets de-prefixed to `tools/cabana/_cabana cereal/messaging/bridge`. + +### I. `assets.cc` / bootstrap icons (`#37994`, `71290f380`, `15267e408`) + +Upstream generates `assets/assets.cc` at build time (`rcc` over an `assets.generated.qrc` that +interpolates the packaged bootstrap-icons SVG path) and gitignores it. iqpilot tracks the generated +14.9k-line `assets.cc` because the icons package isn't available. + +Recommended: vendor the bootstrap-icons **SVG** next to the already-vendored TTF +(`tools/jotpluggler/assets/bootstrap-icons.ttf`), add a tiny local shim module exposing +`SVG_PATH`/`TTF_PATH`, adopt upstream's generation, and untrack `assets.cc`. This also lets +jotpluggler's `icons.cc` IQ patch collapse back to upstream's `BOOTSTRAP_ICONS_TTF` define. +Acceptable fallback: keep tracking `assets.cc` and keep both patches. + +--- + +## Status + +**Phases 0–2 are done and landed.** Both tools are at upstream `e7b1ee3a5` (2026-08-05) with +konn3kt preserved. Departures from the plan as written, and what remains: + +- §3.I resolved better than planned: `third_party/bootstrap/bootstrap-icons.svg` turned out to be + **tracked already**, so upstream's build-time embedding was adopted as-is — no vendored SVG, and + the 14.9k-line generated `assets.cc` is gone (it was never tracked; it was a gitignored build + product). +- §3.D done both ways: `common/yuv.{cc,h}` ported, and `tools/replay/framereader.cc` moved off + libyuv too, so the jotpluggler `runtime.cc` patch disappeared entirely. +- Unplanned dependency found during the port: upstream's `#38430` also added download/decompress/ + parse instrumentation to `LogReader`, which jotpluggler's load-stats panel reads. Ported into + iqpilot's `LogReader` around its own FileReader/decompress steps (so decompress is timed + separately, unlike upstream where the Python downloader does it inline). +- §3.C landed as a **three-mode** `DeviceStream` (`Msgq` / `Zmq` / `Bridge`) rather than an + escape hatch. Upstream's ZMQ path forks a bridge that republishes to **msgq** while still + setting `ZMQ=1` and subscribing over **ZMQ** — that combination reads nothing. Keeping the modes + explicit preserves upstream's bridge convenience *and* the direct attach `konn3kt_canproxy.py` + needs, and fixes that inconsistency. `--bridge ` is a new flag. +- Also de-comma'd beyond the plan: jotpluggler's "Useradmin" / "comma connect" buttons became a + single **konn3kt** link (`KONN3KT_APP_HOST`, default `https://konn3kt.com`, same + `/` path shape `tools/lib/logreader.py` already parses). + +### Phase 3 verification actually performed (macOS arm64, live konn3kt) + +- **konn3kt API, live, real credentials** — every endpoint the port builds returns 200 with real + data: `/v1/me/devices/` (2 devices), `/v1/devices//routes_segments?start=&end=` (1776 + routes), `/v1/devices//routes/preserved` (16), `/v1/route//files` (48 logs / 48 qlogs + / 48 qcameras). +- **Real route in the real GUI** — `_cabana --qcam 0f53129ed44f6920|000001a5--07db1da02e` loaded + **48 valid segments** and came up as a foreground Cocoa app (not offscreen). +- **Live CAN over the ZMQ attach** — a local publisher mimicking `konn3kt_canproxy.py` fed + `_cabana --zmq 127.0.0.1`; cabana ingested **2179 CAN frames** and wrote them to + `~/cabana_live_stream/.../rlog`. This is the exact path `#38484` would have broken. +- **Fixed while verifying:** `utils::icon()` painted a null QPixmap whenever an empty icon id was + requested (`ToolButton("")` in `chartswidget.cc`), logging two `QPainter` warnings on every + dark-theme macOS start. Pre-existing upstream bug, not a port regression; guarded here and worth + upstreaming alongside the `dbc.h` mux fix. + +**Still outstanding:** +- **Phase 3.3 (device half):** the konn3kt relay accepts the websocket and authorizes the stream, + but `0f53129ed44f6920` emits no frames because `canlived` only runs while the `CanLiveStreaming` + param is set. Needs a device with that enabled to close the loop. +- **Phase 3.5:** cabana has not been built for larch64. +- Charts/video widgets were not visually inspected — `screencapture` is blocked by macOS + screen-recording permission for the terminal, so GUI rendering is evidenced by process state and + the ingested-frame log rather than by a screenshot. + +Tests added: `tools/replay/tests/test_api.cc` (konn3kt endpoint paths + error envelope), +`tools/cabana/test_cabana_konn3kt.py`, `tools/jotpluggler/test_jotpluggler.py`, and +`tools/test_tools_local_route.py` (synthesizes a local route and drives both tools headlessly). + +## 4. Execution plan + +Land each phase as its own commit. Build after every sub-step — do not batch §4.2. + +### Phase 0 — infrastructure (no tool code) + +0.1 `SConstruct`: add and `Export` `ffmpeg_libs`. +0.2 Port `common/yuv.h`; drop the `runtime.cc` libyuv patch. +0.3 Vendor bootstrap-icons SVG + local `bootstrap_icons` shim (`SVG_PATH`, `TTF_PATH`). +0.4 `tools/replay/api.{cc,h}`: add `getDevices()`, `getDeviceRoutes()`, `getRouteFiles()` against + the konn3kt endpoints, matching upstream `PyDownloader` JSON shapes. + +**Gate:** `scons -u -j8 tools/replay` clean; a scratch binary lists real devices from +`api-iqlabs.konn3kt.com` with both a device JWT and a PC `auth.json` token. + +### Phase 1 — jotpluggler (6 commits; do first, it validates the whole pattern cheaply) + +1.1 `3f49e2d33` thumbnail source — add `thumbnail.{cc,h}`; `common.h` `kSpecialItemSpecs` 5→6; + `common.cc` `PaneKind::Thumbnail`; `layout_io.cc` `"thumbnail"`; `app.{cc,h}` `ThumbnailView` + + `ThumbnailFrame`; `session.cc` `setThumbnails`. Re-apply the json11/path/`Android` patches. +1.2 `576de9c7e` build speedup — take upstream `generate_event_extractors.py` **wholesale** + (iqpilot's copy has diverged: `event_base_slots`, `static_enums`, single-use scalar getters) + and re-apply only the `getBusTimeDEPRECATED()` patch. +1.3 `e7b1ee3a5` segment-range parsing — empty-begin guard in `sketch_layout.cc`. +1.4 `6b47a5b6b` decompress-in-downloader — iqpilot has no `PyDownloader`; confirm the replay-side + decompression path is unaffected (expected no-op). +1.5 `fef29ad22`/`911f07ee8` — add `test_jotpluggler.py`. + +**Gate:** `scons -u -j8 tools/jotpluggler`; `./tools/jotpluggler/pluggle.py ` opens, +thumbnails render, CAN series decode against iqdbc, a saved layout round-trips. + +### Phase 2 — cabana (53 commits). Bottom-up, one layer per build. + +2.1 **DBC core** — `dbc/{dbc,dbcfile,dbcmanager}.{cc,h}`, new `core/{can_data,color,message_id,settings}.h`, + new `dbc/dbcqt.{cc,h}`. Re-apply iqdbc rename in `dbc/generate_dbc_json.py`. +2.2 **Streams** — `abstractstream`, `livestream`, `replaystream`, `pandastream`, `socketcanstream` + (raw `linux/can`, macOS-excluded), `devicestream`. Re-apply the §3.C canproxy escape hatch. +2.3 **Routes** — port upstream HEAD `streams/routes.{cc,h}`, swap `PyDownloader::` → `CommaApi2::`. + **Delete `utils/api.{cc,h}`.** +2.4 **Widgets** — `binaryview`, `historylog`, `videowidget`, `signalview`, `detailwidget`, + `messageswidget`, `mainwin` (re-apply the iqdbc menu label), `streamselector`, `commands`, + `settings`, `cameraview`, `chart/*`, `tools/*`, `utils/{util,export,elidedlabel}`. +2.5 **SConscript** — rebase on upstream; re-apply: non-nested paths, `iqdbc/dbc` + + `#iqdbc_repo`, `PrettyAction` wrappers, `third_party/json11`, `ffmpeg_libs` from Phase 0.1. + Qt modules collapse to `Widgets/Gui/Core` (+Charts). +2.6 **Tests** — adopt the Qt-free `tests/test_dbc_core` target; drop `tests/test_runner.cc`. +2.7 **Launcher** — adopt `cabana` bash script + `Program('_cabana')`; de-prefix its scons targets; + fix `.gitignore` (`cabana` → `_cabana`, `*.generated.qrc`, `bootstrap_icons.cc`). + +**Gate (each sub-step):** `scons -u -j8 tools/cabana` clean, no new warnings. + +### Phase 3 — konn3kt verification (the part that actually proves the sync) + +3.1 Remote-routes dialog against `api-iqlabs.konn3kt.com`: device list populates; all five periods + (7d / 14d / 30d / 6mo / **Preserved**) return routes with correct start times and durations. + Preserved uses ISO timestamps, the rest use `*_utc_millis` — both paths must be exercised. +3.2 Open a konn3kt route end-to-end: route-file listing, log download + decompression, qcam/fcam + video, iqdbc DBC load, chart, CSV export. +3.3 **Remote live CAN:** `canlived` on device → konn3kt relay → `konn3kt_canproxy.py` on laptop → + Cabana *Live → Device → ZMQ → 127.0.0.1*. Frames must decode. Per prior work this is the + untested leg of the canlive feature — run it on `gutek-a1`. +3.4 Local live CAN: panda stream, MSGQ device stream, SocketCAN (Linux only). +3.5 larch64 device build, if cabana is still expected to build on-device. + +### Phase 4 — plotjuggler (secondary, 14 commits) + +Only `juggle.py`, `layouts/`, `README.md` diverge, and the only IQ delta is the `iqdbc.car.fingerprints` +import plus non-nested paths. Cheap; fold in after Phase 3 or skip. + +--- + +## 5. Risks and rules + +- **Don't batch Phase 2.** ~40 files change; a single "port everything then build" pass produces an + unbisectable wall of link errors. One layer, one build. +- **`git add` only ported files.** The tree currently contains built artifacts (`*.o`, `*.a`, + `moc_*.cc`, `tools/cabana/cabana`, `tools/jotpluggler/jotpluggler`, + `generated_event_extractors.h`, `generated_dbcs/`). Never `git add -A` here. +- `dbc/car_fingerprint_to_dbc.json` is a tracked build product regenerated by scons — expect churn + on every build; keep it tracked so cabana doesn't need iqdbc importable at runtime. +- Dropping `Qt5Network/Concurrent/DBus/Xml/SerialBus` changes the larch64 link set; verify the + device build before assuming the SConscript is done. +- §3.C is the one place where a faithful upstream port actively regresses a konn3kt feature. If + Phase 3.3 can't be run on hardware in this pass, **do not** land 2.2 with upstream's bridge fork + as the only ZMQ path. +- Upstream candidate: the `dbc.h` empty-multiplex-indicator guard is a real upstream bug fix worth + sending back. + +## 6. Explicit non-goals + +- Adopting upstream's nested `openpilot/` tree layout. +- Migrating iqpilot's replay to `PyDownloader` / `tools/lib` Python downloading. +- Renaming cereal `androidLog` → `operatingSystemLog`. +- Converging on the `comma-deps-*` pip wheels. + +--- + +## Appendix A — cabana commits to port (`bcdeec313..e7b1ee3a5`, 53) + +``` +4cfdcea1d 2026-07-28 cabana: fix macOS build and --zmq bridge path (#38484) <- §3.C +6b47a5b6b 2026-07-23 tools: decompress in the python downloader (#38430) <- §3.A +75d590fb9 2026-07-21 replace catch2 tests for 20% faster builds (#38408) <- §3.G +e124d6df9 2026-07-19 more dead code gc +a04c045cd 2026-07-17 cabana: de-Qt, part 3 (#38360) <- §3.B +5d23a78c7 2026-07-16 cabana: de-Qt, part 2 (#38359) <- §3.B +06a73f538 2026-07-16 cabana: de-Qt, part 1 (#38357) <- §3.B +39117a587 2026-07-08 remove submodule symlinks (#38312) +f283f6703 2026-07-08 Revert "try removing submodule symlinks (#38310)" +b827c0f55 2026-07-08 try removing submodule symlinks (#38310) +1e49eac4d 2026-07-08 rm libyuv (#38306) <- §3.D +9877f6ac0 2026-07-08 ffmpeg: use shared libraries (#38308) <- §3.E +05cf8023a 2026-07-07 fix cabana <- §3.H +c02cf706a 2026-07-07 fix cabana launch script <- §3.H +5edc0bd89 2026-06-21 mv root dirs into nested openpilot (#38219) <- §1 (de-prefix) +20e0f21b5 2026-06-21 prefix paths with openpilot (#38223) <- §1 (de-prefix) +37eda06c9 2026-06-21 move cereal/ into nested openpilot (#38220) <- §1 (de-prefix) +bfd8d4868 2026-06-06 cabana: fix "seperated" typo in findsignal placeholders (#38142) +5408c86b7 2026-05-28 Cabana: Fixed internal typos and method casing (#38099) +15267e408 2026-05-11 cabana: gitignore generated file <- §3.I +bea893820 2026-05-10 use packaged bootstrap icons (#37994) <- §3.I +bd1c7f39e 2026-05-07 scons build cleanups (#37981) +0584a5f5e 2026-04-12 add bridge target to cabana run script (#37814) <- §3.H +31e4fe55a 2026-03-22 tools: setup ffmpeg hwaccel (#37718) +a8b5c7450 2026-03-21 prep for imgui tools (#37712) +240e0036d 2026-03-20 macOS: fix build (#37686) +a68ea44af 2026-03-14 cabana: use vendored libusb from commaai/dependencies (#37681) <- §3.E +5e7f5dd84 2026-03-14 replay/cabana: remove unused openssl dependency (#37680) +ee9da82aa 2026-03-13 cleanup build paths (#37667) +71290f380 2026-03-08 cabana: gitignore assets.cc <- §3.I +e42ee228c 2026-03-08 gitignore cleanups (#37615) +5e1a576f3 2026-03-07 cabana: exclude SocketCAN on macOS (#37553) <- §3.B +0c452dbaf 2026-03-03 cabana: fix right pane width limitation (#37527) +06b2c68e0 2026-03-01 macOS: fix cabana builds (#37518) +3478ac133 2026-03-01 cabana: remove QtSerialBus (#37523) <- §3.B +ce04d25f7 2026-03-01 cabana: remove QtConcurrent (#37522) <- §3.B +0c7abf385 2026-03-01 cabana: remove QtXml (#37521) <- §3.B +0b9ab8bb9 2026-03-01 cabana: replace Qt types with stdlib (#37519) <- §3.B (largest) +885658512 2026-02-28 new demo route (#37457) +e7cc70f3f 2026-02-28 consolidate file downloading from C++ to Python (#37497) <- §3.A +276713ddf 2026-02-27 add back bz2 support with vendored bzip2 (#37459) +238fca233 2026-02-25 tools: fix darwin compile errors (#37399) +8810948ec 2026-02-24 CI: ensure no brew (#37387) +76d084d87 2026-02-23 switch to system compilers (GCC on Linux, Apple Clang on macOS) (#37355) +f4a36f7f7 2026-02-22 rm cpp bz2 (#37332) +4bffe422e 2026-02-22 vendor capnproto and ffmpeg via dependencies repo (#37327) <- §3.E +c98ba4ff4 2026-02-20 Qt is optional (#37295) +037e6e749 2026-02-17 cabana: fix crash when zmq address is used (#37222) +af1583cdf 2026-02-12 Reapply tgwarp w NV12 fix (#37168) +45099e7fc 2026-02-10 Revert tgwarp again (#37161) +667f3bb32 2026-02-07 Revert "revert tg calib and opencl cleanup (#37113)" (#37115) +51312afd3 2026-02-07 revert tg calib and opencl cleanup (#37113) +d5cbb89d8 2026-02-06 Remove all the OpenCL (#37105) +``` + +## Appendix B — jotpluggler commits to port (`61608db78..e7b1ee3a5`, 6) + +``` +e7b1ee3a5 2026-08-05 jp: fix segment range parsing +6b47a5b6b 2026-07-23 tools: decompress in the python downloader (#38430) +576de9c7e 2026-07-21 speed up jotpluggler build (#38406) +911f07ee8 2026-07-21 convert tests to unittest (#38387) +fef29ad22 2026-07-19 start porting tests to unittest style (#38384) +3f49e2d33 2026-07-18 jp: add thumbnail source (#38363) +``` + +## Appendix C — how the baselines were established + +Blob-hash matching, not commit archaeology: for each iqpilot file, walk upstream history +newest-first and report the newest commit whose blob for that path is byte-identical. Files with no +match are IQ-modified or IQ-new; the minimum over the untouched files bounds the fork point. + +``` +cd /tmp/openpilot-upstream +git log --format='%H %ad %s' --date=short -30 -- openpilot/tools/cabana tools/cabana > /tmp/cab_commits.txt +for f in $(cd $LOCAL/tools/cabana && find . -type f \( -name '*.cc' -o -name '*.h' \) -not -name 'moc_*' | sed 's|^\./||' | sort); do + h=$(git hash-object "$LOCAL/tools/cabana/$f") || continue + while read -r c d rest; do + b=$(git rev-parse "$c:openpilot/tools/cabana/$f" 2>/dev/null || git rev-parse "$c:tools/cabana/$f" 2>/dev/null) + [ "$b" = "$h" ] && { echo "$f -> ${c:0:9} $d $rest"; break; } + done < /tmp/cab_commits.txt +done +``` + +Cross-checked against the last upstream-authored commits in iqpilot's own history +(`bcdeec313 Reduce pub-sub memory usage by 10x (#36884)` for cabana) and against feature markers +(`virtual std::string routeName` first appears in `0b9ab8bb9`, 2026-03-01 — absent from iqpilot, +confirming the fork predates it). diff --git a/artifacts/iqpilot_hephaestusd_private/manifest.json b/artifacts/iqpilot_hephaestusd_private/manifest.json index b454dfe..ca0bc89 100644 --- a/artifacts/iqpilot_hephaestusd_private/manifest.json +++ b/artifacts/iqpilot_hephaestusd_private/manifest.json @@ -16,27 +16,27 @@ }, "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "2d4cfb58875fee44a7baea50946fb8b31feb5ceb0daf61598b032634f336363e", + "sha256": "9368086c4cdd6cf46b6a6d0a1e1e83fdc7e7dff8d3bd67a42b3bffa91b45dc26", "size": 135536 }, "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "903174fad1ee61fe959be5c9401b8d10e2edd05eadfa02e19e4cd599aa1e464d", + "sha256": "291789f912304c72cd1e0da955bd58fa903cd99e7d6504e6c3c51d39befaf6e8", "size": 67664 }, "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "fd38412866cefa4be7777b7573b46e57665756f0d4d7decdfb189a8dafae8067", + "sha256": "c47ec079040efee31673f55752a6215b3a8908c63c8373625ce5cb4cb1177aff", "size": 204624 }, "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "c95f786dbff8484c2ad63ed04fa37bcf0bdaf16f0b9f80d37e5d1e8e5b224554", + "sha256": "a0d5710938b32e56f2fc55c86ff8947787a3ebeb43850a94a90202c3773ff74e", "size": 69752 }, "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "88b9a944da9cfbf6fe043ee685d4cd04e900b34996e69ab2eb7cedd807798854", + "sha256": "ff345aa71ac599bdd16747368ceb92fae7b5b35657f81466fc0660b76d44aa22", "size": 203072 }, "python/iqpilot_private/konn3kt/flockd/__init__.py": { @@ -46,12 +46,12 @@ }, "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "83336a6b02ffdb451297e3d4f6b586fa764f87195dfe6094805a4a18fcd9d279", + "sha256": "2aa26e4bc71870f639fca6e8e273deb59f50cf6a6d963ba77ce0949c65a52d63", "size": 202168 }, "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "563e6dd93009fd5e99de5d6abebbed20a7020683a8a0266e3c1a1f9d6b5f2889", + "sha256": "96b47ec40dd7e3430cebf3979b4fdfb0096e80029b40da6445cdf44dbcdb8917", "size": 68080 }, "python/iqpilot_private/konn3kt/hephaestus/__init__.py": { @@ -61,67 +61,67 @@ }, "python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip": { "mode": 420, - "sha256": "dc0184fd4971a040ca4ca332b7bcf6b2ee6b8040804c90d3efc3f2806359106c", + "sha256": "f12e44b7a986616d6b3c4e127e4eeae793e346f31d5b821ad9c44249e4731ebd", "size": 2100238 }, "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "8036f724630003afda855cf293f098cc3bb50801a5ca7626c3b57d4556605f5a", + "sha256": "4ac192d102bde18d9b3215ff637e1c316a08eb0eb00c595c07eaa07284d1f28c", "size": 268064 }, "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "a97b390dd697dbba69ac83663c49f65c6408bbcb03be05c889bdee622d9e5efa", + "sha256": "6f2dccd9f85db9d4948ddb31f62b6059d9359f7f16df6d55455b05c6ae4f37b7", "size": 340584 }, "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "a3fea60218cd759a37d4ca423b6b55d02fd5e36cb762e8333604d7fb322b4923", + "sha256": "abb3eb9033205d1c2903b619dbbab7e3de10b859493239c35533b75d00bc3305", "size": 68048 }, "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "548e4910fd18dfc8058c17bfabfbb5f69db15481169efd4fe68841f20c8a0692", + "sha256": "a631270d167cbd9a67db6111de57e5d6ddcdd554706fd9fd919d8f116733bf66", "size": 335728 }, "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "3ed0ce5cfeec2f8cc3b6a0a85b6137d3bf7219904e5e76f6490f027e9d6346a0", + "sha256": "c5c4f480275ddc00e2d7d44aee76808f2b4184b6a6354ef2e757d8b3a7ae8e44", "size": 271680 }, "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "a8667a7a12c8a096e68b8716b0f309fad8d98e5cf056121a8f84371908421402", + "sha256": "a9d9fdfcd6f154e093ed1cc678bf9a1e12c7a3e475a358c19941af7777c40807", "size": 134408 }, "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "af175ad8b662828c25f2e53dd4af1e323318f4b7df51276914327ae110a8283d", - "size": 3514480 + "sha256": "d3468f09df47814c161937306c776df5fe9a6a6d819859a596f7e33be6d253e8", + "size": 3581928 }, "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "0be3eb36bd0fcc798ad602ff7be133366d82c3b112df9663a94347e431dd6486", + "sha256": "c7cf16d3b2297deafd87c53c8b61031d2af84ff04848bab34d9dde37ba939ed5", "size": 136448 }, "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "fa70ebb1569dde9f474040476c495d170874217cac0611e8057a92c9bfee0c92", + "sha256": "113890a2fa588f0c3d6d0c6723aef63adb58fc3ad5355174effe5ac2d3d6d364", "size": 68136 }, "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "674acf92f744733857437a9993e6e7bb41190e924ec894a45adb4acf396b560f", + "sha256": "e97f1650766b15b231657afd6530c019d09352dfa0d96deb2643893acf56efa4", "size": 67840 }, "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "0b4e93091aaf56a2ae1b0a22dab3e7c07e2172df80308f97edb793e412310a3d", + "sha256": "7be8a68c56f6ff37ef887b87275c78e517065995243e88d1ab6a10f3b028abe5", "size": 135648 }, "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "0b6ddf3b89bb78d1d2c68ef0c3f93796867c150606269f7666bdca093ecc856f", + "sha256": "2a1f6cbb8794a29a55decf6d04915e752360eac7701ed55c826355bb348e39a6", "size": 269568 }, "python/iqpilot_private/konn3kt/uploaderd/__init__.py": { @@ -131,7 +131,7 @@ }, "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "9104feff3435925c9235b8424f08dc52eee877ae546093ecea83687b3fd6478a", + "sha256": "613141bdba6cabaf47fa07b438fe964e90796b7e0309c8f0fbfa60b6bf54e6ad", "size": 204184 }, "runtime": { @@ -171,25 +171,25 @@ } }, "signatures": { - "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "P79t7+Txevk0MV7hDKGcDPlx2RH9CACuI89MdIuouzwjTIPyi/XZxb2z5qukLp/SENm8tIO/uvDqNMGg1zsNDQ==", - "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "9iVq7Mj2pYNsFG/663bMRD1XH2IEk6+Xbpd41jT83rhtYCi83aHzXcXyDcqNmyYhQpPeu4It9sp1eSj42w5DBg==", - "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "f3SUgO2s1N9juez3vvXlhi4KV9gibraZs0XKdtlTCHS9TVpUPAforrIv+fiiv+CnXuNQaFxOsVE1nsVxYUFVCg==", - "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "+Em9DTW7a6uzqgeZlLCFFjIjN3su8P7SImlAiLLc9Bc8ylsmvaEqfqLewnblANN0E3fPefIWPy2SXtE/sh8DDw==", - "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "adDfmCgrUH/7F/tctcIfy7PMLkdnEj0Y8dJO6q7/Ds34WpyNZy0JousS007TGsryrIAjZsfujMieVei7DCvbDA==", - "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "khtz+JnG262yIsCgaqzeSxqlkbyXbzVbZrVxuXV6SQhhl+Ob/LL1ZgSs6Wpca48STo2NMYjglzeaM5jwj847Bg==", - "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "ZFG+EQgMvwqq8T6VNUqqgQJmIgAC1AIYNF/QWYU7MxYWWWfnx9s+G5Mz2EwLMUIdW16pGXsqkn02u4kHNxpcCg==", - "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "KW+OPawOOOKdeIl4l7gc1hjV8sdd0QXk8tIBl5UXwXgQSdbGwE+H8mCc2EKEhDb5wR1Zl/Cb6pC7D8ksNZcFCQ==", - "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "Q+2toj33o3kivPIA152qAIRGdUMFI9jKJ97W9bf2cNXCWAs04p8XvHIxjD1JChJMfvcZLHIk9HlumRSalcT1Dg==", - "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "s7qLI/aus4eexP6XEqrreU/stI781n0T0F1yeerxo0BoaUc5oT0HTQkrZTgSagFfGnPT228MKEhB7xW2JOuAAg==", - "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "ESRJsvz0NplwLhzY/oGMGP4ZD3S70fd07QSl/xLdFcX36Scelic5pJO9ClBSeoMd1uA1iWmSMARK4vugJHTBBQ==", - "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "DPbD2qSAUTkR0JmZT+9bPYDZlSeAmZaKnRJiC9Av+uLpX/lCnbIeLMEgRFrOdho/gf4CzmDcbfboULYwgd06AQ==", - "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "ut5z43F74P4rq51YQBaPU35hrOsTphthrkAfYv7ezFUwSfZei0jUBvJAujU9n6fbg58NiUYrIgwe8/AnntpaCw==", - "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "IUzori/U8oSQPTO9KSMvR0/B3zBmpwmSUNdnb2Idop3gGaGBrebNaRv4Ilbjp6+xVNArvEsOybVxPPSapVsjDg==", - "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "7aEw/FLCYQuH+VqyeV88nnfHG9xFrfP0XK9rNltmT6YXAWKN6PtnpXVqPTmrBuuOlkl+pCIKLIvKVm52dbAOAg==", - "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "EDpN12qdopPVzYu4aHbrNwYjN3vfRrEJhln5XNrVnh/RsZNkM0HKTJJdcVauJz//lEwIHTn1KLvOgiKzn5rlBA==", - "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "0ud+tsJGU2wew33iJmC6bJ+Pg9f4Fl0znsbXhO4lUXmuqCWTfaCWYbAASggUVF0MFYccJ9aZsARON2S/24igAw==", - "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "+3ahkAwXUIluEv86JNK0HpnsrprT8iPdKDRBWdCWc4ux+k8YN7bCDQSl+B1gkGRxB/2hCQq0ncF3EKb3+yTCCQ==", - "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "ch5zd8KmXfi/vyI0see68Qdcr7PUBIyJ2iJ2KMGGfIXufa57DBDuyzcwfkyeA6btC8mt+mokIeRap/gUhyraAg==", - "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "iLrfT53m4UBggK+ql3XR2FYQrPtuIxnfGe7jSLUd3skttpG6FNL9g+dEzbU4IPoc9wgE6kvxorjz7KejFq7WBw==" + "python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so": "nSuJmlS6Uu7Z2hCUfo8ia0v3TtLy0k7vXuUtkRteuXr283FbjSL/SAnGZtotLam9QqWcKUZiX5AY7iKRxdJRDA==", + "python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so": "b4kzh2Ih+crCs71fFdIku1vWUarJheL2EFkX9K85ppio/t27Vml4wjdRXt6G9ImEIwXb0FwZQ5TO290oTS81DQ==", + "python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so": "Y4Z6VxELzmD2+MZKv/Uk7mE03ZFxxjXFiV21zHXbGTTQ8x+3OWSs+c43kfEFO7+Y0GRLAV/pFVdS4TJotL1bCA==", + "python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so": "uKdDonv7l3RV5Rk0p18w2B7OFo56b61Gdzj4Q2HCa4MkjzVv81Pe7kf+2AbRXJ+tV74P1Xg/S4uFYH9B03D7Cw==", + "python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so": "+HMqTUeIHFUo9Fr7/yzkI9tQ+/uCJI1dH06oRDg1xgU8YjuiI8XUZCpgsUR4nMn7O497qiCdL5TfiYJw/0j/AQ==", + "python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so": "EOxdzTbj5/I7gOu48kM3Z0G54cJ1pII4tikrDqYhVSFRiJopUPtjb2IAG/Lg0D6kwZaIzbkEVpRKq707MF2BAw==", + "python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so": "fZ8UhZhwmPn4IMxudbJkmrCsBj/A+EOH4cdzT8Vb75kI7SD9V5rR7gRCPFuVEL1nQ6lnlF2QW4Osmik2WOqeDA==", + "python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so": "bptAbc1grlaQ8cQoR1RZ1p5GKEvnHUGOpyRoCmkK2xW2HMRD9YmY+t7HrARFfr14Bkp0dgdVKswzZWa6aUR3Cg==", + "python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so": "4Q9ZzJhFaTqnaj66o+CHE/vtjY9qWOHO7UFYZrxs3uQdUaeIO5JUkXHtr4Fl7mDrZy0zaT8hL+R6g7zj8VLCCg==", + "python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so": "bzGWFkSPQg9xj1Rl8jBkGx3coMF6srLe509MLng4gQMqo8hHHWZTFI3235UsSJCkbnPjOolUWzVLtbDp5bMmDQ==", + "python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so": "pIB/KThS9GZ+HYf+SSd14rxawltaKKAZ1cFyYRPWpAnpEZ4q/Vz7G2o6bdEZ2r8bJsfBMMkzyPLRJQwpX0jODQ==", + "python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so": "FEbOGV+d8BvV4yurjBNz74nK0CT+yQSReXPRliMt9uuMNLKVuRMlUibmgD63sx7tv0hz7Z49VwYYZVlwEPNrDw==", + "python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so": "x17n17jSDgrTCCphHNWZQ4ibHMGITtrhaDK1wBmWFqj6uzr3cWWXV6THxNJy4qbj9/yHVLICGNuW7wIOb0tzDQ==", + "python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so": "ldjMNYlSmOa3XjWrcqlDMrNybvFZ3T9aVVZevwt9TYxVgUrz/5qMui7KIAPdEins39c4fu0jayqM/1V0kjsUCA==", + "python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so": "Cvq80OZLZ0rIywx1RjkV5cd6jKnpL0Rq9evyOxGqYwc0xGCGL1t2YqsoiyyODMeMphEdvdK+w+OMBge6OGk1DQ==", + "python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so": "17P0y74P0Ug92QlhioE6XJJ6p3tz8atS5Wp2SihaQZfnERG8CCjQ6RjVBSwINdvJuU05sGexOlb29Cjebc92DQ==", + "python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so": "wcXjEUKfLHopsy91qnShxa8/1D6wChOsFZcOkPnA0b61vPY7ceKlVMXcv+ECr35BiMsW4gHQG9MSMoyXo1ZJAw==", + "python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so": "8nTnxWCYCQ2mtzd8JL8kcRLsU6JrKVrfMNfI84ovoejxH7kAXCM6d3f1u6TFNPqrQjlIFlc1AyUoL7UoFsQJCA==", + "python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so": "fs6ZLA1aFyUMWRpeSduc0nnv4cJjb3OIdxmUGaCr58D5GAGBSOOUQszzMWDwrEOCQ0JOsW27TM3VmliIhP1cBA==", + "python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so": "YleZT+ZmBpdryTlsH4k+yoLYhyIJ1DMPYnEC8L2ixxJejAjcUBmn2GjXFSS+7JzK6g8aokV6NswVSqbuIVOJDQ==" } } diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so index 54f1be3..b555f05 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/archive_codec.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so index 70871b3..94c7f2d 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_keys.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so index bd1a9bf..ddd256d 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/backup_orchestrator.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so index f4aff6b..2962b80 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/cbc_vault.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so index e7e6ecb..e71528d 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/backups/imahelper.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so index 5264b2d..6c42f82 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/flockd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so index 8c67341..15f5c24 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/flockd/signatures.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip index 7c87990..f6d7ebd 100644 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/_vendor/localapi_runtime.zip differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so index bd7cff0..a06bc41 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_auth.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so index a96f636..f3610cb 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_gatt.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so index 2b9f89d..e3064a9 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_rpc_dispatch.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so index 9ac6f46..b780b96 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/ble_transportd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so index e33545a..05e776f 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/bt_gamepad.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so index faf8a3d..1e6fc15 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/cloud_routes.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so index 7e8ff4d..ccf44a7 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so index dd03fa1..b8d315b 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/kwp2000.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so index 2d813cd..cdccd5b 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/manage_hephaestusd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so index d153c51..f8ff1b0 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/motd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so index 65b6bb2..48c82ad 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/tp20.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so index ccc7f26..bae9d3a 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/hephaestus/vw_pq_flasher.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so index b163f73..43f2db2 100755 Binary files a/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_hephaestusd_private/python/iqpilot_private/konn3kt/uploaderd/iquploaderd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/manifest.json b/artifacts/iqpilot_iqvd_private/manifest.json index 0a4cacf..3f3bb17 100644 --- a/artifacts/iqpilot_iqvd_private/manifest.json +++ b/artifacts/iqpilot_iqvd_private/manifest.json @@ -6,7 +6,7 @@ }, "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "d36af86bf1fe721522e729b6a48e3a962ce033dcfe89f9e00e3d6208b1801591", + "sha256": "48bad6268fabc23a9be62fcd7f61240ebcbabd5e3c6afcf7d3328bd33d93d3a2", "size": 67616 }, "python/iqpilot_private/iqvd/__init__.py": { @@ -16,12 +16,42 @@ }, "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "f81036080eb7bc0c91bae10b11f52064041311a5fc67e7dc0fba32f585318e41", - "size": 201584 + "sha256": "674c4ecd09e36af6c2b9a41285e78914ee94a0e8c4de728904171dd59e02edda", + "size": 135576 + }, + "python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "7ce45dfb9a6ffe8e5426f261cf7e9b3a3825e3d284a0675013d43697e73d56d9", + "size": 67616 + }, + "python/iqpilot_private/iqvd/offload/__init__.py": { + "mode": 420, + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "size": 0 + }, + "python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "439f88b68d220912db2f7e9a43d41827d298f68b113e7c5e5858adea3a05e963", + "size": 135296 + }, + "python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "0199f81ce365fb956d729c61c20c8eaa0120448171db6b83544f3dd1a0861877", + "size": 68232 + }, + "python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "678ebcdd9a97fe527970bbb93021d2ae8f4f9935444b6558dd61b399ab3fe3e9", + "size": 135976 + }, + "python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so": { + "mode": 493, + "sha256": "a8d24107433a037d19876b2240e980b09c89f560ba960dda5e28c16665a03ca6", + "size": 68120 }, "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": { "mode": 493, - "sha256": "976be8806f006a175a14b0c63e9a1c2c2b845ee2dab77a41eeb6759c7feab641", + "sha256": "943e0ef218eed109289d208ea76407e223a4e8b81659f0ec79dc090fbd7ed329", "size": 204568 }, "runtime": { @@ -33,8 +63,13 @@ } }, "signatures": { - "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": "bMboPEsPzZjh0Y1Zk3Brvc6fyWFF5CxuzN9KQr2RK0Hn+l8XiD6l/ym2Y10nFxvj8+uLikIZ0xSAovJlJcRUAA==", - "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": "seyE6m33mqlKZ4H7b1JL6pxxz+r/+dKP1PzcfXxhdhHvsl8Ka8Noi6+ln5z+QSvBgt5ulxQeBJMBP5qdu6lOCw==", - "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": "RvzkJLNxPA+u7yEnH5jd2jecT02PJCQkIDj/LiWvBCE4lFmFFzOf1Q+ZCGGjrFQzhLQZlm75SvW80mlqJ9BJCw==" + "python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so": "YPDPJiSbuWXyg3MpOJXzZtejR5sazfra6c/AFVm+tzE+i9OA9/OPS5qvpFSaHOBPaVZYtsmxkOFq4Q5GJlYOAg==", + "python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so": "H+2OPwV0XS7biX05P6fBzRBCN1Cjnq9eH6YSLk+9BX8OPbdiyJTbDpt8YZYk4nU0LO5FF4dMkEXzFRsewB7BAw==", + "python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so": "vbv8RlHm7LGdrojZuRhs+2StjASHEC1GbkZspXh6FCbmyKmW7F9B5GzHjIwr7HVFSNX6u+ELPPKebfnIsOkmAQ==", + "python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so": "pfDu4/OifTpFWqMD/hyQN+p+9syWaiAavHCkvxG9++0VvHDwKsBPJFxRk8r3vBdylSz4zkIFXlqPPt/hNuzJAg==", + "python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so": "Ll6azxnD3jGmyPPjd+yTmAVCl+CwM2iSdSqpYtTtzqPIb6GFBp0FfF5w+FTpacKjoQ4i3cXV4gdUVOf3qRWzCg==", + "python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so": "ww5BVRLiGB+LkqREN1f7njOrrgOcWghyTXNXTJTJHYeLx5AgIrvsqg1PH7+nq1LuSvk+LHDTOSUAcs7JIDh3DQ==", + "python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so": "3OzAWo66E+qe/H+HtGEP1i73WLW9SPa9+zeb8fOWBe7Nlsx7WrgGcURBq0VTycv2v3QLNLEluei/rnQEYBRMCA==", + "python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so": "C64cBnuSM2wm6vCEO9W3cNqbB4P7Yuy78pFUXxn8iesCQD0hbZ1+w8IuH4VuxB88ubLRLQt2GZ8LYIESb+HYAg==" } } diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so index 6c32f31..d7a4479 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/__init__.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so index 475aca1..db25f80 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/iqvd.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000..a5f6cbc Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.py b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000..0e75f68 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/client.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000..f4e614e Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/geometry.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000..f34cba0 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/perception.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so new file mode 100755 index 0000000..a09d623 Binary files /dev/null and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/offload/protocol.cpython-312-aarch64-linux-gnu.so differ diff --git a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so index 42efbc0..2e35533 100755 Binary files a/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so and b/artifacts/iqpilot_iqvd_private/python/iqpilot_private/iqvd/yolov8_net.cpython-312-aarch64-linux-gnu.so differ diff --git a/cereal/custom.capnp b/cereal/custom.capnp index a63212c..a79382d 100644 --- a/cereal/custom.capnp +++ b/cereal/custom.capnp @@ -333,6 +333,7 @@ struct IQCarParams @0xd4189b5c8aca9f78 { iqLateralNet @4 :LateralNet; longitudinalStoppingSpeedOverride @5 :Float32; # m/s; zero keeps the upstream default stoppingDecelRateOverride @6 :Float32; # m/s^3; zero keeps the upstream default + longActiveWithGasOverride @7 :Bool; # keep long control active while the driver is on the gas struct LateralNet { fuzzyFingerprint @0 :Bool; @@ -762,7 +763,33 @@ struct IQVehicleTracks @0xb877ef4b20a4ae22 { } } -struct CustomReserved13 @0xfd960244a79e2804 { +struct IQEnvironment @0xfd960244a79e2804 { + frameId @0 :UInt32; + offloaded @1 :Bool; + modelValid @2 :Bool; + objects @3 :List(Object); + + struct Object { + x @0 :Float32; + y @1 :Float32; + z @2 :Float32; + width @3 :Float32; + height @4 :Float32; + length @5 :Float32; + prob @6 :Float32; + label @7 :Label; + + enum Label { + car @0; + motorcycle @1; + bus @2; + truck @3; + person @4; + bicycle @5; + stopSign @6; + trafficLight @7; + } + } } struct CustomReserved14 @0xa6e5a1ce8ca5258e { diff --git a/cereal/log.capnp b/cereal/log.capnp index 590c92a..056b9ca 100644 --- a/cereal/log.capnp +++ b/cereal/log.capnp @@ -177,6 +177,15 @@ struct InitData { wallTimeNanos @20 :UInt64; + ufsHealth @25 :UfsHealth; + + struct UfsHealth { + preEolInfo @0 :UInt8; + lifeTimeEstimateA @1 :UInt8; + lifeTimeEstimateB @2 :UInt8; + vendorHealthReport @3 :Data; + } + enum DeviceType { unknown @0; neo @1; @@ -2670,7 +2679,7 @@ struct Event { iqPerfTrace @136 :Custom.IQPerfTrace; iqConstructionZone @137 :Custom.IQConstructionZone; iqVehicleTracks @138 :Custom.IQVehicleTracks; - customReserved13 @139 :Custom.CustomReserved13; + iqEnvironment @139 :Custom.IQEnvironment; customReserved14 @140 :Custom.CustomReserved14; customReserved15 @141 :Custom.CustomReserved15; customReserved16 @142 :Custom.CustomReserved16; diff --git a/cereal/services.py b/cereal/services.py index 8504fd7..50f8e80 100755 --- a/cereal/services.py +++ b/cereal/services.py @@ -100,6 +100,7 @@ _services: dict[str, tuple] = { "iqLiveData": (True, 1., 1), "iqConstructionZone": (True, 2., 2), "iqVehicleTracks": (True, 4., 4), + "iqEnvironment": (True, 4., 4), "mapdOut": (True, 20., 20, QueueSize.MEDIUM), "mapdExtendedOut": (False, 1., -1, QueueSize.MEDIUM), "mapdIn": (False, 1., -1, QueueSize.MEDIUM), diff --git a/common/SConscript b/common/SConscript index a55532d..f05984b 100644 --- a/common/SConscript +++ b/common/SConscript @@ -6,6 +6,7 @@ common_libs = [ 'util.cc', 'ratekeeper.cc', 'clutil.cc', + 'yuv.cc', ] _common = env.Library('common', common_libs, LIBS="json11") diff --git a/common/api/base.py b/common/api/base.py index 5cb5934..7e53329 100644 --- a/common/api/base.py +++ b/common/api/base.py @@ -3,6 +3,7 @@ import os import requests import unicodedata from datetime import datetime, timedelta, UTC +from functools import lru_cache from openpilot.system.hardware.hw import Paths from openpilot.system.version import get_version @@ -11,6 +12,16 @@ KEYS = {"id_rsa": "RS256", "id_ecdsa": "ES256"} +@lru_cache(maxsize=4) +def load_signing_key(private_key: str): + # PyJWT re-parses a PEM string on every encode; an RSA parse is ~40ms, so cache the key object + try: + from cryptography.hazmat.primitives.serialization import load_pem_private_key + return load_pem_private_key(private_key.encode(), password=None) + except Exception: + return private_key + + class BaseApi: def __init__(self, dongle_id, api_host, user_agent="openpilot-"): self.dongle_id = dongle_id @@ -38,7 +49,8 @@ class BaseApi: } if payload_extra is not None: payload.update(payload_extra) - token = jwt.encode(payload, self.private_key, algorithm=self.jwt_algorithm) + key = load_signing_key(self.private_key) if self.private_key else self.private_key + token = jwt.encode(payload, key, algorithm=self.jwt_algorithm) if isinstance(token, bytes): token = token.decode('utf8') return token diff --git a/common/params_keys.h b/common/params_keys.h index 1a6bfc9..58a070c 100644 --- a/common/params_keys.h +++ b/common/params_keys.h @@ -21,6 +21,7 @@ inline static std::unordered_map keys = { {"CalibrationParams", {PERSISTENT, BYTES}}, {"CameraDebugExpGain", {CLEAR_ON_MANAGER_START, STRING}}, {"CameraDebugExpTime", {CLEAR_ON_MANAGER_START, STRING}}, + {"CanLiveStreaming", {CLEAR_ON_MANAGER_START, BOOL}}, {"CarBatteryCapacity", {PERSISTENT, INT}}, {"CarParams", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BYTES}}, {"CarParamsCache", {CLEAR_ON_MANAGER_START, BYTES}}, @@ -199,6 +200,8 @@ inline static std::unordered_map keys = { {"OSMapsHeadingUp", {PERSISTENT, BOOL, "1"}}, {"OfflineTilesBaseUrl", {PERSISTENT, STRING}}, {"OnroadUploads", {PERSISTENT, BOOL, "1"}}, + {"IQAutoUnits", {PERSISTENT, BOOL, "1"}}, + {"IQAutoUnitsRegion", {PERSISTENT, STRING}}, {"IQAlertSilence", {PERSISTENT, BOOL, "0"}}, {"IQAccelMeter", {PERSISTENT, BOOL, "0"}}, {"IQBlinkerIndicators", {PERSISTENT, BOOL, "0"}}, @@ -349,6 +352,7 @@ inline static std::unordered_map keys = { {"ConstructionZoneAssist", {PERSISTENT, BOOL, "0"}}, {"VisionVehicleTracks", {PERSISTENT, BOOL, "0"}}, {"AmbientTrackDots", {PERSISTENT, BOOL, "1"}}, + {"EnvironmentView", {PERSISTENT, INT, "0"}}, {"ConstructionZoneSpeed", {PERSISTENT, INT, "60"}}, {"ShowSpeedLimits", {PERSISTENT, BOOL, "0"}}, {"SLCPolicy", {PERSISTENT, INT, "1"}}, diff --git a/common/tests/native_test.h b/common/tests/native_test.h new file mode 100644 index 0000000..d3dfbb9 --- /dev/null +++ b/common/tests/native_test.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +inline void native_test_check(bool condition, const char *expression, const char *file, int line) { + if (!condition) { + throw std::runtime_error(std::string(file) + ":" + std::to_string(line) + ": check failed: " + expression); + } +} + +#define CHECK(condition) native_test_check(static_cast(condition), #condition, __FILE__, __LINE__) +#define REQUIRE(...) CHECK((__VA_ARGS__)) + +template +int run_native_test(Function &&function) { + try { + function(); + return 0; + } catch (const std::exception &error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/common/yuv.cc b/common/yuv.cc new file mode 100644 index 0000000..a7a64fb --- /dev/null +++ b/common/yuv.cc @@ -0,0 +1,132 @@ +#include "common/yuv.h" + +#include +#include + +namespace yuv { + +namespace { + +inline uint8_t clamp_u8(int v) { + return static_cast(std::clamp(v, 0, 255)); +} + +void copy_plane(const uint8_t *src, int src_stride, + uint8_t *dst, int dst_stride, + int width, int height) { + if (src_stride == width && dst_stride == width) { + std::memcpy(dst, src, static_cast(width) * height); + return; + } + for (int y = 0; y < height; ++y) { + std::memcpy(dst + y * dst_stride, src + y * src_stride, width); + } +} + +void scale_plane_point(const uint8_t *src, int src_stride, int src_width, int src_height, + uint8_t *dst, int dst_stride, int dst_width, int dst_height) { + if (src_width == dst_width && src_height == dst_height) { + copy_plane(src, src_stride, dst, dst_stride, dst_width, dst_height); + return; + } + for (int y = 0; y < dst_height; ++y) { + const int sy = y * src_height / dst_height; + const uint8_t *src_row = src + sy * src_stride; + uint8_t *dst_row = dst + y * dst_stride; + for (int x = 0; x < dst_width; ++x) { + dst_row[x] = src_row[x * src_width / dst_width]; + } + } +} + +// BT.601 limited range → RGB (integer form used widely, incl. similar to libyuv). +inline void yuv_to_rgb(int y, int u, int v, uint8_t *r, uint8_t *g, uint8_t *b) { + const int c = (y - 16) * 298; + const int d = u - 128; + const int e = v - 128; + *r = clamp_u8((c + 409 * e + 128) >> 8); + *g = clamp_u8((c - 100 * d - 208 * e + 128) >> 8); + *b = clamp_u8((c + 516 * d + 128) >> 8); +} + +} // namespace + +void nv12_to_i420(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int width, int height) { + copy_plane(src_y, src_stride_y, dst_y, dst_stride_y, width, height); + + const int uv_width = width / 2; + const int uv_height = height / 2; + for (int y = 0; y < uv_height; ++y) { + const uint8_t *uv = src_uv + y * src_stride_uv; + uint8_t *u = dst_u + y * dst_stride_u; + uint8_t *v = dst_v + y * dst_stride_v; + for (int x = 0; x < uv_width; ++x) { + u[x] = uv[2 * x]; + v[x] = uv[2 * x + 1]; + } + } +} + +void i420_to_nv12(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_uv, int dst_stride_uv, + int width, int height) { + copy_plane(src_y, src_stride_y, dst_y, dst_stride_y, width, height); + + const int uv_width = width / 2; + const int uv_height = height / 2; + for (int y = 0; y < uv_height; ++y) { + const uint8_t *u = src_u + y * src_stride_u; + const uint8_t *v = src_v + y * src_stride_v; + uint8_t *uv = dst_uv + y * dst_stride_uv; + for (int x = 0; x < uv_width; ++x) { + uv[2 * x] = u[x]; + uv[2 * x + 1] = v[x]; + } + } +} + +void i420_scale(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + int src_width, int src_height, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int dst_width, int dst_height) { + scale_plane_point(src_y, src_stride_y, src_width, src_height, + dst_y, dst_stride_y, dst_width, dst_height); + scale_plane_point(src_u, src_stride_u, src_width / 2, src_height / 2, + dst_u, dst_stride_u, dst_width / 2, dst_height / 2); + scale_plane_point(src_v, src_stride_v, src_width / 2, src_height / 2, + dst_v, dst_stride_v, dst_width / 2, dst_height / 2); +} + +void nv12_to_rgba(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_rgba, int dst_stride_rgba, + int width, int height) { + for (int y = 0; y < height; ++y) { + const uint8_t *y_row = src_y + y * src_stride_y; + const uint8_t *uv_row = src_uv + (y / 2) * src_stride_uv; + uint8_t *dst = dst_rgba + y * dst_stride_rgba; + for (int x = 0; x < width; ++x) { + const int uv_x = (x & ~1); + uint8_t r, g, b; + yuv_to_rgb(y_row[x], uv_row[uv_x], uv_row[uv_x + 1], &r, &g, &b); + dst[4 * x + 0] = r; + dst[4 * x + 1] = g; + dst[4 * x + 2] = b; + dst[4 * x + 3] = 255; + } + } +} + +} // namespace yuv diff --git a/common/yuv.h b/common/yuv.h new file mode 100644 index 0000000..7ab096f --- /dev/null +++ b/common/yuv.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +// NV12: Y plane + interleaved UV. I420: planar Y, U, V. + +namespace yuv { + +// Deinterleave NV12 UV into planar I420. +void nv12_to_i420(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int width, int height); + +// Interleave planar I420 UV into NV12. +void i420_to_nv12(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_uv, int dst_stride_uv, + int width, int height); + +// Point-sample scale I420 (equivalent to libyuv::I420Scale + kFilterNone). +void i420_scale(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_u, int src_stride_u, + const uint8_t *src_v, int src_stride_v, + int src_width, int src_height, + uint8_t *dst_y, int dst_stride_y, + uint8_t *dst_u, int dst_stride_u, + uint8_t *dst_v, int dst_stride_v, + int dst_width, int dst_height); + +// Convert NV12 to packed RGBA (R,G,B,A bytes — suitable for GL_RGBA). +// BT.601 limited-range, matching common libyuv defaults. +void nv12_to_rgba(const uint8_t *src_y, int src_stride_y, + const uint8_t *src_uv, int src_stride_uv, + uint8_t *dst_rgba, int dst_stride_rgba, + int width, int height); + +} diff --git a/iqdbc_repo/iqdbc/car/car.capnp b/iqdbc_repo/iqdbc/car/car.capnp index 8bc3d70..48a71b7 100644 --- a/iqdbc_repo/iqdbc/car/car.capnp +++ b/iqdbc_repo/iqdbc/car/car.capnp @@ -208,6 +208,7 @@ struct CarState { blockPcmEnable @60 :Bool; # whether to allow PCM to enable this frame lateralAvailable @61 :Bool; # lateral control is available even if cruise is faulted cruiseFaultLateralMode @62 :Bool; # cruise is faulted but lateral control is still active + carNotReady @95 :Bool; # car is transiently refusing engagement, not a fault radarDisableFailed @66 :Bool; # Physical vehicle odometer in kilometers. Zero means unavailable on this platform. odometer @67 :Float64; diff --git a/iqdbc_repo/iqdbc/car/structs.py b/iqdbc_repo/iqdbc/car/structs.py index f3dabdf..b40c293 100644 --- a/iqdbc_repo/iqdbc/car/structs.py +++ b/iqdbc_repo/iqdbc/car/structs.py @@ -53,6 +53,7 @@ class IQCarParams: enableGasInterceptor: bool = auto_field() longitudinalStoppingSpeedOverride: float = auto_field() stoppingDecelRateOverride: float = auto_field() + longActiveWithGasOverride: bool = auto_field() iqLateralNet: 'IQCarParams.LateralNet' = field(default_factory=lambda: IQCarParams.LateralNet()) diff --git a/iqdbc_repo/iqdbc/car/tesla/carstate.py b/iqdbc_repo/iqdbc/car/tesla/carstate.py index 44aa90a..9e9f251 100644 --- a/iqdbc_repo/iqdbc/car/tesla/carstate.py +++ b/iqdbc_repo/iqdbc/car/tesla/carstate.py @@ -29,6 +29,8 @@ class CarState(CarStateBase, IQCarState): self.hands_on_level = 0 self.acc_state_last = 0 + self.das_accCancel = False + self.das_cancel_last = True self.das_control = None self.das_body_controls_dat = b"" self._odometer_store = vehicle_state.VehicleOdometerStore(CP, Params()) @@ -93,15 +95,24 @@ class CarState(CarStateBase, IQCarState): cruise_state = self.can_define.dv["DI_state"]["DI_cruiseState"].get(int(cp_party.vl["DI_state"]["DI_cruiseState"]), None) speed_units = self.can_define.dv["DI_state"]["DI_speedUnits"].get(int(cp_party.vl["DI_state"]["DI_speedUnits"]), None) acc_state = cp_ap_party.vl["DAS_control"]["DAS_accState"] - # Respect all stock DAS cancel states, not just ACC_CANCEL_GENERIC_SILENT(13). - # ELDA/ELK triggers ACC_CANCEL_GENERIC(0) which must also be forwarded. - self.das_accCancel = acc_state in (0, 1, 2, 12, 13, 14, 15) summon_state = self.can_define.dv["DI_state"]["DI_autoparkState"].get(int(cp_party.vl["DI_state"]["DI_autoparkState"]), None) cruise_enabled = cruise_state in ("ENABLED", "STANDSTILL", "OVERRIDE", "PRE_FAULT", "PRE_CANCEL") self.cruise_override = cruise_state in ("OVERRIDE") self.update_summon_state(summon_state, cruise_enabled) + # Respect all stock DAS cancel states, not just ACC_CANCEL_GENERIC_SILENT(13). + # ELDA/ELK triggers ACC_CANCEL_GENERIC(0) which must also be forwarded. + # The stock AP is isolated from the party bus while the relay is closed, so its accState + # free-runs between ACC_ON and ACC_CANCEL_GENERIC. Only a rising edge while ACC is engaged + # is a real cancel; level-forwarding it pins DI_cruiseState to UNAVAILABLE and blocks engaging. + das_cancel = acc_state in (0, 1, 2, 12, 13, 14, 15) + if not cruise_enabled: + self.das_accCancel = False + elif das_cancel and not self.das_cancel_last: + self.das_accCancel = True + self.das_cancel_last = das_cancel + # Match panda safety cruise engaged logic ret.cruiseState.enabled = cruise_enabled and not self.summon if speed_units == "KPH": diff --git a/iqdbc_repo/iqdbc/car/tesla/fingerprints.py b/iqdbc_repo/iqdbc/car/tesla/fingerprints.py index 21975ff..1a8ea7e 100644 --- a/iqdbc_repo/iqdbc/car/tesla/fingerprints.py +++ b/iqdbc_repo/iqdbc/car/tesla/fingerprints.py @@ -44,6 +44,7 @@ FW_VERSIONS = { b'TeMYG4_Legacy3Y_0.0.0 (6),Y4003.04.0', b'TeMYG4_Main_0.0.0 (77),Y4003.05.4', b'TeMYG4_Main_0.0.0 (78),Y4003.06.0', + b'TeMYG4_Main_0.0.0 (87),Y4003.09.3', ], }, CAR.TESLA_MODEL_X: { diff --git a/iqdbc_repo/iqdbc/car/tesla/interface.py b/iqdbc_repo/iqdbc/car/tesla/interface.py index 1f292c5..3cab3fd 100644 --- a/iqdbc_repo/iqdbc/car/tesla/interface.py +++ b/iqdbc_repo/iqdbc/car/tesla/interface.py @@ -2,7 +2,7 @@ from iqdbc.car import Bus, get_safety_config, structs from iqdbc.car.interfaces import CarInterfaceBase from iqdbc.car.tesla.carcontroller import CarController from iqdbc.car.tesla.carstate import CarState -from iqdbc.car.tesla.values import TeslaSafetyFlags, TeslaFlags, CANBUS, CAR, DBC, LEGACY_DAS_STEERING_FW, Ecu +from iqdbc.car.tesla.values import TeslaSafetyFlags, TeslaFlags, CANBUS, CAR, DBC, Ecu, is_legacy_das_steering from iqdbc.car.tesla.radar_interface import RadarInterface, RADAR_START_ADDR from iqdbc.lvbs.car.tesla.values import TeslaFlagsIQ, TeslaSafetyFlagsIQ @@ -41,7 +41,7 @@ class CarInterface(CarInterfaceBase): ret.openpilotLongitudinalControl = True ret.safetyConfigs[0].safetyParam |= TeslaSafetyFlags.LONG_CONTROL.value - legacy_das = any(fw.ecu == Ecu.eps and fw.fwVersion in LEGACY_DAS_STEERING_FW.get(candidate, []) for fw in car_fw) + legacy_das = any(fw.ecu == Ecu.eps and is_legacy_das_steering(candidate, fw.fwVersion) for fw in car_fw) if legacy_das: ret.flags |= TeslaFlags.LEGACY_DAS_STEERING.value ret.safetyConfigs[0].safetyParam |= TeslaSafetyFlags.LEGACY_DAS_STEERING.value diff --git a/iqdbc_repo/iqdbc/car/tesla/tests/test_tesla.py b/iqdbc_repo/iqdbc/car/tesla/tests/test_tesla.py index c9bb75f..695c8c5 100644 --- a/iqdbc_repo/iqdbc/car/tesla/tests/test_tesla.py +++ b/iqdbc_repo/iqdbc/car/tesla/tests/test_tesla.py @@ -1,11 +1,110 @@ +from collections import defaultdict + +import pytest + from iqdbc.car import gen_empty_fingerprint, structs +from iqdbc.car.structs import CarParams +from iqdbc.car.fw_versions import match_fw_to_car +from iqdbc.car.tesla.fingerprints import FW_VERSIONS from iqdbc.car.tesla.interface import CarInterface from iqdbc.car.tesla.teslacan import TeslaCAN from iqdbc.car.tesla.radar_interface import RADAR_START_ADDR from iqdbc.car.tesla.carcontroller import CarController -from iqdbc.car.tesla.values import CAR +from iqdbc.car.tesla.values import (CAR, FW_PATTERN, LEGACY_DAS_STEERING_FW, TeslaFlags, TeslaSafetyFlags, + get_platform_codes, is_legacy_das_steering) from iqdbc.can import CANPacker, CANParser +Ecu = CarParams.Ecu +EPS_ADDR = 0x730 + + +def fw_match(fw: bytes): + car_fw = [CarParams.CarFw(ecu=Ecu.eps, fwVersion=fw, address=EPS_ADDR, subAddress=0, brand='tesla')] + exact, matches = match_fw_to_car(car_fw, '0' * 17, log=False) + return exact, matches + + +class TestTeslaFwPattern: + def test_all_known_fw_parses(self): + for car, ecus in FW_VERSIONS.items(): + for fws in ecus.values(): + for fw in fws: + assert FW_PATTERN.match(fw) is not None, f'{car}: unparsed FW version: {fw}' + + def test_model_code_identifies_one_platform(self): + # a new platform reusing an existing model code would silently misfingerprint + platforms = defaultdict(set) + for car, ecus in FW_VERSIONS.items(): + for fws in ecus.values(): + for model, _, _ in get_platform_codes(fws): + platforms[model].add(car) + + for model, cars in platforms.items(): + assert len(cars) == 1, f'model code {model} maps to multiple platforms: {cars}' + + def test_exact_match_still_wins(self): + for car, ecus in FW_VERSIONS.items(): + for fws in ecus.values(): + for fw in fws: + exact, matches = fw_match(fw) + assert exact, f'{fw} fell back to fuzzy matching' + assert matches == {car}, f'{fw} matched {matches}, expected {car}' + + @pytest.mark.parametrize("fw, expected", [ + # a firmware bump within a known series, the case that used to fingerprint as MOCK + (b'TeMYG4_Main_0.0.0 (99),Y4003.14.0', CAR.TESLA_MODEL_Y), + (b'TeMYG4_Main_0.0.0 (99),E4H015.09.0', CAR.TESLA_MODEL_3), + (b'TeM3_SP_XP002p2_0.0.0 (40),XPR003.12.0', CAR.TESLA_MODEL_X), + # Tesla bumps the series within a platform (E4014 -> E4015, Y4002 -> Y4003) + (b'TeMYG4_Main_0.0.0 (12),Y4004.01.0', CAR.TESLA_MODEL_Y), + # an unknown model code is a car we don't support + (b'TeCT_Main_0.0.0 (1),CT001.01.0', None), + (b'garbage', None), + ]) + def test_unknown_fw_fuzzy_match(self, fw, expected): + exact, matches = fw_match(fw) + if expected is None: + assert matches == set(), f'{fw} unexpectedly matched {matches}' + else: + assert not exact + assert matches == {expected} + + +class TestTeslaLegacyDasSteering: + def test_reproduces_known_table(self): + for car, ecus in FW_VERSIONS.items(): + for fws in ecus.values(): + for fw in fws: + expected = fw in LEGACY_DAS_STEERING_FW.get(car, []) + assert is_legacy_das_steering(car, fw) == expected, f'{car}: wrong legacy DAS verdict for {fw}' + + @pytest.mark.parametrize("car, fw, expected", [ + # below a family's known modern cutoff (Y4/003 splits at 003.04.0) + (CAR.TESLA_MODEL_Y, b'TeMYG4_Legacy3Y_0.0.0 (5),Y4003.03.9', True), + (CAR.TESLA_MODEL_Y, b'TeMYG4_Main_0.0.0 (99),Y4003.14.0', False), + # E4/015 splits at 015.04.5 + (CAR.TESLA_MODEL_3, b'TeMYG4_Main_0.0.0 (68),E4H015.03.9', True), + (CAR.TESLA_MODEL_3, b'TeMYG4_Main_0.0.0 (99),E4H015.09.0', False), + # families with no known modern FW: interpolate legacy, extrapolate modern + (CAR.TESLA_MODEL_3, b'TeM3_E014p10_0.0.0 (16),E014.18.00', True), + (CAR.TESLA_MODEL_3, b'TeM3_E014p10_0.0.0 (30),E014.22.0', False), + # numeric, not lexical, version compare (XPR003.10.0 > XPR003.6.0) + (CAR.TESLA_MODEL_X, b'TeM3_SP_XP002p2_0.0.0 (30),XPR003.9.0', True), + # an unknown series has no history to compare against + (CAR.TESLA_MODEL_Y, b'TeMYG4_Main_0.0.0 (12),Y4004.01.0', False), + (CAR.TESLA_MODEL_Y, b'garbage', False), + ]) + def test_unknown_fw(self, car, fw, expected): + assert is_legacy_das_steering(car, fw) == expected + + def test_flag_set_from_fuzzy_match(self): + for fw, legacy in ((b'TeMYG4_Legacy3Y_0.0.0 (5),Y4003.03.9', True), + (b'TeMYG4_Main_0.0.0 (99),Y4003.14.0', False)): + car_fw = [CarParams.CarFw(ecu=Ecu.eps, fwVersion=fw, address=EPS_ADDR, subAddress=0, brand='tesla')] + CP = CarInterface.get_params(CAR.TESLA_MODEL_Y, gen_empty_fingerprint(), car_fw, False, False, False) + assert bool(CP.flags & TeslaFlags.LEGACY_DAS_STEERING) == legacy + assert bool(CP.safetyConfigs[0].safetyParam & TeslaSafetyFlags.LEGACY_DAS_STEERING) == legacy + class TestTeslaFingerprint: def test_radar_detection(self): diff --git a/iqdbc_repo/iqdbc/car/tesla/values.py b/iqdbc_repo/iqdbc/car/tesla/values.py index 2800943..80e6209 100644 --- a/iqdbc_repo/iqdbc/car/tesla/values.py +++ b/iqdbc_repo/iqdbc/car/tesla/values.py @@ -1,10 +1,13 @@ +import re +from collections import defaultdict from dataclasses import dataclass, field from enum import Enum, IntFlag +from functools import cache from iqdbc.car import ACCELERATION_DUE_TO_GRAVITY, Bus, CarSpecs, DbcDict, PlatformConfig, Platforms from iqdbc.car.lateral import AngleSteeringLimits, ISO_LATERAL_ACCEL from iqdbc.car.structs import CarParams, CarState from iqdbc.car.docs_definitions import CarDocs, CarFootnote, CarHarness, CarParts, Column, SupportType -from iqdbc.car.fw_query_definitions import FwQueryConfig, Request, StdQueries +from iqdbc.car.fw_query_definitions import FwQueryConfig, LiveFwVersions, OfflineFwVersions, Request, StdQueries Ecu = CarParams.Ecu @@ -69,16 +72,6 @@ class CAR(Platforms): ) -FW_QUERY_CONFIG = FwQueryConfig( - requests=[ - Request( - [StdQueries.TESTER_PRESENT_REQUEST, StdQueries.SUPPLIER_SOFTWARE_VERSION_REQUEST], - [StdQueries.TESTER_PRESENT_RESPONSE, StdQueries.SUPPLIER_SOFTWARE_VERSION_RESPONSE], - bus=0, - ) - ] -) - # Cars with this EPS FW have a 2-bit DAS_steeringControlType and use TeslaFlags.LEGACY_DAS_STEERING LEGACY_DAS_STEERING_FW = { CAR.TESLA_MODEL_3: [ @@ -116,6 +109,99 @@ LEGACY_DAS_STEERING_FW = { ], } +# e.g. TeMYG4_Main_0.0.0 (87),Y4003.09.3 +# 11111_22222_______33____45__666666 +# 1 = EPS firmware program, 2 = build lineage, 3 = build number, 4 = model code, +# 5 = trim/hardware variant, 6 = series and software version +# +# Only the model code identifies the vehicle: 1 and 2 are shared across models (Model 3 and +# Model Y both ship TeM3_ and TeMYG4_ firmware) and 3 is only monotone within one lineage. +FW_PATTERN = re.compile(rb'^Te[A-Z0-9]+_[A-Za-z0-9_]+_0\.0\.0 \(\d+\),' + + rb'(?PE4|E|Y4|Y|XP)[A-Z]{0,2}(?P\d{3})\.(?P\d+(?:\.\d+)*)$') + + +def get_platform_codes(fw_versions: list[bytes] | set[bytes]) -> set[tuple[bytes, bytes, tuple[int, ...]]]: + codes = set() + for fw in fw_versions: + match = FW_PATTERN.match(fw) + if match is not None: + codes.add((match.group('model'), match.group('series'), + tuple(int(v) for v in match.group('version').split(b'.')))) + + return codes + + +@cache +def _das_steering_cutoffs() -> dict[tuple[str, bytes, bytes], tuple[tuple[int, ...] | None, tuple[int, ...] | None]]: + """Per (platform, model code, series) family, the oldest known modern version and the newest + known legacy version. Tesla only ever moves a family forward, so these bound the split.""" + # imported here because fingerprints.py imports this module + from iqdbc.car.tesla.fingerprints import FW_VERSIONS + + legacy: defaultdict[tuple, set] = defaultdict(set) + modern: defaultdict[tuple, set] = defaultdict(set) + for platform, ecus in FW_VERSIONS.items(): + known_legacy = LEGACY_DAS_STEERING_FW.get(platform, []) + for fws in ecus.values(): + for fw in fws: + for model, series, version in get_platform_codes([fw]): + (legacy if fw in known_legacy else modern)[(platform, model, series)].add(version) + + return {k: (min(modern[k]) if k in modern else None, max(legacy[k]) if k in legacy else None) + for k in set(legacy) | set(modern)} + + +def is_legacy_das_steering(candidate: str, fw: bytes) -> bool: + """Whether an EPS FW uses the 2-bit DAS_steeringControlType. Unknown firmware newer than + anything in a family is treated as modern: cars only move forward, and someone left behind + on legacy software can force the platform with CarPlatformBundle.""" + if fw in LEGACY_DAS_STEERING_FW.get(candidate, []): + return True + + codes = get_platform_codes([fw]) + if not len(codes): + return False + + model, series, version = next(iter(codes)) + first_modern, last_legacy = _das_steering_cutoffs().get((candidate, model, series), (None, None)) + if first_modern is not None: + return version < first_modern + + return last_legacy is not None and version <= last_legacy + + +def match_fw_to_car_fuzzy(live_fw_versions: LiveFwVersions, vin: str, offline_fw_versions: OfflineFwVersions) -> set[str]: + # Tesla fingerprints on the EPS alone and Ecu.eps is in FUZZY_EXCLUDE_ECUS, so the generic fuzzy + # matcher can never match a Tesla. Match on the model code, which survives the EPS version bumps + # that ship with Tesla software updates. The series is deliberately not required to be known: + # Tesla bumps it within a platform (E4014 -> E4015, Y4002 -> Y4003). + offline_codes: defaultdict[bytes, set[str]] = defaultdict(set) + for candidate, ecus in offline_fw_versions.items(): + for fws in ecus.values(): + for model, _, _ in get_platform_codes(fws): + offline_codes[model].add(candidate) + + candidates: set[str] = set() + for ecu, addr, sub_addr in {e for ecus in offline_fw_versions.values() for e in ecus}: + if ecu != Ecu.eps: + continue + for model, _, _ in get_platform_codes(live_fw_versions.get((addr, sub_addr), set())): + candidates |= offline_codes[model] + + return candidates if len(candidates) == 1 else set() + + +FW_QUERY_CONFIG = FwQueryConfig( + requests=[ + Request( + [StdQueries.TESTER_PRESENT_REQUEST, StdQueries.SUPPLIER_SOFTWARE_VERSION_REQUEST], + [StdQueries.TESTER_PRESENT_RESPONSE, StdQueries.SUPPLIER_SOFTWARE_VERSION_RESPONSE], + bus=0, + ) + ], + match_fw_to_car_fuzzy=match_fw_to_car_fuzzy, +) + class CANBUS: party = 0 diff --git a/iqdbc_repo/iqdbc/car/volkswagen/carstate.py b/iqdbc_repo/iqdbc/car/volkswagen/carstate.py index fff7957..bb35a8c 100644 --- a/iqdbc_repo/iqdbc/car/volkswagen/carstate.py +++ b/iqdbc_repo/iqdbc/car/volkswagen/carstate.py @@ -381,6 +381,9 @@ class CarState(CarStateBase): ret.cruiseState.available = pt_cp.vl["Motor_51"]["TSK_Status"] in (2, 3, 4, 5) ret.cruiseState.enabled = pt_cp.vl["Motor_51"]["TSK_Status"] in (3, 4, 5) + # TSK winds its braking down through brake_only after a driver brake. Requesting drive-off in this + # state can fault TSK, and stock refuses to engage here as well, so block entry until it clears. + ret.carNotReady = pt_cp.vl["Motor_51"]["TSK_Status"] == 5 # brake_only acc_values = ext_cp.vl.get("MEB_ACC_01", ext_cp.vl.get("ACC_19", {})) ret.cruiseState.nonAdaptive = bool(acc_values.get("ACC_Limiter_Mode", 0)) if self.CP.pcmCruise else bool(pt_cp.vl["Motor_51"]["TSK_Limiter_ausgewaehlt"]) @@ -585,6 +588,7 @@ class CarState(CarStateBase): ret.cruiseFaultLateralMode = allow_lat_only and cruise_faulted and cruise_main_available ret.lateralAvailable = ret.cruiseState.available or ret.cruiseFaultLateralMode ret.blockPcmEnable = ret.cruiseFaultLateralMode + ret.carNotReady = bool(pt_cp.vl["Bremse_8"]["BR8_Sta_VerzReg"]) # Update ACC setpoint. When the setpoint reads as 255, the driver has not # yet established an ACC setpoint, so treat it as zero. diff --git a/iqdbc_repo/iqdbc/dbc/SConscript b/iqdbc_repo/iqdbc/dbc/SConscript index dfae42d..9cfc02c 100644 --- a/iqdbc_repo/iqdbc/dbc/SConscript +++ b/iqdbc_repo/iqdbc/dbc/SConscript @@ -4,7 +4,9 @@ from pathlib import Path env = Environment(ENV=os.environ) # short colored build output, if the top-level pretty tool is present (main-repo build) -_pretty = Dir('#site_scons/site_tools').File('pretty.py') +_pretty = Dir('#tools/scons/site_tools').File('pretty.py') +if not _pretty.exists(): + _pretty = Dir('#site_scons/site_tools').File('pretty.py') if _pretty.exists(): env.Tool('pretty', toolpath=[_pretty.dir.abspath]) if not hasattr(env, 'PrettyAction'): diff --git a/iqdbc_repo/iqdbc/safety/tests/libsafety/SConscript b/iqdbc_repo/iqdbc/safety/tests/libsafety/SConscript index 4962d2f..f0f9567 100644 --- a/iqdbc_repo/iqdbc/safety/tests/libsafety/SConscript +++ b/iqdbc_repo/iqdbc/safety/tests/libsafety/SConscript @@ -26,7 +26,9 @@ env = Environment( ) # short colored build output, if the top-level pretty tool is present (main-repo build) -_pretty = Dir('#site_scons/site_tools').File('pretty.py') +_pretty = Dir('#tools/scons/site_tools').File('pretty.py') +if not _pretty.exists(): + _pretty = Dir('#site_scons/site_tools').File('pretty.py') if _pretty.exists(): env.Tool('pretty', toolpath=[_pretty.dir.abspath]) diff --git a/iqpilot/common/auto_units.py b/iqpilot/common/auto_units.py new file mode 100644 index 0000000..db038d7 --- /dev/null +++ b/iqpilot/common/auto_units.py @@ -0,0 +1,58 @@ +import time + +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog +from openpilot.iqpilot.common.geo_regions import UNKNOWN_REGION, region_for_position, region_is_metric + +CHECK_INTERVAL = 10.0 +CONFIRMATIONS = 3 + + +class AutoUnits: + def __init__(self, params: Params | None = None): + self.params = params or Params() + self._next_check = 0.0 + self._candidate = UNKNOWN_REGION + self._confirmations = 0 + + def _position(self) -> tuple[float, float, bool]: + from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position + + lat, lon, _, valid = current_or_last_gps_position(self.params) + return lat, lon, valid + + def update(self, now: float | None = None) -> None: + if not self.params.get_bool("IQAutoUnits"): + self._candidate = UNKNOWN_REGION + self._confirmations = 0 + return + + now = time.monotonic() if now is None else now + if now < self._next_check: + return + self._next_check = now + CHECK_INTERVAL + + lat, lon, valid = self._position() + region = region_for_position(lat, lon) if valid else UNKNOWN_REGION + if region == UNKNOWN_REGION: + self._confirmations = 0 + return + + if region != self._candidate: + self._candidate = region + self._confirmations = 1 + return + + self._confirmations += 1 + if self._confirmations < CONFIRMATIONS: + return + + if region == self.params.get("IQAutoUnitsRegion"): + return + + self.params.put("IQAutoUnitsRegion", region) + + metric = region_is_metric(region) + if metric != self.params.get_bool("IsMetric"): + self.params.put_bool("IsMetric", metric) + cloudlog.warning(f"auto units: {region} detected, switching to {'km/h' if metric else 'mph'}") diff --git a/iqpilot/common/geo_regions.py b/iqpilot/common/geo_regions.py new file mode 100644 index 0000000..ec47c99 --- /dev/null +++ b/iqpilot/common/geo_regions.py @@ -0,0 +1,140 @@ +MPH_REGIONS = ("US", "GB", "LR") +METRIC_REGION = "METRIC" +UNKNOWN_REGION = "" + +_US_CONUS = [ + (-123.32, 49.00), (-117.03, 49.00), (-110.00, 49.00), (-104.05, 49.00), (-97.23, 49.00), (-95.15, 49.00), + (-95.15, 49.38), (-94.82, 49.30), (-94.68, 48.77), (-93.85, 48.63), (-93.35, 48.62), (-92.72, 48.54), + (-92.30, 48.24), (-91.55, 48.10), (-90.84, 48.24), (-89.99, 48.02), (-89.60, 48.02), (-89.10, 48.32), + (-88.40, 48.30), (-87.00, 47.80), (-85.60, 47.15), (-84.60, 46.75), (-84.42, 46.56), (-84.30, 46.49), + (-84.12, 46.28), (-83.90, 46.05), (-83.40, 45.75), (-82.90, 45.05), (-82.55, 44.00), (-82.42, 43.00), + (-82.70, 42.47), (-82.93, 42.34), (-83.00, 42.33), (-83.05, 42.32), (-83.075, 42.312), (-83.13, 42.25), + (-83.15, 42.18), (-83.11, 42.10), (-83.09, 42.02), + (-82.50, 41.70), (-81.50, 42.00), (-80.20, 42.40), (-79.06, 42.85), (-79.05, 43.27), (-78.00, 43.45), + (-77.00, 43.65), (-76.40, 44.10), (-75.80, 44.50), (-74.75, 45.00), (-73.35, 45.01), (-71.50, 45.01), + (-71.29, 45.30), (-70.90, 45.30), (-70.72, 45.42), (-70.31, 45.86), (-70.05, 46.44), (-69.99, 46.70), + (-69.24, 47.46), (-68.90, 47.20), (-68.38, 47.29), (-67.79, 47.07), (-67.78, 45.94), (-67.42, 45.60), + (-67.03, 44.80), (-68.00, 44.30), (-69.06, 43.80), (-70.20, 43.60), (-70.80, 42.85), (-70.00, 41.90), + (-70.00, 41.55), (-71.20, 41.30), (-72.00, 41.05), (-73.90, 40.55), (-74.20, 39.60), (-75.05, 38.45), + (-75.90, 37.05), (-75.50, 35.20), (-78.50, 33.85), (-80.90, 32.00), (-81.40, 30.70), (-80.03, 26.80), + (-80.15, 25.15), (-81.20, 24.55), (-82.00, 26.40), (-82.80, 27.80), (-83.00, 29.15), (-84.30, 29.90), + (-85.30, 29.65), (-87.50, 30.25), (-89.00, 29.15), (-89.40, 28.95), (-91.30, 29.10), (-93.80, 29.65), + (-95.00, 29.10), (-97.10, 27.80), (-97.14, 25.96), (-98.30, 26.05), (-99.10, 26.40), (-99.50, 27.60), + (-100.40, 28.50), (-101.40, 29.77), (-102.30, 29.88), (-102.90, 29.30), (-103.30, 29.00), (-104.37, 29.56), + (-104.68, 30.13), (-105.30, 30.80), (-105.85, 31.30), (-106.15, 31.50), (-106.30, 31.68), (-106.45, 31.755), + (-106.53, 31.786), (-108.21, 31.783), (-108.21, 31.33), (-111.07, 31.33), (-114.72, 32.72), + (-117.13, 32.53), (-118.40, 33.75), (-119.80, 34.40), (-120.65, 35.10), (-121.90, 36.60), (-122.52, 37.78), + (-123.75, 39.40), (-124.20, 40.45), (-124.15, 42.00), (-124.05, 43.35), (-123.95, 46.25), (-124.75, 48.40), + (-123.30, 48.25), (-123.15, 48.70), +] + +_US_ALASKA = [ + (-141.00, 70.20), (-141.00, 60.30), (-139.05, 60.35), (-137.45, 58.95), (-136.47, 59.63), (-135.03, 59.57), + (-134.30, 58.90), (-133.40, 58.20), (-132.20, 56.90), (-130.60, 56.20), (-130.01, 54.80), (-131.80, 54.70), + (-133.80, 55.90), (-136.60, 58.20), (-140.00, 59.70), (-145.00, 60.00), (-149.20, 59.10), (-152.30, 57.30), + (-155.20, 55.60), (-160.00, 54.60), (-164.50, 54.40), (-162.00, 57.50), (-165.00, 60.20), (-167.50, 62.50), + (-164.00, 64.50), (-168.10, 65.60), (-166.00, 68.30), (-161.00, 70.30), (-156.50, 71.40), (-150.00, 70.50), +] + +_US_ALEUTIANS_EAST = [(-180.00, 51.00), (-158.50, 51.00), (-158.50, 56.00), (-180.00, 56.00)] +_US_ALEUTIANS_WEST = [(172.00, 51.00), (180.00, 51.00), (180.00, 54.00), (172.00, 54.00)] +_US_HAWAII = [(-160.50, 18.80), (-154.70, 18.80), (-154.70, 22.30), (-160.50, 22.30)] +_US_PUERTO_RICO = [(-67.35, 17.85), (-64.55, 17.85), (-64.55, 18.55), (-67.35, 18.55)] +_US_MARIANAS = [(144.50, 13.10), (146.20, 13.10), (146.20, 20.60), (144.50, 20.60)] +_US_SAMOA = [(-171.20, -14.60), (-168.10, -14.60), (-168.10, -11.00), (-171.20, -11.00)] + +_GB_BRITAIN = [ + (-5.72, 50.07), (-4.20, 50.32), (-3.41, 50.62), (-2.45, 50.52), (-1.80, 50.72), (-0.90, 50.77), + (0.58, 50.85), (1.35, 51.13), (1.38, 51.38), (1.15, 51.79), (1.35, 51.95), (1.75, 52.48), + (1.30, 52.94), (0.49, 52.94), (0.34, 53.15), (-0.08, 53.57), (-0.08, 54.12), (-0.61, 54.49), + (-1.18, 54.69), (-1.38, 54.91), (-1.50, 55.13), (-2.00, 55.77), (-2.52, 56.00), (-2.62, 56.28), + (-2.47, 56.55), (-2.21, 56.96), (-2.08, 57.14), (-1.77, 57.50), (-2.00, 57.70), (-2.96, 57.68), + (-3.90, 57.60), (-4.22, 57.48), (-4.05, 57.81), (-3.85, 58.01), (-3.65, 58.12), (-3.09, 58.44), + (-3.01, 58.67), (-3.35, 58.62), (-3.52, 58.60), (-4.99, 58.62), (-5.05, 58.45), (-5.16, 57.90), (-5.70, 57.72), + (-5.72, 57.28), (-5.83, 57.00), (-5.72, 56.65), (-5.47, 56.41), (-5.79, 55.60), (-5.62, 55.31), + (-4.82, 55.64), (-4.63, 55.46), (-4.85, 55.24), (-5.12, 54.84), (-4.86, 54.63), (-4.44, 54.87), + (-4.05, 54.83), (-3.26, 54.98), (-3.05, 54.90), (-3.50, 54.72), (-3.23, 54.07), (-3.05, 53.82), + (-3.40, 53.34), (-3.83, 53.33), (-4.63, 53.42), (-4.72, 53.28), (-4.35, 53.12), (-4.76, 52.80), + (-4.06, 52.72), (-4.09, 52.41), (-4.66, 52.09), (-5.31, 51.88), (-5.06, 51.70), (-4.70, 51.67), + (-4.30, 51.62), (-3.95, 51.56), (-3.70, 51.48), (-3.17, 51.45), (-2.99, 51.55), (-2.67, 51.62), + (-2.48, 51.72), (-2.30, 51.85), (-2.70, 51.50), (-2.98, 51.35), (-3.00, 51.20), (-3.47, 51.21), + (-4.12, 51.21), (-4.55, 50.83), (-5.08, 50.42), (-5.48, 50.21), +] + +_GB_NORTHERN_IRELAND = [ + (-6.03, 54.05), (-6.28, 54.10), (-6.65, 54.17), (-6.86, 54.33), (-7.16, 54.34), (-7.31, 54.12), + (-7.62, 54.14), (-8.00, 54.31), (-8.18, 54.47), (-8.20, 54.52), (-7.90, 54.55), (-7.85, 54.72), (-7.55, 54.75), + (-7.44, 54.94), (-7.25, 55.06), (-6.95, 55.22), (-6.50, 55.25), (-6.25, 55.31), (-6.03, 55.22), + (-5.43, 54.62), (-5.53, 54.24), +] + +_GB_ISLE_OF_MAN = [(-4.85, 54.03), (-4.30, 54.03), (-4.30, 54.42), (-4.85, 54.42)] +_GB_CHANNEL_ISLANDS = [(-2.75, 49.15), (-1.95, 49.15), (-1.95, 49.80), (-2.75, 49.80)] +_GB_ISLE_OF_WIGHT = [(-1.60, 50.55), (-1.05, 50.55), (-1.05, 50.80), (-1.60, 50.80)] +_GB_OUTER_HEBRIDES = [(-7.75, 56.75), (-6.05, 56.75), (-6.05, 58.55), (-7.75, 58.55)] +_GB_INNER_HEBRIDES = [(-7.00, 55.45), (-5.55, 55.45), (-5.55, 57.85), (-7.00, 57.85)] +_GB_ORKNEY = [(-3.50, 58.70), (-2.35, 58.70), (-2.35, 59.45), (-3.50, 59.45)] +_GB_SHETLAND = [(-1.85, 59.80), (-0.65, 59.80), (-0.65, 60.90), (-1.85, 60.90)] + +_LR_LIBERIA = [ + (-11.46, 6.77), (-11.30, 6.95), (-11.16, 7.15), (-11.05, 7.40), (-10.85, 7.75), (-10.60, 8.00), + (-10.28, 8.49), (-9.70, 8.54), (-9.35, 7.80), + (-8.85, 7.40), (-8.48, 7.55), (-8.30, 6.90), (-7.95, 6.20), (-7.60, 5.20), (-7.40, 4.55), + (-7.74, 4.33), (-8.46, 4.61), (-9.06, 4.97), (-9.52, 5.36), (-10.08, 5.85), (-10.40, 6.11), + (-10.83, 6.27), +] + +_REGION_RINGS = { + "US": (_US_CONUS, _US_ALASKA, _US_ALEUTIANS_EAST, _US_ALEUTIANS_WEST, _US_HAWAII, _US_PUERTO_RICO, + _US_MARIANAS, _US_SAMOA), + "GB": (_GB_BRITAIN, _GB_NORTHERN_IRELAND, _GB_ISLE_OF_MAN, _GB_CHANNEL_ISLANDS, _GB_ISLE_OF_WIGHT, + _GB_OUTER_HEBRIDES, _GB_INNER_HEBRIDES, _GB_ORKNEY, _GB_SHETLAND), + "LR": (_LR_LIBERIA,), +} + + +def _bounded(rings): + out = [] + for ring in rings: + lons = [p[0] for p in ring] + lats = [p[1] for p in ring] + out.append(((min(lons), min(lats), max(lons), max(lats)), ring)) + return tuple(out) + + +_REGIONS = tuple((region, _bounded(rings)) for region, rings in _REGION_RINGS.items()) + + +def _point_in_ring(lat: float, lon: float, ring) -> bool: + inside = False + count = len(ring) + j = count - 1 + for i in range(count): + lon_i, lat_i = ring[i] + lon_j, lat_j = ring[j] + if (lat_i > lat) != (lat_j > lat): + crossing = (lon_j - lon_i) * (lat - lat_i) / (lat_j - lat_i) + lon_i + if lon < crossing: + inside = not inside + j = i + return inside + + +def valid_position(lat: float, lon: float) -> bool: + return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4) + + +def region_for_position(lat: float, lon: float) -> str: + if not valid_position(lat, lon): + return UNKNOWN_REGION + + for region, rings in _REGIONS: + for (min_lon, min_lat, max_lon, max_lat), ring in rings: + if min_lon <= lon <= max_lon and min_lat <= lat <= max_lat and _point_in_ring(lat, lon, ring): + return region + + return METRIC_REGION + + +def region_is_metric(region: str) -> bool: + return bool(region) and region not in MPH_REGIONS diff --git a/iqpilot/common/slc_variables.py b/iqpilot/common/slc_variables.py index 421e365..80278d5 100644 --- a/iqpilot/common/slc_variables.py +++ b/iqpilot/common/slc_variables.py @@ -4,27 +4,22 @@ EARTH_RADIUS = 6378137 # Mapbox API limits FREE_MAPBOX_REQUESTS = 100_000 -# Speed limit offset maps for different unit systems -# Each entry is (min_speed_ms, max_speed_ms, param_name) +# Speed limit offset zones for different unit systems +# Each entry is (min_speed_ms, max_speed_ms, param_name); the param value is a +# percent offset applied to the resolved limit (e.g. 10 -> +10%), lower bound inclusive + +OFFSET_PERCENT_MAX = 50.0 OFFSET_MAP_IMPERIAL = [ - (0, 11.2, "speed_limit_offset1"), # 0-24 mph - (11.2, 15.2, "speed_limit_offset2"), # 25-34 mph - (15.2, 19.6, "speed_limit_offset3"), # 35-44 mph - (19.6, 24.1, "speed_limit_offset4"), # 45-54 mph - (24.1, 28.6, "speed_limit_offset5"), # 55-64 mph - (28.6, 33.1, "speed_limit_offset6"), # 65-74 mph - (33.1, 44.2, "speed_limit_offset7"), # 75-99 mph + (0, 8.94, "speed_limit_offset1"), # 0-20 mph + (8.94, 17.88, "speed_limit_offset2"), # 20-40 mph + (17.88, float("inf"), "speed_limit_offset3"), # 40+ mph ] OFFSET_MAP_METRIC = [ - (0, 8.1, "speed_limit_offset1"), # 0-29 km/h - (8.1, 13.6, "speed_limit_offset2"), # 30-49 km/h - (13.6, 16.4, "speed_limit_offset3"), # 50-59 km/h - (16.4, 21.9, "speed_limit_offset4"), # 60-79 km/h - (21.9, 27.5, "speed_limit_offset5"), # 80-99 km/h - (27.5, 33.1, "speed_limit_offset6"), # 100-119 km/h - (33.1, 38.9, "speed_limit_offset7"), # 120-140 km/h + (0, 8.33, "speed_limit_offset1"), # 0-30 km/h + (8.33, 16.67, "speed_limit_offset2"), # 30-60 km/h + (16.67, float("inf"), "speed_limit_offset3"), # 60+ km/h ] # Speed limit filler constants diff --git a/iqpilot/konn3kt/canlive/__init__.py b/iqpilot/konn3kt/canlive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iqpilot/konn3kt/canlive/canlived.py b/iqpilot/konn3kt/canlive/canlived.py new file mode 100755 index 0000000..2cebdee --- /dev/null +++ b/iqpilot/konn3kt/canlive/canlived.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +""" +canlived — live CAN bridge to konn3kt. + +Streams the device's live CAN bus to the konn3kt server so it can be viewed remotely +in Cabana (via a local ZMQ proxy on the laptop). This is the remote analogue of running +`./cereal/messaging/bridge` locally: instead of re-publishing CAN over a LAN ZMQ socket, +canlived opens its OWN websocket to konn3kt and forwards the raw capnp `Event` frames. + +It is deliberately a separate daemon (not part of hephaestusd's control websocket): + * hephaestusd's send path fragments everything as TEXT frames through one queue, which + cannot carry binary capnp and would head-of-line-block the control plane at 100Hz. + * a dedicated socket means CAN traffic and control traffic never contend. + +Lifecycle: the manager launches canlived only while the `CanLiveStreaming` param is set. +hephaestusd sets/clears that param via startCanLive/stopCanLive, which the konn3kt server +calls when the first viewer connects / the last viewer disconnects. So canlived runs only +during an active debug session — no idle connections, no battery/data cost otherwise. + +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved. +""" +import os +import threading + +from websocket import ABNF, create_connection + +import cereal.messaging as messaging +from openpilot.common.api import Api +from openpilot.common.params import Params +from openpilot.common.swaglog import cloudlog + +# Cabana's live "Device" stream subscribes only to "can", so that's all we forward to +# match the local experience exactly. (sendcan/TX is not shown by the live device view.) +CAN_SERVICES = ["can"] + +# Reconnect backoff bounds (seconds). +RECONNECT_MIN = 1.0 +RECONNECT_MAX = 10.0 + + +def _api_host() -> str: + # Same host hephaestusd talks to; force the websocket scheme. + host = os.getenv("HEPHAESTUS_HOST") or os.getenv("KONN3KT_API_HOST") or "wss://api-iqlabs.konn3kt.com" + host = host.rstrip("/") + if host.startswith("https://"): + host = "wss://" + host[len("https://"):] + elif host.startswith("http://"): + host = "ws://" + host[len("http://"):] + return host + + +def _stream_once(dongle_id: str, ws_uri: str, token: str, exit_event: threading.Event) -> None: + """Open one websocket and pump CAN until it drops or we're asked to exit.""" + ws = create_connection(ws_uri, cookie="jwt=" + token, enable_multithread=True, timeout=30.0) + cloudlog.info("canlived: connected to %s", ws_uri) + try: + # Blocking receive with a short timeout so we periodically re-check exit_event and + # the socket stays responsive to shutdown even when the bus is quiet. + socks = [messaging.sub_sock(s, conflate=False, timeout=100) for s in CAN_SERVICES] + while not exit_event.is_set(): + got_any = False + for sock in socks: + while True: + raw = sock.receive(non_blocking=True) + if raw is None: + break + got_any = True + # Forward the exact capnp Event bytes as a single binary frame. canlived owns + # this socket, so there is no fragmentation/interleaving to worry about. + ws.send_frame(ABNF.create_frame(raw, ABNF.OPCODE_BINARY, 1)) + if not got_any: + # Nothing pending across any sub — yield briefly instead of busy-spinning. + exit_event.wait(0.005) + finally: + try: + ws.close() + except Exception: + pass + + +def main(exit_event: threading.Event | None = None) -> None: + if exit_event is None: + exit_event = threading.Event() + + params = Params() + dongle_id = params.get("DongleId", encoding="utf-8") + if not dongle_id: + cloudlog.error("canlived: no DongleId, cannot stream") + return + + api = Api(dongle_id) + host = _api_host() + ws_uri = f"{host}/ws/can/{dongle_id}" + + backoff = RECONNECT_MIN + while not exit_event.is_set(): + try: + token = api.get_token(expiry_hours=1) + _stream_once(dongle_id, ws_uri, token, exit_event) + backoff = RECONNECT_MIN # clean disconnect, reset backoff + except Exception as e: + cloudlog.exception("canlived: stream error: %s", e) + exit_event.wait(backoff) + backoff = min(backoff * 2, RECONNECT_MAX) + + +if __name__ == "__main__": + main() diff --git a/iqpilot/selfdrive/controls/lib/speed_limit_controller.py b/iqpilot/selfdrive/controls/lib/speed_limit_controller.py index d47db3c..c5eed26 100644 --- a/iqpilot/selfdrive/controls/lib/speed_limit_controller.py +++ b/iqpilot/selfdrive/controls/lib/speed_limit_controller.py @@ -17,7 +17,7 @@ from openpilot.common.realtime import DT_MDL from openpilot.common.swaglog import cloudlog from openpilot.iqpilot.common.k3_slc_log import k3_slc_log from openpilot.iqpilot.common.slc_utilities import calculate_bearing_offset, is_url_pingable -from openpilot.iqpilot.common.slc_variables import FREE_MAPBOX_REQUESTS, OFFSET_MAP_IMPERIAL, OFFSET_MAP_METRIC +from openpilot.iqpilot.common.slc_variables import FREE_MAPBOX_REQUESTS, OFFSET_MAP_IMPERIAL, OFFSET_MAP_METRIC, OFFSET_PERCENT_MAX try: import requests @@ -354,6 +354,8 @@ class SpeedLimitController: pass self.executor = ThreadPoolExecutor(max_workers=1) + self._offset_cache = {} + self._offset_cache_t = 0.0 self._last_mapbox_log_t = 0.0 self._last_mapbox_diag_t = 0.0 self._last_mapbox_diag_message = None @@ -416,17 +418,33 @@ class SpeedLimitController: return self._assist.output_a_target def get_offset(self, is_metric): + target = self._assist.target + # offsets only apply to real limit sources: fallback set-speed publishes "None", + # construction clamps must never be inflated + if target <= 0 or self._assist.source in ("None", "Construction"): + return 0.0 offset_map = OFFSET_MAP_METRIC if is_metric else OFFSET_MAP_IMPERIAL for low, high, offset_param in offset_map: - if low < self._assist.target < high: - offset_value = self.params.get(offset_param) - if offset_value is not None: - if isinstance(offset_value, bytes): - return float(offset_value.decode("utf-8")) - return float(offset_value) - return 0.0 + if low <= target < high: + percent = float(np.clip(self._get_offset_percent(offset_param), -OFFSET_PERCENT_MAX, OFFSET_PERCENT_MAX)) + return target * percent / 100.0 return 0.0 + def _get_offset_percent(self, offset_param): + now_mono = time.monotonic() + if now_mono - self._offset_cache_t >= 5.0: + self._offset_cache.clear() + self._offset_cache_t = now_mono + if offset_param not in self._offset_cache: + offset_value = self.params.get(offset_param) + try: + if isinstance(offset_value, bytes): + offset_value = offset_value.decode("utf-8") + self._offset_cache[offset_param] = float(offset_value) if offset_value is not None else 0.0 + except (ValueError, TypeError): + self._offset_cache[offset_param] = 0.0 + return self._offset_cache[offset_param] + @staticmethod def _is_alive(sm, key): if hasattr(sm, "alive"): diff --git a/iqpilot/selfdrive/controls/lib/tests/test_slc_speed_limit_control.py b/iqpilot/selfdrive/controls/lib/tests/test_slc_speed_limit_control.py index e1e61f3..c6c8ddf 100644 --- a/iqpilot/selfdrive/controls/lib/tests/test_slc_speed_limit_control.py +++ b/iqpilot/selfdrive/controls/lib/tests/test_slc_speed_limit_control.py @@ -2,9 +2,11 @@ Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos """ +from datetime import datetime from types import SimpleNamespace from openpilot.common.constants import CV +from openpilot.iqpilot.common.slc_variables import OFFSET_MAP_IMPERIAL from openpilot.iqpilot.selfdrive.controls.lib.slc_vcruise import SLCVCruise, CRUISING_SPEED from openpilot.iqpilot.selfdrive.controls.lib.speed_limit_controller import SpeedLimitController, POLICY_MAP_DATA_PRIORITY, POLICY_COMBINED @@ -92,7 +94,7 @@ def test_speed_limit_controller_resolves_source_by_priority(): slc_params = _base_slc_params_controller() slc_params["slc_policy"] = POLICY_MAP_DATA_PRIORITY - controller.update_limits(25.0, None, True, 30.0, 27.0, sm, slc_params) + controller.update_limits(25.0, datetime.now(), True, 30.0, 27.0, sm, slc_params) assert controller.active_source == "Map Data" assert controller.active_target == 18.0 @@ -112,7 +114,7 @@ def test_speed_limit_controller_combined_mode_prefers_smallest_limit(): slc_params = _base_slc_params_controller() slc_params["slc_policy"] = POLICY_COMBINED - controller.update_limits(28.0, None, True, 31.0, 27.0, sm, slc_params) + controller.update_limits(28.0, datetime.now(), True, 31.0, 27.0, sm, slc_params) assert controller.active_source == "Map Data" assert controller.active_target == 16.0 @@ -128,6 +130,7 @@ def test_slc_vcruise_applies_target_without_increasing_cruise(): slc._get_slc_params = lambda: { "speed_limit_controller": True, + "speed_limit_mode": 3, "show_speed_limits": False, "is_metric": True, "slc_policy": POLICY_MAP_DATA_PRIORITY, @@ -163,6 +166,7 @@ def test_slc_vcruise_show_only_does_not_modify_cruise(): slc.slc.active_source = "Map Data" slc._get_slc_params = lambda: { "speed_limit_controller": False, + "speed_limit_mode": 1, "show_speed_limits": True, "is_metric": True, "slc_policy": POLICY_MAP_DATA_PRIORITY, @@ -198,6 +202,7 @@ def test_slc_vcruise_auto_raises_for_higher_limit_when_confirmation_disabled(): slc._get_slc_params = lambda: { "speed_limit_controller": True, + "speed_limit_mode": 3, "show_speed_limits": False, "is_metric": True, "slc_policy": POLICY_MAP_DATA_PRIORITY, @@ -232,6 +237,7 @@ def test_slc_vcruise_does_not_auto_raise_when_higher_confirmation_enabled(): slc._get_slc_params = lambda: { "speed_limit_controller": True, + "speed_limit_mode": 3, "show_speed_limits": False, "is_metric": True, "slc_policy": POLICY_MAP_DATA_PRIORITY, @@ -392,6 +398,50 @@ def test_construction_zone_never_raises_cruise_even_with_auto_raise(): assert abs(out - 60.0 * CV.MPH_TO_MS) < 1e-6 +def _offset_controller(pct1=10.0, pct2=5.0, pct3=8.0): + params = FakeParams() + params.put("speed_limit_offset1", pct1) + params.put("speed_limit_offset2", pct2) + params.put("speed_limit_offset3", pct3) + controller = SpeedLimitController(params) + controller._assist.source = "Map Data" + return controller + + +def test_get_offset_percent_per_zone(): + controller = _offset_controller() + + controller._assist.target = 6.7 # ~15 mph -> zone 1 + assert abs(controller.get_offset(False) - 6.7 * 0.10) < 1e-9 + + controller._assist.target = 13.4 # ~30 mph -> zone 2 + assert abs(controller.get_offset(False) - 13.4 * 0.05) < 1e-9 + + controller._assist.target = 31.3 # ~70 mph -> zone 3 (open-ended) + assert abs(controller.get_offset(False) - 31.3 * 0.08) < 1e-9 + + +def test_get_offset_zone_lower_bound_inclusive(): + controller = _offset_controller() + boundary = OFFSET_MAP_IMPERIAL[1][0] + controller._assist.target = boundary + assert abs(controller.get_offset(False) - boundary * 0.05) < 1e-9 + + +def test_get_offset_zero_without_real_limit_source(): + for source in ("None", "Construction"): + controller = _offset_controller() + controller._assist.source = source + controller._assist.target = 30.0 + assert controller.get_offset(False) == 0.0 + + +def test_get_offset_percent_clamped(): + controller = _offset_controller(pct3=500.0) + controller._assist.target = 30.0 + assert abs(controller.get_offset(False) - 30.0 * 0.50) < 1e-9 + + def test_construction_zone_fires_event_once_per_zone_entry(): from cereal import custom event = custom.IQOnroadEvent.EventName.constructionZoneDetected diff --git a/iqpilot/tests/test_auto_units.py b/iqpilot/tests/test_auto_units.py new file mode 100644 index 0000000..c9fd94c --- /dev/null +++ b/iqpilot/tests/test_auto_units.py @@ -0,0 +1,91 @@ +from openpilot.common.params import Params +from openpilot.iqpilot.common.auto_units import CONFIRMATIONS, AutoUnits + +SEATTLE = (47.6062, -122.3321) +BERLIN = (52.5200, 13.4050) +LONDON = (51.5074, -0.1278) + + +class StubAutoUnits(AutoUnits): + def __init__(self, params): + super().__init__(params) + self.position = (0.0, 0.0, False) + + def _position(self): + return self.position + + +def settle(auto_units, position, count=CONFIRMATIONS, start=0.0): + auto_units.position = position + for i in range(count): + auto_units.update(now=start + i * 100.0) + return start + count * 100.0 + + +class TestAutoUnits: + def setup_method(self): + self.params = Params() + self.params.put_bool("IQAutoUnits", True) + self.params.remove("IQAutoUnitsRegion") + self.params.put_bool("IsMetric", False) + self.auto_units = StubAutoUnits(self.params) + + def test_no_fix_does_nothing(self): + settle(self.auto_units, (0.0, 0.0, False)) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_disabled_does_nothing(self): + self.params.put_bool("IQAutoUnits", False) + settle(self.auto_units, (*BERLIN, True)) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_metric_region_switches_to_metric(self): + settle(self.auto_units, (*BERLIN, True)) + assert self.params.get("IQAutoUnitsRegion") == "METRIC" + assert self.params.get_bool("IsMetric") + + def test_mph_region_stays_imperial(self): + settle(self.auto_units, (*SEATTLE, True)) + assert self.params.get("IQAutoUnitsRegion") == "US" + assert not self.params.get_bool("IsMetric") + + def test_uk_stays_imperial(self): + self.params.put_bool("IsMetric", True) + settle(self.auto_units, (*LONDON, True)) + assert self.params.get("IQAutoUnitsRegion") == "GB" + assert not self.params.get_bool("IsMetric") + + def test_border_crossing_switches_units(self): + now = settle(self.auto_units, (*SEATTLE, True)) + assert not self.params.get_bool("IsMetric") + + settle(self.auto_units, (*BERLIN, True), start=now) + assert self.params.get("IQAutoUnitsRegion") == "METRIC" + assert self.params.get_bool("IsMetric") + + def test_manual_override_is_kept_within_a_region(self): + now = settle(self.auto_units, (*SEATTLE, True)) + self.params.put_bool("IsMetric", True) + + settle(self.auto_units, (*SEATTLE, True), start=now) + assert self.params.get_bool("IsMetric") + + def test_unconfirmed_region_is_not_applied(self): + settle(self.auto_units, (*BERLIN, True), count=CONFIRMATIONS - 1) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_flapping_region_resets_confirmations(self): + now = 0.0 + for position in (BERLIN, SEATTLE, BERLIN, SEATTLE): + now = settle(self.auto_units, (*position, True), count=1, start=now) + assert self.params.get("IQAutoUnitsRegion") is None + assert not self.params.get_bool("IsMetric") + + def test_rate_limited(self): + self.auto_units.position = (*BERLIN, True) + for _ in range(CONFIRMATIONS * 4): + self.auto_units.update(now=1.0) + assert self.params.get("IQAutoUnitsRegion") is None diff --git a/iqpilot/tests/test_geo_regions.py b/iqpilot/tests/test_geo_regions.py new file mode 100644 index 0000000..ef9ba30 --- /dev/null +++ b/iqpilot/tests/test_geo_regions.py @@ -0,0 +1,165 @@ +import pytest + +from openpilot.iqpilot.common.geo_regions import METRIC_REGION, UNKNOWN_REGION, region_for_position, region_is_metric + +US_POINTS = [ + (47.6062, -122.3321, "Seattle"), + (42.3314, -83.0458, "Detroit"), + (42.8864, -78.8784, "Buffalo"), + (25.7617, -80.1918, "Miami"), + (29.7604, -95.3698, "Houston"), + (32.7157, -117.1611, "San Diego"), + (34.0522, -118.2437, "Los Angeles"), + (61.2181, -149.9003, "Anchorage"), + (58.3019, -134.4197, "Juneau"), + (64.8378, -147.7164, "Fairbanks"), + (21.3069, -157.8583, "Honolulu"), + (18.4655, -66.1057, "San Juan"), + (13.4757, 144.7489, "Guam"), + (44.9778, -93.2650, "Minneapolis"), + (40.7128, -74.0060, "New York"), + (41.8781, -87.6298, "Chicago"), + (31.7900, -106.4300, "El Paso"), + (26.2034, -98.2300, "McAllen"), + (44.8016, -68.7712, "Bangor"), + (48.7519, -122.4787, "Bellingham"), + (46.8772, -96.7898, "Fargo"), + (48.6023, -93.4093, "International Falls"), + (46.4953, -84.3453, "Sault Ste. Marie MI"), + (47.1211, -88.5694, "Houghton"), + (41.6528, -83.5379, "Toledo"), + (42.1370, -83.1930, "Trenton MI"), + (39.7392, -104.9903, "Denver"), + (33.4484, -112.0740, "Phoenix"), + (30.3322, -81.6557, "Jacksonville"), + (42.3601, -71.0589, "Boston"), + (38.9072, -77.0369, "Washington DC"), +] + +GB_POINTS = [ + (51.5074, -0.1278, "London"), + (54.5973, -5.9301, "Belfast"), + (55.8642, -4.2518, "Glasgow"), + (51.4816, -3.1791, "Cardiff"), + (51.4545, -2.5879, "Bristol"), + (53.4084, -2.9916, "Liverpool"), + (53.4808, -2.2426, "Manchester"), + (55.9533, -3.1883, "Edinburgh"), + (52.4862, -1.8904, "Birmingham"), + (53.8008, -1.5491, "Leeds"), + (57.4778, -4.2247, "Inverness"), + (57.1497, -2.0943, "Aberdeen"), + (56.4620, -2.9707, "Dundee"), + (58.6373, -3.0689, "John o' Groats"), + (54.1509, -4.4814, "Douglas"), + (49.1858, -2.1064, "St Helier"), + (55.0000, -7.3200, "Derry"), + (54.3438, -7.6315, "Enniskillen"), + (54.1751, -6.3402, "Newry"), + (54.4783, -8.0906, "Belleek"), + (54.5973, -7.3095, "Omagh"), + (55.2053, -6.6570, "Portrush"), + (58.2090, -6.3890, "Stornoway"), + (58.9809, -2.9605, "Kirkwall"), + (60.1546, -1.1494, "Lerwick"), + (50.7184, -3.5339, "Exeter"), + (50.3755, -4.1427, "Plymouth"), + (52.6309, 1.2974, "Norwich"), + (54.9783, -1.6178, "Newcastle"), + (51.8642, -2.2382, "Gloucester"), + (52.4140, -4.0810, "Aberystwyth"), + (51.6214, -3.9436, "Swansea"), + (50.6938, -1.3040, "Newport IoW"), +] + +LR_POINTS = [ + (6.3005, -10.7969, "Monrovia"), + (4.3750, -7.7169, "Harper"), + (6.9956, -9.4722, "Gbarnga"), + (6.0667, -8.1333, "Zwedru"), + (8.4219, -9.7478, "Voinjama"), + (5.8808, -10.0467, "Buchanan"), + (5.0100, -9.0400, "Greenville"), + (7.3500, -8.7200, "Ganta"), +] + +METRIC_POINTS = [ + (49.2827, -123.1207, "Vancouver"), + (48.4284, -123.3656, "Victoria"), + (43.6532, -79.3832, "Toronto"), + (42.3149, -83.0364, "Windsor"), + (46.5136, -84.3358, "Sault Ste. Marie ON"), + (42.9745, -82.4066, "Sarnia"), + (43.2557, -79.8711, "Hamilton"), + (42.9849, -81.2453, "London ON"), + (45.5019, -73.5674, "Montreal"), + (45.4765, -75.7013, "Gatineau"), + (46.8139, -71.2080, "Quebec City"), + (46.0878, -64.7782, "Moncton"), + (44.6488, -63.5752, "Halifax"), + (49.8951, -97.1384, "Winnipeg"), + (51.0447, -114.0719, "Calgary"), + (53.5461, -113.4938, "Edmonton"), + (52.1332, -106.6700, "Saskatoon"), + (50.6745, -120.3273, "Kamloops"), + (32.5149, -117.0382, "Tijuana"), + (31.7000, -106.4700, "Ciudad Juarez"), + (25.6866, -100.3161, "Monterrey"), + (27.5060, -99.5075, "Nuevo Laredo"), + (19.4326, -99.1332, "Mexico City"), + (53.3498, -6.2603, "Dublin"), + (51.8985, -8.4756, "Cork"), + (53.2707, -9.0568, "Galway"), + (54.9503, -7.7345, "Letterkenny"), + (54.0000, -6.4000, "Dundalk"), + (54.2489, -6.9683, "Monaghan"), + (54.2766, -8.4761, "Sligo"), + (54.6538, -8.1096, "Donegal"), + (52.5200, 13.4050, "Berlin"), + (52.2297, 21.0122, "Warsaw"), + (48.8566, 2.3522, "Paris"), + (60.1699, 24.9384, "Helsinki"), + (50.4501, 30.5234, "Kyiv"), + (-33.8688, 151.2093, "Sydney"), + (35.6762, 139.6503, "Tokyo"), + (8.4844, -13.2299, "Freetown"), + (7.8767, -11.1875, "Kenema"), + (8.2783, -10.5733, "Kailahun"), + (9.6412, -13.5784, "Conakry"), + (7.7562, -8.8179, "Nzerekore"), + (7.4125, -7.5539, "Man"), + (5.3600, -4.0083, "Abidjan"), +] + + +@pytest.mark.parametrize("lat, lon, name", US_POINTS) +def test_us_positions(lat, lon, name): + assert region_for_position(lat, lon) == "US", name + + +@pytest.mark.parametrize("lat, lon, name", GB_POINTS) +def test_gb_positions(lat, lon, name): + assert region_for_position(lat, lon) == "GB", name + + +@pytest.mark.parametrize("lat, lon, name", LR_POINTS) +def test_lr_positions(lat, lon, name): + assert region_for_position(lat, lon) == "LR", name + + +@pytest.mark.parametrize("lat, lon, name", METRIC_POINTS) +def test_metric_positions(lat, lon, name): + assert region_for_position(lat, lon) == METRIC_REGION, name + + +@pytest.mark.parametrize("lat, lon", [(0.0, 0.0), (0.0, 0.00001), (91.0, 10.0), (10.0, 181.0)]) +def test_invalid_positions(lat, lon): + assert region_for_position(lat, lon) == UNKNOWN_REGION + + +def test_region_is_metric(): + assert not region_is_metric("US") + assert not region_is_metric("GB") + assert not region_is_metric("LR") + assert region_is_metric(METRIC_REGION) + assert not region_is_metric(UNKNOWN_REGION) diff --git a/iqpilot/ui/onroad/hud_renderer.py b/iqpilot/ui/onroad/hud_renderer.py index d0505db..a329ae6 100644 --- a/iqpilot/ui/onroad/hud_renderer.py +++ b/iqpilot/ui/onroad/hud_renderer.py @@ -47,6 +47,8 @@ class IQHudRenderer(HudRenderer): has_limit = self.speed_limit_renderer.speed_limit_valid or self.speed_limit_renderer.speed_limit_last_valid self.limit_available = has_limit self.limit_speed_text = str(round(self.speed_limit_renderer.speed_limit_last)) if has_limit else "---" + offset = round(self.speed_limit_renderer.speed_limit_offset) + self.limit_offset_text = f"{offset:+d}" if has_limit and offset != 0 else "" self.turn_signal_controller.update() self.speed_renderer.update() self.soft_warning_renderer.update() diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 5a10351..f915791 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -128,6 +128,16 @@ function launch { fi fi + # Best-effort: install the verified runtime before anything starts the services that + # need it. This only succeeds on a tree that has built _verified_import.so, so on a + # prebuilt release install it is a no-op -- there the runtime arrives with the IQ.OS + # flash, whose image bakes it in. What actually fixes the 20+ minute first-install hang + # is --no-block on the service starts below: hephaestusd/ble-transportd/flockd each + # ExecStartPre-wait up to 600s for /usr/libexec/iqpilot/iqpilot_bundle_runner. + if [ -x "$DIR/system/proprietary_runtime/install_verified_runtime.sh" ]; then + "$DIR/system/proprietary_runtime/install_verified_runtime.sh" || true + fi + # Install/update proprietary runtime bundles. if [ -f "$DIR/artifacts/runtime/ensure_private_installed.sh" ]; then bash "$DIR/artifacts/runtime/ensure_private_installed.sh" || true @@ -161,10 +171,12 @@ function launch { sudo mount -o remount,ro / fi sudo systemctl enable "${service_name}.service" + # --no-block: these units ExecStartPre-wait for the verified runtime. Blocking here + # made a missing runner stall the whole install for the unit's 600s timeout (x3 units). if systemctl is-active --quiet "${service_name}.service"; then - sudo systemctl restart "${service_name}.service" + sudo systemctl restart --no-block "${service_name}.service" else - sudo systemctl start "${service_name}.service" + sudo systemctl start --no-block "${service_name}.service" fi elif [ -f "$service_src" ]; then if [ ! -f "$service_dst" ] || ! cmp -s "$service_src" "$service_dst"; then @@ -173,9 +185,9 @@ function launch { sudo systemctl daemon-reload sudo systemctl enable "${service_name}.service" sudo mount -o remount,ro / - sudo systemctl restart "${service_name}.service" + sudo systemctl restart --no-block "${service_name}.service" elif ! systemctl is-active --quiet "${service_name}.service"; then - sudo systemctl start "${service_name}.service" + sudo systemctl start --no-block "${service_name}.service" fi fi done diff --git a/launch_env.sh b/launch_env.sh index 2869567..6bd7796 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -23,7 +23,7 @@ if [ -z "$AGNOS_VERSION" ]; then case "$DEVICE_MODEL" in *) - export AGNOS_VERSION="IQ.OS 4.9" + export AGNOS_VERSION="IQ.OS 4.9.3" ;; esac fi diff --git a/panda/SConscript b/panda/SConscript index 56d73d9..ce98415 100644 --- a/panda/SConscript +++ b/panda/SConscript @@ -93,7 +93,9 @@ def build_project(project_name, project, main, extra_flags): ) # short colored build output shared with the top-level build (if present) - _pretty = Dir('#site_scons/site_tools').File('pretty.py') + _pretty = Dir('#tools/scons/site_tools').File('pretty.py') + if not _pretty.exists(): + _pretty = Dir('#site_scons/site_tools').File('pretty.py') if _pretty.exists(): env.Tool('pretty', toolpath=[_pretty.dir.abspath]) if not hasattr(env, 'PrettyAction'): diff --git a/panda/board/drivers/interrupts.h b/panda/board/drivers/interrupts.h index 1188a8e..58f89c1 100644 --- a/panda/board/drivers/interrupts.h +++ b/panda/board/drivers/interrupts.h @@ -74,6 +74,10 @@ void init_interrupts(bool check_rate_limit){ for(uint16_t i=0U; i NACK retry storms. +#define IRQ_PRIORITY_COMMS 0U +#define IRQ_PRIORITY_DEFAULT 2U + typedef struct interrupt { IRQn_Type irq_type; void (*handler)(void); diff --git a/panda/board/stm32h7/llspi.h b/panda/board/stm32h7/llspi.h index 05f8e22..5b2d31f 100644 --- a/panda/board/stm32h7/llspi.h +++ b/panda/board/stm32h7/llspi.h @@ -101,6 +101,12 @@ void llspi_init(void) { register_set(&(SPI4->CR1), SPI_CR1_SPE, 0xFFFFU); register_set(&(SPI4->CR2), 0, 0xFFFFU); + // preempt other handlers so the RX DMA is re-armed before the master clocks + // the next phase of a transfer + NVIC_SetPriority(DMA2_Stream2_IRQn, IRQ_PRIORITY_COMMS); + NVIC_SetPriority(DMA2_Stream3_IRQn, IRQ_PRIORITY_COMMS); + NVIC_SetPriority(SPI4_IRQn, IRQ_PRIORITY_COMMS); + NVIC_EnableIRQ(DMA2_Stream2_IRQn); NVIC_EnableIRQ(DMA2_Stream3_IRQn); NVIC_EnableIRQ(SPI4_IRQn); diff --git a/panda/tests/libpanda/SConscript b/panda/tests/libpanda/SConscript index 0f423ac..edd1e0b 100644 --- a/panda/tests/libpanda/SConscript +++ b/panda/tests/libpanda/SConscript @@ -27,7 +27,9 @@ if system == "Darwin": env.PrependENVPath('PATH', '/opt/homebrew/bin') # short colored build output, if the top-level pretty tool is present (main-repo build) -_pretty = Dir('#site_scons/site_tools').File('pretty.py') +_pretty = Dir('#tools/scons/site_tools').File('pretty.py') +if not _pretty.exists(): + _pretty = Dir('#site_scons/site_tools').File('pretty.py') if _pretty.exists(): env.Tool('pretty', toolpath=[_pretty.dir.abspath]) diff --git a/selfdrive/controls/controlsd.py b/selfdrive/controls/controlsd.py index 1bd7b31..ccc0146 100755 --- a/selfdrive/controls/controlsd.py +++ b/selfdrive/controls/controlsd.py @@ -168,8 +168,11 @@ class Controls(IQControlsLayer): CC.latActive = _lat_active and not CS.steerFaultTemporary and not CS.steerFaultPermanent and \ (not standstill or self.CP.steerAtStandstill) + # long control may stay active through a gas override on platforms that opt in + override_longitudinal = any(e.overrideLongitudinal for e in self.sm['onroadEvents']) + long_through_override = self.CP_IQ.longActiveWithGasOverride and self.CP.openpilotLongitudinalControl CC.longActive = CC.enabled and not getattr(CS, 'cruiseFaultLateralMode', False) and \ - not any(e.overrideLongitudinal for e in self.sm['onroadEvents']) and \ + (not override_longitudinal or long_through_override) and \ (self.CP.openpilotLongitudinalControl or not self.CP_IQ.pcmCruiseSpeed) actuators = CC.actuators @@ -184,7 +187,7 @@ class Controls(IQControlsLayer): # accel PID loop pid_accel_limits = self.CI.get_pid_accel_limits(self.CP, self.CP_IQ, CS.vEgo, CS.vCruise * CV.KPH_TO_MS) actuators.accel = float(self.LoC.update(CC.longActive, CS, long_plan.aTarget, long_plan.shouldStop, pid_accel_limits, - long_plan.leadDistance, long_plan.hasLead)) + long_plan.leadDistance, long_plan.hasLead, gas_override=override_longitudinal)) # Steering PID loop and lateral MPC # Reset desired curvature to current to avoid violating the limits on engage diff --git a/selfdrive/controls/lib/longcontrol.py b/selfdrive/controls/lib/longcontrol.py index 4be1953..31fc878 100644 --- a/selfdrive/controls/lib/longcontrol.py +++ b/selfdrive/controls/lib/longcontrol.py @@ -53,7 +53,7 @@ class LongControl: def reset(self): self.pid.reset() - def update(self, active, CS, a_target, should_stop, accel_limits, lead_distance=0.0, has_lead=False): + def update(self, active, CS, a_target, should_stop, accel_limits, lead_distance=0.0, has_lead=False, gas_override=False): """Update longitudinal control. This updates the state machine and runs a PID loop""" self.pid.neg_limit = accel_limits[0] self.pid.pos_limit = accel_limits[1] @@ -87,8 +87,13 @@ class LongControl: else: error = a_target - CS.aEgo output_accel = self.pid.update(error, speed=CS.vEgo, - feedforward=a_target) + feedforward=a_target, + freeze_integrator=gas_override) self.smooth.reset() + if gas_override: + # safety blocks braking while the gas is pressed, and a blocked tx drops the whole frame + output_accel = max(output_accel, 0.0) + self.last_output_accel = np.clip(output_accel, accel_limits[0], accel_limits[1]) return self.last_output_accel diff --git a/selfdrive/selfdrived/helpers.py b/selfdrive/selfdrived/helpers.py index 684b683..3e079fd 100644 --- a/selfdrive/selfdrived/helpers.py +++ b/selfdrive/selfdrived/helpers.py @@ -26,7 +26,8 @@ class ExcessiveActuationCheck: # CS.aEgo can be noisy to bumps in the road, transitioning from standstill, losing traction, etc. # longitudinal accel_calibrated = calibrated_pose.acceleration.x - excessive_long_actuation = sm['carControl'].longActive and (accel_calibrated > ACCEL_MAX * 2 or accel_calibrated < ACCEL_MIN * 2) + excessive_long_actuation = sm['carControl'].longActive and ((not CS.gasPressed and accel_calibrated > ACCEL_MAX * 2) or + accel_calibrated < ACCEL_MIN * 2) # lateral yaw_rate = calibrated_pose.angular_velocity.yaw diff --git a/selfdrive/ui/layouts/settings/toggles.py b/selfdrive/ui/layouts/settings/toggles.py index 69145bb..8762999 100644 --- a/selfdrive/ui/layouts/settings/toggles.py +++ b/selfdrive/ui/layouts/settings/toggles.py @@ -41,6 +41,10 @@ DESCRIPTIONS = { "DashcamEnabled": tr_noop("Record and upload driving data and video. Disabling this stops all recording! No logs, no video, no audio."), 'RecordFront': tr_noop("Upload data from the driver facing camera and help improve the driver monitoring algorithm."), "IsMetric": tr_noop("Display speed in km/h instead of mph."), + "IQAutoUnits": tr_noop( + "Set the units from the device location. Speeds switch to km/h everywhere except the United States, " + + "the United Kingdom and Liberia, and are re-checked when you cross a border." + ), "RecordAudio": tr_noop("Record and store microphone audio while driving. The audio will be included in the dashcam video in Konn3kt."), "LongitudinalControlMode": tr_noop( "Choose longitudinal behavior: IQ.Pilot (IQ longitudinal + end-to-end), " @@ -95,6 +99,12 @@ class TogglesLayout(Widget): "metric.png", False, ), + "IQAutoUnits": ( + lambda: tr("Set Units From Location"), + DESCRIPTIONS["IQAutoUnits"], + "metric.png", + False, + ), } self._long_personality_setting = multiple_button_item( @@ -296,6 +306,8 @@ class TogglesLayout(Widget): def _toggle_callback(self, state: bool, param: str): self._params.put_bool(param, state) + if param == "IQAutoUnits" and state: + self._params.remove("IQAutoUnitsRegion") if self._toggle_defs[param][3]: self._params.put_bool("OnroadCycleRequested", True) diff --git a/selfdrive/ui/mici/layouts/settings/toggles.py b/selfdrive/ui/mici/layouts/settings/toggles.py index cee9df0..ea29bdc 100644 --- a/selfdrive/ui/mici/layouts/settings/toggles.py +++ b/selfdrive/ui/mici/layouts/settings/toggles.py @@ -18,13 +18,15 @@ class TogglesLayoutMici(NavScroller): disengage = BigParamControl("disengage on accelerator", "DisengageOnAccelerator") ldw = BigParamControl("lane departure warnings", "IsLdwEnabled") is_metric = BigParamControl("use metric units", "IsMetric") + auto_units = BigParamControl("set units from location", "IQAutoUnits", toggle_callback=self._auto_units_callback) - self._scroller.add_widgets([disengage, ldw, is_metric]) + self._scroller.add_widgets([disengage, ldw, is_metric, auto_units]) self._refresh_toggles = ( ("DisengageOnAccelerator", disengage), ("IsLdwEnabled", ldw), ("IsMetric", is_metric), + ("IQAutoUnits", auto_units), ) if ui_state.params.get_bool("ShowDebugInfo"): @@ -35,6 +37,10 @@ class TogglesLayoutMici(NavScroller): super().show_event() self._update_toggles() + def _auto_units_callback(self, state: bool): + if state: + ui_state.params.remove("IQAutoUnitsRegion") + def _update_toggles(self): ui_state.update_params() for key, item in self._refresh_toggles: diff --git a/selfdrive/ui/mici/onroad/cameraview.py b/selfdrive/ui/mici/onroad/cameraview.py index 89a4926..87e1f16 100644 --- a/selfdrive/ui/mici/onroad/cameraview.py +++ b/selfdrive/ui/mici/onroad/cameraview.py @@ -4,7 +4,7 @@ import pyray as rl from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import TICI +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +38,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if EGL_DMA_BUF_SUPPORTED: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -121,7 +121,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1 self._engaged_loc = rl.get_shader_location(self.shader, "engaged") self._engaged_val = rl.ffi.new("int[1]", [1]) self._enhance_driver_loc = rl.get_shader_location(self.shader, "enhance_driver") @@ -137,8 +137,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on comma 3/3X. + if EGL_DMA_BUF_SUPPORTED: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -189,7 +189,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if EGL_DMA_BUF_SUPPORTED and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -264,7 +264,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if EGL_DMA_BUF_SUPPORTED: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -387,12 +387,12 @@ class CameraView(Widget): self._initialize_textures() def _initialize_textures(self): - self._clear_textures() - if not TICI: - self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), - int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) - self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), - int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) + self._clear_textures() + if not EGL_DMA_BUF_SUPPORTED: + self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), + int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) + self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), + int(self.client.height // 2), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAY_ALPHA)) def _clear_textures(self): if self.texture_y and self.texture_y.id: @@ -404,7 +404,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if EGL_DMA_BUF_SUPPORTED: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/onroad/augmented_road_view.py b/selfdrive/ui/onroad/augmented_road_view.py index 7f058bc..329fd30 100644 --- a/selfdrive/ui/onroad/augmented_road_view.py +++ b/selfdrive/ui/onroad/augmented_road_view.py @@ -9,6 +9,7 @@ from openpilot.selfdrive.ui.onroad.alert_renderer import AlertRenderer from openpilot.selfdrive.ui.onroad.driver_state import DriverStateRenderer as BaseDriverStateRenderer, BTN_SIZE from openpilot.selfdrive.ui.onroad.hud_renderer import HudRenderer as BaseHudRenderer from openpilot.selfdrive.ui.onroad.model_renderer import ModelRenderer +from openpilot.selfdrive.ui.onroad.environment_renderer import EnvironmentRenderer from openpilot.selfdrive.ui.onroad.cameraview import CameraView from openpilot.system.ui.lib.application import gui_app from openpilot.common.issue_debug import log_issue_limited @@ -55,6 +56,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): self._split_nav_available = False self.model_renderer = ModelRenderer() + self.environment_renderer = EnvironmentRenderer() self.alert_renderer = AlertRenderer() self._hud_renderer = IQHudRenderer() self.driver_state_renderer = DriverStateRendererIQ() @@ -114,6 +116,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): # Draw all UI overlays self.model_renderer.render(camera_rect) + self.environment_renderer.render(camera_rect) AugmentedRoadViewIQ.update_fade_out_bottom_overlay(self, camera_rect) self._hud_renderer.render(camera_rect) @@ -280,6 +283,7 @@ class AugmentedRoadView(CameraView, AugmentedRoadViewIQ): ]) self.model_renderer.set_transform(video_transform @ calib_transform) self.model_renderer.set_frame_transform(video_transform, is_wide_camera) + self.environment_renderer.set_transform(video_transform @ calib_transform) return self._cached_matrix diff --git a/selfdrive/ui/onroad/cameraview.py b/selfdrive/ui/onroad/cameraview.py index 5443948..f552629 100644 --- a/selfdrive/ui/onroad/cameraview.py +++ b/selfdrive/ui/onroad/cameraview.py @@ -4,7 +4,7 @@ import pyray as rl from msgq.visionipc import VisionIpcClient, VisionStreamType, VisionBuf from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import TICI +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED from openpilot.system.ui.lib.application import gui_app from openpilot.system.ui.lib.egl import init_egl, create_egl_image, destroy_egl_image, bind_egl_image_to_texture, EGLImage from openpilot.system.ui.widgets import Widget @@ -38,7 +38,7 @@ void main() { """ # Choose fragment shader based on platform capabilities -if TICI: +if EGL_DMA_BUF_SUPPORTED: FRAME_FRAGMENT_SHADER = """ #version 300 es #extension GL_OES_EGL_image_external_essl3 : enable @@ -82,7 +82,7 @@ class CameraView(Widget): self._texture_needs_update = True self.last_connection_attempt: float = 0.0 self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAME_FRAGMENT_SHADER) - self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not TICI else -1 + self._texture1_loc: int = rl.get_shader_location(self.shader, "texture1") if not EGL_DMA_BUF_SUPPORTED else -1 self.frame: VisionBuf | None = None self.texture_y: rl.Texture | None = None @@ -94,8 +94,8 @@ class CameraView(Widget): self._placeholder_color: rl.Color | None = None - # Initialize EGL for zero-copy rendering on TICI - if TICI: + # Initialize EGL for zero-copy rendering on comma 3/3X. + if EGL_DMA_BUF_SUPPORTED: if not init_egl(): raise RuntimeError("Failed to initialize EGL") @@ -146,7 +146,7 @@ class CameraView(Widget): self._clear_textures() # Clean up EGL texture - if TICI and self.egl_texture: + if EGL_DMA_BUF_SUPPORTED and self.egl_texture: rl.unload_texture(self.egl_texture) self.egl_texture = None @@ -220,7 +220,7 @@ class CameraView(Widget): dst_rect = rl.Rectangle(x_offset, y_offset, scale_x, scale_y) # Render with appropriate method - if TICI: + if EGL_DMA_BUF_SUPPORTED: self._render_egl(src_rect, dst_rect) else: self._render_textures(src_rect, dst_rect) @@ -337,7 +337,7 @@ class CameraView(Widget): def _initialize_textures(self): self._clear_textures() - if not TICI: + if not EGL_DMA_BUF_SUPPORTED: self.texture_y = rl.load_texture_from_image(rl.Image(None, int(self.client.stride), int(self.client.height), 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_GRAYSCALE)) self.texture_uv = rl.load_texture_from_image(rl.Image(None, int(self.client.stride // 2), @@ -353,7 +353,7 @@ class CameraView(Widget): self.texture_uv = None # Clean up EGL resources - if TICI: + if EGL_DMA_BUF_SUPPORTED: for data in self.egl_images.values(): destroy_egl_image(data) self.egl_images = {} diff --git a/selfdrive/ui/onroad/environment_renderer.py b/selfdrive/ui/onroad/environment_renderer.py new file mode 100644 index 0000000..f822b5d --- /dev/null +++ b/selfdrive/ui/onroad/environment_renderer.py @@ -0,0 +1,141 @@ +import numpy as np +import pyray as rl +from cereal import custom + +from openpilot.selfdrive.ui.ui_state import ui_state +from openpilot.system.ui.lib.application import gui_app +from openpilot.system.ui.widgets import Widget + +MODE_OFF = 0 +MODE_OVERLAY = 1 +MODE_REPLACE = 2 + +_ENV_LABEL = custom.IQEnvironment.Object.Label +_OBJECT_COLORS = { + _ENV_LABEL.car: (40, 210, 200), + _ENV_LABEL.truck: (40, 210, 200), + _ENV_LABEL.bus: (40, 210, 200), + _ENV_LABEL.motorcycle: (90, 220, 255), + _ENV_LABEL.bicycle: (90, 220, 255), + _ENV_LABEL.person: (255, 210, 90), + _ENV_LABEL.stopSign: (255, 60, 45), + _ENV_LABEL.trafficLight: (255, 190, 0), +} + +_BOX_EDGES = ( + (0, 1), (1, 3), (3, 2), (2, 0), + (4, 5), (5, 7), (7, 6), (6, 4), + (0, 4), (1, 5), (2, 6), (3, 7), +) + +GRID_HALF_WIDTH = 12.0 +GRID_MAX_DISTANCE = 90.0 +GRID_STEP = 6.0 + + +class EnvironmentRenderer(Widget): + def __init__(self): + Widget.__init__(self) + self._car_space_transform = np.zeros((3, 3), dtype=np.float32) + self._mode = MODE_OFF + self._counter = 0 + + def set_transform(self, transform: np.ndarray): + self._car_space_transform = transform.astype(np.float32) + + def _project(self, pt: np.ndarray): + p = self._car_space_transform @ pt + if abs(p[2]) < 1e-6: + return None + return p[0] / p[2], p[1] / p[2] + + def _in_rect(self, x: float, y: float) -> bool: + r = self._rect + return r.x - 400 <= x <= r.x + r.width + 400 and r.y - 400 <= y <= r.y + r.height + 400 + + def _render(self, rect: rl.Rectangle): + sm = ui_state.sm + if self._counter % 30 == 0: + self._mode = int(ui_state.params.get("EnvironmentView", return_default=True) or 0) if ui_state.active_bundle else 0 + self._counter += 1 + + if self._mode == MODE_OFF: + return + if sm.recv_frame["liveCalibration"] < ui_state.started_frame: + return + + if self._mode == MODE_REPLACE: + self._draw_backdrop(rect) + self._draw_ground_grid() + if sm.valid["modelV2"]: + self._draw_model_scene(sm["modelV2"]) + + if sm.alive["iqEnvironment"] and sm.valid["iqEnvironment"]: + self._draw_objects(sm["iqEnvironment"]) + + def _draw_backdrop(self, rect: rl.Rectangle): + rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), int(rect.height), + rl.Color(14, 17, 22, 255), rl.Color(6, 8, 11, 255)) + + def _draw_ground_grid(self): + col = rl.Color(60, 70, 82, 90) + dist = GRID_STEP + while dist <= GRID_MAX_DISTANCE: + a = self._project(np.array([dist, -GRID_HALF_WIDTH, 0.0])) + b = self._project(np.array([dist, GRID_HALF_WIDTH, 0.0])) + if a and b and self._in_rect(*a) and self._in_rect(*b): + rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col) + dist += GRID_STEP + for off in np.arange(-GRID_HALF_WIDTH, GRID_HALF_WIDTH + 0.1, 3.0): + a = self._project(np.array([GRID_STEP, float(off), 0.0])) + b = self._project(np.array([GRID_MAX_DISTANCE, float(off), 0.0])) + if a and b and self._in_rect(*a) and self._in_rect(*b): + rl.draw_line_ex(rl.Vector2(*a), rl.Vector2(*b), 1.5, col) + + def _draw_polyline(self, xs, ys, zs, color, thick): + pts = [] + for x, y, z in zip(xs, ys, zs, strict=False): + if x < 0: + continue + s = self._project(np.array([x, y, z], dtype=np.float32)) + if s and self._in_rect(*s): + pts.append(rl.Vector2(*s)) + for i in range(len(pts) - 1): + rl.draw_line_ex(pts[i], pts[i + 1], thick, color) + + def _draw_model_scene(self, model): + for i, lane in enumerate(model.laneLines): + a = int(np.clip(model.laneLineProbs[i], 0.0, 0.9) * 255) + self._draw_polyline(lane.x, lane.y, lane.z, rl.Color(235, 235, 235, a), 3.0) + for edge in model.roadEdges: + self._draw_polyline(edge.x, edge.y, edge.z, rl.Color(230, 70, 70, 180), 3.0) + pos = model.position + self._draw_polyline(pos.x, pos.y, pos.z, rl.Color(40, 210, 200, 220), 6.0) + + def _draw_objects(self, env): + for obj in env.objects: + self._draw_box(obj) + + def _draw_box(self, obj): + hx, hy = obj.length / 2.0, obj.width / 2.0 + base = np.array([ + [obj.x - hx, obj.y - hy, obj.z], [obj.x - hx, obj.y + hy, obj.z], + [obj.x + hx, obj.y - hy, obj.z], [obj.x + hx, obj.y + hy, obj.z], + [obj.x - hx, obj.y - hy, obj.z + obj.height], [obj.x - hx, obj.y + hy, obj.z + obj.height], + [obj.x + hx, obj.y - hy, obj.z + obj.height], [obj.x + hx, obj.y + hy, obj.z + obj.height], + ], dtype=np.float32) + + screen = [] + for corner in base: + s = self._project(corner) + if s is None or not self._in_rect(*s): + return + screen.append(s) + + r, g, b = _OBJECT_COLORS.get(obj.label, (40, 210, 200)) + a = int(np.clip(obj.prob, 0.3, 1.0) * 210) + floor = [rl.Vector2(*screen[i]) for i in (0, 1, 3, 2)] + rl.draw_triangle(floor[0], floor[1], floor[2], rl.Color(r, g, b, a // 5)) + rl.draw_triangle(floor[0], floor[2], floor[3], rl.Color(r, g, b, a // 5)) + for i, j in _BOX_EDGES: + rl.draw_line_ex(rl.Vector2(*screen[i]), rl.Vector2(*screen[j]), 2.0, rl.Color(r, g, b, a)) diff --git a/selfdrive/ui/onroad/hud_renderer.py b/selfdrive/ui/onroad/hud_renderer.py index fa09f2b..5649aa4 100644 --- a/selfdrive/ui/onroad/hud_renderer.py +++ b/selfdrive/ui/onroad/hud_renderer.py @@ -32,6 +32,7 @@ class FontSizes: max_speed: int = 28 set_speed: int = 74 limit_speed: int = 64 + limit_offset: int = 30 limit_unit: int = 22 limit_label: int = 24 @@ -70,6 +71,7 @@ class HudRenderer(Widget): self.speed: float = 0.0 self.v_ego_cluster_seen: bool = False self.limit_speed_text: str = "---" + self.limit_offset_text: str = "" self.limit_available: bool = False self._font_semi_bold: rl.Font = gui_app.font(FontWeight.SEMI_BOLD) @@ -168,14 +170,28 @@ class HudRenderer(Widget): else: limit_value_size = 48 limit_value_width = measure_text_cached(self._font_bold, limit_value_text, limit_value_size).x + limit_offset_text = self.limit_offset_text if self.limit_available else "" + limit_offset_width = 0.0 + if limit_offset_text: + limit_offset_width = measure_text_cached(self._font_semi_bold, limit_offset_text, FONT_SIZES.limit_offset).x + 8 + limit_value_x = x + (set_speed_width - limit_value_width - limit_offset_width) / 2 rl.draw_text_ex( self._font_bold, limit_value_text, - rl.Vector2(x + (set_speed_width - limit_value_width) / 2, y + 14), + rl.Vector2(limit_value_x, y + 14), limit_value_size, 0, limit_value_color, ) + if limit_offset_text: + rl.draw_text_ex( + self._font_semi_bold, + limit_offset_text, + rl.Vector2(limit_value_x + limit_value_width + 8, y + 22), + FONT_SIZES.limit_offset, + 0, + COLORS.WHITE_TRANSLUCENT, + ) if self.limit_available: limit_unit_text = tr("LIMIT") diff --git a/selfdrive/ui/soundd.py b/selfdrive/ui/soundd.py index e956e73..3c038be 100644 --- a/selfdrive/ui/soundd.py +++ b/selfdrive/ui/soundd.py @@ -224,6 +224,17 @@ class Soundd(AlertSoundFilter): sm = messaging.SubMaster(['selfdriveState', 'soundPressure']) threading.Thread(target=self.webrtc_audio_thread, daemon=True).start() + while True: + try: + self._stream_loop(sd, sm) + except Exception: + # Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here + # crash-loops the process and selfdrived raises a takeover alert mid-drive over alert + # sounds - stay alive and keep retrying instead; recovers if the DSP comes back. + cloudlog.exception("soundd: audio stream unavailable, retrying") + time.sleep(10) + + def _stream_loop(self, sd, sm): with self.get_stream(sd) as stream: rk = Ratekeeper(20) diff --git a/selfdrive/ui/ui_state.py b/selfdrive/ui/ui_state.py index c0cee13..0c39827 100644 --- a/selfdrive/ui/ui_state.py +++ b/selfdrive/ui/ui_state.py @@ -8,6 +8,7 @@ from cereal import messaging, car, log, custom from openpilot.common.filter_simple import FirstOrderFilter from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +from openpilot.iqpilot.common.auto_units import AutoUnits from openpilot.selfdrive.ui.lib.prime_state import PrimeState from openpilot.system.ui.lib.application import gui_app from openpilot.system.hardware import HARDWARE, PC @@ -42,12 +43,13 @@ class IQUIState: self.update_params() + self.auto_units = AutoUnits(self.params) self.onroad_brightness_timer: int = 0 self.custom_interactive_timeout: int = self.params.get("InteractivityTimeout", return_default=True) self.reset_onroad_sleep_timer() def update(self) -> None: - pass + self.auto_units.update() def onroad_brightness_handle_alerts(self, started: bool, alert): # while an alert is on screen the dim countdown is frozen and re-armed; otherwise it ticks down @@ -254,6 +256,7 @@ class UIState(IQUIState): "radarState", "liveTracks", "iqVehicleTracks", + "iqEnvironment", "deviceState", "pandaStates", "carParams", diff --git a/system/camerad/cameras/spectra.cc b/system/camerad/cameras/spectra.cc index c1a89b7..37716a3 100644 --- a/system/camerad/cameras/spectra.cc +++ b/system/camerad/cameras/spectra.cc @@ -596,7 +596,7 @@ void SpectraCamera::config_bps(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = 2329024; // comes from the striping lib + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; @@ -843,7 +843,7 @@ void SpectraCamera::config_bps_downscale(int idx, int request_id) { tmp.header = CAM_ICP_CMD_GENERIC_BLOB_CLK; tmp.header |= (sizeof(cam_icp_clk_bw_request)) << 8; tmp.clk.budget_ns = 0x1fca058; - tmp.clk.frame_cycles = sensor->frame_width * sensor->frame_height; // matches striping lib pixelCount + tmp.clk.frame_cycles = 20000000; // force max BPS clock (600 MHz) tmp.clk.rt_flag = 0x0; tmp.clk.uncompressed_bw = 0x38512180; tmp.clk.compressed_bw = 0x38512180; diff --git a/system/hardware/__init__.py b/system/hardware/__init__.py index 99079b5..0faef55 100644 --- a/system/hardware/__init__.py +++ b/system/hardware/__init__.py @@ -14,3 +14,9 @@ if TICI: HARDWARE = cast(HardwareBase, Tici()) else: HARDWARE = cast(HardwareBase, Pc()) + +# Only comma 3/3X expose the DMA-BUF EGL extensions used by the zero-copy +# camera renderer and the direct EGL frame-pacing calls. /TICI is also present +# on comma 4, so it identifies the AGNOS hardware family rather than this GPU +# capability. +EGL_DMA_BUF_SUPPORTED = TICI and HARDWARE.get_device_type() in ("tici", "tizi") diff --git a/system/hardware/base.h b/system/hardware/base.h index 3eded65..27fa4a8 100644 --- a/system/hardware/base.h +++ b/system/hardware/base.h @@ -1,15 +1,25 @@ #pragma once +#include #include #include #include +#include #include +#include #include "cereal/gen/cpp/log.capnp.h" // no-op base hw class class HardwareNone { public: + struct UfsHealth { + uint8_t pre_eol_info; + uint8_t life_time_estimate_a; + uint8_t life_time_estimate_b; + std::vector vendor_health_report; + }; + static std::string get_name() { return ""; } static cereal::InitData::DeviceType get_device_type() { return cereal::InitData::DeviceType::UNKNOWN; } static int get_voltage() { return 0; } @@ -21,6 +31,8 @@ public: return {}; } + static std::optional get_ufs_health() { return std::nullopt; } + static void set_ir_power(int percentage) {} static bool PC() { return false; } diff --git a/system/hardware/tici/agnos.json b/system/hardware/tici/agnos.json index 2c4b332..fd89de9 100644 --- a/system/hardware/tici/agnos.json +++ b/system/hardware/tici/agnos.json @@ -67,28 +67,28 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25.img.xz", - "hash": "179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25", - "hash_raw": "179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89.img.xz", + "hash": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", + "hash_raw": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", "size": 18216960, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "2c969938fdd59528e6244820b77ec6b2cef9ab49cc9fedd490b0b3d195c189e4" + "ondevice_hash": "fdcb135e8412a896c4a882738a7022f6b7779939d8716f24b9a566b0b3ff3757" }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img.xz", - "hash": "0c9e7dee6c7365600c77b33c4996456459d04aad005e3e41bf6ee2e8af74ceb9", - "hash_raw": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img.xz", + "hash": "62a08bdf79f6dc0075eba8b93b55cbac6eeee0ecd2b6e8ab05d51ef61a9a880a", + "hash_raw": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", "size": 6291456000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "1ee589d3d728a03561718383bf640171f1aef048573e46fe17239617c7c19fff", + "ondevice_hash": "9ed5bc147a6d4c2e0a89562f31096a7802485ee8e740defeab7674abbf6d4baa", "alt": { - "hash": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img", + "hash": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img", "size": 6291456000 } } diff --git a/system/hardware/tici/agnos.json.sig b/system/hardware/tici/agnos.json.sig index 362267f..c4c5a1b 100644 --- a/system/hardware/tici/agnos.json.sig +++ b/system/hardware/tici/agnos.json.sig @@ -1 +1 @@ -+LyP/p4uBBxbTwRwPrwR/2qPaHkAXmlFkg0GdxOBuujPY89GxP4t5SNKxirqiw9LmMcfA0JfVwMeR3QD9rQ6Dg== +LxtrHkl5DFbSWnd1DZCkinmXiKFUVUausf0oYpa97dyAp3QGpWx7wrEAUZuJbrERQtdIV72ZU2eKOaQwGMBQBg== diff --git a/system/hardware/tici/agnos_tici_15_1.json b/system/hardware/tici/agnos_tici_15_1.json index da5a9ab..77e093e 100644 --- a/system/hardware/tici/agnos_tici_15_1.json +++ b/system/hardware/tici/agnos_tici_15_1.json @@ -56,28 +56,28 @@ }, { "name": "boot", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25.img.xz", - "hash": "179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25", - "hash_raw": "179865564b1c196897c77edc41d99a8d4831a3a95564cac6868e7942cd11fd25", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/boot-50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89.img.xz", + "hash": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", + "hash_raw": "50b102d3dfa5d0eea506d9586745470cd59bcea9a723408a6abc20ffd6493a89", "size": 18216960, "sparse": false, "full_check": true, "has_ab": true, - "ondevice_hash": "2c969938fdd59528e6244820b77ec6b2cef9ab49cc9fedd490b0b3d195c189e4" + "ondevice_hash": "fdcb135e8412a896c4a882738a7022f6b7779939d8716f24b9a566b0b3ff3757" }, { "name": "system", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img.xz", - "hash": "0c9e7dee6c7365600c77b33c4996456459d04aad005e3e41bf6ee2e8af74ceb9", - "hash_raw": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img.xz", + "hash": "62a08bdf79f6dc0075eba8b93b55cbac6eeee0ecd2b6e8ab05d51ef61a9a880a", + "hash_raw": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", "size": 6291456000, "sparse": true, "full_check": false, "has_ab": true, - "ondevice_hash": "1ee589d3d728a03561718383bf640171f1aef048573e46fe17239617c7c19fff", + "ondevice_hash": "9ed5bc147a6d4c2e0a89562f31096a7802485ee8e740defeab7674abbf6d4baa", "alt": { - "hash": "03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a", - "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-03397de3da2e9a6d2808b95b72c945b3af71ab79f7f0298624aed62b8749fc5a.img", + "hash": "a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd", + "url": "https://sdn.konn3kt.com/agnos/16-iqlvbs/system-a473962357e3c0c7639af17006def4567f02e64d578b00e2e0037012755be5cd.img", "size": 6291456000 } } diff --git a/system/hardware/tici/agnos_tici_15_1.json.sig b/system/hardware/tici/agnos_tici_15_1.json.sig index fc701fd..9b8485a 100644 --- a/system/hardware/tici/agnos_tici_15_1.json.sig +++ b/system/hardware/tici/agnos_tici_15_1.json.sig @@ -1 +1 @@ -RCYk34Ifjx7S9PIhPm6p49vFMhamPwvw8Lfbb6a6HO8sUOi3J2PcY4VDUcqQZWU6+Am++HP3nJSea8OmAsckAg== +mKCTfzNHr1d0BYPdmXQRPGpnVuLaDLGZGLbq+86ZJQaGIK1ekcKeYeycoX8BTMNFr+89Tnd77M/CvmjM8yteCg== diff --git a/system/hardware/tici/hardware.h b/system/hardware/tici/hardware.h index d59b45e..c53f43c 100644 --- a/system/hardware/tici/hardware.h +++ b/system/hardware/tici/hardware.h @@ -1,17 +1,63 @@ #pragma once -#include +#include #include +#include +#include +#include +#include #include #include #include #include // for std::clamp +#include +#include #include "common/util.h" #include "system/hardware/base.h" class HardwareTici : public HardwareNone { public: + static std::optional get_ufs_health() { + constexpr unsigned long UFS_IOCTL_QUERY = 0x5388; + constexpr uint32_t UPIU_QUERY_OPCODE_READ_DESC = 0x1; + constexpr uint8_t QUERY_DESC_IDN_HEALTH = 0x9; + constexpr uint16_t QUERY_DESC_HEALTH_SIZE = 0x25; + + struct UfsQuery { + uint32_t opcode; + uint8_t idn; + uint8_t reserved; + uint16_t buf_size; + std::array buffer; + }; + static_assert(offsetof(UfsQuery, buffer) == 8); + + UfsQuery query = {}; + query.opcode = UPIU_QUERY_OPCODE_READ_DESC; + query.idn = QUERY_DESC_IDN_HEALTH; + query.buf_size = QUERY_DESC_HEALTH_SIZE; + + int fd = open("/dev/sda", O_RDONLY | O_CLOEXEC); + if (fd < 0) { + return std::nullopt; + } + + int ret = ioctl(fd, UFS_IOCTL_QUERY, &query); + close(fd); + if (ret != 0 || query.buf_size < 5 || query.buf_size > query.buffer.size() || + query.buffer[0] != query.buf_size || query.buffer[1] != QUERY_DESC_IDN_HEALTH) { + return std::nullopt; + } + + return UfsHealth{ + query.buffer[2], + query.buffer[3], + query.buffer[4], + std::vector(query.buffer.begin() + 5, query.buffer.begin() + query.buf_size), + }; + } + static std::string get_name() { std::string model = util::read_file("/sys/firmware/devicetree/base/model"); return util::strip(model.substr(std::string("comma ").size())); diff --git a/system/loggerd/encoder/v4l_encoder.cc b/system/loggerd/encoder/v4l_encoder.cc index 4a4e0fb..d44e8b4 100644 --- a/system/loggerd/encoder/v4l_encoder.cc +++ b/system/loggerd/encoder/v4l_encoder.cc @@ -78,9 +78,10 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { uint32_t idx = -1; bool exit = false; - // POLLIN is capture, POLLOUT is frame + // POLLIN is capture, POLLOUT is frame. Qualcomm's reference client also + // requests the corresponding normal-data bits. struct pollfd pfd; - pfd.events = POLLIN | POLLOUT; + pfd.events = POLLIN | POLLRDNORM | POLLOUT | POLLWRNORM; pfd.fd = e->fd; // save the header @@ -105,7 +106,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { } int frame_id = -1; - if (pfd.revents & POLLIN) { + if (pfd.revents & (POLLIN | POLLRDNORM)) { unsigned int bytesused, flags, index; struct timeval timestamp; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, &index, &bytesused, &flags, ×tamp); @@ -136,7 +137,7 @@ void V4LEncoder::dequeue_handler(V4LEncoder *e) { queue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE, index, &e->buf_out[index]); } - if (pfd.revents & POLLOUT) { + if (pfd.revents & (POLLOUT | POLLWRNORM)) { unsigned int index; dequeue_buffer(e->fd, V4L2_BUF_TYPE_VIDEO_OUTPUT_MPLANE, &index); e->free_buf_in.push(index); @@ -244,7 +245,7 @@ V4LEncoder::V4LEncoder(const EncoderInfo &encoder_info, int in_width, int in_hei { .id = V4L2_CID_MPEG_VIDEO_H264_LEVEL, .value = V4L2_MPEG_VIDEO_H264_LEVEL_UNKNOWN}, { .id = V4L2_CID_MPEG_VIDEO_H264_ENTROPY_MODE, .value = V4L2_MPEG_VIDEO_H264_ENTROPY_MODE_CABAC}, { .id = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL, .value = V4L2_CID_MPEG_VIDC_VIDEO_H264_CABAC_MODEL_0}, - { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = 0}, + { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_MODE, .value = V4L2_MPEG_VIDEO_H264_LOOP_FILTER_MODE_ENABLED}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_ALPHA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_H264_LOOP_FILTER_BETA, .value = 0}, { .id = V4L2_CID_MPEG_VIDEO_MULTI_SLICE_MODE, .value = 0}, diff --git a/system/loggerd/logger.cc b/system/loggerd/logger.cc index 644da93..2cd97d7 100644 --- a/system/loggerd/logger.cc +++ b/system/loggerd/logger.cc @@ -46,6 +46,14 @@ kj::Array logger_build_init_data() { init.setKernelVersion(util::read_file("/proc/version")); init.setOsVersion(util::read_file("/VERSION")); + if (auto health = Hardware::get_ufs_health()) { + auto ufs_health = init.initUfsHealth(); + ufs_health.setPreEolInfo(health->pre_eol_info); + ufs_health.setLifeTimeEstimateA(health->life_time_estimate_a); + ufs_health.setLifeTimeEstimateB(health->life_time_estimate_b); + ufs_health.setVendorHealthReport(capnp::Data::Reader(health->vendor_health_report.data(), health->vendor_health_report.size())); + } + // log params Params params(util::getenv("PARAMS_COPY_PATH", "")); std::map params_map = params.readAll(); diff --git a/system/loggerd/tests/test_loggerd.py b/system/loggerd/tests/test_loggerd.py index 13cbd6a..7f3a674 100644 --- a/system/loggerd/tests/test_loggerd.py +++ b/system/loggerd/tests/test_loggerd.py @@ -170,6 +170,15 @@ class TestLoggerd: assert initData.dirty != bool(os.environ["CLEAN"]) assert initData.version == get_version() + if TICI: + assert initData._has("ufsHealth") + assert initData.ufsHealth.preEolInfo in (1, 2, 3) + assert 1 <= initData.ufsHealth.lifeTimeEstimateA <= 11 + assert 1 <= initData.ufsHealth.lifeTimeEstimateB <= 11 + assert len(initData.ufsHealth.vendorHealthReport) == 32 + else: + assert not initData._has("ufsHealth") + if os.path.isfile("/proc/cmdline"): with open("/proc/cmdline") as f: assert list(initData.kernelArgs) == f.read().strip().split(" ") diff --git a/system/manager/process.py b/system/manager/process.py index 152d8f8..90faa6c 100644 --- a/system/manager/process.py +++ b/system/manager/process.py @@ -17,6 +17,10 @@ from openpilot.common.basedir import BASEDIR from openpilot.common.params import Params from openpilot.common.swaglog import cloudlog +MAX_CRASH_BACKOFF = 300.0 +CRASH_RESET_TIME = 60.0 +CRASH_LOOP_THRESHOLD = 6 + try: from openpilot.system.proprietary_runtime.runtime_paths import preferred_runner_path except ModuleNotFoundError: @@ -82,6 +86,10 @@ class ManagerProcess(ABC): name = "" shutting_down = False restart_if_crash = False + crash_count = 0 + last_restart_time = 0.0 + last_alive_time = 0.0 + crash_loop_logged = False @abstractmethod def prepare(self) -> None: @@ -318,13 +326,33 @@ def ensure_running(procs: ValuesView[ManagerProcess], started: bool, params=None not_run = [] running = [] + now = time.monotonic() for p in procs: if p.enabled and p.name not in not_run and p.should_run(started, params, CP): - if p.restart_if_crash and p.proc is not None and not p.proc.is_alive(): - cloudlog.error(f'Restarting {p.name} (exitcode {p.proc.exitcode})') - p.restart() + if p.restart_if_crash and p.proc is not None and p.proc.is_alive(): + p.last_alive_time = now + elif p.restart_if_crash and p.proc is not None: + # uptime, not time-since-restart: the latter also counts the backoff wait, + # which would reset the counter as soon as backoff exceeds CRASH_RESET_TIME + if p.last_alive_time - p.last_restart_time > CRASH_RESET_TIME: + p.crash_count = 0 + p.crash_loop_logged = False + + backoff = 0.0 if not p.crash_count else min(MAX_CRASH_BACKOFF, 2.0 ** (p.crash_count - 1)) + if now - p.last_restart_time >= backoff: + p.crash_count += 1 + p.last_restart_time = now + cloudlog.error(f'Restarting {p.name} (exitcode {p.proc.exitcode}) [crash {p.crash_count}]') + if p.crash_count >= CRASH_LOOP_THRESHOLD and not p.crash_loop_logged: + # never stop retrying: giving up on hardwared or ui is worse than restarting slowly + cloudlog.error(f'{p.name} is in a crash loop, backing off to {MAX_CRASH_BACKOFF}s between restarts') + p.crash_loop_logged = True + p.restart() running.append(p) else: + p.crash_count = 0 + p.crash_loop_logged = False + p.last_alive_time = 0.0 p.stop(block=False) for p in running: diff --git a/system/manager/process_config.py b/system/manager/process_config.py index 3421565..43da8d7 100644 --- a/system/manager/process_config.py +++ b/system/manager/process_config.py @@ -98,7 +98,9 @@ def constructiond_onroad(started: bool, params: Params, CP: car.CarParams) -> bo return started and params.get_bool("ConstructionZoneAssist") def iqvd_onroad(started: bool, params: Params, CP: car.CarParams) -> bool: - return started and params.get_bool("VisionVehicleTracks") + # held for 1.0d: iqvd runs a detector per frame and the added load is not + # something 1.0c needs to carry. re-enable by restoring the param check. + return False def only_offroad(started: bool, params: Params, CP: car.CarParams) -> bool: return not started @@ -109,6 +111,12 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool: # down cleanly when the session ends — no subprocess management inside hephaestusd. return params.get_bool("IsLiveStreaming") +def canlive(started: bool, params: Params, CP: car.CarParams) -> bool: + # Remote live CAN debugging via konn3kt. hephaestusd sets CanLiveStreaming when a viewer + # connects (startCanLive) and clears it when the last one leaves (stopCanLive), so canlived + # runs only during an active debug session — no idle connection or battery cost otherwise. + return params.get_bool("CanLiveStreaming") + def is_tinygrad_model(started, params, CP: car.CarParams) -> bool: """Check if the active model runner is tinygrad.""" return bool(get_active_model_runner(params, not started) == custom.IQModelManager.Runner.tinygrad) @@ -179,6 +187,7 @@ procs = [ # debug procs NativeProcess("bridge", "cereal/messaging", ["./bridge"], notcar), PythonProcess("webrtcd", "system.webrtc.webrtcd", or_(iscar, livestream)), + PythonProcess("canlived", "iqpilot.konn3kt.canlive.canlived", canlive), PythonProcess("webjoystick", "tools.bodyteleop.web", notcar), ] diff --git a/system/micd.py b/system/micd.py index 033f70c..814b8c3 100755 --- a/system/micd.py +++ b/system/micd.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import numpy as np import os +import time from functools import cache import threading @@ -136,10 +137,18 @@ class Mic: self.last_device = f"{device}: {sd.query_devices(device)['name']}" if isinstance(device, int) else str(device) cloudlog.info(f"micd selecting input device {self.last_device}") - with self.get_stream(sd) as stream: - cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") - while True: - self.update() + while True: + try: + with self.get_stream(sd) as stream: + cloudlog.info(f"micd stream started: {stream.samplerate=} {stream.channels=} {stream.dtype=} {stream.device=}, {stream.blocksize=}") + while True: + self.update() + except Exception: + # Some A1s wedge the audio DSP (ALSA EINVAL / ADSP_EFAILED until reboot). Dying here + # crash-loops the process and selfdrived raises a takeover alert mid-drive over a + # microphone - stay alive and keep retrying instead; recovers if the DSP comes back. + cloudlog.exception("micd: audio stream unavailable, retrying") + time.sleep(10) def main(): diff --git a/system/timed.py b/system/timed.py index b7131b0..3e2fe9d 100755 --- a/system/timed.py +++ b/system/timed.py @@ -12,7 +12,7 @@ from openpilot.common.gps import get_gps_location_service def set_time(new_time): - diff = datetime.datetime.now() - new_time + diff = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - new_time if abs(diff) < datetime.timedelta(seconds=10): cloudlog.debug(f"Time diff too small: {diff}") return @@ -47,7 +47,7 @@ def main() -> NoReturn: pm.send('clocks', msg) gps = sm[gps_location_service] - gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000.) + gps_time = datetime.datetime.fromtimestamp(gps.unixTimestampMillis / 1000., datetime.UTC).replace(tzinfo=None) if not sm.updated[gps_location_service] or (time.monotonic() - sm.logMonoTime[gps_location_service] / 1e9) > 2.0: continue if not gps.hasFix: diff --git a/system/ui/lib/application.py b/system/ui/lib/application.py index 404854a..c0cd2ba 100644 --- a/system/ui/lib/application.py +++ b/system/ui/lib/application.py @@ -22,7 +22,7 @@ from pathlib import Path from typing import NamedTuple from importlib.resources import as_file, files from openpilot.common.swaglog import cloudlog -from openpilot.system.hardware import HARDWARE, PC +from openpilot.system.hardware import EGL_DMA_BUF_SUPPORTED, HARDWARE, PC from openpilot.system.ui.lib.multilang import multilang from openpilot.common.realtime import Ratekeeper @@ -326,7 +326,7 @@ class GuiApplication(IQAppHooks): rl.glfw_swap_interval(0) except Exception: pass - if not PC and rl.is_window_ready() and not OFFSCREEN: + if EGL_DMA_BUF_SUPPORTED and rl.is_window_ready() and not OFFSCREEN: try: from openpilot.system.ui.lib.egl import set_swap_interval set_swap_interval(0) @@ -349,7 +349,7 @@ class GuiApplication(IQAppHooks): glfw_vsync = False egl_vsync = False - if not PC: + if EGL_DMA_BUF_SUPPORTED: try: from openpilot.system.ui.lib.egl import set_swap_interval egl_vsync = set_swap_interval(interval) @@ -367,7 +367,7 @@ class GuiApplication(IQAppHooks): rl.rl_draw_render_batch_active() def _apply_display_sync_before_swap(self) -> None: - if PC or OFFSCREEN or not DISPLAY_SYNC_BEFORE_SWAP or self._display_sync_available is False: + if not EGL_DMA_BUF_SUPPORTED or OFFSCREEN or not DISPLAY_SYNC_BEFORE_SWAP or self._display_sync_available is False: return try: self._flush_raylib_batch() @@ -395,7 +395,7 @@ class GuiApplication(IQAppHooks): # intermittent screen tearing when scrolling (the content moves, so a mid-scanout swap is # visible). eglSwapInterval is a trivial call, so re-assert it every frame to keep FIFO vsync # pinned instead of relying on the slow (VSYNC_REAPPLY_INTERVAL) full re-apply below. - if self._paced_by_vsync and self._vsync_interval > 0 and not PC and rl.is_window_ready(): + if self._paced_by_vsync and self._vsync_interval > 0 and EGL_DMA_BUF_SUPPORTED and rl.is_window_ready(): try: from openpilot.system.ui.lib.egl import set_swap_interval set_swap_interval(self._vsync_interval) @@ -850,7 +850,7 @@ class GuiApplication(IQAppHooks): # full-res frame texture can make the Adreno/GBM driver silently reset the swap interval to 0 # *after* that call but before this swap, which reintroduced tearing. This is the swap that # actually matters, so pin the interval here too. - if (ENABLE_VSYNC and not OFFSCREEN and not PC and self._paced_by_vsync + if (ENABLE_VSYNC and not OFFSCREEN and EGL_DMA_BUF_SUPPORTED and self._paced_by_vsync and self._vsync_interval > 0 and rl.is_window_ready()): try: from openpilot.system.ui.lib.egl import set_swap_interval diff --git a/system/ui/lib/wifi_manager.py b/system/ui/lib/wifi_manager.py index e5659df..86d15b6 100644 --- a/system/ui/lib/wifi_manager.py +++ b/system/ui/lib/wifi_manager.py @@ -961,6 +961,10 @@ class WifiManager: settings['connection'] = {} changes = False + # NetworkManager updates an active modem connection in place. Quectel + # modems keep the existing PDP bearer in that case, so an APN change + # does not take effect until LTE is disconnected and reactivated. + apn_settings_changed = False auto_config = apn == "" initial_eps_apn = apn if not auto_config else "" @@ -968,11 +972,13 @@ class WifiManager: cloudlog.warning(f'Changing gsm.auto-config to {auto_config}') settings['gsm']['auto-config'] = ('b', auto_config) changes = True + apn_settings_changed = True if settings['gsm'].get('apn', ('s', ''))[1] != apn: cloudlog.warning(f'Changing gsm.apn to {apn}') settings['gsm']['apn'] = ('s', apn) changes = True + apn_settings_changed = True if settings['gsm'].get('home-only', ('b', False))[1] == roaming: cloudlog.warning(f'Changing gsm.home-only to {not roaming}') @@ -983,11 +989,13 @@ class WifiManager: cloudlog.warning(f'Changing gsm.initial-eps-bearer-configure to {bool(initial_eps_apn)}') settings['gsm']['initial-eps-bearer-configure'] = ('b', bool(initial_eps_apn)) changes = True + apn_settings_changed = True if settings['gsm'].get('initial-eps-bearer-apn', ('s', ''))[1] != initial_eps_apn: cloudlog.warning(f'Changing gsm.initial-eps-bearer-apn to {initial_eps_apn}') settings['gsm']['initial-eps-bearer-apn'] = ('s', initial_eps_apn) changes = True + apn_settings_changed = True # Unknown means NetworkManager decides metered_int = int(MeteredType.UNKNOWN if metered else MeteredType.NO) @@ -1005,7 +1013,10 @@ class WifiManager: cloudlog.warning(f"Failed to update GSM settings: {reply}") return - self._activate_modem_connection(lte_connection_path) + if apn_settings_changed: + self._restart_modem_connection(lte_connection_path) + else: + self._activate_modem_connection(lte_connection_path) except Exception as e: cloudlog.exception(f"Error updating GSM settings: {e}") @@ -1034,6 +1045,40 @@ class WifiManager: except Exception as e: cloudlog.exception(f"Error activating modem connection: {e}") + def _restart_modem_connection(self, connection_path: str): + """Reconnect LTE so a changed APN is used for a new PDP bearer.""" + try: + for active_conn in self._get_active_connections(): + conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) + active_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1] + if active_conn_path == connection_path: + cloudlog.warning("Restarting LTE connection to apply APN settings") + reply = self._router_main.send_and_get_reply(new_method_call(self._nm, 'DeactivateConnection', 'o', (active_conn,))) + if reply.header.message_type == MessageType.error: + cloudlog.warning(f"Failed to deactivate LTE connection: {reply}") + return + + for _ in range(20): + if not self._is_connection_active(connection_path): + break + time.sleep(0.25) + else: + cloudlog.warning("LTE connection did not deactivate after APN change") + return + break + + self._activate_modem_connection(connection_path) + except Exception as e: + cloudlog.exception(f"Error restarting modem connection: {e}") + + def _is_connection_active(self, connection_path: str) -> bool: + for active_conn in self._get_active_connections(): + conn_addr = DBusAddress(active_conn, bus_name=NM, interface=NM_ACTIVE_CONNECTION_IFACE) + active_conn_path = self._router_main.send_and_get_reply(Properties(conn_addr).get('Connection')).body[0][1] + if active_conn_path == connection_path: + return True + return False + def stop(self): if not self._exit: self._exit = True diff --git a/third_party/copyparty/copyparty-sfx.py b/third_party/copyparty/copyparty-sfx.py deleted file mode 100755 index 2506c39..0000000 --- a/third_party/copyparty/copyparty-sfx.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -# coding: latin-1 -from __future__ import print_function, unicode_literals -import re, os, sys, time, shutil, signal, tarfile, hashlib, platform, tempfile, traceback -import subprocess as sp - - -""" -to edit this file, use HxD or "vim -b" - (there is compressed stuff at the end) - -run me with python 2.7 or 3.3+ to unpack and run copyparty - -there's zero binaries! just plaintext python scripts all the way down - so you can easily unpack the archive and inspect it for shady stuff - -the archive data is attached after the b"\n# eof\n" archive marker, - b"?0" decodes to b"\x00" - b"?n" decodes to b"\n" - b"?r" decodes to b"\r" - b"??" decodes to b"?" -""" - - -# set by make-sfx.sh -VER = "1.18.9" -SIZE = 846007 -CKSUM = "53f9b019dbba5e9acb44f1e5" -STAMP = 1754082544 - -PY2 = sys.version_info < (3,) -PY37 = sys.version_info > (3, 7) -WINDOWS = sys.platform in ["win32", "msys"] -sys.dont_write_bytecode = True -me = os.path.abspath(os.path.realpath(__file__)) - - -def eprint(*a, **ka): - ka["file"] = sys.stderr - print(*a, **ka) - - -def msg(*a, **ka): - if a: - a = ["[SFX]", a[0]] + list(a[1:]) - - eprint(*a, **ka) - - -def u8(gen): - try: - for s in gen: - yield s.decode("utf-8", "ignore") - except: - yield s - for s in gen: - yield s - - -def yieldfile(fn): - s = 64 * 1024 - with open(fn, "rb", s * 4) as f: - for block in iter(lambda: f.read(s), b""): - yield block - - -def hashfile(fn): - h = hashlib.sha1() - for block in yieldfile(fn): - h.update(block) - - return h.hexdigest()[:24] - - -def unpack(): - """unpacks the tar yielded by `data`""" - name = "pe-copyparty" - try: - name += "." + str(os.geteuid()) - except: - pass - - tag = "v" + str(STAMP) - top = tempfile.gettempdir() - opj = os.path.join - ofe = os.path.exists - final = opj(top, name) - san = opj(final, "copyparty/up2k.py") - for suf in range(0, 9001): - withpid = "%s.%d.%s" % (name, os.getpid(), suf) - mine = opj(top, withpid) - if not ofe(mine): - break - - tar = opj(mine, "tar") - - try: - if tag in os.listdir(final) and ofe(san): - msg("found early") - return final - except: - pass - - sz = 0 - os.mkdir(mine) - with open(tar, "wb") as f: - for buf in get_payload(): - sz += len(buf) - f.write(buf) - - ck = hashfile(tar) - if ck != CKSUM: - t = "\n\nexpected %s (%d byte)\nobtained %s (%d byte)\nsfx corrupt" - raise Exception(t % (CKSUM, SIZE, ck, sz)) - - with tarfile.open(tar, "r:gz") as tf: - # this is safe against traversal - try: - tf.extractall(mine, filter="tar") - except TypeError: - tf.extractall(mine) - - os.remove(tar) - - with open(opj(mine, tag), "wb") as f: - f.write(b"h\n") - - try: - if tag in os.listdir(final) and ofe(san): - msg("found late") - return final - except: - pass - - try: - if os.path.islink(final): - os.remove(final) - else: - shutil.rmtree(final) - except: - pass - - for fn in u8(os.listdir(top)): - if fn.startswith(name) and fn != withpid: - try: - old = opj(top, fn) - if time.time() - os.path.getmtime(old) > 86400: - shutil.rmtree(old) - except: - pass - - try: - os.symlink(mine, final) - except: - try: - os.rename(mine, final) - return final - except: - msg("reloc fail,", mine) - - return mine - - -def get_payload(): - """yields the binary data attached to script""" - with open(me, "rb") as f: - buf = f.read().rstrip(b"\r\n") - - ptn = b"\n# eof\n#" - a = buf.find(ptn) - if a < 0: - raise Exception("could not find archive marker") - - esc = {b"??": b"?", b"?r": b"\r", b"?n": b"\n", b"?0": b"\x00"} - buf = buf[a + len(ptn) :].replace(b"\n#", b"") - p = 0 - while buf: - a = buf.find(b"?", p) - if a < 0: - yield buf[p:] - break - elif a == p: - yield esc[buf[p : p + 2]] - p += 2 - else: - yield buf[p:a] - p = a - - -def confirm(rv): - msg() - msg("retcode", rv if rv else traceback.format_exc()) - if WINDOWS: - msg("*** hit enter to exit ***") - try: - raw_input() if PY2 else input() - except: - pass - - sys.exit(rv or 1) - - -def run(tmp, j2, ftp): - msg("jinja2:", j2 or "bundled") - msg("pyftpd:", ftp or "bundled") - msg("sfxdir:", tmp) - msg() - - sys.argv.append("--sfx-tpoke=" + tmp) - - ld = (("", ""), (j2, "j2"), (ftp, "ftp"), (not PY2, "py2"), (PY37, "py37")) - ld = [os.path.join(tmp, b) for a, b in ld if not a] - - if any([re.match(r"^-.*j[0-9]", x) for x in sys.argv]): - run_s(ld) - else: - run_i(ld) - - -def run_i(ld): - for x in ld: - sys.path.insert(0, x) - - e = os.environ - e["PRTY_NO_IMPRESO"] = "1" - - from copyparty.__main__ import main as p - - p() - - -def run_s(ld): - c = "import sys,runpy;" + "".join(['sys.path.insert(0,r"' + x.replace("\\", "/") + '");' for x in ld]) + 'runpy.run_module("copyparty",run_name="__main__")' - c = [str(x) for x in [sys.executable, "-c", c] + list(sys.argv[1:])] - msg("\n", c, "\n") - p = sp.Popen(c) - - def bye(*a): - p.send_signal(signal.SIGINT) - - signal.signal(signal.SIGTERM, bye) - p.wait() - - raise SystemExit(p.returncode) - - -def main(): - sysver = str(sys.version).replace("\n", "\n" + " " * 18) - pktime = time.strftime("%Y-%m-%d, %H:%M:%S", time.gmtime(STAMP)) - msg() - msg(" this is: copyparty", VER) - msg(" packed at:", pktime, "UTC,", STAMP) - msg("archive is:", me) - msg("python bin:", sys.executable) - msg("python ver:", platform.python_implementation(), sysver) - msg() - - arg = "" - try: - arg = sys.argv[1] - except: - pass - - tmp = os.path.realpath(unpack()) - - try: - from jinja2 import __version__ as j2 - except: - j2 = None - - try: - from pyftpdlib.__init__ import __ver__ as ftp - except: - ftp = None - - try: - run(tmp, j2, ftp) - except SystemExit as ex: - c = ex.code - if c not in [0, -15]: - confirm(ex.code) - except KeyboardInterrupt: - pass - except: - confirm(0) - - -if __name__ == "__main__": - main() - - -# eof -#,ht.7?0U8 ZV<*:DkXF6ʻ-`;?r2%dmX01O%dހA"vu5ʚAfR[8Tfj7h MIeXW|q/v=Ovjʎ❹MɯuF>;F'7W% y`}mv`JCr+޿~kÓT0(~??wܢ5%:M`Irߎ%_(6ڠ8?ruY0n-Bsi-g6)aY7q,՞x1HV͵35qw>s}2ͱVFȋ.t8 jOߋ@ F?rM_4q|?nU'ww~'(+X5A"ʑ:ǂ*6p$$~g[%Tۏa2SJ7??>Bޒ+h?ny>PYRLȌΎ=>;@+,^??9Ixw.u&%*۝I\D|}{:\}+֑;KIkȷ^03hǓa|hOfa( _' ##a`?ng86\Uo4w*O䞓U92kKYG<;bso8#n\S??k-j8tsny;.a`OG t&onhb`hdlT, G(G0̙"?0Ȁ̐R?0$}N>5ov7ވ1 y6Q:ӛ۞k)2J[ɉ.\3??RYc}>L2<Piy7xyVwzЫ<__kţjN;*&8~b'==¼0r֩vOBS+[y*S<ҚE*-tU4eO\G|D3??((͡W7442S`7722?0f?0E?0{߽߸4wNT,y{Ҟ;iliZ%H {os{EW9?0H -ۻ&GN f0(W}2i:y,;hy$aLd*ltΈ~ /_MT Ӟ׿n'U8OR =@Kiz4b UE~i(TDiz_4ٓ(\ }ֈ<%k1"v^kHl?0<<8)vy>VmfF)҈>3>]]G2~/SqĈ`o{КnNh?rF1^6yRJ⓽Ã)>iO?0nB;Vj0c;- 2?0_:u}txH|_v'W%%+Y!(?ri!MLK|[]bv\[}ISZC<~1gR~ň)yd>g$x<='[;gog8-:DLS=ql !R®9=S46mFiG0BlqA.Kd cRjZ?nƓdѬtsvVǝE%[^Pg՛`axEHZ??]YZ¢&olx =l`O74aqGz_n\.?r ]W#׭Wk,k%U&?rd-FcezQ??$b| ҩQ*(`2 SVi. ƒ!c" #[ʲp\h&X\\!T Ȑc)qlv@Xf8ciwbejmZ0Qm+PRQ8':'bbQ3iJ?? Res<[zHXxC_-Sx¥ )1Ή?0zJ8t 1J4LDD@JggC?n0TJ6 LR^5:@^sK5SC -#-Jc$|}}0涔={3tNB8c~ff]T0Zz,f?nclaWVc%i(1C&)=AL|c"}d|у<Q#T 񵆰#bl0gmG@C,JЮ1_7րD??UK$t3qRiFQ3-鶈YhD aK뚎$y@E@ 4&j?n.Ö/3B9????9o6_};C>a R)sK% y93ܗ??黽;FMՔr.a??֦#==ig5p#RO.cݝÃ@&@\1/82bՌcFmRԅ`rw?0iwi")?r }}9/rv_B+0~<DfgA2@(-kr6,dtj)(ʸc,X-H v]jwTAخױ; type??n8&^Pg{=XZٶ?0B?r"!ّ5u<u,ϡ5Hg?0CإMK!׵K){Kfqe2lv=8go߭SE^${o-i?rY#`1S֯V4O @=XDi{,% x=ퟰfy٬>]\8ȡ4(E.dY$X}YXA@'&?rdhP::*#~^nξ:/]UkT?r;Lz:3+fMoɱ@Yq,rab͕?ns{M\۹(׋г?0԰#`4TL9M{{}&.A4쿦bB?0hbn\nFx\fD'C5{?nvL_Glesk3:v7-`<??<|Px]~rySTd4f;hN4E|VUVAš/{g4U,(!e7q+taKb>?0??kW.sHw*zy-ںP!'b. uS#e?nrT/XLhSDrSndL.֏׏Ruv92cciwa:|]1EQPuu6h) ۲T ;;UP&uܤt]?0<w{xh3m֫hzq`,t {eb +F#=7a??:aW/j?n*v(+k۷"'o7dl(@'-iBO6ǦMXc*ULyA2<$\?0l.?0M+ E% gApޢ\@Ur]2eQT?nʟdo)R)yP<)ɾszBt]&ZqmåWO y]ev;?n=T&j{g+;T)J߁c+ ZWn``N3gDjZ1,kAI?0 v6CϜʚW~cUtXjD1/!|zW{Re/AݔEе{@^(3g,X@,Kʽ=ǡG[zl{պ\@9@1qF:km -4KQ_O:=g\/m3eFo nV6/O.4*ܒNɾ_2OzKbGR;du {~.KU<:`VTmw .)d hq4-Q$}.An,cPD:𼙀b# MxJؔ9諯 ?0{6Az"y̽=O#&I(xBFL)(Lܞ̱k"aαErANXb~ILh,9OD$xMX??le:rju^D)_Q_[I9*?rJm@jVl(I3SCk/<^B*_Vs:˹d;) 1 B|IJqE;զp)YDvHΕ9VccKDi~z!|B?n<}ԁ/24Ndqy!Yƨ <-5]sP"(!{[lX^FCg&,3'Pޯڀ\@B}=[(!^^X)*H ! ?n?nA/Ůt}tLN|=4n@::gc rK]?n]GZ*yږFuvjKۗ`4^Uj {*! b >?0XspKd<-_ǟ})0xen7Y"sA$/00|B0St=U~?0b2lG:*E F`aK@%Fʫ}9XB&40JBSq̩bƀQgM)V5mBӐ$?nBރ?rWrDֱv{q>lA+ڿƩ=h}A@ "R5 (@?? j,Z -#&;JbmzM6F8úC,hgd *!vp&f3M}U([8?0a?nAxX0 ).?? c`k??qH3[lƞD-wQzڌBGЃֈ6$[O&0.ڴmpR߱aMKT#iF3G ^WHՙ_}W7vJAQ+s{`o̥-\qe0?r11.쒹vY;єsܲ!DŲ/}-^.6SZ5we$/WbcH͊'HjlȅIb>l+:jO;VAj$3ks[o[}Lܡ|Q#=]yDuBZ4ڞ%|`W9aUUtfe1wp#"˗P`\kn-\k^I{"r|5Q;oQR9vYQw'^T^wy/4}t{O[Y:A!^$MAΉĻ w-' ԃД?0{*F\FݣS{v &Q* c,?0\cγz {`3,$OmZ5iliu.$0??g$(>4!1EXmG?rD?0\jVnJ^ 8emNw??.gNO&yRscˮu8q߅13.:rOs{"c\V|VaW8$1:0=Pnnrr(gE|^EAAi$P+\gFok.ͻ~fJf`*W:m̫=}}0Mtg8ErH1W\߽v{}عwIպu`uw]R*6Uw"Ypq`Ai@?0l-Iz^|j tЍPPo6`"H¦"h=?0órJ4_ti3mUbЮ3yKQHNd|W6֪4MZ|M s8+?nb&eӘxvpCqVn)(ѧ^A'ss+B7s?nEsԳ'l L7[q\feh+mtKMHCz(,6`?nrPcno([͗lL/ftAiilR7??Fܞ؉m<{!|#i>M{\ v'^0,!`hb+Tn<S4{c@:yy?0 i)uu4%{qpn}-rAdrԦV (ȺWGo[ɰ-R-4 `6l@ }VhH"-5zT[ yyM>EvPɳ.A2J밚 ?0sK!z?r߽T?n`f{7?rctFv5Ms-DZONCkD==;q(>e)Żs.Q+:Q?rR)ë95SB>u&@ kVu=DҢ&?rz #%EetP9ը'F\Bnx4(uk5t[UzLHo_hntY¤rmTBUujCY=on3/־znshH=.TH?0r*) ]];~T?r~8 Er~XʺKj|&qX,Zj#7X~Ŗ=v(xMKJ8fNi3o2 NqcԜiCxh0b_!_#߲8 ?n<ᏜM(@P* }l'fD/7vM#Ucf&6شJ?rPK?0 ap^ͼİ4} Ch5-?nUp!&"=@y\h䁚μ!m`8eDLI^{W~Y< J}zqmpoaϨpmբ~ šfo3{PxCP7ʩukօ|gY5Ģ>[3??OB]%݉+v/`ztWz{Nxrp0FLs#694 D7:UEu~@=ۮw??ë!R?rv *B[\njPa&+,TWr[*ၬ v 9\-aj.kp!׈+""ӁFCk`݂.뵓XtH<8{ѻ@䀾ṧSbYg<ЫXX6??-o񷓶ϐ4+iڰAv O?n-Y7Ax\'bW4}̸Cz?0Uy{9:<>r#Gz)!u??W04b*l=B9~0^(;rw 0A'W356622505C?0{׹8?n*]|A?raUj*[)u2}UwGPV~ӿU_ HB3ov@h"_j 6[CBBWuS?0!XVu]$L)h"xWa3Vb,?n%W;D޸u"'怨W3&X)zuڅC)Bϳ%m 얖ujrWשku+Y^g}œdյܩ:"[3PZݬf}_'Qɿ(%wVy+mp Vqy2*C>cD8+P 'j䊫_sm/g`` _?nm7mk]3`EXS+ψ&Y`+?r@ CBwG0'qh@Kp:T+ۆU)9N(<$[^f-X?rJiF#l"+:aJB,O!RuY]m`gl/|϶҇¢Ag.84S Nߴx8yX8c|WpZċ3p?n1??G.>˜'Fe>Sc ueeV*puz̫)Oy|$\֭H@<=VnVyeE5U*.JȞn\,o 3=&X[#ܚte6_)]~hY\r/6޷"n.jY8oCůvl<穖`}GqF^fKb1pylտ_n3q_޶}?nˍPEnN'9˗_^!G&(>K+#LP,HYbB6ɆagpsrVD4ԫj{ƫUDIp9I\“JJzM*agP_VǗ@Kawia8g9Ԁk~?n}{ ?rloiѳ1&ڕ-Ҧqg:=%~&쟲q2!XUؤ,|x{0h$5ϋCi.7x(٥Ͳm}'դoV˗㋧KgfXUnxje # J H{>?r-dI~L_6g)=J <B~w]/0\ϣB7YE?ryL!0>MB@ Džd2%AQpSl̢j ?nEf'X/Ëw_@zUpTZ<(T_F4W^ ν 6_̘D`Ɲ9Q?0ElQ1cf4ud5BmawlX/wiFw,3(Pr?r6q.bΉХ6_i說+SQ"zWjoCK6A6;BMkF6O{fͦ6ַ>׊$hOlɿAHlˣvd4"eiC".?r??3Tw`P?0u)|{7,@|NpˬhIMld-?0)[ッ+@s̢b oo0(a*oB X,x??z>}%??|w|77:?0m1^]D~qp????}ç{{^hn#S,3* =ݺ&N@}t!ܴ0'Ѱds/WA[lFp5עe#$?nwV{q?n:NHC??k `?n4ol492Y!Pщ,:/1; , uwhߺMX&^quʰJ*{ΦZ]E׸iavΦԅd#غimC{զ/o ~kó.DQqXl&6M]e8W]+ؼ Ni^6J -qk9<̲+v=C#泈h?r̫?rj+z?rgث]Ar\T5Ҥ./b߸+R-Mڡ\ v?r2ŕFLw,!E0{[teuO*%{M̊\c?na?nvi1Qr߳}&4R:QJ v&]ܡ{wM!x80iwZkINŻhif*WT閁kxBguo|&K\! EozE}??g ed<&V챹D PKpkZa.Ӑ-\?0\f?0#.ƝIKx%UY/_${v,ph -#4Q>@ :VHU($)@*4b'q0J4.7]Q̝9([2KGϞ\؜AtXojѼ֓?0Vq8#S.oBvb)wq7{ $8* 8 3pe6kZ2K%'|W_efSfʜ-&:󷄌6M6;&"2S!}V4y@johl=FZ ^]~2œoߝB-qskt 咕@lP7/U%!ΥƱ!-q\ A xqK?0B<:Ie#Z[?rNo.\ϰ瘚P-J@g7>̵Qo?0B{:;wcbmEYx5AXM 퍇XGT5"N[WTV5=3*T!* =r UD7'(1D:)~pjld;QFUm7D*K報?0!Z8_ՉhQ\Y8s-1`%57/.bѤWzox;)t Kȳ29dB|0B^  am0ʧ/cQUc;@Tܜ??_}UOB?rh0 {Lx#]F@1O(=mؾy?rbr$Cեq8v7y"RjFR ` L5OjXi .Zq[#dYENݶ?r '6ڒyÃu(zPλXؔw%7~qyAe>2t4t?r1-ljey>.[*0_\v,Z{hRsC[-nx 7 ׯ @ Zm 'cW6tK`*Kɩ8cnvE>J_t6kt-&7Ns#ͺ"Ix+?nG߻OEm} F8X.'s{^Il,# 􅦖9uư?0ja3?ndcK짢WgG$N`E0YS A!zeʯ^كT;qTd1Ǵˑ=G^~zmO??O??}"1z`zκ[ucG ]7^{oLk`z?ncX.Mn#MBOրF6 MskLwnqQd'c?n CX#PiN9ccýMc*4^eضӪ??%%eQ"|J;fguԧm֤$J¯Z$()wsYdB4w~|+??˸f 90󽌣eKj ·3\?rC?ng EM nԞj5pLdt?r??0pXQ4sxI˼Li^5ԱRYj%)G~>/oWOFO#UޚfAGL<}dA?0z@d[z5>]>♇?0n75-*Ztzu^kUlG!4"2.>X2`WkZe\Ќ䥐Y3ly|/M??0J4>ӔKߨqT_tJ ?n>ӹOtzqI[ !81~xUc7k #O<MS~xw*!꺐?n"1Juh?rgFW{/G{^h;Z kv+4:ѹgi* z_$}$'j%92Z?r<zT!1QPi*b|ťƪ`>n2i )I7 B3_I "SfN=](iolH[ JڮSW:׳]Y~=!;f)crڬ,DSeUBNQ#,X059=uSthƆ& u~uNqjѰ?n%c.]0 ׁ36뵌Zj[ۗ)U??tᲖTqʇ4j83&b=ܶ6qb8K?rIt?0oVp5ITßyS R?ru=a`M?0X6<#Z٪ beI(p?r1 Ϙ ÀA{.Vh! s\?0g1Мǜ\nP*c0sЫ]DGu1iN`xIj`2?rqσ݌܅??i?r č-^`xtV5?rbB e?0FÄ.[k+\tCg}h2 FLځ8w>sNxþ2=W?r!N8v Y򈦽)V?na>hJ`@89o5q:1IJ?0aVPf;\T.'3,ݢ4`y 7"uQ)Fv4LEXrLtC;բq+ #ɌjG&yuj46.GJSV%]?0??pmq"-NYH3m}4e's@99vp <,0b;Du7_vpgbIS^cl Mȍ||; {>~.ΙlX\Y  xfH!-'w߼De.&]2<;S{M+䉡azt&߲$ې&#['xgN*\,J3Α#Ary5pHm#9 x}2%9xPjnDŽts5w6T<{RE+g0aHWb\>-ղ ?nl ɛ7H|s).?0\i =_ǚIm4{[ab歧l[ADf6llKkrUuդ^y^ej}tBz)V&Qc|&CTWݴewDC!YeJ]$ku;r]ajB#m IP_r/(|n4o3б,MD7ߝukԦ/T/$ȸ2]3|!McL70կ=??IY=?0Ljj`3D+FDk'{q"z @6Wdm_'h&&"=fn=-68К6W{V*A6rɓ`!i A[g.?0q8}-33??,9:zx}5=!5E`U GwqHTYLIkԉ~>$3OI=!GGGG=pw7ϖb2h*4FHy'EtFc#ZdeCSrX3p1 ?r'd'(ڇ %ZoYGs?nG^d*i_pÃ~QNTN2FW!?nCexP+@j(jFcA= ȓ٭rPeQ1êA(es+m'6M*FGB]:4/t Z$%cY@ڒM<]^ІMWkv&_m(K͹s?nh 9=vWg*h?nvޯt4/SΐW2P |kIU qPP =tI&U)qC%<ɰv&X)Eo?rE%r DTa_>ٱk0 MVxܟmg`:v7Ǡks ǟQy︶[w_94[?0pߙ~?0>R?n5xcp_Znܪ5vʰn^L9{moElcZwVUۊ߂6P栵A[Up):tj|fTHM_-r sFk?rn>?rTl(mK0V4D}5Qe [92]D0Ƕl#~jT;jx$5?0Rȧs!§!)%Cؗe?nmL㧏$u@4oǵA0Xt^!ۡlx;h&C|O^0=EX%ƛa2A"9=齏{g/eyݛi~_7hB ~ׄ%OLeɓ=LNjH?nzEJ)UィO'$^0=G!+4"Rkj -#[d8ё{҈xJ^eebTp 3V}uT(OWf0`eG1o1mFX\$%;8M9"s N{7LRƓݨs_6U2QdϯM̵G%R;!$;=??|$[*:4Ȥ}I$D?0?nB KU?0\13`/pe=X4vu4A[l>$n$Z<9R菦ɛ|!9?rH/A(&./q]w#, h[`u*5We1 GӬ~Cő&^o-=P&*7g\r]u뙈JegZ0/P4Và*@YS~BJ5;J5U!_GɃ7O.??+_ޓIƱa}g޾Ҿ+l0隿~>q䣎-a^b̻BBxe/16xױ6M'THx??.t9C^??˻??5qF=??s,2cˏ<%mf|cy]8:#FMٓW/\*u&zvvG#y>EeX&U` CHoM.D`#]v 9$]M_ā`=|UϿ;Ow/Q@]G1)qك1Uu6EPeTr9'T{4B'j< 5ucY;`Pqg',BmUſFq6x˓W^t/uTCEVH2ggznaO T$WɰG-sh!_hMQxfx`P:s Yhܚ+MSqcCؖ:n[ḻLZ,?r5)ΌzN%8K*Z>lM=zZ:_Kk6>_nU&kٍ< <䐀gIhKPjj/_j{&ACcY|g%@jiAz"7>V|*&9~L?0v@AG!lSUN[Gơ?nw=|p4̩yg4:TwwP J+Sľ ЩQETE~~WreP^'ߎIgxTby{-(u0>oYr&L3MY(sIKs5:pC]c%xm;&u7w܅WCk įOvRϗrɤdoj,}:~P qr\N707l$iH] eGx>D^חړ6+'Fv듋X6uBNM4CːZqhGA֥@;CG)֛`(^n rB2ci)N69QF?rybZ3z,bDdZfϨNCG2s넜rC^.B?0V*9cmJ3O_-+)|!dL}Z7oo ;eɑV&7d\MT}ھ;N*MLk^D*$eVlKNA};a.i:+gqt>{T??I_jk"Y3H"70w #HGIf+ePTìjC.Z5۔Ag4 \YӈV) z1 .)'oUGFѵh$1jgu@ѳ4z<@bt_9Yqe!\u0KHtAo*v۳??]?0ժP?r Auk<%g~3VOݾc왿o؛_uP24h~rd;G[z??<Lf׊دIٶ$Qc<$Q$m"7>?n>Uf2UPNs\Jm6VP,MP"\ockmgw.}dL022o@ ¬`i8U!;j;AHLJ/,hɮ8o=+"vށ*t?n{Ffzv\z*GEс%Go^['lNFQE]2m3XCY>E:VWxKbruH;0 7yw1Atf$#wPp N2k!?nVT7'2.j4ۤŹ =kdLCqWqG#ƥ'؀z?n% τիKjZ8kx`<bJ'~ปUʁnV??.4H%\8b~6~鑝=Z8✤Ȇp6ԋgLJ/AZۢݵ?06~}ʅF?rEDbϷ{<*+Z)".?rCuRM.HN09[GѦhfSB8״N8/BnuXۺ2-LcNcѼύ?r[A˄uG ƫ`dr(N>F ɢ73ѶFK;R,O]VB??e1'>@7bL3kTY4,3ĐR Ʃ1Eiߞ5HKͨ$[O&Ne5V%xvbYA' ?0YU$vvcS!d%1b-GKF Lcv,P}$w9ix"Ѹzݽ"Q%lo9V3n#a]!#=vzǒ:tB))]כ\(J(ilր{2=XzUqf[%-6V[ܚh@6}f?r;uO|N흇[1+i9]8 )`\(M@F}p}%J%Mȝ.{Խ!!L6UaL؇t,q\aX [Mz?n-ī-*9bWLߺ$g68는qQڀڱ&J2K??jEg܏ f}YNb>լn'^7"3;#\8NL217ƵF=/]ѣ< 1y<M釩5 x$fEsĶ=n%.\1uPvLsK>?rk2I,#>y>8$F%>}E$cWXnBu 8*R,l/Lg Kl7!kf&uLV^=yO/,XP29$i&B6X֢*gT"}h"Ѐ%=l;*h'rtg#|y @,p@oׇ;K"_vjf _VTaoYbQGЎ\B?0Vᄃ hz烛Z (>g^r6VKe!օsZchî,o[]L<5ۚM"M io(G94p\ty7pu*k7 *IN2v}Brfv`hb񋋗˗=&X\Agy;nVJN?? g?r`z?07=egv?niHD??LݍۙӋˎ:B+25ġtZ\Ys9yD'Yn֥1.-#F:[(3)6?n=^5"'H(>T2A$Ťj]=NK'xwP7^w&n 9S+_=|Mu M\DsOx s-Yy>7],[f u| 5?0n*yT yV?nnRw7?r|=L$uQ}!74Pm<آnnVc)$HLm=6K”?n[S6տnkkBf5V9û`~T @D>ZENH拶M8FNWsr??YG?nOnPR]Ø^D'VFi5z# #64;k]F>h͓:qſH$5ifp|I)-OT?nE炫mT|h[3hR}u(A~¨4j?01j:3Nt|E"'W&i y}ڿnh^%ϙ_+:NvR%%()]krY#ՏxJ–Y?na&V]ᤆvw qY ??5d0*^x3Q)??RoNJq $lKDr2 {);{}(#&m&Ύ)Ԭ>.iF%DL7R>1ac6}7wh0ܸaupPx#A !J?0>x{1>*l6㏜=fRK%8iwo&;*$(aYKc rpxHbz[Oæ$e.׭s .r9%O`rU2t$T~J-4(X%Y'ޥ ro*._$Uӽ`oʪcT-Mj~{ wMǕAi^Ȇ?nwB1VGuri[m7ۓM$??7iw-4dyV`?0^fbA:S?r?n͠R%#XGEt brdD5h!~u)"TLGF(Εr'c@H3??7miJ?nlWa튑ݮdpdFu7m*`*9)s39_gϽBk.bԯ2&Fm§))P ??"p+N]պtfq!1T_VZfI{* ս3j2(rcqtt4~E/̥Eq|ˍnֹuQ l]Ϥ&>-}2/dT+ևP%$ܹI?r$RUS.]yZ4{'3*{ՙ '4\[K9v8eR)j9YZ Ϝ nSvAP|?n_ǬT~h$,,=?ng&[FcP;}?r/|,EڏSe_ '"H}AqE%?nr C,Q҅,!'ێ;w༝kX.~uTvVW25*ä,(T,k_W\%0b??7Q8w#Di&.Ӑ_??| P'xc4rc X5;u+K5??*oIUby!m(jFR%9d~pCoTHYɠ1!Q<^26?rr6º3}$??_l"s;w3-WF:ed~i~c(i`?ra&?0UmrwuS_??U7xFuS/CM#Ǒ _I4eɯ_O?rxUhr޳EfB?0L+T?njH?n\#?0Vf5U6 _qbJ$,u?0V^̈́oO͔o/+IZufMOA]}A={it) zP[k\|>[g>:N#'DKq  -#0H!O7Sm_- I2\I#f(4jsqA59=גeГCRoONPOrQ&KL%SbXPCQȳ3ꡢt3ZC 7oI=;y{q)y2;$0k͖sdC(Y5WnXDt*m&(l?rmnͭ$?n vGRbtSlq;|EH%qx~Ihlؼvvz||j_.m` J"yO;&nPw(Z}-BQ8$$??63']$r7~u|ŻDnܾo'ky!失Mk!mI3yDN=n#)'G%GH{ĿtTf":qd zv|ϴ93̊XW5_6]C7]Gs/=rƋY2O=|ѐd{U \G:`Y".bzj +#dx"_{?r"*֭,"n]ǯ^>AEy ?rAÍ^tw:(O4g$`R??|W?rO WãAKzk 5&gƒP}|MWPg{1nD9Z0(\!:L*![_y*9y#LT+޽&# ް~5UCD<S_J' kRPеzI\ SA!zk XlEm??iMk^іx?n ,F)?n9p7{V٭[W'w6'51#_/2+??~!J%?r.ۤ!]p/H;T}(,٬BmDMoo݌g&6֜zh0x+Ix֮ͮW$֟MB0GzGj~‚1W;K>)k@?n>ܵrV)ټ_,sC3pVIU$} f`@w_Xr2/،el9CFomln?nf7y11_h??lƙ1e??}Jav1x2*ɣf?ru-N?0JP)Jn-SN;s?n k NiIn9\-KWQTtv][\b>`kWU6VaßV3Z?nuD,?n;gunEoF?nȸ}"/}&"#-RmՐ#6w]lFL;>1VB,ր3FA"P)?n}#sUq}@xhcs[zߞzVq8,XoQߴug!}B*}!jl(om~K-?n--Pğ]T6/8ᒺXgx7G䥨@33ϊxoeŲD6g)Eʊ^Q?0 t2y1<x%֢)H~7wF͡{$?0MBWb RHJ"/Gi?n̍3aS-P>p9z_n:PƂ^v8p`P7Cep gPqTr3RoCSq ^Q'}G'l~?ns$(^~Z0?nps4~fG?nF^(F4\PQu)Keq@x_eqԀ* d[јl~$zxp'eT6\Z]J"SNڼjPůKT%߃.L-La}G0,FesHHjb3j"1mZ{x*BP.ld-(]9vY` umBy/D.dY[Km!2fEӳ8?0}Y^nEQl76YQ*^OJR(Rh#T|Bsfi<{1YƚxGzVspħ/ZЌ$f6 %h\AlfG!BGt?r曞]HѪ^+:yYA%m{3,+z0}F^m4w<3I +"ԧ>.ټGcrբ:$F?rm@g|x0σ7R??͛绛7ݼyꭺ??_~'}G\׿??HDK i?r>4$1[o挸 y$`9,wr8cFֿeA;eyee?rC8^hKv;6fi:Fz7پȃvezW&S)o3|<*Az-`wDz䘉lx4eu~# -#x'Jf.E.O/xܣ]zFe\wK4"Dyά;fcgq1R ٫׳NFGU*/TCc"?0Uo`i!Sekɳ'qީִN|,Q=tK*E?n*b,ȋz*bT ;"vz-"nb# X之2e߬sXrMmtK}iGo)8k/xgK L#C i=ZkF¼7ck+Vl2 H|QA5=!mp x2V1Fw|_ϸRT$uE3j,o&f%yk Զ%/L !9>cXl?? ;,Hs\s=}Ic?0Oڢ?0^}ɯ+}Hǐ&^ !2թ{Od۴c APW`8-L؜m.t 8z)R6adV6+dͻ/uK\w?0쾬3E4&?0 jo#}&Iܿ$'`?neP[R]LJFL;[i_4d'xic ?na`_Cqxh8T"mTY^(u*YD7 F?nh7$e)mͮݚreE۲|`ns~C@NAEW}|ƙ;GXqL\p^i^0Dݙɣ7@qF!z3-3kPv12Cj}g߾x^<;6g.F?0ٜgiќ35'<->4nD;s~`g7x}-.d/Rٌ"ycXHtpmQs&WqgN~.SY9\iYln-d2V4?0uhen>]RR3>!TA{{|^6T&ŋק&#g$zb 9"==f?rby?0)e8e|cS廼X:-Bd(>P Gx# @3qGYRŐ??}#EP ,{:8X3W^JEza,:~f??W 2T[%Ȑ%:TZΧ??C5קzo;)#=%iZMZ k?r8et0eQ~?r۪'\Cřa7=oP5fnC{{qH9~~Y<>(eka /fB$Ja'qsL:l?nDOwY;nP|82LdRɯ:/o7oԗw'g [ ePMͪ PM,{ݥ@D)Ceax#\nדऎ)} ~ d*n,Gdp7o騠e9˪@rڠh 𜣚&TblƓaףO?nx<ɹqɛqi2$I"t[/e.?0 miu: :WLRmhYf@ȝYFu73U8+X&ѫggG=y?n8}qz}owUKRa/}n;DvχF} y_:5*\rH]:[ e! dYu.VRV?njf@9Ҳfs}TH0P)?ri|-e ?0lrㅌ gb@P@4m??x"!S\=lv,<tfrK;@S[1_؋.yЦx0;PJ/61K73쥝aQSkZڥY^/fkPVԓu I=jm>J\GW.Xۅ>}=i ğ͚ V=(A:Z+vy5!z??F1S2qʟW >C7JܒBm95Jji'D]ȋ_̨&.;5-VIJE?n6%TNjs>lfpd/aSH_6HLLȂz?0Gf^!Y3cM8ˆ4񌶉*%,wXrيiέ;M9OFtv)gZtޜ$~uUduMQ_9Mr61nF7>5hgs^EK oQ?nݶk(,_L*:0ၐ63 P쿈8&y1Ё4Bn?r TʐƏBiK% Lq((ף^InEqY+#[3??Mr~8}Gʏ"Ggq~%YCK7FQqsM~\ϧz7o~d.b_OeK{z}C_)٭?0sIfWPzb-Z\}Z)׹SY=|YUb_f)huҶ,m12jex+a!~],-|㯜";Y~nԏiǥVZ[#\}V~ľ29O*Ũ/*T)L}qԔW//S?n+z/KG.RKՍt_'??*q*??8ڨu]lÆ?rn1B,9LThYpLmb>)c@Ovw7ZTs SyEQQ_tVȁ??oMzP=TƹA/@@*]mPmmJ.h &( qRa\={{dwڱyN /|{Rs׷R5'h^n1(nRSPfi1$;[|}OF&rJ{l6Hya3"9o}KN??N}~9L٪MyP3*pYnncðGۣ}LYS{]~5ZIM_$}$XcjQH;ڀMXɋa5VZbT??!gCP?rw[(G.J7ȑni?rZC`cBk:@Nb?r(؎/y?0]"^=RɆja)B?0 ]ܨN@>QjI?0GX]΢aZaI4yjrbg6kwC[ȡȯNPr.&(a%u"=5٬wdUl?r"?rc?r* gٖK"xH b0%$T A؎$fkr??@KёxpE.|:K9=Hݸ~oh|} #$]407:H.t҅ź/8^fi8Qt$/&_o -m{tӋ3ק B!$n:vKYQgFZ+g v< 1RE]{̯[eβ8D?02_6 }-,^c -# 12&FԯOֶ-'-^wfvJ"cT?rôiS]bpK/4gZj fj5=DلoJkz!x*b,$5+sDˠ?nwB~xUVI,7&hYp"!N0Y i!.'aI{tCMiKE\JwH?0*Fl?0_̸6ݽm&vWFrWa6"Z(h]l2A%\Q˛N[:PʆImckmN.s9e']$04QE xܿWzK??'[!/(q_d 7\q SyLԟ< rj^s1-C YN4TᤉFT7p :xnJ1~A?r^J?r y2i~ 0$*A}+--jiZ4)D4ᲣV_bEfek!d'ҳdXaQgAAV;ĉ*h6jży pJ>6XzejZ-*\8_Hw81/zRZa<̪ fTԫ۴| h>ԨRި>0,p4\)\_ B^($=3lHnfBɔGgN{ڳ"b^Zy6"_A{f:Zn2{1C`[ i HH1{Y_hYâٰ*.Nӈ >gH"i^q*?nZu̹aςO{ަgNaQsTƄşqV!i<~TGE,{Uۨb3}@͔??Kq)`Þ]ӫ%TCվ??4b1k02Z..Zd״赦ETq9c,\MM jyE +s2vQ F"̶b`Bp:eDa``r9SL A-91fr3U(/ZS2ȤsVM h6QcQ`C0i }V1e ??6lH bT +V]tho?r0lJ?0TmUuуOahpwn&fepjp&tPMR UQEkEYAF[$F=VMk$?ndtJtkR>zцy3߀@QޛC⽙A!:_.H CDk^W p#\ix4>h~~[?nN𼆂?nmC(isfn?? K) qW7_67e)Q ai&C $X|}Lׇ?n;$ڀO#$Ib9ߡE;r?0uރ6G dͶȉ]jk4''D??HAzF㶺Rq=)$r1bE d"?nS\JhG0f܅Pz̊PP?0f%NfBLƪɃۖTKTj]^Bj/dpyѦ܍(%\ΪI{#/F&d*F$̐rZyGbwgw{WxseUe$꿍(mCyOJ"%z")~2V` SasUC f@/ol<+^'GɴJ̮l hon>(ceU*0tMI8hDY}wuITZT 8jn>$ ZZRuhf) ;NlK?02vvv{ӱn+~8\N'\;Ucigޑl;8M48EP\Cͣň8* 54_yo3zYnDi1yUk{`}:['/ҏ}\#-2D&ynAKd5?r#}ƀnMVL_aX'orULQ+[vy†J@V&='̪\cVY6y#Y9/=ao/JVTW/O,]8ԫۜ!G *rMLh?rx uRNY ?0t:X{2xN_>{Tߞ|FYOJm1k=Fh:2j{=U4V?nF?rӄJf6XO_N#I^?rvSՉh Q\|PTF-]}=^&/#6|PNV4Auew=<\a_P},y 2αuHb.}BEӐū@?nJ_ybwm?0knqeESǒ@M6lM2jsU <#Y?0٧0]eҝHt ΡEh0|K#E>-og8+harhYNjQo4!Q@q1:.?n9n3k׌;꟝_\)!ZJ#iI{ !1MS?rvojm{6E΃Y_?0/3f?0N"rMBQUOUvLbZ.dcG`]?rYEH4LaO;1zNߟȓ\b)u rՀ{>n" a=P,-OOƧ^/W_ךwT^L'jsyBѸt犴Wq{;4tXMSR84@ӳjp MlFdlϚ{TDA؜J+MCM)#aO 2bF :#ax ÒA.0IN?n#m]FpɇN%7b UuzPʲB(S^ Yj14lI8(v)ҍ٢@{JKMoដDBUEY6?n^^}U?rx/æ iXG~*^ԗx#,'tN.:0=zVBt`F . Ha/t859'TiyDX BYWwp~=:&d}$ԗ?r扂-bKN*$@']|!S!9`$"p6*t6qM/=URgTapdQrJw 3۾O'+%vnoӻ;E_??#!-ɴD\]5-78@JBX2?r+h۔~K#|v^nzm?rC4jg.Qw79cT C utqPe>henVH3g{7#:׍6EPìE0KEf% 7K3#.Uer6B ??Fd?r2WIe3>8؏_mø)V9j"KH-U&y^, SKg؁lcdFqo56Ǯ~-zUI$E0TMw?rgrԳGu??y?n9<x^þ "<#,)Da8SkϗX ?n??l}$O.V勃8$;W'YPN&+V??؉TpzyQ1U*'Y|N??|~f=v7݇ed;kE߃_w??w矏 !tׯW~[4QXDctQ\N!!ȭS?0l"WHuo?r#g?rzuU'Sj+bIbHY}!0Pc-5Vm??$Υ,`Azē6GedtzH%S&FÌx~?rY.U"ēKMw6EvBi]O@"rabsեMa1q'llwXO}zUj&%9+M0J1GUZbZ[?nV،hTP7,4sG.D] ILu6dOI)y/zQZIVXēi~'u`T Օ"}A#lcFGSh~Y.lzbܶjO+p>+?0|)*sjObrt[]$%+"MQRrғRRbQJ;RCgf];)r_~Gzqtah6ϚыgAo0}/X-7vyT7la5^EJ"G[$j-f0M#tJ'{Ѱa>\?nF?rqõW[}u y߷^77??0RxaNtL6PVs}&@cXknYEh[EWK9^-aH^QуQ3 !Ŧ,W؏kߎ؇>f ݹbWۍ2,9>7k&!%\̂Y &}ʷock#_iy:?0kTȰX^Pg#va,Q+/gVɌego<5Zz l_S`2ok)god4t823VifmNj`/D/Rev}dBl|$|jjP2guqtIId&k 4։"X]^?rj[`BŠ4-|ׄ] i:*vQPv6!$g?n|I#t\&4z?rѯ~DJSW`$W=@%\D@B2??2fRjw@$ά`}??|k?r| B{~?ny1:ޣ'Mޗ70oye&1r +lCq;$7qդ[g5آUʨ'鬊LmxuY,sFZTDwcNFg)[$L0dgދa8{~Ad/knf*; 'h7?01XEE݅]įޚZxbƮZ+C+8`^1+ AΧ7Pq)t5_g陞}-9Ė}%ٕϹ1O=ŏ0,?n%Wb" H zJ)#nە??^x+\܂j4r"TQEz K7\2BUg5w??i,uimFXK9ODojӚCM??)o{B?n !}XKJ@cOZ(?0_Ga_a&'PUd,2"Vl>D;,SfkS96Xey |x-+Q+v#v<8x|,n/%m=)>0J ?0.n?r<_Rn3WNg}] _WA?nPXDDh'`zĵ33g7'sLm,?n 8,N +z-UX= GJڴ3K~Ɩ] j{ A˟5"FA,THͫ)`y $^%ld_1s'=z"?0*wFBb3s^ƶ}R^QMħ'%A*\!}HE&0㕚xڍNMEA9_Em]?0k!R'??$NzcgpV[jߵ9J]Hc Mm]k:dM|%I8äKX2tbfQN<N@\*XD w' sf 9-xL/m0zeVۿ\mIN-_ذV·(ޕ/ 0=4_K&¾Oݵ$]Hgk|T^3DB-;ar_s[dbZP+-VvB9/e1?rSiCĢtGV84qab#^Wj${f&aGN.=73aӚ:;wl6??eir ΁kؐؐF?r^82m"V??v;9-J+i"H7p&&X6tČT'4 de($^24LQ?rÓĜ&@(h7I!o58ee[v1{tzh޵C7<sWtT_$mRW4^!s=e?n*QS ς ;?r:6뛓\urA{:lnEj8ꮫB@~=_?rG"E{i*[[šeş?r!:N"8MV!@|AtHqVɻ'w5w6 &kh0Cd??+T0>n&:E-n;j#e^pKxrPb 'mڒ2?r=S"ɾ7q]sj! H-BA9d??/ũ{Vy|~???nW(&?ry:VQ9Z+~"/ṪA#x`㩗XdҶ1 xDx'7/j2Ղ#zEH7**!oZ8TcC1@^ !^ʏyh|XidY>_&`s9E)b}ZN˗&s3ľwKg"Xૈp9q5]$J9CԩiN#x+4/0F05HD8.3?rKRe?0Ѯ?nk?0| #(re0z P{E0/T@#5ѩEN$Y.+yAIWC}1ZddZ(n_M?n 'ESQV aO7Nʪ{E:U߫09W]N Θɋ["6Z$H9у6")8Rͭ)i+Z`QSw4]8ʮ|6I=9K?r{ކ+m?nkoE4r5f= w_ykb-0VkVk6TyU{F+Qcl,=pN4J<ˆG2#skSfDtR+V!@*uP Fg8K{.. D&"LoH莘Hȣ&:ǁ;* Gr^ʴy^a54?n=2@ a}m,!JʫtWz,NA ~??s- %po^F^ڧk Z<ӫ憊hVz$&ԗ[:bL?rZE,Bnr_7c<1Sol7Eqs<Zk>d]RXg»4ۅ͍b'UպguzwcH lK5tHtsu}F44ww -#?0x]#١+D(T3{m\>  7aiIF]tm}juJ5ifZ:F}F =d0!7,Cyn/8??W?0G?n%c<44Fc9"Eky~-zAX݅1Ŝ~!cۭuPiЮ'vV}+9Vc10;R=yj:b;vR!E~V 25ඹ'_$ecQ{p<^_yO< |f} JMws@i;W't&]9Z& W"7P(82SA>Yj]]<#ptpM#zYs\ǶGc.JHJez5;YRG|T/3٬H[g2+=}˖z+v)PV̩Wo+A{k_0UEմrJ= a&,# M+?n&!f?0HixQ֨m,ha*Mb[+F(*Kr??)??/b+?n]E:j¡=ܥ37A#pd4z||||]0#0%]W6M%rloTIQkXsoKNsErdɦ'ʴ<}Lէ??Eٷ mjWW!D=P}+ʇ{,DwXg/N5ؗ8rv.@mZ?nM ?rTm?n߁Eblf?n 5S5C?r+ގ4cwEA&y-AptWvRo`ӌ~[t'?0ZXɦE^?r]8О{Bt??ZkV~ :o*l?n(?0 &p|cmb>)0_?r)s3@f#Ndz)پH|H!5g9m=E^<Q  8m &3cgwmtbE\`X.+3Wo.1/ʣ¶ $Qʶ3v\J,V\]AvU.45b,OqV6.*V:3*Wi8R{?n?0$jqIuSo=|so)`_g4w!+eTkGfg@o??eGCĺw1(ChJ2'p"=G*(fBOuAM?0UkU#mgiUZ_iJ tO~p9O!C}6G>2#f'ftj^$eA{z $3fMqnާAz??̌!K$%˖Ët 7R6aHܖfng/4!E̽VIWD"]yT}Fm*VL_nǸ5c_8?0 vyZ*ĻruQO~* kud?r F'Vc?r3e7`7z<"Luqϯ<+;wB-QAc:7ی"*iud{~3jsC;z-ͿŮ>?n)3Q]h]-Vj9oУ+0܊gCf*e]c[6EZeo77t"^CY<0w2NEn$ݟKI#+kE`,P4my>i7U@T P>.PGjᑌ\*VE8! }r^;ª^thER^V"w<ZnX!}*I;hP~F|N@'ѩlJ??Ya,EvE,kd~A#,< wK7N2w 'j~͘{E5ߝ[9t />>zQcKo|"v+v@ۅr֯OڿjG0GJ~dR)& \wUiY]֡(-Q=5OJMWHN"HV'Nd͊Խv맭t뷡sæNg Nvؓb??r .989WQyltu(=ז2Emꀖ'6JQC?nD˗Z?nRI>5'I.]?rw9'5Bꞇ˩ Q,= ?0"F@lL?0f ::,$cwݴY= "wQF{[hgƍb zI-2wv}Y&l'DX^2G'P(-Ië7Y(1ςgsC!EYO XBc`r)+?nnRW[,3v/IM8}tȚTB_҃|i#-X^ɝחɛ+Tߕ1#J&VC U^b\#,R *Dַr vǡ@7BCc_d>=0I[邅l`T+M\%Ԣn}b)%߁$ݙ]IҨkl/qmvV852KcA)JJxF2k$Z)t:tG}K/V~//V#.TQ|Sj}!yR˝uo+ljMU>?0ݹ> >.+W7H6rxbA_S/G1fKSۦ4׿ـjBbhq* ?0 I8u .l}x(?n~;2xBp԰%SER7#G6Cewv'X6Gwʛm?r;o$ |'RrsXuP)0!2V̝8DnL4e$nN?0TvZFb&7 t$5X#XDFonop|InuSLP&)Q?rwIYсOn&F+uz:ukH\EFcy_lN Y-puBh/&rxe?r5S|!IԶ)r=IFJRh-i8Qvx=kvokDە;Oa(frةŴ???r)[<%nv\n OCto?r.̪t >2ý V`}8}roZmF?n,\<r^Usغ@Y%;OfTjTr.Zjn]{m\/;:FW' C CnjEO~ݐ`;Z ??H<%}5??}=;g;92?n %gqPEF%}Bh>fX#dL?n>R'CۣEuHb}7]ZDRНf׹=B[g-)҃!N 󌔫??e?rvր>m(nu@ۮshm7ݔ -W=9J/dm%9g% @X}ʘw_C`:BvYtY*c+%OXm`½TOOCyN~.fPQ9Qs[7ךҝ`R"eWZtC@dۄwtbӽZ 6 .(ۣс[AqӠ[߇rlwqҢey!WMKb\ s+ߧ(?rWVWbS%„}eu%+H[\??[]ٺ"ڵqyn -#ɛ>'[dYڦ'hx1 |tM}P>)'P]}1zބrtm;a퉂|L_Z!,qήVj͌p!7hH*]aTDuEcX%61wu8_+!T@4EZwR?ne]`;+Mb".psu ܰ1H2nt j>>N(g-&CeNyS ThByD@^ㇴˊcU45ʘ?05(Uk$!\}9>5ʉ 1C6ۛOeu?rniqһ'[8S;V?r+QĀ9'M1|?0ޫ2Ʌ $@mNḾlUu!o4TWҼf)! >Т`|958TϽ@iou 0xl}XG?r_c{=c1`h{JG1oY ^e,9G!Y0,H6K\e28Ua.>6?r??xtM?0mڅ$$ҝqZb1$ 1N'ػY3@\IXGa\XO.w>\~Wdm#f4??2VUpD].l?r lu[J?n/hVjQ?r]1wջ4]ZHyBs^Me mN|C(':x[Es,y6DB/B] &-@do{g XJ[?rξeUתL쭒EwX_E$ SK 4"|>tPӸtV=4>mc|MAY')鵮B\rmaQ?0JpM[uT][ ֓>.C*Q*yP⭶?n;Gpd"uV{m"+ty9u5M,eWzl]/X.F9&@HkΣں-uK,8Rʐ쵴/şjnY^g{79YKT"v"h̲AdӬb.??aUly nKc !*?08. i`.ݮ[\`-mR|&?0K.9v?n9EA#k09r&\V UxZ'= pچ0_ky.ֻa H탵n,Xf.GvR%+XJ'c]Um?r[}ˋs??Rr=9w۔LsUyGT3 bz??}]f5>zl7P:6[l#AߛKiDo>+d??!zGv0Hc\(r.H?0uqx_њOj:|#x,݆uyh\bpGVzcRh{)_{F@| j)/F?ntk޵J!փr )s1JFE5tܦaUՉdb/N5$ E BO9GZ.e -H۸ Յ9X?0~?nY(VM,P^0|"1KEoz"IԶ j~ɧW=~JN&ml'}BzhQ??_RN ]Ndt_P ƮV@ͼ\$4Yܽ!y??s#òoɹ(w3G#Ў3Gc6DG`͈8$@U04a D??m_ctsLaY?n첤6DȄްx*7#u6 I92ek(f7/(Wnv@^?0ue@ 6[b<$i]zG=&?r IMk+G4$UUR%e USduNw!hKM{\SOzг3F] |>gsGsAfLh=si"aWCB*U]?nu&@)U#\&oTBcկ(Fg'_?0 ~ԗc*PJE$yUOzU.8M10# iM*=@ڎ!W"~9=oיSۚjnt^ǨǠF7eHo_xc(u3VY;lKF]}&^sPmz"noiAX4nɌ\X@E7fd`a{}Mb!QEf&??W^-P;?rÚ NF?r?0I(˹Y% ϗPHN`6dAԩM2%}H/$KE$ bBPBH˾_bC.e8zA߇_ oGHnټ.ջ5uW/m[ڇ0s&hիE`*Bנ3f[}Dw#e^4gVX5=8Vtj.4J?n?r}OEX5o7Ku`Ʀď]`5_*𨹁DPqjXΖ(ʇ0:fΓ۩&1??AW~XXWPD<-A!?0w%m2˪1uk1/RlNb~KpN^Ɂ H|?nw/F4>^vMAxl9I*'J(@??>Uݦ5Ϯ-yNΨ)e—t<g:!O^gC.?0V+0hnH%Tr)"ztq?0RaT ZŶn 3SW'vq>h<愜V>uשw|q3?nxgȷY1KlxOQ{^ᇪrJF{4F˦Բ?n#{ X?0P>޻ǎBB@+8nST\qJti jF8Ub*|q.Xۈ3w??5C>GЫGLU4h:;O?0Fc76{i@+پ?n.??G0x, ^o:0bb&G=ۇ۰qc<N3RNI2PZa9M32q"Ӝd?n02׉N9N LkXUYӰb3zUR^"ᘤJ|N{2J]ZY ?0d lbj?0 Ppâ(C"36 ??0e1B\c%*5BD[8C x޵͢.Ͷ"$I7dxlAQcD[瘖m:Ji6aaw3mĩ+ZO͘days*0?ni̪=|W1}%OȅeUlMƨM)b۶%yjfvP5턺[8 G7Dv^T?r)Y XWt?n~IdQPaּOU,O?0O???rQtnep^@Q/jܚчÚSpk+OghH:#S?0Pv:Ґj<2OF` N),{7E;nJ|p  C.Tu 3j}99vL6_1??384uIK7FC(QwY(.ʹ)VVstagC(ULvuq\(gmp*F??İq%`mXZ,npRܨd*$+M8 .d;qoZd8Z߀IQ#< aD4CMdI{ѧL~OMR??KM5$-"K>YƆm2AY9d3m&]?0?0}۶??W'H;i۱7NҜV%QJLR]pq'==͊$0  #-W niA։vߕf\^5a9I6T6||q#f`ζ(#[cpKG9H]~͍PKi?0??NY;ukO0LO]oBB(޶,.Bc&@?rBvWvTl_[f('%UTp?06N& F tV<qJ9zlp9 z GW^D˱{61Q)G∠Bt24Zޠt+LK:X. n(U 2z??A=J9Nsvuw=RˢZB{::PJ}!DF'Ї'xݡCᨈUwrE3 09;#mWm))`ʜ{JSs`#!t Ibne-lhʪQ4h+@baY?0y?nt3Z@?0 !}xZ7WhkE_XeIEO/fsOY}$?r32WlL-rf2o7xv,H MAi`GK7RAkpc}'*2M#8n^"J}(j[P|YSBQ-K]6*6O!!ɤM??|h/ʺ. +EK osGȽeT~b?r LwM?0CҌ]SS?n[7{/}Be~ɷ52`*qHh{1X^*R>i$V\2 ?rxd|dxZF},]zVFJβ2v -#Lٝ@֦`[Q:"{UH>,}{C!?nV#Q>?rWQ?n?0晫r}Y(Ï>Aβ ĔZ~yh<)fAEf ?r h)Y2QRYp[Ɣ^9W}%@4H>֏;Sm,P@?nPm3?0X@X'mL?noYg doePy`!LRTu[;lBT:p1LKՆ}0@iM$bwu%b.Ι&ߩQ }g)2d`r(?0SS??hz?0%ľghE6<'7b'X鄅dKj ?0݀2]yu3~m9B'h#m댇IF79J޼yx[.\ҰJRj1& mn9K_WI(Y4[I$hm*s*rc"ݽ"]Y2ccL?n꾩t[OK?0On lŒK ]f]nc!'`&?rbeq-4JwCܺ<&??LsAėqL&zŮ<85yY)vg2L&ܰ7ܽGch_zt&/Oruܲl 4ȟ3N/.G =+S^@[6&TڼDhŒ"N#ZFYt?rG+".p8 X"Fn2=Ӥ d4[2.si~Fʼnϗއރ܄ʻ[\Gԫ2W!A# n2Cy5~?? -#:JQ߸HerشO(s)AN߼1{p"劆:]a?r"oEk>*[84=5`c0/\teKjOf9݁‚[k??7@[2w(6$ƢDn@fDvigJ傷Gr.Q>^K氢b=v_ףFѵ]EJ q<ၠ:K"Ӝp.?rګu1[<4!d/q(CUQUlJN/*}?n*6'AhĈauGN"?0a)A?nw1e??1 avгHK܇ޫ=x裏i`P.q)/7qaq[vLRpaY>q'D*_ Yt2[H^:*MO (;iÆR-Nr"?0t1î /D84߲OO=275֘Y4EqlFNR(8ޯ c!҄gh6r@Yfh&j@ߓt\ lspY}+N/c!-E\}FD=)6\!/6<ⴀc=yJ:]j}/MQnhx%-H+bO}_@wQ~ڟR%t{΢??n,pj~$2j, q cҚւۧ1$ZWqGiRkө=C$"\Nf!$?r5iP?r"@fis ŗt~!X+355E76?0Z.[֥!EQ*OKk`*jSdT[si:}A~nrewЫ"k'HP&6hhU}ˈ#k5w @]@nsz'hр֌RS"}{%=DГSCVX.)u^BkvoY:oVgN^Rɴ&QN (Y?n?r5?nmi:'*EiJHrϢR?0w(YsRrtޕUҠ!SrR!TT S&l!OJ-ꚃ>`87cVp|1M`Gs4Z5-QYlF- !~/M-\CWϋ/T:Y73hy-#wwMmd6䊅ˌ壺.dw5WɆr7hɃo?n@enm ӜM Ii]5ITq]z3hÌbe\b-^N1rBgohrj'g&_pe K?0(K89W]"VL|kq\&xeṕϬ` A}q˼Uu.˩e{N& aѣ1>{:ȋ_Jgne?ngO?0EZ_UpGA鎰[XJ¥]I^F/iKsI SC-߆쏳u~`ġJjm{;AahhӤ*w;0YN5sn!rP*ɼOs*UAbuEzMDY?rrϣ#q\~kLN.?rXkMT(bT6ĤazV^%(\c'r4{d%88DH E~KQWz̉)VWNgL]Jފ*Xfɷ` h{$,뵸nw(Z7feAElYD7EntkP?04^??opwǂ]h u=U¼DZQJgv#Ǹ*`8]zc Ե?0Cs d7G+""p$?rjIT/YDsEj##!͢oBBc31k-N_0&ab6+KϿU*k"'Ⱥn&]^񘓝恬E]C%hZMn#715`dv8j̹Ԥ]ObAi/J233 Z^XzՍ׽!oZGRg-0=@OPVDi_XLDr$"(1KQ]F+="lu,NDpݽnқ .sWEe.vݼDP FtlHe?rDx?nxE P2aWUـ:vˑ'y~uyn' zoMN@^6(}<]Ędηsm&ՃGϾ~1^=u7F EGI^Q,7ӝ?0p??<~}2:ݘxH3Q/hWfr\Hr2f)phtqs09?0iOOq??y^?0 i?r4"r/}kjku_g|WtUl4#ގϗq-CJj(ę)pb<ƠٙMNjA%|qy3%Ryz{@\_W97&[tYtDYmP7'C1k`ˇgiz?ry8m/LBs(P);2V?0¶,1gM&us`һB3mgt~;tWKx8#n:ZXԥvsm5\Mb??pŐ@+?rTWK>V*x.~E M<@S_x'vڈG?rў7*%tkHnƺ9_FEO1ӓ]=#!-Bڙטj?nM:EdxcKtCC7u yJ_!y+5CjfS+zpT}LH!e@S%'-0϶?rxזP?n,ѨLoE-$j(1P?0ҫtV3zȴ6ˏby3]W =/ޘ@c:câr}zxVCxZږBEve٬DojrG nEhC#|;?0sgrglH(sUo;N;u?nw4~EJͨYO *FcV=(@A?n8I}6??B㛀c3R6 !5Kɻ0#h` g|I:-YT^?nMQTx_SrӎXUE?r)??x;7]"V e &lgh_IdV V+87 {+jxn};ɼx`D8?? 8Ke܆A9:!&x}2X\]ެN $s?r 8g9sʥ1oA-l{5uR ' qC3yZKFvվ}?nhfyĤiH&U~⒖K??~ƓګղX~yZ# i,NAI{slZI-̵ u0?0W2??هI0ɡnż Ju(z5nz<ꃋ6͕N??}}=(_?rZ??_)䇵py^5Q-;wj!PTE,@oE{{ɣ>??NS։㖚bGha)s,ͳٱP@3t 9ZmO6G|Vcd(j!V NF@0D7<5??9\JDY8fTm6?nR:g*9?n=oXGdŪ[5W覬v`SnNVօ1K+ !Z@]q~,F7?0ď:v@DJjoqmrV-uVClLB*+͵%8WSgH\KrbZh&U]L$Mz (@ 8mD[Jw|5B$lݡ?nF_Į9lZI?0}p`f0^YHl%K؅q;Q??ŐCP}*|G+[P;,f9,NUL.HNtaiؓl[fjj/i1Dc.sMBzb?n?r|=sJyfLYx|XM N|t q9M{E?rJg1{vAb|{+H?0Q14\o3JϗG"6 A{kyh͍[nǘ̓?0b&^ LrKk݋BT]gKRciD'4t8B5: *[Mӡ^R 't?r??l,8cϝfPrm*Mᴴ  t:??V|fʏRxwSW??q)(AE4Ĭ> Bݺgyv?n "sE?r?nm-?nndubiqN[2Z4~Nf)b]c8OtV%̏KNu滐f+ ?n_Y˖"tY c$ػ+ֹ,b"Sİsz JI\){W?02*< 3R )%$%M}"]DD(dT2'HFlD/Ě'BDaxCU_Fpi%O6)&6C|穆]״e>7D:OЅ)ˮs`3,փL63Yd3eQ\٪ĸɰRwyad??-c3MȊ&Ou)T|z`۩&rX~)J 0!ʉc|3hSiֲ,\BF3 ;dB3)Tf#ytƲ9+B l?rە`Brhۋ#4*Ϲ"^?npZvY#:Q W8P??taЃOJ%Ln_^/71;t$nOZ*Pb:=>K:,|?rZ!3fq(?0 O+nQ'E -#[Gϸr|j/ѤvQ汐h?r9qEw՚HWMRZ 0=0#.F:4:o޲zØjN`ع==q'i??Åx<\|:O_~vpgã????@damtOo;h==kxJV&,b*' ̘ң1x!_5:'Y>iQc07{{O{4g?n^,v)}ns{/xPR g,>@*s##6^πԙ~xE˔9Qop`[:JLu2)L+!??ƈHy(muG&ٱJoʻPTMXF?rʾQ 9WLsn'pzv#Y1D(>|dۓߑuTuKW4ond??Q-'ՂhN/W02fzldoo{"KQh~VK}Dp=0=T??V&ğ?r^?0֫(I8O0ܶFS%VjT6M{<]ѴT΀37Q3$L㞛"lԮpQȒI:lDc/hi㎠r|j9K3x&iZlj?rdŃVu~Azu'EB97&.>WGc- /iiqgȞSZ}*nZ~21\4i*!&}:PNg)-ZU_??dE*i8Dkh>P~V?0J?n`J7 }M??yV 8ЌPaS:J]*[J?0$([DL-Wgm??fUvDu4q?n)*][H"??J^3#觗&q[!iJ`Y"t#S1BGſr͛(,Q%hu^Xx:FgbYlJq]⽽o@ ׯ^=F;J6q&fm_|sX6"?0l7N`x(\`8~KI ?ng:N_E"ǻa@}Z٣)"U6aS+ZvN W0~ 622l̓:_8թmbل,<VTgtNP?0lS W1Ec+E 턒5L:^/=FH {^xߝ4+j|k9[D.+Lh9)PRA79e@ݣ??C@XL??;p%@RXF&7+E-eiBldj}i0Fs]4}u,Y ??|Sʗt^oB}X*P0h=p|x7T 1vi/gtGlQfl -#\= a+ ]<Jc.7%]1ga顔7JY:??JĂѺ8DZa`1u02xoW}#T0Cy-K*[:0 FV4\M,wrT&i+|G ~,y_UJ?nBUDy]ݹ[r}Wx\?r?n4^c 20?0dW&^Im"?0åm47Hg !^BEv< EڝVQi4`ۿ4C+_V;r4"h:{?0JEbi-{$E,o6 ZN` 6"C!1$ϼ/.T(niVLJqj?nN3+1պL^ЮM]۫09-+uS:/Y+yZ$rV.9s]䜟2!ay,%ܖ: Oc, JC5lI!(C|+w8\H*d@B]+$8JH}MGD"P+}?n=޺R^3Xe uuu3UO#|d1;1l|P~& Km.|w!}Auۧ) bʒLC>e`R6c74Y-I!\Jk ̖J+KR8ZN"sVeF7 ADτt+X/HL( =t-6"eR꬏\eOEM,2!\x^|{[6I7hQt$6G;7??ٟ~eaI>͟/72+Y{e]Xf޲ oId%S/g߅PJr孼^mw;$y!HWm2D,)cD.DV(OYj 㷖t}KݽuMh?0HƧ3A'NfP5ҽ?0I&Ηbhႈp?0_䋓't(_HvdGUm̢|ZR>5ڷq~ݨ_բe[pK:?rh:J0> o|ηiH3g?0gCNjXx[*G5|'OySʹۨSPV/ǗRHpYC^Ouf"'35s_$L:s_3ՅC*ʖ}88'31fQfGI]Ek1zze{I?0swK&U_.svBxaa $0.XG„Z7{(+5;G??Ch5.Mc)GI\6Nl˞,q5Fn]?0rRt{?0*MQ?0ġdV>< ?0RЀ#ݕagq2#*!BEܗ +D=0z q8@C"1<Ѐ`?n!aQs}۰rn?0f2 ),xj(Dmq|j-M㜺iDKB<&Z1Yy)IҠj#&A:/ƒ*)'E|yJ;tъbo`_̑ep#DI<Ɗ_cbszclH{#G??A$Hed_2?rC)wy}9?n]}??||:ejYO5Xl 8LAWݕIۛS`*DQP $TJΥ?nUEcdR>v*=5C,2_/E@g$+0 ?0rSwڒ^r$c,I$ִ\eOt.噭Ve]a;kҎEh쏽w}@˛A??qzDZw^J>~FK1דy-t}ّ9m\eB4hq{6% ?0)҂z@5 ʞX2w)-wRe4CV)؏LδڬWG[LPZ,.5?n?01lO 3i!]8t/`?n. k3u`d_ FP FK4vףgpwӭw0JRp̂Fk[ܰ,EZ蘎E??]T?r`AG۹i̚$&.0+m[Y<-]j0?n^.-ldQ\EQB*O㯜b^9T=ϢȃĂg혳iaV7I" @45NOZOq%ÃYz mFҧ˖2yQB&#scqK-5рOP 3Xa]YC7[ (?r;[!&Y?r/ V*h,#s6>8,K5́JZ4fm7S ЄY0{7Pޡ/izdžU񎋐/}'!X$iH\Se5-+2Q6]FEhGލE/CsZ6+[V-,2Jյ3?0g iTo*Ȁv77ԏ?0[Ńi_ J^Xd Ai: ͧi5?r,EEQ >ՖfH`Z??!6?0<:QvceJϜҠ4:t׊;8tbeë?r?0]sr\8 ]P%0Ӵ@mH/ʅgLz{lcF4w~.ݤ P4XEl0[g$^q54ϛ'r|R=ݾ!916Jf,u&/e8תQ)"gĢc?0De"[/Gr$ZyRv8d5q x'aEѵ+ܱJũY9$/Y7;)/<65H?r?0a2OZ7|Ycp, y4yl@ddy|'γpI$,@|3v?nW^;`easr=D}?rp8Ǭp;r J'/O9j3z('t dŃn%Q|hڈ0A|4߼^Q+?nS*L?nJ>d)aEt?nlhifYA,-Jn"s%TuZTxq3^H5`݃wN?r?0N9mᣅRVzqմM3g=mlGa-t , UۚuK?0ѥz?r )$uNa6 F5OA.@%hs3rvWR;"HGJ~ WNQ/"4k6a{?rr(* 4( Nx_A`o}A-o (~?0,[A $P;ȇ??]u8~ynܥWҭA?0Iox7Ho!?0bB 'urNs9sl莹!GTd{t/U3$X0ᓼ.M PEJz>D+(,׭d-E545솩c&p8G!8>gxNu*5$1zeop_rh{L6#|KF MۺiR1v4>?0ݲ%_7/۫,"fiʽ~a5 s;gu Y>B4%^̼΂R{-jp;:YX˅V,Jja4X JS)s~>,qQ?nÑG#ܱf#ɮ/uHUX.G-ET(t1qde%(?0Eʞ>Fd1 gaf*Ip?r!E5{"CvVטCa.`Өު|_"Qxe|Na{?nNf*H6oxŮxLKfc d=w?r!әM/ 5OˤPև@d=2?nPtu8#Qk;-;^u0x.?rEĜIB*+`ӛ27qt%t[ Q" uxvna>E)/.c b?n pʣԄ0im`6 ȟ=NA:8ȐQ)jұy"^ƅ%p1i阂YPr!À &QbW??4T2??'HAfKڭ X\ػ5V[]MQgj8jZ&X% =Qi1+BL+YT*kX~o/\~vmJ %1mX ?rR)0.%}'zW??wtOo/'Jo1(,"G[׻ɿh#${?08??yReR+ju#^?00Bۇŧ ;M8ؘyq;^/`ŝ1ȕr0irVFٳ,f4[bPL^)o?riϮw?0B{#ԻRFpgLKC:v<6{?nW{gJ 9lm{ۧC?0b­@KZXYO+ԋ bTGo㜯^ gm+o=JEŷ|'>I?n2Ǹ=h55My9heo0џO.CuYkB2>fЩ#BgZiհ,eЍ*`hL{ua?n*)yP$MiQvAe &E_>z!Wy3t/[ǯB'.'@k˽/ە/e߼3(НO^<|`?0h??*ͭ}V0R/zap0zG[?0), ގ~|}yI> k]>?0΁X^ϴ'qtWo0JI+Ip;jr)C4!J?nZ^I:3ܗYU 5Z% d?nʔ:2%*͢-NL e֡x.eb޿⬖s6Fr湄~??;Wt{G7^Tu>xՎ{:IsIG%]1CI=Ah,-DFS"?nZo2/x#s,f'@I+2"DX@Ziy6Epn4$??gC g6K;w+QWK,sfjK|==Lka3ہWۍ/,~@u$?0')aIj[BSfX޳F8GVpT1qvgq@oqqƷka7u?nxLdBz;N=tcϔS d:׳e=$D%%<}m ԖKqS8g`1@l,q,eVrZkGB̕ kTD+L>>DmTF>Y#,{o6HRyLy`39ϒzx*cQ aI28E1hT~mX܍;6懨Ka_" ?rZWzTVmm0lo l> 9;4''Y60(Y{?0-QfFTA>{cu_A:4̩%>_",-%Ko?rDo!p-Lq!W&]9=D" ~:?n77z%6$Y%d= Q]p .py$}p" t` CAIzXiOఘCGaDy-dg%??DS C72[עdV~clJ9N,ZhQ}G"-2.tX;,ʄ&Fp7o>2OyRp3F@LL9څEri\?0 ~ 7x3kZƮ?nT^#/I馪A<7 NMX&Ɓ6*ݨAZ`NTB V0)o0'+DK.jL9]) *l|t"[8vmoL+Лv{蛤"B &Nr}$sS2=,*!UhNS( Aw'O{ٿ>wHx9?rbV7p'N?rLf+۞I"8ݭ|ISm4^yE1. 'ZIycaC:V x`cDo#i{m#J?n4!5RK;ke3P@qFchҷg^Ë!??ޙv.NܑI??++^a>.?rZ>Kq>>'KR@]G!s&C6ְw^լNEl')1w3(^kPPvN0-WKcT},Rde@i@{yLnӑwH9~-l25^pv/e7=wp l01r;jK S:&=Mv?0J߆Ak T?nP-p6_^=} HN`H*6h„'N 7_[{U[Yj29\BNU1;M}fu؏Ưx%p8n~ۿW??Kmn7;4!x#S?rX/)Lld 6GaĬu=v|[*fonosU>n Q4(9{=zF+q+~}_}fglDDqOD E;Y!&F&vo,??Z`A EWh=J/W#}VS>jIGume#oidm65.|Gٍ`FE~?0#t֍18#mjգ/{]F5@w,#8qzi`jtK)Ji;͆+IӠ$]1'BG#ei`WuiTE ucj,!"W%G*+u7K|4՝hZuLDs-\s;+)Ӻ"??MDX=źkr`Ԫf?n.ie:;k/.MsȝQQ5iYlC8>!vzS?rkջ,WK4}l[/GDi:MEw宝m`,Jb1}F6jb**\nF$uєjܕS..:\ %'H'*zQelRV:cVzAcW?n12‘y<{Nw[hR"`\__D0.c'??{Uaڱh6̮8k%k#kq^zY/i2Z ;6@gS /TOG8-z씍*qb"E_u'\>Ygzikj??PI#H4{r .QB"֔4 ?r߮ɼ‘ЀC/uW?nV4^ r J } NE :!t'??2B6!"{HP9|?0pu2*Fb9 +}Fj(BƂqu Vq} :xr3^DzVГnbBmJy[qǎ4Y{d­uZ9eqqe{l(!yG5QlTmg;8T.-%5Uu?r EDG.&f0Zm{?0$=#AB?rl+Q0;0ׁ#D:e?0#1+t;UjN{GL^J%2wA$UL#[!?n>l?0^Fw Q4^0̅#%$ c?r2߮#RbFQ |qb-O~P<)K=)i4pcϫ2mVXx?r%??.~_?0.D-{vrA<$I{0Akd>4+rXEo *!szeڜSJD1*oFF8Hk[ףK{gna.VA8nJG{3eⱌC> ႇ/f2棕#΂x*+Rzл?n"{??p[D]?0-|Tqt??~R}`W 8E.rs&a*eӥpB)2\q4lyMg| E0!܌8v$N~8{iq|,*첲A4QBfCiZ/_4~|俞>nЈ$x Wev-UͿH;I6/BߎR{5ϟnIyn??*??=yKP߿~߄,,3Eŀ y>ɢ#4/nrMb0QVxZ6|~o:m(aù 9Ilj| =㔔qY9_ʗ52^JF div> -#30bϢ>i*_ |+??akz_pNJg+J2|町HN/YH~1]"yl5^&mU>0n|i͚ dN-UVXC9:X]ŵ|^׌e%O돋\,C0ҮB%bd0#MJT HlkޒrG ө['91M[zH( nˡ_ol 7 'D.ՔG^<ĉΣ<<ӵ8f/XAe\,{_qP?r:xȺ''`o>$><~wO^Awyjv}!7]37Km 5(֑q%A 8#u3z;6ON|p1?0X30!YG~}Igߝxp.V KއOj{cM7d%lЄp!>Gv/Oٝ/_Q>g/eާZ濟3&m-o_6r9{z83ֳO ??,&~僧1D,z8QdZ٘`̀{~e{(g|hy?0 {m>cQX?0JǏ>ZLW7O.y{Mܔx%)2IGϫfQJؠxRMɠsaT鱆AW&d ^GBrMEP[i:G$7sdW7/c~u WY">e]%^DQ5?r68>lIE~?rv??.xڑi)CwX$W\?rxDohvH'I}NBh6]<vjOZXn= &#:Z^k)8);Z" cԉq^hCGZՕ:$iJo*!??j)ͽ}qViVj^pz|tV)>;ˀrG-t`3ӑ̜s9੆~ *&$ |K-7myqDظ}??n)4H]m/. uDtє|k;{S>>}t>HrWVe?rAF*w2b蒥2HU^Yw%B>9Jcy+{ʧ4] C[yt 3N͇iMf6A}Wd A|g9˾Vapu9ב)a Xf7.b+|A_wbډV^{<.x)8d$LÅ{n0 Y`zJ073 uY n*74/ `ga !!6`\]9`& wi>??>zZ8A hE$i Jjv}VZ%S6Qb*I(tD=:ZL<윘J@ZuGҏ-@%NJ9x`MV}Tt,t}/^,sb$6>὏0Qn!*&pa҃#!5&RmpNMd0>( nB}J}[,nGCD+?0̅.אpC5UN53%eϝ<kx-YDɣL2!]bPL96כL%'fAf.$t Ej.KmБY6ߞ}ww.M/ED* $V?0VK݊u,ww?r` P:co#lN+:r.["#UJRV??(U4,zzWfq(Y.,BfQi#oZV1fmF+ 5-AQQ{gXj⭾4|^8x?nQK: ;'Y+˶d}~8-{~@}ukt:@z?rAj}EgU se Cco%,| z6zX{WX@WˈTTqXWSTs)mj{?nS5/nU5eL]fmkL*F-*Bke' ?0 YxeuT E"4H_+,3bXJP?0Dm!ՈĽ[d&~-ȯtQ#zC>;ҬVД/FA nx9;<۪|~ ݤ1MJ]~}J@\NPRvB/CEᥚz>6"?0nA?nܪŤF"K)?r.,%eT?0&t8xΣj|1^5>6'J)@\<[Q#n:J 5~h,RvPgݭgT ?0F/?n,ꘈAxfh^oɍc>åHbDd{vhjY>ID~UWWSY5bPHu-lo KMWey??B&d3?nj~n"EpkGuFix\5]4ѳUNKRL*,S9)2A;.ξcDE3 c!e"Ԋ)]戁=fMV~dK!e-oX,`\a>|_=~}*ȯRQ?rˬC[řKzЎz0]:5¤N{L [窵1Ұw*Y^iyscIFgI_ nDUM᝖!L+jeu ]x%^`W(ZN]|K}??dzjGos>fKb`cgG!i1Wxrk.qM@Dn,fpys ~y,7,>]59{„.<670:ܴt_0XTseo9FuIB??]Yw?rioʙФ"|??"yRh1u⊂+F??F.rڱrLO>8?rh=#AjYv;A1It/vHy`.-nWf -%XzFO#kYƏgc0Kj0п~!M̋tE_*_Nj~QSߟ:y_<_^CZb7~Qc|SX;EM:⋛޼?nke~A*}$NΛ_5L-痞&@tepE烛㛉i|xr^:&bwjeKD1CxVU??8~t2eb7w(?nPJb퓣5cv-1h8ۇ*#ExZ)֟N̠o p{%+>z={Q`AlZۜnuGxv2|?nmW.~fޭnz{D YzB9blY[ƿ7Bb,UMڏR&4jqFձ`cԄU$6xT\$!?r3봬gk=`^d4#|!e{%N2J) YL})}ifyW%O_$$FM-lH׈\obהW*A?rέ<vx"JtA9)53gxcW]9n߳T0޲a|ETVpY!1?r{mx7(G}w++d(b\.ŘR̮҈S?n24Ͻ?rUɾ.2rfAk.*rTE/1^؈U5#*¥3\vZh]%C%PH.ԑ tO$fa??OXIS [Kr`,ʕ 8/j?nr9S"/b?0\ZѺA9?rht׺[ T?r#*J).,3O騽܃<竑-XCb`RQ6<]T `8>,gkB))?n9ד6h6R+Krv/-`whIM [;!*k;Q!;ys"ڽ͆>j&jk?0(HT?rmjan/huyTߵ0Uq*#?r}7HSj vXZd{蓫QCw&(rEj @ owP e^eR?rFUw%CD9a{QITL,S ?0uj/w0 %n?0(p_`>6S\d1dPzNT,ꢻ|sd%[ڴڤ:^DD!T`33ņ7͠moGI~]|KCB1RB??o%DǴuIC_'sT - sECl5M4RmvÎAu[ +{%9!9XZw'KHQl'g&cUhS`%*B͎ϝD+|2))f%glAwۘܗoXiy K%-??@nWEUj7P?r&CWjc6@2iTi͈2kaTe=R6qIˈy5mLn%=pmmB,Po@iy?n&]A5NAʲ9iDtBĪ^ H`1R4zϒ:5u1*FIذ bڸo ?r}c(Wƅ=p@=5rcrU{B?r??If"@3AεxgNKJaIMJ΀sI(Yyq2%6(iVm ,RL}"?? LԆuVZ*Pm+?0( .6d* `Xȶ\ joqFpM•;8Υ :Zh;GRȶ)Hr%F1ɣ#ϽzHF,F\]JLcIφݟǔ$??>BF:/ajRǐW pIK/4HCc׾3*NPvcCUU> =e -#?0??!/RP?r玓wIAtVTIF]PX*:_EZխ3@qqNMˠ҅iI] CJ}icު\"UlEuz~"!ǀRhӘpґOIEMmuO9+2"$/_??[̘[[n~B~.$PN/(N%h.k]κ#w]7Q :MtUGZHvi,k/wXW M4SfTࢅfԘ%wDwt濺КƁkUwA߹VV+J"kJXWFE?0'PM˜^K8μo)(frI??ٙ@LX͑.Ɯe@0Td4p!=Hv6 =csQ{S 0a6T_+??5?nj߉w^ @p̎S֗<>vj Q3۞W@v?nb>M>Z-O7_ǚbSP2KE1^nݠ(=Uĺp`t0%c̄;xLSϗ֙EY^K*a"ݶD\Vim+8r^^T%ػa%! cx?rsT`򂐬*OB€G1mx~SF3RHD "< u*۽pΤ:C>>m% VRs2,Rus\;NgN^SӘ.ڇS۲`L1?nF*??|`DpB36Glmn6t쩠[y"qH)Z-n[҈㥄eJMd4pո!>oz{ʏ*$r l8[R9Z|4^g1&dAlCHX_ؗ/RrpnD/xOn2Ff"V=F?ny7EV1m0E.ḨW3֫!p|N9!E n1]L~l9c%" 14YCc4^c?0VCɽYΤs$?r?0s)7?0KqYzĒkp@qc>v7ڳv8J|ln: Jɖck"Nj12S}N@r&;Hp|@@Oh?rJ9[O*ƹjxKz0LS\V0,5^zugXUSU)y^֡CVݡQ֩[jl5/ 4(0xfN=zKWVZu%]Ԕ*h 2icطi1YzIj[_;F>EQ>qdH]][/d#( !`_H=2'(Q=hZ+4e24[ CJS'n} YEoDk.ykuMKbJY}]O}sT>ʋxByq$M*x4!q' &Bs=w[Yd=/8\CC@7VhiO΃ݽ g]eԜ/??^[:1θfpLMb$!t9C6F;rU'k*w=4̹!M, >_#ؤ25^!ZB?r!-&lNҢ./= "{!c`R?0:iE)tۦl{?rf@_P/WbUY^1MwaUVVah\j1hFrRyy`HP*5܀yKUd[0qgmn bouȭJlO2N~Q+8_¾}gqܙҰ# nq$vq5 #A=d_@w&Pʰzj^OV{RpZ"mS_0bGâR'Ԭm.iMԧ}*O5 _gs;B??{fX6FwkG%y%M"+?0!Gwg3'OU??ۯhT2_Y Vپ}7ƸUC??i3!Bdy4Fd0gxq63ZxVc9s 继`)_'~25tx!{?0Q2HIgEQP@f㨵T-`C8(m|y~"$`Β-?nݭPWǮc4·j<'mAH,hbjF˽Ԙq0CQP7Xk:t.V$Hz غzM^Uq p nuaP2ogՁǺְ<4o+.)T?09op+Lilcx  g0Qx!8\6NU :@_,722$}IL#?n25pO@8?0`??c}:[,g`wyG,/tHpFo}Efyqk84q*jmum,s=*97UAwxC@;tE*J=kWtj?n r:d-ƆrxBWD-$-ƚPfHalg9;??I/+۳M#R[:y1nMrHCcf-X6P?0f1^*;V ހ"0; nD(7]?rFp wDɍxWC:“p2$@_Ps[,jl29S??6dy'Р̗Ć%LqP%}+cHڪcykjSEG濤/H?0ʉ2ƣPU#ϔpI{#^O4AwlNQ7i1N BV&u%~?r`8.f!p]W^& xwtT}g@πhIӀ"i%]#TK5b_G 0^87ˇ??0,]3Y>If*܄q$A>o"{O%}Ta*\??,~*;<tɫpy,CD,;(> .~cWcz6)5uP 06XA?n%//^{-)_9[_aabL.^nX#Rr* R=U{2&F:gqszML劸!fp,n?0 ={:/CKM}TƵfFN1N<у7hI$}pHotHQ:^'U7J$;MF}.A߰K 7._T;9m*&Y<Ǖd<8KlX͠ Ŷ[Ƕt$I}8unz`GݽJ46!ªw`2OM]6a -#ߦ3͸?0[9->Q݃??}AT>j*g2.IT |4$4vN׵dPRoӍkQ&3}KIE]3{KVZ!Pmlzlx R1ZPROim<3OZ1 n,  9h%Ҭp'?ry[-\^r"ѐ?0jseC[!aQ S6=Gh8~$HwD3BYm@`d^BVDقJH]Az^`kQ_Iw~9<ޯ5۵nүaSZ6-)VoWӬé}I3~r6^8 v5VNQP_BB)6u٬na45ĭ&"ԇR0H~E54 %ћ1.(|@224j @زmo@>}O3BP94#TbeaKzIMLATK?r!վsKy$vT8VEz' O jcЄ;mBgg$MR3]g Hj:e{O ~??yK#;}z* ?nh|ڟã??Fߐj@kƅY=k(Q$+>dԘ=ħӍnLIAZ7&d,M=@\|bR^|7]"no 7U~cҤG*0Ay3uqڰ|A>wљ:lN[c߂whvufʑWn&^xd֦1xҍ4gWݩ#O)}{#̫Gfȿ\bȭ0 p_z"=JaIeczrLb nr[2z9QD](oW?0ͤ"0*f䡎<{MƇ{b@jn6/HTɻ[%kRCbogO#c>1_au&\2ѣÃZĘS} I+.8#ZСj[r{OQI$e ӓ6Fu0p)Bn~cAՈCC+ZG6jմ?0TL93)aYlF7w]?n5(JA%;ok8L'3@M=ezBV̕;=6޶S?rj]~ cAs> &?ne޴GL)}c{5Rr\]5 ^5@-սDz/ۊ{hN#clAid^il 3'{CyGk?rAY詮KEeʟ[r-SD{ݣ8۟KS*掮Y4MHJ?n?nMXIHHI 9zH8?0py18m??H)8F"и;y:}-)2M+y7`C\a"q+r.NT%uXBĥ?n\&?n&-7=R@ 1+wpK_#rob=.^W9OZtP$Mihr[oY1+"vOjz]?nf%[@'Q@UWR*j΀?0oEb[uq-&:/rJc[@Pcuz"_I)g>e$׫ ʋOu/!+r% ]+t>NUʝJ OʎP8W-",[gd+f??dt4K`&)+:A>%#'9tq3^end8j??5lXpyӀiYrHڻw$sM SJ*b _*j1N#eega f`5`+Φh]8%"̙Io<]X#-rd|r⹝m,?nq\!3ۜP?rwc0Юh a擖q#R)[/`k<Ë2~ժbn1yv??ʫ3'}4'dﻢ'?r myxbr[H *!*­{\o/\3L?n`??T_\_%ʸφ|hGe`?rT(7mNTT3Sz5䌿P/٬ Byyg}GdhGT]^0җ@9'a{+Xցi?0P;*[ČQh*LH6QLΜRMERUӅ ˆ?r(-: /PSClӟ]$cL(prA_$מ~h|Hĉz-{XO=:jETz`ִ -{{O&72o z [B8gPsh#'OlPE}Ĩ P(Kt:vMǶZ?r-)@h6 /*U|9{ݛ-}EwGqvXfIjVZh Ogsf:r(MUQkOK?n.1 BnqL^yތ!p.Us{4PDQw9`?0~AOrI1!AQnĺ?0@=Sb0{CApz-.LD0?r7᭡ɈT:_U w&eļkǙ}тSpz.hFGK7)#& rq]w8\g]6z飑Je$ǖ_Bt;P9 QFLYE0eѩá_{K>%WC?r(6xS,꽹5(_wQR-wQ\TsQČEkյC&NQo.=o -#vftrZ/DRR|j5 Dlyq ^?n%ԓ7MƎ; $%at8imE^U}]x ?n#|MHo~}ww'%6H(''$TȎ-`Y8܂ ?rْz?0uˉ5V1J7|m/09O?nm C+@c*?n׃_HIA״+r^v*6-Q/Tm?nwtK*hQݼO@~zIv@mV˭?0 2&Qs?0Rb'M֦fʊ4řZo8zᔸH?0ؿ#*ќwh l`5.:J\1bg%ԬU!'0'I{g>kzbZ߯9?0&qXJ*"}lw(TC=%G( ?0X^rCpwo  ׽"-J\g4𾢏KKGZ.uɝlF`U3k?n\`KGbc]쁏7w[H?0vR!??Tr V^7`L\(Ni6?0JCt5b0Gkk„.-Yz:t_sg%>#$}L[krO??lS3МGJ %$ue.=#P]c\#G[TwT# r*]I7L1T'8SԷۢ>MT'RQ!9{ȁ'G[3G{&PBSB??pvWű/1,tcXm?0tmbW GR՛GTجu]fz .y1*6 [Q6tݡeG#' >5?0V.:9SؠZGD}H!{ޛ% 4)R4?0n h*8ƑA?nhE?nOߪ?0T$A?0F"w]+'ޥ{????h:@>=Bx^ naD˅Zi9?0e\:F&|SÐ48<6JY )LMd?n?nBȶ"?0+*n^rz_8e=\y?rzL, P?r=ckiӭ=dn^?0ADGg[ӽU;(>*OU~!8 5^,qb`cÀ ??Av/Q5%NGZ?0iV,O\]'`wo^ǾTB8Bxk?0OcAK'%ڒ7mNchpX8;(T]J$Eu5cj?nkLtO"Lǻ+39E%vUaCckw??4>HI$!3UWjVP6?r})l6eL-^&B8/+#!޾iJ:VdUqJ1kq{Uݟ}wa8൝,-t?rA$NeC٣R:׍_^KTY%C1moq[ū\p$ɽ{lZ?n/{'/䝪೭.ϳԈFF\bh+8߽WѺ"g 0oB|Ea#!IswŤw]nuj7%19CSR89S1mS[:vHær?0>dHKбΆh1OL]"QP]}`I9il<Q@\GvE2!8Cht>m;w< ?rbǨT&>+J0pP,ֺzeZ ]RLjpPvap:v7X=;*x1,U~@.Gna+?? W????yB /3"~\b~=x4`??@K2؂u?0pݛeC*Mܟ?nWLu]q߁A;\#"jz/?r(աMrkL;q??RKjb,:ρ2;R6K/-{1>2`.1"1vmuSF @3&tޒKʹ(ƍ$yaMn?0-ƻGJm2/tE͝RG;gK|ᛐ.Ws:ϒ-ދf#`!h9QLvFC0j,@oIEq^B&FPcS/Tް\i:\|Щ_?rHעeFRB*=63u?0FOwG^ZD߲,vi p:EbFD?rאg%qm)"("3⼴p`.55X52hؘ~Oj/5rNߍ@Ž?nAbO<oyc7xߛ(}X7z a!5qHaC^(vA^W&:2Jw=˴q[,b[MMy/5??HkKT9PՂoxv4"miS=7 4S?r:2.ќ!:#Eq_)q鬒0p!C"tN@'pl~pzoAzF`EIQnoۮ<5?rSR|}|QdHob<'Xf#?0XX7fTp?r yWmNwz>e2BTR;t=T|BI1_ n13-F|J D_#xꕳI0{M *3+lSlOB'`|@$Jz#yASq8AvAoDLBSvdT| -#ɨU 4İ;DۺnG}Xϳ2'_ЌXQ;3LF *7oDKy&?0MF/Yfqh4NVY=qoqԚi&U&Y$V>rg`a*S]!cjyPI;`wD(?rΝ[̾ڳqB2u ??FE5 Oy4I@n1؝cjyۤ9?r=XO^/f&J;o[i5┚j?rU3_ZL$=*tgn.H?0:jaAZ9J+C00B6^ -ѕ42'%}Z5>~wP*oDxfnԒ`k,ESS_N{mݨ`פ団2XFͲ\)9yޯ/rTN7N6q%ؽnP??S??eu,>ũv( +/!ٗz7X4?nv7rg5#DO71VVٟY?rU@=T.LT~|M~1RkJȧzbV9??'s 1`%y"SF$N7R0lrZyQ:M`{C go.[]X>l191rI?0A!'`l yoD;K??7??lN+2~E\:vIo??+f$jIh=-+>6Mt1iIrK=Kл8(e?n*05٤U|ҁexjjgly^>+kp†IZhЇij>MhW#S??D0~x*Yobult|_m{o~rrtN8V 8aJi=x6P}لRRfTp<|&NIcc;6[q PFAx~wBђ^/kp B?rJ{ZޭCbH5wֲޔ|kO`dTFUጚ??f;n)R,|:&KN7NTO8 {SY< lqkC?0dh:5Zw[Sn4zo7= 6$65̀5z\ i 5ґ VmP|y!_eOY1Z?0K?ruK^b8?rq]I۫IO.'q-ViyBJ~9@cIif/K_Y, .4ܓcQ,sQ|NVSҲӰוWi7m5pgeDM9jpj@Wgw?nk)?0tR{u)_.鸏KdS0`NcCneP ]0g\F;ΊN -#DˮU?n$\c[%z\rƬg}DI&[s6b)VizX[}5-b#w6.V偅X|y94yƠeb4"t6Uvⶸq !IaTIj۷oƶ*͖g+1&#J|@J7#O1.s9\?rf\Dlz+dFCQMe[ȱ4J?0r)پ-y^MO)0u[G97!/<-&7 xC |M/j??JsgToD3;$ІjhhыcDHQO䂈la7\n^]DlAq"5^Ͱ+xN-7瑃\?0RH߉ǹ?0Zҭ?r9hV#w5gN{>뷲vd-2 cvh51v:W!wr5 ъ1rC!p~AYLCdAoiZ_?r[[ekG~SQ}5ϧxG'[R냪:r;2upy:^!26ogkḡvÙg ӢI-,XrGȓ^y]nP·iP@+fN;O`}1B"zڲ9=ukv!;ψK+FfԂ'8[GeZE3m6nTs@?r-ضf5ae:+2uE_Rqb=b3Hv;͟R4=.:rCU5O;c־MV\R__=fN:#rTjf̵o˴iNMp>m$??Ouxa(6-^*:%|7QGf;Ha IsGY/"~|7x O F*C??EܫW6"=8ْH6Di84b(K'&oA۫G杦J?rƁ۠ K!WC8E:jm~sWg0(;(,e23~DWmSO.Oә};oS:]w垙n ګ[eތ. ]ZDSavM,H^FOv!x+HwGQhf]_ݢZVtM649xisZ)QN8lPOYIM?ni\=V :)Ld|ԁ^Ċ2nƖxCD?0<[RFmU[O{G;eg`<=6_O;?0[QLhPbBg?0?r?n͆Sjya;uqg{}s*eCl>+ Ru??~Z0db(G\c+V^{S(Y.qk|텫X m7 4rU_[YdZ-^[U؛!֦5^G<LH-Yte7k!.9(V7 {hvyN:0'$Ű w?nA64e^_Qk\:7bH߈y aM6*:lPQ!jSLM-Ѻ_SHMQS{RD>Vj2kkI&{A#s8m 6v:Ʃ߾yi`G$m bl:^P56_oj-o[xۯݕnsT6(=7V2 8F$mj4NVHW֚gn:隗q?0R^Mw 3@˶If:M]{?0v_??;۶}0QH{AS:3\YtSq:EbKIiޢ6 ث:s=J(M$Gm: ?n":ؔJAKE&?n P_y1D2߽ᤎTz?nNFj4#+m׵GN,%?r}p5:7?0dѤ+?05?0tx6h*YX9}#:ZjF;2 1G40Wyo$/ C!\­*GTq7XaBܸ[X[a.4ƺ&L)#4X1ڄɭ~|] uyIYlUJ@tOTA-?0^B#11a]*OTпXEBrk长?rB@u#??)y-2\`CA&н70ı&?n\dQ1/E2.R׫QƳyb;h{YuO?r0t_^J&ilJgi0|0&Oqtͯ18_#OcT\@6선[U%*Lg&} SWiJE;Qm_Qsj;iOm3T˨(OoMk[XDyO+/  \ss\‘-j;;Wn Ԋ'7] !b(In»?r0 ۑjZ d06B6?r8Hk@^:V b~{Dfa(0c;0rFg>,2HLӊ*Q3w# t؍BNk>*Y{bC޿=;NT\~ݦKs -#JiN5(n.`hkBҙ,-cAq݆2C1Ӵ7ѣGs ?nI?n`olJ̙484jCT7pd'%d‡sBļe_eOZy&WY+sĞcu5YS+⹑?0KԱgUE%Y!! h_R_b,s`REǽlTNQMFXfҁj[ND$gehkXb\-6L a4|EM]|5-:t63FH4gF`%WNd@ Xݕ+j7x6M|"ǟ~_?rbroꑬ7XH-Gy,NK>tu8Wip3ŞH"a )Mci@?r D(ZߠNN֋QT$KfrBtójO08Q^ljSQzz ]c]ul?0%Ƚ?nҝw<:/?0Bpdk WV0#jT{kgQuow^tdxq/nb(\Q#qU}}P^QgDZaq$z^&2цP~<'yc4lcVn6lg&mz zdztn?0=ZFBmAvS>mR0Am6uǯ*& V֏hz=FuO?0t c;.Kw9]IҺcM{Hj][KVa1u?0*_K#~vHjpUp:X~hwo%[i;%NZa`7}ӦZ>?0OrvPS<5#j?rHx?r:0c{%nkW4aJ6EQD!SSKOw' 1 1$ԀZJ}X(?rAE՚lRwfn3r@X 3][aτZ"~<|tjbqMqO2w̬P?n@;Mu_C^D Bܙ$V:8K1hëPz; hr򨆧uӮz\OnKÕ-Bw΄'v;&b%&g}u|nl[ntXxB+D6TW슂YitA) (> aDX-g.7|Cd:$T69ә1XUX&*}; G]{H;Ϧ =O$Hyܙ??A}ޠINAMK 㛐>??ݖgx 4vt˹6;M3[??%;8v&ȊT2eb2-eRoz\:+oe>ֳe\O)=\J%!E®6?nq#gU7HE7"?nhP?0[-x]qwn6:*lLL9??|ϺDuj*ug&rʶŪ?nCwgh!|4HfW.I4XЈn+,_jvKώg .PO)q?n\4Ԇ'˿[1A|B|@[o͠%͠1+R+iYhbC?riYP쩋X rmDž1[6 02[鲒?n'u3??>Hgk{uDgaiwq-(E=O3;3<:8w}Q m(㪅CE?06??OB9PrROd4*$c;VwKQVo^g;/b"DlV9yË$wF Sij2N[(cF{|Y hp%&F^`qⓃԗRQ*?rzf{fb/*<5]qO2?rKiiy5=k{5_'Rq k?nd:^dj?0հ iUqXjp ?r2Rp;c&ٺohNe]IWyِm"ټhJڈDRL}5=8?? 5$U]QFs0!?nHBo)1^/U'8|PqPtMI8z]4!*#rf&>5?0?0۞F78z?n4X(VKݴ(jYn׶> UE`*?0U%pyۻ}y&?0 hv̌??WL~*) WAԸF(0h+MھZ7 iWӍk~$K%bsbH:k%/!=S\+׫Z< uT^32)(4+0*p0 cBwM!e驇oU l!RaͶ(DՂ RN!۲I.B|yOaQ$}Q۶pE?rvK4m'VwT1 ]99ǵ 3EU=W0"jZ-p) %??:cdK᠁63Mv&ߍW\XërG}/Q9ăfE)r"t(;.աSݚ{W_Gy=RtG3,\PR,֫22-=1XEos)I9*=%Ɵ6jtA~ ݻ:?0R?r-5*g68%l35yzČtN -#^Clk{??[- X?rrʶaGFuy 3ӣZOвn_"̯'e<\;o,,{6,mAcVܶS55{R4ĺ2?ruK)?n>F{&_)w6#חRXQ\ A50"yݓTTw+>=Qw.+w┉XxŀŽ1v,KYct p?03ٓS%urtnL~GAG#^Fbjhr["bS:KQ>U)\/(xicXü9u8zRc-]"ů0v67V\T.KfmM2 R|FHnqi*!^oº[VJfql. ?n[f&S[L_HS!uŻ7ZgQg(ЊKCs(B–ȎVtAwHܥݿfVA=<1y*z$JVz./#$OTC_??hg"U|`X] WR[(b[z+G,:ieU1ܪM37J\%Μܖ*p?0 m[:)~P[eZDA&_iou8 ?n'D=W.;D͚MnQ)+LcK>;z~k{=Z}=_L حhyuiVlBSW6H%:m ̐2:"tGBi'Tu{'##ej1\rԅNga>t0Ӫ p͏a0xgU"3U#xi()Ǐ5Fd5 n%DJ*JUwçŎA `5:#4ue\ӆRd|UDAXJpB$IP?r.Nn,.ZTVRҶ?nxpGmSeHj*P̲|RQ,lS3= Fۑ `DNp Nim@o?nfr5$$Z#*xz 2G|ObQدszZ;Ih[%JG7ABUv᪚O;c#bF>L2n:ei \UՌpM"hO5]?0RcUq2d#~81uP\_~w@J ?0bGa.?0M7P f0Ȉ,%ҩH֍V3ڞűb>rz]` ?0472???0J%VͽtE! k ?0BRczbeZA2 Э_М0|=yA7phP\Kק- Me+nG HQ\?nHwQbu;UŽ$N7_Ji@}/n0r4]FyLa6ذN`B(qc{Bc|cs!}  MCپ!i:Cq@@8ZeX]/ npttL[6Ml qa &*;n]'4xY?r#@r߬"L,LF-!65lɺÏ R~Z!߼B9.hpC]ڻv7J㒭E2ܢӣ/t<^?0Dt6NB=tF_kمB%Ns(9Vu0GKHGZ(AYh!p3;w䮊tGnpvY\` h.?05G??֌yy`$1z +XizWFKs(fT)a]كd h|CX( YWn"/MxH=ˊ7OR-%o)]:l r~ZAf+Ob*I.g|?0c̓1]N*)'^^?nobӻ?0OBt LjFe1eb]4Ɩ@>Uì‚кO?rTP C:YB?nJsµ2 =w/%z3U}ސO=+w78m.dsI+":hRB]Šq |%D;$8Wu#u=wI{`bg+/?n0]͠i?rT<Î&l?r5RM˛Qb"r/BK7THYp9Zų;]S?n>mFl}??R{x߮}qݶrtj(T{k2y9Oio+/*n{TgӞq1i4*z7pVV}uŐ^Ga:Cѐͣ￟>??~EKһh-ϪTC|D{SFotbi٨5~_35|΃űc;Ry-m|_ǿ??ǿ/@b??T/zB {įP έ ܢ8IY8rૅ<$T??8>8Li (IդIH-SN9= {??yc??\ᇷ[TYUfY0\v 4 O??$xH?rB<CP'U47s^ڶf٬O^@ ɛ0 O)חkxbbb2:9ГBXr-;gA?nKETg-H>e?rB.-SZ9??ѺXj8lZ@O=|cMYjۦ'gm Uȼ_e ܵW[ZK3 ΌW?rp?0?n>grv/-ϖs?nq4t?rp|4/h%yrC^}yU\72y$xHrJDž//ӅQ>KVT:H?nɍHAU0vj??H!9Kƽ(p=/C+0lUr}*,X}[utK[ZEvc4U0?r8{)ݾáލXs<i`?0x}hFJymxN?rM/ @@nEQ+Ai hpNSܶwEy!=wt="q} '5Ny[F Ӡq+]??_!i . [hY?r%Kp#8dةmeLH.6(Ta(2NВnWn]>XKRɜMJTp| P>AmۻTPyR~']U`㓘&!=/r|,I[9nbqj" MZ<.sBS hO&+ynڐ LIgM)82opHS1F8.q:װ{nsL+i2YcbWB@@ڜcÒ;QI0<Փ6+L5?nX8j(1/߽Y(xJ)Wm|1=䉪'A8BJv)laڀ,-FiҤ [/fm"Čݡ~BЬWWԒ3/a]%i9I+$A-15u+?nƙ'z'n???0k?nIm)O>.E?r\˱۸ry'Zjg#W)gWvEݗ] fU|N(LZuҮ;WZwƆ#6еƵr?0?0v?0Wxm'[wP\]ۉ(HJCMˬÁ毩Gu!fCWµ?0Xc7+ӜhS*i*`=ZHiFBQK]F2(/IsqW;vbS"U=Cޱ;6^;4")rOLؓT=S.־̔.Ѩl!Fa$P$Y>vlTʤj\שa\BCJm;o~EP"Ғmv??QHOp($|RNX%4ɋAx_{[sp9 gFݪvpkBcC(4")ijE9?nh/Uߪ\ &u_?0 6rFye>'l.?ryNIő$7sZXUTL8hX>='3黯7=V/=(?0Xzhu4h_W;??QGvz^޼ox?n^񦖗VeFC-y@G%!s),Zi7?n{,{K\?r?n:xĈ umEw1QjebNڡ?n8UJ+4x]:DF9 *`NuHC!7 7Xo{2V׫FC4fV͛wɲFY[fckT~rMrK?r(QfQ` ǂ?nγdEb߅ZI_#)WTE=pAl9XY*yU>.XFl Uot\ѩ:C7b˅1??&6=qJ/FbTQ*3U#{gu'}ՑWeWg 5ģH|fbb:-}ܰ}~Ah:W2O]S8RYmOui^ph%Oxatf'14wǬ[%&#㖪[B~ ўexF 5(s"ܶ/gثʮ2 ?rh:st.6ý{>tseMw9ySRf "=i3>ۦ†O"O x@>Ù6[yDGyX5)oKɠL鵝$ (mQ@y\poэnZ#U0BwRL&ڍ4o6(Y&,7?r/8"O?r}}#J?n9PmqBD9Jd;3yD8z$꒴'xKǁXh{J6ٜlUU:XQ>mc؇̉ cY>XvC$_OzGѐ/ f?r#7eIk5Y6T(R;_2??mX(DGqSfׂ0F@[o{wh Oc2^7K^AtA⢢Q-g7nB3!h""jEk -lȇ/ŮkӰ[Vc(ݖ*V9GBʪEB˰K??/eu<!&'SϕsN;}Xhdny]y?nem^=DIyظiw4D0n ͶfR)`\,TV@Gqj(ƶZ"u0մ&cCAmaXg%.P: [녷mjM?rek?n``r!5FTs|a1LD0Hll#6^խx;ݬ奢wnq~+HT.YEMiҏ#?0Zq됡:b؍1)5c-u k^à5?0*H@@L~d (alo_rT jpֵ @sL)EbHy܏5AǶEDU} sj`z Qcm[Uk?nxjJ-9iGU 5\5W+af;ӃSKU\Ʋ?rx-ṿTNi#N'??5_'V_B;K;OR YnI;M%)h+rݒ#_¨^ bk~?0biruJ=ZڨElvB@?n0-89;092OQ)<]B??ia3\hGLCG:W)%LӤӝ5tԠńj#H 9??Tf;P!=a9^* -#3s1">?0@IqԩI,g9&@Ah;?0C-w]?0k?0MՒ)UogzylWXjwk L* 6&u_uhfUԝmv5`WFAw`^?0,3AI郎D~B?n=Gk ߶$_gi??J6ZӮrw:t|Ek\da|DP/o^q t7;(f)a?rh4HS0lG<|QQ%{EPF?num:+G -bXt`ף.,zDhLfpr4hC["5jo&xk;PsS y0vOuO NF懓 J_"oϊ_m{RvKm?rxh8N;edD.&[be-Mo`,"A]Ż^Pzl8Dr8BʻuP颊JgWfTha 1d뫖WQ^J??G??{cVVTobVzzՈI.{EJя/ F{A#݄6Ԛ_lYuZnn%G. ݁w\Oۗ B*Lo%E]rK@5DuJG,/hYfTVc!6$6t@Qцr(ɀL@΃vXR)`7䪬YX3z?n=k--i??Nrb&nI*. AR,7CRt^`~7x`@u.X^gXTihȵB:c JAmwR6[qd.͎F=k d>lWMwڋ(/aLk[wu%bTO#nt*uwn;TFagh-E3ZsA0MQEl~N9ԮddJM}aPZdu~:xK&w^+rH/OߺGy}ÄSϧtg:5MᴵL r9m)??.7)S$a%3"lST,CʭY0o9ӧAn\$dힷq WBԧEګPOA"1I(XZV+?rK0KA,G}A\R›|fQ1Te)4X5?n&D5-- pp)v׸Ʋ[C$K 2??̱^ 9K)0,hRm@yGshgվ^PSp?ngC# ??>: E?0pBCJe{-̉7_vjv'λ+ViZ7}[Lz\TfmTjp[$(}t/8}4,mHq\:3ϔ"β01};P*BcV 8{܉N Դ4UNo؃|}rk2v.ǧ ?ncxj_2q??})mxxɩA>9{1b.(?nJP@;O&ʼnѝ\P_͛$Ų$?r+•1tdz^;(\;%wiP5?nL9E^^F6ڀ|SC{ON_7YA%wuh>t_l/D6fQ]셖mn,nM?rw çP?rzl?0Hmtkk(~bot6lۤ.!7[-kWz)H өvw:8X\o!pV9,(_F5v {f5r?04TLC;㤯HSՃt+{ gy9tp{]T[ԣ0]ș_nqcLQ}+-c׋?n4$)W_&PF[+7|sѢ09x?nWxYb oqaXiYN@lҟkgOt^A|. fx$`ùx`3oc]/@/z`b+܍g؃yF4t7oOS#g?ny@3Wá\e7SyهѬv7'uE6=6f\rz5 h}[]nXkfL{D7GKi\Ԑ}״ks=5>5** -#cY_-ZXnmnnA u )?nո{#L.-0/9GG9l"v 8]${j,{?r5>Hhn7+b3|-h(,rJ* j%\0д0eQZ ?n{_UQ޶7??Ӆ'MM>4?nN+I٫lͧ-cerU>۝}@Y8_]{u5 uH ,_gS`xډd[>dvѽw;nwZo,X⨔u}ƈ'#s[_m;kc ëwPLm|1Z\mN։wgϻ*w9ũ=H~o4W_,|d)*[8P*{SidT,gB ڪd:`|>?n2OW.RQe^2X@q_$1Hf&_=l#PմaqB@b cEқix֏bѩ`INHСDR䇳̿\շpnغ_-0/Nu'2בSVUK{A&ݬ+%o3U\cO|]|_;VL7??(:>޳>MʗŘ|S9\Pp;T8"_<%)IW}78JX/ȴtwP5Xn3dMWI˭W1?nَ?rX~EWC?n=,72\su8P,fEgzp.SZskfC.(M,h=x]4ڻm^ǭWftә??[Dgb=>~y1sMP;XݿC-VK( E͇8ET0xQȼ Rڣɞw(goўKЀF!I1y<}S=#?n{?0+.CE3=)=wWwAt?n6AV`/-?nD]XXHehg0ejg{DTG+3VU*#lP4ss=i#|'&= a"~̓wSDT5b#;y4wɠMO_+??F)7S]Tש&^hFGY_QҞhaw0??+oǶlj); u(A%l,LI#92޲;ܨst$N5/_A?0a],?0ܑ,Gp8bk){^Qٗs ջfQ,Tݫw_ߟ޵^^y4!炚3n M.PKrtrj4 o|S??ecc`)5"v|~CchݓRa|=uMTNjS5s)~bnFx[;^0!{9ݲv\Dx<x4כdOh۩OKM??rG{<'|~4ώ }6??YGDYĢt-ek:>Z*M(~Jg=kG`?0%q_/INK'9˶i9Xd ъ}%O;˿N8} ̎VTַduLI?0՗?? .%L~2k:⨨z_=>??~E4ϠKp]٦TKpq2*39YR&38=I?rK>dB??}E B1O .??9)/bZhUtOtȭ[^>~3-ΒU-a;yOv v\q0'9{ɻU\T- ]mfѧ!q:M"1NG3TMcUa3US>n5sCuiVKG30Hz"?nn.Տf6\RWu㗨y?0ll2Rْ׫uTV,?r.-iFkǭ)|pβ3y8Or̈́L`>YLREfQ 0#'?rLdܯ'|\Ix@gxsjGޒ _f (׮*`u[ňROW(??Fk1}y2j`\!tGSǣJQԩ1,S0\\Z< jUq*6EaA۳[4eH&?0ƹb>{rr>xSL EEY躥H0VrX2yY?nB2:;_.mm,+1%?nD^UEu??Εzkv*Gi'E=b$^_#ϕ@;06=s3rL5<+żxE G뢴{ȉdbQ9c!V;Xgd 7Z\$^6rKvje/HumMeǯY3??FDjBΆk6<9|2)&^Yi&'ᆂ>??[o?nwV(/,:cH!:K<<'O_N[ׄIMgaF~$$2bfPM?r;6aO9~;f1qo[~⨳Wz]Fھy_lKǏR#XmIl%v{:SDݣ?0rZ/3??mHyWZOa}I:UwhA&!3٭G.V!aN<3::-w??nE[S#M`?n9t??~]Mev{6u>uUHJדшlAKkF{]%(ٍWe?r$&|b$yD=vj)~N/x2IJAt&h[o{??($aWW~fg*ƉIc7=~|㟟*8!qݰ˙|ab,%5~Uf{q |͓:c&,kcVj$,3?0bVgH)gCRK7HSW+^Uolj^>_px9et??.n~_=BA|9z??yA70yY??'NH2| -#୞ ;Eʮp0YMYL-2l[)d""5BSzI򩧠ON?0;[$P@'~i(7ӂKƧ)Jt tp̪9fA x)v`P?n2Z-+U{˶L7?r^=*2HDg{u\Wjp>Bsù}Vi_DD~O@_%%Ĺå[ lW߀x`xƽqI3,hs>H}G-.~c1^Yp:퇫$gUg37ם:Z|+NY2$:%Jc7]t]7elWM;iyA@"~rw^;??}󢖩/J"AA/^|,C࿺̏q>R_tCIRD§<,u;*p L}<>i#\Fp.#Y0rL䜵 >DJ-#Y^ AH@BT܁hunL!2+Fo֡8Pg̓p{˙RhBhq%5'񬇽pwDiL5۔,.9MC7lEVGF+O03.V[~i)˸&8G9Y.HjhwnnHuN []6%qfYWbg"t`n1g!VSn6 ?nCg`c&w9ϚױTkW!=cP2%?nR?n)a+)??I:EJSSbjnfuH1Q^Up^9ޒ1N13,.a*kڣnG(gsm0a6ފLÌ m&ek]Yg ӞᚍD1l]T\L??~|KWQ_ˬ57dJo{J˳G/k}??Z??=U??ߓ&_~tK&cߊΓDÌuPQ o4yBz??̝{͂x/\'򱫉},sEa݁y]9߸l8SԢ!`- >Z1wF6Z;Y~+opW@2 S79??-KvO eӤ+)Ep6쀘d["f5trw/Lu3q0mv.?0MZjP0^F?0kc@!iM:K_Ek2cL!ؒ_xd4{,\!n9ۭE1QmNY IKj'M??I4_??ɱ)^=~OqH*3VOZU9x>5?r;?rAQz09:W˰C2 @BנsPp;τhY%KWǃ#6\)lYeV*>?rp@j ㈺iKxZj7Oxs@3[yN1?rXҐ9X6ieSyQ9 }5Xieq›z6Q*yZqVrȆЁǞO]Jk9 nA`rL}uk4?n4aX^kEJ^4M*rOש5Ͼ=mbB?r.G,uƲ\e#[2I<vLJu&rwZ*uG + Bmahpl^PgGuY?r~shY-IKQb{Tu}Hc!ZoF+Df%.2tzpӬGς RG+zP+.X wփvh?rCy%Aŕu5OhY??jw}3Y$rsr~ 4A]X0TF"]C#<{P7FR2!je.0C?0Xx) o^86"nwj6ূ'eW>|@3Rmf?0>HX0HN(2]BP(?n2}À~RR7k d$ fǟ@E5?r7礛՘(XzSZ??un+"و>fJ^qe 6;@C!09RK2LYS(+-:yεKhNFwyG"Fgj,>~>bp2 Cr#?n??8gD("6q> ?nԄ$?00/ES_^9)cT*'d p;wGk$5ZYEV4JO+uhXFG$^='ϵ0Kp>߽_װfJ'[ vOvqhdBܛŒfSLZFz߽dCO~w:??>SRҎҨh+ή`\*t<-Q0 ҳeÇa:^ e09ORrxP)B'GO`6(f%3_dqoG7ܵF\_F(+*$Oˢi?0S;pβ0 xohD?rX7KHx6sg1Lƍk|ov0z+WQ9"B)=LX&v65^?r ?0V')mLz:<]Al,=5=lo??ho@7g*?njN.oLh veqSo`?0zcwtxGnP ʩVYG1qMGqSG/[LIȏJ 8ID[0m,x;>l/%R2{iC+`ƇGa/bEn8CCpӝi>o#>)5dɫPsN`mdcJp=Ԫ`*{u^ueuA-?0"=1Ȧ= |mzNHÕ>H5CxVImW`}6_`r!v~)ˆ8P+tP~R/D3Wzw~wj|"?0pcu5Hnu6R]\Sf(H?0?r$uT1za_I;.=2#MMH?rQmK,X;kӀzJ>s7 " L{\Qt q$LX2/ڐ,YN0ʙsW=x`SeKN*&?reo;1X^cݗ1Ͽ"=&RRs{ &HJ>'Sj'']UÂͦx!% -#??翁%2e]^|*'PJݏ>^+'?rf h+!f;}-/8C1Jj,5l50l85n5$\q7Kp0't2ݫs)G$zJճc,ʹH$|vpD|gÛC/@v˗,+Y2!^YRj*kv u֔E>LG+ujfQ]^å& x??w"Cy6du&Qmޙ/\K^pݦFQ2aTP>6By̿l9lmg@S-To)ffJK 0c\558t=i. ߳wm?0izA ",rLaѤ 5fT ppC^@B'shs{q">d! ,<|^ٳOvws G͸RK1b8W O [:Ԓ%?? L)r-Xˌ 0F5-$V 'K*V[D 3ΥIۋUdmѾ[.ZOu{IߺYYMý[Z-MئWhZ\??Ğz"5cD}da߆"dq>V7У t9$$. gpMeo"*9+P8ע>CFz؏_Fq8WQ,CIƉuN뵚3*ĉ/1Dݽ䘼^ҏe@p??{Շ˯v?nhuh, ` @S"bV,.=L<-<(q9$T(Ƭ+:?0"'] $]M1/>ўu\03,*z Nq#WA{tr|OO#=[81B]wV Rgym=v~/?07WzYD3??/dP@`Ak@KN /P׏w9ڣ'sg//ˤKoC8$j4Nxl!1h[ ݣTuFi6E݊ f֭{I0?r??;U}GCzpfo78z| *œ_qPq_QZ F?rZ4˴&J8XYsM#`Hg!4:ʋҎ ȎkF̢+ŕ;?rntCEv- F"S;=8 ޮ]\M:&ؽ?n쾡[]_ߕAgUN)[UP橊)Jg'ޖxt6&t {E=$(>ĨKb:6??w͒5KGlIϨ3F)K3;Rs]Jo#zd,֯73%˚aF4f+>7t%>xʯ7A?rَ$겑)Yz?nSd ?ngrxt*;XyfTk`HI,"͏ΰ:hi(߮~opu6v {d}qq7QO ޽>x$a+2277gOM ??飺m6ةANrskZ?0:c(c&VÌjuv?0ep!??o*:**a[y7:*;Ј.DS f?rv,R=:wjZ5}}DyjM$"ɍX-E҈nn i.)oF*T%7z!?0YxEf?r{EJ:V# }™dj)Q<RDʐG9l51XSf\;9tCր|+T]@yS{F쀜eSu ?0v|3G}s9&žw-\>ES-?rWRfoHaLm:#:"̐~S?nk:[#*vS]U1ͦO??3]xUrCyi8g4pZ@Ҟ<F`y.ܯ?r/ ,|{UݍShYHqȃ<Lvh _a`R]{n0HhY!%)awzSÉ'ML*q7 ޹5?0h}¡i|"|e}:A7#f5pq.jp O.f.\#k\G)^&wRCk>xi?0%,O^?r?0^Azq7N_?rZ$?rnQP䊝OllRЭi=#jO8߽LAꩂI96u,J8V2]PiF?no)yh?roD1V|ǥ[}ɻL h7IeccGod7֡4-Y` ^< u6.$Fƻm&xևS~6@WIyOw씞a~.-,)wbx4e3%~ɯ2?rb`IZ!_qbih/_cR\ <:w9v ~*Ͼ^zoz `칚8&YXYXԲw&)o]Y mء&N0z#Bp\~s??F̰5V2}ͨǯmj⍘a?0>lOquHҎWuΪj&cgrI=`!I:З20^1^Y=*0??u$Ld?0I #uxLֳ@$'~^\Ϗ??w'Ϻ:KBOʱZx9c7A?r/h]qilxj,eٮjݠ$>uB[_r5jJ2/))?rZ?n\W-P|[mN[ڡ,_9x!\H5I#y9-%;kMΒlsv?0 GG!g&s, H{GU}Hz[C~Eޞ= \;D)xN]siIoU4mPKOb^ _?0YJq`fxH<`CƁ\X%RKPP*:G>HsH:6-U2&p(s,D".EvݷuߑylY.ɅsѨNb_@.N4޲q)u<넣#XvLbZW~N%2!p7@)E{w*7)|P O͒mb]kYszXhQx5/UZ1"_?rԣYٶMǙ.`RŒ'jnܚP.Tô?r/D?nzӯAQ.5Թ;2I PJ⁀kDLheyl[0?0a`$C^Z:*·@6ޅaZf[-RYfPrjIx<]z|җc?0\,kmPW>-!dllV׽˟(r(0KYgjjW)-Jz;V[lN# -#V{*xvu)Sv[6$<T1ϰ|rV+t*~??U?0E*?n":|~`iRd Fls;X3`tG` 4*\{*N <孩E4J8-iןl𦱘{>fv$"Rk\wcl &CM-Pt«YoXM2MiFnpz؁pÌ@BD:5B@ي.ܩE[y|6C3D ,N5ݿ2KK.%Sr{ :ZyiK{p&%\dNY컟×<ٍ&L=xHh$\i&*4()'g@*Dh.}0 kdnFI[xKŵL{F1, Z}Xq,rt)^Z4dן$#5 VfR5nυ>Gx2 W{^{2g}%?0UH$c0v%Wm鱄]?nώz =ub?reJX3U$K8W7(TXo /+u" 3n_ Vۯ.aKXJaS=rW%eG{i :M.iFTCb˂A踷IyJ'a{ `w^?ncD[DY1ܵ>{%1)=}/Z"GA^[o{܊~?rܥh "Uj?r`?r~oIe)&c5ڢuw_~Ȣ\??mã{?nZDV+}" _eH(&QAZR8??Tݥw!%.fuꐴ!yh#uKG g/bѤ3b;zy (>-/̡$Җ+S?0Zc  T虃5(0 OU'j܌ȴ^şX>nT۾H?nI??l)Rl%U*iUƅ[}b6EPR7"|?r.v6B)ri~zKnGm*m>syئQ1f,T+H}N4[B ${ b==˘#*#;VG0_BuWѸH2hSZ1a]WJs'oUuRCJ?r+zdrxG:晴rD*o:?rPz-#R.9= ۝C.Rԭ&ZRe9+NZ{霶p%aFЅcᅒa uIMo+(FI5͝+q%J Lu~mHNs[98X )Nw(UvkEkAe;?r_S??~?rP?? $xEgR A Cğr?n1P_RrAxE'^F(,ke:jw) \Eiq ](8t?n. T, N񈡢-F-9ui h#fw T 7zHj^??,Qu៿iՋDYPNAK\G1\ +m#?r4IDxЅ KM俋@EjiQχ(PNJoB:D28ˈK!n#0z"γָԔ8= γX͸!+\i*Lo:3I}bjW}@h??2@81k#f>`NkkxLTAiilՙ D#i㋻HācN+XHZ$NYݳj1%11)-tcaL'=1qeOxFm:ex[M7,d?rB^hGZʼ4z֚%͗yVKs?nSN ىIdkd}„;^dr$ug7+H٬o?nخNUrZJ*&JN})zsg(.x $1C[Iv?0z_D:gzsȥ=8fhZYNaf~ G88Ul%«p.BknʖR121Rߘyc~|0fD~r,Zr:wQUK?n8J(_lB׸bY{b?rr־REM2+p~O\JKSuGHWŁ Mk]jgpY|B@G}E-H zcE6-?r=>h5#¥޸V~*-qP4);q~.12$,|I֏Or~(`/&a'?r9g?ryoKmi@J~tj4 :@/q"KkbI\wB( o 5Ċ*c-w*6W?0?0~8/ Up!;"Ɏ;ޓۙr{%*Xcg&s>\u%O?0@qzd-,?n@P(?nO7-g6)[|x<+r,ƩݿF8ugGȋ/؟Φ9'ңpO3w5={`ޫLqU \|?0$k'>E??n҉zd_cBB7KZϵd).1lZg3p7%)u -wOy'q?r:Dj swb[:9pGSG Yd.*A.}QPx&?ne@ʒk^}bf)ʨ_0ʡֵ@85{0[c|c(ßrYo "RlxnXI 4FmߋCS/f^:ܧ&}Rm_xwPm@K~'t<ÌVK cW~vf kոѻqX.M(3[5nAk&iY G6pہZ% ;O)h/OQbu$]R5 AP6&hMIH nE}6F?nۄ6Mpr3[f2,n2zƢuӺxsI@ ͯfi@q=w?rӹv˞ #?nfu?n ZVKQ"B=m6t.;s^mܫH0+nC -#bCпq3%w"?nm+)ulP-v:e!̋ X<|\X}ˁ\hD@F JϦ3:.sl8ʝ6f<ѥ~ʦ ?0눂hbYB΁{rk\mdPr-FGaT͇Bϊ,{r?n܅PeVR)X-VvN|?rEM??@NSߢ27^t t:vژ R˟i9eqBsJ?rǺuOt2yM wE3ߚa~x~ܪ}YgRVş*Uémhn7QpwũMM7?rkگC'g/|X=<`"Si?r~p6q642jÌ.9 U:_ho+0<Z3݈:ۙ&^tq 'rnQquTc3/8Dt#_y:D/D!ymzvI|soȁk6=G(ugHzaQq`xo??ZpQ^d& X.`'4*nBuk ap(y{ԣ(Ng3ͷp=ǣ5/V?rfW(0C ̨(*ІۯAeH`V( xtv9&KID_.[et;i<2::gŷh|LA[mdh78S&g%dR䯻MU|:KDly:%h)RICKOn퍎*;\yin8Vޮg4h|T#d {1Q43z Di=J^4df?nk?0uk W3 k%Mgbow`NfNJD6VLK${cɍ4z/~d8d&ϑN6#xF,U{l6|?nX:ɵK8Xi~վLe9?0NSAtx_yx%?0 Z2?r":6T`wGxy,??sMWdR' :?n`RHB.u2Ck1h dZk?nsР@!=[Y 3zkR@!,[?n??ƃ6%=|v[hQGuVceK,KIo"0B" zȈf{O,̃.@wg$sN?rg&>?rQEǖJuICN$+E)SQ?nQ*TׅG?nAǺz"0FhQX9f6*0`BҋQ/ ˜`Ă[d]Q4Q-P$|Z{$)U2@矑.Us6('/›6?0}L&N<9vt kLbKX[rRue{?nza(E 4n>sh|ԩ$Lw17rK=yϘ  OK㣔jv1NZ2mt-x\5uww]8yXz3&3cp?rmH,1c{|D⦧81$rv_1b'fժ^T!zEB8XU?0?rl nle?nmRLؼaW?rӀ> 5. Y7@F" ]]Ί.^J2lxTEF8fH/h 8D3a4WNBU;^Gc Kfp ;nPTOkΉ\Ptn*?r-B PT{^?0J?0W,7?0qP)|P_-Y+PU6'38l15B}v9+inmyFW[|J?r?0ljU!Smjc[ /ivHؔ6V{t(Zy1NT L zLYEz/*ͯgr.ԭIzsKomrJ\0A^</7^rkBֿd`f[jPxk( du'C  Oo B,yB?0?n`Vil ޗ??^mbrR^an*2_$Mw%0yo~qGmXNڴ/+"K>(~,TuA[9uA?0գ#GߋktD$RKtKYi>n -#??#~`~'׷"WD EmGEŷN³?r}Ht>N2RA{ʉUI_7?nu %EyKZ?nYL1T؍eR`ҋlXש rEJ%wJ^![?rF_Nh4exiQƪO:!x6ÙKGCʫ*?r^n !Ljh'AȻqo_-+y\m!ې֢bӽсmWcOTյm,oi2aM*?n)Ķ-8ke1;5\51 E^??M }09ڽsY9;FcUJ^A6h[x!Sαٗ.z"n(s lCPGllhITuZqE^mxKD/~{S*,5:v6Fo ),y'FZacaKJmW_95 A6t+g)?r1F^kai jLlgThn0T&E-Op~7%IuTӆ7 mJ ?r^(\㽧Gǽgw?ny:Ŏ,G6"՞w|0cBV.*y6JEav2(Kߗ) GI2 ͎BOI Kt] mQ2#?nS>(x΀Iݜ;OwKWU8t2'g#t-09ܧãB[~"ٻLXmxb[7o#@bK[UC[tDhpm)84⸟δȒL/(*ePbqkq:=Sޥdf‹ay嶱zOng z+1tPz/78k_/˳ "{Z?nހ"V-iϝjI/ qF5 _ԛ;/0 RIӣb)%owz ]S{dZ,ɩFY,KQ!Fd%^vy hxlbVns=AVZBaIY|oeS7 #F陸^\~N3.Hp]s}N[Tj:p g%ŷZxn?n??mgjq9xVudFygC=ţE DD1N'2Bb:Q(??. Stm?rB_݊?nǣ6xg0fȴB7Þ"qѭ{?nYI?rWq~u2RWlf6Eh.GîR.'lNZEiA5P, 1Ia(u̐8!<4Slz[l+QQzaQ?n34lW.f)wF,U&dѬqSө7N^RuS*~L%~BQ }>BN+^s#QW#t S3,n{XoSa,s6$;b}?0 yx67?nb dqJn4,f]4b qpwS†5*vCm>3p^mC(@&Z?0QsKo~Dg[fhw4?0=fj@3z8%lMdTsq2F(E+Y\_yq7JYmR/*˓~!{ WܜyY ͹4&[*{TȢܣB^=4$1"ɓc&אZi].*}隑n)6n47`1fZe E>?rҮ̍: kDlC??t`9d/&"I5{sRty+Gd3Rԥۣt3eeL[6 ;M,ƹ%*yjQR_C.hFuz⡩e+Kcߡꞧso ;??E(zq?r*T.zn쒦t[8iwhOqtďnѥIU;[v:?0oF!_Fle??g߿HSIz[ 2gdK%emFtoԖsI6B AOw \*{/yye,<Ϯ.Ov??>Bhl(c T#E݋=|:Y#8>}H|mܚc>>Avi;qBPE2pxV&ыWA%Z9>*FU?r(紺NqeUB?ra"׷~J0CX;?? S)Օ61guÅf.g`Pa(ĵ svu(R&b?rlɄ2_.mN1^Fc(v}2ǻ O2 EUK"SC&ACpj & 8V(dppK6[o'wD?r$tuUh~Yhwj>xV";k5Ӆ <"~UG"*9Va6Ԝ [(21[@%?rAotLR42$b?r~-A`F[|KFyK ^7nӳ= 3?n Eug=`p` H)A9zwg)ǗzqOQr$5;JJ q:bp"&S?0no7N)0jE!L*'mS<ن B ?r_[gS`3A}ʀi??Tz4`GYta2fw&&)Ig)ASn^r{Z%,>yP;kwA#,i+ׁhaN@M/gh~k'#Ϟ)JL%]mq&})蜊 m§0OF7l6DRu4??K-_ ?rR -#feEvNuV"^W('d.@)Ay%dKDKu~5TG ^5LxNߊ'??ZksdFNyzc48 `^w7=J$z-Ku?nJc),c;_QQ<ԷH a*S\ x^:+r;PU1P瓢.|^Ş4}yԆS%HtiQ@'v"X8*hFA͊2.4QWU?0dy??RN L'R1cy%,??80ʱ{2f:9E'Bܻ jUl_.,?r36=LŏuW|3뎹??R)hڧo$ɣ[-1J$7h`QT??EښqanPl16S50/A~ư:⦏4ȧ=Bt}??RJ\ޅRF_/Z2wҳ\2Cc5E|* 4T}!&M}/Wv??֋rcmqXR-K3%9GcYM?0W~%6d=ux4#y;;1oglQߊ鼜Fy&hG!a?n~]L¢vn#q:~iv8MIQt /??zN_Ltvxt< QЃ{9JƈVJ*I*F\kPN0$dFDW1UofPefjБ+{{zx%oTt\W\Dr`91ůssbLԙ>zˡχlo;/f/VY~0 Y`$St@UP.&lDsކ[TW513&Zm͇ViUUOYDiXY쬊jhXٚzZ#sFf??2*rA\ ] M ƅBn 2J\8}]'e8\J5/g ,LD]EOj%f#o %?n+mRvrusر (dкJ|hq+SBeզuXT*?n0~X&9H$fTqq\v9W}/?0k;LjgPOَy\#X<&C#4"0dsn u??,h W:jJ {kpP┡s&ƧDitfPIey5s,[g* QDTd?rή;.\,K_z@J=0w!oV)gex\BvRLV.dmNOoKPܘKw.K%7Z/1r;h.xI8_qfVNc 4W+\†??l5n] /* B?0Vc`AӠUDX|lvJD 怢YbeoHR)%5բ꡹γ󩾚yp'8b˺#:)sDK˾h:4|Q7bɲ_N5#&򢛑"xu<&SBp2jHМ [d??RvvQ7>#CeJ×0#xFv pz'r$Mj4Pq?rE>73ZWݏ_@t[Lt?r\D4+rZNlpch+&i3??yW1pv9Tq˽{)9![UO%ֿ %Ec"KW7"$ Eްdu=?r21hN֕KЈ$L슞/֎vLbۂ(OK里9?n\^!x Rck*?r v4Iq}ZO"t-RIDl}K -#q7vrxtxo喇??VmZw[Vcmoy~YX 5?0ZydmGTvWj.j:zv^4in5~*%?rWEЌj;pg OdTL*G%ox!LN4xw??aC =^fʬJS3r50Y|Ij/>kW,XjbZ./m]&xi#nQvu[ލ=gI<*f y$9YdfCO|E(ܐ7T'=S{7UԦr,qFd05g@ SAl`d*],]kD| hi]V[Lx[Ǥ.zNǸoMkpzu&??'\p|U;ӹd$#Ub?nSd??ErV`<?nC6jIq޳v{)gue???r Y'޵־??4E?0kH)Tx/uګ;$ 6ęGURM-r{\Q_ 2-:5'n7Dx8GV&8ۃ'.hOh68b>㭜|;^?0?0W)u&'??pYQQP\@mb{۫t?nvm8Ս#G4֞hJJ3&4<>Bvd Ƽ)|S> J}="=r-RIRaJ=s$De#bBqn gX6Kh@.\6yܑ2P:yC?rj88QԻ0?r !J֌S?ns7I>J|_xi}aQn2;n<%s/致nykyė#N_d1Op4ϒAIH(=25I__K:~g8q/ ;M9;E] Cۏ$,f ));ՙlfBCnYzv9OT10'??X(>OM0ȲbqJp\d WZ`diiϻ픇HͻJY(LũtTCrĕRy\؋?n?0v\Ĥ0y C/fb?0g?0 ?rh/X9-(Ic3hxۛG-0FtrB#\_P6 .ƩՀh ef*nWY twaP/EVד9f/{!}_k8(y{<"Nq8dn.8Pt*MghXGa"'Q?n'ODSvWr/.CQkL]jp -76ֿ8里.s+^K.mIWgY|0,Qd8NᲫX41>oz~ڮED5%s\r:jhR1^ɀ^N}hAH-*RqO?nZBbɠŻ_`cǮM*hDEyr]s̘MʎS7vmnpIB5s7"QR7fFQvu?n1.gf>^i"NlAǩW.uRnۖ=|jKF,éUGJ/*DC7~??q!h QNe#iWK4U|!a76TT68ΧQι4_Faq4g^]D aԈg03(>K#?r9>isq}_R-(;؈fL%_0wAYHAߵzh.TPGm0$7 Ki#rjkXFʴTR)Ho=3tB"?nsŧa"8u 'Z֩J֩#(q[aUZ?0mwxczeQi"—}.P_ҭTN<{n%_v_lSL-yq`%>T4??{uc/J><<=RdW??hU/:KtE5+4p$H˺)m{3"- YMYU4-]=D#uC`~X\%.Iq2??,]pǵu:li+f?njyznB4R16e2M_HZJ5Q7O.ؕcV^Ec6g0VwlU>x?0<'Px|6->+a&<`F~恆~Vi?raYQCdPWg?nW'|Vc/(??t7nEjL#Sy5xIn6??~:u%4D\|x~P")ZKbb?n O~VRio#Y+jl_Xp߱jU\-(-]W:͘/OGRU e-GL6`R??= Zu9 \;g=3N,)'_s4?n*7o +?r:ÎQ8uU:AUlP+\者zǵW׫mL13sJfq~(t"2H]BF?nk"ntɼ)Hg4CW㓵ЩUդ%UF/+qT!hDdd߼/(&QQ8܉ǂbd_6g(AR|7x$)H`|?n`.(\No?n}$]aw7r/H`|``.d(3wӭe6%UbdJsyu ʫȦxySƑ+1 W҆X7\f?0nT/k6_w_;]AAoZ?n7/Qb𾤌MrjL#r&R7q*kB}&&$PC74Ee?0iʀFgRrքmN&M'9[kSg>o?n1%k(, m pI)?rHAst9sxX/JfWXLCt\|GәpZG 52ĹٞhMR8>P`z,ӝE’߈!αWSBpVH/P|J+,qP=Z-Q3Kf,QBzt[EMԲeȖ,34~'fjPe{Q)yVz'U?rN >/S[ߵh"ԟu"*-U%q-_މa٧7fkrt/jkx??ӹpsˆ[ŒS??:[hb-wW`U)dzEY!p/PPHwM.+/ZL殺`/SA[j```B띈0D->41i=(S[/ej2?0?nQd'7 u?nx38V4}ͧSo4cu:?nšD.$+fQ#2!Y% yi+O?0},m-aP8W}oz#t_f|Uk,rJ'sMI3+un?01w?ryHAW?rҸeMBP/]mqSJ>SeFV7rߝ ҫt6 qoaR0b*l?ntpN ڛޚL{XsfZMw袞:.hd87GTRڿ̲dp~qRUTBOSy9B۶7p R2.RZJJ{CC @Б%yD?rU;' eW[D{`rUA1xW4xAQ4A?0G59<ŁУ&][48~8]Y f[dڛ_uiƖw_R,bO_nkEuYIUcIYaj߲X%(*m?n[q D[mVԣkMm??V8jM+"R??[SBQ34fY:B0M '\|M"O^d3QAB%:N5!>3;QwyQs]ǎ\bWסb`[Ϗe?0fȭ;~iRf+,MjxZ#NUrRvpL??h Z W?n CG텛ϼY<ŒT+-|ՙ|^cMO&3g?rj_:ihor( SOhpae0EV%TNa;T:й>2WSD^E|g ~+ >xL?rϩ#/1)Ԯډwj/Z RaDH2j׵N'[~?0 FEwtމ>"6YR1i+U&e3D|r\ǧ??<?nA_m)h]rա]PL1^I&LI7],\)oAxNI(*{ژ*WBԝsxO T:zGGo_vb/rSZip߷YQ)kXW눫$]}8nխ'F 0\DwU*E:/^g[D;w[-Vnۍ!H^Gr:*fq2RL0F;2|Z_&O=Fny4=b._{渉 Yv9Cm?06TZ GI_}&1X=eᝓ{oEM/'?nfݹ7S-UOro$/IUڻ7~{N$9j@wH2tt"20n)#?n;##VS?0ϡnuiV<3 =*Js=ܖQ1Xg}sͅA/ς"x3<>irگj'v$Cv 83~yNVSU7Aϭj>Ijs)cd8 !?0f@W)Mɱ$4ݥM 9Xהr?nK?retkIܳu53l9??iNd,?nc 3zJQW{uUQ?0u;Knԟ+jVyXy8nDѐx??DƱqȢ2,/& K_=G,?0F'o*UIN-֙!yk8gFg@ D/`rقG5s1ꟻݺs9V|EF3߁ ɱ|o*ayxvݵ6?nw:NJQeEp0{}$g3y.Fe?rj`kIهW_%vh7hSנaBF/ܝ??R????lm>Zo<*¹u+q4>cFdk8W:uf$y??%=:Q_|+]mN-J(w+8c3}v< Et=xLp:C,9ai'?njˆ!U%4t2B@6Rrf!»"???rbKRhKJya!(u'0V%EI%)Z\}u%dt&܉Ծ2Sr;#_gޡ<8he$[-}0Ϝ2(?r@A+7ٽFrL5U[K/sdJI2QLb;i }y#[d6ĉ+zGҮ"tӤj!ߚ c3qBP4/x(^II{@ ʞ+IEٞ BWM d\1??7K߆%g_ot:kc﫬xa@\&:pɹY/g{d/x01gڝ%D7+w}z#]>Yoy??u֞KZoC\bd0xOދ˄4Y|>[&yyA6]t(+YNbKKn(ȜLRZ=9ColzWC_k?0=jUL@;6嬳i^s$gnHx913dߟᰰ?npϓ1,&Ea3f?0Rӯ+_dڔ7$7/n~ʧ8zȞ}IL?08[Zk>΃tN#BT7^W$-3??<= LaYpLY De\R w<]&?n?0d>Zhf#ٯ??a:&םS[ZD.'G~qyKl2b7p[;'a'Ku]U?r(SA6-0 u+&Kn6y~S?nL0£+_ 풓 |!b#j??$Tٝ??uz,(HqQvO!jRKd`'Fƒ;IຟPaOUz&2BX7pu(G~b?0ocbXx;9tuo:ٻ[դvJ؂3i|4Y F2R:~ط(?n&71lxuQiҭԐa 5'Qp[^EԜ?r@ %ST,eJ,@RhWA(AKI?r2!b,~L)u95:,b TV`$?r6Vj=@lTvk je2 a h1 O9#;d R-?rUh]9qƧ=&CYP'a$<ldQrg7]p>|l!to]_;^jQ??M e?nw7(Z=D?0~;\BK^BWiTDbTFek`Ԋbv?rrTK'YSUA<̀jH<zeA\qyB.8M¢pEo;H!n#{wtLKs=*A 2D@sy{qez[ܴM^fVaǥJ?0w}@Zz?n%6S EPۇYL6se7?rE;qF6]a>2$¦* SGgϥq 8ׂŁP ?r UJUI'=LVE\pB mH[ C ^¯wUZ??7~ӲJ}$IV~`C T_)kVY?0MnW!%[Nt|FpYo4]mi??灍l>m8 &lYZoPb\ʩL?0ہ~ k9Y[;RCl~`CHt1B {dD)ItW#hQhR MoM8{)E$q"t5q2g0:8kGQ6۵(}l;}l43 {z??zlgL_v_z; D>C!_vq 8GGqDGOAZfvJAq*'??A){?n6mR2rpŨ?n{O$U]ļz&3z۟wҹ^M;/Zt@ NM*ݏ.vV Q`,Kj>OZ6)VwW,x ^HLdr;sMT<\{J(4{p6=zA2yMA?r0K{TFQ%r󩧖|J9EI$JDYC-)PmIӕAH&fY(ąGW78xG63S hA(@ץ?rGE5" z^zQ$#jxny{??\$\??fGk0BٱŅT䵊fvvF{??UfAB. X" IC!YUrQưsj} py$Zq[ĸl/Q=~S?rwsKxA_Ė%UjRG"Wb!P+8Lps(-pPOmû_dw[nBlGB`GeIS@Y皛dYcD(MC#A1M8ak"qeH[k#MT<@yvF1.Y}YO7p`P$DI g4#v>]"\??t%5pLD3Fֆ݀wj6,/D򋖲&5ܒ[4Eq=!FmR#5 #h$/uYdUp2(cʷT4-k/ oɽƅ+%AUfY<ш( SRpzV?r[ snҍd\o-=O՜-ҫyFJB??ևq~"pv\Xf;5Ŷһ'#?rp,=3 .HD(;&AQook,FInbX-|UGiʾa*Ո.b(Gtf4H?rr@?r<$-)X(KgMU'IaW{R]FYRH$FO}lTbch;׭Ųt:#FiPc̨ -# 2ż/pyׁ&0 1Z??Z]_/(Mk+aamkVU1L:+N 7%krrsJ,,L2?rvr}?nhD|^C:HR??ruQ=J,<+ZmL˶V?n*,D??wV+'K78CiHQ('-y27HP 6#/](2m9eC?n qd=g&5oy.e(}=!4J2\}25KBVȋb D#%R;x.b>{QT$[GwWL!-ggVRX^npѵU8h?r1r 'I64y!퐬ޠ??eQ#S7^+eJei9 vݑ7?0]cՒ?0eP?n:Y-eݬRG>re}V#jޠArMnkǵTҪݻTХz??.z'jNچrv0m=?n:04E|=+_[26/!]զ^F ,Ӻ_/kf Xe]뚧#ǍoK[]Y\򬂹vׄk9aCø%ډ/@cfz?r#%8gy0&\=܄5'ZΫwjkKUYRWRm.NLvEh8sӞR,8`D2mL$f7λ#X }$#CqwAD*ni?0?0"c`sDYwMFI=Ws YPnhq[kӥ[U^ PIYKD7ר,qL*<2EW=`2"[jZX?rފ{)77_kg?0b8/g_{rLK$;N{68O&IlǫCIͶDIJ<\S??!,RT^Im(?nP(?n{77p5EiP;44o;mCm4[ciCBbZxڤ[}NmF&Yoc6y RZrzPIZ &Vq_d][g'O֋{3jH^?rvVdwvXK:<ECKvWnuWCrXE?0M{h~ ^$g6b4$p?r[=윆78$r G}rxPiTX7E{ eu!N??w窱`kI??a~e(<8<< ZY*m{uTj?r'Ҧ+&s3]meOQfx W޵q0tʝJԕA#hs%DQ #g&⤟?0jd~]PrDoQ4J?nYa/rfE4}_0h-,ZFL,?nTh2Yz-MN=I™m14}ha4v?nR- ^??xWaic#XY?0~~0*ObszY&??v=ޡˀqiu+77qy>.:~5rr!0DML(+)j+m#NlnhăTmYd?0^d(rM3 fT\m"~ Ϳ?r/i>66Q'mWveY8;a;I-6!gH@Y!0f\O%?np?01D+oi]75s;7{D{žat2pl`ʈb-?r|-lTP-a2?0S)e)RV+n3s6{ 6gUIrZ{f<2DS()mPvP;Æ\)k:MZ -#9*/_]$O$Lr&+PtxBNP'o`_dZ')Gm. IS_&HwAgϣ"]'1!O2eEx͈n#y6 8~_,'A??'U"p{roVo4 Rg{x1oo^gaۗtn<++G(?nBn'?0STNc'dyBStseSY.f'@^F&k)6Mv\e@_Do?r-uix^qՍ\0 g.j$\UP4JUEE?rIqRfǪ?r1_rbv*+QՓzQae/[/fԼa@Q&}Jd(LqJoĘDx?r"bcQY 'SP2C{av]3"tDj&Ru?rXUAN Vj#0:}@rfUvs>vL hH8-]L༊lre_$[M٩^gJKɠ⦃ȸڄ(aJ r;s!Ɩ&Yttyvx;L9f3hj)`=S oH?rHy;]*7>y/,MؘjFƮI[?r >KL> Jc+6']Z>t62S4>51uqPM?? I,Y@7S%XCF(9H':5??Z߶ZT.??*F1+2ϧ?0ԮU.b>Otxlxإ=񟔍RO ?rن;==~N&IHq&"u:G9=} a.^^yIw~*JDXrZd^a*M0wG*'F"_s%"W-0Z)X>Ob]o2;}OY8oY4 $`-x?n"?0a#<$Dkװ]pk2d̷ownwqgI]Ɏ*7uht/(ηa Q|ɣ~sхN4ɱF2Wm?n6Ͷ}2NFb< Gf58eLp;Y>)VՃ 3>~ΗW$SX6G+_Na'UA0HF}<6?0Se4y9Ox6S/#osQ:]3p 2C:pzջi1!nLůD! t3v~@l4ܕZ"KPLJSzesz.{Y>{^Wng夌piONH7L.%jLZ5U?ngA"G+uxtڦ;0?nΒ=~Uje@k%B*|frwfffWܱ?0uruX ɪj:%g))/MU)(qe-'qtud_^绉xYDgRGya 'n$Myԍi>5?097*uYӓ'N3b!eRu4M6]-j$ي@*PU=*;187wŀ,q;z-/ *OMLDt+m?r:QS~wZ[lEN^|Q2^ͨTҳn:f?0k`?rUZSBX穪"MYw(]mckoAj?r(stq??`hډq>ݚnuQGַn9T}ojd|¥#?0r`ߠťƹFhk̺UpicߚB_zۮJů}bi=>elR?r-LuSFƒR"nӖF*S&SR(uErH9ooF,wcf\ 5؊(XMĹ?n!V??z9Z[ny~ѥ<]e<2sbto8s~~ v_Esv}D!8~+ۙjL.WFG刜zzD,Ф]|GDtl?rik졒6U@!A.ODMKԴU;[191trQ8>~ÕQӳjCAƆhhJ)&NYhksg])[6Uъ9?095˸5Vn en@`;K;ڹQ?? .Q'!&'p6CuP6m?r~Xϓj_Kf xPhȶXqŚ(m\w^MǞKP_/Qҁ:[F8KiDnB4PE8:;=*]SC?rd_D^q-oo-a@ $?0 C,??+X9ai5f^aϑl?nOu0(y0~1)̠1a^Zrc8RE'(??f,K h?0B2I6ŇcP7YC®Vu^\dv+o8٫]gM%My1i1uc-%e4R4fGGӭK}W0]bVKJ=CW7RWo?n|LĊuxXmZTua0˜9/hh66%C!R=,fG]G _f}妼Իf`%󉷱0fF!g|hgP]c8V#_)'?0{M)9?riC9eSmm6ZmW\Af475?niGARd,'XoR"z #s0?ri-c ?r:>'g|Q?rcWiiZ5Gc@MS3-^GDz:KYmPL&rNuv;zpu?0kut ϱ8 -#{zqx,>.tYFK'aV)U@2ߤf~^:s[*ې9+Dx?rI&TtWwD3\ZC@㵖t$9/'M9[ 㤫2xC˒4Nڡ[,ivg։{ܩU ?rZ;t՗VI\X,$??z??/^c4P,iHM.o+L꧷3c$}$1.A\kyyv9(JսrdO6UW4502)b(/ÆjwLyJ|zB:~+ڤG2&%QC1GoLBB \\S/?ri"[.0/yP%1nIv%8??nD]"fq.הW;x8Q0%YNc9TSӂ;Yzvc Ш?rzK5t!`v`^6nsjmᅋ`nlk?r??ح?n.L9 MJ4mbL9/NDi"nrӅT1ZOQLl$ ްέ2_ls:8:m݀>_ܰפeMD]e2{pq]P˾PfdV62Sf2FPoKvZV+9;'n]єG^>%F (O:SL9.H0'<9?n]"m4}x #9)w%/vf8,V?r"9-M<~a%Qneu"0.4j*9Kkj??px`Q؂ldZ*ׯKYp㹓q%\ jᰍj'W'??b"ێR5pi Uqm8<Ӆ%SW+^\O̯~|wu,7`4?0p{7|+[}0š­]0>%rs_T&??rkq0yNI¦28I"*iJ]fC2ĺα}??I ZH,K!cFL7OPW{ÎIw?0M}ms!Q*ER?n͠իM7*;O@;K"2W{Ԏ:YLmc!P|mU?r]O.`.P4>L*H#_`pVʅ27De5%}EA?nH()8ۍlE1ٷ+nG.YDڦ簬U-ZO?rNKR"LO.-˩aT5Xcz,`-RAUywO‰nJaTG:e_?0>e173S@7.DE?0vQC_Ǹe қP^ãx&!!W nԑw,8ڀНYݺڪ$\-LQ)BPjLoȈw^Td>[U]YYp7htreColH-@??f˚`ybTƘwå7`) -ml?nG:U3q[:dK|i -#;;^ly/^1to{Yj'ɣlj0֨>8J6m/d--$%2# p~[sU"?rE??ns;7ѿu+}#\aT=ӣLYذ62kdgW@yIµjxө5dAbc кXpVX ݆ʅvhUUN"ҔF\RO[ɛ֒)cƱ#9pWJ=u?r{6f݁ ?0.!zDGԟ_VaNIjw;$3Rs\@47ϛbIZH*,P?0Lo5* 7kz?03ܶ-|Wj0qxsAw~&d{PYE0&M9}WH!"c'ٯ/4LBw6ye\9XͦlB?nxLӥDxj4aS׳??]8oӝVmzȝ,R-K EdۭRk}kd`R4M "XT#YC}H9|Q}9>?n8śU@i2ϕ.:r`Kܣ>(ؚĞE??mdpfܿ:i[VH"O!ڔEv=.)%ݝKp&5 Xa-bK/=XdY`+ !bguuZ\+Z5EsN}GlhBS Elcm"71Urr cRLdj@>??u^'?0NwB0xxtr1"ܭc#Lx?0hͰ9M(F>WA6I4φglNpYXN !GYWt ~_|vDžE??R5v(?r T; rl {n;hZnR5Тt65w>ދu{?rd{?r7b-mʑ5mj5 [[ZXi$]+c>I m{b9/`}la;ǥ+H%60?n3(欢"e5>+0ZA?rP4(W% UExPEXT]}v`,N|B?r9";EE#[W5]܁?n=v#ËB5@e{g??~?nE1j|qp~] Jaw^f)p4SuREGL툉ИiXfy>qs_Λ~}+AyF+"{<¤Aid.x&Z!5 @x[΃WD[};?0 Z2p]&SEQϞTVT[qcT\Tጒd.pwC3\F`L7+gB7Qm^1h8҇=Dkҋ'?0tfc|Ee % TMm5kii,vՋjhDpi4,bDцqGkM>AKUo=?0h8G0|~GVjʟ:L\tT%T(KI2E}pC7Ɩ0;^KuoBqp_ӟ^WH?rI9z3q&Uw.ufTNL簋@*bJ xSg_=/?0ъAo:I2/??XZpjڻ'}SG33"sKxt5, a`aLiTҋQ&g"Y̘JhFRR̽?ncY> Bv]Ew韭;CU/yo1UM_?rI8!F8̆?n^)=Z-ҭ@??M.ԛ_0#O;@Խ%g$t!cRs?0w[%yhi)ھh}&euiT^FTb9}\ZmOQo}0|y.#n^U-n:}bv}Bn#|ypcr!??+l="=hzZGVG?0:C 8+ g}ŦA6 dqD@ k\U m"Χ_[$3wGmN<{Y/zcLڛd훁m ,??ѩ;dlzmb5:)ŴD$?0I?ro(Xafjg$Π0a<#|TnHph@;gLO#/| ~m9V%eUQ1]B°ģ[Fdf9kAաE[dٙpKi3Hfv7:b>׌y/52:s a&^I:tҊ uLXnPę'uvɪI9{$?rO* &Fetaڤ#jrI>qY U$ḼRK&Å Fs,ir8?r9a[耢 _ myND>UyiHX8=zH?n2K׎=Ջ^9??9y7nUM;+V ??ƚjnV\l$N:] |o79!eC޽IZoTC%zpCkOU4siQ.U[5I薨Fd"d;QweB}k/|<Ib*4"_BWD,SG%\I[nL7ruQd}j)KKO 1bM2i:hǽΩ_fE o]hT@1Zpk2???0u98=ϸgsⳑQF)Z~9i3^6t!('yc+FJ'#,N`LW)Swb (Fلˬi3GRs9Ek%!8To`zʢnƢ>LbjSiL<0\H3 4js3졍c5v:q{(.lM Xhm۬3p^ꐘz7avho(e@D_ˉ&iس4DGk^+2_ HQCzZzC:[V"8Q-;69ݛ&??}nEw+Gd1$XJ|m"??>r`AvƇAza`uݜ&adܱ7$f?0zmmfeİw.hthC4@Mθs\pN'(Is"s\}uŹqX)2uKh藞˞ _[h&Zzڧ-Q:b86L_!| ̄1 -F?n7gl5-h2P W_q*PcAf?nA&!F!Fd}GnݨsA??o??YD/T;MCdHRv-"&Q^髷z7G-X=N^??{}ȝ)d7]hW'0-vЯۗS:0FSv6XWWc-"FIţǯn$.$sUCg =.rxjm.IBo4 ҉pIjUbDB2eIb~82h5M2;D|??ouDqHx$6O4O/Nz!^USN}/F Q~_Xp Y-/3xD6P|Q??t2TQձ`7+c6MV?n[kA^RZSMҖ*V @ƈn(${4~2( i[VN?0,Z~цLDYƳK]D)<3HʒPDI U7?0-v2?0Tl3&/7 0?r$=!m# 9X)0#6w4% TAF+$S..X`wLF?nӡg<7QoN>bi^lq.I]+٨ZBAFLB??oryBaB7[}kI-C(R Y?0EѱBv2,Pe&0p^1PhYrL1@㷞 ??Kxŗ?0X'ņf8{]%~JCN?nnC¦D>Oq^2$'??<])ȉ u.x/N4%i??]|??>yߞ7iso¼>t9B#gw$:s雓|"IB7oG&Kb$Jp#eOQep̼{¼ѐro0gNMlʱ??Hu)o3i(cJ?n֤RtLɥ)ʨђR??y! ё[n2CpKܳZ)$YV|YB;n1D-mlZ\/&08!rB:-(|4)ۗ#o.F0v#kעD]etUh=E{͈Im6o1piQW8?nY~F[k5gk瓌{c??Ǣ[ZE^IvJ,d 3F2&o9?r7Y@zV<<`xO{TmƻY. $k??2Et5p!uxGnK-)^j&0W gvlL%P1GmQ0QN<{VXy䬙zb [5k.cNB9\%Rį-i`˒IgC4Z?0uI.!,̈[ӎtѲ[HКtHOm|}99tW2ĵ15ḡ} HL3d- +.^mZ`E{$6F{J-&9(JG#:wع1sAjuoT'fM~<:Cab~ʼno`;LIjvhnR$w-?n?0&"mod\4q s,Di6Z)lCw,"~Wmܢu;?rU};w}??\ތP`!53LJ2:{7vT_+fvfd}a\d*{/[ASq6[g]l%g5Av7?n7,]&.HՓ7o^9r ?0QtLcژA$9 Plhu_?rBB<5$??tuM$HoH; ' T47/Giw?? vw'?0&c(KDqIu;[LX1?rZjH.Lz8)x}m?rtc/~-KlP Y?n?n[۩8wNdj.f܎K+???rD)&"MCF;˗SoKzگSd2#n-)*$1%\w: -#~!{N0 rŷlJee,zK0\4"nh`K6$]6] 3 U^: \ z@ńZ$fԦ߈*<'&{uK۷NS[OlJ>?nPf0ULBi@7uIulk\։tGFB~S,{p>%wLO:?n fGTëOf: pb|"~wTAi 3d&y$ŀz˵bŀYX8ޗb xsF+C|n h40B,2ҰzOl(ۛE:wzz}SI7Ӵѣ6qXM.6:D:>Z~#m^%|WIlN$ %[~>0Tx0tilf^xsͻ3OrsFb! VpuVF_Nj:}3W8??O2DnV&w :"@V}a;M+/2.p FB6R"X  2: òZk2PD *'XXu=?rg>O* 9GrZ%dPFOhDIRqOn@Lt2p,[ecY=tP絸4rM8ှn1ܯ{t5j;<hOϚ?0@8vYxƕƆU]H%I2[ގ~}V(5in(Cũ&xbJ-ʔo??uBT7/)%?0Me0Я HKt{/;戎^< @zo 1@) hRU)ϴzc!bu0_#5Jw; mg\֣&?r93WzPAU3n7@͉|X>dZ?nXGy!\#߷sD19ESR>?0m|H%Glh=rev3&].5جD6JtXlPvQ?? vl+ui"&ɢ$D 9lOQ7DEU??FD;Uбt<_ldWd`~=`sCq"Ö]a$w4y-p$W7_j`>3扃Ã>8߆nU>a~g.ӥb e~vc rNVKo>ۗ偵_v?n"_D+ ɟfAf2QUw|8fE (3:}JPDL|WP<?rp53X~o;DPO7??wWo3* 2tw$]==)"?rbikv6N\uhWwBztB\pNU-dup?nbJo9:%o8a6ɀS;,;8sZd͗(}L$~~f0*; ]lO]i8f)dɿk/OnMFe|rt&wW$)_=4aBDXE$__`\D)#??a,)C/-o~+ 艁AY$Yt`>p/R0/F2w:D9}z.[a2pM"on".H,9?n/XDd(^iUXEo,=$8nBy's\K@P] BHl(Mh:TM JLFm<8 $DzJRwSFDnj??XG'D/ù;~^ŽW˧Mk?n%X,\kKگ}MāL{3pT/%*$X'˹G2w4j??leLj 5W0g;;,va>5{>cV>F{,7ϭxHnαx_qXocɵ5??\)ՓW qS=kUY3 | t$n wNO3&cBqpϱ 8WZ-2[%Zl9o;NEʯ???rio??;j??daL/:aZ)4O _VnjlQcΡw 0eTn2Th%1>T|O?n36^=x%&b?ra5L?r[&4`[WV;t}y]~ (?07+o?0Ղ8T<6iLZΌ/>&Xq .G Oeh*-Lo'Ya ]bۧ2xu&wI mr@:@ǵo44Ql]ơjhqisLAlm% ~Wn.keZ個lل_%85P}HyhCgB<:o7qWAprq \'s?nr"o5ju;l+to:8}EUއ(i"YQ^Y?n^Wf4x̣u(x8![ dsg{0 do ٽ/q҈F|<>VۥykDI->),s/{;jn=H,;1:l-?n#R=ݚr?rh%1OpI47Agw;^E$?nN&XQZ7v:R0%\N]t>bD*N?0ڇ[pL?0{(Zey ͩ^ *K/8*ec[}9{x:M&fQ)jr޿^r?nE?r:M14&uz)]  ݑ=xV7հW03 ;Hm>P쎈sږZrK*^`"-gR"$Qqъ6X6d:{cb+QmGmzhn,Q=iP_uVglǞ:;.gBH4biz|[cЉ o,M\UŮ̫ X*[83-m1ggV"l1?0wCrV8^[ǰl\]Rl?07_ٴ.cV!xez Cy ft`^?rJ??dz"\-kO3`w]%36gN1bif[iVȿ:3-wi)JEc4iLHV[В>YH:T&8)b8@eT ߓ;43ժ_WG@Gon]w̖,Ou1tӤF]ugjUwpG6q -#rsTnk?r}.RI*0l _j2Ƭtח$_DioŠB 3-ńsDKc.-̫)( Ǐ]#ݏG~Y['h_Qvm6?rX:;[*o2̕(P \q4p@>Tc *k1W??q߁םbqz?nH4fE WVrJ 2daf7?nn;..9r`P?raKiEQp'+,.t}t>b#^T$A_M|rx)A҆L41Xo\uo>twwH~y~{~[??߀€L9Tv9ҝbGkŵt`@(m(f$(7h?0eB`0\y6 - Z am)5Je-(_mj.d+_(z[ 68uUԭЧ%oc/**UDȬ/Rxbbo+ ?rˋ??1;ZT?0;_|oN~f]:yVۢA%\FNqo*4!j{4hvA6X r=vl f3?nw$\^Zxm,Z liia!IgnvX:^,/wrj?nն*庻UP6B5[%߯*EYJf1䴦Kن"b`A.828fޑjl,`~ŀ%MLG1i'CnIXs,'m[@-%@"oKr3[?0=_[mhm[?0`:Jv곛vjp+MYC,,čX-p]`aZl4Za0S@z_c}>3d|Nxp??N:w18?0=7??*hU* ڰƲ/U7$u1#Tc)N/)2As/][7%(!ќ593TCW:/ԍ3$]o`4  ˕MY<%bN- 4Yf?ri|JxjҢ:8z:wZpQ;𨷒څP +6ui,ӂO%9NBemyMY^ghljױ5ep3hObe0YTSZ*$߯??d%"FrQ7P^>JײaKbb}^:C4bO̓bO-bKqd|~Kw iQC0/wwM6!yBndK3V- T ~~ws>-?r/ead1gLsLrX!X%",Z4 DwD}$΋W:4i NSM\ @ ` <Je5 Mbq‹ϤVuϥWxC\B`zhGx)>q̽Rk5H7a440u6 @4]Z;f~zb^O`s{:b&>α0(G?nj(#ܡ!25ã3̤ǽeF\\gV"U?0a>J)^*<;Ss+5DNeh&4+&fk3xFf?0UY)}_fvn]"_1L,?r ?ny|ȸ"m{\ݢ?0؍&cUvc]֬V1@Ii8m>DJ8kYڅ-Fj>gKDL'{/n#"!]Y0YT$^kq*1/0EH?nTRIXi?0"?r0`h"Yȋg吃_~Oԯm-R)$H̗e⡹l5b!-\@Uu(9c,.aTuf€=alh+iO</!5*T+mB3SH%3?r7$cYBX??v#d`h@'$LkF ^ogZF˻pmџNXkmk ˑP / , E0i,lf>:Lֿӓѽ~V fiV U?0޽+!m\TfY`p*V vpȴO{u?r"#>_@Hq7xz^evW@A1ӿt9WU'Y=lΖ R/#ߪm +dE3 -#mZ+rS֎2#MtAWčDf( ¨!A%:ڴ5 AX̨=/}^hƴ2]h,KDBˣH0wXy儡Ry%kƤ:ZKq>ZKGc*JTydi6?0B$N ^X  'AqNqay.&P_>b)@{Cjy DCq79??F??8o *ǥ*]Bӥ(S>68ؓ(;"-yZe쩿Zxk+CDtfL f2??ȆHiEeXQ-0'1btk5Jhѻ:%kE??U65c*( 7>??IKpW[&f<6 _xz9ku곱sFPڦ4Ѹs+yfc)sY?0R3_T5`l$5?nx cFi_v=S.}2YOn?n??]}b׸+kpәݵu\9={nS*v%f<үRn=f^>_n9{xؗ]G=mٸVw nUp1ųDߡ3$HM7ˏW;=H|ܧ{OYY!]tigK\DSU#l7DQ{DWԳ.m&R摒^.e=ތhHvY>3#_/?rlhz8tqH]7J҈ˮ⍏RhInx ;?rNY`F](.2'k,,|uMF]`NXoAv*qLX°ċhAE?rntu#*VDi`5(&T+3yiR Ae:q {f_(k?n?r$VQur<ݪ9>Gs=ۙ>Ѻܒ,)+14G*&(mIJ@U9½A}@+;)dfKV'&!2 3j{14U\S []@H]ж$-Ot?rŢg~=h&$??dZǤo_<1=Dr1#P}G{Pņj~D|f٬u~J_rt*y,ܰu*m|;[M7r0é^~~aLwoU9"E(WYS: ((ZP[0KkJ2?rÄ/kCY(i+"D>sdʃw/2K5"fܩ{\Ϩ9eؐ#9%}.!W>\:znk^ !NS<U|3Br1eFS??5Ԁ?nQgwGY1Op̓X[0??;>?r??|v3U_cc\}3/| G##'Jf0'ts.hҟCrl1/(t$+ Q\n?0Ru⪸&d IԤhLKXY#&Aŗ߈]Y,#}Wk_7W|?? 2R?0Zȼk'MB뀤LMOf;9p#9_#f+~&e,4xf_ۺɧ sp-ۮ?r{,dW,}#UZn*H=)'Rg0KT-b1En2FYZtd4t,֩1\H_9?0_A*ߗ=O)IicV@ 1~R "@ԏ0 cYMpN~"T&Dӆ D Pf',>M0fc?n&f]#dmQOLZNYp(.mj@Fy?0w?n>a.b$RĦToeF)Koޗ1yT?r_??}Nz, m7~x_]^;G/ɣߛ COSzR aI?04Ō IT89J y^g_D'E~Z0VprgcZ&`ԱbBMxlvJX\7W -#bOwU??szbz`~9߿?n1?0 <:BLWiՍDf2Y& .{Cĕ'`LX{ZDXrD88HLqC@-3".uq;aYWͦl o;;C$jBRjFA?r$o])ף\)8T 4085N]|LjŎi.O/W['&Is9.M3_LpN?0} ~FB*??o(-a!>MAz/dvETN1ݍzތCyo0o_köPTK&w<гk!ijRQw{#1 ֓^lQ3:?0V?n7#~Q% z I`%5ʟp %I WlȆH Bc892[ۘYC"1M>]?rě?0.DH6 g)?n;R;ю:44JIiD%/*  ޿=>TN|!Yr~$(zcdu~ @,O]r(,7GEϋIvO(fg'3܍lXٜ3LwnbS8;,\??x_:7??4y1??Z=wrj*x7hU%|~Ջ̒YC(͏ qf1yz` n\.뜛?rɨGHO)4nK|Ys5O=?0Wp??@ߜ~۩?0(odВ:^rv%=Ɓf()yDQ6+x9\櫙?n mRґE+81^F2dru5ލ}uM+DȚ݌ܗjP!f(`KLd+UFt6FsDOuཛྷ1??_/aO2, ĘJ}}D'MS^MPW}hZnA#OOO~'d*Ya $X^< 8^s&Τҙ"rMx׹nGpM#1)^zGjx]MciVMтmdjQJ|}ŷx]v@)9O׌W^?07)AvҰsݿ*S9O{쬣#겴^&V߱yJ&D!]{zUwk ;_{o&$Slܥ>Ë鰡tgX89$|X&]_H8Jr??Ѩ7*[.WQyӴg:8 Mɼ+2V-kRmVĠ%**d$"<1䰫L&BBٴ;1>NT^\Ok Do57m`yp `G[0^4)t6Ǔ:\qu+O VsV!>U`*''x>_%ד\CE2??4t_>=I//$Ꝟ|WgO藯_[uE+Yʄ%5v+[MITT8e.[b|`tPWBB?r,);%E}۠f{b ?nS xaN??^+G}Nz]T|ry rip*#RAvZu1}Z??yvKQB2Ux+ ?rŨHi60Υ$n^ڲz.`oj?? v߽\39>k)#lMETM^Cwe1HܺB8ڷk #6jK3+eeC??I}/3.?0탧1$]}_it21h26V[ŏstqeާ=Jl}FYobjH6e>0s|Uf)BJwɋqF)D>_77Պ~ډmrw^7œȵ&lT|h?nW1AƜ?rEV`nNѴYH=Dձ)enF?0#Av]Dn!@:)ThM1@ň;?rWaH#_iS>8E~yEZ QR,w𒖘8b^6ey_c%5?n?rMX1:bk2+[%ټ ʔB|V:?nX97pӀjq,5edP0M7W?r*E[r;Pm#hLNcmsy}flf(H\d??yZLxsaNEi{W]^?r?r`WzrEF &DȒ֯Dr[H??V:t}.aƳE<ȚЭy<FTw6LݩvG0zY:bbQ??zWo'ӷ%w}K??oJ=Z6;W~)EIe b~W/M.*o'rA1-??aqQ $wpd$'5T5%ؕ>X-?nx~Lֿi}d5Khƅ='wϞa߃:35Se]y2we=ة0 01^9vϭ瀉fI#=*"ڠ1vtVۺ{ŤY>޳_el>3h5g96wƗg`8llsKL* 3+?0J?rƐ[@ D1vM TIs&7B~A^֥B瞋p+c9q42w`^ Q&9Iy;dy=p7͘zva.3,su1Ӽ5ќP語Dl{pl=G?nmاs7f`VUQ/ԇƵzX)z3MGE/_#ao!%7$`Q`O&k{^M.M+7S'-j\qǤ?noRP5nv:yxXYmp*9E8ta"6,V|pcʢˤ-;H=u<ջ,WFZ)BR5]ceç m?n%nҴ ?r׋b MFd WԃnF??kxl4]2Ge??L8/G2v+FaFI_7ۿ9֘yk].FL/2grqg݉D:[#^Z@XVWN!QltrH2.[~3Z\??Erq5M];4"6?0]-Y ΨQ?n@C5pƘ2AȒܜAW?nZOHF- *WM/?n.fs 6dK?ne~kwcX~> ݲh_ȘM$1ZxXb,U[X@Az O/p?nʯ&{|}jV9?0:ȣ&&ؒf1)*.88@@A%C,m2t]]}?r&RO@V$%^Y9itRMI¬gQVc:o!`x3S!>!QOyl$N:+6h"\b{>VZ╈3EiXJyBxXPJ.F/?0?n{C{.LInڨ*$#";????lGG{P?rɃ?nv8\C\$'?rЮ"Q0aFrS@f(L=Ҽ+@C 6{^k`{H}#~vӨjZ?r9F*A\ry#Mpw|t bmH%姻z\6Qi$5?0Q lj Շ#Lrm,)S4L~`?rUɶG8C1#sa1sRKcdLlq4-4xZ ƧolG/z{Z[z0ԤaflGgODž]&Ty3%&/IEW0k G$W=8b)@8zPU*wc XXN[S'#Tٜ66v֎?r$_=<'n 2&dpd] ?0}W slezO3`b ?07Bz9 >$[kc6G|f4z9Y>5hiMI,L'HK)-%Cgq|j)lW7INF%z89.(Ǻq1~)='A8k4_wd:= :Y?r{*S<=E1~$'DYĪ&Z7;Gi8Ld0T10&%S`>d>s:w847uWɫR6}V#deSX=}ҽ Dk5g.M@58ҳDT,k1l63W!}h?0fV(1omۮ %b?ryRDT.F&moz kӸS*Vlt!@unֽ]Pvț;ʷa_[T~ pä<޼|:E4"mh!σ1?r/N#7؋R, n4 dFoxoSPlbz|nlZEUSĺG hbF_F,u:IBMߵ&{pl|IM{I3Ұ,vdV5JX ?nT)4jj؂z#LuG7l2epcbo5X"#}± c?0_=Ndt?rKEx3NVEM^㑆r)S:҉,bhgjckqxۑ?r`3+s[!5we mW$L?r2#^^3R%aބ% -#}h??e;2ךq*$پ#A?0` &]4p\/5F!`fR$[^D{yM/{u$ƓC+¡!׊|XZRHXq*h&?rMD5iQ?0Ggs.'&>^)u`ܬt* m(l|=ed76ǐrv 3̾$~x2'Rq4+3xSaXGtQ:SUdYNFQo@we>N?nj Uq%}/#c"3I$792z`8Y?r7?rf{hYsU|u5>{;/glgS17ㄐ=OQ` f(=/RUsex#bkqC/Yxald0[0_PehK,a`+bX;m/z{ۆݮŹ㮇gDOO!8A^3e0Yf$- m%?n׉ M0k.Y|T1}4??<<ۦUKYP0Ǘ|l™BsJ3׊c?r*/ksh)'FA|Oؤ[ãrSlR`@jOMV/ :y:%GTlܓlֳ.VF?0j`˳@~gA!--Kl1(\VXb=@;ϚVp4LYBET5Jz{.qU'Q;6㠇ibhdz XTË0"ÇotQy776-#x7GXFaD!qY_+RX[fKJ??l@V%X5äG![~8sBb!N^HtᒆҀ[Rֹkg4[a90??8!?rFBpgdĂ9rcS=**hra,8@iFNUh5<"$5-"+U2eB ;Q/#!rKDж2Tw0wHnMYxvaUb؈ƚlhw.Ws*WQ!H~E1YtYOAE͙r@R>DM7>72g[6+Or(|VUB%Sj?rA%uO??>h\zu̗K7vԑ~]Jh`B_bW=??@bwAEKbXr1uûiPYƋ@TcMkӫ3mObFMY?nbqC?rlxy{ |Ж*>ctCټ&V;Vx(֭-N8m{16F=De\yX I[ЏHbgO6<]&+*?rl٭zgJz"+K?ru)jat?nџuKjAM#CgnVmmltgOY;0!)jHIKcp2v4Qu1$p$;x,ۃ;PGvhϯyP!9Z0 P4(q`̓`rQDx t6cuC@??&]Y=\yl˝ZZZ4ل^cߦ]NT%Yմ4"֤VP=B]$@cÀ]J-fAÉ2T]#%rEFlq9;368" o{k#?rT&݋Üg.pos?rP_?naj`FRwĘY]C$m!o;;+B#??ҩSiFOݓ~Xpd^Y=Cu[0|8g:5zLpdlӎo+}7Q nNۉZUJhA=bò -#5?nx-Fx̮gy6JU0="≩y=' P~j9Si}C Zo&n9CBH9IMqc!ViԇqHHJׇOί?0wu3'j?rkv:M/kQEų߻;KƐ˥ۢfu (~+H9֜zT.܏r_ S?nxTR)ٓO6@O &96??|W??}; jS\Qc.E2Q; $NpF|RL!`E53Dr&H3YRY\;ti^g%Cφ?n[~QXrR۷o_|^??Y. 1.W5Dop |-GuZLB,ȮD(l8-Ο??zֲa5Lumpq1]6.WK??&;ib6(uoq^sPbɱF9X9or[IQ]hπPsz+tү9ځb[wN;s??F6-Qm.jא;Q.B,0uu(j6n(jm^]BԾ#hdcxGc vpc/??ܸ(6=??4R&{lʏ`%z8agtj)A$ 3P?06M3meƞ?0 ,IChA{ZOr?ru9<Ыޝ܃vqr" b:hL: )̘;Ƞ^Rxo(H+ƑE; #R.?nTةݮIpE[vP֬E xa+Nb?03pk0w!#!vF s}Ͱp/;D]6 GMq FϷEarO”0LIQva9wU9vRح '\&JvWn~O64__ߜG]GwSﵗ12 oKٰvEcx\qm_ف+?rKlGPQ sD ;wXUӵ2]n5^z ~ޢ77?rcQ1粒@A xﲅ<(r/׳.9FSnٍ&7'ZЧ\%b%#~R??GE,=mS-+t3z#]™??Fxcv7۹&s{JiJ3yz:}M24f(·o*K%їK6mM #Y?0'M%2as79*Z<pl7Fi'p??oW*Yv셽B`J!X-/??=}xmRh; J96ë!xq[Iפn.datq3zeb?0JEµXav>iH*s`;0F2;e XN*eHN1舆=?rHkE`J7eXزs!ome wkh5WX&Gboͷ`iv$9cQ unm+YΑ%,k?n??{2@G7|ь8!$š$À0{σCKJ"v'PmR 9֥S`Bpu^7^QgO.A٘1vA@6x^-n?rW$YRXW"9@$b9\pYɸsIT{Bq)-9Xt :|$"eN}[Ž?rN`QoYڬ і%Y|LS ]ZyzֹO֒RL-Kd94×]åf ) ڀ,]/4}] =?n ث礦_fq?ny5x5إ3#c,L׮n0,[[ox+Ge2BDcQp [~&.侘djCM?r0xi66<?0FjIf<|W`B z1KprӟeݵX5+Q$Q⃂@'JeD4AW-X#"nE Ww+z~ HkItnT詝7=/&(0- z?n Xee45R l:C.)6~XLQCw?0/ 5`\GSrǟ̯UGV|>~ÖƠ͜OYDϒ1(??66ms,,'Vyʭ4QQ?0Qɯ?0)+:[v_1 q"'كM"zLRh|e9Į#--jqAYK#7L9-k@s!BwR= ir?r2n*8;?0#?0 oi^T4R?rT{(?0?nXe_~!Q4??kU gmbQ5C `4r&nCW>PeJ 3f;dD#8Bܐz+D2̷ns 0oF 1Ye8ܢ]=C) |<3??9;ז9[I|ok& ¶[&GI=2!ݣtZ:g5IУm[݊x]J|aYD7?r[~"mwas??h:#L?0~*K1G\oKRth. TmR%@ ;),@I&̓E׋cGCqtu8l;&~ -#דif,>jLE=;iఞI7;=h_-ya?0n-h> PTY-jNN'k4(2"s֐Y(fOx??{v*>0h:aYtKa?0b S]Ap*AF+ZbN"oTIt/0Sܠh6A֨$X#P_!š`}S2#?0d5.;NZ=O/9#yF餪Zc#ç:Vo{w8X|KOQM vo﫰??NBQQ]kF*|{)w-К//V>|䆗??6?n[2"h>M"Y61Ś}ZxBl!5[e$󔴖 m[`dWv9n<$[1wM*~=8cm:"41E'_[?0>Po[́n+D,6\=`6! BQZ^rVFӵljt??]HA656vw;U?rf6ިoOw?0a,}vd-tNo+ 1(N?r/T#*nC=2gd\Iu["4;fAVlۣT!{mOwtr,CF5omG!هDb6OMQ>YX^<D7ie]GSljI/h[U18=EbuMlBh]:#-XI՗Mq!ǰ:oֆ;e~SOkJO1_/K!4j}r"U;!4?nW"ݝe]>`g (sî~4%0y{L3MƮpgl.sbMjx<]Lvlbx4+4i'Ze5sP9^Y9,0V~F 1$~-niTn-+fIq0vNKh9DxPzV zy)VJm^32DνA{>?nD%p}75O_#46h+ԮZIx#[}PPKuKI G7 w+鑲B,)6E16byy#Wfp]*w sŪnK{5i-Z%/yzhu$ (bYNhM^Za-j>voafZ&?0ڦ)oʳ~R`z̓P,N?nZqŔ8+6=;#fvAlF҇ٺv=B42)Ȧ2RB`x Ҫ%Q˥Dq)hS&M/qp????M3:pV߆y<¬zE?nSs(jU_{ *0F\͔0tҸl8" y)ةԺ'Գ!z"v%LԼ8-l@T\^ \$Ο[QޚKIK C& a%JGp׳sI$/?? rEz]xUKw$Fѐ/n5JnH>@~V-_bf);:*Y!~i d,м!4$RXQ>8=vzdW+h詐%9p(r18i ͼ??ǻO)"fJ,/6eaITD~Ň5΢urqld2{ic/q(ŷ9#`W*3??LQO7|6ݚ!gh;l&+V@eiXj9g>**:?0O/!7G+sp;$m.W|! I9vqr% G/$5Ike@ɒĮ$ISsU(H\(:vedHb"o_gҏKgqta{Rec~4-#{|MeEKS:فtAJ0 ͇ EG g& ̥ TIJz?0C r8Z,??(cٻJܣM04zlў5J$a1UXJ%|}?n>FA l#g2=}^4wgxM6pR<oo<sBJL¾ 1|?rA1`:ʭXipPoFX+AB??jWIV]-g͡kk B#8+#b2kDZ^Pٖ?re/G|@:ŗntmM7BPJǦ~ -#?nd_1_|Cn dVuA*x?0.m㾊c{A[&)4QM<_\>?n-CY0:ټWכf-ҡ(TZ -[< 1(E3Z+@`A9ҷOPlnU_òa0l{=ilaʪZ;Z>)5ͻshK {dL1:dQM&]Jvۉ`{)/?n{UJ&z92?rn3E߱KfWBԺvnR?rE7 J?rC.DJ)wܠZ'ݨLŐ:ASc!xlw>??<??U??piS] .դ -ÂcsEu>CjR?n«T?n餠5ETͦ9 fydS&@ے*q;^X5qZ%qwMzepӣU۲&$^"aJ:nAVF8/Q.:^0ZRbHua`[f-YY̍0)ƃsF &WsBFr@ HA??c-"V?0<M#~p6&a"QnAΆ:uuh\`rV0D?n71~LS1C'?r?0(|k*. X7BJOqG9E̠]BR4ܣNڒDLB?rQ^"GUf2_?0z>o(boqISۘ`D0\b)Pj??@AnY <:BX;2h*Q?n,۲>X>kN{??9a_blk/{%Wȑ :TBwEh*A.bT:N}uk%;JwmxFuKk.GEA3> i6iEY.tDOΐ@祓a$+ceShn*T P`foH|S!"9]*DLIJ(ޜp2F{ 8 ٱEբ@ճYDNiyje#=æB<&n`GAUձ%# kgL_]Nvx͟g.}ODܡVHEO?0>(U{R[OFO Ǵv$CpL$>ow(ywDK% ՖbFO?05ْ`PîQѠU3 ѱT =B?n-|l 9]꘸Jpaet/ӣ}4xrQO@{㊯7҈pۻKs;j79G6nY眶,`\-m@?0R)٣{m$ܶmy N|KYM???rNӽۨ&>N"FslŸ/|r$N-?n\`q!Sx> k)<Ȧ[ J>zL?n^Iѫpѥ]E∩ )+4Bya,6vM?0/??u1 *-.=,iP]7rfjS}k{r_|:O}1%sNLfѫ΋rFTjUdٙފN^3@ y^ڎvxkf>/r6Ye&8ꍚs ,-Eo8ç/H%os?nizh0&4kc]fTKդz]v;Z,-Rx<㎜&㰆UQu3U,I  TԤ:88h$Y?r;Mv˜UR;xu1)`i栭C5oWz_"6PMl[þR=YjGvҼg8]WZ=`bm zDCS~~5>=|F/__'u~>q8.hW?rOuM6a̰*/#aH`?0ѼCLWr7> K=|2'pEq+BbQ57)c7#[mɬi*&L5wِzυrvD'?n~e4r01UtxuZ7t7+vKy&?n褣4t@֨4%(UJ *q}jl2L&_O=?n'vl~?nJjY??*VlP:XZԣ;jLf$i -(PB$#S ?rKQ!C"W8%.FZ(x ɗ[>΂y0H ddžL8II1Xŧ?n7),LԱe/O73?nvyq^EU CdW],H(zdv]&6[p^I{^E1A~i`A LQg':NO{,ֆoT-tzdMV#yĄm̻QKᇗye P7ͨ9R:^=u&?r?n3+}AkK9@T۠5Kz@ث,ŘS\f,g[., Qvu.aE.dĊMBn](In_9ze†ۖ?nwP7:JL4/)fs983Ck>za#zt2=:޻jDz8aߓ̐nI{z|tfPQ3 Isls$@?0C>"]K *&mVyPSâ7??a;CL򆊧]nZQ:qU7ҍyo+/sBms4Rq7xsɆiNUlL>87%"}A^4wB'ퟅ ĩ??)d"ٻSv1f% ɲPHܷntc.t؀Z#ngP^+ta]&d$WB#jqBrjZlʄ>>[ƁhowX-?nLfha?0>j6r ,l[?? g7K?0g-^}/_5f63=tƅVN-;[hJ26\BN7px#& ?0q66N7Q& u ??}H@ւʎOJG4GZxD???nr"O9=տeTCpЅ?0 M@l/ -#?na?r #i.~+zM8??{ǰ(B1E=WJ*Px6+4T*j?n?0*6U9r[j\RΆ=X8JDe3.?n>T_3}oZc]Lbծ8JOͧ-K4/K Wh"eF%REX(\R#PA½v[E+,fu>aH|;C(Xk1.BF8xPhJNKjm`j07WV<1-r8 euq1H>Ɯ&>b5ݣe7raH7<7RHm֡l^?nBOo~+>մaʗ+ǧYB_8elu{[࢜-V,Uݧi?0 {8dXn8Rsс>hn(ppk\B?0qH\4B24PoyO|OpVO1`ql/J,\VP ~ec'9߈bpb"AiAA9?rKiA>0@Ja|h1n*.?n( L*o_`{y^g>?0gvj>c0,x۸a[ƪ[b_ٕ=qKB* ^?0wPop*,Z쨰^pcqV)`=E+va- ?r{?rV  Z+FwW +ݺ-e0RXL^bQ?r8 w% ??Z8;Ixd EQ_Z)8vQ>1?rolGQu*<aNzjw@QNap9 ^'('#÷Wq8B>\ËyW9p*dr?n|HV\a6PK0nPג4xhp)6G&L~l2C0DG;^>T4n{N.guQzbB-Jq81:2AY?rYF&aK"$E 旜m6H>-IBHQ_OuTF|{VٶZhHވ[TސJDH}q`گ?r__ּFb`34qa4VfWfgaF5$ ‚y%,ڽEc??*MDD?n ۱i&iTRaEL_䷃V"6pnw`_91(2laUvq Y1C͏mis@źsȋG}hW2z.& ||]'/HC_P_?r-OJ)GVhZ*g͏x2gj]GUi%Y۠%&1ph$E?0SR9d9\piI:u!>X@}uId/Eͺ޻<5-@{ah,(5#]zENMR/aqꘘijbV˴ZrHvY@Mn}hQh:`?rKu@_FV) M֫:8iK- %\T>&qL8:52u)_c,WCX20SUֱJǣE^31S$ԕ_a.7C5萾6tQj(Pq 4rTmوyd+11n&s)GuMI˚7 .ĎztJJ,R,l(8&gek9sMWӐELnWoxULk#K ox_C^Nң@GCWxO{m][wrshz$?0# tƬ?0?r)‘<].ICehD ^<0kDp(Z}ZȄ8@["K. oYᅢqM4jzQaQaQ;ލr4v+FgZ`d-6^`葤8Dۮ?rݖws"?nב.$ό ͦx1hK zi8HMeoI]玥Jh똚ٹy"?r~w Vk!C).(XG@ëH ??\eFp3>HNEC!ZOZɅb?r(S[R9r0̱$[94:Q=I1{nдٵwG0TJ}̏(pb$/w)(@xf@og"}#y5paû?nl3),%cB8@y\re$T'5L3ٙ+7U Bh}r! ?rEgE].GPnpțjϳ".9U34N .+NU}G#ezX50optB蚷ͺw?? /-0k|IdR8Ԗ˹]9@8wԫ} fJ i؛shyؒöڞi`@){|m]Fmh0MhL?nN$x3UC ߚ-0eVO9͊a/-Szc|y$O.19=?? ';b{hq,F0?nrEPψ y2/a+Tc{UjKjS`ae84??d޻T?r;ZT#+ }JI.+'yt`DӅKǼ4!?rAuUi2hN(#/R߶yڍ(_MF%l.+9?0ܒ ơ#0ȋ@߿›a'ʹxȂ::p]{~lzL~<>ǩNDc{-`D 2khǷ/#7ecj[% GA:$7JݭGW` '15j"gQO=26G3tbZ7#IU<8`z饓kHϟK/A!.z94uGW x!8 jukx{@?0?n\%(JD6jDnȬS?r Φ^w OKg[V,#.6ᠴJe#LG,3»Y]k1"ރ9A-gʉ1/݁vZ??2pܲ ?rlܶ%v9\IX<$[Jrue,[4<:|ޤτ{ʷS1}F[m%Be&h},XlJzV=(S<;BftMC꒛7zMwG64R@4(e@O?0\dωd}{/"#Z7ٸQVG%*yl8o_}c!#<0u@^Ɔb9&F,@TH:y%&qΈ|5s62K.j6N[܎ش梵z>jn[7j\rRtu]Mm6]?r P7 z V>pۢ hQ#HjZT1Z _q& DI ׈;?ny!X5c]m^kTw|l+p|~haOvpOۭ\ayQqΚ0 _ 9j*BӰ1h y{(=63{@/(4Kud y&`??9ј*@hP62k<=/BE-WAN?rמjł nK~o^p,өiveTZ?rP0`=%]FE(*jaK7zgABFp$/vpp!%Mh=ϜHQ3j\sh6aл~UL-i(aZA F-xSy.PF+ %Y-??byVM SףwH6 47ɝo{޸]Yi5?r5|BX+?n4 yQbYP)LA?rѳq9:.@\~IFR%„j^v%wܮ΃ dn|hGG-DOe`_}ʣ?n@鿘?nw/F`LJW(]Qf,h@"j%ğf|ߊS;NOH?n{'f/Em|S6mfj5iT2K4S\)KwZr9rt Nߦڐ|O@Z 7ӑ]cϭr4#[(|ɥg?r$ýЙc7 &w)nu +q?r ERHb)r?0 ֟&?n҅[C>N?0)ayǘQM;0$F4G/ȼ IܝS'q"}OsVՅxńELh[q=ŘM@2t/^W\ՉT?0ֹU/6Pw1N0ե" ;8EW>lzAъ\Λi)]v镍%I`ۛU=MI| ??-ȝ}gd&tP?rߩ?0wH?04~D9#>{~ڹ&)6۬CvAv(??Ïgkm-߷eb|w&s!kHk}6DP,B*d)2$LP"I˔H=:FP%QŒZS$J$pPjN7]{c!`۟'Q{ GIlsfYGǫpu6E깖Tev}OJenIW8hi[VOn䔃h>i'Nd,/t[b%]]\hA~lf9ꗗSWJi)4s:&]unZI[^@*/GBUJ6q1Lc v5i[/6,/8>?0VF6m#Ymj| m#Zme2xUXeJ'XyCs}FE’+CIj\de9j9T5YD[64[YP;mol|=]6˾ F??ox_GgF~M9]u=_{Y/x????///gZOp?0!;>p۸Mk'wa$U4yщጼ[vƘ'<#s21,C&>zE }??|ty.2T,qCG_1\: DErGH?r~tѼdکLx'nttхgԬg/|G姙nvOg׏]m?n[+Juc0L8>'$t}P7} ĴѽI`'.&>=ngo.ţboS=9aQ: ./+m=o7 I`4(WtvVNsd c08۟Wq\ۢCUC6>vUNڜQZBv[,4:?ns]kr|!pHQ YCW@   k r`?n4RE+vEPM-ꦺiEp`RT^^f~,BVbb;Uуt)S`p+n\cKKZ-?0R"jevA\̆J+:{G{%}W׮M+0gu(RG?0װB;#@u_:њ  m("2W`r&:~d (s&L睐??uhpdhqKI!~J"yyYibOPa D߀H`Az"{ 3S_驠ה$il5MYr!^fª=8c^NB?ntDiVIz* rI8ʰtUZhN /(Ѭ'Tp@z(5bdm7i҉.7ުUH"W?0':"b -{o GQY!U8r JXoD9tfz)HkJ7JcrBy_Dp")?00d Olz|@lۣ:)5>^j$M\S<k5Ab+~08OˈPIZL=kBSVɶ͂u:fX(%~) ] ,Iii.Шvuv۾6Vd3À ];md`~g@[~Q/YudYe$ Ecttk*.Dz[eSe%K1C!D=R FA2[{Ǥ ??ki˚"/Vz\$G83Ac&oIC1̅mUݜI`C奍4قe?n$8y!y۬ {,{kwl};T'뭾'x?n./??Gmқ/..O'}9mWݬn[t?rgTu"''iT ?rUA9hCGPfT}Ȓi/(I"&.]6@e؛KҤKi Х!~XdσH7jxLr>CӼ}:g /%rJGv6G7T/ؐ&yU5V=+-\,غQTma%'XF_1jil]3.~0ΨZ?0ޠaZ݄c`9jWhoVpsT@q??4(Ç(0馅W[%5p^U"*^NDM$:r#pS=1gsS?r0*YO04?r4_н~QY*_=i;09MSJ<03R_+n2"=DW>Z嗟cRhf*&aҮf8 =QlCk,y;~?n7Kaau ۠1еksW2  1  o9S?nvđwv@pwmgB5EMjϱÇijXRΒ\nCmùŜwSl[Ⅸr|'??Vm^/9(O^E_Ң_R֣/s9r#^0X/V1N[V%dqCv³3E0(0W=kYQl #HXnhii.%c?r?nqn[7F1T%brD??en˥ޒpA"/'c"/O4秸 Hcぜ5E\^)Ys~%AJwWIBD?rUܖ]rY;6kٴ=l?r#i xfE7x;:@wp?ni??^ȕK^}POa|_[U/r&5ɡga xNTڸ.EUĂgb??MV.a?0C/+򙃆csg/_gp­ӝydŌh}0 fS7-cߝ4XN:YfkrRW%v&_PjlT_@<וeLMWJq?r*[-?0Qc[ց1VxGб;9d8O`pg[vqC4C/8I[,hF_vR;q/GaŲC>D $1.(vں1N_ji2RxW9#P@@U }2Y'̏wƀ??ƶ9?rXqx-6q|qb 9{ $>?n%SC?rEWuc-shoY jtXXQ"=R[&u{[6`iאF 5*(W5@(^B*D`s.,'ܓFY_/Y%_ ,??17>,ۮb]CZd1{?nO=ʾBghY y@ѢK)W:ZPp ӷs$q#U&N`dɃ?0}OH^ Tԋ gLcDl 938A׌0t*]\?r8p̓+fF,R:zd$)Yr`E<:t![ͨ?r۫Υ"W!8*Yڸ`A-a1&?rmy=$K~:?0qPm.RLMni1Q}X΀6cI?ni ?0 8xY4IW5|ƊǁF4+P??W-NcgUqdݴXG,cQOS~/Xk$G= ~ͷQ=g[,eeN??lHς3H9A)Zu5O1D7+x RT-z_;B[ghKp˷,I^l<.1:|_6qjXr?n+?r .:$e;w2mI׷4])ZPZ Nek}{=PY?0zRtH W/<$8FF"[N8a/]M>gv|7TLBès:JR <3p)8#Xa^k(Fd@v;xS)VGbi/"SnH׀#pZjnu??Wng$Sw27n;[,ѶnFrrHe^ɱ &-k@ 3BWKLntvˏ\Qrpr[?nﶁNl/Wc%qp*Mi( 9iVI1`ߪ-E{~?nN%A좰"sq*C2 LG]'QUR~WqSNs%-P:qdGa|O9A19jЀd˃Tc}CMmoª?nݒ7-$8U?0S -[5x??_FC m8-mE:! x2jݿpYWb1MsvFD47hM,$9r:r9 ]eU<p;Rrh.f X15*cGҋ=56b{d̵?njdWd -D0,{1xO25-XrX!e1VZXK"ؿ]eGsXwKlRPp ٽ_{-@G}:6"?n(0XƋnɇไH l,Ȫ4'2Fcd6^RTv#yղ0ͨ,S˲0W4:4c$2]M4w~^2--ݸ[VKvx= YڒS7UJ9J1//=<5q K(/fE-{V*Or9mIG+aBQ&UvYmyb`ޅ磥z}]RkNƷ,"~=_hDqfjc }ƆC~ aC$??*+G6 6}?nUI'QX,}7r{jd_2VQ唍fgS3l0":z|a{`Eu9wհ<֯fET!\)^ qR@JF9dPṚA}} :LTCIqdlt9S{񔒉5YBj\ʫ??'4\ ^^Y2oonPџ-셅}dD\FڒLKj#] ?0EӥmM!4)5jLgb~9=F<L &??G??B8g:8hڷAڇ1h%2+xsH[P(VKD??q!Xb@UcqXBatPĉn,rD$yBl'l0POPz]J [1z'Nch2%jG\hy?0;Q5tՑEce;@&!:>Ž#,D! գ^I|͓?nО5}l> *r| @ȝ@݅($S%<")<>DXQ:??cC(p}KS?nwvcv9=(sŶs?r\4jb~1cU2ͼBKc٘>h,p?nx>*B -p.4=s6QD]}>?rσꕧM麀}7q[YK*7N¬0q.͠+;.F{zTn+IJԥzD>< CA`NP?r'ͯNqi=]kgtMWaX6a;g;C+qiq#[?0 'I6" ?rPoMRw%oNJ>gj!?0At.HmC ?0ق7_JB![?06g?r8.xYpJ05vGcT0n5'r<|H |m@e8xI( G&1Չ-lEW,7UcюTR^>%@.!Hߠa.hv׽9>;>{*'N?n8x] dF v?0H*Խ1ޅGnP r_jC:d~ĸ^J\gE^?r?0$fx7Ai4鱨H;LlǏ??W_ M$R??~Ӡ)~>?n3M\vv,f"'v=@F?n?0'9o$0O($7]`ml K?nۙ7K܀J[G81GB[?nkYp7yRh-<ᇹ$?0o`N\<~]|3pE./>4?n"nӅRI?nC ?n?0x#B#mh?0*ԝ'p Qp+ r8kDʇJ?nnu(;*gy\pg B/dgb,v7x%irX бKS1 ჱyR-TuF_ckћߗhU??m~MçSZC0 B#K{ؒ?rlbi?nu0X_НM??;_3 I Ufˏg>v tL"_?0 5ߍ=4џdI,wG~̱7eWӾ旑f"?0鳳v*32"223/2Ho+2FMͬGC(AS%?r?0h}f"h'bW :MXFqGŲ*l/Jpmk_ъ]HJREXK }CC:뢵L7Q|n{cY?r(w7)!;{>]p[ )6{++0AOH9}nnGEndkCRie o??az1qZ#~RW4Ы?0}keXwͳ?0~9IV쮟nY˺Yum@va0Ugc. `|M޿_QW-Cb|?0Sn>,M 3DIWy{ R$>HnEb2#9 sY j_jempنftAJO?n}Vnl:6܆jp%Э3i, Y-f?n <Oi'Ue`߾Nf~x?0< k:??Gьm(8??'p0&KU84%NyE;,~c`f>Q/ǿW|6BnU-lu??yq?0{9xy&\)K9>5<*{+FV2.W0<]y6܊`LAW=??նZMy'P76O2 hp$w7n(|Dη''z_D f}*2Uc_G7_{sI0)ST*K侧f"ZTIJ2_'R{4qOW+oh/$./"ZQD75qZ慏z7:.ҒHU9G&xo W'IVyAROP>1t's_/~L`ĸgWAwGG`O_~+^DUn nR,{֝)x%kKe?nT*XM(Q+?rv2R!Z'RhS+tSZEWIr:idIA.H']kᜪm`6 h rUaka*\;7JRp8),SK4>߿*Bk??.Uz`"".q0/T~p6Z|="V28lZ?n%i]CGeɒkBV%b9YK޽W?0eЅYNYe^{9U?0$SOPjdǮ'PZj2f LSIy?r%m]qF$fy6?ncɂX*ɶ;IM౉WM= igxf$K}y58ǩ΋2!nK08:* SpO??\ȵ<<G4֮ =P(XѼy`o>q,8*~SBZLi6XsGO\huq?? Õ^R+0U??mEN$^Y,:z$??UE{q_?0_FwIȦ-e! Qu;OԎ!.)$j,3zyj5|{h=tm:iGzQkmP?0Q7\ꞛ@IQ]$ʞ[e??8zaavq!j`6)ptWN>ӧA'ˤE%jaaNp##2H=DBl4rKʡ,n[nY}!7b#)y"/)Bv2uo~zUpv7!gXlѿ_״'7n??|MU:DOΤMw{^meLTY_l'VH9FuA&$R'?n< :R 7-ja8WCn=#2?? jh ??yC7HlPKaF1a`8jJkXI^'B_ʫlQ??'1ƲH?n-.OrFۿUU캏\zwAt}vɩ|!nKwp2s@ؓ0 G?nݝ?rIHܦ= (yɵW!e o8j??]7JXTj$)Hn#/t9q M{D'ZNt`ueZwFzKw^1/%7L)?0ѸM+ג*rC,pVq$WԚ)+> :Tǥ0nb!ShNLp1 !ַ<Μǩ#LAbÿ[(#ñ5.?neC"*{VylK[nV5h95w$F+agPb4*}輥AX@k.[Rq2Jﻫ"@hOoPe `?0~ Z,yϵ ۷ Jy8BtyX{xh RYAv$LY&āH,[ԢxFa9T_#-Niiy1mlptlU̒ ns8mkCVmV{f4֏a$m `%aAUsx[Vj,QE8I)|GF?0UOH?r_k-B IT, T_" ;zUJ^9ﶪ̸ cDVmc.+"??OPYؓpWչ?r-5?nqc Moa^kZrߧɝáѽS IjȆ4m5E&vw<[0f++S'6*=LN(g\WR9dX]u_H*֣.Lfq0͸rJⵋ`m\iPp--޶DUaǻquFਣˈuyve??0#wVrGv6Q74T&כU't|2:C8gm!#7f}fv3G%'֧?nE({TCnVYӣ|qXÙ?0q{fev-/#cL>!&N??N>y_&_EHIE,fz]V z*CC!,Es??i*ޤ5(/{ZiƮ5܌oQ$`r;wS??JSFpu>j_bU}*uҋRz}4Wf BDh* G c]tEG֟_]hdVkp6l>J!#6m[pZ[0=jwݯ~yW9E]zTt*TZ喾fc{_\njL {㒾/tR:Ps݋лKFrtj5WỚiƒ`zw?n[.Oߘ{KM?njA-5Lw `p?0 _=#(] f$,}6lU(MtPQR}ejnlɬNduRDU&(Tj. jrV??ưR\#W#@E<ŸDD>F:Hfn?nc/͸6wDm:79ӢG?rAS//?rq#._p;hevLAă **k6"B6d_?rufzoQ UC;=Am囧c5?nu+=Ճ?nnzex?n=>ȋn ZXi Lύ u~^}̃{R$՜A,Y o!B|P]9)=;oٍ=q9>=;#?0۝<0<zmG7M*sTJvYo.&͢fJDY}X2! hi$XqջO&VlFQ?09ȤdNW)"N|v{%:peft򗛤@6?0ͧ̍*G?0kwj˶Us@Ũ!!`tĨ݌N[⡯;K]A}YK:"ZyFO"}"ఠ.x'`ˁSUz|a*a+>F0YؙvujϘNE|рid7ͽݑ봬u.x?r|42_,1pUACq9B,5`x\ӊKAG)pdHh<^dFđEiNђ% ֺ2f3XBo](DQYU+r> {?nVZZMJaRYn/ њ_Ԓ7`<^%ΒGK8R8H42Fzn4??׋itQ8/N.w~𝎬S=\n|&r7??>}/_|Ǎk7??6>ހ^`hS.lR;??@;epeZuBBvk^49^L0 Ȼ!#%ᣞyYΰrkSakg4Q'S!ts1OߎK<1Ee]U)^amS}_bf:\uQxG?nLTL75Ci>[}/J?0D7?0Oj)?r-ٳOx J\6,'{dcSav?rI/et7R ӎt_[D-nr?nC.-_uYw];:}kdI*_EAzӰ3W1'jgRE^,n?r#~V.2oGw0!={x?n8<.W*ۡ+6ueV'\%jЛ+f89È=L콪Zg QS_u$R:/L#KPҠM= qc r9/;?0p6L +&xz.6}"(`&SirMlŸ7DmB;; Q^1:>ԟ#/=lI~Z5??DQʃ@IUAsZI3_:s]~t"ki9- rܨ)Ewc|?n(6yY¸]>C$s/Kk7znSMr'K. =I?n(o5MD>Ĺ5Dl/3g! R5NO;ҭOVϮR҃"I&(Κ%yL?0m??n}1Dݾyɱ\=[Gm۶h%t?ngLz#c__DED!hv ?ns$'KuUZ&NyFU:92-l'owW EUW^BG*)Ht7G_UXf_I/+>(ov\f1ki?nCEXf5-P~f\bFWr&v{rAaeU{<{wwe#_XwI5 _Rh׀r; 0QqE rV>tWFh޶\DݼQL_~=o]95~y`X@"N<0uX`{yӨ8чb3(v_ӷBL -_)`v=)r|V)wQ$625.Šf Ý=6?0s?0#=0@|m*??wwk0<}xq; Ǽ??EoOBbk?rZ U)Ͽ{o9y8T3gˆsA@G%#޷`V\x*?rNK W-˵7Uy`?n<'Tݐ:^s$󣃽KnmbR%+ºRqEZ eL{czcC?r^{Ca蠳(MwwbNiԸ&?rYTxlD)N}%pF(2>aUu?nVeJ5jC%kgu/Z= C&nvKvOn)PpolpҰ aQ&DFRb#2ZUhhG/_̐#'jxFs5BϠ#2KS__ren@Py)3{,BT.6ڂ^'oɲ\S >#/}OETͰdJ+{,G6,P5x̑f% n +'4~fX[6fCIoG{˯I[ƽdY"0QK:Vg纽:0OX6ꚶy6y<|n;m]tV -T7@@1}qx=[ݿb=~6::' V-Xűzϊ;*|fb ~|Q=K-T})o}}'R8;Iأxш4VbNc}-E/wp8C/=c'/l绵⎪3@$BcN׹w(y=U7{\Nˇ+գ&wz R񿇪JToKXS&{nY6_??|6YlG!MA R =ב?0M(CfjH??H_P}ӏ߼8/Og??xٛ(wg?n?r6K~q>u2~ze"Տ + &ڠZ)O,j!њ !uO§* @S]yykzH 9Hv_us~ʍ͖D[e-ovyx;(eaV\Gs??ÿ9)ʜ1w Yo2UAֿˬ_unaTɜ[_׹??A#`+c"\s[PĦп5~eS;؄o?rN)Soj'^~f8\BNu?rg;m~av\<&oNp?r aiDjqy5(68z{}JËS~X)?r].TZ_L߾~7!и]|MCԻޝeWL2NGMfkN<\a﶑}ݍ8Լ.~-ꯔ'Jk/H|K'+؇T?r]mdmOř߰7t=c_zyjGFʄ?n%(n}^oSxV${3 ?r15po> xE"6@z4ŸxlG!v!U~r"[OኼDMKW_9g&SCwK;|Gr=G2("'Q Ukjěw;/"_U[t„6LEidX[]H]T^.j\ ZXo~avy$6,ǣS??be+)$e.mVF -#w6NNtF??o7EUՒ[ EMN]UNcueB/PØ?0q|k xfD-݇=|?nDQI͵BVs-9KSkͥZd$9 mnI32dm͈TK.<epI%'ۤNQ("t͢f;3rULT(I0F:n4pP2<:ݡvi=M0HFf[*z.}hMN݈*t{"Yt2Pp$`v/z.nw֥«`ۯȘ?rpc+nI kHWp:@Xtj[\pH^ 9 -J0V"??`$qJ>?nz㮦u'+&LLFzapKhH?rCA@Qst}=jqd藭U!>f26[cՊK_]V {[/Ѱf@zAfZP–d[Il6ow{Ul(|ŷ/<ilx?0Ru8ҵa-5 _+fG{a1ue!&A?n:?rYj0[L??e㤿l-0?nIz]f =k'-h}"EM:LŶJ罷(%RIlG0 h!E׸`9eD9%X}$Ebw{8\f{g Fz!)p'Exi^wy\Oe UycJ_%P8J3yX^t\S`:Q^ߟV ?n.(fF&_*Cu׻_&18s_iAXkjjk[?r,*]v72JWpS?r2aO~TYCxZ>O 'gKEbGyUȦ zage/Mc>ᓗ#rkd+z[Yy|*zWzV9&w@ofyֻY|NTj {R襬oUB|Sf-PD!ʤAXҊnimU]"<(V0GW4P+ S ="ffKώwL_"(|KW&U0J #P~rd %Y?rP_Y]".iSc4EǥxExE?rJ7К?r~ѽyJ]6ͧA:.'fHb ֪u=&+ވ҇V{[nMS?n;Z_uLrrP 5KКz=sEz+!=N&{O_.]Z2!-X$[[Ikxx6T禟=RL1)&@qVjwIJ?0uTAcnYJ汎j ]8!84q^{@O6?rY|mUYLJeȩȟ;! fli5dž?0Iښ\{WL3eSwp*wrwPC~xQAzp1; Ky[]%ހ8{+(̆x9س)YnvO]\GbT^J8-pͣs|vňOm?ndž3&>?nyb?0(:e`ը?rl Sopڀ*Z0P>jxY;~TOb|??K(=lgn t:"1x?0|?0k]C($_:}vR$5.gB{EMXAA?rqE=_'׶u[!??~sL<(ḁ"VCUZ oSuDo_CX~[[YBǛԑQA!:+qRS|c7A!q (84 ⵀˤ2 1z3@6"G=[ДTAtwOh+ +;D{?r2Oskcm4j{eZr& ?0ߦ1Th3X𤤒bV5??+3??=??!ޥF`¯,ϐ|tۢᨥ`ބz [:fs물Tx˚n3".WQ,ZtU }*)SJ6p\۪KltwGGó{l ?0@ ^2Vާ ͽW_ބ&4gxF_TLΖ~-?0cG7~*G&!l'Ɩ[Iqlln?0YD<[)补8Yտ_YSeMsӧAUfi>Q~@eQeIƴJ5ʉTGYM|,ڌK#F\7Ǘ hהß=bz.*k`!22{wn& -#?r)߲:,cb#w\f/:5K^H3mZJL&r/g^6Vx7$.NoY[VhQ28݇Ij?0_??Ot+dRNr+{D,ѝF˕2!\5]..!hgoMyیE7otS1Mus42ZEu]̕+KXJ.P^Ӕ5YJTdRڕE0lH/M3Itގs29~5XX\*-[U_Uae2tPl nZhnK6GK.7W`) KEpWUТR!yLER??sl5܏~x~&$;?rCEBUpZX)kh "<`FdOkd9-Kk1M?0n{AOw ,EF?r~$f*Ҽw>?nCyȅzKf%f0jCK Xhf!IIa(\jllb0pcr#P6Ja^\4qTK3V`3:Zf(,>,:??†I7??=??bB)68t`)3J)Јxlͅ:\o،2ЙCFcy;{[g/#}ڵ*&#sDwH 屆W26/g4>tg&WLJTg-?0 N (,a܇U?n館R@3YʹH:t%siZw 8’QvIz9 m ˡjSkKW*%;+eTP>iDfrJ@mQGض^^Sj4PDԨ;a;9Wi*5>AG?00tzK]n-QH3-3V\n-xK5}%E< Km=QNf86?0NvC:M32u \`Y#;r7_"ޮ>?n:ru"8 rLN5Aқ?r"d/kKS` bJm3|oY?r[2`LJsbW`zp1۔9JYobnM GfTi=d[ p(r5\Pnt|s˅cBbDKܳrR86H4"ȈA=Q;fs.sڅ ]/?ru3GlbyYRuX8/&(D#Lf;9G2Eeg-g<Mv"F_~*>-∵C+AdC\U~ʓĈ*` `<y/yd9—vbOshC 2[ָy@$hft+ H)kܠʽk6C9nV+/}LbUWuNs!*;U3FٺsVicB@pFV\fXhRbn$f"woR=_,l7xy;_tfG?nR"9xËK?n u3})?rc3Nh,*wКl&sm8"QBW̿C&4! T??y^F%~T¾s(r1)<"i^m}ˌ"8Pv}?r*7Xr$~%0%?nHvZF8`J)P9??|N$EEy:gy(^ (`O!?rO7Gwi$iv=|瑻]4_. ˯Pt4kU.w=自?n TQzU 4l EIԴFa T!)jQlЉa'w.Byv xyзDٶfEx/ZCXP)rUruUrK tZad=$68̯xJl1q(KER?rVGROz=i\{7 hv;;Ұ3?rį&7QuT"؆x!>Ma"԰\""n?r1.[/$P¹j*7E 1?nMa0Hj'e ٌ7:_>;!CWř_Wֿ#lS1ߴ$o߶ejNߡY_lHpT>affbTk8jםῖaGo1J%t y[Icw`.[ė^tiG>ńMsI/[7w҄-u60f3!t|0E[Ntu[!2b(dxgPqBoYӈ!w݇NߨC?r_rlXvdw+?0c)ݗ,u+d1yu'j@JJL>R`cnQeȎ\j)?r(!yD~sĻb@sfb0f3hrIQz"9qMxHʝ#RZgl1􁡿4o?rar{q1Y'>-NI'P:_"2'|??n8c?n HePmX/#^ܔ׿&q@C"اsQIQ(UFlO>EBxM%nJJ;q) nQ\뫖&ZZ)B|a-h6%)xD^w̺&W,g٥e28ƨk0u@ZDzi'q@hYk鳣{W䇿/[a+զ׽>BWB=??l%xm?0>cyDf>giTR*OP0Isbu_'iC,Iƛ)S:h-oZr4DFyoozmo+@?0eR)YzfȪ ϋDP0}@ $?r{'_9+ OZ͊~i~ᢶ޲ $JEf,e_'qS쫳"itӭ'??~>ij~Zz\v?0apScqԔ&#Ge3!H~ FUmu*d7pA+uDLm-7=]D>.xc?0??>bdw?08i©Cg??msXK#u~{Y;v6@v}х}C* G(-4H2??$a(GdK2dZEHnF$u)e[[RNjo&˖֞kkiFWM79dZ@QrCFL$n??}_'D:ǏՅ֗-ڀsLu5.5/5 b_r0ۯi{BI3Y%^mN,r’# sk)|fs+HBhPp?nVQ~kZ+K?nIvߟ9zr|ct˿IKһ}D *qm&YOݶq(ǐJ*3o<Szv??N!yI(=(.:õ,ޫ'&JL?naRUnX|G& brQ(O^@^<?nSR8<,(7"Lk_{/_}ÀZRvXf6F^\=}ɋ@3q4(H#8L84yj_9:~fW?0̩o%b?05$Gh|j|?0TTZOۙXӹio{R"+PZ=c̩ RBU6)m*:H]Rs&+?rMZѺ\(]K"VvZrmu%4]@Fv5)T~?r9 8?0;*ǥ/PWO]W4&LLp_0R%pj4p1@g=/*#h҈E֧';**ተΠ?0.00IpA?rg̅g@(͍AF9Y$ q6Ќ)Ⴅn?r5/ya1!Vsbה9$ishh~{rgG]`??MKU6V𽜁jQyͣ 1?0@bR4f&KqZ 3rGV}Mv*ߍ&4T&!:3\W#|$CoIegVm}izH42?0h8Dudn!%=cb₸0*>??^}Wt__VḪ~7O_'?rO^??~??ysB)唴qq~؝ yx{AtGT$ O@IQ]i9]/^D^ɼwE߽Z}u`.C}z߃+ܮ&ld'`{*{(}p;5^^̦ZO0Q -Qϳ1HFKj?? !#0.?0RT}\[2iX=Fc2>~%J7[ |zN\mv[ɭXc]pF~??D\o/'0-5. AT}k)O% $*8%Ǣ(w}M/)c*Z[δHfj)5A%,OxZ"J359Ï&W#z,EYjtP3ËJ??Q<)J)ofWQvmh;tL cO4wZ`8yb=#RC|aTOfd5Ƴ="蓺O??M SDl9?nMjθj⌠}~TU~1$GW~rw9+%W"|F&jQk-??R_lݔcqʊ!S7&% 0&g b9B~IEHݩݕ7\)ZDx?r{񰤐E*ED_K)B[\?nA5CY0KN8ĺ\[| {d(@bh~xû_>pgx>v?rR( >KgT49'WצcTvyVwrGȼ#v%!{_J!{.H$\NVN}={KGKRuе2~xRWqY[%;^zg2P{ (Ǟ9?rAul{GW#儕?rJog!|Q??zFSuB+TT:s8ڎG£Z7$43! ˽+'FpZ$-ΟWsOEOqI9E)~My:7y/|CsV xG~F'hF,7APPT(d+AIpW\w_ Ɣk}Aܦ2_ %|YAڽ|ͧV5-~N;g#S4-Iۙh,UK 9(.iJS1_̭qQ~LwW[ 6eL=UOz\cE2{;W|zxt5[[֩JR*t5(L4P?06G7}|B7N^wqt(~i8?n,Ȕϙ&<_ctqIL&9M4-:e޼| *‡?rdo?n8BeMH-> l^xk=W10t.E9Ŷ tD``&S6==ţ(cPe 5J?nQM'ӭh8[ЊOӑ??B]t,g!xcSOl.jDA*Haйp/F4@WR͂^@ X 8IVƀo# ˘^nzmRVnmv`/-P>MQg?rvcT;|;Ԁm?r?r>Xx5R1DXsF<]X$BLtsc-Fn.#{PQM|)w`pFKhVCV*BQw;96l7{s.h+2EӖXMU%\\)*HI1lW9"÷iq&RG:GW?nJH؟z2Ζߜpg0Qjmw^⁷mXb`*1àⅵ~s:o_<ōu{}2`N{'ïI6??yV$mQt"aɋW/OHBў`(79b2VLպ'8&dzLt9\hTxSJs?rO<XK"niw]3y~){ۡ}@twC_&NP=B݄iV 1(@ kxI,x&`A4݄ɩ(rH'0N: M0!xBN.Z~rҠ~xq!Kj2 W6*u\9ɷqgM67{Ͽ~o:[{e`}3<8+w&V+śmlsqHrʄ^"}?nwg_8| [rϫ)=3 rT3ȻgKt Nz0?0t<pV/|j"Qnk5a=D#!,ٻ*S#ǦecbOA.aކeː0>#`w!GynڡZ7L C0y[mu#.>+X]jC O:pRǁ[Ƕ9ިǂ}:1}ʌخE+gS5,֟].eBJpY6SȖZVY??s9CoUo1heɔJ^/ߪ{Ip'WA«9_z؄^i-h0H60- JzHgg肤(4̛I6ܫRv„+8j|ʬ:4+)?n60N-J J4r-??%8E ZBD@?0ml\C^@أ~[y??RT]5yוPl`"a1?rDi$b3cKql9/[(@}NLg)pHj/6%D>ϥC ft2HXN9 'u'.yΌnn[XO4i]*W $2'_U?nX4ioIaR6COS*Kߌn#Q-$PZ*݄?nߦ)]oq~GCÁ#װ2lHjRw#<fʹ?rSJ2ASB__jҊ"B} Y\E6a'3KttӤvٺW[PʩB$k/=D- ]RF4'DrUx:-$$n5e}6LL 08Ug9sL ,zl($<ږZFԊw IHz-dQv`atABqOE4a2<ީB*?0C[eFzXa1, "8$C*O~NQim{P3N Uʊ0Ji:(zg~%T`c}H(?nFY)*o]Ct Զ_d)(<v?nP/o3&g/1Jy(KWa(QtU[VQI7e>c0?r_r5fvP\kdnߎ膑1d$??M[O%??S>xax?02AnǙ]&5V@ *uٛk(G06:Gpu*=||Y+0~qUǂS ش^'%<>??*QBxXTp(0'j< f2"ɖTS_;MN7ŝd#ȆR#zPB3/yvUʶN$bVT"S`?r6V>?n;iP 9W 3Y;,KAdr -#l:j2(,[1q0۸^[ v3TYʵh&YL#mkD akЬV|R1'`SQA?r üyS`e{&jMw$uxe9"#rf/cTI#9d+h3nn7 Fy9OV@=cOhO{8.~I_U޲tj6bA??yCwm/w1ػQZ㒡1YІa~-Ɵٍ7oyˤ׍3>f,Е#YH?06?nT (xZf :It&OI ?n#R.nbv~ѶF"@Hdy Y??`j%\R`?r[WP{a]{9*U~_1}5lCrX$=ZcsY,0LxܰGl7^K??uI3Od3î"sJ'ǔ?? U=6\۪;k?n1^ms ʃ}ۉYӶݤhl%sj~.ƣeuVo!g| {vs۱ͦ?n.l$4Núr(-7J=ᆐN T?nb@:b?nJ:|+pwiAg3{Tnl )ծ[bb@͸6!%=TNcAQ'x$|FKAn6Z ;-Dq?0 8wZ|)8Ǿ 0j?r,ﰼHM52w.rN3!E0[_ܔJgIUxē`836T/}LaA5DǓj :?noxorB`?0$,?????? ƢKQYz7,,[ׯoY%#?riO;%m:a;vy ƅZ˪Gl"%"")7N9ÍI}%ϰ焪yէi69.[dX5eo{*PFx::9Ԛ|hQ•iEI??I4j={ƽUCِggΗ|:om$,Oj|L bd3W]α5k>؇XYsnB~Zs(s?nդ/VA4}69sAd=vMdfBk%mOZd~uX*<̮m$tQ_QxeV%4?0NeaHq(4WjSVRHF¤K9r3t2bHKoYs,kC#I.R١GЉ8ST._Lk`Ƃr_r2+^i܋j9 ;X_.Qcx,??&~3ǹրy4n%⎮+V8(NC^/n<8=@HE|y32?0.)n2~7prP5(pkvPD]G Wcov]0Y&ZZ'Q}`RgA{{_ܯUX^Hc[R)AP$?0Pi7=1XٹtiLA<?ry{9#eGj>Ķ,ޙXZ=0nJC|(ΐ~Q4_$nM|D?0?0bbD.Ik4rHNHc&)CWZ<&ϳĈt~6Z(L|'#䒳@`(u7~V,L ZniLWVd6I؛@BIh5V4HS-ip '(p (M;t8vE_|ax\۩rR7/CYb>$R񑷻n5; x2aQKmW\Frv\M?r?0&@Ȅ!Ft佑ZYZH1Mi#g<Alf`ꇀ,'S)Jkongt f '`|3PeXψb?rŶ9BA^X Qlm!mcA~< S/d{ZaʠX5g|"q\˙ȝ=J$_L .[՜)2튌 |Eu; 6SC2Ȯn6m*$Ç )NSN#y0*Ew@S ?nV՚1!ܥ;u)iVv0P>]?n~ Tz^ҮWge󸩍mzt@AnnŁA <6?0?n@7EHK93t3c?n_tb(m*\yŤ}ݰPz(^|6@(*Z@e1HY@vXB2%)c"icZz'P}@5 Gp辌9e=5> D:D_B"P3$P,fi@??zlVz44?n].NaE_9F]2nV_e?rޘ<{]l??PLe_˴ NJkk=l%ngZm;&05]v[,<`&*ڌziYzI4>,an1yQQҶ9Fm????eQ?06]>6=E! .{PEչ11Դ|SR?rӚoLat+=g7>]B/MrLsT -#hr[ snqd85A{*MrNFbjLS9?n~qG {[?r3ю4[l~sX*VޘF{EN(2*U"t؛YU߁k@3adNv !$;5R=UHx*C1y'0KLm߁*b'Ty33jIυ4??hu@<5MX8 Pq!:h-!_i3aCٕLz#KV2$/!ɪhbA2s{0N<ѫϮЍ ƠFzЖUvdB*SF9_H]A= Q$sRv=#Q >%$878a˗nB@r;ǴKH1f~a}Pw\uQ먲FzJt˗Cz/FW̽6ܶ?nݯzRLZ+ 3?0,dn-?n+4-[uf??I]* (y'aRXC b8 /8C r$bVҗbƤpwkLr|0]NB`9YnM4Wyvg=?n0(͓篞2??IťFm#, J!f~²Cw>ZpkR_<{_Ia·fA3bwL9`%!&dW/Sn#$bf{!˞F+]OGB5?r~ u=Ho cZQ[`P~2i^(ȝ7i;/;ir??;DSw9tL%?r?rλdXPZ4t襖m;Tx2Ƀ*Z޺*U&eV8K Ӥ_[Jb;5gFGsvnH\)55_x >=D.`壜߼iX¦׉@fk m'0 k?n.y/`[0V6fUoU&n{JRa;]NB|`}Rd,JM2!!Ey87|67ٝ@@5Y%s4C-Uu婶z-$C;?0op(rLkeFg<#/uJTo.&~v۶J-~zcObU܋wMdY4 ՟efŭ ؒ?0?n"XPw9T lviWOm>͕?0/->rwW{/) REPp]bT)CjǼ{|msSv'/^ǭ)fk~E5j'a2MN-}?0~P"9Q'xfQ**?r(1bm>`nCar%+a2tCyAS9͎#'),&I&h]Dt(f?n!P^zH{YX&$ 9K<t¡tlӢ+P"Wݦ,PBB2$UEJ*eBoE$~ؑ*bGN="b, rT@Q,!;]@Å mL %!h`5|Z xGۣa}ū  x43W2J"7*|4̘Bd<,,wSTf3ϝgg*̂ \/u::Cpy@v9tދU<\ssk Ց`oOڱU7x:LC2gspB*UlfnA$p\ڰ)1<9ű?n*kIi'8썒,&^JzrAy0H v- ~,؆7((h??&#\o`ǡznЎPj\2>^ٕZ?ni^dgYl#?r#YK&󢿌`lZ$[YFءϖ!<6,~g<\:SC VmA~yir4.ĭAOk4E)nW21yoH*??ɖxc{r b+*6!zx3d`XB; 2u5B-(BaVoQm]|U~4]6mZ &4&̪_o~r`bjpks,\5č;W]Ȋ¹?r+j??GثEyD!^GN$biMӍ2@]][y:FyH,A򞧉NДϩxx!^^h ,ET?rGaÒg|Q#nsec?nc=4)$f?n!W!@nJ=j> Ao4fV۬^UJo&&)a!v =??&mrA| T!_Ś&sv4eħC;3#Xͻ:R~' /Z?rc??M5??C聈 2tF˘ް?nΆI m=wmN}T<)rHSC tp2`JEH1=KL1Xg݁0+߯y>x?n7Mec>\bŏ 0&zyy w-{`~?ns0AVQ$\̭6'GXO\?r/s-\8,iض#zV tCȽ\f,nnmv}5kX+}dec+"t"(2nnyH`| XK}ٍ 2 10V[n5bf4("-v[-* 90ώ?nZ\ 2(?n;(ž/ELgy~7V@AV$;ؿ0gyNrۧ[OW6i/!Wla4rܼ??Y;7#wMgVd-fZԹܦ~̈́?n(j ԃzU x!heԀv??{CmxvJ_E9FL"@cHvg7^?r43c-n(ݎ2!含`7Xsކ.n=NüAwmJΗDf8d5x0%1ú`]X֊#Ɛw"Ï?rt??{ bdȎEJ(%8Rp??'1#[1kO*qnTpr+C/!+T&bS XC Pt73K?r:۾alR?rk1$ԊEJϋMPiEUԫaC{<2PL)\URBZmPE\yLVsWM_L꺑 g%>1K3'[??S[A~&Z"t]:Sm&f%~S].$^nt*؋d"h7{/to[[2C??vkf匂^މ oi Czz9:M"EGɍ`+=#·Ξ;9﯆ӟw8Ά?0mM7ǁsTtck 0lR!!Ui??ԭ*s?n(O^=!kĎ׮ѭs*:R!㞔)X^6zVf݅(??: S/un\F$@a(,?nWk޵л/{|0ϔ^л cs Ej;;,>h65=sE-LZGI\R [\;k eV~=)ܕYA\;nZ5R.j,$u?rT8K2٪eYq ?nR!8H!naYW{_бdH)բ˸_WTsJtFuSu{?0M(.{P'?0@~_5F?n?n:3$ltOhظ%渐Bn-YIAJM署f `E*I?0s2Q+*&^O^3N-'uL$'=V!uG:%bȅ}p~x*@gnnɭ^-#}IEm*a|WE.Bv Rv5&5 nZ`ͩw>v[|rP-FlYݸހR:XKO Pzh-$2sia.a諦^=?rK?? p; QF~zQ;ux[,wÕj$Hx{5O"ź9x^&hFAD.S5_U!P>a6Gq䆌Uߎ?r>F LͼyQ#/旼H"4:sBߝ!W^,=/ҙh j}EV{qlƚMz67r"@b])wOMGz?rz#]c]^_Q ; 7]|SC%>0L}SJ>H\6N +?0(v[b8n-D nOvu??,y5,eb?0ר'igh9??,G|C$QuAr&c*S:??4J?0jZaHva?0XLnbf!"dZՑ85bs9/Oy- hK7|61Y+!(XZ./Ry,q6E9;XVwմ!p}a?rnA930$o,Dy)/B$%+*??Ugv" nV+:Va~D')??@b HKiNTVɱ > lwvr+ H6|CocnPD$FZkFʷJ|F?? -l(pWNG*-()̑?0bPV{͍p-p/v@1?0;M5H՜?r??Rdr@%#[-d̢0r ?n?rX}$4)+ŋqf-%#Pp?r4ZFLS?nWR ðfSW>/}aHHMr6 8M?n????]zi=Hel,IQÜÜ*hOU#hD>1\nGx00+$TѱE?neilmOIO1M)bCtMۓ ik?r vBV(G(~nϿk/"byr|[ys* ZڂMe={lHW^ ~<]k74Ir咄k_2j[%/2o??J}שx%K!HzQR*t/AF&zv\W(N%]?nW+{Zv6Q,~+3aZ/6c = ._$bǗqNǸ[Slૼx̋W 6}w4 7!S8@wFN^̛zkgEc'GG^W??uEKI-7hDù5>?0"o],?0S]Y$Z rk~>PӡtqU;{Cu}^'SzbBiw<>?r紷;E 23qotDŽ?r/.GCyί&g@q1~8ο0|e]TB&3b[] ѝw)3g)h!1ԛc |%.ƃ7O?0 ՝gDsr5υ4ac0H.xd&NB2޳+9Ydi`*K\k(}HJesx0$I%%|1LΆEa$|Z;QxSܔV\OhȄ$fȂn*3gxr V{y: GȬ)mѝvوHI@ǂFx 83ޙ4?? >C`OULf-?0Wc_/=<+H"A9OJ0Y(a+pJ'hh|ϓVWP`|/-~&Gj^8.EqN??zL+`d=!k;_.GloUG+d9l+ac#)~yU<0rHryьF̨-}w!>Ⰿ3<UDJaawI9jym*E$,Wh3J)._.Ju#@n;-U+lB4e0`44 :R.w嬽zQ]??~w&i^8Z+_ì Gv(ۢ{-ì^VsnZF:1OV˞_V{s5Ҹ{RX#vk%[ٹke_goWP3t_5ABQf??ȏfPXn4cx޹;?r??Zdи1kʼfoV)p?r/dN/8os@P%Um޷&bc4H?rU᳻dY|O{nosṄm Jg Um'i% .°4"<mHaso^Vcrjp -#q?nWuUeuCF??qk6w %>xX&!n-1+m.)ʙ;q?nX՝(9N6/V1 Ei_S}xSrL#|lz!#\N'6ugQV ˟xpn[GEuz?r]~VktbyU C}N bY'z7L(??R.fC7cqu=-lDnzb!|Uw.wֱL?0!D #H`\B`qB۰տ9oSDt(oǦ\ќXCk84H xX}fc[y2a3_GIsMGmm|ێ$Qcٔ"GPp^&!v`7KTl/l>0_E>Ite .ݚ_y!γ?0"zi+\.VGYt|b/:3S?nPq&H}:V1 J< ͗+6n>62$nEs~qL307{-wQ+KJ5$RYK,@*UY??njs_C@ α #WwM_TR+EG{g[V:>6j4>Gtl{A.p?rsDf\r y{hr?0,P“g<D\tw:'2:eRTi1U/Z;]M ´R ৚:M#2ev=׏6BuyíY.n;{fO. ̜agc[*JRTYA?nӒ3y?0.&3\mn__?n)~k2Ez5"_p` {Ge* +,oX4-qp?no0ȋsܼdyTKt-~҄4k+%F4&A\a'B3g?rFL0LhضZem,bC?nLHۼ$-YbkyX% D?0Ÿh\y= ?n"̟{i90p3Zm3iބp"PgUb^BWl>g\يW6:zfW4IP\k<[^te+ѓb+[Wy倶rSnбWA>%xVpꡃSmr?rH S kt!WĄgci_NFۑ7L4\Y 1/{^mٹd1卅cGa=k fY:sUY}Y \bse-ŏ/kVf̙eTA8|)?n63\eF&Yo@)7]vk$=h.=.Tͳ9l T7Jඳpľon*uw" y(,Ӊf}54h{xl^Ix;3W𢽂N?nͬVc>Z#ƕ(lYeX_\ Skj!3!J Nԛ?0JW⧱N$??@?0D,) tãE?r}|H[O(+hţZ~m-耚Of0\Rb,'P3[@R2IN ?n2Iőb~oW1,K.?0{J稤;XgP+ʓ0lvܵp#AЌR?ng#[R-?nl=Cg3p~a6`3T#Ҟ*P B4)ml߅Wؚ!C/C&h%켦S#NFFԉ*hns-I.s0 8|i*+{ j#MPEc sRbF0YD?rLBo`>}:35y4??ܙ}A~{ϟog[[[6H$Eѫn6AV}azڛ&??臫~+srd֩/;kR3M80#}aWf"~tq=85إQ6)Beo]?0MjћWNӒv*zg-I $fgG9n6mZ7a>f;O^P 7_ 7oWRX<|b4mnn|mNu~Szut??;,qmvWߵq':]G*ch,pT"]:'0]=ӃWFf(t0׸Hu'dx2Kq-NEAV6w03f9ߟ':hw$b{ًd1}?r2m{񰍟l -#iF2?n-w*c()ĦH?rkjj4P>&e^N6ϘIrvÓ{^ڥƵfb76bqa!L1?n6PƇ9 «ܷ P8ϯv΅qY/3S7ї]eKf7\4xs7O3Z 8@??10KЌa#F'X^rc?n<[gN`Gͦtu_ ȲKOUo|r%NKkP& ?n{YԢ#V?0,EgYKn>3Q+00BQnX]?n.mpx+JLWWQc??~ެa+<1*]3>o>9lF}91O|C٨ḟV530_eDT?n}c??ŪߕXMN??%d:Kۤ7O`=R[jx~]r=%뉺+Tf=ҨQzFQbf*[ctIKԼM_c=]iI'm>zY:TnMs0A˖`=fS֝RVwqVraV>`+G/m-"!=덯F{Ym{M_NKac@~gkk8:V`/dIrPԃOj bՋ{j.RRc)M?r؞[Mn>??VZGP)c I+ǛlĢzq^eM?nEa0B*q+-Pф6+Kَm.t?0^$ (&*21Jso=+¢2Z,!,Trx04\2QMIQ3]~xjZ'&otw7ʕEkP6"1k!G*((+\?0|8Mp#Fԣȵ^C`t 4{.U9 A|????zy/I[|qoWhOO7{??9|׊Cb5M `g&=:w^D6mq]}Y2:W4}nhymsԎM`4zhp0*.3Ml?n4&*EY#Zwr˔`_ӻ%sSp1{'ɘ?nryU0;ަU~26p*ܥ55߮q`?0iWm 1v:-ܻ4??ysKrC9|`>0LӃWqgK.=aܺ@QAIr5, :5-OSnb4FgC x"IN.W#}rSOvm?nF@3e alKP <=\Y&jB?n{`wwF3dTD-/dSqtwof-IC)uu lWGVѳğ>:d٭/:f *@%]F} (CNL"UZB%#HnGɔ[Q.Nc9nGHY2" SWvFNͶauS+8o$ ?0@a_jv'\~M*}X,ec-g~F);ƓY&WQXwL ψUĻRH80I?0"(gPbR1A\WTɈAz8g;?01O{1995׷}FW;1 $KB4tcO87<3^Z_hIrH'w%Bf)>+95hMs]&A/??1cYw'Wjh\!KQ%SN"/s=;`:>c(|0Fv1x؍˔&:LI??I& *MZBA!24:ג7tO5fdQGXڼ669]gr{nd.Q[哑?nA`2x%dLP~u{?r[( 9ꔯ\&ԛLYVSl-2,]2l(\CUuQΑvÙW*o%_??IwU0L*bSz'Z1k3+?nEdx=2xAp){J ~f,#pH".B$WѼn-g?rfm\\UKpl,ˡMd- K0.iT[fwUWdsiN4?0Ch҇zP1Q7*B`41e`٭T?ntU41>iG̿(ֽmrgĮȅ`We0oZOݺ6[}H%W1kYF /Wn7'݄,3Z9I..bk4#_)>$ uJl܎_ȃ1FPm4SkioȣJ, kE~#{ϱ%<x[8zsAptbg̝+'?? g??$L)RTⴧr$M٨xeg;6g͹BY\jo2tB',+·y17V(yK[i+ "V[˶εHYM F{J>|!i*$?rzhf^ʉfЯbH)xTkjYu!Kubleoۮ,~c??;Յ\n={LmdQwK d ?0@?r4?n=Bv>??s}v$]} eb1J5f$#%a8)u*ZݶE"}|6XV]<?n?06؉V ~ u/h{nYkWF׏r_7m??/~}[gJE!XgT¥0fG\2%+??${ji0&y3v.c*`,*Z)H^g 3) 6??TΥ lMX~1]d5Qڲ#?r2w5$4WSkb?n܋VsR-`9RYԡ`>;Hѓg 2ţh!'UDCJ=OU-???rg1m(=k3w~G ?0$V_78D_w+ф"jիvNj4N7;n4aw@fo]o:/»(0*3ngi28$/8UM>c{FxeCSYQNԐPN|8zjifO7_lGg,ur1[ÑwbӪێ,1v?r>t7wPÊ,3f(iedK-ZL&&ŧ=A2a?0)g)IQL!C¹E>AX֖CcL7)7+.W>]x1t4 3m<??7Ό`gy $K"x(:xEyllFyMnOd:e;Iڏ.qt[3:$W?nJazRJIAHGsJkv::$aչxf<l&8D(T5EH%[?nIak1ݪfgU>7?00kخ/Y{:wab)8liƲ?r-˦F^P്,5JkXz#k|oޞ6d[e 1f?r֢{)ilʷq,n&ɇ5I*q:U+7ubλL&4Y̵#'ueT(Ϣs!T6`A(&m&Y+Q7(fIͻafmx.~lAhA`MØLGJǚ/m9۶q3><;6+r83"K|Lwްb]Ɯ˘W??b=g9Σ.^H=Z,Jfn:<(G\|_Y"XV.b6?rN v}Yfq7;X¸??,A0~䍙qZ]y1?0c2N Z=ԧ{0r<玲U< #H( [>ԨeiМv¨|lq= @H{??#WpE3!<`j-NtfӐ,ӧ*t\,ϋD=;AZdƊ܀?nՆK*Pa)~n3??r0Au)Zj^X0Y6CBPV.?0]˅KPRjh?rUve7fïb; [WE&%MuCO;6gdS1>^~2chTc)]5QF$'miMGykYv ԲBl8V 9?r Nn/So--D%h-)ԢB4IqoRjDѢODrށo~Y??Km~ vϿ_:3ndtr+c8Q(|?rzYH>eF2PPyga6Uo@)45mK\f|,^OoL-uzA_o{3|ǀ濶;P9-_'~mn'y79{WamtmƮ??5{/Au6!++ [J|fQ׫e~+PõiFLOȽ dގ.R!#ke;f4n^y?nEy  l;u6)K`?0GL5@AXrURp?nWGIcX 84B}ȷٙU%pQHG(4oھl]1,ƭT&h?r }ڡIQi?0?ndӳ->+@mLVELC0ʀL?n5Ⱦ|?nZB~"U*$qܹne%0TASoVcce?0lL01g?0d"znཐDbpR@(X{"M4O#j&fxijJcG7ĺR4)l ӄ gs?0!34{SN)e"1T&ǻ#,|fLjM+K".Q??&ϔ#Ů7ׂt{5QNh06yFL-)kyyI9}{;ҜߘjrAmJNS?roԫ?n?0ٓK(a dْ[<٩?nyѴI?0L$??>oz8mϮ0p@G>yff,e`(m}<"IGh~I֎bZ;(["skWD$2H%?rKƄj??8???n*Y"- #*)灅{Aq'#/cHDl??2]c'4>?0B?n\nn&wbO 曦[ק?0&ᅄ@éq:k0kǠj6YL_of?0PJJ`-4N ,> 'vgHYE*)???rd.:?n&\(*kb??Bg< 7;bye۪q1گ+h4Q.99{O'<ӆxņ)/[}%kq&ko1$\b*Yzu*Ѹe{Q{)nf925&i1 Ϲ??a?nݹaҏ>}&p[G)CVirx_͚!"c7gR9L,5@vA+1Ƿ"WT\jv[\Ôg?rAVQy6;hص&zy.x??uazr#-k^]Yܖ0V ƴQ%^|߬q?rEH{vs~eʈALUU_qp;/5I6QUbӞS>3cjV>jM!2cV,Z!op a:&i~ ME%lQv[g3c ^h; -#!)6wǽM|*ppm71mIO<xO?nV|5cr_V3]>Lnۊc|bG|֕5gM(ܷrzf4x~bW^b-1rA7V;GAmoC߭-n!F0BPB'u??8N,ʉqҹo\ZT~(νu YyDJ׻!%`j E免}XU^R>E-G C%pt?0eyb*n&3\ 맣tV#3@;Z~#W_1 He$i'[4?0ϒa_# L#ip?0$ЏM4H{F=lu6~s?nI?0e3 I?rgj#ωEfd;}Pr ;QzU%Қ4osYt]VL%Gg"^L'"fpVAoNYTMG# z4?0cT?06a1o%]{a,Tn0dfga[I_zz!b?0Jǽ{d)Co.):<%<[m:?0d$ё ٧{@2º?rh"^f5\Iz5$i:=|?0~-gTvrp HlGZ6OhZª-4N3v/sZ'֪Ooj0m޶+hg5B֮HUXbIj-Ё?0홋V13%6,Ζ?njd(8=?r*hyhldbq'wً(0%*h;~H3@n/H%|)_N?0p'7u@$Z3cnkt@F5+yXJ-A0iNf;R|Ѽ?0mLl韣@?? H[!T0chbޮ<ʺ2fZޟOz\)8J%2༷P&K&$.;tΠuBi!jB˾?r*l*V0R)%>ա=nS'a/gCd6X[ۿ,C%ZpvmQ ,/f'+oDSnۛI``SzEm?r?r t1< =]tFC?n֩Qhv IpN]?rM @??q;E?n=kAeH& ] 7Jr}@^,~ [?n/e/iuazJO%;X9Mh9j0:H0GDI2Y95Et7 *,0$z"# #0\|mD5Zv3SJlaAQk$%詿!iiny,eyA,hlY6- qu'ҺQ. ]3G-**¢C#_(llVea%S8duKl`O>1+ON]֌Sdk'=UK/×ӓ"2e=?0aΥu+IA??/gD]B(]{T볬ZLUtKȢhUo>>6Y#&P3k= 5L()Σ>$cm#֗t*0xnRcKolzh%c8RMe%ʑdS),bEi5o^ZkV;zڕkgJwjrmTW muIHDqǦϐvw5=%VULdlw9xY~5?0:%u?ryg??f"J-3 ſ-a=.9ߍRБמ\_;h7??[٥[&j: \cN??&ш^$kq.FC38D.6ϐjbk"ܨqxXp}]1No 5'LlSVU-55)3ދ^j??F[x=$+_?rZIk R1x)oHt%% {ڑGWc?0[A4_jN.L[n}atZj!jdt[wo ?0漨[ggVĬښTF??[$뇊[M$wd47#YʵE˼r/ %wFDRM(ҁ[4K?n ??JUĕ^xac[Zr!Rm%`筦uID@'QDt]A*xSvi;=&U?rI.TVA-%UT{;" Me!g]t6f6|_mm[Զ,FlTw}A毾;+Ťy˷lֻ+ofج%* #}s.1 sSf?n&r/9hm<[=-SsZ&9z]?0IJg΋2iC]+} ߼waWy#m-}KKHJyDV|mI'{T7dqKI@4uHx9??u}f lt198?0L~n|2;r/ePsE4/^eK,7)¹,r㔩; Lʗ&xr\~ԝQFw?n??6vei,caU?09SXٸ_ as-bWy]K8?0?r77FCC?nI:K2&=C0{& jh}FA|)P<+'aa5/X6jIh24MgOig<$ocO]GˬKMw8Q.thKm?r l"ß?rA\Mjk~QO`޽1f+ʂ<eמ,bUg2K٢&=rEmR/ś`k6 {^nᏞpXbVYYB/B }ixFvO}>6Sh@˒xŒeE`YH܏HܶL)G7lXR4K6 01:꩏??iQ[<d`p5AzOVKP|@A*?r!zb?rI;rOc2?n9>):k qxfYl /,~f> 4=?nؽi'cEa>[eWUo̙Ymkkn0)6l?n?0z?rX52Nx##t21ȟu^*Yomc^??uݢdj=+r: +#8OW+ 9`Wm>L[x*EC6'`6;ir@&vr2.~tHIq\A<`mk+u+x>5>4wLS#C%|𧘔xA?0\?ne7iYlkGM-JFi+H,;N졈~yY`Vwb]ܙ~ɦG1RB:kR3BŀtTX@FL{'=ddQ?n-yy;eU Z6Mihrao]?r/UcVL[1u.`Ax9ew9=sֶm9ՁG1Yrp??pLjϘ.§p 46?rSlt! >??toOإߺ.%{w,~IJ`MBn[┋~–mϢd<{s#,p 19ˆ4WP3EY4Ù#ӅnngD^uJUS0nv>\I?rEA+pxq@i 13XixzL<818f:5՗0EW]-C.#J$C'8Eٳg筪{ʒ@5ma鉆皒7Ϭ&3y`.}X)ѭbG5Dq!fdi^p乨wơj>TA2XٔI6kӒ$諦v)*Hв)md:BDAJ7:"I|)ԍ6 0p)PU?r/_lTc%^"WG,?nKzQvI'N +ipE[/Z(??47$Ž\7U:&KN'VWʙn5CЏ'{6y>z\?nv/U|c%8AQ+b0r?nIX.xI'#dWW#ޔ'FKm 3l26`MEɑ{?rJBoIU&Lфf?0jvp&'/ \+82U}Ë,Inw6gpҝgڿ t =mӎۍ$I>ܜ֏ppՃfTK\\~}q_?n~\~sq]*l9DP?n\ʛ ^neഘ<9^`e婀ʁ=qVǐ042aM~yAAϱ-cwsNQC(\|xIQ}lgP!]V×,5FI_m" 20 }Fqpil%ȑޡ0aD]Ww~.< .=xohiԻA ۰Ltf<ߡ24=7 W|?ns(|TDac%R2^6*t@/H_`-un o'#ӎ^sf!gW澽pW^; 4jjCKДaUqa90??n0E50*m>+28ڠ]W%!q '2j$8\eIi(ˌ&5PVS&#A]3לjG?0D%?nRק|W+Ln2.(?0m*zT:i,}rCTЅVz=BKnǶ_Vcw$,5V#Gmv/Fwxz! NbUJG3or!R9uPrM~w{kj!aŬL#cNnʊ>9]j~w([\~X=9)mRU^@} 3Ĵ2E`x!<*;]R8ǟIʁ?n#k?nt-'MۓF_uc3xe6k,P${A'2ۣ?r8vO%1.ΒЫ] F2EF?0KAVaŻvEt*J̞hX͠IVtIy+vdM,u??um{tjvb.(d;Xk~o]50V:)`~"x^W??.܂?nV8BX̓?r~:ۥj?r?n5RߪYjZ!!CCϩ|6+[X*ƀ,ClJ;Sm#4gB7??dB9흆?r{d=lyWl2PSRba7#w+.$wWk"8 l ҘmR!nQ:1FC bBk&SgPvuPp{Vt `~Pf/e i20nK OCXyKG%d7B_ʑ4@I#{?0Q+?nf~|',aNc:ho/F!M"y:?0x)bIZՕ6.v; Ⱥ&3!F?n%$J mS&˾aJ9QܼmUD%z6#Ė 7??RQNcj#ZpK)~tbcҧarӨʟ]k:?r=է*]7iͳg&Z>Gw$<0{%bjVՑޣYi8Yd23xecP}25 (jc+ӑR%dwCzL\j+Vi^-sY6XA(srF[)d>q4~Acu%r<]1kаKɽ$(.x%0?0V &\ù//Aӣ(FJ5[6hkj05S?rD*8֠H;#ҏDX6LΖ?rHѶ&S wi ×YH]{'_Ќ\>fojK0cC-"%Q^G5|er@Z<Kr8ýM;ehEVV}j+g \7Ex)KP_V&=NoQ)0_`P{(^Rb0˙Q?0?0qw67SpWThciug؏ۛ#KtYԒTbm#nX?0 g[jn~Cܺ?n"k  ׫v<ֺ9Mh24m7zwnҥ%zR:ݢҒ!yWV}AyYxq8{+}CocVrfS0ϰ;gX ZAMdnq(ΒNWd͉̾T'(_SRo??(ώ@.̓;^Ǖc`eB``__㏻LRܲ&N*!>#N`L_KqzktyhA?nՇ,8vN+dPt6ɱ.w?0!:+tu|}Myw΄q֍D1.,U|=;8xٳ.l K2ߚ0n"w]=#sä,E8k@?081g½lLwfwtlT UnH V4˜?0Jjj>yAY??}[B RΔh,lԮ>7j9ꮹ\,'~RϺ͞;?nA_WYּ$r"vw߿Qbv{׆Ok`0߈0Nkz'0SjI?0^Jl$yD_.⦙;m>?rL:?r" ^c8-vi"|YAk^-\–W3(UX0M3kGe\;8#L6LiV/~<==lv"N~-P#xD{hoګ/ݿoeM2-N(Ԫ>U~lf?nOE_݉BUbZ9?nz,?ri8{3c~lC铕wm $?nЩ~ ӝS3<r Ǎx?n|NL_O ,B9>9mkmf!e^U@*Ì;|C.1}Ь_6Ez?0DT&iቨOW1xCQ;07ج}}#OE|Do4:'͍&`.ŗ$.KVzvVOIuZzXo`ʉo1C5 ՛l.;`?n++O;ּ,p@QPLw՜1Ƞ@x9 vW0OwMMngU ԨȳjJ ,8d?ng;fK\|5+2nu ׫?nZKUY'o֑&DugwPtsĺsMʗwhT]Eޝ7NyFe??w^Rܪe~Vv)"^ ʓý5]C,ttP \u.rA/?n@4nA})""$jkrũ;q3 AoZim(]0B^^S;}QꁵZf}pYohT2Y0u,V%O,XEif'r[p-hI1KCh{:]Xy*7}sŖZ] |Stxn^1S'˳*EEXYof5!t~}g$ǼӕuVS9zA 3COQF`Q4*w0D?0z2IUFdא/ݰh`08VJ٩BHu/2cv?0ַƬ?0z(4Ā J[!UcjrqONN=3N59'8լd3??qJ/ÚwH&Nɇcc64Qi3eEO,R= E5B+:w_᫗7dy#gmxz"(PyUeQgfe5dtSkCScF7!נBݿT앭6ֽNet;ьIIeY:-..b L6?r\%NЧJ}-VS'MC%m9D??_uؙ?0b  őzhyLD6(/DsպW* 'ΐ5&r}a\e??xи?0-l')H_+0})??}̐*zB?n!b L/Y98֒( KY~8?0)!{?rtN(;{2"dcq툄χ^~;mC('tSڇmz6_N`o,]rSD}r狷M.Ysְ($-]n%4N[ׁ{7(ٲaڅ%aChA Y#mީhBcQuSD?n@X.öE_kktXvGc`ΌrBY}ysh?0Gm0ݴ2K$hض2Sh,3?0SISl)޼'-gNq1! ҩriMUE9#0=X$Ya! WzHLe(KWQU٘f0Ϭ$IAkbx̏j㔚R}C_9w0ꃖp_<6%lSی1XgQvܪqR#GB'XP8$ U7\%??N»ͮ?r&´2-g9,0AK5'?rVT/K6q 2^媄g{9>?n?0C֦SHhj˺I )_B??SUN8٩@ubUYqx-<!9T#t6gw%TZBCVI-%H}^"Sn0eQvzTNQq0S.y\A!#nzpMT!8KT0'Q=璵jU5G/gڂ^9'3??Fw:cNu~v{ɄԨR_rb!jZK',Igu1M%C".Y~g~"ԣ[ E_eqQ$,_\f{镊`[bS33 ݹGbsm#>LmzP%Жó'j4cr{,CGJvioG9"Aq' HUYlh)\2t߽dR~?nm%o$s2*ŊPgKY |fn.g<];JYjg5=Ы:x疜_]4טԥqڄ0rM{⬑U ':VQViO?r -#Ȅ({l kdgP*?nOVD}x??}qqpe\t*k5+Mm IG3E#Bq׊$f%o}y??}eD>NCa]nDVY>ɰR~cunuނ(ٍUx\>hx w@̹m\lHa AabO$,7z9㜋f^>Ř00{Q/wj!lTwvUU\@9|{)T%gY5PuүA L +v?n6ѽ{2hUNR½-ǠgR9w?rdbg?r Ѻ1ۧg'vj3̷#IH$c&K5d 4C?0{B<Ӓ(ׄͿw;gOOZ5Ԟ%ɩyY'򠥄X4Լ????:y~D,jܶE@р3ʭE &%(݆]Bt^\<dT "@?0ܔD= jvZ ovP|w~ s!Cg34b aЛ9}|1\-ĕA8_Eb,*NL[kJjN_-|r@?0/??PM?0^3ЕY??F6^~ 9?0c#i?0vʱL]^q9.D*9p_??]M:&b]9:) @Kx\%X)5U-o%@b)4۸Fc,hqb]k6˒w.bt*MWp&YQ\ 4²|> a)&oO-* 6P>%V !nsX{EOk/=-PSGU_/uNjήvˆW"88׾w;0Mֵ%+7>'w]+ImWwHpA6IKX7`Ӎ?0 k;Z&'HA* S#>ε_Ѷa+PEw]TV>:?r??vn(?n9.9ii^w٨>t6ez.j%MظU,W[;QlQG9h[6tNl?rva:36b-ʈx_k`]ɈO7XO46;ۖqɛ}HӮkOeXU@|̴??$.npW_77m<1QGW!MO1|f7Ew3 gC3tN~kKyo QxAγd_25#6xvta(R! 2[2Cx>????5'טWt4ofh!Xe0~ZL eiaHMQ\׿hipRt ^ϯox0̝z8ʅʻIW?rge=yUD,oe0z\c}7 bhG>f1{k!14@ɵ5 FٹNNQG!(??c}n* vmm [#EO_ tiTjHQt+;;yC1J}??KgNj@lbRsol/f?0{#=КL nW%,b8[]5n {(X]|w2HmnR&Wj֯ ]YWHp8y\??K!mbSAY-tZL־~MԆiƽ,RFwſk4<';|<<-|?0U" ?0y۱r0` c?006$̼|rDqBq%FHѕ]n+L¶Ȭen9=1K"Ţ"lj:QuJH0{π-;~AX=?0jȑ+ZK.rw*m:j ؽ]/l.XQSHu7ݑXUH,WcZ?n[`)>R 8x[,֟n:vvXo381ۼWITq1aHXG;5ͥ٭D06nƽz9H:y_cE]l.DUc:?r\b&S޼re wū*3J#T.LjnFr1/^tu@eSh3WWwQylHAz+{jɋitsF&W[jÛnf0 ?rɹp_}ǁ{J4EmIФ;նr+^/]\F9+o=_3\J̗Wc?rF-2UɔNh}e**ǜaNia{|+OW`!T?n3fUBќ?0?nKx"4ۋ,N/l_c]՚%޲T\;w& +BPq?n|'F5!jpƯtS^g`??K3sB(/: ݑauew1혢EofI$9!v;mPB;UOJDVez5UwMnpqcxzkws_6"1NHh7a\,h??ḮgfRp4M[jYjeZJu'wV4˙zM_6=|ř=DUOL:P(hwc@Ghjd:fó^6a`_SLutlAˆߪ f6rTJ1ϹU  hڊ'+JmDa??؂PJ-RH+չ:!/z??=QҤB/a﮼2E(I#;.*y)No|W\Lp`%?0DV&}(+\NK!q5vȥ>7㘃R"+mǔQsu?nI vaڃFNɆͧjӖG_?r~eskEVܬ_vV¸U*IݞzUE郤DB UN 1`q4&\z, 4X5L17;k?n^??l6!??mV*؏CJU(?rqe=k]wۊ>!}!ǧ,WqIvd6ff<q@r帱`J%;J*P)n>j?0&a&i*?no6ΨɎ>Q2KݕR *ON^/8zFr]X1*p$'+h [p -#6$yQ`Aܲ Rꦀz?n{x~~z]udGHn:P_4?rK%><{_qHxj?0<Y)ȵ-_F\agw|vpL ;??8*|އ~Ѣq_Gՠ8l 1J?rΔsPW˕ip@nFxb|8X+G&1^k=[ږkWS >??g7vR0YA( 5&hoE[`BU>iag1 gZY^,ʨq:I3h6qX{?nD _oUk,![RHE(C<¶%&owGfQʛR%_VcGϟNäv^n#'vl|.:%d̄{֖??1کcZ=99{wM֏m[vۼI5q.͍Y?r+7x~8?nG癵009Q?r7mׄQ=!tGa^da۠^yeV]ä 9 Qh "뻪qc/??fN%_uz:2^\;as??iCG}ȶ]d5\??+bPO^YAdu+'if+D:8p~<32 p]ch5MH~M>}A[J qDxJcXsy=7`tڒye$M}P?ncE%ʳH.!n 맔N'rTekKE?n(UE3sQ_`Fv89X(??ǘ4SnNIhoc9x_|-??{C~'ÉfWxסsDC)v.k#.yMeC\.J,!c]E*_hFoqhiA!P|nv_81t؅o]NV4n; nM\H5bǒG4{+0fտ/:p1Z1Jƴ{fJ%Bl7 @V~ՒEV(CY > zMJoZ'ڌ (Z{&j1HCm x]8Iӑ $3ˠěN኶beeٺ9@@ 0>ML= rTlX:U54"wJ;z/ 4}???rU9/ekvĮkbttK(vac]Wx=uc>߻b#cJ:u娷e5?n7e}Ba.5ݦy&͒7Tݚ-Vityl`kt5A&Xɍm6˙sG\[1|:I"uLbh nf?nvVE% lMt^4YݾN=rV0)F Ls?rZi"?rt2vNVY]5Ϗ(u5fZˬ?r@ D(Wd?rQ]t) ɕ?r*c7zҙF4ͬ!V`;aWfZbK6cz^RuR??J9B;(IndVTmh5?rbu7 2Voo\\YP➉;%+J=$=`n/legŀ*6h̷t譣dCyC1qKe2+7 `ћܻ,.KfpK2V惷 bZMʣ~t||&|T@d?0DWh2zݢV3W h-`xe:y< %j`Q=p9xE 1Y+?rZV`"՘ 'opu??6 aэC|3?n %&S)|}]UqQdp6&o>8}q/һ].fo|xp7>)|*B0a t_Q,s ȱ|=(cMx{i^zXўNj/FCws(?n9D:H`añE&Oi\^+S{VɈ c$$S\ZA;큹%aϋ,2{^$S&0Fћ8%$4?rLt[F|c?rEJS qM ;^eჯ??nfOXn0M5l-$e߰n 2WR1.s;&Gϖ' ڳmTdin;."()ndx}97MxC{&\ɐ}!j= -#\tN,`(3sPWwmb8#O: *$帰ߵ?n 2%f%u."ΏNzm {>сӂ&֐^`ť8hqVEl4..sȲ?0L7U)/Hת ~}MH??J\|GHw1$LF40)4D6?0x~m(}Cq1M_嗩j\pJV?rqCm 8b @NC|tj8̴rx2,87Ga7 9-j-ȫ4HSG@ V_/QBŶ[6imȴ+s./ Nu|?0hW/3(r'yઙ0Hx(VGS8\n v0.$-\C";quUq1+f<ΥeB,ٛ[Fb/LXo?0P̀"@M}@)bav!agiwOB4d\g޿iGȎ.!W \d5C]?rQۺ`x9jAR@qweq#EEQ;8g)0?0@BqJ?nB]"t[d~,p;c&kKIHJ죾E7cjf?0o?r}/Oz>.>Ýy ??_o????Zq17qƸZ>-oFSWOqe愡Σm&Feb.ΥM1tvNͩvz6|j؋0^ww: 6<;`{g{Mw??~%X:4{}:)SÐkIcNh8?r$O`K>5}OIV:þ]ڭOn ==߯ ~T?0 ]?0[?0$S6s?riAIPlmϵ3x) f*@ZeF8i9e瀎"fxJ,"b:ٖmqNOQ!{%F?rnx2M]f ӖP]91{t(Jez3^,6 îֽt1/7w)a^@u#d4CE%TH8?0?nSFl|Y?0w[/V9?nyI$)*QnV#tKً1䠠,=}X8.jrW>_<ڇS2fM,EHhJT$۩R {mvZkQqM)Ka?r!Ry<\ ޮrxR:4Y3ͲVz_Mҡ3JԽRCw}BC{f2T[Aa5`"o?0k"4HRs(cFO+OAЃ+۠F"< ڛf}~4mr1$Y'/Y1bC3e\,F3oŽ6·͠eY|"G&5ݴrجzpD#\cP?0f.T?nKS_HS3xk.efwХ3/jX?0㿱/\vut&P[p@؉U1X뙴<#TI#H4H?n_z-c(QBMh??jS'PX|,Ṳx=u dJ9aB hDה5fS(dIJP^AmapJd0 h$"}OBGˍ]zXQ|)=mF Fe~i.dz`_Dv6o j6Sѓpœ6zTuW6uns9?0?0n_ύI M w r+FA1`uQڎ`u q F-]CX_B1?n1Ge&"Em.0us"a?0|-eL6."*t_Z@ƶe_mEV8Bp/!ܖCˍ?07Qŧ:)2@`cB%3L!U-J4җj*A{z[:E+j W39X^W,`-2v3)a[h4 ?0U8U=iigMEx~Cz?r~rBcP_z??x!##=d%M{0;hoűRE%bryUe{N_㥥h{.b*(4:Qp\n48DP:8rxjkΩoS%N[0{Aly*hL}n U 4#Ƴ]m??oﱹv(,+|E̙}xUAhXk<^]xNGul#ʂ]p,ITSҫHC`mn(k}.ɲU0XGD+46Hu EZc(r3ocaF~R^WE·wv#nVJr\`3:qٷ`4`"<(RVkn2??dn41@[Wqc8pdL":dsN9=.M AXX_sq0%nGahWȫe75dKğ9aC)(iap1aۮlCIuSnj1^m*#19wtwlv#B?0[Bк0#rR (M'ï?0ɣ޶5w/??ݼ}r1.&BEp@Jۡ}3GG7 <ц]n"C6c;5]*)G$'?r}D*vVKGœOR2u%ΘvLD?n.Cew25OR?nD -2_W_5a9yӌ.SRu?rRExNɖpiipRl ?n0ba0]"2_֨[)~#oQ-1fqɿds?n3,.ep4R/K^h7k0tɂ+Ih&sIKmp Xe!9?n-J"d;OKoUT+|b~Ök6r>8R4$Rsf qؕxMH_{gzh|GZe"x$t2EC!o?n*`D7 9hCῙ=|m ,֔EDF-ȖH~Y XI秴Zn7\.ҹ5.٤x[l՚<ױk[-3)۹ XL'&4N'Rbu.$^gziE|eT&k"Nԛ+s<1Sc3}E$aFr0]`zlV㽪T=TU?n#t8l`Y̬'o4u^m\H':m#6*ocZx+2wp~j=?n?rj+OO??EXfe(??!t?niEkkOР'{\??I/g7ڜ)1-C~ZV(\Y0ĕijKERţ~??^N&ʵ9qRXiΨI\K, uK7MG޻4}*ykzK\\AD+LJjVњ%=tUEl&ZK?r[a F!-a„U#TSX4`-?nht~%;Qdgґ˒4v{>ۅl~aT^ɖ ui\ j+.NW\#%q=?rNn1yMڧb #3I77t~FKeTrbKeo**"^W/3NjdMΉskwmt"SsnRD'ʋi%/??ncʹ qS`t8c̺6f;m{C.v[LM+Z9 7ٍPY?n e(K l:QfؼI%%޹x":5OFFa Q8GAJ)\բKfAWvQYWA*‹FnSЈi?ns:`SKe<0YImږ{2WRY*aeBi3ĈZFz}Y7)p\#@öz`$N@ 6P_eP/~UDㆍϣk~[A1A~e_^D2{;FI!c'-2qU?rC{3Ǝa^m:#L_B/{ mlsXk'䦱I/˾^ta`4XC~5W6?rx|Lܨ[J n]f'_cp|)k^*oyK+'޷^ؙM UՊ҉d۴JH@Dţ|Dbo{A ?0B жG_{O4Ĵ8󝤨`o/"XhqRj\!_OX"Phxʣ5Y@I?0Ӯ2V^@%$i4[;LSyB[KS:*/YېվRS??cLr_DPKXGNJ"t"T3`&fp0d )??n??-?n_hi:Oy?n??6&סE#T}ͧ-/UB^-#5bSCz>|J-9`yCSXp2PP3lZa7btԐ1m3Jt`Kõ?n2jd?nn5+*,4d ]nGp_B>!üzzFcy;S@P)اPfbR,*rB)jrKU_i%]z0Jp|[K顩B~k#XQ C/COiTTXZQBҸAqPcGEH{;p囤ĪW,"%?0aZjrF}D6R fio U^xR`Ф`L64:i}܈},\[|:O&&Zy[^F`oF*~Ͻ8 ǀ[T@$%+f)䛿X??[&T*ԼA][Qt'o#XL??M ֪azy>GSGorcR{W%]bQ??Icիm]]uB床€'bVOc#s$zOo^PY]fLGJ2ɲڈ}KT)Մ\T >%U/id: 12L0|ݟ/)JBK2*Nl?r34~w׻Q_:ߠfh-8St?rMhC{L ϱM0q;/z<+w4ox(d Q֮Lw6$mJ>jZ;r>a˹{-ܬ]:??uǵz c?0۾@WlҗEk6fY]}iǧm[+k2#rV -#i+mڟ@-uo??r:\t XK?0:mJ$Kfk_ 6KC)?0=-7ė++# 3\>?nL7[ۍ?0$'iVwC??`~3TicKK6eOOkܘŇv@,TXD'R;<>5fӅX Smjjee ?nYАN;n*=B(҉f*4sۍ>w]ҙZd~64 S3ޖB~S>w&_K?rCvRb2ykaWMvW_MM˔S֑;0#RdrmR?r??®Bz6*-` 7}sPI@m?rP: ]*C1uFDp[K=iԪN FZܗyxV IyƊQ4|U`$ [d"1T[S:,фqζjD "]bEm/B??_tu1 '#\?nxoYmSN?n/7Jq?n߁HY-{2%Y`\1E쮸Aj$<@]AF??eT ga2ai)0&RQ+Tc=,^9#lއ?nJIi2ܨ׈J=ne/lmY?n,I~2:#sy??&6'Sp8GkRJeKjGeI+s+pXR9$SR~F BTqZgW-?n\YDCCT.FQT*JE2M‹;b)áxmMf٬B_??a}1L"~%A$OۺQ?0?0n0φ[QB>/ϪZ=^b}/f[~Kk}6TU= 6=*hWHdÔ=Xs5qP-?rUP~ 9O &y]>Gqy?n"ؐ/&#I_ ??DC?r\@DT3M<'Gl 5U?r~S57Sܷ\<@J??R7":1*p1n# iuJPSzKpD_/z-']5u6I?r4m֥~0{1!k?r8>x!d!LA( ,wocߐ;j#4q[ezŇ_Z>_܍F,R2E@Fk$ˌ2,n"oeRP,&+Tf]IShu˻];}????{wӻw??ߙû.o$ B$??mj^Q??>/׃f/];>l#7?0lnv4x"QG km3f8c7ј TebwP)V5})ᦆu 81ser0TO??|CeRH%#mfІɻ3eޟSh9ëg!.x۴0IY/kT@XꚳʦXNi6"eخv^\}e?ntUD&3+b=D,TI\WZv-(ԝ g cPLGok `2bDiz4Էpҗ$@z_ud??:Eձ*Aï)n9(Wۡف!)'X<OWF%A/O+TlE*~#oƥ3 'ڧRޓ?nH?rsO 1?0PYr&7a5z?nPWs,:9GHtBHTڰ2"o*T|՛9D"i3eϓ#Lh!X(H??InqPK^][ѧ>y]kne(X$U'ޚq*QfOn{ĽDKg)K#U!Ѵ .ve<ݫ`SU:VJFzG'J4>S5O:Aam3?n[ ŷH0_=ڄ|U5^9GOx>go}o??Ɓ_??:GQ2O??zbo%[c[ҋ# ѷU^6pu!}a|AH??թ?n9_??=~pd2e9ųMwE:8yl<z(sdbB3ӧm4>/J}ȩ ?r埄O=|蹰+iΗˢ@涱^3jsu]o*ik0Ƌmyi2??gz=]l/6m?nAU?0ddgL[ضgw~vZlǚ]۩ǛzB??֭s+??V:wS6ݲ\?r6b;KDz$ܖgg%c$ TNupA YUiu^0$e-h0;IةvD @<1Uk}?n9[0^rbpMCu'71&l`?0Fo?nlMO?rݷv*/lnǒUa ޹+|QFuQ}91k?nS|1Z/ -#+j9?ne*T0sP1#"yU¤]zWzΦn&EQ??q5p??u!'?rd ;džȉ4o+B:k80idLdLWemT-?0?r0o*Qm@TMNޫ0ԗ@W/s/Ңg`+c_b ?0GL 6|]2{?rZȃ{{+j)>"?0Cw/]b}Pa!:h0Wǻ5)Nw+;mEĮb*LŊ~GNptc{=e$7lN^ b^:-,rGE5! wUnH"53GlJ+i#=E6Fa'?0crtіk#pOmz%SeVB=L(H]C8?riI2m%UR&L.??ԄD?n׾ z ӯ23hPfzK;"4J("E0Bw4fO?n i.I#@Umg¡V7 LZuUm`\ayg :rIGg.3l7[Nt(!KdyI%QvcQٝ;>E`@եWkH{:27X5IA!RQT8x*95Rpl`z?0 Nh83Ηƣ;?0ǜI>Bh+N3]>FoD> B"޾̴je?0"wJ g)_?nj n7=:(;oʅSw]X??iv}MvKTƢysg Ԫ2ջM5xudWe+A%~0\!"8X^TP!J=Z酀NLe]&IgLe]ΑiYVInIT??0"z3yhR{g8N!ɤ%MZ5u@\6-\T(4TkRIQl?0PN!u|spp0nUg|s@ߢyPFCeE(KP3Rԍi&5osLOi 1MFh{="h%nlw1I|xUzl=]~MWxhh`duۢik*/Ne^niu}Sώ-x ՋpDk3M$+磺67#GC#TLL@Jp~E:>d%ՀLv~iF??q0ʥ?nقm,nf!.)2+<%Ld_$"8QJƷpφkѹHh$%F|fb shVj=F.Bj:6%fHś,.'G*\}ÀF# \_W\?nW*4!c\Cu5X>BW<>,S x'9**΅>-v6b?n!. i2ƙ1\mCyhk Gj=7݁:$pX?rj޽HOIiג1GJ^]?rtyj`!# eS*U51{+;w~{ xa=jjF[??jꖸW~U_w]vsTPzbP*>l#0gAdbG9k:>̳*8>:*AM\K3."Tƨ9t_;kتtAM& ';/%+B:O/",d|bDɸ}`D FO?nٔoFw ,^-epTYŒeu?0ǯD*??C3zX'Kޯ2ld_i!msxieh4@o!ޕ(%WWz!B^/R?r?01dmJ\Cs1,96~A{˂?0\-h)W=26%Nc^u0#ĉ?nD!BޘB@kTsI/\RuS_攞km3g⤦+Z]L3ЊD6?rdn0)7ؑjŸP~p# 1?nnۄ+Hi␜(c DZ!jCPwѩM+CV/]! uQmЪyhVPn??䢪?r1my^ͶfIs;͵:FҮIlZd;MqcX/*R7Z#RR Y7\z-$x&)ϟ?0 "s?0LNXy3\@WQ|' Rp-<]O|g@:lrr!;"u֢ɪ@C?n$yrw5Sw5"PR""5DGqIfS.?0nSq?n bې#I۲oE^D^rLc/Gto\Ie@\= aZ£BT LGh %:KK#Fpl2VoO*yS2ǩJT&B8Zl%'34mvlEս2HOʓvՋ\T3u<ڞtvzrbH;+*Q0W⟪?rZ^lMmd&H"$#\jbINn%δXK`UѡG8&'ɇcA@iv)̀9p<_4fd%\lzWrBj}SS??'VU@?nx՝hhgկ0kaQ^S`%CQg7VbݿuiU}u5GڇZ (:*briD;r{jg˅T VSrJd hMfێѢPC\q1Q'0bW]h$?? *'txC9lq-{6)y:5ދ_j8_~vpY0z01իb%CLI:vWi)\_xs1??sۜ%13ٺaV@u'qM]9e)N_$#^;F=XyCc;򣛀mOM6_J6M<+ -#"rr8uIKf*;Op53$'y~ψX]:!bj 6f`.z֘!0??nLҷlؔ:'kNH+M-N1*7qhM(9bBu- SȘ4oSP kZ 6ǓL-=)?r?0?n3|NdlՏ*48KaU⟁&XwoW }A*Gbދ$Sl tUbNյVU6 uƙ]\\^苝pu3 Ba De2-w~/d+gπYh¨-ܵipqǑI?0ynqm?n;ϗoujpex 5?rn=>l\¤P?resV]dLB FyеW'|5r(?r?0Ȍ'U\`3p0V; {XAlG??RT3jST%]????·J[hFoa0k5L˾ ZCZY/>9v upcW,n''3C3\7zBVa7JS:FYOQ#hΰJ/.ZםOow EH~a1Q2eУ^#{X8d?0jB־Ƚ;k\q????>U[_K,5}C5.0FJ;T*Y^=loa#EYGX?nJ CT6j;zL??xcG1\o\oۢ &}|6G'Ϫ)2 mوbswZU[p'\2wֈEr鸅T]ddqú]yNO9;V3izxPu<@x=G?r掐gGQKd2q?0vZK?r0?n??qq?rv}(:XoyYȆfvnSͻ,j)r[LGe;9ԉ6Ny6[#`D{Iv[6+?rN$^&?r$Џzrg1lpwݓY}:I> u9˨sӽ4{2wxb\5贄۾幛gvǾZmw;-sHۂsD%wK_1;Z7NS=vחn+QV-}jɟB^B[aQĨQ$ ?rJ-tCOUC T%=}9z] ;p~/c̲ytSFaeB턙rE߳Fp ,#<(|vw;XfNgЁUL p?r[LmՅo~\~>=_oe7zQ_] 2P[6CnE W[)^Wǫr;ornfPn[ulzH,Oq{뿼㡫A.tmץhz]-ZO¡,66+5/KYJ'HO9{|^)~پ^'rEN^L^5~k?nq;tg$BA?nuh2I \ {ĺC;\|i} o{pZ kۘBH?n#tN{/#\s=2(1S.#VN.,UZqY@ Ln/^.Wˡ+؎7c$=KLR-w =4c.,.:MC%M cY'cd?r-8A$7vB??{LtޙCX&3[}׀)h*2L-%T*x n6..pI}Z˥}Y~7]幑^rH?09xI_E]v9y]^%Wav"ݶK 3YFkIFQ?r)`\,Pٝ1?n+Iɇ-FkOz9(%m$Tğ0 dɃawyNae#v?0j1C3hk^UQ0YrщL*qn |ʌI*8@0??з,|P7 Ja,}0WxXJV8;F0+lvuvN}᭜*wJ6vy 8.Y.V?n-~8 f6M~b ?0Xf8?n` "T #5R't9׈LÞ+"&K: -;yl ݲ[?rHuW构)E)Nv"2b;??Ż76^Oq8-MO/gt·7@\YXM.@@"|Vas2AM@"IpQ t6SNj#T؁s>MOR~64 Flǭ@}wRU??tl{bS1L{??j7j/0ͅad<J-Dq[Ѫ)$K3+f<.hZ.v x89>S\,ƗM?r4 :MA<[6mva46̅%;힮f:)ϛܾ!$0ږMM>Kz\ꀳc4LL[#:_4ɎI@[ܚ8Z,e)|Y֫\m?08y%)0T*WW|P&_!eNCH4OW>E-rڼDا'EL%}c(4#=9OѢ]@`%P,+:\cщy&H,䀷ց?n=y~oU9҅rVra u6bg#L +*FV-)-kY,/e+^Us+3=TiUFi[3]š_yeqΈl<\"ժw4;d>]2t^'U9e`O1QqxgIݘ払]Yt9fFtzuO03 -#01w0/0Sy: s0G002 ;^&l+IV&_ܔ } %(Aӱ\PkZkInLGU pzxZڌ(9sWE!@,f^9./5F7Kvڴ뭏TbXru}VF]mLT+z٥8ԫ|iGZh3Z`'D0{]P㓝qjeOk)g9SljwR&3=Q%qT}6ϒ=r8i2(9g5U"쌍qlBNQ*ׯ)??-v c^*ġIR1j䓼a{J}:p9Tm׻@NLӉMe#GbϷ1Ww%'rȤeMj'%t9NX%:Po6֌a`d%:g&R)iM躃^cfMvPmfRP7aITGDGKU81ȊlӦOR㕈Pgd0)7فV??4З}YEop EV>\z]ܼ _8 4)Q?nTZ/k4Iѕ<`Ema}wo0o:a?0MwNš/oSS=Cli顾ݝVȉuH)g |ʑbTװ4͛J81n!i=ok8Ũ>^G݀Zjj0TK.ןt !uo"%dpS4%uefqvú?rۨqبH7*K&`Kզ/lś=pವZPBo5g_Ek(&HĆSŦj- ^$(?rN&YZmypxD~LԌ:3>Rc6-:s)<@wIfhc[{EJ[|50M0"9e7ŒEƮ!QDueChW3Stj(OH7-/p?rf;FQFʼnu-Or*1Su???0vȀNR-WkI3-=mM& Ha$R_6z[[5#?nΥ[|;Wx^Hq@({pe k#S<=R'>:s|V?r?n[zSy{/AAsB)tQmDZU{}e/YC:A;|b#?n'q+37gw>)%&VÜ,Ҁ[i$C+*zwa@E.jyzq>M;NuQم*s5Rԁ"G̍q'(HȜ/mpG^b{vMMN+Fbos$.-f==U`?nQf/ȇ=Q"*3wݓ7Sމ^T%$707]3H[Gq `Hd]&5K6)T65DʶGRdK^G}liM|1z6rˇʰEg%T5?nDJޅ6Tben)%;لlH!jVZ.y鴄eHȔmhwjW.~G/=MS47xHOAJ??dyFy?0H27g@ڻ>eAPT̢͉bG_ ~ȮNlZON^Ռfr3%A@D^C9eoΐTN=,W*ʼnV1uj[?n\{d,`h~pm~1Ի>(~dJ3 e7/β΢΂>[GhS2AfՒO&(6)lL{Yy|'c'go/-7}IJ''7(?rPQ͆??6QSwӊmOnѐ\|fʣfNLC'??W\k#77;??m77v?rg??6C()=M f&8gGϟ<#d ,7&Ho!Λh{yqZZrmTS]I2T7iWo'̃L]}X Y?0\]ʚ SAq/11dHҕ -og?rK4wʕ1AE;?n~fSX??)ru+M5#~|{VR؂Jajᅢ{VHWōEJ),NfD֩v< O}L<p}zyN!xt("(,)YܼipNJYe7*?0&0Y"Ԅ/%=L Lёt.IǺ&GG1|U˫I.Z?0>k v;q#Mg,v-lR6c0[|=6Ď7fzU_۴<}9o\)Psv_ћrG^p_ ~.׶ozqM'e#̚.]h.(WE5Ri<=yS56Goo+*Zgg)H?r r󐝘IP??.B**Qp?0"J_ ;VCV N:??@A Zi|3)#- IE[omǔ++j]5˹^?0ŢI's5;?n^zo0RD]BL-9y1ZDD?rXm;\EVx01n[s8??I[|ޢɀbct-8Z8H!ߥlh\Q{ypˉo\SY)1(Y>KλӁ@?0W=Ƈf!?0A ECך&_\b6XG>MZ:|ɱcgv6ML2m(`[:/ zC؉h<\?0L襙SdN@0 -# Q<'5V!Ûѫm ]?0b+P&C]۟t^3tÕ9~ґ4hήxb~Uj#|ŝu/~-??l~a'Ǖw1qwA[o X ٓ"p?n[ -Pp?0&{7[rE֞=\w%L?r7|ATv&NB[Ty//yӢ$;O^<˗#&'IMvf}vbYy~}ϫ&V47X!FR??~7/ymn_GP&S %dhCi|<]]&El-8ASlzUp??N6Ƿ^>W&ޱk#zB_Eդ)%D~q3'1)xw˯8ӣg_NdS'y=j-d7eEah??{d(!0Lg/4-0ء=?rh2l>JcG򭗿*ŅԼ(<戱Be,]cKbH9ahp'Eٕhx}wrzYd[gx,2[d:M(6xiG'DoayO4U'HO=F'g~Fg'&bh7y6CzVi#i+$'͜R NI?0r"o&p!T^ωb3ѧON%~`Sr<_*l'[KsǼN:.?0wRڢ7~;nqE胝98-G%6ȵ?ry*zZ]ll~U*S$H)Egj*93X @QV%!k[,?n"P=&%>Lr[h|ɺ}<szojAZ1k*mSWcfLUۖpjgcp?nenYmen]F]]kՒ QWi].3 =o꾸oM7}:x#Gkh˙["yFb9O9ql8h~Z)K LzC`>zӢ0; 0q*tk{U\_5>SU\!ULB5ǾYrtJcF3I\B|..0ݡka3~j-(5vW?rW}~ĥ|۸/|!ƪV.{yl?ntH%"n^\5[Ga?nhm_UdFWݬᏟA"?0Ԣ Syc&pxjIG S@) dfB, [#1^USەlJU ŨAH(HyFB?nb<ԫEcO%@>YAJu'JY?rp=+&8#T1(`|c ##*(ӍJhсPjStf`'lM]wvA't-??0Ɂ˗??RxŃ~6eDi[54@#("2jiB=(a4ܜ??o[䘡[V6ha?01%we/wf_h_MlK7Z/W]HW%#ɣ'[;Ffg\@&˵wpCе3IҺOp?0gH1c#??H7Q||K_ky~B1ɴőMhdn·c%/ʯUnYX)ԕ$#<ݒ=eewpͽ|M]>hǿzq;^{l^38F )޿W-ן[?nꏷjNITu#nN R1n%'}:NwB<,1f,0RW_U'GFtݭFM i<2!.l6xm?0fpBZV" w{CDi%(UE2i*м+E)\arݓ$<}|߼j#1Eƣ8[YGA6>S뛞lUѩ_XB??B='?r?0qYak4/">_Ke+/%oX?r”\ueRThz)F.6vn>??>DT|{JTz\Da3UپxVEE}|}8<<&xn*TnMGBa(n#=/]F7cC=θr!b(z*]E"҂R{UhNOIRe+IZhH'"hL>pf0S,_6t`̜TE W$B bӦ*_?nx ]"P!X 1]63c{9cQx9?0%Э$ Jй8#NTK0wZNUC vM"*O?0RCWmcaæ:c[#"O/d $6u ꖄcV"⧺0p>v!zT)UOsHŔ~%(ؘChaHY8Ia s+]{wOӹ;}>W ?r鱁ciN˓{'S -#`-(=C %!VKl+9ؘ2oqp=(86 Y`T͒VV6Xo/}kFޜd{᝷hP.0b5hstK1w]MF*>F-ם,eK# 9F&tVbą /e93= a?rՖ(' ̸Ɖ@E0%/YAR߈l7hs:ԭġwOi%K8b 'cJO8 ㋛.T\,bɍ pW]*M2kM?rGoh}V6u*O>`)wWFsn&qfPeV & *<Ҝܵ#2v@#(͆[ AjH?r5l}#͡l~/׽?nﶰJߏkz1n ۨWio;12^vOD3+W(LǐC-}~mGu¥Mm2єჰ &{?n{?nuG8Vz2؞i4WJ{b_o\TM?0;~%_|kҦ:#wjy0ث2qpK^IPg-H^l2^z^h|\<_o5[]????-PԻͲW\`aׇ4cO}{W3m?r0- /,D?0| Rٌ7eX6;<SP+]I#`~A>U;b]+s(X{uV0?r<҃_?rfkY7uɈ{E1TzM?npo*j%>XL@ZGwX"1Lr$W65kAZ~c*Qy#NWڎU}ZژUiO?03 ^r&VӮvՈ;nGu_QeWQz8 nlᔰWǴ{Ч\&[JU]*76S31X0xhVڂH<,, b/@^l??P$NS痭)d0c?0Z>KZ":jUCHuC k!l/p~$oqU+A?0k|7>نEi Oi|]}:{&RGo52NmT~c%3ɴgփ)n4miS9gKCgtB1^E0e ɣOP@!ؤ!'b-Nt7dh5 hv+KrX,\;W&L~VC?ni,p)TFcP)hƈ"2ژ'YT2O"V&+SlM> 6(FZ:߻KJymAʼn=A=-z/2^- ??#+lkҷaw.UX#Ar3,Q r5u_$[ەsW3??4[_满tYS^0vNWNMisB٢pXp%[5Q6{BmD3٫TaZ8RVAaa"QjxjF?rTgU ى+S0d"~@v;=zU$OP)k=_m}G?02^[VU1/`VT-?r*S<[4?n,\,>iX۾!9bR~#Aև*Ƽg˦Qtf*;'Wusi??rM|m~NΠ/U_]uU(C@^vmm;+8_&C?0xO Iy~(tmw9Ɍqiwn);*Kvuj4d't1/F?ni܇qߔ];jmW&iT?nע|A/a~AA?rgzʜz+a4"aIGOH֋Ͳ9WG7u`ۆ '1BzW"( E"v@Ir7gmX5n)uhv:Gm go̵\Hf}u۲.EQn]w[-%AbQ䳽^r LgU0&##˔S2j3?013Rݑ[z2Йh͖P/Hr=y?r^lT)%-n6n!.Z(2jJ%Q|DO,gdU% WAen@#p'6%;ElZiUEqh#<ۏ/Q.t{17T Jo:V@HwZwAѥdYu!BNF4 7blz~ /գkh??j,Oܧ1ޑgŖM1i뢴UPqYk>ilL<jdQcNOfCcRڱ}\/NwʛI$PI}SpOkcс!qƘPv*(Zes5TϑmHc3;_V=TUEg),S촋3#l,89U;Gd2Jb؞1YFi[?0HZIѼbC4ԫ|Y)Om.xOOxeay?0+!z<'jʲzٮx??_YV07NcK)-|u4|g>g-cǵu! $p8qg1exQ2k]tz#>LZw iɵX5s>jPCɡܜgܶǶɮ6,,x=-MF?n!QGջD[H?rY%Iͽ)UHuc&0L?rV_FoxF@UpnVU?0H2N'|P]~s2{wudghPmY$`6T4QP+5#ts%b̿)ѭwA\тZkM%hޡRMUv&c?0r=$.ޣ*X@<3$Gy{u #;$<*hY?0o]]tj.GZ*h/[mʋ~E?0Czt`pҴQ<.5_2 俫:l??{F6^BfgϨP`(]]Iw4CX^g.(ޥљO񾾲}qWooX:зDlV$gzc??wm#Զ8/j{y9jq:azX%5,Szx ܼWp?0՛lk.ՓzpVTpg}^|m_Ƕ/xxv/~oFs{DR垱SaհWM j[Dա&ofU^ _"aVl;Gū"D+6v9++3,$-zN8m(zrb?nv"fnad1b}w6+;;Vqlw}^iVGDwd˕L q3='HAA`o2G~=w8uCYf6Ŷ]F5a.?r(:?r0 ??B#G>@lО X.s G s~b4es1+*4'B#}7܌TT@4ׯC n9]2.?n?0??&m~??O˻_6އ0qc <^>V:?nkZz+~C9):KSm[ա;zHg\_pEM@gM5s6pīnn@VO@~; B{1&iT~! ao_L/1M]c?0@Xҳ^~5DlCa*R8\7CCdM?n9dp+^bD#C۴я0??iTs??qތ‡A?r(ϰk(v2;̽yKJR.j%w9+S:`?r?nCbfy$;O<^zc8 v,ʿp\ė8R)iŘ-WP!jfF{uؘ.W#@H]s-~V82vM!^l-(Jj<`$8.=352?0)Q ;'_?nF4c?0]!u{xMRWprSp4$?rc([WD[m'-0haS"Gt۱Y/CY Ȏ''5xS8-[lKΤi.:mj_'hTv0ZBDž)Mle\L"|'1 DC"`gՆh[޵DK-ܻ]S}\4d @@z]>P)\b.W!HjM9rHu!pM87aQ7dY1j~xӻfIwNF[C( kG A0??[0 (`Z` ]#iz YhW?0.:8(lVE"'$4iv~Ɔ!h*3Vb#E(D y'pZ0{9?rtdH k,l7J+K05j4\2.)N_o 8~q]EE5Ua(\SG"Ґʖ%?0ƙTID4hNaY ]! r1 ]T 7lv!m4CH Dl(k) gq70e|"dz153-{Wk1H7Xվc+yGpTYWS+ʄY"PaC͘W!N}|Dj|x"?nN2ZX1:"zK''D\af`_ pܢIa(y?0C&nfX>gb?0wyA'$n=w=??xY ?rhƥc6ʰ^fy|p}W^v 1͍86t`!|it&U;WO\q14,?0Di-< 6?njMW. hS ^Bs?nĞw8[1I 2yX-E,#TzD Ϧ^@^f?0%^cqZ06wbxsDbţ8dQG<5d^Kr q> a@ӃZ;8Kv4ikUY (4jUކք3gFx}6{}"b/v6ʼ%G `E6&JC?0LNh}UU@:9H{p±4s!m@Gs&:3bR7O7:bPS0ůmGn 2EJBŢ*3#."!ǡ??85w7Ez -#X׶`o'`Fwǜ| oWؐ46otpY{`-ENYYG b`ē(y]mG8s^LiyGaQf'd}Y8<;%1f@}Pb @G!T?r viڗHW擁eY' z%酥g2Eb8")!=j+xk(uWV!Rp-FxV{y/=idnbdPAWi'G9w9لvͰCtܗ-+2.Dҳ)OEz񸐻Xw:s [Ƿ0*?rJMR*óXS޶zo4xm&* y/cǚ1E1]~84|P\($7Y??`z=B)1X}m6B1ߤNB~C&v͒C *6:NdSnLrCtiT7wdhߏﵹ  hpI Ak;LRoTTWH7l2qU"*?nyݼε=D1t4pȁܭu x2Sj]v/d7",ڽFܩ#{X@.?r6N9>֓M|1ygd#NP˧ⅆ5e-iņ6݌\٣Z"f\BF#`FQc$L@ $s{U-Ym_#%6y.Պm;tc(֧˳߯4ήF nM#/??#?0:RY0W\DXcΈ:?rbpxpϻM1Kh5cqdG<{9M6<{H÷5qmoRg~ؗ?roKmYe9[&Қ&,5<) H`a!xU=ȇmcp$4Hh<:T.zlm$ukУ>x5 |ސWF3bUAm)GA ]dϸDQhN"#J(klI 5I0&cMZXI._ш}]"BEYO̴Tq6ӳQ(29U߁xr`_bK #nwGgCcX"YbUE;2+?r %Y{^Yo䫾Ӡ txTF6.$?r/$0+cث ojl#fІcfOhk;z73F=< w<eCVlvAF"0??2CL^ln8Ry=D!F%JfG|)OA:l!$-EN턺.rM?0}xyK: 5~]Q?0}O:zN޽Row(LWՎ"̃%}Hw愹yߵZrhRܶ^w~[.a~\OK9?ncewaHc\??3?nE!g=n„}U߮ʈ/}RzyU+̬ex(O=,e"FDB>w62~Z5jTa"y^szE_'O(8,mǔ??twJE ?0~d!cz<(%=ӆi~x64quyNܕ?nO1OS D# #-C\t?rnS,tZ3͆Zm0v$@d}өr侹^wĦ?rbQv/ os+EVNUU_ N8N0uaE?0vhM7=Up?n{Ap#t/2=4ckn7$ȯ\#׈nwp-}cyw.S& )J;sv C6 ƍ&z?rX,ҧr\sL??Q?n6?0SgIR'xϞɓs]h@LÊEt~4u_ң͛eKFˢU`E(kKCDϐQxں667>в?0yoS# !*9vj" 4a2F%e?n7XtJ]˻2ouvARC\ >rO]P/<׹ ~i24U(RS,ل4N>>utW{)+g)˖tp=`ˀ'iy]Y2U/* X) a?rGhN <mԀi7`?03Vm]ogDk,4zihÖo% HkQ%$=yUccTt">콩??ۮc@AjZZ^m2@%jY[z?r9"DtJ5M/geL p22I:3$3|s@DsbZ:j#^mVpD?r ErI^ ꮉQtԷx9GDs-륑Try2@!?0@XaRHflIy,_Yk89޲*(!WMFU7T=yxA쟄n[Ra0]dݰD\Z e%QUQV>?rPD`FdI6Eoh1+l2˥~iԜ*[ݥdF3L4vI;e,<`M3U% Wv?riQgs7|a.;?rk+"#w>TI, H9SFTMY|Ɠ~ O -hT2tc9?rx4JUw^ ȋFކ'_*.*??> ^Uw!UefunUxsdq" z6UbBWiS*yµz0H-Ԍ|OY.7ƞf, lI\`'G ^,Ds(x'Tf0(GSQ#XdZ Hwo H??hE] )"eЭ8"!I(3MEgk?rŢ̩>G>p˦8]6= \oykQAɗ9VQ?r,8/w|i0/E;TwRy+ɶbNsȎ y75g67??p=0xp =tbppyINTrqD3H~X626il7 °?rG#ji<4N-ˢ"RIu RMvZRNR$sbu ev,Q?n9@e0??MPso5s/1]#[+w#$Y z@u"ژ,DXب-T(:l?r >S sn\7??cW>3@ЫJ| cg#U7Q^;+HzOBzx IcU CZC?r?r_cU5w?0<^d6HJF,X6HT w+uML:`TZ* K3h*`zG>Wf]Lǫo960I)xt32a5 ̎2E q%a[|l2b0Ll ,5bu|l"I0)P(bU#v axӟMiʅA?rWĺMu߀7dtr-|kvL?0[=<}DG.__^?rIs޴"ԦD:]{oDz{xgCL^o+䐠!]FK,WX O-By-A༐r'yv.^嫌hc7O|~z$+,7EEyx F|^?0~*be04eMg_|z\;44[z7?ng|/Lv"??`6KQ'NI0m鸼C3xB ?0A~$6`!SrT mt)]㒊:?n\??WD>?rqT5wV)z_1qg^Xh CbAmb!݃9w123Gu3ܰ1%}F{5??P??acujMC{d !s#gDUX3KatQ*]?0i&_',ϔDT?nd)oLύ3I`jP3 F|P|P?n> SϢHgӐlIlqNQ&~`k4|~ZVIN"p~ 4??`a@lU0s7 '7 OpbC`gnj5.?rf~`j('4y??t6u{ݿ~׭_tփ{4Ԓ_$FkƾP`|5p?r?r3QRf.2Tש^WePF+:Zq/;TGo?rkG-&??t%MUl.WEr{fd"␳R:HXp,&d s0bfNƻ>2]{:zAW??Sֈ?? x<.3?0%d1nc&$( bkH?0j{ίnmKH*U4X^v$&;%U͝)V[ VQ?rEhdg('1Vg4&zXX%XG+zЫ7Y`vWT%|:C»]iF?0Hk} rsMEfcoM)yNcG=p=j\ݖmN?0]QLy޺kz=/[(11l6A^=4-?r;cgTUkv O9)y\.S:]Kiʭ@-2= G??X{oFQ\ߞ;-p6E -#F5u(,vwwGF.̘g#~5phHBqA#D/??c1鑿aaMI^_]~Y83LuL3qv%0?0f KD:ͽ4~*11 #lH5,9;G 2DAa$ɸ2$`@nBGתXAАժ2UBW)>)d ge~Jhu;,F:̯zކD?02m~ߧbnU ?nnh&a{gErݨxV3{~A9^A3??dIrCizh0 W便=3OtzvLdo߿??xOd'~m3uK2Zˋd}cLա4.$c_o_,g,KmPk{ZJ`>; i "FWIr8 `{0)Y)CH,BC?n7} s_`~1pT|˲odRz@8?0YѬREMk'p %̮Rk0851nqS( =ȗ&XFM[L7آ\233F]@`!Y_' taN9^E.p`ȃ)oS|Y"C5n,s@.Q $!w'\DTL{;tYp'nω'X/6"- X;QD qz+߳h8>_cWRϟ^@x"?rS<7'x8C0s3OkGEqC?0z|g c⹉V`??&9^`cu=hzd7h*_dsw8Cc(g6D+P΁ٸ??vƖ &F5@/0K)ᇷ|j1dOeUAtI?nBT<{R^>1xш`w{kkҼ(T  -tƠXpoO/LB;]tQ F 3yUxϔ?nW`rG ;;=Bc؉g&y̢Bq5J1ʐ\ 5r_ҏ!s4Btc_CPz}Kಚ??Ve3lTَn,?? l9?rBR`#Sqeʩ5 }"{#??9pA>w@T'aȧ=% =t3 9o%b]?nkh2AŴrVq,?n!=x1= zh9B[ ń=gYk Q9Iy?0[h,x}Wۇtxv=yV3 ;oՊ2cKbN!:,%+@OZ0"U392"LSj?n5[rDۓ1?n;;РlԴs(k<#։6idT 0MpbLUjV{]"iv6$RLy!7ミ(sz[T luGB4" "St[^'qaqlQp-(Dxϙ"9?0U/M'v`Lo2-Ž(݇4Eoi]V7zSE%Lߚc_z:l,n S;hra^h@+@g??{lϞ}>lew(??~Ezhi|0LXj{o8uE?n?rYv);R (%)(*t@ kH{XSX܍3 1_!#;Gdfs9HN7UnEjŌ&?rr8)}8nR輙[Q wǕ_MG B55Eu Y9Ph8q\|cXUP|PPLxCu9x>iOcs1WH D;w!&'^>(҃W5域]̒OЎ~0pf==5OYp/n??:Cs$8B9 ާI*Tgi7w ||MhVhDT:4td9֨BK9|iMTr:˦0a0=ϣ)aM$ tf$z|kW9|r:<%zL/Ccgv3)2q! qZ9IHO?0d6HˮPnX2?nap ۾>p d-U5?0*:Viq-+-As]VU:z9 9bfc]++꤇V?0n+yPɌ`CW2?rN6ĀZUD.2j>?r{y~he8{^ۆ!Me~JZ-u I:_9i&i%-lX|7=$Ę@&3LGuw)/ s][pՋ?0G;[*;HUac?r>u]T??5Ae݊%4>a v.S,A k/_ee>$Pf}9Ur @%9HL&O\0Uyr]1&3<5mI~<)*!w"7c 8\ rYAZɤZw-;K֌?n8| z,b2-Ģ【ԑJJZ{xocxæ0 -#?0EŕD`&Q1`ؙyi0T 1( jWꀫ 'P HN^Frt4mPj@č~p%?0.PC?02?0a?nPb?np8$/XT!q';/BL?0?0z244B_olhf>0U?0?0Xo?0|Aݙ{m)f"YՏ?raϋ>dwM*|\{" :ۙAʝ h\/&Y[6.1bX'̟mTBA5R @>LsGX]+aLwh Җw2ʺrDuN6AM->Q=jX  D?rD"?n4ϴEGj_?0Mj K|_sC4 mzgA;*??^s\xo:)Hϙbahw͒W"|<e'ƽ|~"b@e%P&*ׁ??Cd uICk@z}Gѩ,S:{ qv][Rı־Ю5ɬ(Ǭ,?r b}8e?0VƑ;9VP?nGrD,QVv?0zƏh߷(yA2 }ٮ5=8tҭt F|ߞGG?ri:vaᄮ.-A{F,b??+reD~qJiC:ȹ;@|)PqY6lySj(+lͬ`/Xe1 ,5Fqy@dX@EnYytЩbṜLNtuP.D ڏMbD~{nqދ\gAAz<2D3qw -h{=Qt?0s Ԃ%VoTk?nm* 6%0N5[i!WTFqSPEϲŒ嘻 ioL#tJb-dq6/TMEn8$J@P/RHpV/2 ?r ur1@꯷PA4Ac-ЊWb nI(܂4??).h2@y"2B`-]_`rgI"<+?r4}0u nӵCo y~ 2J-|VXpdF{VOr H2BPA??>\(& Ìʛ~uEہ?rk{bhcZ{ Ua۸_cBђL+)zFD[}sQweXp%yQMN#v.W߮ťG^,>/v q؀Ի{m3Ccv:ɔVjtxhCv}??Ż(Ɔq?nɶ.,ἀGje?n%vO:@—dw:Ym_Y?n?rJJbI\l;FO0*Y-l><[݅5I????qn {d_@L]$lևᴣҬ??>24_4};a>57#@?0ٿG _~/+D :e?01U?rE^o[|VC!<'W?0p#փmOëP, 5s)Et>UۄUW'٣v!??A2XVXC'G??tDOR{VToߝyA3!y㳣?r.9s׽2NAk~J M;?n_&C|Ѵ 2;ԭ8לg_nUME{s z8r5j沁d"8)GE:91e1v_QA6k'nJw~UnVS}B]O >GURmqDpI ׈ U٩"6g0Q+^@e@{٫dOqѮ}`ղZm&"G.?r &:4 l?r-Q(oO5ejD ,?0B?r4Q@Ppԣ'{b"M٬c|M*qJc4ui<vLRl8@?r0 :L,Eѣqvdt3fIh2SEG]GXB?0s Հu/F U5ɕ?ruT̂G,ԕO PW!s0Y%,C`8FJ2ݘ]2Sꇄ3.ߤ7wwT'1@Êƪ#!OO;H!A.19|2iy]e*V<)F%|??4?ruP?rl{J; 3,gֆӄ rX\6)yu@n3Bz&6BnD.VR=Nxz՝`+HΛ:%(Dښu,蓾`)G!8)#Y죰2L.`H8/ ,љc^0Ԥ[?r?rjw?r|??/̥48ӔαD:ΫٚM }d u̵}<ՑEqd1#d~Z^`Mk+`7l+A7KƤ?n6gۥA`F$1ˊ yLl62^u!j+qs^ &(bQ['lŅ_0 hR][$*yn.R+?rNϕ?r9Q1HuE-;_A m_H$t{??ߕ p\=D1&BĤFh8vlBv?nıB+d!??'Wk8&>/midmƷNnlYaj"n4,r`U u lB; yKrC+-Z;~J䳿}stv6JAzsJ=???0n)=\EKD?0F~6RerKvOz8;KคuQ?n'6J"!brJQzv='o7?rY????=y{m(^pn%j"[[;:>8:5V?0?0߿ẼCuc )omGի}8 qqPy|ϚByمxpeqG}AnIe%-o>.)AdG]0 |Z=7W>[y,to"@kL$*?0ՎL6盪4ÙDy؛ӎj-~N"ԘbaKb+`fSZszg>1 %mG[shu??+d҂W R\Gl˔qfOЌRLTȦzTPSm?0ǮSq^MV7o\ai"8̪ʍHȪTj߸$P&8>j?ng1-+֚1EqpUk_\{SJ>~Oӛ ?0G05F~??+Ev~"$G'lO}Yi~:F'Ju_t1n\fUFP3TN_Ĝ*DJl t)ys}C Du MaQ1%,YDDh -nQIPTΖBT1X*dgAlv`!C\צwDOqRYgقQϺ;p_z~\K^YW @8R<;J'iji7UYЧ9峬j{:BF_L_i9j[WQa{=OdG=B,lO^HZyfcwX?0Vnt;1@:O3?0uR!LuM B3]?0192C@TiV%R+jq62@I!ޖ8?nJ}j<Ȳvz-f%!Ix=k|fXjJ3I\ '}= ڃXNvn*nK &?0ը"T>f&\,om=0&[| PY$|gGߝuT6UhFI zf=pGEqNJ[|ѰʇYJ2."i?na0Dd("OH?r0nz~Y^9D!`k?0W5R~&6[4=b?r仗iU&up,NqW$K''DLd|ң^2 r05Jgp%1??Eg'֪n7&DH@~. :[?reS4@ᚍJ|6$U7P<-ɧV{??Q?ră l6 ܒY(B&LB0\Wan1 :vJzlPa|)U+uռ"QP/wjs1^HtB"y$wYj*?n@IAX3MC-̟/զ!Z3h *Ĕ2$4&t]`dlk,ԴN Q]@8GA ]z촶)LbBCTh{W۰H3ӏ]jCBYz ԯ@ 5'"֟(ֆPOcO׉ʟMײyV['l@H8)q=ƱH@ov&aR]?rn/BdUTtY/Z!:!Q4s??g܏&4Yx:.4P>z\ud*BCжA=}>wQiRw!L&SGz$E&!LxFѱWZ 7h@?na_&Xš!԰ kagk/o dMDQ:R! =t$~ Y4K?0^Ms~ΠӨKIH,U@g)4/C)Cbv֥ЏaICh!.E$ΒBkR!8ޯ>36 pb\6 .y?n/o%YuQ\_\(m3k@p?0`9'oj8r`3\ӬɱB"DzW([4 ڢĝlF%U92Lbk&0rKjS!l$VTk=R?0rr?06PARٙľb)48%a۟*n3JfJs%wdܵj뺍+CͶy`%&RTL$`eTfP6?r^'v|R T%q@m?nV;FiQOjG͐(Y_YVi㺌'?0j?nI̘^maKJY/`3/E>/* zY ^(rWBau7_qpvnX^Jm83IڼB_bfJIͨGtlxFԏUkKB.޳ RO-sώ#'^u;l􉦨#xAD,D=\o?0lot??knіǷt]k=ik-6Ioh/r[[SwEZ%k9,c>fA_f d^$V"߭l31/j-ߙEޠ;tOr=8^Ãx!?nz?r3cȩ XQ:n9Tü^l{yeͲI7&EhBu,P$0lr}f7Uz:7!ǹooϯ~/zk~+C>)tvx??ס/|9<(З>T_Kȡ>e'1l~B'g$pW?r|C?ri0B٨ҳd ??r??w. ViiO´e;w8IT%ucu qY(*.*#֙#?0xѣg1$1N??X l,??w{;ubO?0ةϣs@4>^&L??u:U;jeUZW҃X'"??}mItކۯk\=׺.cpŧвd}WM䶄h.)k%;cn(A?0]'rssEFR:z%$nܗm"=V¾ZaDDjs?nRp-Yݚ 1KOuɺxy1)/W9=8bcp9i+dB O{vlk-mRαj# \8QV%;oNTӎIr[d.!K33N{5Pyɬ=lUɠ>.78U/#VO&pMA[[y ̲]0wl ȸuHTT$rb>u:??Öǖ4|??H\wbѼ[Eڠjv6>Jq 1*D1;0Ҭ"2;&#Phb 2(&@Zk!.RvLh%_^v?0#VH(kUVQ?0]1\Pǁa)d%Fƒ*gf8Ub; -#>a~mE]%nyڋvq^ھh w¢'ͮE[EɳW6t,Fx$mY ?0yh8|Ԭ4ݲSIwdWl^F?rI`ً3F;¦!:ڀ*?n5+ۜVMޣG|5eZ15H˫%[n׳.)BY/&YXliҥ.,hU$GFP/V}cXN9#+Lfaf|%y^D3Jdwh?0ڄ0EDXH#xN?n2&46Ol3f?rz EUclP&q5yljql7eRnǀ b,F䗮}rk'8AliK'qҹgݦkY{:Aªʫh܎)chڹPO?rXQ[9joXY8qC{R`:cqn?06Cшz(J NR!vg_~64oO*؅'D6*ސɻ()ᰥdzC]-+I9ԴV:<&G/Y$z[^T*Ľ<F?0h|LsW?r.K&ZsMNy&* I_F[tR\vD8{7vr6V2)jo?rgYrWiTOYo??ҊԬeJ6 Tx\G???0)N⤥}P?0 ֭SuChuWUt8obk:[:[\NBWze=IC/&KnLXfg))d`XtbZӣWi3tzlH6z?r IA}%ߊc--ʇ\o?nQ|` z~$YWo( ~ϧ^6IOTzet`-WԣLwj~ 46O<.khyp:saRM%Oy(,]5@5J E}qw6q%5"ɎYޛ6floO^C[tkY4޽>?0Q?0CAaD `0TiqZ+V?r` Wtĵ d ??YUfane:rТ<=(sw_s??wYIi|B_Ր"M|o^揶~yo/X4U%n.VnPM؎7B˫JÊhQ= FĮ84쐫K__ nFHnUf*ZڨS??~J(QN,kmpa"n&Ж{+XX^m)fJx[tn0~՛Ϛݸq??Rn_gJ̈E v'{gu;~jKJQ_!UEʆ??{ˆ-=ߪd:!؍b!Q%NB,^5TdB7Vĕrŋ +*Y,.oAurzmOqYhm")#1tf'he јMhQ6!yj ^\'@H h>)Th$M?n5?rs"|y7yx (P(0hA2;[Ҵ{/萐U:s#wg}0s,n*tw-V??y|FFy=Ռd!7ҙ3=ۧvˍ<#R׹/idrccʡDSt,1-ʫDNᑳFUvPWQ2ۚ'T?nL$'XZוW?rR]y#A.!Wn??L$!-BĔOazi48F"$,K0y@f:{'YeƘά0??V>;ISܩzIy~-W_(l 䟵Wp2tǔUnB rhcd?nݸE8y/6ZTp]ﯥbRK!m1r!y e٠k&ag=V#[ąv';nLU$+-:nbl}!0JJǵ¹(KgZB5iP ßIS5źV?rYDEuUם%2-pDnSRͩ?r6&!?0<1LKLSVXdvRIf{hFdGfm./bx ( $oc)"跿=|uo40'U)ё9]XvC2eP@rh~h,x Sv?n6@tT熒.N^tț^lU&&2 i~+0Y.Эk.mLMH??SF'yZy<4[}HYVA~_؟ƺY@ҫɑ_]g>J9WRzvq")[ݐ×1)A˧9&E$"7Vd0)w?0AyD%WH4F&86~4T9N.x}fn=M?0x^P|[G m,Y@*iCݱ4`wIz]ip&9EGAn`:x /*=#EF(v6-(.uU3FGk&$Z@&'iO蘐 b1טsYV+|*s'AU1wd26z79W yr?rɪi.׷+s:Q buCk?0u®X+)v4BɬBQȅnnXzRVmI2ŷ4!O|n,?0ܟf0eq̬J n('N.:Np^&o=r*A5כֿmO"Ϡۧoi/=^M"v53c -#%* 2[y%4,kHsRL/5,@ْ5`|_|GZB̲ ×Z4A@??RG^zo|Q%鞍 :u)5ҺCaMчMvPY de2ayiͩxOleehD^ItJdbUD%y~c̼iIa|l1/u,tej =DW[3&sc.k8L %pbj~ryS#*NO6Cg~I&3m[-":a ˢpIvi7m#fq=8+0@?0]JcopQp]m7ptc^j5[pfר#h[[9]i!=t\D*\ 28- qzO OS5 tW&v¨nm|F?r8#ʋ@DbN?rS1˰w *i]M=%#("xaڄF2`n̨djU?0-:vbH8H 53E:3:bH-b'J!j* - KeUm3Bnȁs9bʁТ//ŀPo<А.+?n9MJ0;q&DEdU}kgQV5)(V~Q H/ThP`fNLjR̤*=YшdW'VL?nˢ'Y{Bђ y)4le{M @\ʆ?0Yd덵ӣ8!\(]OVWtVgCM M%e<;5eC6t߫"Y9?n=+/Ȍb[$2SvJ!j,=RH?r݇n}..]E8>$6.S2epTu;?0LFPʩ^4YseAe#T 0?rը]/v ej,̠:`O4Ntu_^)DhV7\ksu頢w܁&j2|tܕvj$42]@+,8,XIZFətOz_6eVԜsA ɢ4P0< YzM 3R59vޛifJձ'O;??nWkh3QD۫R4A ^ t1.Oj^\FN--`nH (@* ?nŎv5I2G)GcUXҞ!K8o [{ t?0ݞ=x˅W?0pjDžfAK},| I97S~VQ+ŬH?ni,ݷK??TgoWW7?0_6n_݈;g=7a:K"p΁J{3U{]Ubdw]y92mpF,z_4կe?0pntk*&j@ GQuj2=7?rȇqx#Ղf9O8fwMjVmeZd,K &<$967?0 gL.wۓN8!̸0@GuiLSoa?r.8%Q??h'WwF\Dh,OA}tu#0WtqCulTע@a{cu?n7X{3 gS$1 V`k#8;Ss^ LWkNh0?naTҤ?rnt{4!&&ot~sww??HqgOOaw`_vo:]EI2tt9ɿkp|n(W^qBaxXc]hO????Nd@ou, [%'s[Ic~tճ<Ϳ\$"Y??˛$|ǎOJwGt~H_,հiNO+zų^$0ZHQE/ El|zvMi:@gBnK??'tT"]9#-BvV2jܰ<~P04>j,` ?ndl|aS09~2)NNeAuJ?? ]h>m}MN;qQi]5ai&axTO[@/`+"l鳫6̉ }^q}1Qs?0t7ȧJ[:Sqxw=D,%#fԘ(/)'KrDU~P}V7XQW ?n_CZL>CIKY?r\.ǬpIoKS~8ރ&aw ^Kx^C0wwHV=L2Rv9iixNOzH{cb$wϤWq1ti`S%6ܣff٫h?rzF.IRs:ګox:QɊȵ72XLL&ÿ%a<Φ23kRFJo$LubMq䚷d˸–JRC^~Bs.o*›{!=[>Ko+{* %esD?r/1mv`;A>5ӎ??PƩ!oŌ~b7??P+PW_3M/r}q"M2G|;tЉjm9}mqc!Z-Mqk]&(xYI 8ʯh[U-dnk-NED~Y^35 ^K T-_YAfB7N76\ìvorEیܲ#rhϫA2ɭv/?ryԻ#v[5 vAPrس;ԝm*QYϧ42Xs:3IGYn=vdH]hz4ؿ.] yh:Y6zFO5?nhؗהii^|mMoT:(Ofg?r ]9Of-vrGmS¼#M.0ډxn?r7/{j"͂b[4b'::mvcc$G1y7kq<VsAT*YjrH_Eu_?r:0Xv}u-;MidpFo_(=&s:JwDNiwؼ!MzA$#d+67UGLr5ڣEl`ӚŻDQGih{賗}l6W#W7>:Yϡ8?rQXZyQ3j$_H+Spg]3[je \;/Z}-j:fέ0hW4KLxD>R:+P4$<{6~u)f˕NyV8J^|*]QUL$GbPD<9?rTN"M<(P2 EwQ=yYrS+Q̎$bXި?0m&#e:V=f?0~~"&gĸ4G;wf2eV]-X9Z?0='yk̝q/d^ZxxnD??}Qzcgi֎\CB4= %s??& mE@ @rl+uiy"#0H=6rčT-4CsJ'N1{ZQ ۺ%+SL5a||'sDI#{̠ȵ ~7d 4b]$́vŹnx_E?rtoN≔}EZq%!BJ!Ԏѥ"EL$S](ד,`??Ckgg"EU6ϒ$h+N,R1">$S,bt}Pbg]jeig( h;?0IU,u!;?nӅ U:yꫝ뭛#gUՃ-n6JHÚCO=QꋑXC|KujL@(a6knqq6O0eMVÝWnGgoTUTeW3B#PEâd -#tylɷκe!Qٶ^G(tN =Ngw LM(ܦMC[T̅MUfy]\jt:?r=uiO@JәL"L[ w{L%zm0[`mS u8e<=-X7Yܾ`6p]ZpGp섍w(6~PsWYmm׮O$9???rȕ}:"uYd$EX1U]??@cC${ 7i,YUhY@o\v>WD<*G@&bPbX^q/,"ULar\AYyEA6x6=8p9:48 {AHXܚL<3HBWPق;e!\8L$}Vv+p- y (4v;ws\DblR>eN3~>K?rE+nF6&zLHvÞ 0e HCv6^m:-D'C鬧vbשreu࣍[r!EZz@-a 3ڮϴ{;U!#1 ._"Bw5Sh\ &306@V4]nM @ EUCrH>D˸?0zUizD<#5`g]քs\0]g5'M?n !Vwga7}{4B8^B& *^ֈ+ʞ/ƳGF̌w6ҳe̼ov7=~6Z?? ;?0o$mT:] tA]h^T饵[gsuԪ:e7N&k% .)%s)F^HPuMfx[ҧhe^vG=A-VibJ+r6ܘ9`teKvH kN++rODM]#gU%,s;NoںZ1~BPͱ[??0imq6??!3o~qUgmWϫh&dpk!Vlq+uv+xTWiT"ZDa%32z{:E#ZW?0T~W/Lh iG9 Xn!O c))f o&]EZLX-ll{AuiUrY䌐/".d_ǚ4|3Z(EWn$K?rJ`wTʳа 2U6iگ6&^A "Go4Z" ,%EyԒ'#x%#p@yH<+ߝ+l5-qtOGb?nPXa{lA9&ghR*lO?raP?0V]ш5邁lhl$`?r,a?r-b)EQ_zQm|kEIJ;ƶ~p[G`?n_??eY^ϧ!݊Z;A=@?0RP",Q*?nf%V=|کFkCoĐ)1.n]KaQ04)VG Ap"#Z_vl7 Tm$Z,A$FRǦdjB]4oͻBM矘"pNH5*]މ*\@mg?rl|e'k.njO3¯?0Fh8V??e?0*/DNHTmZݐ)Ƶxfw??tԖXDjbgN9GjI,X*7+զ'v?nbƗz38[hYo,7K"pղgUz 9cSF=9\MsKMj$]w֠A I8kPt??*`e.gpx;FʹҠC,~-MA>m_:= -#u&r Þ%?nJ$HܫUݐ`2](tGĜKLkxj;\lT1e4X4' Ju,xAy;N(nM?n uԊׁ?nGtZH^zVg捺}wVUwG26M[=I(%;Lnڮu!zj۪x) GĎj_FRI<{DkB5ݵb벷1A9??wrX&N:P$c6b4V :P~[B~0h5ca <'FA o&`&&DL+A\svbM$!fTa ^+OriE+V+K:saE9ta"VЊ0,]9O\_h:Q ˪>{+-5lAGҾ珀EiRP$pE5(1;$"_3nQA@r$:f4Ǥ\ald:$Au& ɷE)ɏE~bD?0-1n.P 6o?rv]zv{~ğ_1'6 8X0عy,??lRӦ7ZmNԆ@$$!&.@ZѾ>¾ "eV+qC!Wo^}1P=BVT%I̾_<;qUFٿvqrP&;,#3?rȍrN߱͘} Q!Kn35O64)HZ#rW(gѵ1.)xK%ݏ(|"v3נ[K`YnH$8c]V}%++mV6^V+cu_]o?r;vp;㘱(h}??++e:Q(ziG )U4wc4qȱh݂q vOQw-󸤮~j\O24ikBrf-lHVB5EF˕T9^;Jdǚ^d?rDe*+] ]nPi٭7ݒA`AB??1?nP$ݺov}-ڹ- &aH2Y¸BH[q(t)]p@]7q! .]Zqc??pQ.kV YGXtHoi`GU"r2oUdq|.Up.`:C%E89A.#}1YTWVZ3ufg>G;ۂe7O< n첛r-"5{L0???r0_$lAoӛ"*&o5Jd{绁bz0Ϳd??śׯT??xy_ zĜǨ)#Y-UMYǃj<zxP7%9M??",= 'SLAz-dn)jQ?r3Zi̜t:;WY2HKdWn蝢~?nvbWxڡ5K dR1_H='4/$YE^tEꁎD2=֋.0@;0/_ O6ed'2qɂ;>uʔL0EK?n\I??.n8)z=s??9Gb|o8FTpޗmۆ<'HE.=+RuA@ZvE)WW%ILR y\\j>Xuʒ]ȏ@&R[qdBBWqEI<Rȯj؎vK6> Ύۅ[¾Q| |f䧹rS*FqݡTUFaIVXKoH\?0x<a$DI&wTNExI~6i*gZV*a= ePWq:6.dv9&NRi3(}c8$H0څ[ Gb#NH9kw 8[ZQHLF|`G NHYnAj-NAIeRBZ>ir?niF%vrff mMO(f^r c}ss4 Ff#L?nd-RHʵF&Z1he(364~nd~@^UJJfxR !3q$_,[t?r 葆7 hKyHO!!UlMu&`ٯSІ;?n՛b_HZnAu@p{ޏdޅ`Dŏ_<>\\n(` /[x$l,"d> ?rn8Mg!+]*t Fa ^ãŗ Sy B}E8DY`V#;x ??U?rXpcW r=2b*p|kR"C{`"M?rAG聗b>Qc@.p" 4R0Ԥ#H7qP2?n1ȊI y7m?0~`a?n"ĢYrAՎHńs#֎FP;bo?0a^aarVx'M\;[I^i:LIHBM]"}dK2s /pьmYdLΰl?rp~%&S{`hơpt@@c7:qtkfnTh{>v!`;ui0#?0gKɌ_?04QHHO75C|s/" W?0'd<1Z[ 4"P40xD <a}`L"nk ] .j2mLqx(A1j_:d1LOfѻvsBhm!^p:*O#i7n0w#Lyj+O*?rw4]2B-ThH.KFlT{ [Ab{8LIaΈzM??gToCXLa/G??|~f=m0j]hV,DY& eY{eBQ=DԞQkM8V+h0Є Pb08N@xWzjqPZ^ޔCnx1H7TjyB}\07iϐ6XxfAqmS'/c΁NAdO ~Af02,gO.lyl@΂?r::?r hβi|KVjCQ\ȇ46=4u"eHfTh7r 飼EY`7SMmxU+fNuG@?nqT2m,EwAnNu5ng@`Vp" ''b X19gFI}zvmS}e`\VNdA`>j.hF[tJ$W2uNh4e(yAkJxgK[ ȌA-tX0<?rk+H?n$]>?nZN&#UkCBɵy%" pYcm@<&a:rc}:VQ(C&)DAGwإS:_V_?nή9V4kd"}:8in@Y,[YnPZ=sfh`4خT0^37.>%h"%X>ݦʩ)FS@\ZPD?r&Y 4ڸ|@*峪goQ9`Uip![d iiô-9z/;6Mk;jKӐiy-usJmN;Μ˜98C`0?nb4Θ-r6ذ#2?0fPs6^s#J{Jfl+ rNmҵ x@wfLGFbbj1!q1s$%W,۵W-5{՞f8L}[)z?02Qze'/Kk?r;OМlG%l0-#??zY '5V 즃q},TWGa^`6u@*njBru~/mrBr.xoE[Z1כW???0w<{7m۶m۶m۶mƹ;ߧi;;ji$B߿ʪ<8+;l53#Zzg - X-ɧϏڜ2:>p39:߸#^C9*דF{Crùtȍ1|%Kҕ!#?r7LG kca+|-ugkuPV4`ԱťdE6Ҽb9q]>iB`>-7{R4:UrtR]lZz_>&TI~L({ZzCe#YZPgvH8Z)'MydtOwm٦7?n?0p03C4Փ?0FӀ=)?0?0@?0 I J`g MPPJs0Z07ap9?0X$\?0MlD[&de8 ?0.#"Q?0s\І91(9] ~?0?0x\w???06>J9P0`@ X??&F{?rqk(`xvN !fb=1HXt*=l`Bb(hXK 8څɃi9pʳ}w6xۅ{ "ѡv;#Mc##5F_C?0?r?0J rdŀĀ{Yd74 CWYm??kYCg9?0wT\ȹTY~/2H5 MnSShNvF0~E@z;inwR` k<јeBT?0X)sL?0??$u?0?0->_??O, +N9'c ,c7cdc??"?0ҿ??}!?00D=,rlD p=$T#$܍>+0 H?0L?0?0?0,H?0V;J8$ED~GA`[_?n'X ("z A1V(;Ҽ+n_^N4vw>>Mu9vB飹!"(}j?r@E㓖Sy[ͳvK7H?rG5AJsU]YSִϳ`!IdN_)eqxTʲ1T3!an?0P[3jnvv{ٱT^O 91??*c m[x‹){۾HGN\lP`dD`x@(IE:BEΧ;vg9q& -#5g &"=->v}\ę%heD{CNBx&??i:&iF??)F"|(-1vIʺWYEe[?0A=}ŗo

G_??^xE 1#2r̼4wUYր=!`~jt(RݟoƴPSnpL+޵e!2(8(Jv$T2a $Hd{gZZKxifT-?n8K4X?nCo0-RD#vY):Hf̮kĮ׫%莋,~9?0aud~ T7PPk9\%beY w"v=ɯlXk@H(*~^ [L $ D_rD˩=NESd'Y8=?rN &ey8n:N qUú΃m?n+EyKb1 bź(e}՚ș5{U|[Uz(KY*7 !q?r%1`RjGqIKR- )X,:YВG BH,V})ndΈQQ:+ptØxF9 v;sdw5aw„ Oe"zTs{KMbky/-@e'[VZ.!CṘx:Dø(Bv 4oƿ~}GD_^6 +Y(g2@k6+\fEAPeJïnjCT~!T6~Lu䓆2z1i:)dxEYL-uI!ge,p b}\,?r4+4TJ7kAЮ̀6&]2 ՕOhh5Mm27F j5I~7;̉yb8%!\_LU?0ʽ'Uʻ $|D8UaDڙ6P>W3ZjwwHGYa)Cʕqލm*5ץOffʽQ96V D0C%]>7Ф#ikYԈqMeoKkI?rAҙwz ov?0 n+$t-M !0I[`p8/;ӱcBΑER8ط%" 8Y{SӺ Iw%N8jK\!G{6u~Z@\{iKȵӯcsA>nyi}iUʖAAqQCTGy93%ǧÉEBY4?rK2êJ(Q@~$|Qe?0cqo,gAe[6fV:ܻtYށt5oywlyفq:Dp*'뚊v=i-S麉noLHCoL{0C}^!I:ȃ\dz(L4KgadLm'/r)} #Xa-=DɒVMi|F`z6dwd'_MuQfbt??hdgBܫ011(ADH@yp >SU׀aJ`q\E[]E3KbZy~Dc+b%~#̔_ߴ|oӨzY 9lP`?nV73/r]aFauQ:݄y%ܫ e:R((?nt$$jWM} t s.)_?r>@jJ:+{n0d⓸ֵz6XP# vd*u??=7 #j4oU]{.JbOKiɪLHN/N*&Kk'W&'I,䋐2 HuRh^Ӕ ڌ,d_??&75r$Wyo??ɗY6VXpz.F}#ƒI6ɼ8 Qc.T5|-$fBV !" fUb&k}R&)0fJ es4Z J"})7씮Sb`Xޞ!1DH^V9}GBl1>4iٵ+1%4ju{ u@,!q! -#ǹ a^ l'jڍ"8cDz YA{2GC#g8i[]ptv~?ra2)|jpnlY}+:ѣ퇅2L>[GWWR_yOLJ?n+)[]Y]oBvFby:FѾ]XOQi@\^lY7)'Y??1MFI;]Ճu.=s]ᗘ ԁ6Ҙ5jj?r\&׳S ku??og ??p?rq\LcB0hZ2v^ӥ5|~lh [8ogQ.,|.j?naLsi}yiHrb`(D.U0lZ!Z:ϫ5)??,X2N42}gW>wíFiLoNRrΖ:iJLs9X&P)T8g??L|,:`ܨ2GkZXbQl֧5dlJKhlS£(iUcXCN:+OM%FwѳҥlVJz);f\3%ݬ[2xfӿj=<;"(ΜPc)lyv<5i˓Y&_D0Eغ7'aW3s5JNI@nB0Tǜ`JdPrXiL$Wr)먄SwtV2B5b⸢315sʜ0V]E&(BB)GЭ*?nl !-tbA5210,d#,@թ?nɦ^O_܈cP冑q15",M3|ؖqމB$}KozjPM$v}Х,(i# oTt8BEv'??ޔӜm-~ezXaT-2;.Oz\ ,o=ޜTDXQ';Sж؟$>z7KySYs֮МHGvHU'4|"TcgMHmW !Kȥ#'?0{M5dK%ĥ !(ۭ'Tš+hOQ7O}j%~ZZ}jV$t|OzI_Iߥ8@tT t7 ?n- ?rpC瘯??]Sb͗\?0ՄN[b‹\ZND5mBWAܗ.))6dz_0i(t(;dVи?06&.&6gB#BG]?r?r '.?r:bj<o?n36퓰Hbev@#l@7#QΣq&v,੉\7H̀Gj"9z9롟!EPF'- NvKj=ɄF /΋'+FY==K/|%S\K7e@9@ýv*?n˕Nd:o=55-eX@9Bc [K& %:>$.D ol;^ eI@^[&T(L?n#ef !x?rѝ ILrv??9H~A!;oN>Y#:Amx??IR?0"鈝G#!'*Tx@fh}Rc)x*&W[4??pz\`bbaoiUPdBC {"ɽѪDĈ:`0c9U0 +r1 'GWƞ C6&4d+I+M^x?00hZZZWg7¯s^hWg#$2f[}HH,dNcT5O>܀7>0%DaCb0@ZVYKY^̿ɫ,IV; lܒŚ*% :@LWVD17F0^< +d-{;r>]>ANJkRj!ߚ8]>0mS_cexfv9< 8`<2(qC|nQHPyyս>?nP^pܞc;R^~ƭ[&W?n3 ؁2?0ꗕX}fG}?n8zk%!U%2@A̵giMǜw<Ghw?0بlY_e)W<Q<5a?0Bu!kN#់ݎac&“ G8>qtRu($koA7&YRl9Sxswy{ Lbi_*IqIxB`^bd嬭4!.)+3>hme#5o2LW60Mo ɛc/bIߜ-Kл<_]N2Ǖ (~6j|oBℍvLM*뭃g"ĥFO?nN5؇8PgJg{z1a#&$'/q)!@??}dX]ꀼ~Tд_?0|ClgF`{??wρD"X3TB W ČIEŦACWk-4%6Fe'?nQ qFZ5F6*??wϟrt-X.q4%R*&s7ye-'3ۂ)c3;-uט,?0pY 7r6J;IGFGdž,#*qjVnd~3.*Ҿ}lg73=sJ|9I"sEcvg02?0.b(???rS`t?rG8q$YV2QQج5Mij??2ra -#(: ??NˈfdQt2q1Q56ᒸpVC0v`dcfsHк(C9|k!H֚p9>[ Ͻ{p+T1;w?rIńh?rybw??)889Z8Қz8 #J,=( ie `ӄCrҳv$,[G0Lr4uk^ma^|冟pb1'_-q5ԾP;#ޙ/XW UQlX@b8캆%qwAAu%u# 8玜dAtt,1z> ۍkX?0,yOb{ɓ,Mtm|;!}GTq m??+ ?0ecڈؙ@-\*~kUn97zN)qq%C. nYai>Se2!>Gl駺Ǹ:Нඏ`KRHh#11|ִcږ 0<-lsx]Y6١$r%citF?rtfMÊ>u=Qٍ &v_ j4{FI~ 06??.,Lo7Su}i3kVpVJwk9 MJ)lv`-Ŋc&>*1]T%[%?r@!5PJ[pCLxa˷13{/zFv:{$;_ܬ~????0Z`גS6Jz{IJHX]?nm/:M]S xV!i!_)^'k^7Y}^p+Y"bP|O$(``fj Ygx4.HF!"ZN?n KS22bGpMw e)a!4?0y1@.)Pzjq6%2??'RqޒAiK.'-[9H-efW[q!7mш0ԓFO-?0dXӿVV?0vq=oocB$8ŨsCOX΄.??]oʍ.A%x)ɳR,K 7E wSfqՓ??pH?rOr2kR%+8qD`tJ+nd_8?n[+Ò__kw}v>+\ 3_,y+6?r>~~:K*-ݿE;ްEFVTd֗4t@SA1A }??ҜaOɚwHНkPFTK Pauc8Z`-Tg(_= `+H_J%L<"W19*A$`ICgJ*ŬHN3e*lA_q*h*l Ah;p"g/Sg>ay9BjZ +{>i$8?? 뛋yĥL3?0v*Dʞޛlb@̼)LP-؏ńnG :حU`宦}Ǵx0۔/5@c4}?ryz*Pn/9ZX9^478E#lzuɠdB+od 4w Gvݎac0: HfHJ:<+"o)#I93mxG[if&9PDР7`?rSxao 5o r@ !W;??>;gB(Hd. z6C8:^[c#G,oF08N`ߴr l˞+"2C_noÄҲz,:@ڈ %g_#LVW~!=}맻ё͘^+mj]QӜ>rb{V$5DBQw 5Tgy4SY1sO0=d}8ѸyV/u5:.5t3yPmyиY?r9'o_dLouc>џ1cycgO_ޯsfY7h l\ BfO?na9QOlb f6n/ U+yh0¼ *ENE=FeIhM?0]_n\l{]Rťh̔?nGU2+Y1ˣ%VپCfgͿK1?n\ D$f+5D^.cORԲHMtlfh~;pgT$˞iXGo/\z#t0c@FrAYer1T0AG ,VM-OcjㄷQi@A+9L!$w~M!w9~T306Z?0czgjm!jH1l8\/ X{Tnڿaی<|:oQØcmG'F"YZ ,Z{.sjRkGu:2P zMVOfdx7V,,N̾T,Ը=uk.ή:KC_}GK&AVIU)Iտx0VQn.|ϸ޳uږK]Kh91W[дy{Sx -#4֘ ;}4M}]rzYO#/AJ+??&Sԍ!2OY3N'b)uG8\?n7k8}ІDVѴΙ]u{-1JO]EmM}T[H+JM2ymi[:%Ǚṽ@2c|eO }^{BQuQY-NTG{sgAY6\w>iaڕEH{kr^OCs_G:!??Ő53>N,Σo2AO=%zrvf31I#&A"Gêz%kIx =]~(h ]hn/uG":0טY2qT%4_dOomu8c9{>,*"ˆwU1Z%ɧ?nmN8SwIqg0qnk[]wΗa(h"K^O|r$m7Lfd?nfʷ/s??Fy??s<0Bww19h?r??cr[u70,/YkUՍIU#m2a*d.P#[??ZOmBH@x")h?0c7(rρJEKq?n< <'\='e!^:Z1F7rdI>ƄjрUR*@Qo;b;!F -2"p=_/=MtFS?0??-X]qjCUW-n1c^(# a[|G@7I‚ǯZ@0\'"u~Cr`]>ouII?n{iQINj~X@˼F۠5ٲ3]9V/jP9*Hʬ0$Z ??uut]ϼ |]g郴`O"2QsT)ߤ}7 ^[l9Ԯ86ɺ75ej`Z?rN=.4v?rft[A*ߑ*׍h֭ݼ6@JN\pTK.|s(a7Ev8UN*Pn*gn|UPrtՊյA<x:r_xŬ;jMni%a[_ܼcyxu; C}rՔڊ2;|TUG??Vrw+^A:7ve.5Txy/() t~k]i^:bC >p,]N3 橡F̪" 4_Ea[=[3{-̀9g`* DogԇALP^^i8r=u" p--;VebA, V-sGp(?rkD6gOΓ}_dh˖y!Sc^VwJIx3B"=Jw!}Ivr?rnzK>$(;hiqe"2Rrb.ѮRSd.1x91YKz<%n73g 2ce4%ҲV |(R#9Q^?rk eG!ȭFkJFb_߸aėZN}o[ԙ.``J𚋆Yo/][+Sg%y~m2L`Ֆ&ɹ`yY"0?n2f%Π=i <6Шn 5GdX&N0H㌲Kl4;!݊<LJ$@uK>"1Pd`|n ?0cKfOΉ_F;hASEkChf/0>i jK}dx뙶/ bt9 W`+a_5~RVDY ]y+S3!{znnw!/~r,X'rYb_(!,)tECv݈eð~bItE4q͹/uXx??ꨌo/ 1S[E e LL|d6ξx\VV1veh+2k],nү"$O,;?n陃kp??9f.ѓMP`3+/S3uSކ%=t?nwduW1=-P䱐 )Ba,#zIǒDYz8J7pd+iU;}~aDZJtX^LyFϮ~s?0@׻2?00u:Oc*N4'[`>M?0*'8+7)/M8/ VYm"9+.~ikX`?? 3?rZZ.E"Aof~e^}}sƛݯPhTIwT?r;gv)QFFMC0$p݆3*6nv:О'vF7hʝxS5;LݔfP y f#raSI1q5gXmRqr.I~PW,hԁm?n3eVk^M4v;uOucU + pOZr0@ʁtI)rlf!󂠿\ZFIj--ɠfcZ=!lK߂)1YWr????v?rzETy3=w4OY;*6UWPPwg֠a}yߘ֯ʣ?r 7Ū/g<zOC m5]=y~4i7z ?r86\??f=t__58jMo7:Dk?rӣELdd*&CA$bfS3%xFK&(tj~ _/I ~n9 r:dM{6~`t%$II^?n>RkN7~9bJ%,fE?0O}a9YIpv\Ʊ>0x,Elq9\M<{%,ĩǀ #*v-Ƶ0Fgd>o-o/=V0IҌw -#x%_U5:A}h{bYA1eD@nV@&h65Ӵ%`ْ?n4T%Cc0Uz?nw)TT:,U;N>޸}npOD(RP3+$ImԸf[?rL S$(4lvaWůuBô?02v?rHgǫ_o%CI8/@1ۥ[=)S{?0*qUd2Q&iYNm"eXC\LՙjXI&;KYkhr#&F8[_2vY2[/ US\4sowUpr?npE"i+3)>p/j[e^yz?n8`yf5&%aAxqZLpBN,` }4|Rw咹?neԺ+}?rtd1u8rx3l_{(W+V*Rw}@fY~wd[;!ѣPyĒ3;e5\F^Pp8?nrM;2+`h #7d_jhW7y̸~.I+y5va:Fd9P5Dk'=Z\ ޒԫrhI18,On`[dL8dbF~:oVdS0"ńşdfg܉xЙX9L\Q)(M~TߊZyuSK*[hLDn@4qJD/1)h^{!1_C"kD WQY͈=kTi!StWn[Wp]~R?ry/A?nPew$rW*#X Yk|s@@mb$R,q1?rel/!}G=:uPx<3v0ɠ}r\o@x*cZihbyFتFX8[JD3t5"^͏(oVRHmc83g9Ө¼ۃM0'W΅b'V445bpSR(X`?rd;hQQcPOmyŴ[kAN/?n)PΐDbf\7 Cn=:mqUV?n}kbi\3?0x%*f%:GTPr<-ԏ u\mq>ɕmW51w= -?n>vCZ= ?nl  y箑&&*8/ҶI#MH[[#H\0" gybCQ!d 0>u؀3:x1.w×D։2D*R˱.5++ynHr$HC>TK qSQƔM4tvA}s+ݲa [Ctʪ|+1҇2J7;36H P'fVkz y);ʾkzߖ%N)8;qRJ<,F([Fp=)3ypڃ)TQMq҈+??8C8 oç]l@-cuNK-9[c-xDuQxeoT>nlCCip?n'?r?r˿eFr]MlX@I_ ;G8N|4ꅿXv nn6k)䷒Y?nv#iԠLt0Uߍ]_JR9``Pӈ01O9;_㿎4E??0f7[3*\"O5?ncbdpK܌^2`ꮼJF!|ے[0SmB<"a2ua<}ҏ#D{l&Ĺm>̒H@7QZ9K`CiRdw *)!_~of>74#^??!JϡLYڽȶ/PIq -bܐ(3:{2>/1??nJU1(( ޴1Š2{u0~>$aät( K4 jd2 ??,|W1\C;'ilaX1u܊z2Bm94)rM]$Ϡ__~ksvsnP*":eӤ,5.Bȝ+S@8hM5SBP 1рcOX{r10?rBEc/x?r:#??HxW ֻj%t_q?rn`Z k2[똴L9eYkȔQuy??*\Z?re !G̕ĝ3C^Bɾ0[Cq6sm~'lypF{Y15_(GQ iհGKj}8 NyxHs.uV0F Tb2x%I:TAmNW^0uhK&D<\R\JZe#D@BJqJKL, Tw*7N%E{Ԃx-]PG:W{'\M pC8vSs\tKUJ4xܔM_j]lurC@VV[0*# .NNRPT?r"5 y?0W򅇹l8~Mz?r1/??<]'{bFM?0DOLdJn9u'tlyy&=nu?rMӱKKwZz)Kǹf.?0hj̓ 00Jg߱`&4PSa?0iš?rrї=/Y44&2grƌG!h=j?n7ŕxf zjhOhiCLSWtij Lb -# %V%IR ,hzo< O?n\4X*^bGTq1Ae]`YIN 5=i_YՐ|K\ 1B?0JZ^>??l\hf_Vkp?rɮoJ 7p|xo҃RLz1X}u嵉s]b85-(c?ny??Y <E0N "ncDAۇ&s`zmo D+wV)q$?rt~;xG{wiz<]>PM;CWKbU?r{38o5~=oHjNF;?0Kd$z]W]|LBo꤇g~km?05:@:vk{?n](7a??9;Z!?0cY@L#8/AUVs*{sK.1>to6 ֪hd|6Kb,UJ E/܂/(uw:Cw>DH"A~r-atL?0gU!A?0R&a%4f2xJ¤+2<3E9; d#pb]8+A':[}pJ'eWd|ʝD+8B ҨWkm2 4d.?r(~f!C+?nԡu9"dwaҟ?nmwnj8X1;O{c3v?ru(td}g&\<U!jqv83 Rm8F1AҫչiHv).t1xX-$'VҤwe?0?nLb},{N=x*B(/5ԇln5Qj [;߂P̗'xf?rV7<MA^|s:BkvCCQ"N?nA8?r]⿨ҍ2[WKNxo/6Fz1W ~ v?rue[3tn匇 )8#2p^H*|u?nca}"msZ[5X1aE[!o$n^61z:m|ѹ)??>\ʿJ CMRf9Yrq mZ6Dnuzg6Jꆥբi_eDęg (#y s9}?rc(D-WU$k27O??s̟.⃏AZ6)UC414{_COkm6Ks ??zC ̌ {7+gh{(RQ8bc}v-m/3Df%6St}tD㈒KB6B!.uF3=~Fߠ]]B^8ey';E5O#PF!6m GcP%v`g.M݊\conX$iWy^΃Xd\QF(؄VFD\B6TG=GڌY@%pDS]O:5(qa gpbh876^p-cmc ?0<[]a9r:tA}R͌U/Z-O+쇊?rQBjX A1QTShƔ2("TtR+xڪ׊Ǧ,fgE̳^?03'6#ȱ_HY$!W!ZIeƳ+M+es.@}ziHM+QPYMT#@Z?no0H p3U犠wC1?0ֶm۶m۶m۶m۶8gIsΈz) ޢ jW9?n}֩@v%؝ -pC=#`[1ma˹vs^J6Ee\>8CW]Vyy/jx2QӳP B@./2u1t3??lߙzN2bvq-!UȅJq"h{byj`&z3堲VI?09ѩz WtbUEOzk|zm42¼?nĨu;yc8J\}ZToK'Rvk7J[&bv?nHx9P/'Ođ}Q??˲L6nt&E&))֢B9u`VWPj7B¥V`]^و 5+%?n8 p&OJcsڗ|о^V?03uȯJJf9Vc!! =2,b{탤G+=dM.Q.3ɘԻF5PG8}b jCu\$lN(chA+UO''cgA@ TxBߏ‹(5bU _,6Fg6dt0 }")urO 4YKPM$ 7eܐ{Eͼ`?nlB8Fi)0Y=fyu /9mFr#{P D]c=W|PŻNnV=^h(슧QJuk s,I$F"U2^p.Gbf7%L^;I]/iMo?nm3Bә]/\U?nXtHsVy1Xg=m.^V#L7V;Q\*ʗNߖ_eA>?neî5.&X߆hHf,D$ )TI(FdvR.x]ēp" onጓͼ$|&=pk,l4!To?? e1 Ci8#B'WePz#ID50o0wT?nIF5WD9eL^/(ƈ_ʧ(ծB' Vp{[Gk[f iYK-x=A0pG?r<D!x$a4q?nǞitΑBDRh91PŦcoe=gg@ T-a,twC(Dz~~\xvʩt+O.Sat Y?nVlDP% 0 ;\'9BchP{C?nɐ> ĩi-n`kվpSƤiF/"ۆSxk#)T0!ng ߱%Q!2j "Ws산!g?nxp(K-l/+ KrQh\"1P?rϭ\Tu 싿a`vkaw @ICj۟}}28F.(uUV2P_z'tll/#a*>F:W؀q/~{aWU@c2.dP5Ӄ!m ,qHQ?02YP<4`'EiNy9Nhw }I6yx"@3,mU CO&,ADCf"Ndb'|h%FSk1gDÆv=H8??(|Y3]rxxH@~DE$T-N'h[Oe?0K/QC LMc|Lx R=dwo\H^v}`זϨwP+ /Sq{E4Xju5fs#QJUL"5}p+1X*gSn'1{0AX-"S ŊvSl58r??c"y V-hݚh|h/jk.Q+\JfXS+kg"ny[R 0xՖvX4-d޳3[)&%Re|/iBIum_ˡ_xůޔoTR?nzji1fꕩC)I%PFtA?rb@ v@(dROP,eܦ6\M&V8 +=b7׹Q/e?nㄙ8.޲9?nMȾw,ѤM"mDGȨ9uIN/@ϑ+CKmT-mTlVRk5r'T«2qF&1;1z/а0~ϘsIQx1l_e!Q0L>|="#D\Ú>YK{W5"+?r|4>9#\1N3 -#)dzŐM'ޮTX-ΛGB` L*J?n&q%.[@Ӻ)q{Ic?0SBQ9Tf F*GA:}yqUS0qKXNrY: uǝA"-U`x$DK.&+&1],1="=Ojsia$MGJ0d -hN?rx52/ Y UEOmz??A ^f?rKJw<)R൭QiI])),NGKkדlͶyC0g0qߛZ=IbcFغly8Yl9i?07ߋ1&f='ZjcO_ 6;D4,ݔ)x51؟Ԡ>mRt@яi.sftQhOT~ⱂoFE>؏??j͆M{͖W?n3{LJu#E,?rKlQ[ ([nv#k:jmYr/ӵ~ANd*Q/@1ǂ_^J8##oYyX3:97{I߬W??\K0W=qļ\_:FqHpx _8*[ek`brjHKr$ 6T&$HxIq ?nA.RYzlL$f!M_ ΁(d#?0iL`Q_ < ijW K:ϸA=$DʅдNVB9͑<<'CAK굒IBR==jQL %O?nyVLwk?rJa}1R8AfcZ)yrYޟA_foq}kT%G1ƃ$]zS#~$w_j^KYٝ\??g^xa[}L2$iI[jbb! ۞pQsl8Y?re27PD?r#[Nr8-&+n$)C#Pt?nbNxnSNDE8GȎ|G= /%t?0P4qݷDĢ,1Bn[VHh?nH{Dqim2A@;¿ )҇W*`LulOf)kv~)kaafl 4 \=" F"E4yt|EBg68'XGM6n=_[g"X%oE:е:\9cܪy9Py?n g??lD#!??竨UꙐ^,EǻwqB??Btt!Ob]?n{NJay}W)wR|adsv)t]9GAZ{;C2 u7@4l7,Xr/at }k/b?r2r&8q AX;8YA?r"XsD?0o?0/FVYO;bW-?0U<|eeRݦn-@bIv SCEAxRߴ.{{|_GOcBxLM??M<*h`;s&߰L*p+mBJZfDXwZh:r;<0bWj:hwV$H?nD [?0𫟁ŒVŏuŠWN6d?nҠ}FȆAMO%fuqb7FgPz>0rE$.cBEfRx%R:wBMQD)(|GDHd1:Z'/=Zb^?n%OSlx;r-@zc%/$%6ƞYbmI("[0k`̀Ӧ0|TdlnzldX3h-Q)疒_ٻ%We`pezUI„$DG"jC!X5Re鎊(͖]GVlWPRʡ6kY*pf JnRM$<-̞4D]ܔMGW.jRHJ1011=?ru=)/+Jh4 BVQ>9"@ij|\D_|?rҬ li _G_X[j d;xV.1ya'"q*nbW;X#?0[7xs҅䵏).$XO{BTTQ }~)` ]+w+n|(ISENe3Q O (}ZlVɸ᠟Ad_lUa?nה'nLjޑ2vJMvfPF0ث.|u9??dv??hpE 0FjA\S)2E2/k6dI{ZL']qy-v|9b:>@ƫC5XGiESm(goX׋_%r2fDqkY榁4O͗E?0.9zhceS*?0N0z7hrE=[txPK!.צcV_a-OsZxօ]`-Ccr/Fs\^ہ\Q8 F,ե+PlAhx*V@\~#{,*8y ???0'qilI1]SOsU3r?n;xY(lJ`1-6&񥘝v>X?nӣsXJ+ǜ^{Df3̌8Bu"s:McFYF`޷3?rzUE^\d:Җ??/8a>ɮ;Y׺yJcyt ZfE-g'@ڦH[$bض`mE?ny^yv=\ zNu򻳼IKD+'fdd93|;:?rЮFOdgdd\"\Ls"xl΃?nРxO1.?n"9G?0gHXڝ cI衁v'?rV?rn~> -#gsOҀꆄB?n<{%,io=ыYEޛڕq+kV6vɮH~RJ#VgGX~V^'Ȥ)Z!w!mhԠy-S}:pGaN7IQLJ j{E`,E&.@; )&Gʅ_s"|~OyU :Z5sK»2nJM?rR'?r!}N(Px4??^+>}o߮fMZIѷKT*Phs_fQ#KNwUSūcſ(wVWK@}-<ɳH{2' {}o@{A6GyG*5VVdpo`5Ӱd2es {~>}ޒͅ#؈YФ ZO"1eo??)$=OK*֝[f0Qެ aݜ[OqP{ >z`-(V˶o>>I(A`ƴr\#aw0<7YU`wCj9/1bJLus@}D3u=N?0jRжf׍N0ƹ?nLi?rsa_]h %3,1&rGDR,C5z)8>"B5n2T~sK'wj7s*,([3?rXf02Kٰț @rhq* 5Q}gDm1'gLo Hl#o$ـW(6œ(7֒62|g*1S٢tTl>??8=ghc?nIID#e+9MBp0??&{FŘw>P, ;9Ѓ5>﷔^`VϜ׺`fIswTOE=3Nb*4x^2+I??CDa"9#UOh?nZI:瘝Q! qQ᩷/fͥaCN_bkR?rXivbLTiXgR?rc9i 5Z3 T*۶}]ĂRi<؏*?nQt2g!x!\Bږc5_/juN1c>Rq]/d PL{vG/SF3>$.] ҖSkWGtG~\\WCo4jn??俛F'Tr\B/?nLE7M@3I&̡R1%??+MYL(GMrB/h:!W8N Ӄ\[+fZ&aD1plkvΊ-&3;rSz> 7wĜ>:R??2?0uf4}`/GlSq.BePzm^??U<?0e`% TJ8eʔ]T&XUֺMJ'RO.ULgr~F*;0f߳Bڔ +@\LTkVvLTwvֲm WX&Mh:H@5CSX7xm)y`+b_v% xK'[TvB6I'1lPt&XQSEVf7磆s &LΞa~_?0F!J] ώ=Lu;I?0EoIgT-8ՑEsmjU\fudy uJ:F7CgH8,MgEj= ozEtq~kM=2X܊ì9?r?0-J{8XDڲɹwϝkq3_b8@oO61u_TSmLxs;a@a~:!؃fFdvd".~(G;>?r/H@ >}5!iC2ukN({@-zy"Q,ցM'[J\:'SMtlSP}6V%~'sSV}9x9IF%4)s-@1.qDx& Wm䗜Z+#14??Y??ſӸM#,㴸@|JmaQk8x c?rv(?0n1ia}?r}˭&葯KlF;U [?nwOvbIR52;??@Û_ǿ{xb̈́mG<WQ%;F/?r^/mE(ğeQ*[m 8dZ ߇90:Cw񑦸QM)VK~r74??6 mhGRKM_OMl!"(??uPw?nP:,JnUnW?rj}L7d)K'&ͷ3~(emK ^1 9Z uʋqtl<4*q'S܃e;)[4BL.Z.soyE)MݒZ*{w)N&|Y]??T9 +6[k,ֆLQTZLnr??]U^6!9t??RτFx]kepH2\Vs^88)o![|Wf%GuGO$PzN[JU ZE??0]H)"Q$ɀ[蕟X+4OFQ8sYMyS@8er%@D -#[uNb@lKuF\gܦ5Ic|VN -ڢ2]OX>?n??״V@:g&e0b6C zS$yU;(B>X%Alj?nhdMKIICZ??9"@N2FۈJ]^Y1"4t?0d:dNsTU?nѦ[g`Ú.-Bg_VqGβ{]r= Lap^,ϫd:f&Ud :;AE#{OsMnx۳Db$t"țRn7f+D8RC7N 4!olY sZN:3Ep `6X>oO+@F8+2nc$$#?0 a*li7'V1ʻUUTMs!vO=WxDѠG8BE_BFQi1\!?0ɒe?nyGlM+CKGrnFx6m-ޮGclN?ng*Ȭmтih4q]QZbNV Mm,'F0OTOʁ9Znd8C7.&WU(&M\E-M<_9ߒm¶=[?0a,C.@Cܺ ,콺Єִ-ՒTF??/TҶo~rs8u=[@?n|*>BŤ?0/ x7n~??Xʍ3O|{aȯp?rꮕ*!O]Z!U;#?n8mtvqu5Բ%<-Cxhd&?n^"(,MҼP$с,.{- [??%5Kb ٓ+ΘycpLZRbџNEuAj8\{ဟ9څfM!'ICOIHq˟hl(ݩ0Gج?? f"{Zɜ/_76hd;SJRqei8B+!9d??NH2(/*TvK=H0beq.t/N?0X,k&D*Q0ѺpZ 9kpL75wG6eGkI#N 㪿l,Aǁ `2"[&軴z7 P1b*/#/5?nᠪc쩈:>LQ/Oqa㑩)u0?r[`ab%zLC#}*KQzhpRq:퀚i<6\kRk&6uf")%''E??t:#<E81a-Oh%w38AأoFf!s`utqkb~[H憚L嘍{a'jX>{0sF]H;Σڀ>tqOK[7kn2մ qgSg3[0-|guW6e ^\a-ܕ '6@68H1d>o;f@#xJTo(@A>e~{;IWjkA<;דn#ۂSꖡ543Fk",X +;*w* + Zc._!J$9* /Ol7xF2T,UΚpRϏ3#F@cXWeJA[aN+#@M>\wLQBvam"jhRŹ#A0ఃqX%lHț Y-Wq"L)k,;>Oz޺˅>(C??p/G,a-5.B]A2S7 n69e4q4Hj=27Eݳ!?rnbA3ӒO;G2l*1 ~:|?r`?0?09d|bpQ+nS}*[k'?ngAh>Oϖ +ΞGASSkJX;%]<ᨵ)5J5LPallT^6S[w7k܄15)m(Vsc,* B/ۘ!Za\cev_6s:p @e4v&y7u$}Y)utS/M?ria y}rÍ)%^R>@~tO7u#|6F?r$!Y`?nVawf$ZT ?nև,0?r?0YFyqYI$jӀBˉ˔lB0XES_4=&q/8Ch#W3_G\ q/{ː|An(k{k9,uu,v]c1kUsaޗVI5Ow-rh 0 FU~1Е*y.=6-%{յd ХeIBg7S^gT(js WF7@CF&lOLV^@)K*5dPn4㧋Ѥ%zQJyL~?n`_ye8w?n&)U#ԕ[4,Q׽Y?r&Gd*|W8/)!Y%cLj?0J.[>4.*#rm1pYSjC%R1R_?nF<Ř5QRLiҸẳ3w"HnPwKvs??!Cr±$_پ?nB!myŕO.??#rGƃJzUYQ\Ls^`>-?rB{T=ʙ|+P=$4Yܐ?n/pa4M~l!D. tZl2/9 2Mi5Wu~?0؁HoR`ۓt?r-Ch&x7m^+&񟗽GQ$>Ƌ)e!W!/?nJ??&;860ͧ-[0O<@̾wHr#1Im=|+{v0Q ΤIS|^ ?0`>ΈNї0/Wy*;o|+ms6vձA[3,?n''ԃW!Fͣ&e-:.R}(WaŜM6l{0ZW$V67R5ӽRYd_?rtȔ}[D<_?rwPp,=y5DcOjk/Zj+ЏzxuKߕGLvq۵G(mT$(M\obvT>x?r˸g"'J]CF<5?0k2عġNV#=>??w~ڎ??l1\k-IQjng.?rHK(@*4}k?? Tlx:|f>l Y -#[CY|TOy-*[8D|%E^HݷIО{1yvhu+[})~n`n\wQc̼OR?nMc?? D@Epֻ{{t|ZNn??*E 4k+Bh6 3|(Yd7?rQ_*׭6N\='OhX*nTkw'tvcQ/ dj $xqr㨲??{K}ᓈP d>i0kƱkծz>Ĭzg)9Gu^<cv .l<~Ԇ6XW,Qa.|f(_q.[PmnΗb[ HppW/BXhGR@&>{;}~%=?rllBqo?nZ??d=S4fEKPa٫p\yi5&RƦҞҝS?nOs?r*y$u;//Fڹ6B4I'ַ/* }X7 v)Ruv \9ۦő_D22w!>d#IpngL[qnD3UiU2\pNVVI0Mr$/?rl=NfQFO],p)=:L>^btzK\;L]BlbBx2.ňz6Y\d?nDG`ubbH3QhTғ$⽑;~pw'Uب*9uSz)(tG5 &AX2 V-?0xGǧ%w?nq=JZbzk1tAqic.-r:-ac*زOAeĆ4> Z` u۽&9^T#-CCĈɍ~ٕm5=&ƠOһщNsӥaQwHk!_51|iNK.We?nAY_ŧ =cqmJzBبK?0jfRܼ)tv} Qmh]Bzpԝ?0MN4bf@HC&v ,'ڝ214w|z/i: ZA&#kŚ[!O%/Oiw(yqL{$dv7j^(ov,Ai~ iè]l^Q&S\vp/'C1Tҩ19Y 3(B 8wSQ -J?0(F * I=+Uw?n"g´v DW˽\)9[J3#ܺbc;ƩP!P/?0?0<́@IHc?nqCv khq:2͍c,0hfal=n?r$=?n;,0 5UN?nV?rd_MSI(l)\BOK?nL ]( g?0j!SSR#bypbSݤawOֆߍBc~X4|LoD|={ݑ}= `){FqMc* Uλ0eu@Z ]Z"kdeĂ1^c9/hf]Dsel,x??ldB/r2y@??JOqdADžpw|߂8$9= /N珂XZ= Q {k[o+.r*6U6md.X[tmء @VbsRqV.f\v[l?0Y n$/\z*J*9rF 9?rr!X1~~R'6?0]`E],@ӹӑd}?r>)2sL+ق_dJ7ڕFnt9D#I }j}gi\Yeoh:n4\}DɗyuV?rS#\2 +8B.zN 8g4tgxՔNp=AW;R@輦Q1$)Ww,ـW.rf,S&u}n0CmMn@D+ڮ_R_Y/QNZ P磈B{z1?na1as??Bi.ہC/9OoN!9mh!UMj9?0Dا w3gScͺήHlfN}“m*$Pv<-:W[uERJ/ ӶTW0*-ZKAO`5Tk뛛摵yc.9$jJ[` 5nnʄ3sBʐΔ@;Ѵaay@ѷ dL,X_gm)$;c|jJOE>cP%Md"?0'țh!Dә5&F_,WOiP#-pFAyo{Ex966`8-fߢA2?rPoN)H:ž?0mZI7wjm/v)0-)BOm ?r>ov1OY,8'GmOO?rXpl!Zv˵ :X?r}lw0?nnV6i_SxenCw]~$?0뵀#,Xz$h n;3vr0)ν:7ᛀGyȿ7;0sd#;??TWNFӖ MS)8bZ"P+y:,緱;L>];wHŠ%gT.///|,߹<2kHH%rr~W;Ԍ'F't]meC)R#G{?0瘟{F?nk5յ4uSܗ8ūc B?nI"|8}}mF57hTAydksA{= Q<3U>vSW6knq01pxp :1^_7][\~??vk??&>,|,D~涴 -#`ꍍ3y<^nJ4F0ل˷ڤ*4cZ fWv|tչ+m~?nr8R~OT#BT&0:$_sά2 P>MU}&bؖ@E!nD m`Frf!D9LR5{_'t;xN/"g:<~vUpqgе67Oo6?r5?r sGSܳ4OZ3Yj֖rA>g?0}vHgSB0{25x4>릲,}pP"PY91dBL0kUp$֦h@L54˸*ba7d7H27oܲʩ}8?0+дo0]DVPB5SB.A3 ƯTӅ80mD2-NWZSs%VUL_@5#F^,|fh\AC "+ $|cm=5A_ ֏h̓$/ M[_5ZA-I?raL&2ڟ7qɌGU'E-"8-:̡32`{8ynX+!jZC_6mʧ\T⋲>8Aev.n0`ژR w/֙UIPp%?rǗrzBl67͏????u C^&\;*\ t ނ/` ޭ{1UX3¹ckW(9#seHL;:gL\-;@ְmW{TH2?r+h$m"C0y4uTHтAߍn.=ފE5[pwZMٯj7|옱DrIX!fAC&J2mM?0w"ZX֤4~0ˬ?rT.-#ڹMy[=;1;f rke?n,v?r7rpƮ7  C?r-?0r?r?0̕fVyaƾ%.Iy)&`l-jw0I tuX  P>f/s'ҖeQ'/7Hb*Uoh+blydX}8(T56'oD_:TUƬDkt/QĄcn>AmV㴥rNrQ(cr5e̠Vj<է v:a=6mk$~(:]~wWqc??A'$:#.aiҁn<~Vͅ7{?rP???nvxb6P>5y,Ex'?0:|NJë)13#!lB)ny>3[z/iV{.;#?r$=?0mGm>ĐU\-&@F -uywg%[:f?r P j]yvWrUu.-1`?r$4EIfYvxEIݱlL8V;/M??tr]#Fqaq"o!CP Sf)5+1L"&W$⦔\UBw~m<8ii|${dI;0L83Ogi8$m^q;%<;][Ud$#PcD퉆UyR욂DF XFH=C5C$MC}^TI%UsKgIJ6%kJ9e+gDj V($Q; ??t>WDcrLjGΫ6-_??5ߓ# K"$ܜ2kN7Y>W?r<#xBQo!̇귙 K&4$h=tF,DS_W= jSmcG`myUH??J J+9j-/n4TQ< 7Q4?nBL5[?0Rv #Qe-Q<38&0J"륫ISq$rg((.6F#Y^?0pu ߕ :22Ͼ?n֙4lȩn `2Qk~gڎ??p2}.`x'LDK??@U,W*~2:SZ&&) 9ʎuWEuPl'GEDױ??ߵXUՖ?r8?nezT+:U$k4vѦu?rʔLDSHGYU@pqQ&whJ?n:(՛MkCu$HW".Z??I_24EUq.T.":VJΠi Hr?nPUTD5#S:i=*7M:FTB6ͽK>imiF$28Mékt,4M\T]> <!2h\s'{ot:@-_9~\',,NYgSW@TM{N+*Ra GܛnF>`)7XAi$:V(BC84Ky[K,~W:(\!(\jd-lE:<>*>LqQ?ryWM[iEe s%~i݇}`?na/H*??Q`S܍㍿u(Ɣ#CQuwH.6 -#u2J¬4L< ɑ|C—ʉ^u9<2?n4IXLtV:㴱1Kc/-.bëhFԛ\WB\(05 VTkFr*FP9[lɆvB݉N8hY#d$4&8# _&97^Y<<ⴰ#4%"38]4t+Ύ)?nqK5Ù8A5b5pVחjNpi%+=??8uw#P?rѩVdX?rL7wT|!U?nޢJ}Hsu,?nĂ(%sYBྉeì]&P{0殲B?0\@IZ~KJ Q{4F&Td> :pآzLk##!z4wIEvKRX(^IJ2DҙVȐS݄쪭?nww!f"մ ^Kyho]cDz܌t,gqH02_{yQ6-9ttTEhPqY0nKnq*%o! fcR)qr6(sQ<'??8RŘCZulSt2 |F,'6LKϩ?rҡm,Db~lPCBMSjNviO.nbIJD\w0{#>V׏5‘DΜ*fUD('5GlHp9SR&aPB -#;>th4Ho32ROf"!iH|S?r ͑lіNGR]'|C\~BNVj?r_|?n?nVE;'(7nrSG^#Ts&$?0pDZa|vcpR T@;ՂC/?rnٓlq'=@;/FڏE$5JM}JvB'>DEkKȊNAZHgվf?0&Z)P՛??e$'+f|.Ab+ ҭ .u L?0rB6FoH [`.!JM_mCHvPs}?rrf,7ۺm4g[Ob{jV7澷O)ՔP|XOjbtU^??:^jaY$I֙9\]L7IŦѮ˖UțYNF9vmeM4TEA=A &I4l=n0R0r(*NJ,2{'W&?0@T5͔`.D R*pP8b X. NcSRZ_)O6j ح5.Rۺ]8{s~:):"PDOބa^~b|y:F4,Zu ַQƃGjh+d5C-[0߅򷢀*({{B{+(U8>x7Q -~V]IᬄmM_|9Ago z]1D3Kj)&|HD8?r"#S)lLڮ^fZ~IU[0Tr3sc-Ny2i4)?n-kpK47Av;uUEIF命H??|98}SÉAF?0㈘!XK5,|}oSL ^oObmMk=֡PgZ1A.91E݌7YяFUkWWuGXLlI:64^uѾNBH[uɈe1S.DMY_)ٝ1;0Cb3CjDwQA'%P\0\s~H#SXRy?rbGѯTT}'ZʸMbs ;( w*= a>FJE%.pM*b*Sb[KO ʕ2@{ '7}RO?0?nw(!G_8;@OEsW%bǘhN3J*2U6Uf 6ScSu&|]^}g~ϔ+t4;o˝7HopVѭm|M_J?n\+l9pe[gZTm28dF1H|EmD-WjQ'?n0ibQڦk5:l1NHWzE%H(*]σ_,(|k9kξDw)emG]C;j=~?0IlݼsV]+Uif3Q,݂Zۡ)0M9!,V?ri&hPt{j?nbAǞ3@i YFQ|E\Áz=6" ۩uvHvY;rOvy 9's9|~zu zLQ+e+^6zSͧoQB;%3Ndߖbs*Se?rFӿ-̻~?n)R|؞w烹߉]X$xa JOdzREs)ma+:Ұ /**_ؿGi$`i.`rے$v|?r\q?r??wkbCS>Nb#??'9r \%S]7BK*,~άj:?0bDO*þS?r!b~:K_/<ҚGGRruAQf-_ؒLہO8I\P:rAQ*[rJn OdRWD(m=sEE&\iJER}t)}HeGOѰͬ7Bh=a[#*yzd<>3Z"._GQ72*5ϥHZ2qN֔W.7,RJl^n1Wn}Ȭpy@&Wy``\?0ź:?ni\??nȞ虗ُF_<'\a ^^nQB/;3i{<|w^ċn0wz fG(eۋ(oThNXǙݽSfMb*V m@٥PxZE$*f?n>ڎfCd-΅\|G晦CqǝIɣ%).ɘޖ?03bAjM"?0ǿ5eQ/<8Atqb6k%{sj4Vt*ϳI-θj|ޤ׏R@(@<%;WW<P"74q3q)‰Q|RGJFY?rAGMkC??̡pBLBov߼p8FRi!.JG@ä&:ThҶxƒEU\Մ# w77 K??}]ᶄ\5VZ7":E4ITB.B[@k?rŹxS]\ciVp`X$[tHHABFp_qS]"eĉV{]IZM]pڈQ51'1??t5>d4lpuCWuclb,;TZb!gd|8fχtaR\ ST3Οm0_^[|=.>|u\e HPO;7?rfj913p@Xlp.(^aٮ"9QobE:;nLQ[4*H!jSD316Uhė蝑ōby??>_G.>qpL=. Fx?rSorȪTV3]! Ikc")m4̏.6:mj \!'M@FW02pVįG¸ł09??i`ٹúfP "j`r!wu~8-|؝GTѵWk铑wWE3AtXosa)0j7T-4 l|k50ॖ)c>rRgYGngZ#k`E6UxQgTy8i7`ֶ:Ҩ5?02q|Q?02iTzX5.6DPW@Lrw88L@3uS+DP(+/Jt!`j!XRRɓ+he}6eL@&wx46QQZr{9cw *"D .^D/kR(?r-̋Znoy"FgduV?0q5nH-#Hj 2QnoBgElU:%t.Ǐorꤲdr??JkHn"{o,ks vw+??oSpxDJVMjceO??/憛եO6S ~u8G`Pg%wNg-"ee{ K?rqEetURF\}p8lJ|%&xՐWj1bMڍ~`ͣ9ogy/{[gAUDGWS%6:nB,IBX(v9)i-#n#t{ɘEJ?n>Z$s#*4Jjz8UzphI+rPFoaGRO*wGLD$AW~b9?0 acy|6Hؽ7GIGDW͓eDu4ognY/N%_??`qE;>%U̬s0|x??j&0_>4jYM~NzZv6|15|֯vC$ó !s|)u_f,H|~Y*̘#i]j_0edzq㐦4:׻v,P$%YrcfKm<}pĄD"vWP cFwgbƎmqv3w[=&W&,?n?0e8Ŋe:~VQISSIDIwWlpؗ"""}0VH}$QGUHu}6/Pz]GtH?r' A.$}ɯ:9P:t:nCGw`F(hS iBF3;Okk5fRӉQ -#0_4R`/(jvM/_IB!!{',͓Iqs[DdW\/'- Γ'rL!!@?n4|RS@n:,6ql[Mn2k*8IϾ wY2p`Axnɨj7G7n`0R߁\ڙZ}-'A[Oil3G`sZ;G惹@Z/faIJ\ )}}{=>`P@uEgLQٕ0JqZ i<QV^a;~Z_xq ]ngMI'q@d־O'f'c3$3mvt>\N#\R9BG݃ސz1.^il5fγ8Q!!!صᅝGf{բB4_nRa{}ugOO"2kCݑέ,Nv 7AP#=HX5?n(sA0Gj9]ݠVk;m?r-0 4{Ԟo)qj0B ̨ϔoX?0Zy;`.ʑ |H ??F\b#6-??+>?n1"rn׵>k??l&_PFeSj^OxjyՠfZ-3M߭EE:ǯ}pZv=pJ?0䀻H?n9֛Ta(5PC@[;$y&ۿZ\jӂ/BZU*|<62j?noͣ?r++Π3Z}{|' z?n\ ZIPE #SЋRK;,QE@qRlֻ5I{V &*!cWScPE+ZM &J]rـZs}k?r2=WV}i bnBw3ӱQROƳW U{Q#xj;eЬP$6Y]͗"7Ak氫} t!E'b=lzAv0S|=uT`Swj<,Q ?nxD*[ށ9 If[?rզ̃9rHm(8 \ʛDI800G,Eb._^X?0! `c!"fb4Ft'#t#ӵz,QU-sJ(75-{߻{Arȇ?nm>voQU͌)qNV #Tіj_=t| ^,Lju])?n^x!ĊgpjB;š':H)J?rb\AT:wU9BX)5/<0L`|GX.$Tc0!CZ!L!QmmBc񴔡UOGb|8#{Kr0N(kWW,[v?0PwW W?0. U('(?0۴|VG 5JX'neQ0G-V'z;n?nYj?0Y;މPjBؔ̕L rbyU ??/cxTuYm݆){?0 6!oF)^FYvSD0cܺ&.O (uTkYgj#2vaM4Do3h'aK&T2X))f4V{#7>V,]%@0lg"Ly;P:G|!:Fr)4\i?nw[ᱍ??(6!Ŀ&-_KHS}^HUkXΐStWEẂ3@HkEBcrxhx7Sn~ڻ#ݦ#)J;HK'狦f8?0c@]#P޼?0,QUO8Wݪ~~hG|ܻP1mmZsp\>NRZ}ޯf]~:0\)/rn\4^K3@=NRvrn"9~ L@ڳda,zN u6?rQӮ bd#y9iñZcAtMg/nܶrՕ W^.lEKDU!/Bw|r$h rÔ" :e X=p{$F}4p+-a).븾&&FsNm`'U1.˦AMbӀ#ۭh+nOgA@,ML5oAl1l9Џv*lқܐ]xN*,{v[Fy*2FvW''DEon7ڛ4 {|w78˃';`˕??rX\\?rњ2)( 6upMQLr"`6 Y7?0v$ A@??-ƥK)|+F:plIH|dt`$,ѝ_2u[+`N_/i7Ma:0s[톧ҧƜH64l: DsC 8s?08IsIQ5=ɗA ^^K%=x@_ C%yu|nIL-^8QE_DWP}kW* o7 vK"R[t rrZ,qeH6O%`)㜞s'?r$dD$H3"Do,þvZԣ!?0c'NW{o6_VH0L]s"NH4t=A{"??@Xl$RrGȐ8ɍV1Xウр?0&gbe-3 G :ޣtYW̬}gj?n##??$VWij}\=(+PZi#]SˠF@~XsWw"@RiOY~0 =Fٽs9T'R'kܚ$??ȃo؜???0Otlw'ם8,'s޳A#`:܏ynސB -#Ʒ1?r??^VSc4g5,Oly<(d="MTMÈ~v8a@҄ ]vfΚO=y":??6F9mȋ"(?n:VXRyj(r:?n^T#ўBPVf_ߠxٶ3e`Fx[\ahj}jx% a!b0A?nݗ% }>>4{C^˾\*!R,jTH-ܪPxăױk~d|x67Q ?0[6(UM/~gC&;:qUa?rܚ[8ksX.7UeM*n(=mb0xP c@0أIMmhW29)c8NϺ@Sg^Wȧp!1I!?rQ Z"p޽"2?n!H,2`f?0H'ؚ$T~=.j)Z:5҄r^챈 m$v}I[@Ye3/7ݐF*)b 3wn "8,s`.3&s%ã496U밮SL] M݌̥(op4=(9)$aNL4 hs d=W0p"?0!x8ȧgoHy3lĢm$E" _7K!8v={*f3 fȉ?? yS3"La+Z>nad,~U<eA`8JL')t@KXOڟc W/A ?0k"dv1dPH~7A9FQ)p)n??hA13?nGIZXe+TKlTg:a3?rJ(xh5凷L8InE' D.󥾯 t-a_$m[|?n\ܬޟ?0|*7gf??^@Zrhmѵv-d0?nőG_(j9Cy$1qXEĜ³`O.%φ꓀Ϻ',{R~}u3b??Hҙg)UdT߿y{e_;]R?n2Pb?nb`jN5t[FqrNDXXgq"{ph"ΑHvs6~FXw2Zgp7ղ!f->iKy,pyCAv?0F^=6&~$`>!۷8&ʫfEYyՁ??[_OŚY2'iRf:lGm~!3ړC27WoKfv Zy|y̑td6??9NqDV'[!ϡ=dlܞ֚jJBW ?0&(2 Tq(Sq^z*m:su+3uQlryI(~3ZCV+j[t??Ĉ,< XB.V;kkdqp`\erP_ 36(Pb?01C$vY#6ېP ]MJ^T{r7F듦7,䤱zA!d\Pt ֕[Fk̗y DW˞j?rU<;%Zl!>[ѲO|RP CC-?n|8˕2АOhDёԘ-;:-]eRuT2y,$nVd{HRXۺi8G(vcOsnUQjr*Pĥ/x؝ nTuj"dCsI2 -qp5a'l&?rA (1~ w:BorP&7?0ZU6{;Q?0,:?rnE%^Kf"ѬЧN3VG??BRG:;i"]Zc<2@g*:f~eVTf2>w\. \nrOP 1SDrmpcC^Z8|s)y??ׁ!y}?0U'?04=@'??0ߗAc]PWGxNʆAY+Ri경ÞDHKрc9Z9;g 4WZ^b,z:vNb)M`8a0?0Xa> A뎝 P`k[șqm)SV4Yd[8kzw򘄠M[_??Hr|g/ p=5Pp}6ZtHEfMݷ]#E^̄?r@[/A/p}SDUjp( ѢgpZ78D[ѣAU%1T altJ;TI-n+ Q"eR]-|ZXbv"p%b΋Ԋ'q_Ƨ?r?nQs[@1C*,M&9?riot5|G79?r}:t%N5_!VĤz\TӚ^7_w}ijF~!2b^&DĴ4qC 8P9ϤRJ&Lm Ѐ"jL"w4'saH)ɠQ?rK,ƣ,*C!E'I{UHQ n EN|-k[ J$isє$>רFz4x[ʈKAkgwG'??a?rocbN~S>gV#{r(?0-yֽ_ 7“o^V"C[A0r=5>nAUi]W-a끽Xwべ2FH)>hf*bܮ.ctƠUuV 2LT*|/EQwPWZ3dųQ=Z^#5 4YwSud#8*WغBo~Mӱh~v~et0^SPHZmaUV7S6H&>6LvK0hDk,MȊvY?nv:FRN]* ?0\x*eVoxcv`:6t|TksUhz<}bvl 'S *bJ*4Ҋ3q${lqp?06 ݺdq|+96%pED/.E^EtxAh)J?0gOl FI1'Mr^; B%Nu4O.wU?r8NͶ!O(؀1V*_X*x[В.B>Xm?rH,*D 8j'TUH1_Gdw2{VF+Ί(,ۧ ??R+ɩ` ?n.nk?0 +JZ{F,BO7]Hi6]g`eZ%f.;͟JA.W0o|Ĝ"iZ!'vv~S5* Ţ{YAPf?r0_W ]݂x????,LLv~e???nQ#*64??c^'!`Qk?r rT@qlXЖqjt;gaD_"F#[+k2??Qʴ6^A̜>;iQXRĜ$lY[a嶂UDz>t3BISdDmG'JJWauRW=2]P\Ak!YtDQ hn&n\?rҌLN1 txV,YC뻱*x-_[X^F75Lr)ſRr&Wu8m)L$8)QN*͋8pGÊ,5pV؃Ub` 3B@.;^?? u%81f\]!BUdz9[Y:?nl3/7#HqoKWt2f+plUrSWR҈EDx9dGS^ˬ\SQ1)FP҇#xط:HuPeo=+IB R*t2<.8w $2$hT]ä,ڵQCit vCZBy>O%~0zVΤAfWm *Y)8# ç6|,%.€Ad?n^=1תhJˣӕx ?nx'jXA#,"zbNnK~5mxnA `Jwo*Dg|bJhT!4MniRcuuʆ(/e}v ̽qG̗fG̭2*e!&[tr;d%9q$oMm{(EO{J+, Ԋ2Ed=Vq}#Yq"[ΜJLT\J?n8Z/nx?0bw 3-Xc뼄ھ??ړ1K3wR9Sl5 -"UD׽mg5uhĵfwp$شUt{x)xm/к#,۪5]'NRwph]{ V8!\5zYJ@!:j`SG}u;y|v]"j~(.gh+`A-%{&`NgZBL݊˾ƀ6o,).XW3\-Yʦ",^]S+,@j e u?r*-KSİ 8P(-0\8E={@hc]]u;T& ~S*n|B6&`0@͔i9Mf_wEx`7I\T >|ج6!jR߃1Factd%T_h:l$btP"֑*ݮ,*kTys3(J22mF-FjX;y] ^V/nw7=Z|v&1yRC_fNJ勓7Z %hSX8 &Ofv\SetlNY??LH;AS[bL?n@n<I?n}fOupG-~4cVtFx$ XnQꑾR)tҟqlѠ>Z{4Le@}S+kQ,Z[WȤ}8H[sݷ4?nN)绥%M@fbӄi'c?n)pjІ -#0g YeiW.'#@CSyIS-mn,##A|mK \Ѽti{уІKMBih>[e'@>biU*G !&KKOg>/?nI3?0[J*SRrZRi[ ESs x?r$e1TmC!!QqO^|MȳN?r7ܥ$=.Oiuj.M9^3G Vk'|AkRU?nfLũ֍8 H*Sd<4[WSR4fZ$a#KLM?0Wa6'#BYr{=?n;xIfb$FRa%OFP??}s5Z\㓸uZl]y]z;x[J^h/ b~uGsrZ#w'׮xrdQ|D?rSO"%peq%gLv&ng9n(jydA5yg&ŗ7Aqlj{M̵k8#e HO\ǣ/ &S-I[D?0Tw 2GK\]səGDŽOdG[?nKKz#E¿C}F71[7L+ڼjn??K C~;m'C5`?nimSYu4yνf=W&~4_U@S@k$NlҦ|kR&c3ׁvƺ\HuֲyaTG6[ ??!JXp.GN3JJN?r?0g-jo$Qصu2(̩bT4tws~+nvIEi#d1rx1ze!Vi^%`O|0v8|؍VʆU u1kyȪY1(]=??Lޕ1iPQ%{'xL6DlP kd\nMLcn}|6GWb8}/haW8-)"X@WBFSeDu}-Tu.CSy-gT`=d?n&Ep^pY[l@ ^>c\5fVdO0-?0+j8`/FQo?rD1;xR=X!aI-%,0'؋y{Zz6,ͺ аFȋgN[c$ښ?0[aj6}G(@=%`C0F\BsU?0*I*]Tv=맷;wXGXm[.BeR0(j$FTg?n])r :֕/7;?04!̪U<^pGA% uj{H9$\;>i*Ѳk[wPb$&x,ֈ :mBy\zuJTQqJ^0w x ]mY< v\YGvz]YÆPwSFdY`rp5w[-?rvA!r\iC'U??4{&XEaMx}*mm?nJ6~ܻ^NaWuywYDɛqG_@ Pd 9'SAtwĠR-_ ބ-.R^FF[?nڂFK7y2^[GrSE+ >dȞxxu{lxUˏ]:W1<2`\z5j>?0f&N{2}Ւ$!/u0TY ??M!B"];0$$e%YT\13G誄#/w}H٦wM!l]-;dD*oMʐ3_\4tslk"}oaaZۍ_ly6*Hw{w.qi~jNb]BQi L~͡07V!4E=', mnQ6g\Iu73I{?04 8QpO?0dZ0^|Mb'17I=ّߟֵۼ|i 8ӌumT&?0UL!Tu7*0ϒݓ9#cj1 h4$hwzz Z8cBDB"0ȉqR??#B,µSq??ks4UcI[(?rhh$}<]cB??*!\Ll?r{@ےvCѾa5 1b]N{ETܝq. v&Il3*Nc8T`\cr?r_crknjۄd-Qp̍5aAOd??Pl]L$u_ʗhuן::N^;Ï44$볏[>P4 F/1; Ե $7aPnBHҀ'02]GvmnG5BjꔀݕS~I.>k#9\#*sK?n+ 6<Pf}aE?0cf7 Ċm*:n08Dzt^+??^rz$lߜ-K{+ -#Jg t~ۢ1.6t2:\q-`P_{8ZrԀӕB&ždYRі_j|6 1w{J.bEgҎpJC>yJwAP !+o8(hvq=QScM?rcc|A=+B]RaáZMa(ܖʠt-40tZi}&HLM#L?r&sՈIG!O}òw kڮ]~?nNjf:=1밑qB& U Dfb1؏Ã] pu_h)IQxeKr3Ru%&!;XCTr9x^yLẰ7,ȡ%57zFN3涏sP*q I9o5v4 ?nҠaڢϩ6CH"Tv1??(C,f4dUT vs\M)~SRӁ4#Ĉ%.7JzD=0b)L%gG*$lm!BP5M=e𷜾ߖY 7X0a$FN=͈d\Lo*!g;؁Fj:œQ~Dpi.>䃫V]鐢nvh vP^`J5cU>2}hּ{PE:'apRژ-TJ,0Y??*hQrKÏHR`mǗF+\"bZp$:?r=Jg] :K1)6^ ,][t2TfuIאfB58D +ztl{ ?05F)aa*vylP!4~AhY0Cl4&y!I+'f??-aǷ/TI a-K?0vSc"9uWeU^D)?nev5-]K_YhWSTB3OxJg8EM"R"\";D3JV8?rJr+CJtH/ROQ~ΙM?n8JԢ̪Pȳzf\??b[JJFRꪂ?r=ңT roW%U#: G_ܸ`afLfu Qɠ4h?0 kϬb??&Hhm@,{9M:Ӑ?0W@qkrBxjp, G(Ɓ9UAY56iNܱ ???0%Հ1^Iyߓ^0ɝe5t8lDb"QC?njmERj|.qry=}DcЯ׵^7Y#!R+uBm5#qEJip`wRd0'A&Zw.NÂUYF찥$ϙ/B}C lŬv +"ĥVуopJ4./zĦ[fpߠBg?raa' ["Y~/UmݡcHyMYccN|??Q-pzP'fBws0@;yY ht*}Cot<fؼpdu?rKߧ3ee=3Bnb2!VX/ǡEPQuAŮޟMp {!|%"G{Lf Opw5Y|?0k ؄!yOzIaս2coT.-cmj9+AU{Fn+bzM\n,= x`^7ӔǞ}ux$ -#i`pov,Y URP(<m}Ҕ u 5dX\dgN4{9ߨ6U'%|Sg??א>DKPʁLA>o?0}/<YBZh'K"3w2$ZZB \+k^VO@`Aq-/ ,yFߥy{??K?r뢃: NP'wV'V/AՋ],R~(%7 ɂF2|]s{ keㅂ?nQI??׋ 4LΊ%@2,r^k9U7,&G2?n)i (OӚݒ-ZܳShL}NN0??px7M{̨g/6Xj6]?n%MCc=oDPX{u~%&MS!;pRC?0m,;!{EKb/ p,]oyx;??r^Ba7tl=G:zPS):~d(M ٷx/n`ǩArVU[f/E&8!l٘1RLK,Mhn}m*u ;iT dbdErV\ʔcC[m8ž6փSnedh~$?nxev?npt3KչtL4T 0"\ k (B\(\ԔΟ$.'~4A-Կ;lG?nKF~C}DD*X3 !cHcvXi2k'"!?r::w(lO'|!>HoFqfEh`46%&179-5%=[fItEi%at1fjZSZғ%Xdh~lѭNuX?0Iդ(Xeo US./V_X1< ݶ?n}[l6HV/V*NIo_AxK'l_T\9׷/ZulDGOYkZFqE)VLLksT&&rPO@䱡߃se DRB>h|GWx5pgU="=+G^o}JŒkP ub5w?0D- #ZCvtwKbn|~o 55d^6ЉTtT>1P|,';fj&TSy߰['Mu\q< H4HUJDpM*ae#Q{_kRTKXoIF#YKB4n6װŨԄ,YrZel$2p J?nMI4E6NT?rM傆+'Z_[>fZX ǩ-!E9pK'D{K !z #XxHNx4SLQĬ0z%^WWw3:0 ~U3VT79A9VB-8_(9w3}ζ٩h_X[Rk4+B#&g Ӧ-AnRkk29%?nd#OvoQXnvr!L>}^q`8mB!b!c]57gb97ϼ9aY0$Ŋ>^2[JR|˻TQ0?n(gL7KQOD"UM>͇3F9T7%N[529lC.UOBܙ`Y|2Y]q(B,OG*2??x- Ï]+??ɰ=#/S׵[?0JaE0ړNlWƙ,4rSNpZ %]B?0ctH&F7?rDQv;<V_!%\&ٓF[M_J뺋kEl+D"ʼ 覂.'d߷BT}\??FHAf߁;'[+h6l]l84e+M??RySTA ҋ ?r`9+yqϔlRGih>T ?rMl p&UigSVvm'X܆ˍjцS5?0~toHOgQhʌP??|H1?reF>sOd\Y&:h*MalI^jgE}<#٥({U^M{ Cvh3>?n??ܦo*kYu ESœO=H{??RǦ;XcĝE@\.&Z8ݵE*?0vz?nq ,`MqͳrIXi qt¢;qR+I1ɸFζL||$*؍{n̤3 I9 ]?ru䤋~$cEᎲG^Io|џCڳZX>esޞ/U?0c]kGjd -=4"U2DõKZ71##u^)L?r^zgFgf??c,&ʅ@yw><;H3|Y(0s)5%t'Z)IWZs ??=6]81 *D$B?nbgw]+rs#TG"/s?nr(*|5|?nZ%Gv?r%"L(lle m`.Mpa8m:?0-MS (P{$7Ŏʪ>wbfVt)Ih9`??K-eŚ?rbgr&(Wex6c}#BHϡs%֙jc鐏̒*\Is}NUMCTxȅ؎v=58,azw&I(f2hm*&rKSb$#L+t,59'tM.QrBs]T=ł\Su7f"lF+#s??0Gg{F_q#_?n]IuO3%4߇*d}^Vu+6G VW$ lgy\ʨ7&0Yҗ+ b*DgƼnht2d#{"ؔ>+5k-P6,_8B?rV"c}Mcg&U\ߜv7xڸԵNܢ'@-8lޣѽSz 3:%4h5NI"<6I8Sѡf3`.ݳy{:c$qFx%mVzQ%q/]?nԶ#W*<>BNКflOmn;n.Z[qs_C;7ʆB@-75ow2MCL_ݽk?n(MY„;d[tΙS~!D0w>~>rxIuu|Xo P"ެƒCzho{ҏwFPy¼p-l.7i87t^(7rr۷X"J {b@ «5XQ6<+ض+l5f5<8JNi.M?rVZ]V4̐5"ҠPZ .5$Kn ԟ.2υ_9`&H>ܨT=w*e?0psĚxo0*dV{γ??_J^,W(j{Kȫ@?0wkȢd3"}+Xl`g98MZtb=~J&T3{;5aVpDkLg m͹0Xm.?n8 x_2\Iyi74,]..:O#`hɀc??H[O`ْ]}45#;Bgl%pvUeb!S}?nR^aʙOH]*,kP˹4vvN@[}MU"2QcQml[ c<-;zj#~*?nD͍*IcrH/U;!KJ}cs/C^iPvT.8?rU[/c[c7mX/)K5;﷡dTIRx57A7T>??1+ՠUۿռiVMsV*uƗe4GS6?0"M+){kh"2[H?0D9uJ7_\j20{"LND\89Sʧd:z26/]Ĵ lA!~ PrW<>ϣإS0jsN| X6=Ф4:xSZbOǼc:q>G-[Ư Kw,)^8Iʕ˱:\?rA{vW%]=;7$(Js.~Fg;TO~5۽ڪHѬd?n5λZ{7% *]~n쌴ب&MHv@1??}?n8ণ^)Jg~Ч$QvdpC9lo仠2)$EĨԫ M^䶟 u3Ѭaiirddz:dA5&67ѪV]} HSEwbXwio{O*?0;S+J3Cei???n&v@1B!YQ']V+ e|]0Y7R?r:A?n'ÂN_{&Hĕr??OU?0 ?nҠ\?rхcht=Js-Hҹ͆^ tJ'sиwwo*qpQU=04#')U#kGN̮/1c/>7VRbfrB Z[ZU*^c#XU~87k8]Dm\?n%}% :ݮs]ϬEizɭÞydƞ8[Rn?n|p?n+6uu^s,*uS7g'AX!6s%F D+e +;G@+mg-ѹt|cmp<7TeكO&:/fc??W?0QRgR}t瑅T28-/#rgwztpA^?r튤Sj|2ɋI]mNפsF8ħB{%|ŞRSኁLXbVr3nɸ+@_p!-њK?rҙXiFw?ny&׵??9)~䦋[)r!V|f?r9-_ƹ-%k}/Ԩ\&LJ߀z@55rU ө.?0'_kX۳ mH+0P˸N~EpJMO-ULy=5TJmĘ5J ǷG(?0dsQ9?nAO`60x(˔NDSZ='BKI+A&K@ 0'}L 5/VFheUΥb##¾عSW05iɴr??IL )?0rJ/Rii~%ezoR{9gfƩ|KN]#)DKs┬4-{Լ.z=%ʦҐhG2*RxS/Z?0Bq9,a ]39Sk]oFL1dm42cƪ> -#/v3ܙ|zR#*2I2{d‘G3N]Zgġ15ۢ~0^?n7j'Ju=< :h|D(/#4SuY/9I9xi9FI9gŏmd2I?r̶IQ\jڱTXr%O{uq6SE#SA7r@Rd;ғ骲6tR F̎9Lpct~e"$a+봠`yjNS8l[&xtW$ tz@nILo3im:tLF 7c彣x$W!Roׁ quϐ% +dZ|>WM?0 (NyOuȧv)K6_C򠟜_kJ+J'gZ?rBv!-jUî5䩐w17zMT:)?rB B+ Y0y.lVaR^:[bPamc 5Ntc'~IxPomYK4b6vAOj l췇tdebL yoh@:&0_@:B/zM):O:c՟Y=(g0??V%د{(I4eRZZ1(غS~(dUo`Z"\?ry1m\2|Վ:7ZZ{ paq#pF|}-?nbAl}n]B0dBAK?r]Mj8jǧ}X%?nc;r{óڢ??;<\#8s9^-ei99*MB^gv,ӥC]88{r嫝P3O`Ay*[sb d0g~uWKl낉.4Xy*| [v[fY?0刻CdQ?0Ot#pߩor6Hj_huWx@|.]1͊E?0&Yp\IJ2G>boxWҹv,5v,#pRȊvq]'g+dȲ '=o/, ANqvET8sƻvҳ|Enf ;d@mD$0xtOePGBq ynaw=`bD>Y??x+&a\_C&߃ٯ ^չ7Xv:I-4D*Y[^G9(׬}oܶ³EZ`[Ofܝϫ??(~@'KBǑSh(4:q5 d);햂?ne"61_X|4Y*ōH1Cy5+ɼ DCkXPx]ٔY=M uàE6$]]]6­+ɩڍA c=JB#8QΨ%Kay[??C rg-@%׋<?0|hiM\fSSP1aqkCh!)!ׄ?rfSF"y!7 ˲eh n&MoΡ/Đ0ex*=aK b3.i߫b*.qdL1(cj;Ŕ$眅EeafUlжMO؀R`fI(lbS`-hh??Aq\?rT 2.{5i$)P/LąhP+M ZJB,x j[Kl8k=ӁF;?r@?np L(**%{PPh݃#լʡ-~TLj$?n hl&n%ɇZ}ȁu5蜶OPw|Z|iUj9HE+ׁBL4_Nie7p?n%hxM00/SotjeHH*yJ?rivqՖ-wm:mƽ&iB~.pЮ4HQfEI UaR2r%0"3ZbA`?0_:Co?rfVz WuЁn+8<٭۝ް؈/ U>??TnJl|#j ASiva2>Fu)3*%/nttsɪ$G?r^9R;Nc̊M b2:=5e*̃,8Qfy2|ˆ@]?n8\˜s2,L?nT?? 玡W~?rjv>(ޏ7{$PC.*R^.VHK`m5.O6v* YєiJpo'M\??Z\̼A~.|%Da[9o$„FLJ{"L2) 2 A?0wGZo^hop-\Ϡ6XUNc߭$&>.ye2GJv`q@'B?nu svҏ{QkwfvqHծ/c;^!#Qu?0ώ\2h>z$v _gVB޷\Sj/Q<>tVQ??᠆?? XPUqigvC*m:f/ǯJϊRmcek?0v5*nl<lmRtf cpo$Y@a/K)v?0kexTY9|5H{KL_ wћsJp܊vyŘĐSWBΛm3%9x$6ųzEE,S2k-Z+CaL+, ܘ?0F6?r"N*3*3~?0Dپ(OV{ks;`%v`&K4^'fM]ϑQTM_3o,o>-WЃ?0ag.\\a^ @jQΫ_:p `-,|o/_).{9=t'mTu˓DQL0kxE3vI':x5ĭiD\~X ???0Et濑طV-pey%?0er9?r,,h/u??XFa;hf^Oj8DkVr&msEx\g6+Be5UJbйq?0<1?n2pS3 D).B8@$<(p%^ο'b)Ϭ5uMSLж/_7nQl'ZAK'C5ᖽǴeo.~'?rS乖l-):$OJJ!rvÿM_1e ie;`RW 05T289 Ƌ$=@-YR\8?r/{]&y;??z-eRv.!&??pt/mIxzSw$壆jTC!)O{bDϪap?nlیJ1ķ_禍[p:oLSN fGK?nGưo$m$Pa.,ƈV9d[|ɜ\>a Қۙ+R6zQ]]bg9I}+teGS4oV̰m,-ĉ@N@F 6f6˓9t-ENJBnl/N0Mon&؂)vvD;=Kɰ]ڧˀdv?rK3W # SHbitBe*ktCjl펨Q̟D?n N}$̈??X~XF'XGF`?0uDZ7&`R(V??{42^y"OPEQL'0??sګZD /x?0! o|'Ő̙,k}#w~;|Da \D."!FbxXx]% D[09I+eRZ??s6aI#+}J=Ϧ4X@}K NoUszC^-??G?0ݟdt[@G15Myhhektq ß%g0.Qv|#_jВ ٕ6<>BSHEmosrD/c`qF?0wa{'om,6L(`tZLV`$d*Qct=5G ߚN678+-ߦ1n8?r1_20O:QW'UQ|eC֕0KA?rQ*?0dX&U>U6G>;!郛FGiYp!jrsR!QBp09ZY=|r{?r"*XXhoeYHFWгpbPlk!mO1LIZՒQw1kͩa=&jkbѺT8 >W3 _TDk?0yb@[co dv$Ʌb&?n=R2GPA~+gZߘ -#YvZ`p9!ROx4/禑*vE1c64&_xxiWij|),.>~:?nr %S ˔6ohX)?rAa](SZ '+&~O鶨,ld%&mI#)QBO ?r.o!??ni-Y!Z wז3t.CѠ^IeOo&5x8^Kx⧷I)L5^r-J=+;),=5٬tvFif^YNs0++b/m?0FiOKl6 p?r\áD)N9-|wc???n(ngBL?nRfK}h`?r7Q!!8ҞaPTFk3etqj"]KMw#6yV=]ʹω1WbLyIxQrhnj52HS0A_~̦C^%*NB>TT*>uoWvKTk{ L鞹s+"{gq8~X6X܉bO~F'0Ey1e^~L~"p?0BV.dll@N0pH10o"噶%UL"Z?rPf/X3r3{@;Nn1d w]EIqZ2=jP2M/?n?r"^5.?r--Pc0Tf='\$ȿ-OLuQK3:<:?n eWP3?n-#l.D` ]_̈́H&V4+'T|ŕVOaRM7}6_qbi~ddZ8]Iτ ?rdMMKD"V p*ߴ$.zvTP"}ѝyPeVpzWS#K?nj(B)q g5(ZZvz&YMƢ¥gb{hk%bQNpyTr22Jcgmԩ0N.]r?nIJˁ㳗*K?0PBqzc{oD>⊯QFc5{_mo h?06~P$(vOx?re??̐p~Etx}aPZJ(SN1:T`{n$0{ήt9G~.??Ĭ׮}V`3ep36&#g%|@&!ܓپL3|_=`ڻ jUiQcƴ#2J2Snxy|vGO?r?nЙ*HXvJ!8W3-eyh 2‚*r??(HL!EX_n18U$klPt@3?rB-`mFJ((Cob]) Ĥ#̈́ƺmqNZفK|0?nDޓ9w}vgSo=Hq4NS,[ 9B8q%ϡT3WO6 /0_^unuIj>v{xTpݟە|CY2/r>(6v녫ݭE| ȍ2ۤJ??ALdd(mp)ES9(t{#q|7X}'Q[H: ><@;Mo?rC$;IYoAlPđer]\;b\FT̛zW^(he+%5vؒF??aTn(V=0sCq?0{=LU!z*3K*Ajy^ʐYSS%aKR vI&DI[JHn1Hu3pZ ‡WfaK!f9"${R kr#'(/j;w G&1DwjҞ?r"Ayd2>ij}m6# aE[FB24Ęi79p?0Щ#7 :FdS#l 4 >(HfcȭR1";@2ٚF3?rfg9.>@7>oWQQ&uE~Tg%T'.;EWzq?r'%8`"! X?n6ʎ}s{`=Ʌ*U +@HI`݄MTiֺܹuOr ]9 wY=ہmDCuau†Qa!c~Yµ^Ct6OpQG2 nPF7#G.VC/Lv?n9b`;<]:3jdO{XWkddf??oGqkL??Q|RYj$j4?0/ aHW}~`6 t$=m(䦬vi%4ZmiūqZ<}-o ZrLi$_AoPmm=z]sr8v!N||X\'}[Wo .S|؂nc']_\Nga7pNc].]C6S 4Y٤Z^?ncJVOgR_)[5dl&9K:QϑlT<'K8vI@\/:Lh>02LHGQnZ~;2uVGiv6qpjB(?n]pj_2 y?nl<'տ~~vO^sy{Bع;T<uoEoig>[|BgIx~P"K0Gg'M^&aޝ&BL%YuIX7'H3gzJ=zS?nb|]S??[ղ߉ed_1z]DdHV$@_&-w.DajBm76z}:0p)s|UʫT2Le|@I1TDW*\W\ߣRJvƣ?r+iKVw5{MPyX{AV߹Ew]Kgnk#\2Qh5Y̑hc1B}դ 1ۯ!z-y+rҮ՜+-p"Kz?ruH`A41] *+OeͥB>=I98}pAsut!Pge bl_4t1~?0ё!#b;op?0]{$1;&=C|O0l~#e/٨գ23"%Yؓ2ZX,gg4VMЗ,؂oBR44TYPe 9=DX6$?0I "uSa+fTf2s*v4vI(HG!O" 4G!?n7K|?0XGp8RsZ^刺0dۮۜx_ J-^*bfzzx5?0ɬ("xG%Gf͗Jdb?nZr%.x-J=mH̾.±@RLcML$R~2Gŧ:v́2˝ i}mQ 9bdXڻ^m$O~@}W`\ gjBƃӁXE%+:NJ2Kns%]|l.Οai,Х:th^ %D]<+gsdؖV Ճ ts(p/ځ wIF)Nh=kOnH3q"n_1bDWET5]3b!QbGh2.0FݵI.Ά[C!3gr0'XK(+f@d'3r160+=3|/;??T51BGw#FE?nh5Q73223222,B.(uTڳJE), w`b(8kԃ&<hJ03z0Kpb)ki[yz9 ,ltE.̓M[6myI򫗁q:|9ZՅaфqbm$MDֳR}Y o(2:y}؇wiBMlAq'c,81}WhZī=~kv'>O@@ @f [| +F)Nw{~sa:۫{.$qn?04zyTv#V`WXfSڤZDtY?0oe Z~c6& `p?0D1s??#j.ѬV8Lx1x)gR)b锘}N3좠),)Qh&aDqX71*u>L' ) Q`o?nQJDk?0jNBbL(_,$ umȍCX Oߑ(?r| >w=L7Y:wL&&M*^h  d0-t?r-ʨX#7r 0 @Ư0`UW1 ljsnPgx^[H[J??9oy4OI {:݂!04jBU -#Qkb?r2C?rt1[P(E?0r1*lc06hЅR7=T2tn(x?r M_Sۉi'K!ntKCpZPMIYxqpȢ!!sǼCfE&<$Ӧ뀾=FʡI+~Q g܄j[hw[π,ǜu:iC+QzU1!'MJͲf s"1x:4(sBvL,Ԝ,NZJqŽ?n޻Ž75')EDɁROq"oG,Uj E^sc<G1Xr]GRJl@|>At=lK??;y8kifqPthM0"TzGEi9.6PBZ١߅+z??GCpKp?0'|ۂ??u&=zsZg۲`Akb] U8jkdBB#k??"X ~CYhe랥c~[]f$j%7C[ZmUNvРD4Dq^t.54er8(wu-(4pr)n=uGkx,Dd+*t?0H;,-yxd޶jR[joLi:`^Bl n?0I|Ÿ8&۽??6;dR}8B,??ɤ.9Un>&-$fR3􎯧AVZ)Ƿ7dNVM?0Jz|(}B\~ol3iQ0RÐlI&f?ndc gVe-6-m*;Z7@c.o(%`/r8Ux,;UA$=/(9D d鼄 :}s ~P 7xX^ɳiYkwh_O8n&O̍ fLluPPO?nLz}4kB,(R* G[?n`ogW@LB)qLy@?0ʳ_Gpn\.p;\yW??O|&'hۃg*Mwvha&qJ2sOML5O<'-F~aXi0G$zdJo4f82W>}{-?n|.p>~ 3БvNn1)q9_Tz3ŀ.?rلj $!1dgp^Po%݋ [%(gU4U9E?rbQߧ??kG SU%AQ&L~UfFMK%~ t15ieJj{|{WzϿn@U3'5ДpΓ?01!K4 Zl渼hW?0&ev#< GS~L:@ϙq?r!$ۮ!|6?ndΥqZ-F4 A9yu&]ˢB7gZҕP\0DxCp*ӦS_sjT,pX9u&In<4̹4nWM],,a5'k2ϖIo1q5\ 3XKAzkR3r;Y!?0M??FTw?n;KLFV'Ek l: xWt՞SPKbԵ259LQedDƈ6j3j7ZgN1dWd*A˟Sy)nk*~Vym2УT7É"סx"+zA=hK@e󠷾f mf$}RB2q?r;\Ŏ` #SҏdT 'aJ?n?r,s=x ??pf8?0&縂a[yl??j\װ$-,]ůNKKx$Pd?0d-?0Uo.}?nEͶIC?n~P/䃤?r!sWLL{2 :9>^la}Pۘ.)p p;"QS .= spw*삱"<غ?n5B'Jt߽aM|xQRrۼ!f{&{^?rѻo73\cMskzq` C)ϮbwB|d\pgI\i f, o'7%뢛3!NԳKwN,0_:[P@@ofYW]yD]`??]Z3pPe^bF?njMqw4<"]KׅJ?r[T__ӫ qF5gDI|ly,(08/u9Š9K|םWѦo .V?0tǀ.XzIeMBVg<YeT}cHiwBŖ>fb݌ dH!,DŽ)@ctz ""O-?0%; QD^X7<8cv$ ,`;ҫmƸ\{b?0EmPLK3̶5HjVp3PW덓=67֖: -ԨHx{V~F¬ڧM?rnU65f.mԳ]ӏa>= JAùS;h_#h7_/.2(vled`VPɛKcHS+q~.X*퉿I<66!ثy&L>,0܁I5o,[˩R.*ԤoXz8t!ڧτfR/%?rTMŋ5n %pі#p9xY1 ["\09!4F@Qf}Uڿ??1S;R[X2o\g}"3 >Q/KR%?rf1Au#! .T$`Hir6wʎPc5˒@ozP;Sl5?rp)ͨ1mC̘%e;`h?0}??$FCI`TV;;[^7^??2pkB%6)<Đ1)U U-%P-m.3f H̋)].?0ʻ,&ӃH0hg5lt??2 XGo?nSLҸh&k NW޲Tj+tmK??(>og;5i t!8nnOC?0ߥ}_6CR |"ghv6PXr!cJ*5e.MB8?r١3S”htX"lۦX9~*&5S9Xgx??>CCIݚ)(@ҎH IqkTdw|bCX[&)sG??Z"CRn(x,)Ð?rǠX.G>,%=I8 uT[jzzkl4wTX3?n )DmOFC+O`?r9L@;~{Ӛ?0#4ՠ UpY F1Qxpم(muj&HZ_XX/ꉁF[qG22,zAҕfcQHǖFwio[;-=&=z"S7q馲Ua9L+ywZ2Ґ=!0SXEWNv(WGj'!HY++;ӓqHWhZD"??4Yuw\!P6RSs\-Dq?0㥶kc8U!!Fu)'θJb~uimuuᴲ']ܸ[-iԭ"tn{V›M=R0>J5Ы1O1;N w8xk; 2j-r4wr`VGܽWUb$tWgV+;4H%\Z]~?0\-$8nӠE*!*2`,GT5&oV|wS'I0|VzX[0ըFmGt9!bAQa@8P<4YG~M%ߩe/Vˑ*Ip&Wh??mГ$l3H>0YH.C!:<@՗~CjI:?rm0#"h\x|Ą8,??"rA|dt"tF8f?nC <,AR;V gǧ"ې#)r7/ Te|(\;r'`YRo&Ż4#5 ?nqt7(y<%8$0ݭ\U4qd@} ;I>ZxG&Kfd26q?r#MZ sQC FW|Uѭat7vgtJ=|L.罛zfsU/>g;Y?rs؍,-T<)ZyMڼQ[mtC5/zA"DZ3ݧ19--;@/vTs/ʢ3fJL<t=A Pez)kOv nF}hvwLc2Ӑ$|ݺ?r!O G&S;ɑkKRwA74q8}q1-6BͯIUT#MJ?rZe_9.2ipxse1d)u`Ujh\ncv19{RSMTX$"0z?rEAxv Y\:֥~I?nO:d,LCe5-W]_;b2x!PZ *f:"oG]\)\Tt:,8 FT,i.fYTU-hX;1?0+y\zPi;>YS 0+%tp3xl"# m .aznP|d6~ hN\f(17 '`)_{o+ԣ"QR>}ßH0uzvҠ^_(Q#᝺;͹Ȑa[d^&K;&架a ʤBNW+XCqY9v.39Ν;nJ2տe$$I7j$M+ nįZVXӎ7߲ߛ9)Gpt@N3f G?n6t>;Hq@sQmb>=Ne'@~4t^D*kS߆%nTb,1;O,3`ȪIC1m'X.1GxXZ:(9!mhpơлҲ\}*r@q9>t/JڧVفV">/?nF P]vgFm51PX!h*2,c9ryer Eu9"M%xd?r3-3@OJ_'8alcqds(HSe!y^Rc1bfM{WKX^,Z 8rK=<^]6Iޑ-;a<ƙsgSzV?n7-C7G¯ OӋY6Xy(]L\&#Z"?rl -*h 4:k1Ңn_Ŗ-^ v{ Z"&V\!?r`ՠC4?0LK?030$j|ČkW حriOAfEG6Ht "Y7uq<`PV??@gU݃E'*:|c -'xAyqq-|_wH[Fwe=b|6Sw)8$3"vm??P>+T|F\K=CW0.{!UZ˔!K^|}&}Iw"nњvW=N/W;n2pS)HcvJx\S)ӷ ez))q(=^HlNj4;PP>M?0M[pj_wHǖn~4.X c䃥4wD?rFoJ8pT=I-?rTX']TM9]S[Dj$'jN@}lbŘNV,8EE%Qx6$Xe!ROQEqbyokX/Gck*ma[6f@ ꞙ=m:2Z8 *^1蓞Lސ_e!gQ sLNᕓ{ЈHZӂprs&#}aY݃ҷw\hkip?rxL*(6bb+BJ6o"?0}-YhF@[g?n$a뭩jyK5JM5|b1 KZԝ z2Y?rqCة)O/T]#!t.҄@1J喐)2:`":9Ky0Q<¼N&5!ßk/+x$wnL<3:0ѴDD,.Pe Q2)-KҌ}P<ћYgFz-p%TZ00BBpAs;^=P[IUƺ1h?rAz-8?r;<-Z|'KXʩάWVͨ]E{ijǍvҹ< 7]>O/uX٣ݜ6z9%>W\(+tS*9j~ut(%b+Qq6zU*ID|7[K6JK >)}Is}WAs`Q reKE]\Tͻ8Vu:z~2ԑxf4fp~ػ簟-}ܰ: PE<zss\+QND)fUC1n:fRJ(g͑2L$>u&%{F/u S>|17t)12i3ߕ&Ckp UnL&^(?npѓYG5e:}ÃYRmM V_辮2Llơz,?rX?rS> -xRwNk7+hWwUQ`l-]Mq182m|\#XLO&u|KGyIUAV֞xhS-t?n=;BxA5]=2Y& uaIu&# Է?r׈=Y6}c*!s^ܰAv;Z4{σ[mJqI??[K wTZ!>Lsˇ-zKϡ95BKOW d|(?r7*;SɯcUX1;&oAUh<<;/">j@:Fc.yjڔ\;\_𓜩dy*$KCpBKV7sPԠBHt],ol矞[8sa/?r̷h$g犔\(rJy$l2Q|ȵ*Q$̳5Dh:s5e$f-Zb.xZ.y,(5O!6\&y{??g[>K8tYFe@\ܪ$1o`7jb{&av_|GX' BԌ2Ju}1P{TZ}\?rۂm}??1v6:]3<#06Q(d??ẕ7׽[ q^ rT!dX$.ԴjQ.ȉ5@Z[@BWyky-Yhea]P"e"-[-}|1??3sXwbykʊqónK6kSL>FAchztƔhgۍ37h6]F5W+]<`J*ɊAI uh|0lH*W̑L+-)~њyӹLq?0ً?n АY4at5d8O}Fz\Mv/wmdz3ލq#q!Kb d p+6RM7˗j}Nޕܥ'Dق_kZJX 1h]?0ؿT'S"3IO)O/U(.)IV??>3sp%Lu=Q!&T² r[7A?05 (%ܻB*xkKT?0] {o4E]J88*jѕ>}|vllĨXW`[$_۝¢{bUr??}͢=v[ݮҊޫ T??!2$7E|>ty|ړ2[>`R8mD0tDv96P ?reP|sĈ'ϺCvt#v{ü~<(?rIx-b&?rȿg h[EbӝmTEױAIGM)"KR%C FM!p&"@[Sj05Ѩf vac5J$qHP&#`L7bf9;y1J/Ժ[*zW^c@?nWL#l?nWB}ΝM !,rV 6w}>S'K\~[bioQvIKYVQ;Zq-+rb?n͡ڗy[E pR D$%??kэP׻ks#m\lqPN1꼗$u$IΙGV£YZB=,O?nȆmv/<۹sld..lӞ\x9wUaYUBLrըzt?r蛜Q3ƞ9Y9{vA܃eӃ%4c|YGν0fа"_j.>fTU{^+`ڰ?rڳy|I]FMU¶ScIٟ4n,^py-TfDŝU?rn}'Nr\v;&iy,];V5b7Z+VH*27FbW{޳bbIeRY4i Nk0C !&. +(O.u.\FnA=tM),`i_NBaHRpز]}+uԙ2Jon G?rOzn,tHoQxzҢ+]m(䭓+%ΰ(?r5feMz׼5;BE8H&=ӭU&T{?rӄ*#:bIUAi9h)/5JU ?0CZ3㓝J)zOyTXʅ*lUUMwʦg]ec]{bu)i,?rsEPlÎĘ^XZZ6؍$/_0eĠ«hkRFͅU?r񸚳uw;_NnY?nHR -#D :J$4`~jᓄC%;I=*?rkfB bl"n2G+?? +dqx:D7-\59n9"CmۄGMq4s0o|7~H,ƣ%6lYl6@tҸ(Qor#gzKp8=a594,qޖ,fApɱQq`ۺoЧ XY\&>L^`cCx_*؛Ʀ`ԟ -#V cw9Έȃqd>1}jzcփ3nu᧐T! ?rzxҗX`$-?r\ܫ&B~ɺ\I3%0AkH[Kʄ86]5=dF??t$R`d?rpNv`@>A@EG㦳jr/d7?0i<) U**h(ʦֶ݁5?n>{<7}rb]cڞ,%CHY?n7KEx(MvަkrtL$/AB쌘O\n}Y!!CkYpJŬlWby~逈zN*FxH#,zRzMy8a՘0aOu5Jj6RRn~fcc ew'Q3\oh\Ez]һ~m-SK fw@dl7oJ< BϢ"{Xlyapg *Ѵ'y=W܇MyHinB6Ŀ|pC6Fd ;|;G>T sjBHytK)kPcA km@!|sG;, xKץN$g2uF9%?0L)}ܙ?r To?07aCTn,vgdIԈ81LJ0vd\xs/}-;z{}@T[_Z^x(:j&/ٿK.PJ-$Qܡ77 ~$//VwvKtҦv"o |p}I?r\Qӟ!OIG߼m$1,M ?r6]"${X̱j9đ!-ys9so2}_URt<^@?n]7i6f|CF఩hD£AH{< @۱toQ-)|Ogȱ6keP6`ِBH߅Fr0ic&:IJ{bI:RtзlnPR: ':\)΋&i홢ڀUhV25??2dtvcal-B`:9{lu@{]2?r6 ݊5irD=[MK],ꘐ;@ v޳BO;eG雈7_ӛ'ӗZ뢗=F4%3<-ɱj6'_B<@aF041bAr|-2 ò]нsA/smh]xު7"%H?rKWUDZp7MTxNDԋsz?n?nDڣ]gOؙ;~lm_Pk]t~W[ӣG5=yT.^Te,/Bt8Tg\?nI""ȹ)LG>jxԂFoE{W_AxLj=eQ9ԮbS[Ǵft<={M2- j{CuԒ10bv?n*L>vy/z*h!-ܽ,**U]Ķ i-0B5$jCuNhC)K4;(GF#nH'g `sV: 4pT+ML]Y,?r~/g_W$Ƶ!Tx??>hL,$Q58= y`ӣt`=l͸u0W<2u*_R[9:WcT2 7N_k$:CQIK);BGm={6"nƭ9+P?rtH^ŰkOrt)?rêپ ?rDVbV+f!)Dc*۩Nݠwm.}z[(`Py\YPvB\ʧpt֝wwVSJbI(BLؖ|Ȫԯ@2Y?n s֮*-Bf(1<"?r킾cWtB'~.ԀuTc(L Ł`nS8Ɣ:硬!?n)i(g'j!&+U?0;}g1稠?r@ !X>0 ؤU8EW뢉L8Z4=G6^UgqVܸW?rĘ?rv?0ӰitS@a& _1@cUxmY`XFV}O[x82}ϘZ`rC|3uR+^f?n@11L72\lTl[pa`sWSx ׊UtOH^l`ZIhp-4[E-!EQ?0$˹A` D`B"ѡ,fO͞;`r`[7e.vY~}j+trAΥk֭YuPCbK[?rBo+]tĄO0#9#"ywwؑ9H9վzS&}-ᄠ&?nIqG7.{!^*cU>?r*4V.-IvǼ_?r8,c oy U|%nE!7BvN?ntcʼnU۬.Ek3 ^{:6Qr [?r1?0y($(;۱+]uҝEzeG{??5rRqRccUԨe\ūXo,VWJͥ<м`VZ] .LiɇTe}U5Vn8f]IKd%Xӑ\?rM`/ -#uҾvժī j(K9; 2'(~zlB7>;ŅԨVwVl/2P_s#gK-51)5J?0 F1&,^m^%]s,LI??]KDŽe*T6 Q!ˆmVj76:7Z0C&/9Ρ|Jz|NBFYUUb@U$eɺAX$l3rA;?rcuZZ.a%i\`7Esmel)^Sѣ &́N\/O M+\#r?09/3/* hcx<2tg͑.!K'97Ll(Cd?0 &̴@3L?r -aD`(e?nXرcj]X coѥ>Z|B:s.JM[`ZomIǎ>6ax dS"Bאh RC],0/gA7w#U9IqQNƵ:,ݱhLB4iʋH9*| +>+hn|2˦$kmVrl@{?n* S^[$֤*҂9E(d`ԏ%>ӧ,@[v^YhO- ܘ̌}[x-/ H?0Qa-3'+lc@U}%u܃pSRWОA0qTs4V_-sJКm%!RP)n*$ (웺jmX(pe?r7Cl(ƒ"hv~/'Z?nR~CJ<"sѺmٮ" ?r0 2/͋LG2iQ:fL$Lg55j(?00 iyXO]:J\vUE2 5Xp>R}gzwYM=O@`x>1PD>Wשd[FP?nSU?rŎXZ)b*%g_X[33%r `ٿib3"5;mՓi3X@[nesܡ^1 PAF|l0łg;yD8,;,f37(t|mj>꤃o#æ U^T΢?n5?0Q30_8lJ|"3}" ",??hKJ "̦xzb9')DkZi+C|E$KZ2?n ldtIAD^9C`f?n}h3\x^)??_x35/+kP-Whh=wX}⬃Kۇ,mF:d-O] `կ `ހN.y p nmD쳝EFm[ZOv&I*3}L`ThZ(2{Ȅ/KBFDFqqHv 8j͉Q(0n ^s7DGV,f?n[qhˢl$j_cjy bALhْbsI@[aZf 6n]5=$;Xq\F9QVCaDMSbSWCtK͋'"n3y0t?nƇ;ק&{ZvРuxtl_|"Kz,^e??dh*d?0EjKoWu.O]~*49r|a%Őq^>oK5wb{KjFHd|uh pX+eڝv-]>/'9O@?nqu;KB|h",oxOyttۯ@_yGH_!i7 '3?0BݎTe0Zdos-݋-7l"̐-R|1F/ fH|΄tiY??X*,iUCiܻy{l"e\C<^`PV Nf.^Wܿ1cf3ܴ~$֣˺˒h]MX5g<\m2Mz "Z9Ҩڮ0 * 5>]I"Zfi'$z?nc:,DVP=Obfclb3t?r6tϢIn޲t`]xCU_DIƘQqbj,1wx7oj3v\/EH 85Lyzz??dh)_\Keh[m$Rl}+Հ ;y\OMܑ m5s`ǚB:|~rp|r?n|־p"'\FM3HZ `;[m ?0GMEIq?nb(_p2-]lܰ r=/ݪg;:FK|]@kZ5}ӢKm"r`D4$nB=妓wȜڌL}X^"(k-Y6YQE 8:zj">?r5<8lJKWqeפЃQWDEMTH'$C!iDHz񭣧nɒR?n"ݾݕ"*J[9ˑxO-󟙚+0A^ᛓjWc Z"r],{P疣a7x׆hL?n5M_6vl;!U2 tJ|`,M,[r1Y&o`r`1oIdzm&2Wᨫmmm.??MMׇuy=4G̻fl9WMOIpS>k]zZ>n?06C}Wi?nDž3uR??F8-I:j?0;)#3wVm":.ʓ8rS??`~lHeW8^D1i9{~ġ=-EJ*[r~a!3{?rp ?ra (\C\1}xև۶'=-Rޮ4M{Zp$pYcijLM)??#_xA`jtqf`p#P??[|%ÄIZ;?0m?0}—q{$>уnydNnP!*>[RJJՌ{j ?n͟0Eś! /͌lzWм ?r[M<{;n]z>sꝷ?0sk Kf`!C#&h)/kh$8D]/V!gL#֕olS`d5FUӖz*ل6X$&elۚF;^>^˯u\ 0ǃTW*Z@e[ *?0y" -#l;11V}; : phi7X"A`L7n:+67zk:U%.ʬC.۷>*/R,$K;(?r"ktR65eݙXNǫPvUlhk^N2-BG0ywz Moh8X]ླJ /]Y?rXWvkFϲi*jb tBe1=U-[x>f'2)bx7У5uϱThSeԡ&k ]ְMJv|Gb?r fsW:҉9/O&}jmq0\o :/6Ukj}s3|mj8BT&4.8!]o_9-C]^FlF^A领vST؛x ɂ3儲l =e-*qh^k39H,{4}O\ϹS(ҵ,90TTvo6%ߡh?r_[s` m>/ߤZXi4??)C&:x7N.0cR~[3S-zt ܴl>"+v?n^+PK/^XtjA2?0g?0m?0\gvjQa; ۲@kTRS6/Bs9c.Pu$VUK-*uC{ͳ3n꘱.iu{*񸖊=S&$LQչdSTo?0a w8YJ!nEycpŮZfC!?nm>vL!e?nYF;LzYUBIk%֖L!]kOֺ9+~kP$K"QVtrž?r,@܆pAK4sW2(h3e??1vyZ^#q#*ؤqgӁ/wZ`Z3f6ֵ???n _v{e+dHzL#l,(?0?rrf??rr($ӷJO_$|2;,}ƫM{ZIIq~h\?0(rOIk9N>@xk\.(#p}}kM?0$0uZѝ;KwF?0\Zdp7O;ZB8l$OJ@?05Rl"?0UM AǴ,e5JԘ춴1M]#K.bNKk{tfQ_PcZ_X\3h/^FA9Ԭ[96;m)__۵$䟨ʺ;aixt];9LѴfiQ{R%`kV*vtγ:+|~p2z_;'.u>OtCuge&/??_r 3嚃ò%OJS9 sw&OZ!|N05;??uqb'f_\#|VP_!4-Aa&?n!ӊDzť:0{g!RM.ۚ_w2V7+|Y郐i%ٱ&Xс(Ve1Wsaxv5`VJyDFr0kYCnL$7"eiM>C¬3p1?rAQRP–VO3dЩoԡ{L(rLp34V?rYBI6T]\˸TV>9lh zR+j] l遾'ICǘ!N% WJV[4RK~4m\?n6I3]}SU-PKZZ$<O8H[}eMҚZ,.6 3}:_7f]cś$vΞOjl~\Քc0ҦfK+`.@KJjYa?0Wåx6nߣ(.[M]6MdABlԄ`ڎa+KxN6l^9W`$e=Bu@7^^F4o6PowzygDcnN;T`??mQ6s|>wHQ֗NgAהF3=zhFNFX!UR[ܪVԹxn=-} ŷ_e7}OA)xs]&`ǔ,mu'u[ `ݔb/>QٔrrF/EԹۺ֡TC~ST?0 gBP|6q8I{$ ) 37 ݉~?0V4kc&/Ts=\PC(ҮxHf?n>*\+z71??ẖݰ"08ɔ4Dу{OME,)<'[Zl@ /p̏~M } Ct%#d*I@phpKkèŀ9W{ݒW p,#sw^ ŧݓL5UvVA?r.f'??-o싫O<R! b$cCH,0??F [I}diD0vt\Kdd6+n,1jiv#*.e Z̥! >e}5SS?r+q3ԥ1D*pR3}6~]qF@r?rJ n&ٰ޺H*1<;Xܡ;ZW LMTB0<Ƴxk*ZKM]oF8OCRR2OoZ_]̧tHz> ?r۲჉C|kݭ@]qfȌlD)}OFy˦I# sąOPO]%|`A1 t"KZ4q/ƗUy#T3T܏Ͳ@pKj<;$FM$vcmo6-eڥ/<("rz)T'?r;'N5/K9?rS/~6/nRv+G׻eEk??|YєH& :B@R{eyY;ÿMUе6¯wRNAqy~6G-y6rj?nh994gW;ƥ 3᭫S!YmZ}ŊkBaO+M?r\|7s'kĤ0QڎK3_mpٍ[??vɣYMH˸v*<1a bSpYEY1(GQJd"??uf'V>7~~̜W0X,??N_I\ftY(+Zj޾ͤڎIZTbejkc+s5~.))j|gN27?r-=)V!U#NZrhTc"6G_t֊܆LvVe?n&w#DMUfTe 5e#rZFĿ/ӄEHЏ??\#lY~i?rj1ɉ]Ȑ=~{˚kwJ{HW}3H|{G>!)ݍKt/zlONR/??*`~ 07d W\JUr ?0$h$7$@-\w#.Yt2?0)V9l-tsh`@ܧ\ҩdAlN&Nn E ~5p&SWLҀ*<2)?n'3(0z?nZ|OLLRS?? '$[%}Xҗ'@h?r5)8Pv`)D'FA?no|^9>T}C{%?n&s?nӡnN)g>ŐFFk ~$[*id(λpd/#IӛpGA+[#jغìB넖S\}jU+Ps|&W,,o;??P?r {r ?nis7Yf?0,#_eVq,JìE +Hjg´b%{vN X犔Xb)'ȄcTB#0iX&)ԙECZ$K?rfQ[ՌLw:_͗svϹ84^T't??l3*&UEѥ!JQMڙ??؃#"J%9kerh]?nK5K5i-Nś@S>ẙAm2h!zqE=uJQůRY7<6FO{\(^-+(q9?nbT\*~[o?0^xL7vt6g (# 1=G_qAj0xBz`{Bp-Ѫq޿CNE7rHɻ6o &ˬ^QDLpbA?rP\"tϢB: LSjP \$2esFp]\O?rOFDp`* 8C]ԤEw23Fb7rfȱr^ihsl$mzE/fN59owo-f8Owq$iJ c?0cdЀ';FޜFw#L/tVʬP^\o΂4"<;pWQQU?rps(+MdfddkA>yX62˵^AөPgEM?r4;ec|)4ƙ##7A›x} oY!t\;4r8T2Ia=ܙ,+''?0S.OG׹"S'5zm6d7@dZɎ'@ 3HJP^??+piF?0tmچK?rp*0d&RMtvIy~<27?n] S7Κ\H(?n581hE',9Cz7T)J{A"xFIGx!}HSV+S&UT%)Nr:eZ3LAA&C jwq۫Ɵ"yZ )Д2Δ-R?0d}8?n=Ű;"J_VC"GרKNètyQ>3@#^??l?rW 8#"9tl JQD??:Džo}z[}>??oijsD6aV }ʋ6ǟ_SHNkkcHmT!Bq%#6kxpj74p7#J wWŨқ7& r7廦 /%Qz0tuO('̷7gxAW)n~e0tDH!l{vI_?rG;zuVgi&?rOLN=sv?0^,`FTU5|"=?n&Cxv5$VUhķdv?0ĚAt;!k XС )Jg&?rT"a(+?0Ⱦ@7|md2lۨ C-_B0x?0űMd鶵ͷ?rѡٙ\ -9-t$L͸vpp;YMtt5l~$bيTq` _3AGpPN!59??Ǯ$`͚GhtwwPUuVp[??B~&` ;eq^ۣz Bգ!?n#~ůb׀ldP '=<%#Fz~k@SPtϜ7#_"M!zOSשVs2;Apsp/%y`/?0< `z澤PĻw&O_5k4۶IE&PDDb?0.d?n/>0OQ]u?rz@UEeLH2o`Nt?ra06/ksw{ &&g$n1rv#<#so&-0ټp48vE;緓J N.@I0@|H\//9'Sfj ) 0oScsR۵)Ha*";؅q+ĂkO/w{+є->\Ɓ$D=LX8C-D`77J^ѤA(-L))eHPH.M0SjcaRX\́ݍNP^OGqRP?0~ kWTypBXT:ewt¢%LD4OG;Xi)傹^Q$u 6 xb$|Ң#fWf|~kpiJjbrcSvMi?r%3Be?0b0@vLud%U$'6 \W8uTqqD4PcN9?0 E`?n@gtb/Ifc}iK/hcd!5>޵_U;|(pa8#Fhh' T?0>!JOl+>%x&,3sCJp~:C|cCQ T:Ѫ{$~$8rx2ЊԴD/5{gGI֦(??P敵< M<1'?r?n9oo|&ov6((2 hҼ?nY ɗVcP;Y}y??ZYM޶ce?0 a$_J%Fc?r #?r! PSf٘KHeD,}s@Gт^zw))ބ|,t^4|q |L¦TKR5Uɇ>cѬ*nG2w#R"%`\}uk%a+2(tQ:9?r/^) ;Ŏd$w mɳf52H6BP֟GyP祧)rg+h>YgϦH6^zJC7TNX.?npoȊj=7=&\Dg(fTb?0mqҩ@2JqjzfQ]ՅHK;zUGh88SM~2i֭}jq sۜΜZ㛕ڞsE,"-8JAO3c??ϔ@#Ab ;p301YCq$]"ssfl?nG+0}/Tjk* y?n:p)=T#[J|wÙQ4V Ӝhq/Ngg?0p&DZ6 ]I }:|dw_yYzNK=LDe"a&E=&Lul?0 lq(@0yvxT>q??Q)r MCGAֈl2ҢXS?? ?rGGW+iJ<͓Ǟv [~ݹHUX+`.`4SY0tj\ں[L7[`SÒ95[f8mFTX(K?roş#u'DbyM8׌*v8ȹz-,x&n6)W8#zCQ2??*3}fxQ,S'0n*hgGG6ҿz7bI2h3m)eTr8(n҃0 >ƘG]Et6Y&t4bϩ -#ՋfI6kʹ= d{j⭲rMϤMvfӦL֍uV>D\BMM`/ &0RWǢ4Q(H?rF=,>y@9.Ɠ" - 8?0tofp?0gS55ѱ5TnWħӶNH3 eBP/gfUzX\ztQ;adg0$̑QL22C X h B :8hVi1n7@63oNny4 b#.GI*i >J؍wQo_q_̨f0kwI,%H Lrme۹|ҌPjG,2U@ s41|/i?rf n UyˢuJ+S@Uez.jEd8?? ܚٯŮ(+pϺ$*ՓLܥiLN.5nnł??InralwcUwxZB^T$mT:L27Y]:ƹ.+N2OjkԠEڳhXB?rh)k-oN2R c.84f:)QF"^Ѽd+4QOCK|?nfMharZ\)U?0)M~G1ES0<x*e^@LPsp{2tV˯?0@snp"TDt-w{>܏zaհRZ䩼εQ2"??!ARCTf|ВxXe!K{m1o; ڞq(S6}4xrB4ӵO8wn??C>ǟ t e)|W.e$ddB?repɩn! 2/Tqϳ؊ḁ4X6SX)l7Or0j=@TX1?r{0w<2U@CHwd] wLݷ=šse2 &ܨ45#\uG(9f=1c!*Y5z?n^`SFʄH5T*f?0\K0 4yA8rZ|)kA_-H-qp?rJ?0LSr6CK>p-?rKHMɏߌ)[:e FA"c2?0gPy)Y0tRMjEXX0lUV0k&n3Qz#?nMgg2rv{fԧxcTDL*}1ZܑXnjAu4bH{O[_j fŵ\Jd?r"= tUb!|=rP68CHf|T<chs4\ͺwmijwFS=tlc*1SJ K`M%}. ow݃0_5׭L~  VŁa}R%_Ő+0cċ8ƹ[iyT4;*aQ 3r] j nq>mוZ*0Wơ>6*_ʹ?0՜v:RE}u-[|ߨ#_%S#+4|}0S ?ng-3a00uu OE~4F[,9(`rAիZ?n`Ȣ_ǼԆr#OsK$6>Au4%иs||O\5,:U0;WScz+\>.ޖcީ,6b38=u}׬/Za;ȹ,3/wĆRxAb4p'Nf)DOGSЗ lBrxJLtWx WHbM驎?ni55;Vgjj7=4AF2z JaD%Ͷi_SHU^V5pP[+chH.i2.&SիfNqeސNhsh7Q]ؚ28i`ڲlW,%_qˎ];g LQn!pJac4[DI4%ypQS(51`0֠J3ɏ{RsAtd5 ~>៧(hn9F?r>{,z5479ou)-NT *W?rS7:ӧ('?? #s{%BwD ?n@$v[%tF<3o'.A£GBE6Ghz^]qfAU:f??lz/+kqi".sAHuz] PkybzR7Fw&`ڰΔٖ}7hn΢odgB5?rMjZE:16mGΗf?0{4[m1:M&e'jp)5hW+vB`*7z붻˜yP)3I LLS¯,/6׈/>`0-pYQG N(v$UcN?r#=k;x·́ O6L_Fm8xƊ;n4z-pᆋV[SeJU,n;nkҀZ6?rk @?nkb'Vjm2NR'?nJ"*%txMP{_{a\2Vj.i};k??3@v{sgܼ??Y)0C>')sb0h:;?r9*.cgϱ떟Gw~KcĚ"8hi˧}#dLifvC"3TC6{E(|P6.sdv;R@6??|y`Z??lקoKj(?nkJ p?0}Eyҳ%Oo>n9?00;IfnҺtmQMQ=L%JE!E?0Tko˺!\u'fV4X&|ȃem;eSUmex)SD49QNoF^JD'SI''no(Mj"bQ n.D{ LʾzkH2n!-bs|F5ƟxʼnnIN p0CGrJls7ĉv m?0|Ι: yWVQ!\J X).uΗ}-١a;aZsWr>%s#x?rX??ov[1x1(_Xh,Ch/MAk?r,z\A]6Q}+!kT9Zz1rJ'vDieD9J+y1ߔyN7 #dz.I@>&.Qd,+1N4v0vGi7DblZ軻:H%n3,ԡzСH-M܋ˆ(u%Hu6Ewr@D}Z[tJ#e},,{yľI:4G30g=L7-eJTbs"㋥+ܞabhmy_~3.zõU'R#ƫ1Pb諠L?0W上춿NuKQK?np/$0?rˏ?? F zU*Uti^K LӤDd=A?r~Hnx2бnj&T z".~%q^nJ-'"lׄ.e˘ !/YN›=D=ILa9Pp4?05= W$h6>͈ 2 hlTg*h.r=A$~USAtAmf^WjД4e7HWfVCC{l 4@ Pn@CxhލˀP;{ MPҿOW lJ46ǝxtÈ6,`dMq*輴>$sEU#F„) GJVĊ[hWY-h\6a9#Oh3Xl^??fˮ0dyx`[M.bf1ٜ#:Ek${ *_hy_߯_t?nL^3e :% >CQ`&4mm,)~)_r[EUJ,s1VZ¸_]VtKD4rj!|a9&HI+_o@7"*Zz. qdT!uD6M$*֢ߏ9|͝ᘨ-_+>IO~A*6d/qOiGk x)zb b63WO1C^2CK݀͘{?rRˬbt|xKnPS>mmi)Vո1jaC8P`Gq1߆Ф!ȉ5?0:Q~ynjDOpit uԋ6Ym]ZXK5{ƩC[n=~!M3@ gk5Wۓ5G 2ؓ8Ư%ߓ}mpƨzrzbm??4L̏m$Ɯw/Ws}X䌅} mM-.%]JpR!Zv]ѕA3\۝!Ϻ Nn;q#:K,u[hގ +xZ|:+?n|h?ru^VsOp@EN64b{C'Ԥ<6U.Wl0)i&EY`^ux"$FlY}K,}5Kc$ #f7gʭ v[8QX.Z* őŨe%ȷq:*XF3Yȉwi17ʼu;[]ɘKVAku[T(3@@Y-mMFUА!D{IJDe[@3h+[=w2j]2/K0zl c6y¡}\?n0N(LmsoharY<"6# S͘U f??ěJ9ŴŁrh]dum6-4M*;8ucQZة2qe9X@2:c.DyKsAڱDe 4LFmbHGS0ȟ #s-o&?r[(|ф)_v5]`*1ӕܛEhH?0Ǹxn/RO\g?0 ]\W{n(>= R+#1.vyxo˗V{ OOd0ZuҟB<ৡ?r<5?0*??.fK@G@{2)e,Ӭ7cwz]@hRTv2ʩ1󐘒͞/586k#4R P()rxrô#Nsc tʆ?r,yO+rE+Fo+" Iw9H7u(B5M#'S D}⒪D|9d Z'-;Ǣl88V3Mt<nzoxOĵIkfW#)z[dnj|)u[<])t*\&ho`PGm!+nL|bO MYv [~Ҫ,*$k;bnRR KX6`^ligZ"pY\\=*jV!!Zqk }awcwQ,خ#4>x'HAV*;*\ 2!i9M!&H*ǝ}1?0qqӫ΅Pਘ"Ҿ6ۃ.s!nKKn/9WB#/jH"Rq>n^oxIB?nΪQ4j{@iPtd??~ޔZt=9@4jE/ @\X7T,']?rOJǎx)/n)MMi&\Q\*,sRR]މT`삋L1,,ō]?r{KübHr-ؕ3':f&0sGYg=Og<``h]X^M+DOKhZqSLTTv.tʤrSVlǔ????YV7ѻ}`^o|Qၢ)O6g;~>m??Y(WV@GW3/[Uժr'ҹe]Abv -#.u}5]\p$n.`wB߿o "u._єXbV?nnn_?rH_)AD%ƐxVemhbCXBԶ~S ’WL9^g+}DJ%BiMaS{(CN+_PoV@v*m?0N-ՇbFtΚT `3;I!Y[rK~81D>C d~B?????n2?n ??y1bL쩚aˀY9) ?nMba4K8}F4i&ܸ[׏A%,S(9)>?0(44^s6e,Nef{lqz{{bm;8q 1T[m7\gMLtKKw@uĴ09Bg.,F&fgg 4p1 pJYL,W aWe]3,S۲^&Dc+"SVl|j;a͈몼bJ6:acwOr҇~D??CjG["U8FjRܬe4y[&"(Ôo G&\ gbXiF/f æ?r;Aigs̥6R%I)J6VԪ8C_8FCjOIu7@c3x{/?nLob8oԸs8ڧ'#!=%nX{S3Eqz͹N3ޤiM&l9sku5?nEs$Uuuuuh(WI{*??F xz?r_ѓnZFT=.xжd<y?0*a uT; ^STÍb.6'zMye3hDeg(gU}˟zMC7H_2WWG)U2WZ] Ϗ8NyǏܕ??"yVx>)dNbfgD;:#~yynQ1wQ}5GE%h it0Ȼ@ǏDP銊讨]OjG9#Ne{3:&֯fL.җB.Ya+ˬz1(I JQ ${t|q.Υ9Ź&ѣ K,֓8[^eD|9[%?rm^gے>),U\c[C#b]F*|$z!W5xehlSŢ֚Z[3K]SV\Ǿŕ?n3ӡIeL,Y^G(Mup2񣍷f^Pu꒦<&{3(T+/҃*-}:G 6͂-?nY1R5V!7&I-x:t01l"Q*?r\wGTtXZ%;DmI8;rBR.ZpocEn0?nZ`y?r6y[7ܷ`AP{g/<"=Xy6v0Z;?rHj0~qFqc…6[ڻ`]lYEy ߒPbr:r2땙E_XTu'ՇU贃Egֿ*UHm$8??( 2Q?0t՟a <~7R^vU"1#9Bjor֑,h SX?nc"ˢ !D麃.&;nwI;x'Nƻ.ͣ"ZZ!ZfM .ɪOmJRA rTV?0 գ?0??j_Eh QQ;!w;sȎHİ^]!n3S$oϭR U$EJתd=,N>h-uXؓ0붜3UYy'l5t^7JBO??q\JZtjNɢ*aҝÚ"spUt; ~kUӉ)?r1Yp9m[ykjD9lVr5K"ѥY7WqϠhAQX;94?r݅U?r6#s??L؋QdW9tC(1V.Xr]U^ϚYb5ӱGEkԍmEAL_~<24 ~3L֫0U$"?n:N:?nC[Gd_ ̮v䎂 uOvϮ'ܽ GG3Kü[:L:?r;x}1cY{JQl@*gOxgdxkf:&vipH}tUTHʿ %8)`QKojfjv9LpGQ맃KOzNb Mm*3ؚ#8sfTuSl_X8BqK[W{ዽ.^-8᜿ܥCzvT*²vV+S]c@17Zj㤋.N`EpuMkv$i?n '}{ZRQSE-Ge-jU)U_&حߝī&Wt+/&W/:l0fY1Q [W8Z,x2uEDǁ*yr$/??*3RM'iCban tey4U_SfՐד\:5_fx v (aa?r(SܱYx em *'+=n#A錔nh;1jXZ$u^&n1xNАPji(5Q4h,r!O>ŶS 堅e(zrl'o[RK[FxmT1!)u ˞%]j3f*8LZnm>Rfo胱_fju `Ѣ?r\vw:*)\tDٗOѸ- "&[7M{A@v 1|nTTB8~^l-K aUǞYŭ4' 7&l=hAGM\1+6A<7f+53TlI̟f4^d -#;Ll9r>162o,h,d_m^^=ʮnytN<""=0q',Wa 1Ϣeh9G ytzD"ܖ~?n\[Yr8i?0eT!?0 p?n8T7 ɵpvÏf;ȩD"+E7*j$בt]rW9?rG,s;ӗ\L(GዕQ-dNMGljf߱39Wc?n폘"1(;}0%N/iYy}pYT4)2uFALJO>}S<4?rXr~as@5?0T(vɣ5b &*k/dbmn8-c|?rMFm4۝cpZtW osQyI>vOP]5rV(xpRYRן}o?n[/p駯^5Y3jOΏ.\?rK'NO~Ϙ>^yxq; nt:D'AY6_)C9Q~}gKMdYg&Ob=7GÝ1F4*?nI$%@w?0\2lZ4! )rd xp@aM4z/%7K ?r;&* cGU+=C BPkyޡőZJWgZhD#si}T.$'\tA?r[o8Y(b0>U ,2 O#lg3U{eO~7~wϏ/??{O??~O6pթ.MVaՖʙ-_}&5Q?n*Ku+؉1Mv䦻wZfݖU]ʓO(H_ra.pTRgkf7SVi)=(+mCyyw>W_ 灟(k;rdr4VI^HFCcJ"z,^!Mfђȇ~|pu[n* ;?r]/˱b:O]G%68Xub= /TUwxDS.Iad)xo??K. i"AtUS!<|?nUF<# ~lFȇw`8?r{VI~(2PJ hwg*E6o,?0g ]GhFR{efyte}sH]IفuH߅=]Hik9Vll'={fM'V}b[v7/5?r` JnY5L4  J?n1G"I;|7uևct1D$wi]GfP`RHb q&Cx DvVuh?n-JSȒ7t3':cę_` 5VxIhܘm1"Nĺ?nQW?r&ffm M`v!0+B]#^?rth묩5yL'??x2=|h5rԪ??at}񑰦H`-SRy fY?0pPP馓bH2݌C'qh<@O*[^&"se}Ź@8mkḝHD|X,;#Ed.r؍\xZd b0lnhCk,̶Dv:M,{??BIu+Z-2Ϸq+1\PSY9P\e'VrӦҪx%:[QL52GMmv`"CĻP??k}Gu:`U,6ۅD8Z$Ū\N+ rbg](?0?rr?09pNń@nQx0@W>x2|T] .)?n2\s=i|ؐ;ȯX}{ٱ=k$`qT/|P'kʏ)+H)fgz_R>d˳g?0&=gP {³oau" ღ8"|KU7',Y??;S|l,{dDgBb吂`)=Jp??.F4.yy;14>lTANM`k5y/>\W8LSxjʊKerXgT\T "?noF[2؄M~na>N~>cVђ텳 )*oW [,:hCޕ?0ҙ$k\@% ʇcy3NѮ蟸S ddףܽ?r/Uƺni6=sAV{qX;DFİZ?0{*x bNR??89)KޯCޤeTHi`ã@q[1??ETrne-ïݣI<vrD%H,jӳI^[FMz-3{Cvr11s;Cn 7&VؾJ!S&=RlW-Υ؄6D -#C6!MtG{x'n[!Uj]U5w*Zgn+JH+cPC:?n[FDzFu:"Xc[Vm؞P"5WhgjUi>nURܪ\ߪm8ϯД (>qòB$5>??{B!ip-??&+ȐB\.Hn' P7F]nm ^%j}{1^'"U%dkҋ/??= qb??MI_ }2MB"u6DkA̵F`4^+rTi#yrJRw^5Z?rq\09]a­9Dzd#TN%`|{ռ r_'G=v_,~r퀧9)dϣ#<:1ŎMX2O޼V/UglC7{&bYYYe_h<%HFbو̲u;W+<5&?nGn9^J#vK='Kt*7<5^E??ï7W4rR?rA̦ÿᙌ۫:YU2:+m#@?0?n!;ihe C2ܟQ ?nJ?rONwFs6x/kG_pD]῵ORd?0^w,KNUdsj7eu.?0JY*)!D%b7G˶r\awZJY_os-zbgYK>KWɌjF&'Y;mrFݞ\Qzp%u":7vrMqm9[`'HxO(n!MPwWďaTpvc#]"kqL!/UmmK )?r48O_~=SCnW&X,`#|HxpKY4~2YSQ{-#Aa9b=n7Tib0sg; Hl~YS׾WEˏ @tYc3thG0B]gKmjUH tr}1wc`5"v,Zecu8A{Ŗ&ji5`Ȍ0{/ᏧؒK{H.K^{pd&XZt4aFK>FB~|8^(cJȱԩ_ZWg)~E>ۺvͳE/6QBhGI`,rDSy1thă-a9&8^,qr[=ryFRc&ga$i\"0 ]@qQ)6~|R=U\l͡ y]'u٘TYNE@ǾKA;0??FlݞqsM%66Z١P.ErުhFw;gH>ttݟL>26iUif[V6 \N#=+Ht xc9{d`9SAS!DZ?n'A^:/c8~%p~aQ^o)Cp)L͙ɋ,\nQ oF̳8&U*'w5Zu<29 zRI̲󎆉y5΅6\X֗4BD<ՂH5ى-}3 WܜjA?rG]>ϮtB#5vVŞzFD[xSNQV\g;3Pbr,쵪}k?0[v 3&?0_ҿhWݐ-;䐁mha_MK(-ߺN>~IJy?0{?n[bSptd7=*YҊk?rQ(BZET!4a#gNx6Lh7ǒZ9r?nHsw8ΗSr?nLqS{ݥ7jBLU:l~FMJ0Xoafߞc3%oG?0k@&OzAo^A'WMЕXCNkFG[L6(ٕ=ef@pV\xno;H&->&5`Mզ,kzI.̿s!VvH ?0j%%YuRYgX|?0 &D"}lfg.?r%k[,<" H={Vw777ͬP)],#^g:fCHOwvrS9h"NMk5BÔיŻ.uυo"E7uW]BTomuzޜ認v}@%䷨1GZXF̠XVUi`;:N9,z>+|@pPҫ<)?rY7;_(WU!T[&H]]d\0L: ?0W *.!=ܢd#Τ?r1rJDV$*B,>wl??UԷT3z?0UZ?0xPJU"??ʛf|{1jˇ;R|Bp[o0&r;!@mqϚ>K+6A/w*h.U<Սhr?0i/J +Th KJ..`[=,9ޗ̿Guüpt??LFeYG XPd'VA?r` Q ?nĄ&%}D(Znlm## #.?r03k+-J^w>b+`Kd51jژ*SdrC?nwtrbS-EYjI;XNrAO0F+fk\Bb:"Z}.3L@jq,<,D0'C+0à|HpQ'   r?nI?n A:ZO?rCUz?r?nkaud'}Kd(ƾ'bFƌ^$Rܫu1mJT[R%]q9,=7(/DV .7NЀ2J=G,Ne|7$mI+ߨ3EkVRuxT]cӍi2ƣo!`،S5 7#nG] @*XmRK@0-}5j Q?0;hd3/I:ڪWhG&e; ϧTFI.<4hfެVke@Yj`.ֺ@;ѻ_Hz9>ϟ a*??Uǫ|tUM ,Bʓm#KMWqGj??g?r[1J?rf˃&rWVh)<҉^j^ ]Ό|G7ZEZ$??en|gqQ6#2KeţYFXU=Q&_bPsHVYp;idː5)T(2oK+̡ȧwEe g58IJpo??`.'x*`y%5y=Ez>t ^%rP8fڏkp㆝024ӛ( nywP,KϞ)XR3RLmQQ Qy煮J}1|3!4w?n}= "{2>@,~Xv8Gmx,ݺ>6oiE;vq(l-9"2I7c &$AH 5eu?r~|I(SBNQIVg?0 (PfQfn>- 7-"J4%BzghIpbF(eBKJPʜ&˭o@f0e$n$4`u?0ñ^n`?0?0%EyY0]E?n)hrz0feWU HGw0 dv{L2 oTP D;N$65/dҭd!׆-Ǿ. q?0i, xZ+oY%$m,* Ndy9<fS˜mJ7nFir,zѬUg+ 1J19mʓĕ4?0t@"!OIq5$?nA#:TѨ%(ܲGJ4)-wf_hsg-K[۫;JBJ֖_iu3kVqs2 d9Om?r{M!?rFT?07,[LJPPgl:X5ݎs-A2ևTp^<39Ze7S Qi{|A?0?r/^F9Rį*ݞږ¯IBoß??v`1_SfÎ{(Fa% ƒN}70@Ҿ87uZ7R1o9,(QrRL63 g 9@:%[ Hc|jYvX_Fi9pua;Sz! #e^QntVDа@TWW%h7McߞӺ%-k%4M2rHGN`y}p)e[??O5%bb9F%/!g/6#rᆭ !"rm]GnTa3rɦZ=%ZI(E" iX\w7Zk>Ç"cIxcl\`.Fԕ:*ۋrk8*/9A[o9(YZJ;Q^^N#[^*8@(?nO6jM|-KZK([Evd74?nq/Tϸ$=O?n??E-&|Yz]6o?0Kw LRE^WEn.-n]J`/p0ej`ے~=?0<]Djge}lMc߯w<2Pd5 &\=>{xx8ϑoT,J;&=.oОf}Ėځ[G~$ }LԡPDY}kgqy.fHRXPwhVUQh,7YN|zhQm @`pը/oHsP"Pǔ•^TIb<+ظaF3߲Rlðbyh/]uă[Ja&נgΣǑ5KaYEZwR5LZ F3^L"4쁯GZD2}͚YV.])bNz^U'FY5+oME@>)h*hs|@'S45Xªnb,#OOAX@s!r>ӕoҞ&f&_}#5Puǧn @]=XOk>4Gn7we9_FTT|J'?0Ww>??\p[ӣDX!e=!NZM4ގ{3{pW N9;{~k?r|xA>0 ~Ή?nHq1yNĭ@V?0 W?0 h"@ Aw׋H~;G??fx6,u3LpR/fIG pPg}pʁ>4C"hŬM_ﱥ,G8Pf;`/ hJ")f$ʄ[(C(SW1rܻ"BҌt!_`!Vr0 M8b\Y\btȮ./1  (2,I25U5=Y~XDR]~';}^cx5}5@XVPWfӶxz;%X{:d|3=Hiɰe8JWXFMҴr k@b%S{VA:Or/O -9F*x8Q 7|W7֑%iW`LDʙ5yOx?nK= +UKuF@=??ϟ(tdd5/rE71XQMrXa-Ҳv*"{c:HEK;OH_aqX~xh?0>Q@WYde5 WEVF狇dm5[f<^b,ٕ@ kKb&G -# CQ0ϐy]F@v(ɇhHBiٰZovF8|k6jzyfmtDF)o]ͬ]6TTiU,c/t ,6<]Pdꧪ)I^#(qM@^?nxǎI|leW=([K!* ^){?0"oJ0vX]Xz0~XܵABqnw搘'#kmY@X2ΚyՏ3N*?roۉ;pݱᒽ;`a?0lNApeKMzHN~8n8k[е\F+4:DG @-}/U2)e} V;1Xܒ $H҇{L3r1%^]`t%G pP$[xR,>"laY)_\-er.tAjh.hК'@+gDI9 RWuRlGEA~ +@07;Tc1?nf?nZ]@7þWŮעK$)_ٺbo}=c=Q%?0gW݊mm9oS,7GO1Oi2?01Tba)&Epm_^ C;ĐQV4"HRczMQNjDI帰t`H^M?n(┞x|Zm){5F|ʤFw?0ff8AJBs|z < jK9be7F y)ŜQ??((BkE!v@$ <ߞHÚ}fmxMxZKƯo} H5sFY[uGnie .ro辜8!-%صgKV: gIYA _@2Nؘz]}lsnݷooe+F41{dce!gV'9p\w4[?0WZl|ף{cƑP?n-9ӾI=NR?nIx5kUYX??)9m7,ℜ/2Q8}oҪxh/ͩHxqcvu$[wG<9s3uRFgq"9&`:L'wZN`[!E %ba"ے$j?nmou;??0>W9AP?n |S 2 rWPߗ z NhP"C ?r)R;zRY=ϗ{x" MǵN҂{IT'1?rG7Eى181|/{2VC¡>yvy ^OkNƉ=\o'UBq06?nJ &rv?nЋW?0n.?0?0ѓhG30+./nqjяFFMo?rl_1:@Tg@WB! s/' ~UݏPDDRa;??IMD?rV.ܙ+JUO*λTD”7??"ˀv=٬%C-:pfzbVYY|B,Ҍpd~Iy1燦\p >Hށn5QnJχ#6 QD8l^p(??5<+={_/6ʜXY(T"dK9w??5%?0ŏvZ+Ӊ.b,,_<}5kb'Wp)_=O518?rgق;MP6OS:)K'|r5@]tUū=U:l"f@oMJ't>kPfm9/tfٵCPh }fTP1*w0Pl5Kdn:\5S72>>d ,£}kʲVmΈ%4Xy+F+7{=%eTQ"ǣoY#P/YNY=ϑ.67_Y?0{z8񪧀-:8Oޕ|/g#vwObʄQ~e^ٜ"(e!063Sк?04V`Xhlx.ctD&%V:=9 M:-k+m{A!;>(lA HoԫۼZA}':,=m9u'|Vjsf3^F!7'+g(Tr>L5Isfؗ"\:x4Pak>I#6U&+"`_םv@?r <,85=1=iY0~f)E;)'|nۉ&݇VgpK`>h^ԛH[]oxY0"EasS6tIaTH"ҝMݔ)-Jn C!~.Nj~Ï??}F`a8G|OdZL30D^GE…Ǹ~ӥ7yt1=0}bu+oDu$pMTf|`Kl^\,4S}`W65·b ͷ_ͷ>8^??tC3Ҷi7˿??귚o^i>NA.LAA{ٰz VJ:2ʺzjxB8YV͂A9yXlU,)tE,,~#_{?rVj1/mX0^Afܣ#÷emf&=xIuX[Q Q?r,(`#??-yٚ!@wÏ9O鄯urs$_ ¡~\YG?0eBo&UC塶)fKBtGu݉kK>0?0Rci;+]ǯn_o`7o7,8 .͛ݹuف:N7:[4{pCԣl^zgM9'_fPn7 "hw)VxDx%ϻh{d6hBZCk& ՈuG#!"M0zyˊa4nQN}nP?n^a!ΎV|Ǭ.?0m[Qğ~ǫVs|p9x??l_^ 5δէt@,TwcI]>){9)d??<o[gW_(s(nds"MK&r1^OIdiĤXn¥œQ?rp`?0лU.zp^Pr{OIt*fwpuN KD%!9Pqbpg޳4B=)R_¢tdU -I961Qc儖?0"%ϯ`m* W$S`Z_shDw?0r\ŝ8)kN`~Pp&YOLetz:_x᭙)"s,{Xze(P2Aƹη+ܟ?r.1sɎǼ1obQgu0Xok!Z:`W7 ˻6D1]z^KC[z!G#cu4;<9)kh??1D@oml+ė??nWo_)/\0g>_ S^n&9ݬf_>ZJ'6왵t^{Wɾܥa88WYXZݮ竃lyp4{L:}'^,ᫎa$U y&Zп ʫ -#?nM {dLG7#ThG4ZzF&EZvηt5nHOַP?r:?r{fD s3lf8G#Rx8t1xW|!ܼ//!XW%*߉{l8v@wnJ-W?0LJC3K8Mme:Ocߌe4"{+`sX)l]A9jr>h||\r3|,J5?rj\t#CC}a"Dˀ=D;t\Ѽ?0Ԇ}aEʔLZ?r,+R"@+[%5tH#B-B5_8~!9Z~ZE.ζ7T{fu`ix֋{ru!s&{(۽iWIOsȇTԿnC,p =zR_bKmn"Yל-w\1(W󞆋O@~$nnv??iJl{ 5>DZ>5߅ޞcv׃=c߉my@Zd/} y9>X*S ?rdش UIÚc],ߏ8??W?r$-34Th*s66"R+cYXwi'HZƟ"lJ*$j?0OT8Y9w @[p(ݜT2ѦY4hХ%_} #܍NS˕uZq<nNQz8Yz *?rAɂVWI`Pҙ|RW?niԍ,^Nu{[Vd&?r}[E.J0:DP0¬姝-[|B[,^>k|?r& ȝk,9^]'{ř#II.-̓q=YR1㹸y]`8A(=ܗF*Uq5E%fWP&rc/ ?0$6O4 rFT9h7㴎'skgǙ\ZLY/vl'Ydij"oCs m(AZhkו$@oZ4F)4]R -,Q"" : ?nᖒ&3İlf䋲c=1z-?nϥW u )㚖??9ŗ̎n2vسlqa}Oa=mܭK;^??l >>?? ‘Ѱ} +ٕ٪??+K)sp4qVk/'RE>j3$|=`p)ˊϡ?0]~dOVmȡY/P\62ˆo!j&R6y%b+BYD^15&YU[ 4#TJFw3r&Fvgp7X|kMfcWo]X|kl:Y[,Clˊ4n_ȷUޚF<6&]%5V0{DQOȃ aj҆#$RD5?nL29e!"??NXS #S*Z*[|$YKchzWR5#ا>#BOjX%|9P̃?n &EG4>b[f(n ;*5*u?n,#Ԁhde`[8&{C?0 `4Z-=R7]'D,?r7i4 ݤ!4|*uih<*)0HTQiUdZ"B%Z,KDxްDA]?0QI7Xڛmno848cCE';:URA3ᙥ`)tv;6#^?0b%g oqmkvUfU4`"3YFj6vOMrX$}›}/pQx)N9NSI!?r21 U%R]f: '/Ϻ?0)`dq09˭Ÿ7tKË~~xΡuᣑ?rG^01āA(5DPݛL! O~0EUQ;_RㄙPXz*f~/;lQ"R[74!5Z QgE2^ke{(k4ay`n5ܘZ|76%#PƄǪ'^AlU@AG65^;QXH\.g(?no: yDc'lC?0ɵK;䢰eZVeuYG䓣M1E^GFRA>B˽$1+4(),uGnW6ýaz7ܭx@?0;x, 0EwZy^$?nEnp"0Ma1?ra?rU^,GwndSIsԞ3֤ZZoM|9h4>QؕLb,DmS?0oN??Yp|j5?0^zf16adkWKZh!haͲT?r[2RCP1]B??)O,~Xw^kbLzK6Wmqg<+Zo#Cv#bJⵏ{d0"cV@MLbXhcSjSī !숤!FHEo!Xm@mҩ&?0@{Ph8h)^gmS$`?r9v'tl2wh`n#zM"\hBe??[N~@iN5Rv f7kC hWY[eڮ R.η%ߤ8%mFu}Si+ҕy} cSZم4kmoؠ21Ʈe,k*daKn1}cq!Jhdu0s5$hCt/5XZ9yy->wrwt?nM4*>]ǒK?0lE<ܿ%HK E8|0_ZT7n?rf  u9/?nPhB$)Fb1Ce*+ZwT"~^k??s҄'c}'LtQЮo`}j3@y/0[I?nO.ZL'R\^Bӯ/`65E}=?04< ZBNW?nRyy)Hs2MW!Vu'gy_{%{?rWE~þAlaIgx=1vj !?ru=Aw|<=s Km=q~]!֭*!虛?nnvr<; 5el~x?? ;|y T??4 G3MwМybr@WBskh@LMi˖۸K$||"oM+?0:As,:X_??XSǡh}$fksˆ,?n;l'wG#kĝ&EvU%#0LކUP i$[#;T#S?rQuzf@ !??rYo8̼4\F!qk1;w -#??\x:Dw??y `xv}.,??2zy܃א. cC&1"<[L3rq@-?r F8g0.#Qu)bXCw{a9KuUXZ``ȸ)*n9<;-bdX_ά/K)9uB]f Q.jp`4L|w&um|?rgc[f@ȵq)sִaf"Q|9yްlPc ƷuTe"* z4LdElj[ʄB$&!8`g?0!C_?0b,UFSAQ?nQڦ!|=X:cކiGSsQ >c59:#,DPD1˟=H' I;0t]{7ϝ: QŞNȶ_zc=V M,UUYZM _G!aJkizNAA=ſ\.4XP@]qT%iL<+ I\私OjhtTn%3cpp R[/W,@7d9F-Pt\n#ġ;mz= CsnoPE*J,@?nVn3n*VkΚz*ϱ nz_IHZAƈKk&B#=܌`+u56Gܘ`f,lNmcOsr2#.TS$?ngdTɨVKpA5Q0<0x6vP =b?0IE\ME4/-e>h]|c6.Yr??1`{d*@b"{0(^8n%)5?n;3U-;?nZ'wNm1;z2s+9Cf ɭ'o[u`XM!x7s<>E6T4ה|??& mְxFazZ U͂7.>i.`Ț;<Ƽ泚[UabTWhͨvBEYSY@HIE$z+3@%W;(Fq6hՈ71fп?r]ơU>_[0kWvOToW.A˧ݽ>??Blmn5MjkSsc}c%{GfNaaDӖ# %+K?0qFwFiaOjmR=6g#:L:`/nkNPAʳ!SGouJ[^~??Hgb#DaoH"Ur}ek\;/La5-0~N_?0l~2Jb膂 ~Zh+תvgaj)W|SO]0R+(2SWˏl t2]Ʈw6OvS]µ699t"PI3l,ǔ3PQ?r_{ҙ"U<ߌdRÌ˛A?n;9 ]amhF|ޓS%_G(6k.}09?rgu;krT?rgJck]P}ޯ̉M\fGVnlu70wma񈏪غ%08ǝb)B2LXDtӤlx.#S9s ZoL??+W{ FSŲ>m z䂝W?0qklQ?rM]-"!OXlr1fFPl~ByIXY"2&V7b%UK ܯ$kP k2V18 {'WPt_p(?0L:Yb),vh?nk3YuBf#Qj]'D;͔,V,TbE?n.LWHMqg*0o { faQiC9jOnP|)u[dHi2FA[l`xen%h D(Fc ֑e?0e\dΞp8P`(dmd/ܶv|f=L_R̋|-66hk`?0Q{7t[r޺u2UwQEjylꮦxIEj Tǥ'ĊY>Hʕ2~:Bs??\d,/Z?nWK w7mo˴D(듮Z?rnגGXlm[t/wzyNBdEwEkLm3Tܸ36S8JÐK_XўF{J:+ٷ=Uz l"?ncFuzWnOLGN`sPc;?nKIg.kMi(h{#86QI0>$e??U|1ည}`ʭ̰syVe,Q2?rK&~`XCQ._*vOQēNA?n%;:du&2%_-M*Q41HM"46j($,o/j)EElU@b?n#/D#,ѫC 4qh;{;x)9TX8۵x55a5[}CM3Q"kը˻;DRF njĈ?0n7^$j4jBy?0%?nw%1x*&^9Y./ -#\®&XI qv%f%?0h6yMŒѣuf7.F3qĦ&$vey #F)QeQ+N`svo>&nP\#b!cBnpף <$h(`1)72"Et Z??39$1iH&B)A$8]L_..4!5Hf#de!!3`>?0|~N#JaY^%;?0A-Q"NCJ-r:bSb6l-5,`W?r"yg~}~yAI~M#\tq֛x) ={FM̛'zl:E.WBuI(?rXOӹ#hL%kI>>Fo8 pMP}ib&5_YLTgÖZyP[~J,^H9Q^Qk̟n\,/VҊMtVc4vѨn5$?0M*RbvpJ™po ^ *;>O0#GoM`믺2\3D?n)0&=`^U\XH?nsDfGxx94x\=upԬ}Nj6r r@Ѭ?0^?rX> .j }`I!ƜL؂}eͤd<O?nM=ߧ=#i??Per*ayXu׋.u!dC^wսYmJVY]`~f/*#,c8_ ZҫVn+q[1L1~-ǁ0҅P /?r??[T8?0@vsmY3(PFX>O_a&;eȏ8y6NSψѠtk?0w@tX֒{76MKi,>5Z,Ě^ x?0K#^-[?nCC+m@u`(QAbF??RJmHZjYF=}5q??c<ٸ+å1v] m?n2e&fq#JS=.FD-R>V^"nS:F, @E#"(7 y\-,oۓ0xs^Q&1N0%X*;,byKgEhXAW@j=Wȡ_yBuϿF -Rqɷd=1Pzh׎֏}+tmg꫽Ot,紻^\/p2U)ter4Fl$9uYD Vhm"筒yQ^b>~?n?ry񺇩c6?ryI2@LQь^\y6ݨk_V2?rs!h09JmaToa3Mf1ш!"l*E3U\HS>14<YAښ:뿿K4KH7M,Z5/ip:fV9gf&jע,9m4?ra.Wn]gD`f\Mxc!oJF3!2~|>Ʀȯ@'v$صWMee?rdHXDuE(W0pJ??,]F?r.D?0 z8|s9S'tO>u :Dkl|Tӯ ^Bn7MރOV`$ijQ0?nӰ,vyNtI64R.LBIuEM|Ȩ2)3:sXO :#!i h#j[Drd1g\VL{Wr7:`YRBx~Q39`q3!)l$W1#w͵bp&sڬN'I++q`UF46@߭KbCƖyԭKaMnsxsEGnRQرd寒'2njIJ1ƍ?np\A%ZK#Klj\!AJri2.YA8Iy#ia uVYc54)] 6ʦ*Ĕf.ѯKhK:52dx=>J=tϟ&gzO$/y>%?r??N%1=<&O=}GG#ƣ/w 0ҵ5I<}In>m=K+e&ɸ>;J,vjl@@d??5[6 +ftXGc%%{Ó,$Y S|W^S.UL.ӳW@K'  <,'NYðZvvR4{E9;1e)"_:`iF_@G6?08ŀ7ϟv"< -a8C?rTDG?r1˺W6`9VJG8@6߉bJ&㐳s12> ۙ y.2yL/RZz:/# B"]\<Ƽ9xG1ٱ$VQ߇͝LQzPinzIYljusͦjATƿ[b42q)V 2YTCFx=?0rk7|+]kw5̠=J )Akưuu2^W|8'~,יEvlyòыIaJK GvJ_SϠޅbP.|GE.ը~FyeFCP,t-7v=MHK6+?0`w??Z,kL_X8*Q?n<ΐ?r8?nAĶ@Dyd?0>HutP=J#Sv?rnbEvz+]jDW#jh|zd~Q!=*Ȟ\9nQK8Q<S^C!ǻ} .Dco33!ef??]v2U> X$,0D*H'\,A'aQ!aFwē`\:c\ȰL^ r$she`/ [Vh>\ڃȫ2xyh4/#- {lڜw.>5_跕?0[Ou&Ǻʫ/jK:N(?nXW~kV˩_1?0⫯$eݹj%Uno-C+K6±!hNseI@=gdYߛyu@b)o ?0@"B[;sdgM:juרr5:wDJg}!]Yzʭ4X+sƼm/ Wb*D9>dwD}]Fτ~ q˽n''|}>VvD5x;}(D^NH qv%뎄l%SDԒ g഍Ȓw@gҏ@cgp%Sp+Cm{L5t!+mfT3gAaɖaE<Šmg ,G?0?0;MSh9`yY!ZJ~US=忯N~ɳik6{W'0iom΢Ed?0g -#7?roh3),;f GѨ6<v:52Hym2Ͼ"K"?nw`dG=n5769?n=N&͖wNս]\V5Uyi?0U,v VmL*$\IeUVzBWoKѢNk5σL֐4)WV)Ey3a_mKŔY}+!g;\械#)# BZlίmYy?n){(KaGlvnSݧ֭M=<MXȗ]TXiԒn(+SϽ 9?nIW6K}2D{]G7H]?0B%Zb̛Y5Sl!^Dny[v Fs1;Ϯ39y ~o2 a[6y~ {UeV3P]IM$v}l*6r_2}eZ5B8Oy#w CM9o&`4°1K8lg"#*A"S $B C3`L?nπ,]VRxŅޱ(&P(NyoG?r:ƌߵLIѲm\0H6t FU+B&q=1#C(ѽ簭i@˅7]tbE-TxK&@glٰh.;B?0Ɂ,?rq ?rC,ddzuV ykGog)M(;&fOg?ryͧ3U?0j̪Eil}nAExq1n8(b0[4 _d٣WK&D&Y%?0k\}h:z97?rSP7JX1_M|&6"???0,P^ڲF`ќ)dµV][tTfEoFY6N5cҹZwŦLSl76K?0Hekۭ*m_##*3wYTĭW3Q$T* +y6}m:Zdٌђm7mү@wI\"Ksaˍ>jGx8v߸zǣt@|1 LeQ\OʢՖkR}0Fx-ax*9)H˭kPM[ JK(y8ው:_8C}/F-RاSJZ%hY&qI6 F7AdTJ?nE`(\\X3b$3DZVJRbh;!šRW'ȟuX(Ebhr3n:">7SM"h7dgfoh""))),Fqa&(_#.4q?rXv"Jἀ&E "&] ?nmmD+?rVL;z{]c;Ty??yBZ U;׹yĬ_FbIِ??HUxUݱi#??GU,8f##}C?r 'ofи]#іi{3P;[=${-72h e'Сg'-x?n^?r18JԌT---ل%BwWl.Kڿ*?rQƥH!??9+tdQ "sM63lpGȏrlU9 }*]+ybE9xjЗmj=Z*E}0Uw*(3^IjF%r(T|y)7{!m]1($6^앞ط%?r6/J\uV1m3k\Ѽ(Z/QXd9̒J(*7MP|?0(eK83gѢNK׻OҚp?0Ԭl\7"\G0?0E{G1XۣkO[>m:lPN`}^e4(XЪ< Yh99xlO¼A\F83̬LMZ8b|[>hbFR"2e9b塭LSD]sM,Wˠ~^ 3l_-0*W(Z7uIwUqe-ߖIM2EK'L/騀umj$"Xº`9zz&O7BzzE583y8c#dIZb1ya0(??KS.L:4PsQ҄kFwN?0?n\~&-khg"^s.S ?0Pnc"8X~Pax'3i?r~ₔ3Y/֘!L?n#01#DA9VBmYntyF-g%s"EA+n|8M(s:S8Ox;CGy9_S1CsR#}6MΙeaUO4ٰXVYn=jYRf9-EC=]. [:FXsYjf*Y",CyAJN"!MGݮdrS?0V($',:ebPeDK9F "S=[&1biWxB8QI 坤.t#~&vڜv,_9aL}rfK L$}E32Żkѭ$%QiNVtXO~HJY\; -#/`t;77{l6f9DH cks}O+Feh^9Wy ;vSOT,mݢXp"G[ۓA)&CR=4Là?rbI"?nH?0<`P.>%l K6lhܩ֧ƲtUt/<؇et4TcX2sD6~hkף&ɘ[dזF.KJJ%սa(Cq[Y+s}]gmzؔo\'}S167V"́{&0'N#ᕎFL?0JZVPma?032|ft??.g-UwX\??7hWd70??8%a76[{#Gmqkӝ%f[%UIw?rYn߹M?rnг @$*{m$,qYQNjH\y;WˎgQv,g_|v1ɮC۞Ϣk5½ 2e0ٻ'1>ʾO|5 Q3SD&ߺ\aX@Z&|UÍ_m.J:2]8NΝ?rb??zguwQv8D,$2E%h!GGAgRwnȤlo??.b$~nr]sp?0#140"n>x??t;-S2^ש(w=bCiRQGbC&Ap<|ԫ5^(.Y1$2mK2Ȟ5;0?0/64]L?0lr=}*Ie7I'+0݄NrumA79UL9@`vMXy?nzgQFoh\VzR!O [ix;]Nr|U¹?nqu-`!,kr}Fּ3S)~?0vU?0I_fa"7]µMW?0$hy+R?nNX)& ;x{3Z+ܾE ޼Mh~zpfW|@o"]?0|T&) I#.F](pnˡaT1NS=<%Fc-f?rVN"ax??wkG؋xCЈ'vU\*c?0o kL}ff!N[WTq|yFƛ?nn4l5Zfkmw{ͻU?rr,D[wަQFš!CweCxfrX|2??&"ޘF9 n~ʧ\XͯCA/ P:4L{CKPZٳaRǘ.ZV7߼B??+\TZɰ(8S+lţj*7o)G#`cBJn?rZ֒;F+|??UyMt׀F;׼3jYaY4U|ULqN^,Fc=7g/8u(_t$Y%\,s{>Bh6\7!E1O{?rB0a??+F0fd]fSh#vAaW~֑271轪&.jAɏ1i;fI2Ɨ?rk4NJ|"J D=HjDXtKtg f:WuQ[xG\+or`iOӶ31~#8򯤰Fɏ#)?r?rp?0tCAS:G(gQۛY~E9v;S6w_=6;y+NgIp(=N Ca)S׌똗;>rB$1D![??t21KD7Uen`mʔ6tmKk?0,:FV B,c-=y?nYܜ-Iղ#>bv?r a=ٶKHSVJˉ-{P"?n\ii%[gD3GZ'[Uc 52) 0FuM䝥Sc,dz6(?r?rdKsf/-|VmŦj=8ML˔qr+cSyu0epevUcb?0>I4&m`hqMY-I9k:-y6~vs $[l=lA 3Lhd0{)GZ#3kyߝ Iၘb{ }P^8nE\㲖έnD"~5b},pO9FR@L'Sä+A?rvjmn4C }bΛ9G(U#vo:C|ŸPW,^OH/4&&C?nS_+6:2CN_f&evATeЋ3wx?nK7")\?n;Zndw#F01_?n`V{YnxnP&&IÆDT(29 9GCt.cȏ} @]rŪ h׌2hUU4%yQ~2K\;6-Kaʇu6扽$W;mBĵ.Lܕ#ObRέ%wArSE18(Wz&F"/YSni;[6mh6]<'+#ֺ?r`fÇBbŚM9G{Zzx9vl*;ۈZo(Ͷi.1=%q@ /KV5Sorw=N/0vאYPg!Z,f׮c)*KFcV&&ڍ0~{ʗQu@G??혂6/"*LcQO4_׿JP??PH&믱vx͘k }bp#֌ZX+MS^R$kplu6t%DְltZLJת3wٽMdka?rP(N?n??KuśŞ.WJsy;J؃B{f CdDLRbUͨ_e ??[Iwx[t?0[+keIh 64sb킽?0{NZwAY=\uK-%e{;u0aN?0a:HVm𿴷p~C@y qV,", 4br]iJ Kֲ*QZJϴpik-|ڛCy9@T++[Il/ʩRvq2;7 Ѐx8R7s\"Le{ (ʟJ\բrՋfX{:I)#{|dX,ɑ܏2m쵹0)G,oV~;_Ez??jA)qpϜiF'֭(Y?roCH?0X??d.м[vjlٵ Up7fe7?r|olmSpMw,r|^KK64Q/eeiz2"2KdĶZ$h??1Jʧ%'RGd??i_` rӜLhu9r#U2gyɪȂ'BXV*٥LpMyYCThDJ*{vN_hl2<;`un~hmpNe&GZ(<u~d#+Ok8GMz^hols9]Y?0QA9*YhRH>נ)1R6fPཪ='I"HK)ЋRvNL$iU/ Q{[Cy8iP1 ǽPۄB BZYb_m?nl4Jt`cqNňLM\cTBjWKX(=Oi1^%Dy@ ,W?rz,ZV02ɍh/.69칒;-.\U^0[v u}`Hv@|u\$لF,ֽ+q4fCUK)'u??soY??`0*|煞r~7oom=ԿMM ĊHXzpdHa["9)DJR>|`Y 4œDW6+}p]g0IQwD Ͻo,|-خs0yaz~eҒӍ[o('^zlq8""x\-")irXl#T=2|&U`JJ1v9E7KZK,K g@Jnc;P4D&ڟK?r$Řpw{?0tWXdrP ݟ}c^`xʖGk?rX*} sqo]+e$x#APB,_+8)^5ߌc-:ZBf_SXQOljrC[''Wv Y6ϒ81`  l,՜8 _ʹvB`^U;hC%v.ͮ06ضm۶m۶m۶mw:ɃTw%TҪt5$1pcCގ$+l?npSLiɝaY x~w=X✛Z*_MPu0?nf8F{*"~73w^IJb:.ln.'k9J=WK'i)ߥP+S7LQ64ECSt7 RK z%,_Lk[ˀ3MpiHO'iƠ 3~m60QI[PcwـJβH_0R7%$;#'Ms8P?rtnGqNSFk8ġS$a\Ff??HOLR??2P{9l|Bj?nTYl߭@R,f-S[@ O/. ioFҹbsDؘCleAmE0;Df9oc홴,gҲ??U.2Vg_>hP_?04W:?066?0*~FYfEr_!?retSkIifo@j8tn&jae>G|ih"meO>PV3o^?0ܱV&{n \z#V -]{y#F2Ӕi&4Ρ*JN;Pm}R^z?0erafu&W\T|Gŗa2jAiy,eŠqѓ?rNaZmAb?0v3R0Glf?n̂ȼY 2,D֌#Y IuA(!ɴ9cZo]SF4%EHL3 9[=+ywL7emmk /fU_`XFN=C5?055$n,8<10=FLwzT*D"eD(En/!ax ʸcF;ӟCO (J/N/c裙AFѡw>"lZ̹Txj1QcU2˱"$G*(;S,1k% pdR!ly0\<8@yY cM~RPHKBtF5hFؓ7g?0A8~]㕝Ǻ.]3/5㵉e02QAk'*̰xTykϴ]&5oun1)mN:R }kHtQT?0&VC"ӾM`#ʒ.-H|jj7oBMScr3%s67 ?n֟W,+A協0ם2_/˳!py %LJI+'V`J?0|T% -#{̥ݎLdHZЫ5+z̧냿[J"jh;Fڡi_v<7FBo(5izW0^?0*RbZga-K5??ֺ&C AEhųGTM}=b0]N68`.\ RRJĈ$NI@Jy+1Uxl\V daK4\- cښ(1x+D="O养pKs58s܁?nQyW:ҿ|;"-5Z?07p(3lۥϣTGqprT)Y!C??:nE9QCf!,F˳74] QXнzj&QGje_ 9]SK'%ޫf!e&Pʍw?rN?0(G''y! [ThOLmڲ`R rß,pP¿STގY#fe6-El ;Pxnm\UvpVrzɮ#juQMҺơdbLxZh/>Bϰf^5??jX# c f%B}hV PaUWѕiµhZ+#Ĝk,eӰ8+TZɴdF??ƘcNpaJi`tmՎhXUj(4?rJg=9D.@*sJų:ڭhl{j?0T8qw8o#IE82 csb!lN p@ #zj?nH{BQ(|DYx; -FeF:0%eGRjU.Nڨ0C'Rl%txS_t\bF͸MzǦףF?nY*%Mm%,,El|ڋ 2`?nMׇZvQI,~Bp~?0 6bCЙ&,ҁ=BU?0ZE]R{|-.C*?r^Q?? iNKfp}4~dyƾM ˾37J<]ӛKt@kjDPlg]AefVx_`n<?0FZ|M>~qI^A jCҺDkdGVsrj+&hC]EPDWd. `_k~/v?r+By~+DP2X _ a l 7s%?nJ-mE暵BC[j qL"ۭ|1_{6,cgIQAѱ2A} PI(P~Qe5քGoXR~@@uíZ=xOf9Cp"޺kQ8?rяn8T%XȊ`5?0e%Qʹ3V%Ba~x3Y)SU݃zgLn?0^4q8ABN!ާ0$SA"0!?0n-j~Nm?r}vO?rMH1$C#!K2??٬53~L`hl6ي'vl*H~ijyEf?0?r[S> Z9k%2 z~T:r,$L'c!:h1wd/[/f4dUYחNcۭYۖ4[V6w>Iӟ?0N h-z2&%]i}M<- l?n$?0reort%6YM^e DX"qc7)E ƈ6af 0`p?nohO@Cl{!Wӫd^_#u-m.T P-'^?r1;삟GhLor+?r83?r$Ң8?0s!3@#B<34MSk\ť.7b|ԭSȦ9. 8oշSڪjXm1XƒpWC*Z#EUtFt?rF8ӒsMc??8mGh:Q#qնqTR-?nI3r>nݧS &Qְ2] ?ns&]?ri<+铸~XFxY}kj.}s5gϋ!2W\'[`6jk aSu}"W@ѽ"rC z 2 Weaΰ(RPX9> ep!8'+zd0Ti̻hRy-^oWg/9G13 s?n3bHֈn4(uxEdSIZSrzӥ??TRhlZHeȍ\?0;GZ9.#dFr5[ԞZ!!7wp'i =QnCR`2t WiRKAa~MDlXXy̎bxu|2?n`I"\U~l *pM-\(VdpQFt鞕*IŚ rP>-~"6 TK-y_ctXܧl(bG;K3nU?0u?0G};(]`3ςGX[%(Cmj\7xm]AGijcF ]R&{ c l/ެ??p@;f)QKbߴ{qu0ĽT1ڥ.=<3l`e۵ (9=}Yw?0xdT>CLVZ>;M)4Oe,h~ߺE8, >*3q覷ƬbA\-.74,ӑ'z|Е|YV 5j8z'˽gӽ{όٹ{fK+b;K[/2z9?0eȲfVaSQ,WTۛ F)|8~??j?nTUN@{3Q_נ9?rps79l#E]_&T l3ƘɯV?0 ,B$+5&FUÜ?r{aZBi]yެ?ng`R)@=49(?0eX2>΢ v6)3,q Ziz -#9p]Jk2%{zy5E8wEg/hBx%@|??7Eܾ)=d6O yd"?rz*n?rl#ɳ gBRZ!;|mV=ަΚk4Afc9O?0fZOɺ*X^@=qUiMhhwQ)[Y7Y}6Ǣs=qԅCXn-$49@IJnc;m5%Սz0Oɯ1,t崆hS>3~&>D,!^rn\Tl&q^H̓jȊ1u= ,= E9v CgW#^_@m@n5Wc3}I͑k㰃rVDd_+"ȉW.Ed<Oprm?0>$9̅6/2o4ego%}嫫vSޙobTmk賂N ^u?n]%0t;(.-Q)b&Zgy8op9⮽+"m7gzJd6lkTkM,]zO~Y,fy\OYԔKChl7"p9>Kj%+:~;Er>Vs\ `ۦ/|nwtd(Vj0>u8G/u`iB,Q7vcs+7i{:rK?rp 2F`iUGFU)Aעsk`E˝SU% !BNIq%x֡VaK Y#AOg@+0X\JC`8-1cxyXyKxEkqeO?nҡ3>"0]%jw]584kBÀ46S:gQ\^ZmF|h /԰4*1c@k@b8 UŖSU2zSγ`joc c'?n"CV> ??drx6ϼd& q!4??$PF[D/pM>LJG,k(Â!L:twJhk}b4tf'etJƏT=L%q®>bi3cr?n!HHXq'e}[9:@8#^uL0xy֢?rmX4*y3O.'+2:P+򎓎Du9:΀8ɰo[NA_v2-Usܵ|ŏ+13=` K1> #3`ͭfẽ/ | u~T'W19ݾh.E?0FֳjJr!D;35s.ւmq∊֔i~XqE,\$tuY)j!IuhɿF>7m9;\6s!$_hT?n Ҥ},s)DaE&Ӎb:d9qfJQH??xē-]k+Qd圻JB]ao+#[te&?ry1?0\X\aI'Q9܌؍yq)k(,,l9 Uvۍ?nɎeΏ=aBotU0/rr:]xikgI\RވD?r>{V(CDKDT' :P v;z?no`{L`}/:/4,a|/0OāV?n$)`JW@1'??UeoDYnCM??eϨqzAF+VM)>X??y<#y\OQȗ?r#7iV?0>?rM3mDo(??#OGHasذx"Qz[IZ,Zqtɠ zIG;)[kK$ -#!S6iQy) Mq2"ln}cc'@ƥK{1ʍuhk<~[7ۙãq]*p8TC^DBϛBfEŇ5@hxd_𺲺Z{/<n?0'8RRD}qx8|g 猺B[ivT·d=ɟ\C_Kvz8;'r'F]%ҹꖴnRxZ le𹀨rm{c((1ih$,)f =YaES:N_q"׍Lcu݄;OE,̓@O(lequk٘2n~2gn(y/A d&ɥ{ByDj@j=6d4߫ghyfrc aDd?0YJLyM?nT>#H5W$D "ACDTj?ntܐ3@VtJT%w+J}CO_"rϱ:~yx3%xsz r6!foֻJ_FV:KݏKYi-z/x4' 2QgC!i'obnEOi{+֫Vj@v ׏LK5:Q0Tat Axhvq{@ mqEgc*B1U: T,JIBxx>͛3XQ<?0,cG?rj)вD(>17jޫ]q9A< BN+aAU_ٕQE@_XnB;K$cB(uW?0_ S>}_OM1WDG鵫V??;a -F zEoѶeN0%>[תn6SI`VT&;cP.;_V,>Fg|HcŤ4*Py"a,lX]޽?n}7?rp7ui2p D{??t:6#??>A.f1D/1vsPiY̫)OA }L~k*:sлuK??1'a.JÜhmٱ(oK\sv`ܴ$Ֆu[\7 j]H#ˁЇ&ýF0t;O~Uiu7@?rUrgJCj,S6 #iv{.;]˓?nJxإe&#Cu>UDW{k;L:g}4޿"k  6/-?nMqմ#do{[lwN0v7LSb!E`87_#'a xo5CvH?r^ءEф^bN<Y?nq(,2V WQ2s۔>9\> cv,YY?0 $GwK y?0`RoB=mv,.[D??$ [Ȳb+x^XH@aJg^젳НXlIA:&S78Rͅ9WlrSeFr??׾Ţ'??ix7= i{^:\U3&3u|*w\?rDg?0,eLfҢF,>TtA׮߾yUf3.i̞3n)1tͿ eSZOV1|GƐk^|s3w^ aTY6hN@l@ӕZ5x.S'4Hg]7Q%t@20WZ?r~^ZDP\nRJ7Ej;~rwUˉDO-Jb8$|^ Ǝ?01-_EQЎQW)r "3P{M(`>TnpdeWʹ'q]Nz.7'Vw.9%}3D_,r_kHƎ4913$1X˫E]SG*JFNxex&G̨ERs6ڙrbS"Q$,]e)[~5h/@٤>??PJ;\a&_jV3Onnz^R U~J:u'[S}9(#ނ^3(耡&4$묓hp?nެmEUzmT%t|$nC??VFz?rM(1B'zVm!x+cvrZvđ=kjԘֶO2Ϊ)?0 $^ڴQGFaWd(: # -#lb?0CPG?0S3.nIښ9crmB{-Wr?? lK B mZ^?nKcdDx㣦7 Ӳtk5`!޴??BթJ[bEWLAJye5SL^4 թ-=RQ܁F6o@iغcɚ!)Bx*|,O]*p@ 鰧.;??(%i¡vYDjĶ@A^Hg%,?n6*L]ch}9pկ~!PϷ(J@1\ކm?0f*L0$N9qf(xFPΪo𦯮^ܚIvB1+F܌U<kBlw {{qͽQ~dT%P؋;i*TSt +0h/L?n!x ?0C[;w;}'wZWcZG3}cGCGGSg??zVf?0+ +?0=+??+11?03202IO/H=i!>OچXFg`692caT.Kt'LQ蛌c!x>3??hV]nnqQ=x+J"Hh9 M#-DjX iJ,Q_7?nw.%+$q Zш{[aLqIlߔT'N5UαAm$??ѨBbd3BtC29\+vU١ S Qt\}fWҞ֩4C ʭ1-˛fK?njO?? lV$3wMXV=;Þa[ $FKޠHMh"T¬T%E0QIj[\?rMحHFTNM8d\X>vQ'",9Ee!KjvU$tP7\捛von+ήwQ}5!IR J?rPTrw P/>/Pw|<%cT f =??\K\y>P.`՜Y1Q??UC ROf6l`:8=sG5GƕCФ4;??sOrf\5G̩6E3cڥn"hV~NO8"QalM>Z$p.ܡ$"L}Dd3WѣC_ KJ*a|.0:\$8R\D7 X o20C3"4^XlRSE Z.(^~g{.fJ`sC_7 >S8!.wZ&5odLܬ mb8S0R.a}y2t|?rCoF{z a;BշٿW"nRq-?n۱G52U8s*'C|a cZ?rhy,Da[c,+(Ѹi-L1'}Yim{7l?red 1+???0 1V&*`_@aL`D4j燆fifYiFhbJ2߽D:%ܽ/I1o.bd˟F7etQdyPi>k?n4ؐ}w#.7/܌AURg'v߮/k˭+%KzϺ~aec&̤n]GbHͤۗN?rxNۧe2Fi];}ԞgrDQ7Wv=~t?r*GQO7}f}77`OKQ|M@RIK6DN}zeC:o N.I0,M???n&4jVQ.YFv$]`GmX4B_$:(P,( oh8 *QdP\&j]XT>4|m"{s8ue~~ڔ urjqU3Nx罖~D8=`Vzk/ )4*OaMie1\cҿ$-9I?0^vqPj\8F|Saƀ!Y b[Z= |à%zP.&3&*1Ԉ??IjR-5yuT??I&3*;A;\=='bX'Uaڙ(UWg' 1[?rLQe,׈gﺊ!['m?0}NQ6#`:Ӷ⅐K")!NrYo%C DOUPaZ^ss.Pf :a!x֋J>RN?re?0pЌ5T)"yb`~;YEA[6K46( PI  &O0SŅP"a\РiiH@+ǐ5?n *̰ٻr)r''p<C5^F'È,- d&&9ٕ-"4|[?n\ aejqD,コsBg?r:[HCHшR2E~^[1(I5jʹ:+7c>mcm}??F=v$,"68ɘd\X!j{~6?r`]o$/$}V{ *aM:ŬZ!_IFܞKb?nn&wJ[v$6da =HmF:XgY@?r6׹OUqbo4Kv k[3 eg̵u?r ?r&f%[njPө+7[mҪdjD}Z{c&\E&`(888*_q$(Ј ]Z~ٸh 4Po??ݳǞPPydC s L*fB`i|EO׬ }3Ypve"}DwRL`]! G{??4|N]~}NDWi݉+km[wQJ>gd]Q[]*.x`0 &;fD?n;Orb_TiCa7aYޒYȈ^E#jmN1S{.$"3Of` f!rۡϿ=":V ;5'v؎*8#]1CȉcYjSYE4}yOƺT[gֲT*@@FV_B2q&:UDU-8B5LVdY̧+!~>nv_(.+{8.|6`?r@A~>?0SR[_?0W- 2aB5{c\{ĩôB)CP5K]8xoh&R嫫s59Uj{}meOk,jvHYQ?rM R1>~~DP~Y .ֈ ,KQWӎgonx;Oou5X %  }Ʒ2*Vo/TodH2s@%XB janO'I1EdBzYw[,Vw+?0|g=6_@>|?0 <Syۜ޷ Y[&;)F=?0Z:l ~Riӽ0\0@(Y!a4oqTD$F$ ]໿nȆ>Sy!DF??c燯tH.0DT,H3@D&/nΖ>Y:gXVDuί N#K"^|CBWna4.?0Oخ gCb(Uf̉pa$#4;j.]DϢx-j%KlC??u n rfg=vN0Oھ\s]Zd6p _??m;T,Ė鴸z)k+2Xb\I9tҿߟ*Wl??heo:a*:aPG>?n۲Ǽ7*m(# HZ(h4[l{면\7Ɯo&ԛHe怡?rĚ4lћrz9ߤnFٗ23Y6 pHh,%pX;?rjy"JO ~Wy/??}0@ =wO"!xƒ]!3@5?r:7R!}?rzO/}N2h oz8myAs^n3T^,f~Or9 2_y(b|q*h5J@iCY*M.k֟0*qߙt㏞'??}ke{mLT,m kס??*92-Ƞh\ɕɴt_׿M:`lݝr T',GS?0Xơm6F;إҏY}@0!TR5Kk_ +ASWΡ"_VHAH?n*"[LS X,-Vݵы%ђ*cuSQ^??&* kGVY.Ơ,ژXq@41xen(f ɐS ]$!D9}O)-??[] 5JR?rތ}}jd #mKC9f=#b ԕmsqM,NEyպ$em%[S ?ron;5M""XXiLjARƄ7W*cU$25K+a"Ŏ$Wүʸœu -# m8Cdŝhjͻ2fƊ:]3Rl$h&#~3͘qdK}9c0]?0WK٧Z9(PΥ1_oW3v 0G@Map\?nNuQ<q&sH$˸]oe9P:5"A~?0\n"j?nE}*]uE0rglq?r?rYW U-nIDe{-3g?0:5vϻ1'\Bggں6AZ5s?rÔήՌ-.>;E.UA ȫ0%]keĝsw40ԩOY%]"|CGx9 ;Cf`cmXÚ?n[ \dev\au{Rq{~}ASʧH/9??<Qc@^f0NM0R@ Ux avY:@apă;DM*8L,+i.q?0k S*.P|N>]2yխ8pඎQS8VNNqՖ?0ڦpLyq'-X(?n#gZE[лTR߾!`_(cHEʖk"MX3!N7}&]V3e^xh%=Ǎ{(Y, d9ڀH#4j~vjXKI ?0I|= f?rMu<0 Dm؍۠i }_/?np"-Y^kfe&G%dD˲x?0VfH&Wj7kfVٯ n??-YbKA5A;, `aKZX|D58 DR)ܜ*??lS!ʅB˺-lUS i=}3c@h=~g5 H#ytSp?n-m JUwM9}g.pF3 "[mpqC#j.[I?0vJԲk|M} x||UVH΁KYCq#QܬP,}}C:]>hE5rtfߕD;࿝φwoGgowɒNSd}CT]ޙ~ekҿ-FmkOZ+ȋ"3vi -hf{tOep"1bEBb|R~zRfmnhn7~Y[ۻ!>AؽP'r@,im,Vu0W9`<)c}A#)lM#V/2V%]zlwʷzl_~Pn6?r/-A?0c]R!)]6EAĂ~~,\Y3;1TG3C_,['h#~irG.\Uh8Bm:ȭZۃu  3KAz(ɭ Y |GE8b9B8M#A_rܩtR,I/şN㱛te_,_<6Vc)}7*iTC?0MezcǃB_yٿHsǡyU@grRT[-(.<p#}UʸYވ~dÆ9bp GuIc *`?0E(pLj!,o 6+R3c&Yܿo܁3c/4Kł '6SZnV[tᛥX8z ,)@Y?n.UmHC4I%l:З.0ީkpnVck$IasBSv `#K$B-қ]z_"ӶGevbܮ7l?nzd.n'|12V ۄk! eV6-(E_y뢕k(BY7 dpU!V?r0ǬkJ1;??3eϴ>A@-'??}͹0R^^XA4u!0GFe??IJ>¨qFl'DL)κPJe??:-#H "` -0d6:wutWNd^V=lBYH>!Ն U]" EYPuK[%LMKL#(/;`'챊kE9PV`d:F&GP6غ cx4,Ѧ!o-"/;|8NVoJ??R%tisQD^ڕLt ?0t:̽lXQR_hHVApu{NkF?0GؿmI i:*eTYHƍ|Z=:ڇXt@s|s`N޴YNS*}#扄nlV?r*Cf~ M*h"mY85uf$myIнC6eYj)==̠:?0Gb?r 5}[Nw:kI78kƭB.O@|}sk„N?r'b^T!A#?rR[ t-$>tvC]yU^@N[4Xz{\RN>ycpeŌ 턳OtcY?n'Ɓl"g حbm?0`ՕWW RdD'KGU?r2kiyU@sAjvN.O]J3?rӸ΁`R?0?n=,?0C!?nPLn{zdY^$/s~JREN5(7ޱI;~a5e\ɢ h?n{/wp>1d_ sr0ت9npZVƔGsOLBƙ€N3ڃa3*=hQz#<+q+u%X%`B&Rrv9psJU܄&{8āͪ;`Zac/b\ \N&vZ7dނVZU~eM2y_N9WH$ -#e?0{zHr> !( K"㟩0XU)ِ^LI! ;,0}[J+2t~3h?nj\T& ;+iu>y$`($} 0#t]bM0$', ٪`?0-n5n:þIPnYf} W©ǝ\[ZIpCD9_4Z,a X,ӃRB8=K??q$4AWdmtz]RѽF&6<*Bw-F?n A ..Xz^=.[=́p4֝1,?r=Cl G1??A2YͲ(˶ɾ8x*cR.3⁝IyD~c[s`gOĽYF@PzP[-?n3NpefaT{]B`AzmYt pu=Í]G(SRb{7d^LjŞnd78RxjGcw&A92|9I$ F+ۈ|U7ONןVV9yF^piZ5?rޣW?r9j?nrÆ ~Ż14ݧPh;t甿QpՋ5/xB*e[1BPb\Q~:jȰ5F?0;C *TǔSf=h.i{۸E,Ȗٵi)?0#qƵXC=gP\%)htYvM[Ͼ.Wweԫ8ܩΨz 4}4 Ӯ# 4F0a|k4 7J$_@VD A/U?nfJ ExϞ*tzDP|q˾Rr?n mAA Z"O)ry7cdzNR =xDb;c9f^Ev_)^(J> =€OAgfi+!!#|GE tN\.) ,-.n k]?rZH<~Q^;D?ns QFg+r+Ra6z/$w?0)Hʜg;j#VK#t_PVAqXU ŝ馽g_1O".SwH+.5a@Ӊg}~t@N#<^ d "]9 3wkPasƝ^GI}MS3)REHr jDA=*,1izõ$[GrNrn&;;Qxꍾ>&ܸ{I5B-tVǂ/LWiF|9a̠L#Gk?n&npkN`sZ~?0j)1ʾm,]ia/^^Pg,Nr (|u٣e!<u%Ҟvw.2 .'~]-~Q^M؎fE>ƩиPtÑklD*RjX_OŲȠ???0O7 $SD 04tchC?0?0H>mr~H7{Pm"uBO]#%'XP~^^T4)wiP c;f%B0H96Q&[mINwnϤ,;-~*gN_]0k(*v0]\Gc?0ḳh,++tqՔG t#=hDm/T%fN5vQD+?0Nљ?07 ?00Ѥy` 5"5(_1իi:& (PFES]}ǐ8zc??c \y,[~u?0wjCNSZ?nфayeM=0:jۖǗM=ƈMS{)>PźYX\ZWaIft瑝 ul^J??-?nJ2[M7kK XU*"m0;}ܚz:)@Ft8Ա^zUSE _=^ ȶĭc%"b-9؇|i"i]`G()7`͆/fG#OU|nl'z<{[qjEL43O k?rffU8W8a]ưtd=k?0??גǯPIJo[X??m'tM7ˈ7~)26+?r$ٜ4Î;VqHo?r(P1/Kbcg‚m72w5BPj!NS.Gwˠi Y+r\U3T_S]DۏKq͋eH2Aajqe/51gD7?0Mgs@|K|*R oa?0Hd2nS[1zUolqs]?0q_\FK⡫!JX)3(EV{.)eGFQBM{??`z!f%~Pƭ*D Naۥ]ݦvIۇXP޵N߯_߄aa?0g?r:CYXC7SR?rtk5*/"o nMc/]~Hu:| Nh u{J0@̼ZbT!YawEY7;^46+ƧD<~NÓB]y"׮\?0/X4#_?n=ջ{it/nW%&[[H\ƨ&ΥV˖47Z$Ιܠ'/8?nd cl3buǺ @h6Щ5̤'Ǭ| Hyzcpl3fd;fٞd??v@"f|`uY1$4_d*??cJI,DF?03!Mceר3,(q=6c/?0dM~ QNF:iO8C@IoͿysy y<c-2`GV_P3hNlgz~_)P?r_@JlhXK97vdDD7E?n$C"ZU+Z!j??y+d s]r:!505VOi`h!"S$k?02z#gs~po\P Ld33eidLIpد6 ?n ]55{@'\H/{t6r;l"NSl3cCߟ#6пId2E@?02{{[VZB$^"Dwf*%:wXg^?r)Ȕ(?rб3%.뙄gq-o/[fРM/'|o1gٕ$bpy̳X*Ң]*VG#Ġ1K?0?r&cg7?0_??=33+=?0==#?????0tLt[ # (EC??]O?r!&!'ɯLIoX??o?n{$^l%NߙߚOXʾmDݚpZN z+V>$MWpǟVѯ-٫1n8vxX򤌐3ZAq3?nUblPE8~oU{ep];k /Wz}>նfCvV/?n[xN4ᑽE>Y* }zm3q: OqmSHp6$ryge%2۸Q0eW{[H:9AMY_jhpbY5<W*r&ߟn';[V7jXAl4`]AοHʪR????՟ aֿJ0韚梨%%AU?rdyNFk@ lqDo6s| +ǂy9Gڶ@blT|ſC<ϴSe7=LCA@?0??ȵkQ5-ezGC HL~-Kn>$^m/8:??ݎ;J-V-ދ,4VNj&.p-?rӁ9Vzu?? ``MӍ6 ,AV, VHM?0B?nA1Fu63-I-W[f:-v$6=1=lGglCܺ7vA?0[@?0}mߥ;E`!h3LSoվ1(Ǿ1*t@忸eo}a䯥֓oZ{r*Ao\gpDε]zQ# Eil,[#Z\u%&:d󬂍'쓨";ڻ8xIѨ[Gm6?0N\-X&U7m+D^e{'[a"2e7] -#rv> zRGCf<$ H)2r.ĸۺo_5#-Ɉq_I9wJmwNm :ƣuUBħs{R|R"fI{ƫ]Qz~P"\Snq?n*n?rP5sO^2xvE.zD4XCjPJBѫtG .0WMH:dw??YqDI]oNiB!颭Pmuu?r9 b7%Yw1|qC)~4%_9$$I")4NDyVa @> َ̩ .N烨cd=Ĺet!?nt[Dg?n?ruη#{g-RLyx8?n,6W|!?0Ed"ձ5Z=jR,5h?0& xc}C .@,yL'WEGOHu./9HJ Ao("7ik?r_Gc?r?r@ G1J8KNp2)c=1~.)ks^g R.??U`WŮ8no/M`V@DYZۯF_G߳R`V;`Vs}g1Q~_xQtk1dV4b5_LT<H|^dCr*KH_Nx>r 5ϣ}C-JܣG\*8YT;ɉRvHփD1yiy]1UB6"_j]Yd^*,d^9,|G^RInSy&K?0!J=C&k@h??" '3[ BX>.86m#RJ?0V_t^??hsz\p'"?noU@,ǸKDhhxl0OU ,qa3ɶ4kw6?rZ{_ڪ|GYrEDN\]!^{QkfEETQ#K< ~Bǿ]Ft$!Y?nw/4l#6[ǽ/T HuʇD1*35=jyC8*fʛ)??.9*w)E)qҡ~Q`b4~/lu.F|w[1 O-D}:\Eή;~%yr 2@T{,7Ǎj1ٌf{h)!_rؔ`aZ/(Q68:~xs F8?ruzǑC%#q_jͩ(8{..|Q$??dF $DxPΖ??x&txQygg|uu\3X݈xW<2.d5PgI/3@ ϋ=рEijeэ&ҋMѠFG0D+3!vG0DO.??# _@dK)pB!KcfBƧT#3H/bX$N_xM"S)*4EA S1O]GğiAF&O] 6gk8gV^c??6+dC1^{NĬ" (_b~*¬˸ql嵚?0t‘f?n9=*e~u ʕR,?nwP7O% %[4Aj*ZKC[5A$0U )+A63úLK׺dgvUh15` 훣8v\ RM]ESѿ҆@j)wԘm%S׵9~1ۛK&*ʩh??DÞ;e"HY??8X?0r7[D~# ??^ܮ??0Ҽ/E~--F$Һdl&ŰârE/?ndZ$?nPk@Y>[Z+J\aٓh!Oߓ%-<UuͻW˭A_??3= K^w<d 8߯ga' bo95u}^_L ؾ0huaW?nEcCd08 ϢCf0:%Ch0: "J ~H|p u<8?0N̐qT?0Bs`)q3dCQJbBAF !]$0WRj@kƁSW?0%7,1%МeP'-SM}pV־!IaU??wPh 57nh?n@;ȇBSnYWtUQK_Iv{5 6AkslNxC۹UR0K3SZJ?0.h غLnޘ{g4X.ؑ9Ëӣu_AX-ˮu$g XόQO?0mY$Qs$ LtuGH*l u{'a9b;?r4OfU"V*LBzu 7A"K++*Ե5!7pY[BS?nSśߊlIo=X=YA27y`C7oiDbkzlhdc?0fSqu+-C/&UZIr-Q)-OVN;vO/xҽopCkB;CumRzͱXK9-T -#NWao v~=)!??5/xx`?riM}7'gS09#rҲϝ|{?nvjOZ0R,=K.Ǭ [VQ uIg>5|̈́竬Wk/wQ10tT:8{K$1U2UlrIbFm-NKv sZ8)StKg~3&Ari 2me&\ZQJ ~W,?0Ѭm9/1VlKӾf :l}x q%?rF9??8soJUA~qĚ_cryGT ^>F:ˎFLrq{pQzRڶgZ!]dhYso\D[z5nL,HHށ$Ys:;Igq>zP?nPT)7ʅYݭ.`(A*?rL;Vϣ\A(D0lY?r-7]isXť̌,L^l?? [U?rSI(oe:QT)l> Rtm8#K΂ 8z$ j&U 9C͟WBW>C'hـwȣZЖTJLKϣk!m7Hs~]?ru9X-C8ZKEm}U6DsTzNOl1fu:6q@X6입*F*NV?nXQ;!:%ol2ـ!|vdDÓpJP 5AN_Yb0qts'?ņSb^{P朋?0ps|6d?rqPBx?n?n/*p%3( xỲQ'ɆwϛC5KK2ɤ9Y4ϐ +P7;c6jLz1??B(lha d,֑O@ixomH??8&,16,w#u?rqOJD3?n*qU׳}eaDoU'AiҶ!u:^('P$& ?nFc9?n˨3 Bkz j&cwM7J{Ʀ\ 1c-F NE :G$ -D*+bEtsUxH !?rF[bnZd*H[o`ueln,_sFGt[OVƓ.KRfs_M*$v}?0lXs\Pv}J m{ġ*bec)N%+I# tԝ)\Y/'U/K,&vAĠ$^3i']w+ˆ|*< A_֫y w3OIUS;zfD+]C*b݅&UܗW챿3'b3*wX8*FH?0Ġ|ݕ[]y`1's2#>_ #2uNgb ³!KJyCg8Ϸ5|6d릅3[y)dE]1jtR3&fG4- ^>s{K:E"b>|6vb0Ԉߐ);ؕ?0UYFG.E{^?0~Jzc&Kk*1wWCVClD=?nhRp 87RÃutcږRkĞ΁ 0oH>:ҵ*f(\0^dfC2w:_1$+F"?0]V寇!TqQˆvcDDC±aH㧙:L#2QlG-YSѫ~ui _L&W٠NoilLaCJ hEëevpwy0|@^/Y/kf$&YMQ`'k}S 8N,s|2l8au=ĭCR ¨\?ro=dLu_W;Ca0]r{~)|PgĪkIT}-1IC݄`FRsmR-BBΖҤ1(\ݻG$.~_Οe:hj7еs^D2{JI/pCu~! טkw/=L̿X㛁 CwR-:u?0< %uƒ"]449Qԋ#~;hgg ;дn/Ȇ\UWw{gDjMv5\%~5>sF??ڸkDL tL??׸Lf?rq>ۀ^0;PwַfM*$.hD-MZ3w %RUx(\z*:n#1w%@[89X$SjlEsvO˜P&?nYɺ_00GY3`HjX7rdJji@ࡷe`\8A>\?0$( 5kJ?r(ي'}&QFIXLչw//),,oRo>????Q Pd) t&ɭ$6b&i-&§=`w=Iㄖ8 d?0;e&?0ĩw/M4TiMȜ֕Scq:-G.;vj(7G [5p,JP0ڋܙ1;k;v=m;C֑jF..[CR-C甋ϖ`f+{va-ܤ֠l 7lfmc+UWCM<^Α#[OBG1hjef&u4~4L;10.9Ҟ)1Aށ>?0ל5:&c/g៞!>173Y>e2mYw?n}{Ư2' 87+K?rAo_,RAFaye[3Λ7d^fYBE߽#PQbCnMb<v+bzJ~FԶVs`v&wn<>< kp,/QɌ9#p??¾TӒ)BY#BڻWQ.i.QW6U>Or^B.kBo.^MVd$>ֿd?rU|=u(I'zMG!S(E/1tM!u< GkA*+%_?0[˿˽cTO4O<[%n!y=i|'RQYyS|jVzqhlMK(dgd^Db˒.8}\2Td(誝bˤ>P&KO]U_>z$Oc^~I??|9|~(s>|6.S+s2eڊXbNeh=p&P,8&hO( ȸQRq<>Uv|Jo:ޚe mcjF`{媌gsCʄ휋'ʴgP͗m 73%foe:nGeEֳ}o5ϻ2q7:;l5]on{lwk/c.ʾGy?nmwqss 7lVb̎ ]S?riL S/gow$ؤT9ҧJ9YTb>b%mKMZ&\;)MgE\:N finfχO:mwκ3?n}@A#`לRjŕQprO??v|png'喝eAekq o3\Ě6xa)gR%.ljI_6E{M6pR?0:[cۧwed**inJ%$P,Wǽw)gofBE7>.:(?n^{?n1)2->ajiaLಧ"ssrj+S1[r*{'>78<æO4O(y^ޞ7D0t;UhVB CH;P?nBVW*h{Om>c%ş=k&g6h[5bp~??UgJ9 ͕`z'OJ2:pc>6e[*??TA>WfNlnyA_Ke7_5=cKv7w vTUڃBװ.jn8fno64+7~> A5ƈ| w>QU!DY8Tt=0f=ekKP;mk0R .uyS^JOyiAV"fSkŽpf,!R3yt<7hWT1=;~Bx{ Rd,A\ijU8 PӁX9NϘiryE¬ttF7k<<"GZ zEߔbNO,h~ ۉo?r+ĜBrAĦiJj?r-2bo[#??d6U2^mnt5i>jC0fV߮{oOʆU>eebC5$7#Y:6qնcj $PuZ璯 =о[ ү(]P&OX{7*ԾZKH{CvcDKhIJߚ.A$\X?rZ6N@5Z%jO?nRkD׺ߖЗ2+9>6#[ʒ }PY7A3ÞvvXJs6MشaSBJe?ra<=bl C@6;B}t7_j@|uh=RF+V܅; ނ,&)_a wziε/~c "ݩɜ~*_K\(,;dЧ^Ė+ER[>渽ib;g]R=2L¯z-R)tS𶶿u޾NR^fkc|u?rZ^{Pv?r/0f8:p٥w[T&cc9 Pa1Pu*||lN?0/;|/+cْ|ဝ{0+iC!ﵵ|9_gE!x??|p#*}xkzyL'ɬӖaZs*-c#HF &SKc?nV#J3Hf=b%3+oJgqf?rS'B֧F{1Zoiq~}XlmA$sdb:籬7-bjt5y!?0;p??8LOĀ??PXd|H ԙHPlja|cYOv.M$:[e#-l[XK8bvNBڸ@G]ShȮ Ay{;#_f*^)E(]tHV;Q'sJ]c[?0ʟɔeLeDY—!y)> f}?r1}^fL?nUN+:ޮ=AZi<*1،%8vuK{:Kmpx=kDqɽ\-dVT_-pxz'YIrq֤ n|5x:_??^ k??!=)h*N7}+ػ2.b=%8i?n5˲MȆ2S7bZfI?0XJdR͆5A[??FB2~ ?nnӱ?r܃x_JՆȢiZӄ=B$J -#a|IgyIfmQem3(-M2휫؛v 2qqlQUT]nΩxV!fyHg?? {e/b↙ z%6%`UcwmںB3e(&B/T)eVx'9$5i_8wu*cP`@.omL`N(qI4^Cghgq7 P YR6U_>4imvK0;4)*쟖?n*1T^6fc\`Ǵ~+L#ݗLb6C)?nfq?r*gQcXj4 ۪R,^Rʾ1ؐלUg9{xĻ5zyČWOhh:yȳqhOMk$ˆT0 {J![Yp)9Ie??sH{RJJ%-ƄAJ?r1N\9ltf~tCy Zz˰R0v50`f%jPVC@203 I9}^ ?? ÖYYi9?rm 4A;2H|' ljjj"=ֈ=%''X9p90 Dw\xVr_'=Bf6F]#ȉ~!&?n&(:mȂ)#eG , o&Rqc:x´㮣?r=!iݭBS?0=À1$!O:{EHvWWA&^gGexlz0 e/S5Z??~׮{vB^]f.ˢ-9i]>к1⵫mvh6N%д.~$?n8v\p[^+M{oT/ؚ,%[ŻV>iL5K}hsUu7lgtvBQ?nhDIdf5?nbR%C1EgtW´KpB%qGwFC)~2fs@$IoI1t5F6 E;q:P$as7e2?rC߸@Dʯ|^"gcKȎk}uyzzz)E?0517$ѡkc޲1m:V,2?r[3Y,mBHOB47QwrT642&%ZI??s6ۀJo?rCԁ% 2%F:zFdSgepZ'yܚ[?r[=A{Χ @1p<prȷOg@3]$?0i(-ܥWޮb)IT|%V@NTp8)wkŸpx>??A 1/AO^:I(j"%Œxd_wk\E@ ,pwY@k?0TFCQ gTab.f%ڛy?nIȦXn P/Dz$>GII(UbރU4big }HC!94Z˟V9c"%Cy5L aͦ-4#!~,I 1:0?r@WK8}I5R'3Sd}??qLv.GpYrK[ge}ʯ8/dN6(`3_SuldIuKamڧ3?04&+?0ѝ9ȺN;i KjO"/=0ۂăꍌ1`?n.y2JYߑDEP\wPcti#wZ>?0BB^6(6'?r}%]!jKc-Y,@ck_G]b:ZP'i@#qb558(5UKicۖsCKˆ { Ԥ9}5eS'nzjJZlA??U?r\wB3?0?0"&$@ř69]l.nrO OSR\?n5L1B|X9E*ZJ;ߩOIєA(xh|f韖堔h[dMyV%'-|T?r0]g['e^@)gFcՄJOԊQ⾹\L:<6Q*W֋wirSTR2n?0Zh-<{ f lF22(&?0lwЖԧD3232` -WѐUM1d̼\-5G6):tV֊|V?r~RtKm$dX@5֮Xn92'?0@ u3kR l;0n0ڒ! ' .WʚJ?nÐ9bʔ` :L.emK8Lc0Ohch??h" f't9:UMnugz|/X>`ض[0b4 9Bh*;^1Z{!T9qոܧ욟!Nf7\QÔv˝?0BO6A2Jn,R~U5' :REq9韰'$\>j?nXsus8Ǘ)g T|HbSI<-ZS@﹜A??oMOdt:fve;;o$3ӳ{73 = F??j_0w,xPP ??$?n~Ȅ?rZz%eynָK`p+nj LaBW?n ~ܲ#o7˾:nGtb׸mb%OowH6*baԪiq+w(nP/N]+g\L|G4&?0b#{.t˼5l8?0H?0iÏ}.Zet"#nKP@[ȜEiFZ0+a?rO#U܃ņVF !]#ؠ?rlD:;D+J&__q;𨯻w>yWoUGHWN?nf<;aq:&֛ÈpsRՉD xnNE@X"H̵:dX8<_1 x:Tͱ")7$xݔ*^dv94'(KK??BIưE|V .;XBs! }~?0pn'JeAj1  a8A!K "bN;b0zb̢YehhڍZ̬8?nF˾qm*t~?rwNrj5 {P`3I?n}Xp?0CGS\i)#-㉴4. $ ZՋTjBLqxWz˸qWJ?rwh1=oaY3݋roo,cn)Ow3f+&C :uw%%7SAwm>bNDž_YhyI M9tԙN?n,a{fKV[yOsFJB7#C6??>']11.DD4# E:+[3?ruƢc5n&_bv7<=/\Nroy4R+hv%pϮ0?n"-z-)spSs-ZL, ۬P~~enX4DR)7q;RM4˿E)|qσyR;񱛞,aPF-$=-<aSA{Yn??!H(ڠlt?0HVS['(54Uw0P[]p?0pC_xkJ?r"AirE1˝nϵrk]J'8娣*4$|HgTyk'*Ʌf}Pazbyzy1P86G4©1x 68]?rl?0^@Brei0Yļi=zӮ}M)q*`oe6rd-̂o23Yb+C':NyƱUNR>+eCSq sD1Ug`RnE?nJ@JX_DV,~z%S06f#po2b?n~JɉzOpQTǃb]+9TXJjvU*.Y6G2P*""xL??6kF5zP)gDk?n?00}3#GJ?r}#ϣM)ߧ؁?n@,(;|pF0a\d#Ⳕ|\?0shɈ|pl+(f-Cúm-Ч9 L)%sSX/eK}r(v9@w'sBkq})\v9% 3{sv]L??\V),(vSYZ׿79Ns|ЄGoEx!ivJn.P瓏3b^g<>Kߍ};#CXL}ڲvp/C2QOe{0WZ@T?n.@%/ #2 ????οpu=hC awo6kBfFðOE4|n'l?01K_aR04ʥ,ew?0~J?nSs?0͸{(P@˶IEc%K2y[ԍxs+UіDT FLW}wI);z; xøEn ߆??i?rpޚ䔞ڭ!z*㇫iFԦ||ЖPY j#BISM|J|XR?0C8`8PJ۸)lS-H:K>CBLY?0߬Yx校H=Mq\$!k.޲>OR7ٸMހ@ߝ52sf;bl{j%E@p~ ` pkXd[[wrAb,ʾ?0E$7GJ:.T>GҍO$5#r^!`lr??!#>> -#fX?rVE?0ͰQZ![5 ]sA`6-%XƃVw`9\jmeԏ\?0+;_x#1Xv`J?r()#(c"ȦRY7=<{o/Ooztѻ?rC5 m=HAqp3[JO?n6zWd?r6ryW(809s?rOP a,@S{~~b;jU {%]g|㛥zߟ}?neGo.ɑ7R({%TX+`pT4m058{Vhz.{nm-z^:##LdoO]\d8d+*!^Px afK]iӌҐčTU(HN.Hm tzFE_NOY#iΔѥEbۉkūWj:Ҵkkަu9^]?0%n$8rPlRp GSlxc`&U2icP [V?0]??m Q&JXUroh5Mm Mn$R.L@a jMֿwE2$,2@B::2I')}y!PjB:`}47+cLU4kd;#H[eà./0贿'6HT(nZąڲ88:eBWhcr"*pn[oՏ({w+m μe5-f+7l٪EI1LX{YΨԧA6e.`60Z_"}:'%(M!a>Q ?0x su_νv0$-w*tV<zl=:hנ`(h[&#y%즤s=VYީOp(l?nӭ[^u%!sK H?0ThY f~vS+%'ߤ{NaC,d_Lc'7П_(|&UFc+CFvUZ.=4`b-T>VroyhjG ?nR5W0F ]3e ̙^m- /f vƞBú$Ր{!8d@fxZzCٞY?03TOOy<>^/8Fd??E׀;tDяx񵷋L@Jb ! 㪔 2jtSc+9xkά\:1*E?0r}Gmʉ Y)2@ˏ.=ǥis9sa ?n_JF)}&bRY~#|N9G`vOp|녃c{z_z?r?0?rMBu@ݙwsae_PռB ˼sP>1O.ziryַ7 7WS.gwW??cOG?nZ;\vͿQW- ~OOcG=z.9ti?0n8B֯:؇R-dn=$}/AGgǣCH3`(=frLV_-.[?r.񮟖V^#4z{̜?rOtlFk\Yns+̵n07tus)-w8͎ >v'Gz?n1F9:uq5gz]\xfctu]gƲ'25n,BC dJEy9m*T<6gPCS h-dO?0nM>w(qV;[Pg|9e?00SGV-QAsG^~C{Oyrn+Ce-[`^bf(gvv?r)wѴU쯝h8)P%:=2NJPF+aRטc??'HqMQ:Q_1P%eӘ$@ ݀09~v ㎀D:ћUp[pG;\NL 'awjB8CPjwzd\Z[7zDܷ`k13kڰ6??7ɖ_e'RiڔV@ƚw): :up0GFlӮfKxZO63dSKL QPIx?0dԀ)n)Ǧͯ?0ف>kEIXYKx;g6|KS$3kϠ?r~]6?nM˨'ޒ;}AXq'V'1SӶcw Ƿ`ڔ?0C>Ih<ڧ1] yd ayzq;4g+ns{?nfRW p̢iCt4X ?n4_a9ӰR4"Nq]8 3v)T݃S,v)hUIskmInD6ӹ0tҟ-x- %Dݼ 2>BêEcpdl (o%͵?0O(s>{G]V^8ZL[ڐAt V $K'+ftMQlg.O_/_17D_Y_ s_F{0<]ӎ/)u$3;:jJ՗hwѠy5ݣ՟òϕK3 0V=O_^SǪY^z-F7].,P-TY[Fl εZ?nBsSP1ZWKvWګ#??"r C?0?0cX+W# ʃ6Ѓ|t>nQbV$ZW܋=l8*ʀTD( Gn9mk'QB?0DGU_cnjktロVYu@f73R-dXf;DWɅ~ g9\K#L?rF>-%i2udks.*04!qև/epPA?04.$>t )e~#%И b$NPM+45VӤb!)j7tv%Eh/M+ۭ.t$2Cm -)$bs:^/:=-fbdZ"JRz~R$\ d\jbj ZJu)J~sѹ' CjafWPC*$,6"3CfF;R+iZ0YHGOщc1cY t~!iXvq2bKc8]Q?nI6Yn9~c%[l;c%ٟ, I T͵ىx?0J?n!ze(6nlqs l/2mEYNh /E5q`AZԋ2.X5`.#Ifim)u6:V'lC6秙.Af'eI_l){$]%'1Ci?rQw|NXuMP\$?0 Jշ`iP f?0Zhz$SH7]ү9,gf?nvZ}妦3^WȈ+/U7gkSed_m"@3Ћ嚧6&~Q#j(,(Iv2eEGL?n5[ԍ S%4 UOȡ??ӊGP22\hQ'h,ћ3GF BXYV$ͮW*#8g{:b4:Y%g[y8&tbOMȴMWC>^|3J>z5*7Z,h Y/z_Tsz_y{ODpT25u_U.11k͘'=W^eIgsHJ;^hm98)0 =V^"(gxZNϱ>KgJbUa6b]:~O^tFLb|]]5[ =?rl6b4z%likBE[ȶl[at _7_ݤ{`?nwGh\֩>NRK/cV@3 7݅{F'WkKQH-_4'@Sfe9xm!E{r=d+ۤ`+B}v)iu>SnBGG/c_D>Yd!eǀ;yȤXm#ٞP-ʆ$eH[@8;1#'*-:u<{_aw6r 0?r]ps\>`#ɟni[IGkʺVg?r׺J֊zAO[-|pA!KqHǜ=%T(j;/^D0}^}`9R gzTaB-audp]<&.h)MtwNF"jGkf LOeAZ۠@0'u?nW7 r 3JFILiT:}ܝ?n??F%Ѧ-@BW,mZU~'4Ma5Q tu6poI2K?n#bRg_vq32ruZo{_`G8]uۋhWo"2}i/ӂC[=V""JūOь0I" !"?r2nS +9)A\NS@$Ki'sځ}DOȲىl>?rU =qKjC3ti)=)fu[_jcIayl$?0\XS#&o^C<ۯQ]WSL5*s6j?n,-T;ۇ$BpQ$bQ֭?n(ˣGkc{Ɔ@?0L7pyń;ٚ`BeŸ(^('gQ \0JIc@ ϟ)ʊO6viWºpٸ;m .8oԥL:Ԍvu5V9?0<]1s0Ⱥ}Crl(isȐjڟ˝qvZ6d=Y61|d EcS.ɰښ=Yȝ5z\^ NNҼA(Xd0 ?0weLZcR -# $ER>tO吵 -'cNjdtF=V_0=6ME;X{???rcloK~j?0\W*5!-jNyֽcFL~+?r<TWa]#ʈ6_ц29 T S3:lMe҅COT)A@)!p.gm/R#bHU'M&+&,WR|&2ċk*J^ȹEڀ(2)&s@#"L8QDZ ˧ #.$v@%]p{wl .j7?rgҞ$ 2X}@IF?niZWtE#i&T㦢b[j:ygASi3dX٩,Ay j>\VH{:eσBah9XRg,HP^H8{"r?0{^WnB<M NHé|C+ECSk^coUfET^N~!8gPH3 ډ-;B{ .y҈P{ c+ ܈ҿ&XkGpv&>Wdf3 +yGá皩7k@ps>Cן_&.9O3:}^P-1q Clzj둻bTfwfH7eA6K;- V`swEX~p2rɯ!o3q3$<h)Ow]Dl7BV@H^_>E=O`iBewh4G 6 >CO[)K2",&3{&  Ȩ(#:pje`"ty@$ L(????tKs+!6s@BJv|OdLdjm,;ddu+)EC$iPHWG6*MK`*8o&Qw0+a} Z]P=:?rl~3ٕ)QWCU ]aGڪ/-/&pQG'ݩgm_Ê~g^dF1`/??D n6;bۦtƺ+S)u)ƺ=5|(@|@fYS\3`Bn:,a׾Yhe)Lt9G#BdӝKduBReK~ ?nO8l=wRy;pugL骢3"#ER5Vd-u&IAarA %Am%?02Z6}! ڌ15ay9DB{=?r|ዄO??,Ao]YsrX"trJZC#r}D-X|O>%dڶ[qbȠC#CMU$G??2)l&oN?0v=z{opӟC3StkղT>bA#٪!r =1Z'M?rB!E$!5)립AK9EV`}徳=;Ow*pYʞ %(h[鶼K?0MtY72VjFU[ӿ2fϢ?n?r8WcG?n'di^U~qiKO<*2Eu1D\2;g=L//u|A_N/3;%WܺY=e=4|Zlak%͐]Tn(M'M+{ Sh1Axȗh'3s3\<LaA?r셧787,4H,X{J+!AZf{=NnA|~xr?nO֕pɠ]*$oRd6 bAn[Hgi~n&KmpX(Lj2,Pf@Q)m \??кt2)8ÎAvh(HZJ$CTQ0(*,^?n]>_ ?0 ]5YYBjY[?0M8F⸆S%7ݭ??Z4?row@V?ny#&_z}D5qz|,Nž 5+ˇWݢw%UF]|~ix1~;O1[Fy?nzlSX.w/NNTE _'K:x;K1 .Q8ͼӹ:@Gz,&<.$By)`?rq.t["Iz>ÄF*F?0Bʤ/a`WDo 3;(Ji GT*4[W[3)J~M5V]-2e}ϏA@EQI(fM(F?n=zZND55G֑>"(_ڣ?nBYO jmKJ}"qNlBmJfp4g1g%M <u;?0ЋIh^a"?0AD.d)ؘsbEDvpz5U!A#1J\3&eq/%^[QG9b {ak'ͽ^{zךP ?rN8ΡZ#3gd;qCz1uHoS@R%Y3Eef!M[D=@,(  ,a 2aah$ԷKry~,:>CEGFjj)x{yOp @SSQ_q<\c[Bh8PavZS#gydrn|0Ny8q{NȥSGKg_bs C?nRA D??CKf oC-%q XO1Hu@0ڕgnZֳ9$W|\<Š]3 qRDvLlFB~HZ}xO?r6~m;$!P)mOY`z?rnh135WQ~gR;A`zzC|u?0nEX'ҾɔUng׺"?nɥἤ ??ؑbd:g4|^u&-BQ"&?0qW?n*2@ֆYRM32=HD h|sl\U$c"+zo'{&yPv~N/12oˎrm `%n3W16"M¹y??b>HY2=ݮL`)cΖ͉4N|"<61\Q#E[$NY1GN1"qAplMgVq_d`gA,۱>VnI;K]nqM7ezC.L 8.xRhF>A MYȬ~?0}E..y^~@t?rL]_#Y)@.<5JʇMIh3?0=(S>I{[!D_7꫃YCm?rD@mœW??ڎ}h6MJR: O/%^тV+W &XX].P0QgG2;& ]B??FP)RM@0*{"sVV'J9Vx'@|~_ LW#5LYFM!ES9ŁK"*O˫(_Ѧsb ӤUwÆcVá%ڤy8Gh:1z??XBc´P[Bl#V8+r_T}8PzWjFAfaJv;ovҕp??|:olF:}Mj˛u,'_發+9%´q}yegr&,0r0"c ⶥwlM>mh?nKOůHՈmXw7]ftdqAz,I?nƋ4bUΛcutʈAbmLrh K.O]yR4E]u^{X7zU'VvGsszf*Q6fq.so#ZeyO$aU2A6u/>ZnmONe>3a6-͵Ne.W+{4Gj!Q!6k>U輵Х^6Ț~~0ی3,A+[a=z;UB~ev.o|ousBO?nGc,Gw'շkkd[o9~ FeILذj/f)/ynYzgKcRyWL&sO["e`h2 D?0l*dgϖ,=v=!@wznF%3w]LdMF*߻QB"SsOKZ9lF#C ي˄;=Q-B 5v}UEGMn׍x?r;@i j&բU~ S)8z(æ]xkӲ3]EӼbe7iux`wu}f4{U.Ny#Qo+ϯG%udɨD7I`[˧|`ߵ=_ORRYtYR\s͔ޛhjƪ&`?0!+Irp ʢđa!M~?0e"7-ws&OwiDҸH;ݲx˭B&Xmi?n龜X;[=cp:H1m|vEl<=M,drmAVNCE8ԇ;r4x"J>(h2xex9n?0 Md?0SL{o8 |L)S3M{Nm[N/.Agݤ ae`~VUٕqsqDg֭u[e);thY2M?ng]Sr0- 83ղ2I+^Dׁ] -k8k+`wZ@A4Dyѽz㵊۠iuM)!K&"%re??f&#,wcBYMҎީ3+V$0pnt;vdQh(=ی*t(j#\ DHUMkQBt]GʉM?nLUL7(cplH .JF&8PVe Kf@wAsmstInD?n9)SQ*w -/]/zM\y\gF|k<ƗbavVXr_%]Ryg7_.̓tꌫ3U3e?n7@Nb6r$tpl%&S_H٥|}jQIPw]vB/+ L>Y뇥g#ke@E??4a:or?09>??ڱ%. riͰ;H[Oۗ?rbQnr#}?r.ӎ"DqXvjZ6gvbJ|P_[L' a:mGVKSzAݸbtjGZ'?n/0#j8-oxvB@C,hn;2ޘE??֏x:kDD%\^e]R>NI,?n{cȬݵ)4X O{?n4#`)^ˍMV܁KeU=^OSsGpֹ'g%k3nZ߷cnW|}ƓxykdUfpİӡvQrK,#W^/]`9YBf~ѲPjH3W!Pe[JszBb-Wc{Q!O"K;)IR\&(0:l67,[=y\KPTzUCdoNnSa a{$aq`\oG+EiVWUwQ*U4ѫrxg G ϽiA3L#Da^QGt??P6/ľi|7cJlNp'Dy ugH-RY`+8_AGr7uǮ3Q:7sx(>`\$]}٥?r⽲ι"1euU9wx=3Z)m`0' 쁯N05/M܈+Ry3$ ھ@$5{ ZM=?0{>J7AOMX$$ qU/Ք!JmQԣǴ@/l/srvvn D!?r@M_GQU/ ??lsOЅQ&*HK//͢;- '_&6^~ g5݉ŧ,w??G0u:O?0o$gvNF mUӹfW@Roa2bP?ni3HMY8#`d! x]dk=?rs;7Y&\ LȬK[[a9GRv4g~^=ӕ(= .,;]6]??̒Aso߻B^tyQׄZRmK;OjvBm,\*{ÍOV7?0vKaJ%wUcG'k:.0rW??E)znnLc#Ȋ[fkAWMܶ@=\w oUIk&s<M-q/X??njn/߷i]SǽprL 0t&[ԅ"L2g(*1G $hQlpro??/QH-A92\DCSL1 Ht\h<72Lһ/FCSDUl;o2v#)z\@0ׯ֎Krʝ*'l//i&ڎ'_-im{MXN3')ɞQsTyнuعGp[d7rI+ę͉O0NǙW('ꚥ|7B^>;] MŘ"WvUҥn߀$ċ 7rvX܂+ :zA t u|ro=Ԁ >??K?0F񓁌M/FxH7S-]%JPt1EOlk7S5.vLC6?nd +gg-zON HL~Oe|dHjj0hFt):Jv|7litdԒf^i-L>ɳoSsu{?r4%ȉ[.iZwbVnrF\{J:;h6 LY_ /tIVhٝЭAFUQ0HqscBFhV[CV0{v2'knhg_?r:*j,glp"B0€/5L_F.=aV"-|1+вIrTܱYBn?ryW.]j!B)^'vhjW%%UGq;FHf#?rߥtSΆ;at?r)GQvL?rA^U&|\ >Ҋy7)6~&.]&WB??"DhTKv6H{ꌄqFLO9k$"(ך"EHrY"  >=WAGZT t -#U=(tVjFf;u0lfҎ[a)q39?0,~m۔)9?01?n $1e?0p $/_\̺?rNsԋȑ+fG[)O98&b?0eZVNr2| 'EC"?n'遳iZ$Ѡ$I$^6Q9Xۢ5|L}se=8jS`𾘾p6'{581gFAh0] 2\")իH??鉶eq!MB'x<T#N%03<9_ɥxL Lr_Ǽ->!*b>hkUuUy< 5Vӎ.ƙU==Ҍ?? f 2+uWЂZ2dgFe.!< ذ vWz+M1J,W9<AW('T%FXyX>yy8|CQY!Ӌ??`Ӆ!idPZҦ=M)T!7fBc:f줈p)=euF[ְ?n#?0Z*.~Gu~;|kg">/ g#w4!}pBSgH*_`!ʦP <ӔN-[ѢY / >ŧI{mP??^(kI1?0tC=ɴL_BQ??H[7)Ln%QP^4^ 8e/QZgaF2_ֽOU14#hGm%/ǭJSݡx`LT9CG2?r?rЦP^"=[RlD=3:%ێ)QS??Mi(;J4KN 䨋A5@<.}ʁ,Mx_k 67=??յN?0G|?0kE}P>dXƺTkF tЊ8CVdsb4B:S;:q??A0_ނ, ;ׂva''̝[?0amdLcot #7*/0/,m!;'G5' f}zHRGS)*wYV]ko1a̺g6>:;R901tܡS g*gr@lMCMF̙"Yz+v"(??옡V-! aQ1Yܡ6v-Њ~ڊ(X2u(xwo|DYxz}dƹyuoǎ|WaŠ#qNe 5E3E|CRc땑D>fu>0fQUFޛ2[ o#fd#6ڮ^|)VKß$F%P!I'@Es?0#^y aaz:#$)0*,TIU#XfУ^4.k{u9UL,jߏ$z?r ?rbqdأ)yK#{APnKh΀B_3f,dtL+ƿ<}^}8HUO$X)l4 GYutO߮)a0ś+愘vW \y:?r;$1lX%,t}6B$k-S[F`{P&v-#jU,lllj]N?0n2\6ܨ ְxit|+?n0wВ7mJ|[>KJL[.Mv)<_hN]_z8tPȣF*J|q@Rۦ!o?08G@^(]qw|_[-Ȼ?0?nehr;*a$Q:嶛L=Yn[ԷV3OU.1\$.iVMyYȸl4Jvh5LHΏe?n6@>}oc_{nK,fW\%AÙ9?0Gb>~1ݨ1ݼMU=Q?n OY d.iH&+(}sCuVZus}?0I^??J48Mr|y UTQȞ#]??}Vkv7z9j)Qgc}E)fW=fzoV{??FF҉4`wF},m[,{x=Ğ_"&#f˦T0=U0iy4҂_ ԠTTCh<' b p'7k*kR-2(ITitSPɁu\F+::ùcX¾qQ@Y/(ږYpQwyI5hQ`Z.ߋUuvX ~$ޥ:ohnO갳_J7U7^B~&')S??>i:OIca $?n/V'>+,zcqgp?r]̮גt_ZM-+W${a `a%g`lê5,baKJhm[ȈzYJ#\D1"Q֗/̉nJ2| -#¹r"@?nz:DȄZ'(,^ߟ+$Bbv[`͵-> fΈm^=nyN U;?r5{>PZ`a]~j??Oʀ͞>2l#i<| nּ͢IoɋA٧-?rͅ}URw^,|2,tڃ%kGљ{rSzJҟ ~}RQƹ_0!rA3?n&}d?0eiK_$aej!nL90b<mp(` ۱J??{?nB yt$H>)+~I^\z{ErkS-|UTk¿Nd&?rVfH_WU\k_2,HuF0:ItV7߉<{+Gw}L.Ad' gr^qXr&a ~(?nl`il_f&c``de`gWgV>O??fR2udtaX<~JhJCzы`s?0iB,??=;;Nt(YM,5sknV,9#++++;vg׼`t>zYk案S%V‡ygCM]+A)M@+lXUMAAi9P*1/x{؈rK0{q"mB?r`X38rS3b?rEͲ@-(PH??_灻uWfBBX5N9f+USFMc꽌AJ[ޔ4?r&&kݐ=bUMfC*v`!u/' r`I%r?r??.o2&xeeLueP1>𭮮3Iٺx]Lrz=tX-/wNi 9e&`@KA_f?rm 藑%sv__8*P۝ѝ̿F8":Yr{t96??ǙWK^QZ^!xJINg?0BrKu껋 '}*E\]'#/&Plg??w)yW>s E06RNp$A<ʝk+':??7?0:DJQ]Vy{݉|]sA#1vl H}@x`x=N/EIJ·96RJs*B)*49"o'֊C5c?n8-7*$E5B9hw& DJuCsCne9v"jB3B(L-k./ZHmsWX/0vj7=Pyݝχ9x5'5–;\]C^Y6\u&R31f?0DC378?0{e^NBP9/vBD+*:Q|G0cc%œ%gN 4n)9%ֲFR,$%ףH춿vl4XΎL5&C$I8)ULdBzej(WZ6e=2jP?nX?0oTKyWOⷦγl?n7stÆɴ@n9Zf[ {j'iEa kk6/›*pJ#D1drigMP/AWINiW ҮDҼ[h֣GJp?rlʽ*ٽt9e_y4]ANhu@#g]L!0oMQʑB-Ho)5vթI?n$D%Yfr #"RLҹ^C/??*+Sxw:4!6 $0oejpi??cV, ~?rh{DӨ9h>\R&.+.K5NVݱQ??eOT4r5ǾRYDžYy"{}b޶ikz]L͘λΚq%`,U_a_(iȦ5]c??2:rndNݼeE*rLX/ aBD$u?rpʿe4 M*gM^(m#9a#7k{G6aHfiLԹca)uRDcٵ!^FUGzp v[ԗW8I^d2ōO[ Y N<nﱹȠygH%b*Iq(#+N~R,-R["oi\FO8ou(v/GS,$Ԟ'e 18ѐq%i?n;.%lgyP݈xa,c4d)%@KNЫtt83kja?0ɾ)\WX<q]?0R^{O-6^?nKu\{Md>>>nQ|TPM)/I{\%%%?nnpg)-nܪn_y_!n"Aҋ#`s+Ye[ ٍK+(r!a0BRJtΫOF TyTh1 ɂNR'Dw$y_u)-??+jުp`4קDO0qOuY?r ZR4˯ǝ<֭Fpokab6Lӹm>ʯɑ7€C=\`Z85.3^ 4)Vӯ :t\2zB qӴ>#ګ^:h=l9$kv lq{}f@0~8(q)2r,.+n;DoQhbIZlՌUed]5Ym8VpU9QP9"'ퟶ_ĵXk`(s2x7}z8g]Uy?ra tz}UpzQmp.V !|Ԡ,np@BZ??Htk9(2=m}įTPYv&#t3iq'4Vjꄖ.Qx1d;mw,d_]=m1oyp&aa: _fYY2I+cpjVrޮ.!ps1j\O,ȍnKϤ|C>-ygOV$vWT^#ȴ?0i%l E5ɳ95rd:6zݠx߾V4Z׻،?nc w ӊ6 V,iؓ&% 8uN329P\`rY;jd?0Z Q[{-)r vHO{8??1SaP ̎JSU52_2kN4%QHia~:*e+Nw\dn^e:-{bJ[7ăLX?0u6KB+WdcjKeޭ?rt#7%9c?nZ4Y7kG`X Vϭ0dS?0RRŸr6PYdRgʡL0-L¿ &?nXeEn2t`Iqi@5z:É0h6ζwQSrmھeW blZ׃)W/Ub*"t*u 8LGR>V9vYZ3>??6%r2sƀ!w"E[ޖRgG܏uX~HzeXݏ:tɚu+Z( -# cJ>8٨%]~Q+QKƘe?nJOnm4??NZ[ Xۊ\S2V:ۀ-EEd?n_$'/لN㤍^?rZF}A"v8vVy:iJ[9Dk "iH #XgIء1z~:y2srW?n -iONb6ЀGl@̧Ld?rLلA_0>:ܶ^ek[s XBgB75̛CbnȫVqX],~Ʊ#J' m!v+RTFM?n` V$~a8l=< 3UiɩExV%"L??ZpaZmN:% aV8*%,2mFldCQ]uQMIڷ&(୘8 0MM?n-gy% ؂@`Xtܠz۔Ue6VeglTa0?nzQĦ/?0c~֮K1unrMy_bU?ra_Mq׬`I?0]0=xW'!wn?n ձ7V߿;ij}Q8Zf~4͋@1_t轢-A`4ÂAuSȼb(n?0͋O [im|?r5f?rfs Nj9ރ?n0( p3Rz3O8|Z "?nUc(ͤ ۝}DHذ2}٬?0xwWPOqo ϔsd[ڣ޷tjG{܃1 DVmDև-i#~fRn1.uAlti]ZJ%MYn@ v'f!,mx\[@ :KN z?08&Ǎ+4zJ%eJM+=˗|rFDقJhN+>{?nk]A (*{QR_hXHPHCE}X<>k$:#/AҰ(ntdFjeU_8o*?r8ۭ2sVmAA$)|guEHߴ)(M8:SfDBMSh}}=Ą:2&W]Vz݁ Ԕ!{˟`>x!9݄r.Sh~F*C oA^0b,\і0,:D lAXPV{JWBBzT_N:1dR'Ҹ(2F9n; nK ?rS9veebUfL!7FDHS er=#(7EgRN.O^;,7åZNݰmGB?rSsr(??g`[K:Z% ŭ7pA$??E??ZN0”5uGw}oT.f u.A4 s=Y.(wih•;0g=t^r)?r .zFS縚a:MbZzBF'3۸|s°!;?r؁spe!`ڽ%b?n P7ܺս'Sp'~쏞{fq??ce0(1'?n4=QQ#Dd:r*$2s}% a<ZrOJDqT,dڲ;0j!P`O]Ѝ7 e??=HE9-ܿkc@TWOuJ.Gb"]l&E{CVH≯RK]XJB5LړcYskp'm0&D>u?05TT\O |+ ?0?0x4P-?0ƶm۶m۶m۶m۶yomjNvVέ B@̴RmM]g@l9Y.hq<1S??EdO UlZgD Z'mx↲/wm@toQ/7ZPr'c0m8`DZ:Clpk$6oH-1v`厺όf4 ]ЪG(B:Cd7IctDĉ8= 6ʍДzYo>\@vD6c9-dD'ÿy(#c~l,B́QPe*1??&Qge)r~4#/iA!ӋݱD4&!6ǞdowJ~?rW*h!wfϡ-ŹuYyO>8?n=q?0Ѭw\hxDl8YN{`V˾U8N뿦Pr ;aD: 8?n?r`]dtЊX3bYUH~l??mUlp=R^˱̈??N}x2%?r2:5$f[ߣ8n+?r8\DfzǦGl}uEdޫUk/;FQ!KOG~p#,UoNW|V9"9}:^Φj.^?rk";"/q9nag옮q{:7zl2\#??"z+4VUv]5"WpOid;]?0H3N8.SQ  Db[)|D"lι\}.9sc&TطIB]D>G+ޥ8U}@??ɾPf k*E֔1l4-%N.٬w??i+??Ȧ`H@&?0=`^h??u֯]Bs7uHjeiq?ri4DꀅcxiWDA183ͦo\bW^ z<l9)VX -,Oc??4@GSH?nZ%?0?nTP6 J^v}K x<s?n?nţb#N>'?n$(8}ũ3РU+^<Z9A2m Ze RJZ9bM )f@}*05R0*|姑ivJz{BDm$+|5ٍ/]O+& Q=A^!ȿ#STϮihY@wZ^)=`\ɷS+;ttvӍS[)AiN(P%0byl.U::ZʷS8 9˷v/%}SĘ $Mc+ꚙ"E#unm`$@&Z[\ruCPEMa20GC YBNʗ9[s7Ա߇Z"d̹.x{Ư9 FI1g[E7Qc c?r]ŠoFT>!˭ 7̢)?nr?r&]A-??7v F7M2OK̖(37š^dP$?0jP:?rR?0 ^X% rb`ӻn6?n-23?n U'_f;6g-uia>)of?rv逥.ow ?0,Z w)uʬ?0re1J^;1|r7Jf^D[:ԣÝd!I%,kJ[\e{֟x_Zfv|o!# Qub??tZӵ3j?07bx JtVn,Fe qB6O ?nX^쟨uBGnQ x ' ۩|38J{7 \†x7q?r4@D| #Z?0$i%\ oc#lAfU??@HZĖ8~ewLMl |aJ܀ $ShbAfف. Sfቿ(a@+a$P6ť1hISl|X4rjTJ$gIN6(mzl{C]zl*ٍs?r|=@ckJMšd=bЦ"?0E?rM.&eTC3C#;bv)Z0;Wp{69$3nC0(MSr8+0AEZaǥ+vD,xars[V9x6?0Q$׭GF9G??)`蠬M`z㙌Q*1˱n!>\M(.(onY>A|*l5\!75AtKwWt3Bqysy:u_RQK!Ӷ@)[?0n p`B=;L70ȿ[-{UNJdhqud擑DRmQU%:yS4pu.j ._ZB'kw?0Ō\žUҬgC/6`:,Ik'"Q3yìOKQpeN\gGRJ%mvؒ?nz|'Ezy `?rJt0?rwu/xs5 {D(3,/*R央'Dį)I@|Y:i@?n>5α{{^"`L ^ֆ^.[\U3k_! ܈sůɴB͏'g+9%XK_9=Zgͅ+FKrZh$N`?r1ǝ?r1.ƽ]\RBKyvs?nl#@Ӑ5R?n L0CdSataYQ 2T(M9:ΛH恉2XCMɇcebӍ@O8/S\V۞H>9 II!^eWRTC?nXpbP {r`=L_[na0vڝ#|ol-mK˾'K^Jݢ/9ʗ9=ҟE2?nJи^|GzgJ|I$H觰KsIo֡ 둂d;'1^ z%A8?ra0_ƼCbtaJTw$|YwԖm$mNuZs?r+I[5ԴB)mؗ81TufUEJuF{ղej2(@O=s\GeJo@o)kjSμ?r1̔<>w]X%ep1_4WkjBʾ}??n}:ne&W 공¶9T&}Guy[6眧ni_vnPs7""BJXa$;GՉF?0PҼyEe ) 38?0DzJV??p㈟"#WOʤ*=Iv%qK#2!?rR,Fx* ;'W~ce@I~Q{jL|3ƴ' a|lkZkzΊr?? 𯃡\c}%ݨ2" eÝ"r+w >*t5|d#1:^jEjXGbo*>Z S#b\]цGid v\:0땚r7JSl0p!P2}H8_+B(vP7eݹkZe\sA8j%6?0My#8P=<t榯dמUeDY+D*N5 ?r}{:wmZ6^~V=s![i סwf()@Wc8XIF *[מݱFQ{oi_ q}?0 QkZIC9gW?0`[O ahᚕ\D -#$1xa:BkX-gsG1"s/ٸo\Ul3^+|Qu%yCW:V$n|?n݀N+3:aNf?rӟ??|>5.{%ФZ=VҴ?r!it;f̯RXv_RIhsLvŐW}[W|?n8?? &#rZɹWÎlr7*1Ъ.^<ъʩ4+eϼۧ/l ax4t.b| }P -԰;}O#M=2yAE@ł56+b98R8R6}oF@Vij47Z?n&<;fAc$"",Q2!1C[sWǎrk+3|=?02#O8VVdzOd[C0y0(%M;Eh`.ɍKk 9h|S7XnKBwp6¨ٗ o*I)fR#?? d319/F404cJߏ??l(е<Aj:c??[2MZmNߧ:g}Wp=m { zzY%nfknw`@;|hޑ0??5<.nT_Kij{־S('HoWI~*³4=7YyF~D@Lf=q?n#S]L<>.,Ɍes61.]=wX/B<61; 9p|4BZ\%S,u&1* ]6} "o '?nrzZ:>׎BOqҩOVMsGa%oX_' 1=fN5+;-!iL4ѳң&4XTwx@w_õ$dQr+8ϊRfFarЬ&}lW=9F|ˎ??g??nbq(?0&?0 <!"{- Jhh wRGlg5-Dcj$_F?n!@O*{ 1??qO)l ]LQ.?04#u!D_Bʕ a??v-khm=>i{P>+%M;rk1eVJʆ{`Щ^ũCpy?? P+1#nܐ?04V'9+4uw[QIEe8f5~3IC!s9R[R~kɈ F]{gghj2܈QDD(CE{||(',Q㿿A̬1EZJC3 t{!?ro5sPk'T??V7ظVcVY;0;mʊ7Vunq'duU$LfQ A+Q4`|Ck.)m:<2 Cki;?nO :[NYkYzaYkWXAYl̩v}H`V@hGdu\OfDȶX?n%ޏNri)錻?n|CB̅,?0ǢCR*R6Ao>L95 I")7Rj%VR̗Ux5θϛl4 ?nPstnLU &2f}$ei>WO,;܎?rco?n3?0{?r-9e^vҴ#j~j@ˊ?0q=~pRjb?03h,bT  oXLVZW_f4Iq9KR'"`ħJ)Y˟(?n`#LcdkŊn/@AcsQfNDzdL_@Hѕ4k/|«eSPޑHgu)+,IR$KE5ףiϛhG1 `ȲcW4! (K >%¦T`rErH)yFB V\lN81"WU?rg:[2]cH?n1|[2PacҔKhf[W@EзE=d/mة 8a`j}V]+l@!%e]t7KBsy; ߯??:;ЬJ:}bzjݍsv60ҋ$=&gy$`.vSd֟3dZEJSyN??bBkxTĚr?nbQ Cj(ո&"UؚbJ;j4Co3Ho ?0߭sQ6%WҐ'r;-_PPhK lR ܻ>Z\dX)Uͩ'nGc˪er(ͳqd>--:;:"Ly91\PShsFCdNo7&t$R;m92??vZ~:5a)7wauP{Qv8?0 KXXL<8x:xfynq??0T0:6EF~$QTv_ơ 3,ۦ1qUa"5݆[?r)T),/k&q@%szoaЊ8"u_\0m7{h$1["UTk2rqYֿ֗YlyְLcAuAIi2*CDIn܅i~"J= ]j*=h7BHX[ P|8P TI>'&2-T2P8Fm Leq2y03}- :(dCj0d*g(q*{ vQc1V%2 @ ^>A%w$l?r?01B5tKqȈX#ֹҳ]r;}h~GwK"G}.}RF;cUn7+~OeYp`B&V9wӐk};{h}N7Ili:_Bg3wz߾(9% 3]Rekoc:ŋ p,r_!R?0OzfLǨX?rAG!y`wSbM47YX[causrr{z9Ct{i?rL[ izaܮNRQ@lXIJ=$Pa%T3nSˠ pL]fɭS8b֮7Hg}qR??(Ϊ?ryڒ]Hw{ɤiM1Iz&BjObvO1Q]ao5rwx?0eNNu%u?0{?0֏aʑ%#D?n6NSlJhށ1|ԁZͼ(V&<`kWO=8c?n+ o>|\_(m6??yPTJvQMW-[uHG״o=$yG8*ea !i5ò`w<™~l S,z9/myQijWyyQb)a~LB7`z8G8qnW*T\,AG׍ A_j˛~?0kp>&!@"#G`s%>t?0{CQ͵wgBe6t-ĽfJ:Cv[F1T(fRt+5|?neOtUEtނDncg& SLg93)= P#>Rg}߮RMBJ-qvX;᎐OV% C*tmk]ߡF/ `~TTj0iRi]w,ӥ0)|mqbe75<??(6&N??[H%M3[ O>K ̝ەEԂG3zEHB1zBNU0yQm(ԎLZ>=[,]kc^ xhmFG|X]&#%La5[êi nuF'Zƻ?rmw7{um6@FA4DRM7@_wĝ4]6r??s?r|w$bmZcP(ޡoG9U`¿?r^pVȗ>(.=ľ^:gTRqfj|\չտgS7>-:_-aխOr>I>riꓗ4-7?? dn[lYYipZt_VxvS9yO-N:)d`,YITY_'.27akpupӨg/@vɋ.?01 %i*.<э2"Z *l}L1?0 n!^Ԫs3#Y@2L3F`Z5-6 ?r yCUCkF wQE^A+?nc?0W}݅ZcKctО"ѠȕIg#g_W&??KoL .5*KϽn8qtR:$;̏YQhuE?rC&xHwǂP={)EZ2gK%Z$ J3I;dž%&bc막{~~0g);=\@Jb|ݞe`̕ie .ߔH#ٚ%:R+B1ñ !'G>ў  E`G!,<$ڣs,?n+:}pW&7l5/\Sn%fJݎ03FU{y{?0???r$*1,Ԋe;#y9'nB!@ODLK״=fCF't}Wz Kϡn+A]s_0h]bhE)ZBjxç0A&Q'q pinXg$Ʀ͇?r$3=EVG.4Fz;?r,xC(5e#n[j0"{1料pJ|v=@ǟ[HN{}hr6~20w-X/$)-?0ǁ'8DhC.Lu֦f}"F}Fc|IFA|ۿr`.2bt-\+BD;eIzx:hRuuE1tBjSұ??y)FϿO8Fwme9d7W;5h\<>._!??ݞZy߉K#E?rlw^04<`^AV`XԐ[wJ?0" 7yS&<HZB[\VҦ$3o*,h{9.;]vga]g;?0?0PP&Orӑ??npP :|;=ݓ_RmYՇ0(s+C}[Jaѷڝ݁Wty#7dfzR&M454d>DXX E4"C.5 ze l)0@X~i@l!ݠ!(E?rI>6äΫ|DLU~\ GH4UqHo;LQn?0S"*J"`<7BxN']S{|r^~=OVhaNӻ.€w!>KRHc>C:?n ]X9fif؈&ڵ2FsSˌ$p?n;?r6w1$Ys4㸝 W2c.7(npy#v<5$dKaĦ2U,oSXm)ⶵ 7uaYTpLʰ??p 8?nPF~_!N_4zgKZjwjSh@W bnqe\!qJd`к5f|QGA>b6v¾M'%%}{f#?n)݂*eC(TUeaV/jlΩBW4LUw#E拚h8/ɿ%(w@ B,O|wʉSZsh6%VS')iyK:U 񧿋Jhv!c0!U.J)Ka5A9?r&UQqޣo bx)!4pok#IԽ:EIt ?n`)E̗e4i@_tZ"d-FQ!LB S;E˜:p/SEM sV?rkнhI۵cV<£tdOo??2]l>.= ~XzŤ_VMinn+*<pa:4,GFX_8FLz<|CL9^jK.HA\ˁHRZ ŧGey\Q*OlHH":@>WKeImW1cɢ%s+(k0cx砩3{kBzݒW2V] u($Yy??oЗtA-f9bIR+,r?0Ǒx\ګhy蠹^cā s"iC\w3& ^!nJ\.ʠ,xf|c+7UȈ?0yIWIrM?0y0.õ>dh"?0cƻϚr[+2Z|`a T0H>o$8b?nP[ۻlsӍKTh?rzyqPG0I3{'#)WxKzI-/IF⬢ 5I{LDmD g]fScO&D9&_Ƅ eC?nV@~0,e %sK5N&H%G侸\U?no\AY(F݃i)0ҷ Vl^?n׳S*-T&m,6Kw XdٗR3EԮԏl$hz$A UnjhJ~j=(A*4qabgeubͶ0X 72] d)NOzUa89cӥ OE5M(Tާ)O{s5JVd0k)[$u4{`~|l_8??010032V&_{4=@ʊԀ]??R|͂G gV;E?rn-)J6PdwXz0&?r"2$vbܛm9P;Al Ǖaٹ= E9P5[-CY#zI>h>sXS??`(lkWUdqVZ9tMf#&AnOC??OP ;RUDsfR$$M??ł}$(fXW0`jVC{?n]n}"TBTL.?rPUvp|5??TeRũy5N"VTa5q8E͂IrJ5 1;9\YYz?nKIZEeM0FdO??eW6ٷ(S%ֈ@_o0<԰IW4ZBfF&ozfAH?n?rza2iѰ[uo-h??q??6PgL,˚+O~)P`33ڦ.6Fow7qBvy9|whhnN^E.PO$n%Fj+̽n "+e g U7k"wS؛>_곷ً”kYҚi,X9;P)SӻvOɕKIu~?005ta`CzS Օc6߽: ֚LΜ}m??8Te* -#/,Ê軞^^Y1U[=]~݉gn6j'щ }?0+\3P*rA?r3u̡\vjbosM}EJW6́!ԦM2^$/)L-qCfi@qDqj,y@$iJ"VJ-/,2dt k?r v_ڒJrF؇?nsjRe \lvQ|͝{8]zqy"??PIٹ?rpa,XXt|·p.\s3}ʁn?0%IX,0}濜Ft#"JX@aqp?0N32~ h'Eɤrs||[/R&ZG?r4}% 0ӑI. Ă?rhpR-í{w $=tEt5c{s_縔c X\KE1 \!6MepRWط _țf$L$VNx@JVValZ’?r8xqjnd2=hCQԐ<ҩ4tN'#$ =J]c?n^jn6Y:>hl'~@+{{ܨ77pE]Zrfୡ5TvL⏍">I^Oq'7GV5RTIGER;"DF]][גkY'^q<7v2 y?nh15͡&941J+lRۯ SׯUz`??q|J.aȜsj --rhɄ\w'??C bi5|~0ȷ[:!۠U0@؊Tm1Ú1ᕃ2Ikc6QMwֻp/lE^¿"ɲ&8$kM^XXzQIdwro.jΎ]P?0&?rܿ?n,A ȳ8KNbopY E]FJȘz-T9@~K?nBŮ˻gػ5ta竐,lB#&oBE^D?rQ?nʸEgXr毁R'E៨.b\t/b;RIU$D]77*WH( mHe=eNaLZPNm؛<{Jij<36>{w[wB?n"^`I'䊗'.ύ8ٓ^Q &kyD1L2LPZh?r|JdfD—ܔUg6[T.ᢳk%??'r(cX8.vPXYv~ZFnuynRK}A=ʰUG^XڹC܏ۥH2@?n%J4ܪoQuI5 {ö?0]]U m!6EYb??]hJVۣ]ۻP8e?0ܷ?r ϛF9OƘ.H9<̳ACFT[*P.%^+)C͒4~CFO6̳ N)Jq5p%Iq4s6Yq5 y~AiV1|pv#3G3h`zbΨm:Sإf2N,e*3D6i 4ZXQVKа>KG!_yJoXv_+^{tMD>_ Um>Oz 8@BNFW+QZDX5} ,/q>w4p޾9o zu3K.2JvG"g"tg`]'$񟑡??&F1????vB~@(hL9jNN88'5\w'J&17?riLJ8D[1|m/`BԂZ`U,JU^Cau?nIO~6sJڄB1Cx_w};e3Ir%Vvjfd>Q@ݒva潳cbS3k]F8gLGa홂t<Is~??;f;^BWk?0U?n">3l+VFkb^DCp75ٔ Cl`JSVv֥PgŘWH%oTZ]FAg?nUOԆ?rqoBw211iŞm+ܡ?rl-,Z R觯Y$V,^hJۭnfRu=S4ܳWǛǗVjmQyPO#'OK[VOoFEeY*F^?r-e+gk^?rM7)r)4X)^?nshV1gVdw/ݚ[ލ.Վe )^0/)-O&xVOd&h2!2u*S==㓫vP2?0!n~?rJSFߊ ¡*ibjltM+UQr%>Nj7|jfN}xOʂ(nLpɕ-(Ӥi`7DsWLa;P?0\_`??VF ,F1Đ&7x-8=q!(JЫH9X&h I/,vJbfHӞ??hkQ8fߦkZwԃɆKm0TNJ)%P@p>]soxJp3p>M:8O_Ï*X ux%k-Ĥ(Xl<&k('Z?0ǘ!:)1l:NX?r5aLfAxANVb+8)SScVɼ6fr|/42GΠX!2WJ2b mc@% )'Wpy@Pg=e?rL]l7EbPlf$묖+0+!n`nnN{A`VNZAƤ͙#w$qL_~D?r?r+`:HpK}E??[m9VJf'JD#qdJesS@@L>6ПPh`-?r4څol1vAN . TKaZAy82sCLK.ԹjЩ??(prv8 WqA`F@?r9?nԱ2ZY2v'jZTIY3+$N&a #T8A'y܎g'.\ y??SkmK`W\=[njbOeQi?0&WdPZUq&;?0<(~2x?rd!L??+~DG0YvMph>=|YܤmM: @âIOnʟmI<9.6LdB ~sg?0'h7.Bx/ۀO,dc]y˼xLK F +т0-yoLsFGm;͓U\U;j6/ ?n|>8sK{ &z_8n]p&ջj;???rcBFg/rq[2pW dNƭf??oFU}]LjdX1)ǎ&<Cn=3E?08-p`{gPDqHQ[JM~\꿖%(SDj.Ʊ c`46aiYM?ne)mgpୃlB`e -q1 xzq׍jL0?n?r}UthVf5 ??IƼKFSMqIzIZG$0R=f?nh k|H.PY`kP@#fIŤć^waNdF4^8cn ^8@=Z-K`Y`^Ss,-; "wwmhU^VN??DJ:DKw(?0*?rO,?0uϞ%캚V i6Poeua{j~8^RK:rnS$hR'ĵT???0?r{`,I^p,Hlߙ~Bm#Y88?nx} ƾ9hyF?rZeXӟcWwX{rnu(̳7nR&a7q/"b-6=N^זwL[1B)HloY9*򺻹]mZVҮz>h8faa Ug494yC/2h:#t.4zW_$o|U*&y "dFw;MtXxo"@!k. E,+=̳SVzD(j0^֮B2Nc*:Muߢ6ekݣ/ +Z9UN?nAV*>2'472-1PA{A ??9$R^x'd㥁ondxBإyw]8C*;v1!ܭ}??.ixqqIsu,"<6]f4Mn1 -TA.|Af7 e$u1_GF,`cyd$h9̡ `߼:NA͏0n't4uyt뙲殶N:AʞcH:}}. 8/uzA^DS3ՄWV ֡˅kޛG$uc_4dYr%lbGhQ?0+WRŤg4_;?nmg#qm1GeveߚX~(+/L3cO%(a!&W@OD+"U !m0B^??7T=$$dk(6 x6UbqK,%YaP%:j${47ٌ?ruvh,YDیS8n^g29uwUEf~t!_{kǘԒzs=ڭu??f4zu;SBu.{^$@T hEio,Th~v * !Q]ayo1(!M[?n1=>xNu`E py!&$jEGѹq] Ecъ<֥'M44 Ԩ7ӳN edT뵗ܪWSϨ)Ħv w9ScG,Si]޸6ɓ z#$[ =*cTZ%Z_GfF]vdb)P^uk[qx- ;+D:gOnKSqvf^ŏH<@rh!)*[!IJ!z>PEIn:WOv5++.D`f2!K9V~q+u-e få{W8չ5])\k{v=> to4ئځTaש4+rUR`9%dw&uHM̟n~#ϦǴ)>JY5H0P??W&Й'P6}ta/һoݴn\6Рѽ?n??D7#|m{sfYPnژJϳ{':jy?rH]xn멇OadQ"/ 0-k}JgkC??'K@{e\F_`C?rE2vaS?0pV;%?r羄@>h]>amAZDum?rn9)^Q젻,W[Ƶ$$KgK|_WtD|Xz.*P3??XMŁ??HRQmRyDž%?nT??#i-gV{{w(0=QNoܞJQƟ:|ÅIIΎȹfӦ?n??DLMޝRo8f?rƵY&Iw>Q."B%R.`eeuUr[a#)B E{G@]j?091}VMXu2Pwm,z*·e;C4g_7|U u^-eU"SeJ(." d٦+CvWH W`'L??"Iƭ/tBX)]%^ava On'!*}5WܲdN7>????t!"!Ötwrr9픽MԘ}Ɉtvlz\"?r<}XV㾥yu tgvO؞JAޥ=0Rܣp矀\Aگ t`}o_M40צ.Y4r0cTJ(a'jk=s] Mt\8I8wÜDޤ>)Īo%7@Q}a/!+ؠddQ?0>`K?n-ѐ_r\вctOpM3ݙMkrםM!B~nW?nω2̢nbV`;Dž{(afĄaTӳyg4'?rAL1_,dxFf mJ5L&vG.dO _Ā7i0;y?rX7\&y|+[g2'h~.hb 8x#?n/sR6??s+{TaLnx; )XA,Šs?rqdu4@9sM?rjC^ GѢ?0 1213?000231120??ht@+hDv`vH'TvRQ陖?01]W'/ҫw/L??jÉPϦL44 e>k "0@T! ?rYU/@qr$xH?rߦOgi)뗴 jt!* aMѵ4AsִRqCzC͗C7{F$ ~f- ,LY>GoYg^/W QpI7CND~@el*֚w:gNL??սd'lPu6@HVȓKb1ɜseS+ۜq 1Fh];̳ W0x!!ЬL5LQڅ$g"H -#3"#2gTł-m(Ȣx (+AVR5%*Y9YATN:sh2-rj!5HLiqZY%)+Y6CHĐ8T-B^J0(m*Fk2B(xFvi'J'öx7?rg?0!d$79Zk8E%BG3sQYItY`v?rLč+?rFǾG7cPi25= yASK_ e%Ѐr%xZ$ U񎺢HYVhXMe 4֌Vs3#+ܰ@-zz%I@|vp0?rwֱ~?0k{m6ymh[xD+QiMgzz9xC@&7³74?r5ʂpEҢibW-r3=m{)Bk3'y^})Ɔ_v%/?0;<ȺF&Gz=k׌:<\8j)jD[$?0v#?0iTx5n®O$X!a$JߩRt/M9ڑ]<}`]*1B&4H&<6 Hݨ̼b&V1/+-0tWdZ˞Qu~^"h2m誷xN`iM1}Q8ʽe?nHwkI`bJoIk!,y4/}:S 2HGCˊ~wߧs$?nD{Md.lRs4j?rd@2wT0߈獏x),˱=5SNceeeL "v({i}Yqj'cFdbʦBUsw&Ee14Z<ԅG?nȣ|G~E/LV^/W*͘jʼn)5ЂGsA&=fMSQp85_k_iE|0?nyV-@ȳmc6,Q=^??! %\Aӓ9uwWޥ́DTi-}AtPcPNB1yͤEqoNYa=!w_kU׾P ^j"츘+ې.tVאۇRANuk;vz]< UЯ;/D=9U?0cJZ.e 0>G>,=Y-E3њKd=7dsvc Ob9ǝ=3ZDZ>mה`㎲wRxeoE7OgH%yW^νw ??;Sr =5+?n ++F bOws?n!+tDbxOS/ g~:IARS&SoښR?078b֓,UM^Eb~??8-ԶASvĎR.|LuЍԥN.+w?n ()cyKBnl$$1,gig`+ȁbInÖ}3wζ!K;)a@o}ϲ7rSe!o$1LQgJzwH65 m'(3)xЪ[IjnXG^7FԉM)ۍ"e>=)Ԏ0sV^^y?r/ S_/󼕱gUX|My>ENU%sK hƃEFL!!SUfEﳨW$1##RkBhZW]^|OOR6æz~pI]򮐜y9Gt8?ryª8dOp?n JZn  FY-?0$)_&޴=!9#I2YٳltQT'Rjr`Љ8 5-^L5(]@DŽBkyHbhQB2C8nVVUsuunlLk7g }f*MBGnׁ9}PR'@1'V(o9ZjȽk Pz|j8hr eAUySX֠f9v3Qvi=,?n018,6٤>/HwfzyՔ&n\Szv?009LQ\d??FچAU/OX^xu6*ȍWEP&g~6s*?rR-P4)Pzyn Ɉ6lnA-dw)"DvCo)?0,n~H◘2mp%Ӟ+)SR~z*XQUX.kϥēx.+y;;Š.~TĢ,>wONr$"?nf|)KbD;&Ȟ͙Ĺudgeê7_-jUHN??&c[Soف]&Q^N'^ڴfG oנ_+ 2)HlI%UmI]YQ KTkt%e_>vjY1kmZK?rw'Smy_Xd p TE%:XmG׿`@.nWwrock6KqLۦ?n|8k:( X2qRnUVDon\Uv"[̈zV.O ƻ6vXFoUGQI]`g;Lxf3k -#qd?nhck4Ln}5@n/9T=V8{TIDS= tip24B-XfXzfIl$yO쀡[7^[3ZZɔ> zYemmvkKA ^WDU2Z%dBl$IV,GɌx,Wz&{ؿL'֭)lC#"ͣgTYqZTKlqG+g}Ggz/'q?r`!laL)5??"?nV&<{ s_z:^ дQ꯷ovN??QA2 6R.a`c̬`ZnO/  AزX\6y3ӫ5)e@BۛAW}S31ץ;?0=aY%?nOuQg ֺtN9*etoh{xןJjJ)i}J+PY>mԦ^X7SlUSHt֏V}#s![5V?n0eޚm0M$YcC"z':OXXF_|/#>џGG>7"X4AC Wz_] rt|2ETP^}>>cs(', _J(# r??B}D}>z&{\(0ݻuk첡D/heTV :PJ!&RTC&|c Cb숺xO)yV,ɣ(M=]復OMSIV-LBoxҡlgaBkT6"i* :͎!?n77'&AYF_WW@Noz^vz Ύ؁#=#-#_(~.@4@\T ).u8dUYѹ@=s@@.?0j> UCѫ2q8Ҙ'/NqcTz ?0wYEAe!_rnC|~N,CXg!a)sP}ŏ<%B6,?0UJ%)XBe&-^W+V 6C?n47 M̮n3/@Н+{8SѢ{gms?nA֢ Cfev6|G΢:"??b.Q 1??NVWxH=RShQ'w!B¬BO|&kGOk_վ??j~߿rBoү1Q/Ф+X,[!b;ӆafѼs׻U~sAk' K'eK-f?nm=1k'p(e]Q|D0oK[I|-.#ͺȃB^Wy?nK?0!ϰ\?n1ZTz??1FY GC2- ' SjS! g$cW??0??f2RG\??BFKOg=}l˝G7Ĭ!`Y :~8+qk%Fp¯"Cp.)lJZmAV E\/T uśNJL{N~:|$eeog"Um.x6xwWc|9?n7T#qkA1O2:2=+Rv\{֣'Wj!ָp/cw!C^*jO8.ߴC}0R<ڻR& ;??K5ly!77_I"Ӳ(e rڸm$nZ@u Uzndz=Tr6Sˤ=e{b[=ȇz hrOA $ȝ1!":ios "ľ{|Y?nNW]ΆRs1b6]F!DS<Gݡ"͈V0 'b0)̰6qt[Z>&?n'P~!b;h9 |ޜЭ!$Ҵm93q???r*UC\%b`юP)81%p#eXpaT9<)]A@.v[??!w}#'9yWA BXl˓+EmbI sVhB4\G](9Nǟ|Z8i|u$"7=G%*^ZYlR>y~5Hŋ5zn43&.OG`ͻQ}n4t08Da6VchmmѲ\Lj>J̄j{g??}:5jĔXsafb*1s'H2}z0[K PmQ+z*\9M|Ҹ䇻g Z@I??E:wJZD́3;|9U#`iV˜H%V5H#,fK`+Yb3 =I&gy`7cmIZHCx= G[k-0ٮOQSMg gP(2j#;JfVas N[)}dfVI5؞F||r%P&@CRJ#Vz7odg{R[<9dA+M?nT[|2}qJ K)W'ֺS(ks~mY~RS?r\Tf4o+h)D3AߟN[P.vuMڽ8J;C~S6E[]?0&XIhF؜$89H("3c!{QiQ/j*#_I*& `=bhMCUX.'i9{dibԝt{ˏ+۾Y5͝sHH|Ӷ,J~zT?r-bHQw˛FdSLkCnN挭ѼJ8.?r{|$EudIKoK ˡB{ٴvWA7oCWug6-ns%mz/m>}Nr 4ڝ[ xG 4ڝ^??;M??g<E-BN&GR JH \74}D9Se;b| [B,TE)9V3/uY?n0.:cp!]6qQC'LE[ >و%xݖpC H=Ln;j)Eߧ?rTO -#ʙ5o)'XTbQSJEllMQ@FjbxI+ͪGRGk9'@%M"Ъ_L9fT{ޣf?nENJ7lZiU e>h"m/7oKh0[hl#ZI^eOPV-7?0DA|}d |[^4\>ū2ۤY4FGǂ}-Xf T62,Ѭ!aB*'>W0 Zn["򒻜»Z(#,]޺žkUل6J]큁g1Ќ?0^Cr?0}??h?np |MrJh{\LlH񟠁'Sˆҳ\e,$97lNg¯c GF^wD4nDRC2< a!2GIsDU59KHD*-q??Z -#Ɓg1>)gh8Z~Jy_Z35FS]cUv:Z&B3JX2}V[#sbl'ģDhLO&.'üLdlFhbLfܗ%%W}%ee( ?n0P?0\]9lYȊS??;6XƃJfVv%ivDaK(`ȒQb骉shD l^ N_A|@^𼣱{:\A3S5xW3F?0,ЪR>j!\bTC[74sN(Z<6$@`z}ҟ/#8巃ĠB٥4YxP?rv6a[z7G仴?0}\;(0뢩Q?0qڶ ǖi-?0LOy4Tzkζ=R>@ Qɒ 4U~ie{01lvWQgH?0O"3 ;WW~t>T(uGG7թ۠oBN`Z}?0u6%Gj䑿^G1x8+Ns+(&ˢj%Hh?0漮o͟7OG﷽?0YFs}l':Le^ݛ\p?0(@Cr ,dL܎ A/E fA %dZ.kQ??c:wjbڨ$aASѽ(T?nlc]QcFAdL??Dݪa\Ã[ka}R$9,;Cm?r8(&.;c;'7=+_óucPw~=8È8|㏬HF\gKBw=5\q3c -Id[hM2Rs)Ca9Գ=/[yynZqV1St?rt=Oh(G\]?nQXl/hLBQ& #;fQF!YZk**eoiȦ*`euB2tĢtiDMKczd%;dMGf-Nv%( omY??_VSHЏa0"S[2|2R=xCqOO9?0Fj;<ڎTå:6tf/E8l;(Y"oL20>|v,ǒt'LDCsWG5 )("1rӇY{@.!w:p.Bo7{ngT,{Ehp_pyO'~D]8wRD'x6,+3ܶڥy:fh.G_xN|p>CUNwxQI~Ugq]G`ss{f-_BKz۬ovNѝґn6{+uuO?0RAՂ\}1WA#{8F SR?rߊ?r~崅v`zυ;p?0{No}Kp?ncРCdQ*F=MňFjK~IWqspZN?rV8d6\;%^{Jв|O$T1M@^l535%N~<ہVP*wk{;6eKao,I??kǧDZVX٧' i1KcC(nʴ:/c:~ tbu;g??|f?r`m>%F~.xYKJ}'@@|wTG<}#]͵KZh2(rY1, 8QwhjX4W3`z9K٠?rG"ru8תYԺMԖ7eQʕI̙oЭާ.Ҧ(*"G˼a: f&A]-N??wy+vS>IDx%/".3':6ú2Cܔ>J2G{Aԑ_mfW݊qHIq/SS6];c8e-3c0eV66-[s村WBn?n5'C- ۙs?rSCX\??̹yso;m9-N_k6 ?n׼_?nW$ݶcՙǸp}C??|OPO2FS3biN^ve_+trS~{w@ MRDw~M/8‰Q{80e;нSxY6ڏ Z&.?0&M=BM F0X\$v[O#ō,<;dTN2Jo\@ ' -C+KiĊw?0xaȆA|U}#M't;d~ =쒙{"уm9P?r舓߄@e!M0Lk?rKa2ANԼ8^iZzFE~sGR,u[;S/en-s{B޲G?njf>^U34%gAH+xIwvEԶ"}jw6jzk x3fvuS(3De'q|(Ҟ=kYm^O8vg%KirSHVf-"o* =%7#@nUw/v8ƭa S(|x6?rg,8{w>554%s$%O}JQ3[!ȏw5f}bKBREo\7\vw N%x$0V?n?n?0A?n@?01?rB{Dy2w&;R nwǴ?r<+4NlX,i)N#x*QZ{7Ln"`!T^͐{KQ ^+SO?nŘAK/i%B(z9*Rq!p 0v'r,LZcr2s`G.'bhOCơWSFOp5r24cY-V@b 1M_p pyñ]sb($Ç=b4R,qJ㿭c52GDd1=$.'ޞr/F'.c*A??b?0:s9^1ko?r8Wa˨aLsn7  U??h bM$n{23|:~B$چ<%8 hǸ?noA1Z#̸(B?n b 1e7r ]lD0(_"3941TOd-׎$A%ƍ`ll[X*?0l)?0ZPwɱq̵Jr3Ƚgcљ~Ef3<]p4p}_e1c9}|rxqK?rK@E(p;>x%ԄF_U\F~ #ˋ@7(0]SÎY=xtԎ?n@MfG W,?0OlNS ?0$G?rwUHMVa{ 2";[~jSL??n:UP_݉{ªZ򢃷N JY}O`з8DK'a(w$_!֏?n^Av:S W?nSE}Kc0Ж>>'*`$Fo6 ^ % &c]+Fwy*'n=RQ?01K0kBibxm}Tp~}xyꖚN1R)M925|&c̍{PoNgF= ȍܒ~nGrv3>Ew WU?0W$F2$2L-4֑ց,lwr[?nW?0Gc7es((ٓ{ x f嵀'/sF]Oɝ5G@|r4RysO}"yAN`U=x+dԔ;'.JĪL'Z/?0:iˤV Iz76ܪ&ݴe??1Y%NT*˶mW%iKqmjVV"D;`ƣ~I~Ag'da\#8^!\KC3Hl`?r[¨)* (8o9*~a?ngvlC^+T!y09RZJ7P̀`tmD!wI @Ѿ.넖e(Ul׆m%6M);DcЛ %_!ߺv}eֺ969JMByޏTߍi~|͸=})߷ll[++(FҦ1sNbXðSЕ']B7N"DQ?0`=VAeRO PR*E޼qR/777.GY{r+7.hxE/bǩr嘆q.bѸ}R?r)z}vZS4e0P?rH?0$ݒ8GڄhGDxȂ2͗`f8QJW'_IVhE$SkXR[(a`W[:F#Viz]޲IGLFW='z -%K?0J<{8;U--Ȋ,,/3^i ˫]Sw91p\4ё1{u̒?n[D)c_t!/B^%y*JnCa op*KlK<{??=0^8ڿx捨ʴGDDKtVଖC~ju2bϹݏנS Ұ7B;a,21#L'Ʀ?0U~HA؊,S`w-\ jeSw3G?nh}CFEnʖVt13dDFEFVnH^ fFx1L"}Ga5bG2'{V.Ewj-JJ?r.4aR `?rf )Ҧ,a }|1͓¹" X*VxQ[?n*۲lfXkEz^{bCiN(z,1'??UY 0xk,&x|O@v w(ekg|5e[Өv΅>Zc1{UbZW3/ԘWL/gW}??'V?r`V!$MJ{wi== VF6@'p`pL ^m} `M=ϤyfJ??ڥhǑǓa F0=$I/#@ .%U%#k,VR<-ai?ns`d#>i-D_wԟYLyɹ+J!nI|5,nd\M$[PQu(, Щ2xJAzCHC<5T,5,f tT%)?0.,"ۢT{*eO:sTybOy'F x5Mr΂qTƛ?n,J?nyΊz -#9)Ň hɑ-?r+F;8#>*al%ïRl)??F{bY >9ob>LUةeՅAx| xHP3SNP[j]_K {??UOPEp޵N,ȯH?ruUTf5 9O$0jQ!4:xe1?rJ?0C4c` F=pVq>ch,d??+6Ob7L7)C#{ .Ժb{FG`#J/H!U]hO68ּXk$6F!`Һ\}l"yGHk@:4Y־{SKuU {ʕ T͊J_2 ;}`l*&fɣ2Pk ?np/$EBKcېS ׆Z\^J}6I|J!M?0';Ζ2հ>,7g 1:S-$*G=oa^ij+3#1L~4ro{w>-⧯#ky$&2 N&nWn@4^Ov=|-}!GX8߄Yn wa@j2 Da7L|!8ohĭYri ''Q9х8'@\70?nE^Wdj=Lxj4.lSW$i "qM+|EcZOiu+/=;K4CWCqG\^7M[IƷS ꑊ Of?n| Wu b̸yMz}{LXVP-*`w. k??weYhvc?nnSMȲ ~8rЂLp` 9$Ic|?0p?r8>1þHk;xZ-A,o}+^Mz}co?nq4Ͼ8A`Zi/Uk3)j&O4i>$6 8Ӳ8)?n&S5l՘=eJ)w%ܾ{-%U Cʚ1w +oWN*JmG[o/x眚Ucl5^I "$ekxR3SȭF7Y{}[1ΩօCGhBny1G??D3#UgO;A)hPO($g$@X8OJF)P8eBh,m#x1>ϼEI.oXGרE_ֲ|⃘q2>??3{y5oVxQPS~;]pf\CS "k@;>#H졡8j:a!|W롾N8SI!j܃DZMVbma,qOyZfcD5[rlbI?r^ ~f$0u盶ߘ3$׀> q-]ay%\S'Ja׺fPE K-F4ڽbA65hSヺOdPVp߁l#29@v'dIF-Z,(>{3UxcT2B y_ xgsLPo3;:bMq4>ߌYaE]"xv{ށF¾CS{{n;=4`W<>SsG"A C@">Nvl%:?r7l+sS%~z6ΖR/>a"@8 ::F+QQf+Jm?00at KZnO1~NROL%Op,?nк V!g!??k^#+;॓ ?0sY!'Ŕ*)XpH worL3!E]>X[c|RL\%.2hZh`u|[stHy0.eih7Y;[j, e n!Km:bkcI:ιdW*y~+7 -G[aW{[}` TD?nV_v.;;vPq-H'ņT[`½PXsö-Su A+!>[6ɜgf,M~>@ya?nxlQ*]ӂK/ojf}{9?0W?rlYa+Hk?r6\G.!9U^DT?nhBEjQ]%g4+m Xנ+w7ԔW7\?0,kV 1-5NL pZ.ѫ隣uYg%<6N̎f9;CieÛ|S `/StQJiͱ) 8PYFz2~Y .o#0S} yDh7D p"I#_4c13vPG['yr;vF [uot2Vxs/H???r(ς>cb|F:ʥ~eFb4wu=?0dECͭ,r#zTo_ >o^_óX;+aZ8WAx>zv>DZWᏠH\jLC[iL5?nKCڈg%5C[!C!O;i0 /[iؽ[>+Q??([ *vMmZM9|ᖵ5t%Eui^ԪhY+2yŻr0c<b}?r*r.}rDS%^Le?rI/^2X-O`M^;<:x=pT9Egg8p[tc4ȓ3[ҕJ=ҩ>VrYޒfmn7)??"18-@0;D$01kM]n6r ?r4)&r 9I8m87+O`DiWG]NWbUd?rW?r2^?r?rޖO0CW?r\nc?rZLK3wbJ%cXE?0;GgZXq%.ΟެMs@iٯ?r.8g>!m_v_z͐wp&~r r.uHc?rPjc??P?r7,qݽGs :i[Q9jUp?0DtǙ e~)4?0Z"(}/!4Q.FM3+kYMNBGZHy.UX\e͑*_r#SO>a9O%=Z5iJֱ~~R7^tX2AWտ*F)MMsjgmHl3z8B\n=τБM?rIV}!Qڏ#Kmtc,xhli -#[}iǘctvr;=5­*|vGsO/fθ&|M=?0>v/gPH#œJPB?n?080?0}3?n4sՄ9!` )g6O]VW4Wi㱑O~?rIնx|v ޮ#9%5XKOi4S3ɃҽMNOw d#,K^_@"cN: Bg]L"1:#]8Ď&&\9.[RD.vl!bPl&PwVp?0H&+peI0R.0rÜe`,Y4"!$Laկ8u\>,oe*jQ`Dbv Z҇Yf?0[YACq'|p֨L3g3‘5ZIDO(;?0И`/ѽ ՎnVc.*<+b?07;u݊ |сjp6`~4 cKi+ve|Ϙw?rRzqR\/6E4/?nL腄 ^(/c13wx̞;c|yTd#*M$BVѢإo[&oK.?r5?0zw@\F^&?0 iv&v2Ta/wC++}LB[:?rW0'쁰xvp-}tfΪ~nkʹV-׳ܘYL AW2/5O&s:sA㺚۠*K-8^pF5?reUL|!og؋smg\PQ-vu5ah~ק>H|QZNe<,jV@X6O5hVˎGR, m4PO(j_q%* A0Wg,pͭۑ➶Wh̟6=DhiʹX$xI?r{ܓpM{s-1ϫ6^),y}Ԏf>Emn0ྥx2Ջ$2"|B1%4#YN! E87H b 95"w|vTWv\{& qǫ?n)ݿּ[==tKȫ 3Zw#6I3~`=nX| Toኧ?rQ@ftPc-Uh8W2,R\yڨOYXz0NE!<'TNq=GYSN2rnjL5ݜu}M VTɒAy0pU*>iC`,LMD&Cп48àL#Ngx쳩z5~~ ?n 7C яc_É/s>VJ5O+ޅA&z?nBL½1ȶa'ΗY_ n_Yfѻ'񖮰iЉN{???rq?0otyi9 '8Ve%y%j@ҹ( <2D #bW /yIaAxc&`n'?0q;,g2Sq/ɖ_XęWw6woP'> AWĠ}dk>oT*Lr#ui.};M֞pnc죲_?rTGpc CQ,%=*p|!K`MR2hds4f[?n9ث+ӷEsL(Qo]qVB݈A!~eA}g>D}Ժ6͉գtm^] :`7p!>uJae9I2=LRmDӾNR NqwV.F\U)PiqzܑHk Y]uǟ?0q)<hxu*%yekҠmBW "H??LʋO8v@PA(>c› ~€3c]%J6n=P=wMi3/;"j0d,w.K??(FUyYIqx73Ԫ?r[ڰzxfH:]XSR$AKꇦ`dΪ:PykR-eWNjS_29R m LS-P40l'c{i$Jqm.э?r_ 凎"dcO/66)Cs 8SO'8g[R\N?nl Ll|+ :PVK .[fsEt^M$m&'[QTUx!yokPʾ/w?nh!,>ltqvKLŧbTGvc??3il-l8bI@kD~i";-r?ru􉕆[Nne曏7??R;̙1+}U[st\&Ct}ؘe0++}İBQQV5-" xCm6bᆠ|֓$pVYڃz,?0pIS6`2M`r]%D8X~2nt_ uGe<9x!SPBu3W=Elc8F6^_]){ҝ8g窰o+yˠ@iDV N"Zh!@/L}ڼ ɡTA#S8ꊬ%Z&Cײ4#jնodF- Єut똥;/n6:\0Y&WLz*HA4TA٪A2}h ,4 I-mp('&)bqw1Toݢ??8 >DJÝx?nZ LN\?ny|@5|?nvibU|3nl&Or早g$$ֈ әwzBtU??늖?nWޥ+FFO㎊U*nWKc`p0ހnϋ{,,P*v3LgWH=sJ7px7FO<5;qƟEW?n/*D/"n @|ۙ;u?rh*YmKglمUF1V}{[eĕ#??5}c^P&(kRSD^Y=/Pv07o֚)Ff֙?? j*zϼӧ#3?r:G鹢v8ӷ+bdfS &mRT\IqЩ)pj&b潥JyC\`l?n |WsĪC c -#4}G@ 4r7*U fu??gj.؄ 7kXwn>Bn1>viugMgdI+&ɦt TQ{Pmi4ciߑs'`U@cY?nj_/fOnPY HZ4n@033HtN";ǏPhZ}.ᤶPXىoLm㐔dEHM?rg}o4k\M,W$14?rzǼ ѩc}knE2N f44#ꗻº R\.rՅiQ‰p[cui, ,)"H4?nЖ0l2v?09 ^>c)(MitEo 6&եxuؓ/f:qZrҐov5atEA= r\go{̑]Y?rc Rϭqe-@Tw.mG-q`wx}E34b5.ցZKuk:5P.z 5t/znVԨ[Lz/-gAVO8kO?rKh(+NfBnKY 0$<7m4l_$!@?0<73 Cq?rX@Iv6AnP%2׈W&fa?rJo']" 9H%S!HJZARVN6uA;ZR6#6Odֲ Xj!N Fqt~܏$`V}gEGe=-+# -0H2ጛj6È s96Kv"1V[]_= 8͛roRA=0I"^¤b?rN뻓??/aҡ*R5gB|G'C&iפV%p0wEhp4`WϊB&i_M!" g9 5W5#ڇ0G &^ȁ3놛*~w\;tz??SEuQ?n#`"1(BL!fb=ʧq/)"(ڏ|t4gژq'=+{!\h6b=?n'*:EӒ x?rsQMF<`]M.x?0-b_k{??=+1 1 25+$ 5u~b_ CGqEE,|_g)(??\S0ϑ!O߿L]HO!}tbNYt@v"0X2YshPt*!_IqQM%\#9\Y 1y*Op=C}j.ֺDH #H s!5*DO)(G*ܲ(6@&ƠH~A(?nn"??ÆU{'@+U ru񌀶H'?nz]_8uR.b(da?r :rE3`N1tF3mBbK1Y~!; YJ=rG%]n`čxz%޳,,7^E}< IN1_6VLJSxq bE8Za{} M :6 2?n Ea/UJF؇3sn&*;+-cq{dcK6q.ń>y3 ΝUҟ!e]!CI5֘@bܘWظorM̑$ kOM>[~y:P?r"??86x8V[>?0 k??`ib8'?nPkt=e5?nOC>ɳaЅoԣ Y#F | h}uhTttzJM?0D86$hjԬI\!f$Ja4̍X?n%M4_Ƅ;yTK=isCzZFq$)~>cG&8& ؙ*c%*NbucɄ^vTqҬ@q??EP6 VΘ3il<+)O} ȥƬFֿ`N#:x }^ś]Aw`*|cuA,h.4E ^IOw 0>웡{F CM)z/37Λ9;h`aە??m~{ ,4IAab_W|q-_z^X.kJߚ7?0덲y"nMK-`KT㟾$#*/a$x=30Q[ι :nJ| B͕0ö]ɖp`ZzUc yz3UrR̳m\m'fI~-hA&A2?0e ?rKh:.TH|lF||/??8~7 K_2%n ?rNʴ qͳjX8ې7{F%H Kb?n.8v)ﭜF)P+䨳Qe'o8v ~Do|9QZ3ciJ>hޫYo*b 7j?rnݨ%=mл#^jdN#mlʬpW QbFi @+?n?n,.wk,??w9_YV}tƋT(T@[DҪiY:98 t?nsr߇3iA^Ro\jo-ƴ󍩠~b5ox[E+̰m.)J [ay>g@&byxw2ƒls`"-wd7%;"Q頽TS5??YaAnnx׍c'z,`My}r-!ֆt} wiQwY+HJ2gFJU*vgBc^b~d%rY/kW8e pq񝷔e]>}ru :opN_C BY6mHP8v}YInQ?0|݊:4 _5}| IڑD *h. (ڷӺTn!(쪩,I|PQ;gfwHۉfCܦj^ߊz;"ywoar շqb9w]j9C= 3pQ`[{pV^ YXL˱L734Y|IBވ=4ØX19/V2dr.OMhc;mV(XdfuYa^+!-7.<>l?n}U|'d8ha-KRWxR+0 BE)Ghh¨})b(=RLw}A26e+Y2k&;',V_J |2g:R9 + l)abizd렋'*?r/yOQ5E\Xcڬ(aE/hܥS#PZ BF֘Zk:ؔi-\q! ,g4onz)t*m$$w&&c)*(}:9VTGiM X݈1䝂j]rݞEpܕfuc??z 8OWy??H?0y'vZ+_}z^#8g9J?n ӍFs$G<vغyEc@2D:dZ{ܨa.eACA#vC10 4qdU53 5eޣKm>bk0nӝCYwjJ$DHecSt)?r#^XԨf$-gXmIݶxB= 첱FeS=:fJ?nɐ{t&CZ|=>_p}Z(:Kp[?ruR:,CF&z)Ś۵¼0/Qq{r?r ^H??2E/'P00PDv3J{+ ΋^͆uԣKW*Ob=#'靮}Jidk:I G=2o (&8>XIr7׻ϱry"W[[=_!pĚwvfv>9+y& ^T+YVyԏm\0sn B]85ߛz襑~;??{ӉYkrwutKЎRXqSDzd $Au>TղrͣoATXP *@3v/}}/z)-P}bS8Lbfvtr펷$ЮiN]Q}$(oJj5pq2`(WSl.Q:(va:~HJ)V.Dԃ6)/??n87;nK~$(zƇLp~[AvAP#oM f?n`c?nlsM޼cYWltdCq\9iX_1dFdkE2ƾ1$QeoZ_ǵ~QiafJrZ݆uonöTdd *P Zcs?rrrIc`^ck\?0=FLn zFyDI??rj{&?0c9Ul4XSqzsèqA`xp;I˝$GJq.")F nGu0" kR/@@ŶXe"x`$vJow0BnMz??vuVLUVB}??pg?0 FFѕעP?0MF0!W,x{i^Ie&͌|γ7Y+a$o*ʭؽ3ܥ@IriPjbmX4BY03vMDnC9ü-3׬u##tSKM&AHd/tp!0h.krYFNr ????*j d$[]?rnX7F/^VӁȏ)<Qi}%ަV! q O. B氏6%%#rtD:j40S8GJAI|KAP5ϓ-*JRHRӘȁ+w#ݖ}j}gMy IBH+&?rGz3Pܝ5㧎O<]5\9}Em2??\۔]=au>uimIjQf*#yG'6+(~f.]4>^TеG?0F??USag 9A+I'f¤GX5-m8q@_bU8g--@zW;Lnf?nT??WAe$K4:v9BJ<%łnt%F5Q"|tO^n^'uB&0kiX:FI|4,>adm2#e< ڞ\bc)e$DەEH :Sߤ8$VwB#t3_za+(gdUML:XAfţߠK㯂W֙ne6Km"JIDh bz>w0S[5Hg`X6!~QR*Y?0uyeV3״&-Gql?n_wB±\#`19ۡyVdJ3TQL"z?r^-SXMp+<ީ?n˶\xz??q`. /jVoOqU/?0pzԈMlq"Y (d~GI!JB+?npqj+Oe!b+Vthj Y"b5GQ%d+n DžT9H,,wRK@_hR%Mժ\CV h!CEKPFMB۹و@,n{¹':Ȇb(w??[:H=yN:/*[(A*ʺpXiڧWm> |ŝ-W-DaˮsТ1E){I㹦x=?0LkjtK?0%Բ(X$A+[X0U35 !w"6obv"y h wӘ:SS_\Y>8A^_1i.B5וJUnFߺ3n.ãVP}=OM4&г*??)eѕШLoWýGҴɣ"jmGwwѱPl@'qFY|hs4ԭsDōK_K*$4,4'j]YkFةN>25(Y; .PbhT͑|5/`  X3l<??h|Yb6H.]Ph8!?0p,[{; ?r-?nAe+Nǀ!W=7wR#U@R5140?nga??S1y'h?02}D |u%owAg#Bz)hvJau|1Ss,SDi\.!?0PXf(r~!t bBHHxIâX!BhsY6mضKmC,"Y`sJba& n=`^ ?0,'3|?0p{QwuJ0hʣsl&a?09(rxک?0Cٝȹx`=tu????B+m7Q`xxۨ7\BY'umzڂx[fCZ "TM/+Q9U~*w{??\*d'?rOsΗD> )<)O t e0.ԬMZTG=xlt~FV(??Qs[2=C^qLp|7VՓuuÛ)8[!ixKՑ;};B:?r3; |Wp+[/D?rdTMв#t rFWǼ8 Ҭ?0k?0͍n&.E (I5,I)\yzaօy;G"l/ُJ!5dl4{eGYY%`R ͺОk I먤6 LTt⫂ph+L=1R@:3f3?00^Onc?06Tyw"KV]_Gbޓ!gAUP Dϝǚ ޾P7yE݋ӞheQaʯoP׬/RNH\k$~{w&#ݿ{Ll~8$͹lbx?r/p}ti Q)??yZz`sUi2fN"ܓ0ʡԧ?rx+35H%TVu$sϙzT e&>#W-mS=rX-HSzOt*}r'ޤZg_\?r{ە??0l??Յ+$dM'_@,Bz7ֆC5b |DECyKne=?raPNhFT^#މn}zT-?rgb)c??{T_8WQq})E@S9|<[\z m=J!/b?r&˂Ze>|\k{~zEz@؍~+mwWGuYGE7p&?0h}tmfN?rBeDDyV%%6pǂ=qsi{<+pS#=@VI`/k Ãƭoao przX-?0"}՜&RdBcrj&Pz^;c~#\o|nȡo M-@ύAB,^1pQf,ɆvFB0M1׸JA9hYbw`$K2v(uS"%ώdsKK8ClR+i] &~(r514wz/kzj~?07m5BܓqEdCCm4vp -#??.[vcIIOW6i. iki'&8x bn3+գWQ($nKn?0thp9/%b8C LI͚>ߡǛY(CmIU8e4لdL21e7`]"6Qt ԜAxL9y3b*R}P Y˚+]O,(..ąk${XvZ[]<@yJp2 u^Yтo_E>N+k>m-~?n^Tk锢R dTe??9wJ:b4 !2MZlN|vY7Nv?r:,!5H|NOk??|~OzRCNoŭӀvM틵O67)h2"6汙pX4?rz֞n=feCq\'ᨋ7&R)) UҹxQD;yn_מbe}=𬌢1d"9pnIn> }v7]8%&+&Ugwf!g5@.14?n*;hpiQڞBnn6/?0mP{;uf3VRT0jx)mML=3\\yp8ӾTcX{P™$ÌR=M~{k;2이+c??=s:LjJ7\?ryC;‹%|m:SM9Fd{6nIDmYq&eMX:RڞB?0#JOLR="ِM){ROho,jR'z׬ɿ{$)GӮnxNԦ6vAs+??A,2fxSTgvt`Ӫ kD ~I^ .B_I'[}IJ VxaZX> Ee~LN}:FXGA{Q&s'E-/wTsܛ?r*6Q<(LNKGO2Q{}Q\wk7<̆[+Zo/[蛮vQyuɏXC=-v`^[=%~pȎLF87|Cjm_GC^Ŭ`\BSj\șI&.P)ѕ.U硷cxfb\9MrgNM3S=_k@J&2v {BR:y(:d"1z&4zJAwb.I u9?n3sPiW^_X(7ӾYT'0 [J2Y6[ltIi_U|2"YPsu_3PWFM|M>zK׳hg6Y_)^O ҫl`D7JaU_ClEMfGA|6FzRo.Ӄɸ|_};C{8^@ ]l.I]hjDwQqjŴ=~%[9dBJ5µ ꖓG$B`ο=>R[=yn-9d?0L -#U?0q0QJr?0FϋT:[&pz.1vF K?rIn*b hu  bCSGpj=2dlqoh ;Qao2kh(o/dWyU?nG!3#c^/e1C>;bO=J5 dXJWQu>IeElہ~jTYst$Y?0F vE"JInL{ؖ1n,vY??]R??/.=??PArSGn*;Dǧ!YAq6*pHv(i@OUG;T[y:৬J?npjˎVHF+52l'@›1fAP<|yOk$M&aivu??rWGwe8i&MQ~9)i3#y񛹒K7?nVv?nB,Ke?nUwfiF_-R< r%yi:q?0Pke ?rg<*u1Ht wRq³1֨~6"ȴq/GI]5KC~9ZB-5Xsw变t]^x*X4W-WwO`M?n8aO`veXyA`QqkfʣPh \D_'=(zJt?nZJ Mw/|yO #ee#~> g^l~}`vC]??K{J.P]g,s'j/[%~,͊M+jʿl`('>ڞ>=-gCsUdUJNkᏫob4zkrrJhdt4t")}má:3( K ЃnaBUkw$}T'+Ͳ<YTPol#~Kx??\^A]Dٜm83dZxZ`d3@H?n6`7{i ?reŵSN1J/֗"U0h+#,^S]d1*?nS?na+Zr"iBOHҴz"jx0ʯWd_}$͏PJC)8Jsͬ*#Z]?rMViʥܫTSlLOŃ<M?nP)/E-,qnhMиY%mNJj.l+~6 BbVL5f[w5JTR}JVƜd5b;rM?0x~ @Vњj7-Ztyoň lC&y|޹ެ@nteh8&ЄBW)DvLHj#; Y7lAI[?rF 0\ȾN{jIGܕ6xG]yB0q K {- 7(}"Y]7M X/){s{[)b:ʩɓhpWZVh(ܰ|MɱzHU:_X;%gyFyb2ZưTJ\ӊݬ?n8ݧՆ29U[L9ryen'9$/]'!Cw~ >Ry7?r2_8̀Ii,Ea}>q?0{]ΐ,!|PE;0Ω-;`&L2Q?r`-y:?n=ZϨɱ7J,(BXٝ?0hgFhD={\ 51N?nBFX49-QJVz;/$d='Zse쫭RNJ\gHM5.z8Q5@daJTn[2r @4@FPy.Ρo`x,!2.rHtff0A[ ?n7!^^eHOҦfuAIn_N{o/#(J<"gӌ/ -#`CKo;nuQ2dv#*r4fu ZZioڈl% e!D]nض(b(Nq?n媑 pq@9FgY &vvjtJ~҂Ua ri˸Ub"*p+<osy 8-7Br?n_ĉ.!Y5g4y>Fq\%EIIԬ ;s>pi":o&oy y#R}'.Ѯa׷?r5lˬk/kyWqt տU6mge5)`7gmc_ޫgq2rP6PQG ٰ*Qtmvu\Lqx#O] ͝O&-b&՘˪3*DYQF7\3à N]%BRe [t#  ɲv5FP>g{V jQK7"f]kR߮?00L l~]&~E8~DnuNq[HZnE%fqd{NO0w&ˑrӐ,K'߸z??I^9ձ?n=D Iɤ9@lP+e4MD0o(YC)?rǮfGK<j $q?0qM>3;dU<;MO>fތ ` u{"w<@|ZB W{˫^9Qlb6A6#???0KYدY8ƀ4YW;{7O{??$JŏΩPzdG)-&|Xoo}@6??m<0FnA8J\w52-Q7{i1})-&fױp%)(dn) 6P㟹79nąVTK~Vʰ^(q`&XGJ^ܹpA|I{ͻ"|b a_7Z(}W]͊GEߦЖNn[Ԏ??-$>$%vmOcD8Fg8|}1.?r&w/iVxk8x0?03js1+O?0M([J+`y-ҚGjqo:?nLsTp p?rϷ݌p,HeDF9 5C@ ;a3u1G]t@;g?n:}M+H$O㳾.hKy4{D!0ԡ_A?0_Pe`af߯{?0Wմa9jjNYcH{aw1]*)1"c?0L?06ϵP!$wIvb#hQ2(:ld?rYdZ$HFzƾ\f o7xZQ7^]+ccc Wle؇??h/?rcS?n⽬;O~UkkkA%U8,B?0KJixek b/3aw^l7W2F??\ xÉ!`#9XȔ*=8Jh4W&A|  ʤ؜J zzzzk4 GRu0hѵTOP1Q%?r:qҔ69WFZf_VnV~Vx]c.},gh:/ Vs^8%1x3I%_G# Hr0'Xg%6,PhELQ>?07JTRQ?r\eAf(.˺+b_GqV)8N?n=l3:pYOpaU1 NvW"H5UODcM_NB=wQBn{ϮXteԥft2:v2:tʠU֥(x8a,`fQ| &K]|a:ܳ9 )Vck?rw0IF.n#n~!@Q򀳵|zrj/y_I Oko=i2LM=G^~徺6~U_~.?0?rTAV4mD]+"`IÖdR??'c6edDgŠ.EҚ. [jbbؽf_z7W!ےk*|ESG%<.#W+.^7{A3M Oa%0B7QQ^t~:1~i,S-ߤL;A?0X UNA.egeij/R4%"Npte뛕cawƒgcoEz>'Y9L¹v#છ?r/wi6W]f#\=E!4i1o#&O.aĄ|(*⫒ɞn19 ?0NI;k?n0B,oSv*m8eI\?rxW\T:d7낂8X.S<Rr$vZ֊{|ł$f|@҈gTvh`J}w-nߺu΅=M3Ek39q^E_to >K$5oO Ur[k_06!*jT_iQ()8 ioяٺ!ޏ~Ȃ V7N[?nۙ/*0wCnysK:{4> `Wi떼=C&|?0µ|ԾD4 '‡afq^h-ƄܩJn??cKOFnԪP+F[5)U@MQvZwJb}?r(t%PӢ3ڲOPMթ7T-_?0edǺđhavhyQSYMI#5YFxĺz@b^?0 ̺ i_k ١!)z jM6?n19' &A՝ܨ1N7ջӠ@z:#a:Hx8[g杶1tCHNZJ}T "!ov/6(z0$H5jn??$ގ??ژ`PGUw5L7*Hzp0]!{|_VNs#[h*18$_w_M05h[4W6ˊy8*R???rBYm%©lʀ5xyge/J&q:*ސ<(F:C=e|!vY?r*w\=X.CZGo,!4Z>1_7Ç{=RgFeY±uǮ{iH<楚%?0 "屗F{,ʔ88?rVdK@¸q7dt=_{6![mY!m'2A>j#cf髊C0.P6C -ujxp;/cRњM b9b2Xv %G#=uS٬0"첢b2YwL(?rn~hߟW??/wHz_@":3Wo*?n9gbzc~`U \=NЊPrgzTiQAѴ0L`_xp{z"v fޚfO %g=Lԓ'z=1yjD[Q*"@D4b0lW!{?r" 18>?nb)mLHA&#O;oB9$xJшCyp:8r.Iu’q  ?nx=cRLf5t߈LS=ğSZۣ]6k3m- %spK??tm^ ڶeIb|6Ѐ#\ gQys6]q<`z[[oh|@`KUpOJ84?0饁HwRDц?n߬} ]^Qmn-F)Kl'&Ǧ|ں*$ݦR?n?r G UjOR' 2+tK(b5IۓuJ ̛Ww:^c@UCwѨOc+(NT92ȷmG[rAG70AtAI_YdϿdl$k wrW46iyj[3(s =[-qTTD=UْF^dV=s+# C|1(WF8s2;Ռ?0P*^ҵqAp!?0=&4lzW݇S?rM޽K-I,$b?rh/W&ȩVUTծ"Q*oՑo5H:k\9Pk'Qp-JNcH$ȭJvo"'cN&Լt46f>M|Yu>꜃>*r9~}??Ue(=JȜO{b2>N-O1"E3)D[v 8z?0JSF\ża".m5D8d0}Hlkȫǩ]u ?0ţhL*ݒ)C߂ z3')ARiXӢ"s _6A5y` Yˢ2+`?rM]4ʱj/hFc|[paPSSB܁Pq,{0:#.y@)h|-"9пPV΃'bɅ|4怸Ld[5Bg|uA yh\Ԋ?ne5QgANOcbn9w#dPMFᗐkb-<˷~`TQ7;me*ND|y,|dRG0@Fw6v8SKsYblM%S-G]QY_IU"WrD S1!AP?0~p1s -#4C(&;r\%Q6cN d@6bAXe-Z,PuبG1*eR|i¨ ; 7luRyu?? nX9ɈZw6/y 4<ͭҧ'Tt:yePXP6n3ԗJ[6G;ه?r8,n??|&y}P#f~zظigk&N?nߴQ{ˁB6*@x֓,}R??Ԇ;,GbT@v_J5J m?0?0UUJ`SLeQd:R`yR/\5xD)kRx:ʟWq}4G^f<˦I?nǚ4pd">J!zmgޯ6mjn)yHӡx^ w¦ޖl]?rt1L\J{ȯpѳ/:čQnyJX'?0G 6/@ IRS])LL[B7Wyh͢׺xBu6{2WD,bQP UhUCN%'iHX1&܌eӏF>LTeLNvѐer&#LɖϤ jQ.~//ɃYp3Nj<%?n1u??1z)L&W7Y$ H"倹!~"w3=^?r\8dv6ּXv~龪Fɒ5Mt|t>z2foOuY<ޘŀ ??^]Oˑ=)OIeұ9xiejv C=Q[RҘlOG1??_bt9c׾Eb{ʊ??2Zqno^ełѾm>D_v_egtBtNz~!>wݔt+XGdt+ =jޡ??BbnVɜ+?nmӬ>1&6ťt>3I"\dwz?08l}Ϋ{&:^>ytؘP&k^zz&"#s'/'C𴍓_m+y@/|51W[߃{N<|@X5!hOK=-vkۄq>~mcs A?nFFm~A#kW)Tt%H7^kuCfUZ%~'[2ݓ~MLl5eJ#3N"z^~ze[n8qd>?nMϡ9K?nѻx(SȦ_h Y~Z^Q~oc~,H,ʉǕs<6ɏ Jjhyl'j]O99B`݇|JC^,v[R w]1(jnWM41+T L?n|2t$I']N_@5z}"o?n?r}nW:IE,EP=] Ax!i㒁&#?0]kHqA>>b>x=VPy7tڂa퍋_ng㑙 ˒1rUr5 '`?n͸/?nZ[\Z4s;%MU4*ʁc -o['b':PpQ*BoE_>k*?nٮD `eeηYbԨD"vrOm@%5 2h1n;^zyG(pUNRdU,[*[*X>妄vw1nL0'L0b(|_NY_%e`^ԉЁ"М.-7ޫYf>iw;O_3LA.N_DJJh??WSоϽtrr ZzLRJW{ŐNؒ6gp9J 2=|??! [b/&vf_12?0002101??NY[_4_*f@wZ NRrOlmipO izKiypJQ[ br#~m{bTy!lQ'Ȉ1ZD* n/LߐZjo3y?r>[ vѵjzb=;U?rʀ 5hgy@KGMJI?0ׂKp(!wM'Ǥ^%ćU?0L" -D5i8ebW2與!tޢGybk\"*E7N>DaR;??Oy]?r]e#dz?n)58f 'c tp+-+P񉈃QxeWɲp'a,$K®F*j??[/6i+Il 23|f09"\MB@7N{o?r??(lhw&1y7o%)Q9z?0ڌ͇ aIHRwh(յLd.}<5q=iu=Pqbl?rmjH8`N"çyykBk֢r_\<(OC8G cRJ , MFۙSvjOtDd~*l*6(tohzz/;z`<n\\ f[T d?r¥D7UUK"¤\8q40L"?nF$WCĥai1hb¸+]s״#/%tM*ѫnC>WSϡI®cKS?nAO??[,"?ntOCꨆAN'L0- =?0I1`KW6BJt6$V!oE=*to7^ۃu.?n4m&ɝTa酞fr~˓!sȷԟߏəQdR 6Mxn8A573ౡ?nr8UP^fWطL*vDzۗ)ON*W`yC5v\k"RXGdIVpQpxO??=7~O/En^ΐwvZoz43r|YYƹ7,;V\]ݎМM8uJkؗ/l >FQ4cYv&Ғ˕Yϲ$!:祉'˞K?0h u4PN7:/ǤA[ʇeh鰰Qs[-g_p0ʓyf(MG#?0$^~~)q%?0u9!Q0?0Ƃ?r'Ő?r%$<@7qx^?r@njVu}g(}kUovTn+F?nK-YO?rо+yjStmedC*(\l?rz "UB5?n''n^EkQ؀ĆL??Vm;Gjy9GQ#E9?0$٣kPeB[ky2MIe%ݝ8O??`ʂkp#?0T?r~?npX8V#.7]d!0a`8(8Gd&[SUȷ2u#Bs˂è]6L0<T?n-wFtaKz CSᘣ6 #4f*"3^= ' rx{?r'@GT3~LyEJ?np> Nf??g(52N{K \(i?0; +|ӱcC|ĉ\zL9ʇgjbW2V???rJB?0XC2r1$><\=]>INwբrEB˔\b[|ct?rklRD83nyh2X?nzHb 5+VymW`9k`ſg_ɘ$Tet˖H?0 +v;x/1}iϳ̗`Ӿ%k^% %֘0 :J\w4HI~|yE=*oZ|=7anoH¸*&EJ~{Ypi[Țh7s~a}1`l.ӟf~ŝ@ɅhWhK؀xVe~]%3U?r5?n;}.tik B x˾?0#'?r|Hɚ[߀sz1n("i,5貆?r.+1j 9We8Iu0Bo&ێڡ8:Y ?rfnwO#4Z98Y(K2i?0 2`f+twll +?0޽G䜁^y+ӓd2&}0{?0w}'EOh?r~ $ֿ3Εage{?0v>/SubChKt??_Vtl.j=unK|+Vyy͹馡9l8#,"̞*i("ǞC/C^EfFXw:9k>-] `m_*ZP̤"AnUS;@"PwTeOlؼ4>$>E2v1ih!TB7LJS:Z''a0PaԤ%ܘ |#eWP |LW@d/M!)?0O臀+uɷ8,<؇T:^j[R3 +uQs=m?n;j7}Xе??4O;$;,UU7s3刕T_瑰rChl҈RH?rn$N_gNr:aLtCIsSk@$,鸂&s>œ_-tZZ眊&싅_rv̓ƙ׳a:6#|Mx$GKf3VT@ۢQ@YTd\Z Ne5i=af*pu!Ԩ0p(t;4M/BEi?rq{f^M \I+61ߗ>cP-*cyNfkȜyT9_O,M}FC:^)UQݲ-[ն&F8Y\R¬M+Imuu:7l{'gЛVdշ&Y6T_0&L-(Z`mU*ryvg kes%Pvٹhu:??,=':R;;NOtW3{*~v6>kתѧ{ܣ~{d ЄN\ٴB^ sg V__zS2ZyO֔=+Ԧ]խ?r:_Y:;V$Vz69n9OͯIn.ou՞??F y3f|+??tq#2qӭHak5Yz1)փt'``Nw!7?rdFg幜+M5˒I'T́Kr ]`P3beuF ?rBGdƧ^)"li幄?r??I5?n0V-t$Noض+0cȀX"^-]ђ$9بi0 3G4]d#_ocXԻ?rn*Ih8nBÛPl~j=>Pwf}2sWc 5?0A F|`?0)PG9" Buf%[v ,}?0\~lpѦ7܆{;J> -C+{("BHj<mlU{H4Xif^y59?r<2q9n*gJ&/laK阸*v2HK6rI+eVTYLcHQ^yphq^'m??v8U xb&V^A+Ґi Td7U4kf1?ri5 k+Z(.JoRρ#Wqgx~R\?n-&?r*hՏ#?nY}U `ϚUW=nkd{c-PRFYw;ⓓxDI&Y7|9=1qm=7.i2d6:F00Ic<7:/ tc>?0To`E+`8A=h)t&QaU OC~0+OpxraG <gPxE+#]Z?rm~?nB:pF̀`e%_8xz Pg'"D:0&AQ1yҨs@5鶮}&SfuNEKO٤<~G y?0Ny\gD| ry^6Ǭz⫖Xֱ?0hl. fȚ8V;f na -#RDmT)ݮo}ov;Da?r>LI꜔TcZKs!&hb<({\|3qRb]"bғ `DZE!hs s|*T| L4vNxcXxogV͂E+:7`y5X%Xd8 w7ec#??$Nr\&tLof,̣bJ6t&I$1{̗,eI$!h$7|C|V8/?n `ƺODL1g|OHE}؎3݈UfNL??]qi rag@k[-F/^|?0]f7Z@j1˯O-hCiDL흴??]+3&`^5C??W$VPuSF???n \ E?n??}Y]9O:9I kWxCK6zݖ|ku[+A,z=`(o~Y3ad[ѥ..0|{V_:H/6= x Q2y4pwIBxwˁ]gLz$!R\4M/ć9} Mb{_cwr粈X65.!bmO=]S9H1PQ8!`zwGYٶ⸧J%duD/p ESdIp~AҐ$??Kd엀r .͟kTkߤC0;+KFA`;!0!!)^ʟ5.4],{a=V@9!QW7?0?r?0bJ;]1~ S-8_Jx&hxPxya"v3بQt3IlN,KXy(006?0ۈ  f< f|o%#`б3,~id־KnU8B 5zL!9ާTYF62yd:ɝ0`lɍdYzewyq v3r=%ݔX.b|9R32q1"c5q9n$d޲$#H-0ˑxNF?ny!%a`ǁȑ5ʑ0r'#;<$Ƌ,H#k.[`#r;wfdȑb-0˵;#O&o`~|eIF?nYlY~򄑂?r ,ȕ+a12q{9/?r2W8eOFn`.8kKQ8d0q^YN4=)rVlx䔹~ϞH90j`=0ՍNSN0~9Ív2N#K?r+c`5eF6~n$\%d[,c0Rq|ئ?r0;?nu{w???? ??i7ƞ#ͮ|ky <?n52 á6'bЁq3^$bnC\JxA/hhb-T 07E=G?04I;v\[E36J^i|HZdK:?r,k׳SK4HsxY=_hzo^-]ÔW?0asHD9uOit JtL0T|HrV/1y@QI\ܾ֫+aW%7;ǀ;?0だ4tp_PnX7@a?n;Ğ`nf1\$P8O8Ĺg??ylcz;:&Lx"U{?n?rPm귃RӸ|;e?0U^.<\MUs(C\va"X!x8Q;{9+ F@30???06Rm錩9n4}a_?n"lrjpd?0eFݤ%mƁ_;-*GO:У)M?0l\z*ߔҨH@AWdG ``DYpew8pgYm7*Vg,'./tn6psݸ8$?nC,s;c+в?n,߅hȠ eW7t}N\IMiy^{;98D|wې]<+bi(|11m m43,yy3Ziy9gez‰`1 qM?n 8jsQ_5xb!'Ptwzġ$Q8blrv߽ڱ25ピfYү9| |+N\IFF:"% #qDhTwUOX]ۡqEBI7hAv+ ̦i??@A,eBԃ⃝`AM5*f9w(UM,I7c|l[AqGo`hh8һ-i"޽}['ħe?r RAhl]ؿn̔J]*Dh]o;#AUxؒG C@t6=S⛕zQdvkmi!cv}hRXXJT?nDo$$ӏecs:#AP)~8q=WP$ySEs/< EV 䘸PU2\k,G";H/@=tk‡m 8X<9I34!\(k*/%M??&:@?rlv?0K}Sq?0T  Q_z>,, \U0mB ޗun$J*<.??ȏ|/0nN\^nZ̏XM(NҶe;}ۃ64jϺl[Vhe !?n+`T)[Eg񴊢@e(|[OO,P 92, ծqs^}1*+6,nECp,ܛ s?r z,7\ zཌྷ)1U-Z?r;ݶs O_>Sb.|,&?rޏ307HM (3NPJToY*)cu(=JTe??!}^@=90km[?rUx0/#Eq4p԰.ڔ{W_JAw?0eJӋ\ǁ$1R:ߧ?rŋ^]ɳD׻B#eS1Qaޛ5ʴwm^SL9ېĐ8|oD6X󙗶17XQ(d3BYt)PWhA௰[XS7>C?0/`JVRY D&˓"Uuxi#wޘ%cWaml>K} tC^0ep]ŭuOٶukmdVřnK9ӝfYJQ),.mh.+'e3C >ms~~q@8&V h^RiBTfuw??DmXN 캰s(]??P\ǣ>RD?nSA)E\9f?? p+$?r/k?rЧ?rɴ'Ex`[b_(wɕW?nXU(?0f 볥C8a:MNm0LNT mo4{ͮcQW1&L-x ?n;6y: +d`Z}wf_6S7Đjw譬C^?0{!@rh…|n?n]t֢^H?rھ]5Z%P/þrm8*?rhQeLWr(/Nf&Y?r4^Fc x(Sjak.!V;M?nmFWP 8V[clK8_c?rToX,Q֊4xb{Q#f^3NZ'9u?riARCMb#Tfdw &m[zOOE*f>^aby?0jz?0awHlQѹX:4}Ȋs.ؕ<#[w6??x &}eӡwkٜu8?n{^?0k :>lerlnhDɭnb0ٍ4=qP??321/[Nw+@F!2FR|>X8I[LB5ǒ??۰M.D4c-ra&gx/C/DBxtW?0-|e _[3Fn/^kx:jBھ⩩Ķסs PpT??:,Y(kvϡ?0/yE_0|??ڐ<%Pvx?r,)f]'??߲3Nbc0ӭ?n,+eAI?0TpPo(ÿ@qե`+?nW0``l>&Gx_xremC%I"Hf:b}U#_ߢmwGr| FCG$~]QO4??BamNnXy`dK50R$U ??*f[c\7BG˗lu`^tuBߐ_R.Qٔ.Zm?0@~?0}ہ*s#PAC彐 nK}~Z*[zM2uAѣ+Q{~3pbJbŚUA+Wsݦg*<ѢӢ6RMẻ?nf(hGeۥCԽpɺ,wp~J~2٠?0z.f5d֪Z8J`#Y&J.N2_Cԉn6zF.>O1| 7ῑ[!M(߼90P#3v3-+:-%>=:??@VF[ۙ -#3z w,slȤ&͈HL0̤Mlv>*3QHJ}*#>>lDk<6{[7!|4)zn"?nq#3;Ňf,]x*8j[to9@$܇으mW@JAv^֍Ki_mf<^֔ncYH%gF)df{ ߿U-;A&`I^)O${/"}?n΀KMqt .7s{\m@^ߴeuHhHkG^_]t[T?0N֟Β)2~0PzjHeݵ쳕pEV_a6{B<8T$=UqO4ug,4OuQb%w?n6qcE T0ב'YVcžGCݬ,b'hanvt!(o\-q"M%O%) (f=8$ZЊak>Fd8Ľ`dO8E=0joK^S~Y nu~Z!={[V`@Ww/8'?ngT5h_=5tHo7Rf4*Lm?0L2wD% cI`/-+2.өM%z )^"ۉFz!6?0g?0sj^*M`m\Uņ_ ȬyL(1G7OesWU90P7""y^PĈU;^؋J 9rl]kXyyml7gKNmfIE`#ue[Fp_!xn_n R@Uk^̎s0+C +2hf#$xs.{egsUX/,IG/b /R#[,6ڕ=?nP?0+KE`ʂNp㾰+XkkHN]fxp;N3`yG/t8IoS?04~0;NkD?nq`";ښfJvh^Y ݪpՠ^1rJ=?rl+')]5~qA\?nԂ??>7oiRn]V%SN#U_f;{W̋\ݶr_19Td!-8Y 5jxc #`Vb???raJs}??ϳaQ/??ݚË8ƶ5(w*2bR8 @BY=jD$??w-|K@&FD???nX.ZP?r,4(%w ϿҵXfG.S_rv?r?rL'~'ً[g7s؃lw$=8/ڇ^ ˀebWdXT뤅 "^%+da;ڟula.oOI-u>up?rUS5Xpp~GfSwg:`(;"HGrj,rWƖ2Dd+MUz~+TM8xc7F_t/`i&t(luJ90HLs̛~C lSRmh@Q=|+\γDNI=D3$qmDXxk H?r!-`) P俽ǰsA7ߟ޽[H\l$}_ٰ彷5G&r,?0C>yi. K?n``QX#NU1s4#󞴅fD7g>@j\C~CP^)[at"1 w0XMD$mq#m&6zxElt3B?0n?0{Q׮XlmG !^Ck.U ,ߞ6C8RrqɊ8Ѐ??AC@UZ2g+ Q,Ͳ?rl"bN0aaVZ(=lgNٻzhc"68O+.9!.J| ߹:S?ng`cm]g5mqV0j&iCFDW%P>j53N(mYC7b!gVLsʝ75˒ezXM,uPI%O.ACѭ vj|U |^KzTnWn5.`rG܄ؕE߹gjlk}DcbuuA-e-=_>k8>R?0g.ӊ$PJVn(PXAƂ2\ԠIq?r.56kT˳V\@ģ?0COFy&cv* WV쒸vMmN6wRbc"i*ɻ1ڻ OXrȉ״_#=r'+_[mrbJIz0XfnNe31w+a4VV#.Iq6lҳf^k*U_Щ.튆Auy: M K=F\JqB~$ svU[Sh8woصd+Rs=P\`|4óBGIYj/%X3_u6`;muزi`[[_(#8$}6soٌ/d=ڤqzagx??UIP=42r,,+j+d~Eq C[{??;Nڎfz$Z!\5/1`sS\BW@/]8?r~>';c6\O۷b6 Tn͝B{m⫾a?0(ȍ ܴ59D1]/-W-`\>aXH%ψ+"kV?0Vpd剠ݛ72lG3V_P C}kA+Jz[BpW3񓷚GTÀp%ic6u,??T3u(+TJupˇHC+e 8nSndd&,Yբ?n$?0Ѓ=*p=mXծNuvPo,NVӰ-z2󇿏**yd$wپR;8\;!ɭn\Jm;b;ώ%yHRb3/qufwvRz띺A y,SΗ{${wPe}W3!_ne^wĤ@7Uo+|J ݤgX E* p_p7.SSRp֮E1HvL~k'; m_h:H^s7!%??!0ӹ}sLlݫ6 ^Zf׏,FAuE*$5nH#ٙ2q cogi/8s4º\^hJs|h~e ; n;bRf5n/wć.KխiSW @FEͼmfzN^V m*tg 6Έ𳛯 %G6mxg"$GIhoD{[ٝN7sz–sZStM-0.+Κ()6=.3sjZ訆23 &WwcgI?n*u 8vӋx泐8ova,4L5qU`?r3xhsF7YR5db9^HtEj??€\ iGQ栄鸙iV$N ,SӲ_lgB[/\A8˗/E}pvC1mmT[r -v9u޾Xw.jo[-A>%U8J tmMAVڛ[6(/1#7'Ѳ.- > 覥#W'G??4:?n%wc@`'n?rlyۯw^[ $A[3%x rp{n+-FᆧwT~M(Yy6JF^‡-[T%QRh2» -#h(*rALaKhU4oYoh(w{?n6m c|wV.>LwF\σl !?rrM[z'(_I6qXThqƑD%*AJz>+MW )(>8ETTr7L5BI6)?r}h*WR˶j(hb>vspUǕ?n;\nhXj?n??-fǶ|nV?nO(QGt ( 7?rf{J/+ӣgP?0#vPbDTi \كڷwGo<]9Z!EQV@ o-Tpd??KyV3?nAiathԾEUpT,^U䑽ϰc\c bp!A~oI6t3ܮrB`ǘ:^֘"]Ť` kdj;t:B~FCn?nA9}LH:)/K sGLj턈JEhC9L"2!'7-GT5;.??Yg1 ahxFպX+lnVB4Ugߨm6Ѩ\L~^8c;(mcM4n"Z1wc롊dCPӧDz(RAO>u[L¥b9R?0m{Vib`??f0 l2?n#jw(),]m㠰=f+<:jiPl3Z#"P+5JB*VlT--r??`'3rܥÅ_jlPQSWzIKDko'z %e^bqtAU'_s$"_chw7oS63,2@aSȟ,-QG1h(9IF߁lL?n}&yҡMFmT/YUh(.:Ż?rOe %?r3GGn˔qw?0/m|kX#F7QwPǴ_.փ~of~p 'Ý??AlK??Y|w7 ^CA =\$?n{H8 r`ݥC3XAc???n??|8U?rw15Xw?rD)^pbaƾk4vvXh0Ѫ} ǥ,H{fN-Vᲆ}/˫PClm_ÐC&I\I{kȏCQ(2$O4Gd(0~/{Do7K6=[ 6"y&2#?0????#)a>Oᢉ|lJEs$.KJM;h]c<,0(5Bgsj r=k;&n*؃+N+@?0?n0E.Ou2JM?nX߽p?rCŌeGтf1$3n.fH``ҩ3Ƕ|]h\-mY-vpz5IԪۏЩZKO%4Ł??5٣s??os!kf??O"ʥn@YK'kM,yj7hUij"talxlߞIHELvϤMgZ1?reSېMaEuF9e#~0Bhp@+dµHNCMx;$;c#.V >ё:%~NYur'Y@JЗV^׭.5h^ĄY;P2hI@IҍeuO_t,.O1}Kzc Fmj_?r* f,?0DS!1o4^ڈ ??uPstdz,H3#Q X \Ȣ{ ٶ~{A8PFS\ ;X@B[V{vYt:4s>GvlWr?nv{^p])u^y,A+Ltj&N ɱ}jKrREdU mi?0?rȤ 'e|gLMu??O2)JJ8N2yuX *D.D 3i6v^T:)x.-Yj #U]09n9$XB2%/???r \-_=c "zbl)s!Rڊ+2Χ8_ў^vvor+5*OvXGI۝I}]*ħ3MxS]mw ш??q+?0pR~/;es9삄>80y(տ)Lخh#*8i70]gD|6QSط,kokyr ,6_3``df}wO<ǧQ&?nbFe#UP"dd \oh+^Ot??l=PO`Hփ͆ ^jb=1Se9¯1S;"^jj#W>gYlc1,Zt-K??{l1>??a@Kݎ@l׉ⰷڜ=#ƾMۿHCׂ(EtJH_m[ג5t }loN?0{\2K5tzRW.X-Y CTh@Ccլ%"nFd}ѯ??$V*(U;gߧ=|%~jNoj/OR>/7DmZE .5k"Cϝd\~tS+ u= ̎\o֬)?n-#>=YZ9YTW8o+Gnf,5Btw [08o.TM$q{`FS]٠yU`8s͂#o,A΋!?nؐaC166b@ ֩`l~i޶ *.a26ο[Y?nc(v n;?ro&%bGbNҿ!RF2ѩpC,JĮ҂[Dd ?nY:NQMOD_Pɩ~7ԿI.XGO*"IFm!Ž_1aq-¡ X-dgd?n7x=@CX*%0f/Ch Zvv[㹪~C8Ҽ$x`FY>P mL߿)@1?0Fu`QtЍ6A(('cУzi0[*JXygZ&aT??` ٝp lm='Nid gPl4s4}liHyfk4&[$ VlF߀o$Lxmتя.~_bw@KU C2,{V(9ߠ@+_kPEXA)ᄋQ͗w'RJ^oI:+/] ^EfNE,a@vŏN~o?r|QmxUm믞>~ ??]ʉx??gCe-r=2+5B,^ޭPe|UpMMskѫ)E\R,E 7ۨ!??z5ߩ6;Hoг:Htk jI?rYh>2iF6zuP]bq#H6 o]U.6Jښ2kI_e݆;[\K(2 X[/Nz2Hީ\^MjUb]*/jY@ ~d&?0F_a,l`(uА}50^^64[0~T^n:Fv'*Zտ% IBV,{,Pȴ9GZP`o1O1Gty[^)Q?0??.G ߗtq"Z|Wl[%[ӄ~NAIGqa;| '{.6LĘ)n{6!Z!"-ZzVt`}ɞb}KX{+g-^3??~f`nó"r{zoeg.ٕ?0cڵXҹo$aC enk>o}sg^)mQL˜1 ̂.kJ1y*k~_4/:̊W`[O9+HlAi UIڎŁkdԹ3T>8ΪِCi&vnahR2W߀q n5W毐/ϢS^7;dzDҰmyӹ3µNE|4 ̹+/exװ [fWʈN7O.?0Mk@co Qȼ^96vuD,aS]͒-& 5t8u2;?0pFwgG8h^H:6[& ]zsr+ 񵾏t{iFZ'_Y04 !ZqeM'&w"?n/7N\n𺸽_a,>+Aް?r6s1Gs%3!>^46W|\x!y?0\iKR{eѿrduc:} EOjՉg{5:~3 3b5XQ'x|zQy(ʏ??(J$%ToJeU'e8I,-\$4Jv0MɏU}mY|79mYiR2;g-./a)K%aEޭc(+B 62 lQy}-'}9|.ZVk|6v9#_m9WCzT}eٴA{V @2񋥷Y ;g??kSu6STk(/6cNVeߥ-XO8PV?rs%Q$N7eզm?0R>D$eN?nߋ~pXPe׵9 ]4KqT$_.jv)ݫԚO_i413+Z)4hK5v?nԲ";eg.Ns%ƚ 3kxpAD+_W.#+GFUZ/GQ1yδɱg7"k7ái_lK!pN|[Ҥ vZ;`A~R藾.ENg,ɦy$P?nNpa)([#"U?0OwlUF߭.I,'i?0$#g 1ձ.Z*Nژ>RmP+FL"*n7ѹXf͹s?r d3)L$???0B۳FN].?rѫ0*֖`P\XtOth7U&y#$@m8 MTbf?n v9IRn ۮb5$MW !kx-n{y߸Ji@??N$}r9~Jn[ dȾr\-XDFra6u&Sq*kNp:`8Q8}82J5fSʮ;F6t'0fEvIXAW'G!{[ň1,&?n"p.Ł\," S!N(dC&( 2s,$U>וo\.wn&]t,9C=?rutIt\Il^B\#k$~NXz,/3]$I\7ZUE''L?0G??alj1o}*RZNX^W3EkPYsC>^ﳦ|ɪJݿeZ j'%Ÿ׾1"-[\ P?rlò 7l=[ɎHu$lhx!Gͦ0?0I^dU60!:ITekR.ؘ(ݣyĤL=^Өbƥ#ۑz{e?rt%D{7o0I O6;;W[=˵S%3,5BQ?rSlj\4ESUYm4֢XjlZ,*2UD#ŦfC}wi[55#7$9%.Ϧgv}0 H^+8(& ??I hWmD P QW_mP%lr-Z_ ʋk2M;-z;xg8BSFݸ)ʛQ۵l~7Am:/$\E}v0e\NZcIDy1lFz=m.>d%1]6 :z|5 ai7^tU1H,\`kۓ,cOתoNTH*W'!bf+G>vBIWEAF^[)_C+NQhHc???n,F`d:x1\lbf̋cU#(]Q/:tol&.tпy^wnMꈢyh]&B?n_J6XZ=KJ.<PGl t2p,[-|V*&+`OPm P4DI>iTO?r$!B "KנF_995A`ѵvy4<cYV5.*؀qBY`ʶe,hnmquCAs̸85hƁ'(3 0f ??GtN;??<;W^]".ioz0N_aR0dOe,^:H#[#Wi󸘈&+v "za6?nnpg;Lq83-],:*x1C@)kJ`BvYU0'OzfȟlMuBLުt ΝY1 L)y x9oc{@| je]<}f^R=q>LT75u|.@8|?n$U8#WF@Ե[fm,\]ָ3̠%lTHE"E}fSӕ' >b?07,OJ񟙫r$+S0P&D:uM.fr3cB6 .`}Gu8jB\%>٩ЉJUAa. Iy?r͆Ȓ{(`>ych{mYu;3ռek@vJZ5<`pR 65UKgUȻGwNۨ֯!*{ΞvBykQY,0-|;_kyK+m0VL~F|~1^~uʺqD3ع&MfrnٟoT,B-E*]cp?0R!e 4Mn>YKN!pYdc\3el *ouu2+vWgjUO[th5(VSɚF-2_?r:& %4u&1hʸkn^c?rhY~"g]mA0+,P}kM記WYy4ɧ-l3јh'ذ?rFݢÂ??aXt7DWMExЍtSDOZlhB=C"??4YޤG?r#e䖔tl:_'&DlS} ]Eh`>|xpipvC0?0f-Q6Y}a>=b?0ֵ`TMfrfaOqzsq/O#ģyd\Q[D|*LWTT[$+sqjS MTY̖{rToޤӓx '6s@=-k$GƊ6Բ%L-dhZ|/4Coԑƶ45?0v{/9n4[!\!?nu,x"P2gKgɢdulϕ k۠Uˤ1o!PW)?0XR򄐔|oSi9UlZ/P""?0[4_*-it`u2)C??1DTAZZb0lXV?r-'qGEJ#n5yۧjfFm"xUF5+uɬr.x??e*9K ⭿AULTk QlK?r.;#C`Os[jo_s{dX^m W5LܤϛV԰BmA/f1?n !5?rUb*6yJ 2U"m#dΏ3?r@Z4.N^o/ݶroheݴ弤]*9]k?rC02?0ϼQ%yԳ7F*S2CϨ.Mj= r y7X?0(dHU8+Y6Y kc(w"x 9XTs =uaL;r)˘Q |?r'??x(NAR6 mGޫ.<OJIIđ&zeق3uB,}O^i)J -\LNA!^CUxqNEt_*Z/}ح:"__ku51-V7=\ 6_y[@-,U̚1V?roȡnƀ?0?n&"OBIN=Y,̚x&Xh9;JUe;CU'#UQqS -#b'vCz-߫-ZEܧ藯?nX:|RygW }Xds|gLF2kw(߿0hq3@z7۾()05:|fS'lƚ;?0;jS!9H`g^iG{"-X/&"9R7dC?reHBIcyV^i@WCWŦ ?n??h$ƮP-3թt`丝=Pڭ9Jmkr_,;ipGv|݄sdD+Z6HCU|I˫tPXn4,?0V/GZc>Z0@sbS焹Fcm`G+Lֆ"iDHCe7rs =ǩ*<ԩV?n%{zpNHEf=Hm\?n'~V>QXF\RapX\5y-낀S~/Eq7-mmpK1*!xlp_|àNuPIhR~K6Z;1y3t oGZe`ZOU`ERɇGE~>z?rdp?rچULxmԠ+ЄtmN6j'C6]uBܒyd2d%`e6f=ud`+?nGk>MeZ8ouȊ\n1{*t:hѴf;*&8*FDfyㆲ%gvOc:CPe:z??^]ݏn:e7gDAS??I 8S8XZQ(RPzĒ_2grn4g"9qEOWO-0kO7rvӇ?r?r}R?rheg|=\x-kM*l?rZ?nVM?rMV0yc+??!<~ϻI~^B\eLvڸ#;lg*CktQ<朱hPNWA \+h?0Ϧ3jJ)alcl.ЫjR~hPI-"b%3RȯbǦ-s}5H·=G y"??c,|é]`b ??ls SQz8g`Wt<9Szz|}@iPdQ8M=EER*GWGNڿb&VYzZ5O@]CŨ?0+ޡ?r`a&) ܐ> tVGSZ{|ʆG!Sض\7;3&!n9nbO|1vqJVpY+ߵ*!s#&-;`FzBujͫeu(ni?01 żSH~ CT~ދ}kΉ ~魾qmx!_e֠( v f7ؐhYauҷ8bU??N?0'lA֮|bܭݚ[SNڏ{&AAb \N?0P?? Nb9hBmwnX J[J:)>dWÒ6 ]n H?r׾l'W\ZͿZ8bf'‰zǴV׬&pXJGe̋N3KNH"Z/@(A N3P"K»ԌSXT{uu~ ٱd刞?n*0V:I01S#I0S"*\['{DckRŨ'J0~GJ.xMYɳ띄1h%*BD/d^iqvbȂaK^\Iy$qzla5 zY8thԀBu9FAIzE f8]S0̱klAɰQɇv1GeL _fim>kG.ʨ[?0l|JvKJx.-]?n%s]!#C1B4#sR(O?rY?0,?r6x}!ݸ k#]?0ƵlgrU1Zƃ`bY`Ǚ6^AffB@Z))d.N|af| u(L|<oQ#& հߡIT~{|jCyxuؒپ\K[=wU-3zm#Y*džeXm?nZ6"kFɀQlhc` G?0vq:P&f65CkkKUCp#qgGBUϘhKGO&O2stfhmSsZqv\sEoХSFJrrleam2%S׍1w!yOS'l)&{s|XpJB W>6 =Dп1i%0o&-l%ֹ??d |AN; 8cetq89CB1E/`n8"٬X`QXU#@[c[oH?0]J2]=ܘ)4|0ĄʫYESz 0sHQFgvY뭸}?r_]uoBo 72"~%y3vk?nZwUQغFOmd}ͽ^k{Z},7t+8](OpA2B[$#mWY^ƨSCW=@eC^um=a{\:EImCxY(q\%T8ad/ez?rhl0VR h|fpyQD7k6\ڀBNBUu_}@%1!$lDӢ?n_ګ+>y3Ո>Agn R U6Co{'x* d9hI%G.[zPzƽ`%=CB")_9[T|ĭ7I}?nܘ_aA;]WK:ݾZ oJea!2+jCV{+rQ^'Ӊ&*y}&hP*Gs=%nQ\㢞??8q'"v MS\i~voރw #2 _+٧N.5ӽMOeML.KμDu+a՟#-.>&/bx:WXhx??tQžRmJf?0.\'̘+T\>*Xl qڶ 65!wـ vĕeMPQ?rЬ#?nU\{?0㟛b~6 GIqt?0(a[KMQ?0I6-y6,zݤ%:zbװ)F1Jv~~ut4 / -|?r00ɻƐ?r &]VQ¡tmq xk ( o7a᚝ϲ?rG$+fj0>EdA\ž)6BoØ%l,WKMdЛ{Ee5wuuUw`^"tj.$~BsmC@㜭E9j{=`I%Wl0Jh/^i7x0@|jƎv_mxS'.3ޅQ͊L{ -#+<F: '?r4KO=W>5"PEE:T'(Y]\kH 6\I'Ik>hAWܔ|}#|M>O?? IpjOS{ |C >Ov_P"A.}Ѭ/(v5܇ jD~Լׇ>5?0x;/h@[`5pڪ Ɵ?0]`=p[C??SÄ???rC_h_9{%JӀ9p???r?0 ݷ7?0K?0?0!*/?n<ÿP??>U ~`aO4`egl⃣9cß9'dƞ}t_5.jN_We:?0+Uq}/3ǟ4gnt!.3 xE󃬽`r;>®9kWג$G4;RwLCO=b֪Blxf'Xʮm]Aͯd2>Z=8˵F?n@^>PHlyK{BVCA#Ǝ{λ_&,7zDTHl9hi6q n㓧H6Q/SfB?nSA<lBs%Fxd'ퟭHRΣ{ώz^ǀjĜ!,xN0ԷQENWïR3Z ?0߽~tw.|?n=m~Vbok]*`ԜZ[4I^xgJ6%Vbk* w#{dѼ?rw\Wor<޾/vЏs.umʽc"A2Tɪpoˢ????y,+ߓǂD}6??f_G_?rom@W?n <VTIk4uNPI]]Zҵ.-r\CnY9f4:+bBB[~;NTt??SPY5*00:jj5o$ ]kO4#DV-Z&W}#A F;oI(J@zާk~B@8K6\*ҭ5Uu:Nv,QM[ɼ1L'x6]@oQB[_ߪب^coN`7g>sٖvז4?rUH?n?n5,Զ/kN6tj`iDx@A%n߰  ԓ*dH?0ܶ7x+}x Z"'!7%Xe??f0-i2wWCļ$>V  BU+Ġ7WC"/?0 qgp8BfdLŌrϦ|9Xt':vLAp`qx&"!5I/I'Z he$-J&a!Zɫ]왉EgFl7Of퀳0iI4^NUcD'A04QMYT}jW??&/W؞TNN>}‚0r0Y@JNP}Y;E&M+jPjEɡu |?rL?nkjDr¥|j<5 zamugy"áȅ>/n5?r آ{i-uؾuI/Y$ßb_wCqxqk,w=uWQ Cusyn< 5b*Q{)-E6)뜲01??]h-pYp8MMhhlr*d/ϋ!>BY駻ojU{|eu0òvSɳ=LV}/!AbJC??[ѤYjqfofuah%Q?nKn?raqWhE="1*_g}ǣ+??d+ q5apb"NMd5)'Ww՝}uӼPt~ "jD$1?0@K AXH6ZL2 VT$1ʁVN,.pmwYbISpܮjXCgWAX09;qNV˙r9YM*˂W\tH~羁B.[],/g'~7>`N؊mZw eع(HksGgP_xF3U;mبddl]{(|lI*hΑZ. _{ko]%VY2ow.#NICxmlc%nƴBkjRVjh}E l6BHEnE[*v2P.ƴ!ك۳o~) T~TkkK>Uj!>gEr>?n{>h9 G ?n3;u/WhؙŕNCDUZ›?0miݑPZ `KO[6?reEЧzI<'xoue3:F @cIHUipx[/:fEZ?r'tjmaM!o:Ϻ+?nXu-M@ =PtnsDa6A3xqGTf5yטrp̀Ѥ\haqN??{V)S^dΞ՝[Ws*RwhRN駧H™]!OGL)nx!>`uXA(ܔ@?n٭vK^O| )]L ;o+gj%&.luYщV?rpbtĒcqZ?08?nAb=r]5MtƏzwBT Ǚ{hr+I0v"Z?nBRy nfmg}=VY/2;>twsA?rBFtxyE?rrLd8ݴ>i<(`X k1=C-9SKHnnZǽqx?n]M `v:6t*{(\ uK ʘJ=)gʗSHUKLJNյiK3P7E:{Tz4Һiyuҿ??Ϟ??(5׸k&:c1~a;v\4aI-砡3q, _?nNQT!77??X5?n5h9etdx>??#R/8/]s?07#8/R,D(᱃6&CktAبxo[&??yrzz\dmk?rΞi%?0ÙԟN`97pK8<*6iYT)xi O>$'Ųv'O,)y*uyөm.E|Ѡ&zRGnk2Gˊ2?0{+kѪGJ)5:C]T[Lh*.FߚWjK9$ŅQ?0F/9 M҃~$;t%i%5#/WW1_Df{g;D_*D#7|qa|?r@:O1sSȟ ISw׀͢`(5?narmB9@D۩8k)vCfkqvr:V鋂VJ[MrF#4@L3ے-xZ ??j,8/?nl8?nەiOsLȬF*仦y"Os1HNДyJ}|yIZIɝ"IM &U#"ǹ:%'??%ר |R(ŬAe!U):q@B\b* a_7Ԍ3Y]ċ/H[(r^HC;WZV *ςZОf*zm̕ɼYn/L\1IBf{s@k6⽡zkPT"y87Nc}hid9`M¬eOzPH&C &ƴ/kۓƁE9eB6bi%MBFAmSm"65ry4;'PO|[SQtG<;r}קd1;8?0,H =ZDžD@=IB+ZّkM[T9ĨC:\^u~4&iz8RZ'0|oJ;Q=Ê|n4Dx^Êd` *U8bVn,w#Jd0|??^^S$-R}k?0. t~BSAd?n;qUԟ%!䬶U.=sY]&J'w){p씱# ,e 'vc&ɽLNςWڣm wQAT?r~ <{5k8u铹l91z]&TGW-m9]yr(7~ۯTZdtH Xrb䙠]Ӕ(Hd#rg[N[}d5јoX2MF\R3\DҨ 3OpE`K! I?rYjid.8*zb;:'SeT:l,92<3z?rž/OI!hϮd)V~nyt7_j|@TzaƦ.jFAlњ:{ȃ<~|ȒF}WAxqG(?r#5O*7UDpCif=V??uFypV5Md>ћ&d m\f+@N2p6kUQ72`tHt`6_tL+u4$LO<`(^"M‹a~p2Q6Ke"46ܿ6KtRo*vB+eSeCC;CwCD|..{G`TR}D^]>Xuj5jVs&RYVғsMp~F[,3#%-ln\@W6umгشBStFQ؉LT m%nEł+Ty -d?nByuPCTƜK?n_LYV?ndڡfJqh[e`ΚQ-:" 3'ֈxao'Bt(/B4qy(UӦVxJ|,s!o)5ntGz٩[)I:tC*?nʡrR8IV_aIwsyKJ`m7#]2yL\KV:sR/ѓxo[Ye(П4oTJo/v+J^ 춊T?rB?n6ba?n2U;t3鰐lJH%??W ͦ&V6\Tѧ>G{P%?0d.*264SN_?n-?r3B݂??0 Rqޜo1^33VV\R; Mb,=* vd[mD7:5?nag5حblC+ 9s`?rlݕf1 c@ƐɈ'gQId@θ^>6::/u`i9ojXOO>nvUӺYN5ƭ1?nWFr^2g(0!h1yB[>hFQ>39B_ls)%W9mv,>zq`UC~x!ZѵÉ߲#`6*k 7QﴊL}*0Q:quR?0%l b?0`$N a59NJ?n"n(F6; 8gh K $[\p?rğ=?0{ߖM>\J.137^??PbZx{L2؋0x"?r)uJ:ށS.o唉!Ȣ[}]!AC÷C]zm#8hd>u(%cv'x;2rUcCj\?0W?0 &Im qliSt 9R%ĢvuTnɜZDF NJ?nUClğXp 'Z?r)ɧ\1n3d g%%Z9M6m8ɞ aʭ0% ׎iRі_Nj7BX֠3ݽT[Jţt7)zo-WJoD;lLcUZCs>im `&wZ̫%:M7aJn^kT,Qs)qF;j`>GBqr{UW|^.£M)8mc_>nh yv(IUx_ޜiVkO O|?r)<8;??e8~|ϑzOhxԙU/sFf&;NyϻM9Ԑcy<3IVB$?rfPo7?rʡaD/_@?0ݝҿBVy2gc!m;#m6ܚv&dx=B芿4kN:?? \O/]A[?n:а-z+p/Rfӿ&1vl&Kzr)u1??k7(>Etnh4;kXK$>W^Lȍqoѳ_\bx>v1DxI]ɰ"h8/* [/.UXe|ļ0W@M.^ΑD;EW>wG7ԭrAS-KDINZkl#C&Õ] 40t0J/.vA?rY՟xD ||FDLMc|k*xM_^¹tYoB;ZE؆)-T/8h5{,3UV yT˃f}T89e:awq^R$H3bx/l}?nAhɑdP\ Aʗ^lqӽnPNMM,6ʻ5([m@E 7qU@"ΡXAvvI}`!evzr_??ink(=rM\4^P&@0nq\cIFnbw轏\hqwWNEiף\PQCe.|}jnJ?n<47]}?0Szd}Q@9,axLǸ s8x!pQVrĖYO1[5Բ??TWæ}֚' /lH@D6f1w֏Z9۝'>oƴQ O@P.C)'HKd7lgv>Jv٭>,V˽]?n>,RmpyT|Qla"-\#-8x+𛦆S@#??yX$6GZDںv4=۽:D>YpłHܚ??p4Ezi͡)Ij"hgꉓD?rSZ7߭8gQ[ \`7_o5=/Q??0AX4vc3{Jž/BƶqQ-,/ 9apie k૨ 0"1G:^.Ѣ:y(ZL??b|E/3ߪmel_Z|coQ|}Gm@G"o^wls3Rm}XLdezV}T۳<1CM{CA%3e>YoI4[fױ9-^!yRTWnT=v)/JF7>YjwklcA?0q&uǶlx$\]K3 ??tnyX:qIٲgor??):q/Z8L?0͞%"hd#j'5c-..Whx1"6 n`0*kSxï||_hjPccx]_:E??Bۋ&8oUS+J!PL?n|VM\vuI4VE.5'"I6+KpA_Q"P0$CliK+}Bڀ*,t@!u2r*s?rsCl\7_ŔE>xyЩOmzSGBmjA7שC;x׺9epEb{~Wf\EMCd΍̲`51+jH>mX@#+vZkg37|L=Pnս)lp 4[97ß5~//~8Y-WW GK_zMg-!~ bm]ljq;B)Wx?r5ʱn U1 5?nq~c7,V>tab1QK=iIpiGZ)_Ń#GEыSJ`EQkGT50tvr"w o{ջ<5T/(.S%~m_#/e˫J??jadPN'qxq@ "k-2"-6|y枠{N7OH'و@rŸ6fǔ i@uٵ3}]Q~NSImLǖ0ڏs7 [|[/}Xi9:Oet.q~yռ!Reb3U;+jf,vAb0,!=CyٔB+ryCX#IoWHA RST1G及Upcm|X9Sk@7e͍}#%Իp2ߔ4??VNUS;/qpT=w)_!4KZWؚ"Ng1s))peAUϦ8\Ya PC՛1_m\n6@'#Ίr;{Q^"qiP5Gdְ(1!SScsշG>r,|pfٞy7`>dL{xp'=uL[҄C:U,2?rI\!Ds2`|(h}5aX"l_\SVKr-DFiT&?r 1E{I+ﯚ}JÉĠsw?n깘,4MiiۄX(.65q%EK~Ի*(|^êp*n*<><0'w>8|Wԋ>{'<[ %(FG?0SIGq~/p?0?0믻U"N|ZX?0<1Okxamlh˰"znY;{≭8I%HKآʎ9_)ǀI)]-BV$vQJP9Sn^Ǡ6t 8]#^{??&1HmtY0AW~Za߅a3{.i'I3ec0Nc2˛h29Ux+:Ƽh);?rU6Wo\`ݰjw4PﭛFiDH}acA_=ysΤ93[C??ƐkrRYf/ yd8A51\W59V}I r8ЙP'#%}pJgҊ?nki?rq!P:UDm.A5`H݃X/hdZ9޸Ml ,^^h `ο%o8E}a8C7[{1Q؆Eeyw5]eVIγuYM۬ʫ"1ߚ$P?0$ q~{M՜mYg[S÷7l*i|?nVr*WςƄCT<9|nՖ ̽Em\$-@ʤŧSƚBZ[￘vz`#-vLҚi4 ZudTƋFwXxڬm4Э'Ɯ7^+?0D~se,O3M 07=. -#G?nh1 Y(X9qKՠ9w@͊+@iaAd8iC}??)6#y*p܇A'_\wm1[)/p>S1$'fU'MuD ᩹S~~wE[_.U1"\ZTJvxM[-n*t-ѣk҄SJZb9u3qB#%g. $㧋,hFa|k{ ( ӧY^S#o泐K y|mbfA=sҢvQ?07dJs!K5OjUx׭Ln-66t6i;SK|J`'u҃&t`DƐ"ԇ2id.H\06{$H]¥[c??|(>Mܥh-"XV4=K3XꗆD,wFNiN{yߍ=d֫Y؛怙7XI5e?n?0NCp$$߹F?n='NݩQ:a Jҗ>sAԟdɗyτ1 {S :W-pKZ^5ss*ͼaKY^y^y ަZlI O9ߐ/-]'!p"a+Y4"??IGiCQΘˍMlaѨ BA mUTyc!XjN o2rR9{S ?rI-sRZ<7d@q*dg%vZV@ 66u/W vg3M[_!;3??8D??t$ZZzqGB.YrgxM U6=eD&MDCI8>.٬H^Ӵl>i0WM }.x}6\D(O\st6aI@+Rrc$${)l6Ux?rh._B+C$*19ooA 38Eǐ?nX06R5*k\|5wf\΄=FM-d?n*r3t<#czݤ뵡U㊤>:fOe}WsgAy%L5^??O_X鱝K7m#[d(")LpcluBֆ5&b(9.N𕊷p?0!@ڠ??;*:lx-w`Їѵ?0m똔>??1x^‚}}6Иp0tVY9-)G0ݽ58e~Tg i??)ӟ̉c!묛hhWr_ǭթt(|#!#Ǝ`*=?r~[ 4'8ZY\rBnn"ɠUf}aS 7ؤJ{E/loCB[9lv{pZ[K"_7X q oO#.&MMLym6ֈx2TϵTٳ|tF ??uL\Llx#Rw[w[NgbIE@[lʎҘ??vgcDC4:ϥao{H'ͣj^jh|!lpYVCKHVꚽti򗹚#68#"~YLl+]c"a_GE.ȿre. _j~""3撝p'eM]t-2GՋ>?nI:N]Zu.,ӯ="pA 0 !SMïv^-~ w$\YݥI#ݻraYsP aEx _ލ?ry8j*벽kb@>EiU{a/V_Ii0}P.] `@5 >૰lβrpQyTq²+jJ?0mbu.ܵ~ L+8]|he^YhJ۪/uE֬TM]xQK Wjq>!)J9ã} =ٳ/e5z6~VZBuv??~UAl̮vk6!q~3o 2??EN罿oUm^: UNf?r ngCuO"7ڗ>ZD-%#8ܶachQ^[[QKJ+Z L& ?0nz~?0"(=(Fbf)N?0ϙx)ͣ!'8sժF#7VbD.J/d>=]A 92kȚx)$LEv8TmNèViE^prt &y\]\r6Li*=q]e|q|1]E Z72;9??wjBoN}:S3N 7e??S^TD@+aI6yГ #k+LL6uMkggbq qK`_v+9OV,dYo2)m:fx5Kok꡴\\\̛⣣U?nO>,X^jȂ&.x'AaT, r/g*&Qa9|%ڥ˷]Y(V9v-\*U0'Lhɰ.v@{As=/^~?nW/X!{`?r+Kढ़K6]xУ/NAnuE$+L.r t;6e?0+_4;{AI6M=?n !E7!@ZXMEycs ƴ hQ3Ҩh$袱@ˤ1vȖXAvT`W!9s7SOL%/t{Cw$w33"Ô'Odiᔘ+;TQ=2,0NgÃJ®ߔk??r.j7?nG Ō?0?nCFW'OYk +nnAR`8:Y<taK@4"Ki790??j^.ܮiHy U!'I%Bnn*dEm -=Wl-B!Bb`uWzhR!?r$4#ܓ£BzxG4Dfe(+CM_dy\|n!dƆkx5!Α%2GxB>4vuDfe(ȪIH!f.u/bmz0/OHwG/BC mYx=IBnxpZhn~hK*dYꇅ%8pvMU#´py ha -#xտ=WUڴof v& s?naTHs-O!)P8iBn7|l8HQ!9kx7n*!7\w8CDnw_IB27Q?n)iZhúޟgCn:D'JqW.1fwn96cfy{nǎěnn??A\s#HA@׿:S@- 61诩)g3[]M06 lMY G2˗QS24)"oV[_(NV q 9űvw5:n:(F"c4ya^f??PtUfǂ>F1}l})!a& 8E?rQO M?r{?0iiIe;\*{MFuE~Fv5*K21J/y!B?nt֏ͽ˸5=;cΎ?r_v_??¾^-5XTۏ?n4UEfoџuB8Bnqvz4ĕ99)f^teQ.RFSg^d$bÙD8AYmHré.R~K)W7/W)SQRQ?ni80rED 體ܵ Lle>=vMkՕwiK^Cx5;"Y\U""xR\vgo)M)?0xk绣 7Rm&e-Y,J$ґ??#hE<*OI&l'wCƨ,RH5xh&[:-gt=1ֻ8Gi4A@x߉өU.(+#\t"+n55%I^۩V?nȨ|ĩ?0vY#sCg2~\U~x(",؈|y\U7_d4~;ĬO{>ȏ.5QH Vg.5& e'o~D({*4\s2qnpĂ~M$xPq?r~ž6pp~ RE>cF Q%\OpG-?r6} zt3*u{`J9q^E@í-?0-X\?rh)U^T4h!Uh"`}TK؜X-`VXj*]+D=\hnTӍgTͅ?nZ=eC`rnśBGڦ h_ʁщsLK݄'?rGV k4Pē?nqr0Z`iTU[ew_ųyL3%SVdb].PtYmp[" %~uXN'o2z%16T^%8ڨcMXh u&QHɫ$|":ߒ zJK1VKp2 aEJi:0cR?0^im5p'Pz,w|xAJK$q{m2,I7?0e,'YVKaZ&{tkଏ$Ԏ$̥I:pESQ9$98۔A{JVu'??IjTظ|o>xB{k#̝ u%)p`'&bNÀ4e?0~H.I'U`?0`?0!o`m/1KқuvL]esu:5a]HW#CcV#|8ˢDr"(eh"ܦ?r2g2LR/8; M>edIpn\uN@|HSo.MuvJize`v6'hDzq OL?0}=񭈊V&tgE18Ah`0gp(-?n4w*J#.O5EꏃۑYk\u<`zqHťIxoEb rݍưQ/)d!XnEu&v6 +L݃t8??~~l=24Rv+b'[y;ML]4o%Ԇv5(*2xppRp??d$BΚ`{e4lr@fw5_y>g'Na9y_Mu??T!#޺^/2ft)]&[},F bv86Q="qıVUۼg/sb'Z?n `B@.e CLVw%6UE\\xdt壪B%}KCR *$h[f*^:OI"%?0?0%C:3&~=*Xd.@WU@YKb"qěc}AapF9Az$W>" 餀X{=hN= tUSNĠIV?nł]qe <8ʚ4g 5hYmٳZЂ&n}Մ??qq*$jIw(Zv[v{z/߲l)g\s(iOI6b\?n=f??Y>x&T]d)>MĩnJ/gŌYw8VtVf}jOKD#&l0~eFM})'$xrƝ)??O^)7A4brCRYd_??L慅k LX?rҁqU5H7=1f w7w&C^DEI!9d,x^343ԙ37=x8ѳ6D{5gšdS??wĶCHf 2& $i(m>Ud괗l̸߄uC󃐍VLuņ ˠ?n;|5llŬ"!ivJU3.*=PgYtR-4@w .,(u?nXE }ExrWɐ^9oZqzopLcl+nIk@D9[:c7.t?r}&t5gY5)Ux ?na`pT/``( (*= 9p ]nEgX˕O6^R3#YsL$;r2?nw/FlEp҃dx;Ѣhᝠe-`PGЬ٬Sdl--0-??&W?08?rz '$gKe+?rHՒre1Պ"YQ)GR4zp({K+1dg$LJ4,ަibO3mISvZfIjNw~$5.\.*x6!AkoERj w@%SYU"}5^hO&'wɒt_}^Qef-fgt di{xKBUẁowgkwQ R)kXl' 'ݛexVe2ia$/pXځ0R|Ku&s9Q5A-,n$SX?nf/KU%t0?0jcyaش#zFiVf#҈n̸B觤}+ŖTl=)^]Kd2ɹ?0Lx88LkhUӈM4ÖZ?r ?0?n>C{VE̿K2$egSIޡgq$lL&a#I؈a_&<O܄447/tX<aLq%Ex:bjSOr??a\BcuN?nko@텕ƘpBH?0p;,H8-ܯ4-ނei EmߨQs}<:JР:v?nJX'{r9a4,\DO9kL87 NOYO'N(9??A:o=OG1WeEΊ)k: "?numSֳZb߅^ `|uVYT9Kڅ(5vФ߶"y 7|IAMatvh,!#9B ~1SeOw(K}}AP5 ލ&.PIF)xD>p)!m#=芠Co]ƸITbM8CLޒ&ikR=a??hEJ_W%֘d)Q1")31':C=[q&<[?r;"H4zpׯe<-an̈́]/2?0:j7g6Sbo íF`$82(XpOK"ݜK7!;} Cj|7猆`5ݴ+Ymu*h늩nA\^Sfs[n'/^BTU 뎙u:Nn,qt݃?0Eq_wO;jks׬.H F\KQb|yܩ:]Z}bv <|9#Df٫tncM V=R8[{bU_L6?rfc}+.MjZ;ʝi#N!R6!Aՙ IQ 7.VY)K!SaCӁ&w#'.~{9^s9K^ePY l{m!Qx QC^UaHL$.L6c.?n?ni4?rUJ^Mt9m5oEU-rD2Jy>*DiWVBli1&QhQlr^RZJq7+>'&a{?rR](?0-C12b`\?nKҡ}$P+(Bu^_beRҶrގBU l"g8C~0 &=BqNpSxr㺏`fz;x.3enAkNz?nxd ;ɂN\<}1Ƹ})b\Et9XQ:Y?n'3hίo6:??-4.UyԽqгf6r1??,`Q^NeV+&|ݖ|m= VJ߬D9-]+Шk";Ž}Rl(Hc*UhYvmֶ((~'ڪ$۹Be ~3ϕ??M~tg8ͩp]dV (3Es嫨iԭdbr˫уlj\F3Q?03?n̯Ɍ~zZIy</^=yXϬu{F>,N6`-=v!?r9U-Q=̜l+d/^P4y&em*mMnQ:)CFR9 eLˤ0IT#-S&DRh[u_p*%3ژS6Nk ;"w[LRq;ZXwS^(WCrJqyL[֊bVe d~Zm%[QػQ/Q6Sx 5VpÅErܞf=>)trK^+eߖbW펅n+ۛ!rV(+?r_83m L L L L ̥\;yK:ϻ+q4|ȏxNR޽p_6% IN"BA*I=0?0L]8\8g1N6Ѳo7aq7iq7eqS7mq7cq3`OV 1QOGs!1Syi?0/?ns?na{B8&̞8ZHـhXA4*^bD\$SC!:3]VʘOmVK%H67f3O!лBj.C@|C??)=6'_6A;)7[<[%D8{9.X[+.w&{[doٖ[bv =\;Uӣpdk?nӢGʋ?nbLs ??}Ԍl??>K0165`ԍbSwbpn\TԳaDv߮k.8{Ðqj>Zyr\kejSPPZPJȲrb H3$?n*匞xnIJ@%ibDlP0#UU_-z+iSwb Qŋ( /GI1cO'NY5Uk%A^ea0`V0 4b`h;H8C9"/D<]a_(/_?rB5lMCj m`80M0H0w9$ N]SHQXN"EE"Q4~*€=(6]IQ@W$ݡSZT|c>3V?rM 7nh=AV`/vChh3)c0nZb|r'+@;?rV Xj#䇒~??54WׂZHHɑ:?n%x{\C`,L@'xyʈ-֊d#sS棑Ј]/m!BhZ"(7l|l_(pxGG8lTE6°)p[4qR0/廛] fmb`̐?ne%bv??J*pvߡù;zN)ƌ;R+{W>c~bU]"$7#f[u1\Ye jrb)[aCmS??":\I(`$QYrE "NZmHX"3T]c)`=z9eDPluG6{ʂ'_QXIc}]t5܂ͮ|Z\Na95+؜*Ʋzla^PSegX^qW3n"dI֗'sYZlvӤxbO//OU"̱ߤ;Q(PG47 =IV(8!Mkb@@! LR7.?0# _Y* r.7T.Q"b|s8s9@n 3ϜQXFkGriBN21=y S@29̣ +DF :6LLIQ4Yq6.@g$V|-b;Di*,TAQIџe0..܃˫-1EoM -#*~ɴf՚9ڬtꚪ\M_?nOZ5Y28 C>$p]1 J"BX6Cʐ cp^ Pʸ3Ye)nc@ҤY[YM6 v^HKEkm1樜#Keې5vݥعPְ#S|4$% C^YBsjBQ~3oDgREmQϙjࢡ檔߂h xflv 0,.ޯmx$⢝6xR#i7ډэvծF=FA{؍3R-L } y^Ds].t鐤C)(6z8Xq6Y!!ΐpC{!8<`jXee"^u+%B'<ܕ$7TVͽ^,\D)t7$n-f)IP@Max0˘!ZLr xo_1NUKf}iQ:)b"QQNs3r7D/]UN7?n??l_:QWw؋IM3v⌸kå>SF*>}Xpߛ{&(Rj!$O[" QY_c"`pSi;L_KYwflfmL8+^jO@&uyW4Z{lI-&,m^K^G"}wx^s.&M?r߇:9+~F\M!9-8Bͪ-:R,&EJ^~\jsaȶhk>?nً! xGjxn |+$<$6Y?r~< ceBty3siJ??G&5gd{9T;7xudǎwн~~Lwix} x 1>O2g?rq-Rf0x^H**hf2|@/HD]V?nռBat~yޘ޽ZRK3 r.@ 7Y??k⼹aږ䤵CIyu%(?nay``\%?0X}Դ}ZCQ̅U, $ a1$dRB$_Ik9r?nԎ[  |,ZH1V^9 #f~Gh#i>SP[*GZ??SU9mCO?03wH-gzl>b hG?rff?0Ye"6+JbXkF4I}<Yd?r#H-ΥoEϓlݕ=:Z2Xo?nQgN7 Ѯk:%ݳ~Y=W#*A.s4??R'4hKK3#Сȋ:/oJ\G;4AXNf%g6)Z*&?ncJ Ka_7Q'Q>w}ѰC+ŤK*MN[C6MKoy?rx?r88/k1cLcnc~)vK#`:&tTGd;P%?0+ ^{5杇3q<Í!ttBGYJIS#HPqgۇlOZƃ+^@  3,`zfN B-',NfM'KЗVo6S$2&-zs,}O*`n~Ȝ.3z~q䕤՝;-l`+wj3nnOlb)<c{mD[QNEYE{+}я%R"iDQ~@xPH<-a=CH[4Q O O`ЖH/NasJ~Pc aD]X f'|m\Q\0)쯔b)ĔVVox byc:jVR@b ?0p7x1|r3ÿFs}|`y}1??{wQ -#|zyp{C<룩Tm6ҷr|2=ȣ1rWAL_4kd1GD??~П2-Á5s ݒUP(b??aUTNܞԈ6)_nMx64#vb<1~ v ?n hYњ0(0:w#lvoa0Uu#?0(?0 _'6U VҸC6 I;E;Qb?rlE<琹Ñ-TY/ۨ(|0Q OX3O?? 1JO<"Q"< Մ=B&"=|bc&}pNz2]{9Ŧb6%۳5@u\lNl3ŲZV϶ۘc701mx i??xi)JG xc&2M\7e[TQ=Emך[k^OszJ!nf朓 3zg|#G@o`ta]p |^xQu?05l&01?nԾQ\2DZ|z&ń]gm|:U9A?rtL+U7inLЇ;v5mjc`]^8VU!j^Y~< ߹6FYӽ;_$YKa!uJ[}~N߳6|(?nԥe>|0,<0/"kG«!5;qQ:K_Zmc.k/jmJ؂{Rr_G*4ӟӛc:F~ ΀?? zs}0[dp6*-@p^y kN1ْ"0#Sǟ.u>'E|Bٽ,`}'ed|O{y9dddaYz:,pn.|dޑ૰(sgWyγ0_ Bq'iZFP'TWf=ӋdD*1=,Hb8$d\7 (#({_ ?n5R?nR؞E^@H~Aci.lݻZeX??8P,d疇l?03zs\ AHM<$to0Ǟ?nSYփn/,rs?n2! ԏ?r"dӮ ?nZ}3R +P8旒/,?rrn6hGӲO`x!* ƲH4Yʬ )̞^FQY(YI]J̓?r]ж<g_ i3M3qTBck7 S%RÉ,o }'XD9zbG&/- 7@?r|.Bg<PiR+̍Cџ`4P `VBPD?nOA4?n BY?0mSGEF[pF9,r 3 ?nC}E$?07w?rom, 1px xj]1AUy,#̍ĢT82??b;E5nH[)|-,?nSca sD9YX-6͙C]ǞaUӌ݅[FE??T^tnHl/m!6HVsܐ*i-mkwUa hM2ʇtcZЊU7gFK]_`@:݆A~Zf8߬f_rbH:2?nDq޹QE]?0w͜lmxDUaB;_\M8ߜx#tHU'#^6}se++@?rYD^Rjw%v(b;MZǧIw7m!(cT?0rی祐+!i]KL??#%]o#9"%~C5t{8taPG-5:nO|>d-ZLѦ]Fos'?rYe8/MtoTr9 fN~>r5:rީTwqX6>`U?r2?rkF7h :v"w\?0tcz@LdE;I"NJ*. #E-,Mv+X18'm :ڲ?nJnI=g$->eJWQҩN p^'Vq\~>??]v훌N糇0H}q׷:5=6b);CŮ- cNA2p݊f]9F4}eD^?0[B2Ud?rPP<,ƙ9 Dˠu]3<>Ygf5)W!!6ww".eѣtmF̴6>XRd([?nx"?rQE⍞g}"?0Bqgs ɉNU(Iho#Z`uMFhׅp`^pӼwXvũ<7??KJ㦗G-ls<8r]- 7ĀmFӒ-.`v}xl)qŖ u)\hESwsrsGGT???nr`5`o \zo (U:9'[''cZ0JIŝ9̺z?rEJ7??H+?rLϝl e :Wqn%c(Rf._zf3ӧZ]?0( ?0 t̅94Qb?r#kר%h^v߅+IAv8hh@F4|x?nð5CalS>وl35s?0I㸖S&7%旼Jk?r?r3ptA&W&%p7ݠY) u]?nЛ:]`7kh .?n*>Un0'3VU_Vݚ9T3XIuغpөxBڮ!!4TH % 3ƀ>SA?0/V!^GP1lP -#]ޯ :X-[2LVzlS"SeHīoв]f0?r/>],!LyNȜ+ 0(+2zkQNZW![^jI r=$D~uRSSĜzt'kylU8yAZn>*փĘ#>-C,3|&&Q_V>صLޝ=>$Q<t~m:/"ne.PҸlP&/PA[:֏9?r+סW4#x"fݨKY%p&#zs3z]!d-LAFDϏ`TL?n[ IRmbN wVā%㥢qB {y M Ⱥ`P}1Hf#%K?rһE\[|Lfda1it=K BȣR:giUfKVޡ0&+:}?n4DJ&@T.D%A~fN:2e1Җ #T.T^IZHD4tIN?rB??P㘖6}h&Dss\{](xHs7CQv`AeDn%v&$䍆N1use Q-;]j4xUXT_BH#`YJ|qFgTCR֑Βݲ?0O;ɜrp^1tNxR!npaUƇE\&zb󸼆cCNnb׫NUEyx n?ndWr*hWAbw+n92M.!O?n;܄lU.VA,'BKy+js5-7 7FvFQU76NUOԾlHWYV?r)BlꈞF9΅,}UAb"lxn w.A7ǀt<(?0.ިE瑺a/D9qkb4FǬTuVtPc12fV?r=1max!7H ``` -Replace <ipaddress> with your comma device's IP address. +Replace <ipaddress> with your device's IP address. + +If you can't run the bridge on the device, `--bridge ` runs +`cereal/messaging/bridge` locally against the device instead. + +### Streaming CAN Messages from a Remote Device over konn3kt + +To watch a device that isn't on your network, `tools/cabana/konn3kt_canproxy.py` +re-publishes its live CAN onto a local ZMQ socket: + +```shell +export KONN3KT_JWT="" +./tools/cabana/konn3kt_canproxy.py +cabana --zmq 127.0.0.1 +``` + +The device must have `CanLiveStreaming` enabled so `canlived` is running. See the +header of `konn3kt_canproxy.py` for the full topology. While streaming from the device, Cabana will log the CAN messages to a local directory. By default, this directory is ~/cabana_live_stream/. You can change the log directory in Cabana by navigating to menu -> tools -> settings. diff --git a/tools/cabana/SConscript b/tools/cabana/SConscript index 5976f35..b0ae423 100644 --- a/tools/cabana/SConscript +++ b/tools/cabana/SConscript @@ -1,21 +1,40 @@ import subprocess import os +import shutil +import sys -Import('env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal') +venv_site_packages = os.path.join(Dir("#").abspath, ".venv", "lib", "python3.12", "site-packages") +if os.path.isdir(venv_site_packages) and venv_site_packages not in sys.path: + sys.path.insert(0, venv_site_packages) + +import libusb + +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') + +# Detect Qt - skip build if not available +if arch == "Darwin": + try: + brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() + has_qt = os.path.isdir(os.path.join(brew_prefix, "opt/qt@5")) + except (FileNotFoundError, subprocess.CalledProcessError): + has_qt = False +else: + has_qt = shutil.which('qmake') is not None +if not has_qt: + Return() qt_env = env.Clone() -qt_modules = ["Widgets", "Gui", "Core", "Network", "Concurrent", "DBus", "Xml"] +qt_modules = ["Widgets", "Gui", "Core"] qt_libs = [] if arch == "Darwin": - brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip() qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5" qt_dirs = [ os.path.join(qt_env['QTDIR'], "include"), ] qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules] qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")] - qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"] + qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin")) else: qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip() @@ -32,15 +51,10 @@ else: qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules] qt_libs = [f"Qt5{m}" for m in qt_modules] - if arch == "larch64": - qt_libs += ["GLESv2", "wayland-client"] - qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath) - elif arch != "Darwin": - qt_libs += ["GL"] qt_env['QT3DIR'] = qt_env['QTDIR'] qt_env.Tool('qt3') -qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"] +qt_env['CPPPATH'] += qt_dirs qt_flags = [ "-D_REENTRANT", "-DQT_NO_DEBUG", @@ -54,41 +68,74 @@ qt_env['LIBPATH'] += ['#selfdrive/ui', ] qt_env['LIBS'] = qt_libs base_frameworks = qt_env['FRAMEWORKS'] -base_libs = [common, messaging, cereal, visionipc, 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"] +base_libs = [common, messaging, cereal, visionipc, 'm', 'pthread'] + qt_env["LIBS"] if arch == "Darwin": - base_frameworks.append('OpenCL') - base_frameworks.append('QtCharts') - base_frameworks.append('QtSerialBus') -else: - base_libs.append('OpenCL') - base_libs.append('Qt5Charts') - base_libs.append('Qt5SerialBus') - -qt_libs = base_libs + base_frameworks += ['CoreFoundation', 'CoreVideo', 'CoreMedia', 'IOKit', 'Security', 'VideoToolbox'] cabana_env = qt_env.Clone() +cabana_env['CPPPATH'] += [libusb.INCLUDE_DIR] +cabana_env['LIBPATH'] += [libusb.LIB_DIR] -cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs -iqdbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../iqdbc/dbc").abspath) -cabana_env['CXXFLAGS'] += [iqdbc_path] +# IQ.Pilot patch: replay_lib fetches routes over libcurl and signs konn3kt JWTs with +# OpenSSL (tools/replay/api.cc), and visionipc references OpenCL — upstream gets all +# three transitively from its Python downloader / vendored wheels, we link them here. +cabana_libs = [cereal, messaging, visionipc, replay_lib] + ffmpeg_libs + \ + ['bz2', 'zstd', 'curl', 'ssl', 'crypto', 'usb-1.0'] + base_libs +if arch == "Darwin": + base_frameworks += ['OpenCL'] +else: + cabana_libs += ['OpenCL'] +opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("#iqdbc/dbc").abspath) +cabana_env['CXXFLAGS'] += [opendbc_path] + +# embed the bootstrap icons SVG into the binary +def build_bootstrap_icons_src(target, source, env): + data = open(str(source[0]), 'rb').read() + with open(str(target[0]), 'w') as f: + f.write('#include \n') + f.write('extern const unsigned char bootstrap_icons_svg[];\n') + f.write('extern const size_t bootstrap_icons_svg_len;\n') + f.write('const unsigned char bootstrap_icons_svg[] = {\n') + for i in range(0, len(data), 32): + f.write(','.join(str(b) for b in data[i:i+32]) + ',\n') + f.write('};\n') + f.write('const size_t bootstrap_icons_svg_len = sizeof(bootstrap_icons_svg);\n') + return None + +# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here; the SVG is checked in +# under third_party/bootstrap (see its pull.sh). +bootstrap_icons_src = cabana_env.Command('assets/bootstrap_icons.cc', '#third_party/bootstrap/bootstrap-icons.svg', + cabana_env.PrettyAction(build_bootstrap_icons_src, 'GEN')) # build assets assets = "assets/assets.cc" -assets_src = "assets/assets.qrc" -cabana_env.Command(assets, assets_src, cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC')) -cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"])) +cabana_env.Command(assets, "assets/assets.qrc", cabana_env.PrettyAction("rcc $SOURCES -o $TARGET", 'RCC')) +cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, "assets/assets.o"])) -cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', - 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', - 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'utils/api.cc', - 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', - 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', - 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) -cabana_env.Program('cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_srcs = ['mainwin.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc', + 'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc', 'dbc/dbcqt.cc', + 'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', + 'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc', + 'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc', + 'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.cc'] +if arch != "Darwin": + cabana_srcs += ['streams/socketcanstream.cc'] +cabana_lib = cabana_env.Library("cabana_lib", cabana_srcs + [bootstrap_icons_src], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) +cabana_env.Program('_cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks) if GetOption('extras'): - cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs]) + # This target deliberately uses the base environment and links no Qt libraries. + # It prevents Qt dependencies from creeping back into the DBC core. + dbc_core_test_env = env.Clone() + dbc_core_test_env['CXXFLAGS'] += [opendbc_path] + dbc_core_test_objects = [ + dbc_core_test_env.Object('tests/dbc_core_tests', 'tests/test_cabana.cc'), + dbc_core_test_env.Object('tests/dbc_core_model', 'dbc/dbc.cc'), + dbc_core_test_env.Object('tests/dbc_core_file', 'dbc/dbcfile.cc'), + dbc_core_test_env.Object('tests/dbc_core_manager', 'dbc/dbcmanager.cc'), + ] + dbc_core_test_env.Program('tests/test_dbc_core', dbc_core_test_objects) output_json_file = 'tools/cabana/dbc/car_fingerprint_to_dbc.json' generate_dbc = cabana_env.Command('#' + output_json_file, diff --git a/tools/cabana/assets/assets.qrc b/tools/cabana/assets/assets.qrc index 6a8e5a3..009d63f 100644 --- a/tools/cabana/assets/assets.qrc +++ b/tools/cabana/assets/assets.qrc @@ -1,6 +1,5 @@ - ../../../third_party/bootstrap/bootstrap-icons.svg cabana-icon.png diff --git a/tools/cabana/binaryview.cc b/tools/cabana/binaryview.cc index b5a68c6..5e919dc 100644 --- a/tools/cabana/binaryview.cc +++ b/tools/cabana/binaryview.cc @@ -1,8 +1,10 @@ #include "tools/cabana/binaryview.h" +#include "tools/cabana/dbc/dbcqt.h" #include -#include +#include + #include #include #include @@ -34,20 +36,20 @@ BinaryView::BinaryView(QWidget *parent) : QTableView(parent) { setMouseTracking(true); setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &BinaryView::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &BinaryView::refresh); addShortcuts(); setWhatsThis(R"( Binary View
- + Shortcuts
Delete Signal:  x ,  Backspace ,  Delete 
Change endianness:  e 
- Change singedness:  s 
+ Change signedness:  s 
Open chart:  c ,  p , @@ -64,7 +66,7 @@ void BinaryView::addShortcuts() { QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated); QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{ if (hovered_sig != nullptr) { - UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig)); + UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, hovered_sig)); hovered_sig = nullptr; } }); @@ -111,7 +113,8 @@ void BinaryView::highlight(const cabana::Signal *sig) { if (sig != hovered_sig) { for (int i = 0; i < model->items.size(); ++i) { auto &item_sigs = model->items[i].sigs; - if ((sig && item_sigs.contains(sig)) || (hovered_sig && item_sigs.contains(hovered_sig))) { + auto has = [](const auto &v, auto p) { return std::find(v.begin(), v.end(), p) != v.end(); }; + if ((sig && has(item_sigs, sig)) || (hovered_sig && has(item_sigs, hovered_sig))) { auto index = model->index(i / model->columnCount(), i % model->columnCount()); emit model->dataChanged(index, index, {Qt::DisplayRole}); } @@ -123,7 +126,7 @@ void BinaryView::highlight(const cabana::Signal *sig) { } void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) { - auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos())); + auto index = indexAt(last_mouse_pos); if (!anchor_index.isValid() || !index.isValid()) return; @@ -138,7 +141,7 @@ void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionF void BinaryView::mousePressEvent(QMouseEvent *event) { resize_sig = nullptr; - if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) { + if (auto index = indexAt(last_mouse_pos = event->pos()); index.isValid() && index.column() != 8) { anchor_index = index; auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); int bit_pos = get_bit_pos(anchor_index); @@ -155,15 +158,15 @@ void BinaryView::mousePressEvent(QMouseEvent *event) { } void BinaryView::highlightPosition(const QPoint &pos) { - if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) { + if (auto index = indexAt(pos); index.isValid()) { auto item = (BinaryViewModel::Item *)index.internalPointer(); - const cabana::Signal *sig = item->sigs.isEmpty() ? nullptr : item->sigs.back(); + const cabana::Signal *sig = item->sigs.empty() ? nullptr : item->sigs.back(); highlight(sig); } } void BinaryView::mouseMoveEvent(QMouseEvent *event) { - highlightPosition(event->globalPos()); + highlightPosition(last_mouse_pos = event->pos()); QTableView::mouseMoveEvent(event); } @@ -176,7 +179,7 @@ void BinaryView::mouseReleaseEvent(QMouseEvent *event) { auto sig = resize_sig ? *resize_sig : cabana::Signal{}; std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index); resize_sig ? emit editSignal(resize_sig, sig) - : UndoStack::push(new AddSigCommand(model->msg_id, sig)); + : UndoStack::instance()->push(new AddSigCommand(model->msg_id, sig)); } else { auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer(); if (item && item->sigs.size() > 0) @@ -205,15 +208,15 @@ void BinaryView::refresh() { resize_sig = nullptr; hovered_sig = nullptr; model->refresh(); - highlightPosition(QCursor::pos()); + if (underMouse()) highlightPosition(last_mouse_pos); } -QSet BinaryView::getOverlappingSignals() const { - QSet overlapping; +std::set BinaryView::getOverlappingSignals() const { + std::set overlapping; for (const auto &item : model->items) { if (item.sigs.size() > 1) { for (auto s : item.sigs) { - if (s->type == cabana::Signal::Type::Normal) overlapping += s; + if (s->type == cabana::Signal::Type::Normal) overlapping.insert(s); } } } @@ -258,7 +261,8 @@ void BinaryViewModel::refresh() { int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j; int idx = column_count * (pos / 8) + pos % 8; if (idx >= items.size()) { - qWarning() << "signal " << sig->name << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size; + fprintf(stderr, "signal %s out of bounds.start_bit: %d size: %d\n", + sig->name.c_str(), sig->start_bit, sig->size); break; } if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true; @@ -333,7 +337,7 @@ void BinaryViewModel::updateState() { color.setAlpha(alpha); updateItem(i, j, bit_val, color); } - updateItem(i, 8, binary[i], last_msg.colors[i]); + updateItem(i, 8, binary[i], toQColor(last_msg.colors[i])); } } @@ -404,7 +408,9 @@ bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, con if (!index.isValid()) return false; auto model = (const BinaryViewModel*)(index.model()); int idx = (index.row() + dy) * model->columnCount() + index.column() + dx; - return (idx >=0 && idx < model->items.size()) ? model->items[idx].sigs.contains(sig) : false; + if (idx < 0 || idx >= (int)model->items.size()) return false; + auto &s = model->items[idx].sigs; + return std::find(s.begin(), s.end(), sig) != s.end(); } void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { @@ -418,14 +424,14 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op painter->fillRect(option.rect, item->bg_color); } } else if (option.state & QStyle::State_Selected) { - auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight); + auto color = bin_view->resize_sig ? toQColor(bin_view->resize_sig->color) : option.palette.color(QPalette::Active, QPalette::Highlight); painter->fillRect(option.rect, color); painter->setPen(option.palette.color(QPalette::BrightText)); - } else if (!bin_view->selectionModel()->hasSelection() || !item->sigs.contains(bin_view->resize_sig)) { // not resizing + } else if (!bin_view->selectionModel()->hasSelection() || std::find(item->sigs.begin(), item->sigs.end(), bin_view->resize_sig) == item->sigs.end()) { // not resizing if (item->sigs.size() > 0) { for (auto &s : item->sigs) { if (s == bin_view->hovered_sig) { - painter->fillRect(option.rect, s->color.darker(125)); // 4/5x brightness + painter->fillRect(option.rect, toQColor(s->color.darker(125))); // 4/5x brightness } else { drawSignalCell(painter, option, index, s); } @@ -433,7 +439,7 @@ void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op } else if (item->valid && item->bg_color.alpha() > 0) { painter->fillRect(option.rect, item->bg_color); } - auto color_role = item->sigs.contains(bin_view->hovered_sig) ? QPalette::BrightText : QPalette::Text; + auto color_role = (std::find(item->sigs.begin(), item->sigs.end(), bin_view->hovered_sig) != item->sigs.end()) ? QPalette::BrightText : QPalette::Text; painter->setPen(option.palette.color(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, color_role)); } @@ -480,14 +486,14 @@ void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionVie painter->setClipRegion(QRegion(rc).subtracted(subtract)); auto item = (const BinaryViewModel::Item *)index.internalPointer(); - QColor color = sig->color; + QColor color = toQColor(sig->color); color.setAlpha(item->bg_color.alpha()); // Mixing the signal color with the Base background color to fade it painter->fillRect(rc, option.palette.color(QPalette::Base)); painter->fillRect(rc, color); // Draw edges - color = sig->color.darker(125); + color = toQColor(sig->color.darker(125)); painter->setPen(QPen(color, 1)); if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft()); if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight()); diff --git a/tools/cabana/binaryview.h b/tools/cabana/binaryview.h index 920deb0..c49067a 100644 --- a/tools/cabana/binaryview.h +++ b/tools/cabana/binaryview.h @@ -1,10 +1,9 @@ #pragma once +#include #include #include -#include -#include #include #include @@ -51,7 +50,7 @@ public: bool is_msb = false; bool is_lsb = false; uint8_t val; - QList sigs; + std::vector sigs; bool valid = false; }; std::vector items; @@ -68,7 +67,7 @@ public: BinaryView(QWidget *parent = nullptr); void setMessage(const MessageId &message_id); void highlight(const cabana::Signal *sig); - QSet getOverlappingSignals() const; + std::set getOverlappingSignals() const; void updateState() { model->updateState(); } void paintEvent(QPaintEvent *event) override { is_message_active = can->isMessageActive(model->msg_id); @@ -95,6 +94,7 @@ private: void highlightPosition(const QPoint &pt); QModelIndex anchor_index; + QPoint last_mouse_pos{-1, -1}; BinaryViewModel *model; BinaryItemDelegate *delegate; bool is_message_active = false; diff --git a/tools/cabana/cabana b/tools/cabana/cabana new file mode 100755 index 0000000..02b5965 --- /dev/null +++ b/tools/cabana/cabana @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +ROOT="$(cd "$DIR/../../" && pwd)" + +install_qt() { + if [[ "$(uname)" == "Darwin" ]]; then + brew install qt@5 + brew link qt@5 || true + else + SUDO="" + if [[ ! $(id -u) -eq 0 ]]; then + SUDO="sudo" + fi + $SUDO apt-get install -y --no-install-recommends \ + qtbase5-dev \ + qtbase5-dev-tools \ + qttools5-dev-tools \ + libqt5charts5-dev \ + libqt5svg5-dev \ + libqt5serialbus5-dev \ + libqt5x11extras5-dev \ + libqt5opengl5-dev + fi +} + +# Install Qt if not found +if ! command -v qmake &> /dev/null; then + echo "Qt not found, installing dependencies..." + install_qt +fi + +# Build _cabana +cd "$ROOT" +scons -u tools/cabana/_cabana cereal/messaging/bridge + +exec "$DIR/_cabana" "$@" diff --git a/tools/cabana/cabana.cc b/tools/cabana/cabana.cc index bc50afc..28cca75 100644 --- a/tools/cabana/cabana.cc +++ b/tools/cabana/cabana.cc @@ -1,82 +1,195 @@ +#include +#include +#include +#include + #include -#include #include "tools/cabana/mainwin.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif + +namespace { + +struct CabanaArgs { + bool demo = false; + bool auto_source = false; + bool qcam = false; + bool ecam = false; + bool dcam = false; + bool msgq = false; + bool panda = false; + bool no_vipc = false; + std::string panda_serial; + std::string socketcan; + std::string zmq; + std::string bridge; + std::string data_dir; + std::string dbc; + std::string route; +}; + +void printUsage(const char *argv0) { + fprintf(stderr, + "Usage: %s [options] [route]\n" + "\n" + " route the drive to replay. find your drives at konn3kt.com\n" + "\n" + "Options:\n" + " --help show this help\n" + " --demo use a demo route instead of providing your own\n" + " --auto Auto load the route from the best available source (no video):\n" + " internal, openpilotci, comma_api, car_segments, testing_closet\n" + " --qcam load qcamera\n" + " --ecam load wide road camera\n" + " --dcam load driver camera\n" + " --msgq read can messages from the msgq\n" + " --panda read can messages from panda\n" + " --panda-serial read can messages from panda with given serial\n" +#ifdef __linux__ + " --socketcan read can messages from given SocketCAN device\n" +#endif + " --zmq subscribe to a zmq 'can' publisher at the specified ip-address\n" + " (a device running cereal/messaging/bridge, or\n" + " tools/cabana/konn3kt_canproxy.py on 127.0.0.1)\n" + " --bridge run cereal/messaging/bridge locally against the device\n" + " --data_dir

local directory with routes\n" + " --no-vipc do not output video\n" + " --dbc dbc file to open\n", + argv0); +} + +// Returns true if value was consumed from argv[i+1]. +bool takeValue(int argc, char *argv[], int &i, std::string &out) { + if (i + 1 >= argc) { + fprintf(stderr, "error: %s requires a value\n", argv[i]); + return false; + } + out = argv[++i]; + return true; +} + +// Returns 0 to continue, or a process exit code (0 for --help, 1 for errors). +int parseArgs(int argc, char *argv[], CabanaArgs &args, bool &ok) { + ok = false; + for (int i = 1; i < argc; ++i) { + const char *a = argv[i]; + if (std::strcmp(a, "--help") == 0 || std::strcmp(a, "-h") == 0) { + printUsage(argv[0]); + return 0; + } else if (std::strcmp(a, "--demo") == 0) { + args.demo = true; + } else if (std::strcmp(a, "--auto") == 0) { + args.auto_source = true; + } else if (std::strcmp(a, "--qcam") == 0) { + args.qcam = true; + } else if (std::strcmp(a, "--ecam") == 0) { + args.ecam = true; + } else if (std::strcmp(a, "--dcam") == 0) { + args.dcam = true; + } else if (std::strcmp(a, "--msgq") == 0) { + args.msgq = true; + } else if (std::strcmp(a, "--panda") == 0) { + args.panda = true; + } else if (std::strcmp(a, "--panda-serial") == 0) { + if (!takeValue(argc, argv, i, args.panda_serial)) return 1; + args.panda = true; + } else if (std::strcmp(a, "--socketcan") == 0) { + if (!takeValue(argc, argv, i, args.socketcan)) return 1; +#ifdef __linux__ +#else + fprintf(stderr, "error: --socketcan is only supported on Linux\n"); + return 1; +#endif + } else if (std::strcmp(a, "--zmq") == 0) { + if (!takeValue(argc, argv, i, args.zmq)) return 1; + } else if (std::strcmp(a, "--bridge") == 0) { + if (!takeValue(argc, argv, i, args.bridge)) return 1; + } else if (std::strcmp(a, "--data_dir") == 0) { + if (!takeValue(argc, argv, i, args.data_dir)) return 1; + } else if (std::strcmp(a, "--no-vipc") == 0) { + args.no_vipc = true; + } else if (std::strcmp(a, "--dbc") == 0) { + if (!takeValue(argc, argv, i, args.dbc)) return 1; + } else if (a[0] == '-') { + fprintf(stderr, "error: unknown option %s\n", a); + printUsage(argv[0]); + return 1; + } else if (args.route.empty()) { + args.route = a; + } else { + fprintf(stderr, "error: unexpected argument %s\n", a); + printUsage(argv[0]); + return 1; + } + } + ok = true; + return 0; +} + +} // namespace int main(int argc, char *argv[]) { QCoreApplication::setApplicationName("Cabana"); - QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts); initApp(argc, argv, false); QApplication app(argc, argv); app.setApplicationDisplayName("Cabana"); - app.setWindowIcon(QIcon(":cabana-icon.png")); + //app.setWindowIcon(QIcon(":cabana-icon.png")); // TODO: do this in imgui UnixSignalHandler signalHandler; utils::setTheme(settings.theme); - QCommandLineParser cmd_parser; - cmd_parser.addHelpOption(); - cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai"); - cmd_parser.addOption({"demo", "use a demo route instead of providing your own"}); - cmd_parser.addOption({"auto", "Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet"}); - cmd_parser.addOption({"qcam", "load qcamera"}); - cmd_parser.addOption({"ecam", "load wide road camera"}); - cmd_parser.addOption({"dcam", "load driver camera"}); - cmd_parser.addOption({"msgq", "read can messages from the msgq"}); - cmd_parser.addOption({"panda", "read can messages from panda"}); - cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"}); - if (SocketCanStream::available()) { - cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"}); + CabanaArgs args; + bool args_ok = false; + if (const int code = parseArgs(argc, argv, args, args_ok); !args_ok) { + return code; } - cmd_parser.addOption({"zmq", "read can messages from zmq at the specified ip-address", "ip-address"}); - cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"}); - cmd_parser.addOption({"no-vipc", "do not output video"}); - cmd_parser.addOption({"dbc", "dbc file to open", "dbc"}); - cmd_parser.process(app); AbstractStream *stream = nullptr; - if (cmd_parser.isSet("msgq")) { - stream = new DeviceStream(&app); - } else if (cmd_parser.isSet("zmq")) { - stream = new DeviceStream(&app, cmd_parser.value("zmq")); - } else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) { + if (args.msgq) { + stream = new DeviceStream(&app, DeviceStream::Mode::Msgq); + } else if (!args.zmq.empty()) { + stream = new DeviceStream(&app, DeviceStream::Mode::Zmq, QString::fromStdString(args.zmq)); + } else if (!args.bridge.empty()) { + stream = new DeviceStream(&app, DeviceStream::Mode::Bridge, QString::fromStdString(args.bridge)); + } else if (args.panda || !args.panda_serial.empty()) { try { - stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial")}); + stream = new PandaStream(&app, {.serial = args.panda_serial}); } catch (std::exception &e) { - qWarning() << e.what(); + fprintf(stderr, "%s\n", e.what()); return 0; } - } else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) { - stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan")}); +#ifdef __linux__ + } else if (SocketCanStream::available() && !args.socketcan.empty()) { + stream = new SocketCanStream(&app, {.device = args.socketcan}); +#endif } else { uint32_t replay_flags = REPLAY_FLAG_NONE; - if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM; - if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA; - if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM; - if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC; + if (args.ecam) replay_flags |= REPLAY_FLAG_ECAM; + if (args.qcam) replay_flags |= REPLAY_FLAG_QCAMERA; + if (args.dcam) replay_flags |= REPLAY_FLAG_DCAM; + if (args.no_vipc) replay_flags |= REPLAY_FLAG_NO_VIPC; - const QStringList args = cmd_parser.positionalArguments(); QString route; - if (args.size() > 0) { - route = args.first(); - } else if (cmd_parser.isSet("demo")) { + if (!args.route.empty()) { + route = QString::fromStdString(args.route); + } else if (args.demo) { route = DEMO_ROUTE; } if (!route.isEmpty()) { auto replay_stream = std::make_unique(&app); - bool auto_source = cmd_parser.isSet("auto"); - if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags, auto_source)) { + if (!replay_stream->loadRoute(route.toStdString(), args.data_dir, replay_flags, args.auto_source)) { return 0; } stream = replay_stream.release(); } } - MainWindow w(stream, cmd_parser.value("dbc")); + MainWindow w(stream, QString::fromStdString(args.dbc)); return app.exec(); } diff --git a/tools/cabana/cameraview.cc b/tools/cabana/cameraview.cc index 13c838e..a8df373 100644 --- a/tools/cabana/cameraview.cc +++ b/tools/cabana/cameraview.cc @@ -1,137 +1,40 @@ #include "tools/cabana/cameraview.h" -#ifdef __APPLE__ -#include -#else -#include -#endif +#include +#include +#include +#include #include +#include -namespace { - -const char frame_vertex_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" -#endif - "layout(location = 0) in vec4 aPosition;\n" - "layout(location = 1) in vec2 aTexCoord;\n" - "uniform mat4 uTransform;\n" - "out vec2 vTexCoord;\n" - "void main() {\n" - " gl_Position = uTransform * aPosition;\n" - " vTexCoord = aTexCoord;\n" - "}\n"; - -const char frame_fragment_shader[] = -#ifdef __APPLE__ - "#version 330 core\n" -#else - "#version 300 es\n" - "precision mediump float;\n" -#endif - "uniform sampler2D uTextureY;\n" - "uniform sampler2D uTextureUV;\n" - "in vec2 vTexCoord;\n" - "out vec4 colorOut;\n" - "void main() {\n" - " float y = texture(uTextureY, vTexCoord).r;\n" - " vec2 uv = texture(uTextureUV, vTexCoord).rg - 0.5;\n" - " float r = y + 1.402 * uv.y;\n" - " float g = y - 0.344 * uv.x - 0.714 * uv.y;\n" - " float b = y + 1.772 * uv.x;\n" - " colorOut = vec4(r, g, b, 1.0);\n" - "}\n"; - -} // namespace +#include "common/yuv.h" CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) : - stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QOpenGLWidget(parent) { + stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent); qRegisterMetaType>("availableStreams"); - QObject::connect(this, &CameraWidget::vipcThreadConnected, this, &CameraWidget::vipcConnected, Qt::BlockingQueuedConnection); QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection); QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection); QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread); } CameraWidget::~CameraWidget() { - makeCurrent(); stopVipcThread(); - if (isValid()) { - glDeleteVertexArrays(1, &frame_vao); - glDeleteBuffers(1, &frame_vbo); - glDeleteBuffers(1, &frame_ibo); - glDeleteTextures(2, textures); - shader_program_.reset(); - } - doneCurrent(); -} - -void CameraWidget::initializeGL() { - initializeOpenGLFunctions(); - - shader_program_ = std::make_unique(context()); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Vertex, frame_vertex_shader); - shader_program_->addShaderFromSourceCode(QOpenGLShader::Fragment, frame_fragment_shader); - shader_program_->link(); - - GLint frame_pos_loc = shader_program_->attributeLocation("aPosition"); - GLint frame_texcoord_loc = shader_program_->attributeLocation("aTexCoord"); - - auto [x1, x2, y1, y2] = requested_stream_type == VISION_STREAM_DRIVER ? std::tuple(0.f, 1.f, 1.f, 0.f) : std::tuple(1.f, 0.f, 1.f, 0.f); - const uint8_t frame_indicies[] = {0, 1, 2, 0, 2, 3}; - const float frame_coords[4][4] = { - {-1.0, -1.0, x2, y1}, // bl - {-1.0, 1.0, x2, y2}, // tl - { 1.0, 1.0, x1, y2}, // tr - { 1.0, -1.0, x1, y1}, // br - }; - - glGenVertexArrays(1, &frame_vao); - glBindVertexArray(frame_vao); - glGenBuffers(1, &frame_vbo); - glBindBuffer(GL_ARRAY_BUFFER, frame_vbo); - glBufferData(GL_ARRAY_BUFFER, sizeof(frame_coords), frame_coords, GL_STATIC_DRAW); - glEnableVertexAttribArray(frame_pos_loc); - glVertexAttribPointer(frame_pos_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)0); - glEnableVertexAttribArray(frame_texcoord_loc); - glVertexAttribPointer(frame_texcoord_loc, 2, GL_FLOAT, GL_FALSE, - sizeof(frame_coords[0]), (const void *)(sizeof(float) * 2)); - glGenBuffers(1, &frame_ibo); - glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, frame_ibo); - glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(frame_indicies), frame_indicies, GL_STATIC_DRAW); - glBindBuffer(GL_ARRAY_BUFFER, 0); - glBindVertexArray(0); - - glGenTextures(2, textures); - - shader_program_->bind(); - shader_program_->setUniformValue("uTextureY", 0); - shader_program_->setUniformValue("uTextureUV", 1); - shader_program_->release(); } void CameraWidget::showEvent(QShowEvent *event) { - if (!vipc_thread) { + if (!vipc_thread.joinable()) { clearFrames(); - vipc_thread = new QThread(); - connect(vipc_thread, &QThread::started, [=]() { vipcThread(); }); - connect(vipc_thread, &QThread::finished, vipc_thread, &QObject::deleteLater); - vipc_thread->start(); + vipc_exit = false; + vipc_thread = std::thread(&CameraWidget::vipcThread, this); } } void CameraWidget::stopVipcThread() { - makeCurrent(); - if (vipc_thread) { - vipc_thread->requestInterruption(); - vipc_thread->quit(); - vipc_thread->wait(); - vipc_thread = nullptr; + vipc_exit = true; + if (vipc_thread.joinable()) { + vipc_thread.join(); } } @@ -139,74 +42,29 @@ void CameraWidget::availableStreamsUpdated(std::set streams) { available_streams = streams; } -void CameraWidget::paintGL() { - glClearColor(bg.redF(), bg.greenF(), bg.blueF(), bg.alphaF()); - glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT); +void CameraWidget::paintEvent(QPaintEvent *event) { + QPainter p(this); + p.fillRect(rect(), bg); std::lock_guard lk(frame_lock); - if (!current_frame_) return; + if (rgb_frame.isNull()) return; // Scale for aspect ratio float widget_ratio = (float)width() / height(); - float frame_ratio = (float)stream_width / stream_height; - float scale_x = std::min(frame_ratio / widget_ratio, 1.0f); - float scale_y = std::min(widget_ratio / frame_ratio, 1.0f); + float frame_ratio = (float)rgb_frame.width() / rgb_frame.height(); + int w = std::lround(width() * std::min(frame_ratio / widget_ratio, 1.0f)); + int h = std::lround(height() * std::min(widget_ratio / frame_ratio, 1.0f)); + QRect video_rect((width() - w) / 2, (height() - h) / 2, w, h); - glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio()); - - shader_program_->bind(); - QMatrix4x4 transform; - transform.scale(scale_x, scale_y, 1.0f); - shader_program_->setUniformValue("uTransform", transform); - - glPixelStorei(GL_UNPACK_ALIGNMENT, 1); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width, stream_height, GL_RED, GL_UNSIGNED_BYTE, current_frame_->y); - - glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride/2); - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width/2, stream_height/2, GL_RG, GL_UNSIGNED_BYTE, current_frame_->uv); - - glBindVertexArray(frame_vao); - glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, nullptr); - glBindVertexArray(0); - - // Reset both texture units - glActiveTexture(GL_TEXTURE1); - glBindTexture(GL_TEXTURE_2D, 0); - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, 0); - glPixelStorei(GL_UNPACK_ALIGNMENT, 4); - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); - - shader_program_->release(); -} - -void CameraWidget::vipcConnected(VisionIpcClient *vipc_client) { - makeCurrent(); - stream_width = vipc_client->buffers[0].width; - stream_height = vipc_client->buffers[0].height; - stream_stride = vipc_client->buffers[0].stride; - - glBindTexture(GL_TEXTURE_2D, textures[0]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, stream_width, stream_height, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); - - glBindTexture(GL_TEXTURE_2D, textures[1]); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RG8, stream_width/2, stream_height/2, 0, GL_RG, GL_UNSIGNED_BYTE, nullptr); - assert(glGetError() == GL_NO_ERROR); + p.setRenderHint(QPainter::SmoothPixmapTransform); + if (active_stream_type == VISION_STREAM_DRIVER) { + // mirror driver camera horizontally + const qreal cx = video_rect.x() + video_rect.width() / 2.0; + p.translate(cx, 0); + p.scale(-1, 1); + p.translate(-cx, 0); + } + p.drawImage(video_rect, rgb_frame); } void CameraWidget::vipcFrameReceived() { @@ -218,10 +76,11 @@ void CameraWidget::vipcThread() { std::unique_ptr vipc_client; VisionIpcBufExtra frame_meta = {}; - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!vipc_exit) { if (!vipc_client || cur_stream != requested_stream_type) { clearFrames(); - qDebug().nospace() << "connecting to stream " << requested_stream_type << ", was connected to " << cur_stream; + fprintf(stderr, "connecting to stream %d, was connected to %d\n", + (int)requested_stream_type, (int)cur_stream); cur_stream = requested_stream_type; vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false)); } @@ -231,23 +90,27 @@ void CameraWidget::vipcThread() { clearFrames(); auto streams = VisionIpcClient::getAvailableStreams(stream_name, false); if (streams.empty()) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } emit vipcAvailableStreamsUpdated(streams); if (!vipc_client->connect(false)) { - QThread::msleep(100); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); continue; } - emit vipcThreadConnected(vipc_client.get()); } if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) { + // NV12 -> RGBA once per frame on the receive thread; paint just draws the image + if (rgb_back.width() != (int)buf->width || rgb_back.height() != (int)buf->height) { + rgb_back = QImage(buf->width, buf->height, QImage::Format_RGBA8888); + } + yuv::nv12_to_rgba(buf->y, buf->stride, buf->uv, buf->stride, + rgb_back.bits(), rgb_back.bytesPerLine(), buf->width, buf->height); { std::lock_guard lk(frame_lock); - current_frame_ = buf; - frame_meta_ = frame_meta; + rgb_frame.swap(rgb_back); } emit vipcThreadFrameReceived(); } @@ -256,6 +119,7 @@ void CameraWidget::vipcThread() { void CameraWidget::clearFrames() { std::lock_guard lk(frame_lock); - current_frame_ = nullptr; + rgb_frame = QImage(); + rgb_back = QImage(); available_streams.clear(); } diff --git a/tools/cabana/cameraview.h b/tools/cabana/cameraview.h index 930b13d..40d3776 100644 --- a/tools/cabana/cameraview.h +++ b/tools/cabana/cameraview.h @@ -1,23 +1,21 @@ #pragma once -#include +#include #include #include #include +#include #include -#include -#include -#include -#include +#include +#include #include "msgq/visionipc/visionipc_client.h" -class CameraWidget : public QOpenGLWidget, protected QOpenGLFunctions { +class CameraWidget : public QWidget { Q_OBJECT public: - using QOpenGLWidget::QOpenGLWidget; explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr); ~CameraWidget(); void setStreamType(VisionStreamType type) { requested_stream_type = type; } @@ -26,37 +24,30 @@ public: signals: void clicked(); - void vipcThreadConnected(VisionIpcClient *); void vipcThreadFrameReceived(); void vipcAvailableStreamsUpdated(std::set); protected: - void paintGL() override; - void initializeGL() override; + void paintEvent(QPaintEvent *event) override; void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override { stopVipcThread(); } void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); } void vipcThread(); void clearFrames(); - GLuint frame_vao, frame_vbo, frame_ibo; - GLuint textures[2]; - std::unique_ptr shader_program_; QColor bg = Qt::black; + QImage rgb_frame; // written by vipc thread, drawn by GUI thread; guarded by frame_lock + QImage rgb_back; // vipc thread only std::string stream_name; - int stream_width = 0; - int stream_height = 0; - int stream_stride = 0; std::atomic active_stream_type; std::atomic requested_stream_type; std::set available_streams; - QThread *vipc_thread = nullptr; - std::recursive_mutex frame_lock; - VisionBuf* current_frame_ = nullptr; - VisionIpcBufExtra frame_meta_ = {}; + std::thread vipc_thread; + std::atomic vipc_exit = false; + std::mutex frame_lock; protected slots: - void vipcConnected(VisionIpcClient *vipc_client); void vipcFrameReceived(); void availableStreamsUpdated(std::set streams); }; diff --git a/tools/cabana/chart/chart.cc b/tools/cabana/chart/chart.cc index bc2380e..8496c26 100644 --- a/tools/cabana/chart/chart.cc +++ b/tools/cabana/chart/chart.cc @@ -1,71 +1,50 @@ #include "tools/cabana/chart/chart.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" -// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html) const int AXIS_X_TOP_MARGIN = 4; -const double MIN_ZOOM_SECONDS = 0.01; // 10ms +const int X_TICK_COUNT = 5; +const double MIN_ZOOM_SECONDS = 0.01; // 10ms // Define a small value of epsilon to compare double values const float EPSILON = 0.000001; static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); } +static QMargins layoutMargins(const QStyle *style) { + return { + style->pixelMetric(QStyle::PM_LayoutLeftMargin), + style->pixelMetric(QStyle::PM_LayoutTopMargin), + style->pixelMetric(QStyle::PM_LayoutRightMargin), + style->pixelMetric(QStyle::PM_LayoutBottomMargin), + }; +} + ChartView::ChartView(const std::pair &x_range, ChartsWidget *parent) - : charts_widget(parent), QChartView(parent) { + : x_min(x_range.first), x_max(x_range.second), charts_widget(parent), QWidget(parent) { series_type = (SeriesType)settings.chart_series_type; - chart()->setBackgroundVisible(false); - axis_x = new QValueAxis(this); - axis_y = new QValueAxis(this); - chart()->addAxis(axis_x, Qt::AlignBottom); - chart()->addAxis(axis_y, Qt::AlignLeft); - chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0); - chart()->legend()->setShowToolTips(true); - chart()->setMargins({0, 0, 0, 0}); - - axis_x->setRange(x_range.first, x_range.second); - + align_to = 50; + setMouseTracking(true); tip_label = new TipLabel(this); createToolButtons(); - setRubberBand(QChartView::HorizontalRubberBand); - setMouseTracking(true); - setTheme(utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight); signal_value_font.setPointSize(9); - QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache); - QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache); - QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache); - - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &ChartView::signalRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &ChartView::signalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &ChartView::msgRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &ChartView::msgUpdated); } void ChartView::createToolButtons() { - move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart()); - move_icon->setToolTip(tr("Drag and drop to move chart")); - - QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart")); - close_btn_proxy = new QGraphicsProxyWidget(chart()); - close_btn_proxy->setWidget(remove_btn); - close_btn_proxy->setZValue(chart()->zValue() + 11); + close_btn = new ToolButton("x", tr("Remove Chart"), this); menu = new QMenu(this); // series types @@ -83,17 +62,14 @@ void ChartView::createToolButtons() { menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals); split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); }); - QToolButton *manage_btn = new ToolButton("list", ""); + manage_btn = new ToolButton("list", "", this); manage_btn->setMenu(menu); manage_btn->setPopupMode(QToolButton::InstantPopup); manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }"); - manage_btn_proxy = new QGraphicsProxyWidget(chart()); - manage_btn_proxy->setWidget(manage_btn); - manage_btn_proxy->setZValue(chart()->zValue() + 11); close_act = new QAction(tr("Close"), this); QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); }); - QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered); + QObject::connect(close_btn, &QToolButton::clicked, close_act, &QAction::triggered); QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) { setSeriesType((SeriesType)action->data().toInt()); }); @@ -103,29 +79,11 @@ QSize ChartView::sizeHint() const { return {CHART_MIN_WIDTH, settings.chart_height}; } -void ChartView::setTheme(QChart::ChartTheme theme) { - chart()->setTheme(theme); - if (theme == QChart::ChartThemeDark) { - axis_x->setTitleBrush(palette().text()); - axis_x->setLabelsBrush(palette().text()); - axis_y->setTitleBrush(palette().text()); - axis_y->setLabelsBrush(palette().text()); - chart()->legend()->setLabelColor(palette().color(QPalette::Text)); - } - axis_x->setLineVisible(false); - axis_y->setLineVisible(false); - for (auto &s : sigs) { - s.series->setColor(s.sig->color); - } -} - void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) { if (hasSignal(msg_id, sig)) return; - QXYSeries *series = createSeries(series_type, sig->color); - sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series}); + sigs.push_back({.msg_id = msg_id, .sig = sig, .color = uniqueColor(toQColor(sig->color))}); updateSeries(sig); - updateSeriesPoints(); updateTitle(); emit charts_widget->seriesChanged(); } @@ -136,29 +94,21 @@ bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) co void ChartView::removeIf(std::function predicate) { int prev_size = sigs.size(); - for (auto it = sigs.begin(); it != sigs.end(); /**/) { - if (predicate(*it)) { - chart()->removeSeries(it->series); - it->series->deleteLater(); - it = sigs.erase(it); - } else { - ++it; - } - } + sigs.erase(std::remove_if(sigs.begin(), sigs.end(), predicate), sigs.end()); if (sigs.empty()) { charts_widget->removeChart(this); } else if (sigs.size() != prev_size) { emit charts_widget->seriesChanged(); updateAxisY(); - resetChartCache(); + updateTitle(); } } void ChartView::signalUpdated(const cabana::Signal *sig) { auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; }); if (it != sigs.end()) { - if (it->series->color() != sig->color) { - setSeriesColor(it->series, sig->color); + if (it->color != toQColor(sig->color)) { + it->color = uniqueColor(toQColor(sig->color), sig); } updateTitle(); updateSeries(sig); @@ -188,98 +138,75 @@ void ChartView::manageSignals() { } void ChartView::resizeEvent(QResizeEvent *event) { - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - move_icon->setPos(left, top); - close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top); - int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing); - manage_btn_proxy->setPos(x, top); - if (align_to > 0) { - updatePlotArea(align_to, true); - } - QChartView::resizeEvent(event); + QWidget::resizeEvent(event); + const auto margins = layoutMargins(style()); + QPixmap grip = utils::icon("grip-horizontal"); + move_icon_rect = QRect(QPoint(margins.left(), margins.top()), grip.size() / grip.devicePixelRatio()); + close_btn->resize(close_btn->sizeHint()); + manage_btn->resize(manage_btn->sizeHint()); + close_btn->move(rect().right() - margins.right() - close_btn->width(), margins.top()); + manage_btn->move(close_btn->x() - manage_btn->width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), margins.top()); + updatePlotArea(align_to, true); } void ChartView::updatePlotArea(int left_pos, bool force) { if (align_to != left_pos || force) { align_to = left_pos; - qreal left, top, right, bottom; - chart()->layout()->getContentsMargins(&left, &top, &right, &bottom); - QSizeF legend_size = chart()->legend()->layout()->minimumSize(); - legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right()); - chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size}); + const auto margins = layoutMargins(style()); + QFont bold_font = font(); + bold_font.setBold(true); + QFontMetrics fm(font()), bfm(bold_font); + const int marker_size = fm.height() - 4; + const int row_height = std::max(marker_size, fm.height()) + QFontMetrics(signal_value_font).height() + 3; + const int legend_left = move_icon_rect.right() + margins.left(); + const int legend_right = std::max(manage_btn->x() - margins.right(), legend_left + 10); - // add top space for signal value - int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3; - adjust_top = std::max(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); + // layout legend entries left-to-right, wrapping between the move icon and the buttons + legend_rects.clear(); + int x = legend_left, y = margins.top(); + for (auto &s : sigs) { + int w = marker_size + 5 + bfm.horizontalAdvance(QString::fromStdString(s.sig->name)) + + fm.horizontalAdvance(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString())); + w = std::min(w, legend_right - legend_left); // keep oversized entries clear of the header buttons + if (x + w > legend_right && x > legend_left) { + x = legend_left; + y += row_height; + } + legend_rects.emplace_back(x, y, w, std::max(marker_size, fm.height())); + x += w + 12; + } + + // add top space for the legend and signal values + int adjust_top = (y + row_height) - margins.top(); + adjust_top = std::max(adjust_top, manage_btn->geometry().bottom() + style()->pixelMetric(QStyle::PM_LayoutTopMargin)); // add right space for x-axis label - QSizeF x_label_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2)); - x_label_size += QSizeF{5, 5}; - chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom)); - chart()->layout()->invalidate(); + QSizeF x_label_size = fm.size(Qt::TextSingleLine, QString::number(x_max, 'f', xAxisPrecision())) + QSizeF{5, 5}; + plot_area = rect().adjusted(align_to + margins.left(), adjust_top + margins.top(), + -x_label_size.width() / 2 - margins.right(), + -x_label_size.height() - margins.bottom()); resetChartCache(); } } void ChartView::updateTitle() { - for (QLegendMarker *marker : chart()->legend()->markers()) { - QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection); - } - - // Use CSS to draw titles in the WindowText color - auto tmp = palette().color(QPalette::WindowText); - auto titleColorCss = tmp.name(QColor::HexArgb); - // Draw message details in similar color, but slightly fade it to the background - tmp.setAlpha(180); - auto msgColorCss = tmp.name(QColor::HexArgb); - - for (auto &s : sigs) { - auto decoration = s.series->isVisible() ? "none" : "line-through"; - s.series->setName(QString("%3 %5 %6") - .arg(decoration, titleColorCss, s.sig->name, - msgColorCss, msgName(s.msg_id), s.msg_id.toString())); - } split_chart_act->setEnabled(sigs.size() > 1); - resetChartCache(); + updatePlotArea(align_to, true); } void ChartView::updatePlot(double cur, double min, double max) { cur_sec = cur; - if (min != axis_x->min() || max != axis_x->max()) { - axis_x->setRange(min, max); + if (min != x_min || max != x_max) { + x_min = min; + x_max = max; updateAxisY(); - updateSeriesPoints(); // update tooltip if (tooltip_x >= 0) { - showTip(chart()->mapToValue({tooltip_x, 0}).x()); + showTip(secondsAtPoint({tooltip_x, 0})); } resetChartCache(); } - viewport()->update(); -} - -void ChartView::updateSeriesPoints() { - // Show points when zoomed in enough - for (auto &s : sigs) { - auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan); - if (begin != end) { - int num_points = std::max((end - begin), 1); - QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end; - double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points; - - if (series_type == SeriesType::Scatter) { - qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0); - if (s.series->useOpenGL()) { - size *= devicePixelRatioF(); - } - ((QScatterSeries *)s.series)->setMarkerSize(size); - } else { - s.series->setPointsVisible(num_points == 1 || pixels_per_point > 20); - } - } - } + update(); } void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector &events, @@ -324,8 +251,6 @@ void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap * if (!can->liveStreaming()) { s.segment_tree.build(s.vals); } - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); } } updateAxisY(); @@ -339,18 +264,18 @@ void ChartView::updateAxisY() { double min = std::numeric_limits::max(); double max = std::numeric_limits::lowest(); - QString unit = sigs[0].sig->unit; + QString unit = QString::fromStdString(sigs[0].sig->unit); for (auto &s : sigs) { - if (!s.series->isVisible()) continue; + if (!s.visible) continue; // Only show unit when all signals have the same unit - if (unit != s.sig->unit) { + if (unit != QString::fromStdString(s.sig->unit)) { unit.clear(); } - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); s.min = std::numeric_limits::max(); s.max = std::numeric_limits::lowest(); if (can->liveStreaming()) { @@ -367,28 +292,28 @@ void ChartView::updateAxisY() { if (min == std::numeric_limits::max()) min = 0; if (max == std::numeric_limits::lowest()) max = 0; - if (axis_y->titleText() != unit) { - axis_y->setTitleText(unit); + if (y_unit != unit) { + y_unit = unit; y_label_width = 0; // recalc width } double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05; auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3); - if (min_y != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) { - axis_y->setRange(min_y, max_y); - axis_y->setTickCount(tick_count); + if (min_y != y_min || max_y != y_max || y_label_width == 0) { + y_min = min_y; + y_max = max_y; + y_tick_count = tick_count; + y_precision = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); - int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0); + QFontMetrics fm(font()); int max_label_width = 0; - QFontMetrics fm(axis_y->labelsFont()); for (int i = 0; i < tick_count; i++) { qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1)); - max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', n))); + max_label_width = std::max(max_label_width, fm.horizontalAdvance(QString::number(value, 'f', y_precision))); } - int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height(); + int title_spacing = y_unit.isEmpty() ? 0 : fm.size(Qt::TextSingleLine, y_unit).height(); y_label_width = title_spacing + max_label_width + 15; - axis_y->setLabelFormat(QString("%.%1f").arg(n)); emit axisYLabelWidthChanged(y_label_width); } } @@ -402,6 +327,10 @@ std::tuple ChartView::getNiceAxisNumbers(qreal min, qreal m return {min * step, max * step, tick_count}; } +int ChartView::xAxisPrecision() const { + return std::max(int(-std::floor(std::log10((x_max - x_min) / (X_TICK_COUNT - 1)))), 2); +} + // nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n qreal ChartView::niceNumber(qreal x, bool ceiling) { qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x @@ -420,45 +349,6 @@ qreal ChartView::niceNumber(qreal x, bool ceiling) { return q * z; } -QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) { - QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect; - e->setColor(QColor(40, 40, 40, 245)); - e->setOffset(0, 0); - e->setBlurRadius(radius); - - qreal dpr = px.devicePixelRatio(); - QPixmap blank(px.size()); - blank.setDevicePixelRatio(dpr); - blank.fill(Qt::white); - - QGraphicsScene scene; - QGraphicsPixmapItem item(blank); - item.setGraphicsEffect(e); - scene.addItem(&item); - - QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2)); - shadow.setDevicePixelRatio(dpr); - shadow.fill(Qt::transparent); - QPainter p(&shadow); - scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius)); - return shadow; -} - -static QPixmap getDropPixmap(const QPixmap &src) { - static QPixmap shadow_px; - const int radius = 10; - if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) { - shadow_px = getBlankShadowPixmap(src, radius); - } - QPixmap px = shadow_px; - QPainter p(&px); - QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio()); - p.drawPixmap(target_rect.topLeft(), src); - p.setCompositionMode(QPainter::CompositionMode_DestinationIn); - p.fillRect(target_rect, QColor(0, 0, 0, 200)); - return px; -} - void ChartView::contextMenuEvent(QContextMenuEvent *event) { QMenu context_menu(this); context_menu.addActions(menu->actions()); @@ -471,392 +361,410 @@ void ChartView::contextMenuEvent(QContextMenuEvent *event) { } void ChartView::mousePressEvent(QMouseEvent *event) { - if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) { - QMimeData *mimeData = new QMimeData; - mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this)); - QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation); - charts_widget->stopAutoScroll(); - QDrag *drag = new QDrag(this); - drag->setMimeData(mimeData); - drag->setPixmap(getDropPixmap(px)); - drag->setHotSpot(-QPoint(5, 5)); - drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction); - } else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + press_pos = event->pos(); + if (event->button() == Qt::LeftButton && move_icon_rect.contains(event->pos())) { + charts_widget->startChartDrag(this, event->globalPos()); + } else if (event->button() == Qt::LeftButton && event->modifiers().testFlag(Qt::ShiftModifier)) { // Save current playback state when scrubbing resume_after_scrub = !can->isPaused(); if (resume_after_scrub) { can->pause(true); } - is_scrubbing = true; + mouse_mode = MouseMode::Scrub; + } else if (event->button() == Qt::LeftButton && plot_area.contains(event->pos())) { + mouse_mode = MouseMode::Rubber; + rubber_rect = QRect(); } else { - QChartView::mousePressEvent(event); - } -} - -void ChartView::mouseReleaseEvent(QMouseEvent *event) { - auto rubber = findChild(); - if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) { - rubber->hide(); - auto rect = rubber->geometry().normalized(); - // Prevent zooming/seeking past the end of the route - double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), can->minSeconds(), can->maxSeconds()); - double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), can->minSeconds(), can->maxSeconds()); - if (rubber->width() <= 0) { - // no rubber dragged, seek to mouse position - can->seekTo(min); - } else if (rubber->width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { - charts_widget->zoom_undo_stack->push(new ZoomCommand({min, max})); - } else { - viewport()->update(); - } - event->accept(); - } else if (event->button() == Qt::RightButton) { - charts_widget->zoom_undo_stack->undo(); - event->accept(); - } else { - QGraphicsView::mouseReleaseEvent(event); - } - - // Resume playback if we were scrubbing - is_scrubbing = false; - if (resume_after_scrub) { - can->pause(false); - resume_after_scrub = false; + QWidget::mousePressEvent(event); } } void ChartView::mouseMoveEvent(QMouseEvent *ev) { - const auto plot_area = chart()->plotArea(); // Scrubbing - if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) { + if (mouse_mode == MouseMode::Scrub && ev->modifiers().testFlag(Qt::ShiftModifier)) { if (plot_area.contains(ev->pos())) { - can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), can->minSeconds(), can->maxSeconds())); + can->seekTo(std::clamp(secondsAtPoint(ev->pos()), can->minSeconds(), can->maxSeconds())); } } - auto rubber = findChild(); - bool is_zooming = rubber && rubber->isVisible(); - clearTrackPoints(); + if (mouse_mode == MouseMode::Rubber) { + // horizontal selection, clamped to the plot area + int left = std::clamp(std::min(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + int right = std::clamp(std::max(press_pos.x(), ev->pos().x()), plot_area.left(), plot_area.right()); + rubber_rect = QRect(left, plot_area.top(), right - left, plot_area.height()); + update(); + } - if (!is_zooming && plot_area.contains(ev->pos()) && isActiveWindow()) { + clearTrackPoints(); + if (mouse_mode != MouseMode::Rubber && plot_area.contains(ev->pos()) && isActiveWindow()) { charts_widget->showValueTip(secondsAtPoint(ev->pos())); } else if (tip_label->isVisible()) { charts_widget->showValueTip(-1); } + QWidget::mouseMoveEvent(ev); +} - QChartView::mouseMoveEvent(ev); - if (is_zooming) { - QRect rubber_rect = rubber->geometry(); - rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left())); - rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right())); - if (rubber_rect != rubber->geometry()) { - rubber->setGeometry(rubber_rect); +void ChartView::mouseReleaseEvent(QMouseEvent *event) { + if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::Rubber) { + mouse_mode = MouseMode::None; + // Prevent zooming/seeking past the end of the route + double min = std::clamp(secondsAtPoint(rubber_rect.topLeft()), can->minSeconds(), can->maxSeconds()); + double max = std::clamp(secondsAtPoint(rubber_rect.bottomRight()), can->minSeconds(), can->maxSeconds()); + if (rubber_rect.width() <= 0) { + // no rubber dragged, seek to mouse position + can->seekTo(std::clamp(secondsAtPoint(press_pos), can->minSeconds(), can->maxSeconds())); + } else if (rubber_rect.width() > 10 && (max - min) > MIN_ZOOM_SECONDS) { + charts_widget->zoom_undo_stack.push(new ZoomCommand({min, max})); + } + rubber_rect = QRect(); + update(); + } else if (event->button() == Qt::LeftButton && mouse_mode == MouseMode::None && sigs.size() > 1) { + // toggle series visibility by clicking its legend entry + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + if (legend_rects[i].contains(press_pos) && legend_rects[i].contains(event->pos())) { + sigs[i].visible = !sigs[i].visible; + updateAxisY(); + updateTitle(); + break; + } + } + } else if (event->button() == Qt::RightButton) { + charts_widget->zoom_undo_stack.undo(); + } else { + QWidget::mouseReleaseEvent(event); + } + + // Resume playback if we were scrubbing + if (mouse_mode == MouseMode::Scrub) { + mouse_mode = MouseMode::None; + if (resume_after_scrub) { + can->pause(false); + resume_after_scrub = false; } - viewport()->update(); } } +void ChartView::takeSignalsFrom(ChartView *source) { + for (auto &s : source->sigs) { + sigs.push_back(std::move(s)); + sigs.back().color = uniqueColor(sigs.back().color, sigs.back().sig); + } + source->sigs.clear(); + updateAxisY(); + updateTitle(); + charts_widget->removeChart(source); +} + void ChartView::showTip(double sec) { - QRect tip_area(0, chart()->plotArea().top(), rect().width(), chart()->plotArea().height()); + QRect tip_area(0, plot_area.top(), rect().width(), plot_area.height()); QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area); if (visible_rect.isEmpty()) { tip_label->hide(); return; } - tooltip_x = chart()->mapToPosition({sec, 0}).x(); + tooltip_x = xPos(sec); qreal x = -1; QStringList text_list; for (auto &s : sigs) { - if (s.series->isVisible()) { + if (s.visible) { QString value = "--"; // use reverse iterator to find last item <= sec. - auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x() > x; }); - if (it != s.vals.crend() && it->x() >= axis_x->min()) { - value = s.sig->formatValue(it->y(), false); + auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double v) { return p.x() > v; }); + if (it != s.vals.crend() && it->x() >= x_min) { + value = QString::fromStdString(s.sig->formatValue(it->y(), false)); s.track_pt = *it; - x = std::max(x, chart()->mapToPosition(*it).x()); + x = std::max(x, xPos(it->x())); } - QString name = sigs.size() > 1 ? s.sig->name + ": " : ""; + QString name = sigs.size() > 1 ? QString::fromStdString(s.sig->name) + ": " : ""; QString min = s.min == std::numeric_limits::max() ? "--" : QString::number(s.min); QString max = s.max == std::numeric_limits::lowest() ? "--" : QString::number(s.max); text_list << QString("%2%3 (%4, %5)") - .arg(s.series->color().name(), name, value, min, max); + .arg(s.color.name(), name, value, min, max); } } if (x < 0) { x = tooltip_x; } - QPoint pt(x, chart()->plotArea().top()); - text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3)); + QPoint pt(x, plot_area.top()); + text_list.push_front(QString::number(secondsAtPoint({x, 0}), 'f', 3)); QString text = "

" % text_list.join("
") % "

"; tip_label->showText(pt, text, this, visible_rect); - viewport()->update(); + update(); } void ChartView::hideTip() { clearTrackPoints(); tooltip_x = -1; tip_label->hide(); - viewport()->update(); -} - -void ChartView::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - drawDropIndicator(event->source() != this); - event->acceptProposedAction(); - } -} - -void ChartView::dragMoveEvent(QDragMoveEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction); - event->accept(); - } - charts_widget->startAutoScroll(); -} - -void ChartView::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - if (event->source() != this) { - ChartView *source_chart = (ChartView *)event->source(); - for (auto &s : source_chart->sigs) { - source_chart->chart()->removeSeries(s.series); - addSeries(s.series); - } - sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end())); - updateAxisY(); - updateTitle(); - startAnimation(); - - source_chart->sigs.clear(); - charts_widget->removeChart(source_chart); - event->acceptProposedAction(); - } - can_drop = false; - } + update(); } void ChartView::resetChartCache() { chart_pixmap = QPixmap(); - viewport()->update(); -} - -void ChartView::startAnimation() { - QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this); - viewport()->setGraphicsEffect(eff); - QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity"); - a->setDuration(250); - a->setStartValue(0.3); - a->setEndValue(1); - a->setEasingCurve(QEasingCurve::InBack); - a->start(QPropertyAnimation::DeleteWhenStopped); + update(); } void ChartView::paintEvent(QPaintEvent *event) { - if (!can->liveStreaming()) { - if (chart_pixmap.isNull()) { - const qreal dpr = viewport()->devicePixelRatioF(); - chart_pixmap = QPixmap(viewport()->size() * dpr); - chart_pixmap.setDevicePixelRatio(dpr); - QPainter p(&chart_pixmap); - p.setRenderHints(QPainter::Antialiasing); - drawBackground(&p, viewport()->rect()); - scene()->setSceneRect(viewport()->rect()); - scene()->render(&p, viewport()->rect()); - } + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing); - QPainter painter(viewport()); - painter.setRenderHints(QPainter::Antialiasing); - painter.drawPixmap(QPoint(), chart_pixmap); - if (can_drop) { - painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); - painter.drawRect(viewport()->rect()); - } - QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect(); - drawForeground(&painter, exposed_rect); - } else { - QChartView::paintEvent(event); + // the static layer is invalidated on x-range change and data merge, so cache it in live mode too + const qreal dpr = devicePixelRatioF(); + if (chart_pixmap.isNull() || chart_pixmap.size() != size() * dpr) { + chart_pixmap = QPixmap(size() * dpr); + chart_pixmap.setDevicePixelRatio(dpr); + QPainter p(&chart_pixmap); + p.setRenderHints(QPainter::Antialiasing); + p.setFont(font()); + drawStaticLayer(&p); + } + painter.drawPixmap(QPoint(), chart_pixmap); + + if (can_drop) { + painter.setPen(QPen(palette().color(QPalette::Highlight), 4)); + painter.drawRect(rect()); + } + drawForeground(&painter); +} + +void ChartView::drawStaticLayer(QPainter *painter) { + painter->fillRect(rect(), palette().color(QPalette::Base)); + painter->drawPixmap(move_icon_rect.topLeft(), utils::icon("grip-horizontal")); + drawAxes(painter); + drawLegend(painter); + drawSeries(painter); +} + +void ChartView::drawAxes(QPainter *painter) { + const QColor text_color = palette().color(QPalette::Text); + QColor grid_color = text_color; + grid_color.setAlpha(50); + QFontMetrics fm(font()); + painter->setFont(font()); + + // y grid lines and tick labels + for (int i = 0; i < y_tick_count; ++i) { + double value = y_min + i * (y_max - y_min) / (y_tick_count - 1); + qreal y = yPos(value); + painter->setPen(grid_color); + painter->drawLine(QPointF(plot_area.left(), y), QPointF(plot_area.right(), y)); + painter->setPen(text_color); + QRectF label_rect(0, y - fm.height() / 2.0, plot_area.left() - 6, fm.height()); + painter->drawText(label_rect, Qt::AlignRight | Qt::AlignVCenter, QString::number(value, 'f', y_precision)); + } + + // rotated y axis title (unit) + if (!y_unit.isEmpty()) { + painter->save(); + painter->translate(plot_area.left() - y_label_width + fm.height() / 2.0, plot_area.center().y()); + painter->rotate(-90); + painter->drawText(QRectF(-plot_area.height() / 2.0, -fm.height() / 2.0, plot_area.height(), fm.height()), + Qt::AlignCenter, y_unit); + painter->restore(); + } + + // x grid lines and tick labels + const int x_precision = xAxisPrecision(); + for (int i = 0; i < X_TICK_COUNT; ++i) { + double sec = x_min + i * (x_max - x_min) / (X_TICK_COUNT - 1); + qreal x = xPos(sec); + painter->setPen(grid_color); + painter->drawLine(QPointF(x, plot_area.top()), QPointF(x, plot_area.bottom())); + painter->setPen(text_color); + QString label = QString::number(sec, 'f', x_precision); + QRectF label_rect(x - 100, plot_area.bottom() + AXIS_X_TOP_MARGIN, 200, fm.height()); + painter->drawText(label_rect, Qt::AlignHCenter | Qt::AlignTop, label); } } -void ChartView::drawBackground(QPainter *painter, const QRectF &rect) { - painter->fillRect(rect, palette().color(QPalette::Base)); +void ChartView::drawLegend(QPainter *painter) { + QColor title_color = palette().color(QPalette::WindowText); + // Draw message details in similar color, but slightly fade it to the background + QColor msg_color = title_color; + msg_color.setAlpha(180); + QFont bold_font = font(); + bold_font.setBold(true); + const int marker_size = QFontMetrics(font()).height() - 4; + + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; + const QRect &r = legend_rects[i]; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + QRectF marker_rect(r.left(), r.center().y() - marker_size / 2.0, marker_size, marker_size); + series_type == SeriesType::Scatter ? painter->drawEllipse(marker_rect) : painter->drawRect(marker_rect); + + bold_font.setStrikeOut(!s.visible); + QFont normal_font = font(); + normal_font.setStrikeOut(!s.visible); + + qreal x = r.left() + marker_size + 5; + painter->setFont(bold_font); + painter->setPen(title_color); + QString name = QFontMetrics(bold_font).elidedText(QString::fromStdString(s.sig->name), Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, name); + x += QFontMetrics(bold_font).horizontalAdvance(name); + painter->setFont(normal_font); + painter->setPen(msg_color); + QString msg = QFontMetrics(normal_font).elidedText(QString::fromStdString(" " + msgName(s.msg_id) + " " + s.msg_id.toString()), + Qt::ElideRight, r.right() - x); + painter->drawText(QRectF(x, r.top(), r.right() - x, r.height()), Qt::AlignLeft | Qt::AlignVCenter, msg); + } } -void ChartView::drawForeground(QPainter *painter, const QRectF &rect) { +void ChartView::drawSeries(QPainter *painter) { + painter->save(); + painter->setClipRect(plot_area); + for (auto &s : sigs) { + if (!s.visible) continue; + + // visible points in vals to compute point density + auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), x_min, xLessThan); + auto last = std::lower_bound(first, s.vals.cend(), x_max, xLessThan); + int num_points = std::max(last - first, 1); + double pixels_per_point = 0; + if (first != last) { + const QPointF &right_pt = last == s.vals.cend() ? s.vals.back() : *last; + pixels_per_point = (xPos(right_pt.x()) - xPos(first->x())) / num_points; + } + + if (series_type == SeriesType::Scatter) { + qreal radius = std::clamp(pixels_per_point / 2.0, 2.0, 8.0) / 2.0; + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), radius, radius); + } + } else { + const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; + auto begin = std::lower_bound(points.cbegin(), points.cend(), x_min, xLessThan); + if (begin != points.cbegin()) --begin; + auto end = std::lower_bound(begin, points.cend(), x_max, xLessThan); + if (end != points.cend()) ++end; + if (begin == end) continue; + + std::vector polyline; + polyline.reserve(end - begin); + for (auto it = begin; it != end; ++it) { + polyline.emplace_back(xPos(it->x()), yPos(it->y())); + } + painter->setPen(QPen(s.color, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawPolyline(polyline.data(), polyline.size()); + + // show points when zoomed in enough + if (num_points == 1 || pixels_per_point > 20) { + painter->setPen(Qt::NoPen); + painter->setBrush(s.color); + for (auto it = first; it != last; ++it) { + painter->drawEllipse(QPointF(xPos(it->x()), yPos(it->y())), 4, 4); + } + } + } + } + painter->restore(); +} + +void ChartView::drawForeground(QPainter *painter) { drawTimeline(painter); drawSignalValue(painter); // draw track points painter->setPen(Qt::NoPen); qreal track_line_x = -1; for (auto &s : sigs) { - if (!s.track_pt.isNull() && s.series->isVisible()) { - painter->setBrush(s.series->color().darker(125)); - QPointF pos = chart()->mapToPosition(s.track_pt); + if (!s.track_pt.isNull() && s.visible) { + painter->setBrush(s.color.darker(125)); + QPointF pos(xPos(s.track_pt.x()), yPos(s.track_pt.y())); painter->drawEllipse(pos, 5.5, 5.5); track_line_x = std::max(track_line_x, pos.x()); } } if (track_line_x > 0) { - auto plot_area = chart()->plotArea(); painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine)); - painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()}); - } - - // paint points. OpenGL mode lacks certain features (such as showing points) - painter->setPen(Qt::NoPen); - for (auto &s : sigs) { - if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) { - auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan); - auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan); - painter->setBrush(s.series->color()); - for (auto it = first; it != last; ++it) { - painter->drawEllipse(chart()->mapToPosition(*it), 4, 4); - } - } + painter->drawLine(QPointF{track_line_x, (qreal)plot_area.top()}, QPointF{track_line_x, (qreal)plot_area.bottom()}); } drawRubberBandTimeRange(painter); } void ChartView::drawRubberBandTimeRange(QPainter *painter) { - auto rubber = findChild(); - if (rubber && rubber->isVisible() && rubber->width() > 1) { - painter->setPen(Qt::white); - auto rubber_rect = rubber->geometry().normalized(); - for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { - QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2); - auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); - pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); - painter->fillRect(r, Qt::gray); - painter->drawText(r, Qt::AlignCenter, sec); - } + if (rubber_rect.width() <= 1) return; + + // selection rect + QColor highlight = palette().color(QPalette::Highlight); + QColor fill = highlight; + fill.setAlpha(50); + painter->fillRect(rubber_rect, fill); + painter->setPen(highlight); + painter->setBrush(Qt::NoBrush); + painter->drawRect(rubber_rect); + + // time labels at the bottom corners + painter->setPen(Qt::white); + painter->setFont(font()); + for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) { + QString sec = QString::number(secondsAtPoint(pt), 'f', 2); + auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN); + pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2}); + painter->fillRect(r, Qt::gray); + painter->drawText(r, Qt::AlignCenter, sec); } } void ChartView::drawTimeline(QPainter *painter) { - const auto plot_area = chart()->plotArea(); // draw vertical time line - qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right()); - painter->setPen(QPen(chart()->titleBrush().color(), 1)); - painter->drawLine(QPointF{x, plot_area.top() - 1}, QPointF{x, plot_area.bottom() + 1}); + qreal x = std::clamp(xPos(cur_sec), (qreal)plot_area.left(), (qreal)plot_area.right()); + painter->setPen(QPen(palette().color(QPalette::Text), 1)); + painter->drawLine(QPointF{x, plot_area.top() - 1.0}, QPointF{x, plot_area.bottom() + 1.0}); // draw current time under the axis-x QString time_str = QString::number(cur_sec, 'f', 2); - QSize time_str_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); + QSize time_str_size = QFontMetrics(font()).size(Qt::TextSingleLine, time_str) + QSize(8, 2); QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size); QPainterPath path; path.addRoundedRect(time_str_rect, 3, 3); painter->fillPath(path, utils::isDarkTheme() ? Qt::darkGray : Qt::gray); painter->setPen(palette().color(QPalette::BrightText)); - painter->setFont(axis_x->labelsFont()); + painter->setFont(font()); painter->drawText(time_str_rect, Qt::AlignCenter, time_str); } void ChartView::drawSignalValue(QPainter *painter) { - auto item_group = qgraphicsitem_cast(chart()->legend()->childItems()[0]); - assert(item_group != nullptr); - auto legend_markers = item_group->childItems(); - assert(legend_markers.size() == sigs.size()); - painter->setFont(signal_value_font); - painter->setPen(chart()->legend()->labelColor()); - int i = 0; - for (auto &s : sigs) { + painter->setPen(palette().color(QPalette::Text)); + for (int i = 0; i < sigs.size() && i < legend_rects.size(); ++i) { + const auto &s = sigs[i]; auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec, [](auto &p, double x) { return p.x() > x + EPSILON; }); - QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? s.sig->formatValue(it->y()) : "--"; - QRectF marker_rect = legend_markers[i++]->sceneBoundingRect(); - QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size()); + QString value = (it != s.vals.crend() && it->x() >= x_min) ? QString::fromStdString(s.sig->formatValue(it->y())) : "--"; + QRectF value_rect(legend_rects[i].bottomLeft() - QPoint(0, 1), legend_rects[i].size()); QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width()); painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val); } } -QXYSeries *ChartView::createSeries(SeriesType type, QColor color) { - QXYSeries *series = nullptr; - if (type == SeriesType::Line) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle); - } else if (type == SeriesType::StepLine) { - series = new QLineSeries(this); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries); - } else { - series = new QScatterSeries(this); - static_cast(series)->setBorderColor(color); - chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle); - } - series->setColor(color); - // TODO: Due to a bug in CameraWidget the camera frames - // are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed -#ifndef __APPLE__ - series->setUseOpenGL(true); - // Qt doesn't properly apply device pixel ratio in OpenGL mode - QPen pen = series->pen(); - pen.setWidthF(2.0 * devicePixelRatioF()); - series->setPen(pen); -#endif - addSeries(series); - return series; -} - -void ChartView::addSeries(QXYSeries *series) { - setSeriesColor(series, series->color()); - chart()->addSeries(series); - series->attachAxis(axis_x); - series->attachAxis(axis_y); - - // disables the delivery of mouse events to the opengl widget. - // this enables the user to select the zoom area when the mouse press on the data point. - auto glwidget = findChild(); - if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) { - glwidget->setAttribute(Qt::WA_TransparentForMouseEvents); - } -} - -void ChartView::setSeriesColor(QXYSeries *series, QColor color) { - auto existing_series = chart()->series(); - for (auto s : existing_series) { - if (s != series && std::abs(color.hueF() - qobject_cast(s)->color().hueF()) < 0.1) { +QColor ChartView::uniqueColor(QColor color, const cabana::Signal *exclude) const { + for (auto &s : sigs) { + if (s.sig != exclude && std::abs(color.hueF() - s.color.hueF()) < 0.1) { // use different color to distinguish it from others. - auto last_color = qobject_cast(existing_series.back())->color(); + auto last_color = sigs.back().color; + static thread_local std::mt19937 rng{std::random_device{}()}; + std::uniform_int_distribution sat(35, 99); + std::uniform_int_distribution val(85, 99); color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0), - QRandomGenerator::global()->bounded(35, 100) / 100.0, - QRandomGenerator::global()->bounded(85, 100) / 100.0); + sat(rng) / 100.0, + val(rng) / 100.0); break; } } - series->setColor(color); + return color; } void ChartView::setSeriesType(SeriesType type) { if (type != series_type) { series_type = type; - for (auto &s : sigs) { - chart()->removeSeries(s.series); - s.series->deleteLater(); - } - for (auto &s : sigs) { - s.series = createSeries(series_type, s.sig->color); - const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals; - s.series->replace(QVector(points.cbegin(), points.cend())); - } - updateSeriesPoints(); - updateTitle(); - menu->actions()[(int)type]->setChecked(true); - } -} - -void ChartView::handleMarkerClicked() { - auto marker = qobject_cast(sender()); - Q_ASSERT(marker); - if (sigs.size() > 1) { - auto series = marker->series(); - series->setVisible(!series->isVisible()); - marker->setVisible(true); - updateAxisY(); updateTitle(); } } diff --git a/tools/cabana/chart/chart.h b/tools/cabana/chart/chart.h index f9472bd..b036234 100644 --- a/tools/cabana/chart/chart.h +++ b/tools/cabana/chart/chart.h @@ -1,18 +1,11 @@ #pragma once +#include #include #include #include #include -#include -#include -#include -#include -#include -#include -#include -using namespace QtCharts; #include "tools/cabana/chart/tiplabel.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -25,7 +18,7 @@ enum class SeriesType { }; class ChartsWidget; -class ChartView : public QChartView { +class ChartView : public QWidget { Q_OBJECT public: @@ -38,13 +31,15 @@ public: void updatePlotArea(int left, bool force = false); void showTip(double sec); void hideTip(); - void startAnimation(); - double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); } + double secondsAtPoint(const QPointF &pt) const { + return x_min + (pt.x() - plot_area.left()) * (x_max - x_min) / std::max(plot_area.width(), 1); + } struct SigItem { MessageId msg_id; const cabana::Signal *sig = nullptr; - QXYSeries *series = nullptr; + QColor color; + bool visible = true; std::vector vals; std::vector step_vals; QPointF track_pt{}; @@ -59,7 +54,6 @@ signals: private slots: void signalUpdated(const cabana::Signal *sig); void manageSignals(); - void handleMarkerClicked(); void msgUpdated(MessageId id); void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); } void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); } @@ -68,52 +62,65 @@ private: void appendCanEvents(const cabana::Signal *sig, const std::vector &events, std::vector &vals, std::vector &step_vals); void createToolButtons(); - void addSeries(QXYSeries *series); void contextMenuEvent(QContextMenuEvent *event) override; void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; - void mouseMoveEvent(QMouseEvent *ev) override; - void dragEnterEvent(QDragEnterEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); } - void dragMoveEvent(QDragMoveEvent *event) override; - void dropEvent(QDropEvent *event) override; void resizeEvent(QResizeEvent *event) override; QSize sizeHint() const override; void updateAxisY(); void updateTitle(); void resetChartCache(); - void setTheme(QChart::ChartTheme theme); void paintEvent(QPaintEvent *event) override; - void drawForeground(QPainter *painter, const QRectF &rect) override; - void drawBackground(QPainter *painter, const QRectF &rect) override; - void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); } + void drawStaticLayer(QPainter *painter); + void drawAxes(QPainter *painter); + void drawLegend(QPainter *painter); + void drawSeries(QPainter *painter); + void drawForeground(QPainter *painter); void drawSignalValue(QPainter *painter); void drawTimeline(QPainter *painter); void drawRubberBandTimeRange(QPainter *painter); + int xAxisPrecision() const; std::tuple getNiceAxisNumbers(qreal min, qreal max, int tick_count); qreal niceNumber(qreal x, bool ceiling); - QXYSeries *createSeries(SeriesType type, QColor color); - void setSeriesColor(QXYSeries *, QColor color); - void updateSeriesPoints(); + QColor uniqueColor(QColor color, const cabana::Signal *exclude = nullptr) const; void removeIf(std::function predicate); + void takeSignalsFrom(ChartView *source); + void setDropHighlight(bool highlight) { if (std::exchange(can_drop, highlight) != highlight) update(); } inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; } + inline qreal xPos(double sec) const { return plot_area.left() + (sec - x_min) / (x_max - x_min) * plot_area.width(); } + inline qreal yPos(double val) const { return plot_area.bottom() - (val - y_min) / (y_max - y_min) * plot_area.height(); } + // layout + QRect plot_area; + QRect move_icon_rect; + std::vector legend_rects; + // axes + double x_min; + double x_max; + double y_min = 0; + double y_max = 1; + int y_tick_count = 3; + int y_precision = 0; + QString y_unit; int y_label_width = 0; int align_to = 0; - QValueAxis *axis_x; - QValueAxis *axis_y; + // interaction + enum class MouseMode { None, Rubber, Scrub }; + MouseMode mouse_mode = MouseMode::None; + QPoint press_pos; + QRect rubber_rect; + bool resume_after_scrub = false; + QMenu *menu; QAction *split_chart_act; QAction *close_act; - QGraphicsPixmapItem *move_icon; - QGraphicsProxyWidget *close_btn_proxy; - QGraphicsProxyWidget *manage_btn_proxy; + ToolButton *manage_btn; + ToolButton *close_btn; TipLabel *tip_label; std::vector sigs; double cur_sec = 0; SeriesType series_type = SeriesType::Line; - bool is_scrubbing = false; - bool resume_after_scrub = false; QPixmap chart_pixmap; bool can_drop = false; double tooltip_x = -1; diff --git a/tools/cabana/chart/chartswidget.cc b/tools/cabana/chart/chartswidget.cc index aba25dc..3144c51 100644 --- a/tools/cabana/chart/chartswidget.cc +++ b/tools/cabana/chart/chartswidget.cc @@ -1,13 +1,14 @@ #include "tools/cabana/chart/chartswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include #include -#include #include +#include #include #include -#include #include "tools/cabana/chart/chart.h" @@ -71,11 +72,14 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { range_slider_action = toolbar->addWidget(range_slider); // zoom controls - zoom_undo_stack = new QUndoStack(this); - toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this)); - undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); - toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this)); - redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); + undo_zoom_action = toolbar->addAction(utils::icon("arrow-counterclockwise"), tr("Undo Zoom"), [this]() { zoom_undo_stack.undo(); }); + redo_zoom_action = toolbar->addAction(utils::icon("arrow-clockwise"), tr("Redo Zoom"), [this]() { zoom_undo_stack.redo(); }); + undo_zoom_action->setEnabled(false); + redo_zoom_action->setEnabled(false); + zoom_undo_stack.setCallbacks({.index_changed = [this]() { + undo_zoom_action->setEnabled(zoom_undo_stack.canUndo()); + redo_zoom_action->setEnabled(zoom_undo_stack.canRedo()); + }}); reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom"))); reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); @@ -88,8 +92,6 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { tabbar->setAutoHide(true); tabbar->setExpanding(false); tabbar->setDrawBase(true); - tabbar->setAcceptDrops(true); - tabbar->setChangeCurrentOnDrag(true); tabbar->setUsesScrollButtons(true); main_layout->addWidget(tabbar); @@ -104,6 +106,11 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); main_layout->addWidget(charts_scroll); + // chart drag preview + drag_preview = new QLabel(this); + drag_preview->setAttribute(Qt::WA_TransparentForMouseEvents); + drag_preview->hide(); + // init settings current_theme = settings.theme; column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT); @@ -115,7 +122,7 @@ ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) { align_timer->setSingleShot(true); QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts); QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &ChartsWidget::removeAll); QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged); QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState); QObject::connect(can, &AbstractStream::seeking, this, &ChartsWidget::updateState); @@ -166,15 +173,16 @@ void ChartsWidget::removeTab(int index) { void ChartsWidget::updateTabBar() { for (int i = 0; i < tabbar->count(); ++i) { const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()]; - tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count())); + tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg((int)charts_in_tab.size())); } } void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) { - QFutureSynchronizer future_synchronizer; + std::vector> futures; for (auto c : charts) { - future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events)); + futures.push_back(std::async(std::launch::async, &ChartView::updateSeries, c, nullptr, &new_events)); } + for (auto &f : futures) f.get(); } void ChartsWidget::timeRangeChanged(const std::optional> &time_range) { @@ -184,7 +192,7 @@ void ChartsWidget::timeRangeChanged(const std::optionalsetTimeRange(std::nullopt); - zoom_undo_stack->clear(); + zoom_undo_stack.clear(); } QRect ChartsWidget::chartVisibleRect(ChartView *chart) { @@ -203,7 +211,7 @@ void ChartsWidget::showValueTip(double sec) { } void ChartsWidget::updateState() { - if (charts.isEmpty()) return; + if (charts.empty()) return; const auto &time_range = can->timeRange(); const double cur_sec = can->currentSec(); @@ -247,17 +255,13 @@ void ChartsWidget::updateToolBar() { redo_zoom_action->setVisible(is_zoomed); reset_zoom_action->setVisible(is_zoomed); reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : ""); - remove_all_btn->setEnabled(!charts.isEmpty()); + remove_all_btn->setEnabled(!charts.empty()); } void ChartsWidget::settingChanged() { if (std::exchange(current_theme, settings.theme) != current_theme) { undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise")); redo_zoom_action->setIcon(utils::icon("arrow-clockwise")); - auto theme = utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight; - for (auto c : charts) { - c->setTheme(theme); - } } if (range_slider->maximum() != settings.max_cached_minutes * 60) { range_slider->setRange(1, settings.max_cached_minutes * 60); @@ -281,9 +285,9 @@ ChartView *ChartsWidget::createChart(int pos) { chart->setMinimumWidth(CHART_MIN_WIDTH); chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed); QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start)); - pos = std::clamp(pos, 0, charts.size()); - charts.insert(pos, chart); - currentCharts().insert(pos, chart); + pos = std::clamp(pos, 0, (int)charts.size()); + charts.insert(charts.begin() + pos, chart); + currentCharts().insert(currentCharts().begin() + pos, chart); updateLayout(true); updateToolBar(); return chart; @@ -302,15 +306,11 @@ void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, boo void ChartsWidget::splitChart(ChartView *src_chart) { if (src_chart->sigs.size() > 1) { - int pos = charts.indexOf(src_chart) + 1; + int pos = std::find(charts.begin(), charts.end(), src_chart) - charts.begin() + 1; for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) { auto c = createChart(pos); - src_chart->chart()->removeSeries(it->series); - // Restore to the original color - it->series->setColor(it->sig->color); - - c->addSeries(it->series); + it->color = toQColor(it->sig->color); c->sigs.emplace_back(std::move(*it)); c->updateAxisY(); c->updateTitle(); @@ -318,6 +318,7 @@ void ChartsWidget::splitChart(ChartView *src_chart) { } src_chart->updateAxisY(); src_chart->updateTitle(); + updateState(); QTimer::singleShot(0, src_chart, &ChartView::resetChartCache); } } @@ -327,7 +328,7 @@ QStringList ChartsWidget::serializeChartIds() const { for (auto c : charts) { QStringList ids; for (const auto& s : c->sigs) - ids += QString("%1|%2").arg(s.msg_id.toString(), s.sig->name); + ids += QString("%1|%2").arg(QString::fromStdString(s.msg_id.toString()), QString::fromStdString(s.sig->name)); chart_ids += ids.join(','); } std::reverse(chart_ids.begin(), chart_ids.end()); @@ -340,9 +341,9 @@ void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) { for (const auto& part : chart_id.split(',')) { const auto sig_parts = part.split('|'); if (sig_parts.size() != 2) continue; - MessageId msg_id = MessageId::fromString(sig_parts[0]); + MessageId msg_id = MessageId::fromString(sig_parts[0].toStdString()); if (auto* msg = dbc()->msg(msg_id)) - if (auto* sig = msg->sig(sig_parts[1])) + if (auto* sig = msg->sig(sig_parts[1].toStdString())) showChart(msg_id, sig, true, index++ > 0); } } @@ -388,7 +389,87 @@ void ChartsWidget::updateLayout(bool force) { } } -void ChartsWidget::startAutoScroll() { +void ChartsWidget::startChartDrag(ChartView *chart, const QPoint &global_pos) { + stopAutoScroll(); + drag = {.source = chart, .press_pos = global_pos}; + QPixmap px = chart->grab().scaledToWidth(CHART_MIN_WIDTH * chart->devicePixelRatio(), Qt::SmoothTransformation); + drag_preview->setPixmap(px); + drag_preview->resize(px.size() / px.devicePixelRatio()); +} + +void ChartsWidget::dragChartMove(const QPoint &global_pos) { + if (!drag.active) { + if ((global_pos - drag.press_pos).manhattanLength() < QApplication::startDragDistance()) return; + drag.active = true; + drag_preview->show(); + drag_preview->raise(); + } + drag_preview->move(mapFromGlobal(global_pos) + QPoint(5, 5)); + + // hovering a tab switches to it so the chart can be dropped into another tab + int tab = tabbar->tabAt(tabbar->mapFromGlobal(global_pos)); + if (tab >= 0 && tab != tabbar->currentIndex()) { + tabbar->setCurrentIndex(tab); + } + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + ChartView *target = nullptr; + for (auto c : currentCharts()) { + if (c != drag.source && c->isVisible() && c->geometry().contains(container_pos)) { + target = c; + break; + } + } + if (std::exchange(drop_target, target) != target) { + for (auto c : charts) c->setDropHighlight(c == target); + } + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + bool on_background = !target && in_viewport && !charts_container->childAt(container_pos); + charts_container->drawDropIndicator(on_background ? container_pos : QPoint()); + + if (in_viewport) { + startAutoScroll(global_pos); + } +} + +void ChartsWidget::cancelChartDrag() { + drag = {}; + stopAutoScroll(); + drag_preview->hide(); + charts_container->drawDropIndicator({}); + if (auto target = std::exchange(drop_target, nullptr)) target->setDropHighlight(false); +} + +void ChartsWidget::dragChartRelease(const QPoint &global_pos) { + ChartView *source = drag.source; + bool active = drag.active; + ChartView *target = drop_target; + cancelChartDrag(); + if (!active) return; + + const QPoint container_pos = charts_container->mapFromGlobal(global_pos); + bool in_viewport = charts_scroll->viewport()->rect().contains(charts_scroll->viewport()->mapFromGlobal(global_pos)); + if (target) { + // merge source into target + target->takeSignalsFrom(source); + } else if (in_viewport && !charts_container->childAt(container_pos)) { + // reorder within the current tab + auto w = charts_container->getDropAfter(container_pos); + if (w != source) { + for (auto &[_, list] : tab_charts) { + list.erase(std::remove(list.begin(), list.end(), source), list.end()); + } + auto &cur = currentCharts(); + int to = w ? std::find(cur.begin(), cur.end(), w) - cur.begin() + 1 : 0; + cur.insert(cur.begin() + to, source); + updateLayout(true); + updateTabBar(); + } + } +} + +void ChartsWidget::startAutoScroll(const QPoint &global_pos) { + auto_scroll_pos = global_pos; auto_scroll_timer->start(50); } @@ -404,7 +485,7 @@ void ChartsWidget::doAutoScroll() { } int value = scroll->value(); - QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos()); + QPoint pos = charts_scroll->viewport()->mapFromGlobal(auto_scroll_pos); QRect area = charts_scroll->viewport()->rect(); if (pos.y() - area.top() < settings.chart_height / 2) { @@ -412,41 +493,39 @@ void ChartsWidget::doAutoScroll() { } else if (area.bottom() - pos.y() < settings.chart_height / 2) { scroll->setValue(value + auto_scroll_count); } - bool vertical_unchanged = value == scroll->value(); - if (vertical_unchanged) { + if (value == scroll->value()) { stopAutoScroll(); - } else { - // mouseMoveEvent to updates the drag-selection rectangle - const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos); - const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos); - QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos, - Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt); - QApplication::sendEvent(charts_scroll->viewport(), &mm); + } else if (chartDragActive()) { + // refresh the drop indicator/target at the new scroll position + dragChartMove(auto_scroll_pos); } } QSize ChartsWidget::minimumSizeHint() const { - return QSize(CHART_MIN_WIDTH * 1.5 * qApp->devicePixelRatio(), QWidget::minimumSizeHint().height()); + return QSize(CHART_MIN_WIDTH * 1.5, QWidget::minimumSizeHint().height()); } void ChartsWidget::newChart() { SignalSelector dlg(tr("New Chart"), this); if (dlg.exec() == QDialog::Accepted) { auto items = dlg.seletedItems(); - if (!items.isEmpty()) { + if (!items.empty()) { auto c = createChart(); for (auto it : items) { c->addSignal(it->msg_id, it->sig); } + updateState(); } } } void ChartsWidget::removeChart(ChartView *chart) { - charts.removeOne(chart); + if (drag.source == chart) cancelChartDrag(); + if (drop_target == chart) drop_target = nullptr; + charts.erase(std::remove(charts.begin(), charts.end(), chart), charts.end()); chart->deleteLater(); for (auto &[_, list] : tab_charts) { - list.removeOne(chart); + list.erase(std::remove(list.begin(), list.end(), chart), list.end()); } updateToolBar(); updateLayout(true); @@ -460,7 +539,7 @@ void ChartsWidget::removeAll() { } tab_charts.clear(); - if (!charts.isEmpty()) { + if (!charts.empty()) { for (auto c : charts) { delete c; } @@ -482,6 +561,19 @@ void ChartsWidget::alignCharts() { } bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { + // route all mouse events to the chart drag, even when the source chart is hidden by a tab switch + if (chartDragActive()) { + if (e->type() == QEvent::MouseMove) { + dragChartMove(static_cast(e)->globalPos()); + return true; + } else if (e->type() == QEvent::MouseButtonRelease && static_cast(e)->button() == Qt::LeftButton) { + dragChartRelease(static_cast(e)->globalPos()); + return false; // let the release through so Qt clears the implicit mouse grab + } else if (e->type() == QEvent::MouseButtonPress || e->type() == QEvent::MouseButtonRelease) { + return true; // swallow other buttons during the drag + } + } + if (!value_tip_visible_) return false; if (e->type() == QEvent::MouseMove) { @@ -490,7 +582,7 @@ bool ChartsWidget::eventFilter(QObject *o, QEvent *e) { for (const auto &c : charts) { auto local_pos = c->mapFromGlobal(global_pos); - if (c->chart()->plotArea().contains(local_pos)) { + if (c->plot_area.contains(local_pos)) { if (on_tip) { showValueTip(c->secondsAtPoint(local_pos)); } @@ -522,13 +614,14 @@ bool ChartsWidget::event(QEvent *event) { break; case QEvent::WindowDeactivate: case QEvent::FocusOut: + if (chartDragActive()) cancelChartDrag(); showValueTip(-1); default: break; } if (back_button) { - zoom_undo_stack->undo(); + zoom_undo_stack.undo(); return true; // Return true since the event has been handled } return QFrame::event(event); @@ -537,7 +630,6 @@ bool ChartsWidget::event(QEvent *event) { // ChartsContainer ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) { - setAcceptDrops(true); setBackgroundRole(QPalette::Window); QVBoxLayout *charts_main_layout = new QVBoxLayout(this); charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING); @@ -547,32 +639,6 @@ ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), charts_main_layout->addStretch(0); } -void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - event->acceptProposedAction(); - drawDropIndicator(event->pos()); - } -} - -void ChartsContainer::dropEvent(QDropEvent *event) { - if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) { - auto w = getDropAfter(event->pos()); - auto chart = qobject_cast(event->source()); - if (w != chart) { - for (auto &[_, list] : charts_widget->tab_charts) { - list.removeOne(chart); - } - int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0; - charts_widget->currentCharts().insert(to, chart); - charts_widget->updateLayout(true); - charts_widget->updateTabBar(); - event->acceptProposedAction(); - chart->startAnimation(); - } - drawDropIndicator({}); - } -} - void ChartsContainer::paintEvent(QPaintEvent *ev) { if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) { QRect r = geometry(); diff --git a/tools/cabana/chart/chartswidget.h b/tools/cabana/chart/chartswidget.h index f87b127..8b3003d 100644 --- a/tools/cabana/chart/chartswidget.h +++ b/tools/cabana/chart/chartswidget.h @@ -8,15 +8,13 @@ #include #include #include -#include -#include #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/commands.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" const int CHART_MIN_WIDTH = 300; -const QString CHART_MIME_TYPE = "application/x-cabanachartview"; class ChartView; class ChartsWidget; @@ -24,9 +22,6 @@ class ChartsWidget; class ChartsContainer : public QWidget { public: ChartsContainer(ChartsWidget *parent); - void dragEnterEvent(QDragEnterEvent *event) override; - void dropEvent(QDropEvent *event) override; - void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); } void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); } void paintEvent(QPaintEvent *ev) override; ChartView *getDropAfter(const QPoint &pos) const; @@ -69,7 +64,12 @@ private: void eventsMerged(const MessageEventsMap &new_events); void updateState(); void zoomReset(); - void startAutoScroll(); + void startChartDrag(ChartView *chart, const QPoint &global_pos); + void dragChartMove(const QPoint &global_pos); + void dragChartRelease(const QPoint &global_pos); + void cancelChartDrag(); + bool chartDragActive() const { return drag.source != nullptr; } + void startAutoScroll(const QPoint &global_pos); void stopAutoScroll(); void doAutoScroll(); void updateToolBar(); @@ -81,7 +81,7 @@ private: bool eventFilter(QObject *obj, QEvent *event) override; void newTab(); void removeTab(int index); - inline QList ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } + inline std::vector ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; } ChartView *findChart(const MessageId &id, const cabana::Signal *sig); QLabel *title_label; @@ -97,11 +97,11 @@ private: QAction *redo_zoom_action; QAction *reset_zoom_action; ToolButton *reset_zoom_btn; - QUndoStack *zoom_undo_stack; + UndoStack zoom_undo_stack; ToolButton *remove_all_btn; - QList charts; - std::unordered_map> tab_charts; + std::vector charts; + std::unordered_map> tab_charts; TabBar *tabbar; ChartsContainer *charts_container; QScrollArea *charts_scroll; @@ -110,7 +110,15 @@ private: QAction *columns_action; int column_count = 1; int current_column_count = 0; + struct ChartDrag { + ChartView *source = nullptr; + QPoint press_pos; // global + bool active = false; + } drag; + QLabel *drag_preview; + ChartView *drop_target = nullptr; int auto_scroll_count = 0; + QPoint auto_scroll_pos; QTimer *auto_scroll_timer; QTimer *align_timer; int current_theme = 0; @@ -119,11 +127,10 @@ private: friend class ChartsContainer; }; -class ZoomCommand : public QUndoCommand { +class ZoomCommand : public UndoCommand { public: - ZoomCommand(std::pair range) : range(range), QUndoCommand() { + ZoomCommand(std::pair range) : range(range) { prev_range = can->timeRange(); - setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2)); } void undo() override { can->setTimeRange(prev_range); } void redo() override { can->setTimeRange(range); } diff --git a/tools/cabana/chart/signalselector.cc b/tools/cabana/chart/signalselector.cc index 63f3a7d..85832e7 100644 --- a/tools/cabana/chart/signalselector.cc +++ b/tools/cabana/chart/signalselector.cc @@ -1,6 +1,6 @@ #include "tools/cabana/chart/signalselector.h" +#include "tools/cabana/dbc/dbcqt.h" -#include #include #include #include @@ -20,8 +20,6 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) msgs_combo->setEditable(true); msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg...")); msgs_combo->setInsertPolicy(QComboBox::NoInsert); - msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion); - msgs_combo->completer()->setFilterMode(Qt::MatchContains); main_layout->addWidget(available_list = new QListWidget(this), 2, 0); @@ -46,7 +44,7 @@ SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) for (const auto &[id, _] : can->lastMessages()) { if (auto m = dbc()->msg(id)) { - msgs_combo->addItem(QString("%1 (%2)").arg(m->name).arg(id.toString()), QVariant::fromValue(id)); + msgs_combo->addItem(QString("%1 (%2)").arg(QString::fromStdString(m->name)).arg(QString::fromStdString(id.toString())), QVariant::fromValue(id)); } } msgs_combo->model()->sort(0); @@ -92,8 +90,8 @@ void SignalSelector::updateAvailableList(int index) { } void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) { - QString text = QString(" %1").arg(sig->color.name(), sig->name); - if (show_msg_name) text += QString(" %0 %1").arg(msgName(id), id.toString()); + QString text = QString(" %1").arg(toQColor(sig->color).name(), QString::fromStdString(sig->name)); + if (show_msg_name) text += QString(" %0 %1").arg(QString::fromStdString(msgName(id)), QString::fromStdString(id.toString())); QLabel *label = new QLabel(text); label->setContentsMargins(5, 0, 5, 0); @@ -102,8 +100,8 @@ void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, cons parent->setItemWidget(new_item, label); } -QList SignalSelector::seletedItems() { - QList ret; +std::vector SignalSelector::seletedItems() { + std::vector ret; for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i)); return ret; } diff --git a/tools/cabana/chart/signalselector.h b/tools/cabana/chart/signalselector.h index f46779f..5b6e37e 100644 --- a/tools/cabana/chart/signalselector.h +++ b/tools/cabana/chart/signalselector.h @@ -15,7 +15,7 @@ public: }; SignalSelector(QString title, QWidget *parent); - QList seletedItems(); + std::vector seletedItems(); inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); } private: diff --git a/tools/cabana/chart/sparkline.cc b/tools/cabana/chart/sparkline.cc index 91435cd..f5bef0f 100644 --- a/tools/cabana/chart/sparkline.cc +++ b/tools/cabana/chart/sparkline.cc @@ -31,7 +31,7 @@ void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIt } freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0); - render(sig->color, range, size); + render(toQColor(sig->color), range, size); } void Sparkline::render(const QColor &color, int range, QSize size) { diff --git a/tools/cabana/commands.cc b/tools/cabana/commands.cc index 5286172..b47bf90 100644 --- a/tools/cabana/commands.cc +++ b/tools/cabana/commands.cc @@ -1,25 +1,86 @@ -#include - #include "tools/cabana/commands.h" +#include + +// UndoStack + +void UndoStack::push(UndoCommand *cmd) { + commands_.resize(index_); // drop any redoable commands + if (clean_index_ > index_) clean_index_ = -1; + commands_.emplace_back(cmd); + cmd->redo(); + setIndex(index_ + 1); +} + +void UndoStack::undo() { + if (!canUndo()) return; + commands_[index_ - 1]->undo(); + setIndex(index_ - 1); +} + +void UndoStack::redo() { + if (!canRedo()) return; + commands_[index_]->redo(); + setIndex(index_ + 1); +} + +void UndoStack::clear() { + bool was_clean = isClean(); + commands_.clear(); + index_ = clean_index_ = 0; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (!was_clean && callbacks_.clean_changed) callbacks_.clean_changed(true); +} + +void UndoStack::setClean() { + if (!isClean()) { + clean_index_ = index_; + if (callbacks_.clean_changed) callbacks_.clean_changed(true); + } +} + +void UndoStack::setIndex(int index) { + bool was_clean = isClean(); + index_ = index; + if (callbacks_.index_changed) callbacks_.index_changed(); + if (isClean() != was_clean && callbacks_.clean_changed) callbacks_.clean_changed(isClean()); +} + +UndoStack *UndoStack::instance() { + static UndoStack undo_stack; + return &undo_stack; +} + +QtUndoNotifier::QtUndoNotifier(QObject *parent) : QObject(parent) { + UndoStack::instance()->setCallbacks({ + .index_changed = [this]() { emit indexChanged(); }, + .clean_changed = [this](bool clean) { emit cleanChanged(clean); }, + }); +} + +QtUndoNotifier *undoNotifier() { + static QtUndoNotifier notifier; + return ¬ifier; +} + // EditMsgCommand -EditMsgCommand::EditMsgCommand(const MessageId &id, const QString &name, int size, - const QString &node, const QString &comment, QUndoCommand *parent) - : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) { +EditMsgCommand::EditMsgCommand(const MessageId &id, const std::string &name, int size, + const std::string &node, const std::string &comment) + : id(id), new_name(name), new_size(size), new_node(node), new_comment(comment) { if (auto msg = dbc()->msg(id)) { old_name = msg->name; old_size = msg->size; old_node = msg->transmitter; old_comment = msg->comment; - setText(QObject::tr("edit message %1:%2").arg(name).arg(id.address)); + text = "edit message " + name + ":" + std::to_string(id.address); } else { - setText(QObject::tr("new message %1:%2").arg(name).arg(id.address)); + text = "new message " + name + ":" + std::to_string(id.address); } } void EditMsgCommand::undo() { - if (old_name.isEmpty()) + if (old_name.empty()) dbc()->removeMsg(id); else dbc()->updateMsg(id, old_name, old_size, old_node, old_comment); @@ -31,15 +92,15 @@ void EditMsgCommand::redo() { // RemoveMsgCommand -RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) { +RemoveMsgCommand::RemoveMsgCommand(const MessageId &id) : id(id) { if (auto msg = dbc()->msg(id)) { message = *msg; - setText(QObject::tr("remove message %1:%2").arg(message.name).arg(id.address)); + text = "remove message " + message.name + ":" + std::to_string(id.address); } } void RemoveMsgCommand::undo() { - if (!message.name.isEmpty()) { + if (!message.name.empty()) { dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment); for (auto s : message.getSignals()) dbc()->addSignal(id, *s); @@ -47,15 +108,15 @@ void RemoveMsgCommand::undo() { } void RemoveMsgCommand::redo() { - if (!message.name.isEmpty()) + if (!message.name.empty()) dbc()->removeMsg(id); } // AddSigCommand -AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent) - : id(id), signal(sig), QUndoCommand(parent) { - setText(QObject::tr("add signal %1 to %2:%3").arg(sig.name).arg(msgName(id)).arg(id.address)); +AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig) + : id(id), signal(sig) { + text = "add signal " + sig.name + " to " + msgName(id) + ":" + std::to_string(id.address); } void AddSigCommand::undo() { @@ -75,8 +136,7 @@ void AddSigCommand::redo() { // RemoveSigCommand -RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig) : id(id) { sigs.push_back(*sig); if (sig->type == cabana::Signal::Type::Multiplexor) { for (const auto &s : dbc()->msg(id)->sigs) { @@ -85,7 +145,7 @@ RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *si } } } - setText(QObject::tr("remove signal %1 from %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + text = "remove signal " + sig->name + " from " + msgName(id) + ":" + std::to_string(id.address); } void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); } @@ -93,8 +153,8 @@ void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(i // EditSignalCommand -EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent) - : id(id), QUndoCommand(parent) { +EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig) + : id(id) { sigs.push_back({*sig, new_sig}); if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) { // convert all multiplexed signals to normal signals @@ -108,17 +168,8 @@ EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal * } } } - setText(QObject::tr("edit signal %1 in %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address)); + text = "edit signal " + sig->name + " in " + msgName(id) + ":" + std::to_string(id.address); } void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); } void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); } - -namespace UndoStack { - -QUndoStack *instance() { - static QUndoStack *undo_stack = new QUndoStack(qApp); - return undo_stack; -} - -} // namespace UndoStack diff --git a/tools/cabana/commands.h b/tools/cabana/commands.h index 0736d9b..200a4f2 100644 --- a/tools/cabana/commands.h +++ b/tools/cabana/commands.h @@ -1,29 +1,82 @@ #pragma once +#include +#include +#include #include +#include -#include -#include +#include #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/streams/abstractstream.h" -class EditMsgCommand : public QUndoCommand { +class UndoCommand { public: - EditMsgCommand(const MessageId &id, const QString &name, int size, const QString &node, - const QString &comment, QUndoCommand *parent = nullptr); + virtual ~UndoCommand() = default; + virtual void undo() = 0; + virtual void redo() = 0; + std::string text; +}; + +class UndoStack { +public: + struct Callbacks { + std::function index_changed; + std::function clean_changed; + }; + + void push(UndoCommand *cmd); // takes ownership and calls redo() + void undo(); + void redo(); + void clear(); + void setClean(); + bool isClean() const { return clean_index_ == index_; } + bool canUndo() const { return index_ > 0; } + bool canRedo() const { return index_ < (int)commands_.size(); } + std::string undoText() const { return canUndo() ? commands_[index_ - 1]->text : ""; } + std::string redoText() const { return canRedo() ? commands_[index_]->text : ""; } + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } + static UndoStack *instance(); + +private: + void setIndex(int index); + std::vector> commands_; + int index_ = 0; + int clean_index_ = 0; + Callbacks callbacks_; +}; + +// emits Qt signals for the global undo stack +class QtUndoNotifier : public QObject { + Q_OBJECT + +public: + explicit QtUndoNotifier(QObject *parent = nullptr); + +signals: + void indexChanged(); + void cleanChanged(bool clean); +}; + +QtUndoNotifier *undoNotifier(); + +class EditMsgCommand : public UndoCommand { +public: + EditMsgCommand(const MessageId &id, const std::string &name, int size, const std::string &node, + const std::string &comment); void undo() override; void redo() override; private: const MessageId id; - QString old_name, new_name, old_comment, new_comment, old_node, new_node; + std::string old_name, new_name, old_comment, new_comment, old_node, new_node; int old_size = 0, new_size = 0; }; -class RemoveMsgCommand : public QUndoCommand { +class RemoveMsgCommand : public UndoCommand { public: - RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr); + RemoveMsgCommand(const MessageId &id); void undo() override; void redo() override; @@ -32,9 +85,9 @@ private: cabana::Msg message; }; -class AddSigCommand : public QUndoCommand { +class AddSigCommand : public UndoCommand { public: - AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr); + AddSigCommand(const MessageId &id, const cabana::Signal &sig); void undo() override; void redo() override; @@ -44,29 +97,24 @@ private: cabana::Signal signal = {}; }; -class RemoveSigCommand : public QUndoCommand { +class RemoveSigCommand : public UndoCommand { public: - RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr); + RemoveSigCommand(const MessageId &id, const cabana::Signal *sig); void undo() override; void redo() override; private: const MessageId id; - QList sigs; + std::vector sigs; }; -class EditSignalCommand : public QUndoCommand { +class EditSignalCommand : public UndoCommand { public: - EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr); + EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig); void undo() override; void redo() override; private: const MessageId id; - QList> sigs; // QList<{old_sig, new_sig}> -}; - -namespace UndoStack { - QUndoStack *instance(); - inline void push(QUndoCommand *cmd) { instance()->push(cmd); } + std::vector> sigs; // {old_sig, new_sig} }; diff --git a/tools/cabana/core/can_data.h b/tools/cabana/core/can_data.h new file mode 100644 index 0000000..38141b1 --- /dev/null +++ b/tools/cabana/core/can_data.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" + +struct CanData { + void compute(const MessageId &msg_id, const uint8_t *data, int size, double current_sec, + double playback_speed, const std::vector &mask, double frequency = 0); + + double ts = 0.; + uint32_t count = 0; + double freq = 0; + std::vector dat; + std::vector colors; + + struct ByteLastChange { + double ts = 0; + int delta = 0; + int same_delta_counter = 0; + bool suppressed = false; + }; + std::vector last_changes; + std::vector> bit_flip_counts; + double last_freq_update_ts = 0; +}; + +struct CanEvent { + uint8_t src; + uint32_t address; + uint64_t mono_time; + uint8_t size; + uint8_t dat[]; +}; + +struct CompareCanEvent { + constexpr bool operator()(const CanEvent *const event, uint64_t ts) const { return event->mono_time < ts; } + constexpr bool operator()(uint64_t ts, const CanEvent *const event) const { return ts < event->mono_time; } +}; + +using MessageEventsMap = std::unordered_map>; +using CanEventIter = std::vector::const_iterator; diff --git a/tools/cabana/core/color.h b/tools/cabana/core/color.h new file mode 100644 index 0000000..c29704d --- /dev/null +++ b/tools/cabana/core/color.h @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include + +struct CabanaColor { + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 255; + + constexpr CabanaColor() = default; + constexpr CabanaColor(uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha = 255) + : r(red), g(green), b(blue), a(alpha) {} + + static CabanaColor fromHsv(float hue, float saturation, float value, float alpha = 1.0f) { + const float h = hue - std::floor(hue); + const float c = value * saturation; + const float x = c * (1.0f - std::fabs(std::fmod(h * 6.0f, 2.0f) - 1.0f)); + const float m = value - c; + float red = 0, green = 0, blue = 0; + switch (static_cast(h * 6.0f) % 6) { + case 0: red = c; green = x; break; + case 1: red = x; green = c; break; + case 2: green = c; blue = x; break; + case 3: green = x; blue = c; break; + case 4: red = x; blue = c; break; + default: red = c; blue = x; break; + } + auto channel = [m](float v) { return static_cast(std::clamp((v + m) * 255.0f, 0.0f, 255.0f) + 0.5f); }; + return {channel(red), channel(green), channel(blue), + static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f)}; + } + + CabanaColor darker(int factor = 200) const { + if (factor <= 0) return *this; + if (factor < 100) return lighter(10000 / factor); + auto [hue, saturation, value] = hsv(); + return fromHsv(hue, saturation, value * 100.0f / factor, a / 255.0f); + } + + CabanaColor lighter(int factor = 150) const { + if (factor <= 0) return *this; + if (factor < 100) return darker(10000 / factor); + auto [hue, saturation, value] = hsv(); + const float scaled_value = value * factor / 100.0f; + if (scaled_value > 1.0f) saturation = std::max(0.0f, saturation - (scaled_value - 1.0f)); + return fromHsv(hue, saturation, std::min(1.0f, scaled_value), a / 255.0f); + } + + constexpr int red() const { return r; } + constexpr int green() const { return g; } + constexpr int blue() const { return b; } + constexpr int alpha() const { return a; } + float alphaF() const { return a / 255.0f; } + void setAlphaF(float alpha) { a = static_cast(std::clamp(alpha * 255.0f, 0.0f, 255.0f) + 0.5f); } + + constexpr bool operator==(const CabanaColor &other) const { + return r == other.r && g == other.g && b == other.b && a == other.a; + } + +private: + struct Hsv { float hue; float saturation; float value; }; + Hsv hsv() const { + const float red = r / 255.0f, green = g / 255.0f, blue = b / 255.0f; + const float maximum = std::max({red, green, blue}); + const float minimum = std::min({red, green, blue}); + const float delta = maximum - minimum; + float hue = 0; + if (delta > 0) { + if (maximum == red) hue = std::fmod((green - blue) / delta, 6.0f) / 6.0f; + else if (maximum == green) hue = ((blue - red) / delta + 2.0f) / 6.0f; + else hue = ((red - green) / delta + 4.0f) / 6.0f; + if (hue < 0) hue += 1.0f; + } + return {hue, maximum == 0 ? 0 : delta / maximum, maximum}; + } +}; diff --git a/tools/cabana/core/message_id.h b/tools/cabana/core/message_id.h new file mode 100644 index 0000000..ecac279 --- /dev/null +++ b/tools/cabana/core/message_id.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include +#include +#include +#include + +constexpr int INVALID_SOURCE = 0xff; + +struct MessageId { + uint8_t source = 0; + uint32_t address = 0; + std::string toString() const { char b[64]; snprintf(b, sizeof(b), "%u:%X", source, address); return b; } + static MessageId fromString(const std::string &s) { + const auto p = s.find(':'); + if (p == std::string::npos) return {}; + return {.source = static_cast(std::stoul(s.substr(0, p))), .address = static_cast(std::stoul(s.substr(p + 1), nullptr, 16))}; + } + bool operator==(const MessageId &o) const { return source == o.source && address == o.address; } + bool operator!=(const MessageId &o) const { return !(*this == o); } + bool operator<(const MessageId &o) const { return std::tie(source, address) < std::tie(o.source, o.address); } + bool operator>(const MessageId &o) const { return o < *this; } +}; + +template <> struct std::hash { + size_t operator()(const MessageId &id) const noexcept { return std::hash{}(id.source) ^ (std::hash{}(id.address) << 1); } +}; diff --git a/tools/cabana/core/settings.h b/tools/cabana/core/settings.h new file mode 100644 index 0000000..cad152d --- /dev/null +++ b/tools/cabana/core/settings.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +constexpr int LIGHT_THEME = 1; +constexpr int DARK_THEME = 2; + +struct CabanaSettingsState { + enum DragDirection { MsbFirst, LsbFirst, AlwaysLE, AlwaysBE }; + + bool absolute_time = false; + int fps = 10; + int max_cached_minutes = 30; + int chart_height = 200; + int chart_column_count = 1; + int chart_range = 3 * 60; + int chart_series_type = 0; + int theme = 0; + int sparkline_range = 15; + bool multiple_lines_hex = false; + bool log_livestream = true; + bool suppress_defined_signals = false; + std::string log_path; + std::string last_dir; + std::string last_route_dir; + std::vector recent_files; + DragDirection drag_direction = MsbFirst; + + std::string recent_dbc_file; + std::string active_msg_id; + std::vector selected_msg_ids; + std::vector active_charts; +}; diff --git a/tools/cabana/dbc/dbc.cc b/tools/cabana/dbc/dbc.cc index 9b0de92..c8a794c 100644 --- a/tools/cabana/dbc/dbc.cc +++ b/tools/cabana/dbc/dbc.cc @@ -1,11 +1,17 @@ #include "tools/cabana/dbc/dbc.h" #include +#include -#include "tools/cabana/utils/util.h" - -uint qHash(const MessageId &item) { - return qHash(item.source) ^ qHash(item.address); +namespace { +int numDecimals(double value) { + int decimals = 0; + while (decimals < 6 && std::fabs(value - std::round(value)) > 1e-9) { + value *= 10.0; + ++decimals; + } + return decimals; +} } // cabana::Msg @@ -22,7 +28,7 @@ cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) { return s; } -cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana::Signal &new_sig) { +cabana::Signal *cabana::Msg::updateSignal(const std::string &sig_name, const cabana::Signal &new_sig) { auto s = sig(sig_name); if (s) { *s = new_sig; @@ -31,7 +37,7 @@ cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana: return s; } -void cabana::Msg::removeSignal(const QString &sig_name) { +void cabana::Msg::removeSignal(const std::string &sig_name) { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); if (it != sigs.end()) { delete *it; @@ -57,7 +63,7 @@ cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) { return *this; } -cabana::Signal *cabana::Msg::sig(const QString &sig_name) const { +cabana::Signal *cabana::Msg::sig(const std::string &sig_name) const { auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; }); return it != sigs.end() ? *it : nullptr; } @@ -69,17 +75,17 @@ int cabana::Msg::indexOf(const cabana::Signal *sig) const { return -1; } -QString cabana::Msg::newSignalName() { - QString new_name; +std::string cabana::Msg::newSignalName() { + std::string new_name; for (int i = 1; /**/; ++i) { - new_name = QString("NEW_SIGNAL_%1").arg(i); + new_name = "NEW_SIGNAL_" + std::to_string(i); if (sig(new_name) == nullptr) break; } return new_name; } void cabana::Msg::update() { - if (transmitter.isEmpty()) { + if (transmitter.empty()) { transmitter = DEFAULT_NODE_NAME; } mask.assign(size, 0x00); @@ -129,21 +135,21 @@ void cabana::Msg::update() { void cabana::Signal::update() { updateMsbLsb(*this); - if (receiver_name.isEmpty()) { + if (receiver_name.empty()) { receiver_name = DEFAULT_NODE_NAME; } float h = 19 * (float)lsb / 64.0; h = fmod(h, 1.0); - size_t hash = qHash(name); + size_t hash = std::hash{}(name); float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0; float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0; - color = QColor::fromHsvF(h, s, v); - precision = std::max(num_decimals(factor), num_decimals(offset)); + color = CabanaColor::fromHsv(h, s, v); + precision = std::max(numDecimals(factor), numDecimals(offset)); } -QString cabana::Signal::formatValue(double value, bool with_unit) const { +std::string cabana::Signal::formatValue(double value, bool with_unit) const { // Show enum string int64_t raw_value = round((value - offset) / factor); for (const auto &[val, desc] : val_desc) { @@ -152,8 +158,10 @@ QString cabana::Signal::formatValue(double value, bool with_unit) const { } } - QString val_str = QString::number(value, 'f', precision); - if (with_unit && !unit.isEmpty()) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*f", precision, value); + std::string val_str(buf); + if (with_unit && !unit.empty()) { val_str += " " + unit; } return val_str; diff --git a/tools/cabana/dbc/dbc.h b/tools/cabana/dbc/dbc.h index 134d88a..585325d 100644 --- a/tools/cabana/dbc/dbc.h +++ b/tools/cabana/dbc/dbc.h @@ -1,57 +1,21 @@ #pragma once +#include +#include +#include #include +#include #include #include -#include -#include -#include +#include "tools/cabana/core/color.h" +#include "tools/cabana/core/message_id.h" -const QString UNTITLED = "untitled"; -const QString DEFAULT_NODE_NAME = "XXX"; +const std::string UNTITLED = "untitled"; +const std::string DEFAULT_NODE_NAME = "XXX"; constexpr int CAN_MAX_DATA_BYTES = 64; -struct MessageId { - uint8_t source = 0; - uint32_t address = 0; - - QString toString() const { - return QString("%1:%2").arg(source).arg(QString::number(address, 16).toUpper()); - } - - inline static MessageId fromString(const QString &str) { - auto parts = str.split(':'); - if (parts.size() != 2) return {}; - return MessageId{.source = uint8_t(parts[0].toUInt()), .address = parts[1].toUInt(nullptr, 16)}; - } - - bool operator==(const MessageId &other) const { - return source == other.source && address == other.address; - } - - bool operator!=(const MessageId &other) const { - return !(*this == other); - } - - bool operator<(const MessageId &other) const { - return std::tie(source, address) < std::tie(other.source, other.address); - } - - bool operator>(const MessageId &other) const { - return std::tie(source, address) > std::tie(other.source, other.address); - } -}; - -uint qHash(const MessageId &item); -Q_DECLARE_METATYPE(MessageId); - -template <> -struct std::hash { - std::size_t operator()(const MessageId &k) const noexcept { return qHash(k); } -}; - -typedef std::vector> ValueDescription; +typedef std::vector> ValueDescription; namespace cabana { @@ -61,7 +25,7 @@ public: Signal(const Signal &other) = default; void update(); bool getValue(const uint8_t *data, size_t data_size, double *val) const; - QString formatValue(double value, bool with_unit = true) const; + std::string formatValue(double value, bool with_unit = true) const; bool operator==(const cabana::Signal &other) const; inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); } @@ -72,19 +36,19 @@ public: }; Type type = Type::Normal; - QString name; + std::string name; int start_bit, msb, lsb, size; double factor = 1.0; double offset = 0; bool is_signed; bool is_little_endian; double min, max; - QString unit; - QString comment; - QString receiver_name; + std::string unit; + std::string comment; + std::string receiver_name; ValueDescription val_desc; int precision = 0; - QColor color; + CabanaColor color; // Multiplexed int multiplex_value = 0; @@ -97,20 +61,20 @@ public: Msg(const Msg &other) { *this = other; } ~Msg(); cabana::Signal *addSignal(const cabana::Signal &sig); - cabana::Signal *updateSignal(const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const QString &sig_name); + cabana::Signal *updateSignal(const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const std::string &sig_name); Msg &operator=(const Msg &other); int indexOf(const cabana::Signal *sig) const; - cabana::Signal *sig(const QString &sig_name) const; - QString newSignalName(); + cabana::Signal *sig(const std::string &sig_name) const; + std::string newSignalName(); void update(); inline const std::vector &getSignals() const { return sigs; } uint32_t address; - QString name; + std::string name; uint32_t size; - QString comment; - QString transmitter; + std::string comment; + std::string transmitter; std::vector sigs; std::vector mask; @@ -123,4 +87,8 @@ public: double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig); void updateMsbLsb(cabana::Signal &s); inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; } -inline QString doubleToString(double value) { return QString::number(value, 'g', std::numeric_limits::digits10); } +inline std::string doubleToString(double value) { + char buf[64]; + snprintf(buf, sizeof(buf), "%.*g", std::numeric_limits::digits10, value); + return buf; +} diff --git a/tools/cabana/dbc/dbcfile.cc b/tools/cabana/dbc/dbcfile.cc index 1c03c8a..99a5b71 100644 --- a/tools/cabana/dbc/dbcfile.cc +++ b/tools/cabana/dbc/dbcfile.cc @@ -1,48 +1,86 @@ #include "tools/cabana/dbc/dbcfile.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include -DBCFile::DBCFile(const QString &dbc_file_name) { - QFile file(dbc_file_name); - if (file.open(QIODevice::ReadOnly)) { - name_ = QFileInfo(dbc_file_name).baseName(); - filename = dbc_file_name; - parse(file.readAll()); - } else { - throw std::runtime_error("Failed to open file."); +namespace { + +std::string trim(const std::string &value) { + const auto first = value.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return {}; + return value.substr(first, value.find_last_not_of(" \t\r\n") - first + 1); +} + +bool startsWith(const std::string &value, const char *prefix) { + return value.rfind(prefix, 0) == 0; +} + +std::string unescapeComment(std::string value) { + for (size_t pos = 0; (pos = value.find("\\\"", pos)) != std::string::npos; ++pos) { + value.replace(pos, 2, "\""); } + return trim(value); } -DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") { - parse(content); -} - -bool DBCFile::save() { - assert(!filename.isEmpty()); - return writeContents(filename); -} - -bool DBCFile::saveAs(const QString &new_filename) { - filename = new_filename; - return save(); -} - -bool DBCFile::writeContents(const QString &fn) { - QFile file(fn); - if (file.open(QIODevice::WriteOnly)) { - return file.write(generateDBC().toUtf8()) >= 0; +bool commentComplete(const std::string &line) { + bool escaped = false; + for (size_t i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && !escaped) { + escaped = true; + continue; + } + if (line[i] == '"' && !escaped) { + size_t next = line.find_first_not_of(" \t\r\n", i + 1); + if (next != std::string::npos && line[next] == ';') return true; + } + escaped = false; } return false; } -void DBCFile::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +} // namespace + +DBCFile::DBCFile(const std::string &dbc_file_name) { + std::ifstream file(dbc_file_name, std::ios::binary); + if (!file) throw std::runtime_error("Failed to open file."); + filename = dbc_file_name; + name_ = std::filesystem::path(dbc_file_name).stem().string(); + parse(std::string(std::istreambuf_iterator(file), std::istreambuf_iterator())); +} + +DBCFile::DBCFile(const std::string &name, const std::string &content) : name_(name) { + parse(content); +} + +bool DBCFile::save() { + assert(!filename.empty()); + return writeContents(filename); +} + +bool DBCFile::saveAs(const std::string &new_filename) { + filename = new_filename; + return save(); +} + +bool DBCFile::writeContents(const std::string &fn) { + std::ofstream file(fn, std::ios::binary | std::ios::trunc); + if (!file) return false; + file << generateDBC(); + return file.good(); +} + +void DBCFile::updateMsg(const MessageId &id, const std::string &name, uint32_t size, + const std::string &node, const std::string &comment) { auto &m = msgs[id.address]; m.address = id.address; m.name = name; m.size = size; - m.transmitter = node.isEmpty() ? DEFAULT_NODE_NAME : node; + m.transmitter = node.empty() ? DEFAULT_NODE_NAME : node; m.comment = comment; } @@ -51,221 +89,180 @@ cabana::Msg *DBCFile::msg(uint32_t address) { return it != msgs.end() ? &it->second : nullptr; } -cabana::Msg *DBCFile::msg(const QString &name) { +cabana::Msg *DBCFile::msg(const std::string &name) { auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; }); - return it != msgs.end() ? &(it->second) : nullptr; + return it != msgs.end() ? &it->second : nullptr; } -cabana::Signal *DBCFile::signal(uint32_t address, const QString &name) { +cabana::Signal *DBCFile::signal(uint32_t address, const std::string &name) { auto m = msg(address); - return m ? (cabana::Signal *)m->sig(name) : nullptr; + return m ? m->sig(name) : nullptr; } -void DBCFile::parse(const QString &content) { +void DBCFile::parse(const std::string &content) { msgs.clear(); - - int line_num = 0; - QString line; + header.clear(); + std::istringstream input(content); + std::string raw_line; cabana::Msg *current_msg = nullptr; int multiplexor_cnt = 0; + int line_num = 0; bool seen_first = false; - QTextStream stream((QString *)&content); - while (!stream.atEnd()) { + while (std::getline(input, raw_line)) { ++line_num; - QString raw_line = stream.readLine(); - line = raw_line.trimmed(); + const size_t first_nonspace = raw_line.find_first_not_of(" \t\r"); + std::string line = first_nonspace == std::string::npos ? std::string() : raw_line.substr(first_nonspace); + const int statement_line = line_num; + if ((startsWith(line, "CM_ BO_") || startsWith(line, "CM_ SG_ ")) && !commentComplete(line)) { + std::string continuation; + while (std::getline(input, continuation)) { + ++line_num; + line += "\n" + continuation; + if (commentComplete(line)) break; + } + } bool seen = true; try { - if (line.startsWith("BO_ ")) { + if (startsWith(line, "BO_ ")) { multiplexor_cnt = 0; current_msg = parseBO(line); - } else if (line.startsWith("SG_ ")) { + } else if (startsWith(line, "SG_ ")) { parseSG(line, current_msg, multiplexor_cnt); - } else if (line.startsWith("VAL_ ")) { + } else if (startsWith(line, "VAL_ ")) { parseVAL(line); - } else if (line.startsWith("CM_ BO_")) { - parseCM_BO(line, content, raw_line, stream); - } else if (line.startsWith("CM_ SG_ ")) { - parseCM_SG(line, content, raw_line, stream); + } else if (startsWith(line, "CM_ BO_")) { + parseCM_BO(line); + } else if (startsWith(line, "CM_ SG_ ")) { + parseCM_SG(line); } else { seen = false; } - } catch (std::exception &e) { - throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(e.what()).arg(line).toStdString()); - } - - if (seen) { - seen_first = true; - } else if (!seen_first) { - header += raw_line + "\n"; + } catch (const std::exception &e) { + throw std::runtime_error("[" + filename + ":" + std::to_string(statement_line) + "]" + e.what() + ": " + line); } + if (seen) seen_first = true; + else if (!seen_first) header += raw_line + "\n"; } - - for (auto &[_, m] : msgs) { - m.update(); - } + for (auto &[_, message] : msgs) message.update(); } -cabana::Msg *DBCFile::parseBO(const QString &line) { - static QRegularExpression bo_regexp(R"(^BO_ (?
\w+) (?\w+) *: (?\w+) (?\w+))"); - - QRegularExpressionMatch match = bo_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid BO_ line format"); - - uint32_t address = match.captured("address").toUInt(); - if (msgs.count(address) > 0) - throw std::runtime_error(QString("Duplicate message address: %1").arg(address).toStdString()); - - // Create a new message object - cabana::Msg *msg = &msgs[address]; - msg->address = address; - msg->name = match.captured("name"); - msg->size = match.captured("size").toULong(); - msg->transmitter = match.captured("transmitter").trimmed(); - return msg; +cabana::Msg *DBCFile::parseBO(const std::string &line) { + static const std::regex pattern(R"(^BO_ ([[:alnum:]_]+) ([[:alnum:]_]+) *: ([[:alnum:]_]+) ([[:alnum:]_]+))"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid BO_ line format"); + const uint32_t address = std::stoul(match[1].str()); + if (msgs.count(address)) throw std::runtime_error("Duplicate message address: " + std::to_string(address)); + auto &message = msgs[address]; + message.address = address; + message.name = match[2].str(); + message.size = std::stoul(match[3].str()); + message.transmitter = trim(match[4].str()); + return &message; } -void DBCFile::parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(?
\w+) *\"(?(?:[^"\\]|\\.)*)\"\s*;)"); +void DBCFile::parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt) { + static const std::regex pattern(R"dbc(^SG_ ([[:alnum:]_]+)(?: +([[:alnum:]_]+))? *: ([0-9]+)\|([0-9]+)@([0-9]+)([+-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*))dbc"); + if (!current_msg) throw std::runtime_error("No Message"); + std::smatch match; + if (!std::regex_search(line, match, pattern)) throw std::runtime_error("Invalid SG_ line format"); + if (current_msg->sig(match[1].str())) throw std::runtime_error("Duplicate signal name"); - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); - } - auto match = msg_comment_regexp.match(parse_line); - if (!match.hasMatch()) - throw std::runtime_error("Invalid message comment format"); - - if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt())) - m->comment = match.captured("comment").trimmed().replace("\\\"", "\""); -} - -void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) { - static QRegularExpression sg_regexp(R"(^SG_ (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))"); - - if (!current_msg) - throw std::runtime_error("No Message"); - - int offset = 0; - auto match = sg_regexp.match(line); - if (!match.hasMatch()) { - match = sgm_regexp.match(line); - offset = 1; - } - if (!match.hasMatch()) - throw std::runtime_error("Invalid SG_ line format"); - - QString name = match.captured(1); - if (current_msg->sig(name) != nullptr) - throw std::runtime_error("Duplicate signal name"); - - cabana::Signal s{}; - if (offset == 1) { - auto indicator = match.captured(2); + cabana::Signal signal{}; + const std::string indicator = match[2].str(); + if (!indicator.empty()) { if (indicator == "M") { - ++multiplexor_cnt; - // Only one signal within a single message can be the multiplexer switch. - if (multiplexor_cnt >= 2) - throw std::runtime_error("Multiple multiplexor"); - - s.type = cabana::Signal::Type::Multiplexor; + if (++multiplexor_cnt >= 2) throw std::runtime_error("Multiple multiplexor"); + signal.type = cabana::Signal::Type::Multiplexor; } else { - s.type = cabana::Signal::Type::Multiplexed; - s.multiplex_value = indicator.mid(1).toInt(); + signal.type = cabana::Signal::Type::Multiplexed; + signal.multiplex_value = indicator.size() > 1 ? std::stoi(indicator.substr(1)) : 0; } } - s.name = name; - s.start_bit = match.captured(offset + 2).toInt(); - s.size = match.captured(offset + 3).toInt(); - s.is_little_endian = match.captured(offset + 4).toInt() == 1; - s.is_signed = match.captured(offset + 5) == "-"; - s.factor = match.captured(offset + 6).toDouble(); - s.offset = match.captured(offset + 7).toDouble(); - s.min = match.captured(8 + offset).toDouble(); - s.max = match.captured(9 + offset).toDouble(); - s.unit = match.captured(10 + offset); - s.receiver_name = match.captured(11 + offset).trimmed(); - current_msg->sigs.push_back(new cabana::Signal(s)); + signal.name = match[1].str(); + signal.start_bit = std::stoi(match[3].str()); + signal.size = std::stoi(match[4].str()); + signal.is_little_endian = match[5].str() == "1"; + signal.is_signed = match[6].str() == "-"; + signal.factor = std::stod(match[7].str()); + signal.offset = std::stod(match[8].str()); + signal.min = std::stod(match[9].str()); + signal.max = std::stod(match[10].str()); + signal.unit = match[11].str(); + signal.receiver_name = trim(match[12].str()); + current_msg->sigs.push_back(new cabana::Signal(signal)); } -void DBCFile::parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) { - static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"((?:[^"\\]|\\.)*)\"\s*;)"); - - QString parse_line = line; - if (!parse_line.endsWith("\";")) { - int pos = stream.pos() - raw_line.length() - 1; - parse_line = content.mid(pos, content.indexOf("\";", pos)); +void DBCFile::parseCM_BO(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + prefix >> address; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || first_quote == std::string::npos || last_quote <= first_quote) { + throw std::runtime_error("Invalid message comment format"); } - auto match = sg_comment_regexp.match(parse_line); - if (!match.hasMatch()) + if (auto message = msg(address)) message->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); +} + +void DBCFile::parseCM_SG(const std::string &line) { + std::istringstream prefix(line.substr(7)); + uint32_t address = 0; + std::string name; + prefix >> address >> name; + const size_t first_quote = line.find('"'); + const size_t last_quote = line.rfind('"'); + if (!prefix || name.empty() || first_quote == std::string::npos || last_quote <= first_quote) { throw std::runtime_error("Invalid CM_ SG_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { - s->comment = match.captured(3).trimmed().replace("\\\"", "\""); } + if (auto sig = signal(address, name)) sig->comment = unescapeComment(line.substr(first_quote + 1, last_quote - first_quote - 1)); } -void DBCFile::parseVAL(const QString &line) { - static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))"); - - auto match = val_regexp.match(line); - if (!match.hasMatch()) - throw std::runtime_error("invalid VAL_ line format"); - - if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) { - QStringList desc_list = match.captured(3).trimmed().split('"'); - for (int i = 0; i < desc_list.size(); i += 2) { - auto val = desc_list[i].trimmed(); - if (!val.isEmpty() && (i + 1) < desc_list.size()) { - auto desc = desc_list[i + 1].trimmed(); - s->val_desc.push_back({val.toDouble(), desc}); - } +void DBCFile::parseVAL(const std::string &line) { + static const std::regex header_pattern(R"(^VAL_ ([[:alnum:]_]+) ([[:alnum:]_]+) (.*))"); + static const std::regex entry_pattern(R"dbc(([+-]?[0-9]+(?:\.[0-9]+)?)\s+"([^"]*)")dbc"); + std::smatch match; + if (!std::regex_search(line, match, header_pattern)) throw std::runtime_error("invalid VAL_ line format"); + if (auto sig = signal(std::stoul(match[1].str()), match[2].str())) { + const std::string entries = match[3].str(); + for (std::sregex_iterator it(entries.begin(), entries.end(), entry_pattern), end; it != end; ++it) { + sig->val_desc.emplace_back(std::stod((*it)[1].str()), trim((*it)[2].str())); } } } -QString DBCFile::generateDBC() { - QString dbc_string, comment, val_desc; +std::string DBCFile::generateDBC() { + std::string dbc_string, comment, val_desc; for (const auto &[address, m] : msgs) { - const QString transmitter = m.transmitter.isEmpty() ? DEFAULT_NODE_NAME : m.transmitter; - dbc_string += QString("BO_ %1 %2: %3 %4\n").arg(address).arg(m.name).arg(m.size).arg(transmitter); - if (!m.comment.isEmpty()) { - comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(QString(m.comment).replace("\"", "\\\"")); + const std::string &transmitter = m.transmitter.empty() ? DEFAULT_NODE_NAME : m.transmitter; + dbc_string += "BO_ " + std::to_string(address) + " " + m.name + ": " + std::to_string(m.size) + " " + transmitter + "\n"; + if (!m.comment.empty()) { + std::string escaped = m.comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ BO_ " + std::to_string(address) + " \"" + escaped + "\";\n"; } for (auto sig : m.getSignals()) { - QString multiplexer_indicator; - if (sig->type == cabana::Signal::Type::Multiplexor) { - multiplexer_indicator = "M "; - } else if (sig->type == cabana::Signal::Type::Multiplexed) { - multiplexer_indicator = QString("m%1 ").arg(sig->multiplex_value); - } - dbc_string += QString(" SG_ %1 %2: %3|%4@%5%6 (%7,%8) [%9|%10] \"%11\" %12\n") - .arg(sig->name) - .arg(multiplexer_indicator) - .arg(sig->start_bit) - .arg(sig->size) - .arg(sig->is_little_endian ? '1' : '0') - .arg(sig->is_signed ? '-' : '+') - .arg(doubleToString(sig->factor)) - .arg(doubleToString(sig->offset)) - .arg(doubleToString(sig->min)) - .arg(doubleToString(sig->max)) - .arg(sig->unit) - .arg(sig->receiver_name.isEmpty() ? DEFAULT_NODE_NAME : sig->receiver_name); - if (!sig->comment.isEmpty()) { - comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(QString(sig->comment).replace("\"", "\\\"")); + std::string mux; + if (sig->type == cabana::Signal::Type::Multiplexor) mux = "M "; + else if (sig->type == cabana::Signal::Type::Multiplexed) mux = "m" + std::to_string(sig->multiplex_value) + " "; + const std::string &receiver = sig->receiver_name.empty() ? DEFAULT_NODE_NAME : sig->receiver_name; + dbc_string += " SG_ " + sig->name + " " + mux + ": " + std::to_string(sig->start_bit) + "|" + std::to_string(sig->size) + "@" + + (sig->is_little_endian ? "1" : "0") + (sig->is_signed ? "-" : "+") + + " (" + doubleToString(sig->factor) + "," + doubleToString(sig->offset) + ")" + + " [" + doubleToString(sig->min) + "|" + doubleToString(sig->max) + "] \"" + sig->unit + "\" " + receiver + "\n"; + if (!sig->comment.empty()) { + std::string escaped = sig->comment; + for (size_t pos = 0; (pos = escaped.find('"', pos)) != std::string::npos; pos += 2) escaped.replace(pos, 1, "\\\""); + comment += "CM_ SG_ " + std::to_string(address) + " " + sig->name + " \"" + escaped + "\";\n"; } if (!sig->val_desc.empty()) { - QStringList text; - for (auto &[val, desc] : sig->val_desc) { - text << QString("%1 \"%2\"").arg(val).arg(desc); + std::string text; + for (const auto &[value, description] : sig->val_desc) { + if (!text.empty()) text += " "; + text += doubleToString(value) + " \"" + description + "\""; } - val_desc += QString("VAL_ %1 %2 %3;\n").arg(address).arg(sig->name).arg(text.join(" ")); + val_desc += "VAL_ " + std::to_string(address) + " " + sig->name + " " + text + ";\n"; } } dbc_string += "\n"; diff --git a/tools/cabana/dbc/dbcfile.h b/tools/cabana/dbc/dbcfile.h index bd26789..13841a9 100644 --- a/tools/cabana/dbc/dbcfile.h +++ b/tools/cabana/dbc/dbcfile.h @@ -1,44 +1,44 @@ #pragma once #include -#include +#include #include "tools/cabana/dbc/dbc.h" class DBCFile { public: - DBCFile(const QString &dbc_file_name); - DBCFile(const QString &name, const QString &content); + DBCFile(const std::string &dbc_file_name); + DBCFile(const std::string &name, const std::string &content); ~DBCFile() {} bool save(); - bool saveAs(const QString &new_filename); - bool writeContents(const QString &fn); - QString generateDBC(); + bool saveAs(const std::string &new_filename); + bool writeContents(const std::string &fn); + std::string generateDBC(); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); inline void removeMsg(const MessageId &id) { msgs.erase(id.address); } inline const std::map &getMessages() const { return msgs; } cabana::Msg *msg(uint32_t address); - cabana::Msg *msg(const QString &name); + cabana::Msg *msg(const std::string &name); inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); } - cabana::Signal *signal(uint32_t address, const QString &name); + cabana::Signal *signal(uint32_t address, const std::string &name); - inline QString name() const { return name_.isEmpty() ? "untitled" : name_; } - inline bool isEmpty() const { return msgs.empty() && name_.isEmpty(); } + inline std::string name() const { return name_.empty() ? "untitled" : name_; } + inline bool isEmpty() const { return msgs.empty() && name_.empty(); } - QString filename; + std::string filename; private: - void parse(const QString &content); - cabana::Msg *parseBO(const QString &line); - void parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt); - void parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream); - void parseVAL(const QString &line); + void parse(const std::string &content); + cabana::Msg *parseBO(const std::string &line); + void parseSG(const std::string &line, cabana::Msg *current_msg, int &multiplexor_cnt); + void parseCM_BO(const std::string &line); + void parseCM_SG(const std::string &line); + void parseVAL(const std::string &line); - QString header; + std::string header; std::map msgs; - QString name_; + std::string name_; }; diff --git a/tools/cabana/dbc/dbcmanager.cc b/tools/cabana/dbc/dbcmanager.cc index 8e98d95..7a95a4f 100644 --- a/tools/cabana/dbc/dbcmanager.cc +++ b/tools/cabana/dbc/dbcmanager.cc @@ -1,10 +1,10 @@ #include "tools/cabana/dbc/dbcmanager.h" -#include #include -#include +#include +#include -bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error) { try { auto it = std::find_if(dbc_files.begin(), dbc_files.end(), [&](auto &f) { return f.second && f.second->filename == dbc_file_name; }); @@ -17,11 +17,11 @@ bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QS return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } -bool DBCManager::open(const SourceSet &sources, const QString &name, const QString &content, QString *error) { +bool DBCManager::open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error) { try { auto file = std::make_shared(name, content); for (auto s : sources) { @@ -32,7 +32,7 @@ bool DBCManager::open(const SourceSet &sources, const QString &name, const QStri return false; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); return true; } @@ -40,69 +40,71 @@ void DBCManager::close(const SourceSet &sources) { for (auto s : sources) { dbc_files[s] = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::close(DBCFile *dbc_file) { for (auto &[_, f] : dbc_files) { if (f.get() == dbc_file) f = nullptr; } - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::closeAll() { dbc_files.clear(); - emit DBCFileChanged(); + if (callbacks_.file_changed) callbacks_.file_changed(); } void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->addSignal(sig)) { - emit signalAdded(id, s); - emit maskUpdated(); + if (callbacks_.signal_added) callbacks_.signal_added(id, s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig) { +void DBCManager::updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig) { if (auto m = msg(id)) { if (auto s = m->updateSignal(sig_name, sig)) { - emit signalUpdated(s); - emit maskUpdated(); + if (callbacks_.signal_updated) callbacks_.signal_updated(s); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) { +void DBCManager::removeSignal(const MessageId &id, const std::string &sig_name) { if (auto m = msg(id)) { if (auto s = m->sig(sig_name)) { - emit signalRemoved(s); + if (callbacks_.signal_removed) callbacks_.signal_removed(s); m->removeSignal(sig_name); - emit maskUpdated(); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } } } -void DBCManager::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) { +void DBCManager::updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->updateMsg(id, name, size, node, comment); - emit msgUpdated(id); + if (callbacks_.msg_updated) callbacks_.msg_updated(id); } void DBCManager::removeMsg(const MessageId &id) { auto dbc_file = findDBCFile(id); assert(dbc_file); // This should be impossible dbc_file->removeMsg(id); - emit msgRemoved(id); - emit maskUpdated(); + if (callbacks_.msg_removed) callbacks_.msg_removed(id); + if (callbacks_.mask_updated) callbacks_.mask_updated(); } -QString DBCManager::newMsgName(const MessageId &id) { - return QString("NEW_MSG_") + QString::number(id.address, 16).toUpper(); +std::string DBCManager::newMsgName(const MessageId &id) { + char buf[64]; + snprintf(buf, sizeof(buf), "NEW_MSG_%X", id.address); + return buf; } -QString DBCManager::newSignalName(const MessageId &id) { +std::string DBCManager::newSignalName(const MessageId &id) { auto m = msg(id); return m ? m->newSignalName() : ""; } @@ -118,14 +120,14 @@ cabana::Msg *DBCManager::msg(const MessageId &id) { return dbc_file ? dbc_file->msg(id) : nullptr; } -cabana::Msg *DBCManager::msg(uint8_t source, const QString &name) { +cabana::Msg *DBCManager::msg(uint8_t source, const std::string &name) { auto dbc_file = findDBCFile(source); return dbc_file ? dbc_file->msg(name) : nullptr; } -QStringList DBCManager::signalNames() { +std::vector DBCManager::signalNames() { // Used for autocompletion - QSet names; + std::set names; for (auto &f : allDBCFiles()) { for (auto &[_, m] : f->getMessages()) { for (auto sig : m.getSignals()) { @@ -133,8 +135,8 @@ QStringList DBCManager::signalNames() { } } } - QStringList ret = names.values(); - ret.sort(); + std::vector ret(names.begin(), names.end()); + std::sort(ret.begin(), ret.end()); return ret; } @@ -165,14 +167,16 @@ const SourceSet DBCManager::sources(const DBCFile *dbc_file) const { return sources; } -QString toString(const SourceSet &ss) { - return std::accumulate(ss.cbegin(), ss.cend(), QString(), [](QString str, int source) { - if (!str.isEmpty()) str += ", "; - return str + (source == -1 ? QStringLiteral("all") : QString::number(source)); - }); +std::string toString(const SourceSet &ss) { + std::string result; + for (int source : ss) { + if (!result.empty()) result += ", "; + result += (source == -1) ? "all" : std::to_string(source); + } + return result; } DBCManager *dbc() { - static DBCManager dbc_manager(nullptr); + static DBCManager dbc_manager; return &dbc_manager; } diff --git a/tools/cabana/dbc/dbcmanager.h b/tools/cabana/dbc/dbcmanager.h index 5f18375..5a09fae 100644 --- a/tools/cabana/dbc/dbcmanager.h +++ b/tools/cabana/dbc/dbcmanager.h @@ -1,44 +1,52 @@ #pragma once -#include +#include #include #include #include +#include +#include #include "tools/cabana/dbc/dbcfile.h" typedef std::set SourceSet; const SourceSet SOURCE_ALL = {-1}; -const int INVALID_SOURCE = 0xff; inline bool operator<(const std::shared_ptr &l, const std::shared_ptr &r) { return l.get() < r.get(); } -class DBCManager : public QObject { - Q_OBJECT - +class DBCManager { public: - DBCManager(QObject *parent) : QObject(parent) {} - ~DBCManager() {} - bool open(const SourceSet &sources, const QString &dbc_file_name, QString *error = nullptr); - bool open(const SourceSet &sources, const QString &name, const QString &content, QString *error = nullptr); + struct Callbacks { + std::function signal_added; + std::function signal_removed; + std::function signal_updated; + std::function msg_updated; + std::function msg_removed; + std::function file_changed; + std::function mask_updated; + }; + + DBCManager() = default; + bool open(const SourceSet &sources, const std::string &dbc_file_name, std::string *error = nullptr); + bool open(const SourceSet &sources, const std::string &name, const std::string &content, std::string *error = nullptr); void close(const SourceSet &sources); void close(DBCFile *dbc_file); void closeAll(); void addSignal(const MessageId &id, const cabana::Signal &sig); - void updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig); - void removeSignal(const MessageId &id, const QString &sig_name); + void updateSignal(const MessageId &id, const std::string &sig_name, const cabana::Signal &sig); + void removeSignal(const MessageId &id, const std::string &sig_name); - void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment); + void updateMsg(const MessageId &id, const std::string &name, uint32_t size, const std::string &node, const std::string &comment); void removeMsg(const MessageId &id); - QString newMsgName(const MessageId &id); - QString newSignalName(const MessageId &id); + std::string newMsgName(const MessageId &id); + std::string newSignalName(const MessageId &id); const std::map &getMessages(uint8_t source); cabana::Msg *msg(const MessageId &id); - cabana::Msg* msg(uint8_t source, const QString &name); + cabana::Msg* msg(uint8_t source, const std::string &name); - QStringList signalNames(); + std::vector signalNames(); inline int dbcCount() { return allDBCFiles().size(); } int nonEmptyDBCCount(); @@ -46,24 +54,17 @@ public: DBCFile *findDBCFile(const uint8_t source); inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); } std::set allDBCFiles(); - -signals: - void signalAdded(MessageId id, const cabana::Signal *sig); - void signalRemoved(const cabana::Signal *sig); - void signalUpdated(const cabana::Signal *sig); - void msgUpdated(MessageId id); - void msgRemoved(MessageId id); - void DBCFileChanged(); - void maskUpdated(); + void setCallbacks(Callbacks callbacks) { callbacks_ = std::move(callbacks); } private: std::map> dbc_files; + Callbacks callbacks_; }; DBCManager *dbc(); -QString toString(const SourceSet &ss); -inline QString msgName(const MessageId &id) { +std::string toString(const SourceSet &ss); +inline std::string msgName(const MessageId &id) { auto msg = dbc()->msg(id); return msg ? msg->name : UNTITLED; } diff --git a/tools/cabana/dbc/dbcqt.cc b/tools/cabana/dbc/dbcqt.cc new file mode 100644 index 0000000..4354caf --- /dev/null +++ b/tools/cabana/dbc/dbcqt.cc @@ -0,0 +1,18 @@ +#include "tools/cabana/dbc/dbcqt.h" + +QtDBCNotifier::QtDBCNotifier(QObject *parent) : QObject(parent) { + dbc()->setCallbacks({ + .signal_added = [this](MessageId id, const cabana::Signal *sig) { emit signalAdded(id, sig); }, + .signal_removed = [this](const cabana::Signal *sig) { emit signalRemoved(sig); }, + .signal_updated = [this](const cabana::Signal *sig) { emit signalUpdated(sig); }, + .msg_updated = [this](MessageId id) { emit msgUpdated(id); }, + .msg_removed = [this](MessageId id) { emit msgRemoved(id); }, + .file_changed = [this]() { emit DBCFileChanged(); }, + .mask_updated = [this]() { emit maskUpdated(); }, + }); +} + +QtDBCNotifier *dbcNotifier() { + static QtDBCNotifier notifier; + return ¬ifier; +} diff --git a/tools/cabana/dbc/dbcqt.h b/tools/cabana/dbc/dbcqt.h new file mode 100644 index 0000000..b889f85 --- /dev/null +++ b/tools/cabana/dbc/dbcqt.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include "tools/cabana/dbc/dbcmanager.h" + +Q_DECLARE_METATYPE(MessageId) +Q_DECLARE_METATYPE(ValueDescription) + +class QtDBCNotifier : public QObject { + Q_OBJECT + +public: + explicit QtDBCNotifier(QObject *parent = nullptr); + +signals: + void signalAdded(MessageId id, const cabana::Signal *sig); + void signalRemoved(const cabana::Signal *sig); + void signalUpdated(const cabana::Signal *sig); + void msgUpdated(MessageId id); + void msgRemoved(MessageId id); + void DBCFileChanged(); + void maskUpdated(); +}; + +QtDBCNotifier *dbcNotifier(); diff --git a/tools/cabana/dbc/generate_dbc_json.py b/tools/cabana/dbc/generate_dbc_json.py index 68ab566..193f769 100755 --- a/tools/cabana/dbc/generate_dbc_json.py +++ b/tools/cabana/dbc/generate_dbc_json.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import argparse import json - from iqdbc.car import Bus from iqdbc.car.fingerprints import MIGRATION from iqdbc.car.values import PLATFORMS diff --git a/tools/cabana/deqt.md b/tools/cabana/deqt.md new file mode 100644 index 0000000..3bef2d7 --- /dev/null +++ b/tools/cabana/deqt.md @@ -0,0 +1,53 @@ +we're migrating cabana away from Qt and to eventually entirely use imgui + +we are doing it incrementally, in small pieces that are easy to execute and verify. +we will repeat this until we're all done. + +# Cabana Qt API inventory + +these are all still in cabana. we remove them from this list once they're gone. +each bullet is an atomic unit of work. + +our workflow is: +- pick the easiest of the bulleted items from below +- implement it and make] sure it builds +- spin up reviewer agents to review the code in a clean context and a separate one to click around in xvfb as a gui test +- then implement the fixes from the above reviewer agents + +some rules +- do not add more Qt usage ever + +- `QObject`, `QMetaObject`, `QMetaType` +- `QApplication`, `QCoreApplication`, `QGuiApplication` +- `QString`, `QStringList`, `QStringBuilder`, `QChar`, `QLatin1Char` +- `QVariant` +- `QTimer` +- `QWidget`, `QMainWindow`, `QWindow` +- `QDialog`, `QDialogButtonBox`, `QMessageBox`, `QProgressDialog` +- `QFileDialog` +- `QMenu`, `QMenuBar`, `QAction`, `QActionGroup`, `QWidgetAction` +- `QToolBar`, `QToolButton`, `QPushButton` +- `QCheckBox`, `QRadioButton`, `QButtonGroup`, `QAbstractButton` +- `QComboBox`, `QLineEdit`, `QTextEdit`, `QSpinBox`, `QSlider` +- `QLabel`, `QGroupBox`, `QFrame` +- `QTabBar`, `QTabWidget`, `QSplitter`, `QScrollArea`, `QScrollBar` +- `QDockWidget`, `QStatusBar`, `QProgressBar` +- `QFormLayout`, `QGridLayout`, `QHBoxLayout`, `QVBoxLayout` +- `QSizePolicy` +- `QAbstractItemModel`, `QAbstractTableModel`, `QModelIndex` +- `QAbstractItemView`, `QTableView`, `QTreeView` +- `QTableWidget`, `QTableWidgetItem`, `QListWidget`, `QListWidgetItem` +- `QItemSelection`, `QItemSelectionModel`, `QItemSelectionRange` +- `QHeaderView`, `QStyledItemDelegate`, `QStyleOptionViewItem` +- `QValidator`, `QIntValidator` +- `QColor`, `QRgb`, `QPalette` +- `QBrush`, `QPen` +- `QPainter`, `QPainterPath`, `QStylePainter` +- `QImage`, `QPixmap`, `QPixmapCache`, `QStaticText` +- `QFont`, `QFontDatabase`, `QFontMetrics`, `QTextDocument` +- `QStyle`, `QStyleOption`, `QStyleOptionFrame`, `QStyleOptionSlider` +- `QPoint`, `QPointF`, `QRect`, `QRectF`, `QRegion` +- `QSize`, `QSizeF` +- `QEvent`, `QPaintEvent`, `QResizeEvent`, `QShowEvent`, `QCloseEvent` +- `QMouseEvent`, `QWheelEvent`, `QNativeGestureEvent`, `QContextMenuEvent` +- `QKeySequence`, `QShortcut`, `QToolTip` diff --git a/tools/cabana/detailwidget.cc b/tools/cabana/detailwidget.cc index 35492c8..6b62f54 100644 --- a/tools/cabana/detailwidget.cc +++ b/tools/cabana/detailwidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/detailwidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -56,8 +57,8 @@ DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(chart QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight); QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &DetailWidget::refresh); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &DetailWidget::refresh); QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu); QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) { if (index != -1) { @@ -124,9 +125,9 @@ int DetailWidget::findOrAddTab(const MessageId& message_id) { if (tabbar->tabData(index).value() == message_id) break; } if (index == -1) { - index = tabbar->addTab(message_id.toString()); + index = tabbar->addTab(QString::fromStdString(message_id.toString())); tabbar->setTabData(index, QVariant::fromValue(message_id)); - tabbar->setTabToolTip(index, msgName(message_id)); + tabbar->setTabToolTip(index, QString::fromStdString(msgName(message_id))); } return index; } @@ -151,21 +152,21 @@ std::pair DetailWidget::serializeMessageIds() const { QStringList msgs; for (int i = 0; i < tabbar->count(); ++i) { MessageId id = tabbar->tabData(i).value(); - msgs.append(id.toString()); + msgs.append(QString::fromStdString(id.toString())); } - return std::make_pair(msg_id.toString(), msgs); + return std::make_pair(QString::fromStdString(msg_id.toString()), msgs); } void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) { tabbar->blockSignals(true); for (const auto& str_id : msg_ids) { - MessageId id = MessageId::fromString(str_id); + MessageId id = MessageId::fromString(str_id.toStdString()); if (dbc()->msg(id) != nullptr) findOrAddTab(id); } tabbar->blockSignals(false); - auto active_id = MessageId::fromString(active_msg_id); + auto active_id = MessageId::fromString(active_msg_id.toStdString()); if (dbc()->msg(active_id) != nullptr) setMessage(active_id); } @@ -180,10 +181,10 @@ void DetailWidget::refresh() { warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size)); } for (auto s : binary_view->getOverlappingSignals()) { - warnings.push_back(tr("%1 has overlapping bits.").arg(s->name)); + warnings.push_back(tr("%1 has overlapping bits.").arg(QString::fromStdString(s->name))); } } - QString msg_name = msg ? QString("%1 (%2)").arg(msg->name, msg->transmitter) : msgName(msg_id); + QString msg_name = msg ? QString("%1 (%2)").arg(QString::fromStdString(msg->name), QString::fromStdString(msg->transmitter)) : QString::fromStdString(msgName(msg_id)); name_label->setText(msg_name); name_label->setToolTip(msg_name); action_remove_msg->setEnabled(msg != nullptr); @@ -208,22 +209,22 @@ void DetailWidget::updateState(const std::set *msgs) { void DetailWidget::editMsg() { auto msg = dbc()->msg(msg_id); int size = msg ? msg->size : can->lastMessage(msg_id).dat.size(); - EditMessageDialog dlg(msg_id, msgName(msg_id), size, this); + EditMessageDialog dlg(msg_id, QString::fromStdString(msgName(msg_id)), size, this); if (dlg.exec()) { - UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed(), dlg.size_spin->value(), - dlg.node->text().trimmed(), dlg.comment_edit->toPlainText().trimmed())); + UndoStack::instance()->push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed().toStdString(), dlg.size_spin->value(), + dlg.node->text().trimmed().toStdString(), dlg.comment_edit->toPlainText().trimmed().toStdString())); } } void DetailWidget::removeMsg() { - UndoStack::push(new RemoveMsgCommand(msg_id)); + UndoStack::instance()->push(new RemoveMsgCommand(msg_id)); } // EditMessageDialog EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent) : original_name(title), msg_id(msg_id), QDialog(parent) { - setWindowTitle(tr("Edit message: %1").arg(msg_id.toString())); + setWindowTitle(tr("Edit message: %1").arg(QString::fromStdString(msg_id.toString()))); QFormLayout *form_layout = new QFormLayout(this); form_layout->addRow("", error_label = new QLabel); @@ -241,8 +242,8 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel)); if (auto msg = dbc()->msg(msg_id)) { - node->setText(msg->transmitter); - comment_edit->setText(msg->comment); + node->setText(QString::fromStdString(msg->transmitter)); + comment_edit->setText(QString::fromStdString(msg->comment)); } validateName(name_edit->text()); setFixedWidth(parent->width() * 0.9); @@ -252,10 +253,10 @@ EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &tit } void EditMessageDialog::validateName(const QString &text) { - bool valid = text.compare(UNTITLED, Qt::CaseInsensitive) != 0; + bool valid = text.compare(QString::fromStdString(UNTITLED), Qt::CaseInsensitive) != 0; error_label->setVisible(false); if (!text.isEmpty() && valid && text != original_name) { - valid = dbc()->msg(msg_id.source, text) == nullptr; + valid = dbc()->msg(msg_id.source, text.toStdString()) == nullptr; if (!valid) { error_label->setText(tr("Name already exists")); error_label->setVisible(true); diff --git a/tools/cabana/historylog.cc b/tools/cabana/historylog.cc index 3dbdf5a..3fd569c 100644 --- a/tools/cabana/historylog.cc +++ b/tools/cabana/historylog.cc @@ -1,4 +1,5 @@ #include "tools/cabana/historylog.h" +#include "tools/cabana/dbc/dbcqt.h" #include @@ -14,7 +15,7 @@ QVariant HistoryLogModel::data(const QModelIndex &index, int role) const { const int col = index.column(); if (role == Qt::DisplayRole) { if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3); - if (!isHexMode()) return sigs[col - 1]->formatValue(m.sig_values[col - 1], false); + if (!isHexMode()) return QString::fromStdString(sigs[col - 1]->formatValue(m.sig_values[col - 1], false)); } else if (role == Qt::TextAlignmentRole) { return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter); } @@ -49,12 +50,12 @@ QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, i if (section == 0) return "Time"; if (isHexMode()) return "Data"; - QString name = sigs[section - 1]->name; - QString unit = sigs[section - 1]->unit; + QString name = QString::fromStdString(sigs[section - 1]->name); + QString unit = QString::fromStdString(sigs[section - 1]->unit); return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit); } else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) { // Alpha-blend the signal color with the background to ensure contrast - QColor sigColor = sigs[section - 1]->color; + QColor sigColor = toQColor(sigs[section - 1]->color); sigColor.setAlpha(128); return QBrush(sigColor); } @@ -207,8 +208,8 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged); QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV); QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &HistoryLogModel::reset); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &HistoryLogModel::reset); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &HistoryLogModel::reset); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &HistoryLogModel::reset); QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset); QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); }); } @@ -216,7 +217,7 @@ LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) { void LogsWidget::modelReset() { signals_cb->clear(); for (auto s : model->sigs) { - signals_cb->addItem(s->name); + signals_cb->addItem(QString::fromStdString(s->name)); } export_btn->setEnabled(false); value_edit->clear(); @@ -238,11 +239,11 @@ void LogsWidget::filterChanged() { } void LogsWidget::exportToCSV() { - QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(can->routeName()).arg(msgName(model->msg_id)); - QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(msgName(model->msg_id)), + QString dir = QString("%1/%2_%3.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())).arg(QString::fromStdString(msgName(model->msg_id))); + QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(QString::fromStdString(msgName(model->msg_id))), dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - model->isHexMode() ? utils::exportToCSV(fn, model->msg_id) - : utils::exportSignalsToCSV(fn, model->msg_id); + model->isHexMode() ? utils::exportToCSV(fn.toStdString(), model->msg_id) + : utils::exportSignalsToCSV(fn.toStdString(), model->msg_id); } } diff --git a/tools/cabana/historylog.h b/tools/cabana/historylog.h index 1ac6e5b..1d3200b 100644 --- a/tools/cabana/historylog.h +++ b/tools/cabana/historylog.h @@ -40,7 +40,7 @@ public: uint64_t mono_time = 0; std::vector sig_values; std::vector data; - std::vector colors; + std::vector colors; }; void fetchData(std::deque::iterator insert_pos, uint64_t from_time, uint64_t min_time); diff --git a/tools/cabana/konn3kt_canproxy.py b/tools/cabana/konn3kt_canproxy.py new file mode 100755 index 0000000..fa99fd4 --- /dev/null +++ b/tools/cabana/konn3kt_canproxy.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +konn3kt_canproxy — view a remote device's live CAN in Cabana, through konn3kt. + +Run this on your laptop. It connects to the konn3kt CAN stream for a device you have +access to and re-publishes the raw capnp frames onto a LOCAL ZMQ "can" socket. Then you +just open Cabana → Live → Device, pick ZMQ, and enter 127.0.0.1 — exactly the local +workflow, but the frames are coming from a device across the internet. + +Flow: + device canlived ──ws──> konn3kt relay ──ws──> THIS PROXY ──zmq──> Cabana (127.0.0.1) + +The proxy never parses the CAN bytes; it forwards the exact capnp Event frames the device +produced, so Cabana decodes them identically to a local ZMQ bridge. + +Auth: you authenticate as yourself (a konn3kt user JWT), not as the device. Get your JWT +from the konn3kt app/web session and pass it via --token or the KONN3KT_JWT env var. +Access is enforced server-side (owner-only, or a superuser with an explicit owner grant). + +Usage: + export KONN3KT_JWT="" + ./konn3kt_canproxy.py + # then in Cabana: Live → Device → ZMQ → 127.0.0.1 + +Requires the iqpilot/openpilot Python env (for cereal.messaging) and websocket-client. +""" +import argparse +import os +import sys +import time + +from websocket import create_connection, ABNF, WebSocketException + +# Force cereal messaging onto ZMQ so the local "can" publisher binds a TCP port that +# Cabana's ZMQ Device stream connects to. Must be set before importing messaging. +os.environ["ZMQ"] = "1" +import cereal.messaging as messaging # noqa: E402 + +DEFAULT_HOST = os.getenv("KONN3KT_HOST", "wss://api-iqlabs.konn3kt.com") +RECONNECT_MIN = 1.0 +RECONNECT_MAX = 10.0 + + +def _ws_host(host: str) -> str: + host = host.rstrip("/") + if host.startswith("https://"): + return "wss://" + host[len("https://"):] + if host.startswith("http://"): + return "ws://" + host[len("http://"):] + if host.startswith(("ws://", "wss://")): + return host + return "wss://" + host + + +def run(dongle_id: str, host: str, token: str) -> None: + ws_uri = f"{_ws_host(host)}/v1/devices/{dongle_id}/can-stream?sig={token}" + # Local ZMQ publisher for "can"; Cabana subscribes to this on 127.0.0.1. + pub = messaging.pub_sock("can") + + backoff = RECONNECT_MIN + while True: + try: + print(f"[canproxy] connecting to konn3kt for {dongle_id} ...", file=sys.stderr) + ws = create_connection(ws_uri, enable_multithread=True, timeout=30.0) + print("[canproxy] connected. Open Cabana → Live → Device → ZMQ → 127.0.0.1", file=sys.stderr) + backoff = RECONNECT_MIN + n = 0 + while True: + opcode, data = ws.recv_data(control_frame=True) + if opcode == ABNF.OPCODE_BINARY: + # Republish the exact capnp Event bytes locally. + pub.send(data) + n += 1 + if n % 1000 == 0: + print(f"[canproxy] forwarded {n} frames", file=sys.stderr) + elif opcode == ABNF.OPCODE_CLOSE: + print("[canproxy] server closed the stream", file=sys.stderr) + break + elif opcode == ABNF.OPCODE_PING: + ws.pong(data) + except (WebSocketException, OSError) as e: + print(f"[canproxy] disconnected: {e}; reconnecting in {backoff:.0f}s", file=sys.stderr) + time.sleep(backoff) + backoff = min(backoff * 2, RECONNECT_MAX) + finally: + try: + ws.close() + except Exception: + pass + + +def main() -> None: + ap = argparse.ArgumentParser(description="Bridge a konn3kt remote CAN stream into local ZMQ for Cabana.") + ap.add_argument("dongle_id", help="dongle id of the device to stream") + ap.add_argument("--host", default=DEFAULT_HOST, help=f"konn3kt host (default: {DEFAULT_HOST})") + ap.add_argument("--token", default=os.getenv("KONN3KT_JWT"), + help="konn3kt user JWT (or set KONN3KT_JWT)") + args = ap.parse_args() + + if not args.token: + ap.error("no token: pass --token or set KONN3KT_JWT") + + try: + run(args.dongle_id, args.host, args.token) + except KeyboardInterrupt: + print("\n[canproxy] bye", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/tools/cabana/mainwin.cc b/tools/cabana/mainwin.cc index 7d96dbc..76a73c6 100644 --- a/tools/cabana/mainwin.cc +++ b/tools/cabana/mainwin.cc @@ -1,29 +1,31 @@ #include "tools/cabana/mainwin.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include +#include #include +#include #include +#include -#include -#include -#include #include -#include -#include #include #include #include #include #include #include -#include #include -#include +#include "third_party/json11/json11.hpp" #include "tools/cabana/commands.h" #include "tools/cabana/streamselector.h" #include "tools/cabana/tools/findsignal.h" #include "tools/cabana/utils/export.h" +// IQ.Pilot patch: iqpilot has no py_downloader; installDownloadProgressHandler still +// lives in replay/util.h here. +#include "tools/replay/util.h" MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainWindow() { loadFingerprints(); @@ -34,14 +36,11 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW createShortcuts(); // save default window state to allow resetting it - default_state = saveState(); + default_state = utils::toBytes(saveState()); - // restore states - restoreGeometry(settings.geometry); - if (isMaximized()) { - setGeometry(QApplication::desktop()->availableGeometry(this)); - } - restoreState(settings.window_state); + // restore states; restoreGeometry() itself corrects stale off-screen geometry + restoreGeometry(utils::qbytes(settings.geometry)); + restoreState(utils::qbytes(settings.window_state)); // install handlers static auto static_main_win = this; @@ -50,11 +49,9 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) { emit static_main_win->updateProgressBar(cur, total, success); }); - qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) { - if (type == QtDebugMsg) return; - emit static_main_win->showMessage(msg, 2000); + installMessageHandler([](ReplyMsgType type, const std::string msg) { + emit static_main_win->showMessage(QString::fromStdString(msg), 2000); }); - installMessageHandler([](ReplyMsgType type, const std::string msg) { qInfo() << msg.c_str(); }); setStyleSheet(QString(R"(QMainWindow::separator { width: %1px; /* when vertical */ @@ -63,8 +60,8 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage); QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged); - QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &MainWindow::DBCFileChanged); + QObject::connect(undoNotifier(), &QtUndoNotifier::cleanChanged, this, &MainWindow::undoStackCleanChanged); QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus); QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); }); @@ -72,9 +69,17 @@ MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : QMainW } void MainWindow::loadFingerprints() { - QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json"); - if (json_file.open(QIODevice::ReadOnly)) { - fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll()); + std::ifstream json_file((QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json").toStdString()); + if (!json_file) return; + const std::string contents{std::istreambuf_iterator(json_file), std::istreambuf_iterator()}; + std::string err; + auto doc = json11::Json::parse(contents, err); + if (!err.empty() || !doc.is_object()) return; + fingerprint_to_dbc.clear(); + for (const auto &kv : doc.object_items()) { + if (kv.second.is_string()) { + fingerprint_to_dbc.emplace(kv.first, kv.second.string_value()); + } } } @@ -100,8 +105,17 @@ void MainWindow::createActions() { file_menu->addSeparator(); QMenu *load_iqdbc_menu = file_menu->addMenu(tr("Load DBC from commaai/iqdbc")); // load_iqdbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }"); - for (const auto &dbc_name : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) { - load_iqdbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); }); + std::vector dbc_names; + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH, ec)) { + if (entry.is_regular_file() && entry.path().extension() == ".dbc") { + dbc_names.push_back(entry.path().filename().string()); + } + } + std::sort(dbc_names.begin(), dbc_names.end()); + for (const auto &dbc_name : dbc_names) { + QString name = QString::fromStdString(dbc_name); + load_iqdbc_menu->addAction(name, [this, name]() { loadDBCFromOpendbc(name); }); } file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); }); @@ -119,18 +133,12 @@ void MainWindow::createActions() { // Edit Menu QMenu *edit_menu = menuBar()->addMenu(tr("&Edit")); - auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo")); + undo_act = edit_menu->addAction(tr("&Undo"), []() { UndoStack::instance()->undo(); }); undo_act->setShortcuts(QKeySequence::Undo); - edit_menu->addAction(undo_act); - auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Redo")); + redo_act = edit_menu->addAction(tr("&Redo"), []() { UndoStack::instance()->redo(); }); redo_act->setShortcuts(QKeySequence::Redo); - edit_menu->addAction(redo_act); - edit_menu->addSeparator(); - - QMenu *commands_menu = edit_menu->addMenu(tr("Command &List")); - QWidgetAction *commands_act = new QWidgetAction(this); - commands_act->setDefaultWidget(new QUndoView(UndoStack::instance())); - commands_menu->addAction(commands_act); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, this, &MainWindow::updateUndoRedoActions); + updateUndoRedoActions(); // View Menu QMenu *view_menu = menuBar()->addMenu(tr("&View")); @@ -140,7 +148,7 @@ void MainWindow::createActions() { view_menu->addAction(messages_dock->toggleViewAction()); view_menu->addAction(video_dock->toggleViewAction()); view_menu->addSeparator(); - view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); }); + view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(utils::qbytes(default_state)); }); // Tools Menu tools_menu = menuBar()->addMenu(tr("&Tools")); @@ -187,7 +195,7 @@ void MainWindow::createDockWidgets() { video_splitter->addWidget(charts_container); video_splitter->setStretchFactor(1, 1); - video_splitter->restoreState(settings.video_splitter_state); + video_splitter->restoreState(utils::qbytes(settings.video_splitter_state)); video_splitter->handle(1)->setEnabled(!can->liveStreaming()); video_dock->setWidget(video_splitter); QObject::connect(charts_widget, &ChartsWidget::toggleChartsDocking, this, &MainWindow::toggleChartsDocking); @@ -218,6 +226,14 @@ void MainWindow::undoStackCleanChanged(bool clean) { setWindowModified(!clean); } +void MainWindow::updateUndoRedoActions() { + auto stack = UndoStack::instance(); + undo_act->setEnabled(stack->canUndo()); + undo_act->setText(stack->canUndo() ? tr("&Undo %1").arg(QString::fromStdString(stack->undoText())) : tr("&Undo")); + redo_act->setEnabled(stack->canRedo()); + redo_act->setText(stack->canRedo() ? tr("&Redo %1").arg(QString::fromStdString(stack->redoText())) : tr("&Redo")); +} + void MainWindow::DBCFileChanged() { UndoStack::instance()->clear(); @@ -232,7 +248,7 @@ void MainWindow::DBCFileChanged() { QStringList title; for (auto f : dbc()->allDBCFiles()) { - title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name())); + title.push_back(tr("(%1) %2").arg(QString::fromStdString(toString(dbc()->sources(f))), QString::fromStdString(f->name()))); } setWindowFilePath(title.join(" | ")); @@ -251,27 +267,27 @@ void MainWindow::selectAndOpenStream() { void MainWindow::closeStream() { openStream(new DummyStream(this)); if (dbc()->nonEmptyDBCCount() > 0) { - emit dbc()->DBCFileChanged(); + emit dbcNotifier()->DBCFileChanged(); } statusBar()->showMessage(tr("stream closed")); } void MainWindow::exportToCSV() { - QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(can->routeName()); + QString dir = QString("%1/%2.csv").arg(QString::fromStdString(settings.last_dir)).arg(QString::fromStdString(can->routeName())); QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)")); if (!fn.isEmpty()) { - utils::exportToCSV(fn); + utils::exportToCSV(fn.toStdString()); } } void MainWindow::newFile(SourceSet s) { closeFile(s); - dbc()->open(s, "", ""); + dbc()->open(s, std::string(""), std::string("")); } void MainWindow::openFile(SourceSet s) { remindSaveChanges(); - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { loadFile(fn, s); } @@ -281,13 +297,13 @@ void MainWindow::loadFile(const QString &fn, SourceSet s) { if (!fn.isEmpty()) { closeFile(s); - QString error; - if (dbc()->open(s, fn, &error)) { + std::string error; + if (dbc()->open(s, fn.toStdString(), &error)) { updateRecentFiles(fn); statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn)); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -298,16 +314,25 @@ void MainWindow::loadDBCFromOpendbc(const QString &name) { } void MainWindow::loadFromClipboard(SourceSet s, bool close_all) { + std::string text; + if (!utils::getClipboardText(&text)) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("No clipboard tool found. Install xclip (X11) or wl-clipboard (Wayland).")); + return; + } + if (text.empty()) { + QMessageBox::warning(this, tr("Load From Clipboard"), tr("Clipboard is empty.")); + return; + } + closeFile(s); - QString dbc_str = QGuiApplication::clipboard()->text(); - QString error; - bool ret = dbc()->open(s, "", dbc_str, &error); + std::string error; + bool ret = dbc()->open(s, std::string(""), text, &error); if (ret && dbc()->nonEmptyDBCCount() > 0) { QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!")); } else { QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format.")); - msg_box.setDetailedText(error); + msg_box.setDetailedText(QString::fromStdString(error)); msg_box.exec(); } } @@ -331,7 +356,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { can->start(); loadFile(dbc_file); - statusBar()->showMessage(tr("Stream [%1] started").arg(can->routeName()), 2000); + statusBar()->showMessage(tr("Stream [%1] started").arg(QString::fromStdString(can->routeName())), 2000); bool has_stream = dynamic_cast(can) == nullptr; close_stream_act->setEnabled(has_stream); @@ -339,7 +364,7 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { tools_menu->setEnabled(has_stream); createDockWidgets(); - video_dock->setWindowTitle(can->routeName()); + video_dock->setWindowTitle(QString::fromStdString(can->routeName())); if (can->liveStreaming() || video_splitter->sizes()[0] == 0) { // display video at minimum size. video_splitter->setSizes({1, 1}); @@ -366,13 +391,16 @@ void MainWindow::startStream(AbstractStream *stream, QString dbc_file) { } void MainWindow::eventsMerged() { - if (!can->liveStreaming() && std::exchange(car_fingerprint, can->carFingerprint()) != car_fingerprint) { + if (!can->liveStreaming() && std::exchange(car_fingerprint, QString::fromStdString(can->carFingerprint())) != car_fingerprint) { video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2") - .arg(can->routeName()) + .arg(QString::fromStdString(can->routeName())) .arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint)); // Don't overwrite already loaded DBC - if (!dbc()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) { - QTimer::singleShot(0, this, [this]() { loadDBCFromOpendbc(fingerprint_to_dbc[car_fingerprint].toString() + ".dbc"); }); + auto it = fingerprint_to_dbc.find(car_fingerprint.toStdString()); + if (!dbc()->nonEmptyDBCCount() && it != fingerprint_to_dbc.end()) { + QTimer::singleShot(0, this, [this, dbc_name = QString::fromStdString(it->second)]() { + loadDBCFromOpendbc(dbc_name + ".dbc"); + }); } } } @@ -414,7 +442,7 @@ void MainWindow::closeFile(DBCFile *dbc_file) { void MainWindow::saveFile(DBCFile *dbc_file) { assert(dbc_file != nullptr); - if (!dbc_file->filename.isEmpty()) { + if (!dbc_file->filename.empty()) { dbc_file->save(); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved"), 2000); @@ -424,10 +452,10 @@ void MainWindow::saveFile(DBCFile *dbc_file) { } void MainWindow::saveFileAs(DBCFile *dbc_file) { - QString title = tr("Save File (bus: %1)").arg(toString(dbc()->sources(dbc_file))); - QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)")); + QString title = tr("Save File (bus: %1)").arg(QString::fromStdString(toString(dbc()->sources(dbc_file)))); + QString fn = QFileDialog::getSaveFileName(this, title, QString::fromStdString((std::filesystem::path(settings.last_dir) / "untitled.dbc").string()), tr("DBC (*.dbc)")); if (!fn.isEmpty()) { - dbc_file->saveAs(fn); + dbc_file->saveAs(fn.toStdString()); UndoStack::instance()->setClean(); statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000); updateRecentFiles(fn); @@ -444,8 +472,11 @@ void MainWindow::saveToClipboard() { void MainWindow::saveFileToClipboard(DBCFile *dbc_file) { assert(dbc_file != nullptr); - QGuiApplication::clipboard()->setText(dbc_file->generateDBC()); - QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + if (utils::setClipboardText(dbc_file->generateDBC())) { + QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!")); + } else { + QMessageBox::warning(this, tr("Copy To Clipboard"), tr("Failed to copy DBC to clipboard. Install xclip (X11) or wl-clipboard (Wayland).")); + } } void MainWindow::updateLoadSaveMenus() { @@ -465,26 +496,27 @@ void MainWindow::updateLoadSaveMenus() { auto dbc_file = dbc()->findDBCFile(source); if (dbc_file) { bus_menu->addSeparator(); - bus_menu->addAction(dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")")->setEnabled(false); + bus_menu->addAction(QString::fromStdString(dbc_file->name()) + " (" + QString::fromStdString(toString(dbc()->sources(dbc_file))) + ")")->setEnabled(false); bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); }); bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); }); bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); }); bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); }); bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); }); } - bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? dbc_file->name() : "No DBCs loaded")); + bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? QString::fromStdString(dbc_file->name()) : "No DBCs loaded")); manage_dbcs_menu->addMenu(bus_menu); } } void MainWindow::updateRecentFiles(const QString &fn) { - settings.recent_files.removeAll(fn); - settings.recent_files.prepend(fn); + const std::string filename = fn.toStdString(); + settings.recent_files.erase(std::remove(settings.recent_files.begin(), settings.recent_files.end(), filename), settings.recent_files.end()); + settings.recent_files.insert(settings.recent_files.begin(), filename); while (settings.recent_files.size() > MAX_RECENT_FILES) { - settings.recent_files.removeLast(); + settings.recent_files.pop_back(); } - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } void MainWindow::updateRecentFileMenu() { @@ -497,8 +529,8 @@ void MainWindow::updateRecentFileMenu() { } for (int i = 0; i < num_recent_files; ++i) { - QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName()); - open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(file); }); + QString text = tr("&%1 %2").arg(i + 1).arg(QString::fromStdString(std::filesystem::path(settings.recent_files[i]).filename().string())); + open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(QString::fromStdString(file)); }); } } @@ -558,22 +590,23 @@ void MainWindow::closeEvent(QCloseEvent *event) { remindSaveChanges(); installDownloadProgressHandler(nullptr); - qInstallMessageHandler(nullptr); + installMessageHandler(nullptr); if (floating_window) floating_window->deleteLater(); // save states - settings.geometry = saveGeometry(); - settings.window_state = saveState(); + settings.geometry = utils::toBytes(saveGeometry()); + settings.window_state = utils::toBytes(saveState()); if (can && !can->liveStreaming()) { - settings.video_splitter_state = video_splitter->saveState(); + settings.video_splitter_state = utils::toBytes(video_splitter->saveState()); } if (messages_widget) { settings.message_header_state = messages_widget->saveHeaderState(); } saveSessionState(); + settings.save(); QWidget::closeEvent(event); } @@ -629,26 +662,35 @@ void MainWindow::saveSessionState() { if (auto *detail = center_widget->getDetailWidget()) { auto [active_id, ids] = detail->serializeMessageIds(); - settings.active_msg_id = active_id; - settings.selected_msg_ids = ids; + settings.active_msg_id = active_id.toStdString(); + settings.selected_msg_ids.clear(); + for (const auto &id : ids) settings.selected_msg_ids.push_back(id.toStdString()); + } + if (charts_widget) { + settings.active_charts.clear(); + for (const auto &id : charts_widget->serializeChartIds()) settings.active_charts.push_back(id.toStdString()); } - if (charts_widget) - settings.active_charts = charts_widget->serializeChartIds(); } void MainWindow::restoreSessionState() { - if (settings.recent_dbc_file.isEmpty() || dbc()->nonEmptyDBCCount() == 0) return; + if (settings.recent_dbc_file.empty() || dbc()->nonEmptyDBCCount() == 0) return; QString dbc_file; for (auto& f : dbc()->allDBCFiles()) - if (!f->isEmpty()) { dbc_file = f->filename; break; } - if (dbc_file != settings.recent_dbc_file) return; + if (!f->isEmpty()) { dbc_file = QString::fromStdString(f->filename); break; } + if (dbc_file.toStdString() != settings.recent_dbc_file) return; - if (!settings.selected_msg_ids.isEmpty()) - center_widget->ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids); + if (!settings.selected_msg_ids.empty()) { + QStringList ids; + for (const auto &id : settings.selected_msg_ids) ids.push_back(QString::fromStdString(id)); + center_widget->ensureDetailWidget()->restoreTabs(QString::fromStdString(settings.active_msg_id), ids); + } - if (charts_widget != nullptr && !settings.active_charts.empty()) - charts_widget->restoreChartsFromIds(settings.active_charts); + if (charts_widget != nullptr && !settings.active_charts.empty()) { + QStringList ids; + for (const auto &id : settings.active_charts) ids.push_back(QString::fromStdString(id)); + charts_widget->restoreChartsFromIds(ids); + } } // HelpOverlay diff --git a/tools/cabana/mainwin.h b/tools/cabana/mainwin.h index 92c2714..279ea3b 100644 --- a/tools/cabana/mainwin.h +++ b/tools/cabana/mainwin.h @@ -1,13 +1,16 @@ #pragma once #include -#include #include #include #include #include #include +#include #include +#include +#include +#include #include "tools/cabana/chart/chartswidget.h" #include "tools/cabana/dbc/dbcmanager.h" @@ -67,6 +70,7 @@ protected: void findSimilarBits(); void findSignal(); void undoStackCleanChanged(bool clean); + void updateUndoRedoActions(); void onlineHelp(); void toggleFullScreen(); void updateStatus(); @@ -85,7 +89,7 @@ protected: QVBoxLayout *charts_layout; QProgressBar *progress_bar; QLabel *status_label; - QJsonDocument fingerprint_to_dbc; + std::unordered_map fingerprint_to_dbc; QSplitter *video_splitter = nullptr; enum { MAX_RECENT_FILES = 15 }; QMenu *open_recent_menu = nullptr; @@ -96,8 +100,10 @@ protected: QAction *save_dbc = nullptr; QAction *save_dbc_as = nullptr; QAction *copy_dbc_to_clipboard = nullptr; + QAction *undo_act = nullptr; + QAction *redo_act = nullptr; QString car_fingerprint; - QByteArray default_state; + std::vector default_state; }; class HelpOverlay : public QWidget { diff --git a/tools/cabana/messageswidget.cc b/tools/cabana/messageswidget.cc index ed9aeaf..b07e3ea 100644 --- a/tools/cabana/messageswidget.cc +++ b/tools/cabana/messageswidget.cc @@ -1,4 +1,5 @@ #include "tools/cabana/messageswidget.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -43,8 +44,8 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent); QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions); QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified); - QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, model, &MessageListModel::dbcModified); + QObject::connect(undoNotifier(), &QtUndoNotifier::indexChanged, model, &MessageListModel::dbcModified); QObject::connect(model, &MessageListModel::modelReset, [this]() { if (current_msg_id) { selectMessage(*current_msg_id); @@ -64,7 +65,7 @@ MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget setWhatsThis(tr(R"( Message View
- + Byte color
constant changing
increasing
@@ -146,7 +147,7 @@ void MessagesWidget::menuAboutToShow() { action->setCheckable(true); action->setChecked(settings.multiple_lines_hex); - action = menu->addAction(tr("Show inactive Messages"), model, &MessageListModel::showInactivemessages); + action = menu->addAction(tr("Show inactive messages"), model, &MessageListModel::showInactiveMessages); action->setCheckable(true); action->setChecked(model->show_inactive_messages); } @@ -205,18 +206,18 @@ QVariant MessageListModel::data(const QModelIndex &index, int role) const { } else if (role == Qt::ToolTipRole && index.column() == Column::NAME) { auto msg = dbc()->msg(item.id); auto tooltip = item.name; - if (msg && !msg->comment.isEmpty()) tooltip += "
" + msg->comment + ""; + if (msg && !msg->comment.empty()) tooltip += "
" + QString::fromStdString(msg->comment) + ""; return tooltip; } return {}; } -void MessageListModel::setFilterStrings(const QMap &filters) { +void MessageListModel::setFilterStrings(const std::map &filters) { filters_ = filters; filterAndSort(); } -void MessageListModel::showInactivemessages(bool show) { +void MessageListModel::showInactiveMessages(bool show) { show_inactive_messages = show; filterAndSort(); } @@ -264,20 +265,20 @@ static bool parseRange(const QString &filter, uint32_t value, int base = 10) { } bool MessageListModel::match(const MessageListModel::Item &item) { - if (filters_.isEmpty()) + if (filters_.empty()) return true; bool match = true; const auto &data = can->lastMessage(item.id); for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) { - const QString &txt = it.value(); - switch (it.key()) { + const QString &txt = it->second; + switch (it->first) { case Column::NAME: { match = item.name.contains(txt, Qt::CaseInsensitive); if (!match) { const auto m = dbc()->msg(item.id); match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(), - [&txt](const auto &s) { return s->name.contains(txt, Qt::CaseInsensitive); }); + [&txt](const auto &s) { return QString::fromStdString(s->name).contains(txt, Qt::CaseInsensitive); }); } break; } @@ -323,8 +324,8 @@ bool MessageListModel::filterAndSort() { if (show_inactive_messages || can->isMessageActive(id)) { auto msg = dbc()->msg(id); Item item = {.id = id, - .name = msg ? msg->name : UNTITLED, - .node = msg ? msg->transmitter : QString()}; + .name = msg ? QString::fromStdString(msg->name) : QString::fromStdString(UNTITLED), + .node = msg ? QString::fromStdString(msg->transmitter) : QString()}; if (match(item)) items.emplace_back(item); } @@ -387,10 +388,13 @@ void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, painter->setPen(oldPen); } -void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { +void MessageView::setModel(QAbstractItemModel *model) { + QTreeView::setModel(model); // Bypass the slow call to QTreeView::dataChanged. // QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(model, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void MessageView::updateBytesSectionSize() { @@ -421,9 +425,9 @@ MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizont } void MessageViewHeader::updateFilters() { - QMap filters; - for (int i = 0; i < count(); i++) { - if (editors[i] && !editors[i]->text().isEmpty()) { + std::map filters; + for (int i = 0; i < (int)editors.size(); i++) { + if (!editors[i]->text().isEmpty()) { filters[i] = editors[i]->text(); } } @@ -432,27 +436,24 @@ void MessageViewHeader::updateFilters() { void MessageViewHeader::updateHeaderPositions() { QSize sz = QHeaderView::sizeHint(); - for (int i = 0; i < count(); i++) { - if (editors[i]) { - int h = editors[i]->sizeHint().height(); - editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); - editors[i]->setHidden(isSectionHidden(i)); - } + for (int i = 0; i < (int)editors.size(); i++) { + int h = editors[i]->sizeHint().height(); + editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h); + editors[i]->setHidden(isSectionHidden(i)); } } void MessageViewHeader::updateGeometries() { - for (int i = 0; i < count(); i++) { - if (!editors[i]) { - QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); - editors[i] = new QLineEdit(this); - editors[i]->setClearButtonEnabled(true); - editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name)); + for (int i = (int)editors.size(); i < count(); i++) { + QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString(); + auto edit = new QLineEdit(this); + edit->setClearButtonEnabled(true); + edit->setPlaceholderText(tr("Filter %1").arg(column_name)); - QObject::connect(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); - } + QObject::connect(edit, &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters); + editors.push_back(edit); } - setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0); + setViewportMargins(0, 0, 0, !editors.empty() ? editors[0]->sizeHint().height() : 0); QHeaderView::updateGeometries(); updateHeaderPositions(); @@ -460,5 +461,5 @@ void MessageViewHeader::updateGeometries() { QSize MessageViewHeader::sizeHint() const { QSize sz = QHeaderView::sizeHint(); - return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; + return !editors.empty() ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz; } diff --git a/tools/cabana/messageswidget.h b/tools/cabana/messageswidget.h index 0fc0951..0a9cd25 100644 --- a/tools/cabana/messageswidget.h +++ b/tools/cabana/messageswidget.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include #include #include @@ -35,8 +37,8 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const; int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); } void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override; - void setFilterStrings(const QMap &filters); - void showInactivemessages(bool show); + void setFilterStrings(const std::map &filters); + void showInactiveMessages(bool show); void msgsReceived(const std::set *new_msgs, bool has_new_ids); bool filterAndSort(); void dbcModified(); @@ -56,7 +58,7 @@ private: void sortItems(std::vector &items); bool match(const MessageListModel::Item &id); - QMap filters_; + std::map filters_; std::set dbc_messages_; int sort_column = 0; Qt::SortOrder sort_order = Qt::AscendingOrder; @@ -68,11 +70,11 @@ class MessageView : public QTreeView { public: MessageView(QWidget *parent) : QTreeView(parent) {} void updateBytesSectionSize(); + void setModel(QAbstractItemModel *model) override; protected: void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {} - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override; void wheelEvent(QWheelEvent *event) override; }; @@ -86,7 +88,7 @@ public: QSize sizeHint() const override; void updateFilters(); - QMap editors; + std::vector editors; }; class MessagesWidget : public QWidget { @@ -95,8 +97,13 @@ class MessagesWidget : public QWidget { public: MessagesWidget(QWidget *parent); void selectMessage(const MessageId &message_id); - QByteArray saveHeaderState() const { return view->header()->saveState(); } - bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); } + std::vector saveHeaderState() const { + const auto state = view->header()->saveState(); + return {state.begin(), state.end()}; + } + bool restoreHeaderState(const std::vector &state) const { + return view->header()->restoreState({(const char *)state.data(), (int)state.size()}); + } void suppressHighlighted(); signals: diff --git a/tools/cabana/panda.cc b/tools/cabana/panda.cc index eb836d2..0612d67 100644 --- a/tools/cabana/panda.cc +++ b/tools/cabana/panda.cc @@ -26,16 +26,14 @@ static libusb_context *init_usb_ctx() { return context; } -Panda::Panda(std::string serial, uint32_t bus_offset, bool passive_mode) : bus_offset(bus_offset), passive_mode(passive_mode) { +Panda::Panda(std::string serial, uint32_t bus_offset) : bus_offset(bus_offset) { if (!init_usb_connection(serial)) { throw std::runtime_error("Error connecting to panda"); } LOGW("connected to %s over USB", serial.c_str()); hw_type = get_hw_type(); - if (!passive_mode) { - can_reset_communications(); - } + can_reset_communications(); } Panda::~Panda() { diff --git a/tools/cabana/panda.h b/tools/cabana/panda.h index 6ef3dcc..d318c33 100644 --- a/tools/cabana/panda.h +++ b/tools/cabana/panda.h @@ -48,12 +48,11 @@ struct can_frame { class Panda { public: - Panda(std::string serial="", uint32_t bus_offset=0, bool passive_mode=false); + Panda(std::string serial="", uint32_t bus_offset=0); ~Panda(); cereal::PandaState::PandaType hw_type = cereal::PandaState::PandaType::UNKNOWN; const uint32_t bus_offset; - const bool passive_mode; bool connected(); bool comms_healthy(); diff --git a/tools/cabana/settings.cc b/tools/cabana/settings.cc index e7b1129..8a86f62 100644 --- a/tools/cabana/settings.cc +++ b/tools/cabana/settings.cc @@ -1,15 +1,37 @@ #include "tools/cabana/settings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef __APPLE__ +#include +#endif + #include #include -#include #include #include #include -#include -#include #include +#include "third_party/json11/json11.hpp" #include "tools/cabana/utils/util.h" const int MIN_CACHE_MINIUTES = 30; @@ -17,9 +39,442 @@ const int MAX_CACHE_MINIUTES = 120; Settings settings; -template -void settings_op(SettingOperation op) { - QSettings s("cabana"); +namespace { + +std::filesystem::path settingsFile() { + return utils::configPath() / "cabana.json"; +} + +struct LoadedSettings { + json11::Json::object values; + bool exists = false; + bool valid = true; +}; + +class FileLock { +public: + explicit FileLock(const std::filesystem::path &path) { + fd = open(path.c_str(), O_CREAT | O_CLOEXEC, 0600); + if (fd < 0 || flock(fd, LOCK_EX) < 0) { + fprintf(stderr, "failed to lock Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + if (fd >= 0) close(fd); + fd = -1; + } + } + ~FileLock() { + if (fd >= 0) close(fd); + } + bool isLocked() const { return fd >= 0; } + +private: + int fd = -1; +}; + +LoadedSettings loadSettings() { + std::ifstream input(settingsFile()); + if (!input) return {}; + + const std::string contents{std::istreambuf_iterator(input), std::istreambuf_iterator()}; + std::string error; + auto settings_json = json11::Json::parse(contents, error); + if (!error.empty() || !settings_json.is_object()) { + fprintf(stderr, "failed to read Cabana settings %s%s%s\n", settingsFile().c_str(), error.empty() ? "" : ": ", error.c_str()); + return {.exists = true, .valid = false}; + } + return {.values = settings_json.object_items(), .exists = true}; +} + +bool ensureSettingsDirectory() { + const auto path = settingsFile(); + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + if (error) { + fprintf(stderr, "failed to create Cabana settings directory %s: %s\n", path.parent_path().c_str(), error.message().c_str()); + return false; + } + return true; +} + +bool writeAll(int fd, const std::string &data) { + size_t written = 0; + while (written < data.size()) { + ssize_t result = write(fd, data.data() + written, data.size() - written); + if (result < 0 && errno == EINTR) continue; + if (result <= 0) return false; + written += result; + } + return true; +} + +bool saveSettings(const json11::Json::object &settings_json) { + const auto path = settingsFile(); + const std::string contents = json11::Json(settings_json).dump(); + std::string temporary_path = path.string() + ".tmp.XXXXXX"; + int fd = mkstemp(temporary_path.data()); + if (fd < 0) { + fprintf(stderr, "failed to create temporary Cabana settings %s: %s\n", temporary_path.c_str(), strerror(errno)); + return false; + } + + bool success = writeAll(fd, contents) && fsync(fd) == 0; + if (close(fd) < 0) success = false; + if (success && rename(temporary_path.c_str(), path.c_str()) < 0) success = false; + + if (success) { + int dir_fd = open(path.parent_path().c_str(), O_RDONLY | O_CLOEXEC); + success = dir_fd >= 0 && fsync(dir_fd) == 0; + if (dir_fd >= 0 && close(dir_fd) < 0) success = false; + } + + if (!success) { + const int saved_errno = errno; + unlink(temporary_path.c_str()); + fprintf(stderr, "failed to save Cabana settings to %s: %s\n", path.c_str(), strerror(saved_errno)); + } + return success; +} + +bool preserveCorruptSettings() { + const auto path = settingsFile(); + auto backup = path; + backup += ".corrupt"; + for (int i = 1; std::filesystem::exists(backup); ++i) { + backup = path; + backup += ".corrupt." + std::to_string(i); + } + if (rename(path.c_str(), backup.c_str()) < 0) { + fprintf(stderr, "failed to preserve corrupt Cabana settings %s: %s\n", path.c_str(), strerror(errno)); + return false; + } + fprintf(stderr, "preserved corrupt Cabana settings at %s\n", backup.c_str()); + return true; +} + +// TODO: Remove the legacy QSettings migration after users have had time to migrate to cabana.json. +struct LegacyValue { + std::vector strings; + std::string bytes; + bool is_byte_array = false; +}; + +using LegacySettings = std::map; + +int hexDigit(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +#ifndef __APPLE__ + +void appendUtf8(std::string &result, uint32_t codepoint) { + if (codepoint <= 0x7f) { + result.push_back(codepoint); + } else if (codepoint <= 0x7ff) { + result.push_back(0xc0 | (codepoint >> 6)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else if (codepoint <= 0xffff) { + result.push_back(0xe0 | (codepoint >> 12)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } else { + result.push_back(0xf0 | (codepoint >> 18)); + result.push_back(0x80 | ((codepoint >> 12) & 0x3f)); + result.push_back(0x80 | ((codepoint >> 6) & 0x3f)); + result.push_back(0x80 | (codepoint & 0x3f)); + } +} + +LegacyValue decodeIniValue(std::string_view encoded) { + std::vector> decoded(1); + std::vector quoted(1, false); + bool in_quotes = false; + + for (size_t i = 0; i < encoded.size();) { + char c = encoded[i++]; + if (c == '"') { + in_quotes = !in_quotes; + quoted.back() = true; + } else if (c == ',' && !in_quotes) { + decoded.emplace_back(); + quoted.push_back(false); + while (i < encoded.size() && (encoded[i] == ' ' || encoded[i] == '\t')) ++i; + } else if (c == '\\' && i < encoded.size()) { + c = encoded[i++]; + static const std::map escapes = { + {'a', '\a'}, {'b', '\b'}, {'f', '\f'}, {'n', '\n'}, {'r', '\r'}, {'t', '\t'}, + {'v', '\v'}, {'"', '"'}, {'?', '?'}, {'\'', '\''}, {'\\', '\\'}, + }; + if (auto it = escapes.find(c); it != escapes.end()) { + decoded.back().push_back(static_cast(it->second)); + } else if (c == 'x' && i < encoded.size() && hexDigit(encoded[i]) >= 0) { + uint32_t value = 0; + while (i < encoded.size() && hexDigit(encoded[i]) >= 0) value = (value << 4) + hexDigit(encoded[i++]); + decoded.back().push_back(value & 0xffff); + } else if (c >= '0' && c <= '7') { + uint32_t value = c - '0'; + while (i < encoded.size() && encoded[i] >= '0' && encoded[i] <= '7') value = (value << 3) + (encoded[i++] - '0'); + decoded.back().push_back(value & 0xffff); + } + } else { + decoded.back().push_back(static_cast(c)); + } + } + + LegacyValue result; + for (size_t i = 0; i < decoded.size(); ++i) { + auto &value = decoded[i]; + if (!quoted[i]) { + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(value.begin()); + while (!value.empty() && (value.back() == ' ' || value.back() == '\t')) value.pop_back(); + } + + std::string string_value; + for (size_t j = 0; j < value.size(); ++j) { + uint32_t codepoint = value[j]; + if (codepoint >= 0xd800 && codepoint <= 0xdbff && j + 1 < value.size() && value[j + 1] >= 0xdc00 && value[j + 1] <= 0xdfff) { + codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (value[++j] - 0xdc00); + } + appendUtf8(string_value, codepoint); + } + result.strings.push_back(std::move(string_value)); + } + + if (result.strings.size() == 1 && result.strings[0] == "@Invalid()") { + result.strings.clear(); + } else if (decoded.size() == 1) { + static constexpr std::string_view prefix = "@ByteArray("; + const auto &value = decoded[0]; + if (value.size() >= prefix.size() + 1 && std::equal(prefix.begin(), prefix.end(), value.begin()) && value.back() == ')') { + result.is_byte_array = true; + result.bytes.reserve(value.size() - prefix.size() - 1); + for (size_t i = prefix.size(); i + 1 < value.size(); ++i) result.bytes.push_back(value[i] & 0xff); + } + } + if (!result.is_byte_array) { + for (auto &value : result.strings) { + if (value.compare(0, 2, "@@") == 0) value.erase(0, 1); + } + } + return result; +} + +LegacySettings loadLegacySettings() { + auto path = settingsFile(); + path.replace_filename("cabana.conf"); + std::ifstream input(path); + if (!input) return {}; + + LegacySettings settings; + bool in_general_section = false; + std::string line; + while (std::getline(input, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line == "[General]") { + in_general_section = true; + continue; + } + if (!line.empty() && line.front() == '[') { + in_general_section = false; + continue; + } + if (!in_general_section || line.empty() || line.front() == ';') continue; + if (auto separator = line.find('='); separator != std::string::npos) { + settings[line.substr(0, separator)] = decodeIniValue(std::string_view(line).substr(separator + 1)); + } + } + return settings; +} + +#else + +std::string cfStringToUtf8(CFStringRef value) { + CFIndex length = CFStringGetLength(value); + CFIndex size = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; + std::string result(size, '\0'); + if (!CFStringGetCString(value, result.data(), size, kCFStringEncodingUTF8)) return {}; + result.resize(strlen(result.c_str())); + return result; +} + +LegacyValue cfStringValue(CFStringRef string) { + LegacyValue value; + if (CFStringHasPrefix(string, CFSTR("@ByteArray(")) && CFStringHasSuffix(string, CFSTR(")"))) { + CFRange range{11, CFStringGetLength(string) - 12}; + std::vector data(range.length); + CFStringGetCharacters(string, range, data.data()); + value.is_byte_array = true; + value.bytes.reserve(data.size()); + for (UniChar c : data) value.bytes.push_back(c & 0xff); + } else { + std::string string_value = cfStringToUtf8(string); + if (string_value.compare(0, 2, "@@") == 0) string_value.erase(0, 1); + value.strings.push_back(std::move(string_value)); + } + return value; +} + +LegacySettings loadLegacySettings() { + LegacySettings settings; + CFDictionaryRef values = CFPreferencesCopyMultiple(nullptr, CFSTR("com.cabana"), + kCFPreferencesCurrentUser, kCFPreferencesAnyHost); + if (values == nullptr) return settings; + + CFIndex count = CFDictionaryGetCount(values); + std::vector keys(count); + std::vector objects(count); + CFDictionaryGetKeysAndValues(values, keys.data(), objects.data()); + for (CFIndex i = 0; i < count; ++i) { + if (CFGetTypeID(keys[i]) != CFStringGetTypeID()) continue; + std::string key = cfStringToUtf8(static_cast(keys[i])); + CFTypeRef object = objects[i]; + LegacyValue value; + if (CFGetTypeID(object) == CFBooleanGetTypeID()) { + value.strings.push_back(CFBooleanGetValue(static_cast(object)) ? "true" : "false"); + } else if (CFGetTypeID(object) == CFNumberGetTypeID()) { + int number = 0; + if (CFNumberGetValue(static_cast(object), kCFNumberIntType, &number)) value.strings.push_back(std::to_string(number)); + } else if (CFGetTypeID(object) == CFStringGetTypeID()) { + value = cfStringValue(static_cast(object)); + } else if (CFGetTypeID(object) == CFDataGetTypeID()) { + auto data = static_cast(object); + value.is_byte_array = true; + value.bytes.assign(reinterpret_cast(CFDataGetBytePtr(data)), CFDataGetLength(data)); + } else if (CFGetTypeID(object) == CFArrayGetTypeID()) { + auto array = static_cast(object); + for (CFIndex j = 0; j < CFArrayGetCount(array); ++j) { + CFTypeRef item = CFArrayGetValueAtIndex(array, j); + if (CFGetTypeID(item) != CFStringGetTypeID()) { + value.strings.clear(); + break; + } + auto item_value = cfStringValue(static_cast(item)); + if (item_value.strings.size() != 1) { + value.strings.clear(); + break; + } + value.strings.push_back(std::move(item_value.strings[0])); + } + } + if (!value.strings.empty() || value.is_byte_array || CFGetTypeID(object) == CFArrayGetTypeID()) { + settings.emplace(std::move(key), std::move(value)); + } + } + CFRelease(values); + return settings; +} + +#endif + +template +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, T &value) { + auto it = legacy_settings.find(key); + if (it == legacy_settings.end() || it->second.strings.size() != 1) return; + const auto &stored = it->second.strings[0]; + + if constexpr (std::is_same_v) { + if (stored == "true") value = true; + if (stored == "false") value = false; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + int number = 0; + auto [end, error] = std::from_chars(stored.data(), stored.data() + stored.size(), number); + if (error == std::errc{} && end == stored.data() + stored.size()) value = static_cast(number); + } +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::string &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.strings.size() == 1) value = it->second.strings[0]; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && !it->second.is_byte_array) value = it->second.strings; +} + +void readLegacySetting(const LegacySettings &legacy_settings, const char *key, std::vector &value) { + auto it = legacy_settings.find(key); + if (it != legacy_settings.end() && it->second.is_byte_array) { + value.assign(it->second.bytes.begin(), it->second.bytes.end()); + } +} + +template +void readSetting(const json11::Json::object &settings_json, const char *key, T &value) { + auto it = settings_json.find(key); + if (it == settings_json.end()) return; + + if constexpr (std::is_same_v) { + if (it->second.is_bool()) value = it->second.bool_value(); + } else if constexpr (std::is_integral_v) { + if (it->second.is_number()) value = it->second.int_value(); + } else if constexpr (std::is_enum_v) { + if (it->second.is_number()) value = static_cast(it->second.int_value()); + } +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::string &value) { + auto it = settings_json.find(key); + if (it != settings_json.end() && it->second.is_string()) value = it->second.string_value(); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_array()) return; + + std::vector stored; + for (const auto &item : it->second.array_items()) { + if (!item.is_string()) return; + stored.push_back(item.string_value()); + } + value = std::move(stored); +} + +void readSetting(const json11::Json::object &settings_json, const char *key, std::vector &value) { + auto it = settings_json.find(key); + if (it == settings_json.end() || !it->second.is_string()) return; + + const auto &hex = it->second.string_value(); + if (hex.size() % 2 == 0 && std::all_of(hex.begin(), hex.end(), [](unsigned char c) { return std::isxdigit(c); })) { + value.clear(); + value.reserve(hex.size() / 2); + for (size_t i = 0; i < hex.size(); i += 2) { + value.push_back((hexDigit(hex[i]) << 4) | hexDigit(hex[i + 1])); + } + } +} + +template +void writeSetting(json11::Json::object &settings_json, const char *key, const T &value) { + if constexpr (std::is_same_v) { + settings_json[key] = value; + } else if constexpr (std::is_integral_v || std::is_enum_v) { + settings_json[key] = static_cast(value); + } +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::string &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + settings_json[key] = value; +} + +void writeSetting(json11::Json::object &settings_json, const char *key, const std::vector &value) { + static const char digits[] = "0123456789abcdef"; + std::string hex; + hex.reserve(value.size() * 2); + for (uint8_t b : value) { + hex.push_back(digits[b >> 4]); + hex.push_back(digits[b & 0xf]); + } + settings_json[key] = hex; +} + +template +void settingsOp(Store &s, SettingOperation op) { op(s, "absolute_time", settings.absolute_time); op(s, "fps", settings.fps); op(s, "max_cached_minutes", settings.max_cached_minutes); @@ -47,17 +502,38 @@ void settings_op(SettingOperation op) { op(s, "active_charts", settings.active_charts); } +} // namespace + Settings::Settings() { - last_dir = last_route_dir = QDir::homePath(); - log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/"; - settings_op([](QSettings &s, const QString &key, auto &value) { - if (auto v = s.value(key); v.canConvert>()) - value = v.value>(); - }); + last_dir = last_route_dir = utils::homePath(); + log_path = utils::homePath() + "/cabana_live_stream/"; + const auto stored_settings = loadSettings(); + if (stored_settings.valid) { + if (stored_settings.exists) { + settingsOp(stored_settings.values, [](const auto &s, const char *key, auto &value) { readSetting(s, key, value); }); + } else { + auto legacy_settings = loadLegacySettings(); + settingsOp(legacy_settings, [](const auto &s, const char *key, auto &value) { readLegacySetting(s, key, value); }); + } + } + fps = std::clamp(fps, 1, 100); } -Settings::~Settings() { - settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); }); +// Must be called before main() returns: json11's internal statistics are constructed on first +// use at runtime, so they are destroyed before this pre-main global. Saving from ~Settings +// would use them after destruction and corrupt the heap. +void Settings::save() { + if (!ensureSettingsDirectory()) return; + + auto lock_path = settingsFile(); + lock_path += ".lock"; + FileLock lock(lock_path); + if (!lock.isLocked()) return; + + auto stored_settings = loadSettings(); + if (!stored_settings.valid && !preserveCorruptSettings()) return; + settingsOp(stored_settings.values, [](auto &s, const char *key, const auto &value) { writeSetting(s, key, value); }); + saveSettings(stored_settings.values); } // SettingsDlg @@ -101,8 +577,9 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { log_livestream = new QGroupBox(tr("Enable live stream logging"), this); log_livestream->setCheckable(true); + log_livestream->setChecked(settings.log_livestream); QHBoxLayout *path_layout = new QHBoxLayout(log_livestream); - path_layout->addWidget(log_path = new QLineEdit(settings.log_path, this)); + path_layout->addWidget(log_path = new QLineEdit(QString::fromStdString(settings.log_path), this)); log_path->setReadOnly(true); auto browse_btn = new QPushButton(tr("B&rowse...")); path_layout->addWidget(browse_btn); @@ -115,7 +592,7 @@ SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) { QObject::connect(browse_btn, &QPushButton::clicked, [this]() { QString fn = QFileDialog::getExistingDirectory( this, tr("Log File Location"), - QStandardPaths::writableLocation(QStandardPaths::HomeLocation), + QString::fromStdString(utils::homePath()), QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks); if (!fn.isEmpty()) { log_path->setText(fn); @@ -134,7 +611,7 @@ void SettingsDlg::save() { settings.max_cached_minutes = cached_minutes->value(); settings.chart_height = chart_height->value(); settings.log_livestream = log_livestream->isChecked(); - settings.log_path = log_path->text(); + settings.log_path = log_path->text().toStdString(); settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex(); emit settings.changed(); QDialog::accept(); diff --git a/tools/cabana/settings.h b/tools/cabana/settings.h index 7ab50d1..7357ecf 100644 --- a/tools/cabana/settings.h +++ b/tools/cabana/settings.h @@ -1,56 +1,28 @@ #pragma once -#include +#include +#include + #include #include #include #include #include -#define LIGHT_THEME 1 -#define DARK_THEME 2 +#include "tools/cabana/core/settings.h" -class Settings : public QObject { +class Settings : public QObject, public CabanaSettingsState { Q_OBJECT public: - enum DragDirection { - MsbFirst, - LsbFirst, - AlwaysLE, - AlwaysBE, - }; - Settings(); - ~Settings(); + void save(); - bool absolute_time = false; - int fps = 10; - int max_cached_minutes = 30; - int chart_height = 200; - int chart_column_count = 1; - int chart_range = 3 * 60; // 3 minutes - int chart_series_type = 0; - int theme = 0; - int sparkline_range = 15; // 15 seconds - bool multiple_lines_hex = false; - bool log_livestream = true; - bool suppress_defined_signals = false; - QString log_path; - QString last_dir; - QString last_route_dir; - QByteArray geometry; - QByteArray video_splitter_state; - QByteArray window_state; - QStringList recent_files; - QByteArray message_header_state; - DragDirection drag_direction = MsbFirst; - - // session data - QString recent_dbc_file; - QString active_msg_id; - QStringList selected_msg_ids; - QStringList active_charts; + // Qt frontend layout state. This intentionally stays outside CabanaSettingsState. + std::vector geometry; + std::vector video_splitter_state; + std::vector window_state; + std::vector message_header_state; signals: void changed(); diff --git a/tools/cabana/signalview.cc b/tools/cabana/signalview.cc index 0a0c07a..ebb5374 100644 --- a/tools/cabana/signalview.cc +++ b/tools/cabana/signalview.cc @@ -1,8 +1,9 @@ #include "tools/cabana/signalview.h" +#include "tools/cabana/dbc/dbcqt.h" #include +#include -#include #include #include #include @@ -11,10 +12,10 @@ #include #include #include -#include #include #include "tools/cabana/commands.h" +#include "tools/cabana/utils/util.h" // SignalModel @@ -25,21 +26,21 @@ static QString signalTypeToString(cabana::Signal::Type type) { } SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) { - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh); - QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated); - QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &SignalModel::refresh); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgUpdated, this, &SignalModel::handleMsgChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::msgRemoved, this, &SignalModel::handleMsgChanged); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalModel::handleSignalAdded); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalModel::handleSignalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalRemoved, this, &SignalModel::handleSignalRemoved); } void SignalModel::insertItem(SignalModel::Item *root_item, int pos, const cabana::Signal *sig) { - Item *parent_item = new Item{.sig = sig, .parent = root_item, .title = sig->name, .type = Item::Sig}; - root_item->children.insert(pos, parent_item); + Item *parent_item = new Item{.type = Item::Sig, .parent = root_item, .sig = sig, .title = QString::fromStdString(sig->name)}; + root_item->children.insert(root_item->children.begin() + pos, parent_item); QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type", "Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"}; for (int i = 0; i < std::size(titles); ++i) { - auto item = new Item{.sig = sig, .parent = parent_item, .title = titles[i], .type = (Item::Type)(i + Item::Name)}; + auto item = new Item{.type = (Item::Type)(i + Item::Name), .parent = parent_item, .sig = sig, .title = titles[i]}; parent_item->children.push_back(item); if (item->type == Item::ExtraInfo) { parent_item = item; @@ -63,7 +64,7 @@ void SignalModel::refresh() { root.reset(new SignalModel::Item); if (auto msg = dbc()->msg(msg_id)) { for (auto s : msg->getSignals()) { - if (filter_str.isEmpty() || s->name.contains(filter_str, Qt::CaseInsensitive)) { + if (filter_str.isEmpty() || QString::fromStdString(s->name).contains(filter_str, Qt::CaseInsensitive)) { insertItem(root.get(), root->children.size(), s); } } @@ -124,25 +125,25 @@ QVariant SignalModel::data(const QModelIndex &index, int role) const { const Item *item = getItem(index); if (role == Qt::DisplayRole || role == Qt::EditRole) { if (index.column() == 0) { - return item->type == Item::Sig ? item->sig->name : item->title; + return item->type == Item::Sig ? QString::fromStdString(item->sig->name) : item->title; } else { switch (item->type) { case Item::Sig: return item->sig_val; - case Item::Name: return item->sig->name; + case Item::Name: return QString::fromStdString(item->sig->name); case Item::Size: return item->sig->size; - case Item::Node: return item->sig->receiver_name; + case Item::Node: return QString::fromStdString(item->sig->receiver_name); case Item::SignalType: return signalTypeToString(item->sig->type); case Item::MultiplexValue: return item->sig->multiplex_value; - case Item::Offset: return doubleToString(item->sig->offset); - case Item::Factor: return doubleToString(item->sig->factor); - case Item::Unit: return item->sig->unit; - case Item::Comment: return item->sig->comment; - case Item::Min: return doubleToString(item->sig->min); - case Item::Max: return doubleToString(item->sig->max); + case Item::Offset: return QString::fromStdString(doubleToString(item->sig->offset)); + case Item::Factor: return QString::fromStdString(doubleToString(item->sig->factor)); + case Item::Unit: return QString::fromStdString(item->sig->unit); + case Item::Comment: return QString::fromStdString(item->sig->comment); + case Item::Min: return QString::fromStdString(doubleToString(item->sig->min)); + case Item::Max: return QString::fromStdString(doubleToString(item->sig->max)); case Item::Desc: { QStringList val_desc; for (auto &[val, desc] : item->sig->val_desc) { - val_desc << QString("%1 \"%2\"").arg(val).arg(desc); + val_desc << QString("%1 \"%2\"").arg(val).arg(QString::fromStdString(desc)); } return val_desc.join(" "); } @@ -165,17 +166,17 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r Item *item = getItem(index); cabana::Signal s = *item->sig; switch (item->type) { - case Item::Name: s.name = value.toString(); break; + case Item::Name: s.name = value.toString().toStdString(); break; case Item::Size: s.size = value.toInt(); break; - case Item::Node: s.receiver_name = value.toString().trimmed(); break; + case Item::Node: s.receiver_name = value.toString().trimmed().toStdString(); break; case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break; case Item::MultiplexValue: s.multiplex_value = value.toInt(); break; case Item::Endian: s.is_little_endian = value.toBool(); break; case Item::Signed: s.is_signed = value.toBool(); break; case Item::Offset: s.offset = value.toDouble(); break; case Item::Factor: s.factor = value.toDouble(); break; - case Item::Unit: s.unit = value.toString(); break; - case Item::Comment: s.comment = value.toString(); break; + case Item::Unit: s.unit = value.toString().toStdString(); break; + case Item::Comment: s.comment = value.toString().toStdString(); break; case Item::Min: s.min = value.toDouble(); break; case Item::Max: s.max = value.toDouble(); break; case Item::Desc: s.val_desc = value.value(); break; @@ -189,7 +190,7 @@ bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int r bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) { auto msg = dbc()->msg(msg_id); if (s.name != origin_s->name && msg->sig(s.name) != nullptr) { - QString text = tr("There is already a signal with the same name '%1'").arg(s.name); + QString text = tr("There is already a signal with the same name '%1'").arg(QString::fromStdString(s.name)); QMessageBox::warning(nullptr, tr("Failed to save signal"), text); return false; } @@ -197,7 +198,7 @@ bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) if (s.is_little_endian != origin_s->is_little_endian) { s.start_bit = flipBitPos(s.start_bit); } - UndoStack::push(new EditSignalCommand(msg_id, origin_s, s)); + UndoStack::instance()->push(new EditSignalCommand(msg_id, origin_s, s)); return true; } @@ -214,7 +215,7 @@ void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) { beginInsertRows({}, i, i); insertItem(root.get(), i, sig); endInsertRows(); - } else if (sig->name.contains(filter_str, Qt::CaseInsensitive)) { + } else if (QString::fromStdString(sig->name).contains(filter_str, Qt::CaseInsensitive)) { refresh(); } } @@ -229,7 +230,9 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { int to = dbc()->msg(msg_id)->indexOf(sig); if (to != row) { beginMoveRows({}, row, row, {}, to > row ? to + 1 : to); - root->children.move(row, to); + auto item = root->children[row]; + root->children.erase(root->children.begin() + row); + root->children.insert(root->children.begin() + to, item); endMoveRows(); } } @@ -239,7 +242,8 @@ void SignalModel::handleSignalUpdated(const cabana::Signal *sig) { void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { if (int row = signalRow(sig); row != -1) { beginRemoveRows({}, row, row); - delete root->children.takeAt(row); + delete root->children[row]; + root->children.erase(root->children.begin() + row); endRemoveRows(); } } @@ -248,7 +252,7 @@ void SignalModel::handleSignalRemoved(const cabana::Signal *sig) { SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) { name_validator = new NameValidator(this); - node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this); + node_validator = new NodeValidator(this); double_validator = new DoubleValidator(this); label_font.setPointSize(8); @@ -301,7 +305,7 @@ void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &op path.addRoundedRect(icon_rect, 3, 3); painter->setPen(item->highlight ? Qt::white : Qt::black); painter->setFont(label_font); - painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0)); + painter->fillPath(path, toQColor(item->sig->color.darker(item->highlight ? 125 : 0))); painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1)); rect.setLeft(icon_rect.right() + h_margin * 2); @@ -372,12 +376,6 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator); else e->setValidator(double_validator); - if (item->type == SignalModel::Item::Name) { - QCompleter *completer = new QCompleter(dbc()->signalNames(), e); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - e->setCompleter(completer); - } return e; } else if (item->type == SignalModel::Item::Size) { QSpinBox *spin = new QSpinBox(parent); @@ -395,7 +393,7 @@ QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionVie return c; } else if (item->type == SignalModel::Item::Desc) { ValueDescriptionDlg dlg(item->sig->val_desc, parent); - dlg.setWindowTitle(item->sig->name); + dlg.setWindowTitle(QString::fromStdString(item->sig->name)); if (dlg.exec()) { ((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc)); } @@ -422,8 +420,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QHBoxLayout *hl = new QHBoxLayout(title_bar); hl->addWidget(signal_count_lb = new QLabel()); filter_edit = new QLineEdit(this); - QRegularExpression re("\\S+"); - filter_edit->setValidator(new QRegularExpressionValidator(re, this)); + filter_edit->setValidator(new NonWhitespaceValidator(this)); filter_edit->setClearButtonEnabled(true); filter_edit->setPlaceholderText(tr("Filter Signal")); hl->addWidget(filter_edit); @@ -475,8 +472,8 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); }); QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged); QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged); - QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded); - QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalAdded, this, &SignalView::handleSignalAdded); + QObject::connect(dbcNotifier(), &QtDBCNotifier::signalUpdated, this, &SignalView::handleSignalUpdated); QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); }); QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); }); QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState); @@ -489,7 +486,7 @@ SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), setWhatsThis(tr(R"( Signal view
- + )")); } @@ -518,7 +515,7 @@ void SignalView::rowsChanged() { tree->setIndexWidget(index, w); auto sig = model->getItem(index)->sig; - QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); }); + QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::instance()->push(new RemoveSigCommand(model->msg_id, sig)); }); QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) { emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier); }); @@ -621,7 +618,7 @@ void SignalView::updateState(const std::set *msgs) { for (auto item : model->root->children) { double value = 0; if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) { - item->sig_val = item->sig->formatValue(value); + item->sig_val = QString::fromStdString(item->sig->formatValue(value)); max_value_width = std::max(max_value_width, fontMetrics().horizontalAdvance(item->sig_val)); } } @@ -635,13 +632,13 @@ void SignalView::updateState(const std::set *msgs) { delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2); auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts)); - QFutureSynchronizer synchronizer; + std::vector> futures; for (int i = first_visible.row(); i <= last_visible.row(); ++i) { auto item = model->getItem(model->index(i, 1)); - synchronizer.addFuture(QtConcurrent::run( - &item->sparkline, &Sparkline::update, item->sig, first, last, settings.sparkline_range, size)); + futures.push_back(std::async(std::launch::async, + &Sparkline::update, &item->sparkline, item->sig, first, last, settings.sparkline_range, size)); } - synchronizer.waitForFinished(); + for (auto &f : futures) f.get(); } for (int i = 0; i < model->rowCount(); ++i) { @@ -677,7 +674,7 @@ ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, Q int row = 0; for (auto &[val, desc] : descriptions) { table->setItem(row, 0, new QTableWidgetItem(QString::number(val))); - table->setItem(row, 1, new QTableWidgetItem(desc)); + table->setItem(row, 1, new QTableWidgetItem(QString::fromStdString(desc))); ++row; } @@ -706,7 +703,7 @@ void ValueDescriptionDlg::save() { QString val = table->item(i, 0)->text().trimmed(); QString desc = table->item(i, 1)->text().trimmed(); if (!val.isEmpty() && !desc.isEmpty()) { - val_desc.push_back({val.toDouble(), desc}); + val_desc.push_back({val.toDouble(), desc.toStdString()}); } } QDialog::accept(); diff --git a/tools/cabana/signalview.h b/tools/cabana/signalview.h index 4e746ea..bccfbab 100644 --- a/tools/cabana/signalview.h +++ b/tools/cabana/signalview.h @@ -20,12 +20,15 @@ class SignalModel : public QAbstractItemModel { public: struct Item { enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc }; - ~Item() { qDeleteAll(children); } - inline int row() { return parent->children.indexOf(this); } + ~Item() { for (auto c : children) delete c; } + inline int row() { + auto it = std::find(parent->children.begin(), parent->children.end(), this); + return it != parent->children.end() ? std::distance(parent->children.begin(), it) : -1; + } Type type = Type::Root; Item *parent = nullptr; - QList children; + std::vector children; const cabana::Signal *sig = nullptr; QString title; @@ -126,9 +129,12 @@ private: // update widget geometries in QTreeView::rowsInserted QTreeView::rowsInserted(parent, start, end); } - void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles = QVector()) override { + void setModel(QAbstractItemModel *m) override { + QTreeView::setModel(m); // Bypass the slow call to QTreeView::dataChanged. - QAbstractItemView::dataChanged(topLeft, bottomRight, roles); + QObject::disconnect(m, &QAbstractItemModel::dataChanged, this, nullptr); + QObject::connect(m, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &tl, const QModelIndex &br, const auto &roles) { QAbstractItemView::dataChanged(tl, br, roles); }); } void leaveEvent(QEvent *event) override { emit static_cast(parentWidget())->highlight(nullptr); diff --git a/tools/cabana/streams/abstractstream.cc b/tools/cabana/streams/abstractstream.cc index 1582fcd..c58a980 100644 --- a/tools/cabana/streams/abstractstream.cc +++ b/tools/cabana/streams/abstractstream.cc @@ -1,4 +1,5 @@ #include "tools/cabana/streams/abstractstream.h" +#include "tools/cabana/dbc/dbcqt.h" #include #include @@ -18,8 +19,8 @@ AbstractStream::AbstractStream(QObject *parent) : QObject(parent) { QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection); QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo); QObject::connect(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; }); - QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks); - QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::DBCFileChanged, this, &AbstractStream::updateMasks); + QObject::connect(dbcNotifier(), &QtDBCNotifier::maskUpdated, this, &AbstractStream::updateMasks); } void AbstractStream::updateMasks() { @@ -233,18 +234,18 @@ std::pair AbstractStream::eventsInRange(const Messag namespace { enum Color { GREYISH_BLUE, CYAN, RED}; -QColor getColor(int c) { +CabanaColor getColor(int c) { constexpr int start_alpha = 128; - static const QColor colors[] = { - [GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2), - [CYAN] = QColor(0, 187, 255, start_alpha), - [RED] = QColor(255, 0, 0, start_alpha), + static const CabanaColor colors[] = { + [GREYISH_BLUE] = CabanaColor(102, 86, 169, start_alpha / 2), + [CYAN] = CabanaColor(0, 187, 255, start_alpha), + [RED] = CabanaColor(255, 0, 0, start_alpha), }; return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135); } -inline QColor blend(const QColor &a, const QColor &b) { - return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); +inline CabanaColor blend(const CabanaColor &a, const CabanaColor &b) { + return CabanaColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2); } // Calculate the frequency from the past one minute data @@ -271,7 +272,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in if (dat.size() != size) { dat.assign(can_data, can_data + size); - colors.assign(size, QColor(0, 0, 0, 0)); + colors.assign(size, CabanaColor(0, 0, 0, 0)); last_changes.resize(size); bit_flip_counts.resize(size); std::for_each(last_changes.begin(), last_changes.end(), [current_sec](auto &c) { c.ts = current_sec; }); @@ -317,7 +318,7 @@ void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const in last_change.delta = delta; } else { // Fade out - colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta)); + colors[i].setAlphaF(std::max(0.0f, colors[i].alphaF() - alpha_delta)); } } } diff --git a/tools/cabana/streams/abstractstream.h b/tools/cabana/streams/abstractstream.h index f35b19d..dbbf451 100644 --- a/tools/cabana/streams/abstractstream.h +++ b/tools/cabana/streams/abstractstream.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -11,51 +12,12 @@ #include #include -#include -#include - #include "cereal/messaging/messaging.h" +#include "tools/cabana/core/can_data.h" #include "tools/cabana/dbc/dbcmanager.h" #include "tools/cabana/utils/util.h" #include "tools/replay/util.h" -struct CanData { - void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec, - double playback_speed, const std::vector &mask, double in_freq = 0); - - double ts = 0.; - uint32_t count = 0; - double freq = 0; - std::vector dat; - std::vector colors; - - struct ByteLastChange { - double ts = 0; - int delta = 0; - int same_delta_counter = 0; - bool suppressed = false; - }; - std::vector last_changes; - std::vector> bit_flip_counts; - double last_freq_update_ts = 0; -}; - -struct CanEvent { - uint8_t src; - uint32_t address; - uint64_t mono_time; - uint8_t size; - uint8_t dat[]; -}; - -struct CompareCanEvent { - constexpr bool operator()(const CanEvent *const e, uint64_t ts) const { return e->mono_time < ts; } - constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; } -}; - -typedef std::unordered_map> MessageEventsMap; -using CanEventIter = std::vector::const_iterator; - class AbstractStream : public QObject { Q_OBJECT @@ -65,9 +27,9 @@ public: virtual void start() = 0; virtual bool liveStreaming() const { return true; } virtual void seekTo(double ts) {} - virtual QString routeName() const = 0; - virtual QString carFingerprint() const { return ""; } - virtual QDateTime beginDateTime() const { return {}; } + virtual std::string routeName() const = 0; + virtual std::string carFingerprint() const { return ""; } + virtual std::chrono::system_clock::time_point beginDateTime() const { return {}; } virtual uint64_t beginMonoTime() const { return 0; } virtual double minSeconds() const { return 0; } virtual double maxSeconds() const { return 0; } @@ -113,12 +75,12 @@ protected: const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c); void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size); void waitForSeekFinshed(); + virtual void updateLastMessages(); std::vector all_events_; double current_sec_ = 0; std::optional> time_range_; private: - void updateLastMessages(); void updateLastMsgsTo(double sec); void updateMasks(); @@ -149,7 +111,7 @@ class DummyStream : public AbstractStream { Q_OBJECT public: DummyStream(QObject *parent) : AbstractStream(parent) {} - QString routeName() const override { return tr("No Stream"); } + std::string routeName() const override { return "No Stream"; } void start() override {} }; diff --git a/tools/cabana/streams/devicestream.cc b/tools/cabana/streams/devicestream.cc index 462dd7a..3529101 100644 --- a/tools/cabana/streams/devicestream.cc +++ b/tools/cabana/streams/devicestream.cc @@ -1,34 +1,123 @@ #include "tools/cabana/streams/devicestream.h" +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include "cereal/services.h" #include #include +#include #include -#include -#include -#include + +#include "tools/cabana/utils/util.h" // DeviceStream -DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) { +DeviceStream::DeviceStream(QObject *parent, Mode mode, QString address) + : mode_(mode), address_(address.isEmpty() ? "127.0.0.1" : address), LiveStream(parent) { +} + +DeviceStream::~DeviceStream() { + stop(); + stopBridge(); +} + +void DeviceStream::stopBridge() { + if (bridge_pid <= 0) return; + + ::kill(bridge_pid, SIGTERM); + for (int i = 0; i < 30; ++i) { + int status = 0; + pid_t r = ::waitpid(bridge_pid, &status, WNOHANG); + if (r == bridge_pid || (r < 0 && errno == ECHILD)) { + bridge_pid = -1; + return; + } + usleep(100000); // 100ms, up to ~3s + } + ::kill(bridge_pid, SIGKILL); + ::waitpid(bridge_pid, nullptr, 0); + bridge_pid = -1; +} + +void DeviceStream::start() { + if (mode_ == Mode::Bridge) { + stopBridge(); + const std::string path = (std::filesystem::path(QCoreApplication::applicationDirPath().toStdString()) / + "../../cereal/messaging/bridge").lexically_normal().string(); + const std::string addr = address_.toStdString(); + const char *can_filter = "/\"can/\""; + + // Self-pipe: write end is CLOEXEC so it closes on successful exec. If exec + // fails, the child writes errno and the parent aborts stream start. + int err_pipe[2] = {-1, -1}; + if (::pipe(err_pipe) != 0) { + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + return; + } + + pid_t pid = ::fork(); + if (pid == 0) { + ::close(err_pipe[0]); + ::fcntl(err_pipe[1], F_SETFD, FD_CLOEXEC); + execl(path.c_str(), path.c_str(), addr.c_str(), can_filter, static_cast(nullptr)); + const int err = errno; + (void)!::write(err_pipe[1], &err, sizeof(err)); + _exit(127); + } + + ::close(err_pipe[1]); + if (pid < 0) { + ::close(err_pipe[0]); + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(errno)))); + return; + } + + int exec_errno = 0; + const ssize_t n = ::read(err_pipe[0], &exec_errno, sizeof(exec_errno)); + ::close(err_pipe[0]); + if (n == static_cast(sizeof(exec_errno))) { + // Child failed to exec; reap and surface the error. + int status = 0; + ::waitpid(pid, &status, 0); + QMessageBox::warning(nullptr, tr("Error"), + tr("Failed to start bridge: %1").arg(QString::fromLocal8Bit(strerror(exec_errno)))); + return; + } + + bridge_pid = pid; + } + + LiveStream::start(); } void DeviceStream::streamThread() { - zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1); + // Bridge mode republishes into local msgq, so only the direct Zmq mode talks ZMQ. + // (Upstream sets ZMQ=1 for its bridge path too, which reads nothing — the bridge + // publishes to msgq.) + mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ"); + const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1"; std::unique_ptr context(Context::create()); - std::string address = zmq_address.isEmpty() ? "127.0.0.1" : zmq_address.toStdString(); std::unique_ptr sock(SubSocket::create(context.get(), "can", address, false, true, services.at("can").queue_size)); assert(sock != NULL); // run as fast as messages come in - while (!QThread::currentThread()->isInterruptionRequested()) { + while (!exit_) { std::unique_ptr msg(sock->receive(true)); if (!msg) { - QThread::msleep(50); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); continue; } handleEvent(kj::ArrayPtr((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word))); @@ -40,28 +129,30 @@ void DeviceStream::streamThread() { OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { QRadioButton *msgq = new QRadioButton(tr("MSGQ")); QRadioButton *zmq = new QRadioButton(tr("ZMQ")); + QRadioButton *bridge = new QRadioButton(tr("Bridge")); + zmq->setToolTip(tr("Subscribe directly to a ZMQ 'can' publisher: a device running " + "cereal/messaging/bridge, or konn3kt_canproxy.py on 127.0.0.1.")); + bridge->setToolTip(tr("Run cereal/messaging/bridge locally against the device and read msgq.")); ip_address = new QLineEdit(this); ip_address->setPlaceholderText(tr("Enter device Ip Address")); - QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])"; - QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$"); - QRegularExpression re(pattern); - ip_address->setValidator(new QRegularExpressionValidator(re, this)); + ip_address->setValidator(new IpAddressValidator(this)); group = new QButtonGroup(this); - group->addButton(msgq, 0); - group->addButton(zmq, 1); + group->addButton(msgq, static_cast(DeviceStream::Mode::Msgq)); + group->addButton(zmq, static_cast(DeviceStream::Mode::Zmq)); + group->addButton(bridge, static_cast(DeviceStream::Mode::Bridge)); QFormLayout *form_layout = new QFormLayout(this); form_layout->addRow(msgq); form_layout->addRow(zmq, ip_address); + form_layout->addRow(bridge); QObject::connect(group, qOverload(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) { - ip_address->setEnabled(button == zmq && checked); + if (checked) ip_address->setEnabled(button != msgq); }); zmq->setChecked(true); } AbstractStream *OpenDeviceWidget::open() { - QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text(); - bool msgq = group->checkedId() == 0; - return new DeviceStream(qApp, msgq ? "" : ip); + auto mode = static_cast(group->checkedId()); + return new DeviceStream(qApp, mode, mode == DeviceStream::Mode::Msgq ? "" : ip_address->text()); } diff --git a/tools/cabana/streams/devicestream.h b/tools/cabana/streams/devicestream.h index 3bdf224..4c87147 100644 --- a/tools/cabana/streams/devicestream.h +++ b/tools/cabana/streams/devicestream.h @@ -2,17 +2,37 @@ #include "tools/cabana/streams/livestream.h" +#include + +// IQ.Pilot patch: upstream (#38484) folded the ZMQ path into "fork a local bridge and +// read msgq". iqpilot needs the direct ZMQ attach kept as a first-class mode, because +// tools/cabana/konn3kt_canproxy.py publishes a remote device's CAN onto a LOCAL ZMQ +// "can" socket and Cabana attaches to it — see that script's header for the topology. +// So the mode is explicit rather than inferred from whether an address was entered: +// +// Msgq - local msgq, no address (cabana running on the device) +// Zmq - ZMQ subscribe straight to
(konn3kt_canproxy, or `bridge` on the device) +// Bridge - fork cereal/messaging/bridge
, (upstream's convenience path) +// which ZMQ-subscribes there and republishes +// to local msgq, then read msgq class DeviceStream : public LiveStream { Q_OBJECT public: - DeviceStream(QObject *parent, QString address = {}); - inline QString routeName() const override { - return QString("Live Streaming From %1").arg(zmq_address.isEmpty() ? "127.0.0.1" : zmq_address); + enum class Mode { Msgq, Zmq, Bridge }; + + DeviceStream(QObject *parent, Mode mode = Mode::Msgq, QString address = {}); + ~DeviceStream(); + inline std::string routeName() const override { + return "Live Streaming From " + address_.toStdString(); } protected: + void start() override; void streamThread() override; - const QString zmq_address; + void stopBridge(); + pid_t bridge_pid = -1; + const Mode mode_; + const QString address_; }; class OpenDeviceWidget : public AbstractOpenStreamWidget { diff --git a/tools/cabana/streams/livestream.cc b/tools/cabana/streams/livestream.cc index ac9a6fa..019a67e 100644 --- a/tools/cabana/streams/livestream.cc +++ b/tools/cabana/streams/livestream.cc @@ -1,9 +1,11 @@ #include "tools/cabana/streams/livestream.h" -#include #include +#include #include +#include #include +#include #include "common/timing.h" #include "common/util.h" @@ -14,9 +16,14 @@ struct LiveStream::Logger { void write(kj::ArrayPtr data) { int n = (seconds_since_epoch() - start_ts) / 60.0; if (std::exchange(segment_num, n) != segment_num) { + const time_t start_time = start_ts; + std::tm local_time = {}; + localtime_r(&start_time, &local_time); + std::ostringstream date; + date << std::put_time(&local_time, "%Y-%m-%d--%H-%M-%S"); QString dir = QString("%1/%2--%3") - .arg(settings.log_path) - .arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss")) + .arg(QString::fromStdString(settings.log_path)) + .arg(QString::fromStdString(date.str())) .arg(n); util::create_directories(dir.toStdString(), 0755); fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out)); @@ -35,37 +42,34 @@ LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) { if (settings.log_livestream) { logger = std::make_unique(); } - stream_thread = new QThread(this); - - QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer); - QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); }); - QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater); } LiveStream::~LiveStream() { stop(); } -void LiveStream::startUpdateTimer() { - update_timer.stop(); - update_timer.start(1000.0 / settings.fps, this); - timer_id = update_timer.timerId(); -} - void LiveStream::start() { - stream_thread->start(); - startUpdateTimer(); - begin_date_time = QDateTime::currentDateTime(); + begin_date_time = std::chrono::system_clock::now(); + fps_ = settings.fps; + exit_ = false; + stream_thread = std::thread(&LiveStream::streamThread, this); + update_thread = std::thread(&LiveStream::updateThread, this); } void LiveStream::stop() { - if (!stream_thread) return; + exit_ = true; + if (stream_thread.joinable()) stream_thread.join(); + if (update_thread.joinable()) update_thread.join(); +} - update_timer.stop(); - stream_thread->requestInterruption(); - stream_thread->quit(); - stream_thread->wait(); - stream_thread = nullptr; +void LiveStream::updateThread() { + while (!exit_) { + std::this_thread::sleep_for(std::chrono::milliseconds(1000 / fps_)); + // coalesce: skip the emit if the main thread hasn't processed the previous one yet. + if (!update_pending_.exchange(true)) { + emit privateUpdateLastMsgsSignal(); + } + } } // called in streamThread @@ -85,23 +89,22 @@ void LiveStream::handleEvent(kj::ArrayPtr data) { } } -void LiveStream::timerEvent(QTimerEvent *event) { - if (event->timerId() == timer_id) { - { - // merge events received from live stream thread. - std::lock_guard lk(lock); - mergeEvents(received_events_); - uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; - lastest_event_ts = std::max(lastest_event_ts, last_received_ts); - received_events_.clear(); - } - if (!all_events_.empty()) { - begin_event_ts = all_events_.front()->mono_time; - updateEvents(); - return; - } +// called on the main thread by the queued privateUpdateLastMsgsSignal connection +void LiveStream::updateLastMessages() { + update_pending_ = false; + fps_ = settings.fps; + { + // merge events received from live stream thread. + std::lock_guard lk(lock); + mergeEvents(received_events_); + uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0; + lastest_event_ts = std::max(lastest_event_ts, last_received_ts); + received_events_.clear(); + } + if (!all_events_.empty()) { + begin_event_ts = all_events_.front()->mono_time; + updateEvents(); } - QObject::timerEvent(event); } void LiveStream::updateEvents() { @@ -131,7 +134,7 @@ void LiveStream::updateEvents() { updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size); current_event_ts = e->mono_time; } - emit privateUpdateLastMsgsSignal(); + AbstractStream::updateLastMessages(); } void LiveStream::seekTo(double sec) { diff --git a/tools/cabana/streams/livestream.h b/tools/cabana/streams/livestream.h index 24b9285..5d65b17 100644 --- a/tools/cabana/streams/livestream.h +++ b/tools/cabana/streams/livestream.h @@ -1,11 +1,11 @@ #pragma once #include +#include #include +#include #include -#include - #include "tools/cabana/streams/abstractstream.h" class LiveStream : public AbstractStream { @@ -16,7 +16,7 @@ public: virtual ~LiveStream(); void start() override; void stop(); - inline QDateTime beginDateTime() const { return begin_date_time; } + inline std::chrono::system_clock::time_point beginDateTime() const override { return begin_date_time; } inline uint64_t beginMonoTime() const override { return begin_event_ts; } double maxSeconds() const override { return std::max(1.0, (lastest_event_ts - begin_event_ts) / 1e9); } void setSpeed(float speed) override { speed_ = speed; } @@ -29,19 +29,20 @@ protected: virtual void streamThread() = 0; void handleEvent(kj::ArrayPtr event); + std::atomic exit_ = false; + private: - void startUpdateTimer(); - void timerEvent(QTimerEvent *event) override; + void updateThread(); + void updateLastMessages() override; void updateEvents(); std::mutex lock; - QThread *stream_thread; + std::thread stream_thread, update_thread; + std::atomic update_pending_ = false; + std::atomic fps_ = 10; std::vector received_events_; - int timer_id; - QBasicTimer update_timer; - - QDateTime begin_date_time; + std::chrono::system_clock::time_point begin_date_time; uint64_t begin_event_ts = 0; uint64_t lastest_event_ts = 0; uint64_t current_event_ts = 0; diff --git a/tools/cabana/streams/pandastream.cc b/tools/cabana/streams/pandastream.cc index 03854ee..7ccb18a 100644 --- a/tools/cabana/streams/pandastream.cc +++ b/tools/cabana/streams/pandastream.cc @@ -1,11 +1,13 @@ #include "tools/cabana/streams/pandastream.h" -#include +#include +#include +#include + #include #include #include #include -#include #include PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) { @@ -16,34 +18,48 @@ PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(co bool PandaStream::connect() { try { - qDebug() << "Connecting to panda " << config.serial; - panda.reset(new Panda(config.serial.toStdString(), 0, true)); + fprintf(stderr, "Connecting to panda %s\n", config.serial.c_str()); + panda.reset(new Panda(config.serial)); config.bus_config.resize(3); - qDebug() << "Connected"; + fprintf(stderr, "Connected\n"); } catch (const std::exception& e) { return false; } + panda->set_safety_model(cereal::CarParams::SafetyModel::NO_OUTPUT); + for (int bus = 0; bus < config.bus_config.size(); bus++) { + panda->set_can_speed_kbps(bus, config.bus_config[bus].can_speed_kbps); + + // CAN-FD + if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) { + if (config.bus_config[bus].can_fd) { + panda->set_data_speed_kbps(bus, config.bus_config[bus].data_speed_kbps); + } else { + // Hack to disable can-fd by setting data speed to a low value + panda->set_data_speed_kbps(bus, 10); + } + } + } return true; } void PandaStream::streamThread() { std::vector raw_can_data; - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + while (!exit_) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); if (!panda->connected()) { - qDebug() << "Connection to panda lost. Attempting reconnect."; + fprintf(stderr, "Connection to panda lost. Attempting reconnect.\n"); if (!connect()){ - QThread::msleep(1000); + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); continue; } } raw_can_data.clear(); if (!panda->can_receive(raw_can_data)) { - qDebug() << "failed to receive"; + fprintf(stderr, "failed to receive\n"); continue; } @@ -58,6 +74,7 @@ void PandaStream::streamThread() { handleEvent(capnp::messageToFlatArray(msg)); + panda->send_heartbeat(false); } } @@ -66,7 +83,7 @@ void PandaStream::streamThread() { OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) { form_layout = new QFormLayout(this); if (can && dynamic_cast(can) != nullptr) { - form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(can->routeName()))); + form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(QString::fromStdString(can->routeName())))); form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda.")); QTimer::singleShot(0, [this]() { emit enableOpenButton(false); }); return; @@ -105,16 +122,16 @@ void OpenPandaWidget::buildConfigForm() { bool has_panda = !serial.isEmpty(); if (has_panda) { try { - Panda panda(serial.toStdString(), 0, true); + Panda panda(serial.toStdString()); has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2); } catch (const std::exception& e) { - qDebug() << "failed to open panda" << serial; + fprintf(stderr, "failed to open panda %s\n", serial.toUtf8().constData()); has_panda = false; } } if (has_panda) { - config.serial = serial; + config.serial = serial.toStdString(); config.bus_config.resize(3); for (int i = 0; i < config.bus_config.size(); i++) { QHBoxLayout *bus_layout = new QHBoxLayout; diff --git a/tools/cabana/streams/pandastream.h b/tools/cabana/streams/pandastream.h index e17ad88..f8847f6 100644 --- a/tools/cabana/streams/pandastream.h +++ b/tools/cabana/streams/pandastream.h @@ -19,7 +19,7 @@ struct BusConfig { }; struct PandaStreamConfig { - QString serial = ""; + std::string serial = ""; std::vector bus_config; }; @@ -28,8 +28,8 @@ class PandaStream : public LiveStream { public: PandaStream(QObject *parent, PandaStreamConfig config_ = {}); ~PandaStream() { stop(); } - inline QString routeName() const override { - return QString("Panda: %1").arg(config.serial); + inline std::string routeName() const override { + return "Panda: " + config.serial; } protected: diff --git a/tools/cabana/streams/replaystream.cc b/tools/cabana/streams/replaystream.cc index b8cf1be..dbd44b5 100644 --- a/tools/cabana/streams/replaystream.cc +++ b/tools/cabana/streams/replaystream.cc @@ -1,5 +1,7 @@ #include "tools/cabana/streams/replaystream.h" +#include + #include #include #include @@ -14,10 +16,7 @@ ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) { unsetenv("ZMQ"); setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1); - // TODO: Remove when OpenpilotPrefix supports ZMQ -#ifndef __APPLE__ op_prefix = std::make_unique(); -#endif QObject::connect(&settings, &Settings::changed, this, [this]() { if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes); @@ -46,9 +45,9 @@ void ReplayStream::mergeSegments() { } } -bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags, bool auto_source) { - replay.reset(new Replay(route.toStdString(), {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, - {}, nullptr, replay_flags, data_dir.toStdString(), auto_source)); +bool ReplayStream::loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags, bool auto_source) { + replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"}, + {}, nullptr, replay_flags, data_dir, auto_source)); replay->setSegmentCacheLimit(settings.max_cached_minutes); replay->installEventFilter([this](const Event *event) { return eventFilter(event); }); @@ -68,21 +67,21 @@ bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint QString message; if (auth_content.empty()) { message = "Authentication Required. Please run the following command to authenticate:\n\n" - "python3 tools/lib/auth.py\n\n" + "python3 openpilot/tools/lib/auth.py\n\n" "This will grant access to routes from your comma account."; } else { message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n" - "This is likely a private route.").arg(route); + "This is likely a private route.").arg(QString::fromStdString(route)); } QMessageBox::warning(nullptr, tr("Access Denied"), message); } else if (replay->lastRouteError() == RouteLoadError::NetworkError) { QMessageBox::warning(nullptr, tr("Network Error"), - tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(route)); + tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(QString::fromStdString(route))); } else if (replay->lastRouteError() == RouteLoadError::FileNotFound) { QMessageBox::warning(nullptr, tr("Route Not Found"), - tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(route)); + tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(QString::fromStdString(route))); } else { - QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(route)); + QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(QString::fromStdString(route))); } } return success; @@ -136,10 +135,10 @@ OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(p setMinimumWidth(550); QObject::connect(browse_local_btn, &QPushButton::clicked, [=]() { - QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir); + QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), QString::fromStdString(settings.last_route_dir)); if (!dir.isEmpty()) { route_edit->setText(dir); - settings.last_route_dir = QFileInfo(dir).absolutePath(); + settings.last_route_dir = std::filesystem::absolute(dir.toStdString()).parent_path().string(); } }); QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() { @@ -168,7 +167,7 @@ AbstractStream *OpenReplayWidget::open() { if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM; if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC; - if (replay_stream->loadRoute(route, data_dir, flags)) { + if (replay_stream->loadRoute(route.toStdString(), data_dir.toStdString(), flags)) { return replay_stream.release(); } } diff --git a/tools/cabana/streams/replaystream.h b/tools/cabana/streams/replaystream.h index d429ed1..eecd345 100644 --- a/tools/cabana/streams/replaystream.h +++ b/tools/cabana/streams/replaystream.h @@ -18,15 +18,17 @@ class ReplayStream : public AbstractStream { public: ReplayStream(QObject *parent); void start() override { replay->start(); } - bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); + bool loadRoute(const std::string &route, const std::string &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false); bool eventFilter(const Event *event); void seekTo(double ts) override { replay->seekTo(std::max(double(0), ts), false); } bool liveStreaming() const override { return false; } - inline QString routeName() const override { return QString::fromStdString(replay->route().name()); } - inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); } + inline std::string routeName() const override { return replay->route().name(); } + inline std::string carFingerprint() const override { return replay->carFingerprint(); } double minSeconds() const override { return replay->minSeconds(); } double maxSeconds() const { return replay->maxSeconds(); } - inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); } + inline std::chrono::system_clock::time_point beginDateTime() const override { + return std::chrono::system_clock::from_time_t(replay->routeDateTime()); + } inline uint64_t beginMonoTime() const override { return replay->routeStartNanos(); } inline void setSpeed(float speed) override { replay->setSpeed(speed); } inline float getSpeed() const { return replay->getSpeed(); } diff --git a/tools/cabana/streams/routes.cc b/tools/cabana/streams/routes.cc index 5915c98..9770399 100644 --- a/tools/cabana/streams/routes.cc +++ b/tools/cabana/streams/routes.cc @@ -1,27 +1,77 @@ #include "tools/cabana/streams/routes.h" -#include +#include +#include +#include +#include +#include + +#include #include #include -#include -#include #include #include #include -class OneShotHttpRequest : public HttpRequest { -public: - OneShotHttpRequest(QObject *parent) : HttpRequest(parent, false) {} - void send(const QString &url) { - if (reply) { - reply->disconnect(); - reply->abort(); - reply->deleteLater(); - reply = nullptr; - } - sendRequest(url); +#include "third_party/json11/json11.hpp" +// IQ.Pilot patch: iqpilot's tools/replay has no py_downloader — device and route +// listing come from the konn3kt API over libcurl. CommaApi2 returns the same JSON +// shapes and the same {"error": ...} envelope upstream's PyDownloader produces, so +// only the call sites change. +#include "tools/replay/api.h" + +namespace { + +// Parse a konn3kt API JSON response into (success, error_code). +std::pair checkApiResponse(const std::string &result) { + if (result.empty()) return {false, 500}; + std::string err; + auto doc = json11::Json::parse(result, err); + if (!err.empty()) return {false, 500}; + if (doc.is_object() && doc["error"].is_string()) { + return {false, doc["error"].string_value() == "unauthorized" ? 401 : 500}; } -}; + return {true, 0}; +} + +int64_t nowUnixMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +// Parse ISO-8601 (with optional fractional seconds / Z) to unix ms. Returns 0 on failure. +int64_t parseIsoToUnixMs(const std::string &s) { + std::string bytes = s; + if (!bytes.empty() && (bytes.back() == 'Z' || bytes.back() == 'z')) bytes.pop_back(); + int millis = 0; + auto dot = bytes.find('.'); + if (dot != std::string::npos) { + std::string frac = bytes.substr(dot + 1); + bytes = bytes.substr(0, dot); + while (frac.size() < 3) frac.push_back('0'); + millis = std::atoi(frac.substr(0, 3).c_str()); + } + std::tm tm{}; + const char *ret = strptime(bytes.c_str(), "%Y-%m-%dT%H:%M:%S", &tm); + if (!ret) ret = strptime(bytes.c_str(), "%Y-%m-%d %H:%M:%S", &tm); + if (!ret) return 0; + tm.tm_isdst = -1; + time_t secs = timegm(&tm); + if (secs == static_cast(-1)) return 0; + return static_cast(secs) * 1000 + millis; +} + +QString formatUnixMs(int64_t ms) { + time_t secs = static_cast(ms / 1000); + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + return QString::fromUtf8(buf); +} + +} // namespace // The RouteListWidget class extends QListWidget to display a custom message when empty class RouteListWidget : public QListWidget { @@ -41,7 +91,7 @@ public: QString empty_text_ = tr("No items"); }; -RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent), route_requester_(new OneShotHttpRequest(this)) { +RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Remote routes")); QFormLayout *layout = new QFormLayout(this); @@ -52,41 +102,42 @@ RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent), route_requester_( layout->addRow(button_box); device_list_->addItem(tr("Loading...")); - // Populate period selector with predefined durations period_selector_->addItem(tr("Last week"), 7); period_selector_->addItem(tr("Last 2 weeks"), 14); period_selector_->addItem(tr("Last month"), 30); period_selector_->addItem(tr("Last 6 months"), 180); period_selector_->addItem(tr("Preserved"), -1); - // Connect signals and slots - QObject::connect(route_requester_, &HttpRequest::requestDone, this, &RoutesDialog::parseRouteList); connect(device_list_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); connect(period_selector_, QOverload::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes); connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept); - QObject::connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); - QObject::connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject); - // Send request to fetch devices - HttpRequest *http = new HttpRequest(this, false); - QObject::connect(http, &HttpRequest::requestDone, this, &RoutesDialog::parseDeviceList); - http->sendRequest(CommaApi::BASE_URL + "/v1/me/devices/"); + // Fetch devices + std::thread([this, alive = std::weak_ptr(alive_)]() { + std::string result = CommaApi2::getDevices(); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result)]() { + if (!alive.expired()) parseDeviceList(r, response.first, response.second); + }, Qt::QueuedConnection); + }).detach(); } -void RoutesDialog::parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err) { +void RoutesDialog::parseDeviceList(const QString &json, bool success, int error_code) { if (success) { device_list_->clear(); - auto devices = QJsonDocument::fromJson(json.toUtf8()).array(); - for (const QJsonValue &device : devices) { - QString dongle_id = device["dongle_id"].toString(); - device_list_->addItem(dongle_id, dongle_id); + std::string err; + auto doc = json11::Json::parse(json.toStdString(), err); + if (err.empty() && doc.is_array()) { + for (const auto &device : doc.array_items()) { + QString dongle_id = QString::fromStdString(device["dongle_id"].string_value()); + device_list_->addItem(dongle_id, dongle_id); + } } } else { - bool unauthorized = (err == QNetworkReply::ContentAccessDenied || err == QNetworkReply::AuthenticationRequiredError); - QMessageBox::warning(this, tr("Error"), unauthorized ? tr("Unauthorized, Authenticate with tools/lib/auth.py") : tr("Network error")); + QMessageBox::warning(this, tr("Error"), error_code == 401 ? tr("Unauthorized. Authenticate with tools/lib/auth.py") : tr("Network error")); reject(); } - sender()->deleteLater(); } void RoutesDialog::fetchRoutes() { @@ -95,34 +146,45 @@ void RoutesDialog::fetchRoutes() { route_list_->clear(); route_list_->setEmptyText(tr("Loading...")); - // Construct URL with selected device and date range - QString url = QString("%1/v1/devices/%2").arg(CommaApi::BASE_URL, device_list_->currentText()); + + std::string did = device_list_->currentText().toStdString(); int period = period_selector_->currentData().toInt(); - if (period == -1) { - url += "/routes/preserved"; - } else { - QDateTime now = QDateTime::currentDateTime(); - url += QString("/routes_segments?start=%1&end=%2") - .arg(now.addDays(-period).toMSecsSinceEpoch()) - .arg(now.toMSecsSinceEpoch()); + + bool preserved = (period == -1); + int64_t start_ms = 0, end_ms = 0; + if (!preserved) { + end_ms = nowUnixMs(); + start_ms = end_ms - static_cast(period) * 24LL * 60LL * 60LL * 1000LL; } - route_requester_->send(url); + + int request_id = ++fetch_id_; + std::thread([this, alive = std::weak_ptr(alive_), did, start_ms, end_ms, preserved, request_id]() { + std::string result = CommaApi2::getDeviceRoutes(did, start_ms, end_ms, preserved); + QMetaObject::invokeMethod(qApp, [this, alive, r = QString::fromStdString(result), response = checkApiResponse(result), request_id]() { + if (!alive.expired() && fetch_id_ == request_id) parseRouteList(r, response.first, response.second); + }, Qt::QueuedConnection); + }).detach(); } -void RoutesDialog::parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err) { +void RoutesDialog::parseRouteList(const QString &json, bool success, int error_code) { if (success) { - for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) { - QDateTime from, to; - if (period_selector_->currentData().toInt() == -1) { - from = QDateTime::fromString(route["start_time"].toString(), Qt::ISODateWithMs); - to = QDateTime::fromString(route["end_time"].toString(), Qt::ISODateWithMs); - } else { - from = QDateTime::fromMSecsSinceEpoch(route["start_time_utc_millis"].toDouble()); - to = QDateTime::fromMSecsSinceEpoch(route["end_time_utc_millis"].toDouble()); + std::string err; + auto doc = json11::Json::parse(json.toStdString(), err); + if (err.empty() && doc.is_array()) { + for (const auto &route : doc.array_items()) { + int64_t from_ms = 0, to_ms = 0; + if (period_selector_->currentData().toInt() == -1) { + from_ms = parseIsoToUnixMs(route["start_time"].string_value()); + to_ms = parseIsoToUnixMs(route["end_time"].string_value()); + } else { + from_ms = static_cast(route["start_time_utc_millis"].number_value()); + to_ms = static_cast(route["end_time_utc_millis"].number_value()); + } + const int mins = static_cast((to_ms - from_ms) / 60000); + auto item = new QListWidgetItem(QString("%1 %2min").arg(formatUnixMs(from_ms)).arg(mins)); + item->setData(Qt::UserRole, QString::fromStdString(route["fullname"].string_value())); + route_list_->addItem(item); } - auto item = new QListWidgetItem(QString("%1 %2min").arg(from.toString()).arg(from.secsTo(to) / 60)); - item->setData(Qt::UserRole, route["fullname"].toString()); - route_list_->addItem(item); } if (route_list_->count() > 0) route_list_->setCurrentRow(0); } else { diff --git a/tools/cabana/streams/routes.h b/tools/cabana/streams/routes.h index 045dc67..6ed1456 100644 --- a/tools/cabana/streams/routes.h +++ b/tools/cabana/streams/routes.h @@ -1,11 +1,12 @@ #pragma once +#include +#include + #include #include -#include "tools/cabana/utils/api.h" class RouteListWidget; -class OneShotHttpRequest; class RoutesDialog : public QDialog { Q_OBJECT @@ -14,12 +15,14 @@ public: QString route(); protected: - void parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err); - void parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err); + void parseDeviceList(const QString &json, bool success, int error_code); + void parseRouteList(const QString &json, bool success, int error_code); void fetchRoutes(); QComboBox *device_list_; QComboBox *period_selector_; RouteListWidget *route_list_; - OneShotHttpRequest *route_requester_; + std::atomic fetch_id_{0}; + // expires on destruction; guards main-thread callbacks from detached worker threads + std::shared_ptr alive_ = std::make_shared(true); }; diff --git a/tools/cabana/streams/socketcanstream.cc b/tools/cabana/streams/socketcanstream.cc index e4801df..b616e7f 100644 --- a/tools/cabana/streams/socketcanstream.cc +++ b/tools/cabana/streams/socketcanstream.cc @@ -1,67 +1,99 @@ #include "tools/cabana/streams/socketcanstream.h" -#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + #include #include #include #include -#include SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) { if (!available()) { - throw std::runtime_error("SocketCAN plugin not available"); + throw std::runtime_error("SocketCAN not available"); } - qDebug() << "Connecting to SocketCAN device" << config.device; + fprintf(stderr, "Connecting to SocketCAN device %s\n", config.device.c_str()); if (!connect()) { throw std::runtime_error("Failed to connect to SocketCAN device"); } } +SocketCanStream::~SocketCanStream() { + stop(); + if (sock_fd >= 0) { + ::close(sock_fd); + sock_fd = -1; + } +} + bool SocketCanStream::available() { - return QCanBus::instance()->plugins().contains("socketcan"); + int fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (fd < 0) return false; + ::close(fd); + return true; } bool SocketCanStream::connect() { - // Connecting might generate some warnings about missing socketcan/libsocketcan libraries - // These are expected and can be ignored, we don't need the advanced features of libsocketcan - QString errorString; - device.reset(QCanBus::instance()->createDevice("socketcan", config.device, &errorString)); - device->setConfigurationParameter(QCanBusDevice::CanFdKey, true); - - if (!device) { - qDebug() << "Failed to create SocketCAN device" << errorString; + sock_fd = socket(PF_CAN, SOCK_RAW, CAN_RAW); + if (sock_fd < 0) { + fprintf(stderr, "Failed to create CAN socket\n"); return false; } - if (!device->connectDevice()) { - qDebug() << "Failed to connect to device"; + // Enable CAN-FD + int fd_enable = 1; + setsockopt(sock_fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &fd_enable, sizeof(fd_enable)); + + struct ifreq ifr = {}; + strncpy(ifr.ifr_name, config.device.c_str(), IFNAMSIZ - 1); + if (ioctl(sock_fd, SIOCGIFINDEX, &ifr) < 0) { + fprintf(stderr, "Failed to get interface index for %s\n", config.device.c_str()); + ::close(sock_fd); + sock_fd = -1; return false; } + struct sockaddr_can addr = {}; + addr.can_family = AF_CAN; + addr.can_ifindex = ifr.ifr_ifindex; + if (bind(sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + fprintf(stderr, "Failed to bind CAN socket\n"); + ::close(sock_fd); + sock_fd = -1; + return false; + } + + // Set read timeout so the thread can check for interruption + struct timeval tv = {.tv_sec = 0, .tv_usec = 100000}; // 100ms + setsockopt(sock_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + return true; } void SocketCanStream::streamThread() { - while (!QThread::currentThread()->isInterruptionRequested()) { - QThread::msleep(1); + struct canfd_frame frame; - auto frames = device->readAllFrames(); - if (frames.size() == 0) continue; + while (!exit_) { + ssize_t nbytes = read(sock_fd, &frame, sizeof(frame)); + if (nbytes <= 0) continue; + + uint8_t len = (nbytes == CAN_MTU) ? frame.len : frame.len; // works for both CAN and CAN-FD MessageBuilder msg; auto evt = msg.initEvent(); - auto canData = evt.initCan(frames.size()); - - for (uint i = 0; i < frames.size(); i++) { - if (!frames[i].isValid()) continue; - - canData[i].setAddress(frames[i].frameId()); - canData[i].setSrc(0); - - auto payload = frames[i].payload(); - canData[i].setDat(kj::arrayPtr((uint8_t*)payload.data(), payload.size())); - } + auto canData = evt.initCan(1); + canData[0].setAddress(frame.can_id & CAN_EFF_MASK); + canData[0].setSrc(0); + canData[0].setDat(kj::arrayPtr(frame.data, len)); handleEvent(capnp::messageToFlatArray(msg)); } @@ -87,7 +119,7 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi main_layout->addStretch(1); QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices); - QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText(); }); + QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText().toStdString(); }); // Populate devices refreshDevices(); @@ -95,12 +127,17 @@ OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWi void OpenSocketCanWidget::refreshDevices() { device_edit->clear(); - for (auto device : QCanBus::instance()->availableDevices(QStringLiteral("socketcan"))) { - device_edit->addItem(device.name()); + // Scan /sys/class/net/ for CAN interfaces (type 280 = ARPHRD_CAN) + std::error_code ec; + for (const auto &entry : std::filesystem::directory_iterator("/sys/class/net", ec)) { + std::ifstream type_file(entry.path() / "type"); + int type = 0; + if (type_file >> type && type == 280) { + device_edit->addItem(QString::fromStdString(entry.path().filename().string())); + } } } - AbstractStream *OpenSocketCanWidget::open() { try { return new SocketCanStream(qApp, config); diff --git a/tools/cabana/streams/socketcanstream.h b/tools/cabana/streams/socketcanstream.h index 8083b68..3c5cd18 100644 --- a/tools/cabana/streams/socketcanstream.h +++ b/tools/cabana/streams/socketcanstream.h @@ -1,27 +1,22 @@ #pragma once -#include - -#include -#include -#include #include #include "tools/cabana/streams/livestream.h" struct SocketCanStreamConfig { - QString device = ""; // TODO: support multiple devices/buses at once + std::string device = ""; // TODO: support multiple devices/buses at once }; class SocketCanStream : public LiveStream { Q_OBJECT public: SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {}); - ~SocketCanStream() { stop(); } + ~SocketCanStream(); static bool available(); - inline QString routeName() const override { - return QString("Live Streaming From Socket CAN %1").arg(config.device); + inline std::string routeName() const override { + return "Live Streaming From Socket CAN " + config.device; } protected: @@ -29,7 +24,7 @@ protected: bool connect(); SocketCanStreamConfig config = {}; - std::unique_ptr device; + int sock_fd = -1; }; class OpenSocketCanWidget : public AbstractOpenStreamWidget { diff --git a/tools/cabana/streamselector.cc b/tools/cabana/streamselector.cc index efd00d3..7e8adc5 100644 --- a/tools/cabana/streamselector.cc +++ b/tools/cabana/streamselector.cc @@ -1,14 +1,17 @@ #include "tools/cabana/streamselector.h" +#include + #include #include #include -#include "streams/socketcanstream.h" #include "tools/cabana/streams/devicestream.h" #include "tools/cabana/streams/pandastream.h" #include "tools/cabana/streams/replaystream.h" +#ifdef __linux__ #include "tools/cabana/streams/socketcanstream.h" +#endif StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Open stream")); @@ -35,9 +38,11 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { addStreamWidget(new OpenReplayWidget, tr("&Replay")); addStreamWidget(new OpenPandaWidget, tr("&Panda")); +#ifdef __linux__ if (SocketCanStream::available()) { addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN")); } +#endif addStreamWidget(new OpenDeviceWidget, tr("&Device")); QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject); @@ -49,10 +54,10 @@ StreamSelector::StreamSelector(QWidget *parent) : QDialog(parent) { setEnabled(true); }); QObject::connect(file_btn, &QPushButton::clicked, [this]() { - QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)"); + QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), QString::fromStdString(settings.last_dir), "DBC (*.dbc)"); if (!fn.isEmpty()) { dbc_file->setText(fn); - settings.last_dir = QFileInfo(fn).absolutePath(); + settings.last_dir = std::filesystem::absolute(fn.toStdString()).parent_path().string(); } }); } diff --git a/tools/cabana/test_cabana_konn3kt.py b/tools/cabana/test_cabana_konn3kt.py new file mode 100644 index 0000000..552154b --- /dev/null +++ b/tools/cabana/test_cabana_konn3kt.py @@ -0,0 +1,103 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +CABANA_DIR = Path(__file__).parent +CABANA_BIN = CABANA_DIR / "_cabana" + +pytestmark = pytest.mark.skipif(not CABANA_BIN.exists(), + reason="cabana not built (scons -u tools/cabana/_cabana)") + + +def read(name): + return (CABANA_DIR / name).read_text() + + +class TestCabanaBinary: + def test_help(self): + result = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr + + def test_help_documents_both_live_can_modes(self): + # --zmq attaches directly (konn3kt_canproxy); --bridge is upstream's local-bridge path + out = subprocess.run([str(CABANA_BIN), "--help"], capture_output=True, text=True, timeout=60).stderr + assert "--zmq" in out + assert "--bridge" in out + assert "konn3kt_canproxy.py" in out + + def test_launcher_builds_the_right_targets(self): + launcher = read("cabana") + # iqpilot is not nested under openpilot/ + assert "scons -u tools/cabana/_cabana cereal/messaging/bridge" in launcher + assert "openpilot/tools/cabana" not in launcher + + +class TestKonn3ktIntegration: + def test_routes_dialog_uses_konn3kt_api(self): + # upstream shells into tools/lib via PyDownloader; iqpilot goes through CommaApi2 + routes = read("streams/routes.cc") + assert "PyDownloader::" not in routes + assert "py_downloader.h" not in routes + assert "CommaApi2::getDevices()" in routes + assert "CommaApi2::getDeviceRoutes(" in routes + + def test_no_duplicate_qt_api_client(self): + # the old Qt HttpRequest/JWT client was folded into tools/replay/api.cc + assert not (CABANA_DIR / "utils" / "api.cc").exists() + assert not (CABANA_DIR / "utils" / "api.h").exists() + + def test_no_comma_endpoints(self): + for name in ("streams/routes.cc", "cabana.cc", "README.md"): + body = read(name) + assert "connect.comma.ai" not in body, name + assert "api.comma.ai" not in body, name + + def test_dbc_menu_points_at_iqdbc(self): + mainwin = read("mainwin.cc") + assert "commaai/iqdbc" in mainwin + assert "commaai/opendbc" not in mainwin + + def test_dbc_json_generator_imports_iqdbc(self): + gen = read("dbc/generate_dbc_json.py") + assert "from iqdbc.car" in gen + assert "from opendbc.car" not in gen + + def test_generated_dbc_json_covers_iqdbc_platforms(self): + # built by scons; cabana reads it to auto-select a DBC per car fingerprint + path = CABANA_DIR / "dbc" / "car_fingerprint_to_dbc.json" + assert path.is_file(), "run scons -u tools/cabana" + mapping = json.loads(path.read_text()) + assert len(mapping) > 100 + assert all(isinstance(v, str) and v for v in mapping.values()) + + def test_canproxy_targets_konn3kt(self): + proxy = read("konn3kt_canproxy.py") + assert "konn3kt" in proxy + # the proxy publishes on a local ZMQ 'can' socket that --zmq attaches to + assert 'os.environ["ZMQ"] = "1"' in proxy + + +class TestDeviceStreamModes: + """The ZMQ attach is what konn3kt_canproxy feeds; upstream #38484 replaced it with a + bridge fork. Both must exist, and only the direct attach may set ZMQ=1.""" + + def test_three_modes_exist(self): + header = read("streams/devicestream.h") + assert "enum class Mode { Msgq, Zmq, Bridge };" in header + + def test_only_zmq_mode_talks_zmq(self): + src = read("streams/devicestream.cc") + assert 'mode_ == Mode::Zmq ? setenv("ZMQ", "1", 1) : unsetenv("ZMQ")' in src + + def test_zmq_mode_subscribes_to_the_given_address(self): + # regression guard: upstream hardcodes 127.0.0.1 and relies on the bridge fork, + # which never reads the canproxy publisher + src = read("streams/devicestream.cc") + assert 'const std::string address = mode_ == Mode::Zmq ? address_.toStdString() : "127.0.0.1";' in src + + def test_only_bridge_mode_forks_the_bridge(self): + src = read("streams/devicestream.cc") + assert "if (mode_ == Mode::Bridge) {" in src diff --git a/tools/cabana/tests/test_cabana.cc b/tools/cabana/tests/test_cabana.cc index 48a250b..184b70a 100644 --- a/tools/cabana/tests/test_cabana.cc +++ b/tools/cabana/tests/test_cabana.cc @@ -1,14 +1,15 @@ -#undef INFO -#include +#include +#include -#include "catch2/catch.hpp" +#include "common/tests/native_test.h" +#include "tools/cabana/dbc/dbcfile.h" #include "tools/cabana/dbc/dbcmanager.h" const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2"; -TEST_CASE("DBCFile::generateDBC") { - QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can"); +void test_generate_dbc() { + std::string fn = std::string(OPENDBC_FILE_PATH) + "/tesla_can.dbc"; DBCFile dbc_origin(fn); DBCFile dbc_from_generated("", dbc_origin.generateDBC()); @@ -28,9 +29,9 @@ TEST_CASE("DBCFile::generateDBC") { } } -TEST_CASE("DBCFile::generateDBC - comment order") { +void test_comment_order() { // Ensure that message comments are followed by signal comments and in the correct order - auto content = R"(BO_ 160 message_1: 8 EON + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX BO_ 162 message_2: 8 EON @@ -45,8 +46,8 @@ CM_ SG_ 162 signal_2 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC -- preserve original header") { - QString content = R"(VERSION "1.0" +void test_preserve_original_header() { + std::string content = R"(VERSION "1.0" NS_ : CM_ @@ -65,8 +66,8 @@ CM_ SG_ 160 signal_1 "signal comment"; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("DBCFile::generateDBC - escaped quotes") { - QString content = R"(BO_ 160 message_1: 8 EON +void test_escaped_quotes() { + std::string content = R"(BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX CM_ BO_ 160 "message comment with \"escaped quotes\""; @@ -76,8 +77,8 @@ CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(dbc.generateDBC() == content); } -TEST_CASE("parse_dbc") { - QString content = R"( +void test_parse_dbc() { + std::string content = R"( BO_ 160 message_1: 8 EON SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX SG_ signal_2 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX @@ -119,9 +120,9 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(sig_1->comment == "signal comment"); REQUIRE(sig_1->receiver_name == "XXX"); REQUIRE(sig_1->val_desc.size() == 3); - REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); - REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); - REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); + REQUIRE(sig_1->val_desc[0] == std::pair{0, "disabled"}); + REQUIRE(sig_1->val_desc[1] == std::pair{1.2, "initializing"}); + REQUIRE(sig_1->val_desc[2] == std::pair{2, "fault"}); auto &sig_2 = msg->sigs[1]; REQUIRE(sig_2->comment == "multiple line comment \n1\n2"); @@ -142,16 +143,66 @@ CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\""; REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\""); } -TEST_CASE("parse_iqdbc") { - QDir dir(OPENDBC_FILE_PATH); - QStringList errors; - for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) { +// OPENDBC_FILE_PATH points at iqdbc/dbc here — this is the load-bearing check that +// cabana can still parse every DBC iqpilot ships. +void test_parse_iqdbc() { + std::vector errors; + int parsed = 0; + for (const auto &entry : std::filesystem::directory_iterator(OPENDBC_FILE_PATH)) { + if (!entry.is_regular_file() || entry.path().extension() != ".dbc") continue; try { - auto dbc = DBCFile(dir.filePath(fn)); + auto dbc = DBCFile(entry.path().string()); + ++parsed; } catch (std::exception &e) { errors.push_back(e.what()); } } - INFO(errors.join("\n").toStdString()); + // guard against OPENDBC_FILE_PATH pointing somewhere empty, which would make the + // loop above pass vacuously + REQUIRE(parsed > 100); + std::ostringstream details; + for (const auto &error : errors) details << error << '\n'; + if (!errors.empty()) std::cerr << details.str(); REQUIRE(errors.empty()); } + +void test_dbc_manager() { + DBCManager manager; + int files_changed = 0; + int signals_added = 0; + int masks_updated = 0; + manager.setCallbacks({ + .signal_added = [&](MessageId, const cabana::Signal *) { ++signals_added; }, + .file_changed = [&]() { ++files_changed; }, + .mask_updated = [&]() { ++masks_updated; }, + }); + + std::string error; + REQUIRE(manager.open(SOURCE_ALL, "test", "BO_ 160 message: 8 XXX\n", &error)); + REQUIRE(error.empty()); + REQUIRE(files_changed == 1); + + cabana::Signal signal{}; + signal.name = "speed"; + signal.start_bit = 0; + signal.size = 8; + signal.is_little_endian = true; + manager.addSignal({.source = 0, .address = 160}, signal); + REQUIRE(signals_added == 1); + REQUIRE(masks_updated == 1); + REQUIRE(manager.msg({.source = 0, .address = 160})->sig("speed") != nullptr); +} + +void test_cabana_core() { + test_generate_dbc(); + test_comment_order(); + test_preserve_original_header(); + test_escaped_quotes(); + test_parse_dbc(); + test_parse_iqdbc(); + test_dbc_manager(); +} + +int main() { + return run_native_test(test_cabana_core); +} diff --git a/tools/cabana/tests/test_runner.cc b/tools/cabana/tests/test_runner.cc deleted file mode 100644 index b20ac86..0000000 --- a/tools/cabana/tests/test_runner.cc +++ /dev/null @@ -1,10 +0,0 @@ -#define CATCH_CONFIG_RUNNER -#include "catch2/catch.hpp" -#include - -int main(int argc, char **argv) { - // unit tests for Qt - QCoreApplication app(argc, argv); - const int res = Catch::Session().run(argc, argv); - return (res < 0xff ? res : 0xff); -} diff --git a/tools/cabana/tools/findsignal.cc b/tools/cabana/tools/findsignal.cc index ec56fca..4511af7 100644 --- a/tools/cabana/tools/findsignal.cc +++ b/tools/cabana/tools/findsignal.cc @@ -1,10 +1,12 @@ #include "tools/cabana/tools/findsignal.h" +#include +#include + #include #include #include #include -#include #include #include @@ -20,7 +22,7 @@ QVariant FindSignalModel::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole) { const auto &s = filtered_signals[index.row()]; switch (index.column()) { - case 0: return s.id.toString(); + case 0: return QString::fromStdString(s.id.toString()); case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size); case 2: return s.values.join(" "); } @@ -32,36 +34,49 @@ void FindSignalModel::search(std::function cmp) { beginResetModel(); std::mutex lock; - const auto prev_sigs = !histories.isEmpty() ? histories.back() : initial_signals; + const auto prev_sigs = !histories.empty() ? histories.back() : initial_signals; filtered_signals.clear(); filtered_signals.reserve(prev_sigs.size()); - QtConcurrent::blockingMap(prev_sigs, [&](auto &s) { - const auto &events = can->events(s.id); - auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); - auto last = events.cend(); - if (last_time < std::numeric_limits::max()) { - last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); - } - auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); - if (it != last) { - auto values = s.values; - values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); - std::lock_guard lk(lock); - filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); - } - }); + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (prev_sigs.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < (size_t)prev_sigs.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, (size_t)prev_sigs.size()); + threads.emplace_back([&, start, end]() { + for (size_t i = start; i < end; ++i) { + const auto &s = prev_sigs[i]; + const auto &events = can->events(s.id); + auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent()); + auto last = events.cend(); + if (last_time < std::numeric_limits::max()) { + last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent()); + } + + auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); }); + if (it != last) { + auto values = s.values; + values += QString("(%1, %2)").arg(can->toSeconds((*it)->mono_time), 0, 'f', 3).arg(get_raw_value((*it)->dat, (*it)->size, s.sig)); + std::lock_guard lk(lock); + filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values}); + } + } + }); + } + for (auto &th : threads) th.join(); + histories.push_back(filtered_signals); endResetModel(); } void FindSignalModel::undo() { - if (!histories.isEmpty()) { + if (!histories.empty()) { beginResetModel(); histories.pop_back(); filtered_signals.clear(); - if (!histories.isEmpty()) filtered_signals = histories.back(); + if (!histories.empty()) filtered_signals = histories.back(); endResetModel(); } } @@ -84,9 +99,9 @@ FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags( message_group = new QGroupBox(tr("Messages"), this); QFormLayout *message_layout = new QFormLayout(message_group); message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit()); - bus_edit->setPlaceholderText(tr("comma-seperated values. Leave blank for all")); + bus_edit->setPlaceholderText(tr("comma-separated values. Leave blank for all")); message_layout->addRow(tr("Address"), address_edit = new QLineEdit()); - address_edit->setPlaceholderText(tr("comma-seperated hex values. Leave blank for all")); + address_edit->setPlaceholderText(tr("comma-separated hex values. Leave blank for all")); QHBoxLayout *hlayout = new QHBoxLayout(); hlayout->addWidget(first_time_edit = new QLineEdit("0")); hlayout->addWidget(new QLabel("-")); @@ -172,7 +187,7 @@ FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags( } void FindSignalDlg::search() { - if (model->histories.isEmpty()) { + if (model->histories.empty()) { setInitialSignals(); } auto v1 = value1->text().toDouble(); @@ -196,13 +211,13 @@ void FindSignalDlg::search() { } void FindSignalDlg::setInitialSignals() { - QSet buses; + std::set buses; for (auto bus : bus_edit->text().trimmed().split(",")) { bus = bus.trimmed(); if (!bus.isEmpty()) buses.insert(bus.toUShort()); } - QSet addresses; + std::set addresses; for (auto addr : address_edit->text().trimmed().split(",")) { addr = addr.trimmed(); if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16)); @@ -225,7 +240,7 @@ void FindSignalDlg::setInitialSignals() { model->initial_signals.clear(); for (const auto &[id, m] : can->lastMessages()) { - if ((buses.isEmpty() || buses.contains(id.source)) && (addresses.isEmpty() || addresses.contains(id.address))) { + if ((buses.empty() || buses.count(id.source)) && (addresses.empty() || addresses.count(id.address))) { const auto &events = can->events(id); auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent()); if (e != events.cend()) { @@ -246,12 +261,12 @@ void FindSignalDlg::setInitialSignals() { } void FindSignalDlg::modelReset() { - properties_group->setEnabled(model->histories.isEmpty()); - message_group->setEnabled(model->histories.isEmpty()); - search_btn->setText(model->histories.isEmpty() ? tr("Find") : tr("Find Next")); - reset_btn->setEnabled(!model->histories.isEmpty()); + properties_group->setEnabled(model->histories.empty()); + message_group->setEnabled(model->histories.empty()); + search_btn->setText(model->histories.empty() ? tr("Find") : tr("Find Next")); + reset_btn->setEnabled(!model->histories.empty()); undo_btn->setEnabled(model->histories.size() > 1); - search_btn->setEnabled(model->rowCount() > 0 || model->histories.isEmpty()); + search_btn->setEnabled(model->rowCount() > 0 || model->histories.empty()); stats_label->setVisible(true); stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size())); } @@ -262,7 +277,7 @@ void FindSignalDlg::customMenuRequested(const QPoint &pos) { menu.addAction(tr("Create Signal")); if (menu.exec(view->mapToGlobal(pos))) { auto &s = model->filtered_signals[index.row()]; - UndoStack::push(new AddSigCommand(s.id, s.sig)); + UndoStack::instance()->push(new AddSigCommand(s.id, s.sig)); emit openMessage(s.id); } } diff --git a/tools/cabana/tools/findsignal.h b/tools/cabana/tools/findsignal.h index 5ef7461..239a08c 100644 --- a/tools/cabana/tools/findsignal.h +++ b/tools/cabana/tools/findsignal.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -26,14 +28,14 @@ public: QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; } - int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min(filtered_signals.size(), 300); } + int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min((int)filtered_signals.size(), 300); } void search(std::function cmp); void reset(); void undo(); - QList filtered_signals; - QList initial_signals; - QList> histories; + std::vector filtered_signals; + std::vector initial_signals; + std::vector> histories; uint64_t last_time = std::numeric_limits::max(); }; diff --git a/tools/cabana/tools/findsimilarbits.cc b/tools/cabana/tools/findsimilarbits.cc index c3c6597..8062b61 100644 --- a/tools/cabana/tools/findsimilarbits.cc +++ b/tools/cabana/tools/findsimilarbits.cc @@ -1,6 +1,7 @@ #include "tools/cabana/tools/findsimilarbits.h" #include +#include #include #include @@ -31,7 +32,7 @@ FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::Wi msg_cb = new QComboBox(this); // TODO: update when src_bus_combo changes for (auto &[address, msg] : dbc()->getMessages(-1)) { - msg_cb->addItem(msg.name, address); + msg_cb->addItem(QString::fromStdString(msg.name), address); } msg_cb->model()->sort(0); msg_cb->setCurrentIndex(0); @@ -114,10 +115,10 @@ void FindSimilarBitsDlg::find() { search_btn->setEnabled(true); } -QList FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, - int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { - QHash> mismatches; - QHash msg_count; +std::vector FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, + int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) { + std::unordered_map> mismatches; + std::unordered_map msg_count; const auto &events = can->allEvents(); int bit_to_find = -1; for (const CanEvent *e : events) { @@ -143,14 +144,14 @@ QList FindSimilarBitsDlg::calcBits(uint8_ } } - QList result; + std::vector result; result.reserve(mismatches.size()); for (auto it = mismatches.begin(); it != mismatches.end(); ++it) { - if (auto cnt = msg_count[it.key()]; cnt > min_msgs_cnt) { - auto &mismatched = it.value(); - for (int i = 0; i < mismatched.size(); ++i) { + if (auto cnt = msg_count[it->first]; cnt > (uint32_t)min_msgs_cnt) { + auto &mismatched = it->second; + for (int i = 0; i < (int)mismatched.size(); ++i) { if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) { - result.push_back({it.key(), (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); + result.push_back({it->first, (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc}); } } } diff --git a/tools/cabana/tools/findsimilarbits.h b/tools/cabana/tools/findsimilarbits.h index 77bfac1..3451360 100644 --- a/tools/cabana/tools/findsimilarbits.h +++ b/tools/cabana/tools/findsimilarbits.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -22,7 +24,7 @@ private: uint32_t address, byte_idx, bit_idx, mismatches, total; float perc; }; - QList calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, + std::vector calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt); void find(); diff --git a/tools/cabana/utils/api.cc b/tools/cabana/utils/api.cc deleted file mode 100644 index 89379a8..0000000 --- a/tools/cabana/utils/api.cc +++ /dev/null @@ -1,171 +0,0 @@ -#include "tools/cabana/utils/api.h" - -#include -#include - -#include -#include -#include -#include -#include -#include - -#include -#include - -#include "common/params.h" -#include "common/util.h" -#include "system/hardware/hw.h" -#include "tools/cabana/utils/util.h" - -QString getVersion() { - static QString version = QString::fromStdString(Params().get("Version")); - return version; -} - -QString getUserAgent() { - return "openpilot-" + getVersion(); -} - -std::optional getDongleId() { - std::string id = Params().get("DongleId"); - - if (!id.empty() && (id != "UnregisteredDevice")) { - return QString::fromStdString(id); - } else { - return {}; - } -} - -namespace CommaApi { - -EVP_PKEY *get_private_key() { - static std::unique_ptr pkey(nullptr, EVP_PKEY_free); - if (!pkey) { - FILE *fp = fopen(Path::rsa_file().c_str(), "rb"); - if (!fp) { - qDebug() << "No private key found, please run manager.py or registration.py"; - return nullptr; - } - pkey.reset(PEM_read_PrivateKey(fp, nullptr, nullptr, nullptr)); - fclose(fp); - } - return pkey.get(); -} - -QByteArray rsa_sign(const QByteArray &data) { - EVP_PKEY *pkey = get_private_key(); - if (!pkey) return {}; - - EVP_MD_CTX *mdctx = EVP_MD_CTX_new(); - if (!mdctx) return {}; - - QByteArray sig(EVP_PKEY_size(pkey), Qt::Uninitialized); - size_t sig_len = sig.size(); - - int ret = EVP_DigestSignInit(mdctx, nullptr, EVP_sha256(), nullptr, pkey); - ret &= EVP_DigestSignUpdate(mdctx, data.data(), data.size()); - ret &= EVP_DigestSignFinal(mdctx, (unsigned char*)sig.data(), &sig_len); - - EVP_MD_CTX_free(mdctx); - - if (ret != 1) return {}; - sig.resize(sig_len); - return sig; -} - -QString create_jwt(const QJsonObject &payloads, int expiry) { - QJsonObject header = {{"alg", "RS256"}}; - - auto t = QDateTime::currentSecsSinceEpoch(); - QJsonObject payload = {{"identity", getDongleId().value_or("")}, {"nbf", t}, {"iat", t}, {"exp", t + expiry}}; - for (auto it = payloads.begin(); it != payloads.end(); ++it) { - payload.insert(it.key(), it.value()); - } - - auto b64_opts = QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals; - QString jwt = QJsonDocument(header).toJson(QJsonDocument::Compact).toBase64(b64_opts) + '.' + - QJsonDocument(payload).toJson(QJsonDocument::Compact).toBase64(b64_opts); - - auto hash = QCryptographicHash::hash(jwt.toUtf8(), QCryptographicHash::Sha256); - return jwt + "." + rsa_sign(hash).toBase64(b64_opts); -} - -} // namespace CommaApi - -HttpRequest::HttpRequest(QObject *parent, bool create_jwt, int timeout) : create_jwt(create_jwt), QObject(parent) { - networkTimer = new QTimer(this); - networkTimer->setSingleShot(true); - networkTimer->setInterval(timeout); - connect(networkTimer, &QTimer::timeout, this, &HttpRequest::requestTimeout); -} - -bool HttpRequest::active() const { - return reply != nullptr; -} - -bool HttpRequest::timeout() const { - return reply && reply->error() == QNetworkReply::OperationCanceledError; -} - -void HttpRequest::sendRequest(const QString &requestURL, const HttpRequest::Method method) { - if (active()) { - qDebug() << "HttpRequest is active"; - return; - } - QString token; - if (create_jwt) { - token = CommaApi::create_jwt(); - } else { - QString token_json = QString::fromStdString(util::read_file(util::getenv("HOME") + "/.comma/auth.json")); - QJsonDocument json_d = QJsonDocument::fromJson(token_json.toUtf8()); - token = json_d["access_token"].toString(); - } - - QNetworkRequest request; - request.setUrl(QUrl(requestURL)); - request.setRawHeader("User-Agent", getUserAgent().toUtf8()); - - if (!token.isEmpty()) { - request.setRawHeader(QByteArray("Authorization"), ("JWT " + token).toUtf8()); - } - - if (method == HttpRequest::Method::GET) { - reply = nam()->get(request); - } else if (method == HttpRequest::Method::DELETE) { - reply = nam()->deleteResource(request); - } - - networkTimer->start(); - connect(reply, &QNetworkReply::finished, this, &HttpRequest::requestFinished); -} - -void HttpRequest::requestTimeout() { - reply->abort(); -} - -void HttpRequest::requestFinished() { - networkTimer->stop(); - - if (reply->error() == QNetworkReply::NoError) { - emit requestDone(reply->readAll(), true, reply->error()); - } else { - QString error; - if (reply->error() == QNetworkReply::OperationCanceledError) { - nam()->clearAccessCache(); - nam()->clearConnectionCache(); - error = "Request timed out"; - } else { - error = reply->errorString(); - } - emit requestDone(error, false, reply->error()); - } - - reply->deleteLater(); - reply = nullptr; -} - -QNetworkAccessManager *HttpRequest::nam() { - static QNetworkAccessManager *networkAccessManager = new QNetworkAccessManager(qApp); - return networkAccessManager; -} diff --git a/tools/cabana/utils/api.h b/tools/cabana/utils/api.h deleted file mode 100644 index 1507a1b..0000000 --- a/tools/cabana/utils/api.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "common/util.h" - -namespace CommaApi { - -const QString BASE_URL = util::getenv("API_HOST", "https://api-iqlabs.konn3kt.com").c_str(); -QByteArray rsa_sign(const QByteArray &data); -QString create_jwt(const QJsonObject &payloads = {}, int expiry = 3600); - -} // namespace CommaApi - -/** - * Makes a request to the request endpoint. - */ - -class HttpRequest : public QObject { - Q_OBJECT - -public: - enum class Method {GET, DELETE}; - - explicit HttpRequest(QObject* parent, bool create_jwt = true, int timeout = 20000); - void sendRequest(const QString &requestURL, const Method method = Method::GET); - bool active() const; - bool timeout() const; - -signals: - void requestDone(const QString &response, bool success, QNetworkReply::NetworkError error); - -protected: - QNetworkReply *reply = nullptr; - -private: - static QNetworkAccessManager *nam(); - QTimer *networkTimer = nullptr; - bool create_jwt; - -private slots: - void requestTimeout(); - void requestFinished(); -}; diff --git a/tools/cabana/utils/export.cc b/tools/cabana/utils/export.cc index 79ca97b..d585827 100644 --- a/tools/cabana/utils/export.cc +++ b/tools/cabana/utils/export.cc @@ -1,41 +1,41 @@ #include "tools/cabana/utils/export.h" -#include -#include +#include +#include #include "tools/cabana/streams/abstractstream.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id) { - QFile file(file_name); - if (file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportToCSV(const std::string &file_name, std::optional msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (stream) { stream << "time,addr,bus,data\n"; for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src << "," - << "0x" << QByteArray::fromRawData((const char *)e->dat, e->size).toHex().toUpper() << "\n"; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src) << ",0x" + << std::uppercase << std::hex << std::setfill('0'); + for (int i = 0; i < e->size; ++i) stream << std::setw(2) << static_cast(e->dat[i]); + stream << std::nouppercase << std::dec << "\n"; } } } -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) { - QFile file(file_name); - if (auto msg = dbc()->msg(msg_id); msg && msg->sigs.size() && file.open(QIODevice::ReadWrite | QIODevice::Truncate)) { - QTextStream stream(&file); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id) { + std::ofstream stream(file_name, std::ios::trunc); + if (auto msg = dbc()->msg(msg_id); msg && !msg->sigs.empty() && stream) { stream << "time,addr,bus"; for (auto s : msg->sigs) - stream << "," << s->name; + stream << "," << s->name.c_str(); stream << "\n"; for (auto e : can->events(msg_id)) { - stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << "," - << "0x" << QString::number(e->address, 16) << "," << e->src; + stream << std::fixed << std::setprecision(3) << can->toSeconds(e->mono_time) << "," + << "0x" << std::hex << e->address << std::dec << "," << static_cast(e->src); for (auto s : msg->sigs) { double value = 0; s->getValue(e->dat, e->size, &value); - stream << "," << QString::number(value, 'f', s->precision); + stream << "," << std::fixed << std::setprecision(s->precision) << value; } stream << "\n"; } diff --git a/tools/cabana/utils/export.h b/tools/cabana/utils/export.h index 270906b..ee110e8 100644 --- a/tools/cabana/utils/export.h +++ b/tools/cabana/utils/export.h @@ -1,10 +1,11 @@ #pragma once #include +#include #include "tools/cabana/dbc/dbcmanager.h" namespace utils { -void exportToCSV(const QString &file_name, std::optional msg_id = std::nullopt); -void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id); +void exportToCSV(const std::string &file_name, std::optional msg_id = std::nullopt); +void exportSignalsToCSV(const std::string &file_name, const MessageId &msg_id); } // namespace utils diff --git a/tools/cabana/utils/util.cc b/tools/cabana/utils/util.cc index ca069b3..d989a85 100644 --- a/tools/cabana/utils/util.cc +++ b/tools/cabana/utils/util.cc @@ -1,24 +1,26 @@ #include "tools/cabana/utils/util.h" #include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include #include +#include #include #include -#include -#include #include -#include #include -#include -#include #include -#include -#include +#include #include "common/util.h" // SegmentTree @@ -101,7 +103,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & // Paint hex column const auto &bytes = *static_cast *>(data.value()); - const auto &colors = *static_cast *>(index.data(ColorsRole).value()); + const auto &colors = *static_cast *>(index.data(ColorsRole).value()); painter->setFont(fixed_font); const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color); @@ -116,7 +118,7 @@ void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem & painter->setPen(option.palette.color(QPalette::Text)); painter->fillRect(r, option.palette.color(QPalette::Window)); } - painter->fillRect(r, colors[i]); + painter->fillRect(r, toQColor(colors[i])); } else { painter->setPen(text_pen); } @@ -149,54 +151,211 @@ void TabBar::closeTabClicked() { // UnixSignalHandler -UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) { +UnixSignalHandler::UnixSignalHandler() { if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) { qFatal("Couldn't create TERM socketpair"); } - sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this); - connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm); + waiter = std::thread([this]() { + int tmp = 0; + while (::read(sig_fd[1], &tmp, sizeof(tmp)) < 0) { + if (errno != EINTR) return; + } + if (shutting_down.load()) return; + + // Marshal exit onto the GUI thread (qApp methods are not thread-safe). + QMetaObject::invokeMethod(qApp, []() { + printf("\nexiting...\n"); + qApp->closeAllWindows(); + qApp->exit(); + }, Qt::QueuedConnection); + }); + std::signal(SIGINT, signalHandler); std::signal(SIGTERM, UnixSignalHandler::signalHandler); } UnixSignalHandler::~UnixSignalHandler() { + shutting_down.store(true); + int dummy = 0; + (void)!::write(sig_fd[0], &dummy, sizeof(dummy)); + if (waiter.joinable()) waiter.join(); ::close(sig_fd[0]); ::close(sig_fd[1]); } void UnixSignalHandler::signalHandler(int s) { - ::write(sig_fd[0], &s, sizeof(s)); -} - -void UnixSignalHandler::handleSigTerm() { - sn->setEnabled(false); - int tmp; - ::read(sig_fd[1], &tmp, sizeof(tmp)); - - printf("\nexiting...\n"); - qApp->closeAllWindows(); - qApp->exit(); + (void)!::write(sig_fd[0], &s, sizeof(s)); } // NameValidator -NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {} +NameValidator::NameValidator(QObject *parent) : QValidator(parent) {} QValidator::State NameValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); input.replace(' ', '_'); - return QRegExpValidator::validate(input, pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (!c.isLetterOrNumber() && c != '_') return QValidator::Invalid; + } + return QValidator::Acceptable; } -DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) { - // Match locale of QString::toDouble() instead of system - QLocale locale(QLocale::C); - locale.setNumberOptions(QLocale::RejectGroupSeparator); - setLocale(locale); +// NodeValidator + +NodeValidator::NodeValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State NodeValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + // Match ^\w+(,\w+)*$ ; a trailing comma is Intermediate (user still typing). + bool need_word = true; + for (const QChar &c : input) { + if (c.isLetterOrNumber() || c == '_') { + need_word = false; + } else if (c == ',' && !need_word) { + need_word = true; + } else { + return QValidator::Invalid; + } + } + return need_word ? QValidator::Intermediate : QValidator::Acceptable; +} + +// NonWhitespaceValidator + +NonWhitespaceValidator::NonWhitespaceValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State NonWhitespaceValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + for (const QChar &c : input) { + if (c.isSpace()) return QValidator::Invalid; + } + return QValidator::Acceptable; +} + +// IpAddressValidator + +IpAddressValidator::IpAddressValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State IpAddressValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + + int dots = 0; + int value = 0; + bool has_digit = false; + for (const QChar &c : input) { + if (c.isDigit()) { + value = has_digit ? value * 10 + c.digitValue() : c.digitValue(); + if (value > 255) return QValidator::Invalid; + has_digit = true; + } else if (c == '.') { + if (!has_digit || dots >= 3) return QValidator::Invalid; + ++dots; + has_digit = false; + value = 0; + } else { + return QValidator::Invalid; + } + } + return (dots == 3 && has_digit) ? QValidator::Acceptable : QValidator::Intermediate; +} + +DoubleValidator::DoubleValidator(QObject *parent) : QValidator(parent) {} + +QValidator::State DoubleValidator::validate(QString &input, int &pos) const { + Q_UNUSED(pos); + if (input.isEmpty()) return QValidator::Intermediate; + + // Match QString::toDouble(): C locale, no hex floats / inf / nan. + const std::string bytes = input.toLatin1().toStdString(); + // strtod accepts 0x… hex floats and p-exponents; QString::toDouble does not. + if (bytes.find_first_of("xXpP") != std::string::npos) { + return QValidator::Invalid; + } + + const char *start = bytes.c_str(); + char *end = nullptr; + const double value = std::strtod(start, &end); + if (end == start) { + // Still typing a sign, decimal point, or exponent prefix. + if (input == "-" || input == "+" || input == "." || input == "-." || input == "+.") { + return QValidator::Intermediate; + } + return QValidator::Invalid; + } + if (*end == '\0') { + // Reject inf/nan (strtod accepts them; QDoubleValidator / toDouble path should not). + return std::isfinite(value) ? QValidator::Acceptable : QValidator::Invalid; + } + + // Partial exponent / trailing sign while typing (e.g. "1e", "1e-", "1."). + for (const char *p = end; *p; ++p) { + const char c = *p; + if (!(c == 'e' || c == 'E' || c == '+' || c == '-' || c == '.' || (c >= '0' && c <= '9'))) { + return QValidator::Invalid; + } + } + return QValidator::Intermediate; } namespace utils { +std::string homePath() { + const char *home = ::getenv("HOME"); + return home ? home : ""; +} + +std::filesystem::path configPath() { +#ifdef __APPLE__ + return std::filesystem::path(homePath()) / "Library/Preferences"; +#else + const char *xdg = ::getenv("XDG_CONFIG_HOME"); + return (xdg && xdg[0]) ? std::filesystem::path(xdg) : std::filesystem::path(homePath()) / ".config"; +#endif +} + +#ifdef __APPLE__ +static const char *clipboard_read_cmds[] = {"pbpaste"}; +static const char *clipboard_write_cmds[] = {"pbcopy"}; +#else +static const char *clipboard_read_cmds[] = {"wl-paste --no-newline 2>/dev/null", "xclip -selection clipboard -o 2>/dev/null", "xsel -ob 2>/dev/null"}; +static const char *clipboard_write_cmds[] = {"wl-copy 2>/dev/null", "xclip -selection clipboard 2>/dev/null", "xsel -ib 2>/dev/null"}; +#endif + +bool getClipboardText(std::string *text) { + text->clear(); + bool has_tool = false; + for (const char *cmd : clipboard_read_cmds) { + FILE *f = ::popen(cmd, "r"); + if (!f) continue; + std::string out; + char buf[4096]; + for (size_t n; (n = ::fread(buf, 1, sizeof(buf), f)) > 0;) out.append(buf, n); + int status = ::pclose(f); + if (status == 0) { + *text = std::move(out); + return true; + } + has_tool |= WIFEXITED(status) && WEXITSTATUS(status) != 127; // 127: command not found + } + return has_tool; // tool present but clipboard empty +} + +bool setClipboardText(const std::string &text) { + std::signal(SIGPIPE, SIG_IGN); + for (const char *cmd : clipboard_write_cmds) { + FILE *f = ::popen(cmd, "w"); + if (!f) continue; + size_t written = ::fwrite(text.data(), 1, text.size(), f); + if (::pclose(f) == 0 && written == text.size()) return true; + } + return false; +} + bool isDarkTheme() { QColor windowColor = QApplication::palette().color(QPalette::Window); return windowColor.lightness() < 128; @@ -209,7 +368,10 @@ QPixmap icon(const QString &id) { QString key = "bootstrap_" % id % (dark_theme ? "1" : "0"); if (!QPixmapCache::find(key, &pm)) { pm = bootstrapPixmap(id); - if (dark_theme) { + // IQ.Pilot patch: ToolButton("") (chartswidget.cc) asks for an empty id, so pm can + // be null. Painting a null QPixmap is a no-op that logs two QPainter warnings on + // every dark-theme start. Upstream candidate. + if (dark_theme && !pm.isNull()) { QPainter p(&pm); p.setCompositionMode(QPainter::CompositionMode_SourceIn); p.fillRect(pm.rect(), QColor("#bbbbbb")); @@ -258,10 +420,34 @@ void setTheme(int theme) { } QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) { - QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss" - : (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss"); - if (include_milliseconds) format += ".zzz"; - return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format); + if (absolute_time) { + const auto ms_total = static_cast(std::llround(sec * 1000.0)); + const std::time_t secs = static_cast(ms_total / 1000); + int millis = static_cast(ms_total % 1000); + if (millis < 0) millis = -millis; + std::tm tm{}; + localtime_r(&secs, &tm); + char buf[64]; + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm); + if (include_milliseconds) { + return QString::asprintf("%s.%03d", buf, millis); + } + return QString::fromUtf8(buf); + } + + // Relative duration (not wall-clock). + const bool show_hours = sec > 60 * 60; + int total_ms = static_cast(std::llround(std::max(0.0, sec) * 1000.0)); + const int hours = total_ms / (3600 * 1000); + const int minutes = (total_ms / (60 * 1000)) % 60; + const int seconds = (total_ms / 1000) % 60; + const int millis = total_ms % 1000; + if (show_hours) { + return include_milliseconds ? QString::asprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, millis) + : QString::asprintf("%02d:%02d:%02d", hours, minutes, seconds); + } + return include_milliseconds ? QString::asprintf("%02d:%02d.%03d", minutes, seconds, millis) + : QString::asprintf("%02d:%02d", minutes, seconds); } } // namespace utils @@ -278,24 +464,10 @@ QString signalToolTip(const cabana::Signal *sig) { Start Bit: %2 Size: %3
MSB: %4 LSB: %5
Little Endian: %6 Signed: %7 - )").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) + )").arg(QString::fromStdString(sig->name)).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb) .arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N"); } -void setSurfaceFormat() { - QSurfaceFormat fmt; -#ifdef __APPLE__ - fmt.setVersion(3, 2); - fmt.setProfile(QSurfaceFormat::OpenGLContextProfile::CoreProfile); - fmt.setRenderableType(QSurfaceFormat::OpenGL); -#else - fmt.setRenderableType(QSurfaceFormat::OpenGLES); -#endif - fmt.setSamples(16); - fmt.setStencilBufferSize(1); - QSurfaceFormat::setDefaultFormat(fmt); -} - void sigTermHandler(int s) { std::signal(s, SIG_DFL); qApp->quit(); @@ -306,55 +478,68 @@ void initApp(int argc, char *argv[], bool disable_hidpi) { std::signal(SIGINT, sigTermHandler); std::signal(SIGTERM, sigTermHandler); - QString app_dir; + std::filesystem::path app_dir; #ifdef __APPLE__ // Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering QApplication tmp(argc, argv); - app_dir = QCoreApplication::applicationDirPath(); + app_dir = QCoreApplication::applicationDirPath().toStdString(); if (disable_hidpi) { qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit()); } #else - app_dir = QFileInfo(util::readlink("/proc/self/exe").c_str()).path(); + app_dir = std::filesystem::path(util::readlink("/proc/self/exe")).parent_path(); #endif - qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150)); + qputenv("QT_DBL_CLICK_DIST", "150"); // ensure the current dir matches the exectuable's directory - QDir::setCurrent(app_dir); - - setSurfaceFormat(); + std::error_code ec; + std::filesystem::current_path(app_dir, ec); } -static QHash load_bootstrap_icons() { - QHash icons; +// embedded at build time from the bootstrap_icons package (see SConscript) +extern const unsigned char bootstrap_icons_svg[]; +extern const size_t bootstrap_icons_svg_len; - QFile f(":/bootstrap-icons.svg"); - if (f.open(QIODevice::ReadOnly | QIODevice::Text)) { - QDomDocument xml; - xml.setContent(&f); - QDomNode n = xml.documentElement().firstChild(); - while (!n.isNull()) { - QDomElement e = n.toElement(); - if (!e.isNull() && e.hasAttribute("id")) { - QString svg_str; - QTextStream stream(&svg_str); - n.save(stream, 0); - svg_str.replace("", ""); - icons[e.attribute("id")] = svg_str.toUtf8(); +static std::unordered_map load_bootstrap_icons() { + std::unordered_map icons; + + const std::string content(reinterpret_cast(bootstrap_icons_svg), bootstrap_icons_svg_len); + const std::string sym_open = " with + svg_str.replace(0, 7, " ""); // "" (9) -> "" (6) + icons[id] = std::move(svg_str); } - n = n.nextSibling(); } + pos = end; } return icons; } QPixmap bootstrapPixmap(const QString &id) { - static QHash icons = load_bootstrap_icons(); + static auto icons = load_bootstrap_icons(); QPixmap pixmap; - if (auto it = icons.find(id); it != icons.end()) { - pixmap.loadFromData(it.value(), "svg"); + auto it = icons.find(id.toStdString()); + if (it != icons.end()) { + pixmap.loadFromData((const uchar *)it->second.data(), it->second.size(), "svg"); } return pixmap; } diff --git a/tools/cabana/utils/util.h b/tools/cabana/utils/util.h index f839ffe..5a3c62d 100644 --- a/tools/cabana/utils/util.h +++ b/tools/cabana/utils/util.h @@ -1,26 +1,32 @@ #pragma once #include +#include #include +#include +#include +#include #include #include #include -#include -#include +#include #include #include #include -#include -#include #include #include #include #include +#include #include "tools/cabana/dbc/dbc.h" #include "tools/cabana/settings.h" +inline QColor toQColor(const CabanaColor &color) { + return QColor(color.r, color.g, color.b, color.a); +} + class LogSlider : public QSlider { Q_OBJECT @@ -84,22 +90,53 @@ private: int h_margin, v_margin; }; -class NameValidator : public QRegExpValidator { +// Accepts a single identifier: one or more [A-Za-z0-9_], spaces rewritten to '_'. +class NameValidator : public QValidator { Q_OBJECT public: NameValidator(QObject *parent=nullptr); QValidator::State validate(QString &input, int &pos) const override; }; -class DoubleValidator : public QDoubleValidator { +// Accepts comma-separated identifiers: \w+(,\w+)* +class NodeValidator : public QValidator { + Q_OBJECT +public: + NodeValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// Accepts one or more non-whitespace characters (\S+). +class NonWhitespaceValidator : public QValidator { + Q_OBJECT +public: + NonWhitespaceValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// Accepts a dotted IPv4 address (0-255 per octet). +class IpAddressValidator : public QValidator { + Q_OBJECT +public: + IpAddressValidator(QObject *parent=nullptr); + QValidator::State validate(QString &input, int &pos) const override; +}; + +// C-locale floating-point validator (matches QString::toDouble). +class DoubleValidator : public QValidator { Q_OBJECT public: DoubleValidator(QObject *parent = nullptr); + QValidator::State validate(QString &input, int &pos) const override; }; namespace utils { QPixmap icon(const QString &id); +std::string homePath(); +std::filesystem::path configPath(); +bool getClipboardText(std::string *text); // false if no clipboard tool is available +bool setClipboardText(const std::string &text); bool isDarkTheme(); void setTheme(int theme); QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false); @@ -108,7 +145,22 @@ inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text); } inline QString toHex(const std::vector &dat, char separator = '\0') { - return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper(); + static const char digits[] = "0123456789ABCDEF"; + QString hex; + hex.reserve(dat.size() * (separator ? 3 : 2)); + for (size_t i = 0; i < dat.size(); ++i) { + if (separator && i) hex += QLatin1Char(separator); + hex += QLatin1Char(digits[dat[i] >> 4]); + hex += QLatin1Char(digits[dat[i] & 0xf]); + } + return hex; +} + +// boundary conversions for the remaining Qt byte-array based state APIs +template +std::vector toBytes(const T &dat) { return {dat.begin(), dat.end()}; } +inline auto qbytes(const std::vector &dat) { + return decltype(QString().toUtf8())((const char *)dat.data(), (int)dat.size()); } } @@ -147,20 +199,18 @@ private: void closeTabClicked(); }; -class UnixSignalHandler : public QObject { - Q_OBJECT - +// Watches SIGINT/SIGTERM via a self-pipe and a dedicated waiter thread +// (no Qt notifiers/timers). Exit is marshaled onto the GUI thread. +class UnixSignalHandler { public: - UnixSignalHandler(QObject *parent = nullptr); + UnixSignalHandler(); ~UnixSignalHandler(); static void signalHandler(int s); -public slots: - void handleSigTerm(); - private: inline static int sig_fd[2] = {}; - QSocketNotifier *sn; + std::atomic shutting_down{false}; + std::thread waiter; }; int num_decimals(double num); diff --git a/tools/cabana/videowidget.cc b/tools/cabana/videowidget.cc index 55018f2..67b89cb 100644 --- a/tools/cabana/videowidget.cc +++ b/tools/cabana/videowidget.cc @@ -1,6 +1,7 @@ #include "tools/cabana/videowidget.h" #include +#include #include #include @@ -9,20 +10,20 @@ #include #include #include -#include #include "tools/cabana/tools/routeinfo.h" const int MIN_VIDEO_HEIGHT = 100; const int THUMBNAIL_MARGIN = 3; +// Indexed by TimelineType: None, Engaged, AlertInfo, AlertWarning, AlertCritical, UserBookmark static const QColor timeline_colors[] = { - [(int)TimelineType::None] = QColor(111, 143, 175), - [(int)TimelineType::Engaged] = QColor(0, 163, 108), - [(int)TimelineType::UserBookmark] = Qt::magenta, - [(int)TimelineType::AlertInfo] = Qt::green, - [(int)TimelineType::AlertWarning] = QColor(255, 195, 0), - [(int)TimelineType::AlertCritical] = QColor(199, 0, 57), + QColor(111, 143, 175), + QColor(0, 163, 108), + Qt::green, + QColor(255, 195, 0), + QColor(199, 0, 57), + Qt::magenta, }; static Replay *getReplay() { @@ -50,7 +51,7 @@ VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) { updatePlayBtnState(); setWhatsThis(tr(R"( Video
- + Timeline color @@ -156,7 +157,7 @@ QWidget *VideoWidget::createCameraWidget() { slider->setTimeRange(can->minSeconds(), can->maxSeconds()); QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); }); - QObject::connect(can, &AbstractStream::paused, cam_widget, [c = cam_widget]() { c->showPausedOverlay(); }); + QObject::connect(can, &AbstractStream::paused, cam_widget, qOverload<>(&StreamCameraView::update)); QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); }); QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); }); QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated); @@ -202,7 +203,7 @@ void VideoWidget::timeRangeChanged() { QString VideoWidget::formatTime(double sec, bool include_milliseconds) { if (settings.absolute_time) - sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0; + sec += std::chrono::duration(can->beginDateTime().time_since_epoch()).count(); return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time); } @@ -323,34 +324,40 @@ void Slider::mousePressEvent(QMouseEvent *e) { // StreamCameraView StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent) : CameraWidget(stream_name, stream_type, parent) { - fade_animation = new QPropertyAnimation(this, "overlayOpacity"); - fade_animation->setDuration(500); - fade_animation->setStartValue(0.2f); - fade_animation->setEndValue(0.7f); - fade_animation->setEasingCurve(QEasingCurve::InOutQuad); - connect(fade_animation, &QPropertyAnimation::valueChanged, this, QOverload<>::of(&StreamCameraView::update)); } void StreamCameraView::parseQLog(std::shared_ptr qlog) { std::mutex mutex; - QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [this, &mutex](const Event &e) { - if (e.which == cereal::Event::Which::THUMBNAIL) { - capnp::FlatArrayMessageReader reader(e.data); - auto thumb_data = reader.getRoot().getThumbnail(); - auto image_data = thumb_data.getThumbnail(); - if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { - QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); - std::lock_guard lock(mutex); - thumbnails[thumb_data.getTimestampEof()] = generated_thumb; - big_thumbnails[thumb_data.getTimestampEof()] = thumb; + const auto &events = qlog->events; + unsigned int num_threads = std::max(1u, std::thread::hardware_concurrency()); + size_t chunk = (events.size() + num_threads - 1) / num_threads; + std::vector threads; + for (unsigned int t = 0; t < num_threads && t * chunk < events.size(); ++t) { + size_t start = t * chunk; + size_t end = std::min(start + chunk, events.size()); + threads.emplace_back([this, &mutex, &events, start, end]() { + for (size_t i = start; i < end; ++i) { + const Event &e = events[i]; + if (e.which == cereal::Event::Which::THUMBNAIL) { + capnp::FlatArrayMessageReader reader(e.data); + auto thumb_data = reader.getRoot().getThumbnail(); + auto image_data = thumb_data.getThumbnail(); + if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) { + QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof())); + std::lock_guard lock(mutex); + thumbnails[thumb_data.getTimestampEof()] = generated_thumb; + big_thumbnails[thumb_data.getTimestampEof()] = thumb; + } + } } - } - }); + }); + } + for (auto &th : threads) th.join(); update(); } -void StreamCameraView::paintGL() { - CameraWidget::paintGL(); +void StreamCameraView::paintEvent(QPaintEvent *event) { + CameraWidget::paintEvent(event); QPainter p(this); bool scrubbing = false; @@ -363,7 +370,7 @@ void StreamCameraView::paintGL() { } if (can->isPaused()) { - p.setPen(QColor(200, 200, 200, static_cast(255 * fade_animation->currentValue().toFloat()))); + p.setPen(QColor(200, 200, 200, static_cast(255 * 0.7f))); p.setFont(QFont(font().family(), 16, QFont::Bold)); p.drawText(rect(), Qt::AlignCenter, tr("PAUSED")); } @@ -383,9 +390,9 @@ QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) { void StreamCameraView::drawScrubThumbnail(QPainter &p) { p.fillRect(rect(), Qt::black); - auto it = big_thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = big_thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != big_thumbnails.end()) { - QPixmap scaled_thumb = it.value().scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); + QPixmap scaled_thumb = it->second.scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size()); p.drawPixmap(thumb_rect.topLeft(), scaled_thumb); drawTime(p, thumb_rect, thumbnail_dispaly_time); @@ -393,9 +400,9 @@ void StreamCameraView::drawScrubThumbnail(QPainter &p) { } void StreamCameraView::drawThumbnail(QPainter &p) { - auto it = thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time)); + auto it = thumbnails.lower_bound(can->toMonoTime(thumbnail_dispaly_time)); if (it != thumbnails.end()) { - const QPixmap &thumb = it.value(); + const QPixmap &thumb = it->second; auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds())); int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec); int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1); diff --git a/tools/cabana/videowidget.h b/tools/cabana/videowidget.h index 6da0023..09f7a99 100644 --- a/tools/cabana/videowidget.h +++ b/tools/cabana/videowidget.h @@ -1,12 +1,12 @@ #pragma once +#include #include #include #include #include #include -#include #include #include #include @@ -35,8 +35,7 @@ class StreamCameraView : public CameraWidget { public: StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr); - void paintGL() override; - void showPausedOverlay() { fade_animation->start(); } + void paintEvent(QPaintEvent *event) override; void parseQLog(std::shared_ptr qlog); private: @@ -46,9 +45,8 @@ private: void drawScrubThumbnail(QPainter &p); void drawTime(QPainter &p, const QRect &rect, double seconds); - QPropertyAnimation *fade_animation; - QMap big_thumbnails; - QMap thumbnails; + std::map big_thumbnails; + std::map thumbnails; double thumbnail_dispaly_time = -1; friend class VideoWidget; }; diff --git a/tools/iqmacvisiond/README.md b/tools/iqmacvisiond/README.md new file mode 100644 index 0000000..63dffc3 --- /dev/null +++ b/tools/iqmacvisiond/README.md @@ -0,0 +1,54 @@ +# iqmacvisiond — IQ Vision offload server + +Runs the iqvd perception model on an Apple-Silicon Mac and serves 2D detections +to one IQ device over wifi. The device (`iqvd`) ships the camera frame, the Mac +runs YOLO on the Metal GPU/NPU, and only boxes come back — the device does no +inference, so vision dots no longer contend with the driving model. + +## Wire path + +``` +IQ device (auto-hotspot AP) ──wifi──▶ Mac (IQ Vision.app) + iqvd VisionClient iqmacvisiond server + read frame → downscale 640w cv2 decode → YOLOv8n (Metal) + JPEG encode → INFER ───────────────▶ detect + RESULT ◀─────────────────────────── {tracks: [2D boxes]} + publish iqVehicleTracks (dots) + publish iqEnvironment (3D via ground-plane + calibration) +``` + +- Discovery: the device UDP-broadcasts `IQVISION_DISCOVER_V1` on the subnet; the + Mac replies `IQVISION_HERE_V1:`. No config, no pairing. +- Protocol: `iqvd_private_src/offload/protocol.py` (length-prefixed frames, JSON + header + optional binary blob). Shared verbatim by both sides — it is the ABI. +- Ports: tcp/51998 inference, udp/51999 discovery, tcp/51995 localhost status. + +## The Mac app + +- Menu-bar app (`◎` waiting, `◉` connected). Menu shows device, frames served, + inference p50/p99, and **Quit**. +- Keeps the Mac awake while running (`caffeinate`). +- Ships the model in the dmg — no download on first run. +- First launch creates a small venv (numpy, opencv-headless, rumps); tinygrad is + bundled. + +## Build + +``` +tools/iqmacvisiond/macos/build_dmg.sh +``` + +Produces `IQ Vision.app` and `IQVision.dmg`. See `macos/SIGNING.md` for signing + +notarization. + +## Run from source (dev) + +``` +DEV=METAL python3 tools/iqmacvisiond/server.py # server only +python3 tools/iqmacvisiond/menubar.py # menu-bar wrapper +python3 tools/iqmacvisiond/test_offload.py # protocol/geometry/loopback +``` + +Gating on the device: `VisionVehicleTracks` enables iqvd; when `maciqmodeld` (the +eMac driving offload) is running, iqvd is Mac-or-nothing — it never runs local +inference. On non-eMac setups iqvd falls back to on-device YOLO if no Mac is found. diff --git a/tools/iqmacvisiond/macos/SIGNING.md b/tools/iqmacvisiond/macos/SIGNING.md new file mode 100644 index 0000000..dce30c5 --- /dev/null +++ b/tools/iqmacvisiond/macos/SIGNING.md @@ -0,0 +1,23 @@ +# Signing & notarization — IQ Vision.app + +Unsigned, the app runs after a right-click ▸ Open (Gatekeeper first-run). For +distribution, sign + notarize: + +```bash +APP="tools/iqmacvisiond/macos/dist/IQ Vision.app" +ENT="tools/iqmacvisiond/macos/entitlements.plist" +IDENTITY="Developer ID Application: ()" + +codesign --force --deep --options runtime --entitlements "$ENT" \ + --sign "$IDENTITY" "$APP" + +hdiutil create -volname "IQ Vision" -srcfolder "$(dirname "$APP")" -ov -format UDZO \ + tools/iqmacvisiond/macos/dist/IQVision.dmg + +xcrun notarytool submit tools/iqmacvisiond/macos/dist/IQVision.dmg \ + --apple-id "" --team-id "" --password "" --wait +xcrun stapler staple tools/iqmacvisiond/macos/dist/IQVision.dmg +``` + +The entitlements cover: JIT + unsigned exec memory + library-validation off +(tinygrad Metal JIT) and network server/client (LAN discovery + inference). diff --git a/tools/iqmacvisiond/macos/build_dmg.sh b/tools/iqmacvisiond/macos/build_dmg.sh new file mode 100755 index 0000000..8b44c0d --- /dev/null +++ b/tools/iqmacvisiond/macos/build_dmg.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +REPO="$(cd "$HERE/../../.." >/dev/null && pwd)" +OUT="${1:-$HERE/dist}" +APP="$OUT/IQ Vision.app" + +rm -rf "$OUT" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleNameIQ Vision + CFBundleIdentifiercom.iqpilot.iqvision + CFBundleVersion1.0 + CFBundleShortVersionString1.0 + CFBundlePackageTypeAPPL + CFBundleExecutableiqvision + LSMinimumSystemVersion13.0 + LSUIElement + + +PLIST + +cat > "$APP/Contents/MacOS/iqvision" <<'LAUNCH' +#!/bin/bash +RES="$(cd "$(dirname "$0")/../Resources" && pwd)" +if [ ! -x "$HOME/Library/Application Support/IQVision/venv/bin/python" ]; then + osascript -e "tell application \"Terminal\" + activate + do script \"bash '$RES/macos/setup.sh'\" + end tell" +else + exec bash "$RES/macos/setup.sh" >/tmp/iqvision.log 2>&1 +fi +LAUNCH +chmod +x "$APP/Contents/MacOS/iqvision" + +RES="$APP/Contents/Resources" +SRC="$RES/openpilot/iqpilot/iqvd_private_src" +mkdir -p "$RES/tools/iqmacvisiond" "$RES/macos" "$SRC/offload" "$SRC/models" + +cp "$REPO/tools/iqmacvisiond/server.py" "$REPO/tools/iqmacvisiond/menubar.py" "$RES/tools/iqmacvisiond/" +cp "$HERE/setup.sh" "$RES/macos/" +cp "$REPO/iqpilot/iqvd_private_src/__init__.py" "$REPO/iqpilot/iqvd_private_src/yolov8_net.py" "$SRC/" +cp "$REPO/iqpilot/iqvd_private_src/offload/"*.py "$SRC/offload/" +cp "$REPO/iqpilot/iqvd_private_src/models/yolov8n.safetensors" "$SRC/models/" +touch "$RES/openpilot/__init__.py" "$RES/openpilot/iqpilot/__init__.py" "$RES/tools/__init__.py" \ + "$RES/tools/iqmacvisiond/__init__.py" +rsync -a --exclude=".git" --exclude="__pycache__" --exclude="extra" --exclude="test" \ + --exclude="examples" --exclude="docs" "$REPO/tinygrad_repo/" "$RES/tinygrad_repo/" + +hdiutil create -volname "IQ Vision" -srcfolder "$OUT" -ov -format UDZO "$OUT/IQVision.dmg" >/dev/null +echo "built: $OUT/IQVision.dmg ($(du -h "$OUT/IQVision.dmg" | cut -f1))" diff --git a/tools/iqmacvisiond/macos/entitlements.plist b/tools/iqmacvisiond/macos/entitlements.plist new file mode 100644 index 0000000..e1b1f7d --- /dev/null +++ b/tools/iqmacvisiond/macos/entitlements.plist @@ -0,0 +1,11 @@ + + + + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + com.apple.security.cs.disable-library-validation + com.apple.security.network.server + com.apple.security.network.client + + diff --git a/tools/iqmacvisiond/macos/setup.sh b/tools/iqmacvisiond/macos/setup.sh new file mode 100755 index 0000000..7776f37 --- /dev/null +++ b/tools/iqmacvisiond/macos/setup.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +set -euo pipefail + +RES="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null && pwd)" +SUPPORT="$HOME/Library/Application Support/IQVision" +VENV="$SUPPORT/venv" +PY="$VENV/bin/python" + +mkdir -p "$SUPPORT" + +if [ ! -x "$PY" ]; then + echo "Creating IQ Vision environment (one time)…" + /usr/bin/python3 -m venv "$VENV" + "$PY" -m pip install --upgrade --quiet pip + "$PY" -m pip install --quiet numpy "opencv-python-headless>=4.8" rumps +fi + +export PYTHONPATH="$RES:$RES/tinygrad_repo" +export DEV=METAL +exec "$PY" "$RES/tools/iqmacvisiond/menubar.py" diff --git a/tools/iqmacvisiond/menubar.py b/tools/iqmacvisiond/menubar.py new file mode 100755 index 0000000..1c4c203 --- /dev/null +++ b/tools/iqmacvisiond/menubar.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +import urllib.request +from pathlib import Path + +import rumps + +HERE = Path(__file__).resolve().parent +STATUS_URL = "http://127.0.0.1:51995/status.json" +DASHBOARD_URL = "http://127.0.0.1:51995" + + +class IQVisionApp(rumps.App): + def __init__(self): + super().__init__("IQ Vision", title="◎", quit_button=None) + self.item_state = rumps.MenuItem("Starting…") + self.item_device = rumps.MenuItem("Device: —") + self.item_frames = rumps.MenuItem("Frames: —") + self.item_exec = rumps.MenuItem("Inference: —") + self.item_awake = rumps.MenuItem("Keep awake: —") + self.menu = [ + self.item_state, None, + self.item_device, self.item_frames, self.item_exec, self.item_awake, None, + rumps.MenuItem("Open Dashboard", callback=self.open_dashboard), + rumps.MenuItem("Quit IQ Vision", callback=self.quit_app), + ] + self.proc: subprocess.Popen | None = None + self._start_server() + self.timer = rumps.Timer(self.refresh, 1) + self.timer.start() + + def _start_server(self) -> None: + env = dict(os.environ) + self.proc = subprocess.Popen([sys.executable, str(HERE / "server.py")], env=env) + + def refresh(self, _) -> None: + if self.proc is not None and self.proc.poll() is not None: + self.title = "◎!" + self.item_state.title = "Server stopped — reopen the app" + return + try: + with urllib.request.urlopen(STATUS_URL, timeout=0.8) as r: + s = json.load(r) + except Exception: + self.title = "◎" + self.item_state.title = "Warming up…" + return + live = s.get("connected") and s.get("fresh") + self.title = "◉" if live else "◎" + self.item_state.title = "Connected" if live else "Waiting for device" + self.item_device.title = f"Device: {s.get('peer') or '—'}" + self.item_frames.title = f"Frames: {s.get('infer_count', 0):,}" + p50, p99 = s.get("exec_p50_ms", 0.0), s.get("exec_p99_ms", 0.0) + self.item_exec.title = f"Inference: {p50:.0f} / {p99:.0f} ms" if p50 else "Inference: —" + self.item_awake.title = f"Keep awake: {'on' if s.get('awake') else 'off'}" + + def open_dashboard(self, _) -> None: + subprocess.Popen(["open", DASHBOARD_URL]) + + def quit_app(self, _) -> None: + if self.proc is not None: + self.proc.terminate() + try: + self.proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.proc.kill() + rumps.quit_application() + + +if __name__ == "__main__": + IQVisionApp().run() diff --git a/tools/iqmacvisiond/server.py b/tools/iqmacvisiond/server.py new file mode 100755 index 0000000..2a3f5f2 --- /dev/null +++ b/tools/iqmacvisiond/server.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +from __future__ import annotations + +import argparse +import json +import logging +import os +import signal +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +os.environ.setdefault("DEV", "METAL") +os.environ.setdefault("JIT_BATCH_SIZE", "0") + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +import cv2 +import numpy as np + +from openpilot.iqpilot.iqvd_private_src.offload.protocol import ( + DISCOVERY_MAGIC, DISCOVERY_REPLY, DISCOVERY_PORT_DEFAULT, MSG_HELLO, MSG_HELLO_ACK, MSG_INFER, + MSG_PING, MSG_PONG, MSG_RESULT, ProtocolError, recv_msg, send_msg, +) +from openpilot.iqpilot.iqvd_private_src.offload.perception import Detector + +log = logging.getLogger("iqmacvisiond") + +DEFAULT_PORT = 51998 +STATUS_PORT = 51995 +MODEL_NAME = "yolov8n" +SESSION_IDLE_TIMEOUT_S = 8.0 + +STATUS: dict = { + "connected": False, "peer": "", "model": MODEL_NAME, + "exec_p50_ms": 0.0, "exec_p99_ms": 0.0, "infer_count": 0, + "last_seen": 0.0, "awake": False, +} + + +class KeepAwake: + def __init__(self): + self._proc: subprocess.Popen | None = None + + def start(self) -> None: + if sys.platform != "darwin" or self._proc is not None: + return + try: + self._proc = subprocess.Popen(["caffeinate", "-dimsu"]) + STATUS["awake"] = True + log.info("keep-awake active (caffeinate pid=%d)", self._proc.pid) + except OSError: + log.warning("caffeinate unavailable; display may sleep") + + def stop(self) -> None: + if self._proc is not None: + self._proc.terminate() + self._proc = None + STATUS["awake"] = False + + +class DiscoveryResponder: + def __init__(self, tcp_port: int, disc_port: int = DISCOVERY_PORT_DEFAULT): + self.tcp_port = tcp_port + self.disc_port = disc_port + + def start(self) -> None: + threading.Thread(target=self._serve, daemon=True).start() + + def _serve(self) -> None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("0.0.0.0", self.disc_port)) + except OSError: + log.exception("discovery bind failed on udp/%d", self.disc_port) + return + reply = DISCOVERY_REPLY + f":{self.tcp_port}".encode() + log.info("discovery responder on udp/%d -> tcp/%d", self.disc_port, self.tcp_port) + while True: + try: + data, addr = sock.recvfrom(256) + except OSError: + continue + if data.startswith(DISCOVERY_MAGIC): + try: + sock.sendto(reply, addr) + except OSError: + pass + + +class StatusServer: + def __init__(self, port: int = STATUS_PORT): + self.port = port + + def start(self) -> None: + threading.Thread(target=self._serve, daemon=True).start() + + def _serve(self) -> None: + from http.server import BaseHTTPRequestHandler, HTTPServer + + class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path.startswith("/status.json"): + st = dict(STATUS) + st["fresh"] = (time.time() - st["last_seen"]) < 6 if st["last_seen"] else False + self._send(200, "application/json", json.dumps(st).encode()) + else: + self._send(200, "text/html; charset=utf-8", _STATUS_HTML.encode()) + + try: + HTTPServer(("127.0.0.1", self.port), H).serve_forever() + except OSError: + log.exception("status server failed on %d", self.port) + + +_STATUS_HTML = """ +IQ Vision +
+

IQ Vision

+
Device
+
Model
+
Inference (p50 / p99)
+
Frames served
+
Keep awake
+
""" + + +class Session: + def __init__(self, conn: socket.socket, detector: Detector): + self.conn = conn + self.detector = detector + try: + self.peer = conn.getpeername()[0] + except OSError: + self.peer = "" + self.infer_count = 0 + self.exec_ms: list[float] = [] + + def handshake(self) -> bool: + msg_type, header, _ = recv_msg(self.conn) + if msg_type != MSG_HELLO: + raise ProtocolError(f"expected HELLO, got {msg_type}") + send_msg(self.conn, MSG_HELLO_ACK, {"ok": True, "model": MODEL_NAME, "hostname": socket.gethostname()}) + STATUS.update(connected=True, peer=self.peer, last_seen=time.time()) + log.info("device connected: %s dongle=%s", self.peer, header.get("dongle_id", "")) + return True + + def serve(self) -> None: + while True: + msg_type, header, blob = recv_msg(self.conn) + if msg_type == MSG_INFER: + self._infer(header, blob) + elif msg_type == MSG_PING: + send_msg(self.conn, MSG_PONG, {}) + else: + raise ProtocolError(f"unexpected message type {msg_type}") + + def _infer(self, header: dict, jpeg: bytes) -> None: + st = time.perf_counter() + tracks = [] + try: + rgb = cv2.imdecode(np.frombuffer(jpeg, np.uint8), cv2.IMREAD_COLOR) + if rgb is not None: + tracks = self.detector.detect(cv2.cvtColor(rgb, cv2.COLOR_BGR2RGB)) + except Exception: + log.exception("inference failed for frame %s", header.get("frame_id")) + dt = (time.perf_counter() - st) * 1e3 + send_msg(self.conn, MSG_RESULT, {"frame_id": header.get("frame_id", 0), "tracks": tracks, + "exec_ms": dt}) + self.infer_count += 1 + self.exec_ms.append(dt) + if len(self.exec_ms) > 400: + del self.exec_ms[:200] + if self.infer_count % 10 == 0 or self.infer_count == 1: + recent = self.exec_ms[-200:] + STATUS.update(connected=True, infer_count=self.infer_count, last_seen=time.time(), + exec_p50_ms=float(np.percentile(recent, 50)), + exec_p99_ms=float(np.percentile(recent, 99))) + + +def _weights_ok() -> bool: + from openpilot.iqpilot.iqvd_private_src.offload.perception import _weights_dir + return (_weights_dir() / "yolov8n.safetensors").exists() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--no-keep-awake", action="store_true") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") + + if not _weights_ok(): + log.error("yolov8n.safetensors not found; the app ships the model with it") + sys.exit(1) + + keep_awake = KeepAwake() + if not args.no_keep_awake: + keep_awake.start() + + def _shutdown(*_): + keep_awake.stop() + sys.exit(0) + try: + signal.signal(signal.SIGTERM, _shutdown) + signal.signal(signal.SIGINT, _shutdown) + except ValueError: + pass + + t0 = time.perf_counter() + detector = Detector(None) + for _ in range(3): + detector.detect(np.zeros((416, 640, 3), dtype=np.uint8)) + log.info("model warm in %.1fs", time.perf_counter() - t0) + + DiscoveryResponder(args.port).start() + StatusServer().start() + log.info("status dashboard on http://127.0.0.1:%d", STATUS_PORT) + + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen(1) + log.info("READY listening on %s:%d", args.host, args.port) + + try: + while True: + conn, addr = server.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + conn.settimeout(SESSION_IDLE_TIMEOUT_S) + try: + session = Session(conn, detector) + if session.handshake(): + session.serve() + except (ConnectionError, ProtocolError, OSError) as e: + log.info("session ended: %s", e) + finally: + conn.close() + STATUS.update(connected=False, peer="") + finally: + keep_awake.stop() + + +if __name__ == "__main__": + main() diff --git a/tools/iqmacvisiond/test_offload.py b/tools/iqmacvisiond/test_offload.py new file mode 100755 index 0000000..a6fc1fb --- /dev/null +++ b/tools/iqmacvisiond/test_offload.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/ +""" +import socket +import sys +import threading +import time +from pathlib import Path + +import numpy as np + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from openpilot.iqpilot.iqvd_private_src.offload import protocol +from openpilot.iqpilot.iqvd_private_src.offload.client import VisionClient, discover_server +from openpilot.iqpilot.iqvd_private_src.offload.geometry import pixel_to_ground, tracks_to_objects + + +def test_protocol_roundtrip(): + a, b = socket.socketpair() + protocol.send_msg(a, protocol.MSG_INFER, {"frame_id": 7, "w": 640}, b"\x00\x01\x02payload") + mt, header, blob = protocol.recv_msg(b) + assert mt == protocol.MSG_INFER + assert header == {"frame_id": 7, "w": 640} + assert blob == b"\x00\x01\x02payload" + a.close() + b.close() + + +def test_protocol_empty_blob(): + a, b = socket.socketpair() + protocol.send_msg(a, protocol.MSG_HELLO_ACK, {"ok": True, "model": "yolov8n"}) + mt, header, blob = protocol.recv_msg(b) + assert mt == protocol.MSG_HELLO_ACK and header["model"] == "yolov8n" and blob == b"" + a.close() + b.close() + + +def test_geometry_center_projects_forward(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + device_from_calib = np.eye(3) + p = pixel_to_ground(960.0, 900.0, intr, device_from_calib, 1.22) + assert p is not None + assert p[0] > 0 + assert abs(p[1]) < 1.0 + + +def test_geometry_above_horizon_none(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + assert pixel_to_ground(960.0, 100.0, intr, np.eye(3), 1.22) is None + + +def test_tracks_to_objects(): + intr = np.array([[900.0, 0.0, 960.0], [0.0, 900.0, 604.0], [0.0, 0.0, 1.0]]) + tracks = [{"x1": 0.45, "y1": 0.5, "x2": 0.55, "y2": 0.75, "prob": 0.9, "label": "car"}] + objs = tracks_to_objects(tracks, 1928, 1208, intr, [0.0, 0.0, 0.0]) + assert len(objs) == 1 + assert objs[0]["x"] > 0 and objs[0]["label"] == "car" + + +class _StubDetector: + def detect(self, rgb): + return [{"x1": 0.1, "y1": 0.2, "x2": 0.3, "y2": 0.5, "prob": 0.8, "label": "car"}] + + +def _run_server(port, ready): + from openpilot.iqpilot.iqvd_private_src.offload import protocol as p + import tools.iqmacvisiond.server as srv + disc = srv.DiscoveryResponder(port) + disc.start() + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("0.0.0.0", port)) + server.listen(1) + ready.set() + conn, _ = server.accept() + conn.settimeout(5) + session = srv.Session(conn, _StubDetector()) + session.handshake() + try: + session.serve() + except (p.ProtocolError, OSError): + pass + + +def test_discovery_and_loopback(): + port = 52050 + ready = threading.Event() + threading.Thread(target=_run_server, args=(port, ready), daemon=True).start() + assert ready.wait(5) + time.sleep(0.2) + + found = discover_server(timeout=2.0) + assert found is not None, "discovery failed" + assert found[1] == port + + import cv2 + ok, jpeg = cv2.imencode(".jpg", np.zeros((400, 640, 3), dtype=np.uint8)) + assert ok + + client = VisionClient("test-dongle") + assert client.connect(), "connect failed" + meta = {"frame_id": 42, "wide": False, "w": 640, "h": 400} + tracks = client.infer(jpeg.tobytes(), meta) + assert tracks is not None and len(tracks) == 1 + assert tracks[0]["label"] == "car" + client.close() + + +if __name__ == "__main__": + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + fn() + print(f"ok {fn.__name__}") + print(f"\n{len(fns)} passed") diff --git a/tools/jotpluggler/SConscript b/tools/jotpluggler/SConscript index 06d5e11..8d84fbc 100644 --- a/tools/jotpluggler/SConscript +++ b/tools/jotpluggler/SConscript @@ -17,12 +17,14 @@ from iqdbc.car.fingerprints import MIGRATION from iqdbc.car.values import PLATFORMS from openpilot.common.basedir import BASEDIR -Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib') +Import('env', 'arch', 'common', 'messaging', 'visionipc', 'cereal', 'replay_lib', 'ffmpeg_libs') jot_env = env.Clone() # imgui.MESA_DIR only exists on the larch64 wheel; skip missing paths to avoid an ld search-path warning jot_env["LIBPATH"] += [p for p in [imgui.MESA_DIR, libusb.LIB_DIR] if os.path.isdir(p)] jot_env["CPPPATH"] += [imgui.INCLUDE_DIR, libusb.INCLUDE_DIR] +# IQ.Pilot patch: no comma-deps-bootstrap-icons wheel here, so BOOTSTRAP_ICONS_TTF is +# not defined; icons.cc reads the TTF vendored under tools/jotpluggler/assets instead. jot_env["CXXFLAGS"] += [ "-DGLFW_INCLUDE_NONE", '-DJOTP_REPO_ROOT=\'"%s"\'' % os.path.realpath(BASEDIR), @@ -104,16 +106,15 @@ event_extractors = jot_env.Command("generated_event_extractors.h", [ jot_env.PrettyAction(generate_event_extractors, 'GEN'), ) -libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a"), - # IQ.Pilot patch: iqpilot's replay_lib resolves URL/api work via libcurl - # (vs upstream's Python downloader), so jotpluggler needs to link curl too. - # iqpilot's api.cc additionally uses OpenSSL primitives (PEM/RSA/SHA256) for - # JWT signing, and visionipc references OpenCL — both must also be linked. - "avformat", "avcodec", "avutil", "x264", "yuv", "z", "bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"] +# IQ.Pilot patch: iqpilot's replay_lib resolves route/API work via libcurl (vs upstream's +# Python downloader), so jotpluggler needs curl; api.cc signs konn3kt JWTs with OpenSSL +# primitives, and visionipc references OpenCL. Both must also be linked. +libs = [replay_lib, common, messaging, visionipc, cereal, File(f"{imgui.LIB_DIR}/libimgui.a"), File(f"{imgui.LIB_DIR}/libglfw3.a")] + \ + ffmpeg_libs + ["bz2", "zstd", "curl", "ssl", "crypto", "m", "pthread", "usb-1.0"] if arch == "Darwin": jot_env["FRAMEWORKS"] = ["OpenGL", "OpenCL", "Cocoa", "IOKit", "CoreFoundation", "CoreVideo", "CoreMedia", "VideoToolbox"] else: - libs += ["GL", "OpenCL", "dl", "va", "va-drm", "drm"] + libs += ["GL", "OpenCL", "dl"] program = jot_env.Program("jotpluggler", jot_env.Glob("*.cc"), LIBS=libs) jot_env.Depends(program, generated_dbc_stamp) diff --git a/tools/jotpluggler/app.cc b/tools/jotpluggler/app.cc index efc8484..a55e50b 100644 --- a/tools/jotpluggler/app.cc +++ b/tools/jotpluggler/app.cc @@ -3,6 +3,7 @@ #include "tools/jotpluggler/common.h" #include "tools/jotpluggler/internal.h" #include "tools/jotpluggler/map.h" +#include "tools/jotpluggler/thumbnail.h" #include "system/hardware/hw.h" #include "imgui_impl_glfw.h" @@ -1033,10 +1034,12 @@ bool apply_special_item_to_pane(WorkspaceTab *tab, TabUiState *tab_state, int pa if (pane.title == UNTITLED_PANE_TITLE || previous_kind != PaneKind::Plot) { pane.title = spec->label; } - } else { + } else if (spec->kind == PaneKind::Camera) { pane.title = spec->label; resize_tab_pane_state(tab_state, tab->panes.size()); tab_state->camera_panes[static_cast(pane_index)].fit_to_pane = true; + } else { + pane.title = spec->label; } tab_state->active_pane_index = pane_index; return true; @@ -1565,6 +1568,8 @@ void draw_pane_windows(AppSession *session, UiState *state) { } if (pane.kind == PaneKind::Map) { draw_map_pane(session, state, &pane, static_cast(i)); + } else if (pane.kind == PaneKind::Thumbnail) { + draw_thumbnail_pane(session, state); } else if (pane.kind == PaneKind::Camera) { draw_camera_pane(session, state, tab_state, static_cast(i), pane); } else { @@ -1847,6 +1852,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed = std::make_unique(); } + session.thumbnail_view = std::make_unique(); sync_camera_feeds(&session); if (session.async_route_loading) { @@ -1892,6 +1898,7 @@ int run(const Options &options) { for (std::unique_ptr &feed : session.pane_camera_feeds) { feed.reset(); } + session.thumbnail_view.reset(); return 0; } catch (const std::exception &err) { std::cerr << err.what() << "\n"; diff --git a/tools/jotpluggler/app.h b/tools/jotpluggler/app.h index 872a697..c7b6fb7 100644 --- a/tools/jotpluggler/app.h +++ b/tools/jotpluggler/app.h @@ -81,6 +81,7 @@ struct Curve { enum class PaneKind : uint8_t { Plot, Map, + Thumbnail, Camera, }; @@ -141,9 +142,15 @@ struct CameraFeedIndex { std::vector entries; }; +struct ThumbnailFrame { + double timestamp = 0.0; + int segment = -1; + std::vector jpeg; +}; + enum class LogOrigin : uint8_t { Log, - Android, + OperatingSystem, Alert, }; @@ -318,6 +325,7 @@ struct RouteData { CameraFeedIndex driver_camera; CameraFeedIndex wide_road_camera; CameraFeedIndex qroad_camera; + std::vector thumbnails; GpsTrace gps_trace; std::vector logs; std::vector timeline; @@ -445,6 +453,7 @@ bool icon_menu_item(const char *glyph, class AsyncRouteLoader; class CameraFeedView; +class ThumbnailView; class StreamPoller; class MapDataManager; @@ -486,6 +495,7 @@ struct AppSession { std::unique_ptr route_loader; std::unique_ptr stream_poller; std::array, 4> pane_camera_feeds; + std::unique_ptr thumbnail_view; std::unique_ptr map_data; bool async_route_loading = false; double next_stream_custom_refresh_time = 0.0; @@ -885,3 +895,20 @@ private: struct Impl; std::unique_ptr impl_; }; + +class ThumbnailView { +public: + ThumbnailView(); + ~ThumbnailView(); + + ThumbnailView(const ThumbnailView &) = delete; + ThumbnailView &operator=(const ThumbnailView &) = delete; + + void setThumbnails(const std::vector &thumbnails); + void update(double tracker_time); + void drawSized(ImVec2 size, bool loading); + +private: + struct Impl; + std::unique_ptr impl_; +}; diff --git a/tools/jotpluggler/common.cc b/tools/jotpluggler/common.cc index 74ca8f5..41d444c 100644 --- a/tools/jotpluggler/common.cc +++ b/tools/jotpluggler/common.cc @@ -47,11 +47,11 @@ const char *special_item_label(std::string_view item_id) { } bool pane_kind_is_special(PaneKind kind) { - return kind == PaneKind::Map || kind == PaneKind::Camera; + return kind == PaneKind::Map || kind == PaneKind::Thumbnail || kind == PaneKind::Camera; } bool is_default_special_title(std::string_view title) { - if (title == "Map") return true; + if (title == "Map" || title == "Thumbnail") return true; return std::any_of(kCameraViewSpecs.begin(), kCameraViewSpecs.end(), [&](const CameraViewSpec &spec) { return title == spec.label; }); @@ -162,14 +162,12 @@ void open_external_url(std::string_view url) { } } -std::string route_useradmin_url(const RouteIdentifier &route_id) { - return route_id.empty() ? std::string() - : "https://useradmin.comma.ai/?onebox=" + route_id.dongle_id + "%7C" + route_id.log_id; -} - -std::string route_connect_url(const RouteIdentifier &route_id) { - return route_id.empty() ? std::string() - : "https://connect.comma.ai/" + route_id.canonical(); +// IQ.Pilot patch: iqpilot routes live on konn3kt, not comma connect. konn3kt has no +// useradmin equivalent, so that link is gone; the share link keeps connect's +// / path shape (see tools/lib/logreader.py parse_indirect). +std::string route_konn3kt_url(const RouteIdentifier &route_id) { + static const std::string host = util::getenv("KONN3KT_APP_HOST", "https://konn3kt.com"); + return route_id.empty() ? std::string() : host + "/" + route_id.canonical(); } std::string route_google_maps_url(const GpsTrace &trace) { diff --git a/tools/jotpluggler/common.h b/tools/jotpluggler/common.h index 14db83f..b1f85ca 100644 --- a/tools/jotpluggler/common.h +++ b/tools/jotpluggler/common.h @@ -28,8 +28,9 @@ inline constexpr std::array kCameraViewSpecs = {{ {CameraViewKind::QRoad, "qRoad Camera", "qroad", "qroad", "camera_qroad", &RouteData::qroad_camera}, }}; -inline constexpr std::array kSpecialItemSpecs = {{ +inline constexpr std::array kSpecialItemSpecs = {{ {"map", "Map", PaneKind::Map, CameraViewKind::Road}, + {"thumbnail", "Thumbnail", PaneKind::Thumbnail, CameraViewKind::Road}, {kCameraViewSpecs[0].special_item_id, kCameraViewSpecs[0].label, PaneKind::Camera, kCameraViewSpecs[0].view}, {kCameraViewSpecs[1].special_item_id, kCameraViewSpecs[1].label, PaneKind::Camera, kCameraViewSpecs[1].view}, {kCameraViewSpecs[2].special_item_id, kCameraViewSpecs[2].label, PaneKind::Camera, kCameraViewSpecs[2].view}, @@ -62,6 +63,5 @@ bool app_begin_popup_modal(const char *name, bool *p_open = nullptr, ImGuiWindowFlags flags = ImGuiWindowFlags_AlwaysAutoResize); void open_external_url(std::string_view url); -std::string route_useradmin_url(const RouteIdentifier &route_id); -std::string route_connect_url(const RouteIdentifier &route_id); +std::string route_konn3kt_url(const RouteIdentifier &route_id); std::string route_google_maps_url(const GpsTrace &trace); diff --git a/tools/jotpluggler/dbc.h b/tools/jotpluggler/dbc.h index f8fe4f7..4de10bd 100644 --- a/tools/jotpluggler/dbc.h +++ b/tools/jotpluggler/dbc.h @@ -276,6 +276,7 @@ inline void Database::parseSg(const std::string &line, int line_number, Message signal.type = Signal::Type::Multiplexor; } else if (!indicator.empty() && indicator.front() == 'm') { signal.type = Signal::Type::Multiplexed; + // IQ.Pilot patch: guard a bare "m" indicator; std::stoi("") throws. const std::string mux = indicator.substr(1); signal.multiplex_value = mux.empty() ? 0 : std::stoi(mux); } else { diff --git a/tools/jotpluggler/generate_event_extractors.py b/tools/jotpluggler/generate_event_extractors.py index a2deaed..c68a4b6 100644 --- a/tools/jotpluggler/generate_event_extractors.py +++ b/tools/jotpluggler/generate_event_extractors.py @@ -62,6 +62,8 @@ class Generator: def __init__(self, event_schema): self.event_schema = event_schema self.fixed_paths = [] + self.event_base_slots = {} + self.static_enums = [] self.tmp_index = 0 self.lines = [] self.emits_memo = {} @@ -103,9 +105,13 @@ class Generator: self.emit(indent, f"append_dynamic_scalar_point({path_expr}, tm, {double_expr}, series);") else: slot = self.add_fixed_path(path) - if kind == "Enum": - self.emit_enum_capture(indent, cxx_string(path), enum_names(schema)) - self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") + names = enum_names(schema) if kind == "Enum" else [] + if names: + enum_index = len(self.static_enums) + self.static_enums.append(names) + self.emit(indent, f"append_fixed_enum_point({slot}, {enum_index}, tm, {double_expr}, series);") + else: + self.emit(indent, f"append_fixed_scalar_point(&series->fixed_series[{slot}], tm, {double_expr});") return if type_kind == "struct": @@ -144,9 +150,13 @@ class Generator: self.emit(indent, f"if ({' && '.join(conditions)}) {{") indent += 2 - value_var = self.tmp("value") - self.emit(indent, f"const auto {value_var} = {get_call};") - self.emit_node(indent, type_kind, type_proto, value_schema, value_var, field_path, field_path_expr, dynamic_path) + # Scalar getters are only consumed once. Emitting them directly avoids + # thousands of single-use locals in the generated extractor. + value_expr = get_call + if kind is None: + value_expr = self.tmp("value") + self.emit(indent, f"const auto {value_expr} = {get_call};") + self.emit_node(indent, type_kind, type_proto, value_schema, value_expr, field_path, field_path_expr, dynamic_path) if conditions: indent -= 2 @@ -249,35 +259,47 @@ class Generator: self.emit(indent + 2, "}") self.emit(indent, "}") self.emit(indent, "if (skip_raw_can) {") - self.emit(indent + 2, "return true;") + self.emit(indent + 2, "return;") self.emit(indent, "}") - def emit_event_case(self, field_name): + def emit_event_reader(self, field_name): field = self.event_schema.fields[field_name] proto = field.proto type_kind = field_type(field) type_proto = field_type_proto(field) kind = scalar_kind(type_proto) schema = field.schema if kind == "Enum" or type_kind in NESTED_TYPE_KINDS else None - self.emit(4, f"case static_cast({proto.discriminantValue}): {{") valid_slot = self.add_fixed_path(f"/{field_name}/valid") - mono_slot = self.add_fixed_path(f"/{field_name}/logMonoTime") - seconds_slot = self.add_fixed_path(f"/{field_name}/t") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{valid_slot}], tm, event.getValid() ? 1.0 : 0.0);") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{mono_slot}], tm, static_cast(event.getLogMonoTime()));") - self.emit(6, f"append_fixed_scalar_point(&series->fixed_series[{seconds_slot}], tm, tm);") - if field_name in {"can", "sendcan"}: - self.emit_can_special(6, field_name) - if self.node_emits(type_kind, type_proto, schema): + self.add_fixed_path(f"/{field_name}/logMonoTime") + self.add_fixed_path(f"/{field_name}/t") + self.event_base_slots[proto.discriminantValue] = valid_slot + + emits_payload = self.node_emits(type_kind, type_proto, schema) + if field_name not in {"can", "sendcan"} and not emits_payload: + return None + + reader_name = f"append_event_{proto.discriminantValue}" + needs_can = field_name in {"can", "sendcan"} + header_index = len(self.lines) + self.emit(0, "") + if needs_can: + self.emit_can_special(2, field_name) + if emits_payload: payload = self.tmp("payload") - self.emit(6, f"const auto {payload} = event.{accessor('get', field_name)}();") - self.emit_node(6, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) - self.emit(6, "return true;") - self.emit(4, "}") + self.emit(2, f"const auto {payload} = event.{accessor('get', field_name)}();") + self.emit_node(2, type_kind, type_proto, schema, payload, f"/{field_name}", None, False) + self.emit(0, "}") + self.emit(0, "") + if needs_can: + signature = "const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double tm, SeriesAccumulator *series" + else: + signature = "const cereal::Event::Reader &event, double tm, SeriesAccumulator *series" + self.lines[header_index] = f"__attribute__((noinline)) void {reader_name}({signature}) {{" + return reader_name, needs_can def generate(self): self.lines = [] - self.emit(0, "// Generated by tools/jotpluggler/generate_event_extractors.py; do not edit.") + self.emit(0, "// Generated by openpilot/tools/jotpluggler/generate_event_extractors.py; do not edit.") self.emit(0, "") self.emit(0, "const std::vector &static_event_fixed_paths() {") self.emit(2, "static const std::vector paths = {") @@ -300,11 +322,66 @@ class Generator: self.emit(2, "}") self.emit(0, "}") self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series);") # noqa: E501 + self.emit(0, "") + + self.emit(0, "// Keep each event payload behind its own optimizer boundary. Combining the") + self.emit(0, "// whole schema into one function creates much more code and runs slower.") + event_readers = {} + for field_name in self.event_schema.union_fields: + event_readers[field_name] = self.emit_event_reader(field_name) + + self.emit(0, "static const std::initializer_list static_event_enum_names[] = {") + for names in self.static_enums: + names_expr = "{" + ", ".join(cxx_string(name) for name in names) + "}" + self.emit(2, f"{names_expr},") + self.emit(0, "};") + self.emit(0, "") + self.emit(0, "__attribute__((noinline)) void append_fixed_enum_point(size_t series_slot, size_t enum_index, double tm, double value, SeriesAccumulator *series) {") # noqa: E501 + self.emit(2, "RouteSeries *fixed_series = &series->fixed_series[series_slot];") + self.emit(2, "capture_static_enum_info(fixed_series->path, static_event_enum_names[enum_index], series);") + self.emit(2, "fixed_series->times.push_back(tm);") + self.emit(2, "fixed_series->values.push_back(value);") + self.emit(0, "}") + self.emit(0, "") + self.emit(0, "bool append_event_static_reader(cereal::Event::Which which, const cereal::Event::Reader &event, const dbc::Database *can_dbc, bool skip_raw_can, double time_offset, SeriesAccumulator *series) {") # noqa: E501 - self.emit(2, "const double tm = static_cast(event.getLogMonoTime()) / 1.0e9 - time_offset;") + self.emit(2, "const auto log_mono_time = event.getLogMonoTime();") + self.emit(2, "const double tm = static_cast(log_mono_time) / 1.0e9 - time_offset;") + + invalid_slot = "static_cast(-1)" + max_discriminant = max(self.event_base_slots) + base_slots = [self.event_base_slots.get(i, invalid_slot) for i in range(max_discriminant + 1)] + self.emit(2, "static constexpr size_t event_base_slots[] = {") + for slot in base_slots: + self.emit(4, f"{slot},") + self.emit(2, "};") + self.emit(2, "const size_t event_index = static_cast(which);") + self.emit(2, "if (event_index >= sizeof(event_base_slots) / sizeof(event_base_slots[0])) {") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "const size_t base_slot = event_base_slots[event_index];") + self.emit(2, f"if (base_slot == {invalid_slot}) {{") + self.emit(4, "return false;") + self.emit(2, "}") + self.emit(2, "RouteSeries *base_series = &series->fixed_series[base_slot];") + self.emit(2, "base_series[0].times.push_back(tm);") + self.emit(2, "base_series[0].values.push_back(event.getValid() ? 1.0 : 0.0);") + self.emit(2, "base_series[1].times.push_back(tm);") + self.emit(2, "base_series[1].values.push_back(static_cast(log_mono_time));") + self.emit(2, "base_series[2].times.push_back(tm);") + self.emit(2, "base_series[2].values.push_back(tm);") self.emit(2, "switch (which) {") for field_name in self.event_schema.union_fields: - self.emit_event_case(field_name) + field = self.event_schema.fields[field_name] + self.emit(4, f"case static_cast({field.proto.discriminantValue}):") + reader = event_readers[field_name] + if reader is not None: + if reader[1]: + self.emit(6, f"{reader[0]}(event, can_dbc, skip_raw_can, tm, series);") + else: + self.emit(6, f"{reader[0]}(event, tm, series);") + self.emit(6, "return true;") self.emit(4, "default:") self.emit(6, "return false;") self.emit(2, "}") @@ -323,6 +400,8 @@ if __name__ == "__main__": repo_root = Path(sys.argv[1]).resolve() output = Path(sys.argv[2]) capnp.remove_import_hook() + # IQ.Pilot patch: iqpilot's tree is not nested under openpilot/ and car.capnp lives + # in cereal/ alongside log.capnp, so no extra import path is needed. log = capnp.load(str(repo_root / "cereal" / "log.capnp")) generated = Generator(log.Event.schema).generate() output.parent.mkdir(parents=True, exist_ok=True) diff --git a/tools/jotpluggler/icons.cc b/tools/jotpluggler/icons.cc index 1bbc2cd..df8cedc 100644 --- a/tools/jotpluggler/icons.cc +++ b/tools/jotpluggler/icons.cc @@ -6,7 +6,7 @@ void icon_add_font(float size, bool merge, const ImFont *base_font) { // IQ.Pilot patch: iqpilot's third_party/bootstrap is git-lfs and the TTF isn't // checked in, so we vendor the font alongside jotpluggler instead of relying on - // third_party/bootstrap/bootstrap-icons.ttf. + // upstream's BOOTSTRAP_ICONS_TTF define from the comma-deps-bootstrap-icons wheel. const std::filesystem::path ttf = repo_root() / "tools" / "jotpluggler" / "assets" / "bootstrap-icons.ttf"; ImGuiIO &io = ImGui::GetIO(); ImFontConfig config; diff --git a/tools/jotpluggler/layout_io.cc b/tools/jotpluggler/layout_io.cc index f984c0f..e898015 100644 --- a/tools/jotpluggler/layout_io.cc +++ b/tools/jotpluggler/layout_io.cc @@ -62,6 +62,8 @@ json11::Json workspace_node_to_json(const WorkspaceNode &node, const WorkspaceTa }; if (pane.kind == PaneKind::Map) { obj["kind"] = "map"; + } else if (pane.kind == PaneKind::Thumbnail) { + obj["kind"] = "thumbnail"; } else if (pane.kind == PaneKind::Camera) { obj["kind"] = "camera"; obj["camera_view"] = camera_view_spec(pane.camera_view).layout_name; diff --git a/tools/jotpluggler/runtime.cc b/tools/jotpluggler/runtime.cc index bfc30d7..ea103ea 100644 --- a/tools/jotpluggler/runtime.cc +++ b/tools/jotpluggler/runtime.cc @@ -7,7 +7,7 @@ #include "imgui_impl_opengl3.h" #include "imgui_impl_opengl3_loader.h" #include "implot.h" -#include "libyuv.h" +#include "common/yuv.h" #include "msgq_repo/msgq/ipc.h" #include "tools/replay/framereader.h" @@ -1173,18 +1173,14 @@ struct CameraFeedView::Impl { result.width = reader->width; result.height = reader->height; result.rgba.resize(static_cast(result.width) * static_cast(result.height) * 4U, 0); - // IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap. - libyuv::NV12ToARGB(decode_buffer.y, - static_cast(decode_buffer.stride), - decode_buffer.uv, - static_cast(decode_buffer.stride), - result.rgba.data(), - result.width * 4, - result.width, - result.height); - libyuv::ARGBToABGR(result.rgba.data(), result.width * 4, - result.rgba.data(), result.width * 4, - result.width, result.height); + yuv::nv12_to_rgba(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + result.rgba.data(), + result.width * 4, + result.width, + result.height); result.success = true; result.decode_ms = std::chrono::duration(std::chrono::steady_clock::now() - decode_begin).count(); publish_result(*request, std::move(result)); @@ -1207,18 +1203,14 @@ struct CameraFeedView::Impl { .height = reader->height, }; prefetched.rgba.resize(static_cast(prefetched.width) * static_cast(prefetched.height) * 4U, 0); - // IQ.Pilot patch: vendored libyuv lacks NV12ToABGR; use NV12ToARGB + in-place ARGBToABGR swap. - libyuv::NV12ToARGB(decode_buffer.y, - static_cast(decode_buffer.stride), - decode_buffer.uv, - static_cast(decode_buffer.stride), - prefetched.rgba.data(), - prefetched.width * 4, - prefetched.width, - prefetched.height); - libyuv::ARGBToABGR(prefetched.rgba.data(), prefetched.width * 4, - prefetched.rgba.data(), prefetched.width * 4, - prefetched.width, prefetched.height); + yuv::nv12_to_rgba(decode_buffer.y, + static_cast(decode_buffer.stride), + decode_buffer.uv, + static_cast(decode_buffer.stride), + prefetched.rgba.data(), + prefetched.width * 4, + prefetched.width, + prefetched.height); remember_cached_result(prefetched); } } diff --git a/tools/jotpluggler/session.cc b/tools/jotpluggler/session.cc index beb0a29..bb5edd6 100644 --- a/tools/jotpluggler/session.cc +++ b/tools/jotpluggler/session.cc @@ -19,6 +19,9 @@ void sync_camera_feeds(AppSession *session) { session->pane_camera_feeds[i]->setCameraIndex(session->route_data.*(kCameraViewSpecs[i].route_member), kCameraViewSpecs[i].view); } } + if (session->thumbnail_view) { + session->thumbnail_view->setThumbnails(session->route_data.thumbnails); + } } void apply_route_data(AppSession *session, UiState *state, RouteData route_data) { @@ -456,8 +459,7 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { const char *copy_icon = icon::CLIPBOARD; const char *link_icon = icon::BOX_ARROW_UP_RIGHT; - const std::string useradmin_label = std::string("Useradmin ") + link_icon; - const std::string connect_label = std::string("comma connect ") + link_icon; + const std::string konn3kt_label = std::string("konn3kt ") + link_icon; if (ImGui::Button(copy_icon, ImVec2(34.0f, 26.0f))) { ImGui::SetClipboardText(session->route_id.canonical().c_str()); state->status_text = "Copied route to clipboard"; @@ -470,14 +472,9 @@ void draw_route_info_popup(AppSession *session, UiState *state, ImVec2 anchor) { ImGui::EndTooltip(); } ImGui::SameLine(); - if (ImGui::Button(useradmin_label.c_str(), ImVec2(132.0f, 26.0f))) { - open_external_url(route_useradmin_url(session->route_id)); - state->status_text = "Opened useradmin"; - } - ImGui::SameLine(); - if (ImGui::Button(connect_label.c_str(), ImVec2(156.0f, 26.0f))) { - open_external_url(route_connect_url(session->route_id)); - state->status_text = "Opened comma connect"; + if (ImGui::Button(konn3kt_label.c_str(), ImVec2(120.0f, 26.0f))) { + open_external_url(route_konn3kt_url(session->route_id)); + state->status_text = "Opened konn3kt"; } ImGui::Spacing(); diff --git a/tools/jotpluggler/sketch_layout.cc b/tools/jotpluggler/sketch_layout.cc index a8e982d..3581bff 100644 --- a/tools/jotpluggler/sketch_layout.cc +++ b/tools/jotpluggler/sketch_layout.cc @@ -24,10 +24,11 @@ #include "common/util.h" #include "third_party/json11/json11.hpp" -#include "tools/replay/api.h" #include "tools/replay/logreader.h" // IQ.Pilot patch: iqpilot's tools/replay has no py_downloader; route file listing is -// served directly by CommaApi2::httpGet against the IQ.Pilot konn3kt API. +// served by the konn3kt CommaApi2 helper, which returns the same JSON shape (and the +// same {"error": ...} envelope) as upstream's PyDownloader. +#include "tools/replay/api.h" namespace fs = std::filesystem; @@ -100,6 +101,7 @@ struct LoadedRouteArtifacts { std::vector can_messages; std::vector logs; std::vector timeline; + std::vector thumbnails; std::unordered_map enum_info; }; @@ -263,7 +265,8 @@ RouteSelection parse_route_selection(std::string route_name) { if (separator == "/") { size_t pos = range_str.find(':'); int begin_segment = 0; - if (!parse_segment_number(range_str.substr(0, pos), &begin_segment)) { + const std::string begin_str = range_str.substr(0, pos); + if (!begin_str.empty() && !parse_segment_number(begin_str, &begin_segment)) { return {}; } route.begin_segment = begin_segment; @@ -330,11 +333,7 @@ std::map load_segments_from_json(const json11::Json &json) { } std::map load_segments_from_server(const RouteSelection &route) { - // IQ.Pilot patch: replaces upstream PyDownloader::getRouteFiles with the existing - // iqpilot CommaApi2 helper (same JSON shape: /v1/route//files). - const std::string url = CommaApi2::BASE_URL + "/v1/route/" + route.canonical_name + "/files"; - long response_code = 0; - const std::string result = CommaApi2::httpGet(url, &response_code); + const std::string result = CommaApi2::getRouteFiles(route.canonical_name); if (result.empty()) throw std::runtime_error("Failed to fetch route files for " + route.canonical_name); std::string parse_error; @@ -438,7 +437,7 @@ std::array parse_color(std::string_view color) { return out; } -uint8_t android_priority_to_level(uint8_t priority) { +uint8_t operating_system_priority_to_level(uint8_t priority) { switch (priority) { case 2: case 3: @@ -497,7 +496,7 @@ void append_timeline_entry(std::vector *timeline, double mono_tim }); } -double android_wall_time_seconds(uint64_t timestamp) { +double operating_system_wall_time_seconds(uint64_t timestamp) { if (timestamp == 0) return 0.0; if (timestamp > 1000000000000ULL) return static_cast(timestamp) / 1.0e9; if (timestamp > 1000000000ULL) return static_cast(timestamp) / 1.0e6; @@ -615,13 +614,15 @@ void append_log_event(cereal::Event::Which which, logs->push_back(std::move(entry)); break; } + // IQ.Pilot patch: upstream renamed cereal's androidLog -> operatingSystemLog (#38209); + // iqpilot's cereal still carries the original field name. Only the accessor differs. case cereal::Event::Which::ANDROID_LOG: { - const auto android = event.getAndroidLog(); - auto entry = make_entry(LogOrigin::Android, android_priority_to_level(android.getPriority())); - entry.wall_time = android_wall_time_seconds(android.getTs()); - entry.source = android.hasTag() ? android.getTag().cStr() : "android"; - entry.message = android.hasMessage() ? android.getMessage().cStr() : std::string(); - entry.context = "pid=" + std::to_string(android.getPid()) + ", tid=" + std::to_string(android.getTid()); + const auto operating_system_log = event.getAndroidLog(); + auto entry = make_entry(LogOrigin::OperatingSystem, operating_system_priority_to_level(operating_system_log.getPriority())); + entry.wall_time = operating_system_wall_time_seconds(operating_system_log.getTs()); + entry.source = operating_system_log.hasTag() ? operating_system_log.getTag().cStr() : "operating_system"; + entry.message = operating_system_log.hasMessage() ? operating_system_log.getMessage().cStr() : std::string(); + entry.context = "pid=" + std::to_string(operating_system_log.getPid()) + ", tid=" + std::to_string(operating_system_log.getTid()); if (!entry.message.empty()) { std::string err; if (const auto p = json11::Json::parse(entry.message, err); err.empty() && p.is_object()) { @@ -629,10 +630,10 @@ void append_log_event(cereal::Event::Which which, if (p["SYSLOG_IDENTIFIER"].is_string() && !p["SYSLOG_IDENTIFIER"].string_value().empty()) entry.source = p["SYSLOG_IDENTIFIER"].string_value(); if (auto pri = json_int_value(p["PRIORITY"]); pri.has_value()) - entry.level = android_priority_to_level(*pri); + entry.level = operating_system_priority_to_level(*pri); if (auto ts = json_u64_value(p["__REALTIME_TIMESTAMP"]); ts.has_value()) - entry.wall_time = android_wall_time_seconds(*ts); - entry.context = format_journal_context(p, android.getPid(), android.getTid()); + entry.wall_time = operating_system_wall_time_seconds(*ts); + entry.context = format_journal_context(p, operating_system_log.getPid(), operating_system_log.getTid()); } } logs->push_back(std::move(entry)); @@ -690,6 +691,25 @@ std::vector extract_segment_logs(const std::vector &events) { return logs; } +std::vector extract_segment_thumbnails(const std::vector &events, int segment) { + std::vector thumbnails; + for (const Event &event_record : events) { + if (event_record.which != cereal::Event::Which::THUMBNAIL) continue; + with_parseable_event(event_record.data, [&](const cereal::Event::Reader &event) { + const auto thumbnail = event.getThumbnail(); + const auto jpeg = thumbnail.getThumbnail(); + if (jpeg.size() == 0) return; + const uint64_t timestamp = thumbnail.getTimestampEof(); + ThumbnailFrame frame; + frame.timestamp = static_cast(timestamp != 0 ? timestamp : event.getLogMonoTime()) / 1.0e9; + frame.segment = segment; + frame.jpeg.assign(jpeg.begin(), jpeg.end()); + thumbnails.push_back(std::move(frame)); + }); + } + return thumbnails; +} + RouteMetadata extract_segment_metadata(const std::vector &events) { RouteMetadata metadata; for (const Event &event_record : events) { @@ -802,6 +822,8 @@ Pane parse_dock_area(const json11::Json &dock_area_node) { const std::string kind = dock_area_node["kind"].string_value(); if (kind == "map") { pane.kind = PaneKind::Map; + } else if (kind == "thumbnail") { + pane.kind = PaneKind::Thumbnail; } else if (kind == "camera") { pane.kind = PaneKind::Camera; const std::string camera_view = dock_area_node["camera_view"].string_value(); @@ -910,7 +932,9 @@ void append_scalar_point(RouteSeries *series, series->values.push_back(value); } -void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { +// This has thousands of generated call sites. Inlining it duplicates vector +// growth logic throughout the extractor and is slower both to compile and run. +__attribute__((noinline)) void append_fixed_scalar_point(RouteSeries *series, double tm, double value) { series->times.push_back(tm); series->values.push_back(value); } @@ -1173,6 +1197,7 @@ RouteData build_route_data(std::vector &&series_list, std::vector &&can_messages, std::vector &&logs, std::vector &&timeline, + std::vector &&thumbnails, std::unordered_map &&enum_info, std::string car_fingerprint, std::string dbc_name) { @@ -1239,6 +1264,14 @@ RouteData build_route_data(std::vector &&series_list, route_data.x_min = timeline.front().start_time; route_data.x_max = timeline.back().end_time; } + std::sort(thumbnails.begin(), thumbnails.end(), [](const ThumbnailFrame &a, const ThumbnailFrame &b) { + return a.timestamp < b.timestamp; + }); + if (!route_data.has_time_range && !thumbnails.empty()) { + route_data.has_time_range = true; + route_data.x_min = thumbnails.front().timestamp; + route_data.x_max = thumbnails.back().timestamp; + } if (route_data.has_time_range) { const double time_offset = route_data.x_min; @@ -1260,6 +1293,9 @@ RouteData build_route_data(std::vector &&series_list, entry.start_time -= time_offset; entry.end_time -= time_offset; } + for (ThumbnailFrame &thumbnail : thumbnails) { + thumbnail.timestamp -= time_offset; + } route_data.x_max -= time_offset; route_data.x_min = 0.0; } @@ -1277,6 +1313,7 @@ RouteData build_route_data(std::vector &&series_list, merged_timeline.push_back(std::move(entry)); } route_data.timeline = std::move(merged_timeline); + route_data.thumbnails = std::move(thumbnails); std::sort(can_messages.begin(), can_messages.end(), [](const CanMessageData &a, const CanMessageData &b) { return std::make_tuple(a.id.service, a.id.bus, a.id.address) < std::make_tuple(b.id.service, b.id.bus, b.id.address); @@ -1530,6 +1567,7 @@ LoadedRouteArtifacts load_route_series_parallel( SeriesAccumulator series; std::vector logs; std::vector timeline; + std::vector thumbnails; }; const std::vector> segment_list(segments.begin(), segments.end()); @@ -1579,15 +1617,12 @@ LoadedRouteArtifacts load_route_series_parallel( continue; } - // IQ.Pilot patch: iqpilot's LogReader does not expose load-time telemetry - // (download/decompress/parse seconds, compressed/decompressed sizes). Stub - // these to 0 so the loader UI still renders without per-segment diagnostics. - segment_stats.download_seconds = 0.0; - segment_stats.decompress_seconds = 0.0; - segment_stats.parse_seconds = 0.0; - segment_stats.compressed_bytes = 0; - segment_stats.decompressed_bytes = 0; - stats->bytes_downloaded.fetch_add(0); + segment_stats.download_seconds = reader.download_seconds(); + segment_stats.decompress_seconds = reader.decompress_seconds(); + segment_stats.parse_seconds = reader.parse_seconds(); + segment_stats.compressed_bytes = reader.compressed_size(); + segment_stats.decompressed_bytes = reader.decompressed_size(); + stats->bytes_downloaded.fetch_add(reader.compressed_size()); stats->segments_downloaded.fetch_add(1); stats->publish(RouteLoadStage::DownloadingSegment, index, std::to_string(segment_number)); @@ -1595,6 +1630,7 @@ LoadedRouteArtifacts load_route_series_parallel( results[index].series = extract_segment_series(reader.events, schema, can_dbc, skip_raw_can, worker_budget, segment_workers); results[index].logs = extract_segment_logs(reader.events); results[index].timeline = extract_segment_timeline(reader.events); + results[index].thumbnails = extract_segment_thumbnails(reader.events, segment_number); segment_stats.extract_seconds = std::chrono::duration(LoadStats::Clock::now() - extract_start).count(); segment_stats.event_count = reader.events.size(); segment_stats.series_count = populated_series_count(results[index].series); @@ -1621,6 +1657,7 @@ LoadedRouteArtifacts load_route_series_parallel( } std::vector logs; std::vector timeline; + std::vector thumbnails; for (SegmentResult &result : results) { if (!result.logs.empty()) { logs.insert(logs.end(), @@ -1632,12 +1669,18 @@ LoadedRouteArtifacts load_route_series_parallel( std::make_move_iterator(result.timeline.begin()), std::make_move_iterator(result.timeline.end())); } + if (!result.thumbnails.empty()) { + thumbnails.insert(thumbnails.end(), + std::make_move_iterator(result.thumbnails.begin()), + std::make_move_iterator(result.thumbnails.end())); + } } LoadedRouteArtifacts artifacts; artifacts.series = collect_series(std::move(merged)); artifacts.can_messages = std::move(merged.can_messages); artifacts.logs = std::move(logs); artifacts.timeline = std::move(timeline); + artifacts.thumbnails = std::move(thumbnails); artifacts.enum_info = std::move(merged.enum_info); stats->merge_end = LoadStats::Clock::now(); return artifacts; @@ -1843,6 +1886,7 @@ RouteData load_route_data(const std::string &route_name, std::move(artifacts.can_messages), std::move(artifacts.logs), std::move(artifacts.timeline), + std::move(artifacts.thumbnails), std::move(artifacts.enum_info), metadata.car_fingerprint, resolved_dbc); diff --git a/tools/jotpluggler/test_jotpluggler.py b/tools/jotpluggler/test_jotpluggler.py new file mode 100644 index 0000000..9834f27 --- /dev/null +++ b/tools/jotpluggler/test_jotpluggler.py @@ -0,0 +1,53 @@ +import subprocess +from pathlib import Path + +import pytest + +JOTPLUGGLER_DIR = Path(__file__).parent +JOTPLUGGLER_BIN = JOTPLUGGLER_DIR / "jotpluggler" + +pytestmark = pytest.mark.skipif(not JOTPLUGGLER_BIN.exists(), + reason="jotpluggler not built (scons -u tools/jotpluggler)") + + +def run_jotpluggler(*args): + return subprocess.run([str(JOTPLUGGLER_BIN), *args], cwd=JOTPLUGGLER_DIR, + capture_output=True, text=True, timeout=60) + + +class TestJotpluggler: + def test_help(self): + result = run_jotpluggler("-h") + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stderr + + def test_generated_dbcs_materialized(self): + # the build materializes iqdbc's *_generated.dbc files; layout.cc and + # sketch_layout.cc both resolve DBC names against this directory + generated = JOTPLUGGLER_DIR / "generated_dbcs" + assert generated.is_dir() + assert list(generated.glob("*.dbc")), "no generated DBCs — iqdbc create_all did not run" + + def test_car_fingerprint_header_uses_iqdbc_platforms(self): + header = (JOTPLUGGLER_DIR / "car_fingerprint_to_dbc.h").read_text() + assert "kCarFingerprintToDbc" in header + assert "dbc_for_car_fingerprint" in header + + def test_bootstrap_icons_vendored(self): + # IQ.Pilot vendors the TTF because third_party/bootstrap is git-lfs; icons.cc + # reads this exact path instead of upstream's BOOTSTRAP_ICONS_TTF define + assert (JOTPLUGGLER_DIR / "assets" / "bootstrap-icons.ttf").is_file() + + def test_no_comma_connect_links(self): + # iqpilot routes live on konn3kt; the comma connect / useradmin buttons are gone + common = (JOTPLUGGLER_DIR / "common.cc").read_text() + assert "connect.comma.ai" not in common + assert "useradmin.comma.ai" not in common + assert "route_konn3kt_url" in common + + def test_route_files_go_through_konn3kt_api(self): + # upstream calls PyDownloader::getRouteFiles here; iqpilot has no py_downloader + sketch = (JOTPLUGGLER_DIR / "sketch_layout.cc").read_text() + assert "PyDownloader::" not in sketch + assert "py_downloader.h" not in sketch + assert "CommaApi2::getRouteFiles" in sketch diff --git a/tools/jotpluggler/thumbnail.cc b/tools/jotpluggler/thumbnail.cc new file mode 100644 index 0000000..11e1843 --- /dev/null +++ b/tools/jotpluggler/thumbnail.cc @@ -0,0 +1,253 @@ +#include "tools/jotpluggler/thumbnail.h" + +#include "imgui_impl_opengl3_loader.h" + +#include +#include +#include + +extern "C" { +#include +#include +} + +namespace { + +bool decode_jpeg(const std::vector &jpeg, int *width, int *height, std::vector *rgba) { + if (jpeg.empty()) return false; + + const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_MJPEG); + AVCodecContext *context = codec != nullptr ? avcodec_alloc_context3(codec) : nullptr; + AVFrame *frame = av_frame_alloc(); + AVPacket *packet = av_packet_alloc(); + if (context == nullptr || frame == nullptr || packet == nullptr) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + const bool packet_ready = jpeg.size() <= static_cast(std::numeric_limits::max()) + && av_new_packet(packet, static_cast(jpeg.size())) >= 0; + if (packet_ready) { + std::copy(jpeg.begin(), jpeg.end(), packet->data); + } + const bool decoded = packet_ready + && avcodec_open2(context, codec, nullptr) >= 0 + && avcodec_send_packet(context, packet) >= 0 + && avcodec_receive_frame(context, frame) >= 0; + if (!decoded || frame->width <= 0 || frame->height <= 0) { + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + int chroma_x_shift = 0; + int chroma_y_shift = 0; + switch (static_cast(frame->format)) { + case AV_PIX_FMT_YUV420P: + case AV_PIX_FMT_YUVJ420P: + chroma_x_shift = 1; + chroma_y_shift = 1; + break; + case AV_PIX_FMT_YUV422P: + case AV_PIX_FMT_YUVJ422P: + chroma_x_shift = 1; + break; + case AV_PIX_FMT_YUV444P: + case AV_PIX_FMT_YUVJ444P: + break; + default: + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return false; + } + + *width = frame->width; + *height = frame->height; + rgba->resize(static_cast(*width) * static_cast(*height) * 4U); + const bool full_range = frame->color_range == AVCOL_RANGE_JPEG + || frame->format == AV_PIX_FMT_YUVJ420P + || frame->format == AV_PIX_FMT_YUVJ422P + || frame->format == AV_PIX_FMT_YUVJ444P; + for (int y = 0; y < *height; ++y) { + const uint8_t *y_row = frame->data[0] + y * frame->linesize[0]; + const uint8_t *u_row = frame->data[1] + (y >> chroma_y_shift) * frame->linesize[1]; + const uint8_t *v_row = frame->data[2] + (y >> chroma_y_shift) * frame->linesize[2]; + uint8_t *out = rgba->data() + static_cast(y) * static_cast(*width) * 4U; + for (int x = 0; x < *width; ++x) { + const double luma = full_range ? static_cast(y_row[x]) + : 1.164383 * (static_cast(y_row[x]) - 16.0); + const double u = static_cast(u_row[x >> chroma_x_shift]) - 128.0; + const double v = static_cast(v_row[x >> chroma_x_shift]) - 128.0; + const double red = luma + (full_range ? 1.402 : 1.596027) * v; + const double green = luma - (full_range ? 0.344136 : 0.391762) * u + - (full_range ? 0.714136 : 0.812968) * v; + const double blue = luma + (full_range ? 1.772 : 2.017232) * u; + out[x * 4 + 0] = static_cast(std::clamp(std::lround(red), 0L, 255L)); + out[x * 4 + 1] = static_cast(std::clamp(std::lround(green), 0L, 255L)); + out[x * 4 + 2] = static_cast(std::clamp(std::lround(blue), 0L, 255L)); + out[x * 4 + 3] = 255; + } + } + + av_packet_free(&packet); + av_frame_free(&frame); + avcodec_free_context(&context); + return true; +} + +std::string format_thumbnail_time(double seconds) { + const int rounded = std::max(0, static_cast(std::lround(seconds))); + const int hours = rounded / 3600; + const int minutes = (rounded % 3600) / 60; + const int secs = rounded % 60; + if (hours > 0) { + return util::string_format("%d:%02d:%02d", hours, minutes, secs); + } + return util::string_format("%02d:%02d", minutes, secs); +} + +} // namespace + +struct ThumbnailView::Impl { + ~Impl() { + destroy_texture(); + } + + void setThumbnails(const std::vector &next_thumbnails) { + destroy_texture(); + thumbnails = &next_thumbnails; + displayed_index = -1; + failed_index = -1; + } + + void update(double tracker_time) { + if (thumbnails == nullptr || thumbnails->empty()) return; + auto it = std::lower_bound(thumbnails->begin(), thumbnails->end(), tracker_time, + [](const ThumbnailFrame &frame, double time) { + return frame.timestamp < time; + }); + if (it == thumbnails->end()) { + it = std::prev(thumbnails->end()); + } else if (it != thumbnails->begin()) { + const auto previous = std::prev(it); + if (std::abs(previous->timestamp - tracker_time) <= std::abs(it->timestamp - tracker_time)) { + it = previous; + } + } + const int index = static_cast(std::distance(thumbnails->begin(), it)); + if (index == displayed_index || index == failed_index) return; + + int width = 0; + int height = 0; + std::vector rgba; + if (!decode_jpeg(it->jpeg, &width, &height, &rgba)) { + failed_index = index; + return; + } + + if (texture == 0) { + glGenTextures(1, &texture); + } + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, rgba.data()); + glBindTexture(GL_TEXTURE_2D, 0); + texture_width = width; + texture_height = height; + displayed_index = index; + failed_index = -1; + } + + void drawSized(ImVec2 size, bool loading) const { + size.x = std::max(1.0f, size.x); + size.y = std::max(1.0f, size.y); + ImGui::InvisibleButton("##thumbnail_sized", size); + const ImVec2 pane_min = ImGui::GetItemRectMin(); + const ImVec2 pane_max = ImGui::GetItemRectMax(); + ImDrawList *draw_list = ImGui::GetWindowDrawList(); + draw_list->AddRectFilled(pane_min, pane_max, IM_COL32(24, 24, 24, 255)); + + if (texture != 0 && texture_width > 0 && texture_height > 0) { + const float scale = std::min(size.x / static_cast(texture_width), + size.y / static_cast(texture_height)); + const ImVec2 image_size(static_cast(texture_width) * scale, + static_cast(texture_height) * scale); + const ImVec2 image_min(pane_min.x + (size.x - image_size.x) * 0.5f, + pane_min.y + (size.y - image_size.y) * 0.5f); + const ImVec2 image_max(image_min.x + image_size.x, image_min.y + image_size.y); + draw_list->AddImage(static_cast(texture), image_min, image_max); + + if (thumbnails != nullptr && displayed_index >= 0 + && displayed_index < static_cast(thumbnails->size())) { + const ThumbnailFrame &frame = (*thumbnails)[static_cast(displayed_index)]; + const std::string label = util::string_format("%s · segment %d · %d/%zu", + format_thumbnail_time(frame.timestamp).c_str(), + frame.segment, + displayed_index + 1, + thumbnails->size()); + const ImVec2 text_size = ImGui::CalcTextSize(label.c_str()); + const ImVec2 label_min(image_min.x, std::max(image_min.y, image_max.y - text_size.y - 14.0f)); + draw_list->AddRectFilled(label_min, image_max, IM_COL32(0, 0, 0, 175)); + draw_list->AddText(ImVec2(label_min.x + 7.0f, label_min.y + 7.0f), IM_COL32_WHITE, label.c_str()); + } + return; + } + + const bool has_thumbnails = thumbnails != nullptr && !thumbnails->empty(); + const char *label = loading ? "loading" : (has_thumbnails ? "invalid thumbnail" : "no thumbnails"); + const ImVec2 text_size = ImGui::CalcTextSize(label); + draw_list->AddText(ImVec2(pane_min.x + (size.x - text_size.x) * 0.5f, + pane_min.y + (size.y - text_size.y) * 0.5f), + IM_COL32(187, 187, 187, 255), label); + } + + void destroy_texture() { + if (texture != 0) { + glDeleteTextures(1, &texture); + } + texture = 0; + texture_width = 0; + texture_height = 0; + } + + const std::vector *thumbnails = nullptr; + int displayed_index = -1; + int failed_index = -1; + GLuint texture = 0; + int texture_width = 0; + int texture_height = 0; +}; + +ThumbnailView::ThumbnailView() : impl_(std::make_unique()) {} +ThumbnailView::~ThumbnailView() = default; + +void ThumbnailView::setThumbnails(const std::vector &thumbnails) { + impl_->setThumbnails(thumbnails); +} + +void ThumbnailView::update(double tracker_time) { + impl_->update(tracker_time); +} + +void ThumbnailView::drawSized(ImVec2 size, bool loading) { + impl_->drawSized(size, loading); +} + +void draw_thumbnail_pane(AppSession *session, UiState *state) { + if (session->thumbnail_view == nullptr) { + ImGui::TextDisabled("Thumbnails unavailable"); + return; + } + if (state->has_tracker_time) { + session->thumbnail_view->update(state->tracker_time); + } + session->thumbnail_view->drawSized(ImGui::GetContentRegionAvail(), session->async_route_loading); +} diff --git a/tools/jotpluggler/thumbnail.h b/tools/jotpluggler/thumbnail.h new file mode 100644 index 0000000..6970173 --- /dev/null +++ b/tools/jotpluggler/thumbnail.h @@ -0,0 +1,5 @@ +#pragma once + +#include "tools/jotpluggler/app.h" + +void draw_thumbnail_pane(AppSession *session, UiState *state); diff --git a/tools/joystick/joystickd.py b/tools/joystick/joystickd.py index f7b1ea4..8ccfe9c 100755 --- a/tools/joystick/joystickd.py +++ b/tools/joystick/joystickd.py @@ -3,7 +3,7 @@ import math import numpy as np -from cereal import messaging, car +from cereal import messaging, car, custom from iqdbc.car.vehicle_model import VehicleModel from openpilot.common.realtime import DT_CTRL, Ratekeeper from openpilot.common.params import Params @@ -35,6 +35,7 @@ def joystickd_thread(): params = Params() cloudlog.info("joystickd is waiting for CarParams") CP = messaging.log_from_bytes(params.get("CarParams", block=True), car.CarParams) + CP_IQ = messaging.log_from_bytes(params.get("IQCarParams", block=True), custom.IQCarParams) VM = VehicleModel(CP) sm = messaging.SubMaster(['carState', 'onroadEvents', 'liveParameters', 'selfdriveState', 'iqState', 'testJoystick'], frequency=1. / DT_CTRL) @@ -64,7 +65,9 @@ def joystickd_thread(): CC.enabled = bool(ss.enabled or aol_enabled) CC.latActive = bool(ss.active or joystick_angle_lat_active) and not sm['carState'].steerFaultTemporary and not sm['carState'].steerFaultPermanent - CC.longActive = bool(ss.enabled) and not any(e.overrideLongitudinal for e in sm['onroadEvents']) and CP.openpilotLongitudinalControl + long_through_override = CP_IQ.longActiveWithGasOverride and CP.openpilotLongitudinalControl + override_longitudinal = any(e.overrideLongitudinal for e in sm['onroadEvents']) + CC.longActive = bool(ss.enabled) and (not override_longitudinal or long_through_override) and CP.openpilotLongitudinalControl CC.cruiseControl.cancel = sm['carState'].cruiseState.enabled and (not CC.enabled or not CP.pcmCruise) CC.hudControl.leadDistanceBars = 2 diff --git a/tools/replay/.gitignore b/tools/replay/.gitignore index 83f0e99..47f5168 100644 --- a/tools/replay/.gitignore +++ b/tools/replay/.gitignore @@ -3,3 +3,4 @@ moc_* replay tests/test_replay +tests/test_api diff --git a/tools/replay/SConscript b/tools/replay/SConscript index 136c411..951d4b9 100644 --- a/tools/replay/SConscript +++ b/tools/replay/SConscript @@ -22,3 +22,4 @@ replay_env.Program("replay", ["main.cc"], LIBS=replay_libs, FRAMEWORKS=base_fram if GetOption('extras'): replay_env.Program('tests/test_replay', ['tests/test_replay.cc'], LIBS=replay_libs) + replay_env.Program('tests/test_api', ['tests/test_runner.cc', 'tests/test_api.cc'], LIBS=replay_libs) diff --git a/tools/replay/api.cc b/tools/replay/api.cc index 092b161..53cfe39 100644 --- a/tools/replay/api.cc +++ b/tools/replay/api.cc @@ -160,4 +160,54 @@ std::string httpGet(const std::string &url, long *response_code) { return res == CURLE_OK ? readBuffer : std::string{}; } +std::string apiResponse(const std::string &body, long response_code) { + if (response_code == 401 || response_code == 403) { + return R"({"error": "unauthorized"})"; + } + if (response_code == 404) { + return R"({"error": "not_found"})"; + } + if (body.empty() || response_code < 200 || response_code >= 300) { + return R"({"error": "network"})"; + } + return body; +} + +std::string routeFilesPath(const std::string &route) { + return "v1/route/" + route + "/files"; +} + +std::string devicesPath() { + return "v1/me/devices/"; +} + +std::string deviceRoutesPath(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + if (preserved) { + return "v1/devices/" + dongle_id + "/routes/preserved"; + } + + std::string query; + if (start_ms > 0) query += "start=" + std::to_string(start_ms); + if (end_ms > 0) query += (query.empty() ? "" : "&") + ("end=" + std::to_string(end_ms)); + return "v1/devices/" + dongle_id + "/routes_segments" + (query.empty() ? "" : "?" + query); +} + +static std::string apiCall(const std::string &path) { + long response_code = 0; + const std::string body = httpGet(BASE_URL + "/" + path, &response_code); + return apiResponse(body, response_code); +} + +std::string getRouteFiles(const std::string &route) { + return apiCall(routeFilesPath(route)); +} + +std::string getDevices() { + return apiCall(devicesPath()); +} + +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved) { + return apiCall(deviceRoutesPath(dongle_id, start_ms, end_ms, preserved)); +} + } // namespace CommaApi diff --git a/tools/replay/api.h b/tools/replay/api.h index caa53c7..77f73cd 100644 --- a/tools/replay/api.h +++ b/tools/replay/api.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "common/util.h" @@ -12,4 +13,21 @@ const std::string BASE_URL = util::getenv("API_HOST", "https://api-iqlabs.konn3k std::string create_token(bool use_jwt, const json11::Json& payloads = {}, int expiry = 3600); std::string httpGet(const std::string &url, long *response_code = nullptr); +// konn3kt equivalents of upstream's PyDownloader API helpers. Same endpoints and +// same response shapes, but served over the C++/libcurl path instead of shelling +// into tools/lib. On failure they return {"error": ""} so callers can tell +// unauthorized from a transport error without a second out-param. +std::string getRouteFiles(const std::string &route); +std::string getDevices(); +std::string getDeviceRoutes(const std::string &dongle_id, int64_t start_ms = 0, int64_t end_ms = 0, bool preserved = false); + +// Endpoint paths, relative to BASE_URL. Split out from the fetchers so the konn3kt +// routing can be asserted without network access. +std::string routeFilesPath(const std::string &route); +std::string devicesPath(); +std::string deviceRoutesPath(const std::string &dongle_id, int64_t start_ms, int64_t end_ms, bool preserved); + +// Maps an HTTP status + body to either the body or an {"error": ...} envelope. +std::string apiResponse(const std::string &body, long response_code); + } // namespace CommaApi2 diff --git a/tools/replay/framereader.cc b/tools/replay/framereader.cc index 96ec591..c3c60ac 100644 --- a/tools/replay/framereader.cc +++ b/tools/replay/framereader.cc @@ -6,7 +6,7 @@ #include #include "common/util.h" -#include "third_party/libyuv/include/libyuv.h" +#include "common/yuv.h" #include "tools/replay/util.h" #include "system/hardware/hw.h" @@ -257,12 +257,12 @@ bool FFmpegVideoDecoder::copyBuffer(AVFrame *f, VisionBuf *buf) { memcpy(buf->uv + i*buf->stride, f->data[1] + i*f->linesize[1], width); } } else { - libyuv::I420ToNV12(f->data[0], f->linesize[0], - f->data[1], f->linesize[1], - f->data[2], f->linesize[2], - buf->y, buf->stride, - buf->uv, buf->stride, - width, height); + yuv::i420_to_nv12(f->data[0], f->linesize[0], + f->data[1], f->linesize[1], + f->data[2], f->linesize[2], + buf->y, buf->stride, + buf->uv, buf->stride, + width, height); } return true; } diff --git a/tools/replay/logreader.cc b/tools/replay/logreader.cc index 75abb84..5e357c7 100644 --- a/tools/replay/logreader.cc +++ b/tools/replay/logreader.cc @@ -1,19 +1,31 @@ #include "tools/replay/logreader.h" #include +#include #include #include "tools/replay/filereader.h" #include "tools/replay/util.h" #include "common/util.h" bool LogReader::load(const std::string &url, std::atomic *abort, bool local_cache, int chunk_size, int retries) { + using Clock = std::chrono::steady_clock; + compressed_size_ = decompressed_size_ = 0; + download_seconds_ = decompress_seconds_ = parse_seconds_ = 0.0; + + const auto download_start = Clock::now(); std::string data = FileReader(local_cache, chunk_size, retries).read(url, abort); + download_seconds_ = std::chrono::duration(Clock::now() - download_start).count(); + compressed_size_ = decompressed_size_ = data.size(); + if (!data.empty()) { + const auto decompress_start = Clock::now(); if (url.find(".bz2") != std::string::npos || util::starts_with(data, "BZh9")) { data = decompressBZ2(data, abort); } else if (url.find(".zst") != std::string::npos || util::starts_with(data, "\x28\xB5\x2F\xFD")) { data = decompressZST(data, abort); } + decompress_seconds_ = std::chrono::duration(Clock::now() - decompress_start).count(); + decompressed_size_ = data.size(); } bool success = !data.empty() && load(data.data(), data.size(), abort); @@ -23,6 +35,8 @@ bool LogReader::load(const std::string &url, std::atomic *abort, bool loca } bool LogReader::load(const char *data, size_t size, std::atomic *abort) { + using Clock = std::chrono::steady_clock; + const auto parse_start = Clock::now(); try { events.reserve(65000); kj::ArrayPtr words((const capnp::word *)data, size / sizeof(capnp::word)); @@ -65,6 +79,8 @@ bool LogReader::load(const char *data, size_t size, std::atomic *abort) { migrateOldEvents(); } + parse_seconds_ = std::chrono::duration(Clock::now() - parse_start).count(); + if (!events.empty() && !(abort && *abort)) { events.shrink_to_fit(); std::sort(events.begin(), events.end()); diff --git a/tools/replay/logreader.h b/tools/replay/logreader.h index f8d60ff..2a861dc 100644 --- a/tools/replay/logreader.h +++ b/tools/replay/logreader.h @@ -32,6 +32,15 @@ public: bool load(const char *data, size_t size, std::atomic *abort = nullptr); std::vector events; + // Per-load instrumentation, consumed by jotpluggler's route load stats panel. + // Unlike upstream these are measured around iqpilot's own FileReader/decompress + // steps rather than around a Python downloader, so decompress is timed separately. + uint64_t compressed_size() const { return compressed_size_; } + uint64_t decompressed_size() const { return decompressed_size_; } + double download_seconds() const { return download_seconds_; } + double decompress_seconds() const { return decompress_seconds_; } + double parse_seconds() const { return parse_seconds_; } + private: void migrateOldEvents(); @@ -39,4 +48,9 @@ private: bool requires_migration = true; std::vector filters_; MonotonicBuffer buffer_{1024 * 1024}; + uint64_t compressed_size_ = 0; + uint64_t decompressed_size_ = 0; + double download_seconds_ = 0.0; + double decompress_seconds_ = 0.0; + double parse_seconds_ = 0.0; }; diff --git a/tools/replay/tests/test_api.cc b/tools/replay/tests/test_api.cc new file mode 100644 index 0000000..e86565b --- /dev/null +++ b/tools/replay/tests/test_api.cc @@ -0,0 +1,72 @@ +#include "catch2/catch.hpp" + +#include "tools/replay/api.h" + +// These cover the konn3kt endpoints that replaced upstream's PyDownloader shell-outs. +// Cabana's remote-routes dialog and jotpluggler's route-file listing both route through +// here, so a silent change to a path or to the error envelope breaks konn3kt without +// breaking the build. + +TEST_CASE("konn3kt api paths") { + SECTION("route files") { + REQUIRE(CommaApi2::routeFilesPath("a2a0ccea32023010|2023-07-27--13-01-19") == + "v1/route/a2a0ccea32023010|2023-07-27--13-01-19/files"); + } + + SECTION("devices") { + REQUIRE(CommaApi2::devicesPath() == "v1/me/devices/"); + } + + SECTION("device routes over a time range") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 2000, false) == + "v1/devices/dongle/routes_segments?start=1000&end=2000"); + } + + SECTION("device routes with only one bound") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 0, false) == + "v1/devices/dongle/routes_segments?start=1000"); + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 0, 2000, false) == + "v1/devices/dongle/routes_segments?end=2000"); + } + + SECTION("device routes with no bounds omits the query entirely") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 0, 0, false) == + "v1/devices/dongle/routes_segments"); + } + + SECTION("preserved routes ignore the time range") { + REQUIRE(CommaApi2::deviceRoutesPath("dongle", 1000, 2000, true) == + "v1/devices/dongle/routes/preserved"); + } +} + +TEST_CASE("konn3kt api error envelope") { + // Cabana and jotpluggler both branch on these exact strings. + SECTION("success passes the body through") { + REQUIRE(CommaApi2::apiResponse("[{\"dongle_id\": \"abc\"}]", 200) == "[{\"dongle_id\": \"abc\"}]"); + } + + SECTION("401 and 403 both map to unauthorized") { + REQUIRE(CommaApi2::apiResponse("", 401) == R"({"error": "unauthorized"})"); + REQUIRE(CommaApi2::apiResponse("nope", 403) == R"({"error": "unauthorized"})"); + } + + SECTION("404 maps to not_found") { + REQUIRE(CommaApi2::apiResponse("", 404) == R"({"error": "not_found"})"); + } + + SECTION("transport failure and 5xx map to network") { + REQUIRE(CommaApi2::apiResponse("", 0) == R"({"error": "network"})"); + REQUIRE(CommaApi2::apiResponse("boom", 500) == R"({"error": "network"})"); + } + + SECTION("a 200 with an empty body is still an error") { + REQUIRE(CommaApi2::apiResponse("", 200) == R"({"error": "network"})"); + } +} + +TEST_CASE("konn3kt base url") { + // Everything is built off BASE_URL; an accidental revert to comma's host would + // silently point every tool at api.comma.ai. + REQUIRE(CommaApi2::BASE_URL.find("comma.ai") == std::string::npos); +} diff --git a/tools/replay/tests/test_runner.cc b/tools/replay/tests/test_runner.cc new file mode 100644 index 0000000..62bf747 --- /dev/null +++ b/tools/replay/tests/test_runner.cc @@ -0,0 +1,2 @@ +#define CATCH_CONFIG_MAIN +#include "catch2/catch.hpp" diff --git a/site_scons/site_init.py b/tools/scons/site_init.py similarity index 86% rename from site_scons/site_init.py rename to tools/scons/site_init.py index ed400ed..465d7e2 100644 --- a/site_scons/site_init.py +++ b/tools/scons/site_init.py @@ -19,8 +19,10 @@ _GREEN = "\033[38;5;114m" _RED = "\033[38;5;203m" _RST = "\033[0m" +_READING = "scons: Reading SConscript files ..." + _PHASES = { - "scons: Reading SConscript files ...": f"{_DIM}reading sconscripts…{_RST}", + _READING: f"{_DIM}reading sconscripts…{_RST}", "scons: done reading SConscript files.": f"{_DIM}sconscripts read{_RST}", "scons: Building targets ...": f"{_BLUE}building…{_RST}", "scons: done building targets.": f"{_GREEN}✓ build complete{_RST}", @@ -66,6 +68,15 @@ if not isinstance(_main.display, _Restyle): _main.display = _Restyle(_main.display, _clean) +# SConstruct imports this instead of scons auto-loading a root site_scons dir, so the reading +# banner is already on screen by now; rewrite that one line in place +if not getattr(_main, "_iq_banner_restyled", False): + _main._iq_banner_restyled = True + if _tty() and _main.progress_display.print_it: + sys.stdout.write(f"\033[F\033[2K{_PHASES[_READING]}\n") + sys.stdout.flush() + + # drop only "Could not remove ... No such file" during clean; real errors still print import builtins as _builtins diff --git a/site_scons/site_tools/cython.py b/tools/scons/site_tools/cython.py similarity index 100% rename from site_scons/site_tools/cython.py rename to tools/scons/site_tools/cython.py diff --git a/site_scons/site_tools/pretty.py b/tools/scons/site_tools/pretty.py similarity index 100% rename from site_scons/site_tools/pretty.py rename to tools/scons/site_tools/pretty.py diff --git a/tools/test_tools_local_route.py b/tools/test_tools_local_route.py new file mode 100644 index 0000000..ad872f8 --- /dev/null +++ b/tools/test_tools_local_route.py @@ -0,0 +1,117 @@ +"""End-to-end check that cabana and jotpluggler can still load a route and decode CAN. + +Uses a synthesized local route so it runs offline — the konn3kt API paths are covered +separately by tools/replay/tests/test_api.cc and the per-tool konn3kt tests. +""" +import os +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +import cereal.messaging as messaging +from cereal import log + +TOOLS_DIR = Path(__file__).parent +CABANA_BIN = TOOLS_DIR / "cabana" / "_cabana" +JOTPLUGGLER_BIN = TOOLS_DIR / "jotpluggler" / "jotpluggler" + +DONGLE_ID = "0000000000000000" +TIMESTAMP = "2024-01-01--00-00-00" +ROUTE = f"{DONGLE_ID}|{TIMESTAMP}" + + +def write_rlog(path: Path, n_frames: int = 200): + """A minimal but valid rlog: carParams once, then CAN frames at 100Hz.""" + with open(path, "wb") as f: + cp = messaging.new_message('carParams') + cp.carParams.carFingerprint = "TOYOTA_RAV4_TSS2" + cp.carParams.brand = "toyota" + f.write(cp.to_bytes()) + + for i in range(n_frames): + msg = messaging.new_message('can', 2) + msg.logMonoTime = int(i * 1e7) + for j, addr in enumerate((0x1D2, 0x260)): + msg.can[j].address = addr + msg.can[j].src = 0 + msg.can[j].dat = bytes([i % 256] * 8) + f.write(msg.to_bytes()) + + +@pytest.fixture(scope="module") +def local_route(tmp_path_factory): + data_dir = tmp_path_factory.mktemp("routes") + for seg in range(2): + seg_dir = data_dir / f"{DONGLE_ID}|{TIMESTAMP}--{seg}" + seg_dir.mkdir() + write_rlog(seg_dir / "rlog") + return data_dir + + +def run(cmd, timeout=180): + env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env, + cwd=TOOLS_DIR.parent) + + +@pytest.mark.skipif(not JOTPLUGGLER_BIN.exists(), reason="jotpluggler not built") +def test_jotpluggler_renders_a_local_route(local_route, tmp_path): + out = tmp_path / "plot.png" + result = run([str(JOTPLUGGLER_BIN), "--data-dir", str(local_route), + "--sync-load", "--output", str(out), ROUTE]) + assert result.returncode == 0, result.stdout + result.stderr + assert out.is_file(), result.stdout + result.stderr + # a blank/failed render still writes a file, so require real content + assert out.stat().st_size > 5000, f"suspiciously small render: {out.stat().st_size} bytes" + + +@pytest.mark.skipif(not CABANA_BIN.exists(), reason="cabana not built") +def test_cabana_loads_a_local_route(local_route): + # cabana has no headless render mode: on success it enters the Qt event loop and + # never returns, so run it briefly and assert on what it logged before we kill it. + # A load failure exits before the event loop and logs "failed to load route". + env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"} + proc = subprocess.Popen([str(CABANA_BIN), "--data_dir", str(local_route), "--no-vipc", ROUTE], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env=env, cwd=TOOLS_DIR.parent) + loaded = f"loaded route {ROUTE} with 2 valid segments" + lines = [] + deadline = time.monotonic() + 60 + try: + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: # cabana exited, which means the load failed + break + lines.append(line) + if loaded in line: + break + finally: + proc.kill() + proc.wait() + proc.stdout.close() + + out = "".join(lines) + assert "failed to load route" not in out, out + assert "invalid route format" not in out, out + assert loaded in out, out + + +@pytest.mark.skipif(not shutil.which("python3"), reason="no python3") +def test_replay_logreader_reports_load_stats(local_route): + """LogReader gained download/decompress/parse instrumentation during the sync; + jotpluggler's load-stats panel reads it.""" + header = (TOOLS_DIR / "replay" / "logreader.h").read_text() + for accessor in ("compressed_size", "decompressed_size", "download_seconds", + "decompress_seconds", "parse_seconds"): + assert f"{accessor}() const" in header + + +def test_can_capnp_field_matches_extractor_codegen(): + """jotpluggler's generated extractor reads busTimeDEPRECATED; if cereal is ever + renamed to upstream's `deprecated` group this must be re-patched.""" + assert 'busTimeDEPRECATED' in log.CanData.schema.fields + gen = (TOOLS_DIR / "jotpluggler" / "generate_event_extractors.py").read_text() + assert "getBusTimeDEPRECATED()" in gen
Disengaged