From a41459f10bf6a450fa5bf82d827e1064b18dc022 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Sat, 21 Mar 2026 10:34:04 +0000 Subject: [PATCH] R-000139, R-000140: BLE CLI tools + full OpusBike webapp --- CLAUDE.md | 98 +++++ Dockerfile | 4 + Dockerfile.relay | 7 + cli/bridge.js | 594 +++++++++++++++++++++++++++ cli/package.json | 15 + cli/test_bleak.py | 595 +++++++++++++++++++++++++++ docker-compose.yml | 26 ++ nginx.conf | 39 ++ server/package.json | 13 + server/relay.js | 138 +++++++ webapp/css/style.css | 650 ++++++++++++++++++++++++++++++ webapp/icons/apple-touch-icon.png | Bin 0 -> 10868 bytes webapp/icons/favicon.svg | 11 + webapp/icons/icon-192.png | Bin 0 -> 11459 bytes webapp/icons/icon-512.png | Bin 0 -> 32554 bytes webapp/icons/proactys-logo.png | Bin 0 -> 80586 bytes webapp/icons/qr-code.png | Bin 0 -> 466 bytes webapp/index.html | 196 +++++++++ webapp/js/app.js | 349 ++++++++++++++++ webapp/js/bleService.js | 141 +++++++ webapp/js/boschProtocol.js | 160 ++++++++ webapp/js/qrcode.js | 318 +++++++++++++++ webapp/js/sw.js | 56 +++ webapp/js/wsRelay.js | 133 ++++++ webapp/manifest.json | 24 ++ 25 files changed, 3567 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 Dockerfile.relay create mode 100644 cli/bridge.js create mode 100644 cli/package.json create mode 100644 cli/test_bleak.py create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 server/package.json create mode 100644 server/relay.js create mode 100644 webapp/css/style.css create mode 100644 webapp/icons/apple-touch-icon.png create mode 100644 webapp/icons/favicon.svg create mode 100644 webapp/icons/icon-192.png create mode 100644 webapp/icons/icon-512.png create mode 100644 webapp/icons/proactys-logo.png create mode 100644 webapp/icons/qr-code.png create mode 100644 webapp/index.html create mode 100644 webapp/js/app.js create mode 100644 webapp/js/bleService.js create mode 100644 webapp/js/boschProtocol.js create mode 100644 webapp/js/qrcode.js create mode 100644 webapp/js/sw.js create mode 100644 webapp/js/wsRelay.js create mode 100644 webapp/manifest.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7249575 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md — OpusBike + +> Project P-000014 · Company C-000001 (ProActys GmbH) +> Live: **https://monitor.proactys.swiss/opusbike/** + +## What is this + +PWA webapp that connects to a **Bosch Smart System eBike** via Web Bluetooth and displays live telemetry: **Speed, Torque, Ride Time, Total Kilometer, Mode** (OFF/ECO/TOUR/eMTB/TURBO). + +## Architecture + +``` +/opt/opusbike/ +├── webapp/ Static PWA (HTML/CSS/JS, no framework) +│ ├── index.html Single page — connect view + ride dashboard +│ ├── manifest.json PWA manifest (start_url: ".", scope: ".") +│ ├── css/style.css Brand-compliant styling +│ ├── js/ +│ │ ├── app.js Main UI logic + Demo mode +│ │ ├── bleService.js Web Bluetooth connection to Bosch +│ │ ├── boschProtocol.js Bosch BLE protocol decoder (varint TLV) +│ │ ├── wsRelay.js WebSocket relay client (source/viewer) +│ │ ├── sw.js Service worker (cache-first) +│ │ └── qrcode.js Inline QR code generator +│ └── icons/ PWA icons (192, 512, apple-touch, favicon.svg) +├── server/ +│ ├── relay.js Node.js WebSocket relay (ws library, port 3030) +│ └── package.json +├── Dockerfile nginx:alpine serving /usr/share/nginx/html on port 8080 +├── Dockerfile.relay Node 20 alpine running relay.js on port 3030 +├── docker-compose.yml Two services: nginx + relay, network: shared +└── nginx.conf Static file serving config +``` + +## How it works + +### Three connection modes + +1. **Direct BLE** — Chrome (desktop/Android) or **Bluefy** (iOS) connects directly to the Bosch Smart System via Web Bluetooth. Data is also forwarded to the WS relay for other viewers. + +2. **WebSocket Relay** — Devices without Web Bluetooth (Safari, Firefox) auto-connect to the relay as viewers. They see the same live dashboard streamed from the BLE source. + +3. **Demo Mode** — Simulates realistic riding data. Also broadcasts via WS relay so all connected devices see the demo. + +### iOS / iPhone support + +Safari does NOT support Web Bluetooth. The app detects iOS and shows a banner recommending **Bluefy** (free App Store app) which adds Web Bluetooth to iOS. URL: https://apps.apple.com/app/bluefy-web-ble-browser/id1492822055 + +Once Bluefy is installed, open `https://monitor.proactys.swiss/opusbike/` in Bluefy → tap "Connect via Bluetooth" → pair with the Bosch system → ride. + +### Bosch BLE Protocol + +- Service UUID: `0000fee7-0000-1000-8000-00805f9b34fb` +- Characteristic UUID: `00000011-0000-1000-8000-00805f9b34fb` +- Encoding: varint TLV (tag 0x01=speed/10, 0x02=torque, 0x03=totalKm/1000, 0x04=mode) +- Filter names: `smart system`, `Bosch`, `BRC` + +### WebSocket relay protocol + +- URL: `wss://monitor.proactys.swiss/opusbike/ws` +- Register: `{ type: "register", role: "source" | "viewer" }` +- Telemetry: `{ type: "telemetry", data: { speed, torque, totalKm, mode } }` +- Server broadcasts: `source_connected`, `source_disconnected` to viewers + +## Caddy routing (in /home/ubuntu/monitoring/Caddyfile) + +``` +monitor.proactys.swiss { + handle /opusbike/ws → opusbike-relay:3030 (WebSocket) + handle_path /opusbike/* → opusbike-nginx:8080 (static, prefix stripped) + handle → gitea:3000 (Gitea at root) +} +``` + +## Deploy + +```bash +cd /opt/opusbike +docker compose up -d --build # rebuild both containers +docker restart monitoring-proxy-1 # reload Caddy (bind mount inode issue) +``` + +**Important:** After editing the Caddyfile, you MUST `docker restart monitoring-proxy-1` (not just `caddy reload`) because the Edit tool creates a new file inode which breaks the bind mount. + +## Brand + +- Skill S-000007 (ProActys brand charter) +- Colors: Primary #4285F4, Dark #364052, Navy #051229, Ice #E1E8F0 +- Font: Segoe UI, Arial, Helvetica, sans-serif +- Always: capital P capital A in "ProActys" + +## TODO / Known issues + +- [ ] Bosch BLE protocol needs real-device validation (UUIDs + TLV tags are estimated) +- [ ] Test Bluefy on real iPhone + real Bosch Smart System +- [ ] Add battery level if available in BLE data +- [ ] Add cadence (RPM) if available +- [ ] Consider adding ride history / local storage diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..48217a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY webapp/ /usr/share/nginx/html/ +EXPOSE 8080 diff --git a/Dockerfile.relay b/Dockerfile.relay new file mode 100644 index 0000000..180b107 --- /dev/null +++ b/Dockerfile.relay @@ -0,0 +1,7 @@ +FROM node:alpine +WORKDIR /app +COPY server/package.json ./ +RUN npm install --production +COPY server/relay.js ./ +EXPOSE 3030 +CMD ["node", "relay.js"] diff --git a/cli/bridge.js b/cli/bridge.js new file mode 100644 index 0000000..0ae7857 --- /dev/null +++ b/cli/bridge.js @@ -0,0 +1,594 @@ +#!/usr/bin/env node +/** + * OpusBike — Bosch Smart System BLE Bridge (Noble) + * Connects to a pre-paired Bosch eBike via BLE on Mac, decodes telemetry, + * prints to terminal, serves dashboard at localhost:3000, logs to CSV. + * + * Usage: + * node cli/bridge.js # Real BLE mode + * node cli/bridge.js --demo # Simulated data + */ + +const http = require('http'); +const fs = require('fs'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// CLI arguments +// --------------------------------------------------------------------------- +const args = process.argv.slice(2); +const DEMO_MODE = args.includes('--demo'); +const PORT = parseInt(args.find((a, i) => args[i - 1] === '--port') || '3000', 10); + +// --------------------------------------------------------------------------- +// Bosch BLE constants +// --------------------------------------------------------------------------- +const SERVICE_UUID = '00000010eaa211e981b42a2ae2dbcce4'; // Noble strips dashes, lowercase +const CHAR_UUID = '00000011eaa211e981b42a2ae2dbcce4'; +const DEVICE_NAME_FILTERS = ['smart system', 'bosch', 'brc']; + +const ASSIST_MODES = { 0: 'OFF', 1: 'ECO', 2: 'TOUR', 3: 'eMTB', 4: 'TURBO' }; + +// Confirmed data IDs from nRF Connect Phase 2 testing +const KNOWN_DATA_IDS = { + 0x9809: { field: 'assist_mode', divisor: 1 }, + 0x8088: { field: 'battery', divisor: 1 }, + 0x982D: { field: 'speed', divisor: 10 }, + 0x985A: { field: 'cadence', divisor: 2 }, + 0x985B: { field: 'human_power', divisor: 1 }, + 0x985D: { field: 'motor_power', divisor: 1 }, +}; + +// --------------------------------------------------------------------------- +// Shared telemetry state +// --------------------------------------------------------------------------- +const telemetry = { + speed: 0, + cadence: 0, + human_power: 0, + motor_power: 0, + battery: 0, + assist_mode: 'OFF', + ride_time: '00:00:00', + total_km: 0, + connected: false, + demo: false, + last_update: null, + connection_seconds: 0, +}; + +let connectionStart = 0; +let csvStream = null; + +// --------------------------------------------------------------------------- +// Varint decoder +// --------------------------------------------------------------------------- +function decodeVarint(buf, offset) { + let result = 0; + let shift = 0; + let bytesRead = 0; + while (offset + bytesRead < buf.length) { + const byte = buf[offset + bytesRead]; + result |= (byte & 0x7F) << shift; + bytesRead++; + if ((byte & 0x80) === 0) break; + shift += 7; + } + return { value: result, bytesRead }; +} + +// --------------------------------------------------------------------------- +// Bosch protocol parser +// --------------------------------------------------------------------------- +function parseNotification(buf) { + const parsed = {}; + let offset = 0; + + while (offset < buf.length) { + if (offset >= buf.length) break; + const header = buf[offset]; + if (header !== 0x30) { + offset++; + continue; + } + offset++; // skip 0x30 + + if (offset >= buf.length) break; + const length = buf[offset]; + offset++; + + if (length === 0) continue; + const msgEnd = offset + length; + if (msgEnd > buf.length) break; + + // 2-byte data ID + if (offset + 1 >= buf.length) break; + const dataId = (buf[offset] << 8) | buf[offset + 1]; + offset += 2; + + // Length 0x02 = just the ID, value = 0 + if (length === 2) { + const info = KNOWN_DATA_IDS[dataId]; + if (info) parsed[info.field] = 0; + logRaw(buf, dataId, 0, 0); + continue; + } + + // Skip 0x08 marker + if (offset < buf.length && buf[offset] === 0x08) { + offset++; + } + + // Decode varint + if (offset < msgEnd) { + const { value, bytesRead } = decodeVarint(buf, offset); + offset += bytesRead; + + const info = KNOWN_DATA_IDS[dataId]; + if (info) { + const decoded = info.divisor > 1 ? value / info.divisor : value; + parsed[info.field] = decoded; + logRaw(buf, dataId, value, decoded); + } else { + logRaw(buf, dataId, value, null); + } + } + + if (offset < msgEnd) offset = msgEnd; + } + + return parsed; +} + +// --------------------------------------------------------------------------- +// Apply parsed data to telemetry +// --------------------------------------------------------------------------- +function applyParsed(parsed) { + if ('speed' in parsed) telemetry.speed = Math.round(parsed.speed * 10) / 10; + if ('cadence' in parsed) telemetry.cadence = Math.floor(parsed.cadence); + if ('human_power' in parsed) telemetry.human_power = Math.floor(parsed.human_power); + if ('motor_power' in parsed) telemetry.motor_power = Math.floor(parsed.motor_power); + if ('battery' in parsed) telemetry.battery = Math.floor(parsed.battery); + if ('assist_mode' in parsed) { + const v = Math.floor(parsed.assist_mode); + telemetry.assist_mode = ASSIST_MODES[v] || `UNK(${v})`; + } + + // Update ride time + if (connectionStart > 0) { + const elapsed = Math.floor((Date.now() - connectionStart) / 1000); + telemetry.connection_seconds = elapsed; + const h = Math.floor(elapsed / 3600); + const m = Math.floor((elapsed % 3600) / 60); + const s = elapsed % 60; + telemetry.ride_time = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + } + + telemetry.last_update = new Date().toISOString(); +} + +// --------------------------------------------------------------------------- +// CSV logging +// --------------------------------------------------------------------------- +function setupCSV() { + const csvPath = path.join(__dirname, 'ble_log_noble.csv'); + const exists = fs.existsSync(csvPath) && fs.statSync(csvPath).size > 0; + csvStream = fs.createWriteStream(csvPath, { flags: 'a' }); + if (!exists) { + csvStream.write('timestamp,raw_hex,id_hex,field_name,raw_value,decoded_value\n'); + } + console.log(`[INFO] CSV log: ${csvPath}`); +} + +function logRaw(buf, dataId, rawValue, decodedValue) { + if (!csvStream) return; + const info = KNOWN_DATA_IDS[dataId]; + const fieldName = info ? info.field : 'unknown'; + csvStream.write(`${new Date().toISOString()},${buf.toString('hex')},0x${dataId.toString(16).padStart(4, '0')},${fieldName},${rawValue},${decodedValue !== null ? decodedValue : ''}\n`); +} + +// --------------------------------------------------------------------------- +// Terminal output +// --------------------------------------------------------------------------- +function printTelemetry() { + const ts = new Date().toLocaleTimeString('en-GB'); + const prefix = telemetry.demo ? '[DEMO] ' : ''; + process.stdout.write( + `\r${prefix}[${ts}] ` + + `Speed: ${String(telemetry.speed.toFixed(1)).padStart(5)} km/h | ` + + `Cadence: ${String(telemetry.cadence).padStart(3)} rpm | ` + + `Power: ${String(telemetry.human_power).padStart(3)}W | ` + + `Motor: ${String(telemetry.motor_power).padStart(3)}W | ` + + `Battery: ${String(telemetry.battery).padStart(3)}% | ` + + `Mode: ${telemetry.assist_mode.padEnd(5)} | ` + + `Time: ${telemetry.ride_time}` + ); +} + +// --------------------------------------------------------------------------- +// Web dashboard +// --------------------------------------------------------------------------- +const DASHBOARD_HTML = ` + + + + +OpusBike — Noble BLE Dashboard + + + +

OpusBike Noble BLE

+
Node.js @abandonware/noble — Bosch Smart System
+
Disconnected
+
+
+
+
Speed
+
0.0
+
km/h
+
+
+
Cadence
+
0
+
rpm
+
+
+
Human Power
+
0
+
W
+
+
+
Motor Power
+
0
+
W
+
+
+
Battery
+
0%
+
+
+
+
Assist Mode
+
OFF
+
+
+
Ride Time
+
00:00:00
+
+
+ + +`; + +// --------------------------------------------------------------------------- +// HTTP server for dashboard +// --------------------------------------------------------------------------- +function startWebServer() { + const server = http.createServer((req, res) => { + if (req.url === '/api/telemetry') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(telemetry)); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(DASHBOARD_HTML); + }); + + server.listen(PORT, () => { + console.log(`[INFO] Dashboard: http://localhost:${PORT}`); + }); +} + +// --------------------------------------------------------------------------- +// Demo mode +// --------------------------------------------------------------------------- +function runDemo() { + telemetry.demo = true; + telemetry.connected = true; + telemetry.battery = 87; + connectionStart = Date.now(); + + const modes = ['ECO', 'TOUR', 'eMTB', 'TURBO', 'TOUR', 'ECO']; + let modeIdx = 0; + let tick = 0; + let totalKm = 1847.2; + + console.log('[DEMO] Simulating Bosch Smart System ride data...'); + console.log('[DEMO] Press Ctrl+C to stop\n'); + + setInterval(() => { + const t = tick * 0.5; + const speed = Math.max(0, 22 + 13 * Math.sin(t / 8) + 2 * Math.sin(t / 3)); + const cadence = Math.max(0, Math.floor(speed * 2.8 + 5 * Math.sin(t / 5))); + const humanPower = Math.max(0, Math.floor(cadence * 1.4 + 10 * Math.sin(t / 4))); + const modeMultiplier = { OFF: 0, ECO: 0.5, TOUR: 1.0, eMTB: 1.5, TURBO: 2.2 }; + const modeName = modes[modeIdx % modes.length]; + const motorPower = Math.floor(humanPower * (modeMultiplier[modeName] || 1.0)); + const battery = Math.max(0, 87 - Math.floor(tick * 0.02)); + totalKm += speed / 3600 * 0.5; + + telemetry.speed = Math.round(speed * 10) / 10; + telemetry.cadence = cadence; + telemetry.human_power = humanPower; + telemetry.motor_power = motorPower; + telemetry.battery = battery; + telemetry.assist_mode = modeName; + telemetry.total_km = Math.round(totalKm * 10) / 10; + + applyParsed({}); // updates ride_time + printTelemetry(); + + tick++; + if (tick % 30 === 0) modeIdx++; + }, 500); +} + +// --------------------------------------------------------------------------- +// Real BLE mode (Noble) +// --------------------------------------------------------------------------- +function runBLE() { + let noble; + try { + noble = require('@abandonware/noble'); + } catch (e) { + console.error('[ERROR] @abandonware/noble not installed. Run: cd cli && npm install'); + process.exit(1); + } + + setupCSV(); + + console.log('[INFO] Initializing Noble BLE...'); + console.log('[INFO] Make sure the bike is:'); + console.log(' 1. ON (battery connected)'); + console.log(' 2. Paired in macOS System Settings > Bluetooth'); + console.log(' 3. Not connected to another app (Bosch Flow, nRF Connect)'); + console.log(''); + + let targetPeripheral = null; + + noble.on('stateChange', (state) => { + console.log(`[INFO] BLE adapter state: ${state}`); + if (state === 'poweredOn') { + console.log('[INFO] Scanning for Bosch Smart System eBike...'); + noble.startScanning([], false); // scan all services, no duplicates + } else { + noble.stopScanning(); + } + }); + + noble.on('discover', (peripheral) => { + const name = (peripheral.advertisement.localName || '').toLowerCase(); + if (DEVICE_NAME_FILTERS.some(f => name.includes(f))) { + console.log(`[INFO] Found: ${peripheral.advertisement.localName} (${peripheral.id})`); + noble.stopScanning(); + targetPeripheral = peripheral; + connectToPeripheral(peripheral); + } + }); + + function connectToPeripheral(peripheral) { + console.log(`[INFO] Connecting to ${peripheral.advertisement.localName}...`); + + peripheral.connect((err) => { + if (err) { + console.error(`[ERROR] Connection failed: ${err.message}`); + scheduleReconnect(peripheral); + return; + } + + connectionStart = Date.now(); + telemetry.connected = true; + console.log('[INFO] Connected! Discovering services...'); + + // Monitor connection duration + const connTimer = setInterval(() => { + if (!telemetry.connected) { + clearInterval(connTimer); + return; + } + const secs = Math.floor((Date.now() - connectionStart) / 1000); + telemetry.connection_seconds = secs; + }, 1000); + + peripheral.once('disconnect', () => { + clearInterval(connTimer); + const elapsed = (Date.now() - connectionStart) / 1000; + telemetry.connected = false; + console.log(`\n[WARN] Disconnected after ${elapsed.toFixed(1)}s`); + if (elapsed > 35 && elapsed < 40) { + console.log('[WARN] Disconnected at ~37s — BLE bonding likely failed!'); + console.log('[WARN] Pair the bike in macOS System Settings > Bluetooth first (R-000138)'); + } + scheduleReconnect(peripheral); + }); + + // Discover services and characteristics + peripheral.discoverSomeServicesAndCharacteristics( + [SERVICE_UUID], + [CHAR_UUID], + (err, services, characteristics) => { + if (err) { + console.error(`[ERROR] Service discovery failed: ${err.message}`); + // Try discovering all services as fallback + discoverAll(peripheral); + return; + } + + if (!characteristics || characteristics.length === 0) { + console.log('[WARN] Target characteristic not found via filter, trying full discovery...'); + discoverAll(peripheral); + return; + } + + const char = characteristics[0]; + subscribeToChar(char); + } + ); + }); + } + + function discoverAll(peripheral) { + peripheral.discoverAllServicesAndCharacteristics((err, services, characteristics) => { + if (err) { + console.error(`[ERROR] Full discovery failed: ${err.message}`); + return; + } + + console.log(`[INFO] Found ${services.length} services, ${characteristics.length} characteristics`); + + // Find our target characteristic + const target = characteristics.find(c => + c.uuid.replace(/-/g, '').toLowerCase() === CHAR_UUID + ); + + if (target) { + console.log(`[INFO] Found target characteristic: ${target.uuid}`); + subscribeToChar(target); + } else { + // Log all for debugging + for (const s of services) { + console.log(` Service: ${s.uuid}`); + for (const c of s.characteristics) { + console.log(` Char: ${c.uuid} [${c.properties.join(', ')}]`); + } + } + console.log('[ERROR] Target characteristic not found. Check UUIDs above.'); + } + }); + } + + function subscribeToChar(characteristic) { + console.log(`[INFO] Subscribing to notifications on ${characteristic.uuid}...`); + + characteristic.on('data', (data, isNotification) => { + const parsed = parseNotification(Buffer.from(data)); + if (Object.keys(parsed).length > 0) { + applyParsed(parsed); + printTelemetry(); + } + }); + + characteristic.subscribe((err) => { + if (err) { + console.error(`[ERROR] Subscribe failed: ${err.message}`); + return; + } + console.log('[INFO] Subscribed — receiving data. Press Ctrl+C to stop.\n'); + }); + } + + function scheduleReconnect(peripheral) { + console.log('[INFO] Reconnecting in 3 seconds...'); + setTimeout(() => { + if (peripheral) { + connectToPeripheral(peripheral); + } else { + console.log('[INFO] Restarting scan...'); + noble.startScanning([], false); + } + }, 3000); + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +startWebServer(); + +if (DEMO_MODE) { + runDemo(); +} else { + runBLE(); +} + +process.on('SIGINT', () => { + console.log('\n\n[INFO] Stopped.'); + if (csvStream) csvStream.end(); + process.exit(0); +}); diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..19f8e9e --- /dev/null +++ b/cli/package.json @@ -0,0 +1,15 @@ +{ + "name": "opusbike-cli", + "version": "1.0.0", + "private": true, + "description": "OpusBike BLE CLI tools (Noble bridge + utilities)", + "scripts": { + "bridge": "node bridge.js", + "demo": "node bridge.js --demo" + }, + "dependencies": { + "@abandonware/noble": "^1.9.2-25", + "express": "^4.21.0", + "ws": "^8.18.0" + } +} diff --git a/cli/test_bleak.py b/cli/test_bleak.py new file mode 100644 index 0000000..0159c63 --- /dev/null +++ b/cli/test_bleak.py @@ -0,0 +1,595 @@ +#!/usr/bin/env python3 +""" +OpusBike — Bosch Smart System BLE Test Script (bleak) +Connects to a pre-paired Bosch eBike via BLE, decodes telemetry, +prints to terminal, serves dashboard at localhost:3000, logs to CSV. + +Usage: + python3 cli/test_bleak.py # Real BLE mode + python3 cli/test_bleak.py --demo # Simulated data + python3 cli/test_bleak.py --uuid XX # Specific device UUID +""" + +import argparse +import asyncio +import csv +import json +import math +import os +import signal +import sys +import time +from datetime import datetime, timedelta + +# --------------------------------------------------------------------------- +# Bosch BLE constants +# --------------------------------------------------------------------------- +SERVICE_UUID = "00000010-eaa2-11e9-81b4-2a2ae2dbcce4" +CHAR_UUID = "00000011-eaa2-11e9-81b4-2a2ae2dbcce4" +DEVICE_NAME_FILTERS = ["smart system", "Bosch", "BRC"] + +ASSIST_MODES = {0: "OFF", 1: "ECO", 2: "TOUR", 3: "eMTB", 4: "TURBO"} + +# Confirmed data IDs from nRF Connect Phase 2 testing +KNOWN_DATA_IDS = { + 0x9809: ("assist_mode", 1), # 0=OFF 1=ECO 2=TOUR 3=eMTB 4=TURBO + 0x8088: ("battery", 1), # % + 0x982D: ("speed", 10), # km/h (raw / 10) + 0x985A: ("cadence", 2), # rpm (raw / 2) + 0x985B: ("human_power", 1), # W + 0x985D: ("motor_power", 1), # W +} + +# --------------------------------------------------------------------------- +# Shared telemetry state +# --------------------------------------------------------------------------- +telemetry = { + "speed": 0.0, + "cadence": 0, + "human_power": 0, + "motor_power": 0, + "battery": 0, + "assist_mode": "OFF", + "ride_time": "00:00:00", + "total_km": 0.0, + "connected": False, + "demo": False, + "last_update": None, +} + +connection_start: float = 0.0 +csv_writer = None +csv_file = None + +# --------------------------------------------------------------------------- +# Varint decoder +# --------------------------------------------------------------------------- +def decode_varint(data: bytes, offset: int) -> tuple: + """Decode a protobuf-style varint. Returns (value, bytes_read).""" + result = 0 + shift = 0 + bytes_read = 0 + while offset + bytes_read < len(data): + b = data[offset + bytes_read] + result |= (b & 0x7F) << shift + bytes_read += 1 + if (b & 0x80) == 0: + break + shift += 7 + return result, bytes_read + + +# --------------------------------------------------------------------------- +# Bosch protocol parser +# --------------------------------------------------------------------------- +def parse_notification(data: bytes) -> dict: + """ + Parse a Bosch BLE notification. + Format: [0x30] [length] [id_hi] [id_lo] [0x08] [varint data...] + Multiple messages can be concatenated. + """ + parsed = {} + offset = 0 + + while offset < len(data): + # Expect 0x30 header + if offset >= len(data): + break + header = data[offset] + if header != 0x30: + # Try to skip unknown bytes + offset += 1 + continue + + offset += 1 # skip 0x30 + + if offset >= len(data): + break + + # Read length + length = data[offset] + offset += 1 + + if length == 0: + continue + + msg_end = offset + length + if msg_end > len(data): + break + + # Read 2-byte data ID + if offset + 1 >= len(data): + break + id_hi = data[offset] + id_lo = data[offset + 1] + data_id = (id_hi << 8) | id_lo + offset += 2 + + # Length 0x02 means just the ID, value = 0 + if length == 2: + if data_id in KNOWN_DATA_IDS: + field_name, divisor = KNOWN_DATA_IDS[data_id] + parsed[field_name] = 0 + log_raw(data, data_id, 0, 0) + continue + + # Expect 0x08 marker before varint + if offset < len(data) and data[offset] == 0x08: + offset += 1 + + # Decode varint value + if offset < msg_end: + value, vbytes = decode_varint(data, offset) + offset += vbytes + + if data_id in KNOWN_DATA_IDS: + field_name, divisor = KNOWN_DATA_IDS[data_id] + decoded = value / divisor if divisor > 1 else value + parsed[field_name] = decoded + log_raw(data, data_id, value, decoded) + else: + log_raw(data, data_id, value, None) + else: + offset = msg_end + + # Advance to end of this message if we haven't reached it + if offset < msg_end: + offset = msg_end + + return parsed + + +def log_raw(raw_data: bytes, data_id: int, raw_value: int, decoded_value): + """Log a decoded field to CSV.""" + global csv_writer + if csv_writer is None: + return + field_name = "unknown" + if data_id in KNOWN_DATA_IDS: + field_name = KNOWN_DATA_IDS[data_id][0] + try: + csv_writer.writerow([ + datetime.now().isoformat(), + raw_data.hex(), + f"0x{data_id:04X}", + field_name, + raw_value, + decoded_value if decoded_value is not None else "", + ]) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Apply parsed data to global telemetry +# --------------------------------------------------------------------------- +def apply_parsed(parsed: dict): + """Merge parsed BLE fields into the global telemetry dict.""" + if "speed" in parsed: + telemetry["speed"] = round(parsed["speed"], 1) + if "cadence" in parsed: + telemetry["cadence"] = int(parsed["cadence"]) + if "human_power" in parsed: + telemetry["human_power"] = int(parsed["human_power"]) + if "motor_power" in parsed: + telemetry["motor_power"] = int(parsed["motor_power"]) + if "battery" in parsed: + telemetry["battery"] = int(parsed["battery"]) + if "assist_mode" in parsed: + mode_val = int(parsed["assist_mode"]) + telemetry["assist_mode"] = ASSIST_MODES.get(mode_val, f"UNK({mode_val})") + + # Update ride time + if connection_start > 0: + elapsed = time.time() - connection_start + h, rem = divmod(int(elapsed), 3600) + m, s = divmod(rem, 60) + telemetry["ride_time"] = f"{h:02d}:{m:02d}:{s:02d}" + + telemetry["last_update"] = datetime.now().isoformat() + + +# --------------------------------------------------------------------------- +# Terminal output +# --------------------------------------------------------------------------- +def print_telemetry(): + """Print one-line telemetry to terminal.""" + ts = datetime.now().strftime("%H:%M:%S") + mode = telemetry["assist_mode"] + prefix = "[DEMO] " if telemetry["demo"] else "" + print( + f"\r{prefix}[{ts}] " + f"Speed: {telemetry['speed']:5.1f} km/h | " + f"Cadence: {telemetry['cadence']:3d} rpm | " + f"Power: {telemetry['human_power']:3d}W | " + f"Motor: {telemetry['motor_power']:3d}W | " + f"Battery: {telemetry['battery']:3d}% | " + f"Mode: {mode:<5s} | " + f"Time: {telemetry['ride_time']}", + end="", flush=True, + ) + + +# --------------------------------------------------------------------------- +# Web dashboard (aiohttp) +# --------------------------------------------------------------------------- +DASHBOARD_HTML = """ + + + + +OpusBike — BLE Test Dashboard + + + +

OpusBike BLE Test

