diff --git a/Integrations/ESPHome/CAST-1_ETH.yaml b/Integrations/ESPHome/CAST-1_ETH.yaml index c917dbf..3463c7a 100644 --- a/Integrations/ESPHome/CAST-1_ETH.yaml +++ b/Integrations/ESPHome/CAST-1_ETH.yaml @@ -87,5 +87,31 @@ text_sensor: id: eth_ip entity_category: "diagnostic" +# ESP-NOW has no WiFi to follow on the Ethernet variant, so it needs a +# starting channel. The interval below scans channels until the WizMote is +# heard, then holds. (The espnow handlers/entities live in Core.yaml.) +espnow: + channel: 1 + +interval: + - interval: 2s + then: + - if: + condition: + lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool want = id(wizmote_discovery_mode).state || + (mac != "00:00:00:00:00:00" && mac != ""); + return want && (millis() - id(wizmote_last_packet_ms) > 30000); + then: + - lambda: |- + id(wizmote_scan_channel)++; + if (id(wizmote_scan_channel) > 13) id(wizmote_scan_channel) = 1; + - espnow.set_channel: + channel: !lambda 'return id(wizmote_scan_channel);' + - logger.log: + format: "WizMote channel scan: trying channel %d" + args: ['id(wizmote_scan_channel)'] + packages: core: !include Core.yaml diff --git a/Integrations/ESPHome/Core.yaml b/Integrations/ESPHome/Core.yaml index 54fcdb8..02cd62d 100644 --- a/Integrations/ESPHome/Core.yaml +++ b/Integrations/ESPHome/Core.yaml @@ -1,5 +1,8 @@ substitutions: - version: "26.6.18.1" + version: "26.6.22.1" + +packages: + wizmote: !include wizmote.yaml esp32: variant: ESP32S3 @@ -204,14 +207,6 @@ select: then: - script.execute: apply_ota_source -remote_receiver: - pin: - number: GPIO07 - inverted: true - mode: - input: true - dump: all - audio_dac: - platform: pcm5122 id: external_dac @@ -444,4 +439,4 @@ script: green: 0% blue: 0% - light.turn_off: - id: rgb_light \ No newline at end of file + id: rgb_light diff --git a/Integrations/ESPHome/wizmote.yaml b/Integrations/ESPHome/wizmote.yaml new file mode 100644 index 0000000..063d3b1 --- /dev/null +++ b/Integrations/ESPHome/wizmote.yaml @@ -0,0 +1,482 @@ +# --------------------------------------------------------------------------- +# WizMote remote control (ESP-NOW). Included by Core.yaml. +# +# Pair a WizMote with the Auto-Discovery switch, then control playback: +# ON/OFF -> play/pause, Scene 1/2 -> previous/next, Scene 3/4 -> HA events +# (esphome.cast1_wizmote_scene_3 / _4), Bright +/- -> volume, Night -> RGB light. +# +# The espnow: block carries no channel here so it follows the WiFi channel on +# CAST-1_W. CAST-1_ETH (no WiFi) adds a starting channel + a scan interval. +# --------------------------------------------------------------------------- + +substitutions: + # WizMote button codes (ESP-NOW) + WIZMOTE_BUTTON_ON: "1" + WIZMOTE_BUTTON_OFF: "2" + WIZMOTE_BUTTON_NIGHT: "3" + WIZMOTE_BUTTON_BRIGHT_UP: "9" + WIZMOTE_BUTTON_BRIGHT_DOWN: "8" + WIZMOTE_BUTTON_1: "16" + WIZMOTE_BUTTON_2: "17" + WIZMOTE_BUTTON_3: "18" + WIZMOTE_BUTTON_4: "19" + WIZMOTE_MAC_ADDRESS: "00:00:00:00:00:00" + +globals: + - id: wizmote_last_sequence + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_previous_mac + type: std::string + restore_value: no + initial_value: '""' + - id: wizmote_last_packet_ms + type: uint32_t + restore_value: no + initial_value: '0' + - id: wizmote_scan_channel + type: uint8_t + restore_value: no + initial_value: '1' + +button: + - platform: template + name: "Clear WizMote Pairing" + id: wizmote_clear_button + icon: "mdi:close-circle" + entity_category: config + on_press: + - lambda: |- + std::string mac = id(wizmote_mac_address).state; + if (mac != "00:00:00:00:00:00" && mac != "" && mac.length() == 17) { + std::array mac_bytes; + int parsed = sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + if (parsed == 6) { + id(espnow_component).del_peer(mac_bytes.data()); + } + } + id(wizmote_mac_address).publish_state("00:00:00:00:00:00"); + - lambda: 'id(wizmote_status).update();' + +switch: + - platform: template + name: "WizMote Auto-Discovery" + id: wizmote_discovery_mode + icon: "mdi:radar" + entity_category: config + optimistic: true + restore_mode: ALWAYS_OFF + on_turn_on: + - lambda: 'id(wizmote_status).update();' + on_turn_off: + - lambda: 'id(wizmote_status).update();' + +# One dropdown per WizMote button. Change these in Home Assistant to remap a +# button -- no YAML editing. "Send HA Event" fires esphome.cast1_wizmote_event +# with the button label in the payload so you can wire it to anything in HA. +select: + - platform: template + name: "WizMote On" + id: wizmote_action_on + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Play" + options: &wizmote_actions + - "Nothing" + - "Play" + - "Pause" + - "Play / Pause" + - "Next Track" + - "Previous Track" + - "Volume Up" + - "Volume Down" + - "Toggle Light" + - "Send HA Event" + - platform: template + name: "WizMote Off" + id: wizmote_action_off + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Pause" + options: *wizmote_actions + - platform: template + name: "WizMote Night" + id: wizmote_action_night + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Toggle Light" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Up" + id: wizmote_action_bright_up + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Up" + options: *wizmote_actions + - platform: template + name: "WizMote Brightness Down" + id: wizmote_action_bright_down + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Volume Down" + options: *wizmote_actions + - platform: template + name: "WizMote Button 1" + id: wizmote_action_btn1 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Previous Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 2" + id: wizmote_action_btn2 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Next Track" + options: *wizmote_actions + - platform: template + name: "WizMote Button 3" + id: wizmote_action_btn3 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + - platform: template + name: "WizMote Button 4" + id: wizmote_action_btn4 + icon: "mdi:gesture-tap-button" + entity_category: config + optimistic: true + restore_value: true + initial_option: "Send HA Event" + options: *wizmote_actions + +text_sensor: + - platform: template + name: "WizMote Status" + id: wizmote_status + icon: "mdi:information" + entity_category: diagnostic + update_interval: 300s + lambda: |- + std::string mac = id(wizmote_mac_address).state; + bool discovery = id(wizmote_discovery_mode).state; + if (discovery) { + return std::string("Discovery mode active"); + } else if (mac == "00:00:00:00:00:00" || mac == "") { + return std::string("No WizMote paired"); + } else { + return std::string("Paired: " + mac); + } + +text: + - platform: template + name: "WizMote MAC Address" + id: wizmote_mac_address + icon: "mdi:remote" + entity_category: config + initial_value: "${WIZMOTE_MAC_ADDRESS}" + optimistic: true + restore_value: true + mode: text + min_length: 17 + max_length: 17 + on_value: + - lambda: |- + std::string mac = x; + bool valid = true; + for (int i = 0; i < 17; i++) { + if (i % 3 == 2) { if (mac[i] != ':') { valid = false; break; } } + else { if (!std::isxdigit(mac[i])) { valid = false; break; } } + } + if (!valid) { + ESP_LOGW("wizmote", "Invalid MAC: %s", mac.c_str()); + return; + } + - if: + condition: + lambda: 'return (id(wizmote_previous_mac) != "" && id(wizmote_previous_mac) != "00:00:00:00:00:00");' + then: + - espnow.peer.delete: + address: !lambda |- + std::string mac = id(wizmote_previous_mac); + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - if: + condition: + lambda: 'return (x != "00:00:00:00:00:00" && x != "");' + then: + - espnow.peer.add: + address: !lambda |- + std::string mac = x; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; + - lambda: 'id(wizmote_previous_mac) = x;' + - lambda: 'id(wizmote_status).update();' + +script: + # Run one action. `action` is the chosen menu string; `button` is the label + # used in the HA event payload when the action is "Send HA Event". + - id: run_wizmote_action + parameters: + action: string + button: string + then: + - logger.log: + tag: wizmote + format: "button %s -> %s" + args: ['button.c_str()', 'action.c_str()'] + - if: + condition: + lambda: 'return action == "Play";' + then: + - media_player.play: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Pause";' + then: + - media_player.pause: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Play / Pause";' + then: + - media_player.toggle: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Next Track";' + then: + - media_player.next: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Previous Track";' + then: + - media_player.previous: sendspin_group_media_player + - if: + condition: + lambda: 'return action == "Volume Up";' + then: + - media_player.volume_up: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Volume Down";' + then: + - media_player.volume_down: external_media_player + - logger.log: + tag: wizmote + format: " volume now %.0f%%" + args: ['id(external_media_player).volume * 100.0f'] + - if: + condition: + lambda: 'return action == "Toggle Light";' + then: + - light.toggle: rgb_light + - if: + condition: + lambda: 'return action == "Send HA Event";' + then: + - homeassistant.event: + event: esphome.cast1_wizmote_event + data: + button: !lambda 'return button;' + + # Look up the configured action for the pressed button, then run it. + - id: process_wizmote_button + parameters: + button: int + then: + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_ON};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_on).state;' + button: "on" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_OFF};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_off).state;' + button: "off" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_NIGHT};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_night).state;' + button: "night" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_UP};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_up).state;' + button: "brightness_up" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_BRIGHT_DOWN};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_bright_down).state;' + button: "brightness_down" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_1};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn1).state;' + button: "1" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_2};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn2).state;' + button: "2" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_3};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn3).state;' + button: "3" + - if: + condition: + lambda: 'return button == ${WIZMOTE_BUTTON_4};' + then: + - script.execute: + id: run_wizmote_action + action: !lambda 'return id(wizmote_action_btn4).state;' + button: "4" + - if: + condition: + lambda: |- + return button != ${WIZMOTE_BUTTON_ON} && button != ${WIZMOTE_BUTTON_OFF} && + button != ${WIZMOTE_BUTTON_NIGHT} && button != ${WIZMOTE_BUTTON_BRIGHT_UP} && + button != ${WIZMOTE_BUTTON_BRIGHT_DOWN} && button != ${WIZMOTE_BUTTON_1} && + button != ${WIZMOTE_BUTTON_2} && button != ${WIZMOTE_BUTTON_3} && + button != ${WIZMOTE_BUTTON_4}; + then: + - logger.log: + tag: wizmote + level: WARN + format: "unmapped button %d" + args: ['button'] + +espnow: + id: espnow_component + auto_add_peer: false + enable_on_boot: true + on_unknown_peer: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + uint8_t button = data[12]; + bool discovery_mode = id(wizmote_discovery_mode).state; + + if (discovery_mode) { + ESP_LOGI("wizmote", "Discovery: WizMote MAC: %s, Button: %d", sender_mac.c_str(), button); + std::string current_mac = id(wizmote_mac_address).state; + if (current_mac == "00:00:00:00:00:00" || current_mac == "" || current_mac != sender_mac) { + auto call = id(wizmote_mac_address).make_call(); + call.set_value(sender_mac); + call.perform(); + id(wizmote_discovery_mode).turn_off(); + } + return; + } + + ESP_LOGI("wizmote", "Unknown WizMote: %s (enable discovery mode to pair)", sender_mac.c_str()); + on_broadcast: + then: + - lambda: |- + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", + info.src_addr[0], info.src_addr[1], info.src_addr[2], + info.src_addr[3], info.src_addr[4], info.src_addr[5]); + std::string sender_mac(mac_str); + + if (size != 13) return; + + std::string configured_mac = id(wizmote_mac_address).state; + std::transform(configured_mac.begin(), configured_mac.end(), configured_mac.begin(), ::tolower); + + if (configured_mac == "00:00:00:00:00:00" || configured_mac == "" || sender_mac != configured_mac) return; + + // Note the last valid packet so the ETH channel-scan can lock on. + id(wizmote_last_packet_ms) = millis(); + + uint32_t sequence = (data[4] << 24) | (data[3] << 16) | (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()); + id(process_wizmote_button).execute(button); + +esphome: + on_boot: + # Re-add the saved WizMote peer after ESP-NOW is up. + - priority: -100 + then: + - delay: 2s + - if: + condition: + lambda: |- + std::string mac = id(wizmote_mac_address).state; + return (mac != "00:00:00:00:00:00" && mac != ""); + then: + - logger.log: "Restoring WizMote pairing on boot..." + - espnow.peer.add: + address: !lambda |- + std::string mac = id(wizmote_mac_address).state; + std::array mac_bytes; + sscanf(mac.c_str(), "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", + &mac_bytes[0], &mac_bytes[1], &mac_bytes[2], + &mac_bytes[3], &mac_bytes[4], &mac_bytes[5]); + return mac_bytes; diff --git a/README.md b/README.md index 6d9ed5d..9077424 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,16 @@ Easily make your speakers smart and cast to multiple devices using Music Assistant and Sendspin! +## WizMote + +The CAST-1 gives you full Home Assistant control over a paired Wiz WizMote. Each of the nine buttons (On, Off, Night, Brightness Up/Down, and 1–4) is a configurable select on the device — assign it Play, Pause, Play / Pause, Next/Previous Track, Volume Up/Down, Toggle Light, or Send HA Event. The defaults give you media controls out of the box. + +Set any button to **Send HA Event** to trigger a custom Home Assistant action — play a Music Assistant playlist, toggle a room's lights, run a scene or script. + +Configure every button without leaving Home Assistant using the [CAST-1 WizMote blueprint](https://github.com/ApolloAutomation/Blueprints/tree/main/CAST-1): + +[![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) + Links: \ Discord (Support/feedback/discussion/future products): [http://dsc.gg/ApolloAutomation](http://dsc.gg/ApolloAutomation) \ Shop: [https://apolloautomation.com](https://apolloautomation.com) \