From a9b5703c57b43aa5e34dada40f0817bc6e34a6aa Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 27 Jul 2026 14:19:58 +0000 Subject: [PATCH] Add Phase 2 MHI Modbus register-map profile, xlsx importer, and a standalone connection test script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register map derived and validated from two real Intesis MAPS signals exports (2-unit and 11-unit) taken from the physically installed gateway. The 11-unit export overturned an earlier assumption from the config screen's "Fixed" addressing label: register slots are dense in config/commission order, not fixed-per-IU with gaps — so a unit's register base must come from the imported map (or be entered manually), never computed from room number or SuperLink IU address. - drivers/mhi-profiles/intesis-mhi-modbus.js: register map + encodings for the confirmed gateway (TCP, port 502, slave 1, single-slave mode), documenting the corrected slot-vs-IU distinction - lib/mhi-xlsx-import.js: parses an Intesis MAPS signals export into a structured per-unit register map (0 unmatched rows against both real samples) — this becomes the authoritative Phase 2 source of truth, with the static profile only a pre-first-export fallback - scripts/test-modbus-connection.py: dependency-free Modbus TCP test (validated against a local loopback mock server) to resolve the one remaining open question — the address-base convention — directly from the Proxmox host, which already sits on the trusted admin VLAN - docs/: both real MAPS export samples, preserved as provenance Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + backend/package.json | 3 +- backend/scripts/test-modbus-connection.py | 105 ++++++++++ .../mhi-profiles/intesis-mhi-modbus.js | 140 ++++++++++++++ backend/src/lib/mhi-xlsx-import.js | 182 ++++++++++++++++++ ...aps-signals-sample-11units-2026-07-27.xlsx | Bin 0 -> 19178 bytes ...ntesis-maps-signals-sample-2026-07-27.xlsx | Bin 0 -> 10108 bytes 7 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/test-modbus-connection.py create mode 100644 backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js create mode 100644 backend/src/lib/mhi-xlsx-import.js create mode 100644 docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx create mode 100644 docs/intesis-maps-signals-sample-2026-07-27.xlsx diff --git a/.gitignore b/.gitignore index 1c878b1..eb1d2e1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ dist/ .env uploads/ *.log +__pycache__/ +*.pyc diff --git a/backend/package.json b/backend/package.json index e8f2d57..ba9383e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "luxon": "^3.5.0", "mqtt": "^5.10.3", "pg": "^8.13.1", - "sharp": "^0.33.0" + "sharp": "^0.33.0", + "xlsx": "^0.18.5" } } diff --git a/backend/scripts/test-modbus-connection.py b/backend/scripts/test-modbus-connection.py new file mode 100644 index 0000000..2ad4e1f --- /dev/null +++ b/backend/scripts/test-modbus-connection.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +Standalone Modbus TCP connectivity/address-base test for the Intesis MHI +SuperLink -> Modbus gateway. Pure standard library (socket + struct) — no +pip installs needed, so it can run directly on the Proxmox host shell, +which already has a trusted network link on the admin VLAN. + +This is NOT a throwaway script — it implements the same raw Modbus TCP +request/response framing the real hvac driver (Phase 2) will use, so what +it proves here carries over directly. See hvac/backend/src/lib/drivers/ +mhi-profiles/intesis-mhi-modbus.js for the register map this is testing. + +Usage: + python3 test-modbus-connection.py [port] [slave-id] + +What it does: + 1. Reads the gateway's own "Gateway Communication Status" register + (global block, address 2995) as a basic reachability/framing check + (expect 0 = ok). + 2. Reads unit 1's Setpoint and Room Temperature at TWO candidate + addresses each (the MAPS-exported address, and that address minus + one) to resolve the one open question the MAPS export/config screen + can't answer: whether the exported "Address" column is already the + correct 0-based Modbus wire address, or a 1-based register number + needing -1. Setpoint/room-temp are known to decode as a signed + value x10 in the range 16.0-30.0 C when read correctly — whichever + candidate decodes into that range is the right convention. +""" +import socket +import struct +import sys + +def read_holding_registers(sock, slave_id, address, quantity, transaction_id=1): + """Send a Modbus TCP (MBAP + FC03) request, return the raw register values.""" + pdu = struct.pack('>BHH', 0x03, address, quantity) + mbap = struct.pack('>HHHB', transaction_id, 0x0000, len(pdu) + 1, slave_id) + sock.sendall(mbap + pdu) + + resp = sock.recv(260) + if len(resp) < 9: + raise RuntimeError(f'Short response ({len(resp)} bytes): {resp!r}') + + resp_tid, proto_id, length, resp_slave, func = struct.unpack('>HHHBB', resp[:8]) + if func & 0x80: + exc_code = resp[8] + raise RuntimeError(f'Modbus exception, function 0x{func & 0x7F:02X}, code {exc_code} ' + f'({modbus_exception_name(exc_code)})') + + byte_count = resp[8] + values = struct.unpack(f'>{quantity}H', resp[9:9 + byte_count]) + return values + +def modbus_exception_name(code): + return { + 1: 'Illegal Function', 2: 'Illegal Data Address', 3: 'Illegal Data Value', + 4: 'Slave Device Failure', 5: 'Acknowledge', 6: 'Slave Device Busy', + 11: 'Gateway Target Device Failed to Respond', + }.get(code, 'Unknown') + +def decode_temp_x10_signed(raw_u16): + """Registers are 16-bit; setpoint/room-temp are signed, x10 scaled.""" + signed = raw_u16 - 0x10000 if raw_u16 >= 0x8000 else raw_u16 + return signed / 10.0 + +def try_read(sock, slave_id, label, address, decode=None): + try: + values = read_holding_registers(sock, slave_id, address, 1) + raw = values[0] + decoded = decode(raw) if decode else raw + print(f' [{label}] addr={address:>5} raw=0x{raw:04X} ({raw}) ' + f'decoded={decoded}{" <-- plausible temp (16-30 C)" if isinstance(decoded, float) and 16.0 <= decoded <= 30.0 else ""}') + except Exception as e: + print(f' [{label}] addr={address:>5} FAILED: {e}') + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + host = sys.argv[1] + port = int(sys.argv[2]) if len(sys.argv) > 2 else 502 + slave_id = int(sys.argv[3]) if len(sys.argv) > 3 else 1 + + print(f'Connecting to Modbus TCP gateway at {host}:{port} (slave id {slave_id})...') + with socket.create_connection((host, port), timeout=5) as sock: + print('Connected.\n') + + print('1. Gateway communication status (global, addr 2995 — expect raw=0 => ok):') + try_read(sock, slave_id, 'as-exported', 2995) + + print('\n2. Unit 1 Setpoint — MAPS address 4 (expect a plausible 16-30 C reading ' + 'from whichever candidate is correct):') + try_read(sock, slave_id, 'addr=4 (as-exported)', 4, decode_temp_x10_signed) + try_read(sock, slave_id, 'addr=3 (exported-1)', 3, decode_temp_x10_signed) + + print('\n3. Unit 1 Room Temperature — MAPS address 8:') + try_read(sock, slave_id, 'addr=8 (as-exported)', 8, decode_temp_x10_signed) + try_read(sock, slave_id, 'addr=7 (exported-1)', 7, decode_temp_x10_signed) + + print('\nDone. Whichever addr variant above decoded to a plausible 16-30 C value ' + 'for BOTH setpoint and room temp is the correct addressing convention — ' + 'set that in intesis-mhi-modbus.js\'s `addressBase`/perUnit offsets accordingly.') + +if __name__ == '__main__': + main() diff --git a/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js new file mode 100644 index 0000000..bafaee4 --- /dev/null +++ b/backend/src/lib/drivers/mhi-profiles/intesis-mhi-modbus.js @@ -0,0 +1,140 @@ +// ───────────────────────────────────────────────────────────────────────────── +// Register-map profile: Intesis MHI SuperLink → Modbus TCP gateway +// ───────────────────────────────────────────────────────────────────────────── +// Derived directly from an Intesis MAPS "Excel signals file" export taken from +// the live gateway after the first two indoor units were commissioned +// (MAPS v1.2.31.0, internal protocol "Modbus Slave", external "Mitsubishi Heavy +// Industries", 2026-07-27). See the hvac plan doc's Phase 2 section for why the +// register map lives in code as a named profile (fixed firmware-tied data) while +// the gateway's connection details (IP/port/slave id/unit list) are UI config. +// +// The gateway is the Modbus TCP *server*; hvac connects as the *client/master*. +// +// CONFIRMED from the gateway's Modbus config screen (2026-07-27): +// - Type: TCP; Port: 502; Keep Alive: 10 min. +// - Slave Number: 1 (single connection, defaulted below). +// - Slave Addressing Mode: SINGLE SLAVE -> the whole gateway is one Modbus +// slave; indoor units are addressed by REGISTER OFFSET, not by per-unit +// slave ids. (If this were "Multiple Slaves" the addressing model below +// would be wrong.) +// - Modbus Addresses: "Fixed" on the config screen — but an 11-unit export +// (2026-07-27) DISPROVED the "fixed block per IU with gaps" reading: the +// register slots are DENSE 1..11 in config-list/commission order, while the +// SuperLink IU addresses are sparse (1,2,4,6,8,10,12,21,22,23,25). So a +// unit's register base is keyed to its DENSE gateway slot position, NOT to +// its IU or room number. base=1+(slot-1)*20 holds only when `slot` is that +// dense position. This is why the real export is the source of truth and the +// stored-base-per-device path is PRIMARY, not a fallback — see unitRegister. +// +// STILL TO CONFIRM (only observable from a live Modbus read, not a config screen): +// - Address base: the `address` values below are the raw MAPS addresses. Some +// Modbus masters expect a +40001 holding-register offset. `addressBase: 0` +// here means "use the address as-is in the frame (0-based protocol address)". +// Flip to 40001 only if an integration test shows an off-by-40001 mismatch. +// The RELATIVE structure (stride, offsets, encodings) is unambiguous either +// way — only the absolute base could shift. +// ───────────────────────────────────────────────────────────────────────────── + +export const intesisMhiModbus = { + key: 'intesis-mhi-modbus', + label: 'Intesis MHI SuperLink → Modbus TCP', + source: 'Intesis MAPS signals export, MAPS v1.2.31.0, 2026-07-27', + transport: 'modbus-tcp', + + // Gateway is the Modbus server; these are defaults for the UI-configurable + // connection record (a per-hotel row in the mhi_gateways table). + defaults: { + port: 502, // confirmed on gateway (2026-07-27) + slaveId: 1, // confirmed: Slave Number 1, Single-Slave addressing mode + addressBase: 0, // 0 = raw protocol address; 40001 = holding-register offset + // (STILL to confirm via a live read — see header note) + }, + + // Registers are 16-bit holding registers throughout. + // Read with FC03; write single with FC06 (or FC16 for multi). + registerBits: 16, + + // Value scaling / encoding helpers, keyed by an encoding name used below. + encodings: { + unsigned: { signed: false, scale: 1 }, + // Temperatures (setpoint, room temp) are transmitted x10, two's-complement + // signed — e.g. 21.5 C -> 215. Valid setpoint range 16..30 C (160..300). + tempX10: { signed: true, scale: 10, minC: 16, maxC: 30 }, + }, + + // Broadcast block — absolute register addresses that act on ALL units at once. + // Useful for a future "all public-area units off" style master control. + global: { + gatewayCommStatus: { address: 2995, access: 'r', encoding: 'unsigned', values: { 0: 'ok', 1: 'failure' } }, + onOff: { address: 2996, access: 'rw', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } }, + mode: { address: 2997, access: 'rw', encoding: 'unsigned', enum: 'mode' }, + setpoint: { address: 2998, access: 'rw', encoding: 'tempX10' }, + fanSpeed: { address: 2999, access: 'rw', encoding: 'unsigned', enum: 'fanSpeed' }, + remoteLock: { address: 3000, access: 'rw', encoding: 'unsigned', values: { 0: 'unlock', 1: 'lock' } }, + }, + + // Per-indoor-unit layout. Each unit occupies a contiguous 20-register block; + // unit 1 begins at address 1, unit 2 at 21 -> stride 20. + // base(unit N) = firstUnitBase + (N-1)*stride. + // + // `slot` here is the gateway's DENSE register-slot position (1..N, in + // config-list / commission order) — this is what the register base is keyed + // to. Confirmed by the 11-unit export: slots dense 1..11, IUs sparse. + // It is DISTINCT from all of: + // - the room number (room 205), + // - the SuperLink IU bus address (col L; e.g. IU 25 for room 205), + // - commission chronology if units are ever reordered. + // Because the slot can only be known from the actual export (not derived from + // room/IU), the resolved register base is stored per device + // (zone_devices.mhi_register_base, set at import time) and passed to + // unitRegister() as explicitBase. The base=1+(slot-1)*20 math below is only a + // sanity check / fallback for a hand-entered slot. + perUnit: { + firstUnitBase: 1, + stride: 20, + // Offsets are relative to a unit's base address. + fields: { + commStatus: { offset: 0, access: 'r', encoding: 'unsigned', values: { 0: 'ok', 1: 'error' } }, + onOff: { offset: 1, access: 'rw', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } }, + mode: { offset: 2, access: 'rw', encoding: 'unsigned', enum: 'mode' }, + setpoint: { offset: 3, access: 'rw', encoding: 'tempX10' }, + fanSpeed: { offset: 4, access: 'rw', encoding: 'unsigned', enum: 'fanSpeed' }, + remoteLock: { offset: 5, access: 'rw', encoding: 'unsigned', values: { 0: 'unlock', 1: 'lock' } }, + louver: { offset: 6, access: 'rw', encoding: 'unsigned', enum: 'louver' }, + roomTemp: { offset: 7, access: 'r', encoding: 'tempX10' }, + filterSign: { offset: 8, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } }, + errorCode: { offset: 9, access: 'r', encoding: 'unsigned' }, // 0 = no error, 1..255 = code + compressor: { offset: 10, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } }, + filterReset: { offset: 11, access: 'trigger', encoding: 'unsigned' }, // write 1 to reset + rcErrorReset: { offset: 12, access: 'trigger', encoding: 'unsigned' }, // write 1 to reset + thermoOnOff: { offset: 13, access: 'r', encoding: 'unsigned', values: { 0: 'off', 1: 'on' } }, + expansionValve:{ offset: 14, access: 'r', encoding: 'unsigned' }, // 000..999 pulse + // offsets 15-19 are diagnostics (frequency / heat-exchange temps), + // inactive in the sample export — omitted until a use surfaces. + }, + }, + + enums: { + mode: { 0: 'cool', 1: 'heat', 2: 'fan', 3: 'auto', 4: 'dry' }, + fanSpeed: { 0: 'low', 1: 'medium', 2: 'high', 3: 'powerful' }, + louver: { 0: 'swing', 1: 'pos1', 2: 'pos2', 3: 'pos3', 4: 'pos4' }, + }, +} + +// Resolve a unit's absolute register address for a named field. +// `unitIndex` is the gateway's LOGICAL unit index (sparse/site-derived, e.g. 12 +// or 25 — NOT the room number, NOT commission order). Assumes fixed-slot +// allocation; see the perUnit comment. Applies addressBase offset. +// +// If a device row carries an explicit `mhi_register_base` (for the +// sequential-packing case), pass it as `explicitBase` and index math is skipped. +export function unitRegister(profile, unitIndex, fieldName, addressBase = profile.defaults.addressBase, explicitBase = null) { + const field = profile.perUnit.fields[fieldName] + if (!field) throw new Error(`Unknown MHI field: ${fieldName}`) + const base = explicitBase != null + ? explicitBase + : profile.perUnit.firstUnitBase + (unitIndex - 1) * profile.perUnit.stride + return addressBase + base + field.offset +} + +export default intesisMhiModbus diff --git a/backend/src/lib/mhi-xlsx-import.js b/backend/src/lib/mhi-xlsx-import.js new file mode 100644 index 0000000..a134e11 --- /dev/null +++ b/backend/src/lib/mhi-xlsx-import.js @@ -0,0 +1,182 @@ +import * as XLSX from 'xlsx' + +// Parses an Intesis MAPS "Excel signals file" export (the file MAPS produces +// when you export the currently-configured register map for an MHI SuperLink +// -> Modbus gateway) into a structured register map. +// +// Why import instead of trusting a hardcoded stride formula: MAPS exports the +// gateway's OWN configured addresses directly — it's ground truth for that +// specific gateway/firmware, and stays correct even if a future firmware +// update or a different Intesis model changes the block layout. A static +// profile (see mhi-profiles/intesis-mhi-modbus.js) is kept as a sensible +// fallback for a fresh gateway that hasn't been exported yet, but an imported +// map always wins once one exists — see the hvac plan doc's Phase 2 section. +// +// Field descriptions are matched by substring, not by row position/count, +// because the "Active" column means some rows (usually diagnostics) can be +// disabled — and a future MAPS version might omit inactive signals from the +// export entirely rather than just flagging them false. +const FIELD_PATTERNS = [ + [/gateway communication status/i, 'gatewayCommStatus'], + [/^communication status/i, 'commStatus'], + [/^on\/off/i, 'onOff'], + [/operation mode/i, 'mode'], + [/setpoint/i, 'setpoint'], + [/fan speed/i, 'fanSpeed'], + [/remote lock/i, 'remoteLock'], + [/louver|vane/i, 'louver'], + [/room temperature/i, 'roomTemp'], + [/filter sign status/i, 'filterSign'], + [/filter sign reset/i, 'filterReset'], + [/unit error code/i, 'errorCode'], + [/compressor status/i, 'compressor'], + [/rc error reset/i, 'rcErrorReset'], + [/thermo on\/off/i, 'thermoOnOff'], + [/expansion valve/i, 'expansionValve'], + [/decision frequency/i, 'decisionFrequency'], + [/demand frequency/i, 'demandFrequency'], + [/heat exchange low 1/i, 'heatExchangeLow1'], + [/heat exchange low 2/i, 'heatExchangeLow2'], + [/heat exchange low 3/i, 'heatExchangeLow3'], +] + +function matchFieldKey(description) { + for (const [pattern, key] of FIELD_PATTERNS) { + if (pattern.test(description)) return key + } + return null +} + +function parseReadWrite(raw) { + if (raw == null) return null + const s = String(raw) + if (/read\s*\/\s*write/i.test(s)) return 'rw' + if (/trigger/i.test(s)) return 'trigger' + if (/read/i.test(s)) return 'r' + if (/write/i.test(s)) return 'w' + return s +} + +// unitIdCell looks like "Unit 12 - Indoor Unit 12" for per-unit rows, or "-" +// for the global "all units" block. +function parseUnitIndex(unitIdCell) { + if (!unitIdCell || unitIdCell === '-') return null + const m = String(unitIdCell).match(/^Unit\s+(\d+)/i) + return m ? parseInt(m[1], 10) : null +} + +function toNullableInt(v) { + if (v == null || v === '-' || v === '') return null + const n = parseInt(v, 10) + return Number.isNaN(n) ? null : n +} + +/** + * @param {Buffer} buffer - raw .xlsx file contents + * @returns {{ + * meta: { projectName, mapsVersion, internalProtocol, externalProtocol, timestamp }, + * global: Record, + * units: Array<{ unitIndex:number, iu:number|null, ou:number|null, + * fields: Record }>, + * unmatchedRows: Array<{ row:number, description:string }> + * }} + */ +export function parseIntesisMapsExport(buffer) { + const workbook = XLSX.read(buffer, { type: 'buffer' }) + const sheet = workbook.Sheets[workbook.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null, blankrows: false }) + + const meta = { + projectName: null, + mapsVersion: null, + internalProtocol: null, + externalProtocol: null, + timestamp: null, + } + + let headerRowIdx = -1 + const headerMap = {} // column label -> column index + + for (let i = 0; i < rows.length; i++) { + const row = rows[i] + if (!row || row.length === 0) continue + const first = row[0] + + if (first === 'PROJECT_NAME') meta.projectName = row[1] ?? null + else if (first === 'Intesis MAPS Version') meta.mapsVersion = row[1] ?? null + else if (first === 'Internal Protocol') meta.internalProtocol = row[1] ?? null + else if (first === 'External Protocol') meta.externalProtocol = row[1] ?? null + else if (first === 'Timestamp') meta.timestamp = row[1] ?? null + + // Header row is identified by containing "Unit ID" as one of its cells, + // not by a fixed row number — MAPS export layout could shift slightly + // between versions. + if (row.includes('Unit ID')) { + headerRowIdx = i + row.forEach((label, idx) => { if (label != null) headerMap[label] = idx }) + break + } + } + + if (headerRowIdx === -1) { + throw new Error('Not a recognised Intesis MAPS signals export — no "Unit ID" header row found') + } + + const col = { + description: headerMap['Description'], + address: headerMap['Address'], + readWrite: headerMap['Read / Write'], + unitId: headerMap['Unit ID'], + active: headerMap['Active'], + iu: headerMap['IU'], + ou: headerMap['OU'], + } + for (const [name, idx] of Object.entries(col)) { + if (idx === undefined) throw new Error(`MAPS export missing expected column: ${name}`) + } + + const global = {} + const unitsByIndex = new Map() + const unmatchedRows = [] + + for (let i = headerRowIdx + 1; i < rows.length; i++) { + const row = rows[i] + if (!row || row[col.description] == null) continue + + const description = String(row[col.description]) + const address = toNullableInt(row[col.address]) + if (address == null) continue // header/section rows without a real address + + const active = row[col.active] === true || row[col.active] === 'True' + const readWrite = parseReadWrite(row[col.readWrite]) + const unitIndex = parseUnitIndex(row[col.unitId]) + const fieldKey = matchFieldKey(description) + + if (!fieldKey) { + unmatchedRows.push({ row: i + 1, description }) + continue + } + + const entry = { address, active, readWrite } + + if (unitIndex == null) { + global[fieldKey] = entry + } else { + if (!unitsByIndex.has(unitIndex)) { + unitsByIndex.set(unitIndex, { + unitIndex, + iu: toNullableInt(row[col.iu]), + ou: toNullableInt(row[col.ou]), + fields: {}, + }) + } + unitsByIndex.get(unitIndex).fields[fieldKey] = entry + } + } + + const units = [...unitsByIndex.values()].sort((a, b) => a.unitIndex - b.unitIndex) + + return { meta, global, units, unmatchedRows } +} + +export default parseIntesisMapsExport diff --git a/docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx b/docs/intesis-maps-signals-sample-11units-2026-07-27.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0577e01e051e65c64a9d1a8ff4b246c756637a5b GIT binary patch literal 19178 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 W@HJJ*$RVTM;QtaplLM!rSN{h|C)1e# literal 0 HcmV?d00001 diff --git a/docs/intesis-maps-signals-sample-2026-07-27.xlsx b/docs/intesis-maps-signals-sample-2026-07-27.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e9c16011d03f0be075164bad60fc2e631d61182e GIT binary patch literal 10108 zcmcI~1z1$;*Do!hbV`FDAq+!YK5)vNp z>1+jZf+H-A;BZS$XKSnA5G8ms*LA;h`NiWn)tA<}vQ_KN&^Wa5kMvJ<0^QV~dYzr* z_+q~suqj_y`5NEj`2C?S7q~6i@NonkSBx2}J@&^ccNc%_Qi?T8#Vq5X+u&zpl@GCx z68yF0`9+`gNKtVGO_zaP5~~7o!b_|>W@UIyL+HD5?-)QOvUu7WMQ>bpnmC}P#KS6E zq5-=)g}W#rza4OAf~2-R+E8gqxqVh8?iD&Pt?ogje@GFBk($x!BHlp~LHDl1zUhV7 z$3!ds9Xyd0XLW||+xvs)8|1AblTXmu@pG8-=r*j%VrYunqENw{ZghXYvC-YF zxmICO=cTZ?Os4!BP{ZVFINAV6!l+0{_yABQaARcz+|~{Rv9-NGDlr`XsF@4jzoB#8 zm7ER?8?y+AIgnB1+sN5w$8HtB5`4A)0SQL`M_B62 z#!JisL{<4W#XD7>bpt75y&o|*xm?eGlUmIi750j!#BfiJDqPzTYtVtlS}(5ImoTQ9 zv)fpcSbJ_X2l_H<;eL@5$;v~%sn5{z9a*CRyc5r3NROFa_g#-Q7VVWc`_k-&8~JXZ z*aQ*iGEQX@;qR~Q`X!n3%RXvDLkU-8f5@i3F7X}LC)kQP=r%3`!|BV|N@9*d7$SFt z^m@iuexY1FMep(xer!z7#$oT&p)(d6dUi4JYzZLNcL}dv8ErQY(yjaA~A`4=Y-(JfDDy>Pj4VnQcz-Csy0Rs z`@A;=yY6mo$U6F(%Erx1J4i>Zeg)PwhdZ{3 zwF%X@nJ08}HHxISPb-Q{UBa25mFzy%`xW)~kJM-R%M#vN_%ILE7W3T+iGgl=A&e{3 zJJb8gUea~(-b5TE3AwDGJi$Od$*f|!t)<k< z3!lT(t57~ULMWF`QA&>wQjHV~Q$^;&a$>=V&0y@Ute|6e#K(hxl?Vw!Le1w#l_+YF zbNkyjA_;M4sktY7N8`E1d|PWc3RWe0DZgY1&yuO<%e zUYLdwopmzYYH*sld_d3hU=g3aJ5M@8`XKe}y+z2)fL)nZind_lgM8kRHocU!H}QQh z*qpNIy_^&ox+CXE4mUpOY*tNHcG^K1{5=X4-5$UR+fQ_3X{O#xT9L{xZHJvNzZ-Bx zBO`SvL$51-Z%&~>nyria#kA^lEo&yk(L$!4HYw4;Ox+=hW0F0fzX*jykDtQsIYy#M z-Dr$$6G zU5sgF%cuS0Q?V(p@1s6gYTs7lc5)B37Yvv>zkgBal4o`D9N>_ne9dLbeQdxdmsWLS zz+Kupc9IUW%pY@IVJ6X0*KN4g`DAr|r6=%q5vPaB+TlT7J6(=%46m``5i`AeXlUv~ zSu`xxI3L|3AKeO{TcY$73bMF_VJ~Pp?uRTU%OE~63VnV<^<5VCnZeUpkD$6kwBL(A zBv+C14p98Q3`j^f{w{ud7c1yx)o*B9KbqjeKVy$-2)V^kT*#1oLmdmN#GK3AU}`bU zSV5f4PkR(?W4q>!Xt=qqfR`Ps&HOy^rhodfv2 z_B#-t-iMDmOZssz1Sq_up&u)6M$>7)^NRl|S{B-DMW4%iqL5b1`b2R~%qp`@DVw}}}_^u9fUfE4}bwo+-n(kv%Rbs2B zqFq^Zx6A+HDN72E)2*R)FVQFn=6H*+ZA&F`50{Wk*W0i_CJ9CZ6HMK=isZT3ZpfGt zXCIp{N&D{ewXV-EZhSgs|NP=(-|?el+V3c*YLj@bJ*SYOD)Cjm{dRXrab4;268LA$ zk>eSjl(~2xpQ!kPWW9p(>4o5e>CgH`A?)D~`R}T-rzpxE(N*%Bda~Mv`ataR^UO`~ zD6Rdx-uS6{lEn$$7L(o&Bd$=kxSK3eF7O>3))#cXhm!3WXb#j)}G5e2}PCc>0+qZHepYV4rzpGmjvUl;l-L>2x zdSfWTy+NU#dE(LX#1tvz7KrhC+|AE@RWqHMH-o8S;RF(H*4a>n5QuP^qMCki!trn{ zbvbQB!Wr`KWyPPDExdmn3CYg|35opYMWCG-6l!k=Os~KP`0^SjaRxCid$Y~JxBry< zJJ}+3>?_IC5)-zZ*n&h$bQ2wtP!{_3j@ohkb;b>LtDGj!GR`W&zX}m_ZkZKylVF;e!E`rc;~d-CuM8ZRk5y4r@L?-_`9txn4ZmeW}K^dZr%Rz zguqPi*2L!8S$XbJ-~LACSdx?7R{56G%EDI<2OaCK(W-iuP;A8=V>B)?@B<6Q|`C6*YeqH4MgApF=D> z^84G`azPaEuIjITczw0URg@l9TSrP$>Pa$!}>o!eqGrb9N}23lqDedRQUJJJ(7HHY8o_4h06K(ZfUPsw|{ zpXbm@YBEooIaF*-Y|LD1gCKepz0>5jO$fV~=lX8B@yeZ)vIMBkI0?Lb&D@vvax1B= z!#=KiQfWR-?!<&JA;X-|FQ-Nh!7E3j1T*!BIWnK$1dBdXq!n#0H6&G>|1f3j_&r8c zi8)NEKTYoFgh+*Q{Sl2;yvS1q&qCB2*P?HmoT_h@oHwp-w|r?6xp((udo(piT+aJ| zjB_E&GicdhD|NkVPHJR2q`HLys>y#`wUTt4B7|4qw?A_O^?hlQtLwM3ii+^2jftj# zlM2hNBp38)~xfw~;MJRFxL70b^>W3B?7Nt<$7pkLM;>q@Y>CwvA$FVgP$@lsVz0)<7vGt;r zj{3-c>Ziry@&a>c=R z8ew<((tqCqIg&eB^*hBJkNPSuvAyr@QmIwki?4T(_*oUEo_iN=6II$f^-Sp~SJvoP zDMWw4x_84Vf6>v(d-(}&U#=@vvv?kA+iO}dSFv?eaxLE0kUT*nDv_8 zj$cyCzRo)xi^UPvYUtkMXu&yZX5}abHQ|^uI+Gj)lx?er&fUb+ulI_sfpdOO#dj-0 z+vu0FT#zF3g0)&8*V^k{3eCqmt}AR~`;?O9BM-+z47@ezW|ubOaq3vmX4vo7*!PV) z_eIuIlw>~Oe4XM&_MM5WUAd8+c-xyVG|-r3mIT|m>UdcKgx$0+|A|xY9lt(rKbUbi z;zJvlaNE0nah@E@Sqep8IdJPsNz^P!6^ICZM)$K6avAtFO);q{9_KEgfBj>SmOnFD zyH+F4QCOhyommnb=eCbs#n+DsYmMgCuiPmH#DSl)W~C&xd5oxUdZ?2rcL62@XPWEL ziXJqwKh=K;9gUui#QfW%d?sfm6#WiX=RU4!K)+u>otL&B*>_*^I{-?SZ244KAK6Q; zkKEgi`w;^mp@d$W37AN80ZhbAx?;_V>@n@@ONfM&Ew1y~U0L8G+9u?FmO_R;Ojrv; z(a&1^E_P$J=YwSpT9gZN*>%uooUC_^^=Kga0)OpV!Y;s?0_OVPVP7v&Z(NHQ6id#j z12FZ0Z=^)c`nrED5Z!BYhH$*cvG^)~r=^}8l+EOfRbTZf5XAyZUn}q{B4^}%VCEjI zHIA^p%>N>cwu(I<*d-k2!;2^YpxpKiCANEf^@*Kr&_D*4MrJ3^t`g0Zz6YdEMBpOn4)PYXp9ZIcD$DjzEV1-u;~Q}XB(mpFfgmMT zBDMSg*%Ytn&~cn3$>%H5+t}!}59Kycp>E2mDO4&Xg6xLgYT4J+OKcx()&Y8t z!*pEf%i6WQM+RLkqaIPO6vWP{yFiu#0AowFD40xXqbGomDFN^rsG@m5=r@-j#&xq7 z^^hDj2ZVD;SOBKl^7y$y8cRs*R!J1VlN?b1r%87nz_APzKriTfD!?`XiJC!6Pk886 z>JC?DTd6_$zNXdagM_ukqzmX*sR$wi4$-da0u7Yk=3F%h)Tx>)kC77AmPVVlH`(C) zR4%}F!o4eGD={uW#5;aGH>5Tq2%L!FoFPq0toaSe8Wmq?^ zx94gpY)gz2w_jKQ(t4R~OS+4E{Y%_hK|c5 z%GqrA4vPJEk@YgN%UlEC86g*`sUBY<#YU91cNOg)7JeB{_{;F{e5#+?i$|`=#D`z& zQ};Lffb?ILUHSzzI$Ilvy@BKaBY$MNJq{=c;0OK73IgyzoKr>pNVOI;%ur&~k`~() zpkEU}vJ}#yR&Jnc0_nKQ0Z=bFUL&kI?hh`K7WvY11+xsTNOlycIPgXJHrut7umKf* zpQB0P%0l2*Z*A}BAN8OSE4crQM|FG7HV4C3&i&)j&Sk+d0=4jF#PJO6{2}El3WH{t z>$<`LA^!wWaIb*wx6RZFgj~p*))>y{Rdp_A_(iq&26+#_Z_(Qc`4V7M#Zeoe3m7&# zx@NZpUi6JdpIN{}B#hV@fmW=Hv>vf2Hh;cg95HxAbOM!Ofq7w3ZkB+M6$3U?NI~sD z)JR0B0FgA~RAxH}8hzHMGYl!Y;-=yWTptS7`Ws=y6fXgeH~MS?S?~s085$R)3*ZrZ z56ku9S zzumF84XP{w(<_d;fDV@B1+ZCQK3Mei3QhbF-c|v|y#(7=cx@)!CYKX1GJN%XpEEO z3c`A!EfE96JjIe6gYQ@K9LtV%lsQQH@IvGxAVR>r-srC_9aD^&q=@T(ScxeSgTD+g zsXrLy6<{k4z(|Q)=^N0LvNZ8(pWRX%^$95xwDP?W!J>VHThz_1i$<>)@C}$$zDOgt z1#!`T1aM*wdVgu+^|wnCEte+nfbpQ7%kdws#y=N_NW_G$)DWNC8$B2rpmWlj`kv&t z+*gw}QJ!~h9$!CMTNGEnn%ja3X?Uz>P^4eyq7&J&@EkAXRqdD6-N#ev{+i1KQ8hc6 z+EG(VQ|mJEtJO)a;icy5!PPCO#(1D3$NlYv=yKrtbel~qMsw$sRVz^FS7qS zyp#MwtJLG~bTrr3lGIf=6QZ%9XICsDlKk!K*ADnO-tRX{hKl~B#pu94=9EZC zL{~RU_GVCPDCqM2@;+(fNiTeYpZH7@b6Qj3Z0J_DcI)dY8>Ml&ufpFC?(`8-z9NnmcnJmPKno6!`kC&`KL zzsGYM7Brh}{*s$&MbT+=y0*GhxfnB3^ZARWWN}+EQVtZir0woRhDy;%Ti2=~6gKNZ z?XA1iS#=LsSl(%1#@Fc=l|EtCQXjV}!sk!0ajm&LSkHg|X<~D~2I?)9(X)vSads%3 z&}Yz6Z)FkJejXpIr>OEe7Sl10uwhQ{qjWtfX|XgUBLkzdP{#=@f{$RSEHAwthe$WY ziuHOVGU@MphgA!GltH;NBKXk4q5A{BC%1pzvGvc~uXN&7|rKSGj ziK5T!_qN;FmN{l2Zw_}Rde2UdjK8-J!1cw>QvH%&`51T}EJi1Z7u{cF*lCM?9bZ{;!W^LrX+%Ly3_&ROkr7>h#xy+32^ry&j{u zrYE$xhV3L1y4W2hFK$6@WyNXG!)2agRozBg!+JtJV9l->VBXns(_M%I1rO;p`_i#O zsYnbn!OL$_+V7>)7gxnl3+Znqn3)v5WR%Et-e=`latb+8WdZkn7LyW99(qa9S5gR@ zN(FPpJU$Y9l^l^$=bC+6E29xth*R>peX_unxjo#su%*d39{G^AUiaA9fbBZ~iJ`V3pV>obk zxP^xL4rE_On_6qCXuySC>#@A0lGZw-(v%j-)rboKw zqF>HbFnz0wshaeFmZSSF*V1w8owKfLwu7+p#BI*daA`B;Fu}=wx`XFdnUyCi7BqGOfN#uQYieFd; zPnfE8ItUMU(6T@jo=+3q7oKJlHDC6#0;x;oM1e~Rb=OuWI;&U_N}?5NX(KLm`e~tR z(n2WTmK!(tc?2Tyg3y?cV|@?Tbq^GCFj2ykB6X+(C$t5emft*euj?6}SS{eulxlKJ zG+bd|?s}4#|9*ATq;^vBHqG=p?_Ki4P-o}nd&Z0Ha6=@gU}(CQoV2F zYPHrY4@dQz8l;22dGbUJqrdMz6mK_*0?nn9j7G!oiLgH8tKs3~FcQeiGOXfd%#Od3 zOCdnDRv_gmDh@7PtW<7RWi)TXVRXMIdCUAWgj!Bw&w=y{Wq#E+>{#oNZdHw^2>-`n zXRkZJ5XRD%HO%PbQZ7$A5Rm6xM4FqXhvfb(p5JmmR-;Y>Ek1D<-iY-XGZut*tbSzm6ocj^>b&JUL3<0lhG-hO(6K0U%XNqVrQ%Xh zfs^ZdZN*lJ13j8YI27<9=;V`gw`cW1rUk0a3TrtGGr>bQEFV7nCPOo!b6BGgM2l~* z6^f-8t1;%9qsEaUe(q$X`9*Octmyoo6(*$1B~kLIv9WPrrSIaU0yKDAh_NNa6biC~ zA{@<)p>`l^s6E64Vh;fs!x2ypTVNFsim*2aPVn)-c)><|T!KdYFkW6`ehAb^(1c6K z$OHy6G66$joVIq>CdRf~H4P%-z%mt`BNSn04!03y0CRFN&_QjC;U>TmmMDXRJ&Z$; zL0nAa!obBUXAiM4hW@Sp z9|^n5({`MIt2Tg_FgU^*Vh>G`SH8bB9j z{y);pBgp@Y<{yg0L@pWdGeKfMt`x3_)VZq>oq&Rbbn(Igh-83(TG@d(FW&#}DWD3q zg4hEju`@HbwY$pd|1;`<#h*F=sDIT-r+$g?--P-<o0qlPOH+Hz78R#;dzbOX& zK7xTx9fB~0+KV!P{@7H&KqqG+%Am>%10F^YAtPe}K3;B^2?HJIf6XGIQ^&o@ry@4r2oJ5oWC&_Fcva4x#&tJMtr9T&&hp1qD^w~VEfzoL-V)_WB zI9_vLCB3$^oaDTN2YJPcnCc}v9Vr&%!Tr)6Vt9Jiqcj&!_lZr*?Rg5ED&lKe4x*Sr z!oi$z$ebcmlPw+bWG-f_f?{~-`_lmkXKgJW0Qtu^mZoe z?w?9WB~gEJd^Nzg;CXX^S18y&9fZtyu5za%*L#78jO@m9!5pK|YoS9Gd4(8w^Mbg_ zuNOEls}cf=Xgt-H^TCrG=FW0UillFJ-aDPR2t3f|OWh4)C?_u->h2q{qK~IwhzCu& zBmJoFD`>Rus@@XXB@i%~WJmxnS4Unx6 z&DP}jjN@bCD8PFj6Fa<7>azXD4Sh^KSGL5=wL_P=VJjYM@pRX^oZP#J-)m9Ts#X2r z1N6RIYepjhd=(wJqJeTJ^kxDqd_IodO&iO|=I=Z68qBdyM2_zk zzGoswabIT@SpyhV9u=nrN|c5gO`fvZQ~i0m7CbcwMw z>)@FRu%xtq0%2(`VdSW0nwF2Q&=Sqjpsar!pqB4`o82kEbq*knj6!_5!Q6A*;h#n9KSlps zn)@k=j{VPWt^c-A_otRW?^%Cp;U>G(^5-4wPX+&eP4rU%5%vGV71E#P|C~vGn%AZM zZC?E;`0w58ryxDUW#Ipzm;I^d&r<)XM}!dx>0b(8SpgkLEfNwo@N5KVl6mXm=)V9M Ca`cn{ literal 0 HcmV?d00001