From 8a545b7e7b67b62095af80c81a118637362b7cb3 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:26:40 +0900 Subject: [PATCH 01/19] docs(flash): one line on drivers, not a paragraph The right-hand UART port is the one that needs CP2102 / CH34x, and uploads moved to the left port a while ago, so it is now a port almost nobody uses. Explaining which links belong to it was more words than the situation deserves. Kept to one line rather than removed, because the flasher's own troubleshooting screen still shows those links, and someone who follows them from here gets nowhere. Co-Authored-By: Claude Opus 5 --- BUILD_GUIDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILD_GUIDE.md b/BUILD_GUIDE.md index ffeb86c..f986699 100644 --- a/BUILD_GUIDE.md +++ b/BUILD_GUIDE.md @@ -266,7 +266,7 @@ No installation required — desktop **Chrome or Edge** only (Web Serial; Firefo > > The picker should now offer a line like `USB JTAG/serial debug unit (COM4) – Paired`. The number depends on which USB port you used. > -> Still nothing? On this port it is almost always the **cable** — a charge-only USB-C cable enumerates nothing at all. The ESP32-S3 drives its native port from a USB-Serial/JTAG controller built into the chip, so there is no bridge chip and **no CP2102 / CH34x driver to install**; Windows 10 and later, macOS and Linux all recognise it on their own. The driver links on the flasher's "No port selected" screen are for the *right-hand* `UART` port (§8.2), and chasing them here is a dead end. +> Still nothing? It is almost always the cable — a charge-only USB-C one enumerates nothing at all. **There is no driver to install on this port**: the ESP32-S3 handles USB itself, so the CP2102 / CH34x links on the flasher's troubleshooting screen do not apply here. > 📶 **Changing Wi-Fi later.** The network you set during flashing is **saved on the device and reused on every boot** — it stays until you overwrite it. To move Patternflow to a different Wi-Fi, either **re-flash from the browser** (you'll set the new network during Improv provisioning), or in Arduino IDE do a **full erase** (Tools → *Erase All Flash Before Sketch Upload* → *Enabled*) and re-upload. A plain re-upload does **not** clear the stored credentials. From 9b6255a58c7f2b4dcfa4d6b51fa0e4ca5a17ab3e Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:41 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat(firmware):=20absolute=20param=20bus?= =?UTF-8?q?=20=E2=80=94=20Director=20can=20pin=20a=20look,=20hands=20can?= =?UTF-8?q?=20take=20it=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simone's performance-director branch, absorbed: MQTT param/1..4 (0..1000) with channel presets (Broadcast/Ch1-4/Live), retained channel snapshots for mid-join, a Director mode that overlays a local broker without touching the saved one, and PFParams:: helpers that give every pattern one line per knob instead of four. Physical encoder motion releases an absolute hold — the Director yields to whoever is in the room. One deliberate departure from his tree: descriptors now stamp PF_ABI_MODULE_VERSION 2 while the host API stays 1. His fork keeps everything at 1, which lets a new module load on pre-absolute firmware and read 12 bytes past the host's InputFrame — knobs silently freeze on garbage. With the bump, the old loader's exact-match check turns that into its existing clean refusal, and this loader accepts both generations. Also from his tree: /patterns multi-select → Download ZIP (browser-side fflate pack), the console-pause eviction guards it needs, and the JSON escaping his rework dropped is restored — a community pattern name with a quote in it must not read as a dead device. Co-Authored-By: Claude Fable 5 --- firmware/patternflow/abi/pf_abi.h | 27 +- firmware/patternflow/abi/pf_module.h | 10 +- firmware/patternflow/abi/pf_params.h | 151 +++ firmware/patternflow/pattern_registry.h | 31 +- firmware/patternflow/patternflow.ino | 39 +- firmware/patternflow/src/core_encoders.h | 8 + firmware/patternflow/src/core_module_loader.h | 6 +- firmware/patternflow/src/core_mqtt.h | 1066 ++++++++++++++--- firmware/patternflow/src/core_mqtt_http.h | 194 ++- firmware/patternflow/src/core_pack_select.h | 49 + firmware/patternflow/src/core_params.h | 3 + firmware/patternflow/src/core_patterns_http.h | 108 +- firmware/patternflow/src/mqtt_index.h | 237 ++-- firmware/patternflow/src/patterns_index.h | 91 +- 14 files changed, 1680 insertions(+), 340 deletions(-) create mode 100644 firmware/patternflow/abi/pf_params.h create mode 100644 firmware/patternflow/src/core_pack_select.h create mode 100644 firmware/patternflow/src/core_params.h diff --git a/firmware/patternflow/abi/pf_abi.h b/firmware/patternflow/abi/pf_abi.h index 0c85ef4..a1c0753 100644 --- a/firmware/patternflow/abi/pf_abi.h +++ b/firmware/patternflow/abi/pf_abi.h @@ -4,8 +4,25 @@ // - firmware (src/core_module_api.h fills PFHostAPI) // - modules (abi/pf_module.h wraps it in the familiar PF* namespaces) // -// Bump PF_ABI_VERSION whenever any struct layout, field order, or calling -// convention below changes. The loader refuses modules whose version differs. +// Versioning, two numbers with different jobs: +// +// PF_ABI_VERSION the HOST API generation, passed to the module in +// PFHostAPI.abi_version. Frozen at 1: every module +// ever shipped checks it with `!=`, so raising it +// would refuse the entire installed catalog. +// PF_ABI_MODULE_VERSION what a freshly built module stamps into its +// descriptor. Raised to 2 when the absolute-param +// fields were APPENDED to PFInputFrame — the layout +// prefix is unchanged, so the new host still runs +// v1 modules, but a v2 module on a pre-absolute +// host would read past the host's InputFrame and +// see garbage in paramAbsoluteActive[]. The old +// loader's exact-match check (`!= 1`) is what turns +// that silent corruption into a clean refusal. +// +// The loader accepts descriptor versions PF_ABI_VERSION..PF_ABI_MODULE_VERSION. +// Only ever APPEND fields to these structs; reordering or resizing existing +// members breaks the v1 prefix contract and needs a real generation bump. #pragma once #include @@ -13,6 +30,7 @@ #include #define PF_ABI_VERSION 1 +#define PF_ABI_MODULE_VERSION 2 // Mirrors src/core_encoders.h::InputFrame exactly. Both sides are built with // the same GCC for the same target, so layout matches field-for-field and @@ -25,6 +43,11 @@ typedef struct PFInputFrame { uint32_t now; bool knobAudioActive[4]; float knobAudioValue[4]; + // Absolute bus 0..1000 (Director / Show manager). Appended — older + // modules that never read these fields keep working without a rebuild. + // Modules that DO read them must be built as PF_ABI_MODULE_VERSION 2. + bool paramAbsoluteActive[4]; + uint16_t paramAbsolute[4]; } PFInputFrame; // Services the firmware provides to a loaded module. diff --git a/firmware/patternflow/abi/pf_module.h b/firmware/patternflow/abi/pf_module.h index 7400009..61ae521 100644 --- a/firmware/patternflow/abi/pf_module.h +++ b/firmware/patternflow/abi/pf_module.h @@ -30,6 +30,7 @@ #include #include "pf_abi.h" +#include "pf_params.h" #ifndef PF_PANEL_W #error "PF_PANEL_W not defined - build modules with -DPF_PANEL_W=" @@ -269,13 +270,16 @@ inline void init() { void pf__update_thunk(float dt, const PFInputFrame* input) { \ ns::update(dt, *input); \ } \ + /* Descriptor carries PF_ABI_MODULE_VERSION: this build reads the appended \ + absolute-param fields, and stamping 2 is what makes a pre-absolute \ + loader (exact-match on 1) refuse it instead of feeding it garbage. */ \ PFPatternModule pf__descriptor = { \ - PF_ABI_VERSION, PF_PANEL_W, PF_PANEL_H, nullptr, nullptr, \ - nullptr, nullptr, nullptr, \ + PF_ABI_MODULE_VERSION, PF_PANEL_W, PF_PANEL_H, nullptr, nullptr, \ + nullptr, nullptr, nullptr, \ }; \ } \ extern "C" const PFPatternModule* pf_module_entry(const PFHostAPI* api) { \ - if (!api || api->abi_version != PF_ABI_VERSION) return nullptr; \ + if (!api || api->abi_version < PF_ABI_VERSION) return nullptr; \ if (api->panel_w != PF_PANEL_W || api->panel_h != PF_PANEL_H) return nullptr; \ PFHost::api = api; \ pf__descriptor.name = ns::NAME; \ diff --git a/firmware/patternflow/abi/pf_params.h b/firmware/patternflow/abi/pf_params.h new file mode 100644 index 0000000..fa07913 --- /dev/null +++ b/firmware/patternflow/abi/pf_params.h @@ -0,0 +1,151 @@ +// Patternflow absolute param bus helpers (0..1000 wire scale). +// +// Shared by firmware presets and loadable modules. Priority per channel: +// 1. paramAbsoluteActive → lerp(min, max, v/1000) +// 2. knobAudioActive → lerp(min, max, 0..1) (Weather / audio) +// 3. knobDeltas → integrate with step +// +// License: MIT +#pragma once + +#include +#include + +namespace PFParams { + +inline float unit1000(uint16_t v) { + if (v > 1000) v = 1000; + return (float)v / 1000.0f; +} + +inline float lerp(float lo, float hi, float u) { + return lo + (hi - lo) * u; +} + +inline float clampf(float v, float lo, float hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +// Float param with linear absolute / audio / delta paths. +template +inline void apply(const Frame& input, int i, float* param, float lo, float hi, float step) { + if (!param || i < 0 || i > 3) return; + if (input.paramAbsoluteActive[i]) { + *param = clampf(lerp(lo, hi, unit1000(input.paramAbsolute[i])), lo, hi); + return; + } + if (input.knobAudioActive[i]) { + *param = clampf(lerp(lo, hi, input.knobAudioValue[i]), lo, hi); + return; + } + if (input.knobDeltas[i] != 0) { + *param = clampf(*param + (float)input.knobDeltas[i] * step, lo, hi); + } +} + +// Integer param. When wrap=true, values cycle through [lo, hi] inclusive. +template +inline void applyInt(const Frame& input, int i, int* param, int lo, int hi, int step, + bool wrap = false) { + if (!param || i < 0 || i > 3) return; + if (input.paramAbsoluteActive[i]) { + float u = unit1000(input.paramAbsolute[i]); + if (wrap) { + // 0..1000 maps across the full wrap range (e.g. hue 0..359). + int span = hi - lo + 1; + if (span <= 0) { + *param = lo; + } else { + int v = lo + (int)(u * (float)span); + if (v > hi) v = hi; + *param = v; + } + } else { + *param = lo + (int)(u * (float)(hi - lo) + 0.5f); + if (*param < lo) *param = lo; + if (*param > hi) *param = hi; + } + return; + } + if (input.knobAudioActive[i]) { + float u = input.knobAudioValue[i]; + if (u < 0.0f) u = 0.0f; + if (u > 1.0f) u = 1.0f; + if (wrap) { + int span = hi - lo + 1; + int v = lo + (int)(u * (float)span); + if (v > hi) v = hi; + *param = v; + } else { + *param = lo + (int)(u * (float)(hi - lo) + 0.5f); + if (*param < lo) *param = lo; + if (*param > hi) *param = hi; + } + return; + } + if (input.knobDeltas[i] == 0) return; + int next = *param + input.knobDeltas[i] * step; + if (wrap) { + int span = hi - lo + 1; + if (span <= 0) { + *param = lo; + return; + } + next = (next - lo) % span; + if (next < 0) next += span; + *param = next + lo; + } else { + if (next < lo) next = lo; + if (next > hi) next = hi; + *param = next; + } +} + +// Discrete index 0..count-1 (pattern modes, preset pickers). +template +inline void applyIndex(const Frame& input, int i, int* param, int count, int step = 1) { + if (!param || count <= 0 || i < 0 || i > 3) return; + if (input.paramAbsoluteActive[i]) { + int idx = (int)(unit1000(input.paramAbsolute[i]) * (float)count); + if (idx >= count) idx = count - 1; + *param = idx; + return; + } + if (input.knobAudioActive[i]) { + int idx = (int)(input.knobAudioValue[i] * (float)count); + if (idx >= count) idx = count - 1; + if (idx < 0) idx = 0; + *param = idx; + return; + } + if (input.knobDeltas[i] == 0) return; + int next = *param + input.knobDeltas[i] * step; + next %= count; + if (next < 0) next += count; + *param = next; +} + +// Unit param 0..1 with wrap on delta (typical hueBase / phase knobs). +template +inline void applyUnit(const Frame& input, int i, float* param, float step = 0.05f) { + if (!param || i < 0 || i > 3) return; + if (input.paramAbsoluteActive[i]) { + *param = unit1000(input.paramAbsolute[i]); + return; + } + if (input.knobAudioActive[i]) { + float u = input.knobAudioValue[i]; + if (u < 0.0f) u = 0.0f; + if (u > 1.0f) u = 1.0f; + *param = u; + return; + } + if (input.knobDeltas[i] == 0) return; + *param += (float)input.knobDeltas[i] * step; + *param = *param - floorf(*param); + if (*param < 0.0f) *param += 1.0f; +} + +} // namespace PFParams diff --git a/firmware/patternflow/pattern_registry.h b/firmware/patternflow/pattern_registry.h index 4b51b58..ecc99c6 100644 --- a/firmware/patternflow/pattern_registry.h +++ b/firmware/patternflow/pattern_registry.h @@ -52,10 +52,14 @@ struct PatternEntry { // The four members above are meaningless for a module: its code is not in // firmware.bin, so the loader dispatches instead. const char* modulePath; + // Pattern maps the MQTT absolute 0..1000 bus via PFParams (Director / + // Show manager). Presets declare it as ns::ABSOLUTE_READY; modules carry + // it in their sidecar .json. + bool absoluteReady; }; #define PATTERN_ENTRY(ns) \ - { ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr } + { ns::NAME, ns::KNOB_LABELS, ns::setup, ns::update, ns::draw, nullptr, ns::ABSOLUTE_READY } // To add a pattern: // - Module (the usual way now): build a .pfm and upload it — no rebuild. @@ -220,6 +224,30 @@ inline void readSidecarName(const char* modulePath, char* out, size_t outSize) { snprintf(out, outSize, "%s", json.substring(open + 1, close).c_str()); } +// Same sidecar, different key: whether the module was built against the +// absolute-param helpers. Missing sidecar or missing key both mean "no" — +// every module built before the bus existed is delta-only by definition. +inline bool readSidecarAbsoluteReady(const char* modulePath) { + char jsonPath[MODULE_PATH_BYTES]; + snprintf(jsonPath, sizeof(jsonPath), "%s", modulePath); + char* extension = strrchr(jsonPath, '.'); + if (!extension) return false; + snprintf(extension, sizeof(jsonPath) - (extension - jsonPath), ".json"); + + File metadata = FFat.open(jsonPath, FILE_READ); + if (!metadata) return false; + String json = metadata.readString(); + metadata.close(); + + int key = json.indexOf("\"absoluteReady\""); + if (key < 0) return false; + int colon = json.indexOf(':', key + 15); + if (colon < 0) return false; + String tail = json.substring(colon + 1); + tail.trim(); + return tail.startsWith("true"); +} + // Mount the partition the presets never needed. Label "ffat" is what the // shipped partition table calls it; passing the wrong label mounts nothing and // looks exactly like an empty filesystem. @@ -386,6 +414,7 @@ inline void buildPatternList() { for (int i = 0; i < numModules; i++) { patterns[NUM_PATTERNS++] = { moduleNames[i], MODULE_KNOB_LABELS, nullptr, nullptr, nullptr, modulePaths[i], + readSidecarAbsoluteReady(modulePaths[i]), }; } } diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino index 7924fe6..d078d94 100644 --- a/firmware/patternflow/patternflow.ino +++ b/firmware/patternflow/patternflow.ino @@ -138,14 +138,26 @@ void setup() { // into whatever the K2 info screen was last set to. PatternflowOsc::setRuntimeEnabled(prefs.getBool("osc_runtime", true)); PatternflowAudio::setRuntimeEnabled(prefs.getBool("audio_runtime", true)); - // Same idea for the MQTT role picked on /mqtt. Defaults to Off, so a - // device that has never been told otherwise never dials a broker. - PatternflowMqtt::setRole( - (PatternflowMqtt::Role)prefs.getUChar("mqtt_role", PatternflowMqtt::ROLE_OFF)); - // And the broker itself, typed in on the same page. Left at the compiled-in - // defaults (empty, unless someone set PF_MQTT_* in their secrets header) - // when nothing has been saved. + // MQTT channel + role from /mqtt. loadConfig first (broker + prefix + + // channel), then apply the saved role under the channel's constraints + // (Ch1–4 force Subscriber). Defaults stay Off, so a device that has never + // been told otherwise never dials a broker. PatternflowMqtt::loadConfig(); + { + auto savedRole = (PatternflowMqtt::Role)prefs.getUChar( + "mqtt_role", PatternflowMqtt::ROLE_OFF); + auto ch = PatternflowMqtt::currentChannel(); + if (savedRole == PatternflowMqtt::ROLE_OFF || + ch == PatternflowMqtt::CHANNEL_OFF) { + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, + PatternflowMqtt::ROLE_OFF); + } else { + PatternflowMqtt::applyChannel(ch, savedRole); + } + // Director mode (local authoring broker) survives reboots the same way + // the normal broker does. + PatternflowMqtt::applySavedMode(); + } // Start Wi-Fi non-blocking: boot does NOT wait for the join. OSC, OTA, // and the audio-react server are started from the connect edge in loop() @@ -651,6 +663,14 @@ void readInputFrame(InputFrame& input) { prevKnobs[i] = input.knobs[i]; } + // Physical encoder motion releases an absolute MQTT hold on that channel + // (Director / Show manager yield to hands-on control). Checked here, before + // OSC/MQTT/audio deltas are merged in, so only real knobs release — and + // core_mqtt's grace window ignores chatter right after an absolute set. + for (int i = 0; i < 4; i++) { + if (input.knobDeltas[i] != 0) PatternflowMqtt::releaseAbsolute(i); + } + for (int i = 0; i < 4; i++) { Button* button = logicalButton(i); input.btnPressed[i] = button->pressed(); @@ -680,6 +700,11 @@ void readInputFrame(InputFrame& input) { input.knobAudioActive[i] = PatternflowAudio::isActive(i); input.knobAudioValue[i] = PatternflowAudio::value(i); } + + // Absolute MQTT bus last, so it outranks everything above: held channels + // get their 0..1000 value and their deltas / audio flags cleared, which is + // what lets PFParams::apply pin the mapped parameter deterministically. + PatternflowMqtt::fillAbsolute(input); } // Legacy absolute audio-react path for older clients that still send k=N,v=F. diff --git a/firmware/patternflow/src/core_encoders.h b/firmware/patternflow/src/core_encoders.h index b794feb..6060101 100644 --- a/firmware/patternflow/src/core_encoders.h +++ b/firmware/patternflow/src/core_encoders.h @@ -131,6 +131,14 @@ struct InputFrame { // need audio-specific code. bool knobAudioActive[4]; float knobAudioValue[4]; + + // Absolute Director / Show-manager bus (MQTT param/1..4). Wire scale + // is integer 0..1000. When active, patterns that use PFParams::apply + // set mapped params from this instead of integrating deltas. + // Appended so older modules that only read the fields above still work + // — this struct is mirrored byte-for-byte by abi/pf_abi.h::PFInputFrame. + bool paramAbsoluteActive[4]; + uint16_t paramAbsolute[4]; }; Button btn1, btn2, btn3, btn4; diff --git a/firmware/patternflow/src/core_module_loader.h b/firmware/patternflow/src/core_module_loader.h index 8cfaebc..c67593b 100644 --- a/firmware/patternflow/src/core_module_loader.h +++ b/firmware/patternflow/src/core_module_loader.h @@ -689,7 +689,11 @@ inline bool load(fs::FS& filesystem, const char* path) { using Entry = const PFPatternModule* (*)(const PFHostAPI*); Entry entry = reinterpret_cast(entryAddress); active = entry(&hostAPI); - if (!active || active->abi_version != PF_ABI_VERSION || + // Descriptor version 1 (pre-absolute) and 2 (reads the appended + // absolute-param InputFrame fields) both run here — the host always fills + // the extended frame. Anything else is a layout we do not provide. + if (!active || active->abi_version < PF_ABI_VERSION || + active->abi_version > PF_ABI_MODULE_VERSION || active->panel_w != PANEL_RES_W || active->panel_h != PANEL_RES_H || !active->name || !active->knob_labels || !active->setup || !active->update || !active->draw) { diff --git a/firmware/patternflow/src/core_mqtt.h b/firmware/patternflow/src/core_mqtt.h index bc54e63..d68c69a 100644 --- a/firmware/patternflow/src/core_mqtt.h +++ b/firmware/patternflow/src/core_mqtt.h @@ -1,39 +1,32 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - MQTT sidechannel (knobs + pattern name over a broker) +// PatternFlow - MQTT sidechannel (knobs + pattern + absolute params) // -// The same shape as core_osc.h, pointed at brokers instead of DAWs. Where -// OSC talks to Ableton over UDP in device-local terms (indices, deltas), -// this publishes plain retained values on plain topics, so the other end -// can be a second panel, Home Assistant, or a shell one-liner: +// Topics per prefix (no wildcards — ACL-friendly exact list): // -// /knob/1..4 absolute click count, retained -// /pattern display name, retained +// /knob/1..4 absolute click counts (live; non-retain) +// /pattern display name (live; non-retain) +// /message banner text (broadcast: retain; channels: live) +// /param/1..4 absolute 0..1000 (live; non-retain) +// /snapshot compact JSON mid-join state (channels 1–5 only; retain) +// Director-only (local broker, not the public ACL): +// /query panel → inventory +// /select Director → mark modules on /patterns for ZIP export +// /select/ack panel → {matched,presets,missing,rev} // -// Retained is the point. A Subscriber that joins late still gets the -// current state — hence "turn on the second panel and it catches up" -// rather than "wait for the first knob to move". +// Retention policy (Performance Director proposal): +// Broadcast message retain +// Channel/Live snapshot retain +// Everything else non-retain // -// Roles, chosen on /mqtt and kept in NVS: -// Off socket closed, nothing published -// Publisher sends knob clicks + pattern name as they change -// Subscriber applies the retained snapshot, then follows live changes +// Roles on /mqtt: +// Off socket closed +// Publisher sends knobs + pattern (+ Live snapshot heartbeat) +// Subscriber applies snapshot (on channels) then follows live topics // -// Inbound values are absolute, but patterns only understand deltas, so -// applyRemoteKnob() differences against the previous value and queues the -// result. consumeKnobDelta() then reads exactly like the OSC one, which is -// why the sketch merges both with adjacent lines. -// -// Compiled in by default, but inert until a role is picked AND a broker -// host is configured — see the MQTT block in net_config.h. -// -// Include AFTER pattern_registry.h: name lookup walks patterns[], the same -// arrangement core_patterns_http.h uses. -// -// Originally written by Simone Majocchi (@SimonePDA) for his Patternflow -// fork and contributed upstream. Adapted here to this tree's pattern -// registry and given a non-blocking connect path; the protocol design, -// the role model, and the topic layout are his. +// Channel presets set the prefix: patternflow / patternflow1..4 / patternflow5. +// Channels 1–4 force Subscriber; Broadcast and Live allow Pub or Sub. // +// Include AFTER pattern_registry.h. // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once @@ -46,6 +39,7 @@ #include #include #include "pubsubclient/PubSubClient.h" +#include "core_pack_select.h" #endif namespace PatternflowMqtt { @@ -56,36 +50,45 @@ enum Role : uint8_t { ROLE_SUBSCRIBER = 2 }; +// UI / NVS channel preset. Off is role-only; Custom means freeform prefix. +enum Channel : uint8_t { + CHANNEL_OFF = 0, + CHANNEL_BROADCAST = 1, + CHANNEL_1 = 2, + CHANNEL_2 = 3, + CHANNEL_3 = 4, + CHANNEL_4 = 5, + CHANNEL_LIVE = 6, + CHANNEL_CUSTOM = 7 +}; + +// Normal = saved community/HA broker. Director = local anonymous overlay +// (host = PC LAN IP, port 1883, prefix patternflow, Subscriber). Normal +// credentials stay in NVS and come back when leaving Director mode. +enum MqttMode : uint8_t { + MQTT_MODE_NORMAL = 0, + MQTT_MODE_DIRECTOR = 1 +}; + #if PF_MQTT_ENABLED -// Long enough for any preset name or module slug; nothing in the registry -// is allowed to be longer on the wire. constexpr size_t NAME_BYTES = 48; +constexpr size_t MESSAGE_BYTES = 80; +constexpr size_t SNAPSHOT_BYTES = 384; -// Everything below exists because this runs in the single loop that also -// renders the panel and answers the web console. A broker that stops -// answering must cost that loop a bounded, occasional pause — never a -// multi-second stall every few seconds, which reads from outside as "the -// device is up (it pings, it accepts TCP) but the console is dead". -// -// Three things could block, and all three are now capped: -// DNS resolved once and cached, not on every attempt -// TCP setConnectionTimeout below -// handshake setSocketTimeout below constexpr uint32_t CONNECT_TIMEOUT_MS = 1500; constexpr uint32_t SOCKET_TIMEOUT_S = 1; -// Retry pacing. A broker that is simply gone should be retried rarely. constexpr uint32_t RECONNECT_MIN_MS = 5000; constexpr uint32_t RECONNECT_MID_MS = 15000; constexpr uint32_t RECONNECT_MAX_MS = 60000; -// After this many consecutive failures the cached address is dropped, in -// case the broker moved rather than went away. constexpr uint8_t RERESOLVE_AFTER = 4; +constexpr uint32_t SNAPSHOT_HEARTBEAT_MS = 8000; inline WiFiClient net; inline PubSubClient client(net); inline bool started = false; inline Role role = ROLE_OFF; +inline Channel channel = CHANNEL_BROADCAST; inline uint32_t lastReconnectMs = 0; inline char clientId[20] = {}; inline char lastError[80] = {}; @@ -96,20 +99,21 @@ inline bool haveKnob[4] = {false, false, false, false}; inline long lastRemoteKnob[4] = {0, 0, 0, 0}; inline int32_t pendingKnobDelta[4] = {0, 0, 0, 0}; inline int pendingPatternIdx = -1; +inline char overlayText[MESSAGE_BYTES] = {}; +inline uint32_t overlayExpiresMs = 0; +inline bool overlaySticky = false; inline IPAddress brokerIp; inline bool brokerResolved = false; inline uint8_t failures = 0; -// ── Broker settings, entered on /mqtt and kept in NVS ──────────────────── -// -// Typed in rather than compiled in, for the same reason Wi-Fi is: a broker -// is per-owner, and the alternative is either everybody sharing one set of -// credentials in the public source or nobody having a broker at all. The -// PF_MQTT_* macros remain as build-time defaults for people who do want to -// bake their own in; anything saved here wins over them. -// -// Sizes are deliberate — this is internal DRAM, which is the scarce thing on -// this board (see the measurements in net_config.h). 176 bytes total. +// Absolute param bus (held until physical encoder motion releases a channel). +inline bool paramHeld[4] = {false, false, false, false}; +inline uint16_t paramValue[4] = {500, 500, 500, 500}; +inline uint32_t paramHeldAtMs[4] = {0, 0, 0, 0}; +inline uint32_t lastSnapshotPubMs = 0; +// Ignore encoder chatter briefly after an absolute set (Director spam / noise). +constexpr uint32_t ABSOLUTE_RELEASE_GRACE_MS = 250; + constexpr size_t HOST_BYTES = 64; constexpr size_t USER_BYTES = 32; constexpr size_t PASS_BYTES = 48; @@ -120,30 +124,27 @@ inline char cfgUser[USER_BYTES] = PF_MQTT_USER; inline char cfgPass[PASS_BYTES] = PF_MQTT_PASS; inline char cfgPrefix[PREFIX_BYTES] = PF_MQTT_PREFIX; inline uint16_t cfgPort = PF_MQTT_PORT; +inline MqttMode mqttMode = MQTT_MODE_NORMAL; +inline char dirHost[HOST_BYTES] = {}; +// -2 idle, -1 header, 0..n-1 items, n end +inline int inventorySendIdx = -2; -// ── Message banner (@SimonePDA) ────────────────────────────────────────── -// -// Anything published to /message is drawn over the running pattern -// for a few seconds. Receive-only: a panel never publishes here, so the -// sender is Home Assistant, a script, or somebody with MQTT Explorer open. -// -// Deliberately a broadcast — every panel on the prefix shows it. There is no -// per-device topic on purpose: addressing one panel would need a wildcard in -// the broker's ACL, and a fixed topic list is what keeps a shared broker -// from becoming somewhere anyone can invent topics. -// -// Shown per receipt rather than held. A retained payload — which is how you -// would leave a note for a panel that is currently off — otherwise comes -// back on every reconnect and every reboot, and a banner you cannot clear by -// power-cycling is a fault, not a feature. -constexpr size_t MESSAGE_BYTES = 80; -inline char overlayText[MESSAGE_BYTES] = {}; -inline uint32_t overlayUntilMs = 0; +inline bool isDirectorMode() { return mqttMode == MQTT_MODE_DIRECTOR; } -inline bool hasBroker() { return cfgHost[0] != '\0'; } +inline const char* livePrefix() { + return isDirectorMode() ? "patternflow" : cfgPrefix; +} + +inline const char* connectHost() { + return isDirectorMode() ? dirHost : cfgHost; +} + +inline uint16_t connectPort() { + return isDirectorMode() ? (uint16_t)1883 : cfgPort; +} + +inline bool hasBroker() { return connectHost()[0] != '\0'; } -// Back off as failures pile up: a broker that is down stays down, and each -// attempt costs the loop up to CONNECT_TIMEOUT_MS. inline uint32_t reconnectDelayMs() { if (failures == 0) return RECONNECT_MIN_MS; if (failures < RERESOLVE_AFTER) return RECONNECT_MID_MS; @@ -162,9 +163,81 @@ inline const char* roleName(Role r) { } } +inline const char* channelName(Channel c) { + switch (c) { + case CHANNEL_OFF: return "off"; + case CHANNEL_BROADCAST: return "broadcast"; + case CHANNEL_1: return "ch1"; + case CHANNEL_2: return "ch2"; + case CHANNEL_3: return "ch3"; + case CHANNEL_4: return "ch4"; + case CHANNEL_LIVE: return "live"; + case CHANNEL_CUSTOM: return "custom"; + default: return "custom"; + } +} + +inline Channel channelFromName(const char* name) { + if (!name || !name[0]) return CHANNEL_CUSTOM; + if (strcasecmp(name, "off") == 0 || strcasecmp(name, "none") == 0) return CHANNEL_OFF; + if (strcasecmp(name, "broadcast") == 0 || strcasecmp(name, "bc") == 0) return CHANNEL_BROADCAST; + if (strcasecmp(name, "ch1") == 0 || strcmp(name, "1") == 0) return CHANNEL_1; + if (strcasecmp(name, "ch2") == 0 || strcmp(name, "2") == 0) return CHANNEL_2; + if (strcasecmp(name, "ch3") == 0 || strcmp(name, "3") == 0) return CHANNEL_3; + if (strcasecmp(name, "ch4") == 0 || strcmp(name, "4") == 0) return CHANNEL_4; + if (strcasecmp(name, "live") == 0 || strcasecmp(name, "ch5") == 0 || strcmp(name, "5") == 0) { + return CHANNEL_LIVE; + } + if (strcasecmp(name, "custom") == 0) return CHANNEL_CUSTOM; + return CHANNEL_CUSTOM; +} + +inline const char* prefixForChannel(Channel c) { + switch (c) { + case CHANNEL_BROADCAST: return "patternflow"; + case CHANNEL_1: return "patternflow1"; + case CHANNEL_2: return "patternflow2"; + case CHANNEL_3: return "patternflow3"; + case CHANNEL_4: return "patternflow4"; + case CHANNEL_LIVE: return "patternflow5"; + default: return nullptr; + } +} + +inline Channel channelFromPrefix(const char* prefix) { + if (!prefix || !prefix[0]) return CHANNEL_CUSTOM; + if (strcmp(prefix, "patternflow") == 0) return CHANNEL_BROADCAST; + if (strcmp(prefix, "patternflow1") == 0) return CHANNEL_1; + if (strcmp(prefix, "patternflow2") == 0) return CHANNEL_2; + if (strcmp(prefix, "patternflow3") == 0) return CHANNEL_3; + if (strcmp(prefix, "patternflow4") == 0) return CHANNEL_4; + if (strcmp(prefix, "patternflow5") == 0) return CHANNEL_LIVE; + return CHANNEL_CUSTOM; +} + +inline bool channelForcesSubscriber(Channel c) { + return c >= CHANNEL_1 && c <= CHANNEL_4; +} + +inline bool isBroadcastPrefix(const char* prefix) { + return prefix && strcmp(prefix, "patternflow") == 0; +} + +// patternflow1..5 — timed shows + Live (snapshot retain applies). +inline bool isChannelShowPrefix(const char* prefix) { + if (!prefix) return false; + if (strncmp(prefix, "patternflow", 11) != 0) return false; + char digit = prefix[11]; + return digit >= '1' && digit <= '5' && prefix[12] == '\0'; +} + +inline bool retainMessageTopic() { return isBroadcastPrefix(livePrefix()); } +inline bool retainSnapshotTopic() { return isChannelShowPrefix(livePrefix()); } +inline bool retainLeafTopic() { return false; } + inline const char* stateText() { if (!hasBroker()) return "no broker configured"; - if (role == ROLE_OFF) return "off"; + if (role == ROLE_OFF || channel == CHANNEL_OFF) return "off"; if (WiFi.status() != WL_CONNECTED) return "wifi down"; if (client.connected()) return "connected"; switch (client.state()) { @@ -182,15 +255,53 @@ inline const char* stateText() { } inline void topicKnob(char* buf, size_t n, int index) { - snprintf(buf, n, "%s/knob/%d", cfgPrefix, index + 1); + snprintf(buf, n, "%s/knob/%d", livePrefix(), index + 1); } inline void topicPattern(char* buf, size_t n) { - snprintf(buf, n, "%s/pattern", cfgPrefix); + snprintf(buf, n, "%s/pattern", livePrefix()); } inline void topicMessage(char* buf, size_t n) { - snprintf(buf, n, "%s/message", cfgPrefix); + snprintf(buf, n, "%s/message", livePrefix()); +} + +inline void topicParam(char* buf, size_t n, int index) { + snprintf(buf, n, "%s/param/%d", livePrefix(), index + 1); +} + +inline void topicSnapshot(char* buf, size_t n) { + snprintf(buf, n, "%s/snapshot", livePrefix()); +} + +inline void topicQuery(char* buf, size_t n) { + snprintf(buf, n, "%s/query", livePrefix()); +} + +inline void topicInventory(char* buf, size_t n) { + snprintf(buf, n, "%s/inventory", livePrefix()); +} + +inline void topicInventoryItem(char* buf, size_t n, int index) { + snprintf(buf, n, "%s/inventory/%d", livePrefix(), index); +} + +inline void topicInventoryEnd(char* buf, size_t n) { + snprintf(buf, n, "%s/inventory/end", livePrefix()); +} + +inline void topicSelect(char* buf, size_t n) { + snprintf(buf, n, "%s/select", livePrefix()); +} + +inline void topicSelectAck(char* buf, size_t n) { + snprintf(buf, n, "%s/select/ack", livePrefix()); +} + +inline bool topicIsPattern(const char* topic) { + char expected[48]; + topicPattern(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; } inline bool topicIsMessage(const char* topic) { @@ -199,9 +310,21 @@ inline bool topicIsMessage(const char* topic) { return topic && strcmp(topic, expected) == 0; } -inline bool topicIsPattern(const char* topic) { +inline bool topicIsSnapshot(const char* topic) { char expected[48]; - topicPattern(expected, sizeof(expected)); + topicSnapshot(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; +} + +inline bool topicIsQuery(const char* topic) { + char expected[48]; + topicQuery(expected, sizeof(expected)); + return topic && strcmp(topic, expected) == 0; +} + +inline bool topicIsSelect(const char* topic) { + char expected[48]; + topicSelect(expected, sizeof(expected)); return topic && strcmp(topic, expected) == 0; } @@ -216,8 +339,17 @@ inline int topicKnobIndex(const char* topic) { return strcmp(topic, expected) == 0 ? (n - 1) : -1; } -// "/patterns/cell_ripple.pfm" → "cell_ripple". Empty for a preset, whose -// modulePath is null because its code lives in firmware.bin. +inline int topicParamIndex(const char* topic) { + if (!topic) return -1; + const char* slash = strrchr(topic, '/'); + if (!slash || !slash[1]) return -1; + int n = atoi(slash + 1); + if (n < 1 || n > 4) return -1; + char expected[48]; + topicParam(expected, sizeof(expected), n - 1); + return strcmp(topic, expected) == 0 ? (n - 1) : -1; +} + inline void slugFromModulePath(const char* path, char* out, size_t n) { if (!path || !path[0]) { out[0] = '\0'; return; } const char* filename = strrchr(path, '/'); @@ -226,9 +358,161 @@ inline void slugFromModulePath(const char* path, char* out, size_t n) { if (extension) *extension = '\0'; } -// Display name first (that is what we publish), then a case-insensitive -// pass, then the on-disk slug — so both "Cell Ripple" and "cell_ripple" -// select the same pattern from a shell or an automation rule. +inline void patternSlugAt(int index, char* out, size_t n) { + out[0] = '\0'; + if (index < 0 || index >= NUM_PATTERNS || !patterns) return; + slugFromModulePath(patterns[index].modulePath, out, n); + if (out[0]) return; + const char* name = patterns[index].name ? patterns[index].name : ""; + size_t j = 0; + for (size_t i = 0; name[i] && j + 1 < n; ++i) { + char c = name[i]; + if (c >= 'A' && c <= 'Z') c = (char)(c - 'A' + 'a'); + if (c == ' ' || c == '-') c = '_'; + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + out[j++] = c; + } + } + out[j] = '\0'; +} + +inline void jsonEscape(const char* in, char* out, size_t n) { + size_t j = 0; + for (size_t i = 0; in && in[i] && j + 2 < n; ++i) { + unsigned char c = (unsigned char)in[i]; + if (c == '"' || c == '\\') { + if (j + 3 >= n) break; + out[j++] = '\\'; + out[j++] = (char)c; + } else if (c >= 0x20) { + out[j++] = (char)c; + } + } + out[j] = '\0'; +} + +inline void requestInventory() { + if (!isDirectorMode()) return; + inventorySendIdx = -1; +} + +inline int findPatternByName(const char* name); + +inline void publishSelectAck(uint8_t matched, uint8_t presets, uint8_t missing) { + if (!client.connected()) return; + char topic[56]; + char payload[128]; + topicSelectAck(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), + "{\"matched\":%u,\"presets\":%u,\"missing\":%u,\"rev\":%lu,\"total\":%u}", + (unsigned)matched, (unsigned)presets, (unsigned)missing, + (unsigned long)PatternflowPackSelect::rev, + (unsigned)PatternflowPackSelect::count); + client.publish(topic, payload, false); +} + +// Director → panel: newline-separated names or slugs. "clear" empties the +// list. A leading '+' appends (chunked sends). Only installed .pfm modules +// are marked — presets live in firmware and are not ZIP-exported. +inline void applyPackSelectPayload(uint8_t* payload, unsigned int length) { + if (!isDirectorMode()) return; + char body[481]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + while (n && (body[n - 1] == '\n' || body[n - 1] == '\r' || body[n - 1] == ' ')) { + body[--n] = '\0'; + } + char* start = body; + while (*start == ' ' || *start == '\n' || *start == '\r') start++; + + if (!start[0] || strcasecmp(start, "clear") == 0) { + PatternflowPackSelect::clear(); + publishSelectAck(0, 0, 0); + return; + } + + bool append = false; + if (start[0] == '+') { + append = true; + start++; + } + if (!append) PatternflowPackSelect::clear(); + + uint8_t matched = 0, presets = 0, missing = 0; + char* p = start; + while (*p) { + while (*p == '\n' || *p == '\r' || *p == ',') p++; + if (!*p) break; + char* tok = p; + while (*p && *p != '\n' && *p != '\r' && *p != ',') p++; + char saved = *p; + *p = '\0'; + while (*tok == ' ') tok++; + char* end = tok + strlen(tok); + while (end > tok && end[-1] == ' ') *--end = '\0'; + if (tok[0] && strcasecmp(tok, "clear") != 0) { + int idx = findPatternByName(tok); + if (idx < 0) { + missing++; + } else if (!patterns[idx].modulePath) { + presets++; + } else { + char slug[MODULE_NAME_BYTES]; + patternSlugAt(idx, slug, sizeof(slug)); + if (slug[0] && PatternflowPackSelect::add(slug)) matched++; + else missing++; + } + } + *p = saved; + if (saved) p++; + } + PatternflowPackSelect::bump(); + Serial.printf("[MQTT] pack select matched=%u presets=%u missing=%u\n", + (unsigned)matched, (unsigned)presets, (unsigned)missing); + publishSelectAck(matched, presets, missing); +} + +inline void publishInventoryItem(int index) { + if (index < 0 || index >= NUM_PATTERNS || !patterns) return; + char topic[56]; + topicInventoryItem(topic, sizeof(topic), index); + char nameEsc[NAME_BYTES * 2]; + char slug[NAME_BYTES]; + jsonEscape(patterns[index].name ? patterns[index].name : "", nameEsc, sizeof(nameEsc)); + patternSlugAt(index, slug, sizeof(slug)); + char payload[256]; + snprintf(payload, sizeof(payload), + "{\"i\":%d,\"name\":\"%s\",\"slug\":\"%s\",\"absoluteReady\":%s}", + index, nameEsc, slug, + patterns[index].absoluteReady ? "true" : "false"); + client.publish(topic, payload, false); +} + +inline void pumpInventory() { + if (inventorySendIdx < -1 || !isDirectorMode() || !client.connected()) return; + if (inventorySendIdx == -1) { + char topic[48]; + char payload[40]; + topicInventory(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), "{\"count\":%d}", NUM_PATTERNS); + client.publish(topic, payload, false); + inventorySendIdx = 0; + return; + } + if (inventorySendIdx < NUM_PATTERNS) { + publishInventoryItem(inventorySendIdx); + inventorySendIdx++; + return; + } + char topic[48]; + char payload[40]; + topicInventoryEnd(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), "{\"count\":%d}", NUM_PATTERNS); + client.publish(topic, payload, false); + inventorySendIdx = -2; +} + inline int findPatternByName(const char* name) { if (!name || !name[0] || !patterns) return -1; for (int i = 0; i < NUM_PATTERNS; ++i) { @@ -245,14 +529,6 @@ inline int findPatternByName(const char* name) { return -1; } -// Remote knobs arrive as absolute counts; patterns consume deltas. -// -// The first value after (re)subscribing is the retained snapshot, and -// catching up to it means closing the gap against OUR knob, not adding the -// publisher's number to ours: a panel sitting at 100 receiving a retained -// 417 has to move +317. (Taking the remote value as the delta only looks -// right from a fresh boot, where the local count happens to be 0.) -// Afterwards it is ordinary differencing between successive remotes. inline void applyRemoteKnob(int index, long remote) { if (index < 0 || index > 3) return; int32_t delta; @@ -271,28 +547,154 @@ inline void applyRemotePattern(const char* name) { if (index >= 0) pendingPatternIdx = index; } -/** - * Show a banner, or clear it. - * - * An empty payload clears — which is also how you retract a retained one, so - * "publish empty with retain" both wipes the topic and takes the banner off - * every panel watching it. - */ -inline void applyRemoteMessage(const char* text) { +inline void applyRemoteParam(int index, long value) { + if (index < 0 || index > 3) return; + if (value < 0) value = 0; + if (value > 1000) value = 1000; + paramHeld[index] = true; + paramValue[index] = (uint16_t)value; + paramHeldAtMs[index] = millis(); +} + +inline void releaseAbsolute(int index) { + if (index < 0 || index > 3) return; + // Physical release only after grace — avoids encoder noise dropping Director holds. + if (paramHeld[index] && + (millis() - paramHeldAtMs[index]) < ABSOLUTE_RELEASE_GRACE_MS) { + return; + } + paramHeld[index] = false; +} + +inline void clearAbsoluteAll() { + for (int i = 0; i < 4; ++i) { + paramHeld[i] = false; + paramHeldAtMs[i] = 0; + } +} + +// Copy held absolute values into the frame. Call after physical release. +// When absolute is active, clear deltas / audio flags on that channel so +// legacy paths cannot fight the Director set-point. +inline void fillAbsolute(InputFrame& input) { + for (int i = 0; i < 4; ++i) { + input.paramAbsoluteActive[i] = paramHeld[i]; + input.paramAbsolute[i] = paramValue[i]; + if (paramHeld[i]) { + input.knobDeltas[i] = 0; + input.knobAudioActive[i] = false; + } + } +} + +inline void applyRemoteMessage(uint8_t* payload, unsigned int length) { + overlaySticky = false; + char body[MESSAGE_BYTES]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + while (n && (body[n - 1] == '\n' || body[n - 1] == '\r' || body[n - 1] == ' ')) { + body[--n] = '\0'; + } + if (!body[0]) { + overlayText[0] = '\0'; + overlayExpiresMs = 0; + return; + } + snprintf(overlayText, sizeof(overlayText), "%s", body); + overlayExpiresMs = millis() + PF_MQTT_MESSAGE_DURATION_MS; +} + +// Show player: banner holds until the next message cue (empty clears). +inline void applyHeldMessage(const char* text) { if (!text || !text[0]) { overlayText[0] = '\0'; - overlayUntilMs = 0; + overlayExpiresMs = 0; + overlaySticky = false; return; } snprintf(overlayText, sizeof(overlayText), "%s", text); - overlayUntilMs = millis() + PF_MQTT_MESSAGE_DURATION_MS; + overlaySticky = true; + overlayExpiresMs = 0; +} + +// Minimal snapshot parser — looks for "pattern", "param":[a,b,c,d], "message". +// Empty payload clears absolute holds and the banner (end-of-show sweep). +inline void applyRemoteSnapshot(uint8_t* payload, unsigned int length) { + if (!payload || length == 0) { + clearAbsoluteAll(); + overlayText[0] = '\0'; + overlayExpiresMs = 0; + return; + } + + char body[SNAPSHOT_BYTES]; + unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; + memcpy(body, payload, n); + body[n] = '\0'; + + const char* patKey = strstr(body, "\"pattern\""); + if (patKey) { + const char* colon = strchr(patKey + 9, ':'); + if (colon) { + const char* q1 = strchr(colon + 1, '"'); + if (q1) { + const char* q2 = strchr(q1 + 1, '"'); + if (q2 && q2 > q1 + 1) { + char name[NAME_BYTES]; + size_t len = (size_t)(q2 - (q1 + 1)); + if (len >= sizeof(name)) len = sizeof(name) - 1; + memcpy(name, q1 + 1, len); + name[len] = '\0'; + applyRemotePattern(name); + } + } + } + } + + const char* paramKey = strstr(body, "\"param\""); + if (paramKey) { + const char* bracket = strchr(paramKey + 7, '['); + if (bracket) { + const char* p = bracket + 1; + for (int i = 0; i < 4; ++i) { + while (*p == ' ' || *p == '\t' || *p == ',') ++p; + char* end = nullptr; + long v = strtol(p, &end, 10); + if (end == p) break; + applyRemoteParam(i, v); + p = end; + } + } + } + + const char* msgKey = strstr(body, "\"message\""); + if (msgKey) { + const char* colon = strchr(msgKey + 9, ':'); + if (colon) { + const char* q1 = strchr(colon + 1, '"'); + if (q1) { + const char* q2 = strchr(q1 + 1, '"'); + if (q2) { + char msg[MESSAGE_BYTES]; + size_t len = (size_t)(q2 - (q1 + 1)); + if (len >= sizeof(msg)) len = sizeof(msg) - 1; + memcpy(msg, q1 + 1, len); + msg[len] = '\0'; + applyRemoteMessage((uint8_t*)msg, (unsigned int)len); + } + } + } + } } inline bool overlayActive() { if (!overlayText[0]) return false; - // millis() wraps after ~49 days; the subtraction is what makes that safe. - if ((int32_t)(millis() - overlayUntilMs) >= 0) { + if (overlaySticky) return true; + if (overlayExpiresMs == 0) return false; + if ((int32_t)(millis() - overlayExpiresMs) >= 0) { overlayText[0] = '\0'; + overlayExpiresMs = 0; return false; } return true; @@ -300,13 +702,31 @@ inline bool overlayActive() { inline const char* overlayMessage() { return overlayText; } -/** Milliseconds left on the banner, for the console. */ inline uint32_t overlayRemainingMs() { if (!overlayActive()) return 0; - return overlayUntilMs - millis(); + return overlayExpiresMs - millis(); } inline void onMessage(char* topic, uint8_t* payload, unsigned int length) { + if (isDirectorMode() && topicIsQuery(topic)) { + requestInventory(); + return; + } + if (isDirectorMode() && topicIsSelect(topic)) { + applyPackSelectPayload(payload, length); + return; + } + if (topicIsMessage(topic)) { + applyRemoteMessage(payload, length); + return; + } + if (topicIsSnapshot(topic)) { + // Snapshot is useful for any connected role on a channel (mid-join). + applyRemoteSnapshot(payload, length); + return; + } + if (role != ROLE_SUBSCRIBER) return; + char body[96]; unsigned int n = length < sizeof(body) - 1 ? length : sizeof(body) - 1; memcpy(body, payload, n); @@ -315,21 +735,19 @@ inline void onMessage(char* topic, uint8_t* payload, unsigned int length) { body[--n] = '\0'; } - // Before the subscriber gate on purpose: a banner is worth having on a - // one-panel setup, where the panel is the publisher and Home Assistant is - // the only thing talking back. Mirroring knobs is the part that only makes - // sense as a subscriber. - if (topicIsMessage(topic)) { - applyRemoteMessage(body); - return; - } - - if (role != ROLE_SUBSCRIBER) return; - if (topicIsPattern(topic)) { applyRemotePattern(body); return; } + int param = topicParamIndex(topic); + if (param >= 0) { + if (!body[0]) { + releaseAbsolute(param); + } else { + applyRemoteParam(param, atol(body)); + } + return; + } int knob = topicKnobIndex(topic); if (knob >= 0) applyRemoteKnob(knob, atol(body)); } @@ -339,22 +757,58 @@ inline void publishKnob(int index, long clicks) { char payload[16]; topicKnob(topic, sizeof(topic), index); snprintf(payload, sizeof(payload), "%ld", clicks); - client.publish(topic, payload, true); + client.publish(topic, payload, retainLeafTopic()); } inline void publishPatternName(const char* name) { char topic[48]; topicPattern(topic, sizeof(topic)); - client.publish(topic, name ? name : "", true); + client.publish(topic, name ? name : "", retainLeafTopic()); +} + +inline void publishParam(int index, uint16_t value) { + char topic[48]; + char payload[8]; + topicParam(topic, sizeof(topic), index); + if (value > 1000) value = 1000; + snprintf(payload, sizeof(payload), "%u", (unsigned)value); + client.publish(topic, payload, retainLeafTopic()); +} + +inline void publishChannelSnapshot(bool force) { + if (!retainSnapshotTopic()) return; + if (role != ROLE_PUBLISHER || !client.connected()) return; + uint32_t now = millis(); + if (!force && lastSnapshotPubMs != 0 && + (now - lastSnapshotPubMs) < SNAPSHOT_HEARTBEAT_MS) { + return; + } + lastSnapshotPubMs = now; + + char topic[48]; + char payload[SNAPSHOT_BYTES]; + topicSnapshot(topic, sizeof(topic)); + snprintf(payload, sizeof(payload), + "{\"pattern\":\"%s\",\"param\":[%u,%u,%u,%u],\"message\":\"\"}", + lastPattern[0] ? lastPattern : "", + (unsigned)paramValue[0], (unsigned)paramValue[1], + (unsigned)paramValue[2], (unsigned)paramValue[3]); + client.publish(topic, payload, true); } -inline void publishSnapshot() { +// Live leaf burst for currently connected peers (non-retain). Channel +// mid-join uses retained snapshot instead. +inline void publishLiveBurst() { if (!client.connected() || role != ROLE_PUBLISHER) return; for (int i = 0; i < 4; ++i) publishKnob(i, lastKnobs[i]); if (lastPattern[0]) { publishPatternName(lastPattern); snprintf(publishedPattern, sizeof(publishedPattern), "%s", lastPattern); } + for (int i = 0; i < 4; ++i) { + if (paramHeld[i]) publishParam(i, paramValue[i]); + } + publishChannelSnapshot(true); } inline void ensureClientId() { @@ -364,14 +818,6 @@ inline void ensureClientId() { (unsigned)((mac >> 24) & 0xFFFFFF)); } -// Each knob topic by name, rather than one `knob/+` subscription. -// -// A wildcard needs the broker to grant the wildcard. An ACL written as the -// exact list of topics a device may touch — which is how you would lock down -// a broker shared with strangers, and how @SimonePDA's is configured — -// refuses `patternflow/knob/+` outright, and the failure is silent: the -// device connects, reports "connected", and simply never receives a knob. -// Four subscriptions cost four small packets once per connect. inline void subscribeMessage() { char topic[48]; topicMessage(topic, sizeof(topic)); @@ -379,29 +825,70 @@ inline void subscribeMessage() { } inline void subscribeAll() { + subscribeMessage(); char topic[48]; for (int i = 0; i < 4; ++i) { topicKnob(topic, sizeof(topic), i); client.subscribe(topic); + topicParam(topic, sizeof(topic), i); + client.subscribe(topic); } topicPattern(topic, sizeof(topic)); client.subscribe(topic); + if (isChannelShowPrefix(livePrefix())) { + topicSnapshot(topic, sizeof(topic)); + client.subscribe(topic); + } + if (isDirectorMode()) { + topicQuery(topic, sizeof(topic)); + client.subscribe(topic); + topicSelect(topic, sizeof(topic)); + client.subscribe(topic); + } +} + +// Publisher on a channel also listens for snapshot (ops clear) + message. +inline void subscribePublisherExtras() { subscribeMessage(); + if (isChannelShowPrefix(livePrefix())) { + char topic[48]; + topicSnapshot(topic, sizeof(topic)); + client.subscribe(topic); + } +} + +inline void unsubscribeAll() { + if (!client.connected() || livePrefix()[0] == '\0') return; + char topic[48]; + topicMessage(topic, sizeof(topic)); + client.unsubscribe(topic); + topicPattern(topic, sizeof(topic)); + client.unsubscribe(topic); + topicSnapshot(topic, sizeof(topic)); + client.unsubscribe(topic); + topicQuery(topic, sizeof(topic)); + client.unsubscribe(topic); + topicSelect(topic, sizeof(topic)); + client.unsubscribe(topic); + for (int i = 0; i < 4; ++i) { + topicKnob(topic, sizeof(topic), i); + client.unsubscribe(topic); + topicParam(topic, sizeof(topic), i); + client.unsubscribe(topic); + } } -// A literal address costs nothing to "resolve"; a hostname costs a blocking -// DNS query, and PubSubClient would pay it again on every connect attempt. -// Do it once and hand it the address. inline bool resolveBroker() { if (brokerResolved) return true; - if (brokerIp.fromString(cfgHost)) { + const char* host = connectHost(); + if (brokerIp.fromString(host)) { brokerResolved = true; return true; } - if (WiFi.hostByName(cfgHost, brokerIp) == 1) { + if (WiFi.hostByName(host, brokerIp) == 1) { brokerResolved = true; Serial.printf("[MQTT] %s resolved to %s\n", - cfgHost, brokerIp.toString().c_str()); + host, brokerIp.toString().c_str()); return true; } setError("cannot resolve broker"); @@ -409,9 +896,14 @@ inline bool resolveBroker() { } inline void applyClientSettings() { +#if defined(ESP_ARDUINO_VERSION) && \ + ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) net.setConnectionTimeout(CONNECT_TIMEOUT_MS); +#else + net.setTimeout((CONNECT_TIMEOUT_MS + 999U) / 1000U); +#endif client.setCallback(onMessage); - client.setBufferSize(256); + client.setBufferSize(512); client.setKeepAlive(15); client.setSocketTimeout(SOCKET_TIMEOUT_S); } @@ -428,21 +920,19 @@ inline bool tryConnect() { ensureClientId(); applyClientSettings(); if (!resolveBroker()) { - if (++failures == 0) failures = RERESOLVE_AFTER; // never wrap to "healthy" + if (++failures == 0) failures = RERESOLVE_AFTER; return false; } - client.setServer(brokerIp, cfgPort); + client.setServer(brokerIp, connectPort()); bool ok; - if (cfgUser[0] != '\0') { + if (!isDirectorMode() && cfgUser[0] != '\0') { ok = client.connect(clientId, cfgUser, cfgPass); } else { ok = client.connect(clientId); } if (!ok) { if (++failures == 0) failures = RERESOLVE_AFTER; - // Enough refusals in a row and the address itself is suspect — drop it so - // the next attempt looks the name up again. if (failures >= RERESOLVE_AFTER) brokerResolved = false; snprintf(lastError, sizeof(lastError), "connect state %d", client.state()); Serial.printf("[MQTT] connect failed (%s), retry in %lus\n", @@ -452,54 +942,106 @@ inline bool tryConnect() { failures = 0; setError(""); - Serial.printf("[MQTT] connected as %s (%s) to %s:%d\n", - clientId, roleName(role), cfgHost, cfgPort); + Serial.printf("[MQTT] connected as %s (%s) mode=%s ch=%s prefix=%s to %s:%d\n", + clientId, roleName(role), + isDirectorMode() ? "director" : "normal", + channelName(channel), livePrefix(), + connectHost(), connectPort()); if (role == ROLE_SUBSCRIBER) { - // Forget remote baselines: the retained snapshot about to arrive is - // what we resync against. for (int i = 0; i < 4; ++i) haveKnob[i] = false; subscribeAll(); } else if (role == ROLE_PUBLISHER) { - publishSnapshot(); - // A publisher subscribes to exactly one thing: banners. It mirrors - // nobody's knobs, but there is no reason it should not be able to be - // told something. - subscribeMessage(); + subscribePublisherExtras(); + publishLiveBurst(); } return true; } -inline void setRole(Role next) { - if (next > ROLE_SUBSCRIBER) next = ROLE_OFF; - if (role == next) return; - role = next; +inline void resetSessionState() { for (int i = 0; i < 4; ++i) { haveKnob[i] = false; pendingKnobDelta[i] = 0; } pendingPatternIdx = -1; publishedPattern[0] = '\0'; - if (client.connected()) client.disconnect(); - // Picking a role on /mqtt is a deliberate "try now": clear the backoff so - // the user is not left waiting out a minute earned by an earlier outage. + overlayText[0] = '\0'; + overlayExpiresMs = 0; + overlaySticky = false; + lastSnapshotPubMs = 0; +} + +inline void setRole(Role next) { + if (next > ROLE_SUBSCRIBER) next = ROLE_OFF; + if (channelForcesSubscriber(channel) && next == ROLE_PUBLISHER) { + next = ROLE_SUBSCRIBER; + } + if (role == next) return; + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } + role = next; + if (next == ROLE_OFF) channel = CHANNEL_OFF; + else if (channel == CHANNEL_OFF) channel = channelFromPrefix(cfgPrefix); + resetSessionState(); failures = 0; lastReconnectMs = 0; Serial.printf("[MQTT] role → %s\n", roleName(role)); } +inline void applyChannel(Channel next, Role preferredRole) { + Channel prev = channel; + char prevPrefix[PREFIX_BYTES]; + snprintf(prevPrefix, sizeof(prevPrefix), "%s", cfgPrefix); + + if (next == CHANNEL_OFF) { + channel = CHANNEL_OFF; + role = ROLE_OFF; + } else { + channel = next; + const char* preset = prefixForChannel(next); + if (preset) { + snprintf(cfgPrefix, sizeof(cfgPrefix), "%s", preset); + } + if (channelForcesSubscriber(next)) { + role = ROLE_SUBSCRIBER; + } else if (preferredRole == ROLE_OFF) { + role = ROLE_SUBSCRIBER; + } else { + role = preferredRole; + } + } + + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } + resetSessionState(); + failures = 0; + lastReconnectMs = 0; + Serial.printf("[MQTT] channel → %s (was %s) prefix %s→%s role=%s\n", + channelName(channel), channelName(prev), + prevPrefix, cfgPrefix, roleName(role)); +} + inline Role currentRole() { return role; } +inline Channel currentChannel() { return channel; } inline bool isCompiledIn() { return true; } inline bool isConnected() { return client.connected(); } inline const char* error() { return lastError; } -inline const char* host() { return cfgHost; } -inline uint16_t port() { return cfgPort; } -inline const char* user() { return cfgUser; } -inline const char* prefix() { return cfgPrefix; } -/** Whether a password is set — the value itself never leaves the device. */ -inline bool hasPassword() { return cfgPass[0] != '\0'; } +inline const char* host() { return connectHost(); } +inline uint16_t port() { return connectPort(); } +inline const char* user() { return isDirectorMode() ? "" : cfgUser; } +inline const char* prefix() { return livePrefix(); } +inline bool hasPassword() { return !isDirectorMode() && cfgPass[0] != '\0'; } inline const char* lastPatternName() { return lastPattern; } - -// ── Settings persistence ───────────────────────────────────────────────── +inline const char* normalHost() { return cfgHost; } +inline const char* directorHost() { return dirHost; } +inline const char* modeName() { return isDirectorMode() ? "director" : "normal"; } +inline uint16_t normalPort() { return cfgPort; } +inline const char* normalUser() { return cfgUser; } +inline const char* normalPrefix() { return cfgPrefix; } +inline bool normalHasPassword() { return cfgPass[0] != '\0'; } inline void copyField(char* dest, size_t size, const char* src) { if (!src) src = ""; @@ -507,13 +1049,83 @@ inline void copyField(char* dest, size_t size, const char* src) { dest[size - 1] = '\0'; } -/** - * Load saved settings over the compiled-in defaults. - * - * Each key is read only if it exists, so a device that has saved a host but - * never touched the prefix keeps the built-in prefix rather than being handed - * an empty string. - */ +inline void applyConfigChange(); + +inline void persistMqttMode() { + Preferences prefs; + if (!prefs.begin("patternflow", false)) return; + prefs.putUChar("mq_mode", (uint8_t)mqttMode); + prefs.putString("mq_dirhost", dirHost); + prefs.end(); +} + +inline void applyDirectorOverlay() { + channel = CHANNEL_BROADCAST; + role = ROLE_SUBSCRIBER; +} + +inline void restoreNormalChannelAndRole() { + Preferences prefs; + Channel ch = CHANNEL_OFF; + Role savedRole = ROLE_OFF; + if (prefs.begin("patternflow", true)) { + ch = (Channel)prefs.getUChar("mq_channel", (uint8_t)CHANNEL_OFF); + savedRole = (Role)prefs.getUChar("mqtt_role", (uint8_t)ROLE_OFF); + prefs.end(); + } + if (savedRole == ROLE_OFF || ch == CHANNEL_OFF) { + applyChannel(CHANNEL_OFF, ROLE_OFF); + } else { + applyChannel(ch, savedRole); + } +} + +inline bool setDirectorHost(const char* host) { + copyField(dirHost, sizeof(dirHost), host); + persistMqttMode(); + if (isDirectorMode()) applyConfigChange(); + return dirHost[0] != '\0'; +} + +inline void setMqttMode(MqttMode next) { + if (next == MQTT_MODE_DIRECTOR && dirHost[0] == '\0') { + mqttMode = MQTT_MODE_DIRECTOR; + persistMqttMode(); + applyDirectorOverlay(); + applyConfigChange(); + setError("set Director PC IP"); + return; + } + if (mqttMode == next) { + if (next == MQTT_MODE_DIRECTOR) applyDirectorOverlay(); + return; + } + mqttMode = next; + persistMqttMode(); + if (next == MQTT_MODE_DIRECTOR) { + applyDirectorOverlay(); + applyConfigChange(); + } else { + restoreNormalChannelAndRole(); + applyConfigChange(); + } + Serial.printf("[MQTT] mode → %s\n", modeName()); +} + +inline void applySavedMode() { + if (mqttMode == MQTT_MODE_DIRECTOR) applyDirectorOverlay(); +} + +inline void persistChannelAndRole() { + if (isDirectorMode()) return; + Preferences prefs; + if (!prefs.begin("patternflow", false)) return; + prefs.putUChar("mqtt_role", (uint8_t)role); + prefs.putUChar("mq_channel", (uint8_t)channel); + prefs.putString("mq_prefix", cfgPrefix); + prefs.end(); +} + inline void loadConfig() { Preferences prefs; if (!prefs.begin("patternflow", true)) return; @@ -522,27 +1134,35 @@ inline void loadConfig() { if (prefs.isKey("mq_pass")) prefs.getString("mq_pass", cfgPass, sizeof(cfgPass)); if (prefs.isKey("mq_prefix")) prefs.getString("mq_prefix", cfgPrefix, sizeof(cfgPrefix)); if (prefs.isKey("mq_port")) cfgPort = prefs.getUShort("mq_port", cfgPort); + if (prefs.isKey("mq_dirhost")) prefs.getString("mq_dirhost", dirHost, sizeof(dirHost)); + if (prefs.isKey("mq_mode")) { + mqttMode = (MqttMode)prefs.getUChar("mq_mode", (uint8_t)MQTT_MODE_NORMAL); + } + if (prefs.isKey("mq_channel")) { + channel = (Channel)prefs.getUChar("mq_channel", (uint8_t)CHANNEL_BROADCAST); + } else { + channel = channelFromPrefix(cfgPrefix); + } prefs.end(); + if (channel == CHANNEL_OFF) { + role = ROLE_OFF; + } else if (channelForcesSubscriber(channel)) { + // Role may still be loaded separately; force Sub on timed channels. + } } -/** Drop the connection so the next attempt uses the new settings. */ inline void applyConfigChange() { brokerResolved = false; failures = 0; lastReconnectMs = 0; lastError[0] = '\0'; for (int i = 0; i < 4; ++i) haveKnob[i] = false; - if (client.connected()) client.disconnect(); + if (client.connected()) { + unsubscribeAll(); + client.disconnect(); + } } -/** - * Save what was entered on /mqtt. - * - * A null password means "leave it alone", which is what lets the page show a - * form without ever having been sent the current one — the field arrives - * empty and an empty field must not wipe a working login. Clearing is a - * separate, explicit act (see the clear route). - */ inline void saveConfig(const char* newHost, uint16_t newPort, const char* newUser, const char* newPass, const char* newPrefix) { copyField(cfgHost, sizeof(cfgHost), newHost); @@ -551,30 +1171,45 @@ inline void saveConfig(const char* newHost, uint16_t newPort, const char* newUse cfgPort = newPort ? newPort : 1883; if (newPass) copyField(cfgPass, sizeof(cfgPass), newPass); + Channel detected = channelFromPrefix(cfgPrefix); + if (detected != CHANNEL_CUSTOM) { + channel = detected; + if (channelForcesSubscriber(channel) && role == ROLE_PUBLISHER) { + role = ROLE_SUBSCRIBER; + } + } else if (channel != CHANNEL_OFF) { + channel = CHANNEL_CUSTOM; + } + Preferences prefs; if (prefs.begin("patternflow", false)) { prefs.putString("mq_host", cfgHost); prefs.putString("mq_user", cfgUser); prefs.putString("mq_prefix", cfgPrefix); prefs.putUShort("mq_port", cfgPort); + prefs.putUChar("mq_channel", (uint8_t)channel); + prefs.putUChar("mqtt_role", (uint8_t)role); if (newPass) prefs.putString("mq_pass", cfgPass); prefs.end(); } applyConfigChange(); } -/** Forget the broker entirely, including the password. */ inline void clearConfig() { cfgHost[0] = '\0'; cfgUser[0] = '\0'; cfgPass[0] = '\0'; cfgPort = 1883; + snprintf(cfgPrefix, sizeof(cfgPrefix), "%s", "patternflow"); + channel = CHANNEL_BROADCAST; Preferences prefs; if (prefs.begin("patternflow", false)) { prefs.remove("mq_host"); prefs.remove("mq_user"); prefs.remove("mq_pass"); prefs.remove("mq_port"); + prefs.putString("mq_prefix", cfgPrefix); + prefs.putUChar("mq_channel", (uint8_t)channel); prefs.end(); } applyConfigChange(); @@ -584,6 +1219,13 @@ inline void lastKnobsCopy(long out[4]) { for (int i = 0; i < 4; ++i) out[i] = lastKnobs[i]; } +inline void lastParamsCopy(uint16_t out[4], bool activeOut[4]) { + for (int i = 0; i < 4; ++i) { + out[i] = paramValue[i]; + activeOut[i] = paramHeld[i]; + } +} + inline void begin() { if (WiFi.status() != WL_CONNECTED) return; started = true; @@ -592,15 +1234,16 @@ inline void begin() { applyClientSettings(); lastReconnectMs = 0; if (hasBroker()) { - Serial.printf("[MQTT] ready — broker %s:%d page /mqtt\n", - cfgHost, cfgPort); + Serial.printf("[MQTT] ready — %s broker %s:%d page /mqtt\n", + isDirectorMode() ? "director" : "normal", + connectHost(), connectPort()); } else { Serial.println("[MQTT] compiled in, no broker set — see /mqtt"); } } inline void handle() { - if (!started || role == ROLE_OFF || !hasBroker()) { + if (!started || role == ROLE_OFF || channel == CHANNEL_OFF || !hasBroker()) { if (client.connected()) client.disconnect(); return; } @@ -614,6 +1257,8 @@ inline void handle() { return; } client.loop(); + pumpInventory(); + if (role == ROLE_PUBLISHER) publishChannelSnapshot(false); } inline int32_t consumeKnobDelta(int idx) { @@ -630,8 +1275,6 @@ inline bool consumePatternIdx(int& outIdx) { return true; } -// Called every frame: keeps the status page honest even when off, and -// publishes only the knobs that actually moved. inline void update(const InputFrame& input, const char* contentName) { for (int i = 0; i < 4; ++i) lastKnobs[i] = input.knobs[i]; if (contentName) snprintf(lastPattern, sizeof(lastPattern), "%s", contentName); @@ -642,14 +1285,13 @@ inline void update(const InputFrame& input, const char* contentName) { } } -// Separate from update() because the pattern name changes far less often -// than the knobs, and republishing it every frame would hammer the broker. inline void notePattern(const char* contentName) { if (contentName) snprintf(lastPattern, sizeof(lastPattern), "%s", contentName); if (role != ROLE_PUBLISHER || !client.connected() || !lastPattern[0]) return; if (strcmp(publishedPattern, lastPattern) == 0) return; snprintf(publishedPattern, sizeof(publishedPattern), "%s", lastPattern); publishPatternName(lastPattern); + publishChannelSnapshot(true); } #else @@ -659,11 +1301,25 @@ inline void handle() {} inline void update(const InputFrame&, const char*) {} inline void notePattern(const char*) {} inline void setRole(Role) {} +inline void applyChannel(Channel, Role) {} +inline void loadConfig() {} +inline void saveConfig(const char*, uint16_t, const char*, const char*, const char*) {} +inline void clearConfig() {} +inline void persistChannelAndRole() {} +inline void setMqttMode(MqttMode) {} +inline bool setDirectorHost(const char*) { return false; } +inline void applySavedMode() {} inline Role currentRole() { return ROLE_OFF; } +inline Channel currentChannel() { return CHANNEL_OFF; } inline bool isCompiledIn() { return false; } inline bool isConnected() { return false; } inline bool hasBroker() { return false; } +inline bool hasPassword() { return false; } +inline bool isDirectorMode() { return false; } inline const char* roleName(Role) { return "off"; } +inline const char* channelName(Channel) { return "off"; } +inline Channel channelFromName(const char*) { return CHANNEL_OFF; } +inline bool channelForcesSubscriber(Channel) { return false; } inline const char* stateText() { return "off (compile-time)"; } inline const char* error() { return ""; } inline const char* host() { return ""; } @@ -671,11 +1327,37 @@ inline uint16_t port() { return 0; } inline const char* user() { return ""; } inline const char* prefix() { return ""; } inline const char* lastPatternName() { return ""; } +inline const char* normalHost() { return ""; } +inline const char* directorHost() { return ""; } +inline const char* modeName() { return "normal"; } +inline uint16_t normalPort() { return 0; } +inline const char* normalUser() { return ""; } +inline const char* normalPrefix() { return ""; } +inline bool normalHasPassword() { return false; } inline void lastKnobsCopy(long out[4]) { for (int i = 0; i < 4; ++i) out[i] = 0; } +inline void lastParamsCopy(uint16_t out[4], bool activeOut[4]) { + for (int i = 0; i < 4; ++i) { + out[i] = 0; + activeOut[i] = false; + } +} inline int32_t consumeKnobDelta(int) { return 0; } -inline bool consumePatternIdx(int&) { return false; } +inline bool consumePatternIdx(int& outIdx) { return false; } +inline void applyRemoteParam(int, long) {} +inline void applyHeldMessage(const char*) {} +inline void clearAbsoluteAll() {} +inline bool overlayActive() { return false; } +inline const char* overlayMessage() { return ""; } +inline uint32_t overlayRemainingMs() { return 0; } +inline void releaseAbsolute(int) {} +inline void fillAbsolute(InputFrame& input) { + for (int i = 0; i < 4; ++i) { + input.paramAbsoluteActive[i] = false; + input.paramAbsolute[i] = 0; + } +} #endif diff --git a/firmware/patternflow/src/core_mqtt_http.h b/firmware/patternflow/src/core_mqtt_http.h index 907784f..643bf51 100644 --- a/firmware/patternflow/src/core_mqtt_http.h +++ b/firmware/patternflow/src/core_mqtt_http.h @@ -1,36 +1,25 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - MQTT role page (/mqtt) +// PatternFlow - MQTT role + channel page (/mqtt) // -// GET /mqtt role picker + live connection state +// GET /mqtt channel picker + role + broker form // GET /api/mqtt status JSON -// POST /api/mqtt role=off|publisher|subscriber (persisted in NVS) -// -// The broker itself is compile-time (patternflow_secrets.h): this page only -// chooses what the device DOES with it. Credentials are never sent to the -// browser — the JSON reports the host and user so you can confirm which -// broker is configured, and nothing else. -// -// Rides the patterns manager's WebServer, the same way core_display_http.h -// rides the status one, so the device still runs a single server on port 80. -// -// From Simone Majocchi's (@SimonePDA) Patternflow fork — see core_mqtt.h. +// POST /api/mqtt role=… and/or channel=… +// POST /api/mqtt/config broker fields (Normal mode) +// POST /api/mqtt/director host=LAN-IP (Director mode overlay) +// POST /api/mqtt/mode mode=normal|director +// POST /api/mqtt/forget // // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once #include "../net_config.h" -// Pulled in BEFORE the gate below, not inside it: PF_PATTERNS_HTTP_ENABLED is -// defined by that header rather than by net_config.h, so testing it first -// would read an undefined macro as 0 and silently drop this whole page. -#include "core_patterns_http.h" // shared WebServer + console-wake helpers +#include "core_patterns_http.h" #ifndef PF_MQTT_HTTP_ENABLED #define PF_MQTT_HTTP_ENABLED PF_MQTT_ENABLED #endif -// No patterns manager means no server to ride and no console-wake helpers, -// so the page cannot exist on its own. #if PF_MQTT_HTTP_ENABLED && PF_PATTERNS_HTTP_ENABLED #include #include @@ -54,12 +43,14 @@ inline void persistRole(PatternflowMqtt::Role role) { prefs.end(); } -// Escaped, because not every value here is ours any more. The banner text -// arrives from whoever can publish to /message, and a single quote -// character in it would otherwise end the string early and hand the console a -// JSON parse error — which looks like the device being broken rather than -// somebody having typed an apostrophe. Pattern names get the same treatment; -// they come from a community and nothing stops one containing a quote. +inline void persistChannelAndRole() { + PatternflowMqtt::persistChannelAndRole(); +} + +// Escaped, because not every value here is ours. Pattern names come from a +// community and nothing stops one containing a quote — unescaped it would end +// the JSON string early and hand the page a parse error that looks like the +// device being broken. inline void appendJsonString(String& json, const char* key, const char* value) { json += "\""; json += key; @@ -70,9 +61,6 @@ inline void appendJsonString(String& json, const char* key, const char* value) { json += '\\'; json += (char)c; } else if (c < 0x20) { - // Control characters are not legal raw in a JSON string. None of them - // mean anything on a 64px panel, so they become spaces rather than - // \u00xx escapes nobody will read. json += ' '; } else { json += (char)c; @@ -84,38 +72,57 @@ inline void appendJsonString(String& json, const char* key, const char* value) { inline void sendStatus(int code) { long knobs[4]; PatternflowMqtt::lastKnobsCopy(knobs); + uint16_t params[4]; + bool paramActive[4]; + PatternflowMqtt::lastParamsCopy(params, paramActive); String json = "{\"ok\":true,"; appendJsonString(json, "role", PatternflowMqtt::roleName(PatternflowMqtt::currentRole())); + appendJsonString(json, "channel", + PatternflowMqtt::channelName(PatternflowMqtt::currentChannel())); appendJsonString(json, "state", PatternflowMqtt::stateText()); appendJsonString(json, "host", PatternflowMqtt::host()); appendJsonString(json, "user", PatternflowMqtt::user()); appendJsonString(json, "prefix", PatternflowMqtt::prefix()); appendJsonString(json, "pattern", PatternflowMqtt::lastPatternName()); - appendJsonString(json, "message", PatternflowMqtt::overlayMessage()); appendJsonString(json, "error", PatternflowMqtt::error()); - json += "\"port\":"; + appendJsonString(json, "mode", PatternflowMqtt::modeName()); + appendJsonString(json, "directorHost", PatternflowMqtt::directorHost()); + appendJsonString(json, "normalHost", PatternflowMqtt::normalHost()); + appendJsonString(json, "normalUser", PatternflowMqtt::normalUser()); + appendJsonString(json, "normalPrefix", PatternflowMqtt::normalPrefix()); + json += "\"normalPort\":"; + json += PatternflowMqtt::normalPort(); + json += ",\"normalHasPassword\":"; + json += PatternflowMqtt::normalHasPassword() ? "true" : "false"; + json += ",\"port\":"; json += PatternflowMqtt::port(); json += ",\"connected\":"; json += PatternflowMqtt::isConnected() ? "true" : "false"; json += ",\"configured\":"; json += PatternflowMqtt::hasBroker() ? "true" : "false"; - // Whether a password is set, never the password. The page shows an empty - // field either way and sending it back would put a credential on the - // network on every poll for no benefit — nothing on the page needs to - // read it, only to replace it. json += ",\"hasPassword\":"; json += PatternflowMqtt::hasPassword() ? "true" : "false"; - // The banner currently on the panel, so the page can show what a message - // did without anyone having to be in the room. - json += ",\"messageMs\":"; - json += PatternflowMqtt::overlayRemainingMs(); + json += ",\"forcesSub\":"; + json += PatternflowMqtt::channelForcesSubscriber(PatternflowMqtt::currentChannel()) + ? "true" + : "false"; json += ",\"knobs\":["; for (int i = 0; i < 4; ++i) { if (i) json += ','; json += knobs[i]; } + json += "],\"params\":["; + for (int i = 0; i < 4; ++i) { + if (i) json += ','; + json += params[i]; + } + json += "],\"paramActive\":["; + for (int i = 0; i < 4; ++i) { + if (i) json += ','; + json += paramActive[i] ? "true" : "false"; + } json += "]}"; server().sendHeader("Cache-Control", "no-store"); @@ -137,39 +144,81 @@ inline void handleGet() { inline void handlePost() { PatternflowPatternsHttp::noteConsoleApiCall(); - String roleArg = server().hasArg("role") ? server().arg("role") : String(); - roleArg.toLowerCase(); + if (PatternflowMqtt::isDirectorMode()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"leave Director mode to change channel or role\"}"); + return; + } + + const bool hasChannel = server().hasArg("channel"); + const bool hasRole = server().hasArg("role"); + + if (!hasChannel && !hasRole) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"channel or role required\"}"); + return; + } + + if (hasChannel) { + String chArg = server().arg("channel"); + chArg.toLowerCase(); + PatternflowMqtt::Channel ch = PatternflowMqtt::channelFromName(chArg.c_str()); + PatternflowMqtt::Role preferred = PatternflowMqtt::currentRole(); + if (hasRole) { + String roleArg = server().arg("role"); + roleArg.toLowerCase(); + if (roleArg == "publisher" || roleArg == "pub") { + preferred = PatternflowMqtt::ROLE_PUBLISHER; + } else if (roleArg == "subscriber" || roleArg == "sub") { + preferred = PatternflowMqtt::ROLE_SUBSCRIBER; + } else if (roleArg == "off" || roleArg == "none") { + preferred = PatternflowMqtt::ROLE_OFF; + } + } + if (ch == PatternflowMqtt::CHANNEL_CUSTOM && preferred == PatternflowMqtt::ROLE_OFF) { + preferred = PatternflowMqtt::ROLE_SUBSCRIBER; + } + PatternflowMqtt::applyChannel(ch, preferred); + persistChannelAndRole(); + sendStatus(200); + return; + } + + String roleArg = server().arg("role"); + roleArg.toLowerCase(); PatternflowMqtt::Role next; if (roleArg == "publisher" || roleArg == "pub") { next = PatternflowMqtt::ROLE_PUBLISHER; } else if (roleArg == "subscriber" || roleArg == "sub") { next = PatternflowMqtt::ROLE_SUBSCRIBER; } else if (roleArg == "off" || roleArg == "none" || roleArg.length() == 0) { - next = PatternflowMqtt::ROLE_OFF; + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, PatternflowMqtt::ROLE_OFF); + persistChannelAndRole(); + sendStatus(200); + return; } else { server().send(400, "application/json", "{\"ok\":false,\"error\":\"role must be off, publisher, or subscriber\"}"); return; } - PatternflowMqtt::setRole(next); - persistRole(next); + PatternflowMqtt::Channel ch = PatternflowMqtt::currentChannel(); + if (ch == PatternflowMqtt::CHANNEL_OFF) ch = PatternflowMqtt::CHANNEL_BROADCAST; + PatternflowMqtt::applyChannel(ch, next); + persistChannelAndRole(); sendStatus(200); } -/** - * Save the broker settings typed on /mqtt. - * - * The password is the only field with a rule of its own: it is never sent to - * the page, so the form always posts it empty unless somebody typed a new - * one. An empty field therefore means "unchanged", not "blank it" — clearing - * is what the separate forget button is for. Without that, opening the page - * and pressing Save would silently drop a working login. - */ inline void handleConfig() { PatternflowPatternsHttp::noteConsoleApiCall(); + if (PatternflowMqtt::isDirectorMode()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"leave Director mode to edit the Normal broker\"}"); + return; + } + String host = server().hasArg("host") ? server().arg("host") : String(); host.trim(); @@ -191,16 +240,47 @@ inline void handleConfig() { PatternflowMqtt::saveConfig(host.c_str(), (uint16_t)port, user.c_str(), sentPassword ? pass.c_str() : nullptr, prefix.c_str()); + persistChannelAndRole(); + sendStatus(200); +} + +inline void handleDirector() { + PatternflowPatternsHttp::noteConsoleApiCall(); + String host = server().hasArg("host") ? server().arg("host") : String(); + host.trim(); + if (!host.length()) { + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"Director PC IP is required\"}"); + return; + } + PatternflowMqtt::setDirectorHost(host.c_str()); + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_DIRECTOR); sendStatus(200); } +inline void handleMode() { + PatternflowPatternsHttp::noteConsoleApiCall(); + String mode = server().hasArg("mode") ? server().arg("mode") : String(); + mode.toLowerCase(); + if (mode == "director") { + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_DIRECTOR); + sendStatus(200); + return; + } + if (mode == "normal") { + PatternflowMqtt::setMqttMode(PatternflowMqtt::MQTT_MODE_NORMAL); + sendStatus(200); + return; + } + server().send(400, "application/json", + "{\"ok\":false,\"error\":\"mode must be director or normal\"}"); +} + inline void handleForget() { PatternflowPatternsHttp::noteConsoleApiCall(); PatternflowMqtt::clearConfig(); - // A forgotten broker with a role still set would retry an empty host - // forever, so the role goes back to Off with it. - PatternflowMqtt::setRole(PatternflowMqtt::ROLE_OFF); - persistRole(PatternflowMqtt::ROLE_OFF); + PatternflowMqtt::applyChannel(PatternflowMqtt::CHANNEL_OFF, PatternflowMqtt::ROLE_OFF); + persistChannelAndRole(); sendStatus(200); } @@ -212,6 +292,8 @@ inline void begin() { server().on("/api/mqtt", HTTP_GET, handleGet); server().on("/api/mqtt", HTTP_POST, handlePost); server().on("/api/mqtt/config", HTTP_POST, handleConfig); + server().on("/api/mqtt/director", HTTP_POST, handleDirector); + server().on("/api/mqtt/mode", HTTP_POST, handleMode); server().on("/api/mqtt/forget", HTTP_POST, handleForget); initialized = true; diff --git a/firmware/patternflow/src/core_pack_select.h b/firmware/patternflow/src/core_pack_select.h new file mode 100644 index 0000000..a0a2e36 --- /dev/null +++ b/firmware/patternflow/src/core_pack_select.h @@ -0,0 +1,49 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - Director-marked modules for /patterns ZIP export +// +// MQTT (Director mode) writes this list; the Patterns page reads it and +// checks those rows. RAM only — send again after reboot. +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include +#include "../pattern_registry.h" + +namespace PatternflowPackSelect { + +constexpr uint8_t MAX = 64; + +inline uint32_t rev = 0; +inline uint8_t count = 0; +inline char slugs[MAX][MODULE_NAME_BYTES] = {}; + +inline void bump() { + rev++; + if (rev == 0) rev = 1; +} + +inline void clear() { + count = 0; + bump(); +} + +inline bool has(const char* slug) { + if (!slug || !slug[0]) return false; + for (uint8_t i = 0; i < count; i++) { + if (strcasecmp(slugs[i], slug) == 0) return true; + } + return false; +} + +// True if the slug is now in the list (already present or newly added). +inline bool add(const char* slug) { + if (!slug || !slug[0]) return false; + if (has(slug)) return true; + if (count >= MAX) return false; + snprintf(slugs[count], MODULE_NAME_BYTES, "%s", slug); + count++; + return true; +} + +} // namespace PatternflowPackSelect diff --git a/firmware/patternflow/src/core_params.h b/firmware/patternflow/src/core_params.h new file mode 100644 index 0000000..27aaf4b --- /dev/null +++ b/firmware/patternflow/src/core_params.h @@ -0,0 +1,3 @@ +// Firmware wrapper — absolute param helpers live in abi/ so modules share them. +#pragma once +#include "../abi/pf_params.h" diff --git a/firmware/patternflow/src/core_patterns_http.h b/firmware/patternflow/src/core_patterns_http.h index fd09c14..8cef105 100644 --- a/firmware/patternflow/src/core_patterns_http.h +++ b/firmware/patternflow/src/core_patterns_http.h @@ -8,6 +8,8 @@ // Routes (shares the audio-react server, so one port-80 server total): // GET /patterns management page // GET /api/patterns JSON list (presets + modules) +// GET /api/patterns/pending Director-marked slugs for ZIP export +// GET /api/patterns/file?slug=&ext=pfm|json // POST /api/patterns multipart upload of a .pfm / .json // DELETE /api/patterns?slug= // POST /api/patterns/delete body: one slug per line, or "*" for all @@ -33,6 +35,7 @@ #include "core_audio_ws.h" #endif #include "core_send.h" // low-heap page sender — pages serve WITHOUT pausing the pattern +#include "core_pack_select.h" #include "patterns_index.h" #include "fflate_js.h" #endif @@ -81,21 +84,37 @@ inline char restorePath[MODULE_PATH_BYTES] = {}; inline int restorePresetIdx = -1; inline bool restorePending = false; +inline void evictResidentModule() { + if (!PFModuleLoader::active) return; + PFModuleLoader::unload(); + if (activePatternIdx >= 0 && patterns && patterns[activePatternIdx].modulePath) { + activePatternIdx = -1; + } +} + inline void captureSelectionOnce() { - if (restorePending) return; + if (restorePending) { + // Show / night schedule / MQTT may reload a module while the console + // still holds the pause. Evict again or the wake page loops forever. + evictResidentModule(); + return; + } restorePending = true; restorePath[0] = '\0'; restorePresetIdx = -1; if (activePatternIdx >= 0 && patterns) { if (patterns[activePatternIdx].modulePath) { snprintf(restorePath, sizeof(restorePath), "%s", patterns[activePatternIdx].modulePath); + // Free the module's executable+data RAM before any body bytes arrive. + evictResidentModule(); } else { + // Compiled-in presets (Origin, Weather, …) keep running — the console + // pause exists to reclaim module DRAM, which they do not use. restorePresetIdx = activePatternIdx; } + } else { + evictResidentModule(); } - // Free the module's executable+data RAM before any body bytes arrive. - PFModuleLoader::unload(); - activePatternIdx = -1; } inline void restoreSelection() { @@ -132,6 +151,13 @@ inline void requestReload() { reloadRequestedAtMs = millis(); } // evicted. The sketch draws a PAUSED screen instead of a torn frame. inline bool isConsolePaused() { return restorePending; } +// Play Now / wake alarm owns the panel. Do not snap back to whatever was +// running before the console opened. +inline void releaseConsolePause() { + restorePending = false; + lastConsoleActivityMs = 0; +} + // A resident module and the web console cannot both have the RAM they need. // Opening a console page evicts the module (pausing the pattern); tick() // restores it once the console has been idle. This was removed for one day @@ -147,6 +173,9 @@ inline bool noteConsolePageOpened() { lastConsoleActivityMs = millis(); const bool hadModule = PFModuleLoader::active != nullptr; captureSelectionOnce(); + // If something put a module back before this return, drop it again so the + // wake reload can actually serve the console. + if (PFModuleLoader::active) evictResidentModule(); return hadModule; } @@ -293,10 +322,79 @@ inline void handleList() { } json += '}'; } + json += "],\"pendingRev\":"; + json += PatternflowPackSelect::rev; + json += ",\"pending\":["; + for (uint8_t i = 0; i < PatternflowPackSelect::count; i++) { + if (i) json += ','; + json += '"'; + json += PatternflowPackSelect::slugs[i]; + json += '"'; + } json += "]}"; sendJson(200, json); } +inline void handlePendingSelect() { + noteConsoleApiCall(); + String json = "{\"rev\":"; + json += PatternflowPackSelect::rev; + json += ",\"slugs\":["; + for (uint8_t i = 0; i < PatternflowPackSelect::count; i++) { + if (i) json += ','; + json += '"'; + json += PatternflowPackSelect::slugs[i]; + json += '"'; + } + json += "]}"; + sendJson(200, json); +} + +// Browser ZIP export fetches one file at a time (this server is +// one-connection). Slug is sanitized the same way uploads are. +inline void handleFile() { + noteConsoleApiCall(); + if (!moduleStorageMounted) { + sendJson(409, "{\"ok\":false,\"error\":\"storage not mounted\"}"); + return; + } + if (!server().hasArg("slug")) { + sendJson(400, "{\"ok\":false,\"error\":\"missing slug\"}"); + return; + } + char slug[MODULE_NAME_BYTES]; + if (!slugFromFilename(server().arg("slug") + ".pfm", slug, sizeof(slug))) { + sendJson(400, "{\"ok\":false,\"error\":\"invalid slug\"}"); + return; + } + String ext = server().hasArg("ext") ? server().arg("ext") : String("pfm"); + ext.toLowerCase(); + if (ext != "pfm" && ext != "json") { + sendJson(400, "{\"ok\":false,\"error\":\"ext must be pfm or json\"}"); + return; + } + char path[MODULE_PATH_BYTES]; + snprintf(path, sizeof(path), "%s/%s.%s", MODULE_DIR, slug, ext.c_str()); + if (!FFat.exists(path)) { + sendJson(404, "{\"ok\":false,\"error\":\"not found\"}"); + return; + } + File file = FFat.open(path, "r"); + if (!file) { + sendJson(500, "{\"ok\":false,\"error\":\"cannot open\"}"); + return; + } + char disposition[80]; + snprintf(disposition, sizeof(disposition), + "attachment; filename=\"%s.%s\"", slug, ext.c_str()); + server().sendHeader("Content-Disposition", disposition); + server().sendHeader("Cache-Control", "no-store"); + const char* ctype = + ext == "json" ? "application/json" : "application/octet-stream"; + server().streamFile(file, ctype); + file.close(); +} + // Remove one module's files. Shared by the single and the batch delete; the // caller owns the eviction and the rescan, because doing either per file is // what made clearing a library take a minute. @@ -737,6 +835,8 @@ inline void begin() { // the browser from the pattern's JS (shipped inside packs) — the device // never streams pixels. (/remote and /api/knob went with it — unused.) server().on("/api/patterns/select", HTTP_GET, handleSelect); + server().on("/api/patterns/pending", HTTP_GET, handlePendingSelect); + server().on("/api/patterns/file", HTTP_GET, handleFile); server().on("/api/patterns", HTTP_GET, handleList); server().on("/api/patterns", HTTP_POST, handleUploadDone, handleUpload); // Raw-body PUT is what the page actually uses: the WebServer's multipart diff --git a/firmware/patternflow/src/mqtt_index.h b/firmware/patternflow/src/mqtt_index.h index 189b1fc..3713f79 100644 --- a/firmware/patternflow/src/mqtt_index.h +++ b/firmware/patternflow/src/mqtt_index.h @@ -1,13 +1,5 @@ // ═══════════════════════════════════════════════════════════ -// PatternFlow - /mqtt role page (served by core_mqtt_http.h) -// Same cream/ink/LED tokens as the other consoles; no external assets. -// -// The broker is compile-time, so the only control here is the role. When -// no broker is configured the page says so and points at the secrets file -// rather than leaving you watching a connect loop fail. -// -// The role picker is Simone Majocchi's (@SimonePDA), from his Patternflow -// fork; restyled here to the device console's palette. +// PatternFlow - /mqtt channel + role page (served by core_mqtt_http.h) // License: MIT // ═══════════════════════════════════════════════════════════ #pragma once @@ -19,7 +11,6 @@ static const char MQTT_INDEX_HTML[] PROGMEM = R"HTML( Patternflow - MQTT

MQTT

-
-

Role

-
+ + + +
+
+

Channel

+ -
- - - +
+ + + + + + +
-

Pick one panel as publisher and another as subscriber to keep them in sync. - Values are retained on the broker, so a subscriber that joins late still catches up. - The choice survives a reboot.

-

Publish to patternflow/message and the text appears - over the running pattern for ten seconds — any connected role, empty payload clears it. - It is a broadcast: every panel subscribed on this prefix shows it. Use your own - prefix if you want messages that only reach your own panels.

+

Timed shows use Ch1–4 (subscribe only). Live is channel 5. Broadcast is the + community sandbox. Switching channel unsubscribes the previous prefix and resubscribes.

+
+ +
+

Role

+
+ + +
+

Broadcast and Live allow Pub or Sub. Ch1–4 are always Subscriber.

Broker

-
@@ -134,22 +159,22 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em}
-

The password is stored on the device and never sent back to this page — - leave it empty to keep the one already saved.

+

Channel buttons set the prefix. Edit Prefix only for a custom/local broker namespace, then Save.

State
-
+

Last values

Pattern
-
-
Banner
K1
-
K2
-
K3
-
K4
-
+
P1–4
-
@@ -159,47 +184,62 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} function $(i){return document.getElementById(i)} function say(t,cls){$('msg').textContent=t;$('msg').className=cls||''} -// The form is filled once, and again only after a save. This page polls -// every two seconds, and a poll that writes into the inputs would erase -// whatever was half-typed — so once the fields are populated the status -// updates leave them alone. var cfgFilled=false; function fillConfig(d){ - $('f-host').value=d.host||''; - $('f-port').value=d.port||1883; - $('f-user').value=d.user||''; - $('f-prefix').value=d.prefix||''; - // Never populated: the device does not send it back. The placeholder says - // which of the two empty-field meanings applies. + $('f-host').value=(d.normalHost!=null?d.normalHost:'')||''; + $('f-port').value=d.normalPort||1883; + $('f-user').value=d.normalUser||''; + $('f-prefix').value=d.normalPrefix||'patternflow'; $('f-pass').value=''; - $('f-pass').placeholder=d.hasPassword?'unchanged':'none set'; + $('f-pass').placeholder=d.normalHasPassword?'unchanged':'none set'; + if($('f-dirhost')) $('f-dirhost').value=d.directorHost||''; cfgFilled=true; } function paint(d){ var prefix=d.prefix||'patternflow'; + var ch=d.channel||'off'; + var mode=d.mode||'normal'; $('st').textContent=d.connected?'connected':(d.state||'offline'); if(!cfgFilled)fillConfig(d); + if($('f-dirhost') && d.directorHost!=null) $('f-dirhost').value=d.directorHost||$('f-dirhost').value; + if($('m-normal')) $('m-normal').className='ch'+(mode==='normal'?' on':''); + if($('m-director')) $('m-director').className='ch'+(mode==='director'?' on':''); + if($('directorbox')) $('directorbox').className=mode==='director'?'':'hidden'; + if($('normalbox')) $('normalbox').className=mode==='director'?'hidden':''; $('state').textContent=d.state||'-'; - // Only red when it is actually trying and failing: "off" and an - // unconfigured build are resting states, not faults. $('state').className=d.connected?'ok': - (d.role==='off'||!d.configured?'':'bad'); - $('unset').style.display=d.configured?'none':'block'; + (d.role==='off'||ch==='off'||!d.configured?'':'bad'); + $('unset').style.display=d.configured||mode==='director'?'none':'block'; $('pat').textContent=d.pattern||'-'; - // Counts down while it is on the panel, so you can tell a banner that just - // arrived from one that is about to go. - $('banner').textContent=d.messageMs>0 - ?(d.message||'(blank)')+' · '+Math.ceil(d.messageMs/1000)+'s' - :'-'; var k=d.knobs||[0,0,0,0]; for(var i=0;i<4;i++)$('k'+(i+1)).textContent=k[i]; - $('topics').textContent=prefix+'/knob/1..4 · '+prefix+'/pattern · '+prefix+'/message'; - $('msgtopic').textContent=prefix+'/message'; - ['off','publisher','subscriber'].forEach(function(r){ + var p=d.params||[0,0,0,0]; + var pa=d.paramActive||[false,false,false,false]; + $('params').textContent=p.map(function(v,i){return (pa[i]?v:'—')}).join(' · '); + var topics=prefix+'/knob/1..4 · '+prefix+'/pattern · '+prefix+'/param/1..4'; + if(ch==='ch1'||ch==='ch2'||ch==='ch3'||ch==='ch4'||ch==='live') + topics+=' · '+prefix+'/snapshot'; + if(mode==='director') + topics+=' · '+prefix+'/query · '+prefix+'/inventory/# · '+prefix+'/select'; + $('topics').textContent=topics; + + ['off','broadcast','ch1','ch2','ch3','ch4','live'].forEach(function(c){ + var el=$('c-'+c); + if(el)el.className='ch'+(ch===c?' on':''); + }); + var forces=!!d.forcesSub || ch==='ch1'||ch==='ch2'||ch==='ch3'||ch==='ch4'; + var showRole=ch!=='off'; + $('rolebox').className=showRole?'':'hidden'; + ['publisher','subscriber'].forEach(function(r){ var el=$('r-'+r); - if(el)el.className='role'+(d.role===r?' on':''); + if(!el)return; + el.disabled=forces && r==='publisher'; + el.className='role'+(d.role===r?' on':''); }); + $('rolenote').textContent=forces + ?'Channels 1–4 are Subscriber only (Show manager publishes).' + :'Broadcast and Live allow Publisher or Subscriber.'; if(d.error)say(d.error,'err'); } @@ -208,18 +248,30 @@ function load(){ .then(paint).catch(function(){say('cannot reach device','err')}); } +function postMqtt(body,okMsg){ + say('switching…'); + fetch('/api/mqtt',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:body}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say(okMsg,'good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +} + +Array.prototype.forEach.call(document.querySelectorAll('.ch'),function(btn){ + btn.onclick=function(){ + var channel=btn.getAttribute('data-channel'); + postMqtt('channel='+encodeURIComponent(channel),'channel: '+channel); + }; +}); + Array.prototype.forEach.call(document.querySelectorAll('.role'),function(btn){ btn.onclick=function(){ + if(btn.disabled)return; var role=btn.getAttribute('data-role'); - say('switching…'); - fetch('/api/mqtt',{method:'POST', - headers:{'Content-Type':'application/x-www-form-urlencoded'}, - body:'role='+encodeURIComponent(role)}) - .then(function(r){return r.json()}).then(function(d){ - if(d.ok===false){say(d.error||'failed','err');return} - say('role saved: '+role,'good'); - paint(d); - }).catch(function(){say('no reply from device','err')}); + postMqtt('role='+encodeURIComponent(role),'role saved: '+role); }; }); @@ -231,8 +283,6 @@ function cfgSay(t,cls){var e=$('cfgmsg');e.textContent=t;e.className='note'+(cls if(!host){cfgSay('a host is required — or press Forget','err');return} cfgSay('saving…'); $('save').disabled=true; - // The password rides only when something was typed. An empty field means - // "keep what is stored", so sending it would blank a working login. var body='host='+encodeURIComponent(host)+ '&port='+encodeURIComponent($('f-port').value||1883)+ '&user='+encodeURIComponent($('f-user').value.trim())+ @@ -260,6 +310,47 @@ function cfgSay(t,cls){var e=$('cfgmsg');e.textContent=t;e.className='note'+(cls }).catch(function(){cfgSay('no reply from device','err')}); }; +function dirSay(t,cls){var e=$('dirmsg');if(!e)return;e.textContent=t;e.className='note'+(cls?' '+cls:'')} + +$('m-normal').onclick=function(){ + fetch('/api/mqtt/mode',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'mode=normal'}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say('Normal MQTT','good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +}; + +$('m-director').onclick=function(){ + fetch('/api/mqtt/mode',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'mode=director'}) + .then(function(r){return r.json()}).then(function(d){ + if(d.ok===false){say(d.error||'failed','err');return} + say('Director MQTT — paste the PC IP','good'); + paint(d); + }).catch(function(){say('no reply from device','err')}); +}; + +$('dir').onsubmit=function(ev){ + ev.preventDefault(); + var host=$('f-dirhost').value.trim(); + if(!host){dirSay('paste the Director PC IP','err');return} + dirSay('connecting…'); + $('dirsave').disabled=true; + fetch('/api/mqtt/director',{method:'POST', + headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:'host='+encodeURIComponent(host)}) + .then(function(r){return r.json()}).then(function(d){ + $('dirsave').disabled=false; + if(d.ok===false){dirSay(d.error||'could not save','err');return} + dirSay('saved — connecting to '+host,'good'); + paint(d); + }).catch(function(){$('dirsave').disabled=false;dirSay('no reply from device','err')}); +}; + load(); setInterval(load,2000); )HTML"; diff --git a/firmware/patternflow/src/patterns_index.h b/firmware/patternflow/src/patterns_index.h index a56e024..8d161c5 100644 --- a/firmware/patternflow/src/patterns_index.h +++ b/firmware/patternflow/src/patterns_index.h @@ -159,6 +159,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em}

Installed

+ @@ -175,7 +176,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} function $(i){return document.getElementById(i)} var msg=$('msg'),listEl=$('list'),drop=$('drop'),file=$('file'),fs=$('fs'), qEl=$('q'),retryBtn=$('retry'),bulk=$('bulk'),bulkDel=$('bulkDel'),bulkN=$('bulkN'), - selAll=$('selAll'); + selAll=$('selAll'),bulkZip=$('bulkZip'); // Only modules get a checkbox — presets live in firmware.bin and are not // deletable — so these are also the count of what bulk actions can touch. @@ -196,6 +197,8 @@ function updateBulk(){ bulk.style.display=total?'flex':'none'; bulkDel.textContent='Delete selected ('+n+')'; bulkDel.disabled=n===0; + bulkZip.textContent='Download ZIP ('+n+')'; + bulkZip.disabled=n===0; selAll.textContent=(n===total&&total>0)?'Clear selection':'Select all'; bulkN.textContent=total?(n?'presets cannot be deleted':total+' module'+(total===1?'':'s')):''; } @@ -207,6 +210,35 @@ selAll.onclick=function(){ updateBulk(); }; +// Director MQTT select marks modules here. Rev changes when a new list arrives. +var pendingRev=-1; +function applyPendingSlugs(slugs,rev,announce,force){ + if(!force && rev===pendingRev)return; + var revChanged=rev!==pendingRev; + pendingRev=rev; + var set={}; + (slugs||[]).forEach(function(s){set[String(s).toLowerCase()]=1}); + var n=0; + selBoxes().forEach(function(c){ + var on=!!set[String(c.dataset.slug||'').toLowerCase()]; + c.checked=on; + if(on)n++; + }); + updateBulk(); + if(announce!==false && revChanged && (slugs||[]).length){ + var missed=(slugs||[]).length-n; + var msg='Director marked '+n+' module'+(n===1?'':'s')+' for download'; + if(missed>0)msg+=', '+missed+' not on this list'; + say(msg,n?'good':'err'); + } +} +function pollPending(){ + fetch('/api/patterns/pending',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){ + applyPendingSlugs(d.slugs||[], d.rev||0, true); + }).catch(function(){}); +} +setInterval(pollPending,2500); + function say(t,cls){msg.textContent=t;msg.className=cls||''} // A wait with a moving bar and a seconds count. Used for the requests the @@ -268,6 +300,7 @@ function load(){ } listEl.appendChild(li); }); + applyPendingSlugs(d.pending||[], d.pendingRev||0, false, true); updateBulk(); }).catch(function(){say('cannot reach device','err')}); } @@ -408,6 +441,62 @@ bulkDel.onclick=function(){ deleteSlugs(slugs,slugs.length+' module'+(slugs.length===1?'':'s')); }; +function fetchPatternFile(slug,ext){ + return fetch('/api/patterns/file?slug='+encodeURIComponent(slug)+'&ext='+ext,{cache:'no-store'}) + .then(function(r){ + if(r.status===404)return null; + if(!r.ok)throw new Error('could not read '+slug+'.'+ext); + return r.arrayBuffer().then(function(b){return new Uint8Array(b)}); + }); +} + +// Sequential on purpose: this HTTP server is one-connection. Parallel +// fetches pile up the same way a live preview once locked the panel. +bulkZip.onclick=function(){ + var slugs=selectedSlugs(); + if(!slugs.length)return; + busy('packing zip'); + bulkZip.disabled=true; + loadFflate().then(function(){ + var files={}; + var chain=Promise.resolve(); + slugs.forEach(function(slug,i){ + chain=chain.then(function(){ + say('packing zip — '+(i+1)+'/'+slugs.length); + return fetchPatternFile(slug,'pfm').then(function(pfm){ + if(!pfm)throw new Error(slug+'.pfm not on device'); + files[slug+'.pfm']=pfm; + return fetchPatternFile(slug,'json'); + }).then(function(js){ + if(js)files[slug+'.json']=js; + }); + }); + }); + return chain.then(function(){ + if(!window.fflate||typeof fflate.zipSync!=='function') + throw new Error('zip library missing zipSync'); + var zipped=fflate.zipSync(files,{level:6}); + var blob=new Blob([zipped],{type:'application/zip'}); + var a=document.createElement('a'); + a.href=URL.createObjectURL(blob); + a.download='patternflow-pack.zip'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function(){URL.revokeObjectURL(a.href)},2000); + busyDone(); + bulkZip.disabled=false; + updateBulk(); + say('downloaded '+slugs.length+' module'+(slugs.length===1?'':'s'),'good'); + }); + }).catch(function(e){ + busyDone(); + bulkZip.disabled=false; + updateBulk(); + say((e&&e.message)||'zip failed','err'); + }); +}; + function del(slug,name){ if(!confirm('Delete "'+name+'"?'))return; fetch('/api/patterns?slug='+encodeURIComponent(slug),{method:'DELETE'}) From 16b289bfa05c291c81d720cbce16a77b73783af2 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:08:50 +0900 Subject: [PATCH 03/19] feat(web): every pattern the site emits is absolute-ready from birth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pattern Lab scaffold owns knob accumulation in one deterministic loop, so swapping that loop for PFParams::apply makes every layered export absolute-ready with zero prompt changes — the LLM was never allowed near the knobs. The single-file conversion prompt is the one place a model writes update() by hand, so it now teaches the four PFParams helpers, requires ABSOLUTE_READY, and guards button resets against an active hold. build_module.py sidecars say abi 2 and carry absoluteReady, detected from the source's own declaration rather than assumed — a hand-written pattern that still integrates deltas stays honestly delta-only. Co-Authored-By: Claude Fable 5 --- firmware/toolchain/build_module.py | 11 ++++++++++- web/scripts/module-build-smoke.ts | 7 ++++++- web/src/lib/lab/cppPrompt.ts | 19 +++++++++++++++---- web/src/lib/lab/hExport.ts | 12 ++++++++---- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/firmware/toolchain/build_module.py b/firmware/toolchain/build_module.py index 0827083..7b111ad 100644 --- a/firmware/toolchain/build_module.py +++ b/firmware/toolchain/build_module.py @@ -22,6 +22,7 @@ import contextlib import json import os +import re import shutil import subprocess import sys @@ -201,10 +202,18 @@ def build_one(src_dir: Path, gxx: Path, compile_only: bool, abi: Path, meta_src = src_dir / "module.json" meta = json.loads(meta_src.read_text(encoding="utf-8")) if meta_src.is_file() else {} + # A module built from PFParams-converted source reads the appended + # absolute-param InputFrame fields, so its descriptor (and this sidecar, + # which the device's registry scan reads) says ABI 2. The flag mirrors + # the source's own declaration rather than assuming: a hand-written + # pattern that still integrates raw deltas stays delta-only. + source_text = (src_dir / "pattern.cpp").read_text(encoding="utf-8", errors="replace") + absolute_ready = bool(re.search(r"\bABSOLUTE_READY\s*=\s*true\b", source_text)) meta.update( { "slug": slug, - "abi": 1, + "abi": 2, + "absoluteReady": absolute_ready, "panel_w": PANEL_W, "panel_h": PANEL_H, "module": f"{slug}.pfm", diff --git a/web/scripts/module-build-smoke.ts b/web/scripts/module-build-smoke.ts index f86635c..77d06f1 100644 --- a/web/scripts/module-build-smoke.ts +++ b/web/scripts/module-build-smoke.ts @@ -88,7 +88,12 @@ async function main() { `got ${JSON.stringify(sidecar.name)}`); check("sidecar knob labels", Array.isArray(sidecar.knobs) && sidecar.knobs.length === 4); check("sidecar panel size", sidecar.panel_w === 128 && sidecar.panel_h === 64); - check("sidecar ABI version", sidecar.abi === 1); + // 2 since the absolute-param bus: descriptors stamp PF_ABI_MODULE_VERSION + // so pre-absolute loaders refuse new modules instead of misreading them. + check("sidecar ABI version", sidecar.abi === 2); + // This smoke pattern integrates deltas by hand, so the detector must NOT + // call it absolute-ready — converted sources flip this to true. + check("sidecar absoluteReady flag", sidecar.absoluteReady === false); console.log("\nrejects a malformed header"); const bad = await runModuleBuild([{ code: "not a pattern", label: "bad.h" }], { artifactDir }); diff --git a/web/src/lib/lab/cppPrompt.ts b/web/src/lib/lab/cppPrompt.ts index fbc9efd..1f03d3c 100644 --- a/web/src/lib/lab/cppPrompt.ts +++ b/web/src/lib/lab/cppPrompt.ts @@ -129,6 +129,7 @@ Define one unique namespace. Inside it expose exactly these symbols: const char* NAME = "Short Name"; const char* const KNOB_LABELS[4] = {"...", "...", "...", "..."}; + constexpr bool ABSOLUTE_READY = true; void setup(); void update(float dt, const InputFrame& input); void draw(); @@ -140,6 +141,7 @@ Always-required includes: #include "src/core_display.h" #include "src/core_encoders.h" #include "src/core_canvas.h" + #include "src/core_params.h" Conditional includes — only when actually used in your code: @@ -151,6 +153,10 @@ Conditional includes — only when actually used in your code: Helper signatures — these are the FULL argument lists. Call them exactly like this; do not add size arguments, reorder parameters, or invent overloads: + PFParams::apply(input, i, &floatParam, minF, maxF, stepF); // absolute-bus/audio/delta priority, clamped + PFParams::applyUnit(input, i, &floatParam, stepF); // 0..1 wrapping (hue/phase) + PFParams::applyInt(input, i, &intParam, minI, maxI, stepI, wrap); // wrap defaults to false + PFParams::applyIndex(input, i, &intParam, count, stepI); // discrete 0..count-1, stepI defaults to 1 PFMath::buildSinLUT(); // in setup(); idempotent float s = PFMath::fastSin(angleRadians); float c = PFMath::fastCos(angleRadians); @@ -215,13 +221,18 @@ Last resort — half-resolution rendering: if the pattern is genuinely smooth/lo ## Knob conversion - The JS preview uses input.knobValues as absolute values (after the Pattern Lab min/max ranges are applied). -- The firmware receives input.knobDeltas — the per-frame change in detents. +- The firmware receives input.knobDeltas — plus an absolute override bus. Never integrate deltas by hand; the PFParams helpers (src/core_params.h) do delta accumulation, clamping, AND the absolute-bus override in one call. - For each knob, store the parameter as state initialized to its current Pattern Lab value below. -- In update(): param += input.knobDeltas[i] * STEP[i]; then constrain to the min/max range. +- In update(), one helper call per knob — pick by the parameter's shape: + PFParams::apply(input, i, ¶m, MIN, MAX, STEP[i]); // float with a min/max range (the common case) + PFParams::applyUnit(input, i, ¶m, STEP[i]); // float 0..1 that WRAPS (hue, phase) + PFParams::applyInt(input, i, ¶m, MIN, MAX, STEP, wrap); // int range; wrap=true cycles (e.g. hue 0..359) + PFParams::applyIndex(input, i, ¶m, COUNT, STEP); // discrete index 0..COUNT-1 (mode pickers) - Use the calibrated encoder step below as STEP so physical encoders match the live editor and one detent feels the same on both. +- Declare constexpr bool ABSOLUTE_READY = true; right after KNOB_LABELS — it marks the pattern as driveable by the absolute bus (timed shows / Director). - If the JS reads input.knobNormalized[i] (e.g. generated layer-stack patterns), keep the raw knob state exactly as above and compute the normalized value from it each frame: (raw - min) / (max - min). Do NOT store the normalized value as the knob state itself. - Preserve knob meanings from the JS code (any comments naming the knobs) in KNOB_LABELS. Knobs the comments mark as unused get the label "-" and no update logic. -- Encoder buttons map 1:1: JS input.btnPressed[i] / input.btnHeld[i] become C++ input.btnPressed[i] / input.btnHeld[i] (same bool[4] semantics — edge vs level). If the JS pattern resets, freezes, or triggers on a button, keep that. Never consume long-press; that gesture is reserved for the firmware mode switcher. +- Encoder buttons map 1:1: JS input.btnPressed[i] / input.btnHeld[i] become C++ input.btnPressed[i] / input.btnHeld[i] (same bool[4] semantics — edge vs level). If the JS pattern resets, freezes, or triggers on a button, keep that — but guard resets so they cannot fight an absolute hold: if (input.btnPressed[i] && !input.paramAbsoluteActive[i] && !input.knobAudioActive[i]) { ... }. Never consume long-press; that gesture is reserved for the firmware mode switcher. Pattern Lab knob ranges and current values: ${rangeLines} @@ -241,7 +252,7 @@ Before finalizing your code block, verify each of these. If any answer is wrong, 2. Did I write my own hsvToRgb, sin LUT, atan2 approximation, noise function, or a sin-based hash — or build a hash from PFMath::fastSin? If yes, replace with the PFColor / PFMath / PFNoise helpers. 3. Does draw() end with PFCanvas::present();? 4. Are all pixel writes via PFCanvas::setPixel? Did I avoid touching dma_display? -5. Do my knob parameters consume input.knobDeltas (not input.knobValues), constrained to the documented range? +5. Does every knob go through a PFParams:: helper (no hand-written "param += input.knobDeltas[i] * step" accumulation, no reading input.knobValues), is ABSOLUTE_READY declared, and is every button reset guarded with !input.paramAbsoluteActive[i]? 6. Does every time accumulator wrap at its period (TWO_PI or a common multiple — see Performance)? An unbounded accumulator is a bug even if the preview looks fine. 7. Is every line valid C++ that will compile — no stray tokens, no placeholder text, no truncated statements? Re-read the block once before finalizing. 8. Did I declare any static array bigger than ~2 KB (e.g. float buf[PANEL_RES_W * PANEL_RES_H])? If yes, convert it to a PFMem::allocFloats pointer per the Memory rules. Did I call PFTables::init() without reading rT/thetaT anywhere? If yes, remove the call.${ diff --git a/web/src/lib/lab/hExport.ts b/web/src/lib/lab/hExport.ts index 37f4e03..a1a51ee 100644 --- a/web/src/lib/lab/hExport.ts +++ b/web/src/lib/lab/hExport.ts @@ -227,12 +227,16 @@ ${stack.map((layer, i) => `// L${i}. ${layer.name.replace(/\n/g, " ")} (${laye #include "src/core_mem.h" #include "src/core_math.h" #include "src/core_color.h" -#include "src/core_noise.h"${isPanelFrame ? `\n#include "src/core_tables.h"` : ""} +#include "src/core_noise.h" +#include "src/core_params.h"${isPanelFrame ? `\n#include "src/core_tables.h"` : ""} namespace ${ns} { const char* NAME = "${name.replace(/"/g, "'").slice(0, 24)}"; const char* const KNOB_LABELS[4] = {${knobLabels.map((label) => `"${label.replace(/"/g, "'").slice(0, 14)}"`).join(", ")}}; +// Knobs run through PFParams: the MQTT absolute bus (Director / Show +// manager, 0..1000) can pin any of them deterministically. +constexpr bool ABSOLUTE_READY = true; constexpr int FRAME_W = ${matrix.width}; constexpr int FRAME_H = ${matrix.height}; @@ -370,9 +374,9 @@ ${pixelDecodes ? `${pixelDecodes}\n` : ""}${layerSetups} void update(float dt, const InputFrame& input) { for (int i = 0; i < 4; i++) { - knobVal[i] += input.knobDeltas[i] * KNOB_STEP[i]; - if (knobVal[i] < KNOB_MIN[i]) knobVal[i] = KNOB_MIN[i]; - if (knobVal[i] > KNOB_MAX[i]) knobVal[i] = KNOB_MAX[i]; + // Absolute bus (when held) > audio/weather > encoder deltas — one call, + // same clamping the old delta loop did. + PFParams::apply(input, i, &knobVal[i], KNOB_MIN[i], KNOB_MAX[i], KNOB_STEP[i]); knobNorm[i] = (knobVal[i] - KNOB_MIN[i]) / (KNOB_MAX[i] - KNOB_MIN[i]); } ${layerUpdates} From d09691490481f4dbcec3e0dce2dbb4452b5aafbd Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:09:04 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20the=20whole=20catalog=20goes=20ab?= =?UTF-8?q?solute-ready=20=E2=80=94=20presets,=20Basics=20pack,=20DB=20too?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 33 showcase presets adopt Simone's PFParams conversions (his converter ran on exactly these sources; every diff reviewed, zero manual leftovers). Three of them — 0629, 0629_2, 0718 — kept his soft 0..10 range with the real clamp trailing behind, which squeezes an absolute sweep into a corner of the fader; their apply calls now carry the documented ranges. Basics pack rebuilt from the converted sources: same 33 patterns, sidecars now abi 2 + absoluteReady. migrate_community_absolute.py converts community code_cpp rows the same way, with two rules his script doesn't have: all-or-nothing per pattern (anything not fully claimed stays byte-identical and is reported, instead of being flagged ready with deltas still inside), and originals dumped to JSON before any write. Rows that are byte-copies of repo presets — 34 of the 43 in the dev DB — skip the regexes entirely and take the hand-checked converted file, matched by source hash. Dev DB migrated; the Pi runs the same command at deploy. Deck zips need no cache sweep — their fingerprint digests code_cpp. Verified: all 33 pack modules and all 21 regex-converted community headers compile as .pfm. Co-Authored-By: Claude Fable 5 --- firmware/patternflow/presets/preset_0510.h | 12 +- firmware/patternflow/presets/preset_0511.h | 10 +- firmware/patternflow/presets/preset_0512.h | 10 +- firmware/patternflow/presets/preset_0513.h | 15 +- firmware/patternflow/presets/preset_0514.h | 17 +- firmware/patternflow/presets/preset_0515.h | 17 +- firmware/patternflow/presets/preset_0515_3.h | 17 +- firmware/patternflow/presets/preset_0515_4.h | 17 +- firmware/patternflow/presets/preset_0518.h | 17 +- firmware/patternflow/presets/preset_0519_1.h | 17 +- firmware/patternflow/presets/preset_0520.h | 17 +- firmware/patternflow/presets/preset_0521.h | 17 +- firmware/patternflow/presets/preset_0522.h | 17 +- firmware/patternflow/presets/preset_0527.h | 17 +- firmware/patternflow/presets/preset_0528.h | 17 +- firmware/patternflow/presets/preset_0531.h | 17 +- firmware/patternflow/presets/preset_0601.h | 17 +- firmware/patternflow/presets/preset_0602.h | 17 +- firmware/patternflow/presets/preset_0628.h | 18 +- firmware/patternflow/presets/preset_0629.h | 22 +- firmware/patternflow/presets/preset_0629_2.h | 28 +- firmware/patternflow/presets/preset_0701.h | 18 +- firmware/patternflow/presets/preset_0707.h | 26 +- firmware/patternflow/presets/preset_0710.h | 18 +- firmware/patternflow/presets/preset_0712.h | 18 +- firmware/patternflow/presets/preset_0712_2.h | 26 +- firmware/patternflow/presets/preset_0713.h | 18 +- firmware/patternflow/presets/preset_0715.h | 14 +- firmware/patternflow/presets/preset_0716.h | 18 +- firmware/patternflow/presets/preset_0718.h | 27 +- firmware/patternflow/presets/preset_0719.h | 26 +- .../patternflow/presets/preset_a_big_hit.h | 10 +- firmware/patternflow/presets/preset_origin.h | 64 +-- .../patternflow/presets/preset_wave_saw.h | 36 +- .../toolchain/migrate_community_absolute.py | 471 ++++++++++++++++++ firmware/toolchain/preset_twin_hashes.json | 36 ++ web/public/packs/basics.json | 3 +- web/public/packs/basics.zip | Bin 115598 -> 126012 bytes 38 files changed, 759 insertions(+), 423 deletions(-) create mode 100644 firmware/toolchain/migrate_community_absolute.py create mode 100644 firmware/toolchain/preset_twin_hashes.json diff --git a/firmware/patternflow/presets/preset_0510.h b/firmware/patternflow/presets/preset_0510.h index 4d675ad..86ae501 100644 --- a/firmware/patternflow/presets/preset_0510.h +++ b/firmware/patternflow/presets/preset_0510.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0510 { const char* NAME = "0510"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "scale", "distortion"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -48,13 +50,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase += input.knobDeltas[0] * 0.05f; - if (params.hueBase < 0) params.hueBase += 1.0f; - params.hueBase = fmodf(params.hueBase, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.scale = max(0.1f, params.scale + input.knobDeltas[2] * 0.05f); - params.distortion = max(0.0f, params.distortion + input.knobDeltas[3] * 0.05f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.scale, 0.1f, 10.0f, 0.05f); + PFParams::apply(input, 3, ¶ms.distortion, 0.0f, 10.0f, 0.05f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0511.h b/firmware/patternflow/presets/preset_0511.h index fe6a233..c04edcb 100644 --- a/firmware/patternflow/presets/preset_0511.h +++ b/firmware/patternflow/presets/preset_0511.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0511 { const char* NAME = "0511"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "rowHeight", "segWidth"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -49,11 +51,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.rowHeight = constrain(params.rowHeight + input.knobDeltas[2] * 0.5f, 4.0f, 16.0f); - params.segWidth = constrain(params.segWidth + input.knobDeltas[3] * 1.0f, 8.0f, 48.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.rowHeight, 4.0f, 16.0f, 0.5f); + PFParams::apply(input, 3, ¶ms.segWidth, 8.0f, 48.0f, 1.0f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0512.h b/firmware/patternflow/presets/preset_0512.h index c89c26c..d4810c3 100644 --- a/firmware/patternflow/presets/preset_0512.h +++ b/firmware/patternflow/presets/preset_0512.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0512 { const char* NAME = "0512"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "petals", "fold"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -48,11 +50,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.petals = constrain(params.petals + input.knobDeltas[2] * 0.5f, 3.0f, 16.0f); - params.fold = constrain(params.fold + input.knobDeltas[3] * 0.05f, 0.0f, 5.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.petals, 3.0f, 16.0f, 0.5f); + PFParams::apply(input, 3, ¶ms.fold, 0.0f, 5.0f, 0.05f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0513.h b/firmware/patternflow/presets/preset_0513.h index 9444c63..10a3685 100644 --- a/firmware/patternflow/presets/preset_0513.h +++ b/firmware/patternflow/presets/preset_0513.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0513 { const char* NAME = "0513"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hue = 0.0f; @@ -74,15 +76,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - float d0 = 0.0f, d1 = 0.0f, d2 = 0.0f, d3 = 0.0f; - if (true) { - d0 = input.knobDeltas[0]; d1 = input.knobDeltas[1]; - d2 = input.knobDeltas[2]; d3 = input.knobDeltas[3]; - } - params.hueT = wrap01(params.hueT + d0 * 0.012f); - params.speedT = constrain(params.speedT + d1 * 0.018f, 0.0f, 1.0f); - params.modeT = constrain(params.modeT + d2 * 0.018f, 0.0f, 1.0f); - params.freqT = constrain(params.freqT + d3 * 0.018f, 0.0f, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.hueT, 0.012f); + PFParams::apply(input, 1, ¶ms.speedT, 0.0f, 1.0f, 0.018f); + PFParams::apply(input, 2, ¶ms.modeT, 0.0f, 1.0f, 0.018f); + PFParams::apply(input, 3, ¶ms.freqT, 0.0f, 1.0f, 0.018f); float s = constrain(dt * 7.5f, 0.0f, 1.0f); params.hue = mixHue(params.hue, params.hueT, s); params.speed = mix(params.speed, params.speedT, s); diff --git a/firmware/patternflow/presets/preset_0514.h b/firmware/patternflow/presets/preset_0514.h index 85eeb09..2988397 100644 --- a/firmware/patternflow/presets/preset_0514.h +++ b/firmware/patternflow/presets/preset_0514.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0514 { const char* NAME = "0514"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -64,17 +66,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515.h b/firmware/patternflow/presets/preset_0515.h index 79e6368..60d0759 100644 --- a/firmware/patternflow/presets/preset_0515.h +++ b/firmware/patternflow/presets/preset_0515.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0515 { const char* NAME = "0515"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515_3.h b/firmware/patternflow/presets/preset_0515_3.h index 4c40851..94db0f8 100644 --- a/firmware/patternflow/presets/preset_0515_3.h +++ b/firmware/patternflow/presets/preset_0515_3.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05153 { const char* NAME = "0515-3"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -63,17 +65,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0515_4.h b/firmware/patternflow/presets/preset_0515_4.h index 68a920a..ac986cf 100644 --- a/firmware/patternflow/presets/preset_0515_4.h +++ b/firmware/patternflow/presets/preset_0515_4.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05154 { const char* NAME = "0515-4"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -60,17 +62,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0518.h b/firmware/patternflow/presets/preset_0518.h index a9276c9..ec4a79a 100644 --- a/firmware/patternflow/presets/preset_0518.h +++ b/firmware/patternflow/presets/preset_0518.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0518 { const char* NAME = "0518"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -82,17 +84,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0519_1.h b/firmware/patternflow/presets/preset_0519_1.h index dcbc830..f94a40f 100644 --- a/firmware/patternflow/presets/preset_0519_1.h +++ b/firmware/patternflow/presets/preset_0519_1.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern05191 { const char* NAME = "0519-1"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -56,17 +58,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0520.h b/firmware/patternflow/presets/preset_0520.h index 620dbdc..e5e66e2 100644 --- a/firmware/patternflow/presets/preset_0520.h +++ b/firmware/patternflow/presets/preset_0520.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0520 { const char* NAME = "0520"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0521.h b/firmware/patternflow/presets/preset_0521.h index 4e86aba..6f76c9e 100644 --- a/firmware/patternflow/presets/preset_0521.h +++ b/firmware/patternflow/presets/preset_0521.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0521 { const char* NAME = "0521"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0522.h b/firmware/patternflow/presets/preset_0522.h index e6f162e..0d6df5b 100644 --- a/firmware/patternflow/presets/preset_0522.h +++ b/firmware/patternflow/presets/preset_0522.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0522 { const char* NAME = "0522"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -61,17 +63,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0527.h b/firmware/patternflow/presets/preset_0527.h index 7d014ad..00fcbd8 100644 --- a/firmware/patternflow/presets/preset_0527.h +++ b/firmware/patternflow/presets/preset_0527.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0527 { const char* NAME = "0527"; const char* const KNOB_LABELS[4] = {"Cluster Weight", "Rotation Rate", "Max Distance", "Normal Depth"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.4f; @@ -47,17 +49,10 @@ namespace Pattern0527 { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); params.nodeCount = 4 + floorf(params.rawKnob0 * 6); params.speed = params.rawKnob1; diff --git a/firmware/patternflow/presets/preset_0528.h b/firmware/patternflow/presets/preset_0528.h index 197c846..ef9c1a3 100644 --- a/firmware/patternflow/presets/preset_0528.h +++ b/firmware/patternflow/presets/preset_0528.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0528 { const char* NAME = "0528"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0531.h b/firmware/patternflow/presets/preset_0531.h index d02bcd8..a4eed71 100644 --- a/firmware/patternflow/presets/preset_0531.h +++ b/firmware/patternflow/presets/preset_0531.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0531 { const char* NAME = "0531"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0601.h b/firmware/patternflow/presets/preset_0601.h index 92dd3bd..37d4690 100644 --- a/firmware/patternflow/presets/preset_0601.h +++ b/firmware/patternflow/presets/preset_0601.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0601 { const char* NAME = "0601"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -57,17 +59,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0602.h b/firmware/patternflow/presets/preset_0602.h index 25a0f1c..8eb469f 100644 --- a/firmware/patternflow/presets/preset_0602.h +++ b/firmware/patternflow/presets/preset_0602.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace Pattern0602 { const char* NAME = "0602"; const char* const KNOB_LABELS[4] = {"Knob 1", "Knob 2", "Knob 3", "Knob 4"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float rawKnob0 = 0.0f; @@ -56,17 +58,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.rawKnob0 += input.knobDeltas[0] * 0.05f; - if (params.rawKnob0 < 0.0f) params.rawKnob0 += 1.0f; - params.rawKnob0 = fmodf(params.rawKnob0, 1.0f); - - params.rawKnob1 = constrain(params.rawKnob1 + input.knobDeltas[1] * 0.1f, 0.1f, 10.0f); - - params.rawKnob2 = constrain(params.rawKnob2 + input.knobDeltas[2] * 0.05f, 0.0f, 4.9f); - - params.rawKnob3 += input.knobDeltas[3] * 0.05f; - if (params.rawKnob3 < 0.0f) params.rawKnob3 += 1.0f; - params.rawKnob3 = fmodf(params.rawKnob3, 1.0f); + PFParams::applyUnit(input, 0, ¶ms.rawKnob0, 0.05f); + PFParams::apply(input, 1, ¶ms.rawKnob1, 0.1f, 10.0f, 0.1f); + PFParams::apply(input, 2, ¶ms.rawKnob2, 0.0f, 4.9f, 0.05f); + PFParams::applyUnit(input, 3, ¶ms.rawKnob3, 0.05f); const float knobs[4] = { params.rawKnob0, params.rawKnob1, params.rawKnob2, params.rawKnob3 }; diff --git a/firmware/patternflow/presets/preset_0628.h b/firmware/patternflow/presets/preset_0628.h index dc0b822..d340c03 100644 --- a/firmware/patternflow/presets/preset_0628.h +++ b/firmware/patternflow/presets/preset_0628.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace RetroDigitalTapestry { const char* NAME = "Retro Digital Tapestry"; const char* const KNOB_LABELS[4] = {"Cell Scale", "Speed", "Logic Mode", "Wave Mod"}; +constexpr bool ABSOLUTE_READY = true; float cellScale = -0.101f; float speed = 1.99f; @@ -36,21 +38,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - cellScale += input.knobDeltas[0] * 0.05f; - if (cellScale < -0.101f) cellScale = -0.101f; - if (cellScale > 0.5f) cellScale = 0.5f; + PFParams::apply(input, 0, &cellScale, -0.101f, 0.5f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 0.1f) speed = 0.1f; - if (speed > 5.0f) speed = 5.0f; + PFParams::apply(input, 1, &speed, 0.1f, 5.0f, 0.1f); - logicMode += input.knobDeltas[2] * 0.05f; - if (logicMode < 0.0f) logicMode = 0.0f; - if (logicMode > 1.001f) logicMode = 1.001f; + PFParams::apply(input, 2, &logicMode, 0.0f, 1.001f, 0.05f); - waveMod += input.knobDeltas[3] * 0.05f; - if (waveMod < 0.0f) waveMod = 0.0f; - if (waveMod > 3.0f) waveMod = 3.0f; + PFParams::apply(input, 3, &waveMod, 0.0f, 3.0f, 0.05f); timeAcc += dt * speed * 2.0f; } diff --git a/firmware/patternflow/presets/preset_0629.h b/firmware/patternflow/presets/preset_0629.h index c440516..928332e 100644 --- a/firmware/patternflow/presets/preset_0629.h +++ b/firmware/patternflow/presets/preset_0629.h @@ -15,11 +15,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace ChromaticAberrationVortexPattern { const char* NAME = "Chromatic Vortex"; const char* const KNOB_LABELS[4] = {"Split Dist", "Swirl Speed", "Wave Density", "Color Shift"}; +constexpr bool ABSOLUTE_READY = true; const float CHROMATIC_VORTEX_SPLIT_MIN = 0.0f; const float CHROMATIC_VORTEX_SPLIT_MAX = 1.0f; @@ -57,19 +59,19 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.split += input.knobDeltas[0] * CHROMATIC_VORTEX_SPLIT_STEP; - params.split = fmodf(params.split, 1.0f); - if (params.split < 0.0f) params.split += 1.0f; + PFParams::applyUnit(input, 0, ¶ms.split, CHROMATIC_VORTEX_SPLIT_STEP); - params.speed += input.knobDeltas[1] * CHROMATIC_VORTEX_SPEED_STEP; - params.speed = constrain(params.speed, CHROMATIC_VORTEX_SPEED_MIN, CHROMATIC_VORTEX_SPEED_MAX); + // Real ranges in the apply call so the 0..1000 absolute bus spans the + // whole usable sweep instead of clipping against a second clamp. + PFParams::apply(input, 1, ¶ms.speed, + CHROMATIC_VORTEX_SPEED_MIN, CHROMATIC_VORTEX_SPEED_MAX, + CHROMATIC_VORTEX_SPEED_STEP); - params.density += input.knobDeltas[2] * CHROMATIC_VORTEX_DENSITY_STEP; - params.density = constrain(params.density, CHROMATIC_VORTEX_DENSITY_MIN, CHROMATIC_VORTEX_DENSITY_MAX); + PFParams::apply(input, 2, ¶ms.density, + CHROMATIC_VORTEX_DENSITY_MIN, CHROMATIC_VORTEX_DENSITY_MAX, + CHROMATIC_VORTEX_DENSITY_STEP); - params.colorBias += input.knobDeltas[3] * CHROMATIC_VORTEX_COLOR_BIAS_STEP; - params.colorBias = fmodf(params.colorBias, 1.0f); - if (params.colorBias < 0.0f) params.colorBias += 1.0f; + PFParams::applyUnit(input, 3, ¶ms.colorBias, CHROMATIC_VORTEX_COLOR_BIAS_STEP); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0629_2.h b/firmware/patternflow/presets/preset_0629_2.h index a29a4e7..4c6a80f 100644 --- a/firmware/patternflow/presets/preset_0629_2.h +++ b/firmware/patternflow/presets/preset_0629_2.h @@ -16,11 +16,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace VectorFieldParticleFlowPattern { const char* NAME = "Vector Field Flow"; const char* const KNOB_LABELS[4] = {"Turbulence", "Velocity", "Density", "Palette"}; +constexpr bool ABSOLUTE_READY = true; const float VECTOR_FIELD_TURB_MIN = 0.0f; const float VECTOR_FIELD_TURB_MAX = 1.0f; @@ -69,23 +71,25 @@ void setup() { } void update(float dt, const InputFrame& input) { - // Knob 1: Turbulence (Wrap) - params.turbulence += input.knobDeltas[0] * VECTOR_FIELD_TURB_STEP; - while (params.turbulence < VECTOR_FIELD_TURB_MIN) params.turbulence += (VECTOR_FIELD_TURB_MAX - VECTOR_FIELD_TURB_MIN); - while (params.turbulence > VECTOR_FIELD_TURB_MAX) params.turbulence -= (VECTOR_FIELD_TURB_MAX - VECTOR_FIELD_TURB_MIN); + // Real ranges live in the apply calls so the 0..1000 absolute bus spans + // the whole usable sweep. Turbulence and palette wrap over exactly 0..1, + // which is applyUnit's contract. + + // Knob 1: Turbulence (Wrap 0..1) + PFParams::applyUnit(input, 0, ¶ms.turbulence, VECTOR_FIELD_TURB_STEP); // Knob 2: Speed (Clamp) - params.speed += input.knobDeltas[1] * VECTOR_FIELD_SPEED_STEP; - params.speed = constrain(params.speed, VECTOR_FIELD_SPEED_MIN, VECTOR_FIELD_SPEED_MAX); + PFParams::apply(input, 1, ¶ms.speed, + VECTOR_FIELD_SPEED_MIN, VECTOR_FIELD_SPEED_MAX, + VECTOR_FIELD_SPEED_STEP); // Knob 3: Density (Clamp) - params.density += input.knobDeltas[2] * VECTOR_FIELD_DENSITY_STEP; - params.density = constrain(params.density, VECTOR_FIELD_DENSITY_MIN, VECTOR_FIELD_DENSITY_MAX); + PFParams::apply(input, 2, ¶ms.density, + VECTOR_FIELD_DENSITY_MIN, VECTOR_FIELD_DENSITY_MAX, + VECTOR_FIELD_DENSITY_STEP); - // Knob 4: Palette (Wrap) - params.palette += input.knobDeltas[3] * VECTOR_FIELD_PALETTE_STEP; - while (params.palette < VECTOR_FIELD_PALETTE_MIN) params.palette += (VECTOR_FIELD_PALETTE_MAX - VECTOR_FIELD_PALETTE_MIN); - while (params.palette > VECTOR_FIELD_PALETTE_MAX) params.palette -= (VECTOR_FIELD_PALETTE_MAX - VECTOR_FIELD_PALETTE_MIN); + // Knob 4: Palette (Wrap 0..1) + PFParams::applyUnit(input, 3, ¶ms.palette, VECTOR_FIELD_PALETTE_STEP); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_0701.h b/firmware/patternflow/presets/preset_0701.h index 4c3cca2..1da052e 100644 --- a/firmware/patternflow/presets/preset_0701.h +++ b/firmware/patternflow/presets/preset_0701.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace LissajousWeave { const char* NAME = "Lissajous Weave"; const char* const KNOB_LABELS[4] = {"X Frequency", "Speed", "Y Frequency", "Phase Offset"}; +constexpr bool ABSOLUTE_READY = true; // Knob state tracking initialized to the specified Pattern Lab current values float knob1 = 1.586f; // min 0, max 2.8, step 0.05 @@ -46,24 +48,16 @@ namespace LissajousWeave { void update(float dt, const InputFrame& input) { // Update Knob 1 - knob1 += input.knobDeltas[0] * 0.05f; - if (knob1 < 0.0f) knob1 = 0.0f; - if (knob1 > 2.8f) knob1 = 2.8f; + PFParams::apply(input, 0, &knob1, 0.0f, 2.8f, 0.05f); // Update Knob 2 - knob2 += input.knobDeltas[1] * 0.1f; - if (knob2 < 0.0f) knob2 = 0.0f; - if (knob2 > 2.0f) knob2 = 2.0f; + PFParams::apply(input, 1, &knob2, 0.0f, 2.0f, 0.1f); // Update Knob 3 - knob3 += input.knobDeltas[2] * 0.05f; - if (knob3 < 0.0f) knob3 = 0.0f; - if (knob3 > 7.0f) knob3 = 7.0f; + PFParams::apply(input, 2, &knob3, 0.0f, 7.0f, 0.05f); // Update Knob 4 - knob4 += input.knobDeltas[3] * 0.05f; - if (knob4 < -3.0f) knob4 = -3.0f; - if (knob4 > 3.0f) knob4 = 3.0f; + PFParams::apply(input, 3, &knob4, -3.0f, 3.0f, 0.05f); // Recompute parameters based on updated knob positions freqX = 1.0f + floorf(knob1 * 7.0f); diff --git a/firmware/patternflow/presets/preset_0707.h b/firmware/patternflow/presets/preset_0707.h index 4de5b8e..c4478ec 100644 --- a/firmware/patternflow/presets/preset_0707.h +++ b/firmware/patternflow/presets/preset_0707.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace UntitledPattern { const char* NAME = "Untitled pattern"; const char* const KNOB_LABELS[4] = {"Glitch", "Speed", "Freq", "Quantize"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -70,26 +72,10 @@ void setup() { } void update(float dt, const InputFrame& input) { - if (input.knobDeltas[0] != 0) { - glitch += input.knobDeltas[0] * UNTITLED_GLITCH_STEP; - if (glitch < 0.0f) glitch = 0.0f; - if (glitch > 2.0f) glitch = 2.0f; - } - if (input.knobDeltas[1] != 0) { - speed += input.knobDeltas[1] * UNTITLED_SPEED_STEP; - if (speed < 0.05f) speed = 0.05f; - if (speed > 2.0f) speed = 2.0f; - } - if (input.knobDeltas[2] != 0) { - freq += input.knobDeltas[2] * UNTITLED_FREQ_STEP; - if (freq < 2.0f) freq = 2.0f; - if (freq > 10.0f) freq = 10.0f; - } - if (input.knobDeltas[3] != 0) { - quantize += input.knobDeltas[3] * UNTITLED_QUANTIZE_STEP; - if (quantize < 2.0f) quantize = 2.0f; - if (quantize > 10.0f) quantize = 10.0f; - } + PFParams::apply(input, 0, &glitch, 0.0f, 2.0f, UNTITLED_GLITCH_STEP); + PFParams::apply(input, 1, &speed, 0.05f, 2.0f, UNTITLED_SPEED_STEP); + PFParams::apply(input, 2, &freq, 2.0f, 10.0f, UNTITLED_FREQ_STEP); + PFParams::apply(input, 3, &quantize, 2.0f, 10.0f, UNTITLED_QUANTIZE_STEP); timeAcc += dt * speed; if (timeAcc > TWO_PI) timeAcc -= TWO_PI; diff --git a/firmware/patternflow/presets/preset_0710.h b/firmware/patternflow/presets/preset_0710.h index 178b9c3..07249e6 100644 --- a/firmware/patternflow/presets/preset_0710.h +++ b/firmware/patternflow/presets/preset_0710.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace TileWaves { const char* NAME = "TileWaves"; const char* const KNOB_LABELS[4] = {"Quantize", "Speed", "PhaseShift", "Sharpness"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -116,21 +118,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - quantizeState += input.knobDeltas[0] * TILE_QUANTIZE_STEP; - if (quantizeState < TILE_QUANTIZE_MIN) quantizeState = TILE_QUANTIZE_MIN; - if (quantizeState > TILE_QUANTIZE_MAX) quantizeState = TILE_QUANTIZE_MAX; + PFParams::apply(input, 0, &quantizeState, TILE_QUANTIZE_MIN, TILE_QUANTIZE_MAX, TILE_QUANTIZE_STEP); - speedState += input.knobDeltas[1] * TILE_SPEED_STEP; - if (speedState < TILE_SPEED_MIN) speedState = TILE_SPEED_MIN; - if (speedState > TILE_SPEED_MAX) speedState = TILE_SPEED_MAX; + PFParams::apply(input, 1, &speedState, TILE_SPEED_MIN, TILE_SPEED_MAX, TILE_SPEED_STEP); - phaseShiftState += input.knobDeltas[2] * TILE_PHASESHIFT_STEP; - if (phaseShiftState < TILE_PHASESHIFT_MIN) phaseShiftState = TILE_PHASESHIFT_MIN; - if (phaseShiftState > TILE_PHASESHIFT_MAX) phaseShiftState = TILE_PHASESHIFT_MAX; + PFParams::apply(input, 2, &phaseShiftState, TILE_PHASESHIFT_MIN, TILE_PHASESHIFT_MAX, TILE_PHASESHIFT_STEP); - sharpnessState += input.knobDeltas[3] * TILE_SHARPNESS_STEP; - if (sharpnessState < TILE_SHARPNESS_MIN) sharpnessState = TILE_SHARPNESS_MIN; - if (sharpnessState > TILE_SHARPNESS_MAX) sharpnessState = TILE_SHARPNESS_MAX; + PFParams::apply(input, 3, &sharpnessState, TILE_SHARPNESS_MIN, TILE_SHARPNESS_MAX, TILE_SHARPNESS_STEP); timeAcc += dt * speedState; // timeAcc feeds fastSin with integer multiplier (3.0) and floorf for quantization. diff --git a/firmware/patternflow/presets/preset_0712.h b/firmware/patternflow/presets/preset_0712.h index 5f63625..8c1af5e 100644 --- a/firmware/patternflow/presets/preset_0712.h +++ b/firmware/patternflow/presets/preset_0712.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace MidsummerSea { const char* NAME = "Midsummer Sea"; const char* const KNOB_LABELS[4] = {"Waves", "Speed", "Sun", "Glitter"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -66,21 +68,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - waves += input.knobDeltas[0] * 0.05f; - if (waves < 0.0f) waves = 0.0f; - if (waves > 1.0f) waves = 1.0f; + PFParams::apply(input, 0, &waves, 0.0f, 1.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 0.1f) speed = 0.1f; - if (speed > 3.0f) speed = 3.0f; + PFParams::apply(input, 1, &speed, 0.1f, 3.0f, 0.1f); - sun += input.knobDeltas[2] * 0.05f; - if (sun < 0.0f) sun = 0.0f; - if (sun > 1.0f) sun = 1.0f; + PFParams::apply(input, 2, &sun, 0.0f, 1.0f, 0.05f); - glit += input.knobDeltas[3] * 0.05f; - if (glit < 0.0f) glit = 0.0f; - if (glit > 1.0f) glit = 1.0f; + PFParams::apply(input, 3, &glit, 0.0f, 1.0f, 0.05f); t += dt * speed; // common period for all sine multipliers: 2000π diff --git a/firmware/patternflow/presets/preset_0712_2.h b/firmware/patternflow/presets/preset_0712_2.h index 2049332..85ec459 100644 --- a/firmware/patternflow/presets/preset_0712_2.h +++ b/firmware/patternflow/presets/preset_0712_2.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_mem.h" +#include "../src/core_params.h" namespace BreakoutArcade { const char* NAME = "Breakout Arcade"; const char* const KNOB_LABELS[4] = {"Paddle", "Speed", "Luck", "Glow"}; +constexpr bool ABSOLUTE_READY = true; static const uint8_t RAMP_LUT[256][3] = { {0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0},{0,0,0}, @@ -169,33 +171,25 @@ void update(float dt, const InputFrame& input) { if (!glow) return; // allocation failed — degrade to a blank pattern // --- Knob Paddle (encoder driven, used only for manual mode and idle detection) --- - knobPaddle += input.knobDeltas[0] * 0.05f; - if (knobPaddle < 2.0f) knobPaddle = 2.0f; - if (knobPaddle > 62.0f) knobPaddle = 62.0f; + PFParams::apply(input, 0, &knobPaddle, 2.0f, 62.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < 25.0f) speed = 25.0f; - if (speed > 200.0f) speed = 200.0f; + PFParams::apply(input, 1, &speed, 25.0f, 200.0f, 0.1f); - luck += input.knobDeltas[2] * 0.05f; - if (luck < 0.0f) luck = 0.0f; - if (luck > 1.0f) luck = 1.0f; + PFParams::apply(input, 2, &luck, 0.0f, 1.0f, 0.05f); - glowK += input.knobDeltas[3] * 0.05f; - if (glowK < 0.0f) glowK = 0.0f; - if (glowK > 1.0f) glowK = 1.0f; + PFParams::apply(input, 3, &glowK, 0.0f, 1.0f, 0.05f); - if (input.btnPressed[0]) { + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { memset(dead, 0, sizeof(dead)); } - if (input.btnPressed[1]) { + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { multiball(); flash = 1.0f; } - if (input.btnPressed[2]) { + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { wideT = 6.0f; } - if (input.btnPressed[3]) { + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { fireT = 4.0f; flash = 0.8f; } diff --git a/firmware/patternflow/presets/preset_0713.h b/firmware/patternflow/presets/preset_0713.h index eb24257..82dd197 100644 --- a/firmware/patternflow/presets/preset_0713.h +++ b/firmware/patternflow/presets/preset_0713.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace FireflyHollow { const char* NAME = "Firefly Hollow"; const char* const KNOB_LABELS[4] = {"Flies", "Speed", "Glow", "Wind"}; +constexpr bool ABSOLUTE_READY = true; // Ramp LUT – DO NOT EDIT (generated from user ramp, 256 entries) static const uint8_t RAMP_LUT[256][3] = { @@ -120,21 +122,13 @@ void setup() { void update(float dt, const InputFrame& input) { // ----- knobs ----- - knobFlies += input.knobDeltas[0] * 0.05f; - if (knobFlies < 2.0f) knobFlies = 2.0f; - if (knobFlies > 34.0f) knobFlies = 34.0f; + PFParams::apply(input, 0, &knobFlies, 2.0f, 34.0f, 0.05f); - knobSpeed += input.knobDeltas[1] * 0.1f; - if (knobSpeed < 0.2f) knobSpeed = 0.2f; - if (knobSpeed > 5.0f) knobSpeed = 5.0f; + PFParams::apply(input, 1, &knobSpeed, 0.2f, 5.0f, 0.1f); - knobGlow += input.knobDeltas[2] * 0.05f; - if (knobGlow < 0.0f) knobGlow = 0.0f; - if (knobGlow > 2.0f) knobGlow = 2.0f; + PFParams::apply(input, 2, &knobGlow, 0.0f, 2.0f, 0.05f); - knobWind += input.knobDeltas[3] * 0.05f; - if (knobWind < 0.0f) knobWind = 0.0f; - if (knobWind > 2.0f) knobWind = 2.0f; + PFParams::apply(input, 3, &knobWind, 0.0f, 2.0f, 0.05f); int flies = (int)lroundf(knobFlies); if (flies < 2) flies = 2; diff --git a/firmware/patternflow/presets/preset_0715.h b/firmware/patternflow/presets/preset_0715.h index 015cc6b..421eecc 100644 --- a/firmware/patternflow/presets/preset_0715.h +++ b/firmware/patternflow/presets/preset_0715.h @@ -13,11 +13,13 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_math.h" +#include "../src/core_params.h" namespace PoincareSphere { const char* NAME = "Poincaré Sphere"; const char* const KNOB_LABELS[4] = {"Color Tone", "Speed", "Grid Density", "Curvature"}; +constexpr bool ABSOLUTE_READY = true; static float hueParam = 1.0f; // Knob 1, range 0..1 static float speed = 8.62f; // Knob 2, range 0.1..10 @@ -31,20 +33,16 @@ void setup() { void update(float dt, const InputFrame& input) { // Knob 1: color tone - hueParam += input.knobDeltas[0] * 0.05f; - hueParam = fmaxf(0.0f, fminf(1.0f, hueParam)); + PFParams::apply(input, 0, &hueParam, 0.0f, 1.0f, 0.05f); // Knob 2: rotation speed - speed += input.knobDeltas[1] * 0.1f; - speed = fmaxf(0.1f, fminf(10.0f, speed)); + PFParams::apply(input, 1, &speed, 0.1f, 10.0f, 0.1f); // Knob 3: grid density - density += input.knobDeltas[2] * 0.05f; - density = fmaxf(0.0f, fminf(5.0f, density)); + PFParams::apply(input, 2, &density, 0.0f, 5.0f, 0.05f); // Knob 4: projection curvature - curve += input.knobDeltas[3] * 0.05f; - curve = fmaxf(0.0f, fminf(5.0f, curve)); + PFParams::apply(input, 3, &curve, 0.0f, 5.0f, 0.05f); t += dt * speed; // Wrap at common period 20π to avoid float precision loss over days. diff --git a/firmware/patternflow/presets/preset_0716.h b/firmware/patternflow/presets/preset_0716.h index 6f45766..8204069 100644 --- a/firmware/patternflow/presets/preset_0716.h +++ b/firmware/patternflow/presets/preset_0716.h @@ -14,11 +14,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace TriMarch { const char* NAME = "Tri March"; const char* const KNOB_LABELS[4] = {"Triangle size", "Speed", "March angle", "Color palette"}; +constexpr bool ABSOLUTE_READY = true; static float tsize = 0.0f; static float speed = 3.931f; @@ -31,21 +33,13 @@ void setup() { } void update(float dt, const InputFrame& input) { - tsize += input.knobDeltas[0] * 0.05f; - if (tsize < 0.0f) tsize = 0.0f; - if (tsize > 1.0f) tsize = 1.0f; + PFParams::apply(input, 0, &tsize, 0.0f, 1.0f, 0.05f); - speed += input.knobDeltas[1] * 0.1f; - if (speed < -3.0f) speed = -3.0f; - if (speed > 4.0f) speed = 4.0f; + PFParams::apply(input, 1, &speed, -3.0f, 4.0f, 0.1f); - angle += input.knobDeltas[2] * 0.05f; - if (angle < -0.001f) angle = -0.001f; - if (angle > 5.0f) angle = 5.0f; + PFParams::apply(input, 2, &angle, -0.001f, 5.0f, 0.05f); - palette += input.knobDeltas[3] * 0.05f; - if (palette < 0.0f) palette = 0.0f; - if (palette > 4.0f) palette = 4.0f; + PFParams::apply(input, 3, &palette, 0.0f, 4.0f, 0.05f); t += dt * speed; // Period needed for multipliers 10, 2, 0.05 = 40π = 20*TWO_PI diff --git a/firmware/patternflow/presets/preset_0718.h b/firmware/patternflow/presets/preset_0718.h index 4c81a53..6ed4940 100644 --- a/firmware/patternflow/presets/preset_0718.h +++ b/firmware/patternflow/presets/preset_0718.h @@ -13,11 +13,13 @@ #include "../src/core_canvas.h" #include "../src/core_math.h" #include "../src/core_color.h" +#include "../src/core_params.h" namespace WarpedWave { const char* NAME = "Warped Wave"; const char* const KNOB_LABELS[4] = {"Hue", "Speed", "Warp Amp", "Warp Freq"}; +constexpr bool ABSOLUTE_READY = true; static float s_hue = 0.0f; static float s_speed = 2.0f; @@ -31,23 +33,18 @@ void setup() { } void update(float dt, const InputFrame& input) { - // Accumulate knob deltas - s_hue += input.knobDeltas[0] * 0.05f; - s_speed += input.knobDeltas[1] * 0.1f; - s_warpAmp += input.knobDeltas[2] * 0.05f; - s_warpFreq += input.knobDeltas[3] * 0.05f; - - // Clamp to documented ranges - s_hue = fmaxf(0.0f, fminf(1.0f, s_hue)); - s_speed = fmaxf(-20.0f, fminf(20.0f, s_speed)); - s_warpAmp = fmaxf(0.1f, fminf(1.0f, s_warpAmp)); - s_warpFreq = fmaxf(0.0f, fminf(30.0f, s_warpFreq)); + // Documented ranges in the apply calls so the 0..1000 absolute bus spans + // the whole usable sweep (clamping included — no second clamp needed). + PFParams::apply(input, 0, &s_hue, 0.0f, 1.0f, 0.05f); + PFParams::apply(input, 1, &s_speed, -20.0f, 20.0f, 0.1f); + PFParams::apply(input, 2, &s_warpAmp, 0.1f, 1.0f, 0.05f); + PFParams::apply(input, 3, &s_warpFreq, 0.0f, 30.0f, 0.05f); // Button resets (press, not long-press) - if (input.btnPressed[0]) s_hue = 0.0f; - if (input.btnPressed[1]) s_speed = 2.0f; - if (input.btnPressed[2]) s_warpAmp = 0.2f; - if (input.btnPressed[3]) s_warpFreq = 0.5f; + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) s_hue = 0.0f; + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) s_speed = 2.0f; + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) s_warpAmp = 0.2f; + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) s_warpFreq = 0.5f; // Map warpAmp from raw 0.1..1 to 0..1.225 (as in JS: v[2]*0.25) // We store raw and convert on use, preserving the JS mapping. diff --git a/firmware/patternflow/presets/preset_0719.h b/firmware/patternflow/presets/preset_0719.h index 27b9e44..4d7a463 100644 --- a/firmware/patternflow/presets/preset_0719.h +++ b/firmware/patternflow/presets/preset_0719.h @@ -13,6 +13,7 @@ #include "../src/core_encoders.h" #include "../src/core_canvas.h" #include "../src/core_mem.h" +#include "../src/core_params.h" namespace MagVortex { @@ -23,6 +24,7 @@ const char* const KNOB_LABELS[4] = { "SpinPull", "InflowPitch" }; +constexpr bool ABSOLUTE_READY = true; // Parameter state static float trailDecay = 0.924f; @@ -124,30 +126,22 @@ void update(float dt, const InputFrame& input) { if (!densityMap) return; // allocation failed — degrade to a blank pattern // Knob 0: TrailDecay - trailDecay += input.knobDeltas[0] * 0.05f; - if (trailDecay < 0.5f) trailDecay = 0.5f; - if (trailDecay > 0.98f) trailDecay = 0.98f; + PFParams::apply(input, 0, &trailDecay, 0.5f, 0.98f, 0.05f); // Knob 1: Velocity - velocity += input.knobDeltas[1] * 0.1f; - if (velocity < -3.0f) velocity = -3.0f; - if (velocity > 5.0f) velocity = 5.0f; + PFParams::apply(input, 1, &velocity, -3.0f, 5.0f, 0.1f); // Knob 2: SpinPull - spin += input.knobDeltas[2] * 0.05f; - if (spin < 0.0f) spin = 0.0f; - if (spin > 3.0f) spin = 3.0f; + PFParams::apply(input, 2, &spin, 0.0f, 3.0f, 0.05f); // Knob 3: InflowPitch - pitch += input.knobDeltas[3] * 0.05f; - if (pitch < -20.0f) pitch = -20.0f; - if (pitch > 20.0f) pitch = 20.0f; + PFParams::apply(input, 3, &pitch, -20.0f, 20.0f, 0.05f); // Button resets - if (input.btnPressed[0]) trailDecay = 0.924f; - if (input.btnPressed[1]) velocity = 2.013f; - if (input.btnPressed[2]) spin = 0.746f; - if (input.btnPressed[3]) pitch = -2.203f; + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) trailDecay = 0.924f; + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) velocity = 2.013f; + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) spin = 0.746f; + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) pitch = -2.203f; // Decay the density map for (int i = 0; i < PANEL_RES_W * PANEL_RES_H; i++) { diff --git a/firmware/patternflow/presets/preset_a_big_hit.h b/firmware/patternflow/presets/preset_a_big_hit.h index 826e1ec..c4749f0 100644 --- a/firmware/patternflow/presets/preset_a_big_hit.h +++ b/firmware/patternflow/presets/preset_a_big_hit.h @@ -15,10 +15,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_noise.h" +#include "../src/core_params.h" namespace PatternABigHit { const char* NAME = "a big hit"; const char* const KNOB_LABELS[4] = {"hueBase", "speed", "scale", "chaos"}; +constexpr bool ABSOLUTE_READY = true; struct Params { float hueBase = 0.0f; @@ -49,11 +51,11 @@ void setup() { } void update(float dt, const InputFrame& input) { - params.hueBase = fmodf((float)((params.hueBase + input.knobDeltas[0] * 0.05f)), (float)(1.0f)); + PFParams::applyUnit(input, 0, ¶ms.hueBase, 0.05f); if (params.hueBase < 0) params.hueBase += 1.0f; - params.speed = max(0.0f, params.speed + input.knobDeltas[1] * 0.05f); - params.scale = constrain(params.scale + input.knobDeltas[2] * 0.01f, 0.02f, 0.2f); - params.chaos = constrain(params.chaos + input.knobDeltas[3] * 0.1f, 0.0f, 3.0f); + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 10.0f, 0.05f); + PFParams::apply(input, 2, ¶ms.scale, 0.02f, 0.2f, 0.01f); + PFParams::apply(input, 3, ¶ms.chaos, 0.0f, 3.0f, 0.1f); params.timeAcc += dt * params.speed; } diff --git a/firmware/patternflow/presets/preset_origin.h b/firmware/patternflow/presets/preset_origin.h index 9b18e6b..60aea4e 100644 --- a/firmware/patternflow/presets/preset_origin.h +++ b/firmware/patternflow/presets/preset_origin.h @@ -11,10 +11,12 @@ #include "../src/core_math.h" #include "../src/core_color.h" #include "../src/core_canvas.h" +#include "../src/core_params.h" namespace Origin { const char* NAME = "Origin"; const char* KNOB_LABELS[4] = {"hue", "speed", "mode", "freq"}; + constexpr bool ABSOLUTE_READY = true; // --- 상태 변수 --- struct Params { @@ -91,57 +93,29 @@ namespace Origin { } void update(float dt, const InputFrame& input) { - // Each knob: audio override (when active) takes priority. Audio - // value is 0..1 normalized — map to the same parameter range the - // encoder integrates toward, so toggling between encoder and audio - // doesn't jump. - - // Enc1: Hue (0..360°) - if (input.knobAudioActive[0]) { - params.hueDeg = (int)(input.knobAudioValue[0] * 360.0f) % 360; - } else { - int d0 = input.knobDeltas[0]; - if (d0 != 0) { - params.hueDeg = (params.hueDeg + (int)(d0 * 10)) % 360; - if (params.hueDeg < 0) params.hueDeg += 360; - } - if (input.btnPressed[0]) { params.hueDeg = 0; Serial.println("[Origin] Hue → 0°"); } + // Absolute MQTT (0..1000) > Weather/audio 0..1 > encoder/OSC deltas. + PFParams::applyInt(input, 0, ¶ms.hueDeg, 0, 359, 10, true); + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { + params.hueDeg = 0; + Serial.println("[Origin] Hue → 0°"); } - // Enc2: Speed (0..5) - if (input.knobAudioActive[1]) { - params.speed = input.knobAudioValue[1] * 5.0f; - } else { - int d1 = input.knobDeltas[1]; - if (d1 != 0) { - params.speed = constrain(params.speed + d1 * 0.1f, 0.0f, 5.0f); - } - if (input.btnPressed[1]) { params.speed = 0.0f; Serial.println("[Origin] Speed → 0"); } + PFParams::apply(input, 1, ¶ms.speed, 0.0f, 5.0f, 0.1f); + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { + params.speed = 0.0f; + Serial.println("[Origin] Speed → 0"); } - // Enc3: Mode (preset index, 0..NUM_PRESETS-1). Audio mapped by - // dividing the 0..1 range into NUM_PRESETS buckets. - if (input.knobAudioActive[2]) { - int idx = (int)(input.knobAudioValue[2] * (float)NUM_PRESETS); - if (idx >= NUM_PRESETS) idx = NUM_PRESETS - 1; - params.mode = idx; - } else { - int d2 = input.knobDeltas[2]; - if (d2 != 0) { - params.mode = ((params.mode + (int)d2) % NUM_PRESETS + NUM_PRESETS) % NUM_PRESETS; - } - if (input.btnPressed[2]) { params.mode = 0; Serial.println("[Origin] Mode → 0"); } + PFParams::applyIndex(input, 2, ¶ms.mode, NUM_PRESETS); + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { + params.mode = 0; + Serial.println("[Origin] Mode → 0"); } - // Enc4: Freq (0.1..1000) - if (input.knobAudioActive[3]) { - params.freq = 0.1f + input.knobAudioValue[3] * (1000.0f - 0.1f); - } else { - int d3 = input.knobDeltas[3]; - if (d3 != 0) { - params.freq = constrain(params.freq + (d3 * 10.0f), 0.1f, 1000.0f); - } - if (input.btnPressed[3]) { params.freq = 0.1f; Serial.println("[Origin] Freq → 0.1"); } + PFParams::apply(input, 3, ¶ms.freq, 0.1f, 1000.0f, 10.0f); + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { + params.freq = 0.1f; + Serial.println("[Origin] Freq → 0.1"); } // Apply Mode Change diff --git a/firmware/patternflow/presets/preset_wave_saw.h b/firmware/patternflow/presets/preset_wave_saw.h index 2523609..06eb688 100644 --- a/firmware/patternflow/presets/preset_wave_saw.h +++ b/firmware/patternflow/presets/preset_wave_saw.h @@ -11,10 +11,12 @@ #include "../src/core_math.h" #include "../src/core_noise.h" #include "../src/core_canvas.h" +#include "../src/core_params.h" namespace WaveSaw { const char* NAME = "Wave Saw"; const char* KNOB_LABELS[4] = {"angle", "scale", "dist", "dscale"}; +constexpr bool ABSOLUTE_READY = true; // --- Wave / 매핑 상수 --- const float DETAIL_ROUGHNESS = 0.22f; @@ -51,35 +53,17 @@ namespace WaveSaw { } void update(float dt, const InputFrame& input) { - // Enc1: Angle (방향) - int d0 = input.knobDeltas[0]; - if (d0 != 0) { - angle += d0 * 0.1f; // 회전 속도 조절 - if (angle > PI * 2) angle -= PI * 2; - if (angle < 0) angle += PI * 2; - } - if (input.btnPressed[0]) { angle = 0.0f; Serial.println("[WaveSaw] Angle -> 0"); } + PFParams::apply(input, 0, &angle, 0.0f, (float)(PI * 2), 0.1f); + if (input.btnPressed[0] && !input.paramAbsoluteActive[0] && !input.knobAudioActive[0]) { angle = 0.0f; Serial.println("[WaveSaw] Angle -> 0"); } - // Enc2: Scale (밀도) - int d1 = input.knobDeltas[1]; - if (d1 != 0) { - scale = constrain(scale + d1 * 0.2f, SCALE_MIN, SCALE_MAX); - } - if (input.btnPressed[1]) { scale = 3.0f; Serial.println("[WaveSaw] Scale -> 3.0"); } + PFParams::apply(input, 1, &scale, SCALE_MIN, SCALE_MAX, 0.2f); + if (input.btnPressed[1] && !input.paramAbsoluteActive[1] && !input.knobAudioActive[1]) { scale = 3.0f; Serial.println("[WaveSaw] Scale -> 3.0"); } - // Enc3: Distortion (왜곡 정도) - int d2 = input.knobDeltas[2]; - if (d2 != 0) { - dist = constrain(dist + d2 * 0.1f, DIST_MIN, DIST_MAX); - } - if (input.btnPressed[2]) { dist = 0.0f; Serial.println("[WaveSaw] Dist -> 0.0"); } + PFParams::apply(input, 2, &dist, DIST_MIN, DIST_MAX, 0.1f); + if (input.btnPressed[2] && !input.paramAbsoluteActive[2] && !input.knobAudioActive[2]) { dist = 0.0f; Serial.println("[WaveSaw] Dist -> 0.0"); } - // Enc4: Detail Scale (노이즈 디테일 크기) - int d3 = input.knobDeltas[3]; - if (d3 != 0) { - dScale = constrain(dScale + d3 * 0.2f, DSCALE_MIN, DSCALE_MAX); - } - if (input.btnPressed[3]) { dScale = 1.0f; Serial.println("[WaveSaw] DScale -> 1.0"); } + PFParams::apply(input, 3, &dScale, DSCALE_MIN, DSCALE_MAX, 0.2f); + if (input.btnPressed[3] && !input.paramAbsoluteActive[3] && !input.knobAudioActive[3]) { dScale = 1.0f; Serial.println("[WaveSaw] DScale -> 1.0"); } // 애니메이션 페이즈 업데이트 phase += dt * PHASE_PER_SEC; diff --git a/firmware/toolchain/migrate_community_absolute.py b/firmware/toolchain/migrate_community_absolute.py new file mode 100644 index 0000000..62badf5 --- /dev/null +++ b/firmware/toolchain/migrate_community_absolute.py @@ -0,0 +1,471 @@ +""" +One-shot migration: make community pattern headers absolute-ready (PFParams). + + python firmware/toolchain/migrate_community_absolute.py --db path/to/community.db --dry-run + python firmware/toolchain/migrate_community_absolute.py --db path/to/community.db + +Rewrites `patterns.code_cpp` (and `pattern_headers.code_cpp` where that table +exists) the same way the repo presets were converted: delta-accumulation +idioms become PFParams:: calls, a `constexpr bool ABSOLUTE_READY = true;` +lands after KNOB_LABELS, button resets get the absolute-hold guard, and +`core_params.h` joins the includes. + +Two safety rules this deliberately enforces: + + 1. ALL-OR-NOTHING PER PATTERN. A header is written back only when no + knobDeltas access survives outside comments. A partial conversion would + mix absolute and delta channels in one pattern — worse than either pure + form — so anything the regexes cannot fully claim is left byte-identical + and reported as NEED MANUAL. (Upstream's make_absolute_ready.py marks + those ABSOLUTE_READY=true anyway; that flaw is what this script fixes.) + + 2. ORIGINALS ARE DUMPED FIRST. Before any write, every row this run will + touch is copied to a JSON file beside the database + (community-code-cpp-backup-.json). Restoring is a small + script over that file, not a prayer. + +Deck zips need no cache work afterwards: their fingerprint is a digest of +code_cpp, so every converted pattern's packs rebuild on next download. + +The transformation regexes are ported from Simone Majocchi's +toolchain/make_absolute_ready.py (his performance-director branch) — the +same code that converted the repo's 33 presets — trimmed to the idioms that +actually appear in community headers. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sqlite3 +import sys +import time +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +LABELS_RE = re.compile(r"KNOB_LABELS\s*\[\s*4\s*\]\s*=\s*\{(.*?)\}", re.DOTALL) +ABS_READY_RE = re.compile(r"constexpr\s+bool\s+ABSOLUTE_READY\s*=\s*(true|false)\s*;") + + +# ── include + flag ──────────────────────────────────────────────────────── + + +def ensure_core_params_include(text: str) -> str: + if "core_params.h" in text or "pf_params.h" in text: + return text + # Community headers say `#include "src/..."`; repo presets say + # `#include "../src/..."`. Follow whichever style the file already uses. + prefix = "../src" if '#include "../src/' in text else "src" + lines = text.splitlines(keepends=True) + last_inc = -1 + for i, line in enumerate(lines): + if line.lstrip().startswith("#include"): + last_inc = i + if last_inc < 0: + return f'#include "{prefix}/core_params.h"\n' + text + lines.insert(last_inc + 1, f'#include "{prefix}/core_params.h"\n') + return "".join(lines) + + +def ensure_absolute_ready_flag(text: str) -> str: + if ABS_READY_RE.search(text): + return ABS_READY_RE.sub("constexpr bool ABSOLUTE_READY = true;", text, count=1) + m = LABELS_RE.search(text) + if not m: + return text + semi = text.find(";", m.end()) + if semi < 0: + return text + return text[: semi + 1] + "\nconstexpr bool ABSOLUTE_READY = true;" + text[semi + 1 :] + + +# ── delta-idiom rewrites (ported from make_absolute_ready.py) ───────────── + + +def transform_fminmax_assign(text: str) -> str: + var = r"(?P[A-Za-z_][\w.]*(?:\[[0-3]\])?)" + + def apply_line(m: re.Match) -> str: + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ) + + # var += deltas * step; var = fmaxf(lo, fminf(hi, var)); + text = re.sub( + rf"(?P[ \t]*){var}\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + rf"(?P=var)\s*=\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*(?P=var)\s*\)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + # var += deltas * step; var = fminf(fmaxf(var, lo), hi); + text = re.sub( + rf"(?P[ \t]*){var}\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + rf"(?P=var)\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*(?P=var)\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + # var = fminf(fmaxf(var + deltas * step, lo), hi); + text = re.sub( + rf"(?P[ \t]*){var}\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*(?P=var)\s*\+\s*" + rf"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", + apply_line, text, flags=re.MULTILINE) + + return text + + +def transform_if_delta_blocks(text: str) -> str: + """if (input.knobDeltas[i] != 0) { var += ...; optional clamps }""" + pat = re.compile( + r"(?P[ \t]*)if\s*\(\s*input\.knobDeltas\[(?P[0-3])\]\s*!=\s*0\s*\)\s*\{\s*" + r"(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P=idx)\]\s*\*\s*(?P[^;]+);\s*" + r"(?:if\s*\(\s*(?P=var)\s*<\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=lo)\s*;\s*)?" + r"(?:if\s*\(\s*(?P=var)\s*>\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=hi)\s*;\s*)?" + r"\}", + re.MULTILINE, + ) + + def repl(m: re.Match) -> str: + lo = m.group("lo") + hi = m.group("hi") + if lo and hi: + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{lo.strip()}, {hi.strip()}, {m.group('step').strip()});" + ) + return ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"0.0f, 1.0f, {m.group('step').strip()});" + ) + + return pat.sub(repl, text) + + +def transform_plain_delta_clamps(text: str) -> str: + """var += deltas * step; followed by the common clamp shapes.""" + # += then two if-clamps + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=lo)\s*;\s*" + r"if\s*\(\s*(?P=var)\s*>\s*(?P[^)]+)\)\s*(?P=var)\s*=\s*(?P=hi)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + # var = constrain(var + deltas * step, lo, hi); + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*constrain\s*\(\s*(?P=var)\s*\+\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text) + + # var = clampf(var + deltas * step, lo, hi); + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*clampf\s*\(\s*(?P=var)\s*\+\s*" + r"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,]+)\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, {m.group('hi').strip()}, {m.group('step').strip()});" + ), + text) + + return text + + +def transform_one_sided_max(text: str) -> str: + """var = max(lo, var + deltas * step); — unbounded above → soft 10.0f cap. + + Same call upstream's converter made for the repo presets, so a community + copy of 0510 ends up byte-identical to the converted preset. + """ + return re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*max\s*\(\s*(?P[^,]+)\s*,\s*" + r"(?P=var)\s*\+\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^)]+)\)\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::apply(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('lo').strip()}, 10.0f, {m.group('step').strip()});" + ), + text) + + +def transform_grouped_deltas(text: str) -> str: + """+= lines whose clamp/wrap sits later in the function (ported from + make_absolute_ready.py's windowed transform, minus its dead branches).""" + add_re = re.compile( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*(?:\[[0-3]\])?)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);" + ) + adds = list(add_re.finditer(text)) + if not adds: + return text + + replacements: list[tuple[int, int, str]] = [] + consumed: list[tuple[int, int]] = [] + + def overlaps(start: int, end: int) -> bool: + return any(not (end <= a or start >= b) for a, b in consumed) + + for m in adds: + var = m.group("var") + idx = m.group("idx") + step = m.group("step").strip() + indent = m.group("indent") + window = text[m.end() : m.end() + 900] + + wrap_lo = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*<\s*0(?:\.0f)?\s*\)\s*{re.escape(var)}\s*\+=\s*1(?:\.0f)?\s*;", window) + wrap_hi = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*>=?\s*1(?:\.0f)?\s*\)\s*{re.escape(var)}\s*-=\s*1(?:\.0f)?\s*;", window) + fmod_line = re.search(rf"{re.escape(var)}\s*=\s*fmodf\([^;]+;", window) + while_wraps = list(re.finditer( + rf"while\s*\(\s*{re.escape(var)}\s*[<>]=?\s*[^)]+\)\s*{re.escape(var)}\s*[+-]=\s*[^;]+;", window)) + lo_m = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*<\s*(?P[^)]+)\)\s*{re.escape(var)}\s*=\s*(?P=lo)\s*;", window) + hi_m = re.search( + rf"if\s*\(\s*{re.escape(var)}\s*>\s*(?P[^)]+)\)\s*{re.escape(var)}\s*=\s*(?P=hi)\s*;", window) + fm_m = re.search( + rf"{re.escape(var)}\s*=\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*{re.escape(var)}\s*\)\s*\)\s*;", window) + fm_m2 = re.search( + rf"{re.escape(var)}\s*=\s*fminf\s*\(\s*fmaxf\s*\(\s*{re.escape(var)}\s*,\s*(?P[^,]+)\s*\)\s*,\s*(?P[^)]+)\s*\)\s*;", window) + fm_m3 = re.search( + rf"{re.escape(var)}\s*=\s*fminf\s*\(\s*(?P[^,]+)\s*,\s*fmaxf\s*\(\s*(?P[^,]+)\s*,\s*{re.escape(var)}\s*\)\s*\)\s*;", window) + con_m = re.search( + rf"{re.escape(var)}\s*=\s*constrain\s*\(\s*{re.escape(var)}\s*,\s*(?P[^,]+)\s*,\s*(?P[^)]+)\)\s*;", window) + + clamp = fm_m or fm_m2 or fm_m3 or con_m + if clamp: + new = (f"{indent}PFParams::apply(input, {idx}, &{var}, " + f"{clamp.group('lo').strip()}, {clamp.group('hi').strip()}, {step});") + elif wrap_lo or wrap_hi or fmod_line or while_wraps: + new = f"{indent}PFParams::applyUnit(input, {idx}, &{var}, {step});" + elif lo_m and hi_m: + new = (f"{indent}PFParams::apply(input, {idx}, &{var}, " + f"{lo_m.group('lo').strip()}, {hi_m.group('hi').strip()}, {step});") + else: + continue + + if overlaps(m.start(), m.end()): + continue + replacements.append((m.start(), m.end(), new)) + consumed.append((m.start(), m.end())) + + # Blank out the distant clamp/wrap lines this apply subsumes. + followers: list[re.Match] = [] + if clamp: + followers.append(clamp) + if lo_m and hi_m and not clamp: + followers.extend([lo_m, hi_m]) + for wm in (wrap_lo, wrap_hi): + if wm: + followers.append(wm) + if fmod_line and not clamp: + followers.append(fmod_line) + followers.extend(while_wraps) + for fm in followers: + cs = m.end() + fm.start() + ce = m.end() + fm.end() + if overlaps(cs, ce): + continue + replacements.append((cs, ce, "")) + consumed.append((cs, ce)) + + for start, end, new in sorted(replacements, key=lambda x: x[0], reverse=True): + text = text[:start] + new + text[end:] + return text + + +def transform_hue_wraps(text: str) -> str: + """The three spellings of `hue += delta; wrap into 0..1` → applyUnit.""" + # += step; if (<0) += 1; fmodf(...) + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*" + r"(?P=var)\s*=\s*fmodf\s*\([^;]+;", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + # var = fmodf(var + deltas * step, 1.0f); [optional if (<0) += 1] + # Paren salad tolerated: the generator spelling is + # fmodf((float)((var + deltas * step)), (float)(1.0f)) + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*=\s*fmodf\s*\(\s*[\s(]*(?:\(float\)\s*)?[\s(]*(?P=var)\s*\+\s*" + r"input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^,()]+)[\s)]*,\s*(?:\(float\)\s*)?[\s(]*1(?:\.0f)?[\s)]*\)\s*;\s*" + r"(?:if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*)?", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});\n" + ), + text, flags=re.MULTILINE) + + # += step; wrap via two ifs (+= span / -= span) over 0..1 + text = re.sub( + r"(?P[ \t]*)(?P[A-Za-z_][\w.]*)\s*\+=\s*input\.knobDeltas\[(?P[0-3])\]\s*\*\s*(?P[^;]+);\s*" + r"if\s*\(\s*(?P=var)\s*<\s*0(?:\.0f)?\s*\)\s*(?P=var)\s*\+=\s*1(?:\.0f)?\s*;\s*" + r"if\s*\(\s*(?P=var)\s*>=?\s*1(?:\.0f)?\s*\)\s*(?P=var)\s*-=\s*1(?:\.0f)?\s*;", + lambda m: ( + f"{m.group('indent')}PFParams::applyUnit(input, {m.group('idx')}, &{m.group('var')}, " + f"{m.group('step').strip()});" + ), + text, flags=re.MULTILINE) + + return text + + +def guard_btn_resets(text: str) -> str: + return re.sub( + r"if\s*\(\s*input\.btnPressed\[([0-3])\]\s*\)\s+(?!&&)", + r"if (input.btnPressed[\1] && !input.paramAbsoluteActive[\1] && !input.knobAudioActive[\1]) ", + text, + ) + + +def still_has_deltas(text: str) -> bool: + body = re.sub(r"//.*?$", "", text, flags=re.MULTILINE) + body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) + return "knobDeltas" in body + + +def convert(code: str) -> tuple[str, bool]: + """Return (converted, fully_converted). Original returned when partial.""" + text = transform_fminmax_assign(code) + text = transform_hue_wraps(text) + text = transform_if_delta_blocks(text) + text = transform_plain_delta_clamps(text) + text = transform_one_sided_max(text) + text = transform_grouped_deltas(text) + if still_has_deltas(text): + return code, False + text = ensure_core_params_include(text) + text = ensure_absolute_ready_flag(text) + text = guard_btn_resets(text) + return text, True + + +# ── database walk ───────────────────────────────────────────────────────── + + +# ── repo-twin replacement ───────────────────────────────────────────────── +# +# The community DB was seeded from the repo's presets, so many rows are +# byte-identical copies of a preset's pre-conversion source (verified against +# git HEAD for Origin / Wave Saw / 0513). Those need no regex at all: when a +# row's code matches a preset's PRE-conversion source exactly (by hash), the +# row takes the repo's hand-checked converted file wholesale. Hashes live in +# preset_twin_hashes.json beside this script (generated at conversion time by +# gen_twin_hashes.py); the converted sources come from --presets-dir. + + +def norm_code(s: str) -> str: + return s.replace("\r\n", "\n").strip() + + +def load_twins(presets_dir: Path) -> dict[str, str]: + """sha1(normalized old source) → converted source text.""" + hashes_path = Path(__file__).with_name("preset_twin_hashes.json") + if not hashes_path.is_file() or not presets_dir.is_dir(): + return {} + mapping = json.loads(hashes_path.read_text(encoding="utf-8")) + out: dict[str, str] = {} + for old_sha, filename in mapping.items(): + src = presets_dir / filename + if src.is_file(): + out[old_sha] = src.read_text(encoding="utf-8") + return out + + +def twin_lookup(twins: dict[str, str], code: str) -> str | None: + sha = hashlib.sha1(norm_code(code).encode("utf-8")).hexdigest() + return twins.get(sha) + + +def table_exists(db: sqlite3.Connection, name: str) -> bool: + row = db.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + return row is not None + + +def migrate(db_path: Path, dry: bool, presets_dir: Path) -> int: + db = sqlite3.connect(db_path) + targets = [("patterns", "id")] + if table_exists(db, "pattern_headers"): + targets.append(("pattern_headers", "id")) + + twins = load_twins(presets_dir) + backup: dict[str, dict[str, str]] = {} + planned: list[tuple[str, str, str]] = [] # table, id, new_code + manual: list[tuple[str, str]] = [] + already: int = 0 + twinned: int = 0 + + for table, key in targets: + rows = db.execute( + f"SELECT {key}, code_cpp FROM {table} WHERE code_cpp IS NOT NULL AND code_cpp != ''" + ).fetchall() + for row_id, code in rows: + if "PFParams::" in code: + already += 1 + continue + if "knobDeltas" not in code: + # No knob handling at all — still absolute-safe by + # definition, but flagging it ready would promise Director + # response it cannot give. Leave it alone. + continue + twin = twin_lookup(twins, code) + if twin is not None: + backup.setdefault(table, {})[str(row_id)] = code + planned.append((table, str(row_id), twin)) + twinned += 1 + continue + new_code, ok = convert(code) + if not ok: + manual.append((table, str(row_id))) + continue + backup.setdefault(table, {})[str(row_id)] = code + planned.append((table, str(row_id), new_code)) + + print(f"convertible: {len(planned)} (of which repo twins: {twinned}) " + f"already PFParams: {already} NEED MANUAL: {len(manual)}") + for table, row_id in manual: + print(f" NEED MANUAL: {table}.{row_id}") + + if dry or not planned: + db.close() + return 0 + + stamp = time.strftime("%Y%m%d-%H%M%S") + backup_path = db_path.with_name(f"community-code-cpp-backup-{stamp}.json") + backup_path.write_text(json.dumps(backup, indent=1), encoding="utf-8") + print(f"originals saved: {backup_path}") + + for table, row_id, new_code in planned: + db.execute(f"UPDATE {table} SET code_cpp = ? WHERE id = ?", (new_code, row_id)) + db.commit() + db.close() + print(f"updated {len(planned)} row(s)") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--db", type=Path, required=True, help="community.db path") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--presets-dir", type=Path, + default=Path(__file__).resolve().parents[1] / "patternflow" / "presets", + help="Converted repo presets, used for repo-twin rows") + args = ap.parse_args() + if not args.db.is_file(): + sys.exit(f"no database at {args.db}") + return migrate(args.db, args.dry_run, args.presets_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/toolchain/preset_twin_hashes.json b/firmware/toolchain/preset_twin_hashes.json new file mode 100644 index 0000000..ada0ccf --- /dev/null +++ b/firmware/toolchain/preset_twin_hashes.json @@ -0,0 +1,36 @@ +{ + "4044b37cbce604ecbecc8a9c5356057cc2e8fcd4": "preset_0510.h", + "ff7ed307186d61325b33c392d9f155ff83769488": "preset_0511.h", + "221be07a1c1f9cad7b4380798bbc91b404b8bee8": "preset_0512.h", + "081be119ef39eb10caa26507a886d20eeedde19c": "preset_0513.h", + "48223896919982a3052f2862b49b48e9befaca21": "preset_0514.h", + "80dfeac93dc60b7e386be08e01de3ed05b369538": "preset_0515.h", + "60f0a3d7b69843c66bdbba3350aff8431a67e088": "preset_0515_3.h", + "ec1d86f67497d9063e8f76cb45ebebcbd88b4b83": "preset_0515_4.h", + "371acfbe502189cfbd63314222858629b470877e": "preset_0518.h", + "e065bc66cc42ebe0081a7c91e5c7d91812fa8da1": "preset_0519_1.h", + "7141e3f5464e4165f3eff9651753736b5faff318": "preset_0520.h", + "61f976873096c6a932d1858a4705cbf0166e4e81": "preset_0521.h", + "908b4fc38012f80b7a46c216de7513df6dad26d3": "preset_0522.h", + "a71d33135e5aca9370e851fbaa20afbb8112756d": "preset_0527.h", + "9e4e427db19c7004b8269dcfd6f073b0e93ffc46": "preset_0528.h", + "89e3aa4dd1ae79cad0e3d048f6bb6e375f4d7e40": "preset_0531.h", + "e70692af255cc9ecb9b6bd12f2e1121b703c9506": "preset_0601.h", + "17e30e3bfb949d1a685b079dee1ba06394c47b44": "preset_0602.h", + "45a5cafb4c180f4beeb5a331c863a4b6a2761df1": "preset_0628.h", + "e470c9ee131f1bae629df4499f1c2c4564817386": "preset_0629.h", + "9a5ee231aecc6639ce47cf8acecc7f7033c213cf": "preset_0629_2.h", + "476a886599e5fc8f44100b58c764783e5b3ecb99": "preset_0701.h", + "fd84297e376f6cb594f20b20d79dbdc407f0de3f": "preset_0707.h", + "fb9d9adf5a4acc1a048b3b5e30c30e247f0468ed": "preset_0710.h", + "205b23411ac787e2b5c3f63e8e53361f135f424d": "preset_0712.h", + "d900480bff2519d47c182bb4e518de99e09ca600": "preset_0712_2.h", + "6d65bff5221dbfe7251e9dfb50fc6a5d2c6605fa": "preset_0713.h", + "ebab89c84f0a20990a8d9a90c34f54c66e348ee0": "preset_0715.h", + "9d18183276d809582a07a1ff294ae4f7fa054144": "preset_0716.h", + "362472fc97b2864131c3e03ce0eb3b5407b7342e": "preset_0718.h", + "fcba8ec903854b07803c54db537fffcc3bc8bf09": "preset_0719.h", + "ddab4fdf0f9819cd66552a9bd8ba3022815b34c8": "preset_a_big_hit.h", + "cafd7b3540684d3a1fca7ea044c7b14a94d5d1df": "preset_origin.h", + "3c6de5e80d326ced424f16812b5340981ecb250c": "preset_wave_saw.h" +} diff --git a/web/public/packs/basics.json b/web/public/packs/basics.json index 4ef9d92..3113684 100644 --- a/web/public/packs/basics.json +++ b/web/public/packs/basics.json @@ -1,6 +1,5 @@ { "name": "Basics", - "publisher": "Patternflow", "patterns": 33, "authors": [ "Seunghun LEE" @@ -83,5 +82,5 @@ "magvortex": 719.0, "a_big_hit": 9999.0 }, - "bytes": 115598 + "bytes": 126012 } diff --git a/web/public/packs/basics.zip b/web/public/packs/basics.zip index 3a98bc751a1931831134ed8695d1e468d533f922..ddc7405e6e0ac1c08d044aa130893bab6f7fe6ff 100644 GIT binary patch literal 126012 zcmZ77V{oJavoG+CZQHi3jh$?4+sVeZZQHhOXJgx**tt3HxphySTh(1t^L&`9{`UO( z-=iQ60*drZz`uzFta8Xni-+ z(uqxQ3G=|kc_nlQ;z8>K>JbeIO|n!r!Dd1tr7g9r`TyoaNRtMP4~yadglI*~pp9s2 zI@G`)hGy=APblEFF^i_}4^0!b$z><{*N7cnWj))vG2mS{3Ow!Hy>@Nh@ZD@W&G0>M z-|IHaXlO}NkpU3MTv3|E$A%qlhmn|tuVsm-NYg(`XE#V<*X!wA;^FGJ7%!+$XJ8BNES^kJz_fj~M>3xQe2)yV3ikp-$GGqqu^ck!#qa~Ti%E-K8 z;9!$nd~%eyA7|o=b7j}qPsZDSQxWyAQP~~OguG7qdmasrqCO)7@)kd{l5C0h1snpp zsKm^nWg>Es8<8{Fro{rZ0{cUW_bAPXMK&k6+9q{o@-tJ;;FQKfv;1fQQO4|)?GmNj zk;5t1bEQh~9a$t)sGsb{(U|)kc9IUVa#2#~G=4ph`@d$!1N?q_lemOh_~b{|MuhVv z@O5L~z)Oh2qe$F6vRxTRzvLxdGp1yQu??n3C(h%6etacqRxPP^PxtVQaFx9P*MzNs z&b*-BrVeny;$A1CcExs*f~{O>EF$M7a9Zv*8ghDScArR==k8CJ-kNf=p(~=S5}qx2 z10xY69vbgeIKdNt!8-ThM~$rU5kHoW-&b0+MxnJz99JggxDVx*E!vK2BW%QH#YokP zk|B199L2Z?t#vKf_i=8+p@2IjZm(4DkVNaToyl%VZ;;gsLuU@CV4iT7^H93W?6(#z zk#Fj3S&_Zcu*Qa2k=?gVtEg>KNk61MuY7gX*^Vl>C3)U<*vGsdk}AfPWaw`-&ITsd z{04L_s)Xn{zbrv+bWFD5i)A>Qt3j}$*YLJc@2+>4WPPp*+MF%ZX-OFn_~<+lAsSTt zy)Jk-u}D~Zi2{?4}_%mWVQ&w7fs2o{XQ z?qzqc(M)8nTaFRce4v55@qwdj0`WHA0~SqqJ=Ku~x);4A_U(-jeW^GNkAA0{lX3A0G^#?P>7abk8M!iuxIB&)=C zP_L^Ovl#a|NCynB*16V{jXsrg&4u=7a{p!s>(>`16CaXJ^(dA$SQwd0Le-*IPKM(! zosN<1arjG0VPh1t4PRUL6KFj{XLC`!mQj9Hfe zP6N6%WLU0F!gDwM;v|5JX{?DXX_#a5H_r%u&|531nCb{NcSRssi-I0=mI^1E7k3Wx zAM|@w6^KY{(uU-@DzqEPC*lo09PAfM!&bHo2~yZPKAmL>1m25yvQ)zvrHAs
kayVD5m9&uvgCt-hOO%iTHA}KT+ z5z`UYw6;D7dk?9)dlQr$HG-8F%rI;n$oezJVT-b^0~4&`s)1KMsj0nuR=n&LZNK+N z2nqejVi*nIS5Q?pNq3q4SG(U+7=dUJ)PrQ=K$$I=_g$d*3dc3(8rQzL_aI#8m*vNO zN%&-qgU?FSgH>*n9M*f*UcRr3mx6EueCoD0;A*F4O161sJ+b@HZik`$YCNiWFZZ*_ zQ0>vkX~mbq_e0)x`(sQGy(L?R<^9pF*LT9{WHH@6SFU~cOSj%=GfUSaA=kC*=}WKM ziEmJ3)uTlL@kavNfE) z{C8pQC$~S|T5`X8X9;pigxW6kUdcn8Cv1CB%8+VI+foM62t|B)8yf-NXM<#2P=>iU z%PR;MILKnB<6IV?O&hxzg12kpl1QCq$p>?Z!y@i28Ec=ib2Fw)rWX%~4(8IvT0uoZ z-)oaC^>&0HB3PBDIAa>-dTfZyDC@X*WP0ZN^}C!3Q+1RFioU=qoVE-IZx}@Fx4;r& z{V#t`<&E11eefPK1>IR1cchX}+H!_BB+&?biz2=T=Q3EI-t%{US>PHz`G^CmHN)Xf zI3T?vMY9ng>Z`~c9rI@6|8bM$xbc?=>g9@zJt~Kf!ha&CeGk(1u%F7$O}S>%GZ8#r zBsGU^T=@Hxi21h4tv`!{G`bQ;RiYP&QbSus#w_^O**=_{O|WGm5hdK`T+M{ha|(&f#m z|9WiN0uypo7vJmcq}z?w~PdtWj1_VDS~w@UAYL z(&(IXFWy{k2Ahju=V9rh-?+max#TC=;;0m{K5gPZ8dr98cNHW zdFsdzeZ=3sQe89%uUP-^C+@#+KvpMr9azjj*L`}vv1%A{4~A9c3hykgo<3Qw@~v<9 zkTcm)YSauo6$u7JEige(KALxVh?R-%!5UW%q>q(nXu4GoBr3+D{X2`QchQ^0^qw(^ zVsmCPqB&4DE%hI-cVTB4!Ss$|8exlK>OwS%av(8^0uV)AVGo-kF!Ov1m~y*)OOCkt z=4#@9mCCT7ZpUU7-U=OB2kVL8m){~CIzS)X`#1E`(i6%nxJeihr0W*niXLjafE&!2ur*)Px_a~wS)L?<%Gs-V0hl@|sM=dA%a2Pn;cWoB^s zunm_h@3F_-kZ##LmKYp2Z;Z)Z1$kr# z!LW0NF;{w(4_5NktaQMPHZdN@jL_a?GjdVBX>?1}X5|EHb1s^rT7_{8y@1x?uwyLU zI(4flKCxfnyEWyX%nt=tDjyG1qY#8W!vGf^#RRp|;t!T|9>PW^HgE|2qK&IV35#6t zX57Dpy2ZX!c$p=uuf_oDIhFAzmWR*GTqAd!Mi1dx6Cc-6v0&M1tlYKce|>8Ias8+I zb5R_)9JOP5?e=q_OIUtg@xtv4v_WWc9AM48!JXg3bA5G1GQJ9#6Bv7;u8U*d1hwP> zSmv(hU%2!B`9=aX0v(Bb%@yd~d**y*)R`%JKlhy-GeYj!c!dYau*u!OmL@@dlQaS4 z&a;n>pI9Q@0oTO*(#PrAf{`iGFWiy;dXG(}>=i$-7PY>Z0|kws}zURTyxl^?d)IF-W!fc8t+Tidlkn$7TF{M1fGg5G?$!I1xeyL{sw= zG`>~DPeeJlF^tHRVIX$T0{?DM&J`RZi!35#bY4J>htu-GO;D=Wn^Pw{4cjwr+NQH{c-Eg)a7WMhW;S&?_X=6&_oJT z$ov6>m*mzG60lZ5Ab)ID<8&I?NjJ901Z_;r{?+9_WFcCRB|Xs+lp;b~E-pC6>_MU> z&r?#GD}Gp8Aq=laL_oCKlswODI_v$O0L0E1IQl-%ct3Z$?sVFju3eT>qk#GsGx&R_ zny`+@Z9)5zj+k0#@=qF~kLaa_d~XLk#$;|~|2YtQx{2f_-(GalVoiaR=amg+bE9nUW0x#x-OoEY_; zu=N>6@3yD&RU|}I2aUK0whBe>nb!ilbVNf#qg&rvYeyV`bcEf85z=F29LPdQzl-QN z=%5}4ze!6vWS3|#XB%WBYn4nectRhyNii<@kWJnclVqF=;#UMBeMYRKBq-DWrwmi3 zsYNrjO`k^E8Z~oM>0%JZA#hYjm%KT6o-ozSQQQ^DRR?2Q3kI?dCauz zl_rT07YyW4&yfiSyg}Muk)^2N5~C)^)E}_+5r_}9zTQz2FdY*(myv7*}wKd-#uKqOgJ#^D*B=plV;aC4Xl;bn$w+fMotbwhlTGK5Af|etwl|y%u~Nf>w?TYLU5B@D4m70NY$e5P zMQ)n@K@{EQjRL*?du44!ZFfZuZ{U-KIN4E^DQ|U-m&S7zLJNzgxM|=Ptqdw?Xq~dA zVV{>Ee{95PaV)>es4sMt_O(-c0o2+>^fk3A|Fu>lId$f%3><4@Ds$Gr^5`emhao^olc7%=qFg1~Xp z&wq;8MLyu!pFYU#LW8kXbE&?ua3#-eBU=7DB~d2JZ)z3HL90MH&M=#z%58rQ{iN?# zZ`ZR6r?ye{_)r|F?ZEF<7AjNuZc>s|0~t)6h;XVzBrLj)*)UYwy>u#!EXh$7wH7c} zq%LlK$2BlomtjTaUkq?1+Tp28Ek{ybP|I^EwRenWQn?Y~^KvzKG;bx)v@Ja78*4cd z$CH}H{;{sM6IL?r;>BWgDb>SHksLcnH*q?J$5^6t^e2?b5lNK7)@nxy>%Wr7y0J!qCSv~#X0rECSj6{W~%UYa6cEe&VsReekg{L$nz zBWBpwh?EBw)9ocStMGteWTMyxbl_dtHY@jR)o&O8q93%#ckdS(#?`g|X3qoT&7C;0 zt!)j18?%F9TZnNWTyvgMOzR+ar^6VAz5&a$hBBVBontqqsf#y}znm@nY;p54-plMZM$3yO2m8bkvM?eDoar4j9v;@2|VU*~JHI0$@^aRGA5 zII886%COGJu^TCJ1I6y{R=9u##jaAVu6yvjWEUW`xD261>|fyHaAIMQ3I_bV%^A6d zk(TGg;Mg*AqG8%&hfDzSispb^M0>g^%--b+JWK9_?nVWG+NaG> zgIPlW5|H{py_K8sr8xNpHS-N$MRtws zi17mT8bO>ze0(}Tq|wxakl+n8 z=A7l5c8}on73=r~wa=HPW&6?D-ttqDGiJSM8qXw~xVb0m{Ikv8ic^OlyZ|l2ox_oP zbO)Xnuc60BCX9O{`eT)u6Bzmv@1fH0MstA;vaN0DEKyHS*fk12T=5UW&GsQBK{?_a zDQ@QFjzml=Z*->RXI{gqcjSG{TV%tk$Ih_&Wi=p}hqb>1&#ytWtIoP4XP&6F9*29} z9E1z6%VA!XGe{A>poif5gM&J@0%?*3^#2P3zL zObo}jDxZ`CBivlE5`;nBLjZTKFn;wt7(MyyjYRtWx%WPQTrOfR@GBRLeCgs zAX13&^n)&LFbt2d5iY$u)okoVe$S2D0l|j@`5D>lpSEhkr%zmEI0(3o{HWdWeo#1* zF9J#~$xJ~O@4%}a89z{0H0J#$=E@UOjSa<$3j$NkxX{cu;mJ4ix?MgSv+>>c_kLr@ zRF2nZTzP72SoLkRNAL^(uCF644vRIL={aj=V=Sxb`CnKw0<)<_3w%JU5IW*4$GJzx zHcWInw%*t<)lu7aTKSIHH3iZvPE@*gK-M9;05QNL3Z7>kwYOI91531dKBP@;pbVqM(zaQ4Uw%{E6 z>|uN65a3?i`GY>Jhd#c&#^Ms$Ylv(hQuz89zz=9lINm=0KTB<;vBmWN7>fJHQcypi z|1rjYIZ7q2A9{chHsr=L;@>?vH#?FYb*DR`D>Bk}i+Q9D&oa6qU@IC=uKVbl|4Z-= zav+Y%JZ@T;eTLi!-8;-8|IQ6$*|4++J?p5$vl2L)+o^QGIpEzA zS}0$SN{TF?mqZU&%m)^+xrbba@%XPP>=BVVF0eefzN7Isjo9w4E7sY}H-w-?(_i#f z$j9iUD_oC%0l(e5kTXad2?oMtln}6Wc({0t7jrUvN|By9vP~tic?18yIU4{d`U5|f z`mY@Dzmmb9Ihp>;Qq$?)_R9a20eaf?CeXSFBMSSa%LZVA2saWM2nB8RkrRd2f#QC^ z!>o~67$uNP|I(s(SB=IASr@EF7BLly42L5T(sULR7lDna-yhX4(T@y6?MDz)UOn&0 zx!-p0l9mwoIwkOCv75cgalLLo$??4Q?v$*oEl@`UEztw+OvR!b;l~9dKsMqiujWk| z`Ge)#y`aZ*F($u#M~LCEHXtpvIOU^`*H*?ltnqJw7F%z;3WCQlK!9aW|Mjj_Y5~Yl zo|hoym2sInw7wgYU^9L#U%{OH_HsJA&{X)ck)SIHWR@?-_3hn^X*O*? zLPaa0gg|!}@3PV!N2`tCgfv}tqZWg#R^>}KdTWCXjXXFB4#oT5g+|7zMf_~O*cY~d zt663X#FVrv=Mh6a6;#RfU-Y$2Pm%DkwJDc6tNg>{!^9WbH*FeIY6gSWS0$9VV%z?H z@+QW{j~w9j?Ln!P0qt5*{sNEh#{F5T7n|_YCj%ME4Kx&HP#Sfvg?lG3>DFE2R~HzR zJF;3j;X09`#Q=l5GBf}Q?}PB6*3rn)9=p`g92 zvvMq5@^49i7^>z!KB*A>5r<&r_Ru88UJ2tu3iMQEy>uw@q*l(MVp<}BLTz1dsFDWZ z$#5_)9`zLM(!aGUBQ^$PcXnu!af_y^A%uDJL{H94I_c0-(+(R-GP)2W1;;!f)R+%4 z)}6tRHU^HJ1@BpqI;8%OUw{0s)56lY&s2l&@D!?tdVrUoDUBQ_k75jWG|595IQT>z z+NDp^Xu1p&)Bsq*2q=Wa(li%Rp`p>jyw;~$U_Cb_<3OCa8i=UJh8=x+QOEH;sS_p@LBOY)>9$|n# z13KAPZ6K|#oEQZvQ?eR$Dro$tKt9i7Qg%!RkV?+W*qEg(?@{q#giP$8594?TGk?Ff zy>R)taGAK^`5c0P2Tj0(zHMRQ)!$wvA1BKh4WJliPi6L4dWjv{M-}Byk6JS<=IowL zxR58evbhUC#LnDW&T05sD0)N69zB##d{V@b!opWT1_Oq%?H%Etvb8giZ?RFM&!r%d zc+xuc0yL!37#YqEuinm;L)B-^^|`%PA4aotYBlsK8n!%*-*HHJBKauqk1}&|Z)C3L zmFf%uWCYo*EzFp+JfCM(KEc(M?>5uEB1aRnzzeXraB{epkhEs3ea}91`^g02hR9l5 zqJcKbSnEgWBwe6!RL_{&u?DH3k0dTMd?@R_Wr0T7hK<%zCdBkjSvmo|NaVCOgqH(o3~sEBjn zAbwEI(RN{`n{`}s<;KV-fF7>hH(|p-ZIWC((D3IUFbwq$jLO~EHS#Nd((G&q`(NEw z?zaxya9X;Qv@X*2D;KvG_Kehw+)lpjx3Q?I^l%(g?Tz@v9`@0*uHC7#u^ZZqQR)+@ z$>Z`1=!=uwl-o>{ulVqICmWY%R@aNDQ}9f)!@r46QV>;?P9|89*eWG%VCpM8+RbYX z1gXFsa!8=+aI5?=n+>)=xJ*c~1MF4FWi|y)QeHY3Zs{az{ov?Os;$!c#Sl)iRr=ak z*gU(pRA$7e14tG%nmdzuyDbAc2+!1Q1R_v72ORd^d z1H+RfgCRL*Z5A5+bsy(4oAB547P~9UOW;8r&KScf^l59HF&O()LAj7fV9)UTSbkkH z-;C~LI9QJ_s7)i>^_-EDR6+WXIy0teJ?Ou(XXRa2M6F$O_X-nHLERWk-nH;_3Z(m2P-PrJT(i7La z$+o7)TifGc($+?0uZQT_{yFRf{7C0-no1MY(cUcEf3#N#(N*3-RX4FR@cWrfIrP>d zzxQQXux;eXvZlJW&BnomQK1{WLq%n!g*CK9d(Wfe++B$BZ)mKL@~(l&&q6|-z*f^K zIJ=Vz^J%39e)-kX%F5ER#!a>CP2JI1UnzfKLM+TWY+RW=$ly)wnQHNFA4UZ}8_D+< z_Oud6bhNY8v5v*weY0{3LrulwV5>DBLNVO*9+m%yCPAQfP)N?(m2lDDo@douyD*Iu zcqE5H?1b3hb8PI47ck1jyPE||*}4l)T(_!`ml;$soJT6{Ql<(qg)K9b9XRGxOz+Jf z>}!kh25?4y(310A&vu48Oi>%co)Ou8-{TItnb%ecTQ?OPb>mfW+H+kZSC3 z&Ypp94h*+Phd;n{MOTkB`Y3sW2p=cmz--R~W5-0S`#0V3vDu5aHp2Hdx9y{S+}&f&zG*<3 z@gh!xu3f5xT?p})|9zm1ceM?9;ZqF@)>pjqw~?&a4o`U8F^2{k8o$TFJoDG z0psmEcL1UKkOshIw2HbZHW+%L$Ckr|m?0f;{inP?xH~8tb-}ezwZx%|e@(;Fz$kUl zZhG*k9>J@90BtN}B*6SR?g;gI^UECrP)JzGS##_byq0E%#}u<87{%0rXhhpfI-*WI zggH6*)0*@l(VJayk2ge_oZABq7-j-#!B%Y7dk?%fOgK0F7-0~CJ^8g@W3AI8ogf;% zu3my~?zfo-koAyW5ijC#W>aF*G+Eu_hW6ABxH+-)n4X9lJAq1)zAzFT4)vl|7hiTH zHQ??%rM1T&wS*&GEr_m2rHVB^q8?Mv2U*iUm6-L-`NKuq86OU_Z=mzDSRN2`lJ9RC zenTe9yQJY&@slu?y^{skA-2BnUALg6U75;y-%&@8(xuhj6{k1epKlaM@(>yl+J;N( zZ(NKEmY1K*Gqb=k0FF|hYH~jGQxIGMnN0-8#-*|)vnsJ_;)~{v+y@Lne8MlJ!}p<4 zK_3|}@IwdNDyJ+y-8U@5{ne5B>>t3L-#~JwGIQY%*5B(fW-fLe_t@)WjoBxXTm7|! zUIrr%4y+sul7+h!rW1sNOJ(C|{C@j08mz^@@Ier3&XSWU5kvl@@Qy0e)hE}UXOgN^xFmA@T*oDXOFKj>_XzGZysXJScm#jFeGG8(@RxMs_gJ{VJev7ze*u{>$G8*+C==i1vwl_m4KWBWD7D>Y9ZcYYIan3Lb!-9I<(#ifch|g zg#?X^#ZN%ib=F0?ct9{?@btBv`F`E`+~c}46Cx)s%r@#U)W&)Di%Ba;#BulMGOc7e zLxx!r+@Q9aFY+Y&bGZ`PF+FqBTU0je@SOVFge`*@Z9|fHNv>+NGck)p+#$~JHP`Zi z(uAsb3}Xqwx`?5gpY=O@;VCO9bS$?5M?l516^gG#wtVIqzlMUMIq`z3we(r`y1X60 za+j>;wY{7Jbr0##ArA}hlZq$_><&RhMTH72B#_?vBqemDra`$OcFiBt15;4^Os;Dm zc}Mo3hzRyf)Vz{Q-c}u6NPxHjaC0Akr_<_>2O1#QA>x5Sp2rvEk6kANb2z&C{Ab`KC9T({25qrw?Yj^O?+8)^J+|zL0gwFe1`*xqsb)ht?{@~Ipk1b zD3evqdL6#Y>xe_?mYtZ-OpyQ^PR>duo$|j^T+&d61;1hsuO)eHAIjD`ZRt}8uf4Gk zla53+C;wwfWG2}U%cd#dfa|4u<*T?w^c6b!ezC|gpMvO4P=PsX1qqRhc4wz7M7rK! z^6)J+T=rzB{ONs8ht>Ux{>o)NP89`{= zi0oL*4H>TLETAg@D$9fFMI~=^QI)OsB-`)#341 ztdUQE(Zjg>EyPO=vAViD9L@?5g#YXgxkv3rVR0tp@DuNeG;YjSJo=>6hfGlzzeBA6 zo*8-UgscoXuqL@?vkYyh%%%M#O;i642MW+;ugDUJ33EecYHHy*0p}O$X!gO_brZ;P zSKQ^`j3fa-$AHc5s7T=60!XDsIkCkUZr!;Ohi0ixHf3>%z0*eLO-yo$AGuj1r*xgd zx`Ie1e5$BHqR4YYR@YYMu9Kc(G=x7VgBCJ7om5__bTyCizSoayc{;9FVMS$EoWzGV z@J<`!Dj?nke_GSedNtsENFQWzDT8{tCYKb{LTXu9Ei`96cZ`1ZVQO@1{AlROruQ#kI!^^636P z4=#|SN(wjO08qRH9FG8N;|Z2iEiW@eNq;Hp%+F5YTH|=1bjUkYS;?;VwS@?Jc!VZnFG+7|-#~nWd;aJomyeG= z(J28?rN#@QD8`bL=EJJ|%V~{Vwap2JK1a2j2z?3o9V<>Wzc$29WTKpw^w>*W`4?u* zmQ!bMst@WYTG*o>b-*>2Ce?bKI#=<%VgKUEMc~9SiJFX*BN^9k%Gl}S&6td0$P@gM zy^K;^g`V@JcDQwdV$n^WIz49qP7~NunrLkT3CEFb9rOlufX0HD`RSC~2yJtlWfyZM z4x#LiD)==!XFOpWEoa&+k96dobokf_%2H4jUv66LBiQTRjA~cGK3;89ZNu86S*q)d zq;-Sjfn6h~bZ0&=A$TL?#Nr>0k*-y=nbbf{AJO8Z146@nW!zwAZ(K}bH(J??fpw|X z5-w>aoTtEKseX8F9bW$oH%pjuE?sjXGEwKU%esC^Xdu&pA zU{@v2x`7>ZQk!^vMnU5KwBGjUAk9pWBI6X88TnORW8zIPdwKuNQ9H%9;_v%U&TPc1 zPr(8YJ!h!FZPGfrDDy~?^<@JR+C!&D?g=>?-S{NLPi-f28-KTOpPn_g9j<^$4H56D z6VZm~`cR`BGLNY@CmO80**XT8bziJ}U-;{A_|HpWZ?HojNDy86G4&i0thQwRFWEVI zzb8`j0~#ei{#=MFiy=&g1kp|Vcj>$&S@H?KHSJ=uY z1(Gi1flKjoh_7wq*I zcxt6jzYbatxNC5!LH-Fg`NW)vC+Ti1n0I~2Qwo8{?`@A2SUYDM=^{Q=yvJI|{W3>8 z=i=Vt!JT8r;JR9BTsZnzcL{S*E5aaXlkIb!&wBFIO1jN?``Ln#Jb^loCIJ&9fhgNR zh#tYVLONycsVAF^v(Df7!}!3q{%23ec?_$_ku~@wYTM{h?t_&9(>eBS<>eNOw2iy& zov)(YvSAD;O0PYSH)*jMQ}Ao67ZE0LX5Y+;?ZvC3iANrG_J+8R9m)q^whD!oG( z&%IzSkq;){$*6xyv)a2o3kUgTo%gdCV*d}j= z=I4z<-OcT6n%j%Z^<~X~eiMBA@_Gw{aD=buCo06p+925Z$Vt{2#9h0KC_T8WttwY~ zwG&?J1@7@uFM+Y&6Xpjl6Y40=Nu!E0MK0+7e5#(`sSdLECpaGmr(T&8+M&2d<44aw zAn!!76UW@S?F8`)ojC3ux$VT{R@{HRie)2^?Bv}u9a?8Y#GlD`B#u4w@lC%x$ud9V zWzQ1w{PoFAYwr+r_j4qF{FXMX85WT{8BAia&|a|kW|~N66$^jrg~iRw0{#Lz`s~9? za{Xmj(go3$dlzZnEA^_=20>*9l)Zw*W2Y3-$}BrucMoO4EC1bpD>~6BXcks)vFD6m zlqI=B8OUK1Q|y)CNUqa~yWOND;!bc2JE0Q5&vEB+>Z;@eWQ=BaY(`{P1J+QF4zxX` z!g65tQxsP_ezo*TH%JdHOgHEwv#3TC!>en9nXg{{fGffy(6K_;-qBU}*!p`Sl*v#T zTD7!#H7WtHPxk%?n<)Kt5q9es6dB(;P$#;$=;krA%T406`c%fxr6hT!nIbkvf-3=G zuEgcY+$RA@W{s7pV?31aLd%Lh1c%w^PQ846rwnFo;SFGi*pV6x zV`AMOSx?V?URjQ{qINh>@6q*y&rF{nQJ=y|OtqzyMmcdT7Bw?-K2y)4Vkg9fPU-cp zb`x#nSOw*=)~E8rUk=={Imt#e;xh~$;65YGtF*c`K%Oa zl=Gc>7!_@l>!h>;eN~!}kM!6md!8=PIOvYbHpAj4&v^7q2#-H%ENGXHi}t=tH?QHz z6?g(NWHEVYjb;9V_lddliDOXoK{*tf z7!}IVfBQCm@dIc~V_vCt#1J-O<_Nk|jh(4B%GtGMI7e77o^>|~K;7|A;0T3Kwg!5I zIZ})zPie<8zqw(Zlm;8%47!Bi!5-L|8Ocs!A*ciTKhr1@fvsVk?-f{O-D zf1TCuR$vaB(|m9O7D8es*Vo5ZdYat#=u76= zLmI#?VFz?X#Wpfo2e$d$TCK@D`26eDHN&vVo+olhGp)Fmg!+Z$fIWpG z@C&m3CmQNio{_8?)kdfFId5WP**Cg#H#rbJnk${ikx#Ca zBLHJ`2bj5Pf5CPNuJO$f`bN=0gZrXa@zWq1?N;#UH`F(%l8>9AIjj94(J_?Sspcz1 zT_p_@x*n~++kl=ba4{J#P0AVe;n2^5(!dH3;?Jv#*21G@*z9RFN@YR6f)nRq7a7d% zR!f;l_$~|LG5VL3HdKY&SQtypsic*4G!(gedgYLU)GFmeWvfcZykFNrj8Xs>9}XA< zZqmn%igTq>jqD}8HYmT78&1rIcIX?*c~BO2!H3baFFFYx%zqlDb@X>imG1fCg)eQ22Ccdt$eq}hV@_oH#9w(RNu6u<bU zF)n-;sU?N3a$;)}7Ps+`*sIo3eRdn170csTUYjI;w9zhq5g%Q<{bLm&0A@&y0tVGx zJjzjp(v433cm=f!Wa>uN4!+e_^w%fRv#UUV{FkR`;?1AMBOy|fDT}y3M=)w@a0MA& z*yp*Ki8l~&jPHe2%SHlr#&*klF}L~jH^_G@Y<=uvV>|mpUD9o^hbJ$ z2s)#Ch9O>ec~H44y+EG}HaQd{ZY$}}8MZ!o1yt_P%sZ!`M@{4&759a11a?5F(7x20nm`?%i^PKq#7WHY!zp#vr#Fsd)_Dxk^ zEszl3eU^TY_YX~myYU1^2JH0!6uoQz@sxyEZfy@;w6Y_x&Aa9AtITg6EqTuCHB7TB zyH<=^d9#1+BrMT!@(RMtv?JfDIj{_g^5lz7rkX1M<~_2`r`opt#vjCf^T5@wVsch@ zeOvC&X-&AAN@Ozeh?Y?@zI~=5hrY3H8P9bWpFfeo<1POc0N-L-6m3C30n1n8(bdZN z*+{a=B)XFN8K{!u`pz`C7W`X)F^QCY`89wh5;<o6bSReGdAB^iI>Gi~NG$P&S$O&XZ zO8xqoPiO0{W4P9!AY#a~85e@G^$4bB)^5eKTzn!rxK~?;+k%l&7lCZx9^MZdO2t7| zFi+wcd9*~Smj+{l`Jt|L$i{`Mt4hl3Zc`r9V?; z{D+l_{v|(bIF;LKbo_Z=aX=xJu+QSRV#`s|6Xb4~5S6~tvKEtB5tWPDWxe;VNsc)f z4)5Vt2;;9sP^+7TX7w8GS0`DwreLDjT z@JGE6*9bVHe{TzYx6bB$;vwgDuuU9;ADMaCa90|0IRk$^QD{oR_HfS_do<^k4V5>@ zpMEvL45i}qJ2>nQhGj~EeCeQgY$IZGZs ziiGYyN=}BM$)3TA?w^>bI6p@FiQe-66TLlH88JdkX3sPPM?%v~b_e&|<-XMH5fkV% zv8saoUP{i-NO?={$?Kis+R|&v>!~MGUPM(_i7wpyDscCITNZ3wz!KT|ST8w8%g;wS zwyN>!kLZ^RsKl-aFwebV=-@-FaNy3gx|sC!5EpgMvxMWUsH(8Bf$sp9Vb{vGQeBa+C7KHn^Rws4(PnT*rrH8q0r@Ids@f z<=oolg3Fi*P2V`){_PZ2)==&LBy*p5+>bJ=6)9qL>-q0pL$1-?G=O@29bsVcq+K1-gp?up-H;~h$Rq|}?c9P)047ivCjX0p%m>bgr2=mJk@(`mJMEzsL>z9sYioGzX8#f) zz6aw47QhupfJoriUgKy~>u4k%HeTO8v>|4pg6j= z>zAOx2@b*CVerA-At88hcXto&7TgnpySpqyYaG{vh|3w5X863Jm+-7~iDNZRww%IL*5< z@tv`NF;@_%L-YWj^n2rlpgs_-2nl89z^{gcOTAmH?GAyWapY%mz}oP5#HF5}X!PCH zXQTGid);5cmf#&NvSV{Vz%_?RXgDdfXBQy7&v46@jGxypuY%VqQ<*_68=~l>47@9q&7vz(0ua-DB z_tGVrg?L3*B&-ze{lK$z39_sgHU~c4T?>Bj45;hsUew<@dB1z2H=EC6eLx{+l ziBfEKgn(QfJxvEHmi0KmWdC$;hj?Aqhj2%4>+&M~#XRfx5+}#)k_s%b z4tR?xExKkYyZ3-3&pw`TBY>Xh$1~^(dJ=t&ytj&3{I^-Dk`qJ2H%TYnI#rxpx!7Dzl!(mj*gxn z2p_e32t5*XKe4;hcny(=pG*$)==o=gd5iis<0Y2F$REN9>-QWRi@&T246bW7tGFsa zXq{eHcF zHO_KJNeAj%C1?pn;o&9n1;Bn=3CXvP@$}@Tj=NjmXhG)S>R()a^1=H7$J>h*Ch{DA z+*Rr4BJ>NsCo9eu1zRd7eg9=kgdU?ofHgHDnb0A$F!YyZ?3p7?9YesmGm<7*L>j6a zmCR|hA)y`d0gD2E!vt5hGV1WylHqeD&N4^FI(dl_2iA86eBpJGmbI<%Bm*%ciPfD9 zfB!y9)|+Y-^|-ar59D*hUNHYI56!GLk_P=9_>um<42<)?noCthqp^E@bd+b3MvNG# z0p5Y(R~6=;KxQ#$Sd8MBnZ~6c$Ma6h!#fM=dAFtK z%Q$AWLOiRcFOAh3MrKM}Q@$0sYNZ-WFHgA)`g-r5rNOZ4LI#Gns{PH^>+TlK*PU-W zhTt_6TgW~#y?$Egs+EYV!7J<$PN?y$(c_jkk^=kd5uRw)A3DolhIsq!cfUf@YV%Pb zaGjhV3+NBq8miaE!SxXsR%57ZIu5lhrB6I;9 zX4uGa5R_LT3qE$vcuL>O z)$oWsar3^cLE!8+SIT8E8aW9j?{|Ar-VmvoUsfAQ`{SW8j?`?|>xylEue_Nzs-nTf z?$_L8@zBeI?0$2N?cY3)(8Lry9_;5Iieqrzw;2$}Q z3zU};!!7#>M+wWwcf)j(U#|#8joiXaWZf+7>KoE4wepnL055tmp*RqzsHMA6hcx~vY?7R+m5>QuHrQIFZ zC@Ta+HeABl2oP@o@q}@URv-Z1teSOVT4zGlJ%d($*vJ#Nv)6*G)HF&;gtC@01q^=l(hK@K>ZoX=5OXqXeLw6efX3UveR9k zZ6l=8Q(x+Z3t%*eka0Fi%S9|QI#brl8GsMTcMf#aWNUxIu=6^6sX%Y}nmoF;ns)P{=r_RDQZ8A-jc=*dhj8?JdB!4;GR1W4qN1@-A(2;GUIPzU<1b;^wcn-@4F+dF`=S25*R!7cO8{O50w_z(4 zA;}f0>5djk@Qr#Ql$p_gGV3&$RjtFEX%^j%WE%+CAOV!;%Z<2dIw>B`HNnwY6(|7~ zOEAd+!+Fg?c#@8!N0eF@Crt%4#BCcjljevvz^NDH%tbVFPG%x61olLx;>J)pIKTuX zRu)}yS|>cT-n!dr>yPuX@MJ!Hwsiad(v2N zf~xg>L)45GJUL2@X202oTongO>iudicQ{^|FcZ&2uK4)fRo*&iwlKUUvYOF|WH{44 zs)M4}(eQAB{r9d&(4r>PI&_;iK<;v(z6 z9>h<5&6ob6@L`PVsh5u$WNqX&OdyqRJ><|ZgID4mR;))mT+%AThd|6H6bsygw%_Ma zPY)(0>YF}s}rOF$1g4~{=aikNAuJl@d9S|B-$yV?qGN(Jdjs*2w{xhWM7FU zc$_cW)QsovV6PQdZn!MQ`$>cS2yOtcIgYo6$xf*J7XH|ecErZcu%>={ko%VyTW0i% z>j%x%X?YvxV+Jb{M#s6CS>}?u#l44iSJyJaWsOFzhvy30$_0!}?V2869pgGRR?qVM zO0d>espkq;Kos0TUSHGSZToUa)tSXZ&G2z$7Vx zvW?zMGFJmv<#k({yW%keE1Kp8i~r2Fe)joI{S&FZmE)ycW<^Iy!Cr)ELrjj^F?o2a znqu~;-V=Tovaua0#7mobS^?={TByA+suJfbCoN4 z4IvL&r6;F(*pl*g{-2)mS#hl!ojgsAjhA;G0ZviI@DCdW4J{272Cm-wwR21U)()af z4P~U`WkJFPdTCo+NDHNGLUtkaNJSJyi~_{pk#HK%vF3xaWTS;5BWlc8$(5*;D1?V) zI!@o4AGb5ri+Gr zB&gS`bnwSRmPJ}0KYY`|J=jNMWEaFYb1(+i8K`@t#1E=5UwU_c!B~galZ{3NM$FAh zw0_z^8*?MR{IJ0?))-Z{D#gcDuMz%+_P7et1l>6|J^`15@;StxOa=Tb1|k&k5zL=R z%%6k~p}R>j)`Jyq2`>46X_R0Kp$eqmW9+5ipW`@Tu2uo_{YmdX8Tq)W7h?*MQj$R5 zV&o#|i9Z7_AX#twz&GK|=kuNCx4?VMi{-4-_dzhF|KXJquaAxA9Chp#-kkq$zdZ&{ zCsLM_5Ai$;jZMOKBcwmrHX&`{GY)9rNAA0YbHi;*k&M6PG7|*y(x(Ey#AZo&zna>O zC>aSL49dFT>TPcwZi7j8eMokFUVnae&VqPmL4bn~0+P?d-5TLVJ)Nz%`jms(p!IOM zsD05Ybr1GIs;xUwzqtE2tA4zzOtx43@KZ8KGr-N&s0tVJNhAeBqck}*`uwYWght@I zYcq`OxCK80_$K7KrBg+V7KDNsHX|p#hG5+2?5DT=d_sK&v-rhFqM|-vKP-O zz4$WpW*N^P@g;C&)G=+lLx%(nWBb|uJ%vX)9FWqqwJk~#w~x9bB4AIpzH8)>tBvRM zOl%Oo4Ix^WP*T3o#R@55ZiALp8Qa46D`_^`_OHQ`+CI;=k8=GFgF*>yP5e=6e78Z^hdP!N>gJ$M!MN`V7t3SZbD^L?*BFpDdD2@EVZ+yAkr>5NO&}PT244 zn@faqYKi=P`jBG*i=))VliG711l=V95>6x>C#1Wjg++xH;FI8iH^}BFJUHD7fZo^ktpygQPY-8R1<&vG)-%`xY6o#kh0MQi)HUUET&~Bex^wyd0;ne4_}P}Wa~v7y=Ad(no+dcc z=@+v;4K=l1I5_KQWo>gSuw$>YPitcxa^6aEM7J=pHuicLmrUQ)0~$ts-QXTs#o`)r zF+#XOUx8^%q{DMTbp}K|HYYJ|(9RY=$8xQ$2W>xhAgs@4q;GxMdB%wso*9MLznMr; z+!z-cTW`0F6nva^c#UiZLT?-^TF~#cz4XsxylwD4lLV9iCU`jjZ`Km=;5MC5i3Oz~mj%LgR!N zU@U=)FV}ssl^4C-eOVQA;l}mrDWvb2{qYwi)CN2T@Nr4xEWD8Nimh9g6;ok% zdp=QOMnSfJ&V~Ax3XA>T18MV7^kGqPA>M?N z1LR*SFPBmf7E&2USCs&W%lFV5q1z&14Ym*nz-237DD;esbcjcL% z`D*w}81yY(FpujV1Ec(2V+DEkO#gOqKJiM6$?UlK=PhzzQ9H9rVu|I>@Ng+7Xy#~U zYOdqudc@Y~ei7E~e%&tWI5|i-f{T{KBND9j6lKGQ)eGLkehX?;|%g+K{$s9r`w%L+1X+HG^(ocWrL>3^c47?6k|i2>zl#$!a-RaJx-UPTYVmduYjNjkj2sm zQnqWvn)9SpQ;JGeev3{R#IuH1BONamj!ZtLNbu(->srlojEQjz5eQ?%xc8{rMy4V* zT5SE$4U`sRAw>V6+q~cQ7lk=P2F&pj)7TD&M4DTUd0d1g4E4HLAyij`p9uw_cq&G! zv}$vRPHojR-ARdtDsRWyyS&*G8F>Tn!`FWjtdwPDpKVr^b9Np_#E%PfAj?x2D5ixS zCaBX+zqwGPRh*-+?G2isk%*(ezx$HJ#LUvq$ku>TK}(=06PfR7Zt|)Z~&(Q`U2+s>GB{-TctbKudf}KGB_1ZUHA7oz7Nb|(@#5v&`+8R_;FG!)# zsR)eeySJGH^O345S#42pBqffS9^prFBaRKK7@Mlh&b>S7l~!n3ezGYII>#MKI{wvI z0y%g8B}G_0lhL_)qSTg8!b8xghy+nJwo#?nbhYF=p7k$M|E`Q#guAqGfy#+i%r3ww zFBH?%HLMl;kh~h?c(`efN8k2Us0j+p<_URRD3jxCE!8pkTLtg!k9x=5UucHAb1u^; z>~nnIax~f$d-ivDEot8Yh4-oz39}%%yzrZ*ZhGl9Sl-k0E2HUGNTzDXn>&x_s-}xJ zXKMjtP8P!?_p1Td2b;(V%e?*o*2eO+K)17H4?qc+s&hjD#wyZ;w&hsP_#oO99d=jGnhv^1mnec27pW*UYoCr z?ij0u{%I`l)2`vcJv6LW90w=jBO5ni)KTCWjPKO%zYl7@(7Z+q8HMf}b%jMWo}?c1 zSf>WlhDI2-w-x2keJoizA>i5_3?B#Xbcd`hKD71OKC+59(VP(Z5Nr1PO!#?)=DqG+ zYn$(<4o60XEIO@p@3Aylb=9xcgi*b_ueYoVGqyem_l23zLkCSy0U9WVgPKI_jVJ`m zl^KR+xz_Kw6D6+9Mp-7c)p}#3V-Ki0Zq?Fl2{Zv6 zaJ3+$Mly(j&rh412kt`Dbk-hJi~16hz&m2LHPoPoohl5l?8UnHYpLOhxurKUa(6S@ zoOj^aT{kbD2l41SoN=8^`5R%kSVk@?L>S}xyF%e596mafF!>QAtdAzsHu@#{c|53Q zq~E*k@W{u?z-IrfD#(!w&;AL++s9;-Q^b3YC|u&GFNR}|lc#P8Cn}r68c%Iq`KY9_ zLRaV51(yrK+WPoNdoEafG@-DX$8_uZ*!iNR+>OsS>YT2k;x@^vEY+)OI}cyCo_ijS z;-(>?p(1Ae4*o@GU%}tg)Y#cNUx28&i^M$nes~x<#x?-Ah$Fl;5qT5K;>piDNlNDO#Wjy(rd&4xt*97wwz|a?)Ca8$ zfxr@|vybrSm+T)&pe<~Rt19fHgL!mJHKx9iUWU!h^LU8=Zm^!$bFJdt-L_+;fBAsl zID*K@!(-E22f!|A=d-G6ssJ~YbMK(*WganU7Bnm_yLC{^a!OVs&5zK3KWSO@u4rur z;(%RGj(Y0#Ms0GY_0CT z%1Y}SGV-;`CCZh%%bwosHzt$gXPneFIiHUvp}!y>3=l4Lzpl^+8g5gqp`XzB!8B*! z`^UnCf59+Uj*WA8l;-v2IgI;ozT@+na!uucy%scwcIr?P)g+KEO$2naJD6ODBi!GrK27yvV zNVcBa<$WS~qjCb!-y0Zx?y_h)QLdPp&ft{%(>+KJ!Xm&r>GyZPa^-wN-#=8-lK06U zr-pP#7gQ$sBDRLg?Kl}u03?R|$;zby$!*ESH-Hvs#oiARGO*AmaDjh;pe$?hsC-Mselt=O@9gRq`urX(Lv$${KknR~ zyPew7j#jXZ@ZiPA8t<>P4}Dd2s3X#uItI6caF`MF93Hial8Wcl`j)%x@xz#-y$=z{ zCqKCD(&$`OE)Z9Z-(iP+DS6>e33wDAqsUcZk|NENi*_7Uda60dk~|)xd@-$d#4GCM*Us&(J-Clfxd6!tJ;mDgW_iC~0 z`_1b{0+6?z>mnvF#6M*^w+?^&8P4d#br6WM{E^pJZ$y2&J-VLI5A394!gbXeb6u=$ z!nKB_{(F71Rgu`dE7oSC2+f+p4vt{&TA0j_Xvb_Tnan#}&m?+cQt(Hyt{$_8SX5 zmtwJt(J82R($Hn1QoXG@nS~C%;s#Rre!jtME3jSC(3}&FuJXuFRl+6s8*;){_x4ZA z&4RK`U5Fq%Lir0nCev@s2PAVwgfW2R(~W(69X+>-uFTN;#NYnhY^%A04~*pg#rz*S zov2>~J&T3sG5m!I%f_YsamUtu>|!u~z&A?`%|FV&w-^5+X1JK;4LUI~dex^T{6&Lk zMH)ama};f%o^1hDwBysUOG1c|diOgDE>w=i%S#x4Y!(OC{!ulC1R9 zGyd5AM2VRN?oN&L*Ss^?!@p=*59VxTQ^!)!}vZ@Ao-QiN$8NUB8+0km1xwti{-{)Ga$N#ImyLn!QDP$<9ANWvE zNbl(RJ#2{=;tKYUJT7JXo>XXtsYYU9fzz$S|} zV#UOZO>VuR0d9sC&~d9 zmW3b9?8g3+H?!tGzIa#LqozXh5jsjA+B=@wE1G!thcKqGvr&gn%HMDJ{x3Us#8K`y zN;;3=p~R?l;-^*^S4h@LrS2o#^*VGRDf z(ks@}tdU2>*|jTT^O1CNJQbm-5v{o(F@Ll5u%%7%imu}Gcaz(Sehp76xzpE!WN*lm zM(4!L`t0W%AH+SM$BiE7TYZ))_qT6g?j(;6z4)Gjr(C8EtS!CTvL^SERl6`Qn^5&S zm-?Fb*_&>a)n6_}_P-d7J0?FT^K~YvipUU0vS5#)xFnc?)L##Dhb(8boHAM|H~L)! zzF|ut>h@Ml72V8b-h%8usM6BnL(GaM$|Yn{g0b7k_%$Q><++A8isK8es~Wmtu*>+F zjRaMaMMv!vdYdUTjH`|+zY-s+{e^mHsAgh>83z2d=e#&(`IfFaiq?MPlZ)Z2EaU$7 zue~G9u9hOEMBC(#FLl*&p5Nv1Oz_8S)WYtpy#-}si&S^NjyMr(f(xHQ=5m7=>r{Vd z>TfAtd~oc|s`+Y&KY|*|mtOv?8q`AWQTBDztSmc4^&=i9ZaCOYY_UAO&hh9v@M|tM zD)OAB&EsS(egU+X-+Wn-jCCpsjSxi>zU8z%Q;U9Z04jt$tFeoAM$RSqLzk9 zt@oBkI7B=FX3q(?SMg?@SdVzsq)I=32In|YO9s6lO5!j9A4;8+g8JZ#Fv(bDK@ckS zkPWhdGr3(FdRaMEp6^$#^swES z@l-~0WfCnUU;OqK#_LE-l(|${1yASZbLQqI`lQ@%@d&ea+r8zIN5sgq5>C_HyWcA` z%3Bfw*S6%;wdu<%qfa4NJWlcktjUI>%%zV#>#fcnt=)lbfjy@Q4xIXh1J6OvTv$pjafX{e1K3oH1w0;sYG2i^3e|YP`$#DQM|%OQj){3n$6siFb){BonxI) zfD3D+eQf&IDY)*us2^Xr%+rXesZd%Sm z!E~h?ISkts1o<5kl!9o>z*+TwLHoEG;I|iJ`rhr?)b3goFn8A{#F(2HW zHH@RmT3Sj<4pY1#MzNj3f6Yd;&UE$&C56A}cMfawKlZMY9MrR3-1{&epT9d?RoAR` z|Dkc~D)T3|UecGRQK%%$8Sq`^*=5f4qZPKu1_Jt|9C&l9n@?8pBnvB=2J1vgJg;;N zOG_~CAa1dL!_h-}{Uy0lnY#`;$5~!GGuOWmaxHx{1z9Dk0(7fqUuKIAPIzKoQDsA_ z_r4ltD6Mo~KSo6h$%S)>&S8F`P@)#TOC;&CBn)>PQ#az+WoDmiRx%o?JJnfTjHpp2 zIg=Wj14arc?fVX@xPUWg73LlYb^v_BVOb@Rs)R&Wp|Gq{h^O6}y|H2WnE2pz$t~j5 zDW7wdHvo6ar4xJ_BUNGG4a(3KjlQHP`Zm&#)tT}>A9_W(;2Sd| zHlJ2}RO$BS%@oWUWz6@_CcKiJ=%S~}S=9#$=VBV$c1k=m9GEKaLqH|z<4v`VQ+M>^hjtqjSxKr){Hrvm_O6_p@y@yQ>LU9fNqD>=@ zfI4B(=>0oB#}5K+tPz(?amg-V_bkNsWX(McR#;C2M{*hdB35z|C}DIgeF~)ljy+0W z+CjH1xMs7hRWh8dKL%Zib&JGJA6`Ce(Opb~(zM4v8IN^{M0?)yzh5_v2*9|~djuzg zOQ<)s819s-5F@qIv*1p|dv0docNdZeem;MN*CH`Mt4tR1GjL!Y`1(^D54T!V?}riv z7+>X`OVzyRsCXDfn^FM4?EUfKU8cuwt=V>`|9#t2ui+42c>E|nY47do~wGcmg$ zXOT7O6EY?0+?!Ady>fDJ^E0RbRKjVGw+{hqQN+h#ZS!$E8S^+9H#rznf|}=ub!_;X z7j6kKfqucC`9<14?r@nre(fAO?R$&j65kR?i;1Y?3-I_f$h0*|X20JYzAXush~)+P zmH-kcle0R(X@d#h-(zUjq@Tl;&j*|<*3t&6)W^Q?4}3bSGeS62zDojJdJ94^O$_vt znZFS(uArZf(1yR8?kS7>GwRodB`zd>-KK74-$Z18WWouz|R0GSJ-FQmP_ zltcA+F6ehg@HdY1^F8AV_ty;O`gc6G!NMFf%eaM4iM;-j?_N{13!EQr<8-ell+UiF z2iudm36Q4XOk@6gK6u;q-Q(9S>u`Mw^AB+E?#;yPkhHh+9<8q@+wfD429}g3eo4Q( zGUMu>(ElxLXo1p=|7=Yl^lmnN7Xbfv=Kp^JSmOR+NbjwF?+nQ%M)m7RO~? zpf*3kP^(+DOap;UatkWM(I(NXH|TMgK?MWh&&GJvz=Vh4Kbl3UI@{*3-u_&Y!eMXj z;96Q%y&#OeARFt-SsS2J_!@Bjle_X;B%LvxihZh{E&N${ zQ{}9q<9}6ojT=tR@eYzE{=fR>|GG}Us?OtdkH5!h{)o|nO%@Ttt^P#LRZdnJk)Uhv z5Np@XNy^1iRvkbO-5a=Mxz*VqjvLB-z#H>*nPo0rCQk)28LQ+XRc^^V$q7(O1e+B) z7SaF`!BX34c-zN7?{hQOV3S8_cb}~Fe>T+{e}93vtsa$BB~c-6ks{8C8Nt496Y@p} z)&Ks-m@mQ}o?8@mx8{Df*VP;@zjD!y=e6*0p699Ra8;RZ8F|x!HH|im@+XgYooMG{ z%i4?iSY>gcU*X#{^mbvZ$Sj9>1%V-YG3l`}>G2$s_0Ja^OOO+i5(kL=J4QP2V9T z4$v0+BxpNEZs`l3o_%ehUulrLDj?HHTb9I~(Js-5TYEH;zeHFD zEhh1ayA{nUa`~6uFWKR$-}*CCu=YZ&ygO?}Ub^WqcFryQlD@2a&2K0s3fyvL=N#rD z#FCWFrc=o2)xI+sT!hLiX4ML3m!H|V@D={>YrQZk)_tFtI-WuLZLpF}Lx3~1LPwn{ zPwyL_*IBy!EwQ5_LpHQf6_sGl`bgRLsamP+uadg{WXC7wq(oA=(Xa~(x_W0`%+ngE z%Jt-&@z56B>SdDCK%dNS<~%cj+Q)T6igu_;e9#l!U*{2cwsi-=+I5+=4RQoOz_)4i zEGQ;OEQ&A5V|7puHI}oHCeQ?iScB>M-m3nU12zr|nidTB-ruEP7UC<>jv|vlwXb3l zZ*qUVx;7N&0Tt`88)DWD9z&Kr3yF`Q#OK>dlZM}xccVPuy{r!U6|1$AJK2v@y^W3AG@)hV&qb6vDL_a%gex4(Tiaogw% zlu_Eq=AX6l{SAjMcWbI+VNm0xMN0Qa81>qmIAue`U3jei?@ro^rUiD{i;CuPo38$R zJt_CtG@jgI!J{R_yOwMBN1mVPXCH{O?-s|)b54Gh9pAnEP)5k(`-ArFr_g1@YjX4< zoyclYe&)T5@UM*>@7XBM5iWTTi7BduhHjd9sgyI0eRYC?GAR<$g?v>lySCsbAQ`FJ z_sT;Ya+3+H#o3Ehlp$q%H*wBS4VKF^_t7VkXm+*VrW zmd1@7U>~p~2+Gx-t+6v$3>}E`ylmLz* zjDxRq-ULub#`Fc6Q>`aw*pC{SH?CCVx0u3VL4=r#JKbUMB+7J7T4EIz0HvLO;)+7y z^@v`x`_zMJ`8WuHq3N=TJ3TTj`N2}LTFf3!HaO|{8?(}9>R*n3Cs{bCZutWm^DgK*K0b9vU9`WxqY;OU-;?hyZ|qTcPja?5G~q)5B^hq zYSbL5b{WU$q3ap5wDKUUzCR`IyI^`%p+F&gh(k@0A^o>}FIzbp>A{{F@ko+2hOBK|cf1hH7pXXl5-P9YX%-6Jr1^&3t!cmO@ zn2(^Ny`kU{%#pDsl!l0$e_JBr&Sj9}bw| z(XV$Pm(ri~J~Wuv)GZY7DG18!d+4-XkY|CcS@p&@ZxGs#`1s}*i4~a4^1>sG=ybL; zuY7$jx0HV{wL4vW567#7C;#Plmrbhi{30+tD)wW+d#&HscV0UED&`0HZ+(s9(t?0# z=!e%o>!3vv?Rk9dT(95E-0B-23F4~asM^abIIhg6VlxA=|Ch$(oezccG!~KXW%(w*}l7` zCqpE;sU(i^!a;$>GVxOCRZSEVby&oIJLo*Ow>u zIK;!BZ8T@AP-lGq*!E{S^J@M>5U)@~WE(Xusmnu9Ps&-WPifL4IxFpE4fJ#nbf(vAV3azfS(rxm^RBALA?YD%wnaCtw0lvqwL$ z(_N~soufDM5eeE7-=J68SzPki_uTl5+WHCxfNn9yEmFIq)oSvla=P_U1epy!$HKhJsBn1 z5RXT~BlV}H_JD4MHk$(!BRwUthRq^%jcJ#!Y~w-wg+}Z7-AMHorh=3` z-ZI`^Yik{?=FYhl1Zt0;&Do3eb|k*m%E#<9JA0LbNp}h-YKc-{4D*)LxU}G)xUQpa zd2wmFskLtN(v@UIMBLi=H~QP9b1vpN^I#K4p~f$l&&EgTQ_RsN?uC%hq)|`QzdB<( z+j?UzZdSk|XQfR)b>|n|?mevO456`^HZd{MjInR<+qsy__|oBZ_~@U^*-2>j6rE`u z3Ac4YveEDE!oT-afHzAwIZbyyd3QFypnEg0J+k*CZ|+7`?+YX5G^u!J5M-bIm=shm zU_6~8)-6ewK4!g(R$JT?v(s|=aDuVOlIm5y) zy5Iwj-;n-#4u+T}rSgss9LtZriFpQb%yvo|Dm2({D>USS)2-gr$5fvdvr2C99153M zrBd$a(Tgs)f;BUSJ%9jz($?^OKqc9p@2?Erc$lWU1V@`tLuk(!G81FQgO3IQeHp2wNvPhs0X+MZgf9sj>Z(mm2A0M@euCWK$ z$Cg+otp^&#h?fk7b{geE2PH|M{KWWnJBlgEzV(6qr~TtBmF720Lp%EY2D)Ql+%Fwd zxt-0hz)sn;w#g(9T+pRCVDP4o&Yxz47}rNCMDT3?(TJlP8Jcu2T6lQF)lh#w*f$&N z)-~oBGVv-uhGvqOeo2&6l65Wu9M94`_TCE?^xb-yBdeA)DC_19IBYU(4^2fr!ED~r z#>c&}S;V-m5+^t@tL0K!Wj=kC>`56Fja|*YKV2F}K$*X}?tXi&fq6p3_eZ&Y+o9eA zz1$}ZZ=Gyyr#fyw{q$hD7yK*vfpl6u>!4_L#1G-io%=qgzxBoHJlaSu#l5qWe?Ic> zs?^aF?-Lh)$?50ptl_;x;XfPpA=O~5Yt#1!_G#wpmUp=SU@Z3aELX(xB5%}Nra{l^ zWjk9!Donmo9e>niav#s>EAqbuRIU@3C<8X3PRC;aUb4-PFc zNQr{NQqUHlD3?ga@akSwBiXjwk;?xwRr+IcJ2W39r%W%%++~Je7qSw<%mYujc+28! zjWk=&498d%l5A4<-8YlYIerP1CH2+lyvR8>CdiJB5g>8qlgKbFg8&QSqCi}2$jSTA z4`YPAd4Owa_%5K7UXVE|g1M?;{QL|!W8m$t4 zD_VUT{V-`0?rw$tT_q~sCVk<%fV%!4Cm!#AoOu2YSgyF;U2V%$%iLMALHNj47EtmA zS%v-IFvu-4UvX1NVh0Zz=oJe)f*Knw13udiN)_d!purK3^JV434X7JJIoe%-uqLr8 z9D@E(7J1PAB4ZEC`V~fSxaPfx2Pu#o7q&BRJcq1xomfy@KAqIB;OzrmT z`$lLCIk}KFvQZZsHce*=x||0GWxJCCeeTb;dD^!{J0^y4^(QIdZ~Ls?{OfP=8P~=r zYLv_f&ti}w1?>!3ZnP_2Fy0eVG`c3025km4iJ9KE5zM?n12fJUz~%RQZ*neNmOdR8 zx!2$KOAQ4ycN?g~$Hr#IGg$bl@;!GYmA}4~)oWPtpv`PHWeZ5kawT--t27jfTzMrS zra`&!IC@Pl%Ib?=G#1_F`^Zns6dp~_PyBvA;uPH+YE4c%;Vl!Gbc`CQtBu!8R+hZWwCK=RFqLE-NKC; z?$eJ7bZGz+`Z_Ie8IBH2y_9O26)Qu{YJ^MU0A zM2L^F<&Frlx&r zdxFd)e{W{RRg4>o-VLzMTHG=v7&LX*YTh0R9A+43iz)uiylDy+XuVW`4g?O3SDTnt zXQ9kiopOrNi>7;&vwVp?R>OKaA@V*a;`cZMMNCZo`3-|0W+hlC2pEj+n;r`X>)CEG zEO`FZ$Z%6Q@%yU6*icMV(Vd`Xk!idsq{TeXoosDwQn!-WS)vdmEX4e6NHy22)6*Gl zdN`-@l4?|HX9;oM_|ay%$Y!zuSr1gu~t)ozw8c+&rLjQNCZCu{SIC)g#YR zNx*g4wwpf22v(0D!6|LmoStx%F3#xo2uJrV*OK1bx1d7z057!J;vq9k@4u8VbE}gQ zA)2~J<{8sevNS#FcEOe!1D4UI8|CI|z~5xUsEtk*RQyb8jZOs4p@4I2{p3QwqE)9) znlEmkZA3{^Nkl=ld_97aDs)Mc#y@nJ+TPyE!|jZ9Q5oK(HLZD@7p4o;$KH{QnzkVm zU&#p#*=Jd$@~&F(hG#kG?pUCR3GFqBJ;hPOj)$oVbMOy^5y$7Goj7!qJc!FPGX>w< z7FRxdJC`3j6U7e4X6b3@JN~CPhSpFgujJVn4cMCc8=BNys$E8w#c#R-J?A5 zxn|d9&8?BAHEW3Uyrq1T|M1%u)tBSOx^RJMWsK$8ahi0jwnW6Q4#}l_&?p6!TY)M?YYDxSVoC%Y<-b$3i%0z->5{F?YBVHnI zn-10<7z*}hPG4iE^YbU~R;zE|kWD_Vjwa4FVGR_0h{Duoh;8oI;-V5EVNaEIHbb%UK*w=yyHF@)bJE~&a`{f>T%us=f_H=HH$3*`T} z^wF)lC`NJK+S?M;gL0nExNf-U6nvN8$DzU~qS4(7~O--QC?C1{el+cXxMZ zaCdiicZcHcQ1tbG&iC$p-??A%Zql7*H))fdH0fSlYyB3it`Xe)8G(iBS9mo$$O8^d zmKtx+lAK$#hL5!ov>2}swDoF!4%qV>=@9f$jG$>4cstx2lKC`+7amW&@MnhLG?*9H zF`RT0dkru*CEgnHU2ld0tUB+JF1P#ZMNZzMwji?L8^sBbJGWR<3Fu9K?7$ebrwi#7 z7FIi4`YxlfkKZ$1;>CH4WzF*xwjBKgHgmVbM|}G6Bi3}v2jZA@!}UeHkr3m-tR$D; z;x0tU?=hJ>xw)~n%>;fE=ybpu5#Gt_&R24ba5 zf{Ny**%8RSaY?i$m9Y*nF3g9ev$}>O5+YQ1PT*5AdU5&Ny0q0r(yC{>1?r{T!t(MI z21^IsNRGHK-{AVA7Ixa&-0Bi$Kjp<=5GqaZehSmCO+dQDYm${QoD2L%p_`LIQ1!nI ztaP)Wu;-^JL_UkWCxUH_yDXIQ`pIQcK@5JDt5krEg?8=XR`Q|3L1X>yoztQkL#c(D ze=lPk+(Xzsq=io}#!^%8jEiM#lL&a1#at_M+?0H=edn+AH+)&3wf@gmKFmt|Ja8ZF z`k3DDQ4cwbE0_8Y0NO~cvcj%289T^|&+w%sXKx6M)3d9;4nLUP9=~uq4%vY1l4sqR zrT$brTEC{vhp?cw;~iNKV;llE;;F|%TMuKVno_C*tg6XJdVM70OgB+7E^R1pq_Ktq zH;V4)Z+nlbQEb56LOG!9Q^zkzZ=7SX!=OQLDoW#yb!&pB^4Tu{c{|*kuTl-$6%M$- z5s)4DNHEovRF7=e5pc&je$aoX-UFVcj2&0+)D>yD2!y+>~p?_jxiJ>Yxgl1;;Lyzzg{0ikr;YhGw5qT^Y z>?aQemC)l{o_L!^Txir*pprc@ z%5ztsqQ5vGP5Lgk0JCydxX`6bU-)<-wr3__ovdW+CMPwCYj3}`Mf$238^ZDVcL3}7 z%!~eLTxTk!H4~tt?T|jza%GCj?w2VW=~0u8)4hoWO6_Tizd^@>?P0~@XLe?+AaHgF z@FZ{~JWl6~g1D071M0m;vU`THA)%f5RV2o;o&u4qH?WLL1H}Ux=w@NHtxN~nHDcx_a(XHS!TSyxZ&E-Nl*;8;!Xc77)lb4 ze8)yHj&=xROo1Fom8N1mRFaHrrQNsJ7>k1E1k$_u0^Fn3iRNSG*nYK7{h&vhE{|^o z40`1?JE)=8{1Zc;!U5eT$O=4PgsPy$jwYLG&1h4o_&@pkVM@?v0+T>nBG;sfiI0hWj#B zJtdy$+)~iF5a}#9oLlABaJ42rziE$?X3*Xa!e2^n-}Zi(GitcH*x4>#il+e`sg#=- zFJ^`#7ZBAF@;e7#F>R?GLX5c!Zhmn40#iJVtsGpItO z_c{-x>mDDz_xaWYel+h%cAGPhzRKAr-N!Av~KpekOx&`o6CY<6ZAX;jtPS{VNq zI%F9kjk2<&cOa7m3XYMX=zFs|&11UlJ@o$iWTTPwA0x?BWuFuT!wEYG(+7EZF5sW{ z&^VQ)E>CQbSy#i#^pMf!3=$BIs4{k% zLO1JJr#8PI1!rWq1HxKZ4NWG}&VwiQQ_rW2ES=GOR}*Apoil19Zb)%60>hAKO7-&W zNFQE*ph1$TNa5{CHtl)32ly$wPnGMS!sCrRxaO~HC!RhT^V3ErWy@D0zer0fzayTw z%I6S{@Op8% zyky#2a%G)T{&Oxd@KW(&HT1Y18KsudNMZ3x3*l|e+Lp}tuGY_HD9+k`IB^iiV(uu` zao+gSayyaqys~q|3=r*2n7@Y|LZHFB?u+=TIBof!KdbD=|L|N7740rhikamfEi0gA z*^78|i^NfHd-eFM`*UBY?-OU3mXM|?6Qax;Gv(z{6fmPcWcIGP z9pnBeZJh!0B3;6NS7>+KanospYmx!mR;}Q2TmdF*6LJV#m}=fR(RRfo5U7aZrX~MG z*oZ0%vf@YdXzi(8*sr0yaf>v0;whkCeA67gC`Mq}V?9-Jpk}D~fEYVFm1n?BVEA%I zh}4@9sr~+OTxI_b$SW%Posz_>yp^=#=-~F+M{_qO+!?c(Eapp%WbyraPAXWjTqg|T=?jh-bng4e zwk|w+9Vat5T8;-4nXZl>qXBG)}ziI&6P$!Pdv? zOSd|mGSgks&b%DBOu;XkG?l&3pd=cCfQEp zKTzzyI6yh(ty0L_3SjP4a4GBPL2bldMzFOVsNtLB$P|uPX+ri?DPyZCQ6f6rY}a5N z(&1`^qPtL|s5XJCxK4yQ<-v2KNDPK2V(6~~lMk|-3iM#xxZAC=y3znR!ZK>ZG^03Z zoaijSY3oMzDqsbyI{>h?%W$_FNt|rsYjIzFmpKTR)Iu5?4XwsmNo39~j4^7NS*wSn z(n=5?RkTfL(n?TWt1xFQ!CC{2XjNF2kZ)NwG=Dy51l6mPeI5f)HeL-pe~b_x`^3Bq zxWd2wNqxs`sdQG9uh~WlCjZ?d&3~{BYsLM%F8F~Z)E-@U!P*obv0=!Kz4qfbA@nQ7 z$cG*M(rLj5c3u{;Cn3-W`l#M)j~jXhYRX&u-R;`9|GLRF@l91|oqo_G#s0$md#+W& zOaDw&^4=!Tgm(t}yZ^BRjG{*S1gS zJDCx!aWUW=M{Z&Mc^Z{UDQ8~Lb9pLzNfx*hvt3%j)7t*#sL#Cnw%|$Q-|OLtUyF?6ROoGG3u?5!5}fki$<0>~6Js!+!*So5Da zso;&&A>s(;QM;~=qkxYlkw@!5bc@^p%`^ME5Szggh%~|S(NB7i9R7vO5%>t(Nr<1c zJ>>M~kH2R(%BFcHq)o_S2tyX=Y_jXc;kHK_%#nXt_LU~hKpQpXJ3$7oCiX^cQLVE{ zI`R*Z z_#xhK&Cjn2pFb%F-=Jmu%V8U6R+|Jy;u>spaHtXrt&&FGw$Dsa)`H(?Fny|4er40 z!1wh-YoYK+qrtjGBUyJq`p_5s1m|bSfCdB~+0gfE$e+dX)$nr^I7}`uT?MA6)0q2q z{@ucIK8b=ifcq<)2+8Ls--#c3U+p6Y+|Xj!O_o^0aE@|>A{pgKhBQpkjA;u&;XNHt zc+U@`A5Ga*cjRPEkIpV@U~=SM+?3|_Yp+#2VH&qg+ILK{$AAry4Vg?~KK2A}%n=U8 zp$8m08at%PSLiu*Ul|nR^H;Lh_xN{*f??K9;=${1jQ)^_ z-cSAW8K&vAQj^DB1S9?t%>me0>##G@M~k#~pXLIa878w`KFRF@lSiM}XTjKufee;e zxty$$WKM3~_#8U4tE8pIocj_F$#G9)ycEk{-Z7SIJ05Qspdek2#7q5vCI}G%Er?{E z<^pH`l4ZnZHWd9M8`2)l4rU+2q3Cfc@xE|QqncBB9t0}2Vm-+a*rJHCiuc^x#y&YS zoO5_q7D#CcdB%^GPh~z^vze-Z=j^O%miVL_`8yy?B#+_)b*sF`An>7_eug_rQ0>^s zBx#wnLSC!8x8Opw*y9;SA$gv-Lc*A_*m~pl+W>Lox5&s6i>THbczr${Zhl}K(z7j+ zg78lIl-%RrY88YQ?2@QY8g;s~569JXiSs;X94zl25%z*!sS#-lLxrh5@|+ves}CPV zhBpuG7^oMt+>h-S}I)m?n zoJ)G-cWCSC(Z@f-II_vQ6z9}moKm(Ncz#L-=}Vc$Y`Da;ddPcF6mR9@Y<)8n|Qs8S~>Q zs}wmg3S4J4!|_CaHAk-zneu$=_GEu7w>-#IQps&sH>e+zgB(rj9^ATXe$4pvresjl znKt=Tq;p_kc2N>9?{OXcx1W@`FyhRlty9CunM2s7b%$ELNGk_^ijCi8ey4u%Zu@KB zxf(+?BjSgu>NShk6(Q$P`J$@H$4v+{{MA|cA7Q;+>02mxkk|AK5-|T>zGnLQFRv-} zzo-u$HlOho&iz7>U}J~kgF!}Y@IOSnogr{JTnrC>yBjOrL z6*QoQtU)%KpsxL1G=jv9tqnDp?Hzk&e`ZFSTPs4bavkgdZC|fm%H}rBpG<9J{=12^ z;W540d;$_LaQ;0?gnnKbg&vV^RRN$T5_990O7bAnx6f7gUy~g!U60|=tqbGHdP>G( z&ThH}8W$2Z;lrA=wZUmIPeUN;gORZu(4i6VG>{!gtgkSwr7@?1tMztmZecw!yw6{{ z#51h-Bdz^R`Qb!Fz0R`AXfpH?(_J6sh1N?sL3NrzzKL}CVUPluuhpeG;eA+sSx6?J z_SipxLT&;%h!0GYozPY9P&?>uXB9lu$8Y^Y?^;5i@c3*8m<*{M`POu$Gap}#k9)peI*xz9sTMxpQ!Gi~MCn|Qq)N*!%2P`gs)sRw$qsBf z*D%lE){I)_tC<+E;Lnp5Y4ylg$TxH5YLW4T-&C;QZpW!<)JUsg@hs@o#~}XPb~TKr zIHs7|ev>Y#_|g#`Zy+V`-9)NZ!5EwN*5S`h%l9FsUS1KXyBX5y9gS9fvTdZ|4(XuW z88>fPCL;bk;_x*dNnWA^eq=e}qQ$*xrFiMIJm*ST%U|Igx{J}0*}6Y}9)3l{Ps>`v z;8*#KevIF@v&V~SsydEcfR>mXjJ7=6n+(%5 z%B-2#Mrc?I=fhq!x`d!q4cUthJ&ju#seB^ucUr|g50rVvrk-)$GAX|=V8Z}bduM7M zZ=`(T7hhyZRBgXdk`ZZSe3gb3uaO3`LRXnXq zvhtTGBipmGXYVtS zEas-*SHwiT1_J;>M@571LT1*9;$qfKJ&*}LsN|+{=b?8^3GdE$(IJqt{AcD z*~qbPP6!jlnx57TDj?bP54y|6&Z^IPdM6N&rDyGwY(ZDy{z$J&9>Cw zM@0lr{)?z+cQ@gwKdaHKc>e4~=goi${KYav#BaoRF|uOHel50F-xb}SG2?aG>`V~3 zVH39=+tTA)$EG7qbIbdW#9)3`i5D_Xn}g;A)8?++Igdd~g@TmA7jPYzMf)q5#g^+o zPmQ!)*9Msf1Pp|uSIc>$nD+FOOWsLvgo~oDTp(l zGISqh{Bo#Bf^cqg$~u5#rAN{)QgdmgrElb2w9H?{;MvJoaY!A`sLxWTo5_!N2mia@ zAP2cl0Aoeay=XmSHN*IPJH)j1#07%y6MSFncmkReX(+?MfaG~y>;o?M%8bf`Bm?oC z7~ME#?hXWj0}nCpKzOe=z{~qcn9&yj)EmB`3L;mj&gFC?TGkW--tl%QpFd%N4F0lB zb~jTP;z!KTFKU-SrQ>D0o8Y09ZIV+9eUTeWygSeOl9)lH7vo(4Qnd5m8MAmu^}$Kf zI%J3uXV%r%OROxk=C-Y-!NQ1$<^EZd_?5d79wG9~cEEp%OJ_P&Yh=ypEDLTlo=z^$ zReSjux)re1a3cPQamlP!PtQUhTIyllb!~JV+ zi#vFDP&V`p6Or1sn>y9}Siw5c;2*21V8*#=u7a<6Y;0<3Xoz%ID+4|k#Uy@wapXSw z+PAxSc4cW1r4l1Iex$9f-(HcoNC>A}Yh7=U%xbv3MnjNzDt!RIsBaP{ra7gv*E#8O z5Iv!@h}xp>!s9G^woY>Fu5w@BaQx}DpSQrJ-smE>;b&VO)2kIy1^ONFQrE1aCWNno z*Khq=Xh`>EWpDA~0{_i9`Lz8+da;ewsrw(#=gMpMj0^Biynn)j`szq^fW8e`7YmxW zmWq5(5a!9|h7t`Z1-p~Kv1#h99cf=z~|IH!XHU++zR7SJoa`Mr(EldJvyXf63YN?3!Nf zo?eZ}ukyj>ke68}Dz?Nc;D_hQY9VaVk$J7&A5yb7#+3jWhrym-?#Kv8r}Dj41>E1N z)}`JMuKD)Bb|41C{bJAvAwEcMQ0%ZSu7)#n1os$3It%w&)#lt%L5u}qzeIvF@@xqE zutdF(_QLy)ky5ve*4m2!4fggh`2bIU9E)Q&BTt_f{2RvDYBV2&Tc^Ed!LH#Gev&O{ z@1bvwT{4-XW*jm1$^u&gJc#=$MhqX=P?OjU=`y&SEdPe6Z6lnZ%M%i{+a00)vqK0B<*4B40Y-=L3>-#st3ePoX%a7Z_l8TQQ#-_4ay(dsr$a=?>Vi?x=C6mAR z*d_4>{dY@#ab#A7r^m+Dduz`X)Ep!%5YJjo{2*}o0)HO>vAwEHNU@6G+Y#i7HG(Fm zFXzcV!nG$!bQXp`pt|10mUQ(B7PK8c`ZBHy+m!S5N6D|(Au9yznlD_9Vu?9 zod&hf>^ZZE-|bz4d=dapp%K0ewr-_5dekY)r1e$R5S&5JaqAuR*=}yhu5`xShKHBZxlQW1q7TwBX%?Ef}L-Qpa?6im}sU zbWg5^7(dwhvbxhxK1iqZzX63e0Xt`rnm#f3XVbhx+Xm5m1n=KCeePO0gL{`9k0YCP zPZ*MfPXnCU6n4rjpjKByfuEEUK`w#ft$vPp8jm>b<0#ug&V`%&|KWI$cNT{=gH+M~ z2IhkV!@nn_|3AR|aJhuj|H04+`!8UAC+)w0`80u>oOU;sEOmuxvmRv_X9Lwrm19L` z%6$!#Qgk!)(V)AYNLcg3{E;oZi(gjIQyd^(ekkX*2*}St|FVOH@{gbMOXwpI9Kn_? zf^>QC{(&V4aXB0xXOb{hr8o_i4l`2!GMA_BnVBs0=g)E+HM1tTh!)gOFNjSqiqvWfgBN{_t(SB^rj}nksJ@q9ORRgFY)k zk&hRgZWLYXSdM09%y;fx9$TD8*$$uf5EdJ%h5ke}3*rV(OqGKgUTh!$UPiLz0W&)8 z(DU?P@r*0i&$i{v5mEZbdK#&Po-T_UJvAXawT=K$z~scrG66czDlB{krM{9-H5xYn z7KnRLG2lK={qDe?!)7MF392v}5twmPTE5D-@p&A&Of#&mlcdg*Py}v52ho?(bT8j% zt=^4$Xtx;wx{dg6PAC@h$H*GD8&EYARg$po!v=QmT~H7%UP-*RG$7iY^%r4058 z*O3s@hQ~F~_^Dmft=wReBR(?8v(i$mx<`k~wB^XBy#@2HTrs$U-&X6Yy^Q4+xRtg_ zYQPsPB8s@b?X!;xPRNf~g-@#a9aO4w>7&*5O@#YLy{>UP^w7xHoJaBhM!yVxzJP{w z+>UjODWtA->M#F2HPY;$d8r8hsF9cILoX%9DFcAoTzktatfp*iY{bdM)K#UxAv@|) zp2{kA=GW|TcFCCMJqde1L&SH26A-WLBfobWBBgDEOWwqest)oZY7^`GS{GTHF#NUs zYmJdTd;O!Rk>bojt5~ho<_*PP`5^;0Uq>{jEQ?xbv2~M5V4d8}Wc!1o!0^xS z05}puj>XaqW&iQN6vJ>?qXewlXW5!k@^Z;M$Qf$AWpc#PT#h&q+D3LX!R}w0sSnvv z!I^X@R^9q2>%Vg0R;Zc2b99aJcv_g2A{F@ry0xrk78w%`Cn?(<{MNbQF@(Qt9-Xg^ zv5QE}lXujlcoR-p%8%4W9QHjPOTUh;TBsaJladKfGBp1tWyUi{Hi}i)pWh&5YOd=( z>;(7rEl*@TjYo(v=7iQ5f~}p|iS65O{rpaPqOVJ*kp5H4`xH!>WCE^X2zPO_&?+4J-0A z4mY#iU1Z!v>$fuptEPyijmI$E!Gnr69SyB)ymsj7{p22;(f4riVghq?ZY{$O11nMD zn8x8fAll^SyF<^Eu|#AW)#iDsx4?4pxTy#?*-;6Ne`O8tpUI02?=Ie^NXe^<)`Fe6 z?X8$?#Fr*ZtR(VvIDj^?hNy!**i;&}HNbTD%wY;|*#L2bM+A; z^pb}sS#I`?{k-tcpq^PF?!B3CRM>w_#IAJ5=Bc3%AWD;dm!tXOwp3~zrj_!tc>rb} zaP;$-mIxq+j6)lIr042+t-{7a-?*yEsEQd>mO2Dyo%LsUi-;L(_i>GtZ*^r~b7B)`O?uE2_ad5@Jg% z>4lB2GyML%mRFeP>j?HZDBkzo}fOj{==eCds641=|6G;$1BBSITyx{?h z?vwe8S&t7o5Z7>yPN(ay6U1vf!5a4csgBV{VZfI9o(uRX>=L5qAgx@R+_#fw25<(uU=iD(*&`wVz9+g78OSkLb+t=|gZHd&Wn|9B$qDx7`c z7EfR}hi!O1-Sq3ZJs5}XpphEVI5(B05jNxpg==bQQ*R$#cXj8jwhU^zoD_wLbogqV zn>|#W4*Jr4hp!0JpFEd&o@VUB{YPUj%MrDmjB%jWcyq6`6TI}>^?m+Oj(^YS3)(XH zatgzOAX_YvCEDEWe*G5ISq7XD)ZObE6G%d=JW<2hJS3Wk>+Y&$9GJF><7%|#!k#Ft zhv85+=}?FHt2!k_P|OrgyEe@^0P-(LhH#qE0b3TW&Zf&Iq6`-LNHjS21^2nI!MSla` zlMw0xF-^oLZkbNO7A~d8~FTlWdW5C+M1Y8-ZG!jhU^`7hw=uu2S3vb z+H8CL;1l7-93+y=d}C0H``z7>o^g)1$+`qA^MSIq59BTGyVS`G8P~5N9E5)|MItT0 zRz=PXp-vdkGJaceIB9wAtGrt8`jOe$rl(3~>9gpEcNd$BiF}(&rfK zbPu<9ZHckZY~eNPek9f5 zn(=$a1}RdD->+%Dpw!78;4o!f;KrU9wZ-k_d}&Q+X&I!LI-^x{f(0$qIv*HG#p73OiuGHk}d7s8`-a5rN78`0Lz^+Tz;5*Xor;i(YH zNKhAM^c|r=Uw{JDSnqGxG@SLjZU?&!WSZL4!Q^eWOb(yY=BamX1)%v!N zaz$C{4jlA=mecQSmQu=R;WaK)BeoJrt~jT6)hQzopVopJYp2Pp+X0|?d5Lw z4+EaKh@)KB5^m_)IJ`TOzT-Rl@@(-Npp23X4hutqFAr#|GEP7?Z3(yyE0)FaO zj&=1Ycc1Qcm_z$iQ*mAC8bd_wo+H(Ph>iaVk67MAqy7)!^_5T_;tA3Or@*u`@9mBn{1R6F*HT$HXsHa-X}vJjJPunc*9zkyDuoKP zR2Gy6qT=0*J>owxrzWC=y6KB%N&GuW_wWi$Op+cwGW><2Ucx>r#z!7q)1h)Dd-fbj zvN1hvMxpl^2sK7>$FJ-Q>q%0^HUH*;So{xtT$7Ir_ba?nX9fFzH`{)G^^DH@@4}1e z-<>B9TV)ea=P6^oz#Nyw>SeD`@=S_q?B1`CIHrJQCItnaPhp9rFbYE&^gZ*PLTWXv zpc?;OQr!g4otN7qVdlm`k5B?b9|37rb7q0*604Z3sFHY^T=96x`IH@74T<-;!tLPU zy$zgl>2l~=eh0U>P`&#qXfP{ofLyN;Ry!o~Ov1z_OLb5~nmqD+ltxln&)0c_Ph%e! zwdtUV^m}!>Tvxb`eL||KR-h$m@F~RKpM=@ySL<9%-P6^;lC8*#Am3jzZl|P6AVZhU z2*OI*eWN9g4IiZ?P>L^>^X>=&`TPPC0rn%I?=nEBxr^J(?1tL}I{8Xx6F<`x_0@CW z!nt1G=kVS!ChKk@j?k>rqwWuzv3L2p7t($#BF=re^cHU`m}Q%pyxt4KA=8n2bU3a5 zMM=j|1?|vDIf9q}Z|Pu9BCr$ORg7YXW0Et5#m)~H5h%iWjga5hPlBK zpGvYQbp9rw1$~EA9c`LNhi)|Rv0h8Xf)>Owy#ywIB+$Yg6Xao_(<7S~3oB@KH6umG zB(_YN^FsS&)^Ljo)kobojau$~etPIWK=?Y@3fH2}M1qg3#H5Zg)nI4AEr`CRT|B8Q z#BhvxA<}T_k%#oDO{Ozh1${yx>%07E_Upc$bhrxoxD19;uT0oQn61mo3O*M4N>Y>w z+MKOdtvHeCh&JJejDzFH!Wy)FHB^R#kerz zCn{|aW+%k&=kjR;8z)h^1ix>(QweUR60sj6zC;H`xqtf@827U$9~=*^%`%bUML4dF z_mq= zK;(*sm@|ge;}G7#YZL7BoGC;r{5^cnXeX6cfCky`D%@s~8{J=yc5o3I_G zK7n7ws8+|8T`s`C*^D{Qv`Md4w-3R4WvPx}co)xhMb+TquxxzscMs`bR;BG^)KLb& zAFo#J3AkPq>=mU6&qv@2oy9u@cz@>d<7jOwDh-v52d*(i(I+vM+HjQk{JHWp#~$-? z$VG@^uRy{}dizLsBTmpG*s8-nU4phpbEx;|x5!Cnv^M=U7uleocKbqG?>3W(Id(x$ zWwv-AWYx+Pd`53J8N?VLY!V{Ii{QY{*6-SD2YBMgh6{GMX9IeQfRWql%OSN2slUr_UC z%utn{mlFX&Iti9y&Bma&HN@Z6z@&ddi@Kt-#xAYFT7&avn*CCg@>E;a=xJKz{LfM+ zjW8}nT2(9%aGV$X2iF|QNuhgaoonUyy)3&wp|n0av}4|_Gr_o)lHLKLTve8TtO^uR za+yt|aq4ND;dVmJ4QMu(qR(Vxjr+Z?nbcW)Wx3Bwb~a>nD-imp+6%#>qTK5hMppphBMy;gDa7Kwh(TG>{f;}*Oj&| zWmboSt74QHeIIZCoBPG1zw`3wanp&Q@cR|~a+&2XnN}B#(dBC1y--}ZyEb%@g(U+Y+0J>MHT7#(1aQS_eg|nZ zu0_6Vub*Qd`q!sXr>p)C{C*pVkS(b-7{6nRO>thXNPRRFoNG!tlV!2RUNXo^S~tLL zm=?C#%RZhIssf%rWInQk?K2s%w&i?Pj<1jxKKX0-vTLzN^dJ%<)di6kV?yfUB7DM4 zfBCp6u-QJCD9{%^GxAX+mbZ9%{$1nGQ#`Bjy~qi^MZsW?`13gdxCNee$||S0@k{`B z{NKQj-pYc=ny8B%{FwQD(uXe`mlm68@7P>DC-vHm;;$6eciJk)rmJ;oY6dh-Xlm8z z>h$|XQQLk$EVOz)$Zg*)6^UP1i<@}+8SA+v6fd$*pJ{2vREFc<9k!*f>Cgdz)(c5& zu4gI0^NG&7)~P5c(P%f9cF*zRc3rwIQAAm1y|kR|xfvCwtG!cq_08`veVWTNOMCQq zyH9e17edV*VOfF_J57zXpw*iFyQ*)7>KjT|4lET_HVy17wEA71BD!&V0B>#!w}~?c zeO4S%0_=t6mDRDAq;Vd_W+YL!;&Fhvj)RSIK(*(uTW1e#rvoFifc}fFB@Pw#SA+d3 zf@fkUiHplq3*@4;GkmG)<*QjQCo~sfc1EFLdE= zlwwy0jsf&W8x)uWr2~@tW{f8`#(zBiCDMf9?9D(T&EsbvkKDmgY=Y{gP?zL^>Of+UXiGheA8L|;Vy1^chWIbQ_AV7s%NQ z1V)4AfqaPgCKi}(t#>j+lNit_X1~AUpXd*|VHmfPh&wdLd-GolF*dOgpcw;+#qTNs zmTIhi@S1wIiU#4X|;PfIo7 z0chRYtf7|mldPD(I;!&1hT~n-%5w5kgx1$P^;s-SEBZ2pL)Wsq;MmBUi6grx;=l>} zM3G$|yS;oNSO29}b$GR!2>021C_PMD(4F%gFpwc=O{51`m(UW-5%%_EYt@WK2-ceM zj2-*xSZmXpk@;a&bphSB;sVC`qWheDA3)T7w1#BQcf+oH&p0%%<0}JjCAPyK@qgCP zbJ#O>`yqa(CG~Hm&XFNTLPn}L8 zM?Go$?|!9?qwK?C^&_f##%}OczJGWl9o;!QziH72eH1D~ z2o} z_@Fq_MB~pq-`vwj(k%ubBTZc-$`&UAgW-mz*%b)VKGVQuc5oFJ(+;<{ZPTR}uM!Xb z)(h4U{hxEviE2`aF^FHjWa9m=v>^LGDs3?Bf0dmGt62o3epAskNNR)8)X@d883Ywr zy@>j0i1sS3Cz7)Wn_GPEQga!J(CQ1ZFi8tyI=3#NTu@EHbP<3&=yyT|?HSo!8!y}Z zTD+;J{>5T5J;}YhiRJiaY{vGlrW5x+O{dPMjuhvt^1O5$8nWuf z-HGRsF7_=op*DZ{Y3=vvOu%-wZ4b}kmc72HsASlnqEg*9P+3pfgqr{FhLoseTYC6Z zZGHjs1(`0ljL~l=lZQr2qyhUmy21VWaI@~cY_$40ccAbfu*sj3f>4DA&4c6=qq1uJ^%FS!n163&JW zLpMzJ9NiQG-5mp|SW61-uBLS$MUuDt%g<>bT3Y%lWVE3XG^AG2w0-O;-C#vrCiwI!smAJUvF%)9g_-Ys zv}{XQ1m7?XnzTe-+ERV1rTl~m z>_8@!6x3{*Ro?0)D#}}js%hSl9{5%dg@*sokKvImz^&;L86KITJey~Lol%`ZbqLU3 zDKKodRwqUMAa>0}cZFH-QelBUQ16_{&m=V(mr3f2c z3uS`!znTJP!?3})NwTWE7;n?d^-&hM-R{-bo&n&v->DDv-m|Jlwg(p{?mdPsWhtWZ zZq<(l&0vPZlN+DN5u81g#`5vBb-qb(xXQUzZKy|<8b(IudX;P%BaLJR;XD@M1>Ml zAnOZa`QUe0a!5`$6%JZ==%g@x@bRvee$+?_YZu#g-N)gKi~z$h9-|Pl49hD@etpxY zr>!J49M~Wz;kd0RT>9B@(jrhv)@dlcl?*-gAeM}FGTH52PCE8weA>5)A|bG_bSeIL zDgbNttC^_MxJ(ug2@=9^ zB3Zh6D8CWNQ%T$(YGh5g5!JL%KiZg$zNw;#nClk@b4V8lON7@qQ0l8lWnzHqDxEiv zCZHXO5z^#gKn1j!PE`};?u5c9KovdKbQZ}O-w9qTQKk13i4&nbPF)lv2kELPOh-<} zCry!n%iPjMme+ZixXj?zZ0$cVSir{*P32M?U!B*mx%=xeZPyQJ9b6%~b0xcv`cL)1 z$+tI-tz%k}h%y(akUif0ZXxeeg-PkzV%lkISqdJSMrn zV_><~5({Y@G_-oJX|j_*#s^A$3$v7sET<3^sf0Zg(I&OdUxHFL@=mNt(2HPP`^vjI z2sHM3Br1Q3+8?QTW#}PGTjc7!?1rxUQbnX={8h?`=>K8tEo0+om$luPnPX;Vj+qlX zjyYy#W@d_+IcAQTnVA`49<#^H9y9x!cdfn8`PSa&$C0Yl>efi}YR%q>^ zfj2boGsN%~l|$~I3}8Q3 zZWX*~TBzrPwT%zT#wndn%8>ls82qK_;AVtpqS>-t>^kn{9<`s&y1(9qX6?2Q8m!WK zw%|{MmJHscQfc8|nv7THs#)e-ZUTYFa$G1#j9FlFCZvqWD07#mj0~-=4H>BSY6J7$ zWHx%Y3g4AtTRN7U76U^G<)DWCMTf3a4i@S3sASzLhff)Swu}NP9X=PHL-QMpuEe^G zL5mPGh^(ik?&|7Z{A!-icLOsj#|(Eq@voN06mA7H@?5nt?z&f60MAgalnV zOHFKg$`$^qEj1?LQJM&7NWR_Fsjz|#YlJ$0eJu$xCWo&2jkUdVc-SRWM*}#@7XO%* zjs1s~rY4j&^$Z8Uq$rmy68PtC;`3`g*;>RYK}gUnMYUp4fR2W9A>Q-~4tf3ITN0ZsZM~>wSQ&KEOn&`OVtsP;Ej~{#M?(^DwS8(tMoi6P#V_ z2csJDYI|n%YL2WV3tW0gC%y^Gh+FJL%&Mk?(=$VX8_Nl=Z;+w@Qp{*eU?;w3o1!i4 zjADbLfFyBNu3ap^zIQL|kk~>Qnh>ZkiWYcwc#B5a1AUF|#5!vHy=s_4erY91XG=Yy z2lk2OdNJ>#w{&rnmG_NoS)JALbovp<33&%YW}CRwB0A?&zg0UH2X#j)+RmF3*NJTs zRXA0lJa;g}%U3g1Dgu2iyw?-79w7MXT5Ru)>|-+Kj>>%IJtw|W9W}OsxB3l1D1E8R zO=5N)k^}FjsdggdrrS(op1T}6`z(UUq~OZWx}uKES74l~oRAa=!o5gMl8&A$T|)x# zn9sL2X0{!BlFkt&5A0@uihNxr{fYhC&N~fwi4E~m@a&SUGHUdxp+KZ~!lZkABarlo_=rudgJ|iTF&?QG;Vk+h7Wb@le5}d7ObdqzYk}1bWkz(o z{MBT4BwNe4a9UhxxngmXZ;c{kL0$C;NUpEHzi;w9zoLO5;nfpwA6O`ffXRp(B&+yf zAuv#;V{wkZ;-$pJMzW34SyqICyg&k|BK3B#ihv?yKBAw3XL0+$4WjYD#kUSV@1P^0 zq+$P*zt)MEfsN}Qj^U(7Gy7ueGV)C>^w96ayp~jeUxa!5jPV8bKc&TVcrr73Fry?&~ccyKoFSEQa+YYl{K$~U-CHc~cRK@;O_fTdQ(b+#jL=cTb9GO$8>%?jNas(Ui zyOlXNohWgW>!{V6YSVq2Hr*w_8z4{4Ohmd9m6C82i9A9F2A(IlTHA6)?q7=isJe{aXRR9*N^x=ULQGh-2pa1E@mx z0XexlGksv2-yq4~$wa4q+|dk&7D0`NhqSq038K=Slz{FZxGbV+(+ykbOrtep4_Ms8 zj3}F@G~rAvhUE`kXvbIs6ZS8Ej{L=peMc@IN+BKsm3f|Y5GcobIUCDM>V^OcV=WcV z7uLrjHh~&`P9)0i!C%*4DgF{UJQl-#TiDGoHq(s-9N5P3G2i*xggm#yt|K>ze1^OE zcH%eHL2JZ;wAMb?nYJR=k5b2Q?1`Syp(6h<(UL zjy`Gk`%_E^GUki1VBO)}SvG#;;DRoJu_t|^VAff*fQ#8a-&304fgfLrQt|fp@Zm7K z`A&TH8WbdTrB?Tf<_TJQQ;yG{I*^TU;e(|G+<7$<_C8A;8@mU)7hMD}rw}R+tmc=( z4k@ZKi&UTr@L#V8;I2G|V9^GePeX70Fpxc;-;D74mCt*#kT;B~+nWGk>+&VsbgJCg zoK;l)&X9>NiA>GACjwBiRsuLzLeTSD4B8g^=?4 zf-xceJ?`Lp{TuCJY0>IAH}4P4RD;;5n?;$`1E<&daDOcWMmyBofgc^wc7J0~xE@JF z+|IgpM?%;8Nj^Zx=WgmL+JuUmNpafeQYWgD`m$}?p_)_J{OpOS7e6<4$eW`EHDmil?_{XkCf_Q+PURj#C2$&qeC za1JJj8HM|;&1%`e3R$5q-@4HkK6Q#>$EsE%|Ahx`fV;VamuSO7+i|%|9S6r9Sui6D z7a@cti~>r*hNZ?ZhD%8%#wH!cd$%AW{2Y{8v52PFT@UYq*Jc-D1&;WN0J7KM9T(zLYcvJKyMZK_U9b2@K zXuzu1s&Fx5_Ovsrmg#-e*aS{a1T`M87vY+LZ%` zNEJ?cS&iIaR7R=Fa0}^h3mYuC*SwKsGB;R@GnT@mknLC-{AN;(OgY}t6v%-9se61f zs`LPqWor~oG~hUh=5sm;rFIB*hs#`!^NuU2GbQyX`*}87LW-(XXk;N!(N}w8UlyhR zg}Vg01&PWt5?FqDV&~Z2nl>W=fg8f)-}h?d%c{L2K-~S<3OU0hp8C$?v;KT@>J`Jo zj^mHoKDNx``&&vub|IO*-80fI`%C|z@O&|b!0t_krtq$N0A)4fF#Y@gu_#KYCpd{>R?atqx7mE(b%ni|*V3)psAWcADIEX%K4 zpRZkTCdQRGGO|^uU|m`IY@@$+S37c9@oIh3h^dB)I)h zbIX=SELeGNV{LVc2+J*dqQ1U{bL&=Ve|qcU(%90XV8s~u;&HVa9p&W%Hu}il+-!Ag zsVOOO9=NWKH8|f}5X|Wwem0dO*6aBoPzb4l*y2~%YT~@I@_dci`Xe8+A$BG}p8>Yk ze)p&H>&oNW=<0*iR|pFeQ+!*l*+HCf1v7ALbrEq>H!cJ2PR=e+!;#bmmrqwAkn*0b zy~#cvpXBB);kN-Ogl-hxhv7w zyM=WqnEF43cjv z841>44|?cXM}Rt6pi+(&%|osbgPBa7^g?83fM$D{d@U5<(mXb+zb*5%2fjY_5hnf= z+E08t=Br+2xa0c>7}2PYZp_p}HLTH$1-{b^Yq%k>pW$48UAo*6g6v_qqPv!z4ojHl zCB=C4A@tK`zsSXkbmUgR*cBX=`m)RmcnA=bj0WjZ2+L-PgLJ50{MPLU(~=fVZY7sq z6etA_yn@EUAvELi7fjX_N1uuBgp$~ot&$wM6Yd(GkXOweNjUIbAFCf*XpD2g!~yR| zg~1ZB?(c$Vn=&6XS5g5Anb8fLEGskV(GAXewf;67)6<88`a#hcwCQctxni%!dAS|O zW&;Y5y&?TGNC`5fGYIif;X-5+Fy2BSg7}K?8?olJ7+0~S<$cNi6+K`T*|)(FF<~q2 zi$z+P7jDC|8#7~jvQzKf8WR!Z>pZsm+HGgqQ4lg7W=VuAohB%*=E;5!`S>g2EfuV4k$z728)i&uYJIslN}MYsg*%S;vo!LsIx z*y#Fvf!th$h6&rgqq-uAIFhodcV8tUKkV=DXA})=l;`b z0CSp}Sos)QL^0}JWOwZSKo)c;w|^(9K?eXQaSqx>48^yG1Cj`Ny~ z=&S>)-=?O)InwlgOLU8NKk$I#gDscmhiU{;_v6RoMu0k298N5`U4sOv)>^;(Pl-!$ zJhI|{NL>G4L_Zd+|9?b37=-^?02BCM3t-&;?*f<}>6wY?%fE?!gIP6yq^M^xJ~=>z z3p(3UapXS*`ye}T{9hYj=lD~X|Gfd0Yo8~G`JRsXkHp2(_K(E1DkF6Ed$FJ;K*r~P z6aBV6_2N-{g!&Hd|i*I|x3cs_CS!Jw<4UL}zVr-SO=7-t#JxwtQ`e`T_i zJEEx8gho$$%QpRCEPQJ<;U;S3=HV_c;jLuKS>Yi>6C{R%+)_K(9UH$l9-XF{wZPqZg8fmWjQO)spxx+;fyqH<(Xe>9 z&6?*1NBR1Gbk+)O$X3ExnWyYpHcg$Vfutmz4N0=Oa=P0$cezN7Nmi?6S9RS&ey;RP z$IL|r@1|_i`!=N;CZ)FQ{kq-@E9!Lj;7)2igfvI}4ufBLku+S`Qb%zqR6gLVF3TOIz8;7-a3&|03XXapY zwzvn{b}-4x=hpK2F9qX;k!swUFGZW~$7cR{{yj@o4>|Nk#lm`-%a<^I+(Hh3883T1 zl+&)R_X30xtfr*L%DPRrAF1>Kaz~k0Av#_3akrC(48Jsv&!n4__&}mP>-2 zI!WbbxsQ@mHN*ieJ7Sq;7A<$+;Yj5Wk%~NvOrO6;CN4IgloWSQ4L6J4{aPPZl0*c5 zwstsAI`{KG9#i-_x!w2N7L4of$wU-?J{Ok1Lu5+ng2KISu4cQ>Ce5Nb&?h*4F6he&Tb%r^Me)l7(sT-n%E_=4sV!GG9++YK}F~ zn!blx7Wi6`{tNpymk{V@-LO~TqQ_YijM#)C9cS8QexY0eY*cK8@H z12h?{v8PcA#xiIg^op_u^DLb3-ZLx*gS91btdhnJRtXGg$Vsokz&TAhkHl^mR36

8R}X*IimT7G45go@(s+z7#sH8!;ti6LOcBIx5nKGTWs_h1!`_kf7~Qo$rQP^b4XJpu9F9S$J!OPlM)uW{yAfNQ*mp!oN3 z%*;<-go6G8M|^p>K^I2s-jv!9Ui@_4Wg^heFxx!T!otXN2C*YIsJ*6FJ(;imETFcy zl1GwXn<(#_6#gQgv>VX31U(I2hw{t=UooCu$d73Hy76zN$v0>7K-#98VTh{@G;v${ zr`BoB?PU?kOuBWIgM6E2-aj}==a>%s9P3;|y$$yhfq$f1yzADivH^Rp9oANv>Mtjm zuQ9@s3xg*>5=T{att#xO)XyD^QIW*SGQ@EX32`i18CHVtS}i@*MLVWp%c_RiI}FU$ z7FM9tXF+RgN0oqz#=-g76U%0;u9fDzP4$k|OM}g(7%sgpqwdKp*N>-_O9#)P2&?gy{@sUY@{BDhvz%(*N4a~i55RKf)v91fX*<=`yPW-D?USQU zR27%uty(}@&*kFuBp8EgzetJ6J6lgru+qzBE-~=>j^0%#C_tF`#;u^-6(m+2_FWk1 zdVcN;-EG*I?y-k>f7Z`Yu9z6%y2Pi>_}G>(|Y4_)?XdE+FqQOj+B zsiFs%1##vPjtalAO0Y`eb4?e7lR==-M_E?(97WsyCyq4gRqq-kB6Qwh!IT@EJ(p?X zQ3c1%fLE$R-O;kF2eP;WqVL>}7h=U|waTLdybl!GytxwcyFQ9(%cek;@JofkFi|~9 zVaRJ4@-TYLL*u&F9ZlHI;MOqS5TGW@%1gKcM#;}C?b5DP-Vh^U(R1_dcRu!mNAU;Q^xu;`|b8*L$A?qzU{yj?f)mM=UI&feC^aG>>5CDTqKJYY4B)*^Q{n zQ3hW#=*B`w;O86gMLnpYfLkkGvuoOqAIlW8-Ki8?Q9nTmb9bI=uHmt!P!Jg9f6*(-}lYHd<8Y3VM!3Vc^N}yf5KP7mQ?(4h#Pj9r*nsD}Ef2;?rlAp*L6-Qpf4~*>7 z#=)2^lCgleKWfM{HAr`)^@72N3CsL0^@z)C=dUj?)ZO}ilCS3Rr0D)-4nob# z)pVdPH{~m5nQfPEK6(`9_@&>$>^(mMli1lD$xe6^@;ZEzI&wYMJ&msMkUE(NU$Tmb zxIi5_8%O|M<4Z`LJpu5L%IPd?UFIB4>;h|;Bd$$RC z-5?wrDFAyFUGnYVFXvHW(d5{XoUn3-t%SJY_{1fWHVzm{?J>Gxm%e5XK#O=3q1JQu z3bg;Tuk%0fab0-gyZ^bb15PBZobByYjzT%7F;J%qKMJXWl3kT?+&9ib7t59yhZd`1b$VRQISF zjtc}g)kXiC8^_J|UuiOugUUYkuMh9b?tYe5a`IsblO%M60HYKG_`F29dQYUx@R_<{ za8I2+zH*EP)^u`nC<1A~AbiGzPIqCckeYclY$1YMqu2tu3RMH2ByvSCD1z z2H>%B1YimLm8=pz-)9M@8xmON$f zdbp#$S|j2Lu!b4+v{MtR4vsmikl#v`w*HMRJkbBw&5Yzc0)BHLE!%WLpRP(W9O)1H z6X$+q2ucRSTaf}$OHrth?7K!CUzP|4tn!0z=?W$o6sH;VIDaR+mmSM%R{Z$s<6Atg?n2PfJCkKWKj$ zjpuxiMw8;QE7$TW^{V!&orpY>txVMt%$@&EC1#YO_#`wNzFCcjORoH zTWUeG%7QD&MEvA6QlxI>t(Fn7Tb)4IqImMWIkoChs83Ks%s$479u>8A4-=aNp4|~ z9SxQ0dq#54v**3=t^v9Z8q~^`#;+y!iNg;BkE5ov`5Qi_TYp~|vYJWZVbIzF%(f7` zK12h|0F&Hn@jKB^)Jf}s7>78V19ZsOCn8bQ#N#?5oMICmce#TXF_Ah2;!Ft~XDwRC zF`>^)_3Gfpdj=Et#C`C_>E}nSir|U#PPIDSzuLnzRd+B{Xap8r?EnWu@pXS;jx~Q_ zfy4`u1o|2!98H71Vqa8ZTYVb;Y8%$U8iLpIAm-+-Cg?<*T}Sq47#2bu$&vDE5&mYl zd)dpI1cGGmS%{RIizEh$}E2*Ryii|U~ABv z2%5szVh{FtoaZ6i0T&+T}Tw`VJQ8pBrO!R;J>e@~{pPKys-&)yVWdu$LRE%XR-;LK31p?);z6ZI=6F-T)C{0>PzHrF-z}5Ay3+C*I0da{A!3N6+_^frXnv5__4+i3MjEYx=-qLKFP` zG#UbcFbm0Z@3){0gpJ|=SzH)y*#{MGPH*eTHvxyfZ_IuN+i(8A))5DKfs?TQSiB^m znO&b*iC6WC0fYcV3(35mE|(sRRr9_N=IcxBg}R+I%)a-M+2MkZ4wnzMMie)p9(Ra( z*=QkokXc{hYTIiyChcJ^ge{@FKUZTEy~tS&?5c5ZT!fYX7M$kL{uUQC35x<0SUW~~ z4D1VCIw&H9gi!My(ORxwohx?@xhP*1N{ffv9(F;bKc0MAvl} zwM6#;s($9gzND_Bvh_e{Uf2^)%ficFvKeis0@jt*{!445&4q=ton#huZDr@hsZ(nL zpF3@`h}GxfSZ@D|!dAvz!{w*97H_;lXu~xgt_AKdaM~@h^%M5i@LrkxgxsDP83crc zv>OvqBdv?yq-)SJY#Dt6dW)HPcz9qYXwfEik4wvR^w;+8URcYtbF?txddxDkWSfx( zz1tdZm%siwhfo-CS*)-`FuIlU)FYg^5ji|sX3$!i!PDxSoqq$j#xV5lWtF<&oxbjH`ZHWC)!w+!?8F#LegZoJU z%5DtqyvxV}oju4LP?mX(w^fzRwZtMe-L}VmL~wn~_f(62n?j+#RFI~ygLlZlC6FvVA?UQi z@x`dx_{X<7G%RgPrVlD#jz@WiZmg^tv02{)uC4S!e@qWy5NB^xgg=50F$lgc`J^HC z*1b>A+l$s@}{WJz}6Msn%eJ>2U(IZKY(TzLv0*!!;-1&Bw;vFSto(fh#V+X4s1 zw0kr!;wv|Mw|v&#qadhY_BCVWlUHUTN41yuY_{P$I`;byX!e8FR6J+T3_NFivSFRjMqaYqLnpk0;gHJg zE=nTOgGcCVn`8w_{pxE?mg$zA2kN&hzM3kC_*|@9vSuay-Nf#mZi-=_D%fNNEN%K+ z@wvEsX7*T1VRMn(h8ZfM*gJxt9H{z~ftW&nsM{^hBXit(H#ge@oAy>BH@sU;bWH&$ z?;rj|HhxiA;#@pOP0#)yjG*q6Si)OZztELWm;Hy~%TWHk?C(UgD+UV>>X0QZ_;+0? zqsK`x&+ks?=7F-b(_H1Yw)SS=*Xbieno}U#^_e8CxdOWaMSs$82X4VjicFZR>UVfY z($NvVH$C7tGBk^}fY&C)TuBpKl7EN*=pSIhH}K1BPt_va-b>cI;Guxs!ZLP!bG2Ef ziZ8<`AI%2OV&TZ(7RjnP-*K2&_1=mc5rk)y6zMW-k}!*jtq%Dd>8G}sM;@>v&&MYd zQcwe#|L7gpAYj*??mTM}=z%c8M}+GSt6xX_MfpV8vH2IKR7A3EomEsS&$FG9YI34eV2Z?(sg2Qa-^d~Kl~&0 zQseE^Sy3|BP*{4`>w$^OnhVTFH8xFObYCq4Ih71fC@8+M=OjeALoG|<53VAr*T2nq ztl~AIt>#1OTVjn)%TInJ)nb44&7u^-1Q)m+M&Q~}*XrSaR3S%w0J8t6LjK9q|6PUr zU(|qc9g4ZCL1khJ1q_V&lz$_v-@X4?Nb%ho@s56QLGMpoBw|IUAqRoG_2s5*i`vCp zmwPoF1ruOg+jtCYkIHqw_+0xKo& zLp3+-Qt&QJ557T3jcI2!*Gr#Qk~~q?IrCQemXoS$jxS)^_?Yx7WjS!37rrk@Z73Oy zHM}Vis3;EwVbAcxW-7TOyP?_<$?Cag_}+p4#ynM2c;Z^0Q9tox)mWytDY{PXE}Ad9 z&%gn2QM=~q@fxjcqg+v^jd~i9AUISSOG7<0^tmA7`c0)J4FWq=kv*d?zZDtoC11|y z(VLA9Ps@&p){#j9bAEHVD&%SNwXuaF`+BXC7c)IQ*!xzL~~1uY^K67AbOO zG%I3pW{Jc|D!!&^#8cc4j0MgS@^gG*amBA73x(4~mTtDEl_O1?LLmfSx-VGXw^mZX zK1x5V;b|I$&x+0PWlH1tQ69kOw3PSk*U~R14)Hi zC|aeEk+_ljj98+G^DR=OkEPOf!c>d+|N`dg*GAw(Y6adN0%z;=C9ZCI2LSo-_ zeD%od6V@$=+vL|Qzj!EBba*##&|)Ifrzas0w=3YK<`2Sp%TDNIWx;>bsGo%Rz`y-? zYkUVvKI7K!V@~*7Wq;j@B0ciPw(u!%qu}f?TM1%n`(gQ|fAZd5!>M|-a@bI$SFOp* zP4}hM^J=YZ_|JE2%wW)Yvi?uM`!>HtO9Q#T+0zW^0UHmCO0VI+}9`jAH z7u1J1d|#m+aMWmaL7bqy-sbmqnx!SGCZ4h4^k_?SR;tCS9;=1vJYO&T-}#_7LM&EG z(Blbgh$X4M$L;3@TN#x-^^`bd`_=YV6RGFbp$6D-#Fk`$jk%2}RC18(RArT>mHqVO zR@dRgiD7IbQoFkR4a(c>CCg;ew!_e_0Bqs_Hba#@(M9ehQwiT4!F)r_SOYK4c~Z;o zx^i)!juWEiSv8?}I)=6P6*#CdF4?zJWpkHxw_MYU?*l1DDL-*n%EQ9$vFUMIu~huX zzgUx=^r|iO7pgbwj3?gB&}xG6oiqSSan4k_s10scU(J*`d9fYj^K2dYRpFVp`Ps*i z?eP7!llRl7LZ6tWzeR2WjsX*@V9=SRBP2(bZ%{zV=oxhz6WUG;M8u{gTC$aK8q=2{ zfj`h_wN2|uiK5|S4%*BsfiKZ$yYIevN;Mi}W>J%5=lNht@qBgra8RGxB25z;KI{|h z{^H!myj_EHLwu%{ot1{F?PC8~4%fhg(j$;kW;i|UvM|c;c_iI8RJZb4dYEJ8E3G9M z1f71nFj&t~WRs=Dtzcznkb9JhzQ*xJpozVExS2u!&*;w6NZaqeppv!-C#K|>GsRj4 z7iGj|*_0LgA92f>fQqmFrJl&URj(|)Uh6fP*VsQ%zg@Y_^^lC~ zrP$h=kVG8D^5K5!%J1*Vex|r`nHbW0C9PBH}f%=5KYN+<#x_Z33M66oi{+7$h{q( zs-d~0poMW8$z_DXcYfH}_Y3+dO2@JSro*p!!d1K@r?9Wt+1W`aR-C{Vh1~SjT2iY& zc9HjH(mam?JJsE5?ceGS#+0|+YV55w_mMn<`Yffn7JFRHI-`6MKGcUV35O0OqQyuKaMw1|9q~83J<&echwLWL^c*a= zFCQjj<4>$iJR-Ca{wQj+)xiR`&4c(O!8)S5?HR8#Q0iE57`U?@r=vZjD~$WMYax$r z{F;hq}*1pa5E95NCZKl$k@VkftAJxn8- z;4}pC17`Ar-{{WL;cYrQ?~XvoRdv(7nolpTMmo6+K&{%vL0AULk^HJ8ycr`b_M9Oc zBf+SOa1hfbc=!CTWVCr5lExqP%-XBSAf~B3qHo3Kr{DJ`c(;ON-4%&EaSp2rdq~e7 zBK*_G(P(l%y%KfhFqlXWXE-FQOML{I1ou&H zk79ZxA&Reu_9*%D$naL%>kUo9 zwRiu-8R(x&-g2mq{`!K_=Ia;#NVg(_)nmK8e}y7&5{e{8;pv6QVh%72hUkTVJXZ^~?&$P%8!`07A}P;;h<{Z6dgFf)s1DBXGCZ z5#M#cuKi|r*C#i>tbp1hg?fR@KTpf^l*QZi-n-=JYY^R52q*5PQMS{OC|+-kZ`duT z_^<*W0C}xUggxwKH%%A!T$}KwB`8Ls^?Xu^ zYIeVe1!o11GWxa`sW=yrXstl;UfECC zSboQcVfSZ$Uc=h&H<3$|t~TXc8*zFS>eH8wy#VYcB6#iuS&yq}l8$QNS)8Y2&sWbGXyVt;@)6Eo{qAhM)_eX3 zt{4P}75$G{oPYA)e`og{s9+|TsC)OHV z3Y(%jO;&_>_I(81K%#mV#En6Uq{t67_(VFkc&Lx216XM<`~A+MGk zFAye4ZY4fGTxM1b&qgCrcZDFa77&~#3us~6fx!om6;Rola~~m#1il_=l>ZEh6bWb6 zC~sWB!C+S!xi%+ksjoRa6PV}_pFEP?nw)sLXgkdIo@sl?dB9m(tr!QR!s=uUM9j6K z>SljD7MR;uHFCw`Yhn_f5j>@e5S0mkrsp>U;dUw2l^mVbx<@JM8yMeIGeAJSG*5X- zfI>CKoSD$A3=u|x>bHL&WolO6HUWJzTX{dHR5KikH#2t63B9Dx7dQUOmcso#EnFn? zTUeD}MAB35xHJ43tLTRF_u*${qg0PWQ#^Y-dU@tmwyW5a48tIsEKc_f_|(t7>pYBO!j0cVPYEWiCIB|A{t2{KvLhLl2{*8*E45Y>MqHyqoUkis^A} z$`HF}&Q?}ESKXBTlD+mZnu+Vg{Ikos-LO*Jz-PnN?9)FDMA#Q)f%FH(@DxaHa6#JN zbjf`wPIU^{$Z@>{NY#mU2eRXUbI_(YVH}*})|f%{)Zfg2AW@5AeO>2%D>H|fxx^J~#_h+MAUf6(vEQw8q0mE_pqCfTB9P5rJ_RR>sc zOC$ZX#9#niJrq7g(nE#GPNUo&8ER|jVJq1bXCf819QP5y#xwl(5sPb6K$LNV=g1aX zjCra232^P*a%5{|otZRfoB!s-TKO%~V3qEr!%L3hk=?vxpzyuRA@Pe8$US6lFn&~n z9WM+JexC2X?F?e?E3k6EoKID*HqO`WoD_%2eUtm9hgR1GlEma#S3(W_ za`30xCONUrYErVjUP}TKTl4n>`vP>4vWnoJ&oz}eaz>IyC1Mfp2r6u2d1>Em>COKF zMVH|dy^&o~Y|k8;D%a*&(cB;8;u|*1hkvM3>`KtdYAzeZ$I{Pn6%{~#sO;tz{rtn< ziB}*M=9e!oTE)xGc``9JjHLOYDo-3sGbXJLz)-QXvv&eNd(sM#M*Q}VD#x6 z%&)!or|!%(81JoST)7(y82x*=gD{Ui53S-CFv|1tXr{_gvJUca@h%y0al*`0V)NLE0WN$KtsV{2c zjR=+BWQ;^;cB{a63%yKzIR1W=zrLcVG+jOsQncUh@gt>?l_H7|bvi{)^vMXLac4)) zHh_n!$6qQvM@oQFs3Xtb*g!|gZu)+973HCL20pIqA=bd{de|mvznxkjZB13FubeTs z2%F!PnIsdY%}EwX0NGH|iq|#8f+0(-^?tJ#r;gXvD~nnL@5h*#=dpIqInsEO?CWWO zwRAU%^-RB@LdtC>{1W#!UVZd!B6gXTEKc*(PqBDJ_}q0H(3H@F5h<^;#oxAzRO}%$ z-H1OY>75?~gbG(_C+fzHmo*)VtI=&zzV`JdB}uT)usYtrKqz>jQs~EUtL)FTA&h zaka}Tx;Hs`mNYMoZZdVM`PCL%CzY++dg`W!aCo(Ke^jpS-*9W0d8T;!OnXfPP1C41 z%yeY*F{S0cu3z*&&~{6Ld?POx5r_X=9vFGq)ynu!XcMhHL)Z@da_eVS7_4I)M>mpj z(Sd5_?lI%ak2(e(<`(`BLC#{DyG00r5uKYs*o=z2c9s zhi|bC=hQ0yi(zPm9M2wU)W)JDumGRQ%-#SC+O>K`KJ;FP90#cxYem{ZVedINv>D+d z$ZPS#K0q?)1LeymwS_j{3spA37UD@Rye(Z4&u%)3t)!r_!u+dNzEEHkTnjPjB^Lkd zpSjn|fadfDf8~;k7L5zs_(oqd}8zjGGyz%KQP!XuERh)T&NR}JcMZTS)E z!yHuV|3+iaCwrSF(}q2%$)q|JwIjaQtxpRA=W^}3Y4!Y;~s9opOfo~5zatBi2N5Fm}_xb3-I;5mkfVujj>|or;CyqMd!R@JbG8ho7n`hY9Gg zBu|mqmlDa?he4m&m-1`}@#dYiG@t%fvsng-8T$Oxq%d$FmKD-cOY>jy@CX? z29RPj`fy|V%h(h?*&p0y^7)3}K6*CBH2_8conp_Nz4i#J-OmDGXPjIteYo!7r?%mz zr_kHC-tWZc+G3`Z`{#CZ`k!(N=ZB}}Eq7TcFJPaLfLvruk?=a;V~`+E!^zHL9F^`1bzeR48Ru%C@^Cf);T zeZu{W${cP`)-WLTxJi(5x<+dimGZTKKI_mB3V zz3K^XAj43P07Vavltpi~&;SKQi!rptHYEgcOjt^C<$WMunJDf9T%2F&M&dAb0bjZ2 zp01O+iTC({{I6eV{V?~i2GjYQVxnD53E7vD^)ZZ@5ipEn021p+uaMvuso{0ix5H)D z;Le1j7O^N;z4D`#udt+zmn`?eOt%`bGDOnHs_Wk2SRjt2MsdsjI2V{FKkk6R&r z+2{riKXwYg5OYFglozT>;9Z`4xDA=h!uiLp@SQ)C_AfBvIqu+gYXr=syJi2I*HuD8 z*r=r?9^IV~v$sXk0H#0Oz_XtT_jnsE8-Wk`cyHxfC+Qw*M1`m@MopJ{yzymr zcA*H=wI@(Pv|H8(c~22266ljjcsi3Mq*v!mFj6;K7h%jf^7m4R|HYEX+{tEVWt=^8 zq0=j5rtNd#OvU&<`*r(Cc9KsuH)(zghVXgzKRjh;&X>N_|3lYX2DKG_Yu_ytcM24D z*W&J_xVyVM6u08V-HMmu?oQC)!QF~mg1bKXpXYoz=Xu|mo!PlRBs2TYWY7BTb*<}? zQY4FEfqhfz9;ueCYO=)qqW&!KDUd(uzk)rH=7B0ty~?yNIR^bbr_K`}g1!`3Epjg8 z=Ur>dys2oT_bgJcKb@1Z2R*{R@PDtEDiVb~JQs07CGaPS5S)B5464UB`?9Jl7M!0F zWnoQw@E*QruJYFvc@>@6=jVo5CfR#F414GM|6(V3EA)bYP^bukjztsw|HH+9Xv!mg zQfc5TX4tt8^7vs%NJRyu6Q(ay20Ri)*dN6%YAuqrD`nO4r!$8fyrbYX&_t`C-vs_H zA^e_KtTQ{`0I4M%Ed3oSUVsaNNaxp0iD#6&c6(H_qd1~0^fH`EJ(WxqH^vxxRx@*fl?FPhz@0D4yEZP# z=f{7v>xulJlZY%OQ>QMkzPoYJA6pT3#9Umk)J?>TDnv&+3~_>T@^Th92tu(`x8Kp+ zpV<{yi~>zpnWDj|0kHp*x={7lNKB#9E(55v3kSL}<~DB5j;23s&5b>s-Jp>z|HWIG zXbwcTxV`UQ)`Cjl5cO6HYJWbm)PLzUPIA8`Y$4Q2*HZS$p2feC_~NQ{{zlca*>>7S zvxU!#FQjTYU}{A(WPM{GAtR~^Rr0rEF=S*v()wP0B=&>eA+cJDl63EGbHg2~^(wSm zmA!lJ_BwxmJAVb7fdhlotH}|W-I2gf(CO*8gT9QxWe!CqbDs2phtt93<%+osz`j(? zMg1Dx!bi?$U;PUv?sPNFA_ww?@rHN|x^S_;)xPzy3_CD=J#U)UTX9}<18h3*;&-Id z>NUcP!lS~*YnFi7Mv9s`RliKntK64g4QVMzm}z(5q>f6uWiNw-SO>jHouJ#O`VxHt z`45~Ej<-})RFz}n3g4H7n?W= z;m2lK3&pQwW3CFD=+m@JT4j2ef$Par0*M`Dj8^HpPq_y;DgGhqnACkIn1pp_81-iZ z?FeD7Qo3UV8mV5IvUPlL@Y`$ncP_mMdDAM7jh;cMXQzhyV2stTBWpHZ$bZ~4(Dwe4 zu<{<7uwGggO{rdqN79_x1j-C}3VBDiioRN?(ATh+wv;U!_s~QT z8d8J8vRss9Qp3g6Z?bsP%US!iKB-+P1BIVPgcCIoDfVMqsu<%6=E8oj_n5LqdgajP zChyK>Z%Mn}>LK^PE9()#IOuuFDuxnN*?z0Xq4S?|SQ6G4WaR*3bwchj)b0LGH|r%F zqiAr@*`4x1O~lj8L-hILG?9g4&OQxq_l=2s^z|h4RGv1s4V4s1b3mfEk{;19P-IxB zSkh6cyny4Q1}91^0?bv4NT+Pn#7>uN^-ga0u2JpFp3}b5k0BV29O0N3VsBNe;)7`Vg1zBs$>MYz(z zRgVg{qTfxlr!Mqg$~&DksI)8#J2vcE(JjwCD_57qgw@w8UdMzzVz+#_sg-}*ygw`_ z9sGT`(H?EzR{la8GjPtLay3s>RksaIhNF{|PrE)*!42fz)fRMkEnFsVYKp=CGN7Y6 z2TJYUjCtl5ITPmf2vxDrR<_8+g0x5`b3}!|xW+%N`rH=1u53;C+`0D|A>N_byq|_W ztsb86{t1W=pjhq^TeII~Jw3Eoqyi{mg>Wl_H5a(4?#)o_h%i;_=6l z)O^giBzZA+s#bMV^09KEKyue`4SrJvJE}!5is4*A_>Fbl&!m8P*swu#0dRc~l6HLR zoF{4kElKOgJ^D0-z@+6B)|0mqe%(AGg0wM}rp&1f+~g-rgWb>U_+Qe!sgt~2__7!q zXK!BWB7iI=N{4AyYfKqrcnC2oldyhOn_aSfnj%8~m^ZHnO9w_LC!XHizqo7+#Qx)V zAe^vea6jeyAu(#g#4-bfRAs-a#%9iP5~r*Y6( zz#A|SuE9_kbV7rhKT=`UqQzv2YH=;`Ujh5JGVF;NpvroQ^t4ysXeI^U=m~Wb3m*fP zd!)o8TIIho{1KxPb8gF@;JnlMo8&z)km z^M&=aDfUO+kIB(T6&|ILCk2 zJ7?4OCZ?6h0j2P&3{Sz8tCb-0bwncR_tL6QjZBrZ^OlJDV;-N9k%DUA}G~=u&EbvU5fm%N-x+Yg@-YzKi598vdN{gV#}K zix}C{0Wc5xBaTP{_;zk-AJF>P*V;@q`voIInaLwu>$tk>XVXw5TuNij5^kLR-ENic^Wmo z-)HklF8)WPHQBV_OCzdUZhm;Dudmq)Oxg6|mh$uWI%^QGNe1XagkpjnM7qtGi=tY} z`RCUIYa-1c{w|)`dIH+u^)E*pYvGkkaC;2?vjYt<6&tv?s6#*hC}L3xlHIfau^H~) z3>p|g&n5fTBXJm+)aVh2>yrm^ru=f`kGfKFjh`_9jqPj}08Fk}H+Jnt$Xm zXBT|P_q;Xw#_UpPbEC;h_%=fDj{9euE#^Bwknf!&=NjB->D^L$bVmx02$E|I3{sAYA3#WCNj0Rc;s}4+pqOEL#X{=_Jj@A zs&jk2EzEHB)2wD(TcT-sek?b)IO10(vN(dE_bD%LC|2Uk2f4|RQ zwjM) z_k!W}HkCwT?;AC~O~V>FXHyCQg4~*401xI)N^}Z9-)w5nzGn!ID~=8akw5M*YhyjA zb{A}EWuCh=vXTKEmNpUx!?s6z=jtwTJ{JQWz)4oDpzraPXLMr;Bk?V{R=bbcFnFv> z0pI!GOGk!L&UdFU0Uxe>KyTQW1c~N@e&RinIg~A`FZb;&WS?VtKM%ITjqzWXGc>_sEH+5heWqyYr=bxYkI3|e84}QfAhuS$&Sa;eOMtkdb`dyR5<6!B|dU}3V zNJ&)iG#q+AYpSmcY4zFiyUu)M#NX9I?{y|{)^G{^woy;_1ykf<2>hL`6Og2dLNWc% z0VE>x4UwmIBy234osE2@TP}UWnP-Kw1D42LSADZP)l8H1O9mjOnzrcof!FD(%t_iO z+3mRREsI#CM>6pO__RV|qD9s|A6iNR<4Uu*L2bQZvx0lyHcPH-W&?!^v)@-pLC{wJ z4_XSL5U?`NK%xmpSCdJLw1oD;B!TL;ll@QdOLVaB9&9g8Bd@AK@lhQeH8JNq2>)|? zRxh1l4559ZL1>@oKilJJY5v36&DhG;(!s*m%E8&|{~}9}zxsddp2p=@o8mIDgQxK_ zc$!oD>*`e)Io5F0p5$ZqBmWJv{B<~%zvJjec!RDYDkL+ z6JrSTOd4~K&zZTMFtUtVoH4Ej{ZkzaxV}7ifehZCvG*hdJ5N#g|C$@uxCOC3Q&rvQ z;zc*r4;GP__Swc0>gL@zuLNbbQDdjQ(cGV|+=VfozpywHO;8}Dys=KTV}r3fZX%-a zFb^g-`kfNnbIC>{_G&04)wF$%hPL1aBo2)&N<|2xKdlXXM%C8coY}8MnZWnh#G543 z&*~Y_=&R@u*b0|jc)F`=r)wb85a_AlIHvS79ggq93MF!@&V8Bd6$7vB7{ONi5lUyBf>hMJgg}dnMa%ezj!_tgYXyYuTnb zXL^ird{>s~IW^_p6RYn;e#Ie;W)`7j7IBN7IGMZ+=v7YBAwPE;6OX>|TeY=h?^p94 z6EuF4@+12_QpJ26FGoi2{ulC8k@jcWYb$*&mL^nywaA>MFZjjfTN8ldB#T14iA;x- z_z0sEo);lh4Kp^XpQz9&cP5kB4yt-HQ4E13SvQb{eW<#VJV}AQFNSTCIU;b>^X@b{ zNi+4J`8ISM;H`{<&NuhfrqX4E`X;<1CHA*moelP#pnDOXxn*l-tA)PCiTFN$#&)5^ z{MS&VH2CgDAx7>InkwX24hR9QMsSNP-?Gy@^*jdyCE$jWph`x09Q4Pgr&#Tz|JB4) zb5@^as?H}Z8*5+NBL7LBnLriaZ@2FSJ3)kS z<$O|47)d=h1rC}p8H*!$h0$@s@TXxdQAu?s_2P@@l?fY(PCkb8b!2K2_p%VX6DJil zFF`Qri&K}6ipDag!&rc4~aY8B%%{Y=adaJ;I@2U`BSFBuq6Ire`bHR z!gCk=GS4*{%n-g|J*wbYLK}+ksO$4s?o)LS<97}7d?DfN{BLhmEGXz-Vmbj3zlIZk z(bnX#5$kdCSC=T|x!iI_=2==a`$!JJcI+%=PFsrwI%?R6r@5fs&r-9-2pMb` zVK)xes`L$>ZmM%R2#&s3jRrG6Qe-6qxqetAH`W?uZs=RgJ zKYjI?tC%KI``Xr}U@=?Y=QzB*2~=@%%WD>F@O%X~@z0Dl4trP>J54m`z%%QT_!t)B zR+UJURjXXVTCW+5gmcjga-Y0SoT5q?>0FGOT+GC~P-`nESWgO#WvhQ7$T!Pa3BvI%Gv z+>9XgJFF*OCS+EDVd>$ixzG$Ld(AJ-3l*#!`g-f*MQ*`{AEYx@{Ih1Ue&ChhA;OTV zq?gU;wHtnrRDd1sYw~D|od3!rjG=W94L==RfjC9--qkxc19<_Vfz2GUoupokZSG;Fy7@4%62`3eF}l=c9mG zOhZllK4)`HoSdeYx_7=6 zQP@`p*1QC6$=&W>cWuwUSX&1>mkQXCC)Sc%%oQZ_`M8~IWp535ybjz!b2lC7cn2NU z!4SNqmlHvG$O7vUOvWTn(a@>ef}h^$OAp?147vuxh&FFY16!_BP_OpVQ6J=3Ls4_h zK@;VS*+X+po@4=qgi43yTcsn*?b**^TpJtvj;(DEtraKNbWN`2Cb+SeGhEUf%{}Jq zQztvG)>Q{1;_SogfqwJ@!x@8fsc;GF6_PWt0p{9H?z-uvy?+RfEn3Fvw>whYq+YLf zF8!M4_RhC>sP%r8W_L=pURj~R@+Nq4v-`Hl584cvaDG?c=x z<=Dgn4xMU^Xz;`GOIkAJh5z`VOi_ctpN8)^MFX7bCfq^Y!D(=3i`#8A5oUk7f;AHw z50r=vbfh>lwUhjhR~EGF^;>U$QG5{rN*wBnx^;{lCaP-!uMOK&_mvar%45%I`tj2c z5}~0o1pKm=C=Zl>%W@`PRpQd&o~S`mZ|P%md?XfqWp2TkQ`wTg4);WMP;DTCcsn>( zj#P1M!FT2qd50u9Z|F)v7;&QL5MMnlP{H-q++k8X|e7c6NxuLIWXbepE&hx5wH#rJzTIi}b!VV70&;88}@1F?VMtR5WYuohQy*A-tmvV}zaT40u`3xHTTY_ySlijuD;vZs-qa#&U*h zk9hc;XwM%QvRhq`Z+gb#^rCC%pYMDK^g7vj?uzs#8(I35a6BJaW#sE|N%#nBeaI5e z=H?oJHHW}JI~df-`4jG`px-%uIZaZ)Ph5$nB{&BZ_XV2`R#(XZzWpa(C|@|3ZTq0( z7a7coOc#QM`vk8IhTE>VlasZ?i~sld+$ljDf8G(p_4SRMNh4O^E>{nth^&$;bgUod zh2hJRCTmTlfx*Eyk1k>hflEVj_g~gOo3OrwGAqQ^?*F?49k0(bhaLVNUoT{4 zotw3b8qc!T>)?BOg8R{nW#p%E8XEhRAK}IvY@X&vi6BmY#kTSyGe>(SqTAvs6qY;u zivSl{vzI`yw;Ua7;hWaT?YL|zN=IaS=2%StVIx#x{JF_*c?q(Q+ISe|)&9L?^)tVp zlm*7H5xo9k;eLWitx0Bg_viUX8|C4Z&RqRn;$;p=x}_TrzyI`>PIOH^7;~n`Ovy~V z5C5gt4Tv9O_ncrW^aj`v z2jj2|KN&T}+CUVRh&pxHY1Pi!IoHI7nhKajTT=SYsk8ryRQ>1s~O|5v*{Fu-pr5D}u1{a&e)86WnQemvE;XveV83KeZmhBwC32# zZFjdm0Iff7T2+_UN2tloMZsQY;@Zo@5peS zqz{r~hkbNtK99wbORR9DPS=rDsD<{BdFgDaXm+F+ZYa&QDH-xoA|@<4>nU93?h|y~ z%}6PGXppQZ^U}F;0fq4od3(*&``=?8=xhaX?U3e)Vxk@*n(A|Z4ez_swYk#myAHRz z);7A<$_H4vs+{n~MAF#n0et$0_b&eZ`v)QslzV&bS<)gSb*Nfj-x6Iq{-K3^Wpg`P zd17$?*7#wqvNxXSq+MmJtvzVnVEnQlIq=7d^RI{0ALE@@c4Qf!c%{5ydaGLgQoI;8 z5T;dQnqjTunF~<#QGH3skt2RA@;j84v=+I`Tj^Nix{3EYWT?FT$?lx?puO(=@0Ir& zCS`M4U-6VxW+#za<-oC0*5!1Y{tg`<`?>rezo;U}fyR$VrUg|#T|YzW_8;r~BkYOJ zWb~L8S$$H3Q!;CGbhfGKH-GVLNg?WHYQ(yKJYq%ODk0x>xk3<`+Y|>M#_|HNS2x+F z0Mgk2b~?gR7;f*F6i+>4nMvJw`m;nC+Pbqsnpw|C;en7`0-A5@#Bn0)M9LOf+tC4E z&b58i6l6A6|BkB~E6z2PMa9BRCxvGl5~SPN1p{7s?Krc3e@;CwZj-%YRRUt$cblJf zU#8!9b9FEi#$nzcfUm&O@~qVioi8WL9~8@4ACc@LucPeb%iBH)Ui?nkv5T7a}-H^H` zP_7~~oT!*>dO4HV-K{KueHl9Aeg5R#^j=W?Veuw`W)7aQ_*>8)HkPFCGPcRn)T455 zpjZP~g25zeo+d|V(H>I;>t`s9NGx&uBZg&80-1S7;rJ)%)PU~J7`-cQDwrqHON3ln zlsr-dpD>!3ADiF%DdJOHNTGr3+viHE9llWkCv%oyKCS%vsulv>_4ZMBMc0iQ`OAskV}{F+ z-t3fbMO-V)ft35ki8}{*$2)N4rO5AG+xd8xUGtwqSgc2n6Zrac4Hh=f%W3p{~I;~^~NK>CSmnz(G-qiK|Mj$z?A7~Z4UEEBqa$3)x z7Qc>M_P+2MM(%g(Ts?+!P~k800}xq=hHqRufM+an<-qYLqQiN!q*u>;$c-vTKYI1CT`4ss+gv zgy<9>@O54+$M&NKCyku{)*1*U>a{RqbEX*XWAZI!cxf5@*4&)&bN-GLHYU}jCal@~ zw>-UN{v_4~f2T%c-T!?vdoWk$yz&zPyt598pHbL!1(r)bY=ZdKlTLl{Ul)5b$g8^> zQ}WVOdq*p|c|h<+;Ed6<{;aX4^zRcJ`CKLb6TgE#U2kie?ufsy%q>e{FJ84bUM)2gD4~HtM7sUW-fID-fBf#1 zxilZRv(esiIWT)`2||}h_YZ=yACqyukI|L=hgE85-`C?G{89o)ME}ujpwYd$L!0Rf zCS?#tTW0S24dOr#u?pVzkqE2?+hs&ye!4hQhm1q+k-%R3=FtW*JZu`RnL`1a<6vJ> zee)iWSc~hO%LMvL&8joyRKgKim{~J}!16omd=R`T>wK1g9IJ#d>A@PJ$Hx(#uk$h* z0kBz!Zu}{nVxGYUsB8ZDamK+MOBnpRcl&{hfH=m(>bv*fjL@glbYcz&$yT7nJwYB4N`N`r{^cy!bo z3&4j$Bh5KM3L(BNe4F2*!?y2Ae>zyftWt>^Q}1ASDA}XI%U<&y2$t|}&9@bdZ7i<~ z0ams%u~4~qTOS9i3*$}xGVLX4TKT)4?#pi-xo}Mwt1PS^)m(A0-o%;}qgGd;wmee4 z9mqV7fjWggI)|Efw3gIxwsEL^zN)_1Epu~LY_4rw`{Kz5v&>UVwdWk?En_03t^Wac ztc#oXnG%97-B#3ixhSO=XuL+4S=(M+BuMOn&dILd@;pUMm^$CQMFOH}W?vvR-EX3muUEUrfN z?U06`h>7UA?Wav%#2!qS)F85gqv5|Kta6+8JX=j2g@$CJER=VNWKs#ueL-b4R_FCU zBvtlOANX^C?))K-iFFmK6gODIjpqyKL&pdX?P=&4;CxClHYIYT*kT;Yrc!G2)OgiR zipLG~vS(Tc>>CmJOVcsNvVX5t$9JcjoH0wZVu|j(DSX3`OUs{PHhkBHJh67S4*GuSRrv!rUpe*|AZNsa91Te zZ9aso?yzAh+x7+^M!tCq(#u1)dx=KRc4`^QYPn<#cK{2%ul& zAKAQx&^)q?@uolQ4LdI!yJgjo6H@@qo_vu13ViU(VNcJ8zF!;0-#z~l%l>Bcp#=PV zOw)}gIPxQ>Kh&4yE`hv}tZg_^x2roUr}n~l-e)`emIS!+@H%!GNu0lb=aIw?dS@^< zFpp-hk@`v>YtPOJY>qXJwIAiOvB#f!{q_*#j`ej?y)D!Jy+UZHpxaB1U{{EfV6Cvm z%wUi&cT(SedXpOsp5@N@@CJGWRMh@lLtPc5A3ToadtI;-ya>$!K@X~&Tt|48nxuuc zP1EU}OM=k!6byo<``yZ%+|U1YP=G*@(9eo$2EZ{BGi&h*)$hXA6&VrgfZ}wsNIK9~CwmY68ymJzRXSX7}T_`F?Jdpx1@Nsn~F;t7v z$$ExYRt7KU)_c$!?(`mHZ}sCbDtUsptX^>DtruR^!WX=v7Y0FCCza420Awjda1s@p ztD?+q>pppKbE$frkOx%+YQ;Mek8c$-m+rd=<(Uf(v&N4$UHrzyGDewCN>ppD#JOU- zSB!bkmd20h_~(_JfP~x2_2JPT__KgF4zZ8#__ra-2X1I!fy7uWUrGM4^Qa`>_y6T4kR;ee zvjk-lJ1DmONBH)?+2p^VExymLUko$i+$Y*{>bnLtuCvw+brV-4BV~guEv;u`wiO31 zOMn+$W$>?V$a}#11gMrSjH}`UKdxIERYegEPJTh4*qvTfaWolGZpa^jMon``a`%yu zUkhfg?g9yY1w^@1uS;=cpcmI57qVD4JgWOQ4ju_5eRSd_Gu=Mk;HasZaeC&D$vh5u zuYM(%gZjTI=aXkZ5GlpT1FsFz9#Ie0glW5qZCDMr11&==K2=6o-ykzCVnqRJbL?0W zdIt%@H{A`kxt$i$Yk74CDhnQEa}x=)4Z%Qs^`uo>NXcw^pPu8!*ipw!jg%oTwLm6ND81F) zKjGR*fFPp4$m%L0hk&53q5y2K-zfVw}v!b)< zy(P$L>}9SGF+F`VrLF=Wn2hsrd$yAFcaKREP6PB?jAw=bZ7LpX_YNMR29GLyMh9V@ij3m9J{Q;_#*2{l33LYri|ddtU># z0)ykV!E{Pfqa?_No;2d(1<;oxvV65Jb#9oxXN56&$+2M0{FD@F#5APO zBSsxQmLvY{O38pLQ#C>LJT?dN&j_3mbK&a5D7oD-|N8_d`4LtYgu3z<;22wTU2aQz zXvz_o%l_D|`r*24+Sq_xH^9d>{hW|R^)GScI!AB%X>DJs)`c1EB{y+o=+!KGq;b+~xlD8|zT^`07DRqXdmzK4c2@Ya85Nt8^tCmNhC?O|S51@l za)LXw{#ivYi1zcFI{dPQ)x_LAp+ldp$n%i^9mX9)lK^A}5aCEUG))L=%aAz#F;bDN zKZmnDoIj}K{427MmQ1*a4RPxqvUdKfuEtl~s3DBOe3BV5?(fN$_nMDiDsDb!`XU(r z_2`Sdx*TJW`|FnHEzVufOX{(E+QM_M8q0 z!ZW7+fgDdP2m(Phw?@mjd9i>GDC=C{#@w~P8I`d?2L_|fTUMAB8<5@A1zum}iXx7Y9t<@8P{kd)PxgksnUuv`<&D_nBOxVww zUeJJE>wTF&1*w`i=b|q&G7Tx^UjRXqWj8V^$rzep@;3>?~ zpH|>)n(|CCII*XjOqw!ARhnJfHs%(a=G-ez1Ux&3HyQPsWlXc~NqOU*y)rnq(U2@H zlO1xY50(5&&GPYE<2XUt**=F^qOAJ5RJ-Tmy8MF=f~s1iJh`D&bJ zARl6j&d@cJccvq)!e2MD2#E?zH`p&#x=ufmo}G9Aw7m)hAyM zYZ3U--@KCEtH}XKpgf9qhD2+mQ-1c*cMNe)#MeX18fWq#vQPu2s;Nva5rhLR+1|@o zWLH^h>i(DPMJ)|W`T1c%J^QAM^n%jHIYE#6D@>dRu7CyOQE-^8uYK!mO=b;X!yPcS z)nO`pZhT-ox2kn$d;7ff#Kf2`EfE-`{dJK5O}eS)h>MjG>MPc=@Tf2BvAKv);-((w z^h9O&k>zNq14=mQ6$hf3ys>tW@-!KE;m;b~@&YjAH=7VX_%dQ(V`Jm9=onY@fc+ z6xXwrXu?yP>e#mNa(R&QD0L?@{sM?{Fu;&{7iEO|K0W<0JE`A-rO6MP&P`QhM&xK883%K^D2 z-SdGam^AX+kI>{GBmsJT=&qPSu6t&n1PVbuGAYoZ|Adf|lWF;{Yb0^etYS4#X(9?= z{&&{B7xOXLgMXT46Mfl(nW0;l=IAw;WWjf|?8KoDjXB%riLo!lM>&JC|Aq#oqa1N| zIc7!Cz!XX!TaoS@Ltf!$6Fx}OxNipTSc?Pn@jLbLpJPqj!yf!xN~73oM|j2{s4E89 z@;m{Q)&VuRH}-I^zAFaSkq!sqR3hRIMb=FzH>JNEZ@TW1NP{Z#@cc_Y$(27sPe1os{-;W0fv(H(|db2Bqx*5LO9yqzk*HO_`d%kWw+txEv>uV_d;4P& zB+EYaN0jM+ATZP$)p0u~k2_;ckl70S&ca%kdHb&^v$-R283BU5gVU4-(~1mk%=EJe zE!eVQzaPnf3y2(Ar=r-Yp1#Az&~3}vu>U=@Zj9776vK zV|c;4FC6?X(}m+5`#{{pQ|V(wVLespHtQVbiRJoX)oy2D+V|I5fn(iocSlTcR^2@d zOeM@39Pph%*Gk@Ot<@%Ya@tOH)6<%P5RAk;6B0^j+Jv#03q$R)P8?IO9KcWT4%O&z zyon+s1>H6<*nvMObUZz2Is-?zxgC zs7w$h_se|Ldr;#WBi*B*<_Od!x$@t+h-o~=hiI!_NZW!jPXsvJ(@8~az$%#pJ-cr? zENG0#@xqCY{wUM>F$HWCa=Xx|4|#+8pB;e{%{TT5Xtizh-`ckSr`Y_zy%J@NzOOid zb9FA^?5~tNz>pYNJOr#HCqIW7kLEim%&iT5U5TB$z@X>T0rOZU)emIsJ*qsd*lH!= z%_P z96Vv3pAbQoPAL;Bh*E^)j=fbO^6s@N(|-hALj0D@sq;>jE*ftxt_iTR)&7KY|GN3k zqffynjskb(LQ_G63doGj6yL&<%K8%)wBZ=Qy!l$PDG_DFc9FM<@;@(scQ)EI3+=-P zRN@aGpr;Q%Y#l7Upq&u+|0=ToJECxEt(A-|8_o-fi~^~F2!{HjL;4`5NZ5vdQsM{u zWJWuCospUqcIwkJT7R9%9!=9w9au|pwIga1{aG^>Eq$;FQ%fj|9MfWLw3d{9c@c?* zi3@_e?^(}t1KY=3rP81~#k0IEXx@nXl<@gxr`MdJrZEd5Oj*K*R!KQno3TF_SOwJO z`;r2gk*7BXdgVHN@}pB_r#IX|KguC;-7dR__?*4_Xq!`C=P~vZB&q4LI=kf)CCSo5HGfTz{2_CO;{5t<$Ni*B)8o#a&g*x& zkCM_sk^!t4>OM{Z53}yMfLBN|8)LqK|G#`^miF_Qb>!<)1gB|VdXf*1sL|LX=Y>a$ zoZcB?CKu?z!F{X~m|<7`iK65&GgPWAryb;T9(-8BHW{K;*Vu5T(-a+2Me#HcMX>7~ zUk=8fAsmMvZ9N1itJlmx5m#t@q5f|7WuV({3jDoREaGs2kWKaU#P=|%T_+a2PjaS7 z@msJkA4B+aY+ftz*Nkon&b=A7_MT zj+89L9vOyg(u4Z#S+KivP(MC|Rdww0iWw~TkWpMKK7K`ifhh-G0Z;xOX2OE|8%);m zAg=d72s_(I=!gJQ=vyX-xtst;XJ8&Vch=_=x)^3%f)Lfsm{6FwEaEKSSn=NmO`5MQ zGGTi%o8M8Y;Q|{FXWhrk`yvhizAM zxZxu*d_#{8`KyrCpU6vzcz}w`AgUS^CJ|rZhxmu=7I9Bk4!lquf8Z!6(P-uxJY(}$ z7!3c2P7K0EjIvlR-Pe4`Oj79~$u|{&ELyGvCf=jY-J990e#(cf^9KD44310<`yJ0`F?Q$gViCE(kW|Ywf(<|I4 z=K47Wv2pk3dWuS?ENeG6*`MFo8U=imoPuD5;hKP>WktLUS3jn^$mWEp-Y#I2_@$Xe zMfTjJc9MrbA2X!@@b$U{uv7l2=vD#ztaP9M$X}1ablYZc3W#k20E0Em2hEUW>48y>kCu;kP5(*_LNmm&PwVyT&Z^eD9%6kurX9YZui`j>Tohm2iRSdTu7Icmw(O zZK|KtA&M9&af$XJ{al&$5#h%w?C>YU5-GSnjfdg7_6p-!Qq)6uvYCZZv0kS zW>(xv6+=-Te6IXT7yrWGiMPTCcuj#Ud}lmO0Yr|IHin^#cwN8*b6CDRCE`YO77`mI zQ{PW>ozq=db7#ONKb4d3>ZpqMMXsELe$yA^Cu9<@1N3KN8%pyKi9(F7$l38D)o(s6n2o6RQk@(tuKy~_%@+B#(M?=!k`1LXjeeqTBP=P_zW zU$i?QAv6BEbdx^G4o1Z z9EbbxfoU9@>kTmED%@7IuJ(Z*cS-v!lYT?opvFh|yxob=Z%mU?;>zi0=e6eyOM0C$ zi#2{0t?0+xFH+wJoZ+8t!}E0aHL~6EXs1I3>%yAdq^W*o70+Vr2^CM>1?!U<0bl6v zxuke|)eF{FG+wz?&YUX(*>RbVDGdf-TdtqlL)5I{iUW%a`~mfuKP-I2RC-D^f?z}3 zYOOYi1z?G3Mu-;`FDhs$CJwh`TJ_W3s@^D%75_5R2IMGx@U#~E_9Rj>MG=4-2ME0} zn6;NR{$v*fyIwCEmp$;W`it=3sp$3_Mq-}1;?OhE?9_~R=Yw~C zVqR_3{kZDxF}=3W?3^NhnW#|9q7=g)3a$q*Z<2_S{#=1pqJF` zZQr(`?=3uysmHldb5B)Y_xhZ{`(%klxVzK!>{HtQ+0(%R?xd2n?DW?9`VLAh8eB+H z{4{P)t_PD=I`2~Fc;yLE?IIxc_8hfkx@|IP5p*Y5`I0AaXrS!j=vT+7Msihi9p|Zw zV`Z((IM1Q%5#HK8PUI=@S`}bPzNcSlQ9G~Ry-sB7Autf^`CBY>YOQ}$#a+ID3Cu=1 z;}h6$E(dr|XDrBE^l19g^7uOdk~9rW`k2Z%qxDEsgMC=n*-*!}gXD10tO*BGU0qFe zB9|h$VF0(?DS`Dab56swK3@6Vvi>j4o}uvq_pG-t+BAViRT1a(bfIGWuzbtK?WRec zqd8TT6U=qH)Tj8qKBV z3F&8A8_-e~a^to7KhYZ&!WKL4u85RF?V5gmLP5d%Fk9qCPVEuZ%-rqWM}FRX_pm() zP?aB*wI@&QcFq)k?z6-0ot(-Or&aydK-N&8s5RcR7Kr(7BS#mEz~6X5dMK1MmXZUZ z&R=-&iEE4-)BnvQnPcs)ba-tE$$YJ4*3QXt?o1rhBsCAb+qd^_iHPCaLNgwssS*}- zALsG}O0@LH;45n{5Ai8)%K=kxYD^up+wv~G!*^rp7`x&wQN#VX&Eu>num0RM7WJ3uPMX>?P=A{pcol}L5&G_J~rGmk&1jZ#q7r% zCaGe;$=rYsZkj${P|QM9IUhl#Ym57{9pk-=Qz1w1BSShF+I#FZR&peNJ`Qexx8tvJ z|DLfG$$e()7ExBWv;7><(xIi((oD=Z5V*f;iOGT1cE>;X^n`#Tk8{mBg|D$YZ6FKM zew?!+!n8MWr;BCXGKF`n{=8BA)|l4#HT+yXz%F6wYu?yBgL%)rP;9N{gVh-zW6v=M zAm5-nz(Uv~$yv+e!AdPS-<}9oJA~eupo^jt0~wLttf2wd;MdzgoT?9bmQ;lz+(2xfMIt{+_bFseDBPV%`L}1g7o@{_Z)qd3rO< z7nozUTQ!((YOlHs{k?odDC7@cLZlWzP#NGDF|R8yl=xJ`{Lf!_)Y+wmi(bGyeSC<} zk7{=`dP?(mzV$m(&t#&We09XU06;!2(&EcuBlY>>g%a95f!ecuK5)}n22x9A2Sz|E zf4$`dDW|;993G-Y*PP82*(sIfOg}s}Moifrcx0`e^UnHmmGH$iM(#D|h?cZ8#+Gv? z*89RQx;(=td*u8dvd%HMlBn(46FZsMn%J7ywr%IcoY=NA!Nkr)C$@27+n(6=mluDY z_p91nUA2F7RafoCeebo_WuC<8^pJSxd_m$FZc(Wq|GIyv>PLCRPH(kbVEmA&VBW&m z;iNy%9%e>fG_3Jkq>JOy6?H2Nc1Q_kqp?#@*-@Sg@$=dg=AgMS@${_u%EGkVV54m% zOJ6VT2<^%QuRHUG$E`ba(3Rsi*E6?_d96orH*VouC#&{IU{R6%vLWu{kgb2-HDhV) zn_=V$&?80AH+44mna%QzVKIg@!zx=P!>)5Smj~wF_`JX@ZzufIzqhRzEanL~s*G2p zVY)pexGd3Fui}I0Xx{Gse-abEu|`x(eARApUnMN^f7EdQyJZyB$NaTrtTEx;yWY<;O60#&+J!``D{je>C_Ata3KdVPXD&tTFgB zejCo>9@qujf`?@*cKE6W!DG13u-FcRYHvgoq1r@E3wi_SOsJqq315GJNCP`sYo zdHx(J7j1APk2tuFHmEnP*sht*A^NWsFBgjsK^B_105cc<6b|=tqQz;pVag*MTS_6<~ygr`<(_{ZSBSxg?1d z$BMrGJpuQ7eKo-u;OQtVUgl{9b6$6rUA6s>)w9g4Mf&2D%!Iv_g@CN81IGckdzH(g zW1Lv`8gcjM#ifsc_k%mI^I_En_|60P2l!{QZBuAu=@R_-pq_VS91 zVX$dnz?CDv&5PRfQ_7(XMbX>Me~`KPm2W?OJ> z>;-U!bia45Ld>egA+35PS@pmp~BR4fY9rpaSgv~}6FZD68y5uuDBVH05b8jVFyzrbX?(|G|EpLVFl^EX7kRiJ z$t*eG2%RGu`CB}XA8SY}D`Tt|jwckI+%|y#Rlw4qUci#+4w1(a5HbB@gyY~ZAEiwf zj0J96lRj(aUY-UQH9OTXgL4Q(9d4+5kULluBQ?Ck@Wr-~b4WM%DMl0fbBKQ3&V-+T zl~bR{$$)J(ZKe3_b_gR*_Hs-cR-8NJtCnR=je>PtkPI9CgbNtgRlS^LkL6Q%p6(Mj zdaBe-5TbzxJUGL60{!#`!HCIWV^o;+G6t$HBwV|vu$z^Dc&JnFMEzGL$C^cMja+pG zUA&N!n?l7Nw@tR!nojOjLnWIsI2DVEIa=9uR*u`S*j2nkQ>H8Tbn_JqH58AE&$Ti) zR{Lh(u}~b5+9-#GNEJ~qpQucTaPWua$f&&daN3{qINMwXN=Y0F?yx6%H&dTq1UfRX zSaro7`p6C_Nu@vM1&c{$g14Bh^K*-&YAV91ON{XOIEIFvhrS+m*@=3VZ!Fx~-Q0K< zVBmymBW(y9J#1uml;qMDvXCO2a2wLYk$_%y8wg{t+Xta2wCL#C+hz4OzH*`}SvKSn zrsX9Jirxm{O5tzl-DgxRVSC!D{dA4;i7Kv*nt!!B%0RTP2@GdQV8E zNyX5SJz!;{zQ7-M9~L0?XK)O*j{4w)VZsr!=Z6)Rwk2e_-S_vk zqQ_K9)WsdW0v$=`NJaHalbbTY1-lNVwGm&^YK~Rk zg3_y+2WFF0gEydO zSCiePu@@cj4Mi+*% zmC;FX-B=y{3KRYdEmKySra9j@cJ$01RLxmTNU3y-&kGx&96M7y4(Pu8!tUeLUuFXc zhEQ8D@vn-VhhS5kMugbu@BerjdW*rmWo<4$xr0PS$Xca;}v?Q%*@0p zwvgT|389+YO&%54p||@Tr7)0eh4p6dDRnd;NZb}axNVTVw%3Wp2LDiypZv$dV^6}Q z>r8%hVbx;h;!#`6%bha40|uvQ3PB5$paeju15(-f$dZbl*S(aJ{wnO`=SDM>)u}m&LS~h zm@x+#5#^L=h$ZBo@*(e#-oqzsL;F2m=Rrf-6sPUeko@yFuR7@XX&2wpTo0mx7n4FC z7UdtauzMNn+lo|w1kx9;g@li)hv;?r^f|^)+bo3lU1Zys^qu_WMeo)d`DF}`4>ng{ zcd~sc&o0wkxHcbl0``+3UbCeyoSYo@@8_q9=VFrdsbUdRL2gNu5;Cf$y=JRx(AbE9rQY52d>jH#N1CAT_D6IeAoaoHu4`Nn7x2)r$o;gr5%yb9 zO&@RK2XYDj%`Mp<@;$ED)8XVe7}$4I*}<>`{U4oGR5!R z3L3k&Wq}?8`FF4{fxx>>5B3q#B@HqRq>pMqgeHJ3x!XA&(~(+atV9f>v18w zgW-5zdkg*cF5J7S?5!~6ar?TE81enY{U2%m+SUcu;~+lJhwt!#R(~tZRXca7{p0mO z06#c{5oNu0oSG4r^K}eCcZ;5m?_Tk+HMgL9>+xLDwS*rC>=Y1iS~(!Te7>j+>C9d4 zKA$EwSZAp-!s5EV2vkVYQp zB;|Xj*{vRP&IEl-Ik&DHa+i~_#@L>{7bMO{b!5is+lm4QChnDoImHmdb8UkeAJqsYaz?TpTkNs)0rjNWlB4KvxRTHpDn zNOI@R7Wji=N&u9Y`?@VwK2Ds$%eaw~nJdU4NJ{#5JUiEL`(~V;iS{-%wfR)4@2GZf z_SWV6IHMJPwVnmmPAvtKtFzk2!B~96G{b)ACHajN-lG8e#n$~?n4a7Q}$PPMYkaOeZ--HvV$gq!PChb*K1K%5U;YRou?PK&$Cwp{#6dyrb_LY=)oA zngMWYU3wqOCy#B6AF-u!4o*ooYrFlh`-AAG>sP0h&Z?r}=sF(IJY%C#hjDG~Q{&^K9W&^+(FFD$)DbJiL=FY@m9P?F zsjIPT`{QmViPgLn#PF7Z}ZZgm`Y_kf?&>{hJqec-o{h`*A!v^qso*;1=+dvG7 zLn*Fnv0<{v+AkR2MdvRQbSK!A5en{{spJ^}!ppe?2#lQSL~Igw)fOc(CW#VX!!TR* z<>+f-BYGMEIp2?&N~Kk{1S{gu{13hhk>+eaY;Hw5&^!pn}9t zjekc;f2#lM+(xGt;FH_u5$>3c$#{^GZrTC0Yb)hX30m%!;{%^{+ifQ4lzyvKpdoV) zz5{dOD*{HiM2b?|V@r2oXpt){(AoP4dx2jwI%h0e~|P+-{+51t#vTLU-`X+ zSu`_Hn?Q*QBjk*(NRFKENl)o%D5XB&O5z{23~AL`a_%g@MwyKlz7)|c-1VDlA}=Me zD&YZ@IOr`v;xNG?X6WZld}JxxL5}Dqm`~&r-o9oeu>Ye~*a!cYA33Nol0f8C^~dv6 z521%^9DPT5{Mix;6?fOSyZr?Z%3Eu>gm)HPPzB5Q4-OHp1P_Q|z>T%-q1D;=+%l?^ zeON;}&K&%1XI}lE7P_LeNL`^1%4@4_RXqPmn-?D7@hPu?{+b8!v+t=4AI{D2H+Btv z@cHKx$#q!19Gu6W&amgF(&XI%-pNn!R|F<}ht?6-z)07DtF{S)x#cZ!pKmuC1BdA< z5+Jsl8qkztgrcY)tB}5CX=FEKHp0M`h+hWRU|!1m%hLP%uF;(}V&Xf?&!Yt{_a6pO z1k!Kr+ti&rlGg)60Wj#qFq4!5flhOGU$IO}{a&tq#8e-+H&l_FK|ZM07Td*M?31TI zyE^kV_c}Cz7r^+t69b`#%TV+`EIFip2v1Q4`pM1r*6ZY7k)ND+^Y_HoO0Io~MJLDo-FKMqckU+#;GDnzjPjrAT@C%`E zp)V5ii0BNv!9FJS0lrTcnH#L8*a3aBFfgTri+g!xVbamhiM~hJ6^nCul|8V~;~jC0 zEGQM@{=^delH<7EqeE;~O*c^}?CZ}J`xek=_`?N0S2RxPiFGAD@UrMKqAMg|fctkN5^D~QFCwLcArD$1lkvcjEEmg*vtizesBIn(quHm&tMz_2=(^0myH6VL z8R+Dk=OwrbzVK=Z&ax_sOq!_*zHrYSi{nebaFk|!M<*3`Nrmu}=W9fRFWjDc#!lYi zWiOBqJ!T;J<&u8lUn>lwkcQK}F#KWpFua}-a@|yfDYv@smG~_$ z;mQBexY8Ya(zY%j{5J7nX<4JI>RH*WBS?E#N7C-ybjrAwrMp@jca#G>AXbkXF zmYt_U${xw&OdRPm^a*~SH`0bw&`fQL$Lv$vJ#OE4`T>9UqGt=;rN=!&G~k(}5jahv zZus#T_TUi^7K_JWh4YDi&31a3!8>-E1=iZEH-hl4c0ud*@Yq(~5pqy`rG8u#6vW0y z^pHtRr~09LO|1T8sQfEIM=@9YrWva)c66?P`;3`pOP63_=TU?B3!LB;kUf$Bee7=q z)Wr9uQIdb$_juo8y{MtDfy5SxSjHfGd4|BC&e&5QfqtG{DkO$)`Z zt_|A%*Sz?z&W(ATyv=`$eV;MMa^$DK+ukomc~~+1Q!0wE9sjAlz7aC6AWQdtnO#Xq z-8pe9aO)ZulAxfSAWLfK8fnJx>jw-4RdAmM;pKZ7=5yPzR~kfXIVF|B;%PM73oLT~ zwl${UNB2JvFCOZ<-u)5O`_*v7&2m!YwA&(g46Tu{KIQ&Uz}X6ZCM{;|jd$j(Bd*t!*%di}We+v1z5@%PSdTrzm=ddR3D zZ6}dTA}{eLe~`7<$I0Y{`bfmZ|NAej#`2$0e<0Dy(6S}54T1~Bcy5$iT!nQda#Ite z4T>5~*CDt%%A>PgaybW4Svh~t*?Hs4B%*^5?HRsvts0PO{!;{MnL5-#)pW1Ol#noa zc5aU9)HUv*9yjeNf`m?QPx)hzb5qj}(0LwrLSh5EN@r7;*}aFKyLModnMwg&Iil6K z0;ElF=2_ZE7(#|zSJdVZShn;p*Iaq;1$F8AZ;uLk=95AGrUQ7s13Da~hWAG4I!MWN zR3-u<00wc8Y{j z9;Jlfug}f?lQ#0OO}Zib<03&PXoN?_;+TWU@z*O3s~gwm6PmcuxA!*?iqJtdKc-3Q z5K&+i(`;1myOObRGU6YnpzL@x-bwE22Q&sKU2aJ({b-3}>3uCyL;v!ut~vxi8lT;mT};T-V%FwZS3EZ>jV!78#mw%kAC(nlfaj=})H zxMSed@G#cRoC&x!wzQV*=1~JfG=KT8N_nDvCXjFO4S@W)_=$_~1}7HlR=CA!26s9$ zx2w!!71bcaYo@VSq{mu<8S~oLKMMW?i6sG3UXIluqpptG=~9Kx_&ZT-Lf5Sq(o=iY zSfk_T5S0f!!k)zKP%xkY7!_n!LcGa=&qb?4p}4`c~yEn*IuP3s|}Qc|e&r3M@+#IE`r zF&ys4Yr3;Aex_7ZQPZhtqdso_PRX0hlG1YgEQKD0byGKMcxk}R5CQv+p;GdVk@;>6 z+X2nx6^3iJXOa1D#$;Er;PbFS>rTi)dG-}%V}Ht5dUG&14Te~NZEuK6z8MCc#fM<7@9j0_$wBzR({xTrmGfdo#KH$# zZZzKI&0=t)_6N$7XsgPl(sQkZJVfpv#ZL5HggI^N$SZ(UisYui`gyms5#uvS6yfNJ zDyvn<^4>tri}r$3XAoO)dBv%cv&T=>E)Yvp@XeNlUM<}5w*%{Yn!k*k_ciy2m0q&z zV6IWfL!I=Jtq3sD6jXVqwsNlZ6cO1DQMVbfz6ElwrwXn|`Rd%LjtbeUPDv})eIv!| z`Ek$fsJ8zo>RDAU2~vy4|Fu+zE@tPguTyl_@*GvDRIr|86fUw6Oa#~FCstEHLZ{DS zP)YX&WG-;BL*eBdy2f{1Z!f2Refu)`6zVgVQsZtlTFDBkM%I3m3vXOILM|4RM9?{h zV=kJRI4%9@z-hIX_lqBd@wFJm9YbG2_5#A36)VtXm2wwyHF!!m_Oy;vLF2s=w=Hz*3G`18LW zz9O9?{P-vc$#Vm* ziZuJaA5y>e>m;}gkY+C143RApQaXdL-O_7r{#@M?#9P@Du(z1d zV{YiLb4AcJIKE*q&V+RSX@U3jQ`60z-?0p3ROHo8H?uV>(+Gfoug8btZdtzkA?p^5AW(NcIhk*C$prI!qrJ1aMqOpHuoSKxV-Da37r(J zh^%7rgL9d*NBV_mvS}4{^s$2j{WBx-lU}WBuknE0!zE4+zUcAT-yA^tMDiH3HG*9lf3MQkn4&Xj#2E(t&Gjm!h*|;*Up;ZHDi#F z^@{g@Mf%(a7jz0FX5&T|u7`!|0(MiYtCQrvG zLWdOi*Iv0{#o8Jf3zL%XY!p9*t~Qv|1O;|Y^80E@DG!$%kTl!QnC*58(#H;@M$AVM z`0VleeU(OG-cRxdtPOz{_JZ;So7%LKD@RfD7Iid7Y@M()g6Fhp;D<7BbC$Y8ZY7*4r~>>~IT9|CRef4a^h>_Kn1~u~ ze^b#9*uV!jy!sZW%cZhC+PjW?z~2bYjj3Q!2GWI!e^G-W{&uNKu6<7j1|8Naa7UZ( zMGRyY{gtt}*qZk+v+9N8YYqTd)WEw;$RIyDTvp!Gtne{aEM7MJ)Y86ulq^lx%CeP7 z%aD|r2om)+OphH;HqW34ZZEl`Pi2(NNo?sYdDZet6=Q*~Ul?_KThPKukPZ-vevKL$Oi%KM9jYYx=6(CghSE}BLu5UweN0ElyA2(pabbl@%TiJaYT zrpcTMF4dsf5-Z13jpJ<7R^u%rRyK;}n?_X=h<(cs)?Z?LwLM93Z1CO82)Y-cyNW|c zVmlh(M73OrWFiA%hCfoTX$(GR0g|5zh*3j-vk%W0(Hr)`} z>L3&C&7lLj zbSnc~=&FpaZ8gYxUDjZ(h0m}ILA2cUl}W98Al&A;HLw=CbP|48HE_Q27z3h4Yik~W0(awl5Y$6Nj6Pf8O z&x9y=_R@k)4Y==|JeNwWv<_q$9ZEAk43la*?`PHv(@Sc*+zMi(rs~H?ZYA*Omr()4 zil-VKr2lSa>en}47#U-gi{|k%bf#8u@GY2;RFk+&w^q>`ePVDCnpD}>e+k^pBraM~6 zNl0(_UZ}Oa=&O;vjO%f*Y3iig)b$kFCQcIrGy+Q3id&xy|2Z<_~=TiO2mX|?l{y;?MvLmzn= zqIG$>*+aAjp0W?l;QN9l&_B8YNUyL>7v1fQNF8U*r1)aka#^}xIpbF%=8+%i*8MU% z#wq2<^tarrlJPr1K{x1W0b`nu0G+lW1l~K`OR<)h4P<7Eqq$-9_&{XyP_(2zL{Z-m z5>`630ENZ!3S+l*`-`|MG&4;jsmKSTPjm#WZ;Z!wDJOvr%3jDQV&^A;PVi-F`MD_n zaj(30k4)+rWHw&5-V(t0^1{=P>H$&&%KuC0GGP>o7l9~(2WB-*QTaLAJWd#6A z+8816R=Y!P$SIotL;b00QZh6O9`jvTZ-016T$|O=a)U9tWQlp4!qf=PZ$ZCt26Q$H zc_Pjw&X37)xDOii=sK$3a@id}+?}5rMr>|7+z&lD_q{Kf(g~>3-!g+n?&qlZS$s|A z?%=xVa6W(awg3=Vn-1ewGB{5Lt&Y7*i_dd;&Wt9f`&a&+YX^U(a&Ox?%#KJe<^D-80doVbc zMsMVM0Nl^iYv#SI_nsB*Nd+gjtokMHwTTsCu2(_;Qr3cFQ%?Os)-!kJgf=4poeLkN z4G-`oiP)wDnc^s#`7rMt$9UsTvEKew=3@%mA{YbQ?bM?`>!Ea;lH~eB7UV+c?TerE4*N#-5Th~s#gtaI>ul=03U7;U?(X!FYm30LLgOx@T z1?INa$D@t(^&P|Awg-RU zY_qK{+24EaHIPANE0)WfapxkBd)} zc$ZC-338zgK|YCjZ$C=j1x!l$MzMPyk!Bq%Rxc4Oyt+_k^~&zNq`f~-N;)|oXI(n7 z_jrHN9iUU3^S510iAm2b+;%54Jf3=e5J`J4RUhUiH9S^%`8At$1WUd;$l{$oJCL7m zbW~q5TfH;Q+y+us0q=ZDq`m!tX`jW?-f6%?fAf-L%(A`1%$*@1w z|0ogt4~VWpP^cw6h7$PZR`v;3{>d?{h`QQ7;9T6PGMpRy;LvDufDiQg@E-OKy=f;k zq`s3JQh%YU9?cE$2`e(M;iJ3NRKxfUY_U?-#?AD9ov_0aA~4 z{oA{{!Hlk_13p{Mi}+g|pV(mI$4=R6JDrD{rU7ZHsD)$g>Erdly^k8lN{Sg6X?0uM zioly!&P<;4*7|mTeD2!o(fy^?+03@F>QAfEYE@dBZ+GgF^VMpp%S;8Nyl86f2PfJH z5~+iS0^C*S4O!O=yNb#RG7D^EvD`I(sX0k|x=%?24K=&o!bZE!$&bC$Ek4P%S0L-O zHe>>#H5d)QE`&9CC(o2+=vZ-b+$~7l+Opow(a!q%?@d?M*sT7LiFS~bNU5%EUC`*M zaij8zL8rmO(>)tWRDSQL$;W(i9EM*Z5hG_EH@yUR5QHE@B11Z*Z2KF4Y_x`(J4+^>{{8fvvo_LaV>$Oyj#w?2ZVh zs0zHW80SQh7=t`jJA4jIT*YG;$1^T~T0A8<7JHRR zm@5U$40f&~@HinEH4=-#tyPhkEI&c0T9@zcn%HZ+_coSWdcH#n8|(=^#k}J5gdfRyg@z z|4d}Js;t#UrKMF;TZ|beMAMS&8M;qw1e7c^62%4u*^D*-l%(^CD}h7@OmuYmg6E@w zQ8JTF{#(_&ca3;2O zrP5Mgn}v>1zqL+}U}9#~$ifB?Aa>~b(`Sz+J(?8625CwaIoYBYc=%(h=sJ6t)3T$7 z%V2()F=rR3V$z^2ypG-Vh~{9TBUg3`8F8JpMSgTi0>ZJSVgV6*M_!+;Nya$!J`T7< z7LEYwi$*TV<0wD%gVrgAUz7Qu_IOQf=e~~|q4P;n072*pl2&KpIeFI@`@c>UNj*L; zuY7J`P9@_giBl5@e1+pE{mZ4}{75B}9l(-M!Y@S!R3i#U4gn!;IjARI24$z7`PFX! zJ@&|lw65&(tD8gH1W2FxB63^IV#w+Q;;@n_iKC|ELCd74rfCW{83}QeAg4@upRf}l z1H`|%IJr!NPreZrr5G80>~w6ndj|PKM@vTffNYX(m;+PxYF6B@DBeH_a0j#&h3z58 zS4FrE?P)mp8wPL(bdUC)@Pr{NA4u(27L{1z`}kr1!md6)uQZ&?j!RV2TwhV2Z?mx1 zq`Q{oV~jR8bCgwltPf7ayJQ11`-=W6%erCTFTt{n2VMh)MnQhNWFVFdrsXWAW%{Jp zRwrzh&2X7Wr*#dqJ&#TK4(Y|aU!Ji>-4Zq3k^8*DAFwQOpUDGUAKg@->#c${7{Amf z@$K^gF6<1@B6 zAMaC&V+Pv!=SuA#B5k!|7domzZ~jT`i+@Qj;}i=&9AmF9Xpm-~vhsrIkrtmiNIoyGI4w*W^vFR= zf`TXSR*PJJeEI<=d}6rQ%v?pUs17imtk<}^meyn6qn+YPQDKAuKdzlS_Rw;{Jp)O) z2TjIzbR*+~CkY2GzcEn?SS^m0d81x=?II;?7OvG=dF~@mdq%Vn&4#-Y(wQnExEa*q z93t2GU1Xuz`jK8~Vww8CN+;-6N(c^ocYt{HjYt#Wk=jNL`tT6;kIG|{-R2(96#@uc zqt-vjZ4ql|A^Fo*O5hWd3P=*j-<~@q5raR^4d~kG5dVF}>;jVd!Od<2y{APy22L99 zB1^@?^AKv7I`}MEpIH$joWDmsc}p?DnsZi}|3b$FzhX#orDD8`mh7{ZJO{d{wKClb6%+0SJbUb8mipaHJ5J6e)-ofn z-;c3`yEGFn(f6q!pLv7cT!j4$5?qAsqgZ`7ByoL@qFDd!Y*UL0swXI-?XL_xC;USL ztTlG<#7%>62XMaDN1SsHUOcOhF2!=?_kDF`P|g*;aH+!d3hi(++>IvWvrW+3Wzd_i zKJwmEZjeK+81CMEXx?XMk8xlXai-2unfp9tS<&LyR>Nlg0{MPiqvIJ)T;nWXAxyL6ljbIuc&Bk>o?5^kV0KoydgCMY|f zeCU~1dOCyrNRE_y@-!o8Qcm~~ISCl}E3ii#oN2}v^h$V*YVv;36t~|UcxMn;0p%Cq z0d@ov!NOQYwh4c{2hIuCk%4pR)J?UCXtyHq23^Dpxk+&qJ>YShXGLG^TH-B3H# zEga)Mj&kQ0eCOgDBXkI3(&26JgM$~ho}1kd=FCtshGj~qlm!cF01#1V<@+AAKbEzs z>2L{Mit0^rT00c1P^d2-q>!9<%Q}K0`~uGcstAu@(cnAyIhj`<^m!P;()5J_eU4en5xs@Y{iHv(HHJog2ID)cE8AFqC(&My6XW5 zQu!YO+p{-bxuvM_fxn9K*urowRSk9tr0OHw??RdearRh~zh;JCe67JoqDl41mo-gd z?%SmkU|c^<8-SyE^-Q4Uw9QBs5UMuukSYs^=S2X7!{nl9Dt~`$^^_%a7T6%@6fQbO zwns?%8)s|dBMWEC6y?(D%=K%*wkuHOKv@|p7V6jk^1a$|+clBGM%!9Y9XowF7{Jk7 zg1uja_v1j_^VTG00PNWCzAo<;?Al67 z5N$N)!m4s&YyfSTEGVwud>nnh+Wm=q=ygVFw{_sfdrvOkwnXW{P1vvf>(BH?v_%0b zvm49S9%J-t2#^lve&5g32W73Fu&y@B1IkXtg}bKAD^E=aSb7}L_w;YFl$N;jt8*cJ zlh?L4?M)R76#2f*lJ8dWH}E;r6a~Bb@)d4?{Mds$l0{!%;r4w!4BBI#|GD6M>yWZ^ z)n|+Iq0Dl(5sYr;uYqNUdTDP{*cWceY=Bk`t6skyZw#m>Y%1!(SutGiC2+vr3`IVQ zyy%+5fF<1Hw$XH zM!<!K{drHzbbSi;#v#(T2RjlX zFW7&pYOHWK#wn5l)Ux}+)ekV+I0S1GsAlSoi^thZ{_7;Mr9cH@vms>xDGYm^HKgo6 zO}b&Sd`Q#>K7mP9L_#D8MDpc4Fun*n@W?{2Bmel++hZ6`{<#^(C$jgYXJg-t%qqXO z-vC_gd}mn|knlmBZH_1%V0*~v!G`RTis^bIALflz@7_gf&A{>f_5CsEXDSzk!B~Y2 z?yncL!pmStUQYNJf725Gk5Vqd&7)l1y;zQqADfm-CcRVULAmB4_;h=*T~%mjf>O>Y z{4z1!TXxvL9un5qqHEv5yM@mnKZJk?v;H9bTU_njsecB4e8`J9b|Vu&XjH%;>pyRX`Ww1w8dQklQ^pNIF7KsjVkS%nd#XkZ9(UdG3pqPm)|*nEwxbv| z^~hbO`~JxqSjSzsL?x8S#=H&*W^d48OG zPiiOeUFYl8`LW%!VA)|{xN;AKG|Hy}OH?i02cD;tKd1}iBUQOz-uJfw-&5M)hyLaN zg(+btGpPPo;DqM?#=QMs@xy<6b`<2JzdSpgkD4J_Rh7hLr}E+*9^bvuP%}6R8RU~^ ze@=5u+e*l(Do$13@ztpd4!|~se=;_fu$~RQ1V%}w`F*?qP1)5w#QT=!Bc?;d-~6ny1TP-;BwW>1{3sR;@#HkPc*bK!RYk)>l?g*55#(PU zeo$q3s{gPCDRFx_Lhk|P@SoBTHl7pW<}XN+9P|IEUM#Gf%`I#_4J{pP{}+^W{>R%x zRlTX_aimZ_nYjrgA8V#T*b+iyO^62e=W+Bb7Hk#lZedYF?Sga3=K4gfJuny6v zY$=9P^rEs5jHDxEM_B|LPhrq5gxdGt6@1x$SXRDFJ8oQ=IvpK z9dF?KI1Pex^bMpi$xgh`1$mU%@CoWwF{N1wIRz@jjNvGq3aUxS;dcxzXhl>$2@ho9 zP1f=7A=r3iA|$%{Q=apILrTJ2N3WM^I7y(_SktFT7SevC?z*nsV0-nLUbp?mfIBj# zyjJ%`q1^0VGGo&5&|gJazX#s&9@nH3eQDPKo;xsGwJ32PIGJYpT9L#J zOXEhH0ZuJ$3&_r+q?n$|F1NxN=0oY^RmKQ!@_IsJwq)^jnb_P0dKSF#=OWdkDCV_= zU4@Y{Hd_okR8AE%ry6rDxhstz}-Y+nin@)?%HA4Gm-Dv17gptlebs}Zr!>W>uqt%FP52hzdYlCD? z3ZK3=egE1A@XQ-)q9I`%_~G49fTBu`!JsvOw^|1oq`Hh~9@bIBQGe)OOgVsXJDv$8@(|@4yJ!4iPPx@(0zTY?`O4VGH<5m|PJSp>;W{Li zW+PRyU_Al3F|ek2=FR^6P$6_U>T@_vnpK}Mbg7%&xP7s)=g;EsXl>;DyOXMn6p>h2 zgdQ|ZgKGUMk|J1S-L91-Ho!!K>-ZglI~BBwJ)+!qv{H2B+uwI~IxTRCqS%f8yV6Xt+F+5FKD=ZuO~s2%kAag`bEF?E*lmoMC;bb8R0B z{kUT%X(KQ@=o@$;aH`mEh`y@?3m_NZLx2VDT3!}f^u{Z^U`0Ht*89?6G<1IdHbppz zmCTGEEl3_dgK>E|A33oOZy!?Jgvh09iF#Q;qv6|7(vwL1ed7*_Q#|u*#e@+2STg{9 zGdM1WXNZenA4A0wD-{Hf!kLKZMEU#YmsuX=$_{OnlychwOI}%*pdLK#Y=xuDP8W7f zn!d|zm62gL^oGg@}E zFgd!-0D0E(+wr0*mXse8nfErvs|`p&EuMtQ5O$AYwlwuizzOIL_LMU4I9&+jiL>9j zCI-4xLIU&!7_NcpUsO+>X{r(H3fKwKK#lIDD1KIn*Uq(M*k8CjV=v4~c&8q3O)@*7 zlRbJ#JP=hE^GFSo>y52L3iS?U6{CRqD4f`}3Ml|4*pl;FCIKPND4S?bUQ@R!I*V0nzf3E#s0jpTo|Kd9NP_ z*h78fdaF&h)D$Q@&VBCj1IJ5pu9yQa391&K%<~fE?hNW>6Y51g{2_ao@&0xT0Tj5a zJ{V-(-rTO?xCY{A~M7)>P?*dn={g|@Wv`u8L1t2x{>Fy$<{Q9C&{Dv z_W-<@54o5LeN9UaqWmXM#ObHrv?pu2Bdet#F1f<8wDO}>jhL)X4(n-T3#*w-%{I$} zGBYpuFPcH9G64ce*D+P*Zdqq(8I3?~ae`a$T|aA?CDE>a;;TjMbaryyc&+*f-u#{{ zQLH_2O9?&<$L&gfrV;LIBNJ6K8@^@P`jJQ|fdZ&CGKh zu4_A8h!w{?V#Hm@qXf5DZLsSFqb3r0AipqkSdi8zwB&9{wb4$@Lum&E)Ibvj$wg{Z zC(eLL4dxkaUp`}|NQMGw=uxj{eg!k?;TwV_qTe7O$V^RWGk0K;B4&IW*-cK~ z?yiw81AFlMvXG2OAWKvcRMB_gn3yFg(za>q&2MR#*MUZB>qM!jB1~?+A|65Hp+;4a zqQ(4^Y9x!$!f?DTcuUZgfs6}8_mL=N0|k_3^`37$;r;a-qA`dQ8GF~q!}Yjb4^X0P z)`BW2e5IHuR$o!CX{e|{@Q-G z{D}~R3>n|opGYsKLHH!1XIDanUuo9;10Hk`gSE1>hqof7@g%amYq z_yte?NqmgV$#qY<)5roSbgEW-3mhkxQ!!!zXQ=mesft2O$}P+9IIKo(C2c6XUnYF(==D(YrA9$6n`i-0 z<|^1?$sT+(==)vkX%H-~F>qWBV3MCgp9I+~rgESlr>@^Tx}UXp$&CxxnNliyn)U(< zC%Fi{9y@%EtJkw$HrDDvEee0;R|C}B;?Yz|RuSpG5$yN|AsWVXYx}hAP};oK>2Fi$ z+CgRxUWpFnb~-VP2*R$3jc$V<-{iBwg*tMf?v(L_Z~gr|ju!^T$4AQ&@p+sLCqCoi zve`AY-`xCxM&!{V1GXf zhF&c%&<&qX1I!9GPcs?jZ*0x=q}{y{A03!-a&*k2$4suxi_*iG)Y4t;f{ zc0Wybzh0=m?K8H_vA(3ni~iLk>CbxITQ9kuJco}^x>O8)T;|)!su}{1hvoHqUP;YXX)R1{HW+elDA(r?eb=>}M;~ zwV%^Qw%V@e4c_tD|BtJ;42mo2(zcNhAUFhfclY1~3GVLhE{(efcP9i3!QCAiZ`|F3 zO9PGb^2|(qU(HnQu0BBg5tENnr!emUW)e|6p7n9hHl2p}A33C*3@_a`fWekatZe}$ZN(Leo!4orfo z@=v>`4LOUv8PI;r5hbZg4}p8M<&bpq4wMx&{M6@{y7DZ#IdbP@f%ZHsnoqkjcD{mo zqYHW?>-DPcDS*(uYoI*}*!Rfd)C#%?2<68-tzx)sII4d0wf&oRetOevn2M^snwaf- z(Amg%q>+A?ov~Bx&ZC~%PT)JK&{Tg`I!j=s;c&@Q#ImA30iGA|N$Hl*Gbnbu8&X$*yqnl{i*mz40tUocdlvQ^_@=I_O^U^hR&8Nng z8IC7qzJ0TC&kzv8F3BGQuKKzf#P`p0NpJ4!0*YvBHg!H4hqmgL%Nbkr#?q@PRXrmp zS=V&d_U(TtcoYs3vE`p8)z1=ptL5g8v9nl(mVG)*{;5`4%A>v@e)N+u_Wd&d`h6CFAM`E;Ee_LXv@D8f~)QnPB6C4TRf)AWSQX*y)TRME+ z_Q!LQa@N%Kz1(cl1`b@qrKx(V`^-GrD_jv{V=6Kt;v=Sc+7HNHUG*&w?lDha^i|oX z&MB%-+S`2s{>>dEO<6qub|watmX60-)%M;iQV!1$LH79K_w4w2Tm&=ssNy5ta{I68 z;yL-MB@~m|2)r{V94#u|jT+c~SfZ7^bK^LsJ_dL_)d@t5c%c;|;hW*|uz@1o3HM}y zpBHQLlX*lWcn7BgNm*c{jb_Ab~5Js@8OD@QjzpLj{CBx(jX$1Brz1^yPQU~&WL z=t_d+6Mrv%tV@-=nLesMFpYU{Y;*FK1o)YUHYby0X+<0;M{Y3T#hPSco*R(B2E1$e z04j)dVtqLaSOs0<#1~jXjsxM4opDyMr=4Pr4$qKFZV(u50{c^ z(pCmH8Uo|O$vMytZ%0C84yhF1?U+X1AA^vkdB*KE#_NV!&{m`!6{^7hGuAc#il9Y zk`$i#dSHy@>I^+l{veH0zHbzfpktRn9d=e)PSCZ4)eDu*dgCkp0A@7%4YU%@&SA(- z$P`aF!JwJOKF=WlTT7Er>v^i}o8txa+Uh0sSM)MT%%bg$ zuPYbnt?H!9axEr59*bu^pjXQ+2e9K*9O!kRJ+b_09f)^%MA+u(i1G~oiVE}^4xrY@ zo%c<=#;`u32Og1i;W>&FZ^UY-gjp9Yzflu5( zFWvwVS$=`;?XiHqwAb`wq1XY)K50=Z=Rp~rg}m7<2U>3`d;AVmmuo(dGk|oLE%w^_ z2WLM|;_BVlO7;NW(kn6$Ao?xxn(0Ty?O11(SuCVls*8h^#QftzV?@T)Xp!N4=*|d8 zcsmyG)@aCYXZW$*XEX3<@r^fKUxbIHZ^!T<^kpKYTA&WB+_zLi{=IIi7p!6tj(DyS z*9X5BotoSPpU~YShWJRr)Bv3<5|6CdYrxZFZ@=?OY3i}c(u7yKSOA3hO>knx{=nYz zcfm~kBnyZ{2af^ls2E5s-0SyS#2gmhzR-9H@U+D1LLhTjrLs9#ZJmI=Em3-wA2Npy%-L?@fMfT z1cVf6Mzj~FMcq~&m zpIJ3}BAgvC>cO}XE=~Ck7&Uru()rVYe*%W21sdJmTAlB(T7})bA2#LoMC%hj0CK-y zH9}rXdYno7NgLh17t&D_lvzhwnSCYCc*S=23ltY$-}3vE4!o^(6Me29Wj%lUKeoay zFsMeOA4zW2k0kehIE()$&HLZejY>!%qUb}H0Usz6{MV!?M{M0lMenGnD2-u#0>F?* zbWFGJTsE0|4kW;7b7K5Iw#qO@Y$T%uu70&uQu6@ef0^YHt7x}vwF-r&tg(H6?M$%O z!cE4NOMk_#>DRn2%p_z4uo1oDV96No9_GwOlY3Bzafl-VOMVPui!Meh05T3k)$z-& z&lr$bPa(>i-Cp!hA~aG(4#sE{zRG{fnfa6v;k?>Xxbr7`6~#bI&Qj%^AO8A+l{fVc zyf=u|Cf|@cTOj`jUb35l7`~7AVW|9(I)7at z?%lfBq=x6;7H5n2xvU^cVQYF)a{tP-j4Np%43N5bgB$g}Vpe}mJ{Zh$${HKKia|gsOzp=q- z5q?gPXYk~{eqWh%Tu3-m^+nPKw}P$aYB^_cv#{|IU*yR6*;TiHyufEoC-t^2D+4bF zuFgHCE69JTsV!h>Ztl{Y$z_SvsH6TXL%Pt5hK|36#)1K@Qm!`7Vmr|M(0ih)w=Y-n z+y221S^(pBsC=%uxwMkW!$_jkLuy0AipQf;9UC`q2T;gwYuVs@b5&LzkwXyC3{ur1 z@XAIBRp`rxIe+@7j$w78La-_C*3QI2 z_ldA~u?Du@Mgd=NC-U$Y_SP?XCunPIAgT3Z8SaN&#;BIJdaX-#nF%TRc^$P%`~Z-( zdZt_b++pM86u+H-jYzT{CQr++$=rkmUeD{~$irMsLio0oakY4Lto!Onr^^G3$hLSV zXFg(BtY!dsg0d|nYh#_?Us}x)MR!S+LCK~uz;=bvf!c z$Me|2+T1wFI}2@orwE1A&LkRbusNtqZK%~PSTWx^+=GAr)$SSCEOtCP>a-`rvkoU?lyA_z*F19!{&MWzQjuZHn>D9H4mgB)WK$793At;Gp{hRXA z;A(bilY%XXEA!ok4KLxHHR8pJ=8hxaq`lrq5o_V*2bfAe<`+&T zFw+}mcRP$Kgjzi6jk~|Pb=lYM9=BsxI)vAIhBF-Qm!Ys}z75&*2%WRaH_G<W7kY~R1bx9)LOD7;gh$&A%DiHCAGNBPIFHP ziu}TDLvtT@VDu*nf_|v+cQs3|&i&(dOL~*o71a7UYJ25(vXO!TvH`hJ1r6+GjD61O zP1MA3(EwSD$7Q-Vgb{^bfw5;>8WQVL$u&V|&K7F`v9s^JZt8^vHSwTfAt^)TdgwSI zne#}oe42wb5nur22#d@+W1^61_W+qzaxU799htxg{<#j9HD00iRDZm6D%^$9adoK% zKBis0hfA4uOSel?3!v8)HLk{uaBs=C{Ym`In5k9q4=!AK%ch#v8UaEja_h#zj5FCZ zehgrcLZyh;GjHS~Jq8wIKLh%O@deMoUkL4~{|yI15x5tVZ(T5h z;1CJCR3w7f(fl6X(k4ASMgwkKu&we+m=>bW6cGgOZHN_(pT|r4=mysQW&V0LxP#~} z^|ZJ1R|Nzx_q#-lyacn4*cdoj83E|fPuNWfrB=Lm$p=VvNKfu1DGUq@irl%g@I;6J zbhZ+-{yWWt|2EI3e;Ca#i5NmweNbXK^YuKem>$r*!j~j{`md0y(bU2wn1D`bQbikt znz4>sN2gA9I@nPU1I{zQxbJ{*e~r0z&0J!+F8HeM8-xj6(L;`kFVY@&& zwc>^{b+&xUG5Nj+e1SMnYBKQ;AMExuW?@Uaa+USGDsj^{Px>pS;_* zVUs80>Q%`e*s%73%$&3FhPRon(Nk@9X@BLGn|niF^BA*U8}15@^~9YlL5ch_KROs5 z6bRmv^}GmBd_lev-u-$tK3LQL3UB(N?4Ra^Zof&D4}CXGy``q=SXR9L#&Ylu%?>)Z z*p&{_;I?z)ytdb9G+CLTC7}To@w>|LEDTZ(dUFBdlAVh7*BBm~Wx5@bowRRID)3!j9Ph2;eChm#)Q=mO>Zk`VFnA%osXP|E~K5} zvCn%6gRQjjSbO<=lDSaAMV63N{$rt-%6}p6!Se0kk?;^!ia8w@#I_$-xLZnxc$n(b6clcaY{ZTEqp3@=qj<8N+(W!MI8+& zGk>>Z_I(p%T@hhjNw8i4?^~F%rVd9XeD@}Jq%jGUI(dHoDU!H2Aj#aJS%W?m6w0Kq zjxs^bQg7iMF|@7sVeeIM0T~f24+v)NkjBAa4Ru9y`Kd6nXb4`df3_?6T4rE6kbbTe zazcECbp2GzFi=U(LVlm@XHVfbD=_%1Oimn7HbINuu^Yx`*zsV9Pv`{7^m0t3DXfjV`r*tD?ZvTD zxb`W=!T@&%_sA)kl6WWU-EIX0_qfpDwD$Uc{4RKDexq#t=M?-ur{Vu|Cj1|-566#= zk8lWocpA6ZOH<5pTF`Q>XmQS8@e!HNraQ~&=Y_vD(B|E4P4oC9V&*qGA(NfnMi^#w zyC{QlIG0Vz)Ga+COuXFMQPM&l5g7y6cr4DhP0h@c&8tfxRqdqv`=5WE3@ygQRMG1^_+DCAJFQbTQTQVl zD(;r}JG3v-^4c5lG0zM@dwG;mJ7K`E=?4q?CvxYNz?RV*d1J&%Y0>Qj-Yn?6u{;>X!XKPi4zq9%RcJGw06QDsx zbAu}65y}Ab^WNArlUUT((L=NEm@t*lddhP3i3SaL$+atFDthlQgYI89->ur%{XFu`s_7Foq$D1{qJ*9e@@mTz?2YV-FHgpsQb)E zNiB~IA<^fPHmVO&uMo-m4=B!gO?vb1i!J`rtWOO&`x6YU_jc5_5pJh`GSsO-C&cIq zx8lHHFZruO+Vkzy9(8e6_6{tW$41wQF9OV9STKp8{dj_p@h&|S)>HOGH}Qk;{l6l` zhf5{u-Y0?}6Mebjo6V!{T(^J5bNYTYD_0sWliTSbW8dM?Kq#Mt{|*8!w$qsd2LUX- z;~GxF*9~JuxZJHSq-n>BR*tZb#6x$#DRji%Bg zVsnjDaM(G5r#br5{}FvEgUwY<8*)XfW>sPfJ?QOb5csMpC_V4hzQ6Rtry&`gbOMWN zM3C%qpakbaKa{2BQhF~o0eoOWt@P`WsQ;zOa|RPefQlJMe^E^~S~RlQqtlDiqw$dD zK&agRQnPcB>Q9%40~sRvrP83;H4x8OHg6 zC7qaTdQyk|qp+((rZw z_Id3R$bLfX>Ma%HO3k1bSdw^x00A9m{QH6!gz+C96#z4)dW&Vta3b1PB$xg`<{TPU zRSG@Kx`|KuJH=GXDP%N8>TRe>%fSsadZnE@ zIkeifR1k%J^cAL5E^gb|{gH+8;%n1c1Y=<`0eTzfse%05JeJ0uZq2`#Cz^Dblvruj z^b&FkU$ZF=x)Eik8E_f=HFKIoz}+yCuUHOHZK8G9_J6fbzuoCi)GthMYr;L}nC(ul zXghB2F`5kpx0H@p9(-Xm#7P0svD>}7SGK;{KF!GoYHl~pkp8Y+mblT2xdral6bw9d zf0i5CNbA^vELUs#KW~3Anf>>#p&-7yK>Ixo?_Ivupq3Zf0*!yap>d15EOknWaaf7b z=?n++@=GL#y<`U!ll_^`E9b!nGh=JZ@lDE)DAFMMNl7N~CG6%bX~tldmqO}s_(s~+ z=f$IuDwF}*&U?)T&Xv$1%aAs&T(VcLa#|CL;t+xh|9o>&7j~>%;!|t!R~k*HImDrc z-gfo(j1?>#rX=!Sy_}!!(;B{{g}2xIIqGnwp_7+e_{)p6ogJTTcwvmS>FWV@o*M}C z7Xiq-&u5l2f}0si_*`*X*k-7$!m7)*eX}`}d3VtT-WQ@O_%q0ZN}`SHZx}D3drFl^ zO1Qxj=1*8uq611kkG=y;vE-(7`rWsBxqyJCk$XteF6(V2?akA>0%;GvS{)J2}MCQe{1Ti?+b+%JC-g_BNvW#rZFre=Igf4ho1GmgJo z%j$th5P{YY*CE4mTfGpYP|X&19tvC3P|d*d>w;fQdqPT>lg1oOiGJoLYflb=pHqwP zl!lr&svnEqbnTbSU`6H=8Nbv@OrM>!5)XR9S6g8<|E%*b`;}J{eMCBVk7|Er^=x7v z7`0Akb3U-#ymEKRbPE$R$b%VM%S-A~F%YU(D*U=)pL>Prlyt`@3o zuV5Da%-Q9Em_pX)>UHcGPAJgaG`Blq< zz(8R)JJ@Jp4-Mk!yBLqJA1v$SoYymeh1}fIl5m_;{_X8}xpiXR^9+&eecI5fG2E}E zSYb%d;o{5lgJA9W>34%EjqZz!`0j{0RH6K)NpMEvr!HteKAJlNp&eHvz%(pgM(;8` zt|s%97l<^gBp+;FNiwi8vw+P3^ex44RANwq>(~Kh7PB8zvWe{pwo|soBV}g%Vlpjt zIhas?UYOuHw7;Jl^l7h2PYz5e?Mce4>DLVHR$!%0Q{y2X)VJCglq86Z9=7X@f8dC7 zqU~1SLvX1E0?=`-i7303&Dz0XL~&abp4Kn%CPK$d`o-7myDkKQ*0t<6h|{~G?0kqm z$V%*62{%k&qy33Ca=9Oj0pJ+wHrFo`Fn9(^7vdEAS?~ z&O449&#+0<9qh=n8qb69fMD#CR;JM~MB`8__mmAxPx4(J0EwKSy%sX56&ZS=TJ;<1 zMAP}ozonclxzQ~;zof}@@|-s$80Lop-WyrsX!)(*--+MIbFH~g<_6Q2k#%#8J0Xy` z_nU-qSG|&i=N~E0KO^H;gs}`{aD0>xYOZlWB!7&fj*8)MC&2;Agae%u$G;_x;hCw>3B=SMi&8&Y&|xmA>8oLCN7m&pu$ge=4YkY^ z@2+}x7tL`Uh2qdjYjMGG4~p;<`y8<`w~ahNl!tSnD=+IJ6y^}&rewk$Uf!});MYX) z7AL2?&m<-i7I!tfO;`0XtOGu5&->BGD}Dj4QU3Fc3Q5IkVOqGjC?teZ`X*eu#z(T4`~=+yKbWug@d z_WI3NHmNlUnl7F~ghC6@kmcXk%1AoG!O#bMuuA5R-}&?Kl{Iy5eWUq2ST{zVpi`MA z(0=I;tw+{pc*BmZA-mlXo*m0@dr{Kc@^#?lXdWrrglE<;D(7p5#yHx#0>d z1KRA37OZSLRhz`)|EnP0LcG6xk~=5AOU#4CZnbDlJh#@Sd6pGoS6?^78##vJxA8o3 z_UFDb==i^V?dPr%=u;meN8>}}{1=7d|2x$GN8f0~PyR>WgnfX^)22RQ6G&<}QSWN9 zpx~;1lz7qy|C;H&VZh7-F6+5eeZYR40{MEM$zq9AIzyIy;RKG?#F%J^a_woIEJwe> zpI&wX&7a8ey|x@l^EH{Xd!;qfoKU@A({abkdIqGD>JA|-bqZv6GB6MDHa>Ca^Vs|f zwVqKMR9;Zd*5YXki=in5W)fm&KPy5NU;RaIPxwHVynQEMuVF0!IB^0xz4zhY*24lce?l!A5JXo?n zW@k}}y;ayfh?K-igY1#|U4H)xavkJcKM|2oRQcFnGtmUoj1$`sQ^iE!WMu>IYnsd`U}6DxqNRJ~v3uhs{;DeAXt^d2_H!fk z9!@6qsFx}8ni!q7pGKa}_qiE`xhJjJUw7Qmcf#j!csi!7r)_!lw0tB9U?C;*#3kq<9ZO}6hDM~xJIPOQxlrCV5ZAq>f_#ksU+DV z-}tHv1Gx3a^@Byxo98jetF>yBuTiFG8IA_q#*R^>9$C3 zm;PxVH7qqWIbmwa&_-Wrd89!|2700b9shrhXhAV6O!!=sCtZgD%gG27H z;$rp}S}m8#;OxRMx125}l6J8OCL+Mscm#QmiItdYTwbeS`fD`y0rpo5oZ6TG%geH~ zwclwOk-^xJ&?qte)8%}zCsSO@l%wHiCo(KlmA~6H$M5WLQZ!_zlAYlOW)SwT<(P9;sm1ba~<|Uss?pQ7Bck&GkErDdcWyfP)$W=?MNBKPf@gc_&Fih8((of#;Nx-k57@B z^)*|d8&B&w0J#UZicPo-aiS3kd*JqTIE;x3uB&civ)rvBH4Htd)o^V?Z9T}=!UG0;JTRG^}hYTMF<|T?Y&}n>II{Ip#8iibjUTgb(No zjtKIGk`IpgI6?LtPG{cB7Ogs(o!3Cx602MgT+-cb1#+) ze>}374=+^gkacfpb;s31EwgHyEo(KmxzqP`DDzC<*Ot&zS_W%ClP9@i%tgVsss|x|2$205t;y3 z9eO!!7b(GabLvQua%gkJG%w!>$_xDj>)e;z4{5DN4@;4H``m)2O7cj5M9z8+Q?sck z?L6GU!wWwdZ=X*!Udl(tcI@4tN}dnDtuK96wC~y%P`frQzhs8*lt&_MU13FKXa+#`SN`%e7D%lpAF~O4=xwbR+#K#-a$-`ohNp2e z(~Rj*BcZn*Gec7|3;6NX}&x?mcTJoW1)v$$AZ z%Xz`X0WiYKu-RiLqsfcfTNj^YP<{2JdUpgH7q{~#%OO%w%TRNb=lN_xHE>EO3$E_R zK5}~bMa%~ShLU?>gvQJI=|WtPpx$ zwqfGCw%@^PgR3PiOOA(%lHnn&H_sZro}p4-M2zmDG9&lGn4i*1YfOHcon^m;d@GO| zsFB57!YQW(imB$#Naz3>GhRen9qCCb&AjA5};h8TFOthnL9D3#W(!~}YiJT@dL zCJqVo8lyrT_&DHCjpx(%;%lte5aNqAacj=Pg+|{M6zH0k@Qr=M&-U@u(5MSym-STM zdiK2D-#Yn}S{Z+eGK##9V{G|9bTb0j(*0^OQl@%3Qr+I5e^n#T#C^YQbQ45ALvpw! z+w-4WK8T%4JC!;#fAS@{v)oO6{c_VX^T3KHa=KVtU5kq^0)7zumE15FTVuwsY5n6* z)L||o;fi{Y4Oc$m6A`&dtPWhe_lI}rhOqfMeEY)KtIG@RFDDo{T!^@iDOyOtXZO&- zyCX3I`Pi!PYr*M2cprMXN5Yj+@@oZ;+SFYEr$h~m z5Go`v^MVvcNN?mlpZuPEdicwpOV<05+(Cy`)T?k-jSz$N6imMJGNC=AJ?f{-=S8@C zif$d3nnfcl*T`47&iF&iqaSADVxe)ueiMd+@Gf99(ynj&jt-EUw~BSQQ(R5HlZ^{F z#}0p~-XqTsL!(KtK(bCQK5^scPB0}3mIf_rzXSKa13z6lT~Z^(nqW<-PU5GH!5U1fX}_+JNse93?Z~LD zhL-!Jlfdu?|EGkH@08X8(~>6sKA+d=o&=&kNbQmab!PDm_j>H#8PAEoP$mM)>|H;KK^X)W zOmU)>U&CUGL-jaLN2f);e)N01W|RG8&a`N zV+yf%<>7dziF^P@{CR51WJK^J(!h~x)CY8;5!!@8__LK zv7K=Hm8v9&$X!+8_*KAd`4ky~H$vj{YTHttE@AM|TMf3;22Vvd8J$~t6VOGVT~m5< z;5Nly*!Qo#y0DJq!CYdrF8%2l-y{Pb-TUsQkR_BI!hKY3%EST~Y9stG3RnXIi>u#p z%d&2}gcgenI%Ebl$iPuc!JYEkuE7=ZPu?}yPp1k`-aUr@jLc`Jo~s586BP!@v6j?4 ziWZ8OVo=FJUxInXQdsuEtBS9j+iI2>y{za1wDi6yUP2>ggE>*0bReCRf-m=5=xf0W zHo2@YSu%czBiA-hT`UT7=hw0e6rsJA!OSpvaKOubb^mdk2h(5&7$vMs?1=XCFd+r9 z0WlUmwXe4<_g^Er@=W@-RUiIdCHB|Re>Hu-OPhe!we{VBSjuDz=QDiAiXNAlB0-tr zHh4GWoz~SC`=(g(b(blEr{Kdz&f-Vs-QMFnS8N%=wJbf!{SeuruyuoUl$Ib>K6%cw znP3sRQ@nG67Wtx%HLhOdu-qiMM`0e)H!0`@ashWj$vRanl*&=P5K0I5bpAommq(5I z9nW`||E(;xf9^^GKNQ7((0(7FyZ@yu{v)dl;_FaDKHz?U{NKk+QYhp=lsI7DN4+SP z9Zr$FxkQygWv|2cE6P95t@oz)jXCl#S(+daLY#Iv88t=+G_hZ}Ce!hXB8QrVfVxWs_M6$O~W z056UHGzwDtl*7aIvx@Vq;r0&BML1q;``{9QpGw;2N@aN%kwv^X+Bw5ylU-jPbV*yj zmj%=XnZ|{0fn2kY*K)Ay=KucB|CHO@nLlLIC$j&p4mp@uc{;heTX_8!(J#y2U0pK$ zLrc~4{NPm-hN2`@Jo+Am!OiaXi6;>R_(9POjbW9mkX^jCkC&`ih9wG!3`mMokZ`y% zBO52lA!E6QlCrS!_zdXPQOW9!XF_fGUG#^pD2gq}ly(223i5X@?K5aaKEtQ$V)P>rPsFuAQk8?R=cZ5-u_pC4^}5$h>H(HI@-3zs3QEmS2xRy|P{l%})L zXI=>tiSCl}XzutpXNlhgB$LIt>`lX@#0dyB-$R6oerSpq6PK2$c4+D4a0EP=vE%i?O;=M+%4w2T|B(NDx-2W;w_J@rQTUIu_u6Ng0 z;BMx+{utdBdainb8d16h=hmS;Vb{D!m2+#)yKs8%(ey3F$rDu6I_V@AV1Dsq{oh+> z5YK?>cyj&eJc66o82+8F`h+NIsQLyRTE&sF9$%ftYkD04@Wx!ZN9qt~&ZsOPIGQbq z+dp1NGiL07ttW3jyEq`sv#LoTwwP5*^p*0`Nv_F`yoFZNBT_4}@mVxf^l2lYxo1A~ zM@-01=*jFSR7ZrOP2aL(lmRNVKmH4h;(eHFnaa*HPg}-hcqazW<&whoNt=y9Pds|l z^Q`;%uzcn7ZW0$O38~IVf+suxFm}d&{wHfQQR% zQ8w*WSdCZtSEuYQfBmUswiIbPv&wqH)jvHgOybGf_<6ia4>eM3y2SK3=D3WHl{Ml1 z+|e`=mh9{YUv?e*cEI1Ym}n%BQS-Ub{lH8A4&^qTe`WYak9XQIsi|)OiT*)$Nys-O zr*sBpti}DZyx|U!p}nKJ?HddJt0ebn>xK*U5za5-&yimDdV+8GBmpvC)KXS>=&dU9 z9Lby7-hzfj2FL3i&cx;zO|p)a*j~FKS95Q5k$||3a{Y98CAm;inVK-{JEh7hqdG>+ z%ICjEF35LxjB}BHn~C}Yc#r26{ikcbqTJmxwxCXKxFWz$yvlx?!{*#@DE5Vimw%N7 z9-al1m`4#f8qFf<-4{N|ojJj*4GyF5v(k9n#Vgc_Mqr%Dtk9<^6U(#DRjVF9gf~`A zli<@d)5~I*8}z7YMn;a(ua4R8SvG{q(mIgYlvREmrN{n!=Ues2uTG^S64e+w|H0tJ zoOb+Olxhldr_)$wQI>fSTs(MdZ{il#OLJs z$d&RfCpeFxD2<9$3}UEmELKwpSY)%}sN$_S09Q^hv@dBMnzp&9-Ip)DGMO%q0UI;? zUqK_@%b70uY6h+lB&!74Mi(s z_O+wYqgeDSC7E&VyY&7u?ocbys%ex&g`7h`jRqqQq^;9BjXfG5$P)!xM=Il^OtP9G~$cueJAW z+Lt><8Y5=)_gF5kjj@ZZzrmtHa`m*%5$*TCm)s(Ig5vq&Fpk1T!c>1U2YSApv~3VF#Ur{Q-*+klWWVhzuGij~ z(=9`#qZtZEdP`z%3_rW(%*~TG2U=U_58iVwQ%2)!+|K9p!fe3J`PzT2aU6H|HT2rB zGcS>-Qz;->{n3{^NmzIz8UgLfQK9`os?6g^JZ>dYligXJc^C2C;5hd@>IA|cIr#9M z8bdjw7^7dg^xg$cK{>*_!1po-twF&<0ie5K9^`tBpx=^IG zSKs;_!^Faf5!Nu8DFh?0AP7C8%RyJ4#977BLSn935Gv5WVRRxeBPbxmA$TB&NVejd zVDyWJF@(8>nTB~IxS?B8>{G}~kBE;*)Kbcew~~; zesX4S6AV|CpWciOvJCu=wSMelER!>JX^1FFtKf?b&^Oxy_Owi>h&~HD_l_{WaI;#> zXMCz)bn!6L-w?D7g9hztE5J-ZA66810qQQ{OH=*?&O4Qw-IcJLscZep(>m7;q!gT7 z!xVE0#J>f6Cv17A{&(s1d9z?Myb;o6?O$Kq^DKGVmh32?Ta$R}MBNSE=B333tnS_$A<3*U9e6r|^On=_vN>AUjJ~%dV)b zl2I`)XYQZl`L71&vT;3}C7zDL(F5P!4UC$p8s_#n9WA#h(<*XEe0xlGN6b&#lM|f9 zgG11j&&dhF1~E#vWlhf{t_Tlri_eb1kO#NqkOlY8YMuZb!mVQm_s0#7w^Gm|{J0?K zWL+Tl07SqUN;{;Mgu)x|ga1P+9ac@cG z=H=z)jeXm)1%;ksmA{F>Zq-A zazDV$E*HXygR`Nd`5EQnFugTBYhR)aU=C_EW?}A-4PqD%;_%3sDmwQ*9)8*^qeR$y zSOE;ihXIqtj%0&R@s$?alCCB4a`4tX9I@=HcPGwXbHSWc9sC)|`ome14f$Z6>1WC3 zTbJr1`vc*yw)~Z4U{>2xb?^6FyB`4)ZAlGj&r_JVbG$CiH%-Md>;hT2d4nykhVn03 z;suYnE8yQD;MYMC5VLxA$udEg_@)KZb!TJNHHu$c?C|MJf+|yf2cL+Xnc?9CkiGe) zuEy!D`m45Q4iMYKfF_iJu6uJhxC5}m& zRO&2xbBulhK{N1<)Cs5XzoVpygEZ?GId#Twk%MNcSd8 zv69W${bD$O8fpIn3)T%9u{R@4p#W90K?3{BU4-Vjg~go({~P2t5p${=M^Rw;LXn#Ljti7r4}e?qbVM*6CvXMWge|H9;6Y0J|f7+1aVA zAxpR;+2jOQPuGv2ih%Bz0?0w!R0X^Cv~3!A9U6E7y?0ET0(?p|k9LLcHQ{g1t@#@% zMdAe?YpV4PW;6vRkn*Z!hA**m#2cBqX&h!e0*{anSGgk&{54qyMhTrS%}w~O#Vud* zh9%w1*PpsSHjB{Cw<4MZpSI=uKZl{z z5xpg|T|Vc!MTsP=7AwWEqSX1iM|?zQ$|Yd<&r;D;6e*vTN?@qU(ea=qXgsdn0C+}d zFL>llrN&>DEF1^sO3nf<>(9m3wOQhsJv&qz2j;UMy73?R{$N2rTl&ItiDB=F*g1yh z+bnDoJ5=Dp^)~fkxUgmM-`5>+bq{EWo)Mn}BH;v#kUYC_&T_HO%rhT2vm4TmB6HgE z4-kQx`HY9IuY9u^nWp@O@tot-u~mWvpIY+|us?p%6Auxl`e$_Tghd7U@$>Q;tc-) zk?-QZvt{#HJ=ka#QZOZkFzHo$%obI&)cp&y=u#f*RU@&vB{ zd{TFs<2A-#t1YNrg$;NlM~@s`Hl#>71LC`Ro;2OAW8%lZecSHwUlDtMhJDY-y@w=| z$W`VA_V-eOPz|@_t!Rd&PIvZn2%kG9nL9!0n8iRLb=g$LbA`ADUM|T5Xta%}E6>2H$T@UptHOzUK};*YyO)LgU?K zH1?fZ2O`2grY`}bUYTy=RbaI4pIQI6uI^2UfM58?A*lRMf%gB`)&I#PIDEk7VA#fG)gzIZQc&39|3LR(tR`J~kL=9Sz>|MTyx|?&q)M(4oXHU#Bcfo0U^?pW^JIbkk>`Me5sUY}CE+274kt?mP}> zaqkmXqc>aKSKs4Fz!BKEna`i26#VEjpGNz|rKj{z)@X1(W>=@N6-VGH9r};<3GROf z;4fO#T+Bz?>Dm9ZothY#+E^J`+qnN%kv8+c+fFY(rYRa$m|9JOfkEvubP<__h|%cS zel4vlxgV};+BNP7O2*zT_wdJgb2n^+iCeR#-!xFmtu}b#IM;7sPAI6J7|F5p|fT!yHbqi%@L}q5;hwQya_AHtIhoAE6I{%0J^1^vPpYQkkJm2rCi-R6%?=@QBsC>>tP-^Q6M_z}L$gokp$f-A2lQ5bK zr_wIbdj+c8K4m5}b#;XZRlAon!s7sdjNMYvPPlaO=5jG4~9WI$N9sf*OPfNenbB}FQW;ZXRO;@RYBkA9h zNpqci!cA0PaKrpSdj(_Eq+|ArnVNSsL}iB4cBi5?;#Ws}B>l1VY`Kw1V+u<;dJ3tJ z*%PA)5R~FpVb51{kDOy?Ubt}51bKyX>gU8~j$Ah4JRNE|~LlPAJf0}?^GcR#Ti~XQU??CU({8`fA zrMwJr`)t*lu@Ob-M!sLP8W>|-zSiZ=4HqNx@YVH8=I=w(KVWb9Hm}ya&mW7$-k_T) zX3ym~8WiUh)7^agM*WM)huVdP9v(Lx0y(Cce)8UQh+nUB9xv;!GpFHCoOd+G);23D zD)|xLm=wK>l@Z1KY6)9CXeUbdNGjo+=LRRCh`+`nTW`)KnR>~8=vqHV zC9%IiDKU-M)xQfl8dJgQQ#CuO_SSE@Qi7mX=G1iV+l{cMFot&QExUKEC~UiaTC8(; z#f?U)Q*~b#>&iBh)CWdvdTYLx5lu*RGb6|y5HU21xI6L`7nzxnWPz{HgrnC#-uXBbvY&(B?IY2M3bw- z`!6#x=N1xCrc*TxODPOX9~7RZr`KoQFF%I4%fn-!aOM+uLVSe4>$mr3IU1Ib20h~m zvYDCQW>SG)!ZX{``<l9_pYpL9i#aN!6EDL|la`N_R^nD~a#*=KKbS^eRSrDM2d$ z^8&W16kkj?*mYrZ-k8@YYF_<^!FV4r_I`J|ntQEX)S@+s2KoGkdu1{4hCvBurrs8E zDS>R>8FOAL)Nl$bk17sVsVLG#aSgPE9<66HTXK|HT1@o#G2LvPu$*y8_L_U+Ow@D; zskvs+M`!h#&u=Hq3slaW@z?%Z!^8eEA|g;X`5a^5>2ufOtge3-t#ma1EZXKB{&r!Z zvZAdmOZcI=vx>5_fn(IuyXuM(1WM`h-*tL0{`|w%ym2FG+RZGQDb4bPJRV~2zM^h3 zjn7b&f33NgsC_t)xGNkv7Q;gJSiZLE0*{l#1yf`f%HfXe_ZS-T@@Jd_R&j~E{6U_d zqFp{_i6h1}NH(=3J{M+Otweur%8_ub|2~s(njI+*Y5v-_LcinQ7^23i`6~sfc({k@Z2nOkn`};IjiIhLzq9~{F zwX3eb{>|V_+VM*9Pd@X~`6?eJ2N8rVt=Q^^#HT`fh^-Z3@7c&K(0a^XEUNBLsSZ2j zLMqbQz?rRC9hN=Djc{|BdxPUYsLQrOG;EwagKx#GnBCUuTi6 zBcIAO{85puY4v!E-lCKl`=cOv?$GB1=E5(8k21%Ti8WXv^>bYAWn>6;k>@5nD=O>>gwe=mK={2 z(&_|XDtG6zLo`!VWFV8$xmLfmpUYoUu{ug3G{sAr3HQ{|y=B~Fo#Ak#Tg=Jvn&h`z zN`ChYTmUNXy{X>Kd?b4|f<1?-o zoGFKo4)R7f)_0SkH0zv7^n^BaIBb&YbZ!%Uzb4ve>|)JJXQ6f3mD=zs_+VDe{Yran zOmUa-sFdx=F^AAi%V4#`uA~b`2P0)SLssmA`48F^acjN8R*TMqkG!4k6D{B8Vs&Fxqi^P3dt3aSVMSZ=%ZiJfYi56% z-}=i`YG?ZkJWOS%3ZhNv=Y|1_uIikDowp5A-&rzeYt}a^LWYz5OwlUZ(0$p|K-=2dEw0L&>g_DRU&=KdNWdR2>yK14GfAj}LbuIKj$2 zUY;%f*zOkVkfPI5+=1kHqI*X?yI3;SvXozpjh`*1Rxi$LT6PwUp}-F4JD$~X=pVU1 z^;)z{@3_5+Qjhx#4`j=4GB&F$IZb|U~9``)mK%qhWtTWHy?5z($8F1B0bNy@$ z8Ic3>FE_HaDk(~vU>-K^QbeAsJRG{2B>ACiYw&{jw1NDSr1gFTp7fNS!M8tQHizJG z)Q>b1BLWb@+6VOyGrw*#`yk+qA}sxq8_7DfxALJD3dbW-cspTDDk!2*~-9y9pM?}h`h+s-?9V1%;MbJ#vp z#wX{?h&BqrHj-dV(7~UEgExa;|B&0eD?S=AtC%1S)SQq0YsPOD2QL6ALgE zjFzH1m=Kg0F(}vg5r~QI6C;q@rJ%$pjWhjDg7ge2R(#I}{>6l$#5Ox5$e_i}+}sfp z=7SPDS5M(>2t0eT&$C0fb}$hrG0z*EGtgoYdb<>q*v~dG?r&hBeT?{bxd5@*X*jsO z{=XOsGLWal6?3+C0BI1&?RENh!@;es{%}x&OY}Y`gn)Mq`vh-U?}CuQ47av7g%(VA z`olp9QZNi~y#<0x`@$LFyaS3sg;P?{%?&!7`o6mqlvt7HEAA{1kLG>eo%h?p#8HrY zoSw7LMgfT7{&2EH{NbPk2b33x4+8I2fZ*Tz96{tZC{D%cLSl3;wGC2pLyIM+?@~}=0tcJ}UqXucW$a*(tI5@ziDBq*yH>PI zL5V$6SE2|4D|ZZ775@&SK=BSH3N=Y+CcCsdKp3|xe*L{DkJRo`P-0P}$BntbB=KRu z7aOVD!60YkwXc5fp*<4@Hh_^9gIrDeU?m9l?E?W&o9l1SXgaqsA;>W+M92+6kD1Q! zE(PV8V@hR^JMc_)-)xi^0oYjqNU`zKj^of`gLAtSlvp@UCbkoJCbch&CG$HNEPsy5+0r7|?zQtf2p60}O_P-{yeo0?Lf&fDCtYU`96>neEDZ zzZ(v2%L#{BnAzFs-Ltm0Lpd1j-kpYTM2KVoXB(jI0VDnnCZ*v14xmg}viJEW2_|E~ zzTsdHg2U{s&}L{0aC@AM9vW?AYXvnkCT?vaYk>`S_YKFBa}Yg188P(DPcI6rVu!#g z27df$IJgIRV?mo)7;PV!+Cizq1byCv#b+n!cKc4o-wg*>1bMi7D3t6mcJYlskg03K z4uc)}4T7IIxU32sW@%<^m(C^CfX&r_WU_;|@L!B_F~u zs#<#-i!{^DO&+Cw=y{)KRNIf15UO4y1{Y)`yXnVpELjf literal 115598 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ʙ~>5ZgNap*_Ln@H4#rQqU#zT< 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

Csc1}nu5 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 Date: Sat, 15 Aug 2026 10:25:05 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat(firmware):=20the=20panel=20plays=20s?= =?UTF-8?q?hows=20by=20itself=20=E2=80=94=20.pfs=20tables,=20/show,=20nigh?= =?UTF-8?q?t=20wake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simone's on-device show player, ported: the Director's packed PFST cue tables live on FFat and play locally — pattern switches, absolute params, banners — with no broker anywhere. /show (Sequences) uploads, plays, loops and deletes them; while a show runs it owns pattern selection, and OSC/MQTT/HTTP pickers wait their turn. His night/wake schedule comes along, minus his Weather stack: a new core_clock.h carves out exactly the NTP+timezone slice the scheduler needs (same NVS key his Weather page writes, so the two stay interchangeable), with the UTC offset set on /show itself. One fix over the fork: his cached clock refreshed only until first sync, which would freeze a clock face; accessors here refresh through a 500 ms throttle. The clock face falls back to the stock GFX font — the TomThumb rework stays unported until it gets eyes on hardware. Black joins Origin as the compiled-in night face. /patterns unpack now routes .pfs pack members to the Sequences store, so a deck zip that carries a performance installs it alongside the modules; the end-of-batch rescan flag learned to skip those. performance.json stays in the browser — the device has no use for the editable source. Static DRAM 90.7 → 108.1 KB (show table buffers); 219 KB static headroom left. Runtime heap on real hardware is the release gate. Co-Authored-By: Claude Fable 5 --- firmware/patternflow/pattern_registry.h | 12 +- firmware/patternflow/patternflow.ino | 74 ++- firmware/patternflow/presets/preset_black.h | 26 ++ firmware/patternflow/src/core_clock.h | 108 +++++ firmware/patternflow/src/core_show.h | 392 ++++++++++++++++ firmware/patternflow/src/core_show_http.h | 435 ++++++++++++++++++ firmware/patternflow/src/core_show_schedule.h | 288 ++++++++++++ firmware/patternflow/src/mqtt_index.h | 2 +- firmware/patternflow/src/patterns_index.h | 25 +- firmware/patternflow/src/show_index.h | 274 +++++++++++ 10 files changed, 1620 insertions(+), 16 deletions(-) create mode 100644 firmware/patternflow/presets/preset_black.h create mode 100644 firmware/patternflow/src/core_clock.h create mode 100644 firmware/patternflow/src/core_show.h create mode 100644 firmware/patternflow/src/core_show_http.h create mode 100644 firmware/patternflow/src/core_show_schedule.h create mode 100644 firmware/patternflow/src/show_index.h diff --git a/firmware/patternflow/pattern_registry.h b/firmware/patternflow/pattern_registry.h index ecc99c6..a0ea678 100644 --- a/firmware/patternflow/pattern_registry.h +++ b/firmware/patternflow/pattern_registry.h @@ -37,6 +37,10 @@ // Shipping one preset and letting people choose the rest is what makes the // console reliable, and it is why the pack/deck flow exists. #include "presets/preset_origin.h" +// Black is the night/alarm face for the show scheduler (core_show_schedule.h) +// — a cleared framebuffer the schedule may overlay a clock onto. Costs a few +// bytes of DRAM and doubles as "panel dark, still powered" in the carousel. +#include "presets/preset_black.h" // NOT in presetPatterns[] below on purpose: the calibration test card is an // overlay the tuner summons via /api/display, never a knob-browsable pattern. // See the header's own comment for the full story. @@ -70,11 +74,13 @@ struct PatternEntry { // Pattern 1 stays Origin; modules are appended after the last preset, so // turning back from pattern 1 reaches them. -// ── PRESETS (the failsafe only) ── -// Pattern 1 is Origin and nothing else is compiled in — see the note by the -// includes above. A board with an empty or unmountable FATFS still lights up. +// ── PRESETS ── +// Pattern 1 is Origin, the failsafe: a board with an empty or unmountable +// FATFS still lights up. Black is the scheduler's night face. Everything +// else ships as modules — see the note by the includes above. PatternEntry presetPatterns[] = { PATTERN_ENTRY(Origin), + PATTERN_ENTRY(Black), }; const int NUM_PRESETS = sizeof(presetPatterns) / sizeof(presetPatterns[0]); diff --git a/firmware/patternflow/patternflow.ino b/firmware/patternflow/patternflow.ino index d078d94..0236e00 100644 --- a/firmware/patternflow/patternflow.ino +++ b/firmware/patternflow/patternflow.ino @@ -19,6 +19,11 @@ // Also after the registry: MQTT resolves inbound pattern names against it. #include "src/core_mqtt.h" #include "src/core_mqtt_http.h" +// On-device show player (.pfs cue tables on FFat) + its night/wake +// scheduler. After MQTT: cues apply through the same hold helpers. +#include "src/core_show.h" +#include "src/core_show_schedule.h" +#include "src/core_show_http.h" MatrixPanel_I2S_DMA *dma_display = nullptr; @@ -158,6 +163,10 @@ void setup() { // the normal broker does. PatternflowMqtt::applySavedMode(); } + // Wall clock (NTP + saved UTC offset) and the night/wake schedule that + // depends on it. NTP itself starts once Wi-Fi is up. + PatternflowClock::loadConfig(); + PatternflowShowSchedule::begin(); // Start Wi-Fi non-blocking: boot does NOT wait for the join. OSC, OTA, // and the audio-react server are started from the connect edge in loop() @@ -667,8 +676,17 @@ void readInputFrame(InputFrame& input) { // (Director / Show manager yield to hands-on control). Checked here, before // OSC/MQTT/audio deltas are merged in, so only real knobs release — and // core_mqtt's grace window ignores chatter right after an absolute set. + // The same motion dismisses a wake/snooze alarm cycle. + bool physMove = false; for (int i = 0; i < 4; i++) { - if (input.knobDeltas[i] != 0) PatternflowMqtt::releaseAbsolute(i); + if (input.knobDeltas[i] != 0) { + PatternflowMqtt::releaseAbsolute(i); + physMove = true; + } + } + if (physMove && !brightnessAdjusting && !oscInfoShowing && !knobMapShowing && + !updateShowing) { + PatternflowShowSchedule::noteInteraction(); } for (int i = 0; i < 4; i++) { @@ -759,6 +777,7 @@ void loop() { PatternflowDisplayHttp::begin(); PatternflowWifiHttp::begin(); PatternflowMqttHttp::begin(); + PatternflowShowHttp::begin(); PatternflowMqtt::begin(); Serial.println("[NET] services started"); reportHeap("services up"); @@ -784,6 +803,8 @@ void loop() { // MQTT keepalive + inbound delivery. Reconnects are paced internally, so // an unreachable broker costs one comparison per loop, not a stall. PatternflowMqtt::handle(); + // Advance the running .pfs cue table (no-op when nothing is playing). + PatternflowShow::tick(); // Browser self-update housekeeping: boot-valid marking and the deferred // post-flash reboot. (Upload traffic itself arrives through the shared @@ -804,6 +825,12 @@ void loop() { bool k2Clicked = logicalButton(1)->clicked(); bool k3Clicked = logicalButton(2)->clicked(); bool k4Clicked = logicalButton(3)->clicked(); + // Any click during a wake/snooze alarm cycle dismisses it — the person in + // the room outranks the schedule. + if (!brightnessAdjusting && !oscInfoShowing && !knobMapShowing && + !updateShowing && (k1Clicked || k2Clicked || k3Clicked || k4Clicked)) { + PatternflowShowSchedule::noteInteraction(); + } if (!oscInfoShowing && !knobMapShowing && !updateShowing && logicalButton(0)->longPressed(MODE_HOLD_MS)) { brightnessAdjusting = !brightnessAdjusting; @@ -963,6 +990,7 @@ void loop() { if (!oscInfoShowing && !knobMapShowing && !updateShowing && logicalButton(3)->longPressed(MODE_HOLD_MS)) { if (currentMode == MODE_RUNNING) { + PatternflowShowSchedule::noteInteraction(); currentMode = MODE_SELECTING; contentNoticeTimer = 0.0f; // Physical escape hatch for the calibration overlay: whoever is at the @@ -1001,9 +1029,12 @@ void loop() { // Apply OSC-driven pattern / content changes from the most recent // received packet. Knob deltas were already merged into the input - // frame inside readInputFrame(). + // frame inside readInputFrame(). While a show is playing, the show owns + // pattern selection — remote pickers wait their turn. int oscPatternIdx; - if (PatternflowOsc::consumePatternIdx(oscPatternIdx) && + if (!PatternflowPatternsHttp::isConsolePaused() && + !PatternflowShow::isPlaying() && + PatternflowOsc::consumePatternIdx(oscPatternIdx) && oscPatternIdx >= 0 && oscPatternIdx < NUM_PATTERNS && activatePattern(oscPatternIdx)) { currentPatternIdx = oscPatternIdx; @@ -1013,11 +1044,38 @@ void loop() { Serial.printf(">>> OSC pattern → %s\n", patterns[currentPatternIdx].name); } + // Night/wake scheduler: may queue Black or start the wake sequence. + int showPatternIdx; + if (!PatternflowPatternsHttp::isConsolePaused()) { + PatternflowShowSchedule::tick(currentContentName(), + currentMode == MODE_RUNNING); + } + // Show playback must be able to load a module even if Home/Patterns left + // the console-pause flag set — /show is a player, not a library editor. + if (PatternflowShow::consumePatternIdx(showPatternIdx) && + showPatternIdx >= 0 && showPatternIdx < NUM_PATTERNS) { + PatternflowPatternsHttp::releaseConsolePause(); + if (activatePattern(showPatternIdx)) { + currentPatternIdx = showPatternIdx; + currentMode = MODE_RUNNING; + if (!patterns[showPatternIdx].name || + strcmp(patterns[showPatternIdx].name, "Black") != 0) { + contentNoticeTimer = CONTENT_NOTICE_SECONDS; + } + CalibPattern::overrideOn = false; + Serial.printf(">>> SHOW pattern → %s\n", patterns[currentPatternIdx].name); + } else { + Serial.printf(">>> SHOW pattern failed idx=%d\n", showPatternIdx); + } + } + // Same contract again, fed by a retained /pattern message. The // name was already resolved to an index inside core_mqtt.h, so a rename // on the broker side cannot select the wrong slot here. int mqttPatternIdx; - if (PatternflowMqtt::consumePatternIdx(mqttPatternIdx) && + if (!PatternflowPatternsHttp::isConsolePaused() && + !PatternflowShow::isPlaying() && + PatternflowMqtt::consumePatternIdx(mqttPatternIdx) && mqttPatternIdx >= 0 && mqttPatternIdx < NUM_PATTERNS && activatePattern(mqttPatternIdx)) { currentPatternIdx = mqttPatternIdx; @@ -1029,7 +1087,8 @@ void loop() { // Same contract as the OSC path above, fed by GET /api/patterns/select. int httpPatternIdx; - if (PatternflowPatternsHttp::consumeSelectIdx(httpPatternIdx) && + if (!PatternflowShow::isPlaying() && + PatternflowPatternsHttp::consumeSelectIdx(httpPatternIdx) && httpPatternIdx >= 0 && httpPatternIdx < NUM_PATTERNS && activatePattern(httpPatternIdx)) { currentPatternIdx = httpPatternIdx; @@ -1101,6 +1160,11 @@ void loop() { updateActivePattern(dt, input); drawActivePattern(); drawMqttMessageOverlay(); + // Scheduler-owned clock face, drawn over Black only (dim night clock or + // the big snooze face). + if (currentContentName() && strcmp(currentContentName(), "Black") == 0) { + PatternflowShowSchedule::drawOwnedClock(); + } if (contentNoticeTimer > 0.0f) { drawContentNotice(); diff --git a/firmware/patternflow/presets/preset_black.h b/firmware/patternflow/presets/preset_black.h new file mode 100644 index 0000000..1984221 --- /dev/null +++ b/firmware/patternflow/presets/preset_black.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +// Pattern: Black +// Full-black framebuffer (panel stays powered). Night / alarm-clock face; +// the schedule may overlay a dim or large clock after present(). +#pragma once + +#include "../src/core_canvas.h" +#include "../src/core_encoders.h" + +namespace Black { + const char* NAME = "Black"; + const char* KNOB_LABELS[4] = {"-", "-", "-", "-"}; + constexpr bool ABSOLUTE_READY = false; + + enum Face : uint8_t { Off = 0, Dim = 1, Big = 2 }; + inline Face face = Off; + + void setup() {} + void update(float /*dt*/, const InputFrame& /*input*/) {} + + void draw() { + PFCanvas::resetFrame(); + PFCanvas::clear(); + PFCanvas::present(); + } +} diff --git a/firmware/patternflow/src/core_clock.h b/firmware/patternflow/src/core_clock.h new file mode 100644 index 0000000..2b6a352 --- /dev/null +++ b/firmware/patternflow/src/core_clock.h @@ -0,0 +1,108 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - wall clock (NTP + timezone), no weather attached +// +// The night/wake scheduler needs six things: "is time known", hour, minute, +// second, minutes-since-midnight, and a day key. In Simone's fork those live +// inside core_weather.h; this carves out exactly that slice so the on-device +// show scheduler works without the OpenWeather stack. If full Weather lands +// later it can include this instead of re-owning the clock — the NVS key +// (patternflow/wx_tz) is deliberately the same one his Weather page writes, +// so a timezone set by either survives the other. +// +// One fix over the fork: his cached tm refreshes only until the first +// success, which would freeze a clock face at its first reading. Here every +// accessor funnels through a 500 ms throttled refresh. +// +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include +#include +#include +#include + +namespace PatternflowClock { + +inline int16_t tzOffsetMin = 0; // local = UTC + this many minutes +inline bool ntpStarted = false; + +inline struct tm cachedTm = {}; +inline bool cachedOk = false; +inline uint32_t cachedAtMs = 0; + +inline void beginNtp() { + if (ntpStarted) return; + // Offset is minutes east of UTC (e.g. Seoul +540, Rome +60 / +120 DST). + configTime((long)tzOffsetMin * 60L, 0, "pool.ntp.org", "time.google.com"); + ntpStarted = true; + Serial.printf("[CLOCK] NTP started (UTC%+d min)\n", (int)tzOffsetMin); +} + +inline void maybeRefresh() { + uint32_t now = millis(); + if (cachedOk && (now - cachedAtMs) < 500) return; + cachedAtMs = now; + time_t n = time(nullptr); + if (n < 1609459200LL) { // pre-2021 means SNTP has not settled + cachedOk = false; + return; + } + cachedOk = (localtime_r(&n, &cachedTm) != nullptr); +} + +inline bool timeSynced() { + if (WiFi.status() == WL_CONNECTED && !ntpStarted) beginNtp(); + maybeRefresh(); + return cachedOk && cachedTm.tm_year >= (2020 - 1900); +} + +inline int localHour() { + maybeRefresh(); + return cachedOk ? cachedTm.tm_hour : 12; +} + +inline int localMinute() { + maybeRefresh(); + return cachedOk ? cachedTm.tm_min : 0; +} + +inline int localSecond() { + maybeRefresh(); + return cachedOk ? cachedTm.tm_sec : 0; +} + +inline int localMinutes() { return localHour() * 60 + localMinute(); } + +inline int localDayKey() { + maybeRefresh(); + if (!cachedOk) return -1; + return cachedTm.tm_year * 400 + cachedTm.tm_yday; +} + +inline int16_t timezoneOffsetMin() { return tzOffsetMin; } + +inline void setTimezoneOffsetMin(int tzMin) { + if (tzMin < -720) tzMin = -720; + if (tzMin > 840) tzMin = 840; + tzOffsetMin = (int16_t)tzMin; + Preferences prefs; + if (prefs.begin("patternflow", false)) { + prefs.putInt("wx_tz", (int32_t)tzOffsetMin); + prefs.end(); + } + // Re-apply so the running SNTP session picks the new offset up. + if (ntpStarted) { + configTime((long)tzOffsetMin * 60L, 0, "pool.ntp.org", "time.google.com"); + } + cachedOk = false; +} + +inline void loadConfig() { + Preferences prefs; + if (!prefs.begin("patternflow", true)) return; + tzOffsetMin = (int16_t)prefs.getInt("wx_tz", 0); + prefs.end(); +} + +} // namespace PatternflowClock diff --git a/firmware/patternflow/src/core_show.h b/firmware/patternflow/src/core_show.h new file mode 100644 index 0000000..f1af026 --- /dev/null +++ b/firmware/patternflow/src/core_show.h @@ -0,0 +1,392 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - on-device show player (PFST v1 table) +// +// Director encodes the performance JSON into a packed little-endian file +// (.pfs). This walks that table on Play Now — no nested JSON parser. +// MQTT is not required; pattern/param/message reuse the existing apply +// helpers so absolute-ready modules behave as they do under Director. +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include +#include +#include "../pattern_registry.h" +#include "core_mqtt.h" + +namespace PatternflowShow { + +constexpr const char* SHOW_DIR = "/shows"; +constexpr uint8_t VERSION = 1; +constexpr uint16_t HEADER_BYTES = 76; +constexpr uint16_t CUE_BYTES = 16; +constexpr uint16_t MAX_CUES = 256; +constexpr uint16_t MAX_POOL = 4096; +constexpr uint16_t OFF_NONE = 0xFFFF; +constexpr uint8_t FLAG_PATTERN = 1; +constexpr uint8_t FLAG_PARAM1 = 2; +constexpr uint8_t FLAG_PARAM2 = 4; +constexpr uint8_t FLAG_PARAM3 = 8; +constexpr uint8_t FLAG_PARAM4 = 16; +constexpr uint8_t FLAG_MESSAGE = 32; +constexpr uint8_t MAX_MISSING = 8; +constexpr size_t SLUG_BYTES = 40; + +struct ShowHeader { + char magic[4]; + uint8_t version; + uint8_t flags; + uint16_t length; + uint16_t cueCount; + uint16_t poolBytes; + char title[32]; + char id[32]; +} __attribute__((packed)); + +struct ShowCue { + uint16_t t; + uint8_t flags; + uint8_t reserved; + uint16_t patternOff; + uint16_t param[4]; + uint16_t messageOff; +} __attribute__((packed)); + +static_assert(sizeof(ShowHeader) == 76, "PFST header"); +static_assert(sizeof(ShowCue) == 16, "PFST cue"); + +inline ShowHeader header = {}; +inline uint8_t pool[MAX_POOL] = {}; +inline ShowCue cueTable[MAX_CUES] = {}; +inline uint16_t cueCount = 0; +inline uint16_t poolBytes = 0; +inline bool loaded = false; +inline bool playing = false; +inline bool loopFlag = false; +inline uint32_t startedAtMs = 0; +inline uint16_t lastElapsed = 0; +inline uint16_t nextCue = 0; +inline char loadedSlug[SLUG_BYTES] = {}; +inline char lastPattern[48] = {}; +inline int pendingPatternIdx = -1; +inline bool finishedPending = false; +inline uint8_t missingCount = 0; +inline char missingNames[MAX_MISSING][48] = {}; + +inline uint16_t rd16(const uint8_t* p) { + return (uint16_t)p[0] | ((uint16_t)p[1] << 8); +} + +inline int findPattern(const char* name) { + if (!name || !name[0] || !patterns) return -1; + for (int i = 0; i < NUM_PATTERNS; ++i) { + if (patterns[i].name && strcmp(patterns[i].name, name) == 0) return i; + } + for (int i = 0; i < NUM_PATTERNS; ++i) { + if (patterns[i].name && strcasecmp(patterns[i].name, name) == 0) return i; + } + for (int i = 0; i < NUM_PATTERNS; ++i) { + if (!patterns[i].modulePath) continue; + const char* file = strrchr(patterns[i].modulePath, '/'); + const char* base = file ? file + 1 : patterns[i].modulePath; + char slug[40]; + snprintf(slug, sizeof(slug), "%s", base); + char* dot = strrchr(slug, '.'); + if (dot) *dot = '\0'; + if (strcasecmp(slug, name) == 0) return i; + } + return -1; +} + +inline const char* poolString(uint16_t off) { + if (off == OFF_NONE || off >= poolBytes) return ""; + return (const char*)(pool + off); +} + +inline bool poolOkIn(const uint8_t* poolPtr, uint16_t nPool, uint16_t off) { + if (off == OFF_NONE) return true; + if (!poolPtr || off >= nPool) return false; + for (uint16_t i = off; i < nPool; i++) { + if (poolPtr[i] == 0) return true; + } + return false; +} + +inline bool poolOk(uint16_t off) { + return poolOkIn(pool, poolBytes, off); +} + +inline void noteMissing(const char* name) { + if (!name || !name[0] || missingCount >= MAX_MISSING) return; + for (uint8_t i = 0; i < missingCount; i++) { + if (strcasecmp(missingNames[i], name) == 0) return; + } + snprintf(missingNames[missingCount], sizeof(missingNames[0]), "%s", name); + missingCount++; +} + +inline void scanMissing() { + missingCount = 0; + for (uint16_t i = 0; i < cueCount; i++) { + if (!(cueTable[i].flags & FLAG_PATTERN)) continue; + const char* name = poolString(cueTable[i].patternOff); + if (!name[0]) continue; + if (findPattern(name) < 0) noteMissing(name); + } +} + +inline void queuePattern(const char* name) { + int idx = findPattern(name); + if (idx < 0) return; + pendingPatternIdx = idx; + if (name) snprintf(lastPattern, sizeof(lastPattern), "%s", name); +} + +inline void unload() { + playing = false; + loaded = false; + cueCount = 0; + poolBytes = 0; + nextCue = 0; + lastElapsed = 0; + loadedSlug[0] = '\0'; + lastPattern[0] = '\0'; + missingCount = 0; + memset(&header, 0, sizeof(header)); +} + +inline bool validateBuffer(const uint8_t* data, size_t len, const char** error) { + if (len < HEADER_BYTES) { + if (error) *error = "file too small"; + return false; + } + if (memcmp(data, "PFST", 4) != 0) { + if (error) *error = "not a PFST table"; + return false; + } + if (data[4] != VERSION) { + if (error) *error = "unsupported PFST version"; + return false; + } + uint16_t nCues = rd16(data + 8); + uint16_t nPool = rd16(data + 10); + if (nCues > MAX_CUES || nPool > MAX_POOL) { + if (error) *error = "table too large"; + return false; + } + size_t need = (size_t)HEADER_BYTES + nPool + (size_t)nCues * CUE_BYTES; + if (len < need) { + if (error) *error = "truncated table"; + return false; + } + const uint8_t* poolPtr = data + HEADER_BYTES; + const uint8_t* src = poolPtr + nPool; + for (uint16_t i = 0; i < nCues; i++) { + const uint8_t* c = src + (size_t)i * CUE_BYTES; + if (!poolOkIn(poolPtr, nPool, rd16(c + 4)) || + !poolOkIn(poolPtr, nPool, rd16(c + 14))) { + if (error) *error = "bad string offset"; + return false; + } + } + if (error) *error = ""; + return true; +} + +inline bool loadFile(const char* slug, const char** error) { + unload(); + if (!slug || !slug[0]) { + if (error) *error = "missing slug"; + return false; + } + char path[72]; + snprintf(path, sizeof(path), "%s/%s.pfs", SHOW_DIR, slug); + File file = FFat.open(path, "r"); + if (!file) { + if (error) *error = "not found"; + return false; + } + uint8_t hdr[HEADER_BYTES]; + if (file.read(hdr, HEADER_BYTES) != HEADER_BYTES) { + file.close(); + if (error) *error = "truncated header"; + return false; + } + memcpy(&header, hdr, HEADER_BYTES); + if (memcmp(header.magic, "PFST", 4) != 0) { + file.close(); + if (error) *error = "not a PFST table"; + return false; + } + if (header.version != VERSION) { + file.close(); + if (error) *error = "unsupported PFST version"; + return false; + } + if (header.cueCount > MAX_CUES || header.poolBytes > MAX_POOL) { + file.close(); + if (error) *error = "table too large"; + return false; + } + poolBytes = header.poolBytes; + if (poolBytes && file.read(pool, poolBytes) != poolBytes) { + file.close(); + unload(); + if (error) *error = "truncated pool"; + return false; + } + if (poolBytes < MAX_POOL) pool[poolBytes] = 0; + cueCount = header.cueCount; + uint8_t raw[CUE_BYTES]; + for (uint16_t i = 0; i < cueCount; i++) { + if (file.read(raw, CUE_BYTES) != CUE_BYTES) { + file.close(); + unload(); + if (error) *error = "truncated cues"; + return false; + } + cueTable[i].t = rd16(raw); + cueTable[i].flags = raw[2]; + cueTable[i].reserved = raw[3]; + cueTable[i].patternOff = rd16(raw + 4); + cueTable[i].param[0] = rd16(raw + 6); + cueTable[i].param[1] = rd16(raw + 8); + cueTable[i].param[2] = rd16(raw + 10); + cueTable[i].param[3] = rd16(raw + 12); + cueTable[i].messageOff = rd16(raw + 14); + if (!poolOk(cueTable[i].patternOff) || !poolOk(cueTable[i].messageOff)) { + file.close(); + unload(); + if (error) *error = "bad string offset"; + return false; + } + } + file.close(); + header.title[31] = '\0'; + header.id[31] = '\0'; + loopFlag = (header.flags & 1) != 0; + scanMissing(); + loaded = true; + snprintf(loadedSlug, sizeof(loadedSlug), "%s", slug); + if (error) *error = ""; + return true; +} + +inline void applyCue(const ShowCue& cue) { + if (cue.flags & FLAG_PATTERN) { + const char* name = poolString(cue.patternOff); + if (name[0]) { + int idx = findPattern(name); + if (idx < 0) { + noteMissing(name); + Serial.printf("[SHOW] pattern not on device: %s\n", name); + } else { + bool running = (activePatternIdx == idx); + if (patterns[idx].modulePath && PFModuleLoader::active == nullptr) { + running = false; + } + if (!running || strcmp(lastPattern, name) != 0) { + pendingPatternIdx = idx; + snprintf(lastPattern, sizeof(lastPattern), "%s", name); + } + } + } + } + for (int i = 0; i < 4; i++) { + if (cue.flags & (FLAG_PARAM1 << i)) { + PatternflowMqtt::applyRemoteParam(i, cue.param[i]); + } + } + if (cue.flags & FLAG_MESSAGE) { + PatternflowMqtt::applyHeldMessage(poolString(cue.messageOff)); + } +} + +inline void applyFromStart(uint16_t elapsed) { + nextCue = 0; + lastPattern[0] = '\0'; + while (nextCue < cueCount && cueTable[nextCue].t <= elapsed) { + applyCue(cueTable[nextCue]); + nextCue++; + } +} + +inline void stop() { + playing = false; +} + +inline bool playLoaded() { + if (!loaded || cueCount == 0) return false; + finishedPending = false; + playing = true; + startedAtMs = millis(); + lastElapsed = 0; + applyFromStart(0); + return true; +} + +inline bool playSlug(const char* slug, const char** error) { + stop(); + if (!loadFile(slug, error)) return false; + return playLoaded(); +} + +inline void tick() { + if (!playing || !loaded) return; + uint32_t elapsedMs = millis() - startedAtMs; + uint32_t lengthMs = (uint32_t)header.length * 1000UL; + if (header.length == 0) lengthMs = 1000; + uint16_t elapsed; + if (loopFlag && lengthMs) { + uint32_t mod = elapsedMs % lengthMs; + elapsed = (uint16_t)(mod / 1000UL); + if (elapsed < lastElapsed) applyFromStart(elapsed); + } else { + elapsed = (uint16_t)(elapsedMs / 1000UL); + if (elapsed >= header.length) { + while (nextCue < cueCount) { + applyCue(cueTable[nextCue]); + nextCue++; + } + lastElapsed = header.length; + finishedPending = true; + stop(); + return; + } + } + lastElapsed = elapsed; + while (nextCue < cueCount && cueTable[nextCue].t <= elapsed) { + applyCue(cueTable[nextCue]); + nextCue++; + } +} + +inline bool consumePatternIdx(int& outIdx) { + if (pendingPatternIdx < 0) return false; + outIdx = pendingPatternIdx; + pendingPatternIdx = -1; + return true; +} + +inline bool consumeFinished() { + bool v = finishedPending; + finishedPending = false; + return v; +} + +inline uint16_t playheadSeconds() { return lastElapsed; } +inline bool isPlaying() { return playing; } +inline bool isLoaded() { return loaded; } +inline bool loops() { return loopFlag; } +inline void setLoop(bool on) { loopFlag = on; } +inline const char* title() { return header.title; } +inline const char* id() { return header.id; } +inline const char* slug() { return loadedSlug; } +inline uint16_t length() { return header.length; } +inline uint16_t numCues() { return cueCount; } +inline uint8_t missing() { return missingCount; } +inline const char* missingAt(uint8_t i) { + return i < missingCount ? missingNames[i] : ""; +} + +} // namespace PatternflowShow diff --git a/firmware/patternflow/src/core_show_http.h b/firmware/patternflow/src/core_show_http.h new file mode 100644 index 0000000..1ea59bf --- /dev/null +++ b/firmware/patternflow/src/core_show_http.h @@ -0,0 +1,435 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - /show sequences (PFST tables on FatFS) +// +// GET /show +// GET /api/shows +// PUT /api/shows raw .pfs, filename in X-PF-Name +// POST /api/shows/control op=play|stop|loop slug= loop=0|1 +// POST /api/shows/schedule night/wake fields +// DELETE /api/shows?slug= +// +// This page is deliberately served WITHOUT pausing the running pattern — +// it is a player, not a library editor. +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include "../net_config.h" +#include "core_patterns_http.h" + +#ifndef PF_SHOW_HTTP_ENABLED +#define PF_SHOW_HTTP_ENABLED 1 +#endif + +#if PF_SHOW_HTTP_ENABLED && PF_PATTERNS_HTTP_ENABLED +#include +#include +#include +#include "core_send.h" +#include "core_show.h" +#include "core_show_schedule.h" +#include "show_index.h" +#endif + +namespace PatternflowShowHttp { + +#if PF_SHOW_HTTP_ENABLED && PF_PATTERNS_HTTP_ENABLED + +inline WebServer& server() { return PatternflowPatternsHttp::server(); } +inline bool initialized = false; + +constexpr size_t PUT_MAX = + PatternflowShow::HEADER_BYTES + PatternflowShow::MAX_POOL + + (size_t)PatternflowShow::MAX_CUES * PatternflowShow::CUE_BYTES; + +inline uint8_t putBuf[PUT_MAX]; +inline size_t putLen = 0; +inline bool putFailed = false; +inline char putError[80] = {}; +inline char putSlug[PatternflowShow::SLUG_BYTES] = {}; + +inline void sendJson(int code, const String& body) { + server().sendHeader("Cache-Control", "no-store"); + server().send(code, "application/json", body); +} + +inline bool slugFromName(const String& filename, char* slug, size_t slugSize) { + int slash = filename.lastIndexOf('/'); + int backslash = filename.lastIndexOf('\\'); + int cut = slash > backslash ? slash : backslash; + String base = cut >= 0 ? filename.substring(cut + 1) : filename; + int dot = base.lastIndexOf('.'); + if (dot > 0) base = base.substring(0, dot); + size_t n = 0; + for (size_t i = 0; i < base.length() && n + 1 < slugSize; i++) { + char c = base[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (ok) slug[n++] = c; + } + slug[n] = '\0'; + return n > 0; +} + +inline void jsonEscape(String& out, const char* in) { + for (const char* p = in ? in : ""; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + if ((uint8_t)*p >= 32) out += *p; + } +} + +inline bool ensureDir() { + if (!FFat.exists(PatternflowShow::SHOW_DIR) && + !FFat.mkdir(PatternflowShow::SHOW_DIR)) { + return false; + } + return true; +} + +inline void appendStatus(String& json) { + json += "\"playing\":"; + json += PatternflowShow::isPlaying() ? "true" : "false"; + json += ",\"loaded\":"; + json += PatternflowShow::isLoaded() ? "true" : "false"; + json += ",\"loop\":"; + json += PatternflowShow::loops() ? "true" : "false"; + json += ",\"t\":"; + json += PatternflowShow::playheadSeconds(); + json += ",\"length\":"; + json += PatternflowShow::length(); + json += ",\"cues\":"; + json += PatternflowShow::numCues(); + json += ",\"slug\":\""; + jsonEscape(json, PatternflowShow::slug()); + json += "\",\"title\":\""; + jsonEscape(json, PatternflowShow::title()); + json += "\",\"missing\":["; + for (uint8_t i = 0; i < PatternflowShow::missing(); i++) { + if (i) json += ','; + json += '"'; + jsonEscape(json, PatternflowShow::missingAt(i)); + json += '"'; + } + json += "]"; + json += ",\"schedEnabled\":"; + json += PatternflowShowSchedule::enabled ? "true" : "false"; + json += ",\"nightAt\":\""; + { + char hm[8]; + PatternflowShowSchedule::formatHm(hm, sizeof(hm), + PatternflowShowSchedule::nightHour, + PatternflowShowSchedule::nightMin); + json += hm; + } + json += "\",\"wakeAt\":\""; + { + char hm[8]; + PatternflowShowSchedule::formatHm(hm, sizeof(hm), + PatternflowShowSchedule::wakeHour, + PatternflowShowSchedule::wakeMin); + json += hm; + } + json += "\",\"wakeSlug\":\""; + jsonEscape(json, PatternflowShowSchedule::wakeSlug); + json += "\",\"repeat\":"; + json += PatternflowShowSchedule::repeatUntilInteract ? "true" : "false"; + json += ",\"nightClock\":"; + json += PatternflowShowSchedule::nightClock ? "true" : "false"; + json += ",\"nightDim\":"; + json += (int)PatternflowShowSchedule::nightDimPct; + json += ",\"tzMin\":"; + json += (int)PatternflowClock::timezoneOffsetMin(); + json += ",\"phase\":\""; + json += PatternflowShowSchedule::phaseName(); + json += "\",\"timeSynced\":"; + json += PatternflowClock::timeSynced() ? "true" : "false"; + json += ",\"localTime\":\""; + if (PatternflowClock::timeSynced()) { + char tbuf[12]; + snprintf(tbuf, sizeof(tbuf), "%02d:%02d:%02d", + PatternflowClock::localHour(), PatternflowClock::localMinute(), + PatternflowClock::localSecond()); + json += tbuf; + } + json += "\",\"snoozeMs\":"; + json += PatternflowShowSchedule::snoozeRemainingMs(); +} + +inline void handleIndex() { + PFSend::progmem(server(), SHOW_INDEX_HTML); +} + +inline bool isPfsPath(const char* path) { + if (!path || !path[0]) return false; + size_t n = strlen(path); + if (n < 4) return false; + const char* ext = path + n - 4; + return (ext[0] == '.') && + (ext[1] == 'p' || ext[1] == 'P') && + (ext[2] == 'f' || ext[2] == 'F') && + (ext[3] == 's' || ext[3] == 'S'); +} + +inline void handleList() { + String json; + json.reserve(2048); + json = "{"; + appendStatus(json); + json += ",\"shows\":["; + bool first = true; + // Same listing contract as scanModules(): path() + close the iterator, + // then reopen. Reading the PFST header from the directory File skips + // every entry on this FatFS (openNextFile is a name, not a readable + // stream), which is why uploads said "stored" and the list stayed empty. + File dir = FFat.open(PatternflowShow::SHOW_DIR); + if (dir && dir.isDirectory()) { + File entry = dir.openNextFile(); + while (entry) { + const char* path = entry.path(); + char stored[72] = {}; + bool take = path && !entry.isDirectory() && isPfsPath(path); + if (take) snprintf(stored, sizeof(stored), "%s", path); + entry.close(); + if (take) { + char slug[PatternflowShow::SLUG_BYTES]; + if (slugFromName(stored, slug, sizeof(slug))) { + PatternflowShow::ShowHeader hdr = {}; + bool haveHdr = false; + File file = FFat.open(stored, "r"); + if (file) { + haveHdr = file.read((uint8_t*)&hdr, sizeof(hdr)) == sizeof(hdr) && + memcmp(hdr.magic, "PFST", 4) == 0; + hdr.title[31] = '\0'; + hdr.id[31] = '\0'; + file.close(); + } + if (!first) json += ','; + first = false; + json += "{\"slug\":\""; + jsonEscape(json, slug); + json += "\",\"title\":\""; + jsonEscape(json, haveHdr && hdr.title[0] ? hdr.title : slug); + json += "\",\"length\":"; + json += haveHdr ? hdr.length : 0; + json += ",\"cues\":"; + json += haveHdr ? hdr.cueCount : 0; + json += ",\"loop\":"; + json += (haveHdr && (hdr.flags & 1)) ? "true" : "false"; + json += '}'; + } + } + entry = dir.openNextFile(); + } + } + if (dir) dir.close(); + json += "]}"; + sendJson(200, json); +} + +inline void handleControl() { + String op = server().hasArg("op") ? server().arg("op") : String(); + op.toLowerCase(); + if (op == "stop") { + PatternflowShowSchedule::noteInteraction(); + PatternflowShow::stop(); + String json = "{\"ok\":true,"; + appendStatus(json); + json += '}'; + sendJson(200, json); + return; + } + if (op == "loop") { + PatternflowShow::setLoop(server().arg("loop") == "1"); + String json = "{\"ok\":true,"; + appendStatus(json); + json += '}'; + sendJson(200, json); + return; + } + if (op == "play") { + PatternflowShowSchedule::noteInteraction(); + PatternflowPatternsHttp::releaseConsolePause(); + char slug[PatternflowShow::SLUG_BYTES]; + if (!slugFromName(server().arg("slug"), slug, sizeof(slug))) { + sendJson(400, "{\"ok\":false,\"error\":\"missing slug\"}"); + return; + } + if (server().hasArg("loop")) { + PatternflowShow::setLoop(server().arg("loop") == "1"); + } + const char* err = ""; + bool ok = PatternflowShow::playSlug(slug, &err); + if (ok && server().hasArg("loop")) { + PatternflowShow::setLoop(server().arg("loop") == "1"); + } + if (!ok) { + String body = "{\"ok\":false,\"error\":\""; + jsonEscape(body, err && err[0] ? err : "play failed"); + body += "\"}"; + sendJson(400, body); + return; + } + String json = "{\"ok\":true,"; + appendStatus(json); + json += '}'; + sendJson(200, json); + return; + } + sendJson(400, "{\"ok\":false,\"error\":\"op must be play, stop or loop\"}"); +} + +inline void handleSchedule() { + bool en = server().arg("enabled") == "1"; + bool repeat = server().arg("repeat") == "1"; + bool clock = server().arg("nightClock") == "1"; + int dim = server().hasArg("nightDim") ? server().arg("nightDim").toInt() : 15; + String nightAt = server().hasArg("night") ? server().arg("night") : ""; + String wakeAt = server().hasArg("wake") ? server().arg("wake") : ""; + String slug = server().hasArg("slug") ? server().arg("slug") : ""; + // Timezone rides the same form: without Weather ported there is no other + // page that owns it, and the alarm is the one feature that needs it. + if (server().hasArg("tz")) { + PatternflowClock::setTimezoneOffsetMin(server().arg("tz").toInt()); + } + if (!PatternflowShowSchedule::applyConfig(en, nightAt.c_str(), wakeAt.c_str(), + slug.c_str(), repeat, clock, dim)) { + sendJson(400, "{\"ok\":false,\"error\":\"invalid time\"}"); + return; + } + String json = "{\"ok\":true,"; + appendStatus(json); + json += '}'; + sendJson(200, json); +} + +inline void handleDelete() { + char slug[PatternflowShow::SLUG_BYTES]; + if (!slugFromName(server().arg("slug"), slug, sizeof(slug))) { + sendJson(400, "{\"ok\":false,\"error\":\"missing slug\"}"); + return; + } + if (PatternflowShow::isPlaying() && + strcasecmp(PatternflowShow::slug(), slug) == 0) { + PatternflowShowSchedule::noteInteraction(); + PatternflowShow::stop(); + } + char path[72]; + snprintf(path, sizeof(path), "%s/%s.pfs", PatternflowShow::SHOW_DIR, slug); + if (!FFat.exists(path)) { + sendJson(404, "{\"ok\":false,\"error\":\"not found\"}"); + return; + } + FFat.remove(path); + if (PatternflowShow::isLoaded() && + strcasecmp(PatternflowShow::slug(), slug) == 0) { + PatternflowShow::unload(); + } + sendJson(200, "{\"ok\":true}"); +} + +inline void handlePutBody() { + HTTPRaw& raw = server().raw(); + if (raw.status == RAW_START) { + putFailed = false; + putError[0] = '\0'; + putLen = 0; + putSlug[0] = '\0'; + String name = server().header("X-PF-Name"); + String lowered = name; + lowered.toLowerCase(); + if (!lowered.endsWith(".pfs")) { + putFailed = true; + snprintf(putError, sizeof(putError), "only .pfs tables accepted"); + return; + } + if (!slugFromName(name, putSlug, sizeof(putSlug))) { + putFailed = true; + snprintf(putError, sizeof(putError), "invalid X-PF-Name"); + return; + } + return; + } + if (putFailed) return; + if (raw.status == RAW_WRITE) { + if (putLen + raw.currentSize > PUT_MAX) { + putFailed = true; + snprintf(putError, sizeof(putError), "file too large"); + return; + } + memcpy(putBuf + putLen, raw.buf, raw.currentSize); + putLen += raw.currentSize; + return; + } + if (raw.status == RAW_END || raw.status == RAW_ABORTED) { + if (raw.status == RAW_ABORTED) { + putFailed = true; + snprintf(putError, sizeof(putError), "upload aborted"); + } + } +} + +inline void handlePutDone() { + if (putFailed) { + String body = "{\"ok\":false,\"error\":\""; + jsonEscape(body, putError); + body += "\"}"; + sendJson(400, body); + return; + } + const char* err = ""; + if (!PatternflowShow::validateBuffer(putBuf, putLen, &err)) { + String body = "{\"ok\":false,\"error\":\""; + jsonEscape(body, err && err[0] ? err : "invalid table"); + body += "\"}"; + sendJson(400, body); + return; + } + if (!ensureDir()) { + sendJson(500, "{\"ok\":false,\"error\":\"cannot create /shows\"}"); + return; + } + char path[72]; + snprintf(path, sizeof(path), "%s/%s.pfs", PatternflowShow::SHOW_DIR, putSlug); + File file = FFat.open(path, FILE_WRITE); + if (!file) { + sendJson(500, "{\"ok\":false,\"error\":\"cannot write\"}"); + return; + } + size_t wrote = file.write(putBuf, putLen); + file.close(); + if (wrote != putLen) { + FFat.remove(path); + sendJson(500, "{\"ok\":false,\"error\":\"short write\"}"); + return; + } + String json = "{\"ok\":true,\"slug\":\""; + jsonEscape(json, putSlug); + json += "\",\"bytes\":"; + json += (uint32_t)putLen; + json += '}'; + sendJson(200, json); +} + +inline void begin() { + if (initialized) return; + if (WiFi.status() != WL_CONNECTED) return; + + server().on("/show", HTTP_GET, handleIndex); + server().on("/api/shows", HTTP_GET, handleList); + server().on("/api/shows", HTTP_PUT, handlePutDone, handlePutBody); + server().on("/api/shows", HTTP_DELETE, handleDelete); + server().on("/api/shows/control", HTTP_POST, handleControl); + server().on("/api/shows/schedule", HTTP_POST, handleSchedule); + + initialized = true; + Serial.printf("[SHOW] sequences http://%s/show\n", + WiFi.localIP().toString().c_str()); +} + +#else + +inline void begin() {} + +#endif + +} // namespace PatternflowShowHttp diff --git a/firmware/patternflow/src/core_show_schedule.h b/firmware/patternflow/src/core_show_schedule.h new file mode 100644 index 0000000..adef1c4 --- /dev/null +++ b/firmware/patternflow/src/core_show_schedule.h @@ -0,0 +1,288 @@ +// ═══════════════════════════════════════════════════════════ +// PatternFlow - night / wake schedule for the on-device player +// +// Local wall clock (Weather NTP + timezone). Night start → Black; +// wake time (end of night) → play a .pfs once (loop forced off); +// natural end → Black + large clock; optional 5-minute repeat until +// a physical knob/click, Sequences Stop, or SELECT. Does not fire +// when NTP is unsynced. +// License: MIT +// ═══════════════════════════════════════════════════════════ +#pragma once + +#include +#include +#include +#include + +#include "core_mqtt.h" +#include "core_show.h" +#include "core_ui_text.h" +#include "core_clock.h" +#include "../pattern_registry.h" + +namespace PatternflowShowSchedule { + +constexpr uint32_t SNOOZE_MS = 5UL * 60UL * 1000UL; +constexpr uint8_t DIM_DEFAULT = 15; +constexpr size_t SLUG_BYTES = PatternflowShow::SLUG_BYTES; + +enum Phase : uint8_t { Idle = 0, Night = 1, Wake = 2, Snooze = 3 }; + +inline bool enabled = false; +inline uint8_t nightHour = 23; +inline uint8_t nightMin = 0; +inline uint8_t wakeHour = 7; +inline uint8_t wakeMin = 0; +inline char wakeSlug[SLUG_BYTES] = {}; +inline bool repeatUntilInteract = true; +inline bool nightClock = true; +inline uint8_t nightDimPct = DIM_DEFAULT; + +inline Phase phase = Idle; +inline int lastWakeDay = -1; +inline uint32_t snoozeAtMs = 0; + +inline int nightMinutes() { return (int)nightHour * 60 + (int)nightMin; } +inline int wakeMinutes() { return (int)wakeHour * 60 + (int)wakeMin; } + +inline bool inNightWindow(int nowM) { + int a = nightMinutes(); + int b = wakeMinutes(); + if (a == b) return false; + if (a < b) return nowM >= a && nowM < b; + return nowM >= a || nowM < b; +} + +inline const char* phaseName() { + switch (phase) { + case Night: return "night"; + case Wake: return "wake"; + case Snooze: return "snooze"; + default: return "idle"; + } +} + +inline void setFace(Black::Face f) { Black::face = f; } + +inline void goBlack(Black::Face f) { + setFace(f); + PatternflowShow::queuePattern("Black"); +} + +inline void enterNight() { + PatternflowShow::stop(); + PatternflowMqtt::applyHeldMessage(""); + phase = Night; + goBlack(nightClock ? Black::Dim : Black::Off); +} + +inline void enterSnooze() { + PatternflowShow::stop(); + PatternflowMqtt::applyHeldMessage(""); + phase = Snooze; + snoozeAtMs = millis(); + goBlack(Black::Big); +} + +inline void startWake() { + lastWakeDay = PatternflowClock::localDayKey(); + setFace(Black::Off); + phase = Wake; + PatternflowMqtt::applyHeldMessage(""); + if (wakeSlug[0]) { + const char* err = ""; + if (PatternflowShow::playSlug(wakeSlug, &err)) { + PatternflowShow::setLoop(false); + return; + } + } + enterSnooze(); +} + +inline void noteInteraction() { + if (phase != Wake && phase != Snooze) return; + bool wasSnooze = (phase == Snooze); + PatternflowShow::stop(); + PatternflowShow::consumeFinished(); + setFace(Black::Off); + phase = Idle; + if (wasSnooze) PatternflowShow::queuePattern("Origin"); +} + +inline bool inAlarmCycle() { return phase == Wake || phase == Snooze; } + +inline void formatHm(char* out, size_t n, uint8_t h, uint8_t m) { + snprintf(out, n, "%02u:%02u", (unsigned)h, (unsigned)m); +} + +inline bool parseHm(const char* s, uint8_t& h, uint8_t& m) { + if (!s || !s[0]) return false; + int hh = 0, mm = 0; + if (sscanf(s, "%d:%d", &hh, &mm) != 2) return false; + if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return false; + h = (uint8_t)hh; + m = (uint8_t)mm; + return true; +} + +inline void save() { + Preferences prefs; + if (!prefs.begin("pfshow", false)) return; + prefs.putBool("en", enabled); + prefs.putUShort("nstart", (uint16_t)nightMinutes()); + prefs.putUShort("wake", (uint16_t)wakeMinutes()); + prefs.putString("slug", wakeSlug); + prefs.putBool("rpt", repeatUntilInteract); + prefs.putBool("nclk", nightClock); + prefs.putUChar("ndim", nightDimPct); + prefs.end(); +} + +inline void load() { + Preferences prefs; + if (!prefs.begin("pfshow", true)) return; + enabled = prefs.getBool("en", false); + uint16_t ns = prefs.getUShort("nstart", 23 * 60); + uint16_t wk = prefs.getUShort("wake", 7 * 60); + if (ns > 23 * 60 + 59) ns = 23 * 60; + if (wk > 23 * 60 + 59) wk = 7 * 60; + nightHour = (uint8_t)(ns / 60); + nightMin = (uint8_t)(ns % 60); + wakeHour = (uint8_t)(wk / 60); + wakeMin = (uint8_t)(wk % 60); + if (prefs.isKey("slug")) prefs.getString("slug", wakeSlug, sizeof(wakeSlug)); + repeatUntilInteract = prefs.getBool("rpt", true); + nightClock = prefs.getBool("nclk", true); + nightDimPct = prefs.getUChar("ndim", DIM_DEFAULT); + if (nightDimPct < 5) nightDimPct = 5; + if (nightDimPct > 40) nightDimPct = 40; + prefs.end(); +} + +inline void begin() { load(); } + +inline bool applyConfig(bool en, const char* nightAt, const char* wakeAt, + const char* slug, bool repeat, bool clock, + int dimPct) { + uint8_t nh = nightHour, nm = nightMin, wh = wakeHour, wm = wakeMin; + if (nightAt && nightAt[0] && !parseHm(nightAt, nh, nm)) return false; + if (wakeAt && wakeAt[0] && !parseHm(wakeAt, wh, wm)) return false; + enabled = en; + nightHour = nh; + nightMin = nm; + wakeHour = wh; + wakeMin = wm; + if (slug) { + size_t n = 0; + for (const char* p = slug; *p && n + 1 < sizeof(wakeSlug); ++p) { + char c = *p; + bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (ok) wakeSlug[n++] = c; + } + wakeSlug[n] = '\0'; + } + repeatUntilInteract = repeat; + nightClock = clock; + if (dimPct < 5) dimPct = 5; + if (dimPct > 40) dimPct = 40; + nightDimPct = (uint8_t)dimPct; + save(); + if (!enabled) { + PatternflowShow::consumeFinished(); + if (phase == Wake) PatternflowShow::stop(); + setFace(Black::Off); + phase = Idle; + } + return true; +} + +inline void tick(const char* currentName, bool running) { + bool finished = PatternflowShow::consumeFinished(); + if (!enabled) return; + + if (finished && phase == Wake) { + enterSnooze(); + } else if (finished && phase == Night) { + goBlack(nightClock ? Black::Dim : Black::Off); + } + + if (phase == Wake) PatternflowShow::setLoop(false); + + if (!PatternflowClock::timeSynced()) return; + + int nowM = PatternflowClock::localMinutes(); + int day = PatternflowClock::localDayKey(); + bool night = inNightWindow(nowM); + bool playing = PatternflowShow::isPlaying(); + + if (night) { + if (phase == Idle || phase == Snooze) enterNight(); + } else if (phase == Night) { + startWake(); + playing = PatternflowShow::isPlaying(); + } else if (phase == Idle && nowM == wakeMinutes() && day != lastWakeDay) { + startWake(); + playing = PatternflowShow::isPlaying(); + } + + if (phase == Snooze && repeatUntilInteract && + (int32_t)(millis() - snoozeAtMs) >= (int32_t)SNOOZE_MS) { + startWake(); + playing = PatternflowShow::isPlaying(); + } + + if (!running || playing) return; + bool isBlack = currentName && strcmp(currentName, "Black") == 0; + if (phase == Night) { + setFace(nightClock ? Black::Dim : Black::Off); + if (!isBlack) goBlack(nightClock ? Black::Dim : Black::Off); + } else if (phase == Snooze) { + setFace(Black::Big); + if (!isBlack) goBlack(Black::Big); + } +} + +inline void drawOwnedClock() { + if (Black::face == Black::Off) return; + if (!PatternflowClock::timeSynced()) return; + if (!dma_display) return; + + char buf[12]; + snprintf(buf, sizeof(buf), "%02d:%02d:%02d", + PatternflowClock::localHour(), PatternflowClock::localMinute(), + PatternflowClock::localSecond()); + + uint8_t level = 245; + if (Black::face == Black::Dim) { + uint16_t v = (uint16_t)255 * nightDimPct / 100; + if (v < 8) v = 8; + if (v > 255) v = 255; + level = (uint8_t)v; + } + const uint16_t ink = dma_display->color565(level, level, level); + + // Fork draws this with its TomThumb title font; that rework is not ported + // (needs eyes on hardware), so use the stock GFX font — size 2 for the + // big face, 1 for the dim night clock. + int16_t x1, y1; + uint16_t w, h; + dma_display->setTextSize(Black::face == Black::Big ? 2 : 1); + dma_display->getTextBounds(buf, 0, 0, &x1, &y1, &w, &h); + int x = (dma_display->width() - (int)w) / 2; + int y = (dma_display->height() - (int)h) / 2; + dma_display->setTextColor(ink); + dma_display->setCursor(x - x1, y - y1); + dma_display->print(buf); + dma_display->setTextSize(1); +} + +inline uint32_t snoozeRemainingMs() { + if (phase != Snooze || !repeatUntilInteract) return 0; + int32_t left = (int32_t)SNOOZE_MS - (int32_t)(millis() - snoozeAtMs); + if (left < 0) return 0; + return (uint32_t)left; +} + +} // namespace PatternflowShowSchedule diff --git a/firmware/patternflow/src/mqtt_index.h b/firmware/patternflow/src/mqtt_index.h index 3713f79..2fffca4 100644 --- a/firmware/patternflow/src/mqtt_index.h +++ b/firmware/patternflow/src/mqtt_index.h @@ -86,7 +86,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} #rolebox.hidden{display:none}

MQTT

-
- +

Mode

diff --git a/firmware/patternflow/src/patterns_index.h b/firmware/patternflow/src/patterns_index.h index 8d161c5..d1aed24 100644 --- a/firmware/patternflow/src/patterns_index.h +++ b/firmware/patternflow/src/patterns_index.h @@ -131,7 +131,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em} .colR section{margin-top:16px}}

Patterns

-
- +
@@ -139,7 +139,7 @@ font-family:var(--mono);font-size:11px;letter-spacing:.04em}

Upload

@@ -560,7 +560,7 @@ function runQ(i){ var it=items[i]; var isLast=true; for(var j=i+1;j + +static const char SHOW_INDEX_HTML[] PROGMEM = R"HTML( + + +Patternflow - Sequences +
+

Sequences

-
+ + +
+

Now

+
0:00
+
idle
+
+ + +
+
+ +
+

Night & wake

+
clock not synced
+
+ +
+
+
+
+
+
+ + +
+ +
+
+ +
+
+ +
+
+
+

End of night is the wake time. After the sequence ends, Black shows a large clock; night Black can keep a dim clock (default 15%). Time comes from NTP once Wi-Fi is up — set the UTC offset in minutes (Seoul +540, Rome +60/+120 DST); the alarm does not fire until the clock is synced. A knob turn, click, Sequences Stop, or SELECT dismisses the cycle (not during night itself).

+
+ +
+

Upload

+ +

Director File tab → Save device table. Patterns used in the timeline must already be installed. This page does not pause the panel.

+
+
+ +
+

On device

+
    +
    + +
    Play Now walks the packed cue table. MQTT is not required.
    +
    +)HTML"; From 7ebd76f26e79c31f6df99300a13fbb4a11a9a688 Mon Sep 17 00:00:00 2001 From: engmung <122682380+engmung@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:25:17 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat(web):=20a=20deck=20can=20carry=20its?= =?UTF-8?q?=20performance=20=E2=80=94=20JSON=20in,=20.pfs=20out,=20zip=20d?= =?UTF-8?q?ecorated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deck owner attaches a Director performance (paste or drop the Save-JSON file); the server validates it against the same limits the device's player enforces and stores it canonically. The pack zip is decorated at serve time — performance.json for re-editing, the encoded .pfs for the panel — so attaching or editing one updates downloads instantly and never queues a compile, and a performance that stops encoding degrades to the plain pack rather than taking it down. The TS PFST encoder is a port of the Director's show-table.js, verified byte-identical against all four of its demo .pfs saves; performance-smoke pins the byte layout from here on. Pack manifests now report the highest descriptor ABI in the pack (2 since the absolute bus) instead of a hardcoded 1 — the Basics manifest lost its publisher field in the earlier rebuild and gets it back here. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 14 + firmware/toolchain/make_pack.py | 4 +- web/drizzle/0020_deck-performance.sql | 1 + web/drizzle/meta/_journal.json | 297 ++++++++-------- web/package.json | 3 +- web/public/packs/basics.json | 5 +- web/public/packs/basics.zip | Bin 126012 -> 126025 bytes web/scripts/performance-smoke.ts | 93 +++++ web/src/app/api/community/decks/[id]/route.ts | 24 ++ .../app/api/community/decks/[id]/zip/route.ts | 34 +- .../app/community/d/[id]/DeckDetailClient.tsx | 160 +++++++++ web/src/app/community/d/[id]/page.tsx | 1 + web/src/lib/community/performance.ts | 324 ++++++++++++++++++ web/src/lib/community/queries.ts | 1 + web/src/lib/community/schema.ts | 9 + 15 files changed, 819 insertions(+), 151 deletions(-) create mode 100644 web/drizzle/0020_deck-performance.sql create mode 100644 web/scripts/performance-smoke.ts create mode 100644 web/src/lib/community/performance.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1d7c9..faf7328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to Patternflow will be documented in this file. ## [Unreleased] +Timed performances land, from **Simone Majocchi's ([@SimonePDA](https://github.com/SimonePDA)) performance-director branch**: a deterministic absolute-parameter bus, an on-device show player, and packs that carry a performance with them. + +### Added +- **Absolute parameter bus (0..1000).** MQTT `​/param/1..4` can pin any knob of an absolute-ready pattern to an exact value — a timed show or the Director tool reproduces a look instead of nudging deltas and hoping. Physical encoder motion releases the hold (hands outrank the timeline), audio/weather sit between, and plain deltas keep working exactly as before. Patterns opt in through one `PFParams::apply` line per knob; every preset, the Basics pack, and all convertible community headers are already converted, and everything the site generates from now on is absolute-ready from birth. +- **MQTT channels + snapshots.** `/mqtt` grew channel presets — Broadcast, Ch 1–4 (subscriber-only, for timed shows), Live, Custom — and a retained per-channel `snapshot` so a panel joining mid-show lands on the current look instead of silence. A **Director mode** points the panel at a local authoring broker (PC LAN IP, no password) without disturbing the saved community broker. +- **On-device show player** (`/show`, "Sequences"). The Director saves a performance as a packed `.pfs` cue table; the panel stores those on its filesystem and plays them locally — pattern switches, absolute params, banners — with **no broker required**. Includes a night/wake schedule: panel goes dark (optional dim clock) at a set hour and plays a chosen sequence at wake, dismissed by any knob or click. Time comes from NTP with the UTC offset set on the same page. +- **Packs carry performances.** A deck owner can attach a Director performance JSON to their deck; the pack zip then includes `performance.json` (the editable source) and the encoded `.pfs`, and the device's `/patterns` unpack installs the `.pfs` to Sequences alongside the modules — nothing to pick, nothing extra to click. The server-side PFST encoder is byte-identical to the Director's own saves. +- **Download ZIP from the device.** `/patterns` multi-select → Download ZIP packs installed modules (+ sidecars) in the browser, so "I designed on this panel, share the set" needs no PC library. + +### Changed +- **Module ABI descriptor is now 2** (the appended absolute-param fields). The loader accepts 1 and 2, so every existing `.pfm` keeps loading; **pre-absolute firmware refuses new modules cleanly** instead of misreading them — update firmware to install newly built packs. +- **MQTT retention now follows the show policy**: knob/pattern topics publish non-retained (no stale look pushed at a fresh subscriber); the broadcast banner and channel snapshots are the retained exceptions. The old "second panel catches up from retained knobs" behaviour is replaced by snapshots on channels. +- Black (panel dark, still powered) joins Origin as a compiled-in preset — it is the scheduler's night face. + ## [3.4.0] - 2026-08-12 Patterns leave the firmware and live on the filesystem, so a board ships nearly empty — and a pack ships with it. **Hardware unchanged**; v3.0 board and case carry over as-is. diff --git a/firmware/toolchain/make_pack.py b/firmware/toolchain/make_pack.py index 6cdedd5..54e684d 100644 --- a/firmware/toolchain/make_pack.py +++ b/firmware/toolchain/make_pack.py @@ -193,7 +193,9 @@ def main() -> None: "patterns": len(slugs), "authors": authors, "licenses": licences, - "abi": 1, + # The highest descriptor version in the pack — what a firmware must + # accept to load everything here (2 since the absolute-param bus). + "abi": max((int(m[2].get("abi", 1)) for m in modules), default=1), "panel": {"w": 128, "h": 64}, "order": slugs, # slug -> pattern number, for the modules that came from a repo preset. diff --git a/web/drizzle/0020_deck-performance.sql b/web/drizzle/0020_deck-performance.sql new file mode 100644 index 0000000..6e4a9ef --- /dev/null +++ b/web/drizzle/0020_deck-performance.sql @@ -0,0 +1 @@ +ALTER TABLE `decks` ADD `performance_json` text; diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index 167e12c..f82d481 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -1,146 +1,153 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1784685898155, - "tag": "0000_community-init", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1784708031751, - "tag": "0001_add-likes-and-cpp", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1784710683324, - "tag": "0002_add-made-on", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1784949672023, - "tag": "0003_add-board", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1784987368285, - "tag": "0004_add-builds", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1785135019087, - "tag": "0005_add-build-format", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1785317646720, - "tag": "0006_add-reports", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1785320716351, - "tag": "0007_add-made-how", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1785321984220, - "tag": "0008_add-comment-edited", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1785535849348, - "tag": "0009_add-visibility-and-decks", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1785537782506, - "tag": "0010_add-notifications", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1785648191733, - "tag": "0011_add-pattern-ports", - "breakpoints": true - }, - { - "idx": 12, - "version": "6", - "when": 1785649158014, - "tag": "0012_add-post-notice", - "breakpoints": true - }, - { - "idx": 13, - "version": "6", - "when": 1785840098851, - "tag": "0013_add-map", - "breakpoints": true - }, - { - "idx": 14, - "version": "6", - "when": 1785878219325, - "tag": "0014_merge-unlisted", - "breakpoints": true - }, - { - "idx": 15, - "version": "6", - "when": 1785878534343, - "tag": "0015_add-featured", - "breakpoints": true - }, - { - "idx": 16, - "version": "6", - "when": 1786338000000, - "tag": "0016_add-presence", - "breakpoints": true - }, - { - "idx": 17, - "version": "6", - "when": 1786830000000, - "tag": "0017_add-atlas-pins", - "breakpoints": true - }, - { - "idx": 18, - "version": "6", - "when": 1786833000000, - "tag": "0018_atlas-pin-entry", - "breakpoints": true - }, - { - "idx": 19, - "version": "6", - "when": 1786836000000, - "tag": "0019_deck-zip", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784685898155, + "tag": "0000_community-init", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1784708031751, + "tag": "0001_add-likes-and-cpp", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1784710683324, + "tag": "0002_add-made-on", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1784949672023, + "tag": "0003_add-board", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1784987368285, + "tag": "0004_add-builds", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1785135019087, + "tag": "0005_add-build-format", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1785317646720, + "tag": "0006_add-reports", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1785320716351, + "tag": "0007_add-made-how", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1785321984220, + "tag": "0008_add-comment-edited", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1785535849348, + "tag": "0009_add-visibility-and-decks", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1785537782506, + "tag": "0010_add-notifications", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1785648191733, + "tag": "0011_add-pattern-ports", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1785649158014, + "tag": "0012_add-post-notice", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1785840098851, + "tag": "0013_add-map", + "breakpoints": true + }, + { + "idx": 14, + "version": "6", + "when": 1785878219325, + "tag": "0014_merge-unlisted", + "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1785878534343, + "tag": "0015_add-featured", + "breakpoints": true + }, + { + "idx": 16, + "version": "6", + "when": 1786338000000, + "tag": "0016_add-presence", + "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1786830000000, + "tag": "0017_add-atlas-pins", + "breakpoints": true + }, + { + "idx": 18, + "version": "6", + "when": 1786833000000, + "tag": "0018_atlas-pin-entry", + "breakpoints": true + }, + { + "idx": 19, + "version": "6", + "when": 1786836000000, + "tag": "0019_deck-zip", + "breakpoints": true + }, + { + "idx": 20, + "version": "6", + "when": 1786840000000, + "tag": "0020_deck-performance", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/web/package.json b/web/package.json index 56315a0..bdfc8ce 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "check:ports": "tsx scripts/ports-smoke.ts", "seed:map": "tsx scripts/seed-territories.ts", "check:workshop": "tsx scripts/workshop-smoke.ts", - "check:sandbox": "tsx scripts/sandbox-smoke.ts" + "check:sandbox": "tsx scripts/sandbox-smoke.ts", + "check:performance": "tsx scripts/performance-smoke.ts" }, "dependencies": { "@chenglou/pretext": "^0.0.6", diff --git a/web/public/packs/basics.json b/web/public/packs/basics.json index 3113684..36b3a0b 100644 --- a/web/public/packs/basics.json +++ b/web/public/packs/basics.json @@ -1,5 +1,6 @@ { "name": "Basics", + "publisher": "Patternflow", "patterns": 33, "authors": [ "Seunghun LEE" @@ -7,7 +8,7 @@ "licenses": [ "CC-BY-SA-4.0" ], - "abi": 1, + "abi": 2, "panel": { "w": 128, "h": 64 @@ -82,5 +83,5 @@ "magvortex": 719.0, "a_big_hit": 9999.0 }, - "bytes": 126012 + "bytes": 126025 } diff --git a/web/public/packs/basics.zip b/web/public/packs/basics.zip index ddc7405e6e0ac1c08d044aa130893bab6f7fe6ff..f52bc4c6edc1f9dbbd95cd9a00bd5ffc74e5309a 100644 GIT binary patch delta 339 zcmV-Z0j&PK*aykj2Y|EzZrgwOSNuK?0RRA<0RR9B0000|ML|SOMJ{xBbVZOcZvrt4 zh4=glZ(X>f5}j@X#Kv$ET^Ms0A4y5$$WDaludhQ(J6UP zfe2!!&m>*qfnusNgUM%6N6kfJRT0pk*_ z_|?OaNVh3)A;W$Zm)rpW0017enN0uy delta 326 zcmV-M0lEIk*ay7W2Y|EzZrgvCv=o8@0RRAl0RR9B0000|ML|SOMJ{xBbVZP{Zo@DP zM0b4!w=S_$Af3D=LADGA(j}v8)3F+r6i5k1`t_yVrkw~KbjNpWeaHdV8au>np<@ZciQv6taRq5XfgTE-2*(*T2T zf;)dxb${r>fW97|p#D(PI@B!F8}}l diff --git a/web/scripts/performance-smoke.ts b/web/scripts/performance-smoke.ts new file mode 100644 index 0000000..ab9e3dd --- /dev/null +++ b/web/scripts/performance-smoke.ts @@ -0,0 +1,93 @@ +// Smoke test for the performance library: normalize/validate a Director-shaped +// JSON, encode PFST v1, and assert the byte layout the device's core_show.h +// reads. The encoder was verified byte-identical against the Director PWA's +// own .pfs saves (its four demos) when it landed; this keeps the structural +// contract pinned without needing those files. Run: +// npx tsx scripts/performance-smoke.ts + +import { + PFST_HEADER_BYTES, + PFST_CUE_BYTES, + encodePfst, + normalizePerformance, + pfsFilename, + validatePerformance, +} from "../src/lib/community/performance"; + +let failed = 0; +function check(label: string, ok: boolean) { + console.log(` ${ok ? "ok " : "FAIL"} ${label}`); + if (!ok) failed++; +} + +console.log("\nnormalize + validate"); +const raw = JSON.stringify({ + version: 1, + id: "smoke-set", + title: "Smoke Set", + length: 30, + loop: true, + timeline: [ + { t: 5, param: { "2": 800 } }, // sparse, 1-based object form + { t: 0, pattern: "Origin" }, + { t: 0, param: [200, 500, 500, 900] }, + { t: 12.4, message: "encore" }, // quantizes to 12 + { t: 20, message: "" }, // empty clears the banner + { t: 3, param: [2000, -5, null, null] }, // clamps to 1000 / 0 + { bogus: true }, // no usable field — dropped + ], +}); +const verdict = validatePerformance(raw); +check("validates", verdict.ok); +if (!verdict.ok) { + console.log(` (${verdict.error})`); + process.exit(1); +} +const perf = verdict.perf; +check("cues sorted by time, pattern before param at t=0", + perf.timeline[0].pattern === "Origin" && perf.timeline[1].param?.[0] === 200); +check("bogus cue dropped", perf.timeline.length === 6); +check("param clamps to 0..1000", + perf.timeline[2].param?.[0] === 1000 && perf.timeline[2].param?.[1] === 0); +check("time quantized to whole seconds", + perf.timeline.some((cue) => cue.t === 12 && cue.message === "encore")); +check("filename slug", pfsFilename(perf) === "smoke_set.pfs"); + +console.log("\nPFST bytes (the table core_show.h walks)"); +const bytes = encodePfst(perf); +const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +check("magic PFST", bytes[0] === 0x50 && bytes[1] === 0x46 && bytes[2] === 0x53 && bytes[3] === 0x54); +check("version 1", bytes[4] === 1); +check("loop flag set", bytes[5] === 1); +check("length u16 = 30 (authoring length wins over last cue)", view.getUint16(6, true) === 30); +check("cue count u16 = 6", view.getUint16(8, true) === 6); +const poolBytes = view.getUint16(10, true); +check("total size = header + pool + cues", + bytes.length === PFST_HEADER_BYTES + poolBytes + 6 * PFST_CUE_BYTES); +const title = new TextDecoder().decode(bytes.slice(12, 12 + 9)); +check("title NUL-padded in header", title === "Smoke Set" && bytes[12 + 9] === 0); +// First cue: t=0, pattern "Origin" +const cue0 = PFST_HEADER_BYTES + poolBytes; +check("cue0 t=0 with PATTERN flag", view.getUint16(cue0, true) === 0 && (bytes[cue0 + 2] & 1) === 1); +const patternOff = view.getUint16(cue0 + 4, true); +const pool = bytes.slice(PFST_HEADER_BYTES, PFST_HEADER_BYTES + poolBytes); +const patternName = new TextDecoder().decode( + pool.slice(patternOff, pool.indexOf(0, patternOff)), +); +check('cue0 pattern resolves to "Origin" through the pool', patternName === "Origin"); +check("pool offset 0 is the empty string", pool[0] === 0); + +console.log("\nrefusals"); +check("rejects non-JSON", !validatePerformance("not json").ok); +check("rejects an empty timeline", !validatePerformance('{"timeline":[]}').ok); +const tooMany = JSON.stringify({ + timeline: Array.from({ length: 300 }, (_, i) => ({ t: i, param: [1, null, null, null] })), +}); +check("rejects >256 cues", !validatePerformance(tooMany).ok); +const bigPool = JSON.stringify({ + timeline: Array.from({ length: 200 }, (_, i) => ({ t: i, message: `m${"x".repeat(30)}${i}` })), +}); +check("rejects a >4KB string pool", !validatePerformance(bigPool).ok); + +console.log(failed ? `\n${failed} check(s) failed` : "\nall checks passed"); +process.exit(failed ? 1 : 0); diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 589c573..d2fb1bd 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -8,6 +8,7 @@ import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; import { clearNotificationsFor, notifyDeckInclusion } from "@/lib/community/notify"; import { countPublicDecksByUser, getDeckStub, getPatternsForDeck } from "@/lib/community/queries"; import { rateLimit } from "@/lib/community/ratelimit"; +import { serializePerformance, validatePerformance } from "@/lib/community/performance"; import { deckPatterns, decks } from "@/lib/community/schema"; import { cleanDescription, cleanTitle } from "@/lib/community/validate"; import { cleanVisibility, type Visibility } from "@/lib/community/visibility"; @@ -135,6 +136,28 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st } } + // Optional attached performance (Director timeline). null detaches; a + // string is validated against the same limits the device's .pfs player + // enforces, and stored in canonical form so the pack's performance.json + // is stable across re-serializations. Serve-time zip decoration means no + // rebuild is queued either way. + let performanceJson: string | null | undefined; + if (raw.performanceJson !== undefined) { + if (raw.performanceJson === null || raw.performanceJson === "") { + performanceJson = null; + } else if (typeof raw.performanceJson !== "string") { + return Response.json({ error: "performanceJson must be a JSON string or null." }, { status: 400 }); + } else if (raw.performanceJson.length > 64 * 1024) { + return Response.json({ error: "Performance JSON is too large (max 64 KB)." }, { status: 400 }); + } else { + const verdict = validatePerformance(raw.performanceJson); + if (!verdict.ok) { + return Response.json({ error: `Performance: ${verdict.error}` }, { status: 400 }); + } + performanceJson = JSON.stringify(serializePerformance(verdict.perf), null, 2); + } + } + const db = getDb(); // The list as it stands — the fallback when no replacement came, and the @@ -177,6 +200,7 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st .set({ title, ...(description !== undefined ? { description } : {}), + ...(performanceJson !== undefined ? { performanceJson } : {}), visibility, updatedAt: new Date(), }) diff --git a/web/src/app/api/community/decks/[id]/zip/route.ts b/web/src/app/api/community/decks/[id]/zip/route.ts index 34b6a37..936ef20 100644 --- a/web/src/app/api/community/decks/[id]/zip/route.ts +++ b/web/src/app/api/community/decks/[id]/zip/route.ts @@ -1,8 +1,14 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { unzipSync, zipSync } from "fflate"; import { artifactDir } from "@/lib/community/builds"; import { communityEnabled } from "@/lib/community/db"; import { deckZipFilename, ensureDeckZip, invalidateDeckZip } from "@/lib/community/deckZip"; +import { + encodePfst, + normalizePerformance, + pfsFilename, +} from "@/lib/community/performance"; import { getDeck } from "@/lib/community/queries"; // GET /api/community/decks/[id]/zip — the deck as an installable pack. @@ -98,10 +104,34 @@ async function handleGet(_request: Request, context: { params: Promise<{ id: str ); } - return new Response(new Uint8Array(zip), { + // An attached performance rides the pack: performance.json (the editable + // Director source) plus the encoded .pfs the device's /show player eats. + // Added here at serve time rather than baked into the build artifact, so + // attaching or editing a performance updates downloads instantly without + // queueing a compile. The .pfs joins the device install (the /patterns + // unpack routes it to /api/shows); performance.json deliberately does not. + let bytes: Uint8Array = new Uint8Array(zip); + if (deck.performanceJson) { + try { + const perf = normalizePerformance(JSON.parse(deck.performanceJson)); + const entries = unzipSync(bytes); + entries["performance.json"] = new TextEncoder().encode(deck.performanceJson); + entries[pfsFilename(perf)] = encodePfst(perf); + // Fixed timestamp, same reasoning as make_pack.py: identical inputs + // must produce identical bytes across requests. + bytes = zipSync(entries, { level: 6, mtime: new Date("2026-01-01T00:00:00Z") }); + } catch { + // A performance that stopped encoding must not take the pattern pack + // down with it — serve the plain pack and let the deck page surface + // the attachment error. + bytes = new Uint8Array(zip); + } + } + + return new Response(Buffer.from(bytes), { headers: { "Content-Type": "application/zip", - "Content-Length": String(zip.byteLength), + "Content-Length": String(bytes.byteLength), "Content-Disposition": `attachment; filename="${deckZipFilename(deck.title)}"`, // The URL is stable and the contents are not: a deck can be rearranged // under the same address, so this is revalidated rather than kept. diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 28b05c3..7ef1694 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -34,11 +34,34 @@ export type DeckView = { title: string; description: string | null; visibility: string; + /** Attached Director performance (canonical JSON), or null. */ + performanceJson: string | null; createdAt: string; // ISO username: string | null; displayUsername: string | null; }; +/** What the page says about an attached performance without re-validating it. */ +function summarizePerformance(json: string | null): { title: string; cues: number; seconds: number } | null { + if (!json) return null; + try { + const perf = JSON.parse(json) as { title?: unknown; length?: unknown; timeline?: unknown[] }; + const timeline = Array.isArray(perf.timeline) ? perf.timeline : []; + let last = 0; + for (const cue of timeline) { + const t = Number((cue as { t?: unknown }).t) || 0; + if (t > last) last = t; + } + return { + title: String(perf.title || "Untitled"), + cues: timeline.length, + seconds: Math.max(Math.round(Number(perf.length) || 0), last), + }; + } catch { + return null; + } +} + export default function DeckDetailClient({ deck, items, @@ -54,6 +77,7 @@ export default function DeckDetailClient({ const [busy, setBusy] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [perfOpen, setPerfOpen] = useState(false); const [confirmCopy, setConfirmCopy] = useState(false); const [confirmReplace, setConfirmReplace] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -397,6 +421,20 @@ export default function DeckDetailClient({ {deck.description &&

    {deck.description}

    } + {(() => { + const perf = summarizePerformance(deck.performanceJson); + if (!perf) return null; + const m = Math.floor(perf.seconds / 60); + const s = perf.seconds % 60; + return ( +

    + ♦ Includes a timed performance — “{perf.title}” ({perf.cues} cues,{" "} + {m}:{s < 10 ? `0${s}` : s}). It installs with the pack and plays from the + device’s Sequences page. +

    + ); + })()} + {isOwner && (
    + ); +} + +// A Director performance rides the deck's pack: performance.json (editable +// source) + the packed .pfs the device's Sequences page plays. Author it in +// the Director PWA (Save JSON), paste or drop the file here. The server +// re-validates against the device's limits, so a save that succeeds is one +// the panel will actually load. +function PerformanceModal({ + deckId, + initialJson, + onSaved, + onClose, +}: { + deckId: string; + initialJson: string | null; + onSaved: () => void; + onClose: () => void; +}) { + const [json, setJson] = useState(initialJson ?? ""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const send = async (value: string | null) => { + setBusy(true); + setError(null); + try { + const response = await fetch(communityApiUrl(`/api/community/decks/${deckId}`), { + method: "PATCH", + ...COMMUNITY_FETCH_INIT, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ performanceJson: value }), + }); + const payload = (await response.json()) as { error?: string }; + if (!response.ok) { + setError(payload.error ?? "Could not save."); + return; + } + onSaved(); + } catch { + setError("Network error."); + } finally { + setBusy(false); + } + }; + + const pickFile = (file: File | undefined) => { + if (!file) return; + file + .text() + .then((text) => setJson(text)) + .catch(() => setError("Could not read that file.")); + }; + + return ( +
    +
    event.stopPropagation()}> +
    + Deck performance + +
    +
    +