Compare commits
18
Commits
c469aa4aae
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7f268888 | ||
|
|
994cfb6b60 | ||
|
|
12f8ba2def | ||
|
|
5b8a3f24c2 | ||
|
|
c5b7408b18 | ||
|
|
360e86e46a | ||
|
|
4626019a3e | ||
|
|
43f416ab69 | ||
|
|
0ec94a909e | ||
|
|
5e50c9a296 | ||
|
|
4104e995c0 | ||
|
|
d7f0584e17 | ||
|
|
472b66cf0e | ||
|
|
17633d5909 | ||
|
|
0bd960dbf7 | ||
|
|
5a0ccf6b36 | ||
|
|
bdd3d4b0ae | ||
|
|
91c3996f93 |
@@ -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 }}"
|
||||
+2
-1
@@ -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
|
||||
|
||||
+110
-17
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -17,24 +17,31 @@ Home Assistant must be running and its printer integrations must have current pr
|
||||
|
||||
The configured swipe order is:
|
||||
|
||||
1. PrintsCharming — P1S, Office, both four-slot AMS units.
|
||||
2. Polly-Pocket — A1 mini, Office, external spool.
|
||||
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
|
||||
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
|
||||
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
|
||||
1. Print Status Overview — one steam gauge per printer. The filled part of
|
||||
each ring is that printer's print progress, drawn in the active filament
|
||||
color. Black filament keeps a grey ring behind the gauge so it stays visible
|
||||
on the black page. The center shows the percent complete in white, with the
|
||||
printer name and estimated finish time underneath.
|
||||
The local time is in the top-right corner. Tap a gauge to open that printer.
|
||||
2. PrintsCharming — P1S, Office, both four-slot AMS units.
|
||||
3. Polly-Pocket — A1 mini, Office, external spool.
|
||||
4. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
|
||||
5. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
|
||||
6. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
|
||||
AMS A uses `constipation_orion_ams_1`; AMS B retains its original
|
||||
`h2c_31b8ap5a2200288_ams_2` entity prefix.
|
||||
6. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
|
||||
7. Centauri Carbon — Elegoo FDM, Office, nozzle/bed current and target temperatures
|
||||
and model/auxiliary/enclosure fan speeds.
|
||||
7. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
|
||||
8. Saturn — Elegoo resin, Garage, progress, UV LED temperature, current/total layers,
|
||||
elapsed time, remaining time and estimated finish.
|
||||
|
||||

