Compare commits
64 Commits
8aa8ed3273
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1934f3806 | ||
|
|
38b3ab8f56 | ||
|
|
a0e2687bf5 | ||
|
|
c95ad4cd2c | ||
|
|
d32f26da94 | ||
|
|
a4700b673e | ||
|
|
3bd797bcc5 | ||
|
|
1a486240a0 | ||
|
|
ef0841de3f | ||
|
|
6434885ee9 | ||
|
|
72cc7c4524 | ||
|
|
ed17ec0a4d | ||
|
|
a8537e087c | ||
|
|
82ba5d420c | ||
|
|
a2eb2b6106 | ||
|
|
34c92deb26 | ||
|
|
560449b1c3 | ||
|
|
9e2b0b108c | ||
|
|
653d708b78 | ||
|
|
56a378f783 | ||
|
|
812c3fd820 | ||
|
|
cced711c68 | ||
|
|
0569620c6c | ||
|
|
d75297fde4 | ||
|
|
5d761acf69 | ||
|
|
fd0d061f96 | ||
|
|
c684ecba49 | ||
|
|
1b8ec2f489 | ||
|
|
495224d3eb | ||
|
|
eea8f239fa | ||
|
|
6dcab86122 | ||
|
|
7d332586ea | ||
|
|
2220cec970 | ||
|
|
ee85f43a92 | ||
|
|
3a0d7da7f6 | ||
|
|
31aaab69b7 | ||
|
|
a41523a7ac | ||
|
|
71f35fe822 | ||
|
|
6dd6e39c04 | ||
|
|
01e02033f6 | ||
|
|
be876dc24d | ||
|
|
9b86dca27e | ||
|
|
b1bb0deb0e | ||
|
|
f32acd86e4 | ||
|
|
45c9186f25 | ||
|
|
ccb50f1c37 | ||
|
|
0c3f065f64 | ||
|
|
eadb67b2fc | ||
|
|
a9b9559c23 | ||
|
|
487f3f0207 | ||
|
|
7ad2a7b498 | ||
|
|
2681623838 | ||
|
|
57f5745afe | ||
|
|
039c3a901c | ||
|
|
dd0b85137f | ||
|
|
f0c41a6fdc | ||
|
|
58e4ca6c03 | ||
|
|
4aecb61673 | ||
|
|
72f03f9045 | ||
|
|
c43a3767dd | ||
|
|
240fe06229 | ||
|
|
0acf0ab8bf | ||
| d56fa9d393 | |||
| 9cdc4ecba0 |
293
esphome/cat-medication-tracker.yaml
Normal file
293
esphome/cat-medication-tracker.yaml
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
substitutions:
|
||||||
|
name: cat-medication-tracker
|
||||||
|
friendly_name: "Cat Medication Tracker"
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: ${name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
includes:
|
||||||
|
- spi_helper.h
|
||||||
|
on_boot:
|
||||||
|
- priority: 100
|
||||||
|
then:
|
||||||
|
- light.turn_on: backlight
|
||||||
|
- priority: -100
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
// MADCTL 0x48: MY=0, MX=1, BGR=1 — correct portrait, no mirror for ESP32-2432S035R
|
||||||
|
// ESP-IDF SPI master API used to bypass ESPHome's buffered display layer
|
||||||
|
spi_device_handle_t disp_fix;
|
||||||
|
spi_device_interface_config_t cfg = {};
|
||||||
|
cfg.clock_speed_hz = 1000000;
|
||||||
|
cfg.mode = 0;
|
||||||
|
cfg.spics_io_num = -1; // manual CS
|
||||||
|
cfg.queue_size = 1;
|
||||||
|
if (spi_bus_add_device(SPI2_HOST, &cfg, &disp_fix) == ESP_OK) {
|
||||||
|
gpio_set_level((gpio_num_t)15, 0); // CS low
|
||||||
|
gpio_set_level((gpio_num_t)2, 0); // DC = command
|
||||||
|
spi_transaction_t t = {};
|
||||||
|
t.length = 8;
|
||||||
|
t.flags = SPI_TRANS_USE_TXDATA;
|
||||||
|
t.tx_data[0] = 0x36; // MADCTL register
|
||||||
|
spi_device_polling_transmit(disp_fix, &t);
|
||||||
|
gpio_set_level((gpio_num_t)2, 1); // DC = data
|
||||||
|
t.tx_data[0] = 0x48; // MY=0, MX=1, BGR=1
|
||||||
|
spi_device_polling_transmit(disp_fix, &t);
|
||||||
|
gpio_set_level((gpio_num_t)15, 1); // CS high
|
||||||
|
spi_bus_remove_device(disp_fix);
|
||||||
|
}
|
||||||
|
- component.update: my_display
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: arduino
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: DEBUG
|
||||||
|
logs:
|
||||||
|
xpt2046: WARN
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_encryption_key
|
||||||
|
|
||||||
|
ota:
|
||||||
|
platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_iot_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
ap:
|
||||||
|
ssid: "${name}-fallback"
|
||||||
|
password: !secret fallback_password
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
time:
|
||||||
|
- platform: homeassistant
|
||||||
|
id: homeassistant_time
|
||||||
|
on_time:
|
||||||
|
- seconds: 0
|
||||||
|
minutes: 0
|
||||||
|
hours: 0
|
||||||
|
then:
|
||||||
|
- switch.turn_off: penelope_medicated
|
||||||
|
- switch.turn_off: tess_medicated
|
||||||
|
- script.execute: update_display
|
||||||
|
|
||||||
|
spi:
|
||||||
|
- id: tft_spi
|
||||||
|
clk_pin: GPIO14
|
||||||
|
mosi_pin: GPIO13
|
||||||
|
miso_pin: GPIO12
|
||||||
|
|
||||||
|
display:
|
||||||
|
- platform: mipi_spi
|
||||||
|
model: ILI9488
|
||||||
|
spi_id: tft_spi
|
||||||
|
cs_pin: GPIO15
|
||||||
|
dc_pin: GPIO2
|
||||||
|
reset_pin: GPIO4
|
||||||
|
rotation: 0
|
||||||
|
invert_colors: false
|
||||||
|
color_order: bgr
|
||||||
|
data_rate: 10MHz
|
||||||
|
dimensions:
|
||||||
|
width: 320
|
||||||
|
height: 480
|
||||||
|
id: my_display
|
||||||
|
auto_clear_enabled: false
|
||||||
|
update_interval: 2s
|
||||||
|
color_depth: 16
|
||||||
|
buffer_size: 25%
|
||||||
|
lambda: |-
|
||||||
|
// Colors
|
||||||
|
auto red = Color(255, 0, 0);
|
||||||
|
auto green = Color(0, 200, 0);
|
||||||
|
auto light_grey = Color(200, 200, 200);
|
||||||
|
auto white = Color(255, 255, 255);
|
||||||
|
auto black = Color(0, 0, 0);
|
||||||
|
auto dark_grey = Color(80, 80, 80);
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
it.fill(light_grey);
|
||||||
|
|
||||||
|
// Border: green if all done, red otherwise
|
||||||
|
bool all_done = id(penelope_medicated).state && id(tess_medicated).state;
|
||||||
|
auto border_color = all_done ? green : red;
|
||||||
|
|
||||||
|
int border = 10;
|
||||||
|
it.filled_rectangle(0, 0, 320, border, border_color);
|
||||||
|
it.filled_rectangle(0, 480 - border, 320, border, border_color);
|
||||||
|
it.filled_rectangle(0, 0, border, 480, border_color);
|
||||||
|
it.filled_rectangle(320 - border, 0, border, 480, border_color);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
it.printf(160, 30, id(title_font), black, TextAlign::TOP_CENTER, "Cat Meds");
|
||||||
|
|
||||||
|
// Penelope button
|
||||||
|
int btn_x = 40;
|
||||||
|
int btn_y = 90;
|
||||||
|
int btn_w = 240;
|
||||||
|
int btn_h = 120;
|
||||||
|
|
||||||
|
auto penelope_color = id(penelope_medicated).state ? green : red;
|
||||||
|
it.filled_rectangle(btn_x, btn_y, btn_w, btn_h, penelope_color);
|
||||||
|
it.rectangle(btn_x, btn_y, btn_w, btn_h, dark_grey);
|
||||||
|
it.printf(btn_x + btn_w/2, btn_y + btn_h/2, id(button_font), white, TextAlign::CENTER, "Penelope");
|
||||||
|
if (id(penelope_medicated).state) {
|
||||||
|
it.printf(btn_x + btn_w/2, btn_y + btn_h - 20, id(status_font), white, TextAlign::CENTER, "DONE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tess button
|
||||||
|
btn_y = 230;
|
||||||
|
auto tess_color = id(tess_medicated).state ? green : red;
|
||||||
|
it.filled_rectangle(btn_x, btn_y, btn_w, btn_h, tess_color);
|
||||||
|
it.rectangle(btn_x, btn_y, btn_w, btn_h, dark_grey);
|
||||||
|
it.printf(btn_x + btn_w/2, btn_y + btn_h/2, id(button_font), white, TextAlign::CENTER, "Tess");
|
||||||
|
if (id(tess_medicated).state) {
|
||||||
|
it.printf(btn_x + btn_w/2, btn_y + btn_h - 20, id(status_font), white, TextAlign::CENTER, "DONE");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
int reset_x = 110;
|
||||||
|
int reset_y = 395;
|
||||||
|
int reset_w = 100;
|
||||||
|
int reset_h = 55;
|
||||||
|
it.filled_rectangle(reset_x, reset_y, reset_w, reset_h, dark_grey);
|
||||||
|
it.rectangle(reset_x, reset_y, reset_w, reset_h, black);
|
||||||
|
it.printf(reset_x + reset_w/2, reset_y + reset_h/2, id(status_font), white, TextAlign::CENTER, "RESET");
|
||||||
|
|
||||||
|
# XPT2046 Touchscreen
|
||||||
|
touchscreen:
|
||||||
|
- platform: xpt2046
|
||||||
|
id: my_touchscreen
|
||||||
|
spi_id: tft_spi
|
||||||
|
cs_pin: GPIO33
|
||||||
|
update_interval: 250ms
|
||||||
|
threshold: 1200
|
||||||
|
calibration:
|
||||||
|
x_min: 280
|
||||||
|
x_max: 3850
|
||||||
|
y_min: 340
|
||||||
|
y_max: 3860
|
||||||
|
|
||||||
|
# Touch buttons as binary sensors
|
||||||
|
binary_sensor:
|
||||||
|
- platform: touchscreen
|
||||||
|
touchscreen_id: my_touchscreen
|
||||||
|
name: "Penelope Button"
|
||||||
|
id: penelope_button
|
||||||
|
x_min: 40
|
||||||
|
x_max: 280
|
||||||
|
y_min: 90
|
||||||
|
y_max: 210
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
- switch.toggle: penelope_medicated
|
||||||
|
|
||||||
|
- platform: touchscreen
|
||||||
|
touchscreen_id: my_touchscreen
|
||||||
|
name: "Tess Button"
|
||||||
|
id: tess_button
|
||||||
|
x_min: 40
|
||||||
|
x_max: 280
|
||||||
|
y_min: 230
|
||||||
|
y_max: 350
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
- switch.toggle: tess_medicated
|
||||||
|
|
||||||
|
- platform: touchscreen
|
||||||
|
touchscreen_id: my_touchscreen
|
||||||
|
name: "Reset Button"
|
||||||
|
id: reset_button
|
||||||
|
x_min: 110
|
||||||
|
x_max: 210
|
||||||
|
y_min: 395
|
||||||
|
y_max: 450
|
||||||
|
on_press:
|
||||||
|
then:
|
||||||
|
- switch.turn_off: penelope_medicated
|
||||||
|
- switch.turn_off: tess_medicated
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Penelope Medication Status"
|
||||||
|
lambda: 'return id(penelope_medicated).state;'
|
||||||
|
device_class: running
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Tess Medication Status"
|
||||||
|
lambda: 'return id(tess_medicated).state;'
|
||||||
|
device_class: running
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "All Cats Medicated"
|
||||||
|
lambda: 'return id(penelope_medicated).state && id(tess_medicated).state;'
|
||||||
|
device_class: running
|
||||||
|
|
||||||
|
# Backlight control
|
||||||
|
output:
|
||||||
|
- platform: gpio
|
||||||
|
pin: GPIO27
|
||||||
|
id: backlight_pwm
|
||||||
|
inverted: false
|
||||||
|
|
||||||
|
light:
|
||||||
|
- platform: binary
|
||||||
|
output: backlight_pwm
|
||||||
|
name: "${friendly_name} Backlight"
|
||||||
|
id: backlight
|
||||||
|
restore_mode: ALWAYS_ON
|
||||||
|
|
||||||
|
# Medication state switches (exposed to Home Assistant)
|
||||||
|
switch:
|
||||||
|
- platform: template
|
||||||
|
name: "Penelope Medicated"
|
||||||
|
id: penelope_medicated
|
||||||
|
optimistic: true
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
on_turn_on:
|
||||||
|
- script.execute: update_display
|
||||||
|
on_turn_off:
|
||||||
|
- script.execute: update_display
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Tess Medicated"
|
||||||
|
id: tess_medicated
|
||||||
|
optimistic: true
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
on_turn_on:
|
||||||
|
- script.execute: update_display
|
||||||
|
on_turn_off:
|
||||||
|
- script.execute: update_display
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Reset All Medications"
|
||||||
|
id: reset_all
|
||||||
|
optimistic: false
|
||||||
|
turn_on_action:
|
||||||
|
- switch.turn_off: penelope_medicated
|
||||||
|
- switch.turn_off: tess_medicated
|
||||||
|
|
||||||
|
# Script to update display
|
||||||
|
script:
|
||||||
|
- id: update_display
|
||||||
|
then:
|
||||||
|
- component.update: my_display
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
font:
|
||||||
|
- file: "gfonts://Roboto"
|
||||||
|
id: title_font
|
||||||
|
size: 28
|
||||||
|
|
||||||
|
- file: "gfonts://Roboto"
|
||||||
|
id: button_font
|
||||||
|
size: 24
|
||||||
|
|
||||||
|
- file: "gfonts://Roboto"
|
||||||
|
id: status_font
|
||||||
|
size: 14
|
||||||
@@ -35,22 +35,45 @@ captive_portal:
|
|||||||
web_server:
|
web_server:
|
||||||
port: 80
|
port: 80
|
||||||
|
|
||||||
|
# GPIO Outputs for fan control
|
||||||
|
# IMPORTANT: inverted: true because NPN transistor driver inverts the logic
|
||||||
|
# GPIO HIGH -> NPN ON -> Gate LOW -> Fan OFF
|
||||||
|
# GPIO LOW -> NPN OFF -> Gate HIGH (5V via pull-up) -> Fan ON
|
||||||
output:
|
output:
|
||||||
- platform: gpio
|
- platform: gpio
|
||||||
pin:
|
pin:
|
||||||
number: GPIO3
|
number: GPIO3 # D1 on XIAO
|
||||||
inverted: true # Fix for driver inversion
|
inverted: true
|
||||||
id: fan_80mm_output
|
id: fan_80mm_output
|
||||||
|
|
||||||
- platform: gpio
|
- platform: gpio
|
||||||
pin:
|
pin:
|
||||||
number: GPIO4
|
number: GPIO4 # D2 on XIAO
|
||||||
inverted: true
|
inverted: true
|
||||||
id: fan_120mm_output
|
id: fan_120mm_output
|
||||||
|
|
||||||
|
# Fan entities for Home Assistant
|
||||||
fan:
|
fan:
|
||||||
- platform: binary
|
- platform: binary
|
||||||
name: "80mm Exhaust Fan"
|
name: "80mm Exhaust Fan"
|
||||||
|
id: fan_80mm
|
||||||
output: fan_80mm_output
|
output: fan_80mm_output
|
||||||
|
|
||||||
- platform: binary
|
- platform: binary
|
||||||
name: "120mm Exhaust Fan"
|
name: "120mm Exhaust Fan"
|
||||||
|
id: fan_120mm
|
||||||
output: fan_120mm_output
|
output: fan_120mm_output
|
||||||
|
|
||||||
|
# Optional: Add a switch to control both fans together
|
||||||
|
switch:
|
||||||
|
- platform: template
|
||||||
|
name: "All Fans"
|
||||||
|
id: all_fans
|
||||||
|
turn_on_action:
|
||||||
|
- fan.turn_on: fan_80mm
|
||||||
|
- fan.turn_on: fan_120mm
|
||||||
|
turn_off_action:
|
||||||
|
- fan.turn_off: fan_80mm
|
||||||
|
- fan.turn_off: fan_120mm
|
||||||
|
lambda: |-
|
||||||
|
return id(fan_80mm).state && id(fan_120mm).state;
|
||||||
335
esphome/litter-box-cam.yaml
Normal file
335
esphome/litter-box-cam.yaml
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# LITTER BOX MONITOR - DFRobot ESP32-S3 AI Camera
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
substitutions:
|
||||||
|
device_name: litter-box-cam
|
||||||
|
friendly_name: "Litter Box Camera"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Core Device Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
esphome:
|
||||||
|
name: ${device_name}
|
||||||
|
friendly_name: ${friendly_name}
|
||||||
|
platformio_options:
|
||||||
|
build_flags: "-DBOARD_HAS_PSRAM"
|
||||||
|
board_build.arduino.memory_type: qio_opi
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32-s3-devkitc-1
|
||||||
|
framework:
|
||||||
|
type: arduino
|
||||||
|
version: latest
|
||||||
|
flash_size: 16MB
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# PSRAM Configuration (Critical for camera)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
psram:
|
||||||
|
mode: octal
|
||||||
|
speed: 80MHz
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Logging & API
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
logger:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_encryption_key
|
||||||
|
|
||||||
|
services:
|
||||||
|
- service: capture_and_analyze
|
||||||
|
then:
|
||||||
|
- script.execute: capture_image_script
|
||||||
|
|
||||||
|
- service: update_status
|
||||||
|
variables:
|
||||||
|
needs_scooping: bool
|
||||||
|
litter_level_low: bool
|
||||||
|
cat_present: bool
|
||||||
|
cleanliness_score: int
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(needs_scooping_sensor).publish_state(needs_scooping);
|
||||||
|
id(litter_level_low_sensor).publish_state(litter_level_low);
|
||||||
|
id(cat_present_sensor).publish_state(cat_present);
|
||||||
|
id(cleanliness_score_sensor).publish_state(cleanliness_score);
|
||||||
|
id(last_analysis_time).publish_state(id(homeassistant_time).now().timestamp);
|
||||||
|
if (cat_present) {
|
||||||
|
id(daily_cat_visits) += 1;
|
||||||
|
id(cat_visits_sensor).publish_state(id(daily_cat_visits));
|
||||||
|
}
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WiFi Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_iot_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
ap:
|
||||||
|
ssid: "${device_name}-AP"
|
||||||
|
password: !secret wifi_password
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# MQTT Configuration
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
mqtt:
|
||||||
|
id: mqtt_client
|
||||||
|
broker: !secret mqtt_broker
|
||||||
|
username: !secret mqtt_username
|
||||||
|
password: !secret mqtt_password
|
||||||
|
topic_prefix: litter_box
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Time Synchronization
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
time:
|
||||||
|
- platform: homeassistant
|
||||||
|
id: homeassistant_time
|
||||||
|
on_time:
|
||||||
|
- seconds: 0
|
||||||
|
minutes: 0
|
||||||
|
hours: 0
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
id(daily_cat_visits) = 0;
|
||||||
|
id(cat_visits_sensor).publish_state(0);
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Global Variables
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
globals:
|
||||||
|
- id: daily_cat_visits
|
||||||
|
type: int
|
||||||
|
restore_value: yes
|
||||||
|
initial_value: '0'
|
||||||
|
|
||||||
|
- id: motion_cooldown_active
|
||||||
|
type: bool
|
||||||
|
initial_value: 'false'
|
||||||
|
|
||||||
|
- id: publish_next_image
|
||||||
|
type: bool
|
||||||
|
initial_value: 'false'
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Camera Configuration (OV3660 - DFR1154 Specific)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
esp32_camera:
|
||||||
|
id: litter_cam
|
||||||
|
name: "Litter Box Camera"
|
||||||
|
|
||||||
|
# Hardware Interface - DFR1154 Pinout
|
||||||
|
external_clock:
|
||||||
|
pin: GPIO5
|
||||||
|
frequency: 20MHz
|
||||||
|
i2c_pins:
|
||||||
|
sda: GPIO8
|
||||||
|
scl: GPIO9
|
||||||
|
data_pins: [GPIO16, GPIO18, GPIO21, GPIO17, GPIO14, GPIO7, GPIO6, GPIO4]
|
||||||
|
vsync_pin: GPIO1
|
||||||
|
href_pin: GPIO2
|
||||||
|
pixel_clock_pin: GPIO15
|
||||||
|
|
||||||
|
# Image Parameters
|
||||||
|
max_framerate: 10 fps
|
||||||
|
idle_framerate: 0.1 fps
|
||||||
|
resolution: 800x600
|
||||||
|
jpeg_quality: 10
|
||||||
|
vertical_flip: true
|
||||||
|
horizontal_mirror: false
|
||||||
|
aec_mode: AUTO
|
||||||
|
agc_mode: AUTO
|
||||||
|
wb_mode: AUTO
|
||||||
|
brightness: 0
|
||||||
|
contrast: 1
|
||||||
|
saturation: 0
|
||||||
|
on_image:
|
||||||
|
then:
|
||||||
|
- lambda: |-
|
||||||
|
if (!id(publish_next_image)) return;
|
||||||
|
id(publish_next_image) = false;
|
||||||
|
|
||||||
|
const uint8_t *data = x->get_data();
|
||||||
|
size_t len = x->get_data_length();
|
||||||
|
size_t out_len = 4 * ((len + 2) / 3);
|
||||||
|
char *buf = (char *)malloc(out_len + 1);
|
||||||
|
if (!buf) return;
|
||||||
|
|
||||||
|
static const char b64[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
size_t j = 0;
|
||||||
|
for (size_t i = 0; i < len; i += 3) {
|
||||||
|
uint32_t b = ((uint32_t)data[i] << 16);
|
||||||
|
if (i + 1 < len) b |= ((uint32_t)data[i + 1] << 8);
|
||||||
|
if (i + 2 < len) b |= data[i + 2];
|
||||||
|
buf[j++] = b64[(b >> 18) & 0x3F];
|
||||||
|
buf[j++] = b64[(b >> 12) & 0x3F];
|
||||||
|
buf[j++] = (i + 1 < len) ? b64[(b >> 6) & 0x3F] : '=';
|
||||||
|
buf[j++] = (i + 2 < len) ? b64[b & 0x3F] : '=';
|
||||||
|
}
|
||||||
|
buf[j] = '\0';
|
||||||
|
|
||||||
|
id(mqtt_client).publish("litter_box/camera/image", buf);
|
||||||
|
ESP_LOGI("capture", "Published image (%d bytes raw, %d base64)", len, j);
|
||||||
|
id(status_message).publish_state("Image sent for analysis");
|
||||||
|
free(buf);
|
||||||
|
|
||||||
|
# Web server for manual viewing
|
||||||
|
esp32_camera_web_server:
|
||||||
|
- port: 8080
|
||||||
|
mode: stream
|
||||||
|
- port: 8081
|
||||||
|
mode: snapshot
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# GPIO Outputs
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
output:
|
||||||
|
- id: led_status
|
||||||
|
platform: gpio
|
||||||
|
pin: GPIO3
|
||||||
|
|
||||||
|
- id: ir_led
|
||||||
|
platform: gpio
|
||||||
|
pin: GPIO47
|
||||||
|
|
||||||
|
- id: speaker_gain
|
||||||
|
platform: gpio
|
||||||
|
pin: GPIO41
|
||||||
|
|
||||||
|
- id: speaker_mode
|
||||||
|
platform: gpio
|
||||||
|
pin: GPIO40
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Lights
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
light:
|
||||||
|
- platform: binary
|
||||||
|
name: "Status LED"
|
||||||
|
id: status_led
|
||||||
|
output: led_status
|
||||||
|
|
||||||
|
- platform: binary
|
||||||
|
name: "IR Night Vision"
|
||||||
|
id: ir_night_vision
|
||||||
|
output: ir_led
|
||||||
|
restore_mode: RESTORE_DEFAULT_OFF
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Binary Sensors
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
binary_sensor:
|
||||||
|
- platform: template
|
||||||
|
name: "Motion Detected"
|
||||||
|
id: motion_detected
|
||||||
|
device_class: motion
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Needs Scooping"
|
||||||
|
id: needs_scooping_sensor
|
||||||
|
device_class: problem
|
||||||
|
icon: "mdi:shovel"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Litter Level Low"
|
||||||
|
id: litter_level_low_sensor
|
||||||
|
device_class: problem
|
||||||
|
icon: "mdi:cup-outline"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Cat Present"
|
||||||
|
id: cat_present_sensor
|
||||||
|
device_class: occupancy
|
||||||
|
icon: "mdi:cat"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Sensors
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
sensor:
|
||||||
|
- platform: wifi_signal
|
||||||
|
name: "WiFi Signal"
|
||||||
|
update_interval: 60s
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Cleanliness Score"
|
||||||
|
id: cleanliness_score_sensor
|
||||||
|
unit_of_measurement: "%"
|
||||||
|
icon: "mdi:star"
|
||||||
|
accuracy_decimals: 0
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Daily Cat Visits"
|
||||||
|
id: cat_visits_sensor
|
||||||
|
icon: "mdi:paw"
|
||||||
|
accuracy_decimals: 0
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Last Analysis"
|
||||||
|
id: last_analysis_time
|
||||||
|
device_class: timestamp
|
||||||
|
icon: "mdi:clock-outline"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Text Sensors
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
text_sensor:
|
||||||
|
- platform: version
|
||||||
|
name: "ESPHome Version"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Status Message"
|
||||||
|
id: status_message
|
||||||
|
icon: "mdi:message-text"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Switches
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
switch:
|
||||||
|
- platform: restart
|
||||||
|
name: "Restart Camera"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
name: "Auto Capture Enabled"
|
||||||
|
id: auto_capture_enabled
|
||||||
|
optimistic: true
|
||||||
|
restore_mode: RESTORE_DEFAULT_ON
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Buttons
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
button:
|
||||||
|
- platform: template
|
||||||
|
name: "Capture Now"
|
||||||
|
icon: "mdi:camera"
|
||||||
|
on_press:
|
||||||
|
- script.execute: capture_image_script
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Scripts
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
script:
|
||||||
|
- id: capture_image_script
|
||||||
|
mode: single
|
||||||
|
then:
|
||||||
|
- lambda: 'ESP_LOGI("capture", "Capturing image...");'
|
||||||
|
- light.turn_on: status_led
|
||||||
|
- delay: 200ms
|
||||||
|
- lambda: |-
|
||||||
|
id(publish_next_image) = true;
|
||||||
|
id(litter_cam).request_image(esphome::esp32_camera::SINGLE_SHOT);
|
||||||
|
- light.turn_off: status_led
|
||||||
|
- lambda: 'id(motion_cooldown_active) = true;'
|
||||||
|
- delay: 30s
|
||||||
|
- lambda: 'id(motion_cooldown_active) = false;'
|
||||||
@@ -119,81 +119,57 @@ touchscreen:
|
|||||||
y_min: 340
|
y_min: 340
|
||||||
y_max: 3860
|
y_max: 3860
|
||||||
on_touch:
|
on_touch:
|
||||||
# Wake backlight if off
|
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
id(last_interaction_ms) = millis();
|
uint32_t now = millis();
|
||||||
|
id(last_interaction_ms) = now;
|
||||||
|
|
||||||
|
// Wake backlight on first touch only, don't also trigger a button
|
||||||
if (!id(backlight_is_on)) {
|
if (!id(backlight_is_on)) {
|
||||||
auto call = id(backlight).turn_on();
|
auto call = id(backlight).turn_on();
|
||||||
call.perform();
|
call.perform();
|
||||||
id(backlight_is_on) = true;
|
id(backlight_is_on) = true;
|
||||||
|
id(last_button_press_ms) = now;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce: ignore repeated touch events within 600ms
|
||||||
|
if ((uint32_t)(now - id(last_button_press_ms)) < 600) return;
|
||||||
|
id(last_button_press_ms) = now;
|
||||||
|
|
||||||
ESP_LOGD("touch", "Touch at x=%d, y=%d", touch.x, touch.y);
|
ESP_LOGD("touch", "Touch at x=%d, y=%d", touch.x, touch.y);
|
||||||
# Button 1: Toggle All (top-left)
|
|
||||||
- if:
|
// Button 1: Toggle All (top-left)
|
||||||
condition:
|
if (touch.x >= 20 && touch.x <= 150 && touch.y >= 50 && touch.y <= 130) {
|
||||||
lambda: 'return touch.x >= 20 && touch.x <= 150 && touch.y >= 50 && touch.y <= 130;'
|
|
||||||
then:
|
|
||||||
- lambda: |-
|
|
||||||
ESP_LOGD("touch", "Toggle All pressed");
|
ESP_LOGD("touch", "Toggle All pressed");
|
||||||
id(light1_state) = !id(light1_state);
|
id(light1_state) = !id(light1_state);
|
||||||
id(light2_state) = !id(light2_state);
|
id(light2_state) = !id(light2_state);
|
||||||
id(fan_state) = !id(fan_state);
|
id(fan_state) = !id(fan_state);
|
||||||
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
||||||
- homeassistant.service:
|
id(svc_toggle_all).execute();
|
||||||
service: switch.toggle
|
|
||||||
data:
|
// Button 2: Polly's Light (top-right)
|
||||||
entity_id: ${light_1_entity}
|
} else if (touch.x >= 170 && touch.x <= 300 && touch.y >= 50 && touch.y <= 130) {
|
||||||
- homeassistant.service:
|
|
||||||
service: switch.toggle
|
|
||||||
data:
|
|
||||||
entity_id: ${light_2_entity}
|
|
||||||
- homeassistant.service:
|
|
||||||
service: switch.toggle
|
|
||||||
data:
|
|
||||||
entity_id: ${fan_entity}
|
|
||||||
- component.update: my_display
|
|
||||||
# Button 2: Polly's Light (top-right)
|
|
||||||
- if:
|
|
||||||
condition:
|
|
||||||
lambda: 'return touch.x >= 170 && touch.x <= 300 && touch.y >= 50 && touch.y <= 130;'
|
|
||||||
then:
|
|
||||||
- lambda: |-
|
|
||||||
ESP_LOGD("touch", "Polly's Light pressed");
|
ESP_LOGD("touch", "Polly's Light pressed");
|
||||||
id(light1_state) = !id(light1_state);
|
id(light1_state) = !id(light1_state);
|
||||||
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
||||||
- homeassistant.service:
|
id(svc_toggle_polly).execute();
|
||||||
service: switch.toggle
|
|
||||||
data:
|
// Button 3: Joshua's Light (bottom-left)
|
||||||
entity_id: ${light_1_entity}
|
} else if (touch.x >= 20 && touch.x <= 150 && touch.y >= 150 && touch.y <= 230) {
|
||||||
- component.update: my_display
|
|
||||||
# Button 3: Joshua's Light (bottom-left)
|
|
||||||
- if:
|
|
||||||
condition:
|
|
||||||
lambda: 'return touch.x >= 20 && touch.x <= 150 && touch.y >= 150 && touch.y <= 230;'
|
|
||||||
then:
|
|
||||||
- lambda: |-
|
|
||||||
ESP_LOGD("touch", "Joshua's Light pressed");
|
ESP_LOGD("touch", "Joshua's Light pressed");
|
||||||
id(light2_state) = !id(light2_state);
|
id(light2_state) = !id(light2_state);
|
||||||
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
||||||
- homeassistant.service:
|
id(svc_toggle_joshua).execute();
|
||||||
service: switch.toggle
|
|
||||||
data:
|
// Button 4: Ceiling Fan (bottom-right)
|
||||||
entity_id: ${light_2_entity}
|
} else if (touch.x >= 170 && touch.x <= 300 && touch.y >= 150 && touch.y <= 230) {
|
||||||
- component.update: my_display
|
|
||||||
# Button 4: Ceiling Fan (bottom-right)
|
|
||||||
- if:
|
|
||||||
condition:
|
|
||||||
lambda: 'return touch.x >= 170 && touch.x <= 300 && touch.y >= 150 && touch.y <= 230;'
|
|
||||||
then:
|
|
||||||
- lambda: |-
|
|
||||||
ESP_LOGD("touch", "Ceiling Fan pressed");
|
ESP_LOGD("touch", "Ceiling Fan pressed");
|
||||||
id(fan_state) = !id(fan_state);
|
id(fan_state) = !id(fan_state);
|
||||||
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
id(all_state) = id(light1_state) && id(light2_state) && id(fan_state);
|
||||||
- homeassistant.service:
|
id(svc_toggle_fan).execute();
|
||||||
service: switch.toggle
|
}
|
||||||
data:
|
|
||||||
entity_id: ${fan_entity}
|
id(my_display).update();
|
||||||
- component.update: my_display
|
|
||||||
|
|
||||||
# Backlight control
|
# Backlight control
|
||||||
output:
|
output:
|
||||||
@@ -217,6 +193,41 @@ font:
|
|||||||
id: button_font
|
id: button_font
|
||||||
size: 20
|
size: 20
|
||||||
|
|
||||||
|
# Scripts to call HA services (lambdas can't call homeassistant.service directly)
|
||||||
|
script:
|
||||||
|
- id: svc_toggle_all
|
||||||
|
then:
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${light_1_entity}
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${light_2_entity}
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${fan_entity}
|
||||||
|
- id: svc_toggle_polly
|
||||||
|
then:
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${light_1_entity}
|
||||||
|
- id: svc_toggle_joshua
|
||||||
|
then:
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${light_2_entity}
|
||||||
|
- id: svc_toggle_fan
|
||||||
|
then:
|
||||||
|
- homeassistant.service:
|
||||||
|
service: switch.toggle
|
||||||
|
data:
|
||||||
|
entity_id: ${fan_entity}
|
||||||
|
|
||||||
# Global state tracking
|
# Global state tracking
|
||||||
globals:
|
globals:
|
||||||
- id: all_state
|
- id: all_state
|
||||||
@@ -234,6 +245,9 @@ globals:
|
|||||||
- id: last_interaction_ms
|
- id: last_interaction_ms
|
||||||
type: uint32_t
|
type: uint32_t
|
||||||
initial_value: "0"
|
initial_value: "0"
|
||||||
|
- id: last_button_press_ms
|
||||||
|
type: uint32_t
|
||||||
|
initial_value: "0"
|
||||||
- id: backlight_is_on
|
- id: backlight_is_on
|
||||||
type: bool
|
type: bool
|
||||||
initial_value: "true"
|
initial_value: "true"
|
||||||
|
|||||||
3
esphome/spi_helper.h
Normal file
3
esphome/spi_helper.h
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "driver/spi_master.h"
|
||||||
|
#include "driver/gpio.h"
|
||||||
Reference in New Issue
Block a user