Refactor integration health monitor automation, remove litter box monitor package, update lunch menus formatting, delete voice satellite alarms, and remove litter box analyzer scripts

This commit is contained in:
Joshua King
2026-09-09 09:09:34 -04:00
parent b1fa10e78e
commit e1a2e41eed
49 changed files with 2742 additions and 10854 deletions
-5
View File
@@ -1,5 +0,0 @@
mode: yaml
title: At-a-Glance
icon: mdi:eye-outline
show_in_sidebar: false
filename: dashboards/views/at-a-glance-dashboard.yaml
-4
View File
@@ -1,7 +1,3 @@
voice-alarms: !include voice-alarms.yaml
family-hub: !include family-hub.yaml
at-a-glance: !include at-a-glance.yaml
lunch-menus: !include lunch-menus.yaml
# Each key is the dashboard's url_path, and HA requires it to contain a hyphen # Each key is the dashboard's url_path, and HA requires it to contain a hyphen
# (see homeassistant/components/lovelace: url_path validation). A hyphen-less # (see homeassistant/components/lovelace: url_path validation). A hyphen-less
# key fails validation for the whole `lovelace:` block, not just its own entry, # key fails validation for the whole `lovelace:` block, not just its own entry,
-294
View File
@@ -1,294 +0,0 @@
# At-a-Glance — wall panel
#
# Styled with the CubeCraft Creations display system. The tokens live in
# themes/cubecraft.yaml, which mirrors the `:host` block of the production
# card's stylesheet (custom_components/cubecraft_production/www/
# cubecraft-production-card.js). Anything referenced here as --cc-* comes from
# that theme, so this file never hard-codes a brand hex.
#
# Three motifs carry the style, all lifted from the production card:
# .title → gradient title bar, white 800-weight type, brand radius
# .column header → uppercase 700/.05em muted section labels
# article → 8px radius, 4px brand left rule, shadow-sm on each tile
#
# Colour is deliberately restrained: brand indigo for anything informational,
# and green/amber/red kept in reserve so they still mean something when they
# show up. That is the same discipline the production card uses, where only a
# blocked order goes red.
title: At-a-Glance
theme: cubecraft
views:
- title: At-a-Glance
path: at-a-glance
icon: mdi:eye-outline
theme: cubecraft
type: masonry
cards:
# ── Brand title bar ──
# Mirrors `.shell.kiosk .title`: gradient ground, white type at 1.75rem,
# brand radius and the brand shadow. ha-markdown supplies its own 16px
# inset, so ha-card only tops that up to roughly the card's 20px/28px.
- type: markdown
content: |
# {{ now().strftime("%A, %B %d, %Y") }}
## {{ now().strftime("%I:%M %p") }}
card_mod:
style: |
ha-card {
background: var(--cc-gradient);
color: #fff;
border: none;
border-radius: var(--cc-radius-brand);
box-shadow: var(--cc-shadow-brand);
padding: 6px 12px;
text-align: center;
}
ha-card h1 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.02em;
opacity: 0.85;
color: #fff;
}
ha-card h2 {
margin: 2px 0 0 0;
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.01em;
color: #fff;
}
# ── Weather Now ──
- type: weather-forecast
entity: weather.home
name: Now
show_forecast: false
show_current: true
round_temperature: true
# ── Indoor Climate ──
# First use of the section-label treatment; the rest of the file aliases
# it as *cc_section. Selectors are listed broadly on purpose — a heading
# card's internals are not a stable API, and any selector that misses
# simply leaves that bit at the theme default rather than breaking.
- type: heading
heading: Indoor Climate
heading_style: title
icon: mdi:home-thermometer-outline
card_mod: &cc_section
style: |
ha-card {
background: none;
border: none;
box-shadow: none;
padding: 0;
margin: 4px 0 -4px 0;
}
ha-card .heading,
ha-card .content p,
ha-card p {
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
font-size: 0.9375rem;
color: var(--cc-text-muted, var(--secondary-text-color));
}
ha-card ha-icon,
ha-card ha-svg-icon {
color: var(--cc-accent, var(--primary-color));
}
- type: grid
columns: 3
square: false
cards:
# First use of the `article` treatment; aliased as *cc_tile below.
- type: tile
entity: sensor.living_room_temperature
name: Living Room
icon: mdi:sofa
color: primary
card_mod: &cc_tile
style: |
ha-card {
border-radius: var(--cc-radius-md);
border-left: 4px solid var(--cc-accent);
box-shadow: var(--cc-shadow-sm);
}
- type: tile
entity: sensor.office_temperature
name: Office
icon: mdi:desk
color: primary
card_mod: *cc_tile
- type: tile
entity: sensor.bedroom_temperature
name: Master Bedroom
icon: mdi:bed
color: primary
card_mod: *cc_tile
- type: tile
entity: sensor.upstairs_temperature
name: Upstairs
icon: mdi:stairs
color: primary
card_mod: *cc_tile
- type: tile
entity: sensor.blink_family_room_temperature
name: Family Room
icon: mdi:television
color: primary
card_mod: *cc_tile
- type: tile
entity: sensor.blink_kitchen_temperature
name: Kitchen
icon: mdi:stove
color: primary
card_mod: *cc_tile
# ── 3D Printer Status ──
- type: heading
heading: 3D Printers
heading_style: title
icon: mdi:printer-3d-nozzle
card_mod: *cc_section
- type: grid
columns: 2
square: false
cards:
# Online/offline is a genuine success signal, so these keep green and
# take the success-coloured left rule.
- type: tile
entity: binary_sensor.p1s_01p09c470102673_online
name: PrintsCharming
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: &cc_tile_success
style: |
ha-card {
border-radius: var(--cc-radius-md);
border-left: 4px solid var(--success-color);
box-shadow: var(--cc-shadow-sm);
}
- type: tile
entity: binary_sensor.a1_03919d540806387_online
name: Ds-Turd-Flinger
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: *cc_tile_success
- type: tile
entity: binary_sensor.a1mini_0309ca580403653_online
name: Polly-Pocket
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: *cc_tile_success
- type: tile
entity: binary_sensor.constipation_orion_online
name: Constipation-Orion
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: *cc_tile_success
- type: tile
entity: binary_sensor.jordyns_layer_slayer_online
name: Jordyn's Layer Slayer
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: *cc_tile_success
- type: tile
entity: binary_sensor.centauri_carbon_sdcp_status
name: Centauri Carbon
icon: mdi:printer-3d-nozzle
show_entity_picture: false
color: green
card_mod: *cc_tile_success
# ── Litter Boxes ──
- type: heading
heading: Litter Boxes
heading_style: title
icon: mdi:cat
card_mod: *cc_section
- type: grid
columns: 2
square: false
cards:
# These are the "something needs doing" tiles — the equivalent of a
# blocked order in the production card, so they take the error rule.
- type: tile
entity: binary_sensor.cat_crapper_3000_wastebin_filled
name: Cat Crapper 3000
icon: mdi:delete-empty
show_entity_picture: false
color: red
card_mod: &cc_tile_alert
style: |
ha-card {
border-radius: var(--cc-radius-md);
border-left: 4px solid var(--error-color);
box-shadow: var(--cc-shadow-sm);
}
- type: tile
entity: binary_sensor.cat_crapper_3000_sand_lack
name: CC3000 Litter
icon: mdi:grain
show_entity_picture: false
color: amber
card_mod:
style: |
ha-card {
border-radius: var(--cc-radius-md);
border-left: 4px solid var(--warning-color);
box-shadow: var(--cc-shadow-sm);
}
- type: tile
entity: binary_sensor.poop_box_wastebin_filled
name: Poop Box
icon: mdi:delete-empty
show_entity_picture: false
color: red
card_mod: *cc_tile_alert
- type: tile
entity: binary_sensor.triple_threat_wastebin_filled
name: Triple Threat
icon: mdi:delete-empty
show_entity_picture: false
color: red
card_mod: *cc_tile_alert
# ── Security ──
- type: heading
heading: Security
heading_style: title
icon: mdi:shield-home-outline
badges:
- type: entity
entity: alarm_control_panel.blink_front
name: Front
- type: entity
entity: alarm_control_panel.blink_inside
name: Inside
- type: entity
entity: alarm_control_panel.blink_garage
name: Garage
- type: entity
entity: alarm_control_panel.blink_rear
name: Rear
card_mod: *cc_section
# ── Recent Activity ──
- type: logbook
entities:
- automation.announce_day_camp_preparation_and_pause_tv
- automation.announce_time_to_leave_for_day_camp_and_turn_off_tv
- automation.bedtime_snack_notification
- automation.chore_tracker_evening_reminder
hours_to_show: 24
title: Recent Family Activity
-5
View File
@@ -1,5 +0,0 @@
mode: yaml
title: Voice Alarms
icon: mdi:alarm-panel
show_in_sidebar: false
filename: dashboards/views/voice-alarms-dashboard.yaml
-47
View File
@@ -1,47 +0,0 @@
#include "esphome.h"
#define MAX17048_ADDRESS 0x36
#define MAX17048_VCELL 0x02 // voltage
#define MAX17048_SOC 0x04 // percentage
#define MAX17048_MODE 0x06
#define MAX17048_VERSION 0x08
#define MAX17048_CONFIG 0x0c
#define MAX17048_COMMAND 0xfe
class MAX17048Sensor : public PollingComponent, public Sensor {
public:
Sensor *voltage_sensor = new Sensor();
Sensor *percentage_sensor = new Sensor();
MAX17048Sensor() : PollingComponent(10000) {}
void setup() override {
// Initialize the device here. Usually Wire.begin() will be called in here,
// though that call is unnecessary if you have an 'i2c:' entry in your config
ESP_LOGD("custom", "Starting up MAX17048 sensor");
Wire.begin();
}
uint16_t read16(uint8_t reg) {
uint16_t temp;
Wire.begin();
Wire.beginTransmission(MAX17048_ADDRESS);
Wire.write(reg);
Wire.endTransmission();
Wire.requestFrom(MAX17048_ADDRESS, 2);
temp = (uint16_t)Wire.read() << 8;
temp |= (uint16_t)Wire.read();
Wire.endTransmission();
return temp;
}
void update() override {
float voltage = (float)(read16(MAX17048_VCELL)) * 78.125 / 1000000;
voltage_sensor->publish_state(voltage);
uint16_t percentage_tmp = read16(MAX17048_SOC);
float percentage = (float)(percentage_tmp) / 256;
percentage_sensor->publish_state(percentage);
}
};
File diff suppressed because it is too large Load Diff
+104 -104
View File
@@ -1,115 +1,115 @@
################################################################################ # ################################################################################
# chore-tracker-dashboard.yaml (AUTO-GENERATED — edit chores_config.yaml) # # chore-tracker-dashboard.yaml (AUTO-GENERATED — edit chores_config.yaml)
# Kids: Jordyn, Declan, Chloe # # Kids: Jordyn, Declan, Chloe
################################################################################ # ################################################################################
title: "Chore Tracker" # title: "Chore Tracker"
views: # views:
- title: Today # - title: Today
icon: mdi:checkbox-marked-circle # icon: mdi:checkbox-marked-circle
path: chores # path: chores
cards: # cards:
- type: horizontal-stack # - type: horizontal-stack
cards: # cards:
- type: markdown # - type: markdown
content: > # content: >
### 󰄛 Jordyn # ### 󰄛 Jordyn
{{ # {{
'All done!' if states('sensor.jordyn_all_chores_done') == 'True' # 'All done!' if states('sensor.jordyn_all_chores_done') == 'True'
else states('sensor.jordyn_chores_done_today') ~ '/4 chores done' # else states('sensor.jordyn_chores_done_today') ~ '/4 chores done'
}} # }}
- type: markdown # - type: markdown
content: > # content: >
### 󱑷 Declan # ### 󱑷 Declan
{{ # {{
'All done!' if states('sensor.declan_all_chores_done') == 'True' # 'All done!' if states('sensor.declan_all_chores_done') == 'True'
else states('sensor.declan_chores_done_today') ~ '/2 chores done' # else states('sensor.declan_chores_done_today') ~ '/2 chores done'
}} # }}
- type: markdown # - type: markdown
content: > # content: >
### 󱚣 Chloe # ### 󱚣 Chloe
{{ # {{
'All done!' if states('sensor.chloe_all_chores_done') == 'True' # 'All done!' if states('sensor.chloe_all_chores_done') == 'True'
else states('sensor.chloe_chores_done_today') ~ '/3 chores done' # else states('sensor.chloe_chores_done_today') ~ '/3 chores done'
}} # }}
- type: button # - type: button
name: "Reset ALL Chores" # name: "Reset ALL Chores"
icon: mdi:restart-alert # icon: mdi:restart-alert
tap_action: # tap_action:
action: call-service # action: call-service
service: input_button.press # service: input_button.press
target: # target:
entity_id: input_button.reset_all_chores # entity_id: input_button.reset_all_chores
- type: entities # - type: entities
title: "󰄛 Jordyn's Chores" # title: "󰄛 Jordyn's Chores"
entities: # entities:
- entity: switch.chore_tracker_jordyn_put_on_underwear # - entity: switch.chore_tracker_jordyn_put_on_underwear
name: "󰋣 Put on underwear" # name: "󰋣 Put on underwear"
- entity: switch.chore_tracker_jordyn_brush_teeth # - entity: switch.chore_tracker_jordyn_brush_teeth
name: "󰦩 Brush Teeth" # name: "󰦩 Brush Teeth"
- entity: switch.chore_tracker_jordyn_fill_water_bowls # - entity: switch.chore_tracker_jordyn_fill_water_bowls
name: "󰆫 Fill water bowls" # name: "󰆫 Fill water bowls"
- entity: switch.chore_tracker_jordyn_restock_cat_food_cans # - entity: switch.chore_tracker_jordyn_restock_cat_food_cans
name: "󱜜 Restock Cat Food Cans" # name: "󱜜 Restock Cat Food Cans"
- type: divider # - type: divider
- entity: sensor.jordyn_chores_done_today # - entity: sensor.jordyn_chores_done_today
name: "Chores done today" # name: "Chores done today"
- entity: sensor.jordyn_all_chores_done # - entity: sensor.jordyn_all_chores_done
name: "All Done?" # name: "All Done?"
- type: button # - type: button
name: "Reset Jordyn's Chores" # name: "Reset Jordyn's Chores"
tap_action: # tap_action:
action: call-service # action: call-service
service: input_button.press # service: input_button.press
target: # target:
entity_id: input_button.jordyn_reset_chores # entity_id: input_button.jordyn_reset_chores
- type: entities # - type: entities
title: "󱑷 Declan's Chores" # title: "󱑷 Declan's Chores"
entities: # entities:
- entity: switch.chore_tracker_declan_take_morning_pill # - entity: switch.chore_tracker_declan_take_morning_pill
name: "󰐂 Take morning pill" # name: "󰐂 Take morning pill"
- entity: switch.chore_tracker_declan_scoop_dog_poop # - entity: switch.chore_tracker_declan_scoop_dog_poop
name: "󰇷 Scoop Dog Poop" # name: "󰇷 Scoop Dog Poop"
- type: divider # - type: divider
- entity: sensor.declan_chores_done_today # - entity: sensor.declan_chores_done_today
name: "Chores done today" # name: "Chores done today"
- entity: sensor.declan_all_chores_done # - entity: sensor.declan_all_chores_done
name: "All Done?" # name: "All Done?"
- type: button # - type: button
name: "Reset Declan's Chores" # name: "Reset Declan's Chores"
tap_action: # tap_action:
action: call-service # action: call-service
service: input_button.press # service: input_button.press
target: # target:
entity_id: input_button.declan_reset_chores # entity_id: input_button.declan_reset_chores
- type: entities # - type: entities
title: "󱚣 Chloe's Chores" # title: "󱚣 Chloe's Chores"
entities: # entities:
- entity: switch.chore_tracker_chloe_fill_kitty_feeders # - entity: switch.chore_tracker_chloe_fill_kitty_feeders
name: "󰊩 Fill kitty feeders" # name: "󰊩 Fill kitty feeders"
- entity: switch.chore_tracker_chloe_scoop_kitty_litter # - entity: switch.chore_tracker_chloe_scoop_kitty_litter
name: "󰇷 Scoop Kitty Litter" # name: "󰇷 Scoop Kitty Litter"
- entity: switch.chore_tracker_chloe_replace_kitty_litter_bags # - entity: switch.chore_tracker_chloe_replace_kitty_litter_bags
name: "󰇷 Replace Kitty Litter Bags" # name: "󰇷 Replace Kitty Litter Bags"
- type: divider # - type: divider
- entity: sensor.chloe_chores_done_today # - entity: sensor.chloe_chores_done_today
name: "Chores done today" # name: "Chores done today"
- entity: sensor.chloe_all_chores_done # - entity: sensor.chloe_all_chores_done
name: "All Done?" # name: "All Done?"
- type: button # - type: button
name: "Reset Chloe's Chores" # name: "Reset Chloe's Chores"
tap_action: # tap_action:
action: call-service # action: call-service
service: input_button.press # service: input_button.press
target: # target:
entity_id: input_button.chloe_reset_chores # entity_id: input_button.chloe_reset_chores
+75 -75
View File
@@ -1,81 +1,81 @@
################################################################################ # ################################################################################
# chores_config.yaml — YOUR SINGLE SOURCE OF TRUTH # # chores_config.yaml — YOUR SINGLE SOURCE OF TRUTH
# # #
# Edit this file to change kids, chores, and settings. # # Edit this file to change kids, chores, and settings.
# Then run: python3 generate.py # # Then run: python3 generate.py
# # #
# ICONS: Use MDI codepoints from https://pictogrammers.com/library/mdi/ # # ICONS: Use MDI codepoints from https://pictogrammers.com/library/mdi/
# Format: "\U000FXXXX" e.g. mdi:bed = "\U000F02E3" # # Format: "\U000FXXXX" e.g. mdi:bed = "\U000F02E3"
# The kid avatar field also uses MDI codepoints. # # The kid avatar field also uses MDI codepoints.
# # #
# Common chore icons: # # Common chore icons:
# Bed: "\U000F02E3" mdi:bed # # Bed: "\U000F02E3" mdi:bed
# Brush teeth: "\U000F09A9" mdi:toothbrush # # Brush teeth: "\U000F09A9" mdi:toothbrush
# Broom: "\U000F00A8" mdi:broom # # Broom: "\U000F00A8" mdi:broom
# Book: "\U000F0219" mdi:book-open-variant # # Book: "\U000F0219" mdi:book-open-variant
# Dog: "\U000F01F9" mdi:dog # # Dog: "\U000F01F9" mdi:dog
# Paw: "\U000F0265" mdi:paw # # Paw: "\U000F0265" mdi:paw
# Silverware: "\U000F03E7" mdi:silverware-fork-knife # # Silverware: "\U000F03E7" mdi:silverware-fork-knife
# Trash: "\U000F05B8" mdi:trash-can # # Trash: "\U000F05B8" mdi:trash-can
# Watering can: "\U000F1B25" mdi:watering-can # # Watering can: "\U000F1B25" mdi:watering-can
# Piano: "\U000F0F9E" mdi:piano # # Piano: "\U000F0F9E" mdi:piano
# Dumbbell: "\U000F0F1E" mdi:dumbbell # # Dumbbell: "\U000F0F1E" mdi:dumbbell
# Shower: "\U000F0467" mdi:shower # # Shower: "\U000F0467" mdi:shower
################################################################################ # ################################################################################
settings: # settings:
device_name: chore-tracker # device_name: chore-tracker
friendly_name: "Chore Tracker" # friendly_name: "Chore Tracker"
wifi_ssid: !secret wifi_iot_ssid # wifi_ssid: !secret wifi_iot_ssid
wifi_password: !secret wifi_password # wifi_password: !secret wifi_password
api_key: !secret api_encryption_key # api_key: !secret api_encryption_key
ota_password: !secret ota_password # ota_password: !secret ota_password
reset_time: "00:00:00" # reset_time: "00:00:00"
reminder_time: "18:00" # reminder_time: "18:00"
notify_service: notify.notify # notify_service: notify.notify
static_ip: 192.168.69.115 # static_ip: 192.168.69.115
gateway: 192.168.69.1 # gateway: 192.168.69.1
subnet: 255.255.255.0 # subnet: 255.255.255.0
# ── KIDS ────────────────────────────────────────────────────────────────────── # # ── KIDS ──────────────────────────────────────────────────────────────────────
# avatar: MDI codepoint shown in the sidebar on the kid's chore page # # avatar: MDI codepoint shown in the sidebar on the kid's chore page
# color / color_dark: hex colours for that kid's sidebar # # color / color_dark: hex colours for that kid's sidebar
# # #
kids: # kids:
- name: Jordyn # - name: Jordyn
avatar: "\U000F011B" # mdi:cat # avatar: "\U000F011B" # mdi:cat
color: "5F5980" # #5F5980 is a nice light blue, but it's a bit hard to read when used as a background colour for the sidebar. So I use a darker version of the same colour for the sidebar background. # color: "5F5980" # #5F5980 is a nice light blue, but it's a bit hard to read when used as a background colour for the sidebar. So I use a darker version of the same colour for the sidebar background.
color_dark: "2F3061" # #2F3061 is a darker version of #5F5980, and is easier to read when used as a background colour for the sidebar. # color_dark: "2F3061" # #2F3061 is a darker version of #5F5980, and is easier to read when used as a background colour for the sidebar.
chores: # chores:
- name: Put on underwear # - name: Put on underwear
icon: "\U000F02E3" # mdi:bed # icon: "\U000F02E3" # mdi:bed
- name: Brush Teeth # - name: Brush Teeth
icon: "\U000F09A9" # mdi:toothbrush # icon: "\U000F09A9" # mdi:toothbrush
- name: Fill water bowls # - name: Fill water bowls
icon: "\U000F01AB" # mdi:cup-water # icon: "\U000F01AB" # mdi:cup-water
- name: Restock Cat Food Cans # - name: Restock Cat Food Cans
icon: "\U000F171C" # mdi:food-turkey # icon: "\U000F171C" # mdi:food-turkey
- name: Declan # - name: Declan
avatar: "\U000F1477" # mdi:wizard-hat # avatar: "\U000F1477" # mdi:wizard-hat
color: "720026" # #720026 is a deep red, used for the kid's sidebar background. # color: "720026" # #720026 is a deep red, used for the kid's sidebar background.
color_dark: "4F000B" # #4F000B is a darker version of #720026, easier to read when used as a background colour for the sidebar. # color_dark: "4F000B" # #4F000B is a darker version of #720026, easier to read when used as a background colour for the sidebar.
chores: # chores:
- name: Take morning pill # - name: Take morning pill
icon: "\U000F0402" # mdi:pill # icon: "\U000F0402" # mdi:pill
- name: Scoop Dog Poop # - name: Scoop Dog Poop
icon: "\U000F01F7" # mdi:emoticon-poop # icon: "\U000F01F7" # mdi:emoticon-poop
- name: Chloe # - name: Chloe
avatar: "\U000F16A3" # mdi:robot-excited # avatar: "\U000F16A3" # mdi:robot-excited
color: "73683B" # #73683B is a light brown, used for the kid's sidebar background. # color: "73683B" # #73683B is a light brown, used for the kid's sidebar background.
color_dark: "583E23" # #583E23 is a darker version of #73683B, easier to read when used as a background colour for the sidebar. # color_dark: "583E23" # #583E23 is a darker version of #73683B, easier to read when used as a background colour for the sidebar.
chores: # chores:
- name: Fill kitty feeders # - name: Fill kitty feeders
icon: "\U000F02A9" # mdi:bowl-outline # icon: "\U000F02A9" # mdi:bowl-outline
- name: Scoop Kitty Litter # - name: Scoop Kitty Litter
icon: "\U000F01F7" # mdi:emoticon-poop # icon: "\U000F01F7" # mdi:emoticon-poop
- name: Replace Kitty Litter Bags # - name: Replace Kitty Litter Bags
icon: "\U000F01F7" # mdi:emoticon-poop # icon: "\U000F01F7" # mdi:emoticon-poop
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1 +0,0 @@
CODEOWNERS = ["@blacknell"]
-20
View File
@@ -1,20 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "max17043.h"
namespace esphome {
namespace max17043 {
template<typename... Ts> class SleepAction : public Action<Ts...> {
public:
explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {}
void play(Ts... x) override { this->max17043_->sleep_mode(); }
protected:
MAX17043Component *max17043_;
};
} // namespace max17043
} // namespace esphome
-96
View File
@@ -1,96 +0,0 @@
#include "max17043.h"
#include "esphome/core/log.h"
namespace esphome {
namespace max17043 {
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
static const char *const TAG = "max17043";
static const uint8_t MAX17043_VCELL = 0x02;
static const uint8_t MAX17043_SOC = 0x04;
static const uint8_t MAX17043_CONFIG = 0x0c;
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
void MAX17043Component::update() {
uint16_t raw_voltage, raw_percent;
if (this->voltage_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
this->status_set_warning("Unable to read MAX17043_VCELL");
} else {
float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0;
this->voltage_sensor_->publish_state(voltage);
this->status_clear_warning();
}
}
if (this->battery_remaining_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
this->status_set_warning("Unable to read MAX17043_SOC");
} else {
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
this->battery_remaining_sensor_->publish_state(percent);
this->status_clear_warning();
}
}
}
void MAX17043Component::setup() {
ESP_LOGCONFIG(TAG, "Setting up MAX17043...");
uint16_t config_reg;
if (this->write(&MAX17043_CONFIG, 1) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
if (this->read(reinterpret_cast<uint8_t *>(&config_reg), 2) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
config_reg = i2c::i2ctohs(config_reg) & MAX17043_CONFIG_SAFE_MASK;
ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg);
if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) {
ESP_LOGW(TAG, "Non-standard CONFIG register 0x%X at 0x36; continuing in compatible mode", config_reg);
return;
}
// need to write back to config register to reset the sleep bit
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) {
this->status_set_error("sleep reset failed");
this->mark_failed();
return;
}
}
void MAX17043Component::dump_config() {
ESP_LOGCONFIG(TAG, "MAX17043:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, "Communication with MAX17043 failed");
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Battery Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
}
float MAX17043Component::get_setup_priority() const { return setup_priority::DATA; }
void MAX17043Component::sleep_mode() {
if (!this->is_failed()) {
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
this->status_set_warning();
}
}
}
} // namespace max17043
} // namespace esphome
-29
View File
@@ -1,29 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome {
namespace max17043 {
class MAX17043Component : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
void update() override;
void sleep_mode();
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
battery_remaining_sensor_ = battery_remaining_sensor;
}
protected:
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *battery_remaining_sensor_{nullptr};
};
} // namespace max17043
} // namespace esphome
-77
View File
@@ -1,77 +0,0 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_ID,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
UNIT_VOLT,
)
DEPENDENCIES = ["i2c"]
max17043_ns = cg.esphome_ns.namespace("max17043")
MAX17043Component = max17043_ns.class_(
"MAX17043Component", cg.PollingComponent, i2c.I2CDevice
)
# Actions
SleepAction = max17043_ns.class_("SleepAction", automation.Action)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(MAX17043Component),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x36))
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if voltage_config := config.get(CONF_BATTERY_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
if CONF_BATTERY_LEVEL in config:
sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL])
cg.add(var.set_battery_remaining_sensor(sens))
MAX17043_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(MAX17043Component),
}
)
@automation.register_action("max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA)
async def max17043_sleep_mode_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
-122
View File
@@ -1,122 +0,0 @@
substitutions:
device_name: declan-a1-camera
friendly_name: "Declan's A1 Camera"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
manual_ip:
static_ip: 192.168.69.220
gateway: 192.168.69.1
subnet: 255.255.255.0
ap:
ssid: "${friendly_name} Fallback"
password: !secret fallback_password
# Prevents aggressive reconnection attempts
power_save_mode: none # Use 'light' for battery devices
# Slower but more reliable connection
fast_connect: true
# Handle connection failures gracefully
on_connect:
- logger.log: "Wi-Fi connected!"
on_disconnect:
- logger.log: "Wi-Fi disconnected!"
# Reduce mDNS traffic
mdns:
disabled: true # Set to true if you use static IPs and don't need discovery
# Diagnostic sensors
sensor:
- platform: wifi_signal
name: "${device_name} WiFi Signal"
update_interval: 60s
- platform: uptime
name: "${device_name} Uptime"
update_interval: 60s
text_sensor:
- platform: wifi_info
ip_address:
name: "${device_name} IP Address"
ssid:
name: "${device_name} Connected SSID"
bssid:
name: "${device_name} BSSID"
# Watchdog to auto-reboot if things go wrong
interval:
- interval: 5min
then:
- if:
condition:
and:
- lambda: 'return millis() > 300000;' # >5 min uptime
- not:
wifi.connected:
then:
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
- delay: 10s
- lambda: 'App.safe_reboot();'
captive_portal:
i2c:
- id: camera_i2c
sda: GPIO26
scl: GPIO27
psram:
mode: quad
speed: 80MHz
esp32_camera:
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id: camera_i2c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: 1024x768
jpeg_quality: 15
vertical_flip: true
horizontal_mirror: false
max_framerate: 20 fps
idle_framerate: 0.05 fps
name: ${device_name}
esp32_camera_web_server:
- port: 8080
mode: STREAM
- port: 8081
mode: SNAPSHOT
switch:
- platform: gpio
name: "${device_name}-flash"
pin: 4
+58 -8
View File
@@ -60,7 +60,10 @@ esp32_camera:
name: "${friendly_name}" name: "${friendly_name}"
external_clock: external_clock:
pin: GPIO5 pin: GPIO5
frequency: 20MHz # 10MHz (half the usual 20MHz) slows the sensor's line rate, so each of the
# 1200 exposure lines below collects roughly twice the light. Costs
# framerate, which does not matter for chamber monitoring.
frequency: 10MHz
i2c_pins: i2c_pins:
sda: GPIO8 sda: GPIO8
scl: GPIO9 scl: GPIO9
@@ -87,14 +90,17 @@ esp32_camera:
contrast: -2 contrast: -2
saturation: 0 saturation: 0
# Auto-Exposure & Gain tuning (AEC2 + AE Level lift shadows away from bright LEDs). # Exposure & gain are MANUAL on purpose. The two LED strips in frame kept the
# The gain ceiling is the main lever for the tinted door: 8X was capping how # auto-exposure meter satisfied, so AEC never lengthened the exposure and the
# far AGC could amplify, leaving everything outside the LED strips black. # tinted-door scene stayed black regardless of gain ceiling or AE level.
aec_mode: auto # Pinning both to max forces a bright chamber; the strips clip out entirely.
aec_mode: manual
aec_value: 1200 # max integration time (0-1200 lines)
aec2: true aec2: true
ae_level: 2 ae_level: 2 # only consulted if AEC is flipped back to auto
agc_mode: auto agc_mode: manual
agc_gain_ceiling: 128X agc_value: 30 # max sensor gain (0-30)
agc_gain_ceiling: 128X # only consulted if AGC is flipped back to auto
wb_mode: auto wb_mode: auto
# Camera endpoints: # Camera endpoints:
@@ -113,6 +119,25 @@ switch:
pin: GPIO47 pin: GPIO47
restore_mode: ALWAYS_OFF restore_mode: ALWAYS_OFF
# Flip exposure/gain back to automatic for A/B comparison without a reflash.
# Off (the default) = manual exposure per the esp32_camera block above.
- platform: template
name: "${friendly_name} Auto Exposure"
optimistic: true
restore_mode: ALWAYS_OFF
turn_on_action:
- lambda: |-
sensor_t *s = esp_camera_sensor_get();
if (!s) return;
s->set_exposure_ctrl(s, 1);
s->set_gain_ctrl(s, 1);
turn_off_action:
- lambda: |-
sensor_t *s = esp_camera_sensor_get();
if (!s) return;
s->set_exposure_ctrl(s, 0);
s->set_gain_ctrl(s, 0);
sensor: sensor:
- platform: wifi_signal - platform: wifi_signal
name: "${friendly_name} WiFi Signal" name: "${friendly_name} WiFi Signal"
@@ -159,6 +184,31 @@ interval:
# Dynamic controls exposed to Home Assistant for real-time camera tuning # Dynamic controls exposed to Home Assistant for real-time camera tuning
number: number:
# The two that actually drive image brightness in manual mode.
- platform: template
name: "${friendly_name} Exposure"
min_value: 0
max_value: 1200
step: 25
initial_value: 1200
optimistic: true
set_action:
- lambda: |-
sensor_t *s = esp_camera_sensor_get();
if (s) s->set_aec_value(s, (int)x);
- platform: template
name: "${friendly_name} Gain"
min_value: 0
max_value: 30
step: 1
initial_value: 30
optimistic: true
set_action:
- lambda: |-
sensor_t *s = esp_camera_sensor_get();
if (s) s->set_agc_gain(s, (int)x);
- platform: template - platform: template
name: "${friendly_name} Brightness" name: "${friendly_name} Brightness"
min_value: -2 min_value: -2
-38
View File
@@ -1,38 +0,0 @@
substitutions:
name: family-room-remote
friendly_name: "Family Room Remote"
packages:
base: !include family-room-remote/family-room-remote.base.yaml
ui: !include family-room-remote/family-room-remote.ui.yaml
bindings: !include family-room-remote/family-room-remote.bindings.yaml
esphome:
name: ${name}
friendly_name: ${friendly_name}
name_add_mac_suffix: false
esp32:
board: esp32-s3-devkitc-1
variant: ESP32S3
flash_size: 16MB
framework:
type: esp-idf
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
ap:
ssid: "${friendly_name} Fallback"
password: !secret fallback_password
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
logger:
baud_rate: 0
@@ -1,173 +0,0 @@
psram:
mode: octal
speed: 80MHz
i2c:
id: i2c_main
sda: GPIO19
scl: GPIO20
frequency: 400kHz
scan: false
# --- Backlight control (GPIO2 direct) ---
switch:
- platform: gpio
name: "LCD Backlight Raw"
id: lcd_backlight_raw
restore_mode: ALWAYS_ON
pin:
number: GPIO2
mode:
output: true
light:
- platform: binary
name: "Family Room Remote Backlight"
output: lcd_backlight_out
id: ha_remote_backlight
restore_mode: ALWAYS_ON
output:
- platform: template
id: lcd_backlight_out
type: binary
write_action:
- if:
condition:
lambda: return state;
then:
- switch.turn_on: lcd_backlight_raw
else:
- switch.turn_off: lcd_backlight_raw
# --- Inactivity tracking (off after 2 minutes, wake on touch) ---
globals:
- id: last_activity_ms
type: uint32_t
restore_value: no
initial_value: '0'
interval:
- interval: 1s
then:
- lambda: |-
if (id(last_activity_ms) == 0) id(last_activity_ms) = millis();
- if:
condition:
lambda: |-
const uint32_t idle_s = (millis() - id(last_activity_ms)) / 1000;
return idle_s == 120;
then:
- light.turn_off: ha_remote_backlight
# --- Display (ST7262 parallel RGB, 800x480) ---
display:
- platform: rpi_dpi_rgb
id: main_display
dimensions:
width: 800
height: 480
de_pin: GPIO40
hsync_pin: GPIO39
vsync_pin: GPIO41
pclk_pin: GPIO42
pclk_frequency: 16MHz
color_order: BGR
data_pins:
red:
- GPIO45
- GPIO48
- GPIO47
- GPIO21
- GPIO14
green:
- GPIO5
- GPIO6
- GPIO7
- GPIO15
- GPIO16
- GPIO4
blue:
- GPIO8
- GPIO3
- GPIO46
- GPIO9
- GPIO1
hsync_back_porch: 48
hsync_front_porch: 40
hsync_pulse_width: 48
vsync_back_porch: 32
vsync_front_porch: 13
vsync_pulse_width: 3
update_interval: never
auto_clear_enabled: false
# --- Touch (GT911 capacitive) ---
# NOTE: interrupt_pin GPIO18 requires soldering the R17 bridge on the board.
# If not soldered, remove the interrupt_pin line (falls back to polling).
touchscreen:
platform: gt911
id: touch_panel
i2c_id: i2c_main
reset_pin: GPIO38
on_touch:
then:
- lambda: |-
id(last_activity_ms) = millis();
- light.turn_on: ha_remote_backlight
# --- MDI Icon Font ---
font:
- file: "https://raw.githubusercontent.com/Templarian/MaterialDesign-Webfont/master/fonts/materialdesignicons-webfont.ttf"
id: mdi_icons
size: 24
bpp: 4
glyphs:
- "\U000F0335" # mdi:lightbulb
- "\U000F0425" # mdi:power
- "\U000F0079" # mdi:battery
- "\U000F12A1" # mdi:battery-low
- "\U000F12A2" # mdi:battery-medium
- "\U000F12A3" # mdi:battery-high
- "\U000F12A4" # mdi:battery-charging-low
- "\U000F12A5" # mdi:battery-charging-medium
- "\U000F12A6" # mdi:battery-charging-high
- "\U000F0091" # mdi:battery-unknown
- "\U000F10CD" # mdi:battery-alert-variant-outline
# --- Battery fuel gauge (MAX17048 via I2C) ---
# DISABLED for testing - suspected display interference
# sensor:
# - platform: max17043
# id: max17048_battery
# i2c_id: i2c_main
# update_interval: 120s
# battery_voltage:
# name: "Remote Battery Voltage"
# id: remote_battery_voltage
# entity_category: diagnostic
# device_class: voltage
# state_class: measurement
# accuracy_decimals: 3
# battery_level:
# name: "Remote Battery Level"
# id: remote_battery_level
# entity_category: diagnostic
# device_class: battery
# state_class: measurement
# accuracy_decimals: 0
# on_value:
# then:
# - lambda: |-
# ESP_LOGI("battery", "Level: %.0f%%", x);
# // Update the LVGL battery icon based on level
# lv_obj_t *lbl = id(battery_icon);
# if (x < 10)
# lv_label_set_text(lbl, "\U000F10CD"); // battery-alert-variant-outline
# else if (x < 30)
# lv_label_set_text(lbl, "\U000F12A1"); // battery-low
# else if (x < 70)
# lv_label_set_text(lbl, "\U000F12A2"); // battery-medium
# else
# lv_label_set_text(lbl, "\U000F12A3"); // battery-high
@@ -1,59 +0,0 @@
text_sensor:
- platform: wifi_info
ip_address:
name: "Family Room Remote IP"
id: ip_addr
- platform: homeassistant
id: ts_family_room_tv_stand
entity_id: light.family_room_tv_stand
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_family_room_tv_stand
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_family_room_tv_stand);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_small_family_room_lamp
entity_id: light.small_family_room_lamp
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_small_family_room_lamp
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_small_family_room_lamp);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_big_family_room_lamp
entity_id: switch.big_family_room_lamp
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_big_family_room_lamp
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001F15) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_big_family_room_lamp);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
@@ -1,231 +0,0 @@
lvgl:
displays:
- main_display
touchscreens:
- touch_panel
disp_bg_color: 0x141218
style_definitions:
# ── Top App Bar ──
- id: md3_header
bg_color: 0x1D1B20
bg_opa: 100%
border_width: 0
radius: 0
pad_all: 0
shadow_width: 0
# ── Status Chip ──
- id: md3_chip
bg_color: 0x2B2930
bg_opa: 100%
border_color: 0x49454F
border_width: 1
border_opa: 100%
radius: 8
pad_all: 0
# ── Text Styles ──
- id: md3_title
text_color: 0xE3E2E6
text_opa: 100%
- id: md3_meta
text_color: 0xC4C6CF
text_opa: 100%
- id: md3_icon
text_color: 0xC4C6CF
text_opa: 100%
# ── Card Variants ──
- id: md3_card_blue
bg_color: 0x2B2930
bg_opa: 100%
border_color: 0x49454F
border_width: 1
border_opa: 100%
radius: 16
shadow_width: 0
- id: md3_card_pink
bg_color: 0x2B2930
bg_opa: 100%
border_color: 0x49454F
border_width: 1
border_opa: 100%
radius: 16
shadow_width: 0
- id: md3_card_green
bg_color: 0x2B2930
bg_opa: 100%
border_color: 0x49454F
border_width: 1
border_opa: 100%
radius: 16
shadow_width: 0
theme:
label:
text_color: 0xE3E2E6
button:
text_color: 0xE3E2E6
pages:
- id: home
widgets:
# ── Header ──
- obj:
x: 0
y: 0
width: 800
height: 64
styles: md3_header
widgets:
- label:
x: 24
y: 20
text_color: 0xE3E2E6
text: "Family Room"
# ── TV Stand ──
- button:
id: btn_family_room_tv_stand
x: 24
y: 80
width: 234
height: 300
checkable: true
styles: md3_card_blue
checked:
bg_color: 0xA8C7FA
bg_opa: 100%
border_color: 0xA8C7FA
border_opa: 100%
text_color: 0x001A33
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.family_room_tv_stand
widgets:
- label:
x: 194
y: 16
styles: md3_icon
text_font: mdi_icons
text: "\U000F0335" # mdi:lightbulb
- label:
x: 18
y: 16
styles: md3_meta
text: "LIGHT"
- label:
x: 18
y: 56
styles: md3_title
text: "TV Stand"
- label:
x: 18
y: 90
styles: md3_meta
text: "Tap to toggle"
# ── Small Lamp ──
- button:
id: btn_small_family_room_lamp
x: 282
y: 80
width: 234
height: 300
checkable: true
styles: md3_card_pink
checked:
bg_color: 0xFFD7F1
bg_opa: 100%
border_color: 0xFFD7F1
border_opa: 100%
text_color: 0x22001F
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.small_family_room_lamp
widgets:
- label:
x: 194
y: 16
styles: md3_icon
text_font: mdi_icons
text: "\U000F0335" # mdi:lightbulb
- label:
x: 18
y: 16
styles: md3_meta
text: "LIGHT"
- label:
x: 18
y: 56
styles: md3_title
text: "Small Lamp"
- label:
x: 18
y: 90
styles: md3_meta
text: "Tap to toggle"
# ── Standing Lamp ──
- button:
id: btn_big_family_room_lamp
x: 540
y: 80
width: 236
height: 300
checkable: true
styles: md3_card_green
checked:
bg_color: 0xB5CCBA
bg_opa: 100%
border_color: 0xB5CCBA
border_opa: 100%
text_color: 0x001F15
on_click:
- homeassistant.service:
service: light.toggle
data:
entity_id: light.living_room_lamp_1
widgets:
- label:
x: 196
y: 16
styles: md3_icon
text_font: mdi_icons
text: "\U000F0335" # mdi:lightbulb
- label:
x: 18
y: 16
styles: md3_meta
text: "LIGHT"
- label:
x: 18
y: 56
styles: md3_title
text: "Standing Lamp"
- label:
x: 18
y: 90
styles: md3_meta
text: "Tap to toggle"
# ── Status Bar ──
- obj:
x: 0
y: 400
width: 800
height: 80
styles: md3_header
widgets:
- obj:
x: 8
y: 20
width: 84
height: 40
styles: md3_chip
widgets:
- label:
id: battery_icon
align: center
text_font: mdi_icons
text_color: 0xC4C6CF
text: "\U000F10CD" # mdi:battery-alert-variant-outline
@@ -1 +0,0 @@
CODEOWNERS = ["@blacknell"]
@@ -1,20 +0,0 @@
#pragma once
#include "esphome/core/automation.h"
#include "max17043.h"
namespace esphome {
namespace max17043 {
template<typename... Ts> class SleepAction : public Action<Ts...> {
public:
explicit SleepAction(MAX17043Component *max17043) : max17043_(max17043) {}
void play(Ts... x) override { this->max17043_->sleep_mode(); }
protected:
MAX17043Component *max17043_;
};
} // namespace max17043
} // namespace esphome
@@ -1,72 +0,0 @@
#include "max17043.h"
#include "esphome/core/log.h"
namespace esphome {
namespace max17043 {
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
static const char *const TAG = "max17043";
static const uint8_t MAX17043_VCELL = 0x02;
static const uint8_t MAX17043_SOC = 0x04;
static const uint8_t MAX17043_CONFIG = 0x0c;
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
void MAX17043Component::update() {
uint16_t raw_voltage, raw_percent;
if (this->voltage_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
this->status_set_warning("Unable to read MAX17043_VCELL");
} else {
float voltage = (1.25 * (float) (raw_voltage >> 4)) / 1000.0;
this->voltage_sensor_->publish_state(voltage);
this->status_clear_warning();
}
}
if (this->battery_remaining_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
this->status_set_warning("Unable to read MAX17043_SOC");
} else {
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
this->battery_remaining_sensor_->publish_state(percent);
this->status_clear_warning();
}
}
}
void MAX17043Component::setup() {
ESP_LOGCONFIG(TAG, "Setting up MAX17043...");
// Compatible mode for MAX17048/variant boards:
// avoid setup-time register writes on the shared touch I2C bus.
}
void MAX17043Component::dump_config() {
ESP_LOGCONFIG(TAG, "MAX17043:");
LOG_I2C_DEVICE(this);
if (this->is_failed()) {
ESP_LOGE(TAG, "Communication with MAX17043 failed");
}
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "Battery Voltage", this->voltage_sensor_);
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
}
float MAX17043Component::get_setup_priority() const { return setup_priority::DATA; }
void MAX17043Component::sleep_mode() {
if (!this->is_failed()) {
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
this->status_set_warning();
}
}
}
} // namespace max17043
} // namespace esphome
@@ -1,29 +0,0 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome {
namespace max17043 {
class MAX17043Component : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
float get_setup_priority() const override;
void update() override;
void sleep_mode();
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
battery_remaining_sensor_ = battery_remaining_sensor;
}
protected:
sensor::Sensor *voltage_sensor_{nullptr};
sensor::Sensor *battery_remaining_sensor_{nullptr};
};
} // namespace max17043
} // namespace esphome
@@ -1,77 +0,0 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_ID,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
ENTITY_CATEGORY_DIAGNOSTIC,
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
UNIT_VOLT,
)
DEPENDENCIES = ["i2c"]
max17043_ns = cg.esphome_ns.namespace("max17043")
MAX17043Component = max17043_ns.class_(
"MAX17043Component", cg.PollingComponent, i2c.I2CDevice
)
# Actions
SleepAction = max17043_ns.class_("SleepAction", automation.Action)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(MAX17043Component),
cv.Optional(CONF_BATTERY_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_VOLTAGE,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=3,
device_class=DEVICE_CLASS_BATTERY,
state_class=STATE_CLASS_MEASUREMENT,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x36))
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
if voltage_config := config.get(CONF_BATTERY_VOLTAGE):
sens = await sensor.new_sensor(voltage_config)
cg.add(var.set_voltage_sensor(sens))
if CONF_BATTERY_LEVEL in config:
sens = await sensor.new_sensor(config[CONF_BATTERY_LEVEL])
cg.add(var.set_battery_remaining_sensor(sens))
MAX17043_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(MAX17043Component),
}
)
@automation.register_action("max17043.sleep_mode", SleepAction, MAX17043_ACTION_SCHEMA)
async def max17043_sleep_mode_to_code(config, action_id, template_arg, args):
paren = await cg.get_variable(config[CONF_ID])
return cg.new_Pvariable(action_id, template_arg, paren)
-176
View File
@@ -1,176 +0,0 @@
psram:
mode: octal
speed: 80MHz
i2c:
id: i2c_main
sda: 8
scl: 9
frequency: 400kHz
scan: false
# CH422G I/O expander (Waveshare uses it for LCD reset/backlight/touch reset)
ch422g:
- id: ch422g_hub
i2c_id: i2c_main
# --- Backlight control (CH422G IO2 is common for Waveshare LCD BL) ---
switch:
- platform: gpio
name: "LCD Backlight Raw"
id: lcd_backlight_raw
restore_mode: ALWAYS_ON
pin:
ch422g: ch422g_hub
number: 2
mode:
output: true
# A nicer HA-exposed control (so you can also automate it from HA)
light:
- platform: binary
name: "HA Remote Backlight"
output: lcd_backlight_out
id: ha_remote_backlight
output:
- platform: template
id: lcd_backlight_out
type: binary
write_action:
- if:
condition:
lambda: return state;
then:
- switch.turn_on: lcd_backlight_raw
else:
- switch.turn_off: lcd_backlight_raw
# --- Inactivity tracking (dim/off + wake on touch) ---
globals:
- id: last_activity_ms
type: uint32_t
restore_value: no
initial_value: '0'
interval:
- interval: 1s
then:
- lambda: |-
if (id(last_activity_ms) == 0) id(last_activity_ms) = millis();
# Turn off backlight ONCE at 2 minutes idle.
- if:
condition:
lambda: |-
const uint32_t idle_s = (millis() - id(last_activity_ms)) / 1000;
return idle_s == 120;
then:
- light.turn_off: ha_remote_backlight
# --- Display ---
display:
- platform: mipi_rgb
model: ESP32-S3-TOUCH-LCD-7-800X480
id: main_display
update_interval: never
auto_clear_enabled: false
reset_pin:
ch422g: ch422g_hub
number: 3
mode:
output: true
# --- Touch ---
touchscreen:
platform: gt911
id: touch_panel
i2c_id: i2c_main
interrupt_pin: 4
reset_pin:
ch422g: ch422g_hub
number: 1
mode:
output: true
on_touch:
then:
- lambda: |-
id(last_activity_ms) = millis();
- light.turn_on: ha_remote_backlight
# --- MDI Icon Font ---
font:
- file: "https://raw.githubusercontent.com/Templarian/MaterialDesign-Webfont/master/fonts/materialdesignicons-webfont.ttf"
id: mdi_icons
size: 24
bpp: 4
glyphs:
# Tile icons
- "\U000F0335" # mdi:lightbulb
- "\U000F0425" # mdi:power
- "\U000F0426" # mdi:power-plug
- "\U000F07E9" # mdi:power-socket-us
- "\U000F1A26" # mdi:toggle-switch-variant-off
# Battery status icons
- "\U000F0079" # mdi:battery
- "\U000F12A1" # mdi:battery-low
- "\U000F12A2" # mdi:battery-medium
- "\U000F12A3" # mdi:battery-high
- "\U000F12A4" # mdi:battery-charging-low
- "\U000F12A5" # mdi:battery-charging-medium
- "\U000F12A6" # mdi:battery-charging-high
- "\U000F0091" # mdi:battery-unknown
- "\U000F10CD" # mdi:battery-alert-variant-outline
# Navigation bar icons
- "\U000F04B9" # mdi:sofa
- "\U000F06B5" # mdi:lamp
- "\U000F04DE" # mdi:stove
- "\U000F12BD" # mdi:stairs-up
- "\U000F1239" # mdi:desk
- "\U000F06D9" # mdi:garage
- "\U000F0531" # mdi:tree
# --- LVGL UI ---
# Note: On ESP32-S3-Touch-LCD-7, GPIO14 is used by the RGB display bus,
# so it cannot be reused as ADC for battery telemetry in this display mode.
# --- Battery fuel gauge (Adafruit MAX17048 via compatible driver) ---
sensor:
- platform: max17043
id: max17048_battery
i2c_id: i2c_main
update_interval: 120s
battery_voltage:
name: "Remote Battery Voltage"
id: remote_battery_voltage
entity_category: diagnostic
device_class: voltage
state_class: measurement
accuracy_decimals: 3
on_value:
then:
- lambda: |-
ESP_LOGI("battery", "Voltage: %.3f V", x);
battery_level:
name: "Remote Battery Level"
id: remote_battery_level
entity_category: diagnostic
device_class: battery
state_class: measurement
accuracy_decimals: 0
on_value:
then:
- lambda: |-
const float pct = x;
ESP_LOGI("battery", "Level: %.0f%%", pct);
# - platform: custom
# lambda: |-
# auto max17048_sensor = new MAX17048Sensor();
# App.register_component(max17048_sensor);
# return {max17048_sensor->voltage_sensor, max17048_sensor->percentage_sensor};
# sensors:
# - name: "Voltage"
# unit_of_measurement: V
# accuracy_decimals: 2
# - name: "Percentage"
# unit_of_measurement: '%'
-383
View File
@@ -1,383 +0,0 @@
text_sensor:
- platform: wifi_info
ip_address:
name: "HA Remote IP"
id: ip_addr
- platform: homeassistant
id: ts_family_room_tv_stand
entity_id: light.family_room_tv_stand
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_family_room_tv_stand
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_family_room_tv_stand);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_small_family_room_lamp
entity_id: light.small_family_room_lamp
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_small_family_room_lamp
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_small_family_room_lamp);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_living_room_lamp_1
entity_id: light.living_room_lamp_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_family_room_standing_lamp
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x3D0002) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_family_room_standing_lamp);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_living_room_light_2
entity_id: light.living_room_light_2
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_living_room_main_light
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_living_room_main_light);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_patio_light_1
entity_id: light.patio_light_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_living_room_patio_light
state:
checked: !lambda return x == "on";
- lvgl.widget.update:
id: btn_outside_patio_light
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t off_color = lv_color_hex(0xE3E2E6);
auto *btn1 = id(btn_living_room_patio_light);
auto *btn2 = id(btn_outside_patio_light);
if (x == "on") {
lv_obj_set_style_text_color(btn1, lv_color_hex(0x1A0C00), static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn1); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn1, i), lv_color_hex(0x1A0C00), static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
lv_obj_set_style_text_color(btn2, lv_color_hex(0x001A33), static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn2); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn2, i), lv_color_hex(0x001A33), static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
} else {
lv_obj_set_style_text_color(btn1, off_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn1); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn1, i), off_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
lv_obj_set_style_text_color(btn2, off_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn2); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn2, i), off_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
}
- platform: homeassistant
id: ts_kitchen_sink_light
entity_id: light.kitchen_sink_light
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_kitchen_sink_light
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_kitchen_sink_light);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_office_lamp_2
entity_id: light.office_lamp_2
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_office_lamp
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_office_lamp);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_office_led_strip
entity_id: light.led_strip_controller_led_strip_controller
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_office_led_strip
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_office_led_strip);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_garage_cam_light
entity_id: light.esp32_saturn4_cam_esp32_saturn4_cam_light
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_garage_cam_light
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_garage_cam_light);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_office_echo_plug
entity_id: switch.office_echo_plug
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_office_echo_plug
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x1A0C00) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_office_echo_plug);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_office_end_table_lamp_outlet
entity_id: switch.office_end_table_lamp_socket_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_office_end_table_lamp_outlet
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001F15) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_office_end_table_lamp_outlet);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_office_wax_warmer_outlet
entity_id: switch.office_wax_warmer_socket_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_office_wax_warmer_outlet
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x1E0F3F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_office_wax_warmer_outlet);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_outside_lamppost_outlet_1
entity_id: switch.lamppost_outlets_socket_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_outside_lamppost_outlet_1
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_outside_lamppost_outlet_1);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_outside_lamppost_outlet_2
entity_id: switch.lamppost_outlets_socket_2
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_outside_lamppost_outlet_2
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x1A0C00) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_outside_lamppost_outlet_2);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_outside_porch_decor_outlet
entity_id: switch.washing_machine_socket_1
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_outside_porch_decor_outlet
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001F15) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_outside_porch_decor_outlet);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_living_room_garland_switch
entity_id: switch.big_family_room_lamp
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_living_room_garland_switch
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001F15) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_living_room_garland_switch);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_living_room_calendar_switch
entity_id: switch.digital_calendar
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_living_room_calendar_switch
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x1E0F3F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_living_room_calendar_switch);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_upstairs_airquality_oled
entity_id: switch.airqualitysensor_3_oled_power
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_upstairs_airquality_oled
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x001A33) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_upstairs_airquality_oled);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_upstairs_camera_motion
entity_id: switch.upstairs_camera_motion_detection
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_upstairs_camera_motion
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_upstairs_camera_motion);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_garage_fume_exhaust_fan
entity_id: switch.fume_exhaust_fan
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_garage_fume_exhaust_fan
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x22001F) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_garage_fume_exhaust_fan);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
- platform: homeassistant
id: ts_garage_resin_printer_heater
entity_id: switch.resin_printer_heater
internal: true
on_value:
then:
- lvgl.widget.update:
id: btn_garage_resin_printer_heater
state:
checked: !lambda return x == "on";
- lambda: |-
const lv_color_t text_color = (x == "on") ? lv_color_hex(0x1A0C00) : lv_color_hex(0xE3E2E6);
auto *btn = id(btn_garage_resin_printer_heater);
lv_obj_set_style_text_color(btn, text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
for (uint32_t i = 0; i < lv_obj_get_child_cnt(btn); i++) {
lv_obj_set_style_text_color(lv_obj_get_child(btn, i), text_color, static_cast<lv_style_selector_t>(LV_PART_MAIN | LV_STATE_DEFAULT));
}
File diff suppressed because it is too large Load Diff
@@ -1,49 +0,0 @@
# ===== I2S Audio =====
i2s_audio:
- id: i2s_in
i2s_lrclk_pin: ${i2s_in_lrclk_pin}
i2s_bclk_pin: ${i2s_in_bclk_pin}
- id: i2s_out
i2s_lrclk_pin: ${i2s_out_lrclk_pin}
i2s_bclk_pin: ${i2s_out_bclk_pin}
microphone:
- platform: i2s_audio
id: mic
i2s_audio_id: i2s_in
i2s_din_pin: ${mic_din_pin}
adc_type: external
pdm: false
bits_per_sample: 32bit
channel: left
speaker:
- platform: i2s_audio
id: spk
i2s_audio_id: i2s_out
i2s_dout_pin: ${speaker_dout_pin}
dac_type: external
bits_per_sample: 32bit
channel: mono
sample_rate: 16000
# Voice Assistant needs the speaker to fully stop after playback so it can
# leave RESPONSE_FINISHED and return to IDLE for the next wake word.
timeout: 500ms
rtttl:
id: volume_beep
speaker: spk
# ===== Wake Word =====
micro_wake_word:
id: mww
microphone: mic
models:
- model: okay_nabu
id: wake_word_model
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
- light.turn_on:
id: led_bar
effect: "Listening Pulse"
@@ -1,125 +0,0 @@
packages:
jarvis-satellite-led: !include jarvis-satellite-led.yaml
jarvis-satellite-voice: !include jarvis-satellite-voice.yaml
jarvis-satellite-audio: !include jarvis-satellite-audio.yaml
jarvis-satellite-ui: !include jarvis-satellite-ui.yaml
substitutions:
# Flash & PSRAM
flash_size: "8MB"
psram_mode: "quad"
psram_speed: "80MHz"
# CPU Frequency
cpu_frequency: "240MHz"
# I2S Microphone pins
i2s_in_lrclk_pin: GPIO6
i2s_in_bclk_pin: GPIO7
mic_din_pin: GPIO4
# I2S Speaker pins
i2s_out_lrclk_pin: GPIO45
i2s_out_bclk_pin: GPIO46
speaker_dout_pin: GPIO8
# LED bar
led_pin: GPIO16
led_num_leds: "8"
# Microphone sensitivity (tuned for INMP441)
mic_noise_suppression_level: "2"
mic_auto_gain: "10dBFS"
mic_volume_multiplier: "4.0"
# Button pins
btn_vol_up_pin: GPIO1
btn_vol_down_pin: GPIO2
btn_mute_pin: GPIO3
btn_wake_pin: GPIO10
# Change this to true in case you have a hidden SSID at home.
hidden_ssid: "true"
globals:
- id: volume_level
type: float
restore_value: true
initial_value: '0.8'
esphome:
platformio_options:
board_build.flash_mode: dio
on_boot:
- priority: -100
then:
- lambda: id(spk).set_volume(id(volume_level));
esp32:
board: esp32-s3-devkitc-1
variant: esp32s3
cpu_frequency: ${cpu_frequency}
flash_size: ${flash_size}
framework:
type: esp-idf
version: recommended
sdkconfig_options:
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240: "y"
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_ESP32S3_INSTRUCTION_CACHE_32KB: "y"
# Moves instructions and read only data from flash into PSRAM on boot.
# Both enabled allows instructions to execute while a flash operation is in progress without needing to be placed in IRAM.
# Considerably speeds up mWW at the cost of using more PSRAM.
CONFIG_SPIRAM_RODATA: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST: "y"
CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY: "y"
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC: "y"
CONFIG_MBEDTLS_SSL_PROTO_TLS1_3: "y"
wifi:
id: wifi_id
fast_connect: ${hidden_ssid}
network:
enable_ipv6: true
http_request:
web_server:
port: 80
psram:
mode: ${psram_mode}
speed: ${psram_speed}
captive_portal:
api:
encryption:
key: !secret api_encryption_key
on_client_connected:
- delay: 50ms
- if:
condition:
not:
lambda: 'return id(mww).is_running();'
then:
- micro_wake_word.start:
on_client_disconnected:
- micro_wake_word.stop:
- voice_assistant.stop:
logger:
level: DEBUG
logs:
micro_wake_word: DEBUG
voice_assistant: DEBUG
ota:
- platform: esphome
password: !secret ota_password
@@ -1,111 +0,0 @@
# ===== Physical Buttons =====
# Note: esp32_touch is not supported on ESP32-S3 with ESP-IDF.
# Wire physical momentary buttons to these pins (active LOW with internal pullup).
binary_sensor:
- platform: gpio
pin:
number: ${btn_vol_up_pin}
mode: INPUT_PULLUP
inverted: true
name: "Volume Up"
on_press:
- lambda: |-
float vol = min(1.0f, id(volume_level) + 0.1f);
id(volume_level) = vol;
id(spk).set_volume(vol);
- rtttl.play:
id: volume_beep
rtttl: "beep:d=32,o=6,b=180:c"
- light.turn_on:
id: led_bar
brightness: 100%
effect: "none"
- delay: 150ms
- light.turn_on:
id: led_bar
effect: "Idle Breathe"
- platform: gpio
pin:
number: ${btn_vol_down_pin}
mode: INPUT_PULLUP
inverted: true
name: "Volume Down"
on_press:
- lambda: |-
float vol = max(0.0f, id(volume_level) - 0.1f);
id(volume_level) = vol;
id(spk).set_volume(vol);
- rtttl.play:
id: volume_beep
rtttl: "beep:d=32,o=5,b=180:c"
- light.turn_on:
id: led_bar
brightness: 30%
effect: "none"
- delay: 150ms
- light.turn_on:
id: led_bar
effect: "Idle Breathe"
- platform: gpio
pin:
number: ${btn_mute_pin}
mode: INPUT_PULLUP
inverted: true
name: "Mic Mute"
on_press:
- switch.toggle: mic_mute
- platform: gpio
pin:
number: ${btn_wake_pin}
mode: INPUT_PULLUP
inverted: true
name: "Wake"
on_press:
- if:
condition:
switch.is_off: mic_mute
then:
- voice_assistant.start:
- light.turn_on:
id: led_bar
effect: "Listening Pulse"
else:
- light.turn_on:
id: led_bar
effect: "Error Flash"
- delay: 500ms
- light.turn_on:
id: led_bar
red: 100%
green: 30%
blue: 0%
brightness: 50%
effect: "none"
# ===== Mic Mute Switch =====
switch:
- platform: template
id: mic_mute
name: "Microphone Mute"
icon: "mdi:microphone-off"
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
on_turn_on:
- voice_assistant.stop:
- light.turn_on:
id: led_bar
effect: "none"
- light.turn_on:
id: led_bar
red: 100%
green: 30%
blue: 0%
brightness: 50%
on_turn_off:
- light.turn_on:
id: led_bar
effect: "Idle Breathe"
- voice_assistant.start:
@@ -1,67 +0,0 @@
# ===== LED Bar (WS2812B) =====
light:
- platform: esp32_rmt_led_strip
id: led_bar
chipset: WS2812
rgb_order: GRB
pin: ${led_pin}
num_leds: ${led_num_leds}
name: "Voice Satellite LEDs"
default_transition_length: 200ms
effects:
- addressable_lambda:
name: "Idle Breathe"
update_interval: 50ms
lambda: |-
static float brightness = 0;
static int direction = 1;
brightness += direction * 0.02;
if (brightness >= 1.0) { brightness = 1.0; direction = -1; }
if (brightness <= 0.1) { brightness = 0.1; direction = 1; }
for (int i = 0; i < it.size(); i++) {
it[i] = Color(0, 0, (int)(40 * brightness));
}
- addressable_lambda:
name: "Listening Pulse"
update_interval: 30ms
lambda: |-
static int pos = 0;
pos = (pos + 1) % (it.size() * 2);
for (int i = 0; i < it.size(); i++) {
int dist = abs(pos - i);
if (pos >= it.size()) dist = abs((it.size() * 2 - pos) - i);
int bright = max(0, 255 - dist * 60);
it[i] = Color(0, bright, bright);
}
- addressable_lambda:
name: "Processing"
update_interval: 80ms
lambda: |-
static int offset = 0;
offset = (offset + 1) % it.size();
for (int i = 0; i < it.size(); i++) {
if ((i + offset) % 2 == 0) {
it[i] = Color(80, 0, 180);
} else {
it[i] = Color(0, 0, 0);
}
}
- addressable_lambda:
name: "Speaking"
update_interval: 40ms
lambda: |-
static int wave = 0;
wave = (wave + 1) % 16;
for (int i = 0; i < it.size(); i++) {
int bright = (sin((wave + i * 2) * 0.4) + 1) * 127;
it[i] = Color(0, bright, 0);
}
- addressable_lambda:
name: "Error Flash"
update_interval: 100ms
lambda: |-
static bool on = false;
on = !on;
for (int i = 0; i < it.size(); i++) {
it[i] = on ? Color(255, 0, 0) : Color(0, 0, 0);
}
@@ -1,53 +0,0 @@
# ===== Web UI / HA Entities =====
script:
- id: publish_current_time
then:
- lambda: |-
auto time_now = id(homeassistant_time).now();
id(current_time_sensor).publish_state(time_now.strftime("%H:%M"));
time:
- platform: homeassistant
id: homeassistant_time
on_time_sync:
- script.execute: publish_current_time
on_time:
- seconds: 0
minutes: /1
then:
- script.execute: publish_current_time
text_sensor:
- platform: template
name: "Current device time"
id: current_time_sensor
icon: mdi:clock
number:
- platform: template
name: "Volume"
id: volume_number
icon: mdi:volume-high
entity_category: config
min_value: 0
max_value: 100
step: 5
unit_of_measurement: "%"
lambda: return id(volume_level) * 100.0f;
set_action:
- lambda: |-
float vol = x / 100.0f;
id(volume_level) = vol;
id(spk).set_volume(vol);
select:
- platform: logger
name: "Logger Level"
disabled_by_default: true
button:
- platform: restart
name: "Restart"
icon: mdi:restart
entity_category: config
@@ -1,97 +0,0 @@
# ===== Wake Word Sensitivity =====
select:
- platform: template
name: "Wake Word Sensitivity"
optimistic: true
initial_option: "Slightly sensitive"
restore_value: true
entity_category: config
icon: mdi:ear-hearing
options:
- Slightly sensitive
- Moderately sensitive
- Very sensitive
on_value:
lambda: |-
if (x == "Slightly sensitive") {
id(wake_word_model).set_probability_cutoff(247); // 0.97
} else if (x == "Moderately sensitive") {
id(wake_word_model).set_probability_cutoff(235); // 0.92
} else if (x == "Very sensitive") {
id(wake_word_model).set_probability_cutoff(212); // 0.83
}
# ===== Voice Assistant =====
voice_assistant:
id: va
microphone: mic
speaker: spk
micro_wake_word: mww
noise_suppression_level: ${mic_noise_suppression_level}
auto_gain: ${mic_auto_gain}
volume_multiplier: ${mic_volume_multiplier}
on_listening:
- logger.log: "[VA] on_listening — mic open, sending audio to HA"
- light.turn_on:
id: led_bar
effect: "Listening Pulse"
on_stt_vad_end:
- logger.log: "[VA] on_stt_vad_end — speech detected, processing"
- light.turn_on:
id: led_bar
effect: "Processing"
on_stt_end:
- lambda: |-
ESP_LOGI("va", "STT result: %s", x.c_str());
- light.turn_off:
id: led_bar
on_tts_start:
- lambda: |-
ESP_LOGI("va", "TTS start: %s", x.c_str());
- light.turn_on:
id: led_bar
effect: "Speaking"
on_tts_end:
- logger.log: "[VA] on_tts_end"
on_tts_stream_end:
- logger.log: "[VA] on_tts_stream_end"
on_error:
- lambda: |-
ESP_LOGE("va", "VA error: code=%s, message=%s", code.c_str(), message.c_str());
- if:
condition:
lambda: 'return code != "duplicate_wake_up_detected";'
then:
- light.turn_on:
id: led_bar
effect: "Error Flash"
- delay: 2s
- micro_wake_word.start:
on_end:
- logger.log: "[VA] on_end"
- wait_until:
not:
voice_assistant.is_running:
- light.turn_off:
id: led_bar
- if:
condition:
and:
- voice_assistant.connected:
- not:
micro_wake_word.is_running:
then:
- micro_wake_word.start:
on_idle:
- logger.log: "[VA] on_idle"
- light.turn_on:
id: led_bar
effect: "Idle Breathe"
- if:
condition:
and:
- voice_assistant.connected:
- not:
micro_wake_word.is_running:
then:
- micro_wake_word.start:
-135
View File
@@ -1,135 +0,0 @@
substitutions:
device_name: jordyn-a1-camera
friendly_name: "Jordyn A1 Camera"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: esp-idf
#cpu_frequency: 240MHZ
# Enable logging
logger:
# Enable Home Assistant API
api:
encryption:
key: "PDB5LTKAcNWMC+MeE03/SKpqX42pcnITzmfwu761LjI="
ota:
- platform: esphome
password: "d207583702e4b979e28a43f6f52d19ae"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "Jordyn-A1-Camera"
password: "jN4rzGokp5mt"
# Prevents aggressive reconnection attempts
power_save_mode: none # Use 'light' for battery devices
# Slower but more reliable connection
fast_connect: true
# Handle connection failures gracefully
on_connect:
- logger.log: "Wi-Fi connected!"
on_disconnect:
- logger.log: "Wi-Fi disconnected!"
# Reduce mDNS traffic
mdns:
disabled: false # Set to true if you use static IPs and don't need discovery
#captive_portal:
web_server:
port: 80
i2c:
- id: camera_i2c
sda: GPIO26
scl: GPIO27
psram:
mode: quad
speed: 80MHz
esp32_camera:
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id: camera_i2c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: 1024x768
jpeg_quality: 20
vertical_flip: False
horizontal_mirror: False
max_framerate: 40 fps
idle_framerate: 0.05 fps
# Image settings
name: esp32-jordyn-a1-cam
# brightness: 1
# #contrast: 0
# agc_gain_ceiling: 2X
# agc_mode: MANUAL
# agc_value: 5
# ...
esp32_camera_web_server:
- port: 80
mode: STREAM
- port: 8080
mode: SNAPSHOT
switch:
- platform: gpio
name: "jordyn-a1-cam-flash"
pin: 4
# Diagnostic sensors
sensor:
- platform: wifi_signal
name: "${device_name} WiFi Signal"
update_interval: 60s
- platform: uptime
name: "${device_name} Uptime"
update_interval: 60s
text_sensor:
- platform: wifi_info
ip_address:
name: "${device_name} IP Address"
ssid:
name: "${device_name} Connected SSID"
bssid:
name: "${device_name} BSSID"
# Watchdog to auto-reboot if things go wrong
interval:
- interval: 5min
then:
- if:
condition:
and:
- lambda: 'return millis() > 300000;' # >5 min uptime
- not:
wifi.connected:
then:
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
- delay: 10s
- lambda: 'App.safe_reboot();'
-122
View File
@@ -1,122 +0,0 @@
substitutions:
device_name: laundry-litter-box-camera
friendly_name: "Laundry Litter Box Camera"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: esp-idf
logger:
api:
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
password: !secret ota_password
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
manual_ip:
static_ip: 192.168.69.220
gateway: 192.168.69.1
subnet: 255.255.255.0
ap:
ssid: "laundry-litter-cam-fallback"
password: !secret fallback_password
# Prevents aggressive reconnection attempts
power_save_mode: none # Use 'light' for battery devices
# Slower but more reliable connection
fast_connect: true
# Handle connection failures gracefully
on_connect:
- logger.log: "Wi-Fi connected!"
on_disconnect:
- logger.log: "Wi-Fi disconnected!"
# Reduce mDNS traffic
mdns:
disabled: true # Set to true if you use static IPs and don't need discovery
# Diagnostic sensors
sensor:
- platform: wifi_signal
name: "${device_name} WiFi Signal"
update_interval: 60s
- platform: uptime
name: "${device_name} Uptime"
update_interval: 60s
text_sensor:
- platform: wifi_info
ip_address:
name: "${device_name} IP Address"
ssid:
name: "${device_name} Connected SSID"
bssid:
name: "${device_name} BSSID"
# Watchdog to auto-reboot if things go wrong
interval:
- interval: 5min
then:
- if:
condition:
and:
- lambda: 'return millis() > 300000;' # >5 min uptime
- not:
wifi.connected:
then:
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
- delay: 10s
- lambda: 'App.safe_reboot();'
captive_portal:
i2c:
- id: camera_i2c
sda: GPIO26
scl: GPIO27
psram:
mode: quad
speed: 80MHz
esp32_camera:
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id: camera_i2c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: 1024x768
jpeg_quality: 15
vertical_flip: true
horizontal_mirror: false
max_framerate: 20 fps
idle_framerate: 0.05 fps
name: ${device_name}
esp32_camera_web_server:
- port: 8080
mode: STREAM
- port: 8081
mode: SNAPSHOT
switch:
- platform: gpio
name: "${device_name}-flash"
pin: 4
-33
View File
@@ -1,33 +0,0 @@
packages:
jarvis-satellite: !include jarvis-satellite/jarvis-satellite-base.yaml
### Change the following for each satellite device, e.g. "jarvis-satellite-office" and "jarvis-satellite-bedroom" and to match each ESP board pinout
substitutions:
flash_size: "8MB"
psram_mode: "quad"
psram_speed: "80MHz"
i2s_in_lrclk_pin: GPIO6
i2s_in_bclk_pin: GPIO7
mic_din_pin: GPIO4
i2s_out_lrclk_pin: GPIO45
i2s_out_bclk_pin: GPIO46
speaker_dout_pin: GPIO8
led_pin: GPIO16
led_num_leds: "8"
btn_vol_up_pin: GPIO1
btn_vol_down_pin: GPIO2
btn_mute_pin: GPIO3
btn_wake_pin: GPIO10
cpu_frequency: "240MHz"
esphome:
name: office-jarvis-satellite
friendly_name: "Office Jarvis"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
ap:
ssid: "Jarvis-Satellite-AP"
password: !secret wifi_password
-133
View File
@@ -1,133 +0,0 @@
substitutions:
device_name: polly-a1-mini-camera
friendly_name: "Polly's A1 Camera"
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
esp32:
board: esp32dev
framework:
type: esp-idf
# minimum_chip_revision: 3.1
# Enable logging
logger:
level: INFO
# Enable Home Assistant API
api:
encryption:
key: "iLF5QCgTVCwgaA5vjTGez03Dfjvns8JvkcwmUN4NXR8="
ota:
- platform: esphome
password: "58f86a5e60abe60886cec2f65a1d3da5"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
manual_ip:
static_ip: 192.168.69.194
gateway: 192.168.69.1
subnet: 255.255.255.0
# Enable fallback hotspot (captive portal) in case wifi connection fails
ap:
ssid: "${friendly_name} Fallback"
password: !secret wifi_password
# Prevents aggressive reconnection attempts
power_save_mode: none # Use 'light' for battery devices
# Slower but more reliable connection
fast_connect: true
# Handle connection failures gracefully
on_connect:
- logger.log: "Wi-Fi connected!"
on_disconnect:
- logger.log: "Wi-Fi disconnected!"
# Reduce mDNS traffic
mdns:
disabled: true # Set to true if you use static IPs and don't need discovery
#captive_portal:
web_server:
port: 80
i2c:
- id: camera_i2c
sda: GPIO26
scl: GPIO27
psram:
mode: quad
speed: 80MHz
esp32_camera:
external_clock:
pin: GPIO0
frequency: 20MHz
i2c_id: camera_i2c
data_pins: [GPIO5, GPIO18, GPIO19, GPIO21, GPIO36, GPIO39, GPIO34, GPIO35]
vsync_pin: GPIO25
href_pin: GPIO23
pixel_clock_pin: GPIO22
power_down_pin: GPIO32
resolution: 1024X768
jpeg_quality: 20
vertical_flip: False
horizontal_mirror: True
max_framerate: 20 fps
idle_framerate: 0.05 fps
# Image settings
name: ${device_name}
esp32_camera_web_server:
- port: 8080
mode: STREAM
- port: 8081
mode: SNAPSHOT
switch:
- platform: gpio
name: "${device_name}-flash"
pin: 4
# Diagnostic sensors
sensor:
- platform: wifi_signal
name: "${device_name} WiFi Signal"
update_interval: 60s
- platform: uptime
name: "${device_name} Uptime"
update_interval: 60s
text_sensor:
- platform: wifi_info
ip_address:
name: "${device_name} IP Address"
ssid:
name: "${device_name} Connected SSID"
bssid:
name: "${device_name} BSSID"
# Watchdog to auto-reboot if things go wrong
interval:
- interval: 5min
then:
- if:
condition:
and:
- lambda: 'return millis() > 300000;' # >5 min uptime
- not:
wifi.connected:
then:
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
- delay: 10s
- lambda: 'App.safe_reboot();'
-10
View File
@@ -1,10 +0,0 @@
packages:
respeaker-satellite: !include respeaker-satellite/respeaker-satellite-base.yaml
esphome:
name: respeaker-satellite-fr
friendly_name: "Respeaker Satellite Family Room"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
-10
View File
@@ -1,10 +0,0 @@
packages:
respeaker-satellite: !include respeaker-satellite/respeaker-satellite-base.yaml
esphome:
name: respeaker-satellite-lr
friendly_name: "Respeaker Satellite Living Room"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
-10
View File
@@ -1,10 +0,0 @@
packages:
respeaker-satellite: !include respeaker-satellite/respeaker-satellite-base.yaml
esphome:
name: respeaker-satellite-mbr
friendly_name: "Respeaker Satellite Master Bedroom"
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
File diff suppressed because it is too large Load Diff
+56 -56
View File
@@ -1,65 +1,65 @@
################################################################################ # ################################################################################
# chore-tracker-ha.yaml (AUTO-GENERATED — edit chores_config.yaml) # # chore-tracker-ha.yaml (AUTO-GENERATED — edit chores_config.yaml)
# Kids: Jordyn, Declan, Chloe # # Kids: Jordyn, Declan, Chloe
# # #
# BIDIRECTIONAL SYNC: # # BIDIRECTIONAL SYNC:
# Screen to HA: Each switch calls homeassistant.service on toggle # # Screen to HA: Each switch calls homeassistant.service on toggle
# HA to Screen: ESPHome native API handles this automatically # # HA to Screen: ESPHome native API handles this automatically
################################################################################ # ################################################################################
input_button: # input_button:
jordyn_reset_chores: # jordyn_reset_chores:
name: "Reset Jordyn's Chores" # name: "Reset Jordyn's Chores"
icon: mdi:restart # icon: mdi:restart
declan_reset_chores: # declan_reset_chores:
name: "Reset Declan's Chores" # name: "Reset Declan's Chores"
icon: mdi:restart # icon: mdi:restart
chloe_reset_chores: # chloe_reset_chores:
name: "Reset Chloe's Chores" # name: "Reset Chloe's Chores"
icon: mdi:restart # icon: mdi:restart
reset_all_chores: # reset_all_chores:
name: "Reset All Chores" # name: "Reset All Chores"
icon: mdi:restart-alert # icon: mdi:restart-alert
template: # template:
- sensor: # - sensor:
- name: "Jordyn Chores Done Today" # - name: "Jordyn Chores Done Today"
unique_id: jordyn_chores_done_today # unique_id: jordyn_chores_done_today
icon: mdi:check-circle # icon: mdi:check-circle
state: > # state: >
{% set chores = [states('switch.chore_tracker_jordyn_put_on_underwear'), states('switch.chore_tracker_jordyn_brush_teeth'), states('switch.chore_tracker_jordyn_fill_water_bowls'), states('switch.chore_tracker_jordyn_restock_cat_food_cans')] %} # {% set chores = [states('switch.chore_tracker_jordyn_put_on_underwear'), states('switch.chore_tracker_jordyn_brush_teeth'), states('switch.chore_tracker_jordyn_fill_water_bowls'), states('switch.chore_tracker_jordyn_restock_cat_food_cans')] %}
{{ chores | select('equalto', 'on') | list | count }} # {{ chores | select('equalto', 'on') | list | count }}
- name: "Jordyn All Chores Done" # - name: "Jordyn All Chores Done"
unique_id: jordyn_all_chores_done # unique_id: jordyn_all_chores_done
icon: mdi:check-all # icon: mdi:check-all
state: > # state: >
{{ states('sensor.jordyn_chores_done_today') | int == 4 }} # {{ states('sensor.jordyn_chores_done_today') | int == 4 }}
- name: "Declan Chores Done Today" # - name: "Declan Chores Done Today"
unique_id: declan_chores_done_today # unique_id: declan_chores_done_today
icon: mdi:check-circle # icon: mdi:check-circle
state: > # state: >
{% set chores = [states('switch.chore_tracker_declan_take_morning_pill'), states('switch.chore_tracker_declan_scoop_dog_poop')] %} # {% set chores = [states('switch.chore_tracker_declan_take_morning_pill'), states('switch.chore_tracker_declan_scoop_dog_poop')] %}
{{ chores | select('equalto', 'on') | list | count }} # {{ chores | select('equalto', 'on') | list | count }}
- name: "Declan All Chores Done" # - name: "Declan All Chores Done"
unique_id: declan_all_chores_done # unique_id: declan_all_chores_done
icon: mdi:check-all # icon: mdi:check-all
state: > # state: >
{{ states('sensor.declan_chores_done_today') | int == 2 }} # {{ states('sensor.declan_chores_done_today') | int == 2 }}
- name: "Chloe Chores Done Today" # - name: "Chloe Chores Done Today"
unique_id: chloe_chores_done_today # unique_id: chloe_chores_done_today
icon: mdi:check-circle # icon: mdi:check-circle
state: > # state: >
{% set chores = [states('switch.chore_tracker_chloe_fill_kitty_feeders'), states('switch.chore_tracker_chloe_scoop_kitty_litter'), states('switch.chore_tracker_chloe_replace_kitty_litter_bags')] %} # {% set chores = [states('switch.chore_tracker_chloe_fill_kitty_feeders'), states('switch.chore_tracker_chloe_scoop_kitty_litter'), states('switch.chore_tracker_chloe_replace_kitty_litter_bags')] %}
{{ chores | select('equalto', 'on') | list | count }} # {{ chores | select('equalto', 'on') | list | count }}
- name: "Chloe All Chores Done" # - name: "Chloe All Chores Done"
unique_id: chloe_all_chores_done # unique_id: chloe_all_chores_done
icon: mdi:check-all # icon: mdi:check-all
state: > # state: >
{{ states('sensor.chloe_chores_done_today') | int == 3 }} # {{ states('sensor.chloe_chores_done_today') | int == 3 }}
+32 -32
View File
@@ -1,33 +1,33 @@
# Integration Health Monitor Package # # Integration Health Monitor Package
automation: # automation:
# ---------------------------------------------------------- # # ----------------------------------------------------------
# Kasa-Specific Recovery Attempt # # Kasa-Specific Recovery Attempt
# ---------------------------------------------------------- # # ----------------------------------------------------------
- id: integration_health_kasa_recovery # - id: integration_health_kasa_recovery
alias: "🔄 Attempt Kasa Integration Reload on Failure" # alias: "🔄 Attempt Kasa Integration Reload on Failure"
description: "Automatically attempts to reload Kasa integration after errors" # description: "Automatically attempts to reload Kasa integration after errors"
mode: single # mode: single
trigger: # trigger:
- platform: event # - platform: event
event_type: system_log_event # event_type: system_log_event
event_data: # event_data:
level: ERROR # level: ERROR
condition: # condition:
- condition: template # - condition: template
value_template: > # value_template: >
{{ 'kasa' in (trigger.event.data.name | default('') | lower) # {{ 'kasa' in (trigger.event.data.name | default('') | lower)
and '403' in (trigger.event.data.message | default('')) }} # and '403' in (trigger.event.data.message | default('')) }}
action: # action:
- delay: # - delay:
minutes: 2 # minutes: 2
- service: persistent_notification.create # - service: persistent_notification.create
data: # data:
title: "🔄 Attempting Kasa Recovery" # title: "🔄 Attempting Kasa Recovery"
message: "Detected Kasa authentication errors. Attempting integration reload..." # message: "Detected Kasa authentication errors. Attempting integration reload..."
notification_id: "kasa_recovery" # notification_id: "kasa_recovery"
- service: homeassistant.reload_config_entry # - service: homeassistant.reload_config_entry
target: # target:
entity_id: switch.studio_plug_1_2 # entity_id: switch.studio_plug_1_2
- delay: # - delay:
hours: 1 # hours: 1
-306
View File
@@ -1,306 +0,0 @@
# =============================================================================
# LITTER BOX MONITORING - MQTT Sensors & Automations
# =============================================================================
# Receives analysis results from litter_box_analyzer.py via MQTT and
# exposes them as Home Assistant entities with notifications.
#
# MQTT topics (published by the Python analyzer):
# litter_box/status/cat_present -> ON/OFF
# litter_box/status/motion_detected -> ON/OFF
# litter_box/status/motion_score -> float
# litter_box/status/box_state -> open/closed/unknown
# litter_box/status/box_diff_score -> float
# litter_box/status/litter_box_stuck -> ON/OFF
# litter_box/status/needs_scooping -> ON/OFF
# litter_box/status/cleanliness_score -> 0-100
# litter_box/status/status_message -> text
# =============================================================================
# ---------------------------------------------------------------------------
# MQTT Binary Sensors
# ---------------------------------------------------------------------------
mqtt:
binary_sensor:
- name: "Litter Box - Cat Present"
unique_id: litter_box_cat_present
state_topic: "litter_box/status/cat_present"
payload_on: "ON"
payload_off: "OFF"
device_class: occupancy
icon: "mdi:cat"
- name: "Litter Box - Motion Detected"
unique_id: litter_box_motion_detected
state_topic: "litter_box/status/motion_detected"
payload_on: "ON"
payload_off: "OFF"
device_class: motion
icon: "mdi:motion-sensor"
- name: "Litter Box - Stuck Closed"
unique_id: litter_box_stuck_closed
state_topic: "litter_box/status/litter_box_stuck"
payload_on: "ON"
payload_off: "OFF"
device_class: problem
icon: "mdi:alert-circle"
- name: "Litter Box - Needs Scooping"
unique_id: litter_box_needs_scooping
state_topic: "litter_box/status/needs_scooping"
payload_on: "ON"
payload_off: "OFF"
device_class: problem
icon: "mdi:shovel"
# ---------------------------------------------------------------------------
# MQTT Numeric Sensors
# ---------------------------------------------------------------------------
sensor:
- name: "Litter Box - Motion Score"
unique_id: litter_box_motion_score
state_topic: "litter_box/status/motion_score"
unit_of_measurement: "%"
icon: "mdi:chart-bell-curve"
- name: "Litter Box - Cleanliness Score"
unique_id: litter_box_cleanliness_score
state_topic: "litter_box/status/cleanliness_score"
unit_of_measurement: "%"
icon: "mdi:star"
- name: "Litter Box - Box Diff Score"
unique_id: litter_box_box_diff_score
state_topic: "litter_box/status/box_diff_score"
icon: "mdi:compare"
# MQTT text/state sensors
- name: "Litter Box - Box State"
unique_id: litter_box_box_state
state_topic: "litter_box/status/box_state"
icon: "mdi:state-machine"
- name: "Litter Box - Status Message"
unique_id: litter_box_status_message
state_topic: "litter_box/status/status_message"
icon: "mdi:message-text"
# ---------------------------------------------------------------------------
# Helper Toggles (for notification cooldown tracking)
# ---------------------------------------------------------------------------
input_boolean:
litter_box_stuck_notified:
name: "Litter Box Stuck - Notification Sent"
icon: "mdi:bell-off"
litter_box_cat_notified:
name: "Litter Box Cat - Notification Cooldown"
icon: "mdi:cat"
litter_box_scoop_notified:
name: "Litter Box Scoop - Notification Cooldown"
icon: "mdi:shovel"
# ---------------------------------------------------------------------------
# Timers for cooldown resets
# ---------------------------------------------------------------------------
timer:
litter_box_cat_cooldown:
name: "Cat Visit Cooldown"
duration: "00:15:00"
restore: true
litter_box_scoop_cooldown:
name: "Scoop Reminder Cooldown"
duration: "04:00:00"
restore: true
# ---------------------------------------------------------------------------
# Automations
# ---------------------------------------------------------------------------
automation:
# --- Keep IR on at night so the camera can see the litter box ---
- id: litter_box_ir_on_at_sunset
alias: "Litter Box Camera - IR On At Sunset"
triggers:
- trigger: sun
event: sunset
actions:
- action: light.turn_on
target:
entity_id: light.litter_box_camera_ir_night_vision
mode: single
- id: litter_box_ir_off_at_sunrise
alias: "Litter Box Camera - IR Off At Sunrise"
triggers:
- trigger: sun
event: sunrise
actions:
- action: light.turn_off
target:
entity_id: light.litter_box_camera_ir_night_vision
mode: single
# --- Initialize ESPHome analysis entities so alerts can trigger from known off/clean states ---
- id: litter_box_initialize_analysis_status
alias: "Litter Box Camera - Initialize Analysis Status"
triggers:
- trigger: homeassistant
event: start
- trigger: time_pattern
minutes: "/30"
conditions:
- condition: template
value_template: >-
{{ states('binary_sensor.litter_box_camera_cat_present') in ['unknown', 'unavailable']
or states('binary_sensor.litter_box_camera_needs_scooping') in ['unknown', 'unavailable']
or states('binary_sensor.litter_box_camera_litter_level_low') in ['unknown', 'unavailable']
or states('sensor.litter_box_camera_cleanliness_score') in ['unknown', 'unavailable'] }}
actions:
- action: esphome.litter_box_cam_update_status
data:
cat_present: false
needs_scooping: false
litter_level_low: false
cleanliness_score: 100
mode: single
# --- Request a camera capture after IR turns on so analysis has a lit frame at night ---
- id: litter_box_capture_after_ir_on
alias: "Litter Box Camera - Capture After IR Turns On"
triggers:
- trigger: state
entity_id: light.litter_box_camera_ir_night_vision
to: "on"
actions:
- delay: "00:00:05"
- action: button.press
target:
entity_id: button.litter_box_camera_capture_now
mode: single
# --- Cat Detected Notification ---
- id: litter_box_cat_detected
alias: "Litter Box - Cat Detected Alert"
triggers:
- trigger: state
entity_id: binary_sensor.litter_box_camera_cat_present
from: "off"
to: "on"
conditions:
- condition: state
entity_id: input_boolean.litter_box_cat_notified
state: "off"
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.litter_box_cat_notified
- action: timer.start
target:
entity_id: timer.litter_box_cat_cooldown
- action: notify.notify_events_cats
data:
message: "🐱 A cat is using the litter box right now!"
title: "Litter Box Activity"
mode: single
# --- Reset cat notification cooldown ---
- id: litter_box_cat_cooldown_reset
alias: "Litter Box - Reset Cat Notification Cooldown"
triggers:
- trigger: event
event_type: timer.finished
event_data:
entity_id: timer.litter_box_cat_cooldown
actions:
- action: input_boolean.turn_off
target:
entity_id: input_boolean.litter_box_cat_notified
mode: single
# --- Stuck Closed Alert (status text confirms rotated closed) ---
- id: litter_box_stuck_alert
alias: "Litter Box - Stuck Closed Alert"
initial_state: true
triggers:
- trigger: state
entity_id: sensor.litter_box_camera_status_message
conditions:
- condition: template
value_template: >-
{{ 'stuck' in states('sensor.litter_box_camera_status_message') | lower
or 'closed' in states('sensor.litter_box_camera_status_message') | lower }}
- condition: state
entity_id: input_boolean.litter_box_stuck_notified
state: "off"
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.litter_box_stuck_notified
- action: notify.notify_events_cats
data:
message: "⚠️ The litter box is STUCK ROTATED CLOSED! Visual state is 'closed' — check the mechanism."
title: "Litter Box STUCK CLOSED!"
mode: single
# --- Reset stuck notification when status returns to normal/open ---
- id: litter_box_stuck_reset
alias: "Litter Box - Reset Stuck Notification on Open"
initial_state: true
triggers:
- trigger: state
entity_id: sensor.litter_box_camera_status_message
conditions:
- condition: template
value_template: >-
{{ states('sensor.litter_box_camera_status_message') | lower in ['ok', 'calibrated. monitoring started.', 'image sent for analysis']
or 'open' in states('sensor.litter_box_camera_status_message') | lower }}
- condition: state
entity_id: input_boolean.litter_box_stuck_notified
state: "on"
actions:
- action: input_boolean.turn_off
target:
entity_id: input_boolean.litter_box_stuck_notified
mode: single
# --- Needs Scooping Alert ---
- id: litter_box_needs_scooping_alert
alias: "Litter Box - Needs Scooping Alert"
triggers:
- trigger: state
entity_id: binary_sensor.litter_box_camera_needs_scooping
from: "off"
to: "on"
conditions:
- condition: state
entity_id: input_boolean.litter_box_scoop_notified
state: "off"
actions:
- action: input_boolean.turn_on
target:
entity_id: input_boolean.litter_box_scoop_notified
- action: timer.start
target:
entity_id: timer.litter_box_scoop_cooldown
- action: notify.notify_events_cats
data:
message: "🧹 The litter box could use a scoop! Cleanliness: {{ states('sensor.litter_box_camera_cleanliness_score') }}%"
title: "Litter Box Needs Scooping"
mode: single
# --- Reset scoop notification cooldown ---
- id: litter_box_scoop_cooldown_reset
alias: "Litter Box - Reset Scoop Notification Cooldown"
triggers:
- trigger: event
event_type: timer.finished
event_data:
entity_id: timer.litter_box_scoop_cooldown
actions:
- action: input_boolean.turn_off
target:
entity_id: input_boolean.litter_box_scoop_notified
mode: single
+59 -59
View File
@@ -1,62 +1,62 @@
################################################################################ # ################################################################################
# lunch_menus.yaml # # lunch_menus.yaml
# # #
# Pulls Mon–Fri of the current school week from each lunch Google Calendar into # # Pulls Mon–Fri of the current school week from each lunch Google Calendar into
# template sensors. On Sat/Sun, advances to next week's Monday. The Lunch Menus # # template sensors. On Sat/Sun, advances to next week's Monday. The Lunch Menus
# dashboard renders those attributes — the stock calendar card has no school- # # dashboard renders those attributes — the stock calendar card has no school-
# week view. # # week view.
################################################################################ # ################################################################################
template: # template:
- trigger: # - trigger:
- platform: time_pattern # - platform: time_pattern
minutes: "/15" # minutes: "/15"
- platform: homeassistant # - platform: homeassistant
event: start # event: start
- platform: event # - platform: event
event_type: event_template_reloaded # event_type: event_template_reloaded
action: # action:
- variables: # - variables:
# weekday(): Mon=0 … Sun=6. Weekend → next Monday. # # weekday(): Mon=0 … Sun=6. Weekend → next Monday.
school_monday: >- # school_monday: >-
{% set wd = now().weekday() %} # {% set wd = now().weekday() %}
{% if wd >= 5 %} # {% if wd >= 5 %}
{{ (today_at() + timedelta(days=(7 - wd))).isoformat() }} # {{ (today_at() + timedelta(days=(7 - wd))).isoformat() }}
{% else %} # {% else %}
{{ (today_at() - timedelta(days=wd)).isoformat() }} # {{ (today_at() - timedelta(days=wd)).isoformat() }}
{% endif %} # {% endif %}
- action: calendar.get_events # - action: calendar.get_events
target: # target:
entity_id: # entity_id:
- calendar.bhs_lunch_menu # - calendar.bhs_lunch_menu
- calendar.bis_school_lunches # - calendar.bis_school_lunches
- calendar.hms_lunch_menu # - calendar.hms_lunch_menu
data: # data:
start_date_time: "{{ school_monday }}" # start_date_time: "{{ school_monday }}"
duration: # duration:
days: 5 # days: 5
response_variable: agenda # response_variable: agenda
sensor: # sensor:
- name: "BHS Lunch Menu Week" # - name: "BHS Lunch Menu Week"
unique_id: bhs_lunch_menu_week # unique_id: bhs_lunch_menu_week
icon: mdi:food-apple # icon: mdi:food-apple
state: "{{ agenda['calendar.bhs_lunch_menu'].events | count }}" # state: "{{ agenda['calendar.bhs_lunch_menu'].events | count }}"
attributes: # attributes:
events: "{{ agenda['calendar.bhs_lunch_menu'].events }}" # events: "{{ agenda['calendar.bhs_lunch_menu'].events }}"
week_start: "{{ school_monday }}" # week_start: "{{ school_monday }}"
- name: "BIS Lunch Menu Week" # - name: "BIS Lunch Menu Week"
unique_id: bis_lunch_menu_week # unique_id: bis_lunch_menu_week
icon: mdi:food-apple # icon: mdi:food-apple
state: "{{ agenda['calendar.bis_school_lunches'].events | count }}" # state: "{{ agenda['calendar.bis_school_lunches'].events | count }}"
attributes: # attributes:
events: "{{ agenda['calendar.bis_school_lunches'].events }}" # events: "{{ agenda['calendar.bis_school_lunches'].events }}"
week_start: "{{ school_monday }}" # week_start: "{{ school_monday }}"
- name: "HMS Lunch Menu Week" # - name: "HMS Lunch Menu Week"
unique_id: hms_lunch_menu_week # unique_id: hms_lunch_menu_week
icon: mdi:food-apple # icon: mdi:food-apple
state: "{{ agenda['calendar.hms_lunch_menu'].events | count }}" # state: "{{ agenda['calendar.hms_lunch_menu'].events | count }}"
attributes: # attributes:
events: "{{ agenda['calendar.hms_lunch_menu'].events }}" # events: "{{ agenda['calendar.hms_lunch_menu'].events }}"
week_start: "{{ school_monday }}" # week_start: "{{ school_monday }}"
-125
View File
@@ -1,125 +0,0 @@
# Unified Home Assistant-managed alarms for ESPHome voice satellites.
# This keeps one consistent alarm UX across Jarvis and ReSpeaker devices.
#
# The assist_satellite entity IDs below are derived from the ESPHome device names:
# - office-jarvis-satellite -> assist_satellite.office_jarvis_satellite_assist_satellite
# - respeaker-satellite-fr -> assist_satellite.respeaker_satellite_fr_assist_satellite
# - respeaker-satellite-lr -> assist_satellite.respeaker_satellite_lr_assist_satellite
# - respeaker-satellite-mbr -> assist_satellite.respeaker_satellite_mbr_assist_satellite
# If Home Assistant generated different entity IDs in your instance, adjust them here
# and in dashboards/views/voice-alarms-dashboard.yaml.
input_boolean:
voice_alarm_office_jarvis_enabled:
name: Office Jarvis Alarm Enabled
icon: mdi:alarm
voice_alarm_respeaker_family_room_enabled:
name: ReSpeaker Family Room Alarm Enabled
icon: mdi:alarm
voice_alarm_respeaker_living_room_enabled:
name: ReSpeaker Living Room Alarm Enabled
icon: mdi:alarm
voice_alarm_respeaker_master_bedroom_enabled:
name: ReSpeaker Master Bedroom Alarm Enabled
icon: mdi:alarm
input_datetime:
voice_alarm_office_jarvis_time:
name: Office Jarvis Alarm Time
has_date: false
has_time: true
voice_alarm_respeaker_family_room_time:
name: ReSpeaker Family Room Alarm Time
has_date: false
has_time: true
voice_alarm_respeaker_living_room_time:
name: ReSpeaker Living Room Alarm Time
has_date: false
has_time: true
voice_alarm_respeaker_master_bedroom_time:
name: ReSpeaker Master Bedroom Alarm Time
has_date: false
has_time: true
script:
voice_satellite_alarm_announce:
alias: Voice Satellite Alarm Announce
description: Repeat an alarm announcement on a specific Assist satellite.
mode: parallel
fields:
satellite_entity:
name: Assist satellite entity
required: true
selector:
entity:
filter:
domain: assist_satellite
device_name:
name: Device name
required: true
selector:
text:
message:
name: Message
required: false
selector:
text:
sequence:
- repeat:
count: 3
sequence:
- action: assist_satellite.announce
target:
entity_id: "{{ satellite_entity }}"
data:
message: >-
{% set custom_message = message | default('', true) %}
{{ custom_message if custom_message else 'Alarm. Attention in ' ~ device_name ~ '.' }}
preannounce: true
- delay: "00:00:08"
test_voice_alarm_office_jarvis:
alias: Test Voice Alarm - Office Jarvis
mode: single
sequence:
- action: script.voice_satellite_alarm_announce
data:
satellite_entity: assist_satellite.office_jarvis_satellite_assist_satellite
device_name: Office Jarvis
message: This is a test alarm for Office Jarvis.
test_voice_alarm_respeaker_family_room:
alias: Test Voice Alarm - ReSpeaker Family Room
mode: single
sequence:
- action: script.voice_satellite_alarm_announce
data:
satellite_entity: assist_satellite.respeaker_satellite_fr_assist_satellite
device_name: ReSpeaker Family Room
message: This is a test alarm for the ReSpeaker in the family room.
test_voice_alarm_respeaker_living_room:
alias: Test Voice Alarm - ReSpeaker Living Room
mode: single
sequence:
- action: script.voice_satellite_alarm_announce
data:
satellite_entity: assist_satellite.respeaker_satellite_lr_assist_satellite
device_name: ReSpeaker Living Room
message: This is a test alarm for the ReSpeaker in the living room.
test_voice_alarm_respeaker_master_bedroom:
alias: Test Voice Alarm - ReSpeaker Master Bedroom
mode: single
sequence:
- action: script.voice_satellite_alarm_announce
data:
satellite_entity: assist_satellite.respeaker_satellite_mbr_assist_satellite
device_name: ReSpeaker Master Bedroom
message: This is a test alarm for the ReSpeaker in the master bedroom.
-403
View File
@@ -1,403 +0,0 @@
#!/usr/bin/env python3
"""
Litter Box Camera Analyzer - MQTT-based computer vision service.
Subscribes to base64-encoded JPEG images from the DFRobot DFR1154 ESP32-S3
camera (published via ESPHome to MQTT) and runs analysis to detect:
- Cat presence (motion-based: sustained blob in frame)
- Box visual state (open vs. rotated/closed) via frame comparison
- Litter box stuck closed (prolonged non-open state)
- Cleanliness / needs scooping
Publishes results back via MQTT for Home Assistant to consume.
Dependencies: pip3 install opencv-python numpy paho-mqtt
"""
import argparse
import base64
import logging
import os
import signal
import sys
import time
from collections import deque
from datetime import datetime, timezone
import cv2
import numpy as np
import paho.mqtt.client as mqtt
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
CONFIG = {
"mqtt_broker": os.environ.get("MQTT_BROKER", "localhost"),
"mqtt_port": int(os.environ.get("MQTT_PORT", "1883")),
"mqtt_username": os.environ.get("MQTT_USERNAME", ""),
"mqtt_password": os.environ.get("MQTT_PASSWORD", ""),
"mqtt_topic_image": "litter_box/camera/image",
"mqtt_topic_prefix": "litter_box/status",
"motion_threshold": 15.0, # avg pixel delta for motion (0-255)
"cat_detection_frames": 4, # consecutive motion frames to confirm cat
"cat_absent_frames": 3, # consecutive no-motion to confirm cat left
"box_state_threshold": 30.0, # avg diff from open ref -> closed state
"stuck_closed_frames": 10, # consecutive "closed" frames before stuck alert
"analysis_cooldown": 2.0, # seconds between analyses
"health_check_interval": 300, # seconds between health checks when no motion
"calibration_frames": 10, # frames to average for open reference
"debug_dir": os.environ.get("DEBUG_DIR", ""),
}
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger("litter_box")
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
shutdown_flag = False
mqtt_client: mqtt.Client | None = None
reference_open: np.ndarray | None = None # averaged "open/normal" frame
motion_streak: int = 0
no_motion_streak: int = 0
cat_present = False
cat_visit_completed = False
box_closed_streak: int = 0 # consecutive frames in "closed" state
box_stuck_notified = False
box_state: str = "unknown" # "open", "closed", "unknown"
cleanliness_score: int = 100
needs_scooping = False
calibration_frames: deque = deque(maxlen=CONFIG["calibration_frames"])
calibrated = False
last_analysis_time: float = 0.0
last_mqtt_publish: dict = {} # avoid re-publishing same values
# ---------------------------------------------------------------------------
# Image decoding
# ---------------------------------------------------------------------------
def decode_image(payload: bytes) -> np.ndarray | None:
try:
raw = base64.b64decode(payload)
arr = np.frombuffer(raw, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
log.warning("cv2.imdecode returned None (%d bytes)", len(raw))
return img
except Exception as exc:
log.error("Failed to decode image: %s", exc)
return None
# ---------------------------------------------------------------------------
# Image comparison helpers
# ---------------------------------------------------------------------------
def _downsample_gray(img: np.ndarray, size=(50, 40)) -> np.ndarray:
small = cv2.resize(img, size, interpolation=cv2.INTER_AREA)
return cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
def compute_diff_score(frame: np.ndarray, reference: np.ndarray) -> float:
"""Return mean absolute pixel difference (0-255) between frame and reference."""
f = _downsample_gray(frame)
r = _downsample_gray(reference)
return float(np.mean(np.abs(f.astype(float) - r.astype(float))))
def compute_cleanliness(frame: np.ndarray, reference: np.ndarray) -> int:
"""Return cleanliness % (100 = pristine, matches reference; 0 = very different)."""
diff = compute_diff_score(frame, reference)
raw = max(0.0, 100.0 - (diff * 2.5)) # scale: diff 40 -> 0%, diff 0 -> 100%
return int(min(100, raw))
def build_average_frame(frames: list[np.ndarray]) -> np.ndarray:
stacked = np.stack([f.astype(np.float32) for f in frames], axis=0)
return np.mean(stacked, axis=0).astype(np.uint8)
# ---------------------------------------------------------------------------
# Core analysis
# ---------------------------------------------------------------------------
def analyze_frame(frame: np.ndarray) -> dict:
global reference_open, motion_streak, no_motion_streak
global cat_present, cat_visit_completed
global box_closed_streak, box_stuck_notified, box_state
global cleanliness_score, needs_scooping
global calibration_frames, calibrated
results = {
"cat_present": False,
"cat_visit_complete": False,
"motion_detected": False,
"motion_score": 0.0,
"box_state": "unknown",
"litter_box_stuck": False,
"needs_scooping": False,
"cleanliness_score": 100,
"status_message": "OK",
}
# --- Calibration phase: build "open/normal" reference ---
if not calibrated:
calibration_frames.append(frame.copy())
results["status_message"] = f"Calibrating {len(calibration_frames)}/{CONFIG['calibration_frames']}..."
if len(calibration_frames) >= CONFIG["calibration_frames"]:
reference_open = build_average_frame(list(calibration_frames))
calibrated = True
log.info("Calibration complete - open reference frame established")
results["status_message"] = "Calibrated. Monitoring started."
return results
if reference_open is None:
results["status_message"] = "No reference frame - recalibrating..."
calibrated = False
return results
# --- 1. Box visual state detection (open vs. closed) ---
diff_from_open = compute_diff_score(frame, reference_open)
box_is_closed = diff_from_open > CONFIG["box_state_threshold"]
if box_is_closed:
box_closed_streak += 1
results["box_state"] = "closed"
if box_closed_streak >= CONFIG["stuck_closed_frames"] and not box_stuck_notified:
results["litter_box_stuck"] = True
box_stuck_notified = True
results["status_message"] = (
f"STUCK CLOSED - box closed for {box_closed_streak} frames"
)
else:
box_closed_streak = 0
box_stuck_notified = False
results["box_state"] = "open"
box_state = results["box_state"]
results["box_diff_score"] = round(diff_from_open, 1)
# --- 2. Cat presence via motion relative to the open reference ---
motion_score = diff_from_open # reuse - motion is deviation from empty/open state
results["motion_score"] = round(motion_score, 1)
if motion_score > CONFIG["motion_threshold"]:
results["motion_detected"] = True
motion_streak += 1
no_motion_streak = 0
if motion_streak >= CONFIG["cat_detection_frames"]:
results["cat_present"] = True
cat_present = True
else:
no_motion_streak += 1
motion_streak = 0
if cat_present and no_motion_streak >= CONFIG["cat_absent_frames"]:
cat_present = False
results["cat_visit_complete"] = True
results["cat_present"] = cat_present
# --- 3. Cleanliness ---
cleanliness = compute_cleanliness(frame, reference_open)
results["cleanliness_score"] = cleanliness
if cleanliness < 45:
results["needs_scooping"] = True
results["status_message"] = results.get("status_message", "") + " Needs scooping."
cleanliness_score = cleanliness
needs_scooping = results["needs_scooping"]
# --- 4. Debug ---
if CONFIG["debug_dir"]:
_save_debug(frame, results)
return results
# ---------------------------------------------------------------------------
# Debug frame saving
# ---------------------------------------------------------------------------
def _save_debug(frame: np.ndarray, results: dict) -> None:
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
path = os.path.join(CONFIG["debug_dir"], f"litter_{ts}.jpg")
annotated = frame.copy()
y = 30
for label, color in [
(f"Box: {results.get('box_state', '?')} Diff: {results.get('box_diff_score', 0):.1f}", (255, 255, 0)),
(f"Motion: {results.get('motion_score', 0):.1f} Clean: {results.get('cleanliness_score', 100)}%", (0, 255, 0)),
]:
cv2.putText(annotated, label, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
y += 20
if results.get("cat_present"):
cv2.putText(annotated, "CAT", (annotated.shape[1] - 100, 30),
cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
if results.get("litter_box_stuck"):
cv2.putText(annotated, "STUCK!", (annotated.shape[1] - 140, 60),
cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 0, 255), 3)
cv2.imwrite(path, annotated)
# ---------------------------------------------------------------------------
# MQTT Callbacks
# ---------------------------------------------------------------------------
def on_connect(client, userdata, flags, reason_code, properties=None):
if reason_code == 0:
log.info("Connected to MQTT broker at %s:%d", CONFIG["mqtt_broker"], CONFIG["mqtt_port"])
client.subscribe(CONFIG["mqtt_topic_image"])
log.info("Subscribed to %s", CONFIG["mqtt_topic_image"])
else:
log.error("MQTT connection failed: rc=%d", reason_code)
def on_disconnect(client, userdata, flags, reason_code, properties=None):
log.warning("MQTT disconnected (rc=%d), auto-reconnecting", reason_code)
def on_message(client, userdata, msg):
global last_analysis_time
now = time.time()
if now - last_analysis_time < CONFIG["analysis_cooldown"]:
return
img = decode_image(msg.payload)
if img is None:
return
last_analysis_time = now
results = analyze_frame(img)
_publish_results(results)
def _should_publish(topic: str, value: str) -> bool:
"""Skip re-publishing the same value to reduce MQTT traffic."""
if last_mqtt_publish.get(topic) == value:
return False
last_mqtt_publish[topic] = value
return True
def _publish_results(results: dict) -> None:
prefix = CONFIG["mqtt_topic_prefix"]
publish = mqtt_client.publish
cat_val = "ON" if results.get("cat_present") else "OFF"
motion_val = "ON" if results.get("motion_detected") else "OFF"
stuck_val = "ON" if results.get("litter_box_stuck") else "OFF"
scoop_val = "ON" if results.get("needs_scooping") else "OFF"
motion_score_val = str(results.get("motion_score", 0))
clean_val = str(results.get("cleanliness_score", 100))
box_state_val = results.get("box_state", "unknown")
msg_val = results.get("status_message", "OK")
publish(f"{prefix}/cat_present", cat_val, retain=False)
publish(f"{prefix}/motion_detected", motion_val, retain=False)
publish(f"{prefix}/motion_score", motion_score_val, retain=False)
publish(f"{prefix}/litter_box_stuck", stuck_val, retain=False)
publish(f"{prefix}/needs_scooping", scoop_val, retain=False)
publish(f"{prefix}/cleanliness_score", clean_val, retain=False)
publish(f"{prefix}/box_state", box_state_val, retain=False)
publish(f"{prefix}/status_message", msg_val, retain=False)
if results.get("cat_visit_complete"):
log.info("Cat visit completed!")
if results.get("litter_box_stuck"):
log.warning("LITTER BOX STUCK CLOSED!")
# ---------------------------------------------------------------------------
# Signal handling
# ---------------------------------------------------------------------------
def signal_handler(sig, frame):
global shutdown_flag
log.info("Received signal %d, shutting down...", sig)
shutdown_flag = True
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
global mqtt_client
parser = argparse.ArgumentParser(description="Litter Box Camera Analyzer")
parser.add_argument("--broker", default=CONFIG["mqtt_broker"], help="MQTT broker host")
parser.add_argument("--port", type=int, default=CONFIG["mqtt_port"], help="MQTT broker port")
parser.add_argument("--username", default=CONFIG["mqtt_username"], help="MQTT username")
parser.add_argument("--password", default=CONFIG["mqtt_password"], help="MQTT password")
parser.add_argument("--debug-dir", default=CONFIG["debug_dir"],
help="Save annotated images to this directory")
parser.add_argument("--motion-threshold", type=float, default=CONFIG["motion_threshold"],
help="Avg pixel diff for motion (0-255, default 15)")
parser.add_argument("--box-state-threshold", type=float, default=CONFIG["box_state_threshold"],
help="Avg pixel diff from open ref to flag closed (default 30)")
parser.add_argument("--stuck-frames", type=int, default=CONFIG["stuck_closed_frames"],
help="Consecutive 'closed' frames before stuck alert")
parser.add_argument("--calibration-frames", type=int, default=CONFIG["calibration_frames"],
help="Frames to average for open reference")
args = parser.parse_args()
CONFIG["mqtt_broker"] = args.broker
CONFIG["mqtt_port"] = args.port
CONFIG["mqtt_username"] = args.username
CONFIG["mqtt_password"] = args.password
CONFIG["motion_threshold"] = args.motion_threshold
CONFIG["box_state_threshold"] = args.box_state_threshold
CONFIG["stuck_closed_frames"] = args.stuck_frames
CONFIG["calibration_frames"] = args.calibration_frames
if args.debug_dir:
CONFIG["debug_dir"] = args.debug_dir
os.makedirs(args.debug_dir, exist_ok=True)
calibration_frames.clear()
for _ in range(CONFIG["calibration_frames"]):
calibration_frames.append(None)
calibration_frames.clear()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="litter_box_analyzer")
mqtt_client.on_connect = on_connect
mqtt_client.on_disconnect = on_disconnect
mqtt_client.on_message = on_message
if CONFIG["mqtt_username"]:
mqtt_client.username_pw_set(CONFIG["mqtt_username"], CONFIG["mqtt_password"])
mqtt_client.connect_async(CONFIG["mqtt_broker"], CONFIG["mqtt_port"], keepalive=60)
mqtt_client.loop_start()
log.info("=== Litter Box Analyzer Started ===")
log.info(" MQTT: %s:%d", CONFIG["mqtt_broker"], CONFIG["mqtt_port"])
log.info(" Image topic: %s", CONFIG["mqtt_topic_image"])
log.info(" Status prefix: %s", CONFIG["mqtt_topic_prefix"])
log.info(" Motion threshold: %.1f Box closed threshold: %.1f",
CONFIG["motion_threshold"], CONFIG["box_state_threshold"])
log.info(" Stuck-closed frames: %d Calibration: %d frames",
CONFIG["stuck_closed_frames"], CONFIG["calibration_frames"])
log.info(" Waiting for %d frames to calibrate open reference...",
CONFIG["calibration_frames"])
try:
while not shutdown_flag:
time.sleep(1)
finally:
mqtt_client.loop_stop()
mqtt_client.disconnect()
log.info("Litter Box Analyzer stopped")
if __name__ == "__main__":
main()
-87
View File
@@ -1,87 +0,0 @@
#!/bin/bash
# Start / stop the Litter Box Camera Analyzer service.
# This script manages the Python analyzer as a background process.
#
# Usage:
# ./litter_box_analyzer.sh start
# ./litter_box_analyzer.sh stop
# ./litter_box_analyzer.sh status
#
# Configuration (edit or set env vars):
# MQTT_BROKER - MQTT broker hostname/IP (default: localhost)
# MQTT_PORT - MQTT port (default: 1883)
# MQTT_USERNAME - MQTT username (default: none)
# MQTT_PASSWORD - MQTT password (default: none)
# DEBUG_DIR - Save annotated debug images (default: none)
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ANALYZER="${SCRIPT_DIR}/litter_box_analyzer.py"
PIDFILE="${SCRIPT_DIR}/.litter_box_analyzer.pid"
LOGFILE="${SCRIPT_DIR}/litter_box_analyzer.log"
MQTT_BROKER="${MQTT_BROKER:-localhost}"
MQTT_PORT="${MQTT_PORT:-1883}"
MOTION_THRESHOLD="${MOTION_THRESHOLD:-15}"
BOX_STATE_THRESHOLD="${BOX_STATE_THRESHOLD:-30}"
STUCK_FRAMES="${STUCK_FRAMES:-10}"
CALIBRATION_FRAMES="${CALIBRATION_FRAMES:-10}"
start() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Analyzer is already running (PID $(cat "$PIDFILE"))"
return 1
fi
local extra_args=()
[ -n "${DEBUG_DIR:-}" ] && extra_args+=(--debug-dir "$DEBUG_DIR")
echo "Starting Litter Box Analyzer..."
python3 "$ANALYZER" \
--broker "$MQTT_BROKER" \
--port "$MQTT_PORT" \
--motion-threshold "$MOTION_THRESHOLD" \
--box-state-threshold "$BOX_STATE_THRESHOLD" \
--stuck-frames "$STUCK_FRAMES" \
--calibration-frames "$CALIBRATION_FRAMES" \
"${extra_args[@]}" \
>> "$LOGFILE" 2>&1 &
echo $! > "$PIDFILE"
echo "Started (PID $!). Log: $LOGFILE"
}
stop() {
if [ ! -f "$PIDFILE" ]; then
echo "No PID file found. Analyzer may not be running."
return 1
fi
PID=$(cat "$PIDFILE")
if kill "$PID" 2>/dev/null; then
echo "Stopped analyzer (PID $PID)"
rm -f "$PIDFILE"
else
echo "Process $PID not found. Removing stale PID file."
rm -f "$PIDFILE"
fi
}
status() {
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Analyzer is running (PID $(cat "$PIDFILE"))"
else
echo "Analyzer is NOT running"
[ -f "$PIDFILE" ] && echo "(stale PID file exists)" && rm -f "$PIDFILE"
fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
status) status ;;
*)
echo "Usage: $0 {start|stop|restart|status}"
exit 1
;;
esac