|
||||
|
||||
Swipe left for the next printer or right for the previous printer. Navigation wraps
|
||||
from the last page to the first. Previous/Next buttons provide the same navigation.
|
||||
Swipes propagate through the arc gauges and filament slots. A swipe consumes the
|
||||
rest of that touch so dragging across a navigation button does not also click it.
|
||||
Swipe left for the next page or right for the previous page. Navigation wraps
|
||||
from the last printer back to the fleet page. Previous/Next buttons provide the
|
||||
same navigation. Swipes propagate through the fleet gauges and filament
|
||||
slots. A swipe consumes the rest of that touch so dragging across a gauge or a
|
||||
navigation button does not also click it.
|
||||
|
||||
FDM pages show print status, job name, progress, nozzle and bed current/target
|
||||
temperatures, remaining time, estimated finish time, current/total layers, print
|
||||
@@ -84,17 +91,22 @@ printer rotation are outside its current scope.
|
||||
|
||||
## Install
|
||||
|
||||
1. Keep `esphome/bambu-printer-display.yaml` and the entire `bambu-display/` folder
|
||||
together in the ESPHome configuration directory. The generated package is checked
|
||||
in, so the ESPHome dashboard can compile it without running the generator first.
|
||||
1. Keep both device files and the entire `bambu-display/` folder together in the
|
||||
ESPHome configuration directory. `bambu-printer-display.yaml` is the first board
|
||||
and `bambu-printer-display-2.yaml` is the second. They share `board.yaml` and the
|
||||
generated fleet package, which is checked in, so the ESPHome dashboard can compile
|
||||
either file without running the generator first.
|
||||
2. Supply the existing `api_encryption_key`, `wifi_iot_ssid` and `wifi_password`
|
||||
entries in that directory's `secrets.yaml`.
|
||||
3. Use ESPHome **2026.9.0 or newer**. Compile and install
|
||||
`bambu-printer-display.yaml` over the board's USB-to-UART bridge for the first
|
||||
flash; subsequent installs can use encrypted OTA.
|
||||
4. Add **Bambu Printer Display** through Home Assistant's ESPHome integration using
|
||||
the same API encryption key. The display only subscribes to entities, so it does
|
||||
not require permission to perform Home Assistant actions.
|
||||
3. Use ESPHome **2026.9.0 or newer**. Compile and install the matching device file
|
||||
over that board's USB-to-UART bridge for the first flash; subsequent installs can
|
||||
use encrypted OTA. Flash `bambu-printer-display.yaml` to the first panel and
|
||||
`bambu-printer-display-2.yaml` to the second. Each file has its own ESPHome name,
|
||||
so both panels can be online together.
|
||||
4. Add **Bambu Printer Display** or **Bambu Printer Display 2** through Home
|
||||
Assistant's ESPHome integration using the same API encryption key. Each display
|
||||
only subscribes to entities, so it does not require permission to perform Home
|
||||
Assistant actions.
|
||||
5. Confirm all pages receive data and verify touch direction and color order on the
|
||||
physical panel. Entity IDs match the repository's `ha-entities.txt` snapshot;
|
||||
renamed or disabled entities in live HA must be corrected/enabled there.
|
||||
@@ -103,7 +115,7 @@ The USB D+/D− GPIO19/20 pins are used by the board's touch bus. Logging theref
|
||||
uses UART0. ESPHome may still warn about these two pins; they are intentional for
|
||||
this board. If GT911 is not found at `0x5D`, inspect the I²C scan and use its reported
|
||||
address (some hardware revisions select `0x14`). Backlight is GPIO2; RGB data pins
|
||||
and timings are explicit in the main YAML rather than borrowed from a 5″ board.
|
||||
and timings are explicit in `board.yaml` rather than borrowed from a 5″ board.
|
||||
|
||||
The display and LVGL both use **big-endian RGB565**. With `mipi_rgb`'s named
|
||||
`red`/`green`/`blue` pin groups, its generated bus pin map already exchanges the
|
||||
@@ -121,7 +133,9 @@ CLI checks from the repository root:
|
||||
|
||||
```sh
|
||||
esphome config esphome/bambu-printer-display.yaml
|
||||
esphome config esphome/bambu-printer-display-2.yaml
|
||||
esphome compile esphome/bambu-printer-display.yaml
|
||||
esphome compile esphome/bambu-printer-display-2.yaml
|
||||
```
|
||||
|
||||
## Change or expand the fleet
|
||||
@@ -135,7 +149,7 @@ python esphome/bambu-display/generate.py --check
|
||||
|
||||
The generator requires PyYAML (already included in ESPHome's Python environment).
|
||||
Do not hand-edit `generated.yaml`. There is no two-printer limit in the generator;
|
||||
adding a printer creates its own subscriptions, widgets and swipe page. The practical
|
||||
adding a printer creates its own subscriptions, widgets, swipe page and fleet gauge. The practical
|
||||
limit depends on the board's available RAM and flash, and adding printers requires
|
||||
regenerating and installing the firmware. Seven printers are configured; generation
|
||||
is also tested with nine. Each Bambu printer's filament strip supports up to eight slots.
|
||||
@@ -161,7 +175,9 @@ exist; their mappings are in `EXTRA_FANS`. A remaining-time override also update
|
||||
Filament entries use the Bambu integration's `type`, `color` and `active` attributes.
|
||||
|
||||
For Elegoo printers, set `profile: elegoo_fdm` or `profile: elegoo_resin` (the default
|
||||
profile is `bambu`). These profiles map progress to `percent_complete`, job name to
|
||||
profile is `bambu`). These profiles map progress to `percent_complete`. The Centauri Carbon leaves
|
||||
that sensor at 0 for most of a job, so an active Elegoo print falls back to the
|
||||
layer count and then to elapsed time divided by `total_print_time`. Job name maps to
|
||||
`file_name`, remaining time to `remaining_print_time`, and total layers to
|
||||
`total_layers`. The FDM profile reads targets from the HA `number` entities
|
||||
`target_nozzle_temp` and `target_bed_temp`; it only monitors their state. Per-metric
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace bambu_display {
|
||||
@@ -16,6 +17,7 @@ constexpr uint32_t MUTED = 0xAEB3B7;
|
||||
constexpr uint32_t RED = 0xFF737B;
|
||||
constexpr uint32_t AMBER = 0xF2C572;
|
||||
constexpr uint32_t TRACK = 0x19232B;
|
||||
constexpr uint32_t RING_BACKING = 0x6E767E;
|
||||
|
||||
inline std::string lower(std::string text) {
|
||||
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
@@ -103,6 +105,11 @@ inline uint32_t filament_color(std::string value) {
|
||||
return rgb;
|
||||
}
|
||||
|
||||
// A black ring disappears on the black page. Anything this dark needs a grey arc behind it.
|
||||
inline bool filament_is_black(uint32_t color) {
|
||||
return ((color >> 16) & 0xFF) <= 0x28 && ((color >> 8) & 0xFF) <= 0x28 && (color & 0xFF) <= 0x28;
|
||||
}
|
||||
|
||||
struct Tray {
|
||||
std::string label, type, color, active;
|
||||
};
|
||||
@@ -115,9 +122,9 @@ struct Snapshot {
|
||||
std::string hms_count, print_error_message, print_error_code;
|
||||
std::string print_error_reason, uv_unit, elapsed_unit;
|
||||
bool elegoo = false, resin = false;
|
||||
float uv = NAN, elapsed = NAN;
|
||||
float uv = NAN, elapsed = NAN, total_time = NAN;
|
||||
std::vector<HealthNotice> health_notices;
|
||||
std::string remaining_unit, nozzle_unit, bed_unit;
|
||||
std::string remaining_unit, nozzle_unit, bed_unit, total_time_unit;
|
||||
std::string finish_time;
|
||||
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
|
||||
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
|
||||
@@ -127,6 +134,36 @@ struct Snapshot {
|
||||
std::vector<Tray> trays;
|
||||
|
||||
bool live() const { return connected && is_on(online) && valid_text(status); }
|
||||
// Centauri firmware parks on milestone names such as preheating_completed for
|
||||
// most of a job, so any status that is not a known idle or finished state is active.
|
||||
bool job_active() const {
|
||||
auto idle = [](const std::string &value) {
|
||||
return value.empty() || value == "unknown" || value == "unavailable" || value == "none" ||
|
||||
value == "idle" || value == "standby" || value == "ready" || value == "complete" ||
|
||||
value == "completed" || value == "finish" || value == "finished" || value == "cancelled" ||
|
||||
value == "canceled" || value == "stopped" || value == "stop" || value == "failed";
|
||||
};
|
||||
const auto state = lower(status);
|
||||
const auto machine = lower(stage);
|
||||
if (machine == "printing" || machine == "paused" || machine == "busy" || machine == "heating" ||
|
||||
machine == "preheating" || machine == "running")
|
||||
return true;
|
||||
return !idle(state);
|
||||
}
|
||||
// The Centauri reports percent_complete as 0 while CurrentLayer stays 0.
|
||||
// Layers, then elapsed time against the total, still describe the job.
|
||||
float completion() const {
|
||||
if (!elegoo) return progress;
|
||||
if (std::isfinite(progress) && progress > 0.0f) return std::clamp(progress, 0.0f, 100.0f);
|
||||
if (!job_active()) return std::isfinite(progress) ? 0.0f : NAN;
|
||||
if (std::isfinite(layer) && std::isfinite(total_layers) && layer > 0.0f && total_layers > 0.0f)
|
||||
return std::clamp(100.0f * layer / total_layers, 0.0f, 100.0f);
|
||||
const int elapsed_s = duration_seconds(elapsed, elapsed_unit);
|
||||
const int total_s = duration_seconds(total_time, total_time_unit);
|
||||
if (elapsed_s > 0 && total_s > 0)
|
||||
return std::clamp(100.0f * static_cast<float>(elapsed_s) / static_cast<float>(total_s), 0.0f, 100.0f);
|
||||
return std::isfinite(progress) ? 0.0f : NAN;
|
||||
}
|
||||
bool has_print_error() const {
|
||||
if (!elegoo) return is_on(print_error);
|
||||
return elegoo_fault(print_error) || elegoo_fault(print_error_reason) || lower(status) == "error";
|
||||
@@ -212,6 +249,29 @@ struct Snapshot {
|
||||
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
return text;
|
||||
}
|
||||
std::string overview_task() const {
|
||||
if (!live()) return connected ? "Waiting for printer data" : "Waiting for Home Assistant";
|
||||
return valid_text(task) ? task : "No active print";
|
||||
}
|
||||
std::string overview_meta() const {
|
||||
if (!live()) return "--";
|
||||
const float percent = completion();
|
||||
const std::string progress_text = std::isfinite(percent)
|
||||
? number(std::clamp(percent, 0.0f, 100.0f)) + "%" : "--";
|
||||
const std::string time_line = remaining(time_left, remaining_unit);
|
||||
if (!finish_time.empty() && time_line != "--")
|
||||
return progress_text + " " + finish_time + " " + time_line;
|
||||
return progress_text + " " + time_line;
|
||||
}
|
||||
// Active tray is the spool the printer loaded most recently. Without one, keep green.
|
||||
uint32_t active_filament_color() const {
|
||||
for (const auto &tray : trays)
|
||||
if (is_on(tray.active)) {
|
||||
const uint32_t color = filament_color(tray.color);
|
||||
if (color != TRACK) return color;
|
||||
}
|
||||
return GREEN;
|
||||
}
|
||||
uint32_t status_color() const {
|
||||
if (!live()) return MUTED;
|
||||
const auto state = lower(status);
|
||||
@@ -223,6 +283,42 @@ struct Snapshot {
|
||||
}
|
||||
};
|
||||
|
||||
struct FleetCounts {
|
||||
int attention = 0, printing = 0, paused = 0, idle = 0, offline = 0, syncing = 0;
|
||||
};
|
||||
|
||||
inline void tally(FleetCounts &counts, const Snapshot &data) {
|
||||
if (!data.connected || lower(data.online) == "off" || lower(data.online) == "false") {
|
||||
++counts.offline;
|
||||
return;
|
||||
}
|
||||
if (!data.live()) {
|
||||
++counts.syncing;
|
||||
return;
|
||||
}
|
||||
if (data.has_print_error() || is_on(data.hms_error) || lower(data.status) == "failed") {
|
||||
++counts.attention;
|
||||
return;
|
||||
}
|
||||
const auto state = lower(data.status);
|
||||
if (state == "running" || state == "printing") ++counts.printing;
|
||||
else if (state == "pause" || state == "paused" || state == "pausing") ++counts.paused;
|
||||
else ++counts.idle;
|
||||
}
|
||||
|
||||
inline std::string fleet_summary(const FleetCounts &counts) {
|
||||
const std::pair<int, const char *> items[] = {
|
||||
{counts.attention, "attention"}, {counts.printing, "printing"}, {counts.paused, "paused"},
|
||||
{counts.idle, "idle"}, {counts.offline, "offline"}, {counts.syncing, "syncing"}};
|
||||
std::string text;
|
||||
for (const auto &item : items) {
|
||||
if (!item.first) continue;
|
||||
if (!text.empty()) text += ", ";
|
||||
text += std::to_string(item.first) + " " + item.second;
|
||||
}
|
||||
return text.empty() ? "Syncing" : text;
|
||||
}
|
||||
|
||||
} // namespace bambu_display
|
||||
|
||||
#ifdef USE_LVGL
|
||||
@@ -239,6 +335,9 @@ struct Widgets {
|
||||
GaugeWidgets uv{}, layer{};
|
||||
lv_obj_t *elapsed = nullptr;
|
||||
};
|
||||
struct OverviewWidgets {
|
||||
lv_obj_t *arc, *value, *finish, *backing;
|
||||
};
|
||||
|
||||
inline void set_text(lv_obj_t *label, const std::string &text) {
|
||||
if (text != lv_label_get_text(label)) lv_label_set_text(label, text.c_str());
|
||||
@@ -257,9 +356,10 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
|
||||
set_text(widgets.status, data.status_text());
|
||||
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0);
|
||||
lv_obj_set_style_arc_color(widgets.progress.arc, lv_color_hex(color), LV_PART_INDICATOR);
|
||||
lv_bar_set_value(widgets.top_progress, live ? gauge_value(data.progress, 100) : 0, LV_ANIM_OFF);
|
||||
render_gauge(widgets.progress, live && std::isfinite(data.progress)
|
||||
? std::clamp(data.progress, 0.0f, 100.0f) : NAN, live ? data.progress : NAN, 100,
|
||||
const float percent = data.completion();
|
||||
lv_bar_set_value(widgets.top_progress, live ? gauge_value(percent, 100) : 0, LV_ANIM_OFF);
|
||||
render_gauge(widgets.progress, live && std::isfinite(percent)
|
||||
? std::clamp(percent, 0.0f, 100.0f) : NAN, live ? percent : NAN, 100,
|
||||
live ? remaining(data.time_left, data.remaining_unit) : "--");
|
||||
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
|
||||
: data.connected ? "Waiting for printer data" : "Waiting for Home Assistant");
|
||||
@@ -306,5 +406,33 @@ inline void render(const Snapshot &data, const Widgets &widgets) {
|
||||
}
|
||||
}
|
||||
|
||||
inline void render_overview(const Snapshot &data, const OverviewWidgets &widgets) {
|
||||
const bool live = data.live();
|
||||
const uint32_t filament = data.active_filament_color();
|
||||
const float percent = data.completion();
|
||||
const float shown = live && std::isfinite(percent)
|
||||
? std::clamp(percent, 0.0f, 100.0f) : NAN;
|
||||
const bool black = filament_is_black(filament);
|
||||
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(black ? RING_BACKING : TRACK), LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_color(widgets.arc, lv_color_hex(filament), LV_PART_INDICATOR);
|
||||
lv_arc_set_value(widgets.arc, live ? gauge_value(percent, 100) : 0);
|
||||
if (black) {
|
||||
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_MAIN);
|
||||
lv_obj_set_style_arc_color(widgets.backing, lv_color_hex(RING_BACKING), LV_PART_INDICATOR);
|
||||
lv_obj_clear_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
|
||||
} else {
|
||||
lv_obj_add_flag(widgets.backing, LV_OBJ_FLAG_HIDDEN);
|
||||
}
|
||||
set_text(widgets.value, number(shown));
|
||||
lv_obj_set_style_text_color(widgets.value, lv_color_hex(0xFFFFFF), 0);
|
||||
set_text(widgets.finish, live && !data.finish_time.empty() ? data.finish_time : "--");
|
||||
}
|
||||
|
||||
inline void render_fleet_summary(lv_obj_t *label, const FleetCounts &counts) {
|
||||
set_text(label, fleet_summary(counts));
|
||||
const uint32_t color = counts.attention ? RED : counts.printing ? GREEN : MUTED;
|
||||
lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
|
||||
}
|
||||
|
||||
} // namespace bambu_display
|
||||
#endif
|
||||
|
||||
@@ -24,14 +24,17 @@ EXTRA_FANS = {'aux_fan': 'aux_fan_speed', 'exhaust_fan': 'chamber_fan_speed'}
|
||||
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
|
||||
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
|
||||
ELEGOO_COMMON = {'progress': 'percent_complete', 'time_left': 'remaining_print_time',
|
||||
'layer': 'current_layer', 'total_layers': 'total_layers'}
|
||||
'layer': 'current_layer', 'total_layers': 'total_layers',
|
||||
'total_time': 'total_print_time'}
|
||||
ELEGOO_FDM = {'nozzle': 'nozzle_temperature', 'bed': 'bed_temperature',
|
||||
'elapsed': 'current_print_time',
|
||||
'nozzle_target': ('number', 'target_nozzle_temp'),
|
||||
'bed_target': ('number', 'target_bed_temp'), 'fan': 'model_fan_speed',
|
||||
'aux_fan': 'auxiliary_fan_speed', 'exhaust_fan': 'enclosure_fan_speed'}
|
||||
ELEGOO_RESIN = {'uv': 'uv_led_temp', 'elapsed': 'current_print_time'}
|
||||
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
|
||||
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
|
||||
RING_BACKING = 0x6E767E
|
||||
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
|
||||
|
||||
|
||||
@@ -121,7 +124,7 @@ def fdm_gauges(printer):
|
||||
return widgets
|
||||
|
||||
|
||||
def page_for(printer, index, total):
|
||||
def page_for(printer, display_index, total):
|
||||
pid = printer['id']
|
||||
resin = printer.get('profile') == 'elegoo_resin'
|
||||
widgets = [
|
||||
@@ -129,7 +132,7 @@ def page_for(printer, index, total):
|
||||
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
|
||||
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
|
||||
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
|
||||
label(f'{index + 1} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
|
||||
label(f'{display_index} / {total}', 520, 24, 70, 14, text_color=MUTED, text_align='CENTER'),
|
||||
label('SYNCING', 598, 21, 186, 16, id=f'{pid}_status_label', text_align='RIGHT', text_color=MUTED,
|
||||
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
|
||||
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
|
||||
@@ -213,6 +216,141 @@ def page_for(printer, index, total):
|
||||
'on_load': [{'script.execute': f'{pid}_refresh'}], 'widgets': widgets}
|
||||
|
||||
|
||||
def snapshot_lines(printer, expressions, trays):
|
||||
profile = printer.get('profile', 'bambu')
|
||||
elegoo = profile.startswith('elegoo_')
|
||||
resin = profile == 'elegoo_resin'
|
||||
lines = ['bambu_display::Snapshot data;',
|
||||
'data.connected = id(ha_api).is_connected_with_state_subscription();']
|
||||
for key, expr in expressions.items():
|
||||
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
|
||||
lines.append(f'data.{key} = {expr};')
|
||||
if elegoo:
|
||||
lines.append('data.elegoo = true;')
|
||||
else:
|
||||
for number in range(1, 4):
|
||||
lines.append('data.health_notices.push_back({' + ', '.join(
|
||||
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
|
||||
if resin:
|
||||
lines.append('data.resin = true;')
|
||||
if printer.get('dual_nozzle'):
|
||||
lines.append('data.dual_nozzle = true;')
|
||||
lines += [
|
||||
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
|
||||
'const auto clock = id(ha_time).now();',
|
||||
'if (clock.is_valid() && seconds_left >= 0)',
|
||||
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
|
||||
]
|
||||
for i, tray in enumerate(trays):
|
||||
lines.append('data.trays.push_back({' + ', '.join([
|
||||
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
|
||||
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
|
||||
return lines
|
||||
|
||||
|
||||
def overview_slots(count):
|
||||
"""Place one steam gauge per printer under the page title."""
|
||||
columns = 4 if count > 4 else max(count, 1)
|
||||
rows = (count + columns - 1) // columns
|
||||
top, bottom = 46, 432
|
||||
row_h = (bottom - top) // rows
|
||||
arc = min(154, (800 // columns) - 24, row_h - 48)
|
||||
col_w = 800 // columns
|
||||
slots = []
|
||||
for index in range(count):
|
||||
row, col = divmod(index, columns)
|
||||
in_row = min(columns, count - row * columns)
|
||||
x0 = (800 - in_row * col_w) // 2
|
||||
gauge_w = arc + 16
|
||||
slots.append((x0 + col * col_w + (col_w - gauge_w) // 2,
|
||||
top + row * row_h, arc))
|
||||
return slots
|
||||
|
||||
|
||||
def overview_gauge(printer, x, y, arc):
|
||||
pid = printer['id']
|
||||
scale = arc / 154
|
||||
value_font = 32 if arc >= 130 else 18
|
||||
open_page = [{'lvgl.page.show': f'{pid}_page'}]
|
||||
inner = max(10, int(round(13 * scale)))
|
||||
inner_x = x + int(round(8 * scale))
|
||||
number_y = y + int(round(arc * 0.30))
|
||||
pad = 8
|
||||
return [
|
||||
{'arc': {'id': f'{pid}_overview_ring', 'x': inner_x - pad // 2, 'y': y - pad // 2,
|
||||
'width': arc + pad, 'height': arc + pad, 'min_value': 0, 'max_value': 100,
|
||||
'value': 100, 'start_angle': 135, 'end_angle': 45,
|
||||
'arc_width': inner + pad, 'arc_color': RING_BACKING,
|
||||
'indicator': {'arc_color': RING_BACKING, 'arc_width': inner + pad},
|
||||
'adjustable': False, 'hidden': True, 'gesture_bubble': True, 'clickable': True,
|
||||
'on_click': open_page}},
|
||||
{'arc': {'id': f'{pid}_overview_arc', 'x': inner_x, 'y': y,
|
||||
'width': arc, 'height': arc, 'min_value': 0, 'max_value': 100, 'value': 0,
|
||||
'start_angle': 135, 'end_angle': 45, 'arc_width': inner, 'arc_color': TRACK,
|
||||
'indicator': {'arc_color': GREEN, 'arc_width': inner}, 'adjustable': False,
|
||||
'gesture_bubble': True, 'clickable': True, 'on_click': open_page}},
|
||||
label('--', inner_x, number_y, arc, value_font, id=f'{pid}_overview_value',
|
||||
text_align='CENTER', text_color=0xFFFFFF, clickable=True, on_click=open_page),
|
||||
label('%', inner_x, number_y + (34 if value_font >= 28 else 20), arc, 16,
|
||||
id=f'{pid}_overview_percent', text_align='CENTER', text_color=0xFFFFFF,
|
||||
clickable=True, on_click=open_page),
|
||||
label(printer['name'], x, y + arc + 2, arc + 16, 14, text_align='CENTER',
|
||||
text_color=TEXT_COLOR, clickable=True, on_click=open_page),
|
||||
label('--', x, y + arc + 22, arc + 16, 14, id=f'{pid}_overview_finish',
|
||||
text_align='CENTER', text_color=TEXT_COLOR, clickable=True, on_click=open_page),
|
||||
]
|
||||
|
||||
|
||||
def overview_page(printers):
|
||||
widgets = [
|
||||
label('Print Status Overview', 16, 10, 520, 22, text_color=GREEN),
|
||||
label('--:--', 548, 12, 236, 18, id='fleet_clock', text_align='RIGHT', text_color=TEXT_COLOR),
|
||||
]
|
||||
for printer, (x, y, arc) in zip(printers, overview_slots(len(printers))):
|
||||
widgets += overview_gauge(printer, x, y, arc)
|
||||
for direction, x, text in (('previous', 16, '<'), ('next', 720, '>')):
|
||||
widgets.append({'button': {'x': x, 'y': 446, 'width': 64, 'height': 30,
|
||||
'bg_color': TRACK, 'radius': 8, 'border_width': 0,
|
||||
'pad_all': 0, 'gesture_bubble': True,
|
||||
'widgets': [{'label': {'text': text, 'align': 'CENTER',
|
||||
'text_color': TEXT_COLOR, 'text_font': 'montserrat_18'}}],
|
||||
'on_click': [{f'lvgl.page.{direction}': {}}]}})
|
||||
widgets.append(label(f'1 / {len(printers) + 1}', 94, 449, 612, 14,
|
||||
text_align='CENTER', text_color=MUTED))
|
||||
return {'id': 'fleet_page', 'bg_color': BG, 'bg_opa': 'COVER',
|
||||
'pad_all': 0, 'scrollable': False, 'scrollbar_mode': 'OFF',
|
||||
'on_swipe_left': [
|
||||
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
|
||||
{'lvgl.page.next': {'animation': 'MOVE_LEFT', 'time': '180ms'}}],
|
||||
'on_swipe_right': [
|
||||
{'lambda': Lambda('if (auto *input = lv_indev_active()) lv_indev_wait_release(input);')},
|
||||
{'lvgl.page.previous': {'animation': 'MOVE_RIGHT', 'time': '180ms'}}],
|
||||
'on_load': [{'script.execute': 'fleet_refresh'}], 'widgets': widgets}
|
||||
|
||||
|
||||
def fleet_refresh_lines(snapshots):
|
||||
lines = []
|
||||
for pid, snapshot in snapshots:
|
||||
lines.append('{')
|
||||
lines.extend(snapshot)
|
||||
lines.append('bambu_display::render_overview(data, {' + ', '.join([
|
||||
f'id({pid}_overview_arc)', f'id({pid}_overview_value)',
|
||||
f'id({pid}_overview_finish)', f'id({pid}_overview_ring)']) + '});')
|
||||
lines.append('}')
|
||||
lines += [
|
||||
'{',
|
||||
'const auto clock = id(ha_time).now();',
|
||||
'std::string clock_text = "--:--";',
|
||||
'if (clock.is_valid()) {',
|
||||
' clock_text = esphome::ESPTime::from_epoch_local(clock.timestamp).strftime("%I:%M %p");',
|
||||
' if (!clock_text.empty() && clock_text[0] == \'0\') clock_text.erase(0, 1);',
|
||||
'}',
|
||||
'bambu_display::set_text(id(fleet_clock), clock_text);',
|
||||
'}',
|
||||
]
|
||||
return lines
|
||||
|
||||
|
||||
def color_test_page():
|
||||
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
|
||||
label('Each block must match its label. Swipe to return to the printers.',
|
||||
@@ -236,6 +374,8 @@ def generate(printers):
|
||||
validate_printers(printers)
|
||||
package = {'sensor': [], 'text_sensor': [], 'script': [],
|
||||
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
|
||||
snapshots = []
|
||||
page_total = len(printers) + 1
|
||||
for index, printer in enumerate(printers):
|
||||
pid = printer['id']
|
||||
profile = printer.get('profile', 'bambu')
|
||||
@@ -271,7 +411,8 @@ def generate(printers):
|
||||
text_fields['print_error_message'] = (print_error_entity, 'error')
|
||||
text_fields['print_error_code'] = (print_error_entity, 'code')
|
||||
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'),
|
||||
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed')]:
|
||||
('bed_unit', 'bed'), ('uv_unit', 'uv'), ('elapsed_unit', 'elapsed'),
|
||||
('total_time_unit', 'total_time')]:
|
||||
if numeric_key in fields:
|
||||
suffix = fields[numeric_key]
|
||||
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
|
||||
@@ -287,34 +428,11 @@ def generate(printers):
|
||||
subscription['attribute'] = attribute
|
||||
package['text_sensor'].append(subscription)
|
||||
expressions[key] = f'id({sid}).state'
|
||||
lines = ['bambu_display::Snapshot data;',
|
||||
'data.connected = id(ha_api).is_connected_with_state_subscription();']
|
||||
for key, expr in expressions.items():
|
||||
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
|
||||
lines.append(f'data.{key} = {expr};')
|
||||
if elegoo:
|
||||
lines.append('data.elegoo = true;')
|
||||
else:
|
||||
for number in range(1, 4):
|
||||
lines.append('data.health_notices.push_back({' + ', '.join(
|
||||
expressions[f'hms_{number}_{key}'] for key in ('code', 'message', 'severity')) + '});')
|
||||
if resin:
|
||||
lines.append('data.resin = true;')
|
||||
if printer.get('dual_nozzle'):
|
||||
lines.append('data.dual_nozzle = true;')
|
||||
lines += [
|
||||
'const int seconds_left = bambu_display::duration_seconds(data.time_left, data.remaining_unit);',
|
||||
'const auto clock = id(ha_time).now();',
|
||||
'if (clock.is_valid() && seconds_left >= 0)',
|
||||
' data.finish_time = esphome::ESPTime::from_epoch_local(clock.timestamp + seconds_left).strftime("%H:%M");',
|
||||
]
|
||||
tray_widgets = []
|
||||
for i, tray in enumerate(trays):
|
||||
lines.append('data.trays.push_back({' + ', '.join([
|
||||
json.dumps(tray['label']), expressions[f'tray_{i}_type'],
|
||||
expressions[f'tray_{i}_color'], expressions[f'tray_{i}_active']]) + '});')
|
||||
tray_widgets.append('{' + ', '.join(f'id({pid}_tray_{i}_{part})'
|
||||
for part in ('card', 'swatch', 'label')) + '}')
|
||||
lines = snapshot_lines(printer, expressions, trays)
|
||||
snapshots.append((pid, list(lines)))
|
||||
tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
|
||||
for part in ('card', 'swatch', 'label')) + '}'
|
||||
for i in range(len(trays))]
|
||||
refs = [f'id({pid}_{key})' for key in (
|
||||
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
|
||||
'health_details')]
|
||||
@@ -331,7 +449,13 @@ def generate(printers):
|
||||
package['interval'][0]['then'].append({'if': {
|
||||
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
|
||||
'then': [{'script.execute': f'{pid}_refresh'}]}})
|
||||
package['lvgl']['pages'].append(page_for(printer, index, len(printers)))
|
||||
package['lvgl']['pages'].append(page_for(printer, index + 2, page_total))
|
||||
package['script'].append({'id': 'fleet_refresh', 'then': [
|
||||
{'lambda': Lambda('\n'.join(fleet_refresh_lines(snapshots)))}]})
|
||||
package['interval'][0]['then'].append({'if': {
|
||||
'condition': {'lvgl.page.is_showing': 'fleet_page'},
|
||||
'then': [{'script.execute': 'fleet_refresh'}]}})
|
||||
package['lvgl']['pages'].insert(0, overview_page(printers))
|
||||
package['lvgl']['pages'].append(color_test_page())
|
||||
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
|
||||
'entity_category': 'diagnostic',
|
||||
@@ -351,10 +475,10 @@ def main():
|
||||
if args.check:
|
||||
if not destination.exists() or destination.read_text() != content:
|
||||
raise SystemExit('generated.yaml is stale; run generate.py')
|
||||
print(f'Generated configuration is current ({len(printers)} printer pages).')
|
||||
print(f'Generated configuration is current ({len(printers)} printer pages plus fleet overview).')
|
||||
else:
|
||||
destination.write_text(content)
|
||||
print(f'Generated {destination.name} with {len(printers)} printer pages.')
|
||||
print(f'Generated {destination.name} with {len(printers)} printer pages plus fleet overview.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,12 +22,14 @@ def main():
|
||||
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
|
||||
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200,
|
||||
'fan': 80, 'aux_fan': 45, 'exhaust_fan': 30, 'left_nozzle': 219, 'left_target': 220,
|
||||
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30}
|
||||
'right_nozzle': 37, 'right_target': 0, 'uv': 42, 'elapsed': 30,
|
||||
'total_time': 200}
|
||||
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
|
||||
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
|
||||
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
|
||||
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
|
||||
'uv_unit': '°C', 'elapsed_unit': 'min', 'print_error_reason': 'ok'}
|
||||
'uv_unit': '°C', 'elapsed_unit': 'min', 'total_time_unit': 'min',
|
||||
'print_error_reason': 'ok'}
|
||||
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
|
||||
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
|
||||
for printer in printers:
|
||||
@@ -87,7 +89,21 @@ inline void setup() {
|
||||
''')
|
||||
steps = [{'component.update': entry['id']}
|
||||
for section in ('sensor', 'text_sensor') for entry in package[section]]
|
||||
steps += [{'delay': '1s'}]
|
||||
steps += [{'delay': '1s'},
|
||||
{'lvgl.page.show': 'fleet_page'},
|
||||
{'script.execute': 'fleet_refresh'}, {'delay': '300ms'},
|
||||
{'snapshot.take': {'id': 'panel_display', 'filename': 'fleet.bmp'}},
|
||||
{'lambda': Lambda(
|
||||
'if (std::string(lv_label_get_text(id(fleet_clock))) == "--:--") '
|
||||
'{ ESP_LOGE("preview", "Fleet clock render failed"); exit(7); }')}]
|
||||
for printer in printers:
|
||||
pid = printer['id']
|
||||
steps.append({'lambda': Lambda(
|
||||
f'if (std::string(lv_label_get_text(id({pid}_overview_value))) != "67" || '
|
||||
f'lv_arc_get_value(id({pid}_overview_arc)) != 67 || '
|
||||
f'std::string(lv_label_get_text(id({pid}_overview_percent))) != "%" || '
|
||||
f'std::string(lv_label_get_text(id({pid}_overview_finish))) == "--") '
|
||||
f'{{ ESP_LOGE("preview", "Fleet gauge render failed: {pid}"); exit(7); }}')})
|
||||
for printer in printers:
|
||||
steps += [{'lvgl.page.show': printer['id'] + '_page'},
|
||||
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
|
||||
@@ -125,7 +141,7 @@ inline void setup() {
|
||||
{'lvgl.widget.hide': demo_printer + '_health_panel'},
|
||||
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
|
||||
{'script.execute': demo_printer + '_refresh'}]
|
||||
steps += [{'lvgl.page.show': printers[0]['id'] + '_page'}, {'delay': '250ms'},
|
||||
steps += [{'lvgl.page.show': 'fleet_page'}, {'delay': '250ms'},
|
||||
{'lambda': Lambda('bambu_preview::setup();')}]
|
||||
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
|
||||
steps += [
|
||||
@@ -136,11 +152,13 @@ inline void setup() {
|
||||
if len(printers) > 1:
|
||||
# First -> second -> first -> last -> first, then a swipe starting
|
||||
# on NEXT must advance exactly once even when released over PREV.
|
||||
gestures = [(650, 180, 180, printers[1]['id']),
|
||||
(180, 650, 180, printers[0]['id']),
|
||||
# Fleet -> first printer -> fleet -> last printer -> fleet, then a
|
||||
# swipe starting on NEXT advances exactly once.
|
||||
gestures = [(650, 180, 180, printers[0]['id']),
|
||||
(180, 650, 180, 'fleet'),
|
||||
(180, 650, 180, printers[-1]['id']),
|
||||
(650, 180, 180, printers[0]['id']),
|
||||
(730, 60, 460, printers[1]['id'])]
|
||||
(650, 180, 180, 'fleet'),
|
||||
(730, 60, 460, printers[0]['id'])]
|
||||
for start, end, y, expected in gestures:
|
||||
steps += [
|
||||
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
|
||||
@@ -165,7 +183,7 @@ inline void setup() {
|
||||
subprocess.run(['esphome', 'compile', str(filename)], check=True)
|
||||
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
|
||||
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
|
||||
for name in [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
|
||||
for name in ['fleet'] + [printer['id'] for printer in printers] + ['color_test', 'health_notice']:
|
||||
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
|
||||
with Image.open(bmp) as image:
|
||||
image.save(args.output / (name + '.png'))
|
||||
|
||||
@@ -99,8 +99,77 @@ int main() {
|
||||
assert(data.status_color() == AMBER);
|
||||
data.status = "complete";
|
||||
assert(data.status_text() == "COMPLETE");
|
||||
data.status = "preheating_completed";
|
||||
data.stage = "printing";
|
||||
data.progress = 0;
|
||||
data.layer = 0;
|
||||
data.total_layers = 400;
|
||||
data.elapsed = 30;
|
||||
data.elapsed_unit = "min";
|
||||
data.total_time = 60;
|
||||
data.total_time_unit = "min";
|
||||
assert(std::fabs(data.completion() - 50.0f) < 0.01f);
|
||||
data.layer = 100;
|
||||
assert(std::fabs(data.completion() - 25.0f) < 0.01f);
|
||||
data.progress = 67;
|
||||
assert(std::fabs(data.completion() - 67.0f) < 0.01f);
|
||||
data.progress = 0;
|
||||
data.layer = 0;
|
||||
data.status = "idle";
|
||||
data.stage = "idle";
|
||||
assert(data.completion() == 0.0f);
|
||||
data.online = "off";
|
||||
assert(!data.live());
|
||||
assert(data.status_text() == "PRINTER OFFLINE");
|
||||
assert(data.health_details().find("unavailable") != std::string::npos);
|
||||
|
||||
data.connected = true;
|
||||
data.online = "on";
|
||||
data.elegoo = false;
|
||||
data.print_error = "off";
|
||||
data.print_error_reason = "ok";
|
||||
data.hms_error = "off";
|
||||
data.status = "running";
|
||||
data.task = "Workshop organizer";
|
||||
data.progress = 67;
|
||||
data.time_left = 1.75f;
|
||||
data.remaining_unit = "h";
|
||||
data.finish_time = "16:05";
|
||||
assert(data.overview_task() == "Workshop organizer");
|
||||
assert(data.overview_meta() == "67% 16:05 1h 45m");
|
||||
data.task = "";
|
||||
assert(data.overview_task() == "No active print");
|
||||
data.connected = false;
|
||||
assert(data.overview_task() == "Waiting for Home Assistant");
|
||||
assert(data.overview_meta() == "--");
|
||||
data.trays = {{"A1", "PLA", "#FF8800FF", "off"}, {"A2", "PETG", "00FF00", "on"}};
|
||||
assert(data.active_filament_color() == GREEN);
|
||||
data.trays[1].active = "off";
|
||||
data.trays[0].active = "on";
|
||||
assert(data.active_filament_color() == 0xFF8800);
|
||||
data.trays.clear();
|
||||
assert(data.active_filament_color() == GREEN);
|
||||
assert(filament_is_black(0x000000));
|
||||
assert(filament_is_black(filament_color("#000000FF")));
|
||||
assert(filament_is_black(0x202020));
|
||||
assert(!filament_is_black(0xFFFFFF));
|
||||
assert(!filament_is_black(GREEN));
|
||||
assert(!filament_is_black(0xFF8800));
|
||||
|
||||
FleetCounts counts;
|
||||
data.connected = true;
|
||||
data.online = "on";
|
||||
data.status = "printing";
|
||||
tally(counts, data);
|
||||
data.status = "pause";
|
||||
tally(counts, data);
|
||||
data.print_error = "on";
|
||||
tally(counts, data);
|
||||
data.print_error = "off";
|
||||
data.online = "off";
|
||||
tally(counts, data);
|
||||
data.online = "unavailable";
|
||||
data.connected = true;
|
||||
tally(counts, data);
|
||||
assert(fleet_summary(counts) == "1 attention, 1 printing, 1 paused, 1 offline, 1 syncing");
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ class FleetTests(unittest.TestCase):
|
||||
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
|
||||
self.printers.append(extra)
|
||||
package = generate(self.printers)
|
||||
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count)
|
||||
self.assertEqual(len(package['script']), expected_count)
|
||||
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), expected_count + 1)
|
||||
self.assertEqual(len(package['script']), expected_count + 1)
|
||||
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
|
||||
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
|
||||
for page in package['lvgl']['pages']:
|
||||
@@ -123,7 +123,7 @@ class FleetTests(unittest.TestCase):
|
||||
pass
|
||||
for tag in ('!secret', '!include'):
|
||||
Loader.add_constructor(tag, lambda loader, node: loader.construct_scalar(node))
|
||||
config = yaml.load((HERE.parent / 'bambu-printer-display.yaml').read_text(), Loader=Loader)
|
||||
config = yaml.load((HERE / 'board.yaml').read_text(), Loader=Loader)
|
||||
display = config['display'][0]
|
||||
pins = display['data_pins']
|
||||
natural = pins['blue'] + pins['green'] + pins['red']
|
||||
@@ -135,6 +135,62 @@ class FleetTests(unittest.TestCase):
|
||||
for i, pin in enumerate(driver))
|
||||
self.assertEqual(panel_word, color, f'RGB565 color {color:#06x} is wired incorrectly')
|
||||
|
||||
def test_both_boards_share_one_hardware_package(self):
|
||||
class Loader(yaml.SafeLoader):
|
||||
pass
|
||||
Loader.add_constructor('!include', lambda loader, node: loader.construct_scalar(node))
|
||||
names = set()
|
||||
for filename in ('bambu-printer-display.yaml', 'bambu-printer-display-2.yaml'):
|
||||
config = yaml.load((HERE.parent / filename).read_text(), Loader=Loader)
|
||||
names.add(config['substitutions']['device_name'])
|
||||
self.assertEqual(config['packages']['board'], 'bambu-display/board.yaml')
|
||||
self.assertEqual(config['packages']['fleet'], 'bambu-display/generated.yaml')
|
||||
self.assertEqual(len(names), 2)
|
||||
|
||||
def test_fleet_overview_lists_every_printer(self):
|
||||
package = generate(self.printers)
|
||||
page = package['lvgl']['pages'][0]
|
||||
self.assertEqual(page['id'], 'fleet_page')
|
||||
self.assertFalse(page.get('skip'))
|
||||
arcs = [widget['arc'] for widget in page['widgets']
|
||||
if widget.get('arc', {}).get('id', '').endswith('_overview_arc')]
|
||||
self.assertEqual([arc['id'][:-len('_overview_arc')] for arc in arcs],
|
||||
[printer['id'] for printer in self.printers])
|
||||
backings = [widget['arc'] for widget in page['widgets']
|
||||
if widget.get('arc', {}).get('id', '').endswith('_overview_ring')]
|
||||
self.assertEqual([arc['id'][:-len('_overview_ring')] for arc in backings],
|
||||
[printer['id'] for printer in self.printers])
|
||||
for backing, arc in zip(backings, arcs):
|
||||
self.assertTrue(backing['hidden'])
|
||||
self.assertEqual(backing['arc_color'], 0x6E767E)
|
||||
self.assertGreater(backing['width'], arc['width'])
|
||||
self.assertGreaterEqual(backing['x'], 0)
|
||||
self.assertGreaterEqual(backing['y'], 0)
|
||||
self.assertLessEqual(backing['x'] + backing['width'], 800)
|
||||
self.assertLessEqual(backing['y'] + backing['height'], 480)
|
||||
for arc, printer in zip(arcs, self.printers):
|
||||
self.assertEqual(arc['on_click'], [{'lvgl.page.show': printer['id'] + '_page'}])
|
||||
self.assertGreaterEqual(arc['width'], 120)
|
||||
self.assertLessEqual(arc['width'], 154)
|
||||
self.assertEqual(arc['value'], 0)
|
||||
self.assertEqual(arc['arc_color'], 0x19232B)
|
||||
self.assertEqual(arc['start_angle'], 135)
|
||||
self.assertLessEqual(arc['x'] + arc['width'], 800)
|
||||
self.assertLessEqual(arc['y'] + arc['height'], 480)
|
||||
code = next(script['then'][0]['lambda'] for script in package['script'] if script['id'] == 'fleet_refresh')
|
||||
for printer in self.printers:
|
||||
self.assertIn(f'id({printer["id"]}_overview_arc)', code)
|
||||
self.assertIn(f'id({printer["id"]}_overview_finish)', code)
|
||||
self.assertIn(f'id({printer["id"]}_overview_ring)', code)
|
||||
titles = [next(iter(widget.values())).get('text') for widget in page['widgets']]
|
||||
self.assertIn('Print Status Overview', titles)
|
||||
self.assertNotIn('fleet_summary', {next(iter(widget.values())).get('id') for widget in page['widgets']})
|
||||
shown = [step['if']['condition']['lvgl.page.is_showing'] for step in package['interval'][0]['then']]
|
||||
self.assertIn('fleet_page', shown)
|
||||
charming = next(item for item in package['lvgl']['pages'] if item['id'] == 'prints_charming_page')
|
||||
numbers = [next(iter(widget.values())).get('text') for widget in charming['widgets']]
|
||||
self.assertIn(f'2 / {len(self.printers) + 1}', numbers)
|
||||
|
||||
def test_diagnostic_color_page_is_skipped_during_normal_navigation(self):
|
||||
package = generate(self.printers)
|
||||
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')
|
||||
|
||||
@@ -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
|
||||
@@ -1,121 +1,10 @@
|
||||
# Sunton ESP32-8048S043C: 4.3" 800x480 RGB LCD + GT911 capacitive touch.
|
||||
# Edit bambu-display/printers.yaml, then run its generate.py to change the fleet.
|
||||
# The second board is bambu-printer-display-2.yaml; both include bambu-display/board.yaml.
|
||||
substitutions:
|
||||
device_name: bambu-printer-display
|
||||
friendly_name: Bambu Printer Display
|
||||
|
||||
esphome:
|
||||
name: ${device_name}
|
||||
friendly_name: ${friendly_name}
|
||||
min_version: 2026.9.0
|
||||
includes:
|
||||
- bambu-display/dashboard.h
|
||||
|
||||
esp32:
|
||||
board: esp32-s3-devkitc-1
|
||||
flash_size: 16MB
|
||||
flash_mode: dio
|
||||
cpu_frequency: 240MHz
|
||||
framework:
|
||||
type: esp-idf
|
||||
sdkconfig_options:
|
||||
CONFIG_ESP32S3_DATA_CACHE_64KB: "y"
|
||||
CONFIG_ESP32S3_DATA_CACHE_LINE_64B: "y"
|
||||
CONFIG_SPIRAM_FETCH_INSTRUCTIONS: "y"
|
||||
CONFIG_SPIRAM_RODATA: "y"
|
||||
|
||||
psram:
|
||||
mode: octal
|
||||
speed: 80MHz
|
||||
|
||||
logger:
|
||||
hardware_uart: UART0 # GPIO19/20 are the touch bus, not native USB.
|
||||
|
||||
api:
|
||||
id: ha_api
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
ota:
|
||||
- platform: esphome
|
||||
encryption:
|
||||
key: !secret api_encryption_key
|
||||
|
||||
wifi:
|
||||
ssid: !secret wifi_iot_ssid
|
||||
password: !secret wifi_password
|
||||
|
||||
i2c:
|
||||
id: touch_bus
|
||||
sda: GPIO19
|
||||
scl: GPIO20
|
||||
scan: true
|
||||
|
||||
display:
|
||||
- platform: mipi_rgb
|
||||
id: panel_display
|
||||
model: RPI
|
||||
dimensions: 800x480
|
||||
color_order: RGB
|
||||
# Named RGB data_pins are byte-swapped by mipi_rgb's pin mapping.
|
||||
# Send big-endian RGB565 pixels to match that wiring transformation.
|
||||
byte_order: big_endian
|
||||
de_pin: GPIO40
|
||||
hsync_pin: GPIO39
|
||||
vsync_pin: GPIO41
|
||||
pclk_pin: GPIO42
|
||||
pclk_inverted: true
|
||||
pclk_frequency: 16MHz
|
||||
hsync_front_porch: 8
|
||||
hsync_pulse_width: 4
|
||||
hsync_back_porch: 8
|
||||
vsync_front_porch: 8
|
||||
vsync_pulse_width: 4
|
||||
vsync_back_porch: 8
|
||||
data_pins:
|
||||
red: [GPIO45, GPIO48, GPIO47, GPIO21, GPIO14]
|
||||
green: [GPIO5, GPIO6, GPIO7, GPIO15, GPIO16, GPIO4]
|
||||
blue: [GPIO8, GPIO3, GPIO46, GPIO9, GPIO1]
|
||||
auto_clear_enabled: false
|
||||
update_interval: never
|
||||
|
||||
touchscreen:
|
||||
- platform: gt911
|
||||
id: panel_touch
|
||||
display: panel_display
|
||||
i2c_id: touch_bus
|
||||
address: 0x5D
|
||||
# Polling avoids the GT911 INT pin's boot/address-selection behaviour.
|
||||
update_interval: 20ms
|
||||
|
||||
output:
|
||||
- platform: ledc
|
||||
id: backlight_output
|
||||
pin: GPIO2
|
||||
|
||||
light:
|
||||
- platform: monochromatic
|
||||
id: panel_backlight
|
||||
name: Backlight
|
||||
output: backlight_output
|
||||
restore_mode: ALWAYS_ON
|
||||
default_transition_length: 0s
|
||||
|
||||
time:
|
||||
- platform: homeassistant
|
||||
id: ha_time
|
||||
timezone: America/New_York
|
||||
|
||||
packages:
|
||||
board: !include bambu-display/board.yaml
|
||||
fleet: !include bambu-display/generated.yaml
|
||||
|
||||
lvgl:
|
||||
id: dashboard
|
||||
displays: [panel_display]
|
||||
touchscreens: [panel_touch]
|
||||
buffer_size: 12%
|
||||
color_depth: 16
|
||||
byte_order: big_endian # Must match mipi_rgb's named RGB pin mapping above.
|
||||
bg_color: 0x000000
|
||||
text_color: 0xEEEEEE
|
||||
default_font: montserrat_18
|
||||
|
||||
@@ -1,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
|
||||
Reference in New Issue
Block a user