Compare commits

...
28 Commits
Author SHA1 Message Date
Joshua KingandClaude Opus 5.5 14e3e405c1 Route OctoEverywhere alerts to a Google Chat space per printer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:47:44 -05:00
Joshua KingandClaude Opus 5.5 2ae16ceb97 Forward OctoEverywhere progress updates to Google Chat with a progress card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:12:27 -05:00
Joshua King da927bd554 Update Declan's school announcement message for humor and clarity 2026-11-05 10:13:13 -05:00
Joshua King 9f84188f22 Update Declan's school announcement message for clarity and positivity 2026-11-05 07:08:32 -05:00
Joshua KingandClaude Opus 5.5 61024f9eec Add temporary Family Room Show volume debug logger
Logs every volume and song change on media_player.family_room_show to the
Logbook, with the source taken from the state context, to find what raises
the volume near the end of songs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:17:30 -05:00
root 34c2c833df Merge branch 'main' of https://code.cubecraftlabs.com/overseer/Home-Assistant 2026-11-04 14:26:27 -05:00
root 71f607deae updates 2026-11-04 14:24:58 -05:00
Joshua KingandClaude Opus 5.5 3763b33b38 Disable Obico spaghetti detection automation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:23:24 -05:00
Joshua King b0491cfe9d Fix Obico HA address and Notify.Events alert titles 2026-11-04 14:21:16 -05:00
Joshua KingandClaude Opus 5.5 54eaa88616 Send OctoEverywhere alerts to Google Chat instead of Notify.Events
Replace the Notify.Events delivery with a Google Chat space webhook. The
automation builds a cardsV2 card (printer/event header, print details,
camera snapshot, Open printer button) and falls back to plain text if
Google rejects the card or the request fails.

Adds rest_command.google_chat_octoeverywhere, which needs
google_chat_octoeverywhere_webhook in secrets.yaml on the host. The
notify_event_octoeverywhere_token secret is no longer used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:44:51 -05:00
Joshua KingandClaude Opus 5.5 7831b35af2 Add OctoEverywhere webhook to Notify.Events automation
LAN-mode OctoEverywhere webhooks post fixed JSON that Notify.Events can't
parse. This local-only webhook automation translates important events
into titled Notify.Events alerts, passing the OctoEverywhere channel token
per message (the notify_events platform ignores per-platform api_key).

