diff --git a/.github/workflows/autoassign.yml b/.github/workflows/autoassign.yml index 03b36f5..270236f 100644 --- a/.github/workflows/autoassign.yml +++ b/.github/workflows/autoassign.yml @@ -1,8 +1,8 @@ name: Auto Assign - -# pull_request_target (not pull_request) so auto-assign works on -# fork-submitted PRs; fork pull_request runs only get a read-only token. +# pull_request_target (not pull_request) so assignment works on +# fork-submitted PRs; fork pull_request runs get a read-only token. # Safe because this workflow never checks out or executes PR code. +# The assignee comes from the AUTO_ASSIGNEE organization variable. on: issues: types: [opened] @@ -16,6 +16,3 @@ permissions: jobs: auto-assign: uses: ApolloAutomation/Workflows/.github/workflows/autoassign.yml@main - with: - assignees: bharvey88 - num-of-assignees: 1 diff --git a/.github/workflows/build-beta.yml b/.github/workflows/build-beta.yml index c71b8c7..e88f4b7 100644 --- a/.github/workflows/build-beta.yml +++ b/.github/workflows/build-beta.yml @@ -44,7 +44,7 @@ jobs: matrix: include: - { yaml: Integrations/ESPHome/CAST-1.yaml, name: firmware } - uses: esphome/workflows/.github/workflows/build.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 + uses: esphome/workflows/.github/workflows/build.yml@cc3e76de337dc59bc1cba8da58d963cd23b873f1 # 2026.9.0 with: files: ${{ matrix.yaml }} esphome-version: stable diff --git a/CAST_Card.jpg b/CAST_Card.jpg new file mode 100644 index 0000000..8ed5364 Binary files /dev/null and b/CAST_Card.jpg differ diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 5243f92..4329ef8 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,5 @@ substitutions: - version: "26.9.21.1" + version: "26.10.30.1" stable_manifest_base: "https://apolloautomation.github.io/CAST-1" beta_manifest_base: "https://github.com/ApolloAutomation/CAST-1/releases/download/beta-fw" stable_manifest_dir: "firmware" @@ -8,16 +8,17 @@ substitutions: packages: wizmote: !include wizmote.yaml + led_strip: !include led_strip.yaml esphome: friendly_name: Apollo CAST-1 comment: Apollo CAST-1 name_add_mac_suffix: true - # network: priority: (WiFi + Ethernet in one firmware) needs 2026.8 - min_version: 2026.8.0 + # improv_ble (renamed from esp32_improv) needs 2026.10 + min_version: 2026.10.0 platformio_options: board_build.flash_mode: dio - # List form so package on_boot entries (wizmote.yaml) merge + # List form so package on_boot entries (wizmote.yaml, led_strip.yaml) merge on_boot: - priority: 800 then: @@ -61,6 +62,19 @@ esphome: - priority: -100 then: - script.execute: apply_ota_source + # Firmware before ESPHome 2026.10 saved volume 0 as muted, and volume no + # longer clears mute; unmute that once (silent at volume 0) + - priority: -100 + then: + - if: + condition: + and: + - lambda: "return !id(zero_volume_mute_fixed);" + - media_player.is_muted: external_media_player + - lambda: "return id(external_media_player).volume < 0.001f;" + then: + - media_player.unmute: external_media_player + - lambda: "id(zero_volume_mute_fixed) = true;" # Ethernet never came up, so bring WiFi back for rejoin or provisioning - priority: -100 then: @@ -209,8 +223,8 @@ captive_portal: improv_serial: id: improv_serial_1 -esp32_improv: - id: esp32_improv_1 +improv_ble: + id: improv_ble_1 authorizer: none esp32: @@ -251,6 +265,11 @@ api: encryption: globals: + # Set after the one-time volume-0 unmute check on first boot + - id: zero_volume_mute_fixed + type: bool + restore_value: yes + initial_value: 'false' - id: cycleCounter type: int restore_value: no @@ -458,6 +477,9 @@ light: default_transition_length: 0s chipset: WS2812 num_leds: 4 + # The S3 has 192 RMT TX symbols in total and each strip defaults to all of + # them, so split them or the second strip's channel fails + rmt_symbols: 48 channel_colors: GRB effects: - pulse: @@ -473,6 +495,37 @@ light: min_brightness: 50% max_brightness: 100% +number: + # How far music drops while an announcement plays (0 = no ducking) + - platform: template + id: announcement_ducking + name: "Announcement Ducking" + icon: mdi:volume-minus + entity_category: config + unit_of_measurement: "dB" + mode: slider + min_value: -80 + max_value: 0 + step: 1 + optimistic: true + restore_value: true + initial_value: -40 + + # Onboard LED brightness while an announcement plays (0 = LED stays off) + - platform: template + id: announcement_led_brightness + name: "Announcement LED Brightness" + icon: mdi:brightness-percent + entity_category: config + unit_of_measurement: "%" + mode: slider + min_value: 0 + max_value: 100 + step: 1 + optimistic: true + restore_value: true + initial_value: 50 + select: - platform: template id: firmware_channel @@ -588,15 +641,19 @@ media_player: - lambda: return (!id(announcement_triggered)); then: - lambda: id(announcement_triggered) = true; - - light.turn_on: - id: rgb_light - blue: 100% - red: 0% - green: 100% - brightness: 50% + - if: + condition: + lambda: "return id(announcement_led_brightness).state > 0;" + then: + - light.turn_on: + id: rgb_light + blue: 100% + red: 0% + green: 100% + brightness: !lambda "return id(announcement_led_brightness).state / 100.0f;" - mixer_speaker.apply_ducking: id: media_mixer_input - decibel_reduction: 40 + decibel_reduction: !lambda "return (uint8_t) -id(announcement_ducking).state;" duration: 0.0s - wait_until: media_player.is_announcing: external_media_player diff --git a/Integrations/ESPHome/led_strip.yaml b/Integrations/ESPHome/led_strip.yaml new file mode 100644 index 0000000..06ce7cc --- /dev/null +++ b/Integrations/ESPHome/led_strip.yaml @@ -0,0 +1,656 @@ +# LED strip on the exposed 5V / GND / IO1 header; included by Core.yaml + +substitutions: + # The most LEDs the LED Strip Count can be set to (its buffers are sized + # for this many at compile time); set led_strip_num_leds in the device + # YAML's substitutions to raise it. Partitions must fit inside it + led_strip_num_leds: "300" + +esphome: + on_boot: + # Apply the saved LED strip settings before the light's first write + # Must run after the strip and the restored entities set up (800) + - priority: 600 + then: + - lambda: |- + id(led_strip_output)->set_num_leds( + (uint16_t) clamp(id(led_strip_count).state, 1.0f, ${led_strip_num_leds})); + - script.execute: led_strip_blank_all + - script.execute: + id: led_strip_apply_color_order + order: !lambda "return std::string(id(led_strip_color_order).current_option());" + - script.execute: + id: led_strip_apply_chipset + chipset: !lambda "return std::string(id(led_strip_chipset).current_option());" + - script.execute: + id: led_strip_apply_max_brightness + percent: !lambda "return id(led_strip_max_brightness).state;" + +# Every LED strip entity sits under one header on the device's web page +web_server: + sorting_groups: + - id: sg_led_strip + name: "External LED Strip" + sorting_weight: 10 + +light: + - platform: esp32_rmt_led_strip + id: led_strip + output_id: led_strip_output + name: "LED Strip" + icon: mdi:led-strip-variant + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 1 + pin: GPIO1 + # Timings and color order are swapped at runtime by the select entities + chipset: WS2812 + channel_colors: GRB + num_leds: ${led_strip_num_leds} + # The rest of the RMT TX memory after the onboard light's 48 + rmt_symbols: 144 + # The RMT interrupt refills from these buffers mid-frame; PSRAM reads can + # stall it under audio load and cut the frame short + use_psram: false + default_transition_length: 1s + restore_mode: RESTORE_DEFAULT_OFF + # Re-send the settled frame once, and clear the whole strip after turn-off + on_state: + - script.execute: led_strip_refresh + # New effects go at the end: the light restores its effect by position + effects: + - addressable_rainbow: + name: "Rainbow" + - addressable_color_wipe: + name: "Color Wipe" + - addressable_scan: + name: "Scan" + - addressable_twinkle: + name: "Twinkle" + - addressable_random_twinkle: + name: "Random Twinkle" + - addressable_fireworks: + name: "Fireworks" + - addressable_flicker: + name: "Flicker" + - pulse: + name: "Pulse" + - strobe: + name: "Strobe" + + # Effect A and Effect B run the pattern their Pattern select picks, at + # their own Speed. Partition lights in the device YAML can + # list the same two entries (https://esphome.io/components/light/partition/) + - addressable_lambda: + name: "Effect A" + # About one frame per main-loop pass (~16 ms); the helper keeps its + # own clock, so this only caps how often the strip is re-sent + update_interval: 15ms + lambda: "id(led_strip_slot_effect)(it, current_color, initial_run, 0);" + - addressable_lambda: + name: "Effect B" + update_interval: 15ms + lambda: "id(led_strip_slot_effect)(it, current_color, initial_run, 1);" + +globals: + # Renders Effect A (slot 0) and Effect B (slot 1) for any addressable light, + # so the LED Strip and partition lights share one copy and one set of + # Pattern / Speed entities. A globals initializer is plain C++, + # so it names the entities directly instead of through id() + - id: led_strip_slot_effect + type: std::function + restore_value: no + initial_value: |- + [](light::AddressableLight &it, Color color, bool initial_run, int slot) { + struct SlotState { + int pattern; + float level; + float target; + uint32_t next_ms; + uint32_t cycle_start; + uint32_t pos; + }; + // One state per light, since a light runs one effect at a time + static const light::AddressableLight *owners[16] = {}; + static SlotState states[16]; + int i = 0; + while (i < 16 && owners[i] != nullptr && owners[i] != &it) i++; + if (i == 16) return; + if (owners[i] == nullptr) { + owners[i] = ⁢ + initial_run = true; + } + SlotState &st = states[i]; + const uint32_t now = millis(); + const int32_t n = it.size(); + if (n <= 0) return; + + // Each template select is its own type (it carries its initial option), + // so pick through the shared base classes + select::Select *pattern_sel = slot == 1 ? static_cast(led_strip_effect_b_pattern) + : static_cast(led_strip_effect_a_pattern); + number::Number *speed_num = slot == 1 ? static_cast(led_strip_effect_b_speed) + : static_cast(led_strip_effect_a_speed); + const int pattern = (int) pattern_sel->active_index().value_or(0); + const float speed = clamp(speed_num->state, 1.0f, 100.0f); + // Speed 1-100 on a log curve: 1 = 1000 ms, 50 = 182 ms, 100 = 32 ms, + // so the fastest step lands on two ~16 ms main-loop passes + const uint32_t period = (uint32_t) (1000.0f * powf(0.032f, (speed - 1.0f) / 99.0f)); + + // A pattern change mid-effect starts the new pattern fresh + if (initial_run || pattern != st.pattern) { + st.pattern = pattern; + st.level = 1.0f; + st.target = 1.0f; + st.next_ms = now; + st.cycle_start = now; + st.pos = 0; + for (int32_t p = 0; p < n; p++) it[p].set_effect_data(0); + } + + // Steps due since the last frame, one per period. After a stall the + // pattern skips ahead instead of racing to catch up + uint32_t steps = 0; + const int32_t late = (int32_t) (now - st.next_ms); + if (late >= 0) { + steps = 1 + (uint32_t) late / period; + st.next_ms += steps * period; + if (steps > 4) { + steps = 4; + st.next_ms = now + period; + } + } else if (-late > (int32_t) period) { + // A raised Speed pulls in a step that was scheduled further out + st.next_ms = now + period; + } + + switch (pattern) { + case 1: { + // Strobe: on for one period, off for the next + const uint32_t cycle = period * 2; + if (now - st.cycle_start >= cycle) st.cycle_start = now - (now - st.cycle_start) % cycle; + it.all() = (now - st.cycle_start) < period ? color : Color::BLACK; + break; + } + case 2: { + // Rainbow: one rainbow spans about 50 LEDs and shifts a hue step + // every period (it ignores the light's color) + st.pos += steps * 4; + for (int32_t p = 0; p < n; p++) + it[p] = light::ESPHSVColor((uint8_t) (st.pos + p * 5), 255, 255).to_rgb(); + break; + } + case 3: { + // Color Wipe: fills one more LED each period, then clears them + // one at a time from the start + st.pos = (st.pos + steps) % (uint32_t) (2 * n); + const int32_t q = (int32_t) st.pos; + for (int32_t p = 0; p < n; p++) it[p] = (q < n ? p <= q : p > q - n) ? color : Color::BLACK; + break; + } + case 4: { + // Scan: one lit LED moves a step each period and bounces at the ends + const uint32_t span = n > 1 ? (uint32_t) (2 * n - 2) : 1; + st.pos = (st.pos + steps) % span; + const int32_t q = (int32_t) st.pos; + it.all() = Color::BLACK; + it[q < n ? q : (int32_t) span - q] = color; + break; + } + case 5: { + // Twinkle: random LEDs fade in and out over 8 periods; each LED's + // effect data holds how far along its twinkle is (0 = dark) + for (uint32_t s = 0; s < steps; s++) { + for (int32_t p = 0; p < n; p++) { + uint8_t d = it[p].get_effect_data(); + if (d != 0) { + d = d >= 224 ? 0 : d + 32; + } else if (random_float() < 0.05f) { + d = 1; + } + it[p].set_effect_data(d); + } + } + for (int32_t p = 0; p < n; p++) { + const uint8_t d = it[p].get_effect_data(); + it[p] = color * (uint8_t) (d < 128 ? d * 2 : (255 - d) * 2); + } + break; + } + case 6: { + // Pulse: fades smoothly from off to full and back every 4 periods + const uint32_t cycle = period * 4; + if (now - st.cycle_start >= cycle) st.cycle_start = now - (now - st.cycle_start) % cycle; + const float t = (float) (now - st.cycle_start) / (float) cycle; + it.all() = color * (uint8_t) ((0.5f - 0.5f * cosf(6.2831853f * t)) * 255.0f); + break; + } + case 7: { + // Fireworks: sparks light at random each period, then fade while + // spreading to their neighbours; effect data holds each LED's level + for (uint32_t s = 0; s < steps; s++) { + uint8_t prev = 0; + for (int32_t p = 0; p < n; p++) { + const uint8_t cur = it[p].get_effect_data(); + const uint8_t next = p + 1 < n ? it[p + 1].get_effect_data() : 0; + it[p].set_effect_data((uint8_t) std::max(cur * 3 / 5, (prev + next) * 3 / 10)); + prev = cur; + } + if (random_float() < 0.15f) it[random_uint32() % (uint32_t) n].set_effect_data(255); + } + for (int32_t p = 0; p < n; p++) it[p] = color * it[p].get_effect_data(); + break; + } + default: { + // Flicker: every period the light picks a new random level, no lower + // than 30%, and eases toward it within a few frames + if (steps > 0) st.target = 1.0f - 0.7f * random_float(); + st.level += (st.target - st.level) * 0.6f; + it.all() = color * (uint8_t) (st.level * 255.0f); + break; + } + } + } + +number: + # How many LEDs are driven; LEDs past it are left dark + - platform: template + id: led_strip_count + name: "LED Strip Count" + icon: mdi:counter + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 20 + mode: box + min_value: 1 + max_value: ${led_strip_num_leds} + step: 1 + optimistic: true + restore_value: true + initial_value: 30 + set_action: + - script.execute: + id: led_strip_apply_count + count: !lambda "return (int) x;" + + # Caps every channel so a long strip can't overdraw the header's 5V rail + - platform: template + id: led_strip_max_brightness + name: "LED Strip Max Brightness" + icon: mdi:brightness-percent + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 21 + unit_of_measurement: "%" + mode: slider + min_value: 10 + max_value: 100 + step: 5 + optimistic: true + restore_value: true + initial_value: 50 + set_action: + - script.execute: + id: led_strip_apply_max_brightness + percent: !lambda "return x;" + + # Speed 1-100 maps on a log curve from 1 s (1) to 32 ms (100) between changes + - platform: template + id: led_strip_effect_a_speed + name: "LED Strip Effect A Speed" + icon: mdi:speedometer + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 12 + mode: slider + min_value: 1 + max_value: 100 + step: 1 + optimistic: true + restore_value: true + initial_value: 50 + + - platform: template + id: led_strip_effect_b_speed + name: "LED Strip Effect B Speed" + icon: mdi:speedometer + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 15 + mode: slider + min_value: 1 + max_value: 100 + step: 1 + optimistic: true + restore_value: true + # Strobe at 45 flashes about twice a second, under the 3 Hz photosensitivity limit + initial_value: 45 + +select: + - platform: template + id: led_strip_color_order + name: "LED Strip Color Order" + icon: mdi:palette + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 22 + optimistic: true + restore_value: true + options: + - "GRB" + - "RGB" + - "BRG" + - "RBG" + - "GBR" + - "BGR" + initial_option: "GRB" + set_action: + - script.execute: + id: led_strip_apply_color_order + order: !lambda "return std::string(x);" + + - platform: template + id: led_strip_chipset + name: "LED Strip Chipset" + icon: mdi:chip + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 23 + optimistic: true + restore_value: true + options: + - "WS2812" + - "SK6812" + - "WS2811" + - "APA106" + - "SM16703" + initial_option: "WS2812" + set_action: + - script.execute: + id: led_strip_apply_chipset + chipset: !lambda "return std::string(x);" + + - platform: template + id: led_strip_music_effect + name: "LED Strip Music Effect" + icon: mdi:music-circle + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 24 + optimistic: true + restore_value: true + options: + - "Rainbow" + - "Color Wipe" + - "Scan" + - "Twinkle" + - "Random Twinkle" + - "Fireworks" + - "Flicker" + - "Pulse" + initial_option: "Rainbow" + + # Patterns for the Effect A / Effect B slots; the slot effect picks the + # pattern by position, so new patterns go at the end + - platform: template + id: led_strip_effect_a_pattern + name: "LED Strip Effect A Pattern" + icon: mdi:animation-play + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 11 + optimistic: true + restore_value: true + options: + - "Flicker" + - "Strobe" + - "Rainbow" + - "Color Wipe" + - "Scan" + - "Twinkle" + - "Pulse" + - "Fireworks" + initial_option: "Flicker" + + - platform: template + id: led_strip_effect_b_pattern + name: "LED Strip Effect B Pattern" + icon: mdi:animation-play + entity_category: config + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 14 + optimistic: true + restore_value: true + options: + - "Flicker" + - "Strobe" + - "Rainbow" + - "Color Wipe" + - "Scan" + - "Twinkle" + - "Pulse" + - "Fireworks" + initial_option: "Strobe" + +switch: + # Music Mode: the strip turns on with the Music Effect while audio plays and + # off 3 s after playback stops + - platform: template + id: led_strip_music_mode + name: "LED Strip Music Mode" + icon: mdi:music-note + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 25 + optimistic: true + restore_mode: RESTORE_DEFAULT_OFF + on_turn_on: + - if: + condition: + binary_sensor.is_on: led_strip_music_playing + then: + - script.execute: led_strip_music_start + on_turn_off: + - if: + condition: + binary_sensor.is_on: led_strip_music_playing + then: + - light.turn_off: led_strip + +binary_sensor: + - platform: template + id: led_strip_music_playing + internal: true + # Sendspin's player and the HTTP source report stop at once; the main + # player only goes idle ~10s later, once its buffers drain + lambda: |- + return id(sendspin_group_media_player).state == media_player::MEDIA_PLAYER_STATE_PLAYING || + id(http_media_source)->get_state() == media_source::MediaSourceState::PLAYING; + filters: + # Bridge track changes and rebuffering so the strip doesn't blink off + - delayed_off: 3s + on_press: + - if: + condition: + switch.is_on: led_strip_music_mode + then: + - script.execute: led_strip_music_start + on_release: + - if: + condition: + switch.is_on: led_strip_music_mode + then: + - light.turn_off: led_strip + +button: + # Steps the strip through red, green, blue, white, then a rainbow so a bad + # channel, wrong color order, or dead pixel shows up at a glance + - platform: template + id: led_strip_test_button + name: "LED Strip Test" + icon: mdi:led-strip-variant + entity_category: diagnostic + disabled_by_default: true + web_server: + sorting_group_id: sg_led_strip + sorting_weight: 30 + on_press: + - script.execute: led_strip_test + +script: + - id: led_strip_apply_count + mode: queued + parameters: + count: int + then: + - lambda: |- + auto *out = id(led_strip_output); + // Blank every pixel the buffers cover, so LEDs past a lowered count go dark + out->set_num_leds(${led_strip_num_leds}); + for (int32_t i = 0; i < out->size(); i++) (*out)[i] = Color::BLACK; + out->write_state(id(led_strip)); + // Never past the buffers, even if a count was saved under a larger maximum + out->set_num_leds((uint16_t) clamp(count, 1, ${led_strip_num_leds})); + out->clear_effect_data(); + // Repaint the current color at the new length (effects repaint themselves) + out->update_state(id(led_strip)); + ESP_LOGI("led_strip", "Driving %d LEDs", (int) out->size()); + + - id: led_strip_apply_max_brightness + parameters: + percent: float + then: + - lambda: |- + const float scale = percent / 100.0f; + id(led_strip_output)->set_correction(scale, scale, scale); + id(led_strip_output)->update_state(id(led_strip)); + + - id: led_strip_apply_color_order + parameters: + order: string + then: + - lambda: |- + // Each letter's position is that color's byte offset on the wire; + // all options are 3-byte, so the allocated buffers still fit + id(led_strip_output)->set_channel_colors(light::ChannelColors{ + (uint8_t) order.find('R'), (uint8_t) order.find('G'), (uint8_t) order.find('B'), + light::ChannelColors::NO_WHITE}); + id(led_strip_output)->update_state(id(led_strip)); + + - id: led_strip_apply_chipset + parameters: + chipset: string + then: + - lambda: |- + // Bit timings in ns, matching ESPHome's esp32_rmt_led_strip chipset table + auto *out = id(led_strip_output); + if (chipset == "SK6812") { + out->set_led_params(300, 900, 600, 600, 0, 0); + } else if (chipset == "WS2811") { + out->set_led_params(300, 1090, 1090, 320, 0, 300000); + } else if (chipset == "APA106") { + out->set_led_params(350, 1360, 1360, 350, 0, 0); + } else if (chipset == "SM16703") { + out->set_led_params(300, 900, 900, 300, 0, 0); + } else { + out->set_led_params(400, 1000, 1000, 400, 0, 0); + } + out->schedule_show(); + + - id: led_strip_refresh + mode: restart + then: + - delay: 1500ms + - if: + condition: + light.is_off: led_strip + then: + - script.execute: led_strip_blank_all + else: + - lambda: "id(led_strip_output)->schedule_show();" + + - id: led_strip_blank_all + then: + - lambda: |- + // Clear every pixel the buffers cover, so LEDs past the LED Strip Count + // (never written otherwise) can't keep colors latched from IO1 noise + // during a reboot or an earlier, longer count + auto *out = id(led_strip_output); + const uint16_t count = out->size(); + out->set_num_leds(${led_strip_num_leds}); + for (int32_t i = 0; i < out->size(); i++) (*out)[i] = Color::BLACK; + out->write_state(id(led_strip)); + out->set_num_leds(count); + + - id: led_strip_music_start + then: + - light.turn_on: + id: led_strip + effect: !lambda "return std::string(id(led_strip_music_effect).current_option());" + + - id: led_strip_test + mode: restart + then: + - logger.log: "LED strip test: red" + - light.control: + id: led_strip + effect: none + - light.turn_on: + id: led_strip + transition_length: 0s + brightness: 100% + red: 100% + green: 0% + blue: 0% + - delay: 2s + - logger.log: "LED strip test: green" + - light.turn_on: + id: led_strip + transition_length: 0s + red: 0% + green: 100% + blue: 0% + - delay: 2s + - logger.log: "LED strip test: blue" + - light.turn_on: + id: led_strip + transition_length: 0s + red: 0% + green: 0% + blue: 100% + - delay: 2s + - logger.log: "LED strip test: white" + - light.turn_on: + id: led_strip + transition_length: 0s + red: 100% + green: 100% + blue: 100% + - delay: 2s + - logger.log: "LED strip test: rainbow" + - light.turn_on: + id: led_strip + effect: "Rainbow" + - delay: 5s + - light.turn_off: led_strip + - logger.log: "LED strip test: done" diff --git a/Integrations/ESPHome/wizmote.yaml b/Integrations/ESPHome/wizmote.yaml index 538b7ac..c5e2122 100644 --- a/Integrations/ESPHome/wizmote.yaml +++ b/Integrations/ESPHome/wizmote.yaml @@ -268,6 +268,8 @@ script: condition: lambda: 'return action == "Volume Up";' then: + # Volume no longer clears mute on its own (ESPHome 2026.10) + - media_player.unmute: external_media_player - media_player.volume_up: external_media_player - logger.log: tag: wizmote @@ -464,13 +466,14 @@ espnow: // Lets ETH channel-scan lock on id(wizmote_last_packet_ms) = millis(); - uint32_t sequence = (data[4] << 24) | (data[3] << 16) | (data[2] << 8) | data[1]; + uint32_t sequence = ((uint32_t) data[4] << 24) | ((uint32_t) data[3] << 16) | + ((uint32_t) data[2] << 8) | data[1]; uint8_t button = data[6]; if (sequence == id(wizmote_last_sequence)) return; id(wizmote_last_sequence) = sequence; - ESP_LOGI("wizmote", "Button: %d seq=%d from %s", button, sequence, sender_mac.c_str()); + ESP_LOGI("wizmote", "Button: %u seq=%" PRIu32 " from %s", button, sequence, sender_mac.c_str()); id(process_wizmote_button).execute(button); esphome: diff --git a/README.md b/README.md index 8cd99c7..ae84912 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Apollo CAST-1 - -![Apollo CAST-1](https://placehold.co/800x400?text=CAST-1) +

