Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7f268888 | ||
|
|
994cfb6b60 | ||
|
|
12f8ba2def | ||
|
|
5b8a3f24c2 | ||
|
|
c5b7408b18 | ||
|
|
360e86e46a | ||
|
|
4626019a3e | ||
|
|
43f416ab69 | ||
|
|
0ec94a909e | ||
|
|
5e50c9a296 | ||
|
|
4104e995c0 | ||
|
|
d7f0584e17 | ||
|
|
472b66cf0e | ||
|
|
17633d5909 | ||
|
|
c469aa4aae | ||
|
|
0bd960dbf7 | ||
|
|
5a0ccf6b36 | ||
|
|
bdd3d4b0ae | ||
|
|
91c3996f93 | ||
|
|
3c940f8027 | ||
|
|
f672de484e | ||
|
|
19adc71fef | ||
|
|
495efeb672 | ||
|
|
14c50015a6 | ||
|
|
5e51a1f9aa | ||
|
|
34c4e54c13 |
@@ -61,6 +61,9 @@
|
||||
### ESPHome Folders ###
|
||||
!esphome/
|
||||
!esphome/fonts/**
|
||||
!esphome/bambu-display/
|
||||
!esphome/bambu-display/**
|
||||
esphome/bambu-display/**/__pycache__/
|
||||
!esphome/ha-remote/
|
||||
!esphome/ha-remote/components/
|
||||
!esphome/ha-remote/components/**
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
},
|
||||
"_firmware_jobs": [
|
||||
{
|
||||
"job_id": "cd93b1b34cb5",
|
||||
"configuration": "resin-curing-timer.yaml",
|
||||
"job_id": "b5a2e3675005",
|
||||
"configuration": "bambu-printer-display-2.yaml",
|
||||
"job_type": "compile",
|
||||
"status": "completed",
|
||||
"created_at": "2026-10-17T19:07:37.762042+00:00",
|
||||
"started_at": "2026-10-17T19:07:37.763132+00:00",
|
||||
"completed_at": "2026-10-17T19:09:40.012778+00:00",
|
||||
"compile_started_at": "2026-10-17T19:07:57.149555+00:00",
|
||||
"compile_ended_at": "2026-10-17T19:09:39.494762+00:00",
|
||||
"created_at": "2026-10-20T18:14:44.512443+00:00",
|
||||
"started_at": "2026-10-20T18:14:44.512752+00:00",
|
||||
"completed_at": "2026-10-20T18:15:31.213919+00:00",
|
||||
"compile_started_at": "2026-10-20T18:14:50.121007+00:00",
|
||||
"compile_ended_at": "2026-10-20T18:15:30.642966+00:00",
|
||||
"exit_code": 0,
|
||||
"error": null,
|
||||
"port": "",
|
||||
@@ -31,7 +31,100 @@
|
||||
"new_name": "",
|
||||
"depends_on": "",
|
||||
"dependency_released": false,
|
||||
"progress": 99,
|
||||
"progress": 83,
|
||||
"remote_peer": "",
|
||||
"remote_job_id": "",
|
||||
"remote_peer_label": "",
|
||||
"device_name": "",
|
||||
"device_friendly_name": "",
|
||||
"source": "local",
|
||||
"source_pin_sha256": "",
|
||||
"source_label": "",
|
||||
"source_esphome_version": "",
|
||||
"target_esphome_version": "",
|
||||
"failure_reason": ""
|
||||
},
|
||||
{
|
||||
"job_id": "bb19d223ab5c",
|
||||
"configuration": "bambu-printer-display.yaml",
|
||||
"job_type": "compile",
|
||||
"status": "completed",
|
||||
"created_at": "2026-10-19T19:05:44.828542+00:00",
|
||||
"started_at": "2026-10-19T19:05:44.829561+00:00",
|
||||
"completed_at": "2026-10-19T19:06:58.521787+00:00",
|
||||
"compile_started_at": "2026-10-19T19:05:54.474763+00:00",
|
||||
"compile_ended_at": "2026-10-19T19:06:57.987575+00:00",
|
||||
"exit_code": 0,
|
||||
"error": null,
|
||||
"port": "",
|
||||
"flash_bootloader": false,
|
||||
"is_deferred_install": false,
|
||||
"new_name": "",
|
||||
"depends_on": "",
|
||||
"dependency_released": false,
|
||||
"progress": 83,
|
||||
"remote_peer": "",
|
||||
"remote_job_id": "",
|
||||
"remote_peer_label": "",
|
||||
"device_name": "",
|
||||
"device_friendly_name": "",
|
||||
"source": "local",
|
||||
"source_pin_sha256": "",
|
||||
"source_label": "",
|
||||
"source_esphome_version": "",
|
||||
"target_esphome_version": "",
|
||||
"failure_reason": ""
|
||||
},
|
||||
{
|
||||
"job_id": "b4aaf902c652",
|
||||
"configuration": "bambu-printer-display.yaml",
|
||||
"job_type": "upload",
|
||||
"status": "cancelled",
|
||||
"created_at": "2026-10-18T13:33:52.743290+00:00",
|
||||
"started_at": "2026-10-18T13:35:06.474973+00:00",
|
||||
"completed_at": "2026-10-18T13:44:03.699755+00:00",
|
||||
"compile_started_at": null,
|
||||
"compile_ended_at": null,
|
||||
"exit_code": -15,
|
||||
"error": null,
|
||||
"port": "OTA",
|
||||
"flash_bootloader": false,
|
||||
"is_deferred_install": false,
|
||||
"new_name": "",
|
||||
"depends_on": "0bac95d6302f",
|
||||
"dependency_released": true,
|
||||
"progress": 50,
|
||||
"remote_peer": "",
|
||||
"remote_job_id": "",
|
||||
"remote_peer_label": "",
|
||||
"device_name": "",
|
||||
"device_friendly_name": "",
|
||||
"source": "local",
|
||||
"source_pin_sha256": "",
|
||||
"source_label": "",
|
||||
"source_esphome_version": "",
|
||||
"target_esphome_version": "",
|
||||
"failure_reason": ""
|
||||
},
|
||||
{
|
||||
"job_id": "197c6aafb405",
|
||||
"configuration": "resin-curing-timer.yaml",
|
||||
"job_type": "compile",
|
||||
"status": "completed",
|
||||
"created_at": "2026-10-17T19:20:52.620376+00:00",
|
||||
"started_at": "2026-10-17T19:20:52.620726+00:00",
|
||||
"completed_at": "2026-10-17T19:21:20.348169+00:00",
|
||||
"compile_started_at": "2026-10-17T19:20:55.169397+00:00",
|
||||
"compile_ended_at": "2026-10-17T19:21:19.787726+00:00",
|
||||
"exit_code": 0,
|
||||
"error": null,
|
||||
"port": "",
|
||||
"flash_bootloader": false,
|
||||
"is_deferred_install": false,
|
||||
"new_name": "",
|
||||
"depends_on": "",
|
||||
"dependency_released": false,
|
||||
"progress": 36,
|
||||
"remote_peer": "",
|
||||
"remote_job_id": "",
|
||||
"remote_peer_label": "",
|
||||
@@ -736,9 +829,6 @@
|
||||
"declan-a1-camera.yaml": {
|
||||
"mac_address": "D4:E9:F4:A3:59:E8"
|
||||
},
|
||||
"esp32-h2c-camera.yaml": {
|
||||
"mac_address": "E8:F6:0A:87:12:B0"
|
||||
},
|
||||
"laundry-litter-box-camera.yaml": {
|
||||
"mac_address": "D4:E9:F4:A3:59:E8"
|
||||
},
|
||||
@@ -760,16 +850,19 @@
|
||||
"panel-hardware-test.yaml": {
|
||||
"mac_address": "68:B6:B3:33:69:50"
|
||||
},
|
||||
"living-room-remote.yaml": {
|
||||
"mac_address": "68:B6:B3:33:69:50"
|
||||
},
|
||||
"family-room-remote.yaml": {
|
||||
"mac_address": "34:CD:B0:0C:3D:CC"
|
||||
},
|
||||
"living-room.yaml": {
|
||||
"mac_address": "B0:CB:D8:03:3B:6C"
|
||||
},
|
||||
"resin-curing-timer.yaml": {
|
||||
"mac_address": "B0:CB:D8:D7:B9:18"
|
||||
},
|
||||
"bambu-printer-display.yaml": {
|
||||
"mac_address": "34:CD:B0:0E:42:A0"
|
||||
},
|
||||
"family-room-control.yaml": {
|
||||
"mac_address": "A4:F0:0F:68:A1:8C"
|
||||
},
|
||||
"bambu-printer-display-2.yaml": {
|
||||
"mac_address": "34:CD:B0:0C:3D:CC"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
# Bambu and Elegoo fleet display
|
||||
|
||||
ESPHome/LVGL monitor for the **Sunton ESP32-8048S043C**, with an 800×480 landscape RGB
|
||||
display, GT911 capacitive touch, 16 MB flash and 8 MB octal PSRAM. The hardware pins
|
||||
and LCD timings follow the [ESPHome device reference](https://devices.esphome.io/devices/sunton-esp32-8048s043c/).
|
||||
This configuration is for the **C** capacitive-touch version.
|
||||
|
||||
Inspired by [BambuHelper](https://github.com/Keralots/BambuHelper), this implementation
|
||||
subscribes to the existing Home Assistant Bambu Lab and Elegoo Printer entities over
|
||||
a single encrypted ESPHome API connection. Home Assistant handles the printer connections. No Bambu
|
||||
access codes, cloud credentials or additional Home Assistant automations are needed.
|
||||
Home Assistant must be running and its printer integrations must have current printer data.
|
||||
|
||||
## Pages and navigation
|
||||
|
||||

|
||||
|
||||
The configured swipe order is:
|
||||
|
||||
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.
|
||||
7. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
|
||||
and model/auxiliary/enclosure fan speeds.
|
||||
8. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
|
||||
elapsed time, remaining time and estimated finish.
|
||||
|
||||

|
||||
|
||||
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
|
||||
stage, fan speeds and filament colors/types. The layout follows BambuHelper's
|
||||
classic gauges: black background, bright green accents, six arc gauges and a
|
||||
vertical AMS column. Nozzle arcs are orange and the bed arc is cyan. For dual-nozzle
|
||||
printers the auxiliary-fan position becomes the right-nozzle gauge. The active
|
||||
filament slot has a green outline. Print errors use red; paused prints use amber.
|
||||
|
||||
**HMS means Health Management System.** Its binary sensor means the Bambu integration
|
||||
has one or more printer health notifications, which can include information or
|
||||
maintenance messages as well as faults. Tap the status in the upper-right corner
|
||||
or the scrolling message above the footer to open the health-details panel. It
|
||||
shows the codes, severities and descriptions reported by Home Assistant, for up to
|
||||
three current HMS notices, plus print-error details. Scroll vertically for long
|
||||
messages and tap Close to return. If more than three notices exist, the panel says
|
||||
how many additional notices are available in Home Assistant.
|
||||
|
||||
Elegoo pages use the same status-tap details panel for `print_error` and
|
||||
`error_status_reason` enum values. `none` and `ok` are normal states; unknown or
|
||||
unavailable errors do not imply a fault. Elegoo pages use `sdcp_status` for printer
|
||||
connectivity and do not subscribe to Bambu HMS or AMS entities. The Saturn page
|
||||
omits nozzle, bed, fan and filament widgets.
|
||||
|
||||
The status uses **HEALTH INFO** (cyan) for information-only notifications,
|
||||
**HEALTH NOTICE** (amber) for common/unknown severities or incomplete details, and
|
||||
**HEALTH ERROR** (red) for fatal/serious severities. Severity comes from the Bambu
|
||||
integration's `N-Severity` attributes. Missing or unloaded messages never establish
|
||||
an information-only status. The display does not dismiss or clear printer alerts.
|
||||
The [`hms_errors` sensor attributes](https://github.com/greghesp/ha-bambulab/blob/main/custom_components/bambu_lab/pybambu/models.py)
|
||||
provide `Count`, `1-Code`, `1-Error`, `1-Severity`, and the corresponding numbered
|
||||
fields for subsequent notices. Stale attributes are ignored when the sensor is off.
|
||||
|
||||
When Home Assistant disconnects or a printer is offline, its live metrics are cleared
|
||||
to `--` rather than continuing to show cached values as live. Unknown individual
|
||||
values also show `--`. Remaining time respects the HA sensor's `h`, `min`, `s` or `ms`
|
||||
unit, and temperature labels use the imported HA temperature unit. Only the visible
|
||||
page is refreshed each second; every printer's subscriptions remain connected.
|
||||
Auxiliary and exhaust fans are imported only for printers with those sensors;
|
||||
unsupported fan readings show `--`. Finish time is estimated from HA's remaining
|
||||
time and the HA-synchronized clock. Set `time.timezone` in the main YAML if your
|
||||
timezone differs from `America/New_York`.
|
||||
|
||||
This version provides monitoring and local page navigation. Camera feeds, cover
|
||||
images, printer pause/stop controls, power switching, cloud login and automatic
|
||||
printer rotation are outside its current scope.
|
||||
|
||||
## Install
|
||||
|
||||
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 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.
|
||||
|
||||
The USB D+/D− GPIO19/20 pins are used by the board's touch bus. Logging therefore
|
||||
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 `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
|
||||
two bytes. Selecting little-endian pixels corrupts colors on the physical panel
|
||||
even though a host preview can look correct. Keep both byte-order settings aligned
|
||||
with the bus mapping; changing the RGB/BGR label alone is insufficient for this
|
||||
RPI panel's direct framebuffer path.
|
||||
|
||||
To check the physical colors, open the device's **Display Color Test** diagnostic
|
||||
button in Home Assistant. It shows labeled red, green, blue, white, cyan and magenta
|
||||
blocks. Swipe to return to the printers. This test page is skipped during normal
|
||||
printer navigation. Its expected image is [previews/color_test.png](previews/color_test.png).
|
||||
|
||||
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
|
||||
|
||||
Edit **`printers.yaml`**, then regenerate and flash the display:
|
||||
|
||||
```sh
|
||||
python esphome/bambu-display/generate.py
|
||||
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, 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.
|
||||
|
||||
```yaml
|
||||
- id: another_printer
|
||||
name: Another Printer
|
||||
subtitle: Workshop
|
||||
entity_prefix: my_bambu_printer
|
||||
entities:
|
||||
progress: sensor.renamed_progress
|
||||
time_left: sensor.renamed_remaining_time
|
||||
filament_banks:
|
||||
- label: A
|
||||
entity_prefix: my_bambu_printer_ams_1
|
||||
```
|
||||
|
||||
For a dual-nozzle printer add `dual_nozzle: true`. For external filament add
|
||||
`external_spools: [{label: EXT, entity: sensor.my_external_spool}]`. Per-metric
|
||||
`entities` overrides use the keys in `NUMERIC`, `DUAL`, `TEXT` and `BINARY` inside
|
||||
`generate.py`. Use `extra_fans: [aux_fan, exhaust_fan]` only where those HA entities
|
||||
exist; their mappings are in `EXTRA_FANS`. A remaining-time override also updates its unit subscription.
|
||||
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`. 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
|
||||
`entities` overrides support `sensor`, `binary_sensor` and `number` domains.
|
||||
Sensor types, duration units and enum states follow the
|
||||
[Elegoo integration definitions](https://github.com/danielcherubini/elegoo-homeassistant/blob/main/custom_components/elegoo_printer/definitions.py).
|
||||
|
||||
The configured Elegoo prefixes are `centauri_carbon` and `saturn`, matching the
|
||||
repository entity snapshot. Some older automations refer to `saturn_iv`; if your
|
||||
live integration still uses that prefix, change the Saturn `entity_prefix` before
|
||||
regenerating. If individual entities have different names in your integration
|
||||
version, use `entities` overrides, for example:
|
||||
|
||||
```yaml
|
||||
entities:
|
||||
print_error_reason: sensor.saturn_current_print_error_status_reason
|
||||
```
|
||||
|
||||
No new HA helpers are required. `dual_nozzle`, filament slots and `extra_fans` are
|
||||
Bambu options; the Elegoo FDM profile includes its three fan subscriptions.
|
||||
|
||||
## Development checks and screenshots
|
||||
|
||||
```sh
|
||||
python -m unittest discover -s esphome/bambu-display -v
|
||||
clang++ -std=c++17 -Wall -Wextra -Werror esphome/bambu-display/test_dashboard.cpp -o /tmp/bambu-dashboard-test
|
||||
/tmp/bambu-dashboard-test
|
||||
python esphome/bambu-display/preview.py
|
||||
```
|
||||
|
||||
`preview.py` compiles the same generated LVGL widgets for ESPHome's SDL host with
|
||||
clearly synthetic telemetry, then captures all seven pages and a synthetic health
|
||||
notice into `previews/`. The demo notice is not a diagnosis of any real printer. It needs
|
||||
ESPHome, Pillow, a C++ compiler and SDL2 (`sdl2-config` on PATH). It runs headlessly,
|
||||
without connecting to Home Assistant or any printer. It also injects LVGL pointer
|
||||
samples to check left/right swipes, page wraparound and consumption of a swipe that
|
||||
starts on a navigation button. These are software layout
|
||||
previews; physical panel timings, touch and Wi-Fi still need a hardware check.
|
||||
The preview also checks Elegoo telemetry, error text and clearing of offline data.
|
||||
@@ -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
|
||||
@@ -0,0 +1,438 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace bambu_display {
|
||||
|
||||
constexpr uint32_t GREEN = 0x00FF00;
|
||||
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)));
|
||||
return text;
|
||||
}
|
||||
|
||||
inline bool valid_text(const std::string &text) {
|
||||
const auto value = lower(text);
|
||||
return !value.empty() && value != "unknown" && value != "unavailable" && value != "none";
|
||||
}
|
||||
|
||||
inline bool is_on(const std::string &text) {
|
||||
const auto value = lower(text);
|
||||
return value == "on" || value == "true" || value == "1";
|
||||
}
|
||||
|
||||
inline bool elegoo_fault(const std::string &text) {
|
||||
const auto value = lower(text);
|
||||
return valid_text(value) && value != "ok" && value != "0" && value != "off" && value != "false";
|
||||
}
|
||||
|
||||
inline std::string humanize(std::string text) {
|
||||
if (!valid_text(text)) return "--";
|
||||
std::replace(text.begin(), text.end(), '_', ' ');
|
||||
if (!text.empty()) text[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(text[0])));
|
||||
return text;
|
||||
}
|
||||
|
||||
inline int gauge_value(float value, int maximum) {
|
||||
return std::isfinite(value) ? static_cast<int>(std::clamp(value, 0.0f, float(maximum))) : 0;
|
||||
}
|
||||
|
||||
inline std::string number(float value) {
|
||||
if (!std::isfinite(value) || value < 0 || value > 1000000) return "--";
|
||||
char buffer[24];
|
||||
snprintf(buffer, sizeof(buffer), "%.0f", value);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
inline std::string temperature(float current, float target, const std::string &unit) {
|
||||
return number(current) + " / " + number(target) + (valid_text(unit) ? " " + unit : "");
|
||||
}
|
||||
|
||||
inline std::string remaining(float value, const std::string &unit) {
|
||||
if (!std::isfinite(value) || value < 0) return "--";
|
||||
double minutes;
|
||||
if (unit == "h") minutes = double(value) * 60;
|
||||
else if (unit == "min") minutes = value;
|
||||
else if (unit == "s") minutes = double(value) / 60;
|
||||
else if (unit == "ms") minutes = double(value) / 60000;
|
||||
else return "--"; // Never silently interpret hours as minutes.
|
||||
if (minutes > 1000000) return "--";
|
||||
const auto rounded = static_cast<int>(std::ceil(minutes - 0.001));
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%dh %02dm", rounded / 60, rounded % 60);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
inline int duration_seconds(float value, const std::string &unit) {
|
||||
if (!std::isfinite(value) || value < 0) return -1;
|
||||
double seconds;
|
||||
if (unit == "h") seconds = double(value) * 3600;
|
||||
else if (unit == "min") seconds = double(value) * 60;
|
||||
else if (unit == "s") seconds = value;
|
||||
else if (unit == "ms") seconds = double(value) / 1000;
|
||||
else return -1;
|
||||
return seconds <= 60000000 ? static_cast<int>(std::round(seconds)) : -1;
|
||||
}
|
||||
|
||||
inline float celsius(float value, const std::string &unit) {
|
||||
return unit == "°F" || unit == "F" ? (value - 32.0f) * 5.0f / 9.0f : value;
|
||||
}
|
||||
|
||||
inline uint32_t filament_color(std::string value) {
|
||||
if (!value.empty() && value[0] == '#') value.erase(0, 1);
|
||||
// Bambu publishes RGB or RGBA, with alpha at the end.
|
||||
if (value.size() != 6 && value.size() != 8) return TRACK;
|
||||
uint32_t rgb = 0;
|
||||
for (size_t i = 0; i < value.size(); ++i) {
|
||||
const char c = static_cast<char>(std::tolower(static_cast<unsigned char>(value[i])));
|
||||
const int digit = c >= '0' && c <= '9' ? c - '0' : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
|
||||
if (digit < 0) return TRACK;
|
||||
if (i < 6) rgb = (rgb << 4) | digit;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
struct HealthNotice { std::string code, message, severity; };
|
||||
|
||||
struct Snapshot {
|
||||
bool connected = false;
|
||||
std::string online, status, task, stage, print_error, hms_error;
|
||||
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, total_time = NAN;
|
||||
std::vector<HealthNotice> health_notices;
|
||||
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;
|
||||
float aux_fan = NAN, exhaust_fan = NAN;
|
||||
bool dual_nozzle = false;
|
||||
float left_nozzle = NAN, left_target = NAN, right_nozzle = NAN, right_target = NAN;
|
||||
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";
|
||||
}
|
||||
std::string error_message() const {
|
||||
if (!elegoo) return valid_text(print_error_message) ? print_error_message : "Print error reported; tap status for details";
|
||||
std::string message;
|
||||
for (const auto &text : {print_error, print_error_reason}) {
|
||||
if (!elegoo_fault(text)) continue;
|
||||
if (!message.empty()) message += " / ";
|
||||
message += humanize(text);
|
||||
}
|
||||
return message.empty() ? "Print error reported; tap status for details" : message;
|
||||
}
|
||||
int notice_count() const {
|
||||
int loaded = 0;
|
||||
for (const auto ¬ice : health_notices)
|
||||
if (valid_text(notice.code) || valid_text(notice.message)) ++loaded;
|
||||
char *end;
|
||||
const long count = std::strtol(hms_count.c_str(), &end, 10);
|
||||
return end != hms_count.c_str() && *end == '\0' && count >= 0 && count < 10000
|
||||
? std::max(loaded, static_cast<int>(count)) : loaded;
|
||||
}
|
||||
int health_severity_rank() const {
|
||||
int rank = 1, loaded = 0;
|
||||
for (const auto ¬ice : health_notices) {
|
||||
if (!valid_text(notice.code) && !valid_text(notice.message)) continue;
|
||||
++loaded;
|
||||
const auto severity = lower(notice.severity);
|
||||
rank = std::max(rank, severity == "fatal" || severity == "serious" ? 3 : severity == "info" ? 1 : 2);
|
||||
}
|
||||
// Missing/unloaded messages cannot establish an info-only notification.
|
||||
return loaded == 0 || notice_count() > loaded ? std::max(rank, 2) : rank;
|
||||
}
|
||||
std::string alert_summary() const {
|
||||
if (has_print_error()) return error_message();
|
||||
if (is_on(hms_error)) {
|
||||
for (const auto ¬ice : health_notices)
|
||||
if (valid_text(notice.message)) return notice.message;
|
||||
return "Printer health notification; tap status for details";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
std::string health_details() const {
|
||||
if (!live()) return "Printer health data is unavailable. Waiting for a live connection.";
|
||||
std::string details;
|
||||
if (has_print_error()) {
|
||||
details = "PRINT ERROR\n";
|
||||
if (valid_text(print_error_code)) details += print_error_code + "\n";
|
||||
details += error_message();
|
||||
details += "\n\n";
|
||||
}
|
||||
if (is_on(hms_error)) {
|
||||
details += "HMS = Health Management System\n";
|
||||
int loaded = 0;
|
||||
for (const auto ¬ice : health_notices) {
|
||||
if (!valid_text(notice.code) && !valid_text(notice.message)) continue;
|
||||
details += "\nNotice " + std::to_string(++loaded);
|
||||
if (valid_text(notice.severity)) details += " / " + notice.severity;
|
||||
details += "\n";
|
||||
if (valid_text(notice.code)) details += notice.code + "\n";
|
||||
details += valid_text(notice.message) ? notice.message : "Message not supplied by Home Assistant.";
|
||||
details += "\n";
|
||||
}
|
||||
if (loaded == 0) details += "\nNotification is active; details have not arrived from Home Assistant.\n";
|
||||
if (notice_count() > loaded)
|
||||
details += "\n" + std::to_string(notice_count() - loaded) + " more notification(s). View all in Home Assistant.\n";
|
||||
}
|
||||
return details.empty() ? "No current printer health notifications." : details;
|
||||
}
|
||||
std::string status_text() const {
|
||||
if (!connected) return "HA OFFLINE";
|
||||
if (lower(online) == "off" || lower(online) == "false") return "PRINTER OFFLINE";
|
||||
if (!live()) return "SYNCING";
|
||||
if (has_print_error()) return "PRINT ERROR";
|
||||
if (is_on(hms_error))
|
||||
return health_severity_rank() >= 3 ? "HEALTH ERROR" : health_severity_rank() == 1 ? "HEALTH INFO" : "HEALTH NOTICE";
|
||||
const auto state = lower(status);
|
||||
if (state == "running" || state == "printing") return "PRINTING";
|
||||
if (state == "pause" || state == "paused") return "PAUSED";
|
||||
if (state == "finish" || state == "finished" || state == "complete" || state == "completed") return "COMPLETE";
|
||||
auto text = humanize(status);
|
||||
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);
|
||||
if (has_print_error() || state == "failed") return RED;
|
||||
if (is_on(hms_error))
|
||||
return health_severity_rank() >= 3 ? RED : health_severity_rank() == 1 ? 0x00E5E5 : AMBER;
|
||||
if (state == "pause" || state == "paused" || state == "pausing") return AMBER;
|
||||
return GREEN;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
#include <lvgl.h>
|
||||
|
||||
namespace bambu_display {
|
||||
|
||||
struct TrayWidgets { lv_obj_t *card, *swatch, *label; };
|
||||
struct GaugeWidgets { lv_obj_t *arc, *value, *detail; };
|
||||
struct Widgets {
|
||||
lv_obj_t *status, *task, *top_progress, *remaining, *layers, *stage, *health_details;
|
||||
GaugeWidgets progress, nozzle, bed, fan, aux, exhaust;
|
||||
std::vector<TrayWidgets> trays;
|
||||
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());
|
||||
}
|
||||
|
||||
inline void render_gauge(const GaugeWidgets &widgets, float value, float position,
|
||||
int maximum, const std::string &detail) {
|
||||
lv_arc_set_value(widgets.arc, gauge_value(position, maximum));
|
||||
set_text(widgets.value, number(value));
|
||||
set_text(widgets.detail, detail);
|
||||
}
|
||||
|
||||
inline void render(const Snapshot &data, const Widgets &widgets) {
|
||||
const bool live = data.live();
|
||||
const uint32_t color = data.status_color();
|
||||
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);
|
||||
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");
|
||||
if (data.resin) {
|
||||
render_gauge(widgets.uv, live ? data.uv : NAN,
|
||||
live ? celsius(data.uv, data.uv_unit) : NAN, 100,
|
||||
live && valid_text(data.uv_unit) ? data.uv_unit : "--");
|
||||
const float layer_percent = std::isfinite(data.layer) && std::isfinite(data.total_layers)
|
||||
&& data.layer >= 0 && data.total_layers > 0 ? 100 * data.layer / data.total_layers : NAN;
|
||||
render_gauge(widgets.layer, live ? data.layer : NAN, live ? layer_percent : NAN, 100,
|
||||
live ? "/ " + number(data.total_layers) : "--");
|
||||
set_text(widgets.elapsed, "Elapsed: " + (live ? remaining(data.elapsed, data.elapsed_unit) : "--"));
|
||||
} else {
|
||||
const float nozzle = data.dual_nozzle ? data.left_nozzle : data.nozzle;
|
||||
const float target = data.dual_nozzle ? data.left_target : data.nozzle_target;
|
||||
render_gauge(widgets.nozzle, live ? nozzle : NAN, live ? celsius(nozzle, data.nozzle_unit) : NAN,
|
||||
300, live ? "/ " + number(target) + " " + data.nozzle_unit : "--");
|
||||
render_gauge(widgets.bed, live ? data.bed : NAN, live ? celsius(data.bed, data.bed_unit) : NAN,
|
||||
120, live ? "/ " + number(data.bed_target) + " " + data.bed_unit : "--");
|
||||
render_gauge(widgets.fan, live ? data.fan : NAN, live ? data.fan : NAN, 100, "%");
|
||||
if (data.dual_nozzle)
|
||||
render_gauge(widgets.aux, live ? data.right_nozzle : NAN,
|
||||
live ? celsius(data.right_nozzle, data.nozzle_unit) : NAN,
|
||||
300, live ? "/ " + number(data.right_target) + " " + data.nozzle_unit : "--");
|
||||
else
|
||||
render_gauge(widgets.aux, live ? data.aux_fan : NAN, live ? data.aux_fan : NAN, 100, "%");
|
||||
render_gauge(widgets.exhaust, live ? data.exhaust_fan : NAN, live ? data.exhaust_fan : NAN, 100, "%");
|
||||
}
|
||||
std::string time_line = live ? remaining(data.time_left, data.remaining_unit) : "--";
|
||||
if (live && !data.finish_time.empty()) time_line = data.finish_time + " | " + time_line;
|
||||
set_text(widgets.remaining, time_line);
|
||||
set_text(widgets.layers, live ? "Layer " + number(data.layer) + " / " + number(data.total_layers) : "--");
|
||||
std::string stage = live ? humanize(data.stage) : "Telemetry unavailable";
|
||||
if (live && (data.has_print_error() || is_on(data.hms_error))) stage = data.alert_summary();
|
||||
set_text(widgets.stage, stage);
|
||||
lv_obj_set_style_text_color(widgets.stage, lv_color_hex(live && !data.alert_summary().empty() ? color : MUTED), 0);
|
||||
set_text(widgets.health_details, data.health_details());
|
||||
for (size_t i = 0; i < std::min(data.trays.size(), widgets.trays.size()); ++i) {
|
||||
const auto &tray = data.trays[i];
|
||||
const auto &widget = widgets.trays[i];
|
||||
set_text(widget.label, tray.label + "\n" + (live && valid_text(tray.type) ? tray.type : "--"));
|
||||
lv_obj_set_style_bg_color(widget.swatch, lv_color_hex(live ? filament_color(tray.color) : TRACK), 0);
|
||||
lv_obj_set_style_border_color(widget.card, lv_color_hex(live && is_on(tray.active) ? GREEN : TRACK), 0);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the ESPHome fleet package from printers.yaml. Requires PyYAML."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import yaml
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
NUMERIC = {
|
||||
'progress': 'print_progress', 'nozzle': 'nozzle_temperature',
|
||||
'nozzle_target': 'nozzle_target_temperature', 'bed': 'bed_temperature',
|
||||
'bed_target': 'bed_target_temperature', 'time_left': 'remaining_time',
|
||||
'layer': 'current_layer', 'total_layers': 'total_layer_count',
|
||||
'fan': 'cooling_fan_speed',
|
||||
}
|
||||
DUAL = {
|
||||
'left_nozzle': 'left_nozzle_temperature', 'left_target': 'left_nozzle_target_temperature',
|
||||
'right_nozzle': 'right_nozzle_temperature', 'right_target': 'right_nozzle_target_temperature',
|
||||
}
|
||||
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',
|
||||
'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
|
||||
|
||||
|
||||
class Lambda(str):
|
||||
"""A C++ lambda serialized with ESPHome's YAML tag."""
|
||||
|
||||
|
||||
class Dumper(yaml.SafeDumper):
|
||||
def ignore_aliases(self, data):
|
||||
return True
|
||||
|
||||
|
||||
Dumper.add_representer(Lambda, lambda dumper, text: dumper.represent_scalar('!lambda', text, style='|'))
|
||||
|
||||
|
||||
def entity(printer, key, suffix, domain='sensor'):
|
||||
return printer.get('entities', {}).get(key, f'{domain}.{printer["entity_prefix"]}_{suffix}')
|
||||
|
||||
|
||||
def trays_for(printer):
|
||||
result = []
|
||||
for bank in printer.get('filament_banks', []):
|
||||
for slot in range(1, 5):
|
||||
result.append({'label': f'{bank["label"]}{slot}',
|
||||
'entity': f'sensor.{bank["entity_prefix"]}_tray_{slot}'})
|
||||
return result + printer.get('external_spools', [])
|
||||
|
||||
|
||||
def validate_printers(printers):
|
||||
if not isinstance(printers, list) or not printers:
|
||||
raise ValueError('printers must be a nonempty list')
|
||||
ids = set()
|
||||
for printer in printers:
|
||||
for key in ('id', 'name', 'subtitle', 'entity_prefix'):
|
||||
if not isinstance(printer.get(key), str) or not printer[key]:
|
||||
raise ValueError(f'Each printer needs a nonempty {key}')
|
||||
pid = printer['id']
|
||||
if not re.fullmatch(r'[a-z][a-z0-9_]*', pid) or pid in ids:
|
||||
raise ValueError(f'Invalid or duplicate printer id: {pid}')
|
||||
ids.add(pid)
|
||||
profile = printer.get('profile', 'bambu')
|
||||
if profile not in ('bambu', 'elegoo_fdm', 'elegoo_resin'):
|
||||
raise ValueError(f'{pid}: unsupported profile: {profile}')
|
||||
if profile != 'bambu' and (printer.get('dual_nozzle') or trays_for(printer) or printer.get('extra_fans')):
|
||||
raise ValueError(f'{pid}: dual_nozzle, filament slots and extra_fans require the bambu profile')
|
||||
if len(trays_for(printer)) > 8:
|
||||
raise ValueError(f'{pid}: the filament strip fits up to eight slots')
|
||||
if 'dual_nozzle' in printer and not isinstance(printer['dual_nozzle'], bool):
|
||||
raise ValueError(f'{pid}: dual_nozzle must be true or false')
|
||||
if any(key not in EXTRA_FANS for key in printer.get('extra_fans', [])):
|
||||
raise ValueError(f'{pid}: extra_fans supports aux_fan and exhaust_fan')
|
||||
for value in printer.get('entities', {}).values():
|
||||
if not isinstance(value, str) or not re.fullmatch(r'(sensor|binary_sensor|number)\.[a-z0-9_]+', value):
|
||||
raise ValueError(f'{pid}: invalid entity override: {value}')
|
||||
|
||||
|
||||
def label(text, x, y, width, font=18, **kwargs):
|
||||
return {'label': {'text': text, 'x': x, 'y': y, 'width': width,
|
||||
'height': font + 8, 'text_color': TEXT_COLOR,
|
||||
'text_font': f'montserrat_{font}', 'long_mode': 'DOT',
|
||||
'gesture_bubble': True, **kwargs}}
|
||||
|
||||
|
||||
def gauge(pid, key, title, x, y, color, maximum):
|
||||
return [
|
||||
{'arc': {'id': f'{pid}_{key}_arc', 'x': x + 8, 'y': y, 'width': 154, 'height': 154,
|
||||
'min_value': 0, 'max_value': maximum, 'value': 0,
|
||||
'start_angle': 135, 'end_angle': 45, 'arc_width': 13,
|
||||
'arc_color': TRACK, 'indicator': {'arc_color': color, 'arc_width': 13},
|
||||
'adjustable': False, 'gesture_bubble': True}},
|
||||
label('--', x + 14, y + 47, 142, 32, id=f'{pid}_{key}_label', text_align='CENTER'),
|
||||
label('--', x + 14, y + 86, 142, 16, id=f'{pid}_{key}_detail', text_align='CENTER', text_color=MUTED),
|
||||
label(title, x, y + 137, 170, 18, text_align='CENTER', text_color=color),
|
||||
]
|
||||
|
||||
|
||||
def fdm_gauges(printer):
|
||||
pid = printer['id']
|
||||
widgets = gauge(pid, 'nozzle', 'Nozzle L' if printer.get('dual_nozzle') else 'Nozzle',
|
||||
202, 62, ORANGE, 300)
|
||||
widgets += gauge(pid, 'bed', 'Bed', 388, 62, CYAN, 120)
|
||||
widgets += gauge(pid, 'fan', 'Part', 16, 229, 0xEEEEEE, 100)
|
||||
widgets += gauge(pid, 'aux', 'Nozzle R' if printer.get('dual_nozzle') else 'Aux',
|
||||
202, 229, ORANGE if printer.get('dual_nozzle') else 0xEEEEEE,
|
||||
300 if printer.get('dual_nozzle') else 100)
|
||||
widgets += gauge(pid, 'exhaust', 'Exhaust', 388, 229, GREEN, 100)
|
||||
return widgets
|
||||
|
||||
|
||||
def page_for(printer, display_index, total):
|
||||
pid = printer['id']
|
||||
resin = printer.get('profile') == 'elegoo_resin'
|
||||
widgets = [
|
||||
{'bar': {'id': f'{pid}_top_progress', 'x': 0, 'y': 0, 'width': 800, 'height': 5,
|
||||
'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'{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,
|
||||
'border_width': 0, 'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
|
||||
]
|
||||
widgets += gauge(pid, 'progress', 'Progress', 16, 62, GREEN, 100)
|
||||
if resin:
|
||||
widgets += gauge(pid, 'uv', 'UV LED', 202, 62, ORANGE, 100)
|
||||
widgets += gauge(pid, 'layer', 'Layer', 388, 62, CYAN, 100)
|
||||
widgets += [label('FINISH / TIME REMAINING', 16, 260, 544, 18,
|
||||
text_align='CENTER', text_color=MUTED),
|
||||
label('--', 16, 354, 544, 20, id=f'{pid}_elapsed_label',
|
||||
text_align='CENTER', text_color=MUTED)]
|
||||
else:
|
||||
widgets += fdm_gauges(printer)
|
||||
widgets += [
|
||||
label('--', 16, 300 if resin else 401, 544, 26, id=f'{pid}_remaining_label', text_color=GREEN, text_align='CENTER'),
|
||||
label('Telemetry unavailable', 16, 430, 544, 12, id=f'{pid}_stage_label', text_color=MUTED,
|
||||
text_align='CENTER', long_mode='SCROLL_CIRCULAR', clickable=True,
|
||||
on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
|
||||
label('--', 594, 397, 190, 16, id=f'{pid}_layers_label', text_color=MUTED, text_align='CENTER'),
|
||||
]
|
||||
trays = trays_for(printer)
|
||||
for i, tray in enumerate(trays):
|
||||
bank = i // 4
|
||||
slot = i % 4
|
||||
if slot == 0:
|
||||
title = 'AMS ' + tray['label'][:-1] if tray['label'][-1:].isdigit() else 'EXTERNAL SPOOL'
|
||||
widgets.append(label(title, 594, 70 + bank * 145, 190, 20,
|
||||
text_align='CENTER', text_color=MUTED))
|
||||
widgets.append({'obj': {
|
||||
'id': f'{pid}_tray_{i}_card', 'x': 594 + 48 * slot, 'y': 102 + bank * 145,
|
||||
'width': 44, 'height': 82, 'bg_color': BG, 'radius': 5,
|
||||
'border_width': 1, 'border_color': TRACK, 'pad_all': 0,
|
||||
'scrollable': False, 'gesture_bubble': True,
|
||||
'widgets': [
|
||||
{'obj': {'id': f'{pid}_tray_{i}_swatch', 'x': 3, 'y': 3, 'width': 36,
|
||||
'height': 40, 'bg_color': TRACK, 'radius': 4, 'border_width': 0,
|
||||
'pad_all': 0, 'scrollable': False, 'gesture_bubble': True}},
|
||||
label(f'{tray["label"]}\n--', 1, 46, 40, 12, height=34,
|
||||
id=f'{pid}_tray_{i}_label', text_align='CENTER'),
|
||||
]}})
|
||||
if not trays:
|
||||
widgets.append(label('ELEGOO' if printer.get('profile', '').startswith('elegoo_') else 'No AMS', 594, 150, 190, 20,
|
||||
text_color=MUTED, text_align='CENTER'))
|
||||
widgets.append(label(printer['subtitle'], 594, 185, 190, 16,
|
||||
text_color=MUTED, text_align='CENTER'))
|
||||
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('Waiting for Home Assistant', 94, 449, 612, 14,
|
||||
id=f'{pid}_task_label', text_align='CENTER'))
|
||||
widgets.append({'obj': {
|
||||
'id': f'{pid}_health_panel', 'x': 24, 'y': 62, 'width': 752, 'height': 366,
|
||||
'hidden': True, 'bg_color': BG, 'bg_opa': 'COVER', 'border_width': 2,
|
||||
'border_color': MUTED, 'radius': 10, 'pad_all': 0, 'scrollable': False,
|
||||
'gesture_bubble': False,
|
||||
'widgets': [
|
||||
label('Printer health / ' + printer['name'], 16, 14, 716, 20),
|
||||
{'obj': {'x': 12, 'y': 50, 'width': 724, 'height': 246,
|
||||
'bg_color': BG, 'border_width': 0, 'pad_all': 0,
|
||||
'scroll_dir': 'VER', 'scrollbar_mode': 'AUTO', 'gesture_bubble': False,
|
||||
'widgets': [label('No current health notifications.', 0, 0, 700, 18,
|
||||
id=f'{pid}_health_details', long_mode='WRAP', height='SIZE_CONTENT')]}},
|
||||
{'button': {'x': 624, 'y': 310, 'width': 108, 'height': 40, 'bg_color': TRACK,
|
||||
'radius': 6, 'widgets': [{'label': {'text': 'CLOSE', 'align': 'CENTER'}}],
|
||||
'on_click': [{'lvgl.widget.hide': f'{pid}_health_panel'}]}},
|
||||
]}})
|
||||
return {'id': f'{pid}_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': 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.',
|
||||
16, 62, 768, 18)]
|
||||
colors = [('RED', 0xFF0000), ('GREEN', 0x00FF00), ('BLUE', 0x0000FF),
|
||||
('WHITE', 0xFFFFFF), ('CYAN', 0x00FFFF), ('MAGENTA', 0xFF00FF)]
|
||||
for i, (name, color) in enumerate(colors):
|
||||
widgets += [
|
||||
{'obj': {'x': 16 + i * 129, 'y': 118, 'width': 122, 'height': 200,
|
||||
'bg_color': color, 'bg_opa': 'COVER', 'border_width': 0,
|
||||
'scrollable': False, 'gesture_bubble': True}},
|
||||
label(name, 16 + i * 129, 332, 122, 18, text_align='CENTER'),
|
||||
]
|
||||
return {'id': 'color_test_page', 'skip': True, 'bg_color': BG, 'bg_opa': 'COVER',
|
||||
'pad_all': 0, 'scrollable': False,
|
||||
'on_swipe_left': [{'lvgl.page.next': {}}],
|
||||
'on_swipe_right': [{'lvgl.page.previous': {}}], 'widgets': widgets}
|
||||
|
||||
|
||||
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')
|
||||
elegoo = profile.startswith('elegoo_')
|
||||
resin = profile == 'elegoo_resin'
|
||||
fields = {**ELEGOO_COMMON, **(ELEGOO_RESIN if resin else ELEGOO_FDM)} if elegoo else dict(NUMERIC)
|
||||
if printer.get('dual_nozzle'):
|
||||
fields.update(DUAL)
|
||||
fields.update({key: EXTRA_FANS[key] for key in printer.get('extra_fans', [])})
|
||||
expressions = {}
|
||||
for key, suffix in fields.items():
|
||||
sid = f'{pid}_{key}'
|
||||
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
|
||||
eid = entity(printer, key, suffix, domain)
|
||||
package['sensor'].append({'platform': 'homeassistant', 'id': sid,
|
||||
'entity_id': eid, 'internal': True})
|
||||
expressions[key] = f'id({sid}).state'
|
||||
if elegoo:
|
||||
text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in {
|
||||
'status': 'print_status', 'task': 'file_name', 'stage': 'current_status',
|
||||
'print_error': 'print_error', 'print_error_reason': 'error_status_reason'}.items()}
|
||||
text_fields['online'] = (entity(printer, 'online', 'sdcp_status', 'binary_sensor'), None)
|
||||
else:
|
||||
text_fields = {key: (entity(printer, key, suffix), None) for key, suffix in TEXT.items()}
|
||||
text_fields.update({key: (entity(printer, key, suffix, 'binary_sensor'), None)
|
||||
for key, suffix in BINARY.items()})
|
||||
hms_entity = entity(printer, 'hms_error', BINARY['hms_error'], 'binary_sensor')
|
||||
text_fields['hms_count'] = (hms_entity, 'Count')
|
||||
for number in range(1, 4):
|
||||
for key, attribute in [('message', 'Error'), ('code', 'Code'), ('severity', 'Severity')]:
|
||||
text_fields[f'hms_{number}_{key}'] = (hms_entity, f'{number}-{attribute}')
|
||||
print_error_entity = entity(printer, 'print_error', BINARY['print_error'], 'binary_sensor')
|
||||
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'),
|
||||
('total_time_unit', 'total_time')]:
|
||||
if numeric_key in fields:
|
||||
suffix = fields[numeric_key]
|
||||
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
|
||||
text_fields[key] = (entity(printer, numeric_key, suffix, domain), 'unit_of_measurement')
|
||||
trays = trays_for(printer)
|
||||
for i, tray in enumerate(trays):
|
||||
for key, attr in [('type', 'type'), ('color', 'color'), ('active', 'active')]:
|
||||
text_fields[f'tray_{i}_{key}'] = (tray['entity'], attr)
|
||||
for key, (eid, attribute) in text_fields.items():
|
||||
sid = f'{pid}_{key}'
|
||||
subscription = {'platform': 'homeassistant', 'id': sid, 'entity_id': eid, 'internal': True}
|
||||
if attribute:
|
||||
subscription['attribute'] = attribute
|
||||
package['text_sensor'].append(subscription)
|
||||
expressions[key] = f'id({sid}).state'
|
||||
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')]
|
||||
for key in ('progress', 'nozzle', 'bed', 'fan', 'aux', 'exhaust'):
|
||||
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}'
|
||||
if not resin or key == 'progress' else '{}')
|
||||
refs.append('{' + ', '.join(tray_widgets) + '}')
|
||||
if resin:
|
||||
for key in ('uv', 'layer'):
|
||||
refs.append('{' + ', '.join(f'id({pid}_{key}_{part})' for part in ('arc', 'label', 'detail')) + '}')
|
||||
refs.append(f'id({pid}_elapsed_label)')
|
||||
lines.append('bambu_display::render(data, {' + ', '.join(refs) + '});')
|
||||
package['script'].append({'id': f'{pid}_refresh', 'then': [{'lambda': Lambda('\n'.join(lines))}]})
|
||||
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 + 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',
|
||||
'on_press': [{'lvgl.page.show': 'color_test_page'}]}]
|
||||
return package
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--check', action='store_true', help='Fail if generated.yaml is stale')
|
||||
args = parser.parse_args()
|
||||
printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
|
||||
package = generate(printers)
|
||||
content = ('# AUTO-GENERATED by bambu-display/generate.py. Edit printers.yaml or generate.py.\n'
|
||||
+ yaml.dump(package, Dumper=Dumper, sort_keys=False, allow_unicode=True, width=110))
|
||||
destination = HERE / 'generated.yaml'
|
||||
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 plus fleet overview).')
|
||||
else:
|
||||
destination.write_text(content)
|
||||
print(f'Generated {destination.name} with {len(printers)} printer pages plus fleet overview.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the real LVGL pages with demo telemetry using ESPHome's headless SDL host."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
import yaml
|
||||
|
||||
from generate import Dumper, HERE, Lambda, generate
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--output', type=Path, default=HERE / 'previews')
|
||||
args = parser.parse_args()
|
||||
printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
|
||||
package = generate(printers)
|
||||
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,
|
||||
'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', 'total_time_unit': 'min',
|
||||
'print_error_reason': 'ok'}
|
||||
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
|
||||
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
|
||||
for printer in printers:
|
||||
prefix = printer['id'] + '_'
|
||||
for section in ('sensor', 'text_sensor'):
|
||||
for entry in package[section]:
|
||||
if not entry['id'].startswith(prefix):
|
||||
continue
|
||||
key = entry['id'][len(prefix):]
|
||||
if section == 'sensor':
|
||||
value = str(demo_values[key])
|
||||
if key == 'time_left' and printer.get('profile', '').startswith('elegoo_'):
|
||||
value = '105'
|
||||
else:
|
||||
if key.startswith('tray_'):
|
||||
_, index, attribute = key.split('_')
|
||||
text = {'type': 'PLA', 'color': colors[int(index) % len(colors)],
|
||||
'active': 'on' if index == '0' else 'off'}[attribute]
|
||||
elif key.startswith(('hms_1_', 'hms_2_', 'hms_3_')):
|
||||
text = ''
|
||||
else:
|
||||
text = demo_text[key]
|
||||
if printer.get('profile', '').startswith('elegoo_'):
|
||||
text = {'status': 'printing', 'print_error': 'none',
|
||||
'remaining_unit': 'min'}.get(key, text)
|
||||
value = 'std::string(' + json.dumps(text, ensure_ascii=False) + ')'
|
||||
entry.clear()
|
||||
entry.update({'platform': 'template', 'id': prefix + key, 'internal': True,
|
||||
'lambda': Lambda('return ' + value + ';'), 'update_interval': '60s'})
|
||||
for script in package['script']:
|
||||
code = script['then'][0]['lambda']
|
||||
script['then'][0]['lambda'] = Lambda(code.replace(
|
||||
'id(ha_api).is_connected_with_state_subscription()', 'true'))
|
||||
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='bambu-lvgl-preview-') as work:
|
||||
root = Path(work)
|
||||
# Inject real LVGL pointer samples; test bubbling through cards/buttons
|
||||
# without a GUI window or a physical touchscreen.
|
||||
touch_header = root / 'preview_touch.h'
|
||||
touch_header.write_text('''#pragma once
|
||||
#include <lvgl.h>
|
||||
namespace bambu_preview {
|
||||
inline int x = 0, y = 0;
|
||||
inline bool pressed = false;
|
||||
inline void setup() {
|
||||
auto *input = lv_indev_create();
|
||||
lv_indev_set_type(input, LV_INDEV_TYPE_POINTER);
|
||||
lv_indev_set_display(input, lv_display_get_default());
|
||||
lv_indev_set_read_cb(input, [](lv_indev_t *, lv_indev_data_t *data) {
|
||||
data->point.x = x;
|
||||
data->point.y = y;
|
||||
data->state = pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
|
||||
});
|
||||
}
|
||||
}
|
||||
''')
|
||||
steps = [{'component.update': entry['id']}
|
||||
for section in ('sensor', 'text_sensor') for entry in package[section]]
|
||||
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'},
|
||||
{'snapshot.take': {'id': 'panel_display', 'filename': printer['id'] + '.bmp'}}]
|
||||
if printer.get('profile', '').startswith('elegoo_'):
|
||||
pid = printer['id']
|
||||
checks = {f'{pid}_status_label': 'PRINTING', f'{pid}_progress_detail': '1h 45m'}
|
||||
if printer['profile'] == 'elegoo_resin':
|
||||
checks.update({f'{pid}_uv_label': '42', f'{pid}_layer_label': '134',
|
||||
f'{pid}_elapsed_label': 'Elapsed: 0h 30m'})
|
||||
for widget, expected in checks.items():
|
||||
steps.append({'lambda': Lambda(f'if (std::string(lv_label_get_text(id({widget}))) != {json.dumps(expected)}) {{ ESP_LOGE("preview", "Elegoo telemetry render failed: {widget}"); exit(4); }}')})
|
||||
steps += [
|
||||
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'filament_runout'}},
|
||||
{'script.execute': f'{pid}_refresh'},
|
||||
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINT ERROR" || std::string(lv_label_get_text(id({pid}_health_details))).find("Filament runout") == std::string::npos) {{ ESP_LOGE("preview", "Elegoo error render failed"); exit(5); }}')},
|
||||
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'off'}},
|
||||
{'script.execute': f'{pid}_refresh'},
|
||||
{'lambda': Lambda(f'if (std::string(lv_label_get_text(id({pid}_status_label))) != "PRINTER OFFLINE" || std::string(lv_label_get_text(id({pid}_progress_label))) != "--") {{ ESP_LOGE("preview", "Elegoo offline render failed"); exit(6); }}')},
|
||||
{'text_sensor.template.publish': {'id': f'{pid}_print_error_reason', 'state': 'ok'}},
|
||||
{'text_sensor.template.publish': {'id': f'{pid}_online', 'state': 'on'}},
|
||||
{'script.execute': f'{pid}_refresh'},
|
||||
]
|
||||
steps += [{'lvgl.page.show': 'color_test_page'}, {'delay': '300ms'},
|
||||
{'snapshot.take': {'id': 'panel_display', 'filename': 'color_test.bmp'}}]
|
||||
demo_printer = printers[0]['id']
|
||||
for key, value in {'hms_error': 'on', 'hms_count': '1', 'hms_1_code': 'DEMO_CODE',
|
||||
'hms_1_severity': 'common',
|
||||
'hms_1_message': 'DEMO: A printer maintenance notification. This is synthetic preview data, not an actual fault diagnosis.'}.items():
|
||||
steps.append({'text_sensor.template.publish': {'id': f'{demo_printer}_{key}', 'state': value}})
|
||||
steps += [{'lvgl.page.show': demo_printer + '_page'},
|
||||
{'script.execute': demo_printer + '_refresh'},
|
||||
{'lvgl.widget.show': demo_printer + '_health_panel'}, {'delay': '300ms'},
|
||||
{'snapshot.take': {'id': 'panel_display', 'filename': 'health_notice.bmp'}},
|
||||
{'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': 'fleet_page'}, {'delay': '250ms'},
|
||||
{'lambda': Lambda('bambu_preview::setup();')}]
|
||||
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
|
||||
steps += [
|
||||
{'lambda': Lambda(f'bambu_preview::x = {x}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
|
||||
{'delay': '70ms'}, {'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '150ms'},
|
||||
{'lambda': Lambda(f'if (lv_obj_has_flag(id({demo_printer}_health_panel), LV_OBJ_FLAG_HIDDEN) != {str(expect_hidden).lower()}) {{ ESP_LOGE("preview", "Health panel tap failed"); exit(3); }}')},
|
||||
]
|
||||
if len(printers) > 1:
|
||||
# First -> second -> first -> last -> first, then a swipe starting
|
||||
# on NEXT must advance exactly once even when released over PREV.
|
||||
# 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, '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;')},
|
||||
{'delay': '70ms'},
|
||||
{'lambda': Lambda(f'bambu_preview::x = {end};')}, {'delay': '70ms'},
|
||||
{'lambda': Lambda('bambu_preview::pressed = false;')}, {'delay': '350ms'},
|
||||
{'lambda': Lambda(f'if (!id({expected}_page).is_showing()) {{ ESP_LOGE("preview", "Swipe navigation failed"); exit(2); }}')},
|
||||
]
|
||||
steps += [{'lambda': Lambda('exit(0);')}]
|
||||
config = {'esphome': {'name': 'bambu-display-preview',
|
||||
'includes': [str(HERE / 'dashboard.h'), str(touch_header)],
|
||||
'on_boot': {'priority': -100, 'then': steps}},
|
||||
'host': {}, 'logger': {'level': 'WARN'},
|
||||
'time': [{'platform': 'host', 'id': 'ha_time', 'timezone': 'America/New_York'}],
|
||||
'display': [{'platform': 'sdl', 'id': 'panel_display', 'dimensions': '800x480',
|
||||
'headless': True, 'auto_clear_enabled': False, 'update_interval': 'never'}],
|
||||
'lvgl': {'id': 'dashboard', 'displays': ['panel_display'], 'buffer_size': '100%',
|
||||
'color_depth': 16, 'bg_color': 0x000000, 'text_color': 0xEEEEEE,
|
||||
'default_font': 'montserrat_18', **package.pop('lvgl')}, **package}
|
||||
filename = root / 'preview.yaml'
|
||||
filename.write_text(yaml.dump(config, Dumper=Dumper, sort_keys=False, allow_unicode=True))
|
||||
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 ['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'))
|
||||
print(f'Saved {len(printers)} demo screenshots to {args.output}')
|
||||
print('Passed LVGL pointer swipe tests, including wraparound and button gesture consumption.')
|
||||
print('Passed status-tap health panel open and Close-button tests.')
|
||||
print('Passed Elegoo telemetry, text-error and offline rendering checks.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,63 @@
|
||||
# Order here is the swipe order. Add/remove entries and rerun generate.py.
|
||||
# entity_prefix is the HA entity ID between the domain and the metric suffix.
|
||||
# Optional entities: {metric: sensor.custom_entity} overrides individual mappings.
|
||||
printers:
|
||||
- id: prints_charming
|
||||
name: PrintsCharming
|
||||
subtitle: P1S / Office
|
||||
entity_prefix: p1s_01p09c470102673
|
||||
extra_fans: [aux_fan, exhaust_fan]
|
||||
filament_banks:
|
||||
- label: A
|
||||
entity_prefix: p1s_01p09c470102673_ams_1
|
||||
- label: B
|
||||
entity_prefix: p1s_01p09c470102673_ams_2
|
||||
|
||||
- id: polly_pocket
|
||||
name: Polly-Pocket
|
||||
subtitle: A1 mini / Office
|
||||
entity_prefix: a1mini_0309ca580403653
|
||||
external_spools:
|
||||
- label: EXT
|
||||
entity: sensor.a1mini_0309ca580403653_externalspool_external_spool
|
||||
|
||||
- id: ds_turd_flinger
|
||||
name: Ds-Turd-Flinger
|
||||
subtitle: A1 / Upstairs
|
||||
entity_prefix: a1_03919d540806387
|
||||
filament_banks:
|
||||
- label: A
|
||||
entity_prefix: a1_03919d540806387_ams_1
|
||||
|
||||
- id: jordyns_layer_slayer
|
||||
name: Jordyns-Layer-Slayer
|
||||
subtitle: A1 / Upstairs
|
||||
entity_prefix: jordyns_layer_slayer
|
||||
filament_banks:
|
||||
- label: A
|
||||
entity_prefix: jordyns_layer_slayer_ams
|
||||
|
||||
- id: constipation_orion
|
||||
name: Constipation-Orion
|
||||
subtitle: Dual nozzle / Office
|
||||
entity_prefix: constipation_orion
|
||||
dual_nozzle: true
|
||||
extra_fans: [exhaust_fan]
|
||||
filament_banks:
|
||||
- label: A
|
||||
entity_prefix: constipation_orion_ams_1
|
||||
- label: B
|
||||
# This unit retains its original hardware-based HA entity names.
|
||||
entity_prefix: h2c_31b8ap5a2200288_ams_2
|
||||
|
||||
- id: centauri_carbon
|
||||
name: Centauri Carbon
|
||||
subtitle: FDM / Office
|
||||
entity_prefix: centauri_carbon
|
||||
profile: elegoo_fdm
|
||||
|
||||
- id: saturn
|
||||
name: Saturn
|
||||
subtitle: Resin / Garage
|
||||
entity_prefix: saturn
|
||||
profile: elegoo_resin
|
||||
@@ -0,0 +1,175 @@
|
||||
#include "dashboard.h"
|
||||
#include <cassert>
|
||||
|
||||
int main() {
|
||||
using namespace bambu_display;
|
||||
assert(remaining(1.75f, "h") == "1h 45m");
|
||||
assert(remaining(105.0f, "min") == "1h 45m");
|
||||
assert(remaining(6300.0f, "s") == "1h 45m");
|
||||
assert(remaining(6300000.0f, "ms") == "1h 45m");
|
||||
assert(remaining(0.15f, "h") == "0h 09m");
|
||||
assert(remaining(NAN, "h") == "--");
|
||||
assert(remaining(-1, "min") == "--");
|
||||
assert(remaining(10, "") == "--");
|
||||
assert(remaining(INFINITY, "h") == "--");
|
||||
assert(number(NAN) == "--");
|
||||
assert(temperature(219, 220, "°C") == "219 / 220 °C");
|
||||
assert(filament_color("#00FF00FF") == GREEN);
|
||||
assert(filament_color("60de9a") == 0x60DE9A);
|
||||
assert(filament_color("#60DE9AZZ") == TRACK);
|
||||
assert(filament_color("unknown") == TRACK);
|
||||
assert(gauge_value(NAN, 100) == 0);
|
||||
assert(gauge_value(110, 100) == 100);
|
||||
assert(gauge_value(-10, 100) == 0);
|
||||
assert(duration_seconds(1.75f, "h") == 6300);
|
||||
assert(duration_seconds(105, "min") == 6300);
|
||||
assert(duration_seconds(6300000, "ms") == 6300);
|
||||
assert(duration_seconds(NAN, "h") == -1);
|
||||
assert(duration_seconds(1, "") == -1);
|
||||
assert(std::abs(celsius(392, "°F") - 200) < 0.01f);
|
||||
|
||||
Snapshot data;
|
||||
data.online = "on";
|
||||
data.status = "running";
|
||||
assert(!data.live());
|
||||
assert(data.status_text() == "HA OFFLINE");
|
||||
data.connected = true;
|
||||
assert(data.live());
|
||||
assert(data.status_text() == "PRINTING");
|
||||
data.online = "off";
|
||||
assert(!data.live());
|
||||
assert(data.status_text() == "PRINTER OFFLINE");
|
||||
data.online = "unavailable";
|
||||
assert(data.status_text() == "SYNCING");
|
||||
data.online = "on";
|
||||
data.status = "unknown";
|
||||
assert(!data.live());
|
||||
data.status = "pause";
|
||||
assert(data.status_text() == "PAUSED");
|
||||
assert(data.status_color() == AMBER);
|
||||
data.print_error = "on";
|
||||
assert(data.status_text() == "PRINT ERROR");
|
||||
assert(data.status_color() == RED);
|
||||
data.print_error = "off";
|
||||
data.hms_error = "True";
|
||||
assert(data.status_text() == "HEALTH NOTICE");
|
||||
assert(data.status_color() == AMBER);
|
||||
assert(data.health_details().find("have not arrived") != std::string::npos);
|
||||
data.hms_count = "1";
|
||||
data.health_notices = {{"DEMO_CODE", "A synthetic information message", "info"}};
|
||||
assert(data.status_text() == "HEALTH INFO");
|
||||
assert(data.health_details().find("DEMO_CODE") != std::string::npos);
|
||||
assert(data.alert_summary() == "A synthetic information message");
|
||||
data.health_notices.push_back({"DEMO_2", "A synthetic serious message", "serious"});
|
||||
data.hms_count = "2";
|
||||
assert(data.status_text() == "HEALTH ERROR");
|
||||
assert(data.status_color() == RED);
|
||||
assert(data.health_details().find("A synthetic serious message") != std::string::npos);
|
||||
data.health_notices.pop_back();
|
||||
data.hms_count = "5";
|
||||
assert(data.status_text() == "HEALTH NOTICE");
|
||||
assert(data.health_details().find("4 more") != std::string::npos);
|
||||
data.hms_error = "off";
|
||||
assert(data.health_details() == "No current printer health notifications.");
|
||||
data.status = "finish";
|
||||
assert(data.status_text() == "COMPLETE");
|
||||
assert(data.status_color() == GREEN);
|
||||
|
||||
// Elegoo exposes error enums rather than Bambu binary errors/HMS notices.
|
||||
data.elegoo = true;
|
||||
data.print_error = "none";
|
||||
data.print_error_reason = "ok";
|
||||
data.status = "printing";
|
||||
assert(data.live());
|
||||
assert(!data.has_print_error());
|
||||
assert(data.status_text() == "PRINTING");
|
||||
data.print_error_reason = "filament_runout";
|
||||
assert(data.status_text() == "PRINT ERROR");
|
||||
assert(data.status_color() == RED);
|
||||
assert(data.health_details().find("Filament runout") != std::string::npos);
|
||||
data.print_error = "invalid_resolution";
|
||||
assert(data.alert_summary() == "Invalid resolution / Filament runout");
|
||||
data.print_error_reason = "unavailable";
|
||||
assert(data.alert_summary() == "Invalid resolution");
|
||||
data.print_error = "unknown";
|
||||
assert(!data.has_print_error());
|
||||
data.status = "error";
|
||||
assert(data.has_print_error());
|
||||
data.status = "pausing";
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Run with: python -m unittest discover -s esphome/bambu-display -v."""
|
||||
|
||||
import copy
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
from generate import HERE, generate, validate_printers
|
||||
|
||||
|
||||
class FleetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.printers = yaml.safe_load((HERE / 'printers.yaml').read_text())['printers']
|
||||
|
||||
def test_all_subscriptions_match_repository_entities(self):
|
||||
known = {line.split(' | ')[0] for line in
|
||||
(HERE.parent / 'ha-entities.txt').read_text().splitlines()}
|
||||
package = generate(self.printers)
|
||||
subscribed = {entry['entity_id'] for domain in ('sensor', 'text_sensor')
|
||||
for entry in package[domain]}
|
||||
self.assertFalse(subscribed - known)
|
||||
|
||||
def test_fleet_can_expand_beyond_configured_printers(self):
|
||||
expected_count = len(self.printers) + 2
|
||||
for index in (8, 9):
|
||||
extra = copy.deepcopy(self.printers[0])
|
||||
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 + 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']:
|
||||
self.assertIn('lvgl.page.next', page['on_swipe_left'][-1])
|
||||
self.assertIn('lvgl.page.previous', page['on_swipe_right'][-1])
|
||||
|
||||
def test_elegoo_profiles_use_available_metrics_and_domains(self):
|
||||
package = generate(self.printers)
|
||||
subscriptions = {entry['id']: entry for domain in ('sensor', 'text_sensor')
|
||||
for entry in package[domain]}
|
||||
self.assertEqual(subscriptions['centauri_carbon_nozzle_target']['entity_id'],
|
||||
'number.centauri_carbon_target_nozzle_temp')
|
||||
self.assertEqual(subscriptions['centauri_carbon_fan']['entity_id'],
|
||||
'sensor.centauri_carbon_model_fan_speed')
|
||||
for pid in ('centauri_carbon', 'saturn'):
|
||||
self.assertEqual(subscriptions[f'{pid}_online']['entity_id'], f'binary_sensor.{pid}_sdcp_status')
|
||||
self.assertEqual(subscriptions[f'{pid}_remaining_unit']['entity_id'], f'sensor.{pid}_remaining_print_time')
|
||||
self.assertEqual(subscriptions[f'{pid}_print_error']['entity_id'], f'sensor.{pid}_print_error')
|
||||
self.assertEqual(subscriptions[f'{pid}_print_error_reason']['entity_id'], f'sensor.{pid}_error_status_reason')
|
||||
self.assertNotIn(f'{pid}_hms_error', subscriptions)
|
||||
self.assertNotIn('saturn_nozzle', subscriptions)
|
||||
self.assertNotIn('saturn_fan', subscriptions)
|
||||
self.assertEqual(subscriptions['saturn_uv_unit']['entity_id'], 'sensor.saturn_uv_led_temp')
|
||||
self.assertEqual(subscriptions['saturn_elapsed_unit']['entity_id'], 'sensor.saturn_current_print_time')
|
||||
|
||||
def test_resin_page_contains_only_resin_gauges(self):
|
||||
package = generate(self.printers)
|
||||
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'saturn_page')
|
||||
arcs = {widget['arc']['id'] for widget in page['widgets'] if 'arc' in widget}
|
||||
self.assertEqual(arcs, {'saturn_progress_arc', 'saturn_uv_arc', 'saturn_layer_arc'})
|
||||
|
||||
def test_number_overrides_are_supported(self):
|
||||
self.printers[-2]['entities'] = {'nozzle_target': 'number.custom_target'}
|
||||
package = generate(self.printers)
|
||||
entry = next(row for row in package['sensor'] if row['id'] == 'centauri_carbon_nozzle_target')
|
||||
self.assertEqual(entry['entity_id'], 'number.custom_target')
|
||||
|
||||
def test_reject_unknown_profiles(self):
|
||||
self.printers[0]['profile'] = 'unsupported'
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported profile'):
|
||||
generate(self.printers)
|
||||
|
||||
def test_overrides_apply_to_values_and_unit_subscriptions(self):
|
||||
self.printers[0]['entities'] = {'time_left': 'sensor.custom_remaining'}
|
||||
package = generate(self.printers)
|
||||
for domain, key in [('sensor', 'time_left'), ('text_sensor', 'remaining_unit')]:
|
||||
entry = next(row for row in package[domain] if row['id'] == f'prints_charming_{key}')
|
||||
self.assertEqual(entry['entity_id'], 'sensor.custom_remaining')
|
||||
|
||||
def test_orion_has_both_ams_units_with_their_actual_entity_prefixes(self):
|
||||
package = generate(self.printers)
|
||||
types = [entry['entity_id'] for entry in package['text_sensor']
|
||||
if entry['id'].startswith('constipation_orion_tray_') and entry.get('attribute') == 'type']
|
||||
self.assertEqual(len(types), 8)
|
||||
self.assertIn('sensor.constipation_orion_ams_1_tray_1', types)
|
||||
self.assertIn('sensor.h2c_31b8ap5a2200288_ams_2_tray_4', types)
|
||||
|
||||
def test_health_messages_import_numbered_ha_attributes(self):
|
||||
package = generate(self.printers)
|
||||
subscriptions = {entry['id']: entry for entry in package['text_sensor']}
|
||||
message = subscriptions['prints_charming_hms_1_message']
|
||||
self.assertEqual(message['entity_id'], 'binary_sensor.p1s_01p09c470102673_hms_errors')
|
||||
self.assertEqual(message['attribute'], '1-Error')
|
||||
self.assertEqual(subscriptions['prints_charming_hms_3_severity']['attribute'], '3-Severity')
|
||||
|
||||
def test_reject_empty_duplicate_and_malformed_printer_ids(self):
|
||||
for invalid in ([], self.printers + [self.printers[0]],
|
||||
[{**self.printers[0], 'id': 'invalid-id'}]):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValueError):
|
||||
validate_printers(invalid)
|
||||
|
||||
def test_reject_overflowing_filament_strip(self):
|
||||
self.printers[0]['external_spools'] = [{'label': 'EXT', 'entity': 'sensor.external_spool'}]
|
||||
with self.assertRaisesRegex(ValueError, 'eight slots'):
|
||||
generate(self.printers)
|
||||
|
||||
def test_progress_and_dual_nozzle_widgets_fit_the_panel(self):
|
||||
package = generate(self.printers)
|
||||
for page in package['lvgl']['pages']:
|
||||
for widget in page['widgets']:
|
||||
props = next(iter(widget.values()))
|
||||
self.assertGreaterEqual(props.get('x', 0), 0)
|
||||
self.assertGreaterEqual(props.get('y', 0), 0)
|
||||
self.assertLessEqual(props.get('x', 0) + props.get('width', 0), 800)
|
||||
self.assertLessEqual(props.get('y', 0) + props.get('height', 0), 480)
|
||||
|
||||
def test_rgb565_primaries_reach_the_correct_panel_bits(self):
|
||||
# mipi_rgb emits named RGB data pins with bytes exchanged, then copies
|
||||
# LVGL RGB565 bytes straight into the ESP32 little-endian framebuffer.
|
||||
# Exercise red, green, blue and combined primaries through that path.
|
||||
class Loader(yaml.SafeLoader):
|
||||
pass
|
||||
for tag in ('!secret', '!include'):
|
||||
Loader.add_constructor(tag, lambda loader, node: loader.construct_scalar(node))
|
||||
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']
|
||||
driver = natural[8:] + natural[:8]
|
||||
order = {'big_endian': 'big', 'little_endian': 'little'}[config['lvgl']['byte_order']]
|
||||
for color in (0xF800, 0x07E0, 0x001F, 0xFFFF, 0x07FF, 0xF81F):
|
||||
memory_word = int.from_bytes(color.to_bytes(2, order), 'little')
|
||||
panel_word = sum(((memory_word >> i) & 1) << natural.index(pin)
|
||||
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')
|
||||
self.assertTrue(page['skip'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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
|
||||
@@ -0,0 +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
|
||||
|
||||
packages:
|
||||
board: !include bambu-display/board.yaml
|
||||
fleet: !include bambu-display/generated.yaml
|
||||
@@ -1,275 +0,0 @@
|
||||
substitutions:
|
||||
device_name: esp32-h2c-camera
|
||||
friendly_name: "H2C Camera"
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
# Required for ESP32 camera buffering.
|
||||
psram:
|
||||
mode: octal
|
||||
|
||||
logger:
|
||||
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: !secret ota_password
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_iot_ssid
|
||||
password: !secret wifi_password
|
||||
manual_ip:
|
||||
static_ip: 192.168.69.90
|
||||
gateway: 192.168.69.1
|
||||
subnet: 255.255.255.0
|
||||
ap:
|
||||
ssid: "${friendly_name} Fallback"
|
||||
password: !secret fallback_password
|
||||
# Prevents aggressive reconnection attempts
|
||||
power_save_mode: none # Use 'light' for battery devices
|
||||
|
||||
# Slower but more reliable connection
|
||||
fast_connect: true
|
||||
|
||||
# Handle connection failures gracefully
|
||||
on_connect:
|
||||
- logger.log: "Wi-Fi connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "Wi-Fi disconnected!"
|
||||
|
||||
# Reduce mDNS traffic
|
||||
mdns:
|
||||
disabled: true # Set to true if you use static IPs and don't need discovery
|
||||
|
||||
# captive_portal:
|
||||
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
esp32_camera:
|
||||
name: "${friendly_name}"
|
||||
external_clock:
|
||||
pin: GPIO5
|
||||
# 10MHz (half the usual 20MHz) slows the sensor's line rate, so each of the
|
||||
# 1200 exposure lines below collects roughly twice the light. Costs
|
||||
# framerate, which does not matter for chamber monitoring.
|
||||
frequency: 10MHz
|
||||
i2c_pins:
|
||||
sda: GPIO8
|
||||
scl: GPIO9
|
||||
data_pins: [GPIO16, GPIO18, GPIO21, GPIO17, GPIO14, GPIO7, GPIO6, GPIO4]
|
||||
vsync_pin: GPIO1
|
||||
href_pin: GPIO2
|
||||
pixel_clock_pin: GPIO15
|
||||
|
||||
# Resolution & quality (ESP32-S3 with Octal PSRAM handles XGA easily)
|
||||
resolution: 1024X768
|
||||
jpeg_quality: 10
|
||||
max_framerate: 20 fps
|
||||
idle_framerate: 0.2 fps
|
||||
frame_buffer_count: 2
|
||||
|
||||
# Orientation
|
||||
vertical_flip: true
|
||||
horizontal_mirror: false
|
||||
|
||||
# Image tuning for chamber monitoring through tinted door.
|
||||
# Low contrast + high brightness lifts the shadows; the LED strips are
|
||||
# allowed to blow out since the chamber is what matters.
|
||||
brightness: 2
|
||||
contrast: -2
|
||||
saturation: 0
|
||||
|
||||
# Exposure & gain are MANUAL on purpose. The two LED strips in frame kept the
|
||||
# auto-exposure meter satisfied, so AEC never lengthened the exposure and the
|
||||
# tinted-door scene stayed black regardless of gain ceiling or AE level.
|
||||
# Pinning both to max forces a bright chamber; the strips clip out entirely.
|
||||
aec_mode: manual
|
||||
aec_value: 1200 # max integration time (0-1200 lines)
|
||||
aec2: true
|
||||
ae_level: 2 # only consulted if AEC is flipped back to auto
|
||||
agc_mode: manual
|
||||
agc_value: 30 # max sensor gain (0-30)
|
||||
agc_gain_ceiling: 128X # only consulted if AGC is flipped back to auto
|
||||
wb_mode: auto
|
||||
|
||||
# Camera endpoints:
|
||||
# Stream: http://<ip>:8080
|
||||
# Snapshot: http://<ip>:8081
|
||||
esp32_camera_web_server:
|
||||
- port: 8080
|
||||
mode: stream
|
||||
- port: 8081
|
||||
mode: snapshot
|
||||
|
||||
switch:
|
||||
# DFRobot board IR illumination control pin.
|
||||
- platform: gpio
|
||||
name: "${friendly_name} IR LED"
|
||||
pin: GPIO47
|
||||
restore_mode: ALWAYS_OFF
|
||||
|
||||
# Flip exposure/gain back to automatic for A/B comparison without a reflash.
|
||||
# Off (the default) = manual exposure per the esp32_camera block above.
|
||||
- platform: template
|
||||
name: "${friendly_name} Auto Exposure"
|
||||
optimistic: true
|
||||
restore_mode: ALWAYS_OFF
|
||||
turn_on_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (!s) return;
|
||||
s->set_exposure_ctrl(s, 1);
|
||||
s->set_gain_ctrl(s, 1);
|
||||
turn_off_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (!s) return;
|
||||
s->set_exposure_ctrl(s, 0);
|
||||
s->set_gain_ctrl(s, 0);
|
||||
|
||||
sensor:
|
||||
- platform: wifi_signal
|
||||
name: "${friendly_name} WiFi Signal"
|
||||
update_interval: 60s
|
||||
|
||||
- platform: uptime
|
||||
name: "${friendly_name} Uptime"
|
||||
|
||||
- platform: wifi_signal
|
||||
name: "${device_name} WiFi Signal"
|
||||
update_interval: 60s
|
||||
|
||||
- platform: uptime
|
||||
name: "${device_name} Uptime"
|
||||
update_interval: 60s
|
||||
|
||||
button:
|
||||
- platform: restart
|
||||
name: "${friendly_name} Restart"
|
||||
|
||||
text_sensor:
|
||||
- platform: wifi_info
|
||||
ip_address:
|
||||
name: "${device_name} IP Address"
|
||||
ssid:
|
||||
name: "${device_name} Connected SSID"
|
||||
bssid:
|
||||
name: "${device_name} BSSID"
|
||||
|
||||
# Watchdog to auto-reboot if things go wrong
|
||||
interval:
|
||||
- interval: 5min
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
and:
|
||||
- lambda: 'return millis() > 300000;' # >5 min uptime
|
||||
- not:
|
||||
wifi.connected:
|
||||
then:
|
||||
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
|
||||
- delay: 10s
|
||||
- lambda: 'App.safe_reboot();'
|
||||
|
||||
# Dynamic controls exposed to Home Assistant for real-time camera tuning
|
||||
number:
|
||||
# The two that actually drive image brightness in manual mode.
|
||||
- platform: template
|
||||
name: "${friendly_name} Exposure"
|
||||
min_value: 0
|
||||
max_value: 1200
|
||||
step: 25
|
||||
initial_value: 1200
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) s->set_aec_value(s, (int)x);
|
||||
|
||||
- platform: template
|
||||
name: "${friendly_name} Gain"
|
||||
min_value: 0
|
||||
max_value: 30
|
||||
step: 1
|
||||
initial_value: 30
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) s->set_agc_gain(s, (int)x);
|
||||
|
||||
- platform: template
|
||||
name: "${friendly_name} Brightness"
|
||||
min_value: -2
|
||||
max_value: 2
|
||||
step: 1
|
||||
initial_value: 2
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) s->set_brightness(s, (int)x);
|
||||
|
||||
- platform: template
|
||||
name: "${friendly_name} Contrast"
|
||||
min_value: -2
|
||||
max_value: 2
|
||||
step: 1
|
||||
initial_value: -2
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) s->set_contrast(s, (int)x);
|
||||
|
||||
- platform: template
|
||||
name: "${friendly_name} AE Level"
|
||||
min_value: -2
|
||||
max_value: 2
|
||||
step: 1
|
||||
initial_value: 2
|
||||
optimistic: true
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (s) s->set_ae_level(s, (int)x);
|
||||
|
||||
select:
|
||||
# Caps how far AGC may amplify. Higher = brighter but noisier; back this off
|
||||
# if the chamber image gets too grainy at 128X.
|
||||
- platform: template
|
||||
name: "${friendly_name} Gain Ceiling"
|
||||
optimistic: true
|
||||
initial_option: "128X"
|
||||
options:
|
||||
- "2X"
|
||||
- "4X"
|
||||
- "8X"
|
||||
- "16X"
|
||||
- "32X"
|
||||
- "64X"
|
||||
- "128X"
|
||||
set_action:
|
||||
- lambda: |-
|
||||
sensor_t *s = esp_camera_sensor_get();
|
||||
if (!s) return;
|
||||
int idx = 6;
|
||||
if (x == "2X") idx = 0;
|
||||
else if (x == "4X") idx = 1;
|
||||
else if (x == "8X") idx = 2;
|
||||
else if (x == "16X") idx = 3;
|
||||
else if (x == "32X") idx = 4;
|
||||
else if (x == "64X") idx = 5;
|
||||
s->set_gainceiling(s, (gainceiling_t)idx);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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.89"
|
||||
|
||||
esphome:
|
||||
name: "family-room-control"
|
||||
friendly_name: "Family Room Control"
|
||||
|
||||
packages:
|
||||
display:
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
ref: screens-v0.4.89
|
||||
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
|
||||
@@ -1,128 +0,0 @@
|
||||
# Lights-only first version for the Family Room remote.
|
||||
# Entity IDs came from the project's HA snapshot; verify them in live HA.
|
||||
|
||||
substitutions:
|
||||
device_name: family-room-remote
|
||||
friendly_name: Family Room Remote
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
packages:
|
||||
hardware: !include panel-remote-common.yaml
|
||||
|
||||
binary_sensor:
|
||||
- platform: homeassistant
|
||||
id: standing_lamp_state
|
||||
entity_id: light.living_room_lamp_1
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: standing_lamp_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
- platform: homeassistant
|
||||
id: small_lamp_state
|
||||
entity_id: light.small_family_room_lamp
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: small_lamp_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
- platform: homeassistant
|
||||
id: tv_stand_state
|
||||
entity_id: light.family_room_tv_stand
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: tv_stand_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
|
||||
lvgl:
|
||||
rotation: 90
|
||||
on_idle:
|
||||
- timeout: 45s
|
||||
then:
|
||||
- light.turn_on:
|
||||
id: panel_backlight
|
||||
brightness: 15%
|
||||
pages:
|
||||
- id: room_page
|
||||
widgets:
|
||||
- label:
|
||||
x: 12
|
||||
y: 8
|
||||
text: Family Room
|
||||
- button:
|
||||
id: standing_lamp_button
|
||||
x: 10
|
||||
y: 45
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: Standing Lamp
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.living_room_lamp_1
|
||||
- button:
|
||||
id: small_lamp_button
|
||||
x: 165
|
||||
y: 45
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: Small Lamp
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.small_family_room_lamp
|
||||
- button:
|
||||
id: tv_stand_button
|
||||
x: 10
|
||||
y: 135
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: TV Stand
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.family_room_tv_stand
|
||||
- button:
|
||||
x: 165
|
||||
y: 135
|
||||
width: 145
|
||||
height: 75
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: All Off
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.living_room_lamp_1
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.small_family_room_lamp
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.family_room_tv_stand
|
||||
@@ -1,198 +0,0 @@
|
||||
substitutions:
|
||||
device_name: led-controller-polly-a1m
|
||||
friendly_name: "Polly's LED Controller"
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
esp32:
|
||||
board: esp32-c3-devkitm-1
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
# Enable logging
|
||||
logger:
|
||||
|
||||
# Enable Home Assistant API
|
||||
api:
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
# Enable over-the-air updates
|
||||
ota:
|
||||
- platform: esphome
|
||||
password: !secret ota_password
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_iot_ssid
|
||||
password: !secret wifi_password
|
||||
manual_ip:
|
||||
static_ip: 192.168.69.181
|
||||
gateway: 192.168.69.1
|
||||
subnet: 255.255.255.0
|
||||
# Enable fallback hotspot (captive portal) in case wifi connection fails
|
||||
ap:
|
||||
ssid: "${friendly_name} Fallback"
|
||||
password: !secret fallback_password
|
||||
|
||||
# Prevents aggressive reconnection attempts
|
||||
power_save_mode: none # Use 'light' for battery devices
|
||||
|
||||
# Slower but more reliable connection
|
||||
fast_connect: true
|
||||
|
||||
# Handle connection failures gracefully
|
||||
on_connect:
|
||||
- logger.log: "Wi-Fi connected!"
|
||||
on_disconnect:
|
||||
- logger.log: "Wi-Fi disconnected!"
|
||||
|
||||
# Reduce mDNS traffic
|
||||
mdns:
|
||||
disabled: false # Set to true if you use static IPs and don't need discovery
|
||||
|
||||
#captive_portal:
|
||||
web_server:
|
||||
port: 80
|
||||
|
||||
# LED Light configuration
|
||||
light:
|
||||
- platform: esp32_rmt_led_strip
|
||||
rgb_order: GRB
|
||||
chipset: WS2812
|
||||
pin: GPIO7
|
||||
num_leds: 10
|
||||
name: "${friendly_name}"
|
||||
id: led_strip
|
||||
|
||||
# Default color on boot
|
||||
restore_mode: RESTORE_DEFAULT_OFF
|
||||
|
||||
|
||||
# Effects - you can enable these for more features!
|
||||
effects:
|
||||
# Basic effects
|
||||
- random:
|
||||
name: "Random"
|
||||
transition_length: 5s
|
||||
update_interval: 7s
|
||||
|
||||
- strobe:
|
||||
name: "Strobe"
|
||||
colors:
|
||||
- state: true
|
||||
brightness: 100%
|
||||
red: 100%
|
||||
green: 100%
|
||||
blue: 100%
|
||||
duration: 500ms
|
||||
- state: false
|
||||
duration: 250ms
|
||||
|
||||
- flicker:
|
||||
name: "Flicker"
|
||||
alpha: 95%
|
||||
intensity: 1.5%
|
||||
|
||||
# Rainbow effects
|
||||
- addressable_rainbow:
|
||||
name: "Rainbow"
|
||||
speed: 10
|
||||
width: 50
|
||||
|
||||
- addressable_color_wipe:
|
||||
name: "Color Wipe"
|
||||
colors:
|
||||
- red: 100%
|
||||
green: 0%
|
||||
blue: 0%
|
||||
num_leds: 1
|
||||
- red: 0%
|
||||
green: 100%
|
||||
blue: 0%
|
||||
num_leds: 1
|
||||
- red: 0%
|
||||
green: 0%
|
||||
blue: 100%
|
||||
num_leds: 1
|
||||
add_led_interval: 100ms
|
||||
reverse: false
|
||||
|
||||
- addressable_scan:
|
||||
name: "Scan"
|
||||
move_interval: 100ms
|
||||
scan_width: 3
|
||||
|
||||
- addressable_twinkle:
|
||||
name: "Twinkle"
|
||||
twinkle_probability: 5%
|
||||
progress_interval: 4ms
|
||||
|
||||
- addressable_fireworks:
|
||||
name: "Fireworks"
|
||||
update_interval: 32ms
|
||||
spark_probability: 10%
|
||||
use_random_color: true
|
||||
fade_out_rate: 120
|
||||
|
||||
# Pulse effect
|
||||
- pulse:
|
||||
name: "Pulse"
|
||||
transition_length: 1s
|
||||
update_interval: 1s
|
||||
|
||||
# Sensors for monitoring
|
||||
sensor:
|
||||
# WiFi Signal Strength
|
||||
- platform: wifi_signal
|
||||
name: "${friendly_name} WiFi Signal"
|
||||
update_interval: 60s
|
||||
|
||||
# Uptime
|
||||
- platform: uptime
|
||||
name: "${friendly_name} Uptime"
|
||||
|
||||
# Diagnostic sensors
|
||||
- platform: wifi_signal
|
||||
name: "${device_name} WiFi Signal"
|
||||
update_interval: 60s
|
||||
|
||||
- platform: uptime
|
||||
name: "${device_name} Uptime"
|
||||
update_interval: 60s
|
||||
|
||||
# Text sensors
|
||||
text_sensor:
|
||||
# ESPHome version
|
||||
- platform: version
|
||||
name: "${friendly_name} ESPHome Version"
|
||||
|
||||
# WiFi info
|
||||
- platform: wifi_info
|
||||
ip_address:
|
||||
name: "${device_name} IP Address"
|
||||
ssid:
|
||||
name: "${device_name} Connected SSID"
|
||||
bssid:
|
||||
name: "${device_name} BSSID"
|
||||
|
||||
# Button to restart ESP32
|
||||
button:
|
||||
- platform: restart
|
||||
name: "${friendly_name} Restart"
|
||||
|
||||
# Watchdog to auto-reboot if things go wrong
|
||||
interval:
|
||||
- interval: 5min
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
and:
|
||||
- lambda: 'return millis() > 300000;' # >5 min uptime
|
||||
- not:
|
||||
wifi.connected:
|
||||
then:
|
||||
- logger.log: "Wi-Fi not connected for 5 min, rebooting..."
|
||||
- delay: 10s
|
||||
- lambda: 'App.safe_reboot();'
|
||||
@@ -1,128 +0,0 @@
|
||||
# Lights-only first version for the Living Room remote.
|
||||
# Entity IDs came from the project's HA snapshot; verify them in live HA.
|
||||
|
||||
substitutions:
|
||||
device_name: living-room-remote
|
||||
friendly_name: Living Room Remote
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
friendly_name: ${friendly_name}
|
||||
|
||||
packages:
|
||||
hardware: !include panel-remote-common.yaml
|
||||
|
||||
binary_sensor:
|
||||
- platform: homeassistant
|
||||
id: ceiling_light_state
|
||||
entity_id: light.living_room_light_2
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: ceiling_light_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
- platform: homeassistant
|
||||
id: small_lamp_state
|
||||
entity_id: light.small_living_room_lamp
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: small_lamp_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
- platform: homeassistant
|
||||
id: lampstand_state
|
||||
entity_id: light.studio_bulb_1
|
||||
trigger_on_initial_state: true
|
||||
on_state:
|
||||
- lvgl.widget.update:
|
||||
id: lampstand_button
|
||||
state:
|
||||
checked: !lambda return x;
|
||||
|
||||
lvgl:
|
||||
rotation: 90
|
||||
on_idle:
|
||||
- timeout: 45s
|
||||
then:
|
||||
- light.turn_on:
|
||||
id: panel_backlight
|
||||
brightness: 15%
|
||||
pages:
|
||||
- id: room_page
|
||||
widgets:
|
||||
- label:
|
||||
x: 12
|
||||
y: 8
|
||||
text: Living Room
|
||||
- button:
|
||||
id: ceiling_light_button
|
||||
x: 10
|
||||
y: 45
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: Main Light
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.living_room_light_2
|
||||
- button:
|
||||
id: small_lamp_button
|
||||
x: 165
|
||||
y: 45
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: Small Lamp
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.small_living_room_lamp
|
||||
- button:
|
||||
id: lampstand_button
|
||||
x: 10
|
||||
y: 135
|
||||
width: 145
|
||||
height: 75
|
||||
checkable: true
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: Lampstand
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.toggle
|
||||
data:
|
||||
entity_id: light.studio_bulb_1
|
||||
- button:
|
||||
x: 165
|
||||
y: 135
|
||||
width: 145
|
||||
height: 75
|
||||
widgets:
|
||||
- label:
|
||||
align: CENTER
|
||||
text: All Lights Off
|
||||
on_click:
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.living_room_light_2
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.small_living_room_lamp
|
||||
- homeassistant.action:
|
||||
action: light.turn_off
|
||||
data:
|
||||
entity_id: light.studio_bulb_1
|
||||
@@ -5,7 +5,7 @@ substitutions:
|
||||
DEVICE_FRIENDLY_NAME: "Living Room"
|
||||
LANGUAGE: "en"
|
||||
DISPLAY_MODEL: "ST7789V"
|
||||
GITHUB_REF: "screens-v0.4.88"
|
||||
GITHUB_REF: "screens-v0.4.89"
|
||||
|
||||
esphome:
|
||||
name: "living-room"
|
||||
@@ -14,7 +14,7 @@ esphome:
|
||||
packages:
|
||||
display:
|
||||
url: https://github.com/MaxGramser/homeassistant_espscreen
|
||||
ref: screens-v0.4.88
|
||||
ref: screens-v0.4.89
|
||||
files: [packages/cyd.yaml]
|
||||
refresh: 0s
|
||||
local_overrides: !include living-room.local.yaml
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
substitutions:
|
||||
device_name: resin-curing-timer
|
||||
outlet_entity: switch.resin_uv_light # Home Assistant UV light outlet.
|
||||
display_model: ESP32-2432S028 # Original ILI9341 CYD; see README for variants.
|
||||
display_model: ESP32-2432S028-7789 # 2.8" CYD with ST7789V (USB-C + micro-USB).
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
@@ -56,6 +56,7 @@ display:
|
||||
id: tft
|
||||
model: ${display_model}
|
||||
spi_id: tft_spi
|
||||
dimensions: 240x320 # Native portrait size; LVGL rotates to 320x240 landscape.
|
||||
auto_clear_enabled: false
|
||||
update_interval: never
|
||||
|
||||
@@ -208,7 +209,7 @@ interval:
|
||||
|
||||
lvgl:
|
||||
displays: [tft]
|
||||
rotation: 90
|
||||
rotation: 90 # Landscape; LVGL also rotates touch coordinates.
|
||||
touchscreens: [touch]
|
||||
buffer_size: 25%
|
||||
bg_color: 0x1C2638
|
||||
@@ -278,6 +279,7 @@ lvgl:
|
||||
text_align: CENTER
|
||||
text_font: montserrat_48
|
||||
text_color: 0xA7EFF5
|
||||
# All four time presets share one horizontal row on the 320x240 page.
|
||||
- button:
|
||||
id: preset_30
|
||||
x: 10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Timer helpers
|
||||
# Example:
|
||||
# bedtime_timer:
|
||||
# name: Bedtime Timer
|
||||
# duration: "00:30:00"
|
||||
# icon: mdi:timer
|
||||
# Used by esphome/resin-curing-timer.yaml and its companion HA package.
|
||||
resin_curing:
|
||||
name: Resin Curing
|
||||
duration: "00:30:00"
|
||||
icon: mdi:timer-outline
|
||||
restore: true
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Companion to esphome/resin-curing-timer.yaml.
|
||||
# Keep the outlet and controller IDs in sync with that file's substitutions.
|
||||
# The timer helper is defined in helpers/timer.yaml.
|
||||
template:
|
||||
- sensor:
|
||||
- name: Resin Curing Deadline
|
||||
default_entity_id: sensor.resin_curing_deadline
|
||||
unique_id: resin_curing_deadline
|
||||
icon: mdi:timer-sand
|
||||
# The ESPHome display expects Unix epoch seconds, rather than an ISO date.
|
||||
state: >-
|
||||
{{ as_timestamp(state_attr('timer.resin_curing', 'finishes_at'), 0) | int
|
||||
if is_state('timer.resin_curing', 'active') else 0 }}
|
||||
|
||||
automation:
|
||||
- id: resin_curing_timer_control
|
||||
alias: Resin Curing Timer - Control UV Light
|
||||
description: >-
|
||||
Handle the CYD presets and STOP button, turn off the UV outlet when the
|
||||
timer ends, and reconcile the timer and outlet after restart or recovery.
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: esphome.resin_curing_timer
|
||||
event_data:
|
||||
controller: resin-curing-timer
|
||||
id: command
|
||||
- trigger: state
|
||||
entity_id: timer.resin_curing
|
||||
to:
|
||||
- idle
|
||||
- paused
|
||||
id: timer_stopped
|
||||
- trigger: event
|
||||
event_type: timer.finished
|
||||
event_data:
|
||||
entity_id: timer.resin_curing
|
||||
id: timer_finished
|
||||
- trigger: state
|
||||
entity_id: switch.resin_uv_light
|
||||
to:
|
||||
- "on"
|
||||
- "off"
|
||||
id: outlet_changed
|
||||
- trigger: homeassistant
|
||||
event: start
|
||||
id: startup
|
||||
- trigger: event
|
||||
event_type: automation_reloaded
|
||||
id: reload
|
||||
# Retry a missed shutoff once the outlet recovers, including after downtime.
|
||||
- trigger: time_pattern
|
||||
seconds: "/30"
|
||||
id: reconcile
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.id == 'command'
|
||||
and trigger.event.data.get('command') == 'start'
|
||||
and trigger.event.data.get('minutes') | string
|
||||
in ['30', '45', '60', '90']
|
||||
and states('switch.resin_uv_light') in ['on', 'off'] }}
|
||||
sequence:
|
||||
# Arm the shutoff before requesting power. Each preset replaces
|
||||
# the remaining time, matching the duration shown on the CYD.
|
||||
- action: timer.start
|
||||
target:
|
||||
entity_id: timer.resin_curing
|
||||
data:
|
||||
duration: "{{ (trigger.event.data.minutes | int) * 60 }}"
|
||||
- action: switch.turn_on
|
||||
target:
|
||||
entity_id: switch.resin_uv_light
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ trigger.id == 'command'
|
||||
and trigger.event.data.get('command') == 'stop' }}
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
continue_on_error: true
|
||||
target:
|
||||
entity_id: switch.resin_uv_light
|
||||
- action: timer.cancel
|
||||
target:
|
||||
entity_id: timer.resin_curing
|
||||
default:
|
||||
# Ignore malformed commands. Recheck current state when a queued
|
||||
# event runs so an old expiry cannot stop a newly started session.
|
||||
- condition: template
|
||||
value_template: "{{ trigger.id != 'command' }}"
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: >-
|
||||
{{ not is_state('timer.resin_curing', 'active')
|
||||
and not is_state('switch.resin_uv_light', 'off') }}
|
||||
sequence:
|
||||
- action: switch.turn_off
|
||||
continue_on_error: true
|
||||
target:
|
||||
entity_id: switch.resin_uv_light
|
||||
- conditions:
|
||||
- condition: state
|
||||
entity_id: timer.resin_curing
|
||||
state: active
|
||||
- condition: state
|
||||
entity_id: switch.resin_uv_light
|
||||
state: "off"
|
||||
sequence:
|
||||
# A physical/HA outlet shutoff ends the displayed countdown.
|
||||
- action: timer.cancel
|
||||
target:
|
||||
entity_id: timer.resin_curing
|
||||
mode: queued
|
||||
max: 20
|
||||