Requires notify_event_octoeverywhere_token in secrets.yaml on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:23:14 -05:00
Joshua King 9eee3374b2 Set Family Room Show to 30% before 5PM Christmas music 2026-11-03 17:18:38 -05:00
Joshua KingandCursor 651b37e37a Turn porch decorations on when the schedule is run manually.
A manual run has no trigger id, so the sunset branch never matched and Right Porch Outlet 2 stayed off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 19:55:50 -05:00
Joshua KingandCursor aed97280b8 Add Right Porch Outlet 2 to porch decoration automations.
Turn the outlet on with the sunset schedule and off at bedtime, 9 AM, and the midnight decoration sweep.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-02 12:56:15 -05:00
root 5f0080e11c updates 2026-11-02 08:44:07 -05:00
Joshua KingandCursor af7f268888 Derive Centauri progress from layers or print time when percent stays at zero.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 14:02:50 -04:00
Joshua KingandCursor 994cfb6b60 Fill overview gauges to each printer's actual progress.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 21:00:03 -04:00
Joshua KingandCursor 12f8ba2def Show a grey ring behind black filament gauges so they stay visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 20:46:41 -04:00
Joshua KingandCursor 5b8a3f24c2 Format the overview clock through a mutable ESPTime so ESPHome 2026.9.1 compiles.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 19:34:52 -04:00
Joshua KingandCursor c5b7408b18 Color overview gauges by active filament and show finish times.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 19:22:04 -04:00
Joshua KingandCursor 360e86e46a Show each printer on the fleet page as a status gauge.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 15:13:24 -04:00
root 4626019a3e automation updates 2026-10-20 14:30:20 -04:00
Joshua KingandCursor 43f416ab69 Add a fleet overview page that shows every printer's status at once.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 13:05:32 -04:00
Joshua KingandCursor 0ec94a909e Add a second ESPHome fleet display that shares the existing printer pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 11:24:47 -04:00
Joshua King 5e50c9a296 Refactor automation configurations by removing obsolete NestQuest access controls and updating automation inclusion in configuration.yaml 2026-10-19 21:08:59 -04:00
root 4104e995c0 Merge branch 'main' of https://code.cubecraftlabs.com/overseer/Home-Assistant 2026-10-19 20:10:14 -04:00
root d7f0584e17 updates 2026-10-19 20:03:46 -04:00
Joshua King 472b66cf0e updated blueprints 2026-10-19 16:18:52 -04:00
36 changed files with 3927 additions and 619 deletions
+34
View File
@@ -0,0 +1,34 @@
- id: '1792458749185'
alias: Declan - NestQuest Access Block
description: Blocks network access for Declan's devices once the remaining daily
quest sensor reaches zero.
use_blueprint:
path: Maniacs-Mansion/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_declan_quests_remaining_today
client_switches:
- switch.declan_iphone_blocked
- switch.default_blocked_2
- switch.iphone_blocked_2
- switch.iphone_blocked_3
- id: '1792458829477'
alias: Jordyn - NestQuest Access Block
description: Automatically blocks network access for Jordyn's devices once the remaining
daily quests sensor reaches zero.
use_blueprint:
path: nestquest/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
client_switches:
- switch.jordyn_iphone_blocked
- switch.default_blocked_3
- id: '1792458886560'
alias: Chloe - NestQuest Access Block
description: Automatically blocks network access for Chloe's device once the daily
NestQuest allowance has been exhausted.
use_blueprint:
path: nestquest/unifi_quest_access.yaml
input:
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
client_switches:
- switch.switch_v1_blocked
@@ -60,6 +60,7 @@
- switch.big_family_room_lamp
- switch.studio_plug_1_2
- switch.porch_decorations_3
- switch.outside_right_porch_outlet_2
- switch.outside_lamppost
- type: turn_off
device_id: 8b5cc6f6d2e727522ad3069656e2020f
@@ -29,9 +29,9 @@
alias: Porch Decorations Schedule
description: >-
Turn on Porch Decorations 1, Porch Decor 1, Porch Decor 2, Porch Decor 3,
and Porch Decor 1 Smart Plug one hour before sunset. Turn them off at
22:30 Sunday through Thursday and at 23:45 Friday and Saturday, along
with the Outside Lamppost.
Porch Decor 1 Smart Plug, and Right Porch Outlet 2 one hour before sunset.
Turn them off at 22:30 Sunday through Thursday and at 23:45 Friday and
Saturday, along with the Outside Lamppost.
mode: single
triggers:
- trigger: sun
@@ -48,8 +48,13 @@
actions:
- choose:
- conditions:
- condition: or
conditions:
- condition: trigger
id: decorations_on
# A manual run has no trigger id. Treat it as the sunset turn-on.
- condition: template
value_template: "{{ trigger.id | default(none) is none }}"
sequence:
- action: switch.turn_on
continue_on_error: true
@@ -60,6 +65,7 @@
- switch.big_family_room_lamp
- switch.studio_plug_1_2
- switch.porch_decorations_3
- switch.outside_right_porch_outlet_2
- conditions:
- condition: trigger
id: decorations_off_weeknight
@@ -80,6 +86,7 @@
- switch.big_family_room_lamp
- switch.studio_plug_1_2
- switch.porch_decorations_3
- switch.outside_right_porch_outlet_2
- switch.outside_lamppost
- conditions:
- condition: trigger
@@ -98,4 +105,5 @@
- switch.big_family_room_lamp
- switch.studio_plug_1_2
- switch.porch_decorations_3
- switch.outside_right_porch_outlet_2
- switch.outside_lamppost
@@ -23,6 +23,10 @@
entity_id: 8ebd9a84e00e9a8a4c05860cb01767ef
domain: switch
enabled: true
- type: turn_off
device_id: 89d1cfaee4e3bfdda1bbfe6b5bc7abb9
entity_id: 416b554cc68bf1090bde534f837951dc
domain: switch
- type: turn_off
device_id: 3096deb74c1dddf893e78fcdfa5b23aa
entity_id: aa15e9f5b3c00639b64501acd18f2081
@@ -180,6 +184,11 @@
'
actions:
- action: media_player.volume_set
target:
entity_id: media_player.family_room_show
data:
volume_level: 0.3
- target:
entity_id: media_player.family_room_show
data:
@@ -90,7 +90,7 @@
data: {}
continue_on_error: true
- data:
message: Time for school! Last call to get your water bottle, snack, and put on your shoes. Good luck, have fun, don't die!!
message: Time for school! Last call to get your water bottle, snack, and put on your shoes. Have a great day and never trust a fart!
targets:
- media_player.living_room
- media_player.declan_s_room
@@ -1,10 +1,11 @@
- id: '1743176311797'
alias: Bambu Lab - Spaghetti Detection
description: ''
description: Obico spaghetti detection; intentionally disabled (no longer used, Obico server not verified).
initial_state: false
use_blueprint:
path: nberktumer/spaghetti_detection.yaml
input:
home_assistant_host: http://192.168.1.86:8123
home_assistant_host: http://10.60.1.80:8123
obico_host: http://192.168.1.85:3333
obico_auth_token: 5b3dd0ff-7e41-47f4-9027-9e609813ae66
printer_current_stage_sensor: sensor.p1s_01p09c470102673_current_stage
@@ -54,10 +55,9 @@
target:
entity_id: light.jordyns_layer_slayer_chamber_light
data: {}
- type: turn_on
device_id: d174bbc498cde1d02e455cb133c04675
entity_id: 0736793ae0d143db76ea7c64d86f4268
domain: switch
- action: switch.turn_on
target:
entity_id: switch.jordyn_s_a1_camera_jordyn_a1_camera_flash
mode: single
- id: '1770938581699'
alias: D’s turd flinger Light on during print
@@ -81,10 +81,9 @@
data:
color_temp_kelvin: 6500
brightness_pct: 100
- type: turn_on
device_id: c63f4513655bdd5b44b998f45b491136
entity_id: fe6d0eee24eb1b397d37b7389137b5b5
domain: switch
- action: switch.turn_on
target:
entity_id: switch.declan_s_a1_camera_declan_a1_camera_flash
mode: single
- id: '1770938855253'
alias: Jordyn-a1-light-off-print-send
@@ -108,10 +107,9 @@
- light.jordyn_s_led_controller_jordyn_s_led_controller
data:
transition: 60
- type: turn_off
device_id: d174bbc498cde1d02e455cb133c04675
entity_id: 0736793ae0d143db76ea7c64d86f4268
domain: switch
- action: switch.turn_off
target:
entity_id: switch.jordyn_s_a1_camera_jordyn_a1_camera_flash
mode: single
- id: '1770943298493'
alias: D’s turd flinger Light Off after print
@@ -133,10 +131,9 @@
entity_id:
- light.a1_03919d540806387_chamber_light
data: {}
- type: turn_off
device_id: c63f4513655bdd5b44b998f45b491136
entity_id: fe6d0eee24eb1b397d37b7389137b5b5
domain: switch
- action: switch.turn_off
target:
entity_id: switch.declan_s_a1_camera_declan_a1_camera_flash
mode: single
- id: '1770944104889'
alias: Prints-Charming-Lights-On-Print-Started
@@ -235,10 +232,9 @@
data:
color_temp_kelvin: 6500
brightness_pct: 100
- type: turn_on
device_id: a78c9554b983d1e2459194cc0fee22d3
entity_id: d430d19aa2f118038cb01a3157865ed0
domain: switch
- action: switch.turn_on
target:
entity_id: switch.polly_a1_mini_camera_polly_a1_mini_cam_flash
mode: single
- id: '1771272773906'
alias: polly-pocket-a1-mini-lights-off-during-print
@@ -260,10 +256,9 @@
entity_id:
- light.a1mini_0309ca580403653_chamber_light
data: {}
- type: turn_off
device_id: a78c9554b983d1e2459194cc0fee22d3
entity_id: d430d19aa2f118038cb01a3157865ed0
domain: switch
- action: switch.turn_off
target:
entity_id: switch.polly_a1_mini_camera_polly_a1_mini_cam_flash
mode: single
### Printer Exhaust Fan Control ###
@@ -0,0 +1,326 @@
- id: '1743176311797'
alias: Bambu Lab - Spaghetti Detection
description: ''
use_blueprint:
path: nberktumer/spaghetti_detection.yaml
input:
home_assistant_host: http://192.168.1.86:8123
obico_host: http://192.168.1.85:3333
obico_auth_token: 5b3dd0ff-7e41-47f4-9027-9e609813ae66
printer_current_stage_sensor: sensor.p1s_01p09c470102673_current_stage
printer_print_status_sensor: sensor.p1s_01p09c470102673_print_status
printer_camera: camera.p1s_01p09c470102673_camera
printer_pause_button: button.p1s_01p09c470102673_pause_printing
printer_resume_button: button.p1s_01p09c470102673_resume_printing
printer_stop_button: button.p1s_01p09c470102673_stop_printing
printer_chamber_light: light.p1s_01p09c470102673_chamber_light
detection_frequency: /30
notification_settings: standard
notification_service: notify.mobile_app_joshuas_iphone_of_pain
### Status Notifications ###
- id: '1767118574570'
alias: Centauri Carbon Status Updates
description: Centauri Carbon progress notifications; intentionally disabled at startup.
initial_state: false
use_blueprint:
path: danielcherubini/elegoo_printer_progress.yaml
input:
error_status_reason_entity: sensor.centauri_carbon_error_status_reason
notify_device: eabafa43fa1148e4facc8e5a4151958d
current_status_entity: sensor.centauri_carbon_current_status
print_status_entity: sensor.centauri_carbon_print_status
percent_complete_entity: sensor.centauri_carbon_percent_complete
### Printer Light Automations ###
- id: '1770938475431'
alias: Jordyn-A1-lights-on-during-print
description: ''
triggers:
- device_id: 6d6eebb0e8ea6f46ac2ec3a3a5a5eae3
domain: bambu_lab
type: event_print_started
trigger: device
- device_id: 6d6eebb0e8ea6f46ac2ec3a3a5a5eae3
domain: bambu_lab
type: event_print_error_cleared
trigger: device
conditions: []
actions:
- action: light.turn_on
metadata: {}
target:
entity_id: light.jordyns_layer_slayer_chamber_light
data: {}
- type: turn_on
device_id: d174bbc498cde1d02e455cb133c04675
entity_id: 0736793ae0d143db76ea7c64d86f4268
domain: switch
mode: single
- id: '1770938581699'
alias: D’s turd flinger Light on during print
description: ''
triggers:
- device_id: 299743e9ce66334a0f3bac10eb24cf31
domain: bambu_lab
type: event_print_started
trigger: device
- device_id: 299743e9ce66334a0f3bac10eb24cf31
domain: bambu_lab
type: event_print_error_cleared
trigger: device
conditions: []
actions:
- action: light.turn_on
metadata: {}
target:
entity_id:
- light.a1_03919d540806387_chamber_light
data:
color_temp_kelvin: 6500
brightness_pct: 100
- type: turn_on
device_id: c63f4513655bdd5b44b998f45b491136
entity_id: fe6d0eee24eb1b397d37b7389137b5b5
domain: switch
mode: single
- id: '1770938855253'
alias: Jordyn-a1-light-off-print-send
description: ''
triggers:
- device_id: 6d6eebb0e8ea6f46ac2ec3a3a5a5eae3
domain: bambu_lab
type: event_print_finished
trigger: device
- device_id: 6d6eebb0e8ea6f46ac2ec3a3a5a5eae3
domain: bambu_lab
type: event_print_canceled
trigger: device
conditions: []
actions:
- action: light.turn_off
metadata: {}
target:
entity_id:
- light.jordyns_layer_slayer_chamber_light
- light.jordyn_s_led_controller_jordyn_s_led_controller
data:
transition: 60
- type: turn_off
device_id: d174bbc498cde1d02e455cb133c04675
entity_id: 0736793ae0d143db76ea7c64d86f4268
domain: switch
mode: single
- id: '1770943298493'
alias: D’s turd flinger Light Off after print
description: ''
triggers:
- device_id: 299743e9ce66334a0f3bac10eb24cf31
domain: bambu_lab
type: event_print_finished
trigger: device
- device_id: 299743e9ce66334a0f3bac10eb24cf31
domain: bambu_lab
type: event_print_canceled
trigger: device
conditions: []
actions:
- action: light.turn_off
metadata: {}
target:
entity_id:
- light.a1_03919d540806387_chamber_light
data: {}
- type: turn_off
device_id: c63f4513655bdd5b44b998f45b491136
entity_id: fe6d0eee24eb1b397d37b7389137b5b5
domain: switch
mode: single
- id: '1770944104889'
alias: Prints-Charming-Lights-On-Print-Started
description: 'Repaired by agent: replaced deleted Bambu device trigger with P1S
print_status entity trigger.'
triggers:
- trigger: state
entity_id: sensor.p1s_01p09c470102673_print_status
to:
- printing
- running
conditions: []
actions:
- action: light.turn_on
metadata: {}
target:
entity_id: light.p1s_01p09c470102673_chamber_light
data: {}
mode: single
- id: '1770944453024'
alias: Prints-Charming-Light-Off-Print-Completes
description: 'Repaired by agent: replaced deleted Bambu device trigger with P1S
print_status entity trigger.'
triggers:
- trigger: state
entity_id: sensor.p1s_01p09c470102673_print_status
to:
- finish
- complete
- completed
- idle
- failed
- cancelled
conditions:
- condition: template
value_template: '{{ trigger.from_state is not none and trigger.from_state.state
in [''printing'',''running'',''paused'',''pause'',''prepare'',''preparing''] }}'
actions:
- action: light.turn_off
metadata: {}
data: {}
target:
entity_id: light.p1s_01p09c470102673_chamber_light
mode: single
- id: '1770944259063'
alias: Constipation-Orion-Light-On-When-Print-Starts
description: ''
triggers:
- device_id: 615a0b19447425670a6b61ec2343c791
domain: bambu_lab
type: event_print_started
trigger: device
conditions: []
actions:
- action: light.turn_on
metadata: {}
target:
entity_id: light.constipation_orion_chamber_light
data: {}
mode: single
- id: '1770944373316'
alias: Constipation-Orion-Light-Off-Print-Finishes
description: Turn the chamber light off after a completed or cancelled print.
triggers:
- device_id: 615a0b19447425670a6b61ec2343c791
domain: bambu_lab
type: event_print_finished
trigger: device
- device_id: 615a0b19447425670a6b61ec2343c791
domain: bambu_lab
type: event_print_canceled
trigger: device
conditions: []
actions:
- action: light.turn_off
metadata: {}
target:
entity_id: light.constipation_orion_chamber_light
data: {}
mode: single
- id: '1771272500745'
alias: polly-pocket-a1-mini-lights-on-during-print
description: ''
triggers:
- device_id: 1cf63ab34db5ac50291c1477cddf2b9a
domain: bambu_lab
type: event_print_started
trigger: device
conditions: []
actions:
- action: light.turn_on
metadata: {}
target:
entity_id:
- light.a1mini_0309ca580403653_chamber_light
data:
color_temp_kelvin: 6500
brightness_pct: 100
- type: turn_on
device_id: a78c9554b983d1e2459194cc0fee22d3
entity_id: d430d19aa2f118038cb01a3157865ed0
domain: switch
mode: single
- id: '1771272773906'
alias: polly-pocket-a1-mini-lights-off-during-print
description: ''
triggers:
- device_id: 1cf63ab34db5ac50291c1477cddf2b9a
domain: bambu_lab
type: event_print_finished
trigger: device
- device_id: 1cf63ab34db5ac50291c1477cddf2b9a
domain: bambu_lab
type: event_print_canceled
trigger: device
conditions: []
actions:
- action: light.turn_off
metadata: {}
target:
entity_id:
- light.a1mini_0309ca580403653_chamber_light
data: {}
- type: turn_off
device_id: a78c9554b983d1e2459194cc0fee22d3
entity_id: d430d19aa2f118038cb01a3157865ed0
domain: switch
mode: single
### Printer Exhaust Fan Control ###
- id: fdm_printer_exhaust_fans
alias: FDM Printer Exhaust Fans During Prints
description: >-
Turn on the FDM Printer Exhaust Fans outlet when Constipation Orion,
Prints Charming, or Printney Spears (Centauri Carbon) is printing. Leave it
on until all three have been finished for 30 minutes. A pause keeps the
fans on. An unknown printer status does not start the shutoff timer.
mode: restart
triggers:
- trigger: state
entity_id:
- sensor.constipation_orion_print_status
- sensor.p1s_01p09c470102673_print_status
- sensor.centauri_carbon_print_status
- trigger: homeassistant
event: start
- trigger: event
event_type: automation_reloaded
conditions: []
actions:
- variables:
active_states:
- printing
- running
- pause
- paused
- pausing
- prepare
- preparing
printers:
- sensor.constipation_orion_print_status
- sensor.p1s_01p09c470102673_print_status
- sensor.centauri_carbon_print_status
- if:
- condition: template
value_template: >-
{{ expand(printers) | selectattr('state', 'in', active_states) | list | count > 0 }}
then:
- action: switch.turn_on
target:
entity_id: switch.studio_plug_2
else:
- condition: template
value_template: "{{ trigger.platform == 'state' }}"
- condition: template
value_template: >-
{{ expand(printers) | selectattr('state', 'in', ['unknown', 'unavailable']) | list | count == 0 }}
- delay:
minutes: 30
- condition: template
value_template: >-
{{ expand(printers) | selectattr('state', 'in', active_states) | list | count == 0
and expand(printers) | selectattr('state', 'in', ['unknown', 'unavailable']) | list | count == 0 }}
- action: switch.turn_off
target:
entity_id: switch.studio_plug_2
@@ -0,0 +1,162 @@
### OctoEverywhere -> Google Chat ###
# OctoEverywhere (LAN mode) POSTs fixed JSON to this webhook. Google Chat needs a
# `text` or `cardsV2` body, so this automation builds a card and sends it through
# rest_command.google_chat_octoeverywhere_<printer> (helpers/rest_command.yaml).
# Each printer has its own Google Chat space, picked by a keyword in PrinterName
# (see `spaces` below). Unknown printers and the safety-net message go to the
# shared space, rest_command.google_chat_octoeverywhere.
# Requires `google_chat_octoeverywhere_webhook` and the per-printer
# `google_chat_octoeverywhere_<printer>` secrets in secrets.yaml.
#
# Card: header (printer + event), details (file, progress, layer, time, error),
# camera snapshot, and an "Open printer" button to OctoEverywhere Quick View.
# Progress updates (event 6) get their own layout: percent in the header, a text
# progress bar, elapsed time, time left, and an estimated finish time.
# If Google rejects the card or the request fails, a plain-text message is sent.
#
# Event types: https://docs.octoeverywhere.com/webhook-notifications/event-types/
# Forwarded: 1-12, 16. Skipped: 13, 14, 15.
- id: '1793812953197'
alias: OctoEverywhere to Google Chat
description: Turns OctoEverywhere webhook JSON into Google Chat cards
mode: queued
max: 10
triggers:
- trigger: webhook
webhook_id: octoeverywhere-7301a739d954078c5a5c5fb3
allowed_methods:
- POST
local_only: true
actions:
# HA only fills trigger.json when the request's Content-Type contains "json".
# Otherwise the body is dropped and p is empty, which the safety net catches.
- variables:
p: "{{ trigger.json | default({}, true) }}"
ev: "{{ p.EventType | default(0) | int(0) }}"
wanted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16]
names:
"1": Print started
"2": Print complete
"3": Print failed
"4": Print paused
"5": Print resumed
"6": Print progress
"7": Gadget possible failure
"8": Gadget paused the print
"9": Printer error
"10": First layer complete
"11": Filament change required
"12": Needs your attention
"13": OctoEverywhere daily notification limit reached
"14": Third layer complete
"15": Bed cool down complete
"16": Test notification
# Safety net: body wasn't readable JSON, so say so instead of failing silently
- if:
- condition: template
value_template: "{{ not p }}"
then:
- action: rest_command.google_chat_octoeverywhere
data:
payload:
text: "⚠️ *OctoEverywhere webhook problem:* Home Assistant got a webhook it could not read as JSON. Check the trace for the \"OctoEverywhere to Google Chat\" automation."
- stop: Body was not JSON
# Drop events we don't want
- condition: template
value_template: "{{ ev in wanted }}"
# Build the message
- variables:
printer: "{{ p.PrinterName | default('Printer', true) | string }}"
# Keyword in the printer name (lowercase, letters/digits only) -> rest_command
spaces:
constipation: google_chat_octoeverywhere_constipation_orion
printney: google_chat_octoeverywhere_printney_spears
charming: google_chat_octoeverywhere_prints_charming
polly: google_chat_octoeverywhere_polly_pocket
slayer: google_chat_octoeverywhere_layer_slayer
turd: google_chat_octoeverywhere_turd_flinger
chat_cmd: >-
{%- set key = printer | lower | regex_replace('[^a-z0-9]', '') -%}
{%- set ns = namespace(cmd='google_chat_octoeverywhere') -%}
{%- for word, cmd in spaces.items() if word in key and ns.cmd == 'google_chat_octoeverywhere' -%}
{%- set ns.cmd = cmd -%}
{%- endfor -%}
{{ ns.cmd }}
pct: "{{ p.Progress | int(0) }}"
event_name: >-
{{ 'Print progress: ' ~ pct ~ '%' if ev == 6
else names[ev | string] | default('Event ' ~ ev, true) }}
emoji: "{{ '🛑' if ev in [3, 8, 9] else '⚠️' if ev in [4, 7, 11, 12] else '✅' if ev == 2 else '⏳' if ev == 6 else '🖨️' }}"
# [label, value] rows, shared by the card and the plain-text fallback
details: >-
{%- set rows = [] -%}
{%- if p.FileName %}{% set rows = rows + [['File', p.FileName | string]] %}{% endif -%}
{%- if p.Progress is defined and ev == 6 -%}
{%- set filled = [[pct // 10, 0] | max, 10] | min -%}
{%- set rows = rows + [['Progress', '▰' * filled ~ '▱' * (10 - filled) ~ ' ' ~ pct ~ '%']] -%}
{%- elif p.Progress is defined and ev not in [1, 2] -%}
{%- set rows = rows + [['Progress', pct ~ '%']] -%}
{%- endif -%}
{%- if (p.TotalLayers | int(0)) > 0 and ev not in [1, 2] %}{% set rows = rows + [['Layer', (p.CurrentLayer | int(0)) ~ ' of ' ~ (p.TotalLayers | int(0))]] %}{% endif -%}
{%- if (p.DurationSec | int(0)) > 0 and ev == 6 -%}
{%- set d = p.DurationSec | int(0) -%}
{%- set rows = rows + [['Elapsed', (d // 3600) ~ 'h ' ~ ((d % 3600) // 60) ~ 'm']] -%}
{%- endif -%}
{%- if (p.TimeRemainingSec | int(0)) > 0 and ev not in [2, 3] -%}
{%- set s = p.TimeRemainingSec | int(0) -%}
{%- set rows = rows + [['Time left', (s // 3600) ~ 'h ' ~ ((s % 3600) // 60) ~ 'm']] -%}
{%- if ev == 6 -%}
{%- set eta = now() + timedelta(seconds=s) -%}
{%- set day = '' if eta.date() == now().date() else eta.strftime('%a ') -%}
{%- set rows = rows + [['Done around', day ~ (eta.strftime('%I:%M %p') | regex_replace('^0', ''))]] -%}
{%- endif -%}
{%- endif -%}
{%- if p.DurationSec is defined and ev in [2, 3] -%}
{%- set d = p.DurationSec | int(0) -%}
{%- set rows = rows + [['Ran for', (d // 3600) ~ 'h ' ~ ((d % 3600) // 60) ~ 'm']] -%}
{%- endif -%}
{%- if p.Error %}{% set rows = rows + [['Error', p.Error | string]] %}{% endif -%}
{{ rows }}
# Google Chat Message body: short text (shows in phone notifications) + card
card: >-
{%- set ns = namespace(w=[]) -%}
{%- for r in details -%}
{%- set ns.w = ns.w + [{'decoratedText': {'topLabel': r[0], 'text': r[1] | replace('&', '&amp;') | replace('<', '&lt;') | replace('>', '&gt;'), 'wrapText': true}}] -%}
{%- endfor -%}
{%- set sections = [{'widgets': ns.w if ns.w else [{'decoratedText': {'text': event_name, 'wrapText': true}}]}] -%}
{%- if p.SnapshotUrl %}{% set sections = sections + [{'widgets': [{'image': {'imageUrl': p.SnapshotUrl | string, 'altText': printer ~ ' camera snapshot'}}]}] %}{% endif -%}
{%- if p.QuickViewUrl %}{% set sections = sections + [{'widgets': [{'buttonList': {'buttons': [{'text': 'Open printer', 'onClick': {'openLink': {'url': p.QuickViewUrl | string}}}]}}]}] %}{% endif -%}
{{ {'text': emoji ~ ' *' ~ printer ~ ':* ' ~ event_name,
'cardsV2': [{'cardId': 'octoeverywhere-' ~ ev,
'card': {'header': {'title': printer, 'subtitle': emoji ~ ' ' ~ event_name},
'sections': sections}}]} }}
# Plain-text fallback in Google Chat markup (*bold*, <url|label>)
plain: >-
{%- set ns = namespace(lines=[emoji ~ ' *' ~ printer ~ ':* ' ~ event_name]) -%}
{%- for r in details %}{% set ns.lines = ns.lines + ['*' ~ r[0] ~ ':* ' ~ r[1]] %}{% endfor -%}
{%- if p.SnapshotUrl %}{% set ns.lines = ns.lines + ['<' ~ p.SnapshotUrl ~ '|Snapshot>'] %}{% endif -%}
{%- if p.QuickViewUrl %}{% set ns.lines = ns.lines + ['<' ~ p.QuickViewUrl ~ '|Open printer>'] %}{% endif -%}
{{ ns.lines | join('\n') }}
# Send the card to this printer's space
- action: "rest_command.{{ chat_cmd }}"
data:
payload: "{{ card }}"
response_variable: chat_resp
continue_on_error: true
# rest_command doesn't raise on HTTP 4xx/5xx, so check the status. If the card
# was rejected (or the request failed), send plain text so the alert still lands.
- if:
- condition: template
value_template: "{{ chat_resp is not defined or (chat_resp.status | int(0)) != 200 }}"
then:
- delay: 2
- action: "rest_command.{{ chat_cmd }}"
data:
payload:
text: "{{ plain }}"
@@ -0,0 +1,51 @@
### TEMPORARY DEBUG: Family Room Show volume changes ###
# Investigating volume rising near the end of songs and resetting on the next song.
# Writes a Logbook entry for every volume change and every song change, with the
# source of the change taken from the new state's context:
# parent_id set -> an HA automation or script caused it
# user_id set -> a person in HA (dashboard, app, Developer Tools)
# neither -> not HA: Alexa/Amazon itself, or the integration reporting it
# late (Alexa Media Player can report HA-made changes on a poll)
# Remove this file once the cause is found.
- id: '1793834214055'
alias: DEBUG - Family Room Show volume logger
description: Temporary. Logs Family Room Show volume and song changes with their source.
mode: queued
max: 50
triggers:
- trigger: state
entity_id: media_player.family_room_show
attribute: volume_level
id: volume
- trigger: state
entity_id: media_player.family_room_show
attribute: media_title
id: song
actions:
- variables:
source: >-
{%- set c = trigger.to_state.context -%}
{{ 'HA automation/script (parent context ' ~ c.parent_id ~ ')' if c.parent_id
else 'HA user ' ~ c.user_id if c.user_id
else 'not from HA (Alexa/Amazon, or a late integration update)' }}
position: >-
{%- set pos = trigger.to_state.attributes.media_position -%}
{%- set dur = trigger.to_state.attributes.media_duration -%}
{{ (pos | int(0) ~ 's of ' ~ dur | int(0) ~ 's') if pos is number and dur is number else 'unknown' }}
- action: logbook.log
data:
name: Volume debug
entity_id: media_player.family_room_show
message: >-
{%- if trigger.id == 'volume' -%}
volume {{ trigger.from_state.attributes.volume_level | default('?') }} -> {{ trigger.to_state.attributes.volume_level | default('?') }}
| song: {{ trigger.to_state.attributes.media_title | default('?') }}
| position: {{ position }}
| state: {{ trigger.to_state.state }}
| source: {{ source }}
{%- else -%}
song changed: {{ trigger.from_state.attributes.media_title | default('?') }} -> {{ trigger.to_state.attributes.media_title | default('?') }}
| volume now: {{ trigger.to_state.attributes.volume_level | default('?') }}
| source: {{ source }}
{%- endif -%}
+18
View File
@@ -0,0 +1,18 @@
- id: bernard_voice_play_music
alias: Bernard - Voice play music
description: Route Bernard's music request to his own Music Assistant player.
triggers:
- trigger: conversation
command:
- "play [some ]music"
- "put on [some ]music"
- "play [my ]Discovery Station"
conditions:
- condition: template
value_template: >-
{{ trigger.get('satellite_id') == 'assist_satellite.bernard_assist_satellite'
or trigger.get('device_id') == device_id('assist_satellite.bernard_assist_satellite') }}
actions:
- action: script.bernard_play_discovery_station
- set_conversation_response: "Starting Discovery Station."
mode: single
@@ -0,0 +1,67 @@
blueprint:
name: NestQuest afternoon reminder
description: 'Sends an afternoon nudge listing ONLY the children who still have
open quests today. Children who are present and all clear are omitted (the nudge
is for stragglers, not a roll call), children who are not present today are omitted
entirely, and the household rollup sensors are excluded by the same present guard.
When every present child is already done the automation sends NOTHING at all:
a template condition stops the run before the notification. The notify service
is chosen by the household when importing this blueprint — NestQuest never hard-codes
a personal notify target.'
domain: automation
input:
afternoon_time:
name: Afternoon time
description: Local time of day the reminder is sent.
default: '15:00'
selector:
time: {}
notify_target:
name: Notify service
description: The Home Assistant notify service to call for the reminder, for
example notify.mobile_app_your_phone — a placeholder; pick the notify service
for your own device.
selector:
text:
multiline: false
multiple: false
send_reminder:
name: Send the reminder
description: Turn off to keep the automation installed but skip the notification
entirely.
default: true
selector:
boolean: {}
source_url: https://github.com/Maniacs-Mansion/NestQuest/blob/main/custom_components/nestquest/blueprints/automation/afternoon_reminder.yaml
mode: single
variables:
send_reminder: !input send_reminder
triggers:
- trigger: time
at: !input afternoon_time
conditions:
- condition: template
value_template: '{{ send_reminder | bool }}'
- condition: template
value_template: "{%- set ns = namespace(any_open=false) -%}\n{%- for due in states.sensor\n
\ | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_due_today(_\\\\d+)?$')
-%}\n {%- if due.attributes.get('present') -%}\n {%- set remaining_values
= states.sensor\n | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_remaining_today(_\\\\d+)?$')\n
\ | selectattr('attributes.child_id', 'eq', due.attributes.child_id)\n
\ | map(attribute='state')\n | list -%}\n {%- if remaining_values
and (remaining_values | first | int) > 0 -%}\n {%- set ns.any_open = true
-%}\n {%- endif -%}\n {%- endif -%}\n{%- endfor -%}\n{{- ns.any_open -}}\n"
actions:
- action: !input notify_target
data:
title: NestQuest afternoon reminder
message: "{%- set ns = namespace(lines=[]) -%}\n{%- for due in states.sensor\n
\ | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_due_today(_\\\\d+)?$')
-%}\n {%- if due.attributes.get('present') -%}\n {%- set remaining_values
= states.sensor\n | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_remaining_today(_\\\\d+)?$')\n
\ | selectattr('attributes.child_id', 'eq', due.attributes.child_id)\n
\ | map(attribute='state')\n | list -%}\n {%- if remaining_values
and (remaining_values | first | int) > 0 -%}\n {%- set line = due.attributes.child_name
~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}\n
\ {%- set ns.lines = ns.lines + [line] -%}\n {%- endif -%}\n {%- endif
-%}\n{%- endfor -%}\n{{- ns.lines | join('\\n') -}}"
@@ -0,0 +1,87 @@
blueprint:
name: NestQuest day complete celebration
description: 'Celebrates the moment a child clears every quest for the day: the
automation is driven by the nestquest_child_day_complete event, which NestQuest
only fires when quests were owed and all of them are complete — a zero-quest day
can never trigger this celebration. Once-per-child-per-day dedupe: point last_celebrated_helper
at ONE input_text helper (create it under Settings, Devices and Services, Helpers
— plain text). The automation records each celebrated child as "<child_id>:<today>"
inside that helper, and a child who un-completes a quest and completes it again
the same day is celebrated once, not twice — while a SECOND child clearing their
day the same date is still celebrated in full. The helper is pruned to today''s
entries on every celebration, so it never grows past one entry per child per day.
A helper that does not exist yet, or has never been set, lets the first celebration
through. The notification message may use the event payload''s child_name, quests_due
and quests_completed. The notify service is chosen by the household when importing
this blueprint — NestQuest never hard-codes a personal notify target.'
domain: automation
input:
notify_target:
name: Notify service
description: The Home Assistant notify service to call for the celebration,
for example notify.mobile_app_your_phone — a placeholder; pick the notify
service for your own device.
selector:
text:
multiline: false
multiple: false
celebration_message:
name: Celebration message
description: 'The message sent when a child clears every quest today. May use
the event payload: trigger.event.data.child_name, trigger.event.data.quests_due
and trigger.event.data.quests_completed.'
default: Legendary! {{ trigger.event.data.child_name }} cleared every quest
today!
selector:
text:
multiline: false
multiple: false
celebrate:
name: Send the celebration
description: Turn off to keep the automation installed but skip the notification
entirely.
default: true
selector:
boolean: {}
last_celebrated_helper:
name: Last-celebrated helper
description: A single input_text helper remembering which children were already
celebrated today, as "<child_id>:<date>" entries separated by commas. The
automation skips a child whose entry for today is already stored, and rewrites
the helper with only today's entries plus the new one after each celebration.
selector:
entity:
filter:
- domain:
- input_text
multiple: false
reorder: false
source_url: https://github.com/Maniacs-Mansion/NestQuest/blob/main/custom_components/nestquest/blueprints/automation/day_complete_celebration.yaml
mode: queued
variables:
celebrate: !input celebrate
last_celebrated_helper: !input last_celebrated_helper
triggers:
- trigger: event
event_type: nestquest_child_day_complete
conditions:
- condition: template
value_template: '{{ celebrate | bool }}'
- condition: template
value_template: "{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))\n
\ not in ((states(last_celebrated_helper) or '')\n .split(',')\n |
select('ne', '')\n | list) }}"
actions:
- action: !input notify_target
data:
title: NestQuest day complete
message: !input celebration_message
- action: input_text.set_value
target:
entity_id: !input last_celebrated_helper
data:
value: "{%- set today = now().strftime('%Y-%m-%d') -%} {%- set ns = namespace(entries=[])
-%} {%- for entry in (states(last_celebrated_helper) or '').split(',') -%}\n
\ {%- if entry and entry.endswith(':' ~ today) -%}\n {%- set ns.entries =
ns.entries + [entry] -%}\n {%- endif -%}\n{%- endfor -%} {{ (ns.entries + [trigger.event.data.child_id
~ ':' ~ today]) | join(',') }}"
@@ -0,0 +1,89 @@
blueprint:
name: NestQuest end-of-day report
description: Sends the parent an end-of-day report on how the day went. It runs
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it reads the same
day's data (what is still open at report time, and how much of the day was completed)
rather than tomorrow-morning's hindsight. Every PRESENT child gets a completion
line, so the parent always receives the day's summary; children who still owe
quests get their open count called out on that line, and children who are not
present today are omitted entirely. A child whose remaining-count sensor is unavailable
is reported with the completion line only. By default the report is skipped when
every present child is already done — turn on send-even-when-clear to always get
the day's completion summary. The notify service is chosen by the household when
importing this blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
report_time:
name: Report time
description: Local time of day the report is sent — deliberately before the
midnight missed-quest sweep, so the report reads the same day's data.
default: '20:00'
selector:
time: {}
notify_target:
name: Notify service
description: The Home Assistant notify service to call for the report, for example
notify.mobile_app_your_phone — a placeholder; pick the notify service for
your own device.
selector:
text:
multiline: false
multiple: false
send_report:
name: Send the report
description: Turn off to keep the automation installed but skip the notification
entirely.
default: true
selector:
boolean: {}
send_even_when_clear:
name: Send even when clear
description: Send the report even when every present child has already finished
their quests today. When off (the default), an all-clear day sends nothing.
default: false
selector:
boolean: {}
source_url: https://github.com/Maniacs-Mansion/NestQuest/blob/main/custom_components/nestquest/blueprints/automation/end_of_day_report.yaml
mode: single
variables:
send_report: !input send_report
send_even_when_clear: !input send_even_when_clear
triggers:
- trigger: time
at: !input report_time
conditions:
- condition: template
value_template: '{{ send_report | bool }}'
- condition: template
value_template: "{%- set ns = namespace(any_open=false) -%}\n{%- for due in states.sensor\n
\ | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_due_today(_\\\\d+)?$')
-%}\n {%- if due.attributes.get('present') -%}\n {%- set remaining_values
= states.sensor\n | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_remaining_today(_\\\\d+)?$')\n
\ | selectattr('attributes.child_id', 'eq', due.attributes.child_id)\n
\ | map(attribute='state')\n | list -%}\n {%- if not remaining_values
or (remaining_values | first | int(-1)) != 0 -%}\n {%- set ns.any_open =
true -%}\n {%- endif -%}\n {%- endif -%}\n{%- endfor -%}\n{{- send_even_when_clear
| bool or ns.any_open -}}\n"
actions:
- action: !input notify_target
data:
title: NestQuest end-of-day report
message: "{%- set ns = namespace(lines=[]) -%}\n{%- for due in states.sensor\n
\ | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_due_today(_\\\\d+)?$')
-%}\n {%- if due.attributes.get('present') -%}\n {%- set due_count = due.state
| int -%}\n {%- set completed_values = states.sensor\n | selectattr('entity_id',
'search', '^sensor\\\\.nestquest_.*_quests_completed_today(_\\\\d+)?$')\n |
selectattr('attributes.child_id', 'eq', due.attributes.child_id)\n |
map(attribute='state')\n | list -%}\n {%- set remaining_values =
states.sensor\n | selectattr('entity_id', 'search', '^sensor\\\\.nestquest_.*_quests_remaining_today(_\\\\d+)?$')\n
\ | selectattr('attributes.child_id', 'eq', due.attributes.child_id)\n
\ | map(attribute='state')\n | list -%}\n {%- set completed_count
= (completed_values | first | int) if completed_values else 0 -%}\n {%- set
remaining_count = (remaining_values | first | int) if remaining_values else
none -%}\n {%- if remaining_count is not none and remaining_count > 0 -%}\n
\ {%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ '
still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}\n {%- else
-%}\n {%- set line = due.attributes.child_name ~ ': ' ~ completed_count
~ '/' ~ due_count ~ ' done.' -%}\n {%- endif -%}\n {%- set ns.lines =
ns.lines + [line] -%}\n {%- endif -%}\n{%- endfor -%}\n{{- ns.lines | join('\\n')
-}}"
@@ -0,0 +1,92 @@
blueprint:
name: NestQuest UniFi quest access
description: One automation per child. Completely block the selected UniFi clients
after the local weekday/weekend cutoff while that child has quests remaining TODAY.
Restore network access immediately when the count reaches zero, and at midnight
even if yesterday was incomplete. A missing or unavailable quest sensor is treated
as still open after the cutoff; verify the sensor before enabling this automation.
The selected UniFi client switches are ON when access is allowed and OFF when
blocked. Do not select infrastructure or adult devices.
domain: automation
input:
remaining_sensor:
name: Child's quests remaining today
description: Select this child's NestQuest quests remaining today sensor.
selector:
entity:
filter:
- integration: nestquest
domain:
- sensor
multiple: false
reorder: false
client_switches:
name: Child's UniFi client access switches
description: Select every UniFi client access switch for this child. These switches
are ON when network access is allowed and OFF when blocked.
selector:
entity:
multiple: true
filter:
- integration: unifi
domain:
- switch
reorder: false
weekday_cutoff:
name: Monday-Friday cutoff
default: '18:25:00'
selector:
time: {}
weekend_cutoff:
name: Saturday-Sunday cutoff
default: '14:00:00'
selector:
time: {}
source_url: https://github.com/Maniacs-Mansion/NestQuest/blob/main/custom_components/nestquest/blueprints/automation/unifi_quest_access.yaml
mode: restart
variables:
remaining_sensor: !input remaining_sensor
client_switches: !input client_switches
weekday_cutoff: !input weekday_cutoff
weekend_cutoff: !input weekend_cutoff
triggers:
- trigger: state
entity_id: !input remaining_sensor
- trigger: time
at: !input weekday_cutoff
- trigger: time
at: !input weekend_cutoff
- trigger: time
at: 00:00:00
- trigger: homeassistant
event: start
- trigger: time_pattern
minutes: /5
conditions:
- condition: template
value_template: '{{ client_switches | count > 0 }}'
actions:
- variables:
should_block: "{{ now().strftime('%H:%M') >=\n (weekday_cutoff[:5] if now().weekday()
< 5 else weekend_cutoff[:5])\n and (states(remaining_sensor) | int(-1)) !=
0 }}"
- repeat:
for_each: '{{ client_switches }}'
sequence:
- choose:
- conditions:
- condition: template
value_template: '{{ should_block | bool and is_state(repeat.item, ''on'')
}}'
sequence:
- action: switch.turn_off
target:
entity_id: '{{ repeat.item }}'
- conditions:
- condition: template
value_template: '{{ not (should_block | bool) and is_state(repeat.item,
''off'') }}'
sequence:
- action: switch.turn_on
target:
entity_id: '{{ repeat.item }}'
@@ -0,0 +1,90 @@
blueprint:
name: NestQuest afternoon reminder
description: >-
Sends an afternoon nudge listing ONLY the children who still have
open quests today. Children who are present and all clear are
omitted (the nudge is for stragglers, not a roll call), children
who are not present today are omitted entirely, and the household
rollup sensors are excluded by the same present guard. When every
present child is already done the automation sends NOTHING at all:
a template condition stops the run before the notification. The
notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
afternoon_time:
name: Afternoon time
description: Local time of day the reminder is sent.
default: "15:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the reminder,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_reminder:
name: Send the reminder
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_reminder: !input send_reminder
triggers:
- trigger: time
at: !input afternoon_time
conditions:
- condition: template
value_template: "{{ send_reminder | bool }}"
# Send nothing when every present child is already done: the guard is
# false unless at least one present child has remaining quests today.
- condition: template
value_template: |
{%- set ns = namespace(any_open=false) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
{%- set ns.any_open = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.any_open -}}
actions:
- action: !input notify_target
data:
title: NestQuest afternoon reminder
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values and (remaining_values | first | int) > 0 -%}
{%- set line = due.attributes.child_name ~ ' still has ' ~ (remaining_values | first | int) ~ ' quest(s) open.' -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- ns.lines | join('\n') -}}
@@ -0,0 +1,109 @@
blueprint:
name: NestQuest day complete celebration
description: >-
Celebrates the moment a child clears every quest for the day: the
automation is driven by the nestquest_child_day_complete event,
which NestQuest only fires when quests were owed and all of them
are complete — a zero-quest day can never trigger this
celebration. Once-per-child-per-day dedupe: point
last_celebrated_helper at ONE input_text helper (create it under
Settings, Devices and Services, Helpers — plain text). The
automation records each celebrated child as
"<child_id>:<today>" inside that helper, and a child who
un-completes a quest and completes it again the same day is
celebrated once, not twice — while a SECOND child clearing their
day the same date is still celebrated in full. The helper is
pruned to today's entries on every celebration, so it never
grows past one entry per child per day. A helper that does not
exist yet, or has never been set, lets the first celebration
through. The notification message may use the event payload's
child_name, quests_due and quests_completed. The notify service
is chosen by the household when importing this blueprint —
NestQuest never hard-codes a personal notify target.
domain: automation
input:
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the celebration,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
celebration_message:
name: Celebration message
description: >-
The message sent when a child clears every quest today. May
use the event payload: trigger.event.data.child_name,
trigger.event.data.quests_due and trigger.event.data.quests_completed.
default: "Legendary! {{ trigger.event.data.child_name }} cleared every quest today!"
selector:
text: {}
celebrate:
name: Send the celebration
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
last_celebrated_helper:
name: Last-celebrated helper
description: >-
A single input_text helper remembering which children were
already celebrated today, as "<child_id>:<date>" entries
separated by commas. The automation skips a child whose entry
for today is already stored, and rewrites the helper with only
today's entries plus the new one after each celebration.
selector:
entity:
filter:
- domain: input_text
mode: queued
variables:
celebrate: !input celebrate
last_celebrated_helper: !input last_celebrated_helper
triggers:
- trigger: event
event_type: nestquest_child_day_complete
conditions:
- condition: template
value_template: "{{ celebrate | bool }}"
# Once-per-child-per-day dedupe: celebrate only when THIS child has
# no equal entry for today in the helper. Entries compare as whole
# comma-separated tokens — a substring check would wrongly suppress
# child 1 after child 11 was celebrated. An unset or missing helper
# reads as '' and celebrates.
- condition: template
value_template: >-
{{ (trigger.event.data.child_id ~ ':' ~ now().strftime('%Y-%m-%d'))
not in ((states(last_celebrated_helper) or '')
.split(',')
| select('ne', '')
| list) }}
actions:
- action: !input notify_target
data:
title: NestQuest day complete
message: !input celebration_message
# Record this child's celebration, keeping only today's entries so the
# helper never outgrows its text limit and stale entries cannot block
# next week's celebrations.
- action: input_text.set_value
target:
entity_id: !input last_celebrated_helper
data:
value: >-
{%- set today = now().strftime('%Y-%m-%d') -%}
{%- set ns = namespace(entries=[]) -%}
{%- for entry in (states(last_celebrated_helper) or '').split(',') -%}
{%- if entry and entry.endswith(':' ~ today) -%}
{%- set ns.entries = ns.entries + [entry] -%}
{%- endif -%}
{%- endfor -%}
{{ (ns.entries + [trigger.event.data.child_id ~ ':' ~ today]) | join(',') }}
@@ -0,0 +1,121 @@
blueprint:
name: NestQuest end-of-day report
description: >-
Sends the parent an end-of-day report on how the day went. It runs
deliberately BEFORE NestQuest's midnight missed-quest sweep, so it
reads the same day's data (what is still open at report time, and
how much of the day was completed) rather than tomorrow-morning's
hindsight. Every PRESENT child gets a completion line, so the
parent always receives the day's summary; children who still owe
quests get their open count called out on that line, and children
who are not present today are omitted entirely. A child whose
remaining-count sensor is unavailable is reported with the
completion line only. By default the report is skipped when every
present child is already done — turn on send-even-when-clear to
always get the day's completion summary. The notify service is
chosen by the household when importing this blueprint — NestQuest
never hard-codes a personal notify target.
domain: automation
input:
report_time:
name: Report time
description: >-
Local time of day the report is sent — deliberately before the
midnight missed-quest sweep, so the report reads the same
day's data.
default: "20:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the report,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_report:
name: Send the report
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
send_even_when_clear:
name: Send even when clear
description: >-
Send the report even when every present child has already
finished their quests today. When off (the default), an
all-clear day sends nothing.
default: false
selector:
boolean: {}
mode: single
variables:
send_report: !input send_report
send_even_when_clear: !input send_even_when_clear
triggers:
- trigger: time
at: !input report_time
conditions:
- condition: template
value_template: "{{ send_report | bool }}"
# Skip an all-clear day unless the household opted into
# send-even-when-clear: the guard is false only when every present
# child has a usable remaining count of zero. A MISSING or
# non-numeric remaining sensor reads as -1 and counts as open —
# a degraded state must reach the parent, not look like all-clear.
- condition: template
value_template: |
{%- set ns = namespace(any_open=false) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if not remaining_values or (remaining_values | first | int(-1)) != 0 -%}
{%- set ns.any_open = true -%}
{%- endif -%}
{%- endif -%}
{%- endfor -%}
{{- send_even_when_clear | bool or ns.any_open -}}
actions:
- action: !input notify_target
data:
title: NestQuest end-of-day report
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set due_count = due.state | int -%}
{%- set completed_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_completed_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- set completed_count = (completed_values | first | int) if completed_values else 0 -%}
{%- set remaining_count = (remaining_values | first | int) if remaining_values else none -%}
{%- if remaining_count is not none and remaining_count > 0 -%}
{%- set line = due.attributes.child_name ~ ': ' ~ remaining_count ~ ' still open (' ~ completed_count ~ '/' ~ due_count ~ ' done).' -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ ': ' ~ completed_count ~ '/' ~ due_count ~ ' done.' -%}
{%- endif -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endfor -%}
{{- ns.lines | join('\n') -}}
@@ -0,0 +1,78 @@
blueprint:
name: NestQuest morning summary
description: >-
Sends a morning notification listing what each child still owes
today. Only children whose NestQuest presence sensor is on are
included; children who are not present today are omitted entirely.
The notify service is chosen by the household when importing this
blueprint — NestQuest never hard-codes a personal notify target.
domain: automation
input:
morning_time:
name: Morning time
description: Local time of day the summary is sent.
default: "08:00"
selector:
time: {}
notify_target:
name: Notify service
description: >-
The Home Assistant notify service to call for the summary,
for example notify.mobile_app_your_phone — a placeholder;
pick the notify service for your own device.
selector:
text: {}
send_summary:
name: Send the summary
description: >-
Turn off to keep the automation installed but skip the
notification entirely.
default: true
selector:
boolean: {}
mode: single
variables:
send_summary: !input send_summary
triggers:
- trigger: time
at: !input morning_time
conditions:
- condition: template
value_template: "{{ send_summary | bool }}"
actions:
- action: !input notify_target
data:
title: NestQuest morning summary
message: |
{%- set ns = namespace(lines=[]) -%}
{%- for due in states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_due_today(_\\d+)?$') -%}
{%- if due.attributes.get('present') -%}
{%- set remaining_values = states.sensor
| selectattr('entity_id', 'search', '^sensor\\.nestquest_.*_quests_remaining_today(_\\d+)?$')
| selectattr('attributes.child_id', 'eq', due.attributes.child_id)
| map(attribute='state')
| list -%}
{%- if remaining_values -%}
{%- set remaining = remaining_values | first | int -%}
{%- if remaining > 0 -%}
{%- set line = due.attributes.child_name ~ ' owes ' ~ remaining ~ ' quest(s) today.' -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ ' is all clear today (0 quests remaining).' -%}
{%- endif -%}
{%- else -%}
{%- set line = due.attributes.child_name ~ "'s quest count is unavailable today." -%}
{%- endif -%}
{%- set ns.lines = ns.lines + [line] -%}
{%- endif -%}
{%- endfor -%}
{%- if ns.lines -%}
{{- ns.lines | join('\n') -}}
{%- else -%}
No children present today.
{%- endif -%}
@@ -0,0 +1,96 @@
blueprint:
name: NestQuest UniFi quest access
description: >-
One automation per child. Completely block the selected UniFi clients
after the local weekday/weekend cutoff while that child has quests
remaining TODAY. Restore network access immediately when the count
reaches zero, and at midnight even if yesterday was incomplete.
A missing or unavailable quest sensor is treated as still open after
the cutoff; verify the sensor before enabling this automation. The
selected UniFi client switches are ON when access is allowed and
OFF when blocked. Do not select infrastructure or adult devices.
domain: automation
input:
remaining_sensor:
name: Child's quests remaining today
description: Select this child's NestQuest quests remaining today sensor.
selector:
entity:
filter:
- integration: nestquest
domain: sensor
client_switches:
name: Child's UniFi client access switches
description: >-
Select every UniFi client access switch for this child. These
switches are ON when network access is allowed and OFF when blocked.
selector:
entity:
multiple: true
filter:
- integration: unifi
domain: switch
weekday_cutoff:
name: Monday-Friday cutoff
default: "18:25:00"
selector:
time: {}
weekend_cutoff:
name: Saturday-Sunday cutoff
default: "14:00:00"
selector:
time: {}
mode: restart
variables:
remaining_sensor: !input remaining_sensor
client_switches: !input client_switches
weekday_cutoff: !input weekday_cutoff
weekend_cutoff: !input weekend_cutoff
triggers:
- trigger: state
entity_id: !input remaining_sensor
- trigger: time
at: !input weekday_cutoff
- trigger: time
at: !input weekend_cutoff
- trigger: time
at: "00:00:00"
- trigger: homeassistant
event: start
# Reconcile after missed events or a temporary UniFi outage.
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: "{{ client_switches | count > 0 }}"
actions:
- variables:
should_block: >-
{{ now().strftime('%H:%M') >=
(weekday_cutoff[:5] if now().weekday() < 5 else weekend_cutoff[:5])
and (states(remaining_sensor) | int(-1)) != 0 }}
- repeat:
for_each: "{{ client_switches }}"
sequence:
- choose:
- conditions:
- condition: template
value_template: >-
{{ should_block | bool and is_state(repeat.item, 'on') }}
sequence:
- action: switch.turn_off
target:
entity_id: "{{ repeat.item }}"
- conditions:
- condition: template
value_template: >-
{{ not (should_block | bool) and is_state(repeat.item, 'off') }}
sequence:
- action: switch.turn_on
target:
entity_id: "{{ repeat.item }}"
+2 -1
View File
@@ -27,7 +27,8 @@ frontend:
lovelace:
dashboards: !include dashboards/dashboards.yaml
automation: !include_dir_merge_list automations/
automation manual: !include_dir_merge_list automations/
automation ui: !include automations.yaml
script: !include scripts.yaml
scene: !include scenes.yaml
input_boolean: !include helpers/input_boolean.yaml
File diff suppressed because it is too large Load Diff
+39 -23
View File
@@ -17,24 +17,31 @@ Home Assistant must be running and its printer integrations must have current pr
The configured swipe order is:
1. PrintsCharming — P1S, Office, both four-slot AMS units.
2. Polly-Pocket — A1 mini, Office, external spool.
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
1. Print Status Overview — one steam gauge per printer. The filled part of
each ring is that printer's print progress, drawn in the active filament
color. Black filament keeps a grey ring behind the gauge so it stays visible
on the black page. The center shows the percent complete in white, with the
printer name and estimated finish time underneath.
The local time is in the top-right corner. Tap a gauge to open that printer.
2. PrintsCharming — P1S, Office, both four-slot AMS units.
3. Polly-Pocket — A1 mini, Office, external spool.
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
5. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
6. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
AMS A uses `constipation_orion_ams_1`; AMS B retains its original
`h2c_31b8ap5a2200288_ams_2` entity prefix.
6. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
7. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
and model/auxiliary/enclosure fan speeds.
7. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
8. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
elapsed time, remaining time and estimated finish.
![Saturn resin page with synthetic telemetry](previews/saturn.png)
Swipe left for the next printer or right for the previous printer. Navigation wraps
from the last page to the first. Previous/Next buttons provide the same navigation.
Swipes propagate through the arc gauges and filament slots. A swipe consumes the
rest of that touch so dragging across a navigation button does not also click it.
Swipe left for the next page or right for the previous page. Navigation wraps
from the last printer back to the fleet page. Previous/Next buttons provide the
same navigation. Swipes propagate through the fleet gauges and filament
slots. A swipe consumes the rest of that touch so dragging across a gauge or a
navigation button does not also click it.
FDM pages show print status, job name, progress, nozzle and bed current/target
temperatures, remaining time, estimated finish time, current/total layers, print
@@ -84,17 +91,22 @@ printer rotation are outside its current scope.
## Install
1. Keep `esphome/bambu-printer-display.yaml` and the entire `bambu-display/` folder
together in the ESPHome configuration directory. The generated package is checked
in, so the ESPHome dashboard can compile it without running the generator first.
1. Keep both device files and the entire `bambu-display/` folder together in the
ESPHome configuration directory. `bambu-printer-display.yaml` is the first board
and `bambu-printer-display-2.yaml` is the second. They share `board.yaml` and the
generated fleet package, which is checked in, so the ESPHome dashboard can compile
either file without running the generator first.
2. Supply the existing `api_encryption_key`, `wifi_iot_ssid` and `wifi_password`
entries in that directory's `secrets.yaml`.
3. Use ESPHome **2026.9.0 or newer**. Compile and install
`bambu-printer-display.yaml` over the board's USB-to-UART bridge for the first
flash; subsequent installs can use encrypted OTA.
4. Add **Bambu Printer Display** through Home Assistant's ESPHome integration using
the same API encryption key. The display only subscribes to entities, so it does
not require permission to perform Home Assistant actions.
3. Use ESPHome **2026.9.0 or newer**. Compile and install the matching device file
over that board's USB-to-UART bridge for the first flash; subsequent installs can
use encrypted OTA. Flash `bambu-printer-display.yaml` to the first panel and
`bambu-printer-display-2.yaml` to the second. Each file has its own ESPHome name,
so both panels can be online together.
4. Add **Bambu Printer Display** or **Bambu Printer Display 2** through Home
Assistant's ESPHome integration using the same API encryption key. Each display
only subscribes to entities, so it does not require permission to perform Home
Assistant actions.
5. Confirm all pages receive data and verify touch direction and color order on the
physical panel. Entity IDs match the repository's `ha-entities.txt` snapshot;
renamed or disabled entities in live HA must be corrected/enabled there.
@@ -103,7 +115,7 @@ The USB D+/D− GPIO19/20 pins are used by the board's touch bus. Logging theref
uses UART0. ESPHome may still warn about these two pins; they are intentional for
this board. If GT911 is not found at `0x5D`, inspect the I²C scan and use its reported
address (some hardware revisions select `0x14`). Backlight is GPIO2; RGB data pins
and timings are explicit in the main YAML rather than borrowed from a 5″ board.
and timings are explicit in `board.yaml` rather than borrowed from a 5″ board.
The display and LVGL both use **big-endian RGB565**. With `mipi_rgb`'s named
`red`/`green`/`blue` pin groups, its generated bus pin map already exchanges the
@@ -121,7 +133,9 @@ CLI checks from the repository root:
```sh
esphome config esphome/bambu-printer-display.yaml
esphome config esphome/bambu-printer-display-2.yaml
esphome compile esphome/bambu-printer-display.yaml
esphome compile esphome/bambu-printer-display-2.yaml
```
## Change or expand the fleet
@@ -135,7 +149,7 @@ python esphome/bambu-display/generate.py --check
The generator requires PyYAML (already included in ESPHome's Python environment).
Do not hand-edit `generated.yaml`. There is no two-printer limit in the generator;
adding a printer creates its own subscriptions, widgets and swipe page. The practical
adding a printer creates its own subscriptions, widgets, swipe page and fleet gauge. The practical
limit depends on the board's available RAM and flash, and adding printers requires
regenerating and installing the firmware. Seven printers are configured; generation
is also tested with nine. Each Bambu printer's filament strip supports up to eight slots.
@@ -161,7 +175,9 @@ exist; their mappings are in `EXTRA_FANS`. A remaining-time override also update
Filament entries use the Bambu integration's `type`, `color` and `active` attributes.
For Elegoo printers, set `profile: elegoo_fdm` or `profile: elegoo_resin` (the default
profile is `bambu`). These profiles map progress to `percent_complete`, job name to
profile is `bambu`). These profiles map progress to `percent_complete`. The Centauri Carbon leaves
that sensor at 0 for most of a job, so an active Elegoo print falls back to the
layer count and then to elapsed time divided by `total_print_time`. Job name maps to
`file_name`, remaining time to `remaining_print_time`, and total layers to
`total_layers`. The FDM profile reads targets from the HA `number` entities
`target_nozzle_temp` and `target_bed_temp`; it only monitors their state. Per-metric
+114
View File
@@ -0,0 +1,114 @@
# Shared Sunton ESP32-8048S043C board for every fleet display.
# Device name and friendly name come from the top-level YAML substitutions.
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
min_version: 2026.9.0
includes:
- bambu-display/dashboard.h
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
flash_mode: dio
cpu_frequency: 240MHz
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
CONFIG_SPIRAM_RODATA: "y"
psram:
mode: octal
speed: 80MHz
logger:
hardware_uart: UART0 # GPIO19/20 are the touch bus, not native USB.
api:
id: ha_api
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
encryption:
key: !secret api_encryption_key
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
i2c:
id: touch_bus
sda: GPIO19
scl: GPIO20
scan: true
display:
- platform: mipi_rgb
id: panel_display
model: RPI
dimensions: 800x480
color_order: RGB
# Named RGB data_pins are byte-swapped by mipi_rgb's pin mapping.
# Send big-endian RGB565 pixels to match that wiring transformation.
byte_order: big_endian
de_pin: GPIO40
hsync_pin: GPIO39
vsync_pin: GPIO41
pclk_pin: GPIO42
pclk_inverted: true
pclk_frequency: 16MHz
hsync_front_porch: 8
hsync_pulse_width: 4
hsync_back_porch: 8
vsync_front_porch: 8
vsync_pulse_width: 4
vsync_back_porch: 8
data_pins:
red: [GPIO45, GPIO48, GPIO47, GPIO21, GPIO14]
green: [GPIO5, GPIO6, GPIO7, GPIO15, GPIO16, GPIO4]
blue: [GPIO8, GPIO3, GPIO46, GPIO9, GPIO1]
auto_clear_enabled: false
update_interval: never
touchscreen:
- platform: gt911
id: panel_touch
display: panel_display
i2c_id: touch_bus
address: 0x5D
# Polling avoids the GT911 INT pin's boot/address-selection behaviour.
update_interval: 20ms
output:
- platform: ledc
id: backlight_output
pin: GPIO2
light:
- platform: monochromatic
id: panel_backlight
name: Backlight
output: backlight_output
restore_mode: ALWAYS_ON
default_transition_length: 0s
time:
- platform: homeassistant
id: ha_time
timezone: America/New_York
lvgl:
id: dashboard
displays: [panel_display]
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
bg_color: 0x000000
text_color: 0xEEEEEE
default_font: montserrat_18
+133 -5
View File
@@ -7,6 +7,7 @@
#include <cstdint>
#include <cstdlib>
#include <string>
#include <utility>
#include <vector>
namespace bambu_display {
@@ -16,6 +17,7 @@ constexpr uint32_t MUTED = 0xAEB3B7;
constexpr uint32_t RED = 0xFF737B;
constexpr uint32_t AMBER = 0xF2C572;
constexpr uint32_t TRACK = 0x19232B;
constexpr uint32_t RING_BACKING = 0x6E767E;
inline std::string lower(std::string text) {
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
@@ -103,6 +105,11 @@ inline uint32_t filament_color(std::string value) {
return rgb;
}
// A black ring disappears on the black page. Anything this dark needs a grey arc behind it.
inline bool filament_is_black(uint32_t color) {
return ((color >> 16) & 0xFF) <= 0x28 && ((color >> 8) & 0xFF) <= 0x28 && (color & 0xFF) <= 0x28;
}
struct Tray {
std::string label, type, color, active;
};
@@ -115,9 +122,9 @@ struct Snapshot {
std::string hms_count, print_error_message, print_error_code;
std::string print_error_reason, uv_unit, elapsed_unit;
bool elegoo = false, resin = false;
float uv = NAN, elapsed = NAN;
float uv = NAN, elapsed = NAN, total_time = NAN;
std::vector<HealthNotice> health_notices;
std::string remaining_unit, nozzle_unit, bed_unit;
std::string remaining_unit, nozzle_unit, bed_unit, total_time_unit;
std::string finish_time;
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
@@ -127,6 +134,36 @@ struct Snapshot {
std::vector<Tray> trays;
bool live() const { return connected && is_on(online) && valid_text(status); }
// Centauri firmware parks on milestone names such as preheating_completed for
// most of a job, so any status that is not a known idle or finished state is active.
bool job_active() const {
auto idle = [](const std::string &value) {
return value.empty() || value == "unknown" || value == "unavailable" || value == "none" ||
value == "idle" || value == "standby" || value == "ready" || value == "complete" ||
value == "completed" || value == "finish" || value == "finished" || value == "cancelled" ||
value == "canceled" || value == "stopped" || value == "stop" || value == "failed";
};
const auto state = lower(status);
const auto machine = lower(stage);
if (machine == "printing" || machine == "paused" || machine == "busy" || machine == "heating" ||
machine == "preheating" || machine == "running")
return true;
return !idle(state);
}
// The Centauri reports percent_complete as 0 while CurrentLayer stays 0.
// Layers, then elapsed time against the total, still describe the job.
float completion() const {
if (!elegoo) return progress;
if (std::isfinite(progress) && progress > 0.0f) return std::clamp(progress, 0.0f, 100.0f);
if (!job_active()) return std::isfinite(progress) ? 0.0f : NAN;
if (std::isfinite(layer) && std::isfinite(total_layers) && layer > 0.0f && total_layers > 0.0f)
return std::clamp(100.0f * layer / total_layers, 0.0f, 100.0f);
const int elapsed_s = duration_seconds(elapsed, elapsed_unit);
const int total_s = duration_seconds(total_time, total_time_unit);
if (elapsed_s > 0 && total_s > 0)
return std::clamp(100.0f * static_cast<float>(elapsed_s) / static_cast<float>(total_s), 0.0f, 100.0f);
return std::isfinite(progress) ? 0.0f : NAN;
}
bool has_print_error() const {
if (!elegoo) return is_on(print_error);
return elegoo_fault(print_error) || elegoo_fault(print_error_reason) || lower(status) == "error";
@@ -212,6 +249,29 @@ struct Snapshot {
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return text;
}
std::string overview_task() const {
if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant";
return valid_text(task) ? task : "No active print";
}
std::string overview_meta() const {
if (!live()) return "--";
const float percent = completion();
const std::string progress_text = std::isfinite(percent)
? number(std::clamp(percent, 0.0f, 100.0f)) + "%" : "--";
const std::string time_line = remaining(time_left, remaining_unit);
if (!finish_time.empty() && time_line != "--")
return progress_text + " " + finish_time + " " + time_line;
return progress_text + " " + time_line;
}
// Active tray is the spool the printer loaded most recently. Without one, keep green.
uint32_t active_filament_color() const {
for (const auto &tray : trays)
if (is_on(tray.active)) {
const uint32_t color = filament_color(tray.color);
if (color != TRACK) return color;
}
return GREEN;
}
uint32_t status_color() const {
if (!live()) return MUTED;
const auto state = lower(status);
@@ -223,6 +283,42 @@ struct Snapshot {
}
};
struct FleetCounts {
int attention = 0, printing = 0, paused = 0, idle = 0, offline = 0, syncing = 0;
};
inline void tally(FleetCounts &counts, const Snapshot &data) {
if (!data.connected || lower(data.online) == "off" || lower(data.online) == "false") {
++counts.offline;
return;
}
if (!data.live()) {
++counts.syncing;
return;
}
if (data.has_print_error() || is_on(data.hms_error) || lower(data.status) == "failed") {
++counts.attention;
return;
}
const auto state = lower(data.status);
if (state == "running" || state == "printing") ++counts.printing;
else if (state == "pause" || state == "paused" || state == "pausing") ++counts.paused;
else ++counts.idle;
}
inline std::string fleet_summary(const FleetCounts &counts) {
const std::pair<int, const char *> items[] = {
{counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"},
{counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}};
std::string text;
for (const auto &item : items) {
if (!item.first) continue;
if (!text.empty()) text += ", ";
text += std::to_string(item.first) + " " + item.second;
}
return text.empty() ? "Syncing" : text;
}
} // namespace bambu_display
#ifdef USE_LVGL
@@ -239,6 +335,9 @@ struct Widgets {
GaugeWidgets uv{}, layer{};
lv_obj_t *elapsed = nullptr;
};
struct OverviewWidgets {
lv_obj_t *arc, *value, *finish, *backing;
};
inline void set_text(lv_obj_t *label, const std::string &text) {
if (text != lv_label_get_text(label)) lv_label_set_text(label, text.c_str());
@@ -257,9 +356,10 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
set_text(widgets.status, data.status_text());
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0);
lv_obj_set_style_arc_color(widgets.progress.arc, lv_color_hex(color), LV_PART_INDICATOR);
lv_bar_set_value(widgets.top_progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF);
render_gauge(widgets.progress, live && std::isfinite(data.progress)
? std::clamp(data.progress, 0.0f, 100.0f) : NAN, live ? data.progress : NAN, 100,
const float percent = data.completion();
lv_bar_set_value(widgets.top_progress, live ? gauge_value(percent, 100) : 0, LV_ANIM_OFF);
render_gauge(widgets.progress, live && std::isfinite(percent)
? std::clamp(percent, 0.0f, 100.0f) : NAN, live ? percent : NAN, 100,
live ? remaining(data.time_left, data.remaining_unit) : "--");
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
: data.connected ? "Waiting for printer data" : "Waiting for Home Assistant");
@@ -306,5 +406,33 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
}
}
inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) {
const bool live = data.live();
const uint32_t filament = data.active_filament_color();
const float percent = data.completion();
const float shown = live && std::isfinite(percent)
? std::clamp(percent, 0.0f, 100.0f) : NAN;
const bool black = filament_is_black(filament);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(black ? RING_BACKING : TRACK), LV_PART_MAIN);
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(filament), LV_PART_INDICATOR);
lv_arc_set_value(widgets.arc, live ? gauge_value(percent, 100) : 0);
if (black) {
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_MAIN);
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_INDICATOR);
lv_obj_clear_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
}
set_text(widgets.value, number(shown));
lv_obj_set_style_text_color(widgets.value, lv_color_hex(0xFFFFFF), 0);
set_text(widgets.finish, live && !data.finish_time.empty() ? data.finish_time : "--");
}
inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) {
set_text(label, fleet_summary(counts));
const uint32_t color = counts.attention ? RED : counts.printing ? GREEN : MUTED;
lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
}
} // namespace bambu_display
#endif
+159 -35
View File
@@ -24,14 +24,17 @@ EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'}
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
ELEGOO_COMMON = {'progress': 'percent_complete', 'time_left': 'remaining_print_time',
'layer': 'current_layer', 'total_layers': 'total_layers'}
'layer': 'current_layer', 'total_layers': 'total_layers',
'total_time': 'total_print_time'}
ELEGOO_FDM = {'nozzle': 'nozzle_temperature', 'bed': 'bed_temperature',
'elapsed': 'current_print_time',
'nozzle_target': ('number', 'target_nozzle_temp'),
'bed_target': ('number', 'target_bed_temp'), 'fan': 'model_fan_speed',
'aux_fan': 'auxiliary_fan_speed', 'exhaust_fan': 'enclosure_fan_speed'}
ELEGOO_RESIN = {'uv': 'uv_led_temp', 'elapsed': 'current_print_time'}
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
RING_BACKING = 0x6E767E
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
@@ -121,7 +124,7 @@ def fdm_gauges(printer):
return widgets
def page_for(printer, index, total):
def page_for(printer, display_index, total):
pid = printer['id']
resin = printer.get('profile') == 'elegoo_resin'
widgets = [
@@ -129,7 +132,7 @@ def page_for(printer, index, total):
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
label(f'{index + 1} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label(f'{display_index} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED,
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
@@ -213,6 +216,141 @@ def page_for(printer, index, total):
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
def snapshot_lines(printer, expressions, trays):
profile = printer.get('profile', 'bambu')
elegoo = profile.startswith('elegoo_')
resin = profile == 'elegoo_resin'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
'const auto clock = id(ha_time).now();',
'if (clock.is_valid() && seconds_left >= 0)',
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
]
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
return lines
def overview_slots(count):
"""Place one steam gauge per printer under the page title."""
columns = 4 if count > 4 else max(count, 1)
rows = (count + columns - 1) // columns
top, bottom = 46, 432
row_h = (bottom - top) // rows
arc = min(154, (800 // columns) - 24, row_h - 48)
col_w = 800 // columns
slots = []
for index in range(count):
row, col = divmod(index, columns)
in_row = min(columns, count - row * columns)
x0 = (800 - in_row * col_w) // 2
gauge_w = arc + 16
slots.append((x0 + col * col_w + (col_w - gauge_w) // 2,
top + row * row_h, arc))
return slots
def overview_gauge(printer, x, y, arc):
pid = printer['id']
scale = arc / 154
value_font = 32 if arc >= 130 else 18
open_page = [{'lvgl.page.show': f'{pid}_page'}]
inner = max(10, int(round(13 * scale)))
inner_x = x + int(round(8 * scale))
number_y = y + int(round(arc * 0.30))
pad = 8
return [
{'arc': {'id': f'{pid}_overview_ring', 'x': inner_x - pad // 2, 'y': y - pad // 2,
'width': arc + pad, 'height': arc + pad, 'min_value': 0, 'max_value': 100,
'value': 100, 'start_angle': 135, 'end_angle': 45,
'arc_width': inner + pad, 'arc_color': RING_BACKING,
'indicator': {'arc_color': RING_BACKING, 'arc_width': inner + pad},
'adjustable': False, 'hidden': True, 'gesture_bubble': True, 'clickable': True,
'on_click': open_page}},
{'arc': {'id': f'{pid}_overview_arc', 'x': inner_x, 'y': y,
'width': arc, 'height': arc, 'min_value': 0, 'max_value': 100, 'value': 0,
'start_angle': 135, 'end_angle': 45, 'arc_width': inner, 'arc_color': TRACK,
'indicator': {'arc_color': GREEN, 'arc_width': inner}, 'adjustable': False,
'gesture_bubble': True, 'clickable': True, 'on_click': open_page}},
label('--', inner_x, number_y, arc, value_font, id=f'{pid}_overview_value',
text_align='CENTER', text_color=0xFFFFFF, clickable=True, on_click=open_page),
label('%', inner_x, number_y + (34 if value_font >= 28 else 20), arc, 16,
id=f'{pid}_overview_percent', text_align='CENTER', text_color=0xFFFFFF,
clickable=True, on_click=open_page),
label(printer['name'], x, y + arc + 2, arc + 16, 14, text_align='CENTER',
text_color=TEXT_COLOR, clickable=True, on_click=open_page),
label('--', x, y + arc + 22, arc + 16, 14, id=f'{pid}_overview_finish',
text_align='CENTER', text_color=TEXT_COLOR, clickable=True, on_click=open_page),
]
def overview_page(printers):
widgets = [
label('Print Status Overview', 16, 10, 520, 22, text_color=GREEN),
label('--:--', 548, 12, 236, 18, id='fleet_clock', text_align='RIGHT', text_color=TEXT_COLOR),
]
for printer, (x, y, arc) in zip(printers, overview_slots(len(printers))):
widgets += overview_gauge(printer, x, y, arc)
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
'bg_color': TRACK, 'radius': 8, 'border_width': 0,
'pad_all': 0, 'gesture_bubble': True,
'widgets': [{'label': {'text': text, 'align': 'CENTER',
'text_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
'on_click': [{f'lvgl.page.{direction}': {}}]}})
widgets.append(label(f'1 / {len(printers) + 1}', 94, 449, 612, 14,
text_align='CENTER', text_color=MUTED))
return {'id': 'fleet_page', 'bg_color': BG, 'bg_opa': 'COVER',
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
'on_swipe_left': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.next': {'animation': 'MOVE_LEFT', 'time': '180ms'}}],
'on_swipe_right': [
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
{'lvgl.page.previous': {'animation': 'MOVE_RIGHT', 'time': '180ms'}}],
'on_load': [{'script.execute': 'fleet_refresh'}], 'widgets': widgets}
def fleet_refresh_lines(snapshots):
lines = []
for pid, snapshot in snapshots:
lines.append('{')
lines.extend(snapshot)
lines.append('bambu_display::render_overview(data, {' + ', '.join([
f'id({pid}_overview_arc)', f'id({pid}_overview_value)',
f'id({pid}_overview_finish)', f'id({pid}_overview_ring)']) + '});')
lines.append('}')
lines += [
'{',
'const auto clock = id(ha_time).now();',
'std::string clock_text = "--:--";',
'if (clock.is_valid()) {',
' clock_text = esphome::ESPTime::from_epoch_local(clock.timestamp).strftime("%I:%M %p");',
' if (!clock_text.empty() && clock_text[0] == \'0\') clock_text.erase(0, 1);',
'}',
'bambu_display::set_text(id(fleet_clock), clock_text);',
'}',
]
return lines
def color_test_page():
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
label('Each block must match its label. Swipe to return to the printers.',
@@ -236,6 +374,8 @@ def generate(printers):
validate_printers(printers)
package = {'sensor': [], 'text_sensor': [], 'script': [],
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
snapshots = []
page_total = len(printers) + 1
for index, printer in enumerate(printers):
pid = printer['id']
profile = printer.get('profile', 'bambu')
@@ -271,7 +411,8 @@ def generate(printers):
text_fields['print_error_message'] = (print_error_entity, 'error')
text_fields['print_error_code'] = (print_error_entity, 'code')
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'),
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed')]:
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed'),
('total_time_unit', 'total_time')]:
if numeric_key in fields:
suffix = fields[numeric_key]
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
@@ -287,34 +428,11 @@ def generate(printers):
subscription['attribute'] = attribute
package['text_sensor'].append(subscription)
expressions[key] = f'id({sid}).state'
lines = ['bambu_display::Snapshot data;',
'data.connected = id(ha_api).is_connected_with_state_subscription();']
for key, expr in expressions.items():
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
lines.append(f'data.{key} = {expr};')
if elegoo:
lines.append('data.elegoo = true;')
else:
for number in range(1, 4):
lines.append('data.health_notices.push_back({' + ', '.join(
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
if resin:
lines.append('data.resin = true;')
if printer.get('dual_nozzle'):
lines.append('data.dual_nozzle = true;')
lines += [
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
'const auto clock = id(ha_time).now();',
'if (clock.is_valid() && seconds_left >= 0)',
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
]
tray_widgets = []
for i, tray in enumerate(trays):
lines.append('data.trays.push_back({' + ', '.join([
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}')
lines = snapshot_lines(printer, expressions, trays)
snapshots.append((pid, list(lines)))
tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
for part in ('card', 'swatch', 'label')) + '}'
for i in range(len(trays))]
refs = [f'id({pid}_{key})' for key in (
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
'health_details')]
@@ -331,7 +449,13 @@ def generate(printers):
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
'then': [{'script.execute': f'{pid}_refresh'}]}})
package['lvgl']['pages'].append(page_for(printer, index, len(printers)))
package['lvgl']['pages'].append(page_for(printer, index + 2, page_total))
package['script'].append({'id': 'fleet_refresh', 'then': [
{'lambda': Lambda('\n'.join(fleet_refresh_lines(snapshots)))}]})
package['interval'][0]['then'].append({'if': {
'condition': {'lvgl.page.is_showing': 'fleet_page'},
'then': [{'script.execute': 'fleet_refresh'}]}})
package['lvgl']['pages'].insert(0, overview_page(printers))
package['lvgl']['pages'].append(color_test_page())
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
'entity_category': 'diagnostic',
@@ -351,10 +475,10 @@ def main():
if args.check:
if not destination.exists() or destination.read_text() != content:
raise SystemExit('generated.yaml is stale; run generate.py')
print(f'Generated configuration is current ({len(printers)} printer pages).')
print(f'Generated configuration is current ({len(printers)} printer pages plus fleet overview).')
else:
destination.write_text(content)
print(f'Generated {destination.name} with {len(printers)} printer pages.')
print(f'Generated {destination.name} with {len(printers)} printer pages plus fleet overview.')
if __name__ == '__main__':
File diff suppressed because it is too large Load Diff
+27 -9
View File
@@ -22,12 +22,14 @@ def main():
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200,
'fan': 80, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30}
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30,
'total_time': 200}
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
'uv_unit': '°C', 'elapsed_unit': 'min', 'print_error_reason': 'ok'}
'uv_unit': '°C', 'elapsed_unit': 'min', 'total_time_unit': 'min',
'print_error_reason': 'ok'}
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
for printer in printers:
@@ -87,7 +89,21 @@ inline void setup() {
''')
steps = [{'component.update': entry['id']}
for section in ('sensor', 'text_sensor') for entry in package[section]]
steps += [{'delay': '1s'}]
steps += [{'delay': '1s'},
{'lvgl.page.show': 'fleet_page'},
{'script.execute': 'fleet_refresh'}, {'delay': '300ms'},
{'snapshot.take': {'id': 'panel_display', 'filename': 'fleet.bmp'}},
{'lambda': Lambda(
'if (std::string(lv_label_get_text(id(fleet_clock))) == "--:--") '
'{ ESP_LOGE("preview", "Fleet clock render failed"); exit(7); }')}]
for printer in printers:
pid = printer['id']
steps.append({'lambda': Lambda(
f'if (std::string(lv_label_get_text(id({pid}_overview_value))) != "67" || '
f'lv_arc_get_value(id({pid}_overview_arc)) != 67 || '
f'std::string(lv_label_get_text(id({pid}_overview_percent))) != "%" || '
f'std::string(lv_label_get_text(id({pid}_overview_finish))) == "--") '
f'{{ ESP_LOGE("preview", "Fleet gauge render failed: {pid}"); exit(7); }}')})
for printer in printers:
steps += [{'lvgl.page.show': printer['id'] + '_page'},
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
@@ -125,7 +141,7 @@ inline void setup() {
{'lvgl.widget.hide': demo_printer + '_health_panel'},
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
{'script.execute': demo_printer + '_refresh'}]
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'},
steps += [{'lvgl.page.show': 'fleet_page'}, {'delay': '250ms'},
{'lambda': Lambda('bambu_preview::setup();')}]
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
steps += [
@@ -136,11 +152,13 @@ inline void setup() {
if len(printers) > 1:
# First -> second -> first -> last -> first, then a swipe starting
# on NEXT must advance exactly once even when released over PREV.
gestures = [(650, 180, 180, printers[1]['id']),
(180, 650, 180, printers[0]['id']),
# Fleet -> first printer -> fleet -> last printer -> fleet, then a
# swipe starting on NEXT advances exactly once.
gestures = [(650, 180, 180, printers[0]['id']),
(180, 650, 180, 'fleet'),
(180, 650, 180, printers[-1]['id']),
(650, 180, 180, printers[0]['id']),
(730, 60, 460, printers[1]['id'])]
(650, 180, 180, 'fleet'),
(730, 60, 460, printers[0]['id'])]
for start, end, y, expected in gestures:
steps += [
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
@@ -165,7 +183,7 @@ inline void setup() {
subprocess.run(['esphome', 'compile', str(filename)], check=True)
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
for name in [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
for name in ['fleet'] + [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
with Image.open(bmp) as image:
image.save(args.output / (name + '.png'))
+69
View File
@@ -99,8 +99,77 @@ int main() {
assert(data.status_color() == AMBER);
data.status = "complete";
assert(data.status_text() == "COMPLETE");
data.status = "preheating_completed";
data.stage = "printing";
data.progress = 0;
data.layer = 0;
data.total_layers = 400;
data.elapsed = 30;
data.elapsed_unit = "min";
data.total_time = 60;
data.total_time_unit = "min";
assert(std::fabs(data.completion() - 50.0f) < 0.01f);
data.layer = 100;
assert(std::fabs(data.completion() - 25.0f) < 0.01f);
data.progress = 67;
assert(std::fabs(data.completion() - 67.0f) < 0.01f);
data.progress = 0;
data.layer = 0;
data.status = "idle";
data.stage = "idle";
assert(data.completion() == 0.0f);
data.online = "off";
assert(!data.live());
assert(data.status_text() == "PRINTER OFFLINE");
assert(data.health_details().find("unavailable") != std::string::npos);
data.connected = true;
data.online = "on";
data.elegoo = false;
data.print_error = "off";
data.print_error_reason = "ok";
data.hms_error = "off";
data.status = "running";
data.task = "Workshop organizer";
data.progress = 67;
data.time_left = 1.75f;
data.remaining_unit = "h";
data.finish_time = "16:05";
assert(data.overview_task() == "Workshop organizer");
assert(data.overview_meta() == "67% 16:05 1h 45m");
data.task = "";
assert(data.overview_task() == "No active print");
data.connected = false;
assert(data.overview_task() == "Waiting for Home Assistant");
assert(data.overview_meta() == "--");
data.trays = {{"A1", "PLA", "#FF8800FF", "off"}, {"A2", "PETG", "00FF00", "on"}};
assert(data.active_filament_color() == GREEN);
data.trays[1].active = "off";
data.trays[0].active = "on";
assert(data.active_filament_color() == 0xFF8800);
data.trays.clear();
assert(data.active_filament_color() == GREEN);
assert(filament_is_black(0x000000));
assert(filament_is_black(filament_color("#000000FF")));
assert(filament_is_black(0x202020));
assert(!filament_is_black(0xFFFFFF));
assert(!filament_is_black(GREEN));
assert(!filament_is_black(0xFF8800));
FleetCounts counts;
data.connected = true;
data.online = "on";
data.status = "printing";
tally(counts, data);
data.status = "pause";
tally(counts, data);
data.print_error = "on";
tally(counts, data);
data.print_error = "off";
data.online = "off";
tally(counts, data);
data.online = "unavailable";
data.connected = true;
tally(counts, data);
assert(fleet_summary(counts) == "1 attention, 1 printing, 1 paused, 1 offline, 1 syncing");
}
+59 -3
View File
@@ -27,8 +27,8 @@ class FleetTests(unittest.TestCase):
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
self.printers.append(extra)
package = generate(self.printers)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count)
self.assertEqual(len(package['script']), expected_count)
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count + 1)
self.assertEqual(len(package['script']), expected_count + 1)
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
for page in package['lvgl']['pages']:
@@ -123,7 +123,7 @@ class FleetTests(unittest.TestCase):
pass
for tag in ('!secret', '!include'):
Loader.add_constructor(tag, lambda loader, node: loader.construct_scalar(node))
config = yaml.load((HERE.parent / 'bambu-printer-display.yaml').read_text(), Loader=Loader)
config = yaml.load((HERE / 'board.yaml').read_text(), Loader=Loader)
display = config['display'][0]
pins = display['data_pins']
natural = pins['blue'] + pins['green'] + pins['red']
@@ -135,6 +135,62 @@ class FleetTests(unittest.TestCase):
for i, pin in enumerate(driver))
self.assertEqual(panel_word, color, f'RGB565 color {color:#06x} is wired incorrectly')
def test_both_boards_share_one_hardware_package(self):
class Loader(yaml.SafeLoader):
pass
Loader.add_constructor('!include', lambda loader, node: loader.construct_scalar(node))
names = set()
for filename in ('bambu-printer-display.yaml', 'bambu-printer-display-2.yaml'):
config = yaml.load((HERE.parent / filename).read_text(), Loader=Loader)
names.add(config['substitutions']['device_name'])
self.assertEqual(config['packages']['board'], 'bambu-display/board.yaml')
self.assertEqual(config['packages']['fleet'], 'bambu-display/generated.yaml')
self.assertEqual(len(names), 2)
def test_fleet_overview_lists_every_printer(self):
package = generate(self.printers)
page = package['lvgl']['pages'][0]
self.assertEqual(page['id'], 'fleet_page')
self.assertFalse(page.get('skip'))
arcs = [widget['arc'] for widget in page['widgets']
if widget.get('arc', {}).get('id', '').endswith('_overview_arc')]
self.assertEqual([arc['id'][:-len('_overview_arc')] for arc in arcs],
[printer['id'] for printer in self.printers])
backings = [widget['arc'] for widget in page['widgets']
if widget.get('arc', {}).get('id', '').endswith('_overview_ring')]
self.assertEqual([arc['id'][:-len('_overview_ring')] for arc in backings],
[printer['id'] for printer in self.printers])
for backing, arc in zip(backings, arcs):
self.assertTrue(backing['hidden'])
self.assertEqual(backing['arc_color'], 0x6E767E)
self.assertGreater(backing['width'], arc['width'])
self.assertGreaterEqual(backing['x'], 0)
self.assertGreaterEqual(backing['y'], 0)
self.assertLessEqual(backing['x'] + backing['width'], 800)
self.assertLessEqual(backing['y'] + backing['height'], 480)
for arc, printer in zip(arcs, self.printers):
self.assertEqual(arc['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
self.assertGreaterEqual(arc['width'], 120)
self.assertLessEqual(arc['width'], 154)
self.assertEqual(arc['value'], 0)
self.assertEqual(arc['arc_color'], 0x19232B)
self.assertEqual(arc['start_angle'], 135)
self.assertLessEqual(arc['x'] + arc['width'], 800)
self.assertLessEqual(arc['y'] + arc['height'], 480)
code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh')
for printer in self.printers:
self.assertIn(f'id({printer["id"]}_overview_arc)', code)
self.assertIn(f'id({printer["id"]}_overview_finish)', code)
self.assertIn(f'id({printer["id"]}_overview_ring)', code)
titles = [next(iter(widget.values())).get('text') for widget in page['widgets']]
self.assertIn('Print Status Overview', titles)
self.assertNotIn('fleet_summary', {next(iter(widget.values())).get('id') for widget in page['widgets']})
shown = [step['if']['condition']['lvgl.page.is_showing'] for step in package['interval'][0]['then']]
self.assertIn('fleet_page', shown)
charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page')
numbers = [next(iter(widget.values())).get('text') for widget in charming['widgets']]
self.assertIn(f'2 / {len(self.printers) + 1}', numbers)
def test_diagnostic_color_page_is_skipped_during_normal_navigation(self):
package = generate(self.printers)
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')
+10
View File
@@ -0,0 +1,10 @@
# Second Sunton ESP32-8048S043C fleet display. Same pages and hardware as
# bambu-printer-display.yaml, with its own ESPHome name so both boards can
# join Home Assistant. Edit bambu-display/printers.yaml, then run generate.py.
substitutions:
device_name: bambu-printer-display-2
friendly_name: Bambu Printer Display 2
packages:
board: !include bambu-display/board.yaml
fleet: !include bambu-display/generated.yaml
+2 -113
View File
@@ -1,121 +1,10 @@
# Sunton ESP32-8048S043C: 4.3" 800x480 RGB LCD + GT911 capacitive touch.
# Edit bambu-display/printers.yaml, then run its generate.py to change the fleet.
# The second board is bambu-printer-display-2.yaml; both include bambu-display/board.yaml.
substitutions:
device_name: bambu-printer-display
friendly_name: Bambu Printer Display
esphome:
name: ${device_name}
friendly_name: ${friendly_name}
min_version: 2026.9.0
includes:
- bambu-display/dashboard.h
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
flash_mode: dio
cpu_frequency: 240MHz
framework:
type: esp-idf
sdkconfig_options:
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
CONFIG_SPIRAM_RODATA: "y"
psram:
mode: octal
speed: 80MHz
logger:
hardware_uart: UART0 # GPIO19/20 are the touch bus, not native USB.
api:
id: ha_api
encryption:
key: !secret api_encryption_key
ota:
- platform: esphome
encryption:
key: !secret api_encryption_key
wifi:
ssid: !secret wifi_iot_ssid
password: !secret wifi_password
i2c:
id: touch_bus
sda: GPIO19
scl: GPIO20
scan: true
display:
- platform: mipi_rgb
id: panel_display
model: RPI
dimensions: 800x480
color_order: RGB
# Named RGB data_pins are byte-swapped by mipi_rgb's pin mapping.
# Send big-endian RGB565 pixels to match that wiring transformation.
byte_order: big_endian
de_pin: GPIO40
hsync_pin: GPIO39
vsync_pin: GPIO41
pclk_pin: GPIO42
pclk_inverted: true
pclk_frequency: 16MHz
hsync_front_porch: 8
hsync_pulse_width: 4
hsync_back_porch: 8
vsync_front_porch: 8
vsync_pulse_width: 4
vsync_back_porch: 8
data_pins:
red: [GPIO45, GPIO48, GPIO47, GPIO21, GPIO14]
green: [GPIO5, GPIO6, GPIO7, GPIO15, GPIO16, GPIO4]
blue: [GPIO8, GPIO3, GPIO46, GPIO9, GPIO1]
auto_clear_enabled: false
update_interval: never
touchscreen:
- platform: gt911
id: panel_touch
display: panel_display
i2c_id: touch_bus
address: 0x5D
# Polling avoids the GT911 INT pin's boot/address-selection behaviour.
update_interval: 20ms
output:
- platform: ledc
id: backlight_output
pin: GPIO2
light:
- platform: monochromatic
id: panel_backlight
name: Backlight
output: backlight_output
restore_mode: ALWAYS_ON
default_transition_length: 0s
time:
- platform: homeassistant
id: ha_time
timezone: America/New_York
packages:
board: !include bambu-display/board.yaml
fleet: !include bambu-display/generated.yaml
lvgl:
id: dashboard
displays: [panel_display]
touchscreens: [panel_touch]
buffer_size: 12%
color_depth: 16
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
bg_color: 0x000000
text_color: 0xEEEEEE
default_font: montserrat_18
+1
View File
@@ -0,0 +1 @@
{}
+32
View File
@@ -0,0 +1,32 @@
# Keep this file safe: it contains the unique keys for this screen.
# Wi-Fi comes from the secrets.yaml of ESPHome Device Builder.
substitutions:
DEVICE_NAME: "family-room-control"
DEVICE_FRIENDLY_NAME: "Family Room Control"
LANGUAGE: "en"
DISPLAY_MODEL: "ST7789V"
GITHUB_REF: "screens-v0.4.97"
esphome:
name: "family-room-control"
friendly_name: "Family Room Control"
packages:
display:
url: https://github.com/MaxGramser/homeassistant_espscreen
ref: screens-v0.4.97
files: [packages/cyd.yaml]
refresh: 0s
local_overrides: !include family-room-control.local.yaml
api:
encryption:
key: "T0Qgy1qdHCXD0IiO72V0AqUb1WL3F9pOQ/zg2RlG9sg="
ota:
- platform: esphome
password: "zPMI1ZoPpQIu8yulgUiUTi8pPT8UClg9"
allow_partition_access: true
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
+2 -2
View File
@@ -5,7 +5,7 @@ substitutions:
DEVICE_FRIENDLY_NAME: "Living Room"
LANGUAGE: "en"
DISPLAY_MODEL: "ST7789V"
GITHUB_REF: "screens-v0.4.89"
GITHUB_REF: "screens-v0.4.97"
esphome:
name: "living-room"
@@ -14,7 +14,7 @@ esphome:
packages:
display:
url: https://github.com/MaxGramser/homeassistant_espscreen
ref: screens-v0.4.89
ref: screens-v0.4.97
files: [packages/cyd.yaml]
refresh: 0s
local_overrides: !include living-room.local.yaml
+42
View File
@@ -35,3 +35,45 @@ notion_update_print:
"date:Completed:start": { "date": { "start": "{{ completed }}", "time_zone": "America/New_York" } }
}
}
# Google Chat incoming webhook (space webhook) for OctoEverywhere printer alerts.
# Call with `payload` = a dict shaped like a Chat Message body (text and/or cardsV2).
# Used by automations/05_printers_and_workshop/05c_octoeverywhere_google_chat.yaml.
google_chat_octoeverywhere:
url: !secret google_chat_octoeverywhere_webhook
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
# One Google Chat space per printer. The automation picks one by PrinterName and
# falls back to google_chat_octoeverywhere (shared space) for unknown printers.
google_chat_octoeverywhere_constipation_orion:
url: !secret google_chat_octoeverywhere_constipation_orion
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
google_chat_octoeverywhere_printney_spears:
url: !secret google_chat_octoeverywhere_printney_spears
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
google_chat_octoeverywhere_prints_charming:
url: !secret google_chat_octoeverywhere_prints_charming
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
google_chat_octoeverywhere_polly_pocket:
url: !secret google_chat_octoeverywhere_polly_pocket
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
google_chat_octoeverywhere_layer_slayer:
url: !secret google_chat_octoeverywhere_layer_slayer
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
google_chat_octoeverywhere_turd_flinger:
url: !secret google_chat_octoeverywhere_turd_flinger
method: POST
content_type: "application/json; charset=UTF-8"
payload: "{{ payload | to_json }}"
+15 -1
View File
@@ -35,8 +35,9 @@ notify_events_alerts:
- action: notify.notify_events_alerts
data:
message: "{{ message }}"
title: "{{ title | default('Home Assistant') }}"
data:
# notify_events reads title from data only; a top-level title is ignored
title: "{{ title | default('Home Assistant') }}"
token: !secret notify_event_alerts_token
family_announcement:
@@ -65,3 +66,16 @@ family_announcement:
target: "{{ targets }}"
message: "{{ message }}"
data: "{{ delivery | default({}) }}"
bernard_play_discovery_station:
alias: Bernard - Play Discovery Station
description: Start Discovery Station on Bernard via Music Assistant.
mode: restart
sequence:
- action: music_assistant.play_media
target:
entity_id: media_player.bernard_speaker_2
data:
media_id: apple_music--cVk7PuLm://playlist/ra.q-GAI6IDE5MmM0ODg1MmU2MTBjMjQxYWJiNzBmZjc1N2FhOTVm
media_type: playlist
enqueue: play