+ Apollo CAST-1 +

Easily make your speakers smart and cast to multiple devices using Music Assistant and Sendspin! @@ -32,6 +33,38 @@ Configure every button without leaving Home Assistant using the [CAST-1 WizMote [![Import Blueprint](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https%3A%2F%2Fgithub.com%2FApolloAutomation%2FBlueprints%2Fblob%2Fmain%2FCAST-1%2FCAST-1-WizMote.yaml) +## Announcements + +While an announcement plays, music ducks and the onboard LED lights up. Two sliders in Home Assistant set how much: + +- **Announcement Ducking** (−80 to 0 dB, default −40) is how far the music drops. 0 turns ducking off. +- **Announcement LED Brightness** (0–100%, default 50%) is how bright the onboard LED is. 0 keeps the LED off. + +## External LED Strip + +The 5V / GND / IO1 header drives an addressable LED strip (WS2812, SK6812, WS2811, APA106 or SM16703). The firmware supports it out of the box, but its entities are disabled by default, since most CAST-1s don't have a strip connected. To use one, open the CAST-1's device page in Home Assistant and enable the **LED Strip** entities. + +The strip's settings live in Home Assistant and under **External LED Strip** on the device's web page: + +- **Count** is how many LEDs the strip has (up to 300, default 30). +- **Chipset**, **Color Order** and **Max Brightness** describe your strip. +- **Effect A** and **Effect B** are two effect slots. Each has a **Pattern** and a **Speed** (1 = one step per second, 100 = one step every 32 ms, on a log curve). Changes apply live while the effect runs. The patterns: + - **Flicker** dips to random levels between full and 30% brightness. + - **Strobe** is on for one step and off for the next. + - **Rainbow** moves a rainbow along the strip. It ignores the light's color. + - **Color Wipe** fills the strip one LED at a time, then clears it the same way. + - **Scan** moves one lit LED back and forth. + - **Twinkle** fades random LEDs in and out. + - **Pulse** fades the whole strip from off to full and back. + - **Fireworks** lights random sparks that spread and fade. + +In automations, set the pattern and speed with `select.select_option` and `number.set_value`, then call `light.turn_on` with `effect: "Effect A"`. + +> [!WARNING] +> **Photosensitivity:** Strobe flashes faster than 3 times per second from about Speed 50 up, and reaches about 16 flashes per second at Speed 100. Pulse passes 3 per second from about Speed 75. Flashing in this range can trigger seizures in people with photosensitive epilepsy. + +Power long strips from their own 5V supply with a shared ground; the header can't supply a full-brightness strip, and **LED Strip Max Brightness** (default 50%) caps the draw. It doesn't reach partition lights, so give each partition its own `color_correct`. + Links: \ Discord (Support/feedback/discussion/future products): [http://dsc.gg/ApolloAutomation](http://dsc.gg/ApolloAutomation) \ Shop: [https://apolloautomation.com](https://apolloautomation.com) \