+
Python bleak — Bosch Smart System
+
Disconnected
+
+
+
Speed
+
0.0
+
km/h
+
+
+
Cadence
+
0
+
rpm
+
+
+
Human Power
+
0
+
W
+
+
+
Motor Power
+
0
+
W
+
+
+
Battery
+
0%
+
+
+
+
Assist Mode
+
OFF
+
+
+
Ride Time
+
00:00:00
+
+
+ + +""" + + +async def start_web_server(port: int = 3000): + """Start aiohttp web server for the dashboard.""" + try: + from aiohttp import web + except ImportError: + print("\n[WARN] aiohttp not installed — dashboard disabled. Run: pip3 install aiohttp") + return + + app = web.Application() + + async def handle_index(request): + return web.Response(text=DASHBOARD_HTML, content_type="text/html") + + async def handle_api(request): + return web.json_response(telemetry) + + app.router.add_get("/", handle_index) + app.router.add_get("/api/telemetry", handle_api) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", port) + await site.start() + print(f"[INFO] Dashboard: http://localhost:{port}") + + +# --------------------------------------------------------------------------- +# Demo mode +# --------------------------------------------------------------------------- +async def run_demo(): + """Generate fake BLE data simulating a ride.""" + global connection_start + telemetry["demo"] = True + telemetry["connected"] = True + telemetry["battery"] = 87 + connection_start = time.time() + + modes = ["ECO", "TOUR", "eMTB", "TURBO", "TOUR", "ECO"] + mode_idx = 0 + tick = 0 + total_km = 1847.2 + + print("[DEMO] Simulating Bosch Smart System ride data...") + print("[DEMO] Press Ctrl+C to stop\n") + + while True: + t = tick * 0.5 # 2 Hz + # Speed: sinusoidal 0-35 km/h with noise + speed = max(0, 22 + 13 * math.sin(t / 8) + 2 * math.sin(t / 3)) + # Cadence correlates with speed + cadence = int(max(0, speed * 2.8 + 5 * math.sin(t / 5))) + # Human power correlates with cadence + human_power = int(max(0, cadence * 1.4 + 10 * math.sin(t / 4))) + # Motor power depends on mode + mode_multiplier = {"OFF": 0, "ECO": 0.5, "TOUR": 1.0, "eMTB": 1.5, "TURBO": 2.2} + mode_name = modes[mode_idx % len(modes)] + motor_power = int(human_power * mode_multiplier.get(mode_name, 1.0)) + # Battery decreases slowly + battery = max(0, 87 - int(tick * 0.02)) + # Total km increases with speed + total_km += speed / 3600 * 0.5 # 0.5s tick + + telemetry["speed"] = round(speed, 1) + telemetry["cadence"] = cadence + telemetry["human_power"] = human_power + telemetry["motor_power"] = motor_power + telemetry["battery"] = battery + telemetry["assist_mode"] = mode_name + telemetry["total_km"] = round(total_km, 1) + + # Update ride time + elapsed = time.time() - connection_start + h, rem = divmod(int(elapsed), 3600) + m, s = divmod(rem, 60) + telemetry["ride_time"] = f"{h:02d}:{m:02d}:{s:02d}" + + print_telemetry() + + tick += 1 + if tick % 30 == 0: # Change mode every 15s + mode_idx += 1 + + await asyncio.sleep(0.5) + + +# --------------------------------------------------------------------------- +# Real BLE mode +# --------------------------------------------------------------------------- +async def run_ble(device_uuid: str = None): + """Connect to real Bosch eBike via BLE and stream data.""" + global connection_start + + try: + from bleak import BleakClient, BleakScanner + except ImportError: + print("[ERROR] bleak not installed. Run: pip3 install bleak") + sys.exit(1) + + # Scan for device + print("[INFO] Scanning for Bosch Smart System eBike...") + device = None + + if device_uuid: + print(f"[INFO] Looking for device UUID: {device_uuid}") + device = await BleakScanner.find_device_by_address(device_uuid, timeout=15.0) + else: + devices = await BleakScanner.discover(timeout=10.0) + for d in devices: + name = d.name or "" + if any(f.lower() in name.lower() for f in DEVICE_NAME_FILTERS): + device = d + print(f"[INFO] Found: {d.name} ({d.address})") + break + + if device is None: + print("[ERROR] Bike not found. Make sure:") + print(" 1. Bike is ON (battery connected)") + print(" 2. LED Remote is active") + print(" 3. Bike is paired in macOS System Settings > Bluetooth") + print(" 4. No other device (Bosch Flow app) is connected to the bike") + print(f"\nTip: Run with --demo to test without the bike") + sys.exit(1) + + print(f"[INFO] Connecting to {device.name} ({device.address})...") + + def on_disconnect(client): + elapsed = time.time() - connection_start if connection_start > 0 else 0 + telemetry["connected"] = False + print(f"\n[WARN] Disconnected after {elapsed:.1f}s") + if 35 < elapsed < 40: + print("[WARN] Disconnected at ~37s — BLE bonding likely failed!") + print("[WARN] Pair the bike in macOS System Settings > Bluetooth first (R-000138)") + else: + print("[INFO] Will attempt reconnection in 3 seconds...") + + def notification_handler(sender, data: bytearray): + """Handle incoming BLE notifications.""" + parsed = parse_notification(bytes(data)) + if parsed: + apply_parsed(parsed) + print_telemetry() + + # Connection loop with auto-reconnect + while True: + try: + async with BleakClient( + device.address, + disconnected_callback=on_disconnect, + timeout=20.0, + ) as client: + if not client.is_connected: + print("[ERROR] Failed to connect") + await asyncio.sleep(3) + continue + + connection_start = time.time() + telemetry["connected"] = True + print(f"[INFO] Connected! Subscribing to {CHAR_UUID}...") + + # List services for debugging + for service in client.services: + if SERVICE_UUID.lower() in service.uuid.lower(): + print(f"[INFO] Found Bosch service: {service.uuid}") + for char in service.characteristics: + props = ", ".join(char.properties) + print(f" Char: {char.uuid} [{props}]") + + # Subscribe to notifications + await client.start_notify(CHAR_UUID, notification_handler) + print("[INFO] Subscribed — receiving data. Press Ctrl+C to stop.\n") + + # Keep alive — check connection every second + while client.is_connected: + await asyncio.sleep(1.0) + + except Exception as e: + print(f"\n[ERROR] {e}") + + telemetry["connected"] = False + print("[INFO] Reconnecting in 3 seconds...") + await asyncio.sleep(3) + + +# --------------------------------------------------------------------------- +# CSV setup +# --------------------------------------------------------------------------- +def setup_csv(path: str): + global csv_writer, csv_file + csv_file = open(path, "a", newline="") + csv_writer = csv.writer(csv_file) + if os.path.getsize(path) == 0: + csv_writer.writerow(["timestamp", "raw_hex", "id_hex", "field_name", "raw_value", "decoded_value"]) + print(f"[INFO] CSV log: {path}") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +async def main(): + parser = argparse.ArgumentParser(description="OpusBike Bosch BLE Test (bleak)") + parser.add_argument("--demo", action="store_true", help="Run with simulated data") + parser.add_argument("--uuid", type=str, default=None, help="BLE device UUID/address") + parser.add_argument("--port", type=int, default=3000, help="Dashboard port (default: 3000)") + args = parser.parse_args() + + # CSV logging + csv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ble_log.csv") + if not args.demo: + setup_csv(csv_path) + + # Start web dashboard + await start_web_server(args.port) + + # Run BLE or demo + if args.demo: + await run_demo() + else: + await run_ble(args.uuid) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\n\n[INFO] Stopped.") + if csv_file: + csv_file.close() + sys.exit(0) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32dbd99 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +services: + nginx: + build: + context: . + dockerfile: Dockerfile + container_name: opusbike-nginx + restart: unless-stopped + ports: + - "8080:8080" + networks: + - shared + + relay: + build: + context: . + dockerfile: Dockerfile.relay + container_name: opusbike-relay + restart: unless-stopped + environment: + - PORT=3030 + networks: + - shared + +networks: + shared: + external: true diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..471bb6d --- /dev/null +++ b/nginx.conf @@ -0,0 +1,39 @@ +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + + # Security headers + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets + location ~* \.(css|js|png|svg|json|ico)$ { + expires 1h; + add_header Cache-Control "public, immutable"; + } + + # Service worker — no caching + location = /js/sw.js { + expires off; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # Manifest + location = /manifest.json { + add_header Content-Type "application/manifest+json"; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..9fd2139 --- /dev/null +++ b/server/package.json @@ -0,0 +1,13 @@ +{ + "name": "opusbike-relay", + "version": "1.0.0", + "private": true, + "description": "WebSocket relay for OpusBike dual-device streaming", + "main": "relay.js", + "scripts": { + "start": "node relay.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/server/relay.js b/server/relay.js new file mode 100644 index 0000000..35d32b7 --- /dev/null +++ b/server/relay.js @@ -0,0 +1,138 @@ +/** + * OpusBike WebSocket Relay Server + * Receives telemetry from phone (BLE bridge via 5G) and broadcasts to all viewers + */ + +const { WebSocketServer, WebSocket } = require('ws'); +const http = require('http'); + +const PORT = process.env.PORT || 3030; +const HEARTBEAT_INTERVAL = 30000; // 30 seconds + +// Store latest telemetry state so new viewers get immediate data +let latestState = null; + +// Create HTTP server for health checks +const httpServer = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + clients: clients.size, + sources: [...clients].filter(([, info]) => info.role === 'source').length, + viewers: [...clients].filter(([, info]) => info.role === 'viewer').length, + hasState: !!latestState, + uptime: process.uptime() + })); + return; + } + res.writeHead(404); + res.end(); +}); + +// Create WebSocket server +const wss = new WebSocketServer({ server: httpServer }); + +// Track connected clients +const clients = new Map(); // ws -> { role, connectedAt, alive } + +// Heartbeat — ping all clients every 30s, terminate unresponsive ones +const heartbeat = setInterval(() => { + for (const [ws, info] of clients) { + if (!info.alive) { + console.log(`Terminating unresponsive client (${info.role})`); + ws.terminate(); + continue; + } + info.alive = false; + ws.ping(); + } +}, HEARTBEAT_INTERVAL); + +wss.on('close', () => { + clearInterval(heartbeat); +}); + +wss.on('connection', (ws) => { + const clientInfo = { role: 'viewer', connectedAt: Date.now(), alive: true }; + clients.set(ws, clientInfo); + console.log(`Client connected (total: ${clients.size})`); + + ws.on('pong', () => { + clientInfo.alive = true; + }); + + ws.on('message', (raw) => { + try { + const msg = JSON.parse(raw); + + if (msg.type === 'register') { + clientInfo.role = msg.role || 'viewer'; + console.log(`Client registered as: ${clientInfo.role}`); + + // Notify viewers that a source has connected + if (clientInfo.role === 'source') { + broadcast({ type: 'source_connected' }, ws); + } + + // Send latest state to new viewers so they get immediate data + if (clientInfo.role === 'viewer' && latestState) { + ws.send(JSON.stringify({ + type: 'state', + data: latestState.data, + ts: latestState.ts + })); + } + return; + } + + if (msg.type === 'telemetry' && clientInfo.role === 'source') { + const ts = new Date().toISOString(); + + // Store latest state for new viewers + latestState = { data: msg.data, ts }; + + // Broadcast telemetry with timestamp to all viewers + const payload = JSON.stringify({ type: 'telemetry', data: msg.data, ts }); + for (const [client] of clients) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } + } + } catch (e) { + // Ignore malformed messages + } + }); + + ws.on('close', () => { + const wasSource = clientInfo.role === 'source'; + clients.delete(ws); + console.log(`Client disconnected (total: ${clients.size})`); + + // Notify viewers if source disconnected + if (wasSource) { + broadcast({ type: 'source_disconnected' }); + } + }); + + ws.on('error', (err) => { + console.error('WS error:', err.message); + }); +}); + +/** + * Broadcast a message to all connected clients + */ +function broadcast(msg, exclude = null) { + const payload = JSON.stringify(msg); + for (const [client] of clients) { + if (client !== exclude && client.readyState === WebSocket.OPEN) { + client.send(payload); + } + } +} + +httpServer.listen(PORT, () => { + console.log(`OpusBike relay server running on port ${PORT}`); +}); diff --git a/webapp/css/style.css b/webapp/css/style.css new file mode 100644 index 0000000..08de71b --- /dev/null +++ b/webapp/css/style.css @@ -0,0 +1,650 @@ +/* OpusBike — ProActys Light Theme */ +/* Primary: #4285F4, Dark: #364052, Navy: #051229, Ice: #E1E8F0, Silver: #A9B0B8 */ + +:root { + --color-primary: #4285F4; + --color-dark: #364052; + --color-navy: #051229; + --color-ice: #E1E8F0; + --color-white: #FFFFFF; + --color-silver: #A9B0B8; + --color-bg: #F5F7FA; + --color-card: #FFFFFF; + --color-text: #1A1A2E; + --color-text-secondary: #6B7280; + --color-border: #E5E7EB; + --color-success: #10B981; + --color-warning: #F97316; + --color-danger: #EF4444; + --font-family: 'Segoe UI', Arial, Helvetica, sans-serif; + --radius: 8px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + height: 100dvh; + overflow: hidden; + font-family: var(--font-family); + background: var(--color-bg); + color: var(--color-text); + -webkit-user-select: none; + user-select: none; + -webkit-tap-highlight-color: transparent; +} + +/* Views */ +.view { + display: none; + height: 100%; + height: 100dvh; + overflow: hidden; +} + +.view.active { + display: flex; + flex-direction: column; +} + +/* ===== Connect View ===== */ +.connect-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem 1.5rem; + gap: 1rem; + text-align: center; + overflow: hidden; +} + +.logo-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.app-logo { + width: 48px; + height: 48px; + border-radius: 12px; +} + +.logo-header h1 { + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--color-navy); +} + +.subtitle { + color: var(--color-text-secondary); + font-size: 0.8rem; +} + +/* Status badge */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.75rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 500; +} + +.status-badge.disconnected { + background: rgba(239, 68, 68, 0.1); + color: var(--color-danger); +} + +.status-badge.connected { + background: rgba(16, 185, 129, 0.1); + color: var(--color-success); +} + +.status-badge.demo { + background: rgba(249, 115, 22, 0.1); + color: var(--color-warning); +} + +.status-badge.relay { + background: rgba(66, 133, 244, 0.1); + color: var(--color-primary); +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Buttons */ +.connect-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + max-width: 280px; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border: none; + border-radius: var(--radius); + font-family: var(--font-family); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--color-primary); + color: var(--color-white); +} + +.btn-primary:hover { + background: #3b78e0; +} + +.btn-secondary { + background: var(--color-ice); + color: var(--color-dark); +} + +.btn-secondary:hover { + background: #d1d9e6; +} + +.btn-small { + padding: 0.4rem 0.75rem; + font-size: 0.75rem; +} + +.btn-danger { + background: rgba(239, 68, 68, 0.1); + color: var(--color-danger); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.15); +} + +/* Info card */ +.info-card { + background: rgba(66, 133, 244, 0.05); + border: 1px solid rgba(66, 133, 244, 0.15); + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.8rem; + color: var(--color-text-secondary); + max-width: 280px; + width: 100%; +} + +.info-card p { + margin-bottom: 0.375rem; +} + +.info-card .ws-status { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; + color: var(--color-primary); + font-weight: 500; +} + +.hidden { + display: none !important; +} + +/* Bluefy hint for iOS */ +.bluefy-hint { + margin: 0.5rem 0; + padding: 0.5rem; + background: rgba(66, 133, 244, 0.05); + border: 1px solid rgba(66, 133, 244, 0.15); + border-radius: var(--radius); + text-align: center; +} + +.bluefy-hint p { + margin-bottom: 0.375rem; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.bluefy-hint a:not(.btn) { + color: var(--color-primary); + text-decoration: underline; +} + +.btn-bluefy { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; +} + +.btn-bluefy svg { + flex-shrink: 0; +} + +.relay-fallback-text { + font-size: 0.75rem; + color: var(--color-silver); + margin-top: 0.5rem; + margin-bottom: 0.25rem; +} + +/* Mode role label (Bridge / Viewer) */ +.mode-role-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + min-height: 1em; +} + +/* BLE Troubleshooting */ +.troubleshoot-section { + max-width: 280px; + width: 100%; + font-size: 0.8rem; + color: var(--color-text-secondary); +} + +.troubleshoot-section summary { + cursor: pointer; + font-weight: 600; + color: var(--color-primary); + padding: 0.5rem 0; + list-style: none; +} + +.troubleshoot-section summary::before { + content: '\25B6 '; + font-size: 0.6rem; + margin-right: 0.25rem; +} + +.troubleshoot-section[open] summary::before { + content: '\25BC '; +} + +.troubleshoot-content { + background: rgba(249, 115, 22, 0.05); + border: 1px solid rgba(249, 115, 22, 0.15); + border-radius: var(--radius); + padding: 0.75rem; + margin-top: 0.25rem; +} + +.troubleshoot-content p { + margin-bottom: 0.5rem; + font-size: 0.78rem; + line-height: 1.4; +} + +.troubleshoot-content ol { + padding-left: 1.25rem; + margin-bottom: 0.5rem; +} + +.troubleshoot-content li { + margin-bottom: 0.375rem; + font-size: 0.78rem; + line-height: 1.4; +} + +.troubleshoot-note { + font-size: 0.72rem; + color: var(--color-silver); + font-style: italic; + margin-bottom: 0; +} + +/* QR Code section */ +.qr-section { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem; + background: var(--color-white); + border-radius: var(--radius); + border: 1px solid var(--color-border); +} + +.qr-label { + color: var(--color-text-secondary); + font-size: 0.7rem; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.qr-image { + width: 140px; + height: 140px; + border-radius: 4px; +} + +#qr-canvas { + display: none; +} + +.qr-url { + color: var(--color-primary); + font-size: 0.7rem; + margin-top: 0.375rem; + font-family: monospace; + opacity: 0.7; +} + +.brand-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + color: var(--color-silver); + font-size: 0.65rem; + opacity: 0.6; + margin-top: auto; + padding-top: 0.5rem; +} + +.brand-footer img { + height: 14px; + width: auto; + opacity: 0.5; +} + +/* ===== Ride View (Dashboard) ===== */ +.dashboard { + flex: 1; + display: flex; + flex-direction: column; + padding: 0.75rem; + padding-bottom: 4rem; + overflow: hidden; +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.header-left h2 { + font-size: 1rem; + font-weight: 700; + color: var(--color-navy); +} + +.proactys-logo-sm { + height: 16px; + width: auto; + opacity: 0.4; +} + +.demo-badge { + display: none; + padding: 0.15rem 0.5rem; + background: rgba(249, 115, 22, 0.1); + color: var(--color-warning); + border: 1px solid rgba(249, 115, 22, 0.2); + border-radius: 999px; + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.05em; +} + +.demo-badge.visible { + display: inline-block; +} + +/* Metrics grid — no scroll, fills viewport */ +.metrics-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 2fr 1fr 1fr; + gap: 0.5rem; + flex: 1; + min-height: 0; +} + +.metric-card { + background: var(--color-card); + border-radius: var(--radius); + border: 1px solid var(--color-border); + padding: 0.75rem; + display: flex; + flex-direction: column; + justify-content: center; + position: relative; + overflow: hidden; + min-height: 0; +} + +.metric-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +/* Speed = hero card — full width, large value */ +.metric-card.metric-speed { + grid-column: 1 / -1; + text-align: center; + align-items: center; +} + +.metric-card.metric-speed::before { background: var(--color-primary); } +.metric-card.metric-speed .metric-value { + justify-content: center; +} +.metric-card.metric-speed .metric-value span:first-child { + font-size: clamp(2.5rem, 10vw, 5rem); + color: var(--color-primary); +} +.metric-card.metric-speed .metric-unit { + font-size: 1rem; + align-self: flex-end; + margin-bottom: 0.25rem; +} + +.metric-card.metric-torque::before { background: #F97316; } +.metric-card.metric-time::before { background: #8B5CF6; } +.metric-card.metric-distance::before { background: #10B981; } +.metric-card.metric-mode::before { background: #EC4899; } + +.metric-card.metric-mode { + grid-column: 1 / -1; +} + +.metric-label { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-secondary); + margin-bottom: 0.125rem; +} + +.metric-value { + display: flex; + align-items: baseline; + gap: 0.25rem; +} + +.metric-value span:first-child { + font-size: clamp(1.5rem, 5vw, 2.25rem); + font-weight: 700; + line-height: 1; + font-variant-numeric: tabular-nums; + color: var(--color-text); +} + +.metric-unit { + font-size: 0.75rem; + color: var(--color-text-secondary); + font-weight: 500; +} + +/* Mode indicator */ +.mode-indicator { + display: flex; + gap: 0.375rem; + margin-top: 0.25rem; + flex-wrap: wrap; +} + +.mode-dot { + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.625rem; + font-weight: 600; + background: var(--color-ice); + color: var(--color-text-secondary); + transition: all 0.3s ease; +} + +.mode-dot.active { + color: var(--color-white); +} + +.mode-dot[data-mode="OFF"].active { background: var(--color-silver); color: var(--color-white); } +.mode-dot[data-mode="ECO"].active { background: #10B981; } +.mode-dot[data-mode="TOUR"].active { background: #4285F4; } +.mode-dot[data-mode="EMTB"].active { background: #F97316; } +.mode-dot[data-mode="TURBO"].active { background: #EF4444; } + +/* ===== Tab Bar ===== */ +.tab-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + background: var(--color-white); + border-top: 1px solid var(--color-border); + padding: 0.375rem 0; + padding-bottom: max(0.375rem, env(safe-area-inset-bottom)); + z-index: 100; +} + +.tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; + padding: 0.375rem; + border: none; + background: none; + color: var(--color-text-secondary); + font-family: var(--font-family); + font-size: 0.625rem; + font-weight: 500; + cursor: pointer; + transition: color 0.2s; +} + +.tab.active { + color: var(--color-primary); +} + +/* ===== Responsive ===== */ + +/* iPhone SE and small phones */ +@media (max-height: 600px) { + .connect-container { + gap: 0.5rem; + padding: 0.75rem 1rem; + } + .app-logo { width: 36px; height: 36px; } + .logo-header h1 { font-size: 1.25rem; } + .qr-image { width: 100px; height: 100px; } + .btn { padding: 0.6rem 1rem; font-size: 0.8rem; } +} + +/* Dashboard: tab bar offset */ +@media (max-height: 700px) { + .dashboard { + padding-bottom: 3.5rem; + } + .metrics-grid { + gap: 0.375rem; + } + .metric-card { + padding: 0.5rem; + } +} + +/* Desktop */ +@media (min-width: 768px) { + .metrics-grid { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: 1.5fr 1fr; + max-width: 800px; + margin: 0 auto; + } + + .metric-card.metric-speed { + grid-column: 1 / -1; + } + + .metric-card.metric-mode { + grid-column: auto; + } + + .connect-container { + gap: 1.5rem; + } + + .app-logo { + width: 64px; + height: 64px; + } +} diff --git a/webapp/icons/apple-touch-icon.png b/webapp/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c2ff5e6483aeb7a09f342cf3261aee8cdc9126e5 GIT binary patch literal 10868 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 KCR;6S9{PU=1Jk_# literal 0 HcmV?d00001 diff --git a/webapp/icons/favicon.svg b/webapp/icons/favicon.svg new file mode 100644 index 0000000..493161d --- /dev/null +++ b/webapp/icons/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + OPUS + diff --git a/webapp/icons/icon-192.png b/webapp/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..f7adefa66aa517806a800e4fa2542f70d521862c GIT binary patch literal 11459 zcmWk!1ymGG9KWNyqZ_14Qc|SjI6^==q#L9`x>LFl5Re81q`SMNOS(IxyTAS3z1w~7 z-pt<4%+CK;8?L1A0Rxo;6$Aod$Vf}50OyYXzYrAQsIp}93OJz{fBYZ;dinp8-CB?U z0#SivB*fI*(~mMd4Dq+``rE5DTodH4WqEF7a2;XrE~Y>P=Fo^Mq$iIWiYp6Sg0qqc zl?T<5TJ?{Ai){VWb+9nm6_D|j)Pi9CO94xC&HS#svnv;&Y_Z5By>@1*_r1)Ihl)Y3~wM;#Ffr4v-RxKRR}x zDgxLFBF2j3+}K*Y0|6D3C+6Hz!16~G9AMt8lIt|^FJOP-wtjTH=@f|MQ66NTH8-7f zWVzPX8?!GK{PX8P&(5B;*cUW^Nf1&?6dwJw1Hzm*&Vg;3@fTVWQdV88j#6+B!Cn>iuln!w*9M5KnjP}WdgdiK_ z-h&oF@PRGJI944Eua6f3)@4oXt()2@eECr|1gf(5_>vAN$;*a4&QwRt6Q9p)Ir3y?(stjR`I<=#p^SdBPy0)ZxGyb zM7=a|@78zg?+$`;L2~}ULn}Q`8ei$QfC2`BeHLg~K<15Fg4&HM{B=1l`19L~W$`5%&k50P}GREk_#lHy5(; zZ-iWFWC@iw65phi0^=2U;ezWG7+pr3U7Qwy%f^B8bEpfQ9>Q}RqHsJeEeU0{kahwn zat{~s)`HN0nq{0N7q#jyYL%prusx)X#aHOm71u6?Da{q7I#?QVt%rr}0zC1V#~-Y$X+EoyZ-@w9gIgOUsC*fGlY(Pu~Lgqvq$wow(Gx?evMxG3T%qsyQq(A*nMrY68!ghRCYg<0>30t zGehyQ^aE9{G^?cX#QSkm15kkUfxSyhpsVJ%UOUmzHmQ-7Fz^Gyw+e9jSDHp*OQ(}S zF5CQe_4UM+I>(yFQyx!+qgT?m43zXXAP}Q0NPR}==SU;@1QhxCQ>X^YrHh5|Bc=1F z6Ht!NOcbYK>M05^=;)H*Vw~T9F6)^^%qkx66^`%lqDDk-bqLzIj4)s{v&NHw>R}n) zoF^vpC{0G3b-O^h-6;TlUD_;ks#|OTVnK36i`2T&26d z^IRMR<_6v5g8K5Xh_5w(Y{vJ}MJ>C0vw(Ru5eD(_1}FZ0mo1$Zld@$D>EHi_E0Y^n z`{n%xS~+ZFw16|aoV@%%tUJg9(E^Q4eDqCh%;GN@2!_cJ^&U%IPO}|Wx@&d+tle4rgHf#bNv=7IYoU2?N&6s943 z$xsI~Xhp0Gu#2f76|I?yC|g+GD_X{oQF~i+_Yudyou9^p!NWy3KsxaMl}J#FY8rb1 zh1&X?L?cGV`63R~dJGsmll>gTFE8&5eMukU^%und(bV)}} zdR9*)W$iIC5JEgsLUL)aL_Yc-7cq<1AW7<@7Qr~*l?;lkgNp0CyN*@y83GF{mi~A* zd-&rtOT0&?Jyk?pwMcR<(tvgYiHaGjg72NNbg)WL6j%v?+L6s;>4-i-2NKX`^@`-W zl}ss8M}#U!s=sn%kG~O13+?!W?h0<~s3Nl#G)FU}i9Hh|HB>^H81x34#_!H)+?4%t zPbjpOvR2SH_V}LNWAZIWC5Cg3=ONx+$@15wH;SH@JYiXm$Pzdcg$w7J(!>ELDk-;t zxp6*cCKRiWPfg5Z9yj?=w~H)!w`GdNRv*Osg;+1=OnP%_$7CS_{6-u(q*k!$a5E89 z)vxav3I64N;1#3aiK}Lm?IRN*ou-$d((^O>gyZQd#mG@D@=zS-2?fRb3k)$~WZ6_- zS{3u#U!=B=)|7Scd5QfoRz%3LCOG|8ay%{&QUbahi#_$ zB39>wo0r0|oD^we{M@l@KD*i@0JWSyGHXqXnnO#xC=~VoaoEGwu6~&Y~5-fzTM4g_=_2A%_$U zrb}Mw5QwfU{nuJiobc*0Y4OYbUTBHw_E|!_rZ>hIiGz3tsXrYv;cq89^EZpL%He%a zhaNK)8f(C$rc?3C(t%U|-ucRng#k%GX|LQO*t z=9K9Tn>M9UNy1EJ#!%Lt6_&Yod0#=S!(n03W9UZfxfoOBDtQlp-0L^ItwYTI3I>hj z?-yrpVJs(53q&Qe;m$-SY$9p)4-1zewFR9w5Ba-ry)v-uG^B(NJLlJ1p=&kORR z*x<@{kicw?X|~w?GL=V-NITa3^mgk9KLh*q+sE<`UDL&yCknAd8_wC)J5JnwH;V1H z=Q6q6DZYnNpHy=qVm0!1w?rr{w)x>XZ;vhgSk@!{49!$&OT7Q*GtYj-8f^SpWp^o} zWhhfpP9I zdAk240*0HRk#Tl4=FxVVclLxdH-99@W}UpW=!rskvBH><}jAnP`QK{=@B}{Zy&K=^Zvy|LQs? z^lZ11E1epWn`F;5n5rGSHuox%hz0o1k73F`zgIQgVMMRqUGBt&$w;h^J#Wnfd+-E} zbJTtpGpHmGF1UVI$CR7l%JW>~%;>BvcoHt5gw^TJ234);>GX@M+T=_g?F` z-~D#^GJ>UheAm#TdXbnZvlFB2YQmcCgR6#`?!bUiZ2;%s2DOL?Im7_unw0US)?QY*S6Mp}x~m0r5g{dldv;hqtG708H`jY-kBld@pB^MrfA4EZ;w1gy@UL6& z{~~IGsMm66dN`Z+`9YPhZrx5Zs?=_~XOb9S|EX$BV6}$qQB>-6fHbOnjQL-|Tl#Et zSehSG-Y@{lo+NGCdPFicgsQ}L&&N%|eD_&d55Exee*Z!@J6YoUyq2Yz-!W}HOG`r^ z@8$!v?lin%lH#{rH)}cEfPzgxI1zM=ns0rt80t>TB4NYL3mMyrUP6=O>6hvAn}j3-B#ZMdh_@PO;@;5^Tq2=iUJ0C#dCoGuUNa5kK?vQ zK1nmH^A#@c;B~nUZaG+of{j3M8M@*slKVm1mILZ<64YK3U+rRf|9QQkvVS&mCeRtb zt$pwg`zMJ7XZ(IvgqXG}O_7BaF4tR3VFQIA0wm2m>X*a9$X?m%P%(w&?&>zrb7Apk z4Vb*w5`c%cd&}I_aT5KsBaxG5nd5plZ!AL1YoymPFfYp~5)W{RdIWS~NYMK#sgb_aVsR7+OKjRbkv0qZX!(G17i-De0 zhzcABH}aOn@crD>05J#>0aq(|Iec}u3^FaML>vZnOl$Y)@Zv_`Qi-g()bcN%v5G+L z*U)sbDDGuvNkh+qlh2cT#39A`q+IeSqMKg+FBty4qB-pe{QF%xiTl;MC`~xD$FftZojlzNAekk3-76<|XP&K{&JWV<9gm8(mFfadjWrhDeuG}S@pV^SxqcLN)| zk;Z4Io22cZgP@WspTbXUGU->aaT3-h85AJVnW_dCT=bx@pA$;?BSG8(I7mn{ZJ5Mq znCFC=v#RY_4M9;6wcmbEQiacS%@pi<%T?#ZD@)-f0AgqG@EH@&_q{*Fj4SWe0MGcy zV)Xhbk?@?yw`3dUaJk@1j~lACFL#9^x;^`_{&2+!=QWp^Wvw~mccdKSKDf%2dCMaa zTb6qz&*@WVpbn$`uOl|Z@3sPRk>Z$fIp>x;?rv?)4)Md}g~ED!R)vHnM_A^jKlMFn z{&*|byl+c~ueV(VC4YlYU3qJjxO8hTVO7OPxjKDtFIcJd)g!WZyo-@G=F(m+&Hw`j zi1$Q?)0A0~--BQ7?qEbt#-R+-rp$7U_hvVD$mTdW31Lhc?LBwV>ug%R%P;ekoxo5^4Ui_-+nW$wzmQ}GY&$HV|_+P zAAsc+wxcQ)e1;YXu1#9Z+Rgduy@_cVQVh{No-81?4iV^A7ED5Wyhky_v+FRQiR)eS zOx7AaJGPh#m^C^w6V0gnp^&%VvC98fEZ^ZNWB9GBb!%{R?5fCK8baGKaI1WpHjd%$ z-2P&`r1~~Rs$PLUiQ%ldszsD@36e1?<^J5M&e`$g`pyb) z9~)pvF33sy$;s7U)^$%mBHAmL1~fR3`MRJ z5jIJWl1qwdi$NXjufN_mADk~}ez*-I;pOx$fFEg?Sb1Gn?HF_#zRKG*H!OCCKJTK@C9cz!s^kCe1|t^ceyNge&6`-Lv!G;~iVwROV>qJ_F&HvA!3SHF~)l zn=Oao=>3-7xkp4BHj+W*L=e+EyA4}igLr`p!sIDRo%R*mZ|Fo4m5i>0JfMmP27nED z6dQ>{F!YihP_1wqzb8BRhyhfh2ACq}B%Qm+_qmJ`32yT zKe7X^NtELt_YOkSit*7@PPJ9@kjX;eM=gRA3~qd;H(_bs;X@|m3?f$+<_oH7`@?$c zue?YM>ra!9`T=kOfrxcd(N>vNZdnVK&!1izS=&>pZymzulv`rP-sD2~0ZHb-e*@fr zHdbr*;q72lV-#1bZJYhuy$Ar;@FEAtIGLuXOylAEte;KL7GhdX&QsJ<1JmCqp7Zd` z0!G7R>8s+aQ$P1ocLw~SY&o+P8Q!M(pv(6GMxau&DC%x_JpMsCa?8Pl_Zr6By`DXK z?~Z=Zv{wD8fC$mgkv0Mn-ffCfAAuV08!Y|)*{!$6!&me{A(i`H`d5Y@VZ{DZJYl{* z@AJsgM7lQr=K0>B*~OXl~y+}Qg6Zpo0 zN$9E2L7-xlprT8y5?Q90(Bu1ZK!TynzG6ZJBqBtbHY}@(43MqIb$@LZ#)kwa)@J4k?7DKf=vQIC8u-;`WHN%EKKz*;SI;Eud0lw3xPw# zy>g{y6-{#r~~Y#uqiGZK1(dvv;q$543*fJ2>Bi5eb6%=V$rRHw-o&OPerd$FWrkYc4y z8Hs6R9IlD`QR5G8WM0?fb_3Nm*SPw%DlTpA=ER~33R-M)YWLi|;DnQvD0c$e7t!S$ zMu}cCT0mV$=hYJbv-Izt?Eg&x2tBUd)yVWW)>N}G9-$d%YWvmk z0uMY=jb#Vy<67N5OxJ+t4+Jr;)dA|PN-|M|z?5ewHuM1-ve=au*divLIe?};zxAd| zE+JzMXTy=N{ZTru!6SvsjRTJ0C&OX<~0T++YchJ z%{Gl&gVW4lz#q#m^UhEno%1B_7j0O4MdBd^Bk>ow5W?3V`Bz7}R|NQ3b{nO^MS|S( zXmPE3V#&*Io&stWL+_XUwwA~I*b-s?FTR)>nN#~J9;q(|VkF8!8~s{73}nbt2Y+Wt zv6-Jtr-BjM8`O4uhQQFG~%*dL1e z6Y(IuVR8NUBdt3%*PY{EK=%=)+2lL}_~9*o*K4l#$9G}XG1BU>jH+QdTR*My>xLzb+)N1y3bX!m^lG4va8rSn)fE$`7 zk%J?~7mUJS?R*KK2*V+t&`C&) zj_rbemoC}5*R{tntHxE|LO)gA7ukY+E)c=bD4-DLZuxc>+TxTnpvU0qi&@8aJ<2{k z#j+>%^V!A>G~nTiu)hr*idfhMN z_j$=g&zr+8^%6T@kj27Fy?^^%02K!a#OHG<{we$d^uGYBK^}4kyr%!%U)%z{A=x?u zjo@pOG>@4u(!eed~|785%7IfM1cx2%bQ!>vGO|nE20cEG6N5lCl*?hG zMdT%>!8A;$zSlG~T53#~nr7C~d#+;6o(>%tTNJ%$(fHO{CjXLya!FFVT8dJi|p~ z><6+aU{0raE*F=OEP=&I{oXHTNwt@Sn2+32Gh%>10>z>qx;6ZaSlRK1l)m(4!Hdq2 zXM2d)KCHhlhcOzhsy$3+islr5CW_TQ`#4Sl3_7Vb_WRctV^&3|WJ-$-D?Pi;kY=k5 z4K=n5hvmfA0llWayz%3F`&DNHaVBYq6(Y#LuiQYq|4dOx;DgQ=rU-lYcXR?BQOOWV z+=x$)S;c)s@z2TFC}NGx*-SB)r~E%LU^fU2WO*5$`^}XYH?Ya! zF|Q4*UrE2&stZ9D;9#EwelAE3_s0yqMuH0}*B|n0z7mN4-)YY%PUWT?ta)uAe2J;u z{vw)1pY(t$OC>Q`a~S9PysUpEXu*Gbk~1w8g8RKw0+62|e{60_cRYoUlw3q5B-P-X zMo2H$&n!0v<7%RftxO`#BujMqM5Zau{oGLOKT1x22!h7>YIh~mMeeDwe%vLGbNN$R zvUpL}i6Q=PC&PNut37#?9rziw?@*#xL&wjLL=i-CzCzU(-u_H?OPk#$wJab2RuI!l zV^=fiC%`L#h$sakdZ77{e<1NUMOHZw^#B-od1y86cxoWd-!FD~pz&)2e&5oJlfn1* z&>BeZ0K1+TODJMRaM-L>oLsQG|FP5=-fPd0oZs;k@M22g#Xm zyXOPw#<*7Pa&ZicE{Q*kE?D5u8%Tt{3OF4%SmRG9u#nu8e$Yuh^tH^98c+xrl{9E1 zK-8-p(0jz+Ivd^=S`t%6=%FK`rEetGLeuD+RzVT(<^^n9|ISRQ=K>l0V1Qo&@R>cB zjs_Ta=FJOF_g2$(2Bd0dkDF#)E#QzGAS@#^_zIA!^_Irq<;h4O*F$%m1){P*;1a;B z0IuC zmQ!Dgi{x{W?>^(8>PlqSYA9ypAKO??yKId^$dmU$FoRlh3@?Ka z+ltWDN`yqHHK-einW(JS5lc{8LU0l9wbLIDBmtOtER8S#5=4LnF&0+u(Ar*?QuOyC zkpd04{a(hhoes|>=Rl)k@w!o4siL8PP3TwL#nfuM{l7(#m8^v2+=a4+B00zn3>Z!R zI#?`CaLpMAI&TO7*_1mQ8Z8kBl`R>qM>u_}Z-~%Yo0VZN^-opp1acQ$;vm*38~ztC zQ2NTmC{RFv_W-h8;j(aK3lb7p%`+4fp$-BH2I{hlS5El?;TN9RMG&b0_9J1xs7$OgWN7n<=~_L(YZ6L`0i?19m{hv& zOw+^6P$hb|F_Xkz95$H&w_ipsaMm6bP+MM$Mm*fzHWqz`iipF)BgEsgQS;4w=+-eR zH0mo1_>y|-_VRE7Ae>N9M+FjV2^Ao>eiKBqU&vuwunbi5Cx_}`Nb@{WWM#CT@HB_M zn18GULPQa?IpFrIt2XDH%jBw6kVAlft`!mzWHds`s4EH&p-Bo!_6s_A+--0*tcPYe zQ#nxU-N(4NY{{Q;DAm1z*Ox*lE;Dhz zZ!WX`5uX{sU+p)zaBxD|@!i%S>f2CSdbDM)=+P%P7Z;P+%qO<;)U2$p!56zUEG*V~u ztp&`VKpirSgIBbs2&X+J#ituh1$`}dU@A_r$lWX-1VzzCPu)#3#lfCpcG1v9KZovy zvUh~#k9Sy6yrLi_eB?@r;WUZ>bZ{He8a@~#Iba7GdMf^Q(s<`r*3E9G&$&@uSLbT; zY)a~U&t|d7-Ea_LCM_>7;`EqLS|+Kg%JO`5{mMlDVJ@7xrnVN5m@6~)H!?QF*D+M2 z;ef)s&5Kf7dx7s!3T7#T!oqpRKp#(!?<=Mr{5PV?Wka;SXnu~;a+yENRIOM7+XOt#<}5FfrCLBx2#Cj zn)*wJ|407jBhWV*x5S-FNO{(7SzoQ4`R$ z#RUq2T*{5n=e_}LvFK=4lfkUQLXlnX&#`0kZ`-}0=jT-)yj{=L@;=NOc9#i1NVSyG zn5jH&>=4}cuf7C|uO9ahEw1!$#;(NjSGTYv68uwwg|ZgC$l=Uq6~VcAw6h2Op!m;w zL?hmE!~AF+F;1E3yT;rx=_f42nfdK4Pg1&--q0X8SmhgT{I_r18x8Zuy))#^!s99A z&Y` zCLN`FfH$zPUob+KIX6xq`*Tb$$bt>SGcrDfkbQOvoAk6&88#p?zEQaFnx+ z`$;qo+J?;hYrIa7!kib4f$)tuQNV!#KLlq5jq2xDiT0(3k+Al6?Cxb)Df=;j>HOU3&r=j3IOKxJMp4JRZS`-|?fhn}K7F>B2z7guJ|oR|ni*gRJkMwG6YN6vK*_ zj{>*2*9jc-vpots@D+szD(UJ=e`?xW0qq#x=W7H3XV04Y!?-x?*dC;#u!~_rmQSMR z+`A&)aLCHzu3yE@*{f0Sy3?);hrPuN3oE-|sk{)`-^30I0Dk;iemN?%mi#c}Fcdwz z{++b^>%6g~lM@RH3QG1w;RoBc+M1f2TxRE?EX1u>Ua_Y}ZXfyw{ubAxrgtTQt`WH{@eX37V3$k&srj8}_gr>Nv2;E3prqHWcsA#@x)@Zt_<<)EJF<5mGm-yb z=j51ktsYCL(;|$^5{Z6S0d6TXQJA*+xq98rI`iw)>#8eQG@L;Tsy4R-je7mCS#y99 zSU7jW2-l0nE~Q(Z$m!3It?RO?rhd==XZx|SRWG#ur5OiHxQdX0^oOqgE-(yIhlJyW zRxI_uE^KDYVvx>J_BME^LTt@6zQuGNQJ0U;(_DiG+9%;ld~Mx-y7l#k<8#vp&C8g! zx@0soQq6AUm<(+TZlGccU(^pVnWxhIR9#a3#WsBh-6qgVW)rFj{KqKlDkvV6CL2FL zPB_#d?E8nj@^&opCbo9us9vk-fdOeTIcM~@glGWH08c}OuQ3`D0v&xH?HeYrva<3Q zJ~9qsxk6D7)XyKKcS)x33lD9DlcMxmv}Vt+i!XekO69Z?+QlJIt#r_F-Hlpnuhd)~ zoCjC_IVosO7}Tl7!XalN-YJW7K&Yem=Fs&!g@z-pB`T3}&VyLq+&DO) z{*=VP;31UZ;H>cvXOa=+G^1cQ1lo-kimb4Xu4wPuF(g^qJ!zIZGUyhp5`cjJ9!4c6p{N9^2MWg72H9TDB^|pF=Qw2yMtbTWdpTW-}J1C>0mamB%Xon3fRshm5rO9FcE|#z<-7r zf}q`mxdl}2BPfem6RKlcW`@2y$FEqbE<0tiEh((wm~sNDPWD{Q1p z@`CU1$JYGLNYb(x>9m#j~0;GgxT86Yc{ysv6j^?=-Z~;JggaV{TyFnZr(n} z2|X0uHk9EtKW=s8M9GavZ~CIZ_8~5&M=f((K}ZfMF2F|KFKImnjhes*%V%3Gs}dsj zyYzzu>SVAEWb*0$m>FUOA&3h=i|R(~H`0R_pbj%m>NwR`t@-hcPS~uDRlv>uJ&B=U zVBiZ!Xe%*oJ81{4oF1jCB@NWnTA!RIARpP1u!F;A5zUa%wTi|p!&b`MTYG;fB7odS zaOWe7v8$XEy-%YRg^~ScmuzdLp*a@(X8S!+|P#*E*-5S-$@!QEH06(1KpRT z9XeON-E-1aCf-owq$)dHNe5xDo0+Hh`GlmB=fn;kRGZ=m?QU2n7~QYq*svh2fE)O6 zo|0E(;MdLnJ_)Qn0OHC<9Qns*^@zI+LkK@86&~zj`D$;^`D&%!{(JC1%u^8&;##;L zyR{l-bbEypcknPS9i)CF%rC(7HXL!6qRblXkOX#s|3z+SQk!8w1Jaijh&E3NXDsv{ zO}t>X4j0T5Y!nuMQnzDz-VW?AyjX-B%(q=r`P>!Eeuw>7R8chJymt1v2 zK)0nX$y$G)e|MK1(ix}owE-&M4tKaEi%J7^%ZHmHVkqHc$0PHg^vQR6?en1k;;=WR zbo#G59jc%0#0*w}%1V(>lGpNuzE9V@wnExkXU5G8&l&=5G9hfUM1OP!{JSxFI`U#K znW%-UG*##PCRmE3lK+tsyc{y*E_hOs_t?i!hCWdW$7Qa;5jfC}>fWA(DqGdyJk zCbL9;V2%7+`mKO}6b^gIWoafe5Y)uR2|7rW2_e`4RC#9w#mPB;!;4Zg2l8zCnp-OQ zu5Fzj1~8WzQh&~n>F5i3+Vu(+D&1Y5#F5l+sJwfav<;XLiX=~w&zk#~$oLJjct`Rj zdr7q#;mx&p^F4?dG;dPZ1lRuU^pu-@hi4I}h|%B2m0q))=!Q+Hn))sdKbponRR4!h z7(WD<#wuQ{#zFTAa`VH zq&z3Mq0G+v3a%M0e?-6QFQXPWxwp9U?;xL5|FQ0@DT9}a=!a?ljyw1dETD9x>o~Fd z91pVl0TXCs^OsCVt(2wuT&Qe)^0MIVHBD34B?zFV&z&&;o2~3rn@`h!^{6WE=0sH) zE_t-e?tepCFbvzgcz&r1C(93Sc&3>#>g%y<5swdEk|M+J&mzmR5cyl?7j2BV%13~5 zv`qIz0bAF%KKZ|PKGTY_w~ch4AR{SxzY(-u^Rwu{k5CEtw<0Dvsdj^-Y|uMenY!6} z7GGC9z;@C7Z*=dCgQCDK&NYH8AG{&)-v)oGNAOO_vMW2oX(HWC$67SRRAW0u`U|9z z(ozF9oDql3R&Vu$>;CH6{aQB_9GAjB&i`%vB&)9ChTY30F;8s}D)xaR2#_L`2so9t;EFU(a9uLlu_~oe{C!M~=x;QT@-x zx8#xY{`5P?IL~*KDZU_fh}b?*{BM6rF}Hbx)3u%-C{uW%i~nuQwSeUeRMp|vS!*aZ zkP)2(h0ZnY&3)LehM*^=it_hU>}>!yTfZO$Gxf1syS*1 zNl7YfiCucsW_8;p_SQS=?Nuvy5CuLjwEvqmV-X)szp{O0?uih>soz@*OF;oe5{OrK ziccGaK84FB#9Kj@`v%~DLz$(Fr}Q`&gb|)U^eRU{l5ckC|F`70pc(jm#sauklMwqD zj(3Gr{|)jw;LAIRU)v%nK(28Yy z$^W-gl8US6T(g3ho~DS@9O#UeTKKT(v};2Cp5Z)RFC&Zl)k>r}z=p_0mj8Rjfq1_| z?n7{WZVvOLIKKSW@mo=A2s(!CRtztsqg3O+QH?mr9RvS>Lg55rh}}%$``e2jnE;_pE?WM z99#{1@xx5-tzX~MOscYHkzlu8ib!q5%H<@{qEvnV&3|T+kMRg-+5DwWc=MbJO&oPn zD2l{Te%x>VC~$tucW^6cesAGiISs|E5=E0h{t2f7IVO8LNuQimEZ>zwj{&)!#_wp zrcP8&>8_^8vEpL1zOMrcukIZ`6+d6weLbRtqv|gn5_J;uMu{RH-u<2+7wcUr!mbX6 z;jo_1Md^+Gzv7sqlDWIXB{@Y+Mv7T0L5E_oQx9X>paFF;Ke|y=r7yopKu) zMbQe*`p#Fq7{NU38WBgxU6YS2hC-d>(!YI-gPJY|%5e>B86+XOV4-W z4j|1Ctsz{VB3R38KrFxSt=^g*3^tD46~%hJqWQ1+1?|T1ET}{yqPw4^&+ATO%4xj+ zEfF$_%D}33<8+dk;WvU*NIjZR=p@ff^HSLNN2Uw+WEI>y(PP?IvWv;M4us@F@eRe% z2wFu>^Z}&4qq^V)M!LK&NP0Knofuz23KDyyFsBg~x##Q92q>T7W**GIrpry@HP?*x zBZkL!5VtHBF-^z_D-BDqCt{mzqHw^?3FU~WE9=%iSNMgA>wrtaHImXg^A57~o#NA4E7+Ua@joEOd^4$j8LRP`_$;~+eplz?`y@#|u(IBP za&zwLbDagxkbI~WMDERJl3BX;M;6!Nx8I5G5j!$feM6%JxM~ma5Ru7iIf8L#R1n1D zsQOO_lD+Y_D6fTno>>x!301CIU?A5&J~LtGoLZBH?y7=^5)JEVn%#-elRjYS%EZoY z;v$41B6GE@Gh_0Lybec}pUOq{fl`LkJ-g}G3v6jpuoV74tH8z+xkjmMc=8|%t_o~- z2&J(g^5kY7$mofBk$&`9@SORU`q=Wc;9gJm@$b0ZjfWuj!wi!KhGCA|oM#*En#Xz{ zXy7VpmJQR$v*AS>nr`#%cwF0sjJ>r0<$pEo!C@OGn&Y_=mC6mPWVT(f8ESI0#C@yv zvXkvM&qHL*z=$z<5?By!icVwMwYYIw@4-T&H;&+ad85-tYUFy65Z^K!T09I`Lm?YL z4l$;nKry(q`j&Zyr9H)4R~{78np?BJGk?Jtao|ZF2eksv1qXuXP{)?ukBB`8dth(T zNEHId-0?j8u%Hq`3eOLb`px$C`5WaXxVAlwFM>GS5n%5pPZv13Nl1TziWNJA9FnWx zHyGBnULn0UHSSlK@Nh>Iu4wYf9d~k7@NX={cn$J*4PqP%$<2|6cEPbV?L}8R9C&V& z;M#oBJ|N@L{lhiC#I-2GrSqp!kSBI)D0qHPZD)n45&9Pov*j9c`{IefSR~g;D8SQC zejgt7=4V74flB=uvGyON_er_z@)A%kp9v|(F5lN9NXWR@m_}e~N8i^yc{L*n$$KmA z17arC`E;Z4(R}8wY>q_o8!-$Wx4SciyvMz}2`L-#6g3IU{^5J_mrS-DaVTDXf;sZl zYb?klESP;S^h9V3=a6p3nc^1-V}QxD*ti;uh#JuB%1d*;OyR6j#01)o|~C+2`|_81If*m7)USn4Tx@$x$D2a}8Tfy6Ghub~ z>)4Q;eB%gi_<|(T+uv<-ZlQ*vaJs|R^x??>^Vm~nUrjCycw5mJaF6?g#TXsu)YkQT?8mU*PzTXasG2xE=!-$%EIqST?gM{qMe{K3zNnl- z4j51hup^0m@9@~U{ANR_|T7QfYDv5CN+49KKlwA->hlq8JMZ45V zC=8?>3-8nRwc~|+CuV3MI1Ba8G&ZL9j~@v+n)^;1n`1j5eeZvp)Se0jeEB!=RAg!c zc{P-&6IMEuFiExP%!)aGoNS&u%e11V^ggPKf`)U{V`RHNSEx*n?-KntHIwaXsY=9 zkbocQfh4&$n@X#Jd}RtkEWF*3q|2v#eeXZ;Y*^a;_yzG4ubhk3cj9m$(ML4@6gj&| zLBvh<`mV`i$LVz!;UTbMRf(8Q@S%C2tXX|uElL~26N~ltn1`MY?P-g+UlpVz!GO>p z7m?5}21sYb`tSyaZb9Tkt4f;caC0P_$*~%4FSgj5k*`XD zn>)^dN~X57HFAuA8g^}%{MS`}ZdiRwzjv~4$#&uork}Webt~{h`FP|K=kV-<54jJ* zUqM%e#JrQAmDK4tB28p^?!uX1n%kt&ZpxIccEup2BZsUD<*v@|XOx3LsIq{WLWP zeBaXlY0U9%TkDU$4rKJG+#j@~;hCv$V(!TXRVf9L`7-SiV<-`U6?TIH`yiWxccQdSPwXk2#As&NZWv zfk_WzT>Ew*Z4;;vA=<#<^4GhAD-glA`)XIiD^+kA6;J4EKVFq7(8yE2B9XdIYX`ZW zKiPYsVJ*Wucb5Juyj^v+!)0`AVrJxH84k!#+_+Yd0V7tS8}||6qx1JkRR*y?ybioJ z&65wjWWjyHM_0beKK)_WsoMmPG4}6OX{JP(!U9VFDU;}-E^g`D3!D2qkmmifnIR4q z6u~!z-m*zJ-tvlu_KS3`Pgz+x!D2>Z;FNWrPAxB5uW|I>vJ+*TAc2_4@F!{l?`sYo z#6g9EGuvjfY(W1@?Cf{Xt^;l11%pR4v!JwEj=+3e;Bc^YXzvdf3EsW<)A}OWz#GYR zIpEL!u`e_fWbrQ%GLQkd&n-S-j-P)2={AUvojAXGn0s8U1KXIILTTH56AOQ2EPeErv3~3yV8^ud5CC;Kg703h1bOUE}<6d6LwF=)U|&K>qkZ z4?5vLXv$Z)Ybyp)$Iaf;GYdW=XVa?Sxeg37a6T`kupsj&{TM}hPK+(zS!+2yRy6L? z9C#jB@BI1FUjC^(!1LR;ki6}(m8U{^%891*)|PW+shJxw{t+5|DbZh$m{!3k+08~5 z>XN^}$>*(G4vAWxI%ZHW?51H=!|=$&TN{s6tB4n^02TB2n3xe1=IDhOm8bja2Dc23 z+QXh=XzUiQx@3H&2_GU3;?|LqSg*x-I6xE!@WN%yCvUrOxM%LDh)Y|@{057i$dL9#D4?Dov+yiJRrW8vt_;&>ao(nXHvtmW)~;iZq!bzvq> zf7CAru-4dX9eAaUelr zyhk*c-k*Miy(x|*9>%KY9LeGQFCK~~`s`S8eR}1!2u~iUSD09+GNjLI1oE~I@0_ZH zluyq%bL4A1>Aljf-zDcOnDD-NePOhRM6JEXz-EPv>U3-{aR zka46s4ix%AFM^a!a|?asNnCyi^H7Rzc$j0I0TBfnX7<|J{whdcmXrZSy(>4wX zA;)^+(vo9IX_kh9!Fh)@;%s}2CiNA(iu-{pZ_m%eaF}VTHmcJ$I{Ri`Hzq`uugo8} zjQ%ek->dVl7cF1g3!Jn?k&4<@w?VD~qweJ*Q)}2$u#B}LWV$KTmAsCdINjr%1izfB z@KbU1W1ttGZV6r*JQ*mFbT(n^t9o4>rfxFs(;j#NR;=oUs)xA%uJ_@76um_#?z?8k*QYwlrJ8`{Wcj?Kb3aRlY!#*;ix)#YBeXkoZP<4&!b zb+Y*2NYK-h&Tu32L44-MrTg!NWn5NKFu4!34~;=R!zIa#^^xEdH8t}Qo58ugK;BCt z!YX%NiSLyc9e!0M!wtJLrxnM~U}7W&AuCASoZoaij#j6!k^>`b!%mez8p(sb^QqXI zl{~9OwWi@C$i-YwzwE5z(u(}SG@B(lkuAMRR&lzDJridKqcD$yiqQ!^|h;=V2i2$Xq9^z%Xb}tO0p_3>DRcD96G6;2hkzt%J)ID*HP(j2($k2 z&z$4e1EXlkdi(QM=ajAEn%C<*xIu>otc_!lA^)AdANe#5b?KZcSz~@*ApfZgnI9tG z1&T=H8A?e1kIZK8;dYB)F$wjl_RH5wUdymBR4i;*->5OJG?$G`E&r{o1u5}@ZRY&# zmzK~Y0q+v`4-#rR+tyEcgOB9I!8n`dD+TT!{u`FM;RxSUBmIZ_a!QWAh1(HI>XH6i zIhHsmUfK8)DsQ(^@%9*-Fs$HF7v)r?;)fuce>Tm#_-0p}DS%HZK0Un>C?|W1pBpO0 z7SUJO7C7+BXRmQP>ydJSMG#E|!O;FAt6d=TXpYqwfLEF`?}>A1+Kb)LXzs!S87BW- zG~i`n6sP_+m6yFq5hzKGXNo3=APkqkL`lJTtI5BenbZFyF}G4?^+96bF_58YqI!#X zrtohI#@k8Ly;!DTa)>{Vu70xjt&95jV)L%o!e%sXM#_O3N$c^W^koYyU5;ob?YV{W z6v1&7fz*gX+^jW|{uqS?>r9KcpaB$_Z4FQG-rKsxTebP-xfj2lX^hH$lf?3YbK7DM ztZ=U@7V3WY)#B@#Yz$O&rX?Vzn@*ngy}`eny>NI(D^78l(^D|aP&RUhAz}^I=JtJ3 z@v}#$u@Rr>`PUf|ay*GBMG+qV0{#`PYgD5s$@w8&8VmbJ)L5A5%-YUarAP1@j`bCH z%o6LA|JjoKld!UlEeCZn9Wpzzlimfgrf?w|&Mk@o!$v62_pobt5JudetJZ`p9^s*xDzi) zE5I5);dZL4(wkM+*Zb91G@)oCQHOmODgeQ9rk-d{c@Z`R=hA7+)^zsjvYbS|D@wL4 zphMjMAEB_+B0Mw>F3#gjA|1FSugAAiH3#4 zP##-c0O)b=_3ccGJqSQ;Je7h+&ircEn(5`^%b7DIS>4J9gWprnB$36zjo4so!6n)* z5-266umstp9m0&K#cp1_E=W~=FFjB!f}xXO{-doQ!5W~H48kj2+Ka5o2McneK4F3% zI{qp@ppCUi7jN2pUBd=tr3U+Rp_jiPJL!g1Cc93`Mb5t}NTWfq_CMIw~s&IBQA2H3GDysF0=7gn;AQZX3a+vE94EYujxEZkrx+oF)`v%r7mskD!Y)qaBtkQL22cRiwllMD>0dL#ahCiN>PwJCL?KZ-u{{ zf9wEl>C`(rsT(0NDVfR7xFfYD3;m~lNMkL50|uT*Hkt4N3?6tm%9i??4zbV?OaWS# z$=+4LE02y-1`H^7kobz`R^z~A4zhS5fB}j%pAcINHR%nWw!9~EDFeSZo^wBatH`@A zU0fabG4-d!!@cJeo}%r|#*9G}IE|60`XjP7hOm&)x8iaN!L03X6FdPB2s(XoW5wRG z&_Sa-FmRaiJ-WzwVo@9`2#@;J;gHWpw?Jk&&u9yBrkL zuA87Y7$Kme`t!m{JhBJG*tDGkjCZoyARaZ z%z`B2tt%lQ2a;_*{ZyZ$iCI_jxI)@l>=Gdn^5lchrt_N*0bSmZv zWI}8JFALHMOlX8moXLGG_5#R|{OOrj zocXdE#a(0YWZPpQshxg>lTZ+T7Dlf5^mA3`E%p(|lroNuUmtur52Ja;rLl1i5sL^Y zI1NEi>Dc2i%(Qm)6sQ+H(RmrOiHd6WY)Eq%-eA-UG%T#-nLU6}Fbi^~6 z1n;QjXHz=S5=?9Z7%sm$P~qR3*vYpYOFCbN+-z_o)FasH;`I~6v#Kl)R8UrpI7$~F zS8m!RO}5H1S|ExuRu7GXJ`$}N@J|&{N zfVG3+J3hf63wzA?KX%6t{NOr?NGrsuR#Rl=uJdIO59+wW0q5f@Ya{~=@#r2GzPw_# z`Se#%N1Km0>WCtpa0n6+99F>A90{UD?jx_aKd&9lxkzWMnT^NE`}cfK$vhxGV-@$#kdgh&@mUfmx8kZ%gwzGhDm z^>EM*s2#&hE3=?yD}Q@cZslcoFG-n!+nj)5HwqWm8rDc4+9%^2xIcyPKq9x5yy?i~joH zSpf#4dV~iFs3a4^N8IZnp?D*OYUwr3Q+LNH{LoknTBB1^ajF1-PQghV)mpLx zXtuafC#DCHp?Rgx^m-9omw_KbIBu6o-FwXH^kOiG;8F%)$UClwNkO*xzy2P35&(qF zeruLzJOOlgU^S3Ph{cG_`RY&33XRRh$+728$~jf1N70i+4lVX=Kpx#T zh@g7+KLb@zUCVpWc`aAJgN2@->I;YXkoyd~oXy7P6VA24tL*ww3ku$&BGJhqYpr+V zl5Bi~zy-M$P~}W+fMvo@Qd@5DUyt*^QI4FGrVQ@&-vRWY0)s`j+~QexE4PLl{D|l$Jliw zDf51A-r9IeDhK;t5R20L^hFIFraF&@Kea5k)FW4zzw{qH1RY9Jwo}O#LhDJ6#E%!f zyOu^!&pqAw1wSW!Hqn*%?j{v=#?#%1>MMaSMWzbJN?iyPI=9eUBT(E+?klAiHH z#n7uW{byd`fVKn2GGGG(th`MZLgYgQ0dGLbZtVpCbc`Ai83EzhD8S;tt*#E~qDGmY zKlIp)`!rlgfh%7a>w^%3&kZp0KsTzWZSJZ&tSz^J$4OKl8AVHMQ_b`-5oNxOer*iO ze>GwSd?>^pZoL(sKi1^g&OM$u?YQmZcr+~2qa=m|PCm>YR~{PtHZ=Pgt3%~s#!Re# zB|KBV{33O7Ydhtb1?@mcMz>?9tAs%`*VlA zhYu1ML1#JMw~}}GT{s2DnlVp%AR6pH?z-wvjA$bsa=;e{l$?-8G?e>8_dNXR>*S5$ z{RYvMC)Env1eXIEQ;ZOU(^9r*?qum8iI| zNXW$Xve@n)M=~h4FTnAF4spEU)&{8lfEJZ_|Eb{+x=8CC1oKv=1`pX>`rAhKmTlC-mC={OFYCt~&SuQJ5+3 zX}60GkZ2D@<7YZ-JfjE4_jzE3FX=ThMQ{^76=!Td>&e?>3pa%46f(TWKdoaMv~MUM zdt3=KD*lAmCmrQCdLiRiae?LnZ{hbJEN;AaB3>UJHktHpb$Zvy|EP2m9K_pGRI_SU zk4wJtC5K>{YE-w+nFl@N7GCHRiH-%QOV-P%U*WoBF%V4|?L~xe{G@w2XKK(boY^zh z-xg#6=G%Dl_9loW&4FwXfP8**2iw~<#bd^q{+V2X!&`IB(0k<(5!H&mOZ> zZ?Q0u*c>C@WZ)sr*Gy-=catC%kW<}@NIZU$UU#5~9?}pCn#mihT9V;zIVal^g7K%< z<#1LI^{o<-V7DmH7glMyUmhcX{F@r?mGf_3=G}wt{2BLSsl zSRcPmMo}XSC()E%C!e=C$0%k)&bNeIsv>0b@CQBhry5Ank{f6CGl4Yb2Wm2DlUred z+|y7ynpY(1d>lfap!9YYJyrVGysXLhsi^|~qxo{ag(4k{2C&l~7l|gnmZ|0h#+eQ? zX@1;pU(4XTfATF2<0bUooC5IesP!j5x$p-*#y&a=udQa#ylYJUgrWTaJ`d~Tl9i4ZErL@~H6yz#YApuZ| z>MN9F3vZ>#pTHi8io%C}z)l4)dZcU*su?-*ozVb`zNt3(xka7MI*mAzc4i{!yRf`G zK0aOwNvwO4PP^NQ0eDaK)eGrD1fVlu(R^z7iy<3nr5NZDUbum{B{UGcGo%@6Sl^kT z)elxFmM;^d&ANF703c&- zv}eyt(NrAvSO3P_W&eO22x+EYa(A{2!e6)0D>}yDfK!W$h}#@-oH%)+`e^R?+^@G> zu2kCe}DBQ0Ml$|nKIQgRi6AC2YtLIG*MNFrRDtZqbcF@ei0PFDv3t<^Lpb= z6IPvkV_|gO)j190WIel$0o&npc=#pX4j%|%EOhQ9rDqX9p%<3qt?FAGVQuyk4>-j% z#n^psF-1z|UJ;z($Ps(s{GM^D31DsH;s*GFGv`IlW8QhR(N?(&Jm7Us5=$H7Jpgt# z$9(!BtY_#)E7$ji(u5vFc+q3-QKNb{3rZh!*f<(0<0Ad0zh$o9(pK^*(R(lolw;^gU*GzM zMKHe;G0oJ=!oe}gexiZv5fkWIjXq6pVAu}c~I>5u|E`9cJk?HWs^h- zGv_#?otm5?p0Z!76&;{1NGv=BgRknZsnQBjfl^}nzXb)Qvy42AV;+L*Q32X%M9$=o z{kuyi&j2KZ#6l|*12X94*0E{-ni4T+E6c^D`}jmBI$pr_cc%1TVHK;(VnHOwTHe>A zLD8oDe>A?!0CEn{;Z(1~^gqeyi)r{@$!xu-c)QO?5VzRdL_WHpq#ON#Kmeg_nn{-c zTS>Q8C!d!^HA>Y9;WUMNf`(fnZH1O|`;G8ccuEMiJihyj$Jfp<&>P@9=!B*slQCI0 zLS(x-Hpe-zBMF+S8A|TUqEb0pa?BenY1Vd*0F;^HrLAf$U^I#_$b; za2X;)PnE$1na8hQWvS1MrO98V@tf;ueZ`X zkwqo)w6YBaI0-;3Q?}$jf*0d5HO53x&ZPxkCY1CvJozFcs$CpMds-BGm(b!=rAw1A zgO?Pq?MyO(s3FTON*%~k=m2Y~xZc0*$Lw~`*^e=xX1Jd9IEurhf+dgq=HPLsVn|dBN6fU0lG#+@}KOshWAOls7O-jm=xY>mvLvWdNnrb&kk6j4r%blQ%Vp zOYTtVUawv^)f#x7!jn7yc@_)Q)|Eiaf6fK1@{9qixpj%xe&B>a@)pnN+WS33Jm|tG<5_v0uc^oe&gI* zxB!H+4>~*mw&9t_@aTI*-^0t3N}8V8H*QEnjDuETFw`Kh;tKx6@21FDJ6$N*<^D+$@Cw}3`#OL(rZZk+4rHAv zWKQ{w4g@H-#oROP8aZJX(U;_qw_Kg+nJEsVOxCqv2D7x5uX{N3#3^Q8|ALnDhefn# z$TJmF{Nn+A*&8=576qV{=-ag?Rlv+H{~fN}IFDwn#;U;scpNT<(omR@a@E8BdMsJb zwx6P-_Q8*9XC&)i5T+3uNAAdR_add}sgIc@T)Z*&;ZK5{0cSOKk~4eHyKOWc$@vzS zie4Ub07`=>J!S(T7zp!nGO8s8TFoLuE(h|3Mo0O)SaN}h4h_Yk-z@CYEj>l!JZjHI zCO86FCK&;sGb()z!vRMeH`&_=`AHt*ZZv z@hPXR{6wJK;fjy^jDRa&=5I5yt5e->I~GNM+$j%=zS6z!Cj>I-ext|T6%Emf_kKJ$ zKk-KDzb)d$T(%CT8+&J2WrIbOdhZZ0JI7@#`6mE1h!h!)l2xzP*K(HbN0c~Fs)5}2 zUB@diw^Ma~8$N&G@$z%uHN&hyxf&k0a3)`4Wx)44F)jpHHy(t=QU)V08aUc~Iknv| z&Yd{lqbUzHJF6WcHfsl55<(V{W`6)(TOfB9yj&GOL^Fkvm0@3(fFAS@;V2dJdlIK% z6@ZK-a1dCE^Ef2O+V_Z;NBQkh4X{%qP^eA+3k5aBKX;SQrh~VhP_$=|@M#o-Lm*?R z(i+PRkJ%cUX~OIs{Nu3r_nQlN{hZl{ul{;g{o4lTMRD{tyW%=$(;1H9I%8VnE?${} ziXMPFn%?`4W0(P5WzbQBSD_PgSe!6?jDa^jNTcdSzz_XVPYjznY4iB~1@4!bM`d9!*AP+mXL0Yk{bO1ubWY48X0vCZ zP4P<5Q{#P7PLbfcwC%>g34A1kDH3eOuBAmHbR}1QyoLUKlD&B5-;eK;vWv8t8p|p3 z)^~;z{(=jV8rq~T8ah5|q3@SV(Fk~MdvWY6FKc1)P-wGrL{y$f3G6A-@o7|_Ou5sd zS$d|SMAcF!G!)ZwVoUH&kCcPHu#D`r<|gd2yqZ^j!%NDI_qz#EI%WFu&MHN^zm5`% zc@WH)hcr6`TL6XAk0#nsZm>t+U?Qq6I5acxkr&e&#cJ#R9RLxy0U^RhqA_DNp2WZ! z%nt^yBByuaY^4}rD#!F5%bpUnJzQFai%yA}@2nUb?PG?XvwP0QmLJQ^&3ElM!uA)>Sn>GMOS zs0irPMe!8uIXRLa<0_d^BU8SCmb}X% zC#+5TV9<#m35u7e#A%`5#~4xH0+GkB1J7>3j^J%RI0B25k}j!O+aw@9sxT^6Ztw1R z!zz3Zu9kt#ULGVGsgYPCru?8tsGBcyB21Q)S2 z$VD{L!d`u!htS+{xY;?k=B!*l&!}~of!N5yPDvW<9~%`mSESwBD!0Lti|o?z!bS|3 zx4JLiwsJ(hj0!~9`E$WoHf$(ygenfDK3?D_pT!av>H5DArOnoLRbW$JFgvsXLn5E2(V=eVDIgdYhH*k>S5!>)oP+CF0C6xY7Nrc_U{qSGkj4K!hTSHTAkMf1T zA0YtAuu0LH{OB3ZCvQxUU@a1}=z$B@jC_S`6(AE9_mOI^q-jT&7tmTk2>ZOysq4sn zb`AvbPeV#c0Skz(ktar3@kl|?Y@j7t(SFYUrOmlw&u!-K089MkjTB!1)B2=%D>pY) zhN)=Nf1y=7;xmF_HN$U@!(ga9APtAm*G}l|5U4}ZYRg+GAIBn#H`8}q3D~Va1wt9r zhkv|iR5G#1MTmid#Ed*^@YVtycGa08E+vS<>Ec0o4-MPvR7Z`x0WInUi5fJEAn5y_zD)~j!4apW?(J+ZQ|c2 zoh%okUkDUQzg7nFr6`(zulNJ52quy0B{A)lh@X2H8jo`x^L|c&V*Y5XL$mRrM&=vC zdEKC-d>u0pKWOeMwO!*ZJ!@u=-lh8AcpLU{#jJ|P+k;(=JlqvwrT-#nR{2Zk^^LffF}zobS0x`vzlv> z2E_~j5Fj3pUU|H9sLEF~f>QxMmPb*eYN3CYAA?!*z66$zqsZ#Uh6ECU`!%(6_Co39 zx5kv%{10D>i9ltJI*!S03$cmtp1ZJcFM6$su*&=ryT$PB;Cd>(e9wqIomtScIu7Y; zpwBpRQ!wpKrm|51^-vk!Z46>B`Wz)$5 zac27KF{^sy=gL=KtU0Ld6zr?LK^uS@+vxp}10;&PkrlE)BZMfs2+H?jLI2&qipDnS zhJFnP>N?y&W*)-hMIZ;No1=7BCI=iOI)E%f5F8`6O91f?B=DeFjcxacFC9;_rE(36 zdRIgqXTuYw9Mk&L-mb9BZzWa0*N@l!(IMXg-3JS7QAj0K`Cq}g8sf^)9Lab3XARAx zKST{+#XM-z#hoJt%Ntto6pMG~Xsl5!yUMlVgK$DL2@NON_g161_^m(nGD zEooFpw=1$=ob@y@OPV3eNQF%5c-&2(dosEl@Thz7QkcV|0$ozgNL0$%s#&%~Ij{FHjE1O8zA=2M2vbs~>9ou#vgQ@yAb zkhn$^eV%MW-=Ng7QtuYdN~G*gXeT}DG9fGqB257VoZva}P!w?xp9 zjV=j!M)6X!*StX*Fth+9=^`QsVXqcx1Y6(qbdg%Q>N!)Ge`5EXDIqEfAq}m#@&v8V z$9^&(_6DII;5E_YbTQNZ5yv^qFlYdLLBzY#(Up*k1#Gr`5CACPf#CN-FJAg_O*sv} z-l(|v4evXWbye^wOb);{^84V$>1S_a5b}_HasckPCN2g7jYu&2^yTIM*8)&tDI1sG z(9eHnbLK>kerf^05gpUZH3iUt4@U7W)Di>t0#FNs@|s$~sbsupb;BbA3h*Km@U2iv zpUZ}Nr}401s^s|X@?9bAVg)e}$W47ELRXi%7+EWk=!{byj-m@iyRdyM1j~u17Y)C` z_9;Ei03VNRX&+j4~`2v{^lM3Esp4)J_ zhUr=sKJa#?4~fk;s}qhn7l0q15Aih4_>p0^zsE4ZUlUL+#*jQ?~q$3mo_Hi zd_t!X;t%Vgy#?P`NKc+|GUHpD{;q*R=J>-Qf**X+P8aelTzS6BYX1TdgLPcM?(2G1 zozWN3v@13dr64rpMUM?dbpt_OmNyAvxV~?32o(SYS+rKr-f|5_C4>Y`0R1*m(a=vI z0x_y@O5RE)voIvjI(UucgAV^yH+g%2NqSR2S`YrSZyf}pdUll z4dOvDziho0m@iZ%*Ox)N^B7ehH`@|I<%bjl)k}|v?5y8(%JxL_qTUZa>UX?q#s?*vyQOY$61c<3)DuS(f&t^;Lo+TnV4hHjwl9M<&)AK_h6K#)Fy-~RQiz3>t^LA=i6ZCXEi%Z(B zyA_0s)T}@Wdw1JR6#DTCz^S>%x#hc`ieYB>E{_7Nr$FdH2O}A0oa{9qy)zrUDzrtw z*D^CCj}$F~#oCHpL59_UDEBY|yab!sb0$4^C z?eQ`%DIMZX+2-;CB@fq3#ZqLCzUxRPgE$u}aXeEvOb0j{PpRmmXyfR4NT+wvFr3PB+8 zbaSD}lWMfBGKsaUef2~Kz8%z7qFvG>K;s(Yph%3TJ9UduTbQRr5`EJDNGFjA!1W%OGz!S=m>nMTHV#) zHwcfYXWJA#S+X14*xdRT$6{v~10OTRGXz11O2FVSSYn ztvsoj%&Aba~Qhp*Uzkeyc9*5va zGMfja4eu^A#2|p{fB$+eS3K)2>cQDZBBB}3m#Bc1I@~o8_@qvybUJ@ zUPTAmKdy0_Ba+fLKs5=pN3{x%U5cbQMrlHC^=44}`~~ zyYmxCiAT4fgc1TG-6Aaw(%p!FbShE;(%k~m-3`*+{SQA&Sj%TE3% z>=NegjooLH1DjOBK)}4@M--U9L0Jw6N44)8$8AaK{bi2@b`o!_PO{a#b>ul3PsLSm zXFd^GE#84g5AY01pM$_z9GnC7jhI+qbdb7+y|?c|1DVTC%65|Ykw7>Q2vTVu*-4jB z-n)Wyarc_4Q+6?!FQaL>&&NHkF#k|v$qH2lyC2*;F9PMsDTBeLPYtH=!vS;k9*}uD zIP>S!*8Fd;l)xc( zH|{OhDJX7oU;G>hr&Q6NXuy>JaEs^@elYphb&o93z1VL>1rrqUGw5xS-l11bGI*u7 zzMT56uU$Upx5o+#0XK20>rSs72{nkuf{p5Yh_V8XO}m;SCH*MMw-jolo+w9vmg&6Eu;0f`dccNAEDC#f_Q7wev$}mjPnR8<;2uYV zmH6=$4NVR+beACxa|{qq0zf4Abl2f+)kS5UL#&zy63;7T2~UoYZuOFj*Bgb#^51wnwjf6JE&_0aUV=tRabxD#{) zUVy0!VtI0o=Y+vBt!l2|=Yn-=piRu^)e#uc7icR>uOQUZE{xy4Fa}DwF$f944ODS1_Q6r-28|5{g?Jn zTSy-^5>|$OCNYqH-`7zxv3#a7HJ`@>#S88JSEQq~2=*h8fIY1D-X0fJ zi|UQ{NVlJUkH+se`i>x3lldtoalm76lhK z4&%&Q&*Um4+kZ)2q=9P2##Z!PF>jrCHSRyL9uyqhuCW7y z!DU2lMBTfus%d8!9}Y7Tm)4fwIkFrg$CAaxI`tT-SQ3@E!Do)Gv&W7x3Wj7%^nauJ z4wETbwCYTu8c;U{6;v51F! zlT0j)azM5n$tui(q(ugDRefz8(#9-oE&o`+=9hyQiOKhSCnw0XFo&t;f%EJd{hJVl;g*%(_|L%pA{5(^cC!g-yD8c!YnFtT z_`l;zxjc6GWF}8>)+}+rUqWBJH$feeRQMR*nW>SMKP=ol%;w5n{g6F1Da2Sg;goCc zq5r&Z^)BKM%4zK?`%k1p00USXa}63_7k|2bVmari#}?@ibxz_zpp%h^1`g#e7v;bq zbvYhys_tJ#i{Bl}@x0{<;?%*c6&hQu1Sy=Gh11UU4(WNHw1h)p6eb0<7WhG~4GHQ$ zP+8)0a{e__m#o|FYA|SrO}1NF_%nZCWmHAmk^lUq-%_UkbxDpxueU)pxmMTF%MYsu zQWhgzDwgAWdNAER+w3v=fvoR7jf(9}B{Ck~WEtLqV}}2MmJ;^!`QaBob<;}ezS}3^ zG0r)4F--Jx@q(%G^?sI52ye%FXt{1pyV;9TUt5`c*;KjLkcNn~gCHHMpqLs->XOcv zjBh^R4{2ZLbbY*H6JCs*_^q@#p+V2c-}P}Z_U1|XFIcdENcfEYVo;iM>`r_{c=`*8 znhnhcRG;h5`cm)9biZnMxSgW5JQ=^c$J?c6k0nbNLtp-oBb&cII0LioOvl~Gw47C_ zXw1g|w?fd;bbAQ2+x!e{QwXq`<%qUl3==o}j*x zV()EpX05Aet^*Tt4o{2y7_>$TPC1ldD|?iTF{Ii+?VQekWS>}ZvM=L0L#kpqUG(DX z=Lb%NRRxE?KsLx&(27`|(9!~5RGa|<@mqBm(z|0T`lF$;eTL>7j`Wy&r4<7s4-+!| zh-lMJPRXY!b_=lzbkiMbY2(&cGj)^we#Yfsz%yj?<;rClr}0bELv!_$q6(U#`vlMM4%948-@vgVMgfCrdGmm#-7=GTi~tcz zc%}I{BJ-PX_E`Bl?5VqZ?5RH8>@kL4IV=#pKErCMr)m-mBOa z*!@h1!%iu8B3k;WI9f(8E_w`QS(85|tjG-SXClq;wg{GztgD)8b%Km)j) zEXb8YKZa9kR^8iId>y)`UjRaaev5r~^R_f<*S`>r<8JT7EKsxTafXL>dwkRvxH4LQ zp#gc((ss8787Z=~P#Ed^ze6fF!l#wZ(wfcDGp@$+4ug*3tpW^3HZvm3+DgUo?koHxF zq{zfCvt3;;rpT2u9@Sd%R_Wt!7S`hf?_j;BcZQ%hhHtu zfG30hAv0u-FiMc&V^lIzA7`?5Yz*#=^HIP&(LQQY^rf9~?NAsFPqxp@@BirNh~vp) zms0vE!--Y=&k&;d3~!Pqdawar>;>=bp@#N+TX)kW13w{)l6o!4RP~ji+jK8zYGp zXF^g9sft7qMO)!?vH2ZrPoY1jPDLo`_Y7XmflW~~xrMXg|FSEyC)9&F43{co^#ra{ z;SShcFS!m^%(JkR2rF&WmR*LZrr9l(RzRrQ<M-rN z8WKKmXrt$e(bO|^m5+bFN|HKGtAaTpQ>*~JVv{vsMDN$eO5%|ptK%=kBYOWK>}N?1 zIygN7r}*bWnfhtnD+82P-hC*!y)4?_Qd)>1SY!PA!D3NT)<^{$UQ-&sd%#}>4f;{HWe3|W`an}Kt;X@sU-F3T6@+Mf!XB6ip z@XxE<^wgk9wbsuQGfRY9a;wyTNDb^x!V`qTUVfJH)CDBZWY8T=mAm@tLefP0cMhnl#4JF)3rJ=d(-3DMA01Cx7so1mm$Z*FNAwW4E{H<=3e) zVMrt*QB$qYlT2wlNIL!vw6%n1nAJ5nkDqIPYZPK!$wtqN{<(M~sQt>zL?dIBk(BEU zPn$a?W>g;ZFZQ=%uDE=!E>NOZ{sYF@8kZrc*r{&-++K^5$w16p9nUV)t1MI8e^6Z_XoH> z0VWBQo>ol%w({&0EW+|8G=e za=H5@L#BY4kM$nMfQI!LCAS+s&)KZMF_;(!kH8k$%;;^)r!T;0PO_r8H3#$W_}49D zdCU0QKU$e*kKJ``) zdA7}dFl1sLF#?ll+E?6Ndn()gRkCO`)AEMRqGYaB2;DG5%=)ILt}$a7n7XnkB=91D z;O?efqHgupqE+~chQnK^gBiQ*ajgIs)t2{Mtp1{G`#n*SG5pU@B-)?zSNz+~-fcqY z5Lyvy-XOgjK|iD4@8sw@*pYLd$9SK0>6LT7xNG86soxQu;0_@H3qBA?fwvcx5*pjP zE(nBG+9+1|fJ(;vwJ}v-j9w@iFUplBhyPRFSSLb=#A>UpO68p?NSGK?29szApv?qO z1~|MCdcQ#$I$+}{obFfvBXf*K=@0)YPsBp)lK~;}ildQooCqw+NgYzXMR{7?0D&U! zuy!a65kQW`{d|s<2D6H-p6@WS$e1a%#e5WhWZFBd0SG2WF%s!tcVmS%_cLL8h=%3G zKJz>H0Z_58p0pauP2;}KY_w$AVPyfu=5`%;pflYSZ+W?uMRcT`sva~}R?XnD-~s5e zR+nHFc=6Sw7;>wjIaQ!588}B{5wR-*Xeh+`ihn{y8TdPl(K;awZU@p-34KB^I`1r) zg5-UCZw&9TP)YLN0`|qAFMPOMv#?ZGfrG1;OnRM$8!Erg^({EA)1AIHL!ACez5*({H(SB zDVkYeX%rdDNHy5{Y2^PqkKxTULi9(B# znb3m0l9n-*ULpJ=2BhVD`3%>R$)bNQJd$?6!RW1M;Izl&gLE(f&L8}4F~oN?JxKkB z5!Hrm+h`ZW#J`3Zm35KZhmgWe2{`wLGIe$r0t6&{dv(r_%weq;lHZ))xxsn=b#Jb78m zN89^`m%(i%ws|SyTX@RU>9P#6^3gw~2)H+YYi7b;n^LVu0H6Bj^cAdvX!d%p<){6{ z-agot*z6dU^5qg8OiX%XeAO3=eh}M-T3||s|JEB$OqNGUc@pwLay5awVxoIZ6Tl2J zrdltmn|=B?<0|59_nt>W_#gvM6sUd2PWC0Gs6V9ww86PLyL$IT+~kep`kfb;FN&;LRpf#lF0F2B;7D4nldgUieMjIo@W}z9CiMC7wH)#GEo7VPKI$ zx}3Zx^$sgXS+dZO>(WQw{f8Obh2jx5A7hihv>j`Taw~!-S57El883TtcKk!&A zp6WzSe4#NcCx0i$pd9m`KRn(E$p~ZcG!*YH&uBvl&`-)os|aev?T#!VqD++@*7srX zayOXU&Z@HV{IC9yqaBk_)c$#rr2pqFOo=y0Tw^iOx_<<~7I?A`l@bRtHaUJp4; zbgY7o&ISqyV`Kw$&&I;0(+#xdZ?>&Z)wX#>ytQA`J?##{eC+@Sw5bsAonAQpLwgha zy)#|+{bCN3c_Mp7Z02rg!gJR zj?b$&pP#;J@U-XiJDNM6622Fb1y71&>dxy;sL0a*JjF}XDF${sn%<~I2ITQ~N*iXq z4s%pLkkVAcLk&)IVr;g603QwbOn}G$h#!L(F^uC%BC3zpuBhq{NVVb9K2*6d?3rj zQKp-elfD7gg_i~ZD&dIS@H0r8qS~@$#pG_APY*6#!=mei{oJcxSdwEcAOHnH1V<&+ zhDmEyW28A@43FCleRNSZk;Q{YEd_{J;2-0GMgjUQI18Do*BDG;m9lj-Eu7+_oEWi) zbO^izzF;eYuIL60%e0tKkul_}$|}F71%9=JzBl|&0uR)$eb1@k4AbW+FCN{I8}J3` z84bJ3T&}!4+rGJnpKn-pJqE_|Sh z;U#gCK@6&$+s9PGZLImgAltMl^vf!y^mM^k&23~*LzN;*i0}z2s7{E6K? zo>i0+ssJBSJw_?0YQ|p3HBSqILrzhBT(gGOJ^^IN0U%o%)s`x|2+66!GW(3uckTY| zEV9&*ucfFkxZen|b7drg1jM+kkq&7xd|W4ak*~?FKY1~s5%T`31dD~&_bs`?VPQDo z76IZ%LXSC9&BxA9-5iN9rhQ_^f6;f5{}y##DL+(vPif4Iy9JEP&MEYim$(Z>538@| z%f#moPic8SAl8r!WAgc83Z)({)libOQA`aO!G{2+Y5cVsz1x84)a)HmCz>(Ltk%Ic zOaNIKCzMnHR*u04eem=OEkK@z4r;-+7*;@4(l#>PErdgt2cq?lFabS#v=)PVT=Lh% z(x142EExa@>;#hyoB{%&u7A8EKm{tuW(f&7SVG9az6G3~?ECRBMmdp7DVLIK1FZeb zOX9T=J53qj3f3<>Ho){_#IDD}Z2xuMi)I8;^`}`(o&2|b@flOt)`%(?o(LwAXM#GO z&*g2cJf%GqjtoOKP_}q?AFx0r*2b)`r5ZrwY{y2=Te=Vm_s%jFWUrbct|Myag@Aw3 zAMG3`d9;+88egE$0q|_5(+s9JyyTN7bw^|T?7B^BFCN?_!N_V)OV` z&YNI5=Wfc54Sl3q?RFv9ks*IL5_{*4-RVX2hk36R7XT4n3iN8UJjPGI$mwBJX6#bD z?l7Df&oX?#0(#7k*3TWb;2_Ui>aLgY1To^58+~%}$i)m2%7a5Xe%GJbfK15nIC&|h zM8e1+BY6RgYjk1{4l<`1kWj@|@Iho)3K3vd7E{fB#D{dBv4G`z{RP0D;Grkb4g{+% zM7BOD?(tc0Xovrh{^;p7DJ$jf7Z+?k4UvC`K^G98`R!T9`hbl3ge*`o06oQZ076;G zEt(RBX7=i)jYQ_6w|s`_2GGUlA)9K^r*1r^@a*3l5{?tH(9m8qM3fWb3OaBW&86wI z6(({jO;D^f7Wi;N&b!^@##DCia^$mHyd41%H$7WK!@7%rSQ7SaSPD~O!iK3 zFx-tn=h=?7_Ava`rlk#Y!k21@=V2s{y<>UvdnLJ|L+l zU+P$>zM#!sbVZfPEsGjs`R3#_M0G_)RoR@AV@9$&Ve^f-61xZ7i z4am2WVDSPHwWmQ!nGqgyDG80}lQ2;CX7x8F%`@p8nMO5!&lF)DJUnd&)x|+uQWBad zgC2QMX2#o7&it%p?l^IcdqlFV#^XE!{kZ@9GqIGUfc*htC!8=X6hvFaJVhpDb`l2? z?2jmI$B%`E795f;?};Lc*GrQv(8Q#)mQXEBk!#$2gdkE{rSGr-4QT;mF=54trHMlg zP5S%GfL8>#PMc-o@mZm7&|gUAhx!B7_wD)s(2s;)%vzD!T9MXVTiXT`0xj8v{`uud z#Gg@6TU&*g>DF+O1B8;=mwzSIW=PhX@&fQmr*M=8m_es?vrkH2|u0ePcoF6BDm1!0)_N1to{6^rRbG}< zzfn<%Y?EgCDXNOms`SyL`C=mc4J)BCRsHGNMd^dY-~yu__l2sC=dwv&6#IYwb*+%- zAE=~>hew9C%}qb&vgw1GnBoLze|OyS^luFb`)m1?>X(e0EKyik@0&MTSmEP$;sOCR z)6`>Q-+WhbTDY4ZeHP=DRb%dlg4%Dw8~7GdejbiEQL8Zt$$k}MgndbTROtSAY?>uq zT^XK%GYtE)?#^q1(s=mzYgIdGugvq1nh&OVyG z>##5h;u*h4TOBJK;CIT46F^Y7%z2^wi6q0!SD#tkK}AEO7q^8UpP;8*CXZXnN*ey> zd@vi0D|YLbjt?*CKoX9As9f{b|GF!3wjAJ^k(ntW;<~su)!>C@qvQHE+V(!L{`|IZ z!>Ij2e<@V%K=vndhxO!nMNAB>ViGT`q~Vaz*-9@IZBhr8q~m-}aF?pX(Zzr}A6U}t zic9b(dVjqC!I2OPEeN-_Q`|<|=dwntT0a9`y;RdI0MYo*%P=koK8a0{9PCsm*4i(i zvwF&~ogUL;`px@Hr%3;f>_vv~oUxM#(^DSgXmJNz4X-ln5BWVst6T(`8q0Up`fY7! zC+>HszHk~AEz{q+UEsW&2fhkoJ|vz0;^YGmTgQUW52xj9>FMZ{E0TwYV=p)FG|TK- zMTRZw)SR8so9-OQf?z$SRI-k$`GgAk?tiv4u>&q^rFcnFwXQC5owY7ic6kF3JO3zt zwSId2@PsGnhGsE6Qii_uv63G94ew58_Tl*W0j9Ri0Z4bFT5@>fPLq03FA4tqj*<+~ zTteKgQ$Hl4OZXi6*Vset2U2@~Ec<%c8dITEFS?8V(-%#2ilD}X!P<68o*i#~B(TLN zE@(}wUhEb^r_*b16}%sT+?qPZ6wg;lQ*#I)Lkana=*(k4HxG)PaUr61hOhRhgeV2I zaf_=^{Ag+EMF@L(LPMaqJHuzxpD#JU=xnjAzqr`+Ot`*KXn!o4OXBChz_0615hn2U zMdHYq92yF!3u3OQrnzxbVI_pZV1CSuq#zfL*JO+x3Z&N7=lTz~fpI|?npy~sk14bU zb>H%#f@zRSEY8Nmue}V@d(Og5x|Og(jgqc~yFfbxrT3Lsmwk6{e{00n#%e(?=clb} z(7c=+Uic(~4S$TEK#M^f`rp57udhS_b_85%Vbdf8k9}oKEcx7irn%g{&en3`X>06W zxi$Jj=(ClyG{J)I`G5R23%&^xRio4_@vmZ&zXTNZA(0B1a4|0`KNhRTSOG|W=2v;aulU^j# zplFcfbdvI9NO*Xwb+t|~6KB)1D!;k)+;0?dn>pu+)jv_xzUJwk@Ru(K76a(p+U*u< zK9#xc3^s;tj)VWkaMRA`8(-`Thfvrw;-dQ>6?M(*Ijd`oXZ|ig|2PR023ImLY`^gu zVw?4+2KkcRxl^RUqhlm4XN{=5QwZ9h+`2&evKi^uu<04v>_HpUIx3sK#rdUYcSB}- z&%!3G14c50>mgY%Bs|i2i_+z0eR7f8V*AU(FJ6}I5vBRjS`jkg z)0uM}94A2?4Y%fCY1B__Y6xb|VTm8fBIj$C|9K1z4XvFDz6!*j)Zyz|*WRC4$Z9u! z&EdS2D(Ro+f4` z#ultWIr_CzMtsbFx?cGT8x6-Z!WNb`d-h6cO$37+ZsXrS{TpaPbr8G}t?-&{T>MV) zPTeE`W3-e64{v|Wex5llI1b*nu(iLHX9X$p4SEZ4q*jvYCd2NbiI^hYpedo@C}|5T znLMrA|D!akOFmCSO&q4e5PVkmOPH|c5Q~9cWX%8NRGfTj3ip@vgqfTq5JIstXy zN&L$6_i{@KY5VWgsp$(pzPX>qH!;?MVJ!sjjvrGx((lxYumEz$6x=dM;JW^^v-QXJjSaxPzAY0?5~ zq5kUpK@W3F&`_X+O~|h9AiZ{eJ)jzCU$@#EjLCW%0&z`pKi(8mdt|j&UqRrx&4ikG#}kJ5&LK0>Jhx# zi##XSPg|e52m9?ksHEJbmue>8sD1j=s%TDiWK{lAKe3k**M}cUPmgJ5cto=_MK88L zVd`jYTGJvi$&=9^bK9Dy(-XCdu;6W#4~cFMS-v6_Vw_z6=;BOXYJH>Z(s63(N4xav zhPtbuL@a=raf*Qtm{$&jH~r&pmDLT}VDlllrDe2xE-@X)X4SjF``(-B;09;nN(TCm zf~dFc>}62FC*Q+Swy|Gai2FG?ebD+o()fON!EQd@rg0(T^Jz~a*ZWi&+Kt{Q%KR>C z7mByB#?Sfr>v*i1a(j4t zTUBYw2P=~w>%ZLRAo25TGZ63jc(m$0Z+F4Gm)uL*VDX;@PC$`)lpMT7MY zK_8#!?>rHPC!8@N8n#B<0&12vhtUy~usi{a9!f;Z9vRPb-Fh>ne!Ob)O-z7tfM;vf zdl6{-(pxN)wJg$LLmqpY7xlK#LkvgtyR+9saA$itiMIAk>4y`T)l36QOPOrKi)bMX zf4oCa#DZ&lpy-Ff)JO55fx(RW6SAOJG$F5q3P_~-k1uxux@_Q{Srn< zC_E1VE|bAX2lu66yGHexn7LNELLr{Jk-_|z#D$J%it5_Cx?dKylXT3?fAfuvv(tq4 z)w8qdQ^gY2snK5;3nVmLqPwQxRL?iGrVRDbcfS8O;@AR6Ev@+ARo8i5 z9B^L12OO_mDQr(>htJ-=tyHE-Nt`UNJOqV@@2shzLs&}+wp6HGct|4<+L{MrWg>&^ zMno5tXj^JCu7zi<>FIN;=n&oS+IDkyqGA~_&$-C!%bf4b@}f5ou-9VFwk4Wt9=Eae zbcwxbVqU8WR2BzAX9Yj1pE?`Lb$nfu!S37KaELs~Q?&blE@>b7sh$)a4Q(as<{|W_ z{DwYfjTD`W&DKT~tZ~+y1MWb{vIHZ2s_o(KH3$^FhN?9JSX*>sEjnG^Nl%`B{pqqG zg5?RYByXbgaz%jyL@Iu+lQ{71ep*wsVEu{Ljdh8_!>w)OhbDzd&8J_rY7EjxyCx?E zvVZ+*1;$VHKO1dVzAD>$;s>u=Bu3^x^ zfd~IBlGlNCB+rRWc>YWGJk`s~`>EmH%JyU>7*V0k^d-gJBS}vHVj(!zXFtc@*aV}? zJ8bKVHF5@WP2TY@%vF(TRnlvj36eUjiLF1O4Uq}}1wkY*a z_gp@vi6yKe8ZK{FYYx)i<2eScC&hiiKb+W6uv;g3uKV}6zk0x2FiDZQb1l@k?6foQ zXi?v*B|JQ%g=uuaB_FA$hdgARrolIKBhvmlb>0JZ%E&=yPA^Z{hGW6*VBJ}vwt01| z%Mu%G-i!hjsNZo9pmaHlFF!o!qS+j-1tisKwuwM)hkR&k3|SHmryvLn2tX*Na-k2a zh`t|lI0`RWy~EW>ftDXMr@--7=A?KY8gB6ZjY5zl2=F7UDKv%BZV=_2ev2*#a&B%! zLXPg;COiSb!H=#px+$*XwZP`8^MPOp+s5Md=}}fPe0RDM^!seZ9>T5^3O<}Ux|KD$ zGd!FX{3<`-1wq8uuZ$6-Vjb&5EL1RzKgxB4?0Q_Ed@u2QJ^T+?o7$-ow|j&jc^$o` z7k}Nnp7~h0=*Gbzm$$udNyQi^x4L@0iSej|1j~Qa^9}xB7hm>|c0b$hH{TSlKODH} zJ!)yJu60a*mEUn$woxbYV?`ZRvL#$Wv>NB7i45Y0BKgMoj?G+yzs-HeJl18)0LJnU zr!MygtfiWN$T>1$Nf43xN#?CjAd!DGM_b9me7g|VISp$^Bd!QiaC@szm_@?pTQscj zSKr1TPQz_WT^1$H&OCs!^Xzk)^BWeM?{V@>k%*g@Jl;n?12+b! zt80`;N!|vx*lSsQV0YLVP_?lJQ)EP=B8Z@-bH9m$&MX}5vBMg!2INX-b@k4EcY(qO z_i8TvnB5N^o?_S5wN=%e^x3hkqv-D!O?TM+-FO3ANtiuwnWe@xdUteY*-*c$2Eqtv zmRm%P#}kSiE}Z5U0LE43T-r;*CtO!%a&60J_gvSm@#}|l`}OP6#K(h$PjJrWYhvevy*pv= zP#M452FV(Oziu@K+o;tX=#IYux#~Jm&}+|HQH>s_{X9ag$C2R!`ksYjzZP7E>~}5K z6Mw7=U%DpG3P6OeA|k|G5FB*|_{>U_v#a@^|39CO*!ZpHf~dp~9Bw4Uy+1&r;(Brs zwsWj~b*DxVWTGn(pB@Ht0hLIpzK@7sEN*XYGZM(V&G6dFC*|iQX%AjEoQl!OWn?+u zc4ud|o~l@9d2~KUCBJmozCGdDVy&P&o|5uN2OZ7Y_USho!V4X~zoi{uM;ZKx?bKfb zy|(@X1X$X{1R*6YO`G@^3ASV{a(c~CQTah-aVnx!a=`TDM1+q!^Z3rN75*j7rs7^SB^oo_}72t2XepK$CNwnY34o%JZwFeizQTHT$JI<%b6Kp9J19XJ1 zV~n}XEcAlhNWPN3ZwlW+F){sAvjXC)vkQBn{;klQ;gId*9M=EXU@A~fph#@wJj{%otLcKOjN66B3*1E|0(jlosPBoFZTj|J4 zY!fPv^?Hr(^S0rs0sT&j_~VKV1hVPKADnOfU6F_hCD{Hjh=t%WlN%`I;j=|O2Z}oK z;-Yqlo@-8gkf~Cf-6dg#B`wJ1VW`KxW`N-R^?A@F*xHyG(bs2(sELO-Xqi7Yk9(E` zeSBY41@YTbm;=v&E1Dg?hfB%?Ue@)SzifU;HmD61Dv$3eE3~jQNQw3CqaED5?%c60 z`hD2-$!fW9Vl%~`O45!d#Hex(5q&1s&&PwL1e^}_a0m5h3Y&3C==xapfLkd zF}n=eIpbmH=PBY#D6b*+ig@Xwnm(&IKH+9y@k9!GMU#a_c>yV5e>Wf$THfG*-Lf^M zRNM)Zo#V-ufue8`icPeD*NznQPa444ewtO`PH@f3T^x1d*yo`HlqHaE)5m<|GaMyi zpchj!ud2&oBLrfzkXuRm=!r(U=ZXTiN@s}Q1GWey}wHR*Zdl8#{%Ko zL4XFj^&?smTVZNbvtavRLiI=Ywh-ykU#P)5TPa2y8#ajoHAs8B^!hcA6kIIkC8dJm zO#4|;drgWyKZGUV`^UA^V<9A09~2Po1e$y0NdPhIOLbAhs*_{9<-5Ei!Ur2jl!DUR z9^vsxNDW$x-Gn`8Pge#*ftJSh>XQs)BCtpyQf$i$@y>=0_MmTkozj zNwjf+mFaA;h#yaofA}O&g6oQDI^QBiRU&tNwjDhdzX-gz{*_5FJKeGNf~|NW7MX>N zS%MVB;Mt}V5~vRag^#~aX`KHWNuf>^NXJjrQnos7fUjortF@FRD zTzyxw(P;gI`Dba7m&SbHe#Q!1y^_W_V46dGFbptilqELNhd3bGFpE{xthW%&1BhiV j{)bAwsW(aj?iZKd-~^2&7K`0`2>5v^t1R$(V3R+PraCBp@QK=@!8NmURCXAT6yq_}kx z_)Ysn6%+6c!&y~Y98}atfdYXZgTRtv>TZb}2zM`oL-*~o?Xs~MGS;p=+b%yQrYEA} zicYeay?o$1R=6Lt-<1APcq|{jC`K@kKFC&x&vb~SJ^V)P z%>r>xr8YXzJ@Xivz@JT-L8IooF`?MXPeVptOPv3F`l|r$4xQ~(q)$;8$;_+>wvn7Gih3wb z_ymV^oC{l+6jYy%KXsvRwKgUMzPg)p&^78{hQRv0cFrq|h8y1NV55gM!c1@l&aCW% zrJ{m_t=np+JtBg$Xn9l(hI)a*?BS=0FTza23fZ{^NBZ6I>&f3`eJ9XHI!C`>)O5_) z6#LCi>3GzB(q!m>*139wVfauBHg@^EivrX=+wO2S%5L{u&JE#w80T`)-Se-HvfxD! z_)Xh+4vEkW^F**W*c^0+pjJ*NO_h!rBKj~>z3k4>gAk7Sg*w_et6F)ZsUJxY!ru$m zb3&V9{WcqSSsiQxqgsZRo{_-v<#yv+3M-TJEL#wz zp+)2G`LT^5|E&Kd9c1*g@~Yj&pKv`OV&Ih?7HVeG&{^^{ zExNOo0kx}(V&gGruf_Roe>IMm3ORqlnQYc^(O^E`46k4+2YnKjD2gm7gQAjwC@(U%L6mCImeAjSt~_rkX7rH? z@a)|>s?vKf(Uz=nc`YFCr(&)xGj4@m`RNnJMX%N(gUo6@M$P_7b9GMK9jO&h0a%(y zhA+?XbY@v1iI%H=#{SHq^|~BCuHORm=&0V95qKDlu0+;buj-ezlCyCUBk14K`wK%@+}S2#)GR*f1=flZ#<;4*JYq`cMV@l-?CAL zAJ1>0bY8&cos@^TS>;yOgE$mE3%lu`l1UxNRnVt+c(yQ0MyoD zf>428Z}b!UKh?=hBrq4;bOiN~@YOt1$dl!lds6*kVKn}%NK4NM$W7vax<({^E6pSa zOFu-ZD175Zufcyg@gwDqRH{gReow`9dI;v(YH!Q( z16_mUA;3E$m_=OWEdlQL=K)3P(MKr+d}e+&jiG2rfigIRKT7m3qBGNs+R zc+9AZ-y~6am$8YVAU$ZvP^Hcg<4easHj28ZgHm4T4q$zMR-XE&mX?Dsz4n;1dDV>V zaxlIWM~uCf^(g$EkLrTBJKs3%^T7OAAbC2{eyK_8-~~5oHI-2}JgR z9=n@ImuvmHlpX2>)F#-1i4h)Vc*ai-=D+Ee3t5C2s!`|h$yg+C72S+miYP6C zW{bmwQC^?k429_$_WV;-bZH=?8@~>?bL;VTI0$|!A;#uODRPIM6K3tCBl@sX-%gHs zK+KEchqLK|@ryg8ze9Vyr08SE`ffetiE?gCZkBS2N!oas$`5 z%CLw`ZJg3ao3-mV0X!i0JJ+5@Y4ob95u3%cDfaV@E*n5JtOY&&`MV*A$f(;{*gYI& z7hs&OUL9(k_S_e-5BD!ELJayr9SrT`-V9-UD;QHq zE=tZg5qs6E5|xxS@-sd!kw-6u3I6hc-P?QA44s_m{5$-9xl$MnGJ@!+#y(S_aFY;)iKj?nRcZ3?!==(`7AW-?PPBQ9EilP zbluc#l=L)P`J{mJm^64&2r?rTpV0T6PZ=7jXWh~$pidS>=!!q4xeY^-ty7;Jw{Olo ziG~A>C@ng6A|g}bpM6MUG<+63mAj%eJG9~Y1h(jkOlvJMJR4u^&K}Zz_%W}?{Y_Nw z4UJrc_r>HzTY~NPR3?n`n6@OUzB}C8E=W|J zGlT3s?IZou^SmBI-9Pk1)C|bZH&ka8{rgXZ7;@=RtU0Y&!o_1X+a4900=qTj+lTQe ze0~v|(R~obUxfZz=tjh1&JLTi~2OvSw?=ZcHJPvL*w#ggG@OPlz!q19p zI~$yWKG-g|##77UA-K8VH3~rsGM++N$d{GlqQZNa(QSo=YR>xBrSi~*9M${mwVsK+ z{xNHzdF8IHXNrviZT_1+!MO);m_}@h$vd*G+VZxqZ6#pm@}ztn0xFiQUo2 zi2*+sThj8o>}A1cV{a2FeSC}{heZK;wr@C*?MvlVo}wM_vx2ej$gh zUY_t6H1k+&k6hV0RPj0(vkb5$3ERYvEKS%BOWL12TQQS5YMU$uU)myD5Sx9J9x`;* zTZP&$PX2%>D2fnhineDC_2$Pvd>B#qK=Oa8^D$H_mSY#_@ZWoGG$ka=qnD)fkXcAN zce?#sPjr`y#fO6;f#R|cC!O!K(1QWz7bP9OtP>jay=*`E^d}ikGVGhR;6^q^EI;|W z2kndwAJMd06X@?h`0kOus1UR?$@4IO9Zzmltuy^Ba0{`;EuBfN8(84!bWuO%(xni) z{7-uYWnD+qb4km`{8=V-r;Fw_LpA8v(m)Nl*0&pW9!*oPA51b1#O=zU3j#Oh@J$Xq zGY!go3xzh)`|i8y4d=FPU0Ox8ptm*k@ zXxdOw=nxr${bW_82oWxus~YJO$m=n9fo%IOIR` z%|88UM|kWK+lLlaAYtxwe_enz@db*2d^jY2@%m9s;qdI;HlF}+a73SzVC-cZ=GpNi z$(a$@B2E@Qi-h23uRM#kLqF!WOJD3=-ijkFGxpf-%Nc=XAWA=hf=tS2tFS6e`o(_b<)oDYh8NchPW@2vQutG)rD()3s4Xq z_VGP5N8`bMsgAd9cAMHx#esZ*v@7+HMe=lx#TkLk)dkP^JZ6fss_+=9qNMy8xmkbt z<7nr!K7H8MWQ9*4I%eN~O-*NX9{Fj{H|iihvrK+q7+@Jy(}2w0~XTy-6!3LINjbjj{n_azdj1?jSo2I47;!~k56`q>3R#tz_Z<*v;oAj zj#EYTaDIGNHe>e-y~O0_aWKTnT5omj&{?@Ff{dJ9vM+b6wu^k&`GrJ8i_GPIPC4@U zY;?8)!4aCNqzB9rz+pP6B6_<}&wA1Q-va?ZPDzZ0FJAjdVJ^xwgOYV2>0s6Sjamoa zF=8vb4h|WIL@ox`H&b7DDD8}$xoFn<9Gsc&d^M~`#td1Wz!t<(;&3aJhrFt$8@79w z_kK0F(ZXz612#nGfrh>Lh(6Bb|K?lM4GGhtL~XIci9VPANVyLGDG zlDqA69t}~G6&za!d3=wO;y9C*=e6CpM-~`8c@w~!9_DH2jp~gogX-nl!pu+lJI? zi~A(TFT?_WPA}z{2}>SwCfBn~{c$^|Js#&`lw+So zg@1hPAC_oS)HXq*@@;ZTBolTt`#JzGvfQn}ZD-(DiJn2d>bm>y#P&0J1sDRo@HXUu zDLSjuW0#IO9G6=KK%m5C{X6Q+Nc6fn|3h%y%gXP1%t`E^jmU?bRgY$UUr>t-y3z&XE_Dd^iRKutznqg*X2o&!u4!HG3l z=3ZkK(HG6ZLUjmC%MnylfyIij>c+=(`$td zjL&A1T|#q->kKM=ayNNlI@6Q<+#Ol;J#UaoOEK6S*n@9v*NXDSX8K`pf+WM_y@ATH z{6(&j7wGEkA84(Xi$5|_6ySAXU>n2|$^}O91yNjs7#G%6~Sk3AOfNWOIT4 zN|bJ!uw|AP64A~_T&qIdgy*g{mF=_9s7CrEQ|6j=f%TZ1!Sr0vQ7tbhZ~6-FnBXT_ zCAasC2_@=z>z_v;uE8+gxI4@1%Z11fGw*2D?d`{7U6_A6#IGwA6gKSSHDyVzVt%1I z73q>Kt~Y@?j2WE}`@V9!f-(&9IAO4;&Rz!HFqTifcXVURNP$zr#H7x8lXEJjC#}lm zw7^O=(xOZOSM7h$?Tx0)Jx#y+di@Swx*Xqc)xG+?C}`zH6g3F+K zO4Y;agGM;p?U`pY`&Hhf7GLh<^S$j&>14wR?Y$~ZuQQDLr6l0;Nbg*yILWngB}6Fu zQ1G`6j1b~E$4OltcT*g}`<#n}|kTc7_uC#r*=aM&-Sp;F5G_smK ziyYe$o2t>h3lD{cr~Yeo9OAif9DA0kHTiti$KbdN{&M%4L;57<+dMPt+{5H9nt~BR z0JrJEvXayhn&nAH7}ayEus+dUQBA;r%nCNIulgy;xaW1Lq1*8wCWxx5Oko z4ooP)L!wtrtyM}n#{|DKpc`|Pcd2)w-($1Bj9V$<1+Cf5a*KVZ9@?aQZPv-H-2FNi zb6x%(EQKCOqFI^B!)!xd)g0u@#;H*GmFdC+K?5PgQG(U!oAy{r_&o2AqsAe%6{n+L z!4uD5u1K}DnL+!BTte-JPzNDusqUH4bfJ4}=fCF@qVBJ81ou|vr|fpd*svr8mJ*;&S9-2dCc1Oq4wh(jU?+1x$7sMXS56rVZX-Jp9J=7HW+G`0n z;L+}v>@yfnb^W|>CMOSZO5zw+Oj33UBh0nf-(zN{VDekv-mk6RT5V&HP(AfSt9kv2 z{xUEn?acqpDqm4kZ>b%TI+UhmIzPw{gLv4POCmi|>G2*c{ZoK_Sv^hhp{vdj-UsDa zu|#2ueUEQlu`jN5qjQ7p@jn67sXV>;JcEk#e-)D{P)x4C9Ov*9ui`PTbYtV`f9P$db97K(d5%bOjR&H_=HlLFG)#S5 zAJ*1Shdzg#(^WH0BfV~436Kn}!^R=2i?D%%f{qDW_QmOu z@1mrIsF8GT!qa?*dbka3mwYUVpiAI_vzi-Vw8m^|oN1WpAzI~Mu?fNy#9in!3kmR& zETIkgC|hAabdbpv9WS73yR*-RKA6r?n|iQ*+xc*XlRYMHAQw zqJY30o|#HrPJwKP&k7gTH6EHpDEw*M%G$(pD#~X8E|wb?F101bDs{tK{>+_0SziFhu3eIE{E#M#$z+T& zD1Yl`@;159ro@{(W?X4F`-&(&`H(k3X#VTTZ7CLycA+`EbVwx6q+=m6#Y6V&Umkq+ zfUzlKu7#bF!c)Y)AY}|_^N|Yb$y=5s&HYv5!_Fy+c2!@J@0c9QMaY#!^l2986VL4z zLLzg94>=r!8Z!`z^^AYQk;*zqK!IIlAA(vc zb^v+}+3@E~m!TTvM|s)QaqAkxw7UI&WC-q00yp_U2IwgD9hw?io$qU73c_qC`QiPRxwzmBMJ0JmDby|GNg^0OO9 z(#cNXWAlvy>V+((R-y6%5q)X}(@afBIY9mqjPQDI?20xL40-%Hz)~mE9Wd=qJUl;; z@Epohg7?(Rq7t^z{+{+Fmq0Z}R!Z0F!X|j?Y?>__F~CIJ{_pe_h zKX!Q`-PiewM&!qCk$G6DC*ne$D>#Rr9C+rpAdTlCpWE9rs`Y@01+1C%x-G^~cQ7Rz z5G$va9i0iZFb$H+vC$Lu4D1VD6Kvu+rQyIrgP-B(s3sf~LPXJ^I9 zd4F*aSv1Cm(qGxE7|`J}oJ$xaEJ$a3y_YBo+>~!l*FwZj@)0;le4@em z)Pg=Kcd2@64d*=lS%z!1y2zGewlLnoAycoW`1D~$CRbFchS6He1FBh%hV10O^FUVj z+U>EZL^WzFt?en-YIkPN>!!uX24L*xEf~MshFD%&1CYk|@zupAsdi9ygs3E3h%rT& zrv!i8!56bbZ>4o6@Rjf=pIp=MdhA4BrbJX;TgsvCC=W&5@rS>c=@v^2ap>F~m>Vfv zyMDcTy7~}?avW6y;-&+F^O-t8&NIlEDqG8FAq+|R}D!Q_df^k&i889GOox+_xnPMM&aQ0C4aeUAzXr+P#&)Bn zUoG}jDZ=)T5>PFG-tERUDi~P2)Gb=*dLTnE)K@M`Jix0YTf-c=YkC08#HN$ZX5-CcVWT$adlISI}D z>e~_?@T|%7WwYhH-e%TEOu>8Cg6~?SiIUlw3N`9*(W&@}G{!K4}QbBTkD6=JWyXpl^l+Ml#<7IA4}J{<^g9(G!6&Os`qK3$Ecq{0wqZ>8Kj#EK1ucGP}r$3N_-!&XWu<10$HB zJQ@Bs14X7kdL5Cj?NfRdn0oNdsy4Gq4h^N;l$@fFv*BOxbw1%z-7&yE-Kr(=WJVQ$ zKy%9kRD!&wyrx=8CaY(LUW4Qoy`?jlKd)7_@_WW68kNmWl3BOy=7w_f7qY5{`rNWU z+IbsiIf&~yM;v>&y1gvQAv1FB{E`qN1EGCeR(AnYcVrrs3(|?^l zNkZ@|)Mi}*9Q4#|`&79#7;J$ltMjl^^6InfE`1~nytn>B_p1mkO2FyN@a~$CFx*5$ zj{h+T6m8FYe>%0xfI5yUkO0Ao3jJ-|d*I;OsNV5aaoCrb$FIb*ba`MS{IDV5NMfLJ zbXMDRFkOv!_Dps3bgZ2Y-E*Hq^D{X?ziRL5*|jXc#sWI4F-1u2K>}vu$8_XSpN_z2 zoJ**xGcZgcIwaY) z4t_i-)aej@HX!l?=;sW;ebesGVA7j=jRd_Zdc#D%7778glar_rFcK4#s=6^d>-!Ge z$TP7^p9DuBj$+QU|B7rLb5UpDf=fT!m;+6>%r%GFPP~i*Tg3C*vk08O-}o`ZB!b$y zYJNiKTA*hgR#i2{@{%oCGv`qU)tfV%Yhtw9UuEz83HZK<>*GQlrrpFBNZ&~%W zqoj$gboI~xlm=oW%3N=Bbvu|*6^d1ed{@8o`R0KeU!?rd6z$bn{5WBST?oG>P5BNnF!-qUgA z?gB3g7Zy7GWHu%=^ZfcyfFw^Bn5_yQ@!;bap{ljfr0zjR9={sShw9SehDa&C9K5T< zId75OsQnsz%^U{8W(bTwkL`;J>vT4@zI8H(+C4lY1ZDu#Medz?B(92U4dpjpbj_PZw5j6OoR8{XZ55!ZD(WqlwWf8It+l6XVNo(NW6BjQq;(>rq4+~s7A zpb;Q>hGV3@OV&`?vJ7Q<@JlCJXC#Enup}Q7^xFCxiZ#+Vn|_aUl0O|n?Kxl+?LrUq zAp?pNeD{Q3-cmCwe`R4Q5Z~_wdZw*n+fCsm9V(ICd3Qagw7lCd_aUF;kl;m^aPGDe z6mmUwGW1%TL`BvnSi9IVOb-wNyRDZxo}@$Lxn50m)%&Xww>5OVpNrEE#m~%EPqon$ zS-M(o_k?Bdpn|Tclk{o!rW`=b<`~tIx-_NVU0Ewgm(N!gpMIfug#q%rU+o$4CU~Da zB`yi(I@>ID5`TT0P1GtK6qdgKv*`4LF)e)c!ZE)X7(GPvxtszMv}5cEW0+bIYLUu( zO-^H7k34pq!#T;lEoSBs>a@h5yZc(9AUW+35p(_1he|ccIqEeB?fA6O=MWF6}8*0l*6z2zlgS3-kacfM(3@{QV9#) zQOjn2;4tA@z-5{$jM(X2xC#zzOYN>+OmsZwT{_(*jnO%hL^}MLH`QXwaTum9(G7B%ogy3F6%Fa#}B1829%j7>N!(XL9e*4d9P-pFbWU=#QC;@ zPTpxRv%hr|e){&eiNFLly<+RbT5-^;ZMdBVuBI(2$#G4kOAj_mdYrb(tYzH{CLpKS zd)rZ)g+6|Rm#ES@Z+f&!;DHl*U7C@&tX7*9Xc=2{U{@%|%aRF(F}4!lzU6WJ?JA_X zNMZ5McRu$z_?O{NZ;9)C_0Z_`o0$dL?|1`3X$+B?sjeWx>AJBMKxOp};O5FL^802! zv+;$yM^pS4Et!izzqHlw_B=Z={lz|6wLQ>=op606MN}mN6!z4-!B?hZDTjdjYp|AlB zsgtcVD6Bhu13fvVL-X8!1fQ|rJtgrpd98hQHzCep<`p?$_o?H zPh$!*s~W>Y{HH>PBWlc3FV8^AD)J60OCmPwJL?glm>|)T@oQ7*4U{{bB};_$J5R2` zmY4&mLF;RcBsBEn)zrY06dsOwvd74X9&jncd(g&3Y6+zk;`*gch5q3nArEUAb+WzQ z_DL^4+PxDP^thJmpmrNv#8QM%S2sB?$&ko^}M`a}q zTN4|2HD5{|7l3mPW4I}OdZnw5&%2TZzzmhi)T{7jFK;CbP=rNxc^9?i)S7QHaIq8= zB(`K&jo9uuhHMFMwf%(}-CAAKYUI)Rc7<9U7T>Kl9CzN=D&`1O`Ysa~vZq{d(eo11Pn2Dr(>DecJ1h(iGK@z=O4x-JorHDY1f- zA|q>oJr%Fmx>0#gRM-vU%o#7>7Az{1#M2S2SSP;ay6m}izTX?yRMhVZ6zJCJSJ)4G zonwvuwT_i;RC?Isfe8r2V|%U8$I&xKYgs_8q42L$x`nzhM&Ylc?=rbCK)4;h?CpcR z_11a3bbpqw*kk`(hQv8f(m+02iIlC~??5m0$iqN@{tT@UW7@33rrf9LE!E|b8q}X&i zf24!w%MTQ%du~g+(&yElOy_~ygqY(obI0sBMwZpFTw>;Ls>1!Ff0Zd~)*g}94Q8=C zjh3b6e`asdDMfoz<~*{=`LPe)oYD>c(%_Nsj|Obp?q*&c>s8#6nK0G6yMRlZLz ztSm(u13Dj3QX;s9UM8!`W)X`lLNS47%zAI$sL14E=|GiS>4M~fFhrkzE{f+67)nZ; zH(q)EF%0t|q}ZVcQ-PSHSod=ytuv%{R>`J=h<8L!(4}#6OA6U8HIz`WPAzYW$8n&QBq%`_Xg6JuL^-Tmp)W zWMwHe7r22oMkfcU`cpwxmH33^hxMvOmX<5vr(S)V6gt7j0-yBQbQ_<)G^80Y_6y~9 zrt|utR$L!^LWj+(;&&g!bw5h&R^sFLLm!e7vaPXP5ch^qeMchktN!Yr+iP+^w=wc9j^44Rg@#p5TgYY`XQAO4DY|Q zWF7<&VHF&Fp~WBJeX?9A*aF>A!R5H=2<^MLpu9B*PDvbb39Mg)5?@Do6!YDjD(+aO ztGd5+UU$Bvs(_9@;DFphp<@jY%Yu+0-VWd?dI7ek`~ua+P_vm_3D1h>m}TZvGvGF= zRhr(t;r-$cNhPOMA*X^x+rWm)mf8A#ha)-GiAQVj!F9Iw7fXcRcCg^e>{o#{N_*;7 zmh)}m&P0;JJjqWFcsJ>fZ?&Cv<;g8u;0U%Tt=AIHf1MvYH-9_=gBrU};!ex)>`72f z@#(M%h$Jt9zmi;1ZM9NwH^|;pP1(61)8y=@cn`A+7)B_FF>x=|YL0nOIB#}ttFY2Y zrVt(69%L9BxMEs`+`uwS6|o+WPH*&E-DVP-J>AdR7KJ<5zP$2#{R>%SE4J6Mqcbb( z8eX0D$pT}=OSIv!D0`KS?*wVMnr~`9HsAs$eZ9xW#5ZzsJoPbS$24NAvOV>)qGV$# z(Q}%hvc-QpidCYfn!fi!?%gUsJb3~9B))IlRIxq zXytZympTEx{?y{FpcPB&J!+J`Qa)4!dF$V#FB*<_=kAX#3UTIn%&{h&;y`}X!n9;E|!c2lA-C3mSl#ck&ZRavHJVY}uzqR(>> zt(y!~#f3~FJ<%o6s@CtJ+vYm7B$N1e*n9#5NamtdWfh>hep))n+ELaRU3E;+7k3mr zv9@V1T1`}A99kS>NIS^5RxW5Co4MtooO4;whVG>M!LkA9#45_2ehy=4X3YtP9!Mc8 z8iV=^Q2?G}brS_a=ll(VjsaiLPBzE^(<}1FOy-f`emFZC-0{=HUN1{QB0bUA#UQ?A;lC2!VH*x$HW<;)0FMee}Z%%PoV z!^+Ukul*HD6&01QF-P8jbL8zd({B2)bU26p%LP{MTsea{9H7V3_YJ+JtcK9~4%M+) zJ994g9%N9`_|e?tD9~s0RT`_#s40rBFrgs?-K?$k+|t&s6c{x%rxPjkCp3v?^23k4 zDq7=l&V#q0UMfG>=DTSb{7|sY=bcM7GdxjGTUm&5wAHAp@e`Vj2ke{It|nW9luO{_ zwtn7Ir`H*)*%zrdt?2kzYo_fyLY(#f-j&?^OV*;tOSReVrDK~EKnAOGhDV7?@1*!q zESUH^z(>`+5QV$Zs>~_^Qy=}Zadj6jlB!!NztZEo^NnXO9HoM{WtWS52)hw| z&xmeRT3GK=g0_GAghzanX=j?(p9&iH?J|9+zP|cj4eOMlE+_mr?ZEpJH;6f?_Q1KqL3gf38W9h zAFU&D7o#PV^r~)+)14?H*}H8Wp7z3|6$>PcQ*tQx)j!=At>-P(W#1uo2V_g0Y<6@| zJxS))qwlEM8%^5!;W&_}UUp%wiu#@O4TdqI*paH8?}6skURc5MI=eF(hcho-Fi|y> zELr*sZ?=hw^qTEkP43~E`qQlB(k7h8v zNl2E;RuuU;-D@tgN7rrUFoAFFM)>m+t7tdr4JC@L+dSOeuf^f2vS#=+6LlPXR(4JT z66x_P6SI$($)1LGX+Bc)qajN;F|tQ0U$;Q!feCM-$8Gk&>A@}b)vc}MHRzqJs37Ub z0}!Ie!_$3fK-|n|uKT3s;VW?L<{j@&1?u`(hu12CNVb_@WJXMVM9sz-RFW2Vo9Sg}-MdNG#8obSmGuW3NxkS5{@q|AM=FZbtI}WSy^a+t+-fq6(VkQW(z= z#%pLxH+a9F@1?R7UW#5roQ0t`j$AvJVXN`H>@L30n9aI-7CoVHOpzahp%J&y-$n((x4q;pJo3u9VC4O6^Pge$#Q5oAOn@7XeK!Tqv{K9;;mS-tu8xS=WRsayfh!hXovhxEsn3-pA zfj8_{wtxit!-=37;wF1uR(%vSJ6zM~se?wtdz^=@T;e|~o+3)0!M@)jWph{wxR_ey z8{gfw5)w%*L`KJ}(*gu*?t?vpjK4lp3!buN`U=V4h#WXMF|0<{WHH^hOSPn0MZ-92 zbYK4WU4X2S=5mNj3dd6}#NSb1xsE~=R7~e9(L>PZaez-tpx%Yn&F8Rg3*JnjdxrIc z(Cazg^NzWR?2{RWcwwYs!qy@oo)#~9VC|^6dFEYRaRdgB)0;-2!qubm&A8Hq7B>Of z%-~s$?EfO<;vt|)I$j^RqD9|O$oYU}2hdc6N)PMBzU%4rvv3Cvys^MAESRmMe4-P- z(vt*g+=PY(M!Lg!_Fq(~U6)1RkDDgJ&(?52TX(}z0s^(>HAxacsf))u)Fz1m_|lcQ9-zg(W=Oy&-3QobZ}kCOiKu-G><_4vx3r6~h7o zHm>$%3DK3wu`)(3n=N}q#FARFW{gB%Z0dsKjada3q5f~I<1Ku#njEJ4o~sbg5QNSc z%Um0&?jI4pIn(yKWa<%+SuZv>)0}VccKr>}FMF!Rfqho*lcB0`qQ6;jTi_;H~Tx$V4=_ zL+%NPz-vgx@w0qb;E^Gz#mWHAtouO|sW-U2jb>RwWMiY1GE~k(+XHRzFn8Su-fimF zpEs;_1O+JzonKf!6iez%8=;kabFetgX!uq@l(`^|#SXJ(W|QK6$M#jnhhN=&xYy<| zU|EDuJ$gm+zP?%eX!#nWaxbv?-^<~cX0*?9zpt|#?W3Z6ZvZX!s`ZTCGRp0_nIa39HFeVw z(fnl3a&$MpXYdn4+Bu4IV!e;aYGR2t_;Z#iAI1Uf-j?;qQYUyqi-)%}Jr8}aUSro_ zdvjNy%31#7E#{l2KYsq54T3V0Ago^Z)q6WSmp*1DA8VG>QIXfW)-qW0Rzv0(tHe!+eN}6}24LotWb>%&8zmNm zKyi4o2HuZP6{<5`QX8PN81nqvet5QhAdm<9dzm!n0E5fy9nYd#rU+>%! zjrotw53bp|!vt1=;HAWIfy8$BLtX4?mU1-lj}WaJ?mzB2-$}NM-}X*NIdiT$EggL~ z3Q*m}N08FJUrmC`giXacjb0Y62Focw@99^mV(OHvV*A>(=Drt`~vx&X)o5F~d^i{1*n z2a1b3Mu;s+B&DYRKKN@!0xY*`(#!zFRc-;maz29uTD( zx_n$ESr`se56?U=OOPr4<1?Sv0w0G7WuAbvf2Q)hm-hW3jb373V*8uanHra=)v>Anqj^Vr}k-fhl(>Pgj){~ALm>WGR1 zlgQ+iBUPyOOgAZJlbqg4x7bm1KYdx_@D_*2kS7m0a&rfA2I?Q4&c|*Ib^Etw+$n+9J{NC8HxT><}8+C9L z8a>rPC>jAAeT(y6JcFqC{V?oOA`qifF^H5_X!`7CRC;CT941HyK_|@=jliT#D~>#u zROKKW{^WZN20YgdZ{#h9oFlbjP6)2E-$~S~0i2HR4}`^Jc*TbYW-GxB*A4F5f(eG- zb%SH$d4TlVeR9A4;2yuyw?L~$v1XJ;E;95A7 z#(R4(v)%QUOPob_5FQ2y z%WqUIYQNBxs}0)tvqk8v7XLHh^2At>&$r_8;_lMtO_*m^_d_lsni3q7WQdosQuN1K z_5q1z-Ti^SD~{zfr@w*EYjGpNv+_*dApmsrxs&dxs^Fjvp@BrhYYcMl>?%`q&`u?n z)<*|kXQ``$rA#em?{7(wtPE|m<=HiR_xwa^l1!g|9C>*F1d&tK!eaIbCfM=;!#nA9 zSy}#P2Q!axJ7Y3&1n-7(@$=VOWSX&NMUfdF*~bvAU=$Ml#jA6aikNMt$GhJz_a@T) z1XDnip5kEE^B+36*4xo5jr}E}j^ILCkA)h4eD#yL#MvKwr@5yA+TZpD zZl6|dAN&$@mO6g&c|RLeb!$ltj7ZqHuqFESeopiA{z@E6i%1H@x!;kALMeACVuPh= zDl!v92=Xs&-n?^PJh9H@zI4*9-0MniGd_ry?0@jcT14bai)Z9LW`fZ93KW?fQMzY=3{dSD{1QRwl>kSu1eemDL(iA9+aRwE&x?Shny2;eUKxIz6NJCN)mO#H z@e9a-{(@~Z^_DI{)@|0GUlTLlR9VO zbF;Q546~rtC(yg^-k6a`e#-}Y40Fb)%3_Q^T0PAMS^NjKHGpkDDg+dG&&&Oc_JFXV z(*MKMTL!fmc3Z>2-QB%76ln3{6ev)NySuwP6sNdr@d5>kyB2pT?i!rn4)4wLob#RU z2Q!(FFq11=)?RDv3n^c&FIy+lJmmMea2Gi|10)18?Adu3S7ankPllV(8Wp?DeQUFK zQx?C>TlI}j-(_SqjN+M_Fnif^3?I##4up|UX~~hTZ7yP6RcXH4j2FIr?E9U;%pQtx zjzi;-OqyZw{@vT$ce->is@W1x@kRBw4%?|9$eNwMZ8k5VL#lW3F)mQ zA^#5HF8*1tt~`3(Rs`{w_J!CHXjF9s(_NwAea<&@I0@p^eLl^Puh@?lt}AI-PDeL8 zC^iVO#i&m;gEdn}ZT&28b6Rd<0I)zNqZ^|AMrZxbzSYM*RRrcA8X!GKJX{qOgJ$=d zg5Hu7JbgHR;;=t3@22_ubV50G&!}EFyd}@(g>QUQn#g2IVFuH_;vaS)-iH8_y;wl( ze=$38#QP|0q4}+T7~gfyoVxlv0wc$ket<92djt51Z9qr>2clB>2G4l?0QSc$Cy9NW z1q5eIN(}Khky6v6vX6Cb2S$)Upi;MUEdqEr$z+d=K%7aWlr^hsq zv?^;3!Wgas-+^`w2cm7q)XCUb6tH3%X&~FD|M;=bAO3zGsXr=qV@6l^dFCyWt(2CU z%fb4+8+pH1YUJKv6X#nj7j5dLxRD1>V5e~$T8jp91PNDVurJ$ZGe4i}%pWo?{rE(8 z*VdN*#MkKXyT@;sWtEhPV14p=98B5Zht#qYh34kq%QAW_}Cn!%4rTbDvk&l%sKc z;p1qO9iLUO;|nY#b2IAmZ~p?q0J3+3y`w-de^aFTIRR&@K7urENz;F(G3bDpn8}c? z@S)Rf>f1p=oNXkT`7eq@(dhe39z`l&T-cl76Lm5QRv%0Fy43S$td3qd55ezjg zH?X&sv|{(s^?@j{B-F=J$#9|t*?^c?<#POGjInyQ5UTrlwp_6?>tL>s%*f0J3mOnT zzG=o*AGvWM@+4_^*!<8QN(PCNx$=5ZdvHtNANuJU9ZoiX8HgF&-<_L*zqM=PYM=I; z-4WH7_5&05-L_XH)dNTmKrVFJD>3;h#!*8pr%cfIgSMADnvKqd%HmO;asjm~R z8~x_S_Rb9~2oK?ZC|1G@SR;6`RcnHjlB*kvD!#@)KfNAuhPXgW>tc^7nOjF7$M-<* z*kkTyObKaPCYyE?n@W2gJyDmlN$5#u_DWGUDcTvl#DYS0oSCb_dmTJsvASrvVB1T@ zf6o|;aX_0iW1q=B(UeV-O{{h23e|bw7K7yBu`76_^B)#0W2L2~Wf-AgI{llh=~ntu z#>EVV+rs$GMgg=B_f+(DB(0O4uIeKD3`dF*(`~(L=+|K57oJt-^?FbhfP!mg@UQp` zPO3boGi-fuIga7~0M}5^YWWOI{*K=IG~YzPaje(=BX*_Z2cu=hiU$Tplw`JZKU+tE zf56R6@%@EQexExC^4Mnp^1kgLUy91MWPF&dDj>*7Xn%bdm^~PtU%3>kFQ+ZwhUTBn zQL+DA2k=kLw?_^Nyqdr}-$t!+{6RGi55k4{NPiZiWz!D9tXT*9tKUVL8XTvhGJMXG z_Baw5PDOVyc>@Z88*^{S2X5x_EaBCXHnTGk=N+WE~E)H_m5G+rYr}H{?qcKAf zXC|h`!q*MAy1J_TRy3K+ z;rLtZg1QT+l{Vb)--tTSX=opOty@?O?e^p&s5X`^g6X>C08knY$CK-rTBY@a?G{A? z+yC*4=w?~J@Vb@D2?mvc+5f}lzq3wGgoi)&uXjF4_s?<%&j$ZxD2SQl&_QYnieDm2 zm^|k?wQ80R!(c!SXcINwUd2txrogKpd&$SyrGj;0!hGIUv1TW`7hg=X+e3PDMS9WQ z+7NrG&2Yh+C?ndQPu-`=z)W<@fvo=SqYHe#!|rdqX9{2fT( za6lLjf6C7OG=Pll%|;FRCMhZTK1Q618YZCb9p_i*H#Sl}1L&2zX}YZ%OZfX`#W(qynsMguV1 z@)fkVAN5MJ+)+#PU#C~Reb9urz&9CF^CJrilbD|2_GO`F2-z?%Z#*wQC-wiUpt=PS%Oe8odPFGK13*3(ulUJv^M z@$UEC`=){Lcg1Hqr4?2~f3&a=<}mFE0d%8MD*!ZHgJtz+pwFsk{!lmt#CVKjiAMaF zkNEm*c50jLk&#=}kd}*5$W)Za6)sXH_#VUW!5hHSQA$i)ijb5yfg^}!W&9@)w-=ln zJrd(Ild9UP50~dz%Brdt)|mG1IM+NP2CuTobZe*Is@!AzD25Acr#)pSLA;_s3=tvlp1S~rC>Fxy+~roM*kAn^KXktIuE(e-=}BBH0KD0Bfu)2EU{Y`_BuHa3y9h@TVz!akl+jOu-zYoI#;zw)Y=AIlMeG<|^nn+W35 z%}5!E?iK_0@8Q1#kyqxx<-mC7Z3D7{kr%c(X17fLfo*6mxSPHIg!fA+s(1&Q%z&L3 zqZp&RK+u<}etE$R?ihl6bfZH=Qs}`CP{r}Ta75`M+(C5_KhX?G!riInE;+WEKVI(U z+P$J5K3CdrmXbd3{KhpvHe`}`j~Dj^q>LyBrHIsx3x$mI9TpL19}biJr50ou@c1Hk zM27sbUkwo}qt`4SqMsys`hbQmzoP@i^X|hhh@MDC%4G94*{}gez{vrjY-zmP*{Lv# z6})*@_Z03|8k5MlBG4BqC9$76;hfM#;uCnFAt4cX30}lu5L_@cY5*bZBuuxkmHSsO-Jv?W{p5PMJwv3+nz1sWI zLi1J^ruld>gKv#QE;@ito zTT(?YjY#XoOcKf+X&bI~ivu4Y+ zwPa%}nfPu8Kyvw6Le*9dQvH7bVD(Z6Ut0(M~ikz z5`v(V<6^VY{ALwlFXc~Tw>Ov)A^%zzo`~dJ^X+S>fmsI5(;w&vdkI>;Y(hY00DM~^&*k(pUm##}q$-u9%sD*Cpq zB?H-?>EhQd(6r!Ntw}B)14?Qv`t~p3x`@=rQUZ%IJqk~{W!@L~$I*x^>}(#)5{y(% z#mLk@jIAoSd(ugQL=b|99zc`-hvs6h{|C(x&8r*Jov~hsA5I>f z7wWRgWB`F8t|ZmR*q7qVs^4t^ZCPI@T)B6{B7Q1Y0p6J5?s0kZEKn(7?K@GZ1y6PU z!SeG^=6=DyKnp0L)(;;pzhJHHy2VIfdE{BDum1ZjY!ISyaEIm5QQ7jeBQdbHxOV+rL?~zf zep^&pT-SB6%q6>W&q!n~>~ZN-S9 zwztDdl#n%WHzRiz{8Erm@HGrc%`adpn;v*ToKYYTTWlD8QTV33-eun2pZGb}(><0~ zrN9D&q1Zquz@?1v{IS@kKFHp86^Pj{h2WrqBpD{V~;`L(9>0gW;= z>WmOXz1dS&*QU^$6N9C|w%VI&LsFtGauOKh-RQfqBBKxN+=|xk9<)Ps-2#(G(l8~y z8&&4xHs(^0-}fV}dr<|Hc@ao@gsx1?7q#N^CP2*Nf!PzL?jaGuMWmfoZt%ry*6A7{ z$sDss+a&>j>ReJx+BPw{8F|Y&98?vxM==~?llkQzRN5{qPz)vnoAAk9Jn9~yE6n0| zSn=E@&&%RzhrUYsZ*YP{(k!m61urmUmw(q02^RkYcH~sfIhQcu4>$m^VIjN02a)}O;AFbVs;Zvj za8{2GnOX2G+{Ir-NYloM&%&DYS17bk9NIobg4SIHq^P~%r=BL*1U-J{MmXlz9hU%; zVzefp1iIEvh#t>1FL1=SH<;fa+@}^eO7m-{l>b}q#PfA$=TGF&G%n<;s5=S}C-vU1 zZf+DHY>A zS?OJ+in%4ft%h-t==g6W)Yj>PQhW;StQKp5UbY+BkdtF|mFd;<4`m0&4lIL@jfEv- z*S)rNp7dlxeY-6;J2j;Z)~G&k;LYxJLHvrhh4aQN+O~b48*q_@DpFy310pW$P-7o4 z`PNPZBeuUzTaDk|tEs8rwtZZyb%zhi-agSPVcwV#e@)<;1kN5h{86Hx$C}O<(@b5s zgJQoQ07RmZBoEv=Q%mRqHqn8RvARf^7$8+vkU z9Vk~f^k!}k&|u@=_x#SXrz_aVO0KltMX)vQKpqyu@<{YJ$VtBZJx!~37GP&%Dmp4k z)8(&Vn0fC=Gl?)f#_$&-Q;0s;4m_lyzcgB^h!O?TgWKS3ye!0y_!V$VwES?U>bxrn z@IUUZZZ~AuT=D+zF4FM>bN{KPPNZ9eHryC+1$g4Lf7J=UuK&0zJI$FgZ1u)$q3mjb zoL9#a@Ou?D<6kQ)SiF}`7=|Y2h@N-|pM)Eoe<{Y@&dsUl2u+Q^La&;C8!faOV^3nX zvKr=Y0$6aMpLt0ZP69HgqO$59PW-QBK5S9W&UP*jGRD^!MgAHyMuaYfjq?>9fg;}1 zTR{OBUHel_LyE?XGH)lV*8lYa)ClTA<$5h@Zj2eaKc&!t=1gn>BN42cBvi`7@uv`p zu4>dA?T`8m<~qRB61JV_&e*2;bW?a(SR1CN21^5wcSUWm%MervczMg^K{0GCRDF$5tVMhJu zP&*x`+;*TKFFDuBf*ZpN^_4A-IZ?+4At1?qb$YUAmm|zR65^e%0*vsFVPaToEL1NK z!sLZ^#^ldlp|-0ZB{f5px-7QV7#~k0KM*n;ndqV0e9X{6h*LY?9{&6M{UVs9pu@1oG6%zOpif zzS_L`8-KJI(a%e?pa_J@(s3&~T$ACve)9lC)U5>H#%v41?rYf^XZWr6uR0OO$o-X$ z0#czhAy1KS=9KjA>Y6qXMwd1CMF=vY#amFFK0=%^7K}Fbjy7~z(m|AIpl#D{u4t>} z?U#9dZSM>c5zCZ0K2IZlCE4HJM!V(QT?N&2*JJ!?$y;n5i-5R>v)~xi;P05>`20E- z>R6fVp+m#4HguY%>PVZklrUFSJun^$|E8+?z#D#L!%V02Zw^Z*E~Q#!4siZd+E`<( z5=VqUgY0oOG#a^@E%4!D-zc!s3Yx-OJb>vG4HTL03yj&YroI#6eD<~BM11+t1MI-+ zD!940Oz;kNYXwYv1AZuvayATU?HmN8YIXn@y<*sQNbyMF@(IiNKIuXua>uW_I^cx1 zmuep_&{(j&_r>1>)0|JMhI%LX=d~;?G1^&c1#aq3qqfH6=drAa(!Y-#Gu;~!4I+cM zwRY&a4Adp9*)+;X4okl?|I)t!x?LgWjAuBH2?M)ho=-}~D6!TSo716Y=FY}Zo&)gu zfey)tY?z{6k=;`}3N{utn?@v0i6AMu$0bG@UNmyM%u&fZYYGEb<67q^_8uO|LJr-> zujYXmNFJ@<@4gz>URH#vnLSImxjG04h?j^tNKk_#H~b}fX=%d3d10sLgw(_P0By&V zI|1$uFy5f?R>8E1=Hh%#@P})*H2-;2_4dA&?9YtAwYBw)Vb;q_t$w2Mk5K7*ZaH;0 ze1G%qWq~=Pl@{YEi@gO}NZArfD43VSN0!!IjU$|LVmTHt&d}(+Ng`yZ&;iEcF~Tf5 zj*!|=#p1oQ$G8vwng3*2KFTC7&#}i(4lWOmrl)1>*B{PT+wAoINYxWF=d5k%qaFuz z>f)JBnj#?koebavYkc6IF2)4SJh|9yZ5-7;)TM=yXv-0F5y0utP+vlkZhdAGkKV4| zUpS;NG8B(;X78ea@os1vqK3|5A-8Lih^8uI4?_yz>-ejIrO2Teu6b=1}x@VEOp#_=Bk~uEtNWPDN-P(+kIUtK<0zv4sS2 zy+S-xIc2{XJebox51C~3s3AI>RebmZkR_KNMVzw#8BC@2t0?E75)xRgoZ>)DEsg7K zOLJJi=^5=10Ja5>0*FPjJpFHf@Z++Mno=18eFB08mY}<@cRE^UnN&Hd&IaEFNqDM--}`w= zqH1bk)EcUCEM}b-ojR3$ee~?N7Nx(lz?U)|e+pq4C$4_aW%Wm_`_IO;nj-O%@|Qc7 zjoZI=5RVV6A~|k3{$KB8nq!N3gdbl;T2otUpBhwA zN+H|KiixOu`o88fWnU#MrJn)>3(Ip6TEDc6=`}Mv+WV+*wN(@U$ITN8D_7#@7`#nK zN@4hTB!v&81l{Hj@L9s{F0kysfftKw4X6PTpBfXh5k^xzSU{aHej<*z@ey;PXRFre z*u1=4HU}sEtKx!D#XU)$CT?BA2Z&d@<)`Ay$-0>;~R>14WI1EMl2P8bA5IY}*Z9Xv% zsP)h-55An92Xrnu?{Q{6%PlZycbXhy+@k;U@*DZM747rqa1xu|z$|dOUi9(`2mdI*nt`u=_c*sN%z4Zjh(GdFj`svIk6%kt*8E;^GqY21L zjXXM22=4>Rr0p9o?~O1D9|SJ+6jH%h~`1ex<{4p1TCFeB`KTwf3)k6u2(68mQ2r zoj3ReZGL0}KHK}uC*{OzLtLrAQf!x{UkWnE@(JmpSok8>^XMv)#+TCI1f)u*aadUX zmGQWO{^z}?w=DzqQGKw@xi1Ds@!ARM(EE~PH)E7eCiGWC0RLR>a6(zJ{;AJfe9GVS zBR_N_|L7MVKB`bBLf^2G{Uq^-Sp(eJ?vK%3(%SM+QerEZtOlevTszF3D%7~l^3NT8 z%C>4s1uJXoVPR!;<%Z%iruyZ=h;U_X%^Mea)y2l6GG-w-Wi`?GOOdCv-X`_MSbLj0 z>6JD7{UySI%`bGh2#IE!_z}9GQyK^!D2i$>1a}<%cwl9mTQTVOi#uxMAP=XdY$g!( zL-6B&8QD7$+i`!_Q*v2Z4^Nb(Yn|7c;H^t3tE*PGH?;Uvv+mAf{tLeItr@`FNITCF zKLk%5{AAwX0!28}4y5wO-9;}G_5VF1Q0|POI@T=8(hqlv+l=>a<@s%r!zjK9nFl=3 zEx78<$AIDhUP90?z=0b?@KMvp3xMe`1eAIWQT;pu5Mkh=DflaJIVl6=YGU*thJ5H9 zMAiF2!4GM&T_!oB5LGehgv$u!a`M0}G>dkSWMY%YZYv%us`E>st^ZB@72Z3g9e_=q z=CFZsu&V4N7^S7=O;vm;Iv3KbyHHx(b0fzShK|A(bT)T2y?=ctFd<#9YATH40{d55 zq1*w`tXbe8`j&*UMKbR0?xtq}ng|yOE`oG%gqQ}wwuZi{u6-0U?|0H}Tz=ir1?Y#0 z`WKEA{=Vu{BP6L&MZXBHdB+@#>&Hz(W*-ON**BblWEouD=+YM8CYT!LkG-jt8tD@!5}t~QJVKPLwDqbNI^YSfD*05Qf(c$161w6gak^& z+M~Lls>ylTQ|vBy4+Q{Q>&z6*`BV47X?+yM(#zi0TM%nzKJY~Ufd1PX@v_e)8cteQ z+x(W#TMf0GWp0g}U%4%Hr~lsSAH=ASJz+c zM(+DvY{Tj&5<=%w> zS~GgwzsinxOK>&6ii=sSZ*?k58GW4)JhNuWTi}A|hB`XJb=KzA>RTIHwn9zxy>MB_kFUGqX6WKQSh2LKHWC_dWe zQf&Gj_IWkcCxG}mP}A0R6s@VmLv+!VyT}}BStEb_1DC3%YOYt3Xb3FnIfHakGRkY0 zgTPOR842r!dk;g(0!2IQps%ZdXD}ta&^C$+E27Z)_mCV4ge}vABE@v~jCdiU^6=mn zJcwO3GQ}=moT3eci55Jw#&F;yP%uo|dJ*NWD*0aC=TE5il($>*b-K~<>OgS?S?dPD zw%znUO2tgqOv>iy(eZ)LMJi61cLv-;3vhQbx;_*cPmcM^L|e0v_m^&P zch2cuVHHe8s;Z5>5%Y=C7Sr|BR)$x6o_=hr*y*-z(H?j zWMmk%kD28G6>nMOL$MF#1vmumX11uPNV5}UkReSGVC~)z_Cjr-d?6Kg1Te8@bF)dW z>pS`aLiM}5_8^V;8Yof?%k0~cUM-jM;COu~X$vE*n)o|I+v^?X4tnMHgue;HA*UvK zi5u{0$?O$mUMr*0CnvAM-tOcS>bhsKuh%bG=|deKtrbNpweaDpB9W25I7;H0SZ3lg zl(QDeW8z|ojy>x&9!*5WB^9;Xz_k>yKtl%+Fi>v=1^o>7fU1yAkXe#TDIOZlx_o$! zFJ>*uOB~9Y0B5C7T#8b}hK}={x!*XCKVEuvhGhyCR^A$OK@Y`;CoEoH#@>|TZNUtD1riv z-;J?#{&IWocuI+R-n`snm$n;ib2NtK(FOdoF;IR0ShljpDb>Fj+<+tJ28>|wU=0`h7h=f488wvi+dOk zw_2y%H~n(u=~U{%U5o_DPv?PBp!>E7M{dSn7#}{WtZ$f;CK8jBS1s#(pKuLS_e@&W zr;&B`Src*iI1fc#xcX-YW=?&T=jGsooI`SWp?nFJneOwmeb|O9pWtqhJld@A;O4#0 z)ao*DiLQ^}JeV#X7a`Z);@8{bxzfD6LvySU8mPS)U$yQU*8MT&!X8={~ z$#4SJb*Z?|5_wifh$wg`|vTX!_%0~=Lky+ZN$+zrxm@LB009~bz8!Kq>Yo2-e z$iX6{kJF*}BY5!q%pt(2Dx~(<&1k;xw|DERaTQeSexzn}miSh{KD&bblXS{ijMJS}Q#2!K`GR)<1K=Q4L|CZ<^8&nSRk&|VKe{S)5jLp&1uT_*1WcH>l01h@+ z%%N!_Fo%`dF=nVy_sn~GLBm&9-F|B_>nxPuDRV}^!}Xh%4>(EW!+Yn1*wD zHE{v4n_g}&kCH^Le*%TEnKz8YL|oD5?1U^U`KIml!q}%&sjaTMa&L1bSF`is;c)%- z=ABUd&0SM?Qrh^ya1LviwpyrB-|h-Gd^}+Q!?&zI6-mE&c;F4TcnY^APUfRbHB+wn zUEVW~UamvYE`f*N&B*Lj&wq;6tP7f|8+e5CsA^7v4z+-V(9c*NpZ0tmclaa(|Gn() z<&RYJgrmbNpvF`wfAa9G6%05}`4y!-52;mYlL#nymQh7mbL=MeyDEdb_x$JDvk)KHM*QpDiLp@@oYcCE482S&~9f;<@CJAx*nu}xc z^tR&{hwI|Y3WogG=q)zU9;oD)kU!#vgd7j-7D=>^@D_e`-zNMKAaw_@7er9z^NmsEP24MRV=Km>)8E;8QH5e5$DPOhSTl};=;DlUvAJ$KR#H4SBd z0y=e++RtehJ2gm_AIeBu*@Y4ak}%bq7Tkmb(~iv>Wrh~gG7Hg@@#viR@t@MB@ulrH zbKH<0G1RL;YjTLKd_{ZyavAiMJ5!NzHuI~2deo_@W%WQ974JV6HgEB*+?+vkfsEOq z`B6OmN=g2V-FdRHL*>FzD9o1pIK(w#$NLR+d&Bet9prdo$B8aGlz2{|6rIG!XioY# z)g>dO$o}czlA}m-d3kriYUCjH2O{yV@=W4#f(YEb@ZNN^SrWvyxTDL+pOpeaX(NlH z{YW|$nc;->+2YSi+S(6HfhBT%^cK0s@-Rb)pV-E_(_8Z64=^<45HcL}TjSVEMKKNU>2yD3uZsDTZ|r@1$t3f@BuF~IaE9+#5NUW=G>CbbKhQ<9r2*Lpuv zZ{2Q%kN1aWmWBxx;~+=TW|$#{{1`VVr# zg5ZkzU*uE;$DLw%nSqi&1w^l_Y2^gThaYIF=FoqPDZ~cXB#8Sl&yaJ={&ZCR;8Nr| zzfD55ZQs;cq?RcJQEH(dtor}jlUFdnOG@z#v!>&IsZ~v&DYaIpI6bpS=0L?b@es+H zsMx0~M6s2tdFzvMVD}o*E(CdS{Kx#WGPC>aGezV~E6stwP_+Vl!O>msX87bRG{L?D zu_=*!^iE=rKH#m)i3G)L!z!DU&ZMM4Z_y_m^OY=2AFIp2HK!R%+ysxd*LAuh4)ke8 z>QWu>hayx7#K8(;81m^^9?UiCNWp)26dFvu*uut`Po~DcvzC7Sh*27a7iJ*7*A9Ys z-`4GCO*VLBa;&KbQl>8p&~34i`I1LJ;OJpyLzsMcPePQT^*+^hm>Jt$oKw(MYe;Lgc9M^u%sEa)87l6vqXT68N0=MZ$uvT=M(} zqNwq9qttd5br_hLTN`o)7~)7-PO|SB%O`#Wor1YfuCEeyw^~vRqpEA-D z1eS=HG!eKoB`Zs{Z{^uH9j4CtMK8oQJbNI@(E@giwId^3z>aJ8901XFN5OahAgTJa>p@ zkSuU(Zj+VNqYon*l}ZEie%Nj^mvQURF7r0IqJqAm{z@=xMd|1oJ1DFLZXv*c31%iZ z^WtKOb}PVGz9sfl-7P8sZwtz_W_lkU4~*6zCtirH`svv`dXlZd9Je)028!?mf_tW$ znKTVMKq^tTa6FY7b*#E-6@9WcMgfNXY_vklzjP=1M~AE zak-8?g(Z!z%%GRml9^5}a*BsE1@PRL5A1gJa2~IWM%fWfu(nV_+6OL2E(&?D1W|(x zE3`YE9)gbfB5N6!I5?el7QY^}$0tY(qSbV~LVjaXx2%UnLN+=&1~_2AYWmE?N+4&Nfmyd7#NE$Zr-`h_h= zIWFYKk6GZVIvP1IFL>cE!x+!eo1D)`!V^_L_VBOVvXWl&r>JS1HzZApBbp57SI0Q9 zOBBGQurHHuz~Z##BA33YD;08>p2@KgqLV;X;FDG)Kt@}-law_93y?4Mw|AOL-($(_~eYd zsJUJ9WPR<}$Mjy%bIWzUB|nEJL)R?2kwd{-hdHS6Z2~wi+r8|aUrk$VdZ1lA7K@?d zNb4R67Kc>Mxt{uGex=DpN^^bml!CJklyDr?-X737975ccZ86fc|2HGx5CuBBtAsG?p2ZsXd&Dck5I^_xPiTiL_>jqsG#_j}Qd2A+UfU!1s!KVaKHfdNgh{Dy zXRr3ip38nj?;T$d)X9~ONyExRUst)^4DGXIf==XN7OJGrmECJnna=)Nm{3%FnZ_i> zRtGGXV8zyE^&)AKzbN_o*5_$9d8;WaCsI1ZHUtFGczGk3TSl3mOs+2bQw4Z-z*FVg zTKz&+-R!gfzcZs2g z7#JHDol6cXQx&YyKl8iOZe0U6I{w7jSO(IF6K4s*|DIMg=uwnb9=u6HO%$~yG-GLU zxX=B0n2C9We(jV*(x_(M&L+`?>EoXQUST1w94&SW)>?ESLf1+zKOAuaV`hR8J*h9h zby!L(hI(V>lhuE|%hj4f`CGryPiqKqE7VNc74{MwaY0|ChA2JyAkAt}S(8RQPJc10 z=NVD;_O|_qxlab6RjTY+E#AuQOtFXYEGoTAr+lB!K;JNyEV6FSmP_9VwP3&>TZ$7F;MEn`(_rU}yqa%`mzjnAJvLFsf$lvi{k+JPeBg%rFX~_GcR`P{s8D%GBfP z&Z+V5$ju*J_EcQ2Nd`LcBm4Ug!;abR9Nwk~i%&Z6R386a$UCqEy~g9c*F?!B!j1)W zYOO`D=R#_($MpP9g34eMAt1^F${GteCf8l7^jRko<~|~OvYn=l%ZUQ^8Do2)@^>6S zz`0W1|BGBxR@7D*2SFA0>YR7guyCbR>>5(@PP&i-ZSdn#5EdhLrf8eomL(`Qp$Wr; zzB^M?4-FFb-G?M8>eHZ-()b0Xv&?;)R61)MhT>3bo{6xOjICn??w0Eke)#B7T9uIh zn9jlYPC!F*0xjmkeGPRJIHQ@?A4#I1)aL#NJ6m8F0Fd1L9e+G{n9WlQi+DAJa z9FSxc7~ej^?t0lz;f=88cmSzQzO-g5`B+N_Ep-q>WX85?>FP`EU_KKaDZI-@BM;kw zyMbipqMtcIy6dy*f+tiL)fENT@D;qn&O5J>@Z||EFX+-h{hpi zFY|5~dXrojsRWX)_?opP>&Ms`OBnsi@$mLQd?r~gZXo^yq%;ZZfDpslVx(Vmpn-kXM%?qM%6anEc)$3jUk2X^?zX(eW<3 zYW%%z3c6;63?W%+Hslate%4GB=c-&g!MX2~CV>uB5p+c!q#Xz75AoIG~UQ|2TB*%wOh71d(_oZk|@f_i~l zK@|uCkAPs!%*@Pl6ixCO-~rkrxQd-)!Z%u*N2Fs<=H1S&c6TK9b8{8ceSQ0kS3gCp zi$FGQVhE7jO6q(J-MbKa6BaM%L5bsrsMXgaG4Wj!aakMOCt14{@S#%tTBapy zBp9Xk9ywkaK+K;i&v@nG<;^)*W&UM;6)liQ-wi*GJPQ!181(%zBel_d<7tKr0axuV zOTP%?RC7rNd~*VjUvZf+c_j=a8hG?Yu=3@Q)$=_8FG#1jCO3-7$mGSg824vAT018A zFk1cBf0BNO1SpH$^CZNmuD(d`k%bDma-i4CxN?Lgj`&qtVf1o~f$hUOH|<5cW$s(! z7UNJwh4qc002TRdT#VUy^;fDeM$65XQsXCqR@`ad=R%3iVw+exq7hr}xts-Pp8!ON zD`w?^pi@Zi0wHFG8ez$LPmS(!gm7c253((0Nb7iH?B{Z(i=sHI_Njcl-@^?+Q&wY3 zfodWAd!u%RKoQ-dvu714Wg0c&ZK!>%t+vTnDRFt{apK^V^iU0uaPr87AcZG+$z8}n z9i_c}ig3IKXJdps22^B1tz2I$Vii`jJ`6m8y6b2%7&uK+YFJaP3O;$vZoi?lTh0A zhho9tncX?n)h|Xs9h}>-ff5o)8BOU_zN){zT5iB4_iRR})v_~YFteS*JM*FJaCLC- zC_Cx32RKQ0)azhTFSE zo$u~tXFOr?B1*XRh-3)A+lh z{`zFcQm!S6*lX5jcNDLxfA|}&PQAUZ0^s=Q!&nk>U2*YiXdnLNZKS8Xj*hMB6|*9Q zKye?zc2soqz1Q|3=fJD+Xg0uw4bHq8Tsw(6T4v2(QBz*Lb zB{uqihMN0GMjt&bc0yK!D3>Q|CgSOJ);J7sWEI53WiJ?A1zrVTBq#Q$T2Wr)e@$KW zSk7)ph_#06Z`gz5Or{(15CXy_>^@`%f8_J#Ab&3_#N-G)A_wNseO`80wLlIg?38vD z47p|39w}fyHi)vlw<2Wz z0S!uoaF%7e&TbqZWXFqQeCEE8{XmOSGMKJH)tXu_nrq zzsl%k_7jd@I?^;1r(`!oeX&f<+}Ag%AGYw&6{dL(JaWnys5;!PQk|~d(QwPMKD~1E z%?QH?*`OSX#dg&%1$(Xeocuqg-Z`q$H{2iInG>hUwx^oxCfiN+RFiFgvu#h-WbJI* zwkF%}GwYnQe(#^{R;{(~=YB4Ha9u3l`wC1p+EGnFA!rEBPZyQdDh*;QX9k774_V{Sk{EN)KB%2#ri#mC_9mb@v^H+MR|XFrm$tm1bZ(82Rg zM4F_HMMh=gPI%npQys?AaFD&y!}fw&G}?~d=fsmqzV5MB5yrFPMzM*@!2MLA+VMTJ zQ3oM=!NJGlJIp_IeG_XLaY9j{48U6yJNmfg{F{=A99j(J>y8y%RpUN1bOu!75G|kX5MumE(}9dfG=(*74#V z=7Zn;2GZcw(C1g`R54Gev?j~_x8t({2Uk1kyOpbjz?RjfJf zRMQaEV+=2)9yi5ifUe$jvyjUEOV+?Zk2$(Q73S3;berIZ_Ewj^~(vQ!ZDo-M! z2fFMU?D9sbS`Dt-R` z;A!EyV%Ft35419Ap})mSoUy^7iG44y3NyHD41CR+`nw)+ z?-+CpBAiwfqoAHITTs%XKOh+>fPz7XRh&;7k)hc`?-si%WK)=we_2NC&jl&m7Msk4 zN#zEG{zWVR`{|f@v47Ex+%GH9`j8g${pDsv&6aPr+0`XjUq{ts;+#KVj|Ks1_+&F! zOy^O^nlp(FJ%U~Ey zZ~Ye#_OCc*-DP`=x|Qzu0pcTB_2*@N?6&LbVb}%d+CSiHF)mQ}b>`wnDoqEkgNa1l ztC9qm-{RB+xu-fcJckyNS(jbG$B-u`69ZwL;XA}hQ#0496OrKz`!`io$z- zpbi2i&qam!&3T~1gMgawL@g;r9?W0M?pnvVy3*H6<1$vlz$DfX_As5^u@2Zvtoy&z zhgD)?LGE8plMG}0ys&D7*GH%^LfBc?`WXKy@ z+<+2W)db^)iHA4R2JuTbm#WLm(<2z)c>uX}W20Lk!o-_+yPvE^QGe63>e?5M&oA*b zyMSDNYJOpaq+362qSu35+YhyQVfp&R936!OZE4{bw0aDd;X=H^P^Zh=$G;f3dbo?O zaLHgYiLILr=N}lTy|Amo*raqCO#zT8kzt)NQPzaxcTfiaM~ve**anhB{xzKL56$dk9=+9F&uk=*;~R;y8_EltEt+my%50XMeHO|ax&Lf( zn44V2G;E>vMnN3=Ctzqj3R8K{I-Y}nd}s@gt;?_?yq?v!5M(rixn3=xAX!Pe*}nk2 zoAAUifW2inBfa#4(Tk^jvU}KD#>Ha>Rz|3W!TI#rp???WSAFZN+@3~`@xeF&u!J>< z@!*%VbGW>dxi2z3?B_R{*UFSBF@cphz!Ea^!`vKcqwDIhT zvd1!gpE-`~l^~#7N7BtP)U*^G83pZ_T&Usd9P`$hT?>lE~M}}1qJ^Abv+O5$!%&-Em`3woh ze7U>`ha1r_(}U+v@waa1&IH@1HZ4MAmt`q*g73o-R4?2Xn(7@Ex)*QMXdCV(6nZ6U z(yLX!Dz8mp`D&7)y$tttaEG?$J1lII%U3eyeTeo0Nj@;UE{eP`b~Ix*srTOi!pkrE z$)SyTSYGLr$4kjiv*>0CpVa4N6Uk(z%Ggl+ni>f8ut6!S=VqRZi=0lepo_^91F{WO zjdqqpsbjKcVM{L5#!qkGC@{kCJhGEvG%3XKf6;Rug<`pU`jm7kY3?T8n>WzE<6`q-qnasuQ)a6M3dwo^g&r)O=2e12vQz4HBVnkJXdrJq$ zDg%TOdY6iY(`e(m&B{a9#hOyDx)qN1PrF>947%*!j4vei7xFzdS8A#5u5i|vxo{f| zC|UNzuZFIFx*O~>q5j9Ir~F(78BMOp6=d$92r+1FH985m3q&ZTQJx1uP|KJT(QK@G zqPw-@jU#*g-YAM&+^B4m+2;K9)3qwV9%-MApL{PcRTdvIqq&v~ScF^xZ5cv7V1Qw^ z)PNDd;a+9UqI9c=0UAEAjhCCs%io*(Whg;YRbGpF83I^3_ac&h9 z-N5stoTn`l$!w{kBg`E9M;lhMG|t1Rf#xuh%vdpl#k?6wX^$Yvs`a0ME?KjL=zvon zA}_j>$hP?AuHt)+zmvE$Iduqp+EC1YAGq(|_^8=`sVPVFhXPNU)K+V;{ za;c$Rjt1ScBcZVdzwVLklC5WXt)$pfJ{G}k`IhazB)*yo)GyaHT4dBuw#_BVzSGTe z;2Nwo48~=>N2Y3;97h-l4)Z0c^?Mrfj5hmFjLl=4tLi;z9 zl~a>`)R#JENK@qd~TbKPs6bLApTx9n_- zEEfNT8>sVJmV{zl;`w8jX1pow5Q01JQETgruk#=#KE&7px&pM=@a>ieNpmM}K)8v$ zg6)o%wX;m22)9;uVmwVpr#13nXjjTOBJ}&plA<#}WpN1K3I^7|xCP>!mZ>`r!gX9M zJ`83{(feiG^&?SD?pkPmEaYx5ar=<2lbU$uaf*>Cv$CF4Av$9B=%i|@+&rASkwJ7t z3!}t>nSA!jlgalR3hVay#4l|yveMP%6S~eoC|Vwo4C%kFyfqe?W3%^2h)Ae&u%-4z z+i{|Qv<6EsXmz(wq)iYMHai`n=KW5=$*fRJrN?KAw>CD`9@H59(&=*##*zit`|(TZ zEs)nYi$7*zh4pTT+g8rtc=Y*L`K^hH`$}>&<6HXY%aB%y&G~9 za}NFF*_r3b^r*}Q=rSpjicmrrZ*%J$B@m+G~x*)0XRkh+IUUZN`5y$ zOhlzf$X1(|t8Vd0@ackM{=p;MArlQhKNTmX-sH?lqEUop*)5+yfB&nzC z2y{>o=y5FQ6?1r)Zu$z7TM|wK$m##63&`bRM#ebh7<0u;jc4&g}z+2=JXA?#GD zCh7a)i&o0?51+xo7S_VV2TG(*A~-F|4)LoD`xqO|(T4^(<3n3*e5~*qs6G2rL{(@kZ+az2OHGQJnb`*Pv zoWhF`_W*l;%Wak$S_x2A$QOJU5*2gQGiMVP6({{Whd29=bPv*Xz1TV=UDDSU+EKEu zxt0ZF2`$jXqw`Py{Q%@>%Kt)LQG0vp@wVJ}nc` z(+REs+vuRbG{5zAgnp!^4(rV_RUB!hQGS5Kv-BbG+@DNdoIWoI z=SGKb*1qf}MTfz_eeb&VH2P^`sLsz5m81ce0OJRAE4=f}z^KfPbrRTAv+e0xt79F; z7VR1b2>RImEF#gmKDFRqJlLH{eLWp7x*hg+QnlQHDO$56vCQccq$*WCbw!R-B5uqi znir`Y1G@+yA9J3lEnFT==w-%GG9MLf?R3u0YSCdjx2ea5aTTe?aA<=#cO18kV!xoJ z|I-2lX+0p(Tg~AxR)>a4T(bc@!6-xvw2(^y!>}MyrdU)|Qs;WTHxwGV6Hj(PJ^yfd zpcL3t)p!ftSb`v-L-~2Xn_@EdW&6zxc%!VASsXb1+5k47v^-te>Xz-f#)@&Px$s`9YVXnM*q zJYUB;5Y}~zfBLVg+d>t-m`g)pQG;~5xY^-W* zJ^zoo@rAhl!U!QY(&~wc^lHACW!uVu+KyjU?wn_;qI;LXv%8;hh9uLmyvSKkb=JE1 zAIXhwNuO=5owv&O@w*Nxi_}ARY0*_(*{zE+L@^cJdE3^Vw-}^78eJBt%nV724qaWs z%qTgY-)no{vXjd)L``N`+Ve!8Frh~B$SC{DAu|W#!P?5Dn2!7)Ho0H;l0$n&rvDvL}d(qFlK z(9@mJH0zDxgJ6@@9eIvbMO_>9P-Rb_IMKx0ra4(?r^@M=`QN@I%k3i{R_c>(mmMdcpvqHo8otf#{fQ-z$Kr9n)yovdjPjVzRKK1o~LkF4Z>-t?s z(@|g_TAh})&Sz^U-r`QL*dac8odmeQb3c^1JA@p6b3#Y3h0qi30BU0ql0F2WC{C30 z|LX$>@@L+idZqGBJ4lP_*(k>M>hT0QeLec}V9g47 z-Dljv>xD@ALCJG?X6oNZkLL3>JvzdI` z-72!d{Jmgu7KHxPKf3xX3>A#r-D!bDHzR%BDLQ5qFw00p>{bld!C4+Se0YL?efdy( zIhi}1K`#L<_&1yy#msyD_$4$lRaQJCJ$f@@35mV6wK} z3349ngcoQ#(o0UCApaWbSjVrbw6MpKW?)`c*yYVH))fN_T{juH;bGu}MBI^^Ofp)`n|ysN@J$UhJqYC^X+HAOK3q7&&p&Y78$gkBkH7%bBz zH5*Zg6$2(?UnAZ4AcdgG440vKP5;yR5!&w>Y1?Zr^5`yCJkvbkgb~yww;f^l{7j$eA zxwcubs%lOxe-Tm4_tLW!Ek{Ghbvh$Lt6@9}EXu1oIRKi+2T_;kt=pUj_IQnmy$?3q z_+m3pa|VpjQL*5=yLkoYJBnvfMKd1kP;kZ;o9>l=HRrsB9=SU>tt>B(6%C2F!rlcA zjD>h+HdG`z=ML)1I2&N3{qk^>+~B1T6t+U8^cyWN{7HE5vT>gKM!0zqEFe3=p$g6K zF4$m*2K2esiN{KI-N;HE2B;#(5mkxuNOv5ZOt&^LBs-7?KwUGu&6@{McJbGCsmh+V z^T+N?M}_)CjDv)PH~SW|{SUft*UHDfx3#sV(qbE*E~yV#w-xvXxwqm*M>>B+TqN(K zV!KV&vh!Vnd6%~apR~`nw6zzmW*JYPk^nhcRHbFRepAKaAB=aDEmu)H)edI)!XXXb z^nK}B_f(7lKybtf$TZ&C3*dU$%U8d5*XP24Q2#X_5M7iT#ylY;irG#7dJntFdzTq^ z+ArH4AiRie<@t49yZjEfZDD1luM)Ph6d7gZ`qQ|xbc!B}IIECeU7Hq-YrWIzYyo3Hx}XZmf?}h=its^$_M54P z_C91gEmx;rixA}7yWnfo4mL!l+n8(P8JrZc!pIexm(JECgJQHG=mo~#6Duo+&%u3I z@HNy}b3tC(jUPIc(tp932$;LR1Zl(utu18p#Q(MIMnB}|@4r)pYS8*W ztHNK}{QdzUD@&dj_JIgXdgwYvKbCcYN#=*yVR~McD`?gV*orU~cdYx|^?l_$|+`w6s*pX%N#DBDI{p2u&Yys+3;uN8fH> z4#xDVj~=wkLtA0cp>S9uKFL2lAYF8xvB~F@@JQE)`EE=-@#_Vz4f{wZ550Q@?9`OA zLs!KUJ+}?+qFFb>?rnnJ8{I2WZ58kN7}37*Et=7haKP7+=jdDqUUI;1qYUUB=HJAb z7E}Aog~FugATk8^2q8rlB$vdLlZpM;Ly1=z1lB;u>s zN-nGB@TZnm<3 z0qV6QTACg{qXQBts*u~HWZA1(r$s_Ha&#@jC^e)iL`i^wm#G7+LWGNV7zjqgWD^r3 z1A@E8*yrm(b=n@S(-jPe1vui!pa*9b^Z7n_axa#6bGVEXWZ$?oXfMv(0>*{_#h zCYwqcB$m=;Wo3;{gD)D8Gx_vIpgND|;Ap5z6uoo+3w1zr!NxkwJ&30hjQIGq z<%Ir48uAf2hwJVDJwSmJIUpY4#NHtsFuQ$06N1W0HXb{crn&Fv*NtSH&=dcSM!gNs)l-qgm5Itm+R8}Zfgu`}RIoLQzTeLv^PR?@qEppc zq?&EaMWvH460}Rx%CBP*_1K(Pg!JOu_z++oy}yC>iJv5Q`6}>ipsfM+Wh606oiL3N z-T1!MeV*Od8_8A4W=a1ypNqcO3StA18J!6(Tib(LU&WX!Q;2)Ob_&ZB+`u)EL4+0c zWxGyB{pQ$GAt@#50Gt8v%-IbAP&C9jYMWe`>>%LJB_h=SNM~ND%NSboqk{giLj8Sk zBs3AB((#fJB#?!#BU}~-Gy99*j`*pfJ&zbHI9};-ja!SXrtKO zUK}ZNPfkbmPS20^;o@eU`af^|$97T)SWQjhpQo0wo>8=t|fn-%uey&g>CS zHUJIJN`}}&ttzxFTh?GA8jPl2NJ+6=aV#VHXDj^Y>EBu)lJxN!X#L7^`VX=5I>e$9 z@{a<}{IS31_lRBOg5mHPrz*K2Is01Ad_irq9y8#cF0X$+ z`5yz8bOs}G`1L?6d&O8@)+4fdz@T@*krhY_$u#zEph*w&jjXJy>KX<#c@#-YG&zsU zJOoc*0ethC=CEeP#$9!+>d`sR0yzwttxj_ldE0wM(X>?3I1cQP_6cmOa=3Q z=)9O@0T*c#hKK|Z2X3)~PW>;OE_WydW;~?yVBnf^zqKZA~QG+~_QHeZV2 zyOgjiWPJWqM)^MyHUDofK*CZYMz3hWQ|wg-Kz#pCYP9SmpJwiv7zg-WerV##t0KO% zFIy({a9~(KO40F=pEpuh31|ZTs!CH$b$_XZ*9~NejXJLmK2sjtQAA9`M;EGc8d$kQ zgMjd@4?rr*NV!2;;22%Z0EuG#X(97d*J7(HLi*ePqXNS6<`Aq4W(g{k0U!Iebd zX20zR|FWgi$#KsF;Yfc8;>eDl*B{T*r@YUNR;lz=%N6$liIh#(e&f351e9E&AhU~* zX4%3kUJbiW?Cwo)JjUTz#xvB_oiz|Vo(^yohF^FOcvBk2lYSqYFwlE}r?9rlKYpab zJmTKDMdf+tGUvVUjD}GZ-Q->u1_$F+wsi;k^~cSFoHokK;*_elT)P1q>jsi!%Xu^O zvse%Nmq|pHlX9pjAkcyVsn4J=UnyIW(3ksrn@@@wD;5|(**~(j$^n#2VNW2>fIiHS zC5#bxCxSt65X~LD8y{L4eD=c!z5Ga?oT7=1rT_9OpG;cGrkdSiMstBWJi9PY zUDqwwna5rX^z}4(D`~O@&6RS9*Lg0hR#JgStXUGt8yn6FY{mBB84juP=3^Bl zM=)-k6R8w{h@Lr=kA@tu>n`2jBy(tKPtAVW`Yud+RxqkV)mg+_MC`$VN)%`P;tpXm z;Vs^+0gxWmiS_9d#vUd`AVJBNFkhgoTHCyNF*<&v70fF`q%^-MHdNS{wYke)FZuu;xf4;XGrX0jL& z7-6DEoLSS(bvA;`i2*t$@SvzWwQ`6JGoF~^1PAm?Y9OyvxqUFV?uNPluH5}$3&?2H zALEpDT1U?xl#s#ihV_@}Kc8fN!wZJUZd6S}orp(VE>o62*>)WAg#oVhLO_)5chyr0 zU~a*sI@iK7yb+(vT!UD*R0M{9HKK`G zmv%KdnQG$2uV_uyWW)z1^LP}RaZN%bzWJpeZw^38irGocl>b^0@oQ6vL;~W+;wXD!RudR0qoYmKMC51XNQ`{!2i&SZjr3k0UwgR09bzhISe07S42Xz>J6IxXMv?! z4rkTTH->A@8-CCKvH0khe8Eb>0_0h2zl3ehqT_#wtXEYYYA_|E&uX5PRYsNVa}cdv z?0go!B|$f<=KKz=6XRCpQdGCSGP_@$&~5|hGizYpFm35o)f($Fp3)l`0Mk!MS2h0# z+-k-=c6B=M)1(jZ(Ew==XG%tpt{>`6FAsGT;w<-(2r&2{vmyptGix=nM@&>1df_Ni zYKT_5@#`#;m3yJ5d5JKzIEUb84-HHiUTo)MkS&Z4xIr?`&d!3FLp2nD3yX3vU35>IN#)}xoZ9B%Mk`6av%DD32ci$t}- z#E7(5m6`kFp8{oh}HX0>M$4ohz@rugFI!Jl;tPq8NBSsS?fU2a^We`rQ2fWpnPW10ctQRWs zel6nK=21xf{x=JQjr;r=W+s844~l|A?wJe_b`sou)VW?<+0Q$_Kk8RI{&cWlO_NHH zQWiqf(PmQb>qk1R-0~1M70WB6RYdy(v!W`rxURNHxcJt3x<6QG(XHO{`6%eKC^igw zQg~ZHPk4C14iili1)CgOf=t5L827~{v4u3F9Pi>y8rzMJ01+RNEZZSiUuxFm06cm4 z_SDySoAITeLa}El#HqK|j+Kq09Wk5ZWReri*}i#Ok>v3$ECR6 zO&&er8W$L^R_9;EabRySd@8?)ZGvI&T7qnK;0eXafhN-GOx$d@(KPhzwbmCg`xdC{ z`H{;#2Y&q3ekxB}NEZcyLVL=GkI`AR_P%X3_3Cn>Dl8-RUi723^n?L_|KQltZQ>B0 zdWeY@M|ic)8`M^}j>-8z>Jzs;wKx2jn9Ja+)P&G!9>BY#aDo9}Blph=_;GA`|F;J% zy5OO6_vdj+Quw7L<8w^$hFoq#tZ^mYrqQ7gPr)`bqJ1E6Djed)V(J*Xy;wtdqhU1B zU(0fuEq9gQ^=oi#v?uK8*&96%>^0z4Zi9h+=w--cj|fAKMap~*)=h6u&g-{MAN+;C zaQ!vXCSp0eLyzI9!k{*zbE$vry{*|Cihx{5L&6Pe3o7>~W(PxbEl$cb2?1uOY zZT?s6Bi#Br-94a=0h?O;OF)Q6$0-jQ=gXK0+xf}a!R~6xrQc>m(2;kQ`%rr7`-Tg? z;VF$rVIr%9A2sQ<7!qGc)2deeJ;A-T&ah_j6;0vDcZW5UlK4>5`CG)t)07>Ut7cM7 zR`sZFTVXL+UNl$M){-3+?cNg+Xs<)7We(?ELY3D362_igZB^$G)qJ8C&!9Q1%MQ#i z(O@aB-qCdEg9mD5;@jGvETVm|CibL*7!D~N18M8viG@kP=In%m8rOB~c^~$%Z$Rc; zMcibf7*aIOw=a`*bB}u$Pe@2VFLrt-^Yo42)i2XKvmIW`aJSu(W7JYc>BivSbGI*& zh=PKzl&p;a!otil2~R6oi1lK zgk9JuF%|e3@;&o2naw=s*6!7I0z84nC4#v%twk=KP(}urafs^2aDe*5Q_>Nln;I`T z^S=@xIMr%Cm>PKCm7Ddw!6}?#8IrJE3;4mTcUexiw%>-8$!JJC@<2?WmsXx>!e>&D zHK9_(Pokhu39^6N>+ez6$7(MZd79WiEz~?I(!ewuAPM2eAF;f6_@KEAG;FbQY1vce_R@ z@dFp!4WkERA46i-92@f84mzy&f$t0kLhtub1c@Hn4`!{d8ymFF6aEO8sHDH#4i)3p zR*f}gx37znQHi%NWZ3lQaol$W%bWZ z71_dM%ABc^Vsi7;&Okhi1|^#jx}7*v35JVj=$#M~Y)D^wRsX{9k|2xY)RXvaYiIZOfr)jK-0i<(K0PwG|IqZuxrQe{Q7LP{RU`w0 z%zwpO(Gn#MOqqIb{O`Z8)S2z?zmHWbF{sLfcc zpNLl7JbBLrIoSSc?+-G7Jz73^PmjGothi`=BkBy@pCSHjCE-z*DaW|9qLZY){LtlFYt)~0@g;tV=!QG> zsRv*u+fx!qWNXpy6=d|5J3gRbER#C1tyq`{(dJ6Nx2|?HL&~q)u3pNwo>On~W1aj+ ztdA(Pc*4|wGw<_lTr1(|yQDlyU&5+?kHpKN{^w!}4Dy?T>EvSmatlc^7Kh)%&sB=m zqjb#f90(dfrm=-#c-bVdpS*D}3Seu;ehq0a(||`uK%t5!W;`+&QLlAcjZ|-&m^j${ ztz3%fs?f4Y^@uX-4j%jy>P33?y9y_iy7MO~xUnG6xJae6=Lp_opPrs0m(p5clZ(uv zxk(3y_7pMpj(C3x)3@u5gKcu`nPTM*v~d+?eay)io@7>yVp(s2eUD7#*gNj%u^{cu zeX_TU={lszznUrceQ?C@Y-Cc>C%7xDKil=0;FWfbMc6wPMFPeP?m6Wacg z=E?8zUb|}2t=L(Y+K1oueRGZi(mt$su*mMPmC~zc^x);&NJqiZy4_9YjcsKE|9hfx z85h|Q{H3M;8ssHnO*6cv_ySXdyjI)Xdq~6jYaGXBQkhlb+3fs~^19rb4!rk@A6)tK zg^j`C(2LY(aQsq!LrZ;FS%}=~D$DKcc&to&l|?|P^?m!FA%cQCwaTk&DuItETxF2o zaYpr?XZ9D$Nl6Ov15YhBg+N{jBqBlE1Rfs&g>5@G4UKQL@LPC7fTe)NUx?oC<52Qs z`MrB@GvY|S*5|jY7TsoOf1gJw#CFRxhth2x$pO=O!tj~Qt&J?Oj5Gy4oB9$glnr-D-Q}@D>C@1W0Vq)A zXw?V?uMQF2O8r?5xGycBh@6X{5;4WX@ib1mo|@ht|8Q8LkkD(o@xO|Yj6&$_TPTt1 z6nt_U&F9!kGNbv!_o|dnzvVqf)wu;PuxBXsv;PU%4~f4b4ck78lEGvwW-HA+gm~ao zgGk=ibUH&aax*PSmf62f){NC(l-I8sc|awHKoa>w!pw6IcBZXAq>O9DPopD_%cGus zi6A}uXSVo3QN;1_y25Gelk#M{L(XqI#INntFYnOijcjfYC#(CV&WAfqgI@F{e#d-NlM3LN;w38<5XR|G$vVY;zLGniaYnc>e`>rp>7MuAQZ8qlpl zJ4KGbUSm!8fgl{1uaMpF5AU6k0XPs}A%7rX&wg@+z7o06^y+r9DpbWq(0b(_TdzD( zn`_B6n2GUqC97R^3U&w^EL;{V`%_cliRNW?j8SS7XO16AJ$ZZ*x@O2DS1=R|v z=Pt!i{iR!_9^%4m+I+9({qfQl-K$N59U&VyOJ6EHYr4@d)%{CsK0f!y6>#4-bgqTi zflp81>|A{L?jX9>6)in`FT|Wg+{O*ao*v$==2BYo88=CsuZ;?4VZqlyz_$+V8h!bL zdfz*QxU+$3hu9UZwrbKr>DEqQa9np9@Wp>Pts zHil0+QS&=%RwE#QcCowKW3nOV+DPlW4^213@^NDx(kDMgbFf$YH~miOheRdmG~sRS zWk12+)aYg8=`iLl&~hLxtK4Nu5t8&{BNPVNmH?Ir9Bk~}&yff$^7w6x*lldWe;Wz@ zy7+~irW{SO7tYaZCM#&fYdi_;w8SCU79S-KWM_1i#gD3TLJQAwtA-&kK`73Da;vH2 zw)=FCAl4M9LX=;uJ^c%6<52Qt6m78%me0>`_otv**sKPuYjn2W(c@DtA-M21?AI(f z`m!Ag%Aw-x9SjDLt_KN-#D`kDMX%YQ!uGI%kzP?nn5E1A>e_&Lu7&X6d9V5GY}@iRzSRp~q#`h>5xx0H z93jaX!w-JZ%JHM$e*dPTk6x#*jWKREb-y72!pnJAx@bgM;y?Zj`?WuToUz@{Ivp60 zTx4#w>jSn%>$1DxS_*ot3p{zTNcJ~cp^8P1wa|%_$Fta$>h81%<+oEQG$iU?H_qIywX!!;D zyeg(>sK>dks%dSC(>2*Gli9SfZ9_CGoKRGKrsM`AJ|1D)zQpJw)J}ERlZh}O|gmZ^=#xKHU!zC=L@75 z>tOp55f82dG3SSs)P9VA%l6vP*V#5UP#4b+Yl!-^iQm9BAzC$?+cUkBiYHh;?^ZZI z7&Y!VQ<5~kl~HtHo^FN!9%KabQ0_P>QVK zsd=L)N&&xffykZQGhV3h8FB-lZULjG2m+1!t;YshyOH7r6ot)~eVEShHmv!Ul3j=&O214SA z8SGxYiaLl^M}UFi!paBEqK&R%z12|eRUn0o2@^B~ra{qrUpTmg91QezyfP8hiwJWy ztcgywbaRDjzDB|!%t>~Q1^9IS1>eN)3a=0kt7Cj@tB!rOfi{Hlnhj#|nl*XN!~ubC zOE1pwu)TR=EVRQe7?$U;DvWUw5DYZ^PkfC+1W)_G?U)l$u-hJ=`SXn8uHDrWf(uWO zJ<77flaPk&)96Cm>A22{@nz`x=3WWobw8zpGG4r`oTxG^#RQ?e|~_ zMWhCVZk?7cCs;N4OwK=!drq^oBf+c|Tz_Eu|C-zhX=aJ%E{}y-cOJ&cnZ~RYT^IE3J10Rc_j1! z`VHfCO`CQCABv)X!~a~+cnreE2ojwEm}Syt_f{8A3d#N;bB-YzOWmD8 z2y$^s$@ha5V?=kQQy z7>A$2gYiu%0&zSz@$wQ9qeT8(4ibTGuB|f?jXKtVg)7+!omgI58c%D2y^i`toe)_{65yCHs- zXdiB-PHsa+qeG;N>6RcVz|UH^qw!}1STNY6`Ps=Yxi%Gtbf#CY0$C)rR!~16~ zuIru4&DBzAlBbGf$33B#0{$HUaaHXC6XSq;@EU|Oyse2f*y*{Lg2L!~PJgO3zvzeE z%;VGXCDtr5GE(*U(@HS@rRbcwx0KA=(j8Ep*b70rLpv=&ZZSM<_%F2R06J z&PZTpII*=n1iMN%&bJhxx+blW>b{vQ4A8IB)I*9p zGr)S|BVnj9SuL|>^=&M!SRy+sZyG*!fi2!|g)4!gnE7*Yto!A4cz~^$H{`pQ>8|uo zR^uhl0+~O!aYjution-SEK}^T>gpYwEt3}0Xw03BBZ=7ka65O1f_(>ASgPF^08?5+*87CuWMiWzU z?3?1hy^UbqL$3heSBHKpq6?ufM;15q)x!F+p6035jnBEYnEip2Ojt-jajNDsp`gC7 zWUKBO6BzO?=5VMvDZMO5H{XX~#;gDq=d&86cZ9rG8NQ?ymI&ixkTg^qD4B^r2 z?&ZCsF>}|l?aDai@zYOl2%dI4pTiorO)OWLy{<|3LPD0GY-w{F`Z!rPu{U&&OMw`v zsj+9!KXobfV7`!$tdjKfdmBAiB_-eu*nO$&Vo7#BvUsVAh_u|U@!6@0l6msYrT{{; zKv)(Vr;FrzX70aI0w{{-uWX@+0;|R3Iyo>O>mY1b=jZ*X_q=9t%W@(^Nl0U{L9aQX ze6(G=Xnc^}Ibd0l>k?6;TV1_{+kjMkh?wEOd(;0PS8o*+SF~(_0>Rx01Pc({HMqM4 z2u=gR-Q5YngF6HW!QI^0SvjLdt-deXmr}EQJ-pvlLg?}+SDZYKz@h*V_}GE?(iGh zJ#;%v@G8*O{?5pT(>K$}U=Awm+OwfrhQ~=3@JHR}

w+?@Oi*$ybCW*)ez(}Ah++812IQH7 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 DIJW_K literal 0 HcmV?d00001 diff --git a/webapp/icons/qr-code.png b/webapp/icons/qr-code.png new file mode 100644 index 0000000000000000000000000000000000000000..5a8a33ea3d8025c501e121a42dcd89a345219008 GIT binary patch literal 466 zcmV;@0WJQCP)QnQv*prOrtj5hoI5dceM6ef`2a>=?tfH*M7WeR-e%)C zKjWXv6AB|_9e=oEvf`-bLQ*`A<=lX|$n8o(VSlU)CWz}a&i(agrIK%HPJeo16it^+ zwp=VD{Rp2qnA<;RAZ2y|%Og(X+>H^S>MEiHnXSi|$hiws-Y|kCLuPGcQLv|R?#4K_ zuy`}3We!bxpoyG&F_tr@GPf$bzP|oom(ht&-SS6lPvhL~e`=oghX| + + + + + OpusBike — ProActys + + + + + + + + + + + + + + + + + + + +

+
+
+ +

OpusBike

+

Bosch Smart System Monitor

+
+ +
+ + Disconnected +
+ +
+ +
+ + +
+ + + + +
+ Don't see your bike? +
+

Web Bluetooth uses its own pairing flow. If your bike is paired in iOS Settings, the Web Bluetooth picker won't find it.

+
    +
  1. Go to Settings → Bluetooth
  2. +
  3. Find "smart system eBike" (or similar Bosch name)
  4. +
  5. Tap the (i) icon → Forget This Device
  6. +
  7. Come back here and tap Connect via Bluetooth
  8. +
  9. The Web Bluetooth picker will appear — select your bike
  10. +
+

Make sure the Bosch system is powered on (turn the crank or press the button on the display).

+
+
+ +
+

Share with your team

+ QR Code + +

monitor.proactys.swiss/opusbike

+
+ + +
+
+ + +
+
+
+
+

OpusBike

+
+ + Connected +
+ DEMO +
+ ProActys + +
+ + +
+ +
+
Speed
+
+ 0.0 + km/h +
+
+ +
+
Torque
+
+ 0 + Nm +
+
+ +
+
Ride Time
+
+ 00:00:00 +
+
+ +
+
Total Kilometer
+
+ 0.0 + km +
+
+ +
+
Mode
+
+ OFF +
+
+ OFF + ECO + TOUR + eMTB + TURBO +
+
+
+
+
+ + + + + + + + + + + + + + diff --git a/webapp/js/app.js b/webapp/js/app.js new file mode 100644 index 0000000..6c2fb19 --- /dev/null +++ b/webapp/js/app.js @@ -0,0 +1,349 @@ +/** + * OpusBike App — Main UI Logic + * Auto-detects role: Bridge (BLE + upload), Viewer (WS only), Demo + */ + +const app = (() => { + let mode = 'idle'; // idle | ble | demo | relay + let rideStartTime = null; + let rideTimer = null; + let demoTimer = null; + let demoTick = 0; + const DEMO_MODES = ['ECO', 'TOUR', 'eMTB', 'TURBO', 'TOUR', 'ECO']; + + // Structured speed profile (60s cycle): [time_pct, target_speed] + const SPEED_PROFILE = [ + [0, 0], [0.05, 15], [0.2, 28], [0.35, 22], + [0.5, 30], [0.7, 25], [0.85, 18], [0.95, 5], [1, 0] + ]; + + /** + * Detect iOS (iPhone/iPad/iPod) + */ + function isIOS() { + return /iPhone|iPad|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + } + + /** + * Detect app mode based on Web Bluetooth availability + * - Bridge mode: navigator.bluetooth exists (Bluefy on iOS, Chrome on desktop/Android) + * - Viewer mode: no bluetooth API (Safari, Firefox, etc.) + */ + function detectMode() { + return BLEService.isAvailable() ? 'bridge' : 'viewer'; + } + + /** + * Initialize the app + */ + function init() { + const detectedMode = detectMode(); + const modeLabel = document.getElementById('mode-label'); + + if (detectedMode === 'viewer') { + // No BLE — hide connect button, show viewer UI + document.getElementById('btn-connect').classList.add('hidden'); + document.getElementById('ble-unsupported').classList.remove('hidden'); + + // On iOS without Bluefy, show hint + if (isIOS()) { + document.getElementById('ios-bluefy-hint').classList.remove('hidden'); + } + + if (modeLabel) modeLabel.textContent = 'Viewer (live)'; + + // Auto-connect to WebSocket relay as viewer + startRelayViewer(); + } else { + // BLE available — bridge mode + if (modeLabel) modeLabel.textContent = 'BLE Bridge (uploading)'; + } + + // Set up BLE callbacks + BLEService.onData((data) => { + updateDashboard(data); + // Forward to WebSocket relay for other devices + WSRelay.sendTelemetry(data); + }); + + BLEService.onStatus(handleBleStatus); + } + + /** + * Interpolate speed from the structured profile + */ + function getProfileSpeed(pct) { + for (let i = 1; i < SPEED_PROFILE.length; i++) { + if (pct <= SPEED_PROFILE[i][0]) { + const [t0, s0] = SPEED_PROFILE[i - 1]; + const [t1, s1] = SPEED_PROFILE[i]; + const ratio = (pct - t0) / (t1 - t0); + const eased = ratio * ratio * (3 - 2 * ratio); + return s0 + (s1 - s0) * eased; + } + } + return 0; + } + + /** + * Connect to bike via BLE (bridge mode — phone connects to bike) + */ + async function connect() { + try { + const name = await BLEService.connect(); + mode = 'ble'; + rideStartTime = Date.now(); + startRideTimer(); + showRideView('connected', `BLE Bridge: ${name}`); + setDemoBadge(false); + + // Also connect to WS relay as source to upload data to VPS + WSRelay.connect(true); + } catch (error) { + alert(error.message); + } + } + + /** + * Disconnect + */ + function disconnect() { + if (mode === 'ble') { + BLEService.disconnect(); + } + if (mode === 'demo') { + stopDemo(); + } + WSRelay.disconnect(); + stopRideTimer(); + mode = 'idle'; + setDemoBadge(false); + showConnectView(); + } + + /** + * Start Demo Mode — structured simulation per spec + */ + function startDemo() { + mode = 'demo'; + rideStartTime = Date.now(); + demoTick = 0; + startRideTimer(); + showRideView('demo', 'Demo Mode'); + setDemoBadge(true); + + // Connect to WS relay as source (so other devices see the demo too) + WSRelay.connect(true); + + let totalKm = 1847.2; + const CYCLE_TICKS = 120; // 60 seconds at 500ms interval + + demoTimer = setInterval(() => { + demoTick++; + const cyclePct = (demoTick % CYCLE_TICKS) / CYCLE_TICKS; + + const targetSpeed = getProfileSpeed(cyclePct); + const speed = Math.max(0, targetSpeed + (Math.random() - 0.5) * 2); + + const baseTorque = speed > 0.5 ? (speed * 1.8) + 5 : 0; + const torque = Math.round(Math.max(0, baseTorque + (Math.random() - 0.5) * 8)); + + totalKm += speed / 3600 * 0.5; + + // Cycle through modes every 15 seconds (30 ticks) + const modeIndex = Math.floor(demoTick / 30) % DEMO_MODES.length; + + const data = { + speed: Math.round(speed * 10) / 10, + torque: torque, + totalKm: Math.round(totalKm * 10) / 10, + mode: DEMO_MODES[modeIndex] + }; + + updateDashboard(data); + WSRelay.sendTelemetry(data); + }, 500); + } + + /** + * Stop Demo Mode + */ + function stopDemo() { + clearInterval(demoTimer); + demoTimer = null; + demoTick = 0; + } + + /** + * Show/hide DEMO badge + */ + function setDemoBadge(visible) { + const badge = document.getElementById('demo-badge'); + if (badge) { + badge.classList.toggle('visible', visible); + } + } + + /** + * Start relay viewer mode (for devices without BLE) + */ + function startRelayViewer() { + WSRelay.onData((data) => { + if (mode !== 'relay') { + mode = 'relay'; + rideStartTime = Date.now(); + startRideTimer(); + showRideView('relay', 'Viewer (live)'); + setDemoBadge(false); + } + updateDashboard(data); + }); + + WSRelay.onStatus((status) => { + const wsText = document.getElementById('ws-status-text'); + if (wsText) { + switch (status) { + case 'connected': + wsText.textContent = 'Connected to relay — waiting for bike data...'; + break; + case 'source_connected': + wsText.textContent = 'Bike bridge connected — receiving live data!'; + break; + case 'source_disconnected': + wsText.textContent = 'Bike bridge disconnected'; + break; + case 'disconnected': + wsText.textContent = 'Relay disconnected — reconnecting...'; + break; + } + } + }); + + WSRelay.connect(false); + } + + /** + * Update dashboard with telemetry data + */ + function updateDashboard(data) { + document.getElementById('val-speed').textContent = (data.speed || 0).toFixed(1); + document.getElementById('val-torque').textContent = Math.round(data.torque || 0); + document.getElementById('val-totalkm').textContent = (data.totalKm || 0).toFixed(1); + + const modeStr = data.mode || 'OFF'; + document.getElementById('val-mode').textContent = modeStr; + + // Update mode indicator dots + document.querySelectorAll('.mode-dot').forEach(dot => { + const isActive = dot.dataset.mode === modeStr || + (dot.dataset.mode === 'EMTB' && modeStr === 'eMTB'); + dot.classList.toggle('active', isActive); + }); + } + + /** + * Start ride timer + */ + function startRideTimer() { + stopRideTimer(); + rideTimer = setInterval(() => { + if (!rideStartTime) return; + const elapsed = Math.floor((Date.now() - rideStartTime) / 1000); + const h = String(Math.floor(elapsed / 3600)).padStart(2, '0'); + const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); + const s = String(elapsed % 60).padStart(2, '0'); + document.getElementById('val-time').textContent = `${h}:${m}:${s}`; + }, 1000); + } + + /** + * Stop ride timer + */ + function stopRideTimer() { + clearInterval(rideTimer); + rideTimer = null; + } + + /** + * Show the ride/dashboard view + */ + function showRideView(statusClass, statusText) { + document.getElementById('connect-view').classList.remove('active'); + document.getElementById('ride-view').classList.add('active'); + document.getElementById('tab-bar').classList.remove('hidden'); + + const badge = document.getElementById('dash-connection-status'); + badge.className = 'status-badge ' + statusClass; + document.getElementById('dash-status-text').textContent = statusText; + } + + /** + * Show the connect view + */ + function showConnectView() { + document.getElementById('ride-view').classList.remove('active'); + document.getElementById('connect-view').classList.add('active'); + document.getElementById('tab-bar').classList.add('hidden'); + + const badge = document.getElementById('connection-status'); + badge.className = 'status-badge disconnected'; + document.getElementById('status-text').textContent = 'Disconnected'; + } + + /** + * Handle BLE status changes + */ + function handleBleStatus(status) { + const badge = document.getElementById('connection-status'); + const text = document.getElementById('status-text'); + + switch (status) { + case 'scanning': + badge.className = 'status-badge demo'; + text.textContent = 'Scanning...'; + break; + case 'connecting': + badge.className = 'status-badge demo'; + text.textContent = 'Connecting...'; + break; + case 'discovering': + badge.className = 'status-badge demo'; + text.textContent = 'Discovering services...'; + break; + case 'connected': + badge.className = 'status-badge connected'; + text.textContent = 'Connected'; + break; + case 'disconnected': + if (mode === 'ble') { + mode = 'idle'; + stopRideTimer(); + setDemoBadge(false); + showConnectView(); + } + break; + } + } + + /** + * Switch between tab views + */ + function switchTab(viewId) { + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.getElementById(viewId).classList.add('active'); + document.querySelectorAll('.tab').forEach(t => { + t.classList.toggle('active', t.dataset.view === viewId); + }); + } + + // Initialize on DOM ready + document.addEventListener('DOMContentLoaded', init); + + return { + connect, + disconnect, + startDemo, + switchTab + }; +})(); diff --git a/webapp/js/bleService.js b/webapp/js/bleService.js new file mode 100644 index 0000000..a0d3caa --- /dev/null +++ b/webapp/js/bleService.js @@ -0,0 +1,141 @@ +/** + * BLE Service — Web Bluetooth connection to Bosch Smart System + */ + +const BLEService = (() => { + let device = null; + let server = null; + let characteristic = null; + let onDataCallback = null; + let onStatusCallback = null; + + /** + * Check if Web Bluetooth is available + */ + function isAvailable() { + return !!(navigator.bluetooth && navigator.bluetooth.requestDevice); + } + + /** + * Connect to a Bosch Smart System eBike + */ + async function connect() { + if (!isAvailable()) { + throw new Error('Web Bluetooth is not supported on this device'); + } + + updateStatus('scanning'); + + try { + device = await navigator.bluetooth.requestDevice({ + filters: [ + { services: [BoschProtocol.SERVICE_UUID] }, + { namePrefix: 'smart system' }, + { namePrefix: 'Bosch' }, + { namePrefix: 'BRC' } + ], + optionalServices: [BoschProtocol.SERVICE_UUID] + }); + + device.addEventListener('gattserverdisconnected', onDisconnected); + + updateStatus('connecting'); + server = await device.gatt.connect(); + + updateStatus('discovering'); + const service = await server.getPrimaryService(BoschProtocol.SERVICE_UUID); + characteristic = await service.getCharacteristic(BoschProtocol.CHAR_UUID); + + // Subscribe to notifications + await characteristic.startNotifications(); + characteristic.addEventListener('characteristicvaluechanged', onCharacteristicChanged); + + updateStatus('connected'); + console.log('BLE connected to:', device.name); + + return device.name; + } catch (error) { + updateStatus('disconnected'); + if (error.name === 'NotFoundError') { + throw new Error('No bike found. Make sure the Bosch system is powered on.'); + } + throw error; + } + } + + /** + * Disconnect from the bike + */ + function disconnect() { + if (characteristic) { + characteristic.removeEventListener('characteristicvaluechanged', onCharacteristicChanged); + characteristic = null; + } + if (device && device.gatt.connected) { + device.gatt.disconnect(); + } + device = null; + server = null; + updateStatus('disconnected'); + } + + /** + * Handle incoming BLE notifications + */ + function onCharacteristicChanged(event) { + const dataView = event.target.value; + const parsed = BoschProtocol.parseNotification(dataView); + if (onDataCallback) { + onDataCallback(parsed); + } + } + + /** + * Handle unexpected disconnection + */ + function onDisconnected() { + console.warn('BLE device disconnected'); + characteristic = null; + server = null; + updateStatus('disconnected'); + } + + /** + * Update connection status + */ + function updateStatus(status) { + if (onStatusCallback) { + onStatusCallback(status); + } + } + + /** + * Set callback for incoming data + */ + function onData(callback) { + onDataCallback = callback; + } + + /** + * Set callback for status changes + */ + function onStatus(callback) { + onStatusCallback = callback; + } + + /** + * Check if currently connected + */ + function isConnected() { + return device && device.gatt && device.gatt.connected; + } + + return { + isAvailable, + connect, + disconnect, + onData, + onStatus, + isConnected + }; +})(); diff --git a/webapp/js/boschProtocol.js b/webapp/js/boschProtocol.js new file mode 100644 index 0000000..7fd5df4 --- /dev/null +++ b/webapp/js/boschProtocol.js @@ -0,0 +1,160 @@ +/** + * Bosch Smart System BLE Protocol Decoder + * Decodes varint-encoded data from the Bosch BLE characteristic. + * + * Confirmed from Phase 2 testing (nRF Connect): + * - Characteristic: 00000011-EAA2-11E9-81B4-2A2AE2DBCCE4 + * - Data rate: ~2 messages/second + * - Message format: 0x30 header, varint-encoded TLV pairs + * - Confirmed fields: assist mode, battery %, speed, cadence + * - Unknown data IDs found: 0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091 + */ + +const BoschProtocol = (() => { + // Bosch BLE UUIDs + const SERVICE_UUID = '0000fee7-0000-1000-8000-00805f9b34fb'; + const CHAR_UUID = '00000011-eaa2-11e9-81b4-2a2ae2dbcce4'; + + // Assist modes mapping + const ASSIST_MODES = { + 0: 'OFF', + 1: 'ECO', + 2: 'TOUR', + 3: 'eMTB', + 4: 'TURBO' + }; + + // Known data IDs from Phase 2 testing + const KNOWN_IDS = { + 0x01: 'speed', // in 0.1 km/h + 0x02: 'torque', // in Nm + 0x03: 'totalKm', // in meters (divide by 1000) + 0x04: 'mode', // assist mode index + 0x05: 'battery', // percentage (0-100) + 0x06: 'cadence' // RPM + }; + + // Unknown data IDs discovered during testing — need real-ride validation + const UNKNOWN_IDS = [0x9865, 0x981A, 0x9874, 0x80C4, 0x808A, 0x8091]; + + /** + * Decode a varint from a DataView at a given offset. + * Returns { value, bytesRead } + */ + function decodeVarint(dataView, offset) { + let result = 0; + let shift = 0; + let bytesRead = 0; + + while (offset + bytesRead < dataView.byteLength) { + const byte = dataView.getUint8(offset + bytesRead); + result |= (byte & 0x7F) << shift; + bytesRead++; + if ((byte & 0x80) === 0) break; + shift += 7; + } + + return { value: result, bytesRead }; + } + + /** + * Decode a signed varint (zigzag encoding). + */ + function decodeSignedVarint(dataView, offset) { + const { value, bytesRead } = decodeVarint(dataView, offset); + const decoded = (value >>> 1) ^ -(value & 1); + return { value: decoded, bytesRead }; + } + + /** + * Parse a Bosch BLE notification payload. + * Handles the 0x30 header byte confirmed from Phase 2 testing. + */ + function parseNotification(dataView) { + const data = { + speed: 0, + torque: 0, + totalKm: 0, + mode: 'OFF', + battery: null, + cadence: null, + raw: [], + unknownFields: {} + }; + + // Store raw bytes for debugging + for (let i = 0; i < dataView.byteLength; i++) { + data.raw.push(dataView.getUint8(i)); + } + + if (dataView.byteLength < 4) return data; + + try { + let offset = 0; + + // Skip header byte(s) — 0x30 confirmed from Phase 2, also handle 0x01/0x02 + const header = dataView.getUint8(0); + if (header === 0x30 || header === 0x01 || header === 0x02) { + offset = 1; + } + + // Parse TLV pairs + while (offset < dataView.byteLength - 1) { + // Read tag — may be 1 or 2 bytes + let tag = dataView.getUint8(offset); + offset++; + + // Check for 2-byte tags (high bit set patterns from unknown IDs) + if ((tag & 0x80) !== 0 && offset < dataView.byteLength) { + tag = (tag << 8) | dataView.getUint8(offset); + offset++; + } + + const { value, bytesRead } = decodeVarint(dataView, offset); + offset += bytesRead; + + switch (tag) { + case 0x01: // Speed in 0.1 km/h + data.speed = value / 10; + break; + case 0x02: // Torque in Nm + data.torque = value; + break; + case 0x03: // Total distance in meters + data.totalKm = value / 1000; + break; + case 0x04: // Assist mode + data.mode = ASSIST_MODES[value] || 'OFF'; + break; + case 0x05: // Battery percentage + data.battery = value; + break; + case 0x06: // Cadence RPM + data.cadence = value; + break; + default: + // Log unknown fields for future identification + data.unknownFields[tag.toString(16)] = value; + break; + } + + if (bytesRead === 0) break; // Safety + } + } catch (e) { + console.warn('Bosch parse error:', e); + } + + return data; + } + + return { + SERVICE_UUID, + CHAR_UUID, + ASSIST_MODES, + KNOWN_IDS, + UNKNOWN_IDS, + decodeVarint, + decodeSignedVarint, + parseNotification + }; +})(); diff --git a/webapp/js/qrcode.js b/webapp/js/qrcode.js new file mode 100644 index 0000000..8162948 --- /dev/null +++ b/webapp/js/qrcode.js @@ -0,0 +1,318 @@ +/** + * Minimal QR Code generator — no dependencies + * Encodes a URL into a canvas element + * Based on QR Code Model 2, Version 3 (29x29), Error Correction Level L + */ +(function () { + 'use strict'; + + // Generate QR code on a canvas for a given URL + function generateQR(canvas, url) { + // Use the Google Charts API as a simple fallback-free approach: + // Draw QR as an image loaded from a data URL generated client-side. + // For a truly zero-dependency approach, we use a proven minimal encoder. + const size = canvas.width; + const ctx = canvas.getContext('2d'); + + // Try using the minimal encoder + try { + const modules = encode(url); + const moduleCount = modules.length; + const cellSize = Math.floor((size - 16) / moduleCount); // 8px padding each side + const offset = Math.floor((size - cellSize * moduleCount) / 2); + + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, size, size); + + ctx.fillStyle = '#051229'; // Navy from brand + for (let r = 0; r < moduleCount; r++) { + for (let c = 0; c < moduleCount; c++) { + if (modules[r][c]) { + ctx.fillRect( + offset + c * cellSize, + offset + r * cellSize, + cellSize, + cellSize + ); + } + } + } + } catch (e) { + // Fallback: show URL text + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = '#051229'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(url, size / 2, size / 2); + } + } + + // ---- Minimal QR Encoder (byte mode, ECC L) ---- + + // GF(256) math + const EXP = new Uint8Array(512); + const LOG = new Uint8Array(256); + (function initGF() { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP[i] = x; + LOG[x] = i; + x = (x << 1) ^ (x & 128 ? 0x11d : 0); + } + for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; + })(); + + function gfMul(a, b) { + return a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]; + } + + function polyMul(a, b) { + const r = new Uint8Array(a.length + b.length - 1); + for (let i = 0; i < a.length; i++) + for (let j = 0; j < b.length; j++) + r[i + j] ^= gfMul(a[i], b[j]); + return r; + } + + function polyRemainder(data, gen) { + const r = new Uint8Array(data.length + gen.length - 1); + r.set(data); + for (let i = 0; i < data.length; i++) { + if (r[i] === 0) continue; + for (let j = 0; j < gen.length; j++) + r[i + j] ^= gfMul(gen[j], r[i]); + } + return r.slice(data.length); + } + + function generatorPoly(n) { + let g = new Uint8Array([1]); + for (let i = 0; i < n; i++) + g = polyMul(g, new Uint8Array([1, EXP[i]])); + return g; + } + + // Version info: [version, size, dataBytes, eccPerBlock, numBlocks] + // We pick the smallest version that fits + const VERSIONS = [ + [1, 21, 19, 7, 1], + [2, 25, 34, 10, 1], + [3, 29, 55, 15, 1], + [4, 33, 80, 20, 1], + [5, 37, 108, 26, 1], + [6, 41, 136, 18, 2], + [7, 45, 156, 20, 2], + [8, 49, 194, 24, 2], + [9, 53, 232, 30, 2], + [10, 57, 274, 18, 4], + ]; + + function pickVersion(dataLen) { + // Byte mode: 4 bits mode + char count bits + data + terminator + for (const v of VERSIONS) { + const charCountBits = v[0] <= 9 ? 8 : 16; + const totalBits = 4 + charCountBits + dataLen * 8; + const capacity = v[2] * 8; + if (totalBits <= capacity) return v; + } + return VERSIONS[VERSIONS.length - 1]; // fallback to largest + } + + function encode(text) { + const data = new TextEncoder().encode(text); + const [version, size, dataBytes, eccPerBlock, numBlocks] = pickVersion(data.length); + const charCountBits = version <= 9 ? 8 : 16; + + // Build data bitstream + const bits = []; + function pushBits(val, len) { + for (let i = len - 1; i >= 0; i--) + bits.push((val >> i) & 1); + } + + pushBits(0b0100, 4); // Byte mode + pushBits(data.length, charCountBits); + for (const b of data) pushBits(b, 8); + pushBits(0, Math.min(4, dataBytes * 8 - bits.length)); // terminator + + // Pad to byte boundary + while (bits.length % 8 !== 0) bits.push(0); + + // Pad bytes + const padBytes = [0xEC, 0x11]; + let padIdx = 0; + while (bits.length < dataBytes * 8) { + pushBits(padBytes[padIdx % 2], 8); + padIdx++; + } + + // Convert to bytes + const dataArr = new Uint8Array(dataBytes); + for (let i = 0; i < dataBytes; i++) { + let byte = 0; + for (let b = 0; b < 8; b++) byte = (byte << 1) | (bits[i * 8 + b] || 0); + dataArr[i] = byte; + } + + // Split into blocks and generate ECC + const blockSize = Math.floor(dataBytes / numBlocks); + const gen = generatorPoly(eccPerBlock); + const dataBlocks = []; + const eccBlocks = []; + + for (let b = 0; b < numBlocks; b++) { + const start = b * blockSize; + const end = b === numBlocks - 1 ? dataBytes : start + blockSize; + const block = dataArr.slice(start, end); + dataBlocks.push(block); + eccBlocks.push(polyRemainder(block, gen)); + } + + // Interleave + const codewords = []; + const maxDataLen = Math.max(...dataBlocks.map(b => b.length)); + for (let i = 0; i < maxDataLen; i++) + for (const block of dataBlocks) + if (i < block.length) codewords.push(block[i]); + for (let i = 0; i < eccPerBlock; i++) + for (const block of eccBlocks) + if (i < block.length) codewords.push(block[i]); + + // Create module matrix + const modules = Array.from({ length: size }, () => new Uint8Array(size)); + const reserved = Array.from({ length: size }, () => new Uint8Array(size)); + + // Finder patterns + function drawFinder(row, col) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const rr = row + r, cc = col + c; + if (rr < 0 || rr >= size || cc < 0 || cc >= size) continue; + reserved[rr][cc] = 1; + if (r >= 0 && r <= 6 && c >= 0 && c <= 6) { + modules[rr][cc] = + (r === 0 || r === 6 || c === 0 || c === 6 || + (r >= 2 && r <= 4 && c >= 2 && c <= 4)) ? 1 : 0; + } + } + } + } + drawFinder(0, 0); + drawFinder(0, size - 7); + drawFinder(size - 7, 0); + + // Alignment pattern (version >= 2) + if (version >= 2) { + const pos = alignmentPositions(version); + for (const r of pos) { + for (const c of pos) { + if (reserved[r][c]) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const rr = r + dr, cc = c + dc; + reserved[rr][cc] = 1; + modules[rr][cc] = + (Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0)) ? 1 : 0; + } + } + } + } + } + + // Timing patterns + for (let i = 8; i < size - 8; i++) { + reserved[6][i] = 1; + modules[6][i] = i % 2 === 0 ? 1 : 0; + reserved[i][6] = 1; + modules[i][6] = i % 2 === 0 ? 1 : 0; + } + + // Dark module + reserved[size - 8][8] = 1; + modules[size - 8][8] = 1; + + // Reserve format info areas + for (let i = 0; i < 8; i++) { + reserved[8][i] = 1; + reserved[8][size - 1 - i] = 1; + reserved[i][8] = 1; + reserved[size - 1 - i][8] = 1; + } + reserved[8][8] = 1; + + // Reserve version info (version >= 7) — not needed for our range + + // Place data + let bitIdx = 0; + const totalBitsToPlace = codewords.length * 8; + let col = size - 1; + + while (col >= 0) { + if (col === 6) col--; // skip timing column + for (let dir = 0; dir < 2; dir++) { + const cc = col - dir; + const upward = ((size - 1 - col + (col > 6 ? 1 : 0)) / 2) % 2 === 0; + for (let i = 0; i < size; i++) { + const r = upward ? i : size - 1 - i; + if (reserved[r][cc]) continue; + if (bitIdx < totalBitsToPlace) { + const byteIdx = Math.floor(bitIdx / 8); + const bitOff = 7 - (bitIdx % 8); + modules[r][cc] = (codewords[byteIdx] >> bitOff) & 1; + bitIdx++; + } + } + } + col -= 2; + } + + // Apply mask (mask 0: (row + col) % 2 === 0) + for (let r = 0; r < size; r++) + for (let c = 0; c < size; c++) + if (!reserved[r][c] && (r + c) % 2 === 0) + modules[r][c] ^= 1; + + // Write format info (mask 0, ECC L) + const formatBits = getFormatBits(0); // ECC L, mask 0 + // Horizontal + for (let i = 0; i < 6; i++) modules[8][i] = (formatBits >> (14 - i)) & 1; + modules[8][7] = (formatBits >> 8) & 1; + modules[8][8] = (formatBits >> 7) & 1; + modules[7][8] = (formatBits >> 6) & 1; + for (let i = 0; i < 6; i++) modules[5 - i][8] = (formatBits >> (5 - i)) & 1; + // Vertical + for (let i = 0; i < 7; i++) modules[size - 1 - i][8] = (formatBits >> (14 - i)) & 1; + for (let i = 0; i < 8; i++) modules[8][size - 8 + i] = (formatBits >> (7 - i)) & 1; + + return modules; + } + + function alignmentPositions(version) { + if (version === 1) return []; + const positions = [6]; + const last = version * 4 + 10; + const count = Math.floor(version / 7) + 2; + const step = Math.ceil((last - 6) / (count - 1)); + for (let i = 1; i < count; i++) positions.push(6 + i * step > last ? last : 6 + i * step); + // Ensure last is exact + positions[positions.length - 1] = last; + return positions; + } + + function getFormatBits(mask) { + // Pre-computed format info for ECC Level L (01) with masks 0-7 + const FORMAT_INFO = [ + 0x77C4, 0x72F3, 0x7DAA, 0x789D, 0x662F, 0x6318, 0x6C41, 0x6976, + ]; + return FORMAT_INFO[mask]; + } + + // Auto-init on load + window.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('qr-canvas'); + if (canvas) { + generateQR(canvas, 'https://monitor.proactys.swiss/opusbike/'); + } + }); +})(); diff --git a/webapp/js/sw.js b/webapp/js/sw.js new file mode 100644 index 0000000..d66e832 --- /dev/null +++ b/webapp/js/sw.js @@ -0,0 +1,56 @@ +/** + * OpusBike Service Worker — Cache static assets for PWA + */ + +const CACHE_NAME = 'opusbike-v3'; +const STATIC_ASSETS = [ + './', + './index.html', + './css/style.css', + './js/boschProtocol.js', + './js/bleService.js', + './js/wsRelay.js', + './js/app.js', + './manifest.json', + './icons/icon-192.png', + './icons/icon-512.png', + './icons/apple-touch-icon.png', + './icons/favicon.svg', + './icons/proactys-logo.png', + './icons/qr-code.png' +]; + +// Install — cache static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// Activate — clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(keys => + Promise.all( + keys.filter(key => key !== CACHE_NAME) + .map(key => caches.delete(key)) + ) + ).then(() => self.clients.claim()) + ); +}); + +// Fetch — cache-first for static assets, network-first for everything else +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Skip WebSocket requests + if (url.pathname.endsWith('/ws')) return; + + // Cache-first for known assets + event.respondWith( + caches.match(event.request) + .then(cached => cached || fetch(event.request)) + ); +}); diff --git a/webapp/js/wsRelay.js b/webapp/js/wsRelay.js new file mode 100644 index 0000000..e72d2c6 --- /dev/null +++ b/webapp/js/wsRelay.js @@ -0,0 +1,133 @@ +/** + * WebSocket Relay Client + * Bridge mode: sends decoded BLE telemetry to VPS over 5G/WiFi + * Viewer mode: receives telemetry broadcast from VPS + */ + +const WSRelay = (() => { + let ws = null; + let onDataCallback = null; + let onStatusCallback = null; + let reconnectTimer = null; + let isSource = false; // true if this client is the BLE bridge + const RECONNECT_DELAY = 2000; + + /** + * Get the WebSocket URL based on current location + */ + function getWsUrl() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}/opusbike/ws`; + } + + /** + * Connect to the WebSocket relay server + */ + function connect(asSource = false) { + isSource = asSource; + + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + clearTimeout(reconnectTimer); + + try { + ws = new WebSocket(getWsUrl()); + + ws.onopen = () => { + console.log('WS relay connected (source:', isSource, ')'); + // Register role with server + ws.send(JSON.stringify({ type: 'register', role: isSource ? 'source' : 'viewer' })); + if (onStatusCallback) onStatusCallback('connected'); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + // Handle both live telemetry and initial state snapshot + if ((msg.type === 'telemetry' || msg.type === 'state') && onDataCallback) { + onDataCallback(msg.data); + } else if (msg.type === 'source_connected' && onStatusCallback) { + onStatusCallback('source_connected'); + } else if (msg.type === 'source_disconnected' && onStatusCallback) { + onStatusCallback('source_disconnected'); + } + } catch (e) { + // Ignore non-JSON messages + } + }; + + ws.onclose = () => { + console.log('WS relay disconnected'); + if (onStatusCallback) onStatusCallback('disconnected'); + scheduleReconnect(); + }; + + ws.onerror = (err) => { + console.warn('WS relay error:', err); + }; + } catch (e) { + console.warn('WS connect failed:', e); + scheduleReconnect(); + } + } + + /** + * Schedule a reconnection attempt + */ + function scheduleReconnect() { + clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => connect(isSource), RECONNECT_DELAY); + } + + /** + * Send telemetry data to relay (bridge mode — phone uploading to VPS) + */ + function sendTelemetry(data) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'telemetry', source: 'ble', data })); + } + } + + /** + * Disconnect from relay + */ + function disconnect() { + clearTimeout(reconnectTimer); + if (ws) { + ws.close(); + ws = null; + } + } + + /** + * Set callback for incoming telemetry (viewer mode) + */ + function onData(callback) { + onDataCallback = callback; + } + + /** + * Set callback for connection status changes + */ + function onStatus(callback) { + onStatusCallback = callback; + } + + /** + * Check if connected + */ + function isConnected() { + return ws && ws.readyState === WebSocket.OPEN; + } + + return { + connect, + disconnect, + sendTelemetry, + onData, + onStatus, + isConnected + }; +})(); diff --git a/webapp/manifest.json b/webapp/manifest.json new file mode 100644 index 0000000..1c957fe --- /dev/null +++ b/webapp/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "OpusBike — Bosch Smart System Monitor", + "short_name": "OpusBike", + "description": "Real-time bike telemetry dashboard for Bosch Smart System eBikes", + "start_url": ".", + "display": "standalone", + "orientation": "portrait", + "background_color": "#F5F7FA", + "theme_color": "#F5F7FA", + "icons": [ + { + "src": "icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +}