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:
|
lovelace:
|
||||||
dashboards: !include dashboards/dashboards.yaml
|
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
|
script: !include scripts.yaml
|
||||||
scene: !include scenes.yaml
|
scene: !include scenes.yaml
|
||||||
input_boolean: !include helpers/input_boolean.yaml
|
input_boolean: !include helpers/input_boolean.yaml
|
||||||
|
|||||||
+110
-17
@@ -14,15 +14,15 @@
|
|||||||
},
|
},
|
||||||
"_firmware_jobs": [
|
"_firmware_jobs": [
|
||||||
{
|
{
|
||||||
"job_id": "cd93b1b34cb5",
|
"job_id": "b5a2e3675005",
|
||||||
"configuration": "resin-curing-timer.yaml",
|
"configuration": "bambu-printer-display-2.yaml",
|
||||||
"job_type": "compile",
|
"job_type": "compile",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"created_at": "2026-10-17T19:07:37.762042+00:00",
|
"created_at": "2026-10-20T18:14:44.512443+00:00",
|
||||||
"started_at": "2026-10-17T19:07:37.763132+00:00",
|
"started_at": "2026-10-20T18:14:44.512752+00:00",
|
||||||
"completed_at": "2026-10-17T19:09:40.012778+00:00",
|
"completed_at": "2026-10-20T18:15:31.213919+00:00",
|
||||||
"compile_started_at": "2026-10-17T19:07:57.149555+00:00",
|
"compile_started_at": "2026-10-20T18:14:50.121007+00:00",
|
||||||
"compile_ended_at": "2026-10-17T19:09:39.494762+00:00",
|
"compile_ended_at": "2026-10-20T18:15:30.642966+00:00",
|
||||||
"exit_code": 0,
|
"exit_code": 0,
|
||||||
"error": null,
|
"error": null,
|
||||||
"port": "",
|
"port": "",
|
||||||
@@ -31,7 +31,100 @@
|
|||||||
"new_name": "",
|
"new_name": "",
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
"dependency_released": false,
|
"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_peer": "",
|
||||||
"remote_job_id": "",
|
"remote_job_id": "",
|
||||||
"remote_peer_label": "",
|
"remote_peer_label": "",
|
||||||
@@ -736,9 +829,6 @@
|
|||||||
"declan-a1-camera.yaml": {
|
"declan-a1-camera.yaml": {
|
||||||
"mac_address": "D4:E9:F4:A3:59:E8"
|
"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": {
|
"laundry-litter-box-camera.yaml": {
|
||||||
"mac_address": "D4:E9:F4:A3:59:E8"
|
"mac_address": "D4:E9:F4:A3:59:E8"
|
||||||
},
|
},
|
||||||
@@ -760,16 +850,19 @@
|
|||||||
"panel-hardware-test.yaml": {
|
"panel-hardware-test.yaml": {
|
||||||
"mac_address": "68:B6:B3:33:69:50"
|
"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": {
|
"living-room.yaml": {
|
||||||
"mac_address": "B0:CB:D8:03:3B:6C"
|
"mac_address": "B0:CB:D8:03:3B:6C"
|
||||||
},
|
},
|
||||||
"resin-curing-timer.yaml": {
|
"resin-curing-timer.yaml": {
|
||||||
"mac_address": "B0:CB:D8:D7:B9:18"
|
"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:
|
The configured swipe order is:
|
||||||
|
|
||||||
1. PrintsCharming — P1S, Office, both four-slot AMS units.
|
1. Print Status Overview — one steam gauge per printer. The filled part of
|
||||||
2. Polly-Pocket — A1 mini, Office, external spool.
|
each ring is that printer's print progress, drawn in the active filament
|
||||||
3. Ds-Turd-Flinger — A1, Upstairs, four-slot AMS.
|
color. Black filament keeps a grey ring behind the gauge so it stays visible
|
||||||
4. Jordyns-Layer-Slayer — A1, Upstairs, four-slot AMS.
|
on the black page. The center shows the percent complete in white, with the
|
||||||
5. Constipation-Orion — Office, left/right nozzles and both four-slot AMS units.
|
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
|
AMS A uses `constipation_orion_ams_1`; AMS B retains its original
|
||||||
`h2c_31b8ap5a2200288_ams_2` entity prefix.
|
`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.
|
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.
|
elapsed time, remaining time and estimated finish.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Swipe left for the next printer or right for the previous printer. Navigation wraps
|
Swipe left for the next page or right for the previous page. Navigation wraps
|
||||||
from the last page to the first. Previous/Next buttons provide the same navigation.
|
from the last printer back to the fleet page. Previous/Next buttons provide the
|
||||||
Swipes propagate through the arc gauges and filament slots. A swipe consumes the
|
same navigation. Swipes propagate through the fleet gauges and filament
|
||||||
rest of that touch so dragging across a navigation button does not also click it.
|
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
|
FDM pages show print status, job name, progress, nozzle and bed current/target
|
||||||
temperatures, remaining time, estimated finish time, current/total layers, print
|
temperatures, remaining time, estimated finish time, current/total layers, print
|
||||||
@@ -84,17 +91,22 @@ printer rotation are outside its current scope.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Keep `esphome/bambu-printer-display.yaml` and the entire `bambu-display/` folder
|
1. Keep both device files and the entire `bambu-display/` folder together in the
|
||||||
together in the ESPHome configuration directory. The generated package is checked
|
ESPHome configuration directory. `bambu-printer-display.yaml` is the first board
|
||||||
in, so the ESPHome dashboard can compile it without running the generator first.
|
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`
|
2. Supply the existing `api_encryption_key`, `wifi_iot_ssid` and `wifi_password`
|
||||||
entries in that directory's `secrets.yaml`.
|
entries in that directory's `secrets.yaml`.
|
||||||
3. Use ESPHome **2026.9.0 or newer**. Compile and install
|
3. Use ESPHome **2026.9.0 or newer**. Compile and install the matching device file
|
||||||
`bambu-printer-display.yaml` over the board's USB-to-UART bridge for the first
|
over that board's USB-to-UART bridge for the first flash; subsequent installs can
|
||||||
flash; subsequent installs can use encrypted OTA.
|
use encrypted OTA. Flash `bambu-printer-display.yaml` to the first panel and
|
||||||
4. Add **Bambu Printer Display** through Home Assistant's ESPHome integration using
|
`bambu-printer-display-2.yaml` to the second. Each file has its own ESPHome name,
|
||||||
the same API encryption key. The display only subscribes to entities, so it does
|
so both panels can be online together.
|
||||||
not require permission to perform Home Assistant actions.
|
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
|
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;
|
physical panel. Entity IDs match the repository's `ha-entities.txt` snapshot;
|
||||||
renamed or disabled entities in live HA must be corrected/enabled there.
|
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
|
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
|
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
|
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
|
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
|
`red`/`green`/`blue` pin groups, its generated bus pin map already exchanges the
|
||||||
@@ -121,7 +133,9 @@ CLI checks from the repository root:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
esphome config esphome/bambu-printer-display.yaml
|
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.yaml
|
||||||
|
esphome compile esphome/bambu-printer-display-2.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Change or expand the fleet
|
## 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).
|
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;
|
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
|
limit depends on the board's available RAM and flash, and adding printers requires
|
||||||
regenerating and installing the firmware. Seven printers are configured; generation
|
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.
|
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.
|
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
|
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
|
`file_name`, remaining time to `remaining_print_time`, and total layers to
|
||||||
`total_layers`. The FDM profile reads targets from the HA `number` entities
|
`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
|
`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 <cstdint>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace bambu_display {
|
namespace bambu_display {
|
||||||
@@ -16,6 +17,7 @@ constexpr uint32_t MUTED = 0xAEB3B7;
|
|||||||
constexpr uint32_t RED = 0xFF737B;
|
constexpr uint32_t RED = 0xFF737B;
|
||||||
constexpr uint32_t AMBER = 0xF2C572;
|
constexpr uint32_t AMBER = 0xF2C572;
|
||||||
constexpr uint32_t TRACK = 0x19232B;
|
constexpr uint32_t TRACK = 0x19232B;
|
||||||
|
constexpr uint32_t RING_BACKING = 0x6E767E;
|
||||||
|
|
||||||
inline std::string lower(std::string text) {
|
inline std::string lower(std::string text) {
|
||||||
for (char &c : text) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
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;
|
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 {
|
struct Tray {
|
||||||
std::string label, type, color, active;
|
std::string label, type, color, active;
|
||||||
};
|
};
|
||||||
@@ -115,9 +122,9 @@ struct Snapshot {
|
|||||||
std::string hms_count, print_error_message, print_error_code;
|
std::string hms_count, print_error_message, print_error_code;
|
||||||
std::string print_error_reason, uv_unit, elapsed_unit;
|
std::string print_error_reason, uv_unit, elapsed_unit;
|
||||||
bool elegoo = false, resin = false;
|
bool elegoo = false, resin = false;
|
||||||
float uv = NAN, elapsed = NAN;
|
float uv = NAN, elapsed = NAN, total_time = NAN;
|
||||||
std::vector<HealthNotice> health_notices;
|
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;
|
std::string finish_time;
|
||||||
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
|
float progress = NAN, nozzle = NAN, nozzle_target = NAN, bed = NAN, bed_target = NAN;
|
||||||
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
|
float time_left = NAN, layer = NAN, total_layers = NAN, fan = NAN;
|
||||||
@@ -127,6 +134,36 @@ struct Snapshot {
|
|||||||
std::vector<Tray> trays;
|
std::vector<Tray> trays;
|
||||||
|
|
||||||
bool live() const { return connected && is_on(online) && valid_text(status); }
|
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 {
|
bool has_print_error() const {
|
||||||
if (!elegoo) return is_on(print_error);
|
if (!elegoo) return is_on(print_error);
|
||||||
return elegoo_fault(print_error) || elegoo_fault(print_error_reason) || lower(status) == "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)));
|
for (char &c : text) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||||
return text;
|
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 {
|
uint32_t status_color() const {
|
||||||
if (!live()) return MUTED;
|
if (!live()) return MUTED;
|
||||||
const auto state = lower(status);
|
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
|
} // namespace bambu_display
|
||||||
|
|
||||||
#ifdef USE_LVGL
|
#ifdef USE_LVGL
|
||||||
@@ -239,6 +335,9 @@ struct Widgets {
|
|||||||
GaugeWidgets uv{}, layer{};
|
GaugeWidgets uv{}, layer{};
|
||||||
lv_obj_t *elapsed = nullptr;
|
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) {
|
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());
|
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());
|
set_text(widgets.status, data.status_text());
|
||||||
lv_obj_set_style_text_color(widgets.status, lv_color_hex(color), 0);
|
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_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);
|
const float percent = data.completion();
|
||||||
render_gauge(widgets.progress, live && std::isfinite(data.progress)
|
lv_bar_set_value(widgets.top_progress, live ? gauge_value(percent, 100) : 0, LV_ANIM_OFF);
|
||||||
? std::clamp(data.progress, 0.0f, 100.0f) : NAN, live ? data.progress : NAN, 100,
|
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) : "--");
|
live ? remaining(data.time_left, data.remaining_unit) : "--");
|
||||||
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
|
set_text(widgets.task, live ? (valid_text(data.task) ? data.task : "No active print")
|
||||||
: data.connected ? "Waiting for printer data" : "Waiting for Home Assistant");
|
: 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
|
} // namespace bambu_display
|
||||||
#endif
|
#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'}
|
TEXT = {'status': 'print_status', 'task': 'task_name', 'stage': 'current_stage'}
|
||||||
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
|
BINARY = {'online': 'online', 'print_error': 'print_error', 'hms_error': 'hms_errors'}
|
||||||
ELEGOO_COMMON = {'progress': 'percent_complete', 'time_left': 'remaining_print_time',
|
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',
|
ELEGOO_FDM = {'nozzle': 'nozzle_temperature', 'bed': 'bed_temperature',
|
||||||
|
'elapsed': 'current_print_time',
|
||||||
'nozzle_target': ('number', 'target_nozzle_temp'),
|
'nozzle_target': ('number', 'target_nozzle_temp'),
|
||||||
'bed_target': ('number', 'target_bed_temp'), 'fan': 'model_fan_speed',
|
'bed_target': ('number', 'target_bed_temp'), 'fan': 'model_fan_speed',
|
||||||
'aux_fan': 'auxiliary_fan_speed', 'exhaust_fan': 'enclosure_fan_speed'}
|
'aux_fan': 'auxiliary_fan_speed', 'exhaust_fan': 'enclosure_fan_speed'}
|
||||||
ELEGOO_RESIN = {'uv': 'uv_led_temp', 'elapsed': 'current_print_time'}
|
ELEGOO_RESIN = {'uv': 'uv_led_temp', 'elapsed': 'current_print_time'}
|
||||||
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
|
BG, CARD, TRACK, TEXT_COLOR, MUTED, GREEN = (
|
||||||
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
|
0x000000, 0x000000, 0x19232B, 0xEEEEEE, 0xAEB3B7, 0x00FF00)
|
||||||
|
RING_BACKING = 0x6E767E
|
||||||
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
|
ORANGE, CYAN = 0xFFAA00, 0x00E5E5
|
||||||
|
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ def fdm_gauges(printer):
|
|||||||
return widgets
|
return widgets
|
||||||
|
|
||||||
|
|
||||||
def page_for(printer, index, total):
|
def page_for(printer, display_index, total):
|
||||||
pid = printer['id']
|
pid = printer['id']
|
||||||
resin = printer.get('profile') == 'elegoo_resin'
|
resin = printer.get('profile') == 'elegoo_resin'
|
||||||
widgets = [
|
widgets = [
|
||||||
@@ -129,7 +132,7 @@ def page_for(printer, index, total):
|
|||||||
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
|
'min_value': 0, 'max_value': 100, 'value': 0, 'bg_color': TRACK,
|
||||||
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
|
'indicator': {'bg_color': GREEN}, 'gesture_bubble': True}},
|
||||||
label(printer['name'], 16, 17, 485, 26, text_color=GREEN),
|
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,
|
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'}]),
|
clickable=True, on_click=[{'lvgl.widget.show': f'{pid}_health_panel'}]),
|
||||||
{'obj': {'x': 578, 'y': 64, 'width': 1, 'height': 362, 'bg_color': TRACK,
|
{'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}
|
'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():
|
def color_test_page():
|
||||||
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
|
widgets = [label('DISPLAY COLOR TEST', 16, 18, 768, 28),
|
||||||
label('Each block must match its label. Swipe to return to the printers.',
|
label('Each block must match its label. Swipe to return to the printers.',
|
||||||
@@ -236,6 +374,8 @@ def generate(printers):
|
|||||||
validate_printers(printers)
|
validate_printers(printers)
|
||||||
package = {'sensor': [], 'text_sensor': [], 'script': [],
|
package = {'sensor': [], 'text_sensor': [], 'script': [],
|
||||||
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
|
'interval': [{'interval': '1s', 'then': []}], 'lvgl': {'pages': []}}
|
||||||
|
snapshots = []
|
||||||
|
page_total = len(printers) + 1
|
||||||
for index, printer in enumerate(printers):
|
for index, printer in enumerate(printers):
|
||||||
pid = printer['id']
|
pid = printer['id']
|
||||||
profile = printer.get('profile', 'bambu')
|
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_message'] = (print_error_entity, 'error')
|
||||||
text_fields['print_error_code'] = (print_error_entity, 'code')
|
text_fields['print_error_code'] = (print_error_entity, 'code')
|
||||||
for key, numeric_key in [('remaining_unit', 'time_left'), ('nozzle_unit', 'nozzle'),
|
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:
|
if numeric_key in fields:
|
||||||
suffix = fields[numeric_key]
|
suffix = fields[numeric_key]
|
||||||
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
|
domain, suffix = suffix if isinstance(suffix, tuple) else ('sensor', suffix)
|
||||||
@@ -287,34 +428,11 @@ def generate(printers):
|
|||||||
subscription['attribute'] = attribute
|
subscription['attribute'] = attribute
|
||||||
package['text_sensor'].append(subscription)
|
package['text_sensor'].append(subscription)
|
||||||
expressions[key] = f'id({sid}).state'
|
expressions[key] = f'id({sid}).state'
|
||||||
lines = ['bambu_display::Snapshot data;',
|
lines = snapshot_lines(printer, expressions, trays)
|
||||||
'data.connected = id(ha_api).is_connected_with_state_subscription();']
|
snapshots.append((pid, list(lines)))
|
||||||
for key, expr in expressions.items():
|
tray_widgets = ['{' + ', '.join(f'id({pid}_tray_{i}_{part})'
|
||||||
if not key.startswith(('tray_', 'hms_1_', 'hms_2_', 'hms_3_')):
|
for part in ('card', 'swatch', 'label')) + '}'
|
||||||
lines.append(f'data.{key} = {expr};')
|
for i in range(len(trays))]
|
||||||
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')) + '}')
|
|
||||||
refs = [f'id({pid}_{key})' for key in (
|
refs = [f'id({pid}_{key})' for key in (
|
||||||
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
|
'status_label', 'task_label', 'top_progress', 'remaining_label', 'layers_label', 'stage_label',
|
||||||
'health_details')]
|
'health_details')]
|
||||||
@@ -331,7 +449,13 @@ def generate(printers):
|
|||||||
package['interval'][0]['then'].append({'if': {
|
package['interval'][0]['then'].append({'if': {
|
||||||
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
|
'condition': {'lvgl.page.is_showing': f'{pid}_page'},
|
||||||
'then': [{'script.execute': f'{pid}_refresh'}]}})
|
'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['lvgl']['pages'].append(color_test_page())
|
||||||
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
|
package['button'] = [{'platform': 'template', 'name': 'Display Color Test',
|
||||||
'entity_category': 'diagnostic',
|
'entity_category': 'diagnostic',
|
||||||
@@ -351,10 +475,10 @@ def main():
|
|||||||
if args.check:
|
if args.check:
|
||||||
if not destination.exists() or destination.read_text() != content:
|
if not destination.exists() or destination.read_text() != content:
|
||||||
raise SystemExit('generated.yaml is stale; run generate.py')
|
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:
|
else:
|
||||||
destination.write_text(content)
|
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__':
|
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,
|
demo_values = {'progress': 67, 'nozzle': 218, 'nozzle_target': 220, 'bed': 60,
|
||||||
'bed_target': 60, 'time_left': 1.75, 'layer': 134, 'total_layers': 200,
|
'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,
|
'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',
|
demo_text = {'status': 'running', 'task': 'Workshop organizer - plate 2',
|
||||||
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
|
'stage': 'printing', 'online': 'on', 'print_error': 'off', 'hms_error': 'off',
|
||||||
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
|
'hms_count': '0', 'print_error_message': '', 'print_error_code': '',
|
||||||
'remaining_unit': 'h', 'nozzle_unit': '°C', 'bed_unit': '°C',
|
'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',
|
colors = ['#60DE9AFF', '#F4C96BFF', '#74A9E8FF', '#F8F4ECFF',
|
||||||
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
|
'#DF8BBFFF', '#FA936BFF', '#8E79CAFF', '#4CC9C1FF']
|
||||||
for printer in printers:
|
for printer in printers:
|
||||||
@@ -87,7 +89,21 @@ inline void setup() {
|
|||||||
''')
|
''')
|
||||||
steps = [{'component.update': entry['id']}
|
steps = [{'component.update': entry['id']}
|
||||||
for section in ('sensor', 'text_sensor') for entry in package[section]]
|
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:
|
for printer in printers:
|
||||||
steps += [{'lvgl.page.show': printer['id'] + '_page'},
|
steps += [{'lvgl.page.show': printer['id'] + '_page'},
|
||||||
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
|
{'script.execute': printer['id'] + '_refresh'}, {'delay': '300ms'},
|
||||||
@@ -125,7 +141,7 @@ inline void setup() {
|
|||||||
{'lvgl.widget.hide': demo_printer + '_health_panel'},
|
{'lvgl.widget.hide': demo_printer + '_health_panel'},
|
||||||
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
|
{'text_sensor.template.publish': {'id': demo_printer + '_hms_error', 'state': 'off'}},
|
||||||
{'script.execute': demo_printer + '_refresh'}]
|
{'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();')}]
|
{'lambda': Lambda('bambu_preview::setup();')}]
|
||||||
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
|
for x, y, expect_hidden in [(690, 30, False), (700, 394, True)]:
|
||||||
steps += [
|
steps += [
|
||||||
@@ -136,11 +152,13 @@ inline void setup() {
|
|||||||
if len(printers) > 1:
|
if len(printers) > 1:
|
||||||
# First -> second -> first -> last -> first, then a swipe starting
|
# First -> second -> first -> last -> first, then a swipe starting
|
||||||
# on NEXT must advance exactly once even when released over PREV.
|
# on NEXT must advance exactly once even when released over PREV.
|
||||||
gestures = [(650, 180, 180, printers[1]['id']),
|
# Fleet -> first printer -> fleet -> last printer -> fleet, then a
|
||||||
(180, 650, 180, printers[0]['id']),
|
# swipe starting on NEXT advances exactly once.
|
||||||
|
gestures = [(650, 180, 180, printers[0]['id']),
|
||||||
|
(180, 650, 180, 'fleet'),
|
||||||
(180, 650, 180, printers[-1]['id']),
|
(180, 650, 180, printers[-1]['id']),
|
||||||
(650, 180, 180, printers[0]['id']),
|
(650, 180, 180, 'fleet'),
|
||||||
(730, 60, 460, printers[1]['id'])]
|
(730, 60, 460, printers[0]['id'])]
|
||||||
for start, end, y, expected in gestures:
|
for start, end, y, expected in gestures:
|
||||||
steps += [
|
steps += [
|
||||||
{'lambda': Lambda(f'bambu_preview::x = {start}; bambu_preview::y = {y}; bambu_preview::pressed = true;')},
|
{'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)
|
subprocess.run(['esphome', 'compile', str(filename)], check=True)
|
||||||
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
|
binary = root / '.esphome/build/bambu-display-preview/.pioenvs/bambu-display-preview/program'
|
||||||
subprocess.run([str(binary)], check=True, timeout=30, cwd=root)
|
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')
|
bmp = root / '.esphome/snapshots/bambu-display-preview' / (name + '.bmp')
|
||||||
with Image.open(bmp) as image:
|
with Image.open(bmp) as image:
|
||||||
image.save(args.output / (name + '.png'))
|
image.save(args.output / (name + '.png'))
|
||||||
|
|||||||
@@ -99,8 +99,77 @@ int main() {
|
|||||||
assert(data.status_color() == AMBER);
|
assert(data.status_color() == AMBER);
|
||||||
data.status = "complete";
|
data.status = "complete";
|
||||||
assert(data.status_text() == "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";
|
data.online = "off";
|
||||||
assert(!data.live());
|
assert(!data.live());
|
||||||
assert(data.status_text() == "PRINTER OFFLINE");
|
assert(data.status_text() == "PRINTER OFFLINE");
|
||||||
assert(data.health_details().find("unavailable") != std::string::npos);
|
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}')
|
extra.update(id=f'printer_{index}', entity_prefix=f'new_printer_{index}')
|
||||||
self.printers.append(extra)
|
self.printers.append(extra)
|
||||||
package = generate(self.printers)
|
package = generate(self.printers)
|
||||||
self.assertEqual(len([page for page in package['lvgl']['pages'] if not page.get('skip')]), 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)
|
self.assertEqual(len(package['script']), expected_count + 1)
|
||||||
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
|
subscriptions = {entry['id']: entry['entity_id'] for entry in package['sensor']}
|
||||||
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
|
self.assertEqual(subscriptions['printer_9_progress'], 'sensor.new_printer_9_print_progress')
|
||||||
for page in package['lvgl']['pages']:
|
for page in package['lvgl']['pages']:
|
||||||
@@ -123,7 +123,7 @@ class FleetTests(unittest.TestCase):
|
|||||||
pass
|
pass
|
||||||
for tag in ('!secret', '!include'):
|
for tag in ('!secret', '!include'):
|
||||||
Loader.add_constructor(tag, lambda loader, node: loader.construct_scalar(node))
|
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]
|
display = config['display'][0]
|
||||||
pins = display['data_pins']
|
pins = display['data_pins']
|
||||||
natural = pins['blue'] + pins['green'] + pins['red']
|
natural = pins['blue'] + pins['green'] + pins['red']
|
||||||
@@ -135,6 +135,62 @@ class FleetTests(unittest.TestCase):
|
|||||||
for i, pin in enumerate(driver))
|
for i, pin in enumerate(driver))
|
||||||
self.assertEqual(panel_word, color, f'RGB565 color {color:#06x} is wired incorrectly')
|
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):
|
def test_diagnostic_color_page_is_skipped_during_normal_navigation(self):
|
||||||
package = generate(self.printers)
|
package = generate(self.printers)
|
||||||
page = next(page for page in package['lvgl']['pages'] if page['id'] == 'color_test_page')
|
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.
|
# 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.
|
# 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:
|
substitutions:
|
||||||
device_name: bambu-printer-display
|
device_name: bambu-printer-display
|
||||||
friendly_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:
|
packages:
|
||||||
|
board: !include bambu-display/board.yaml
|
||||||
fleet: !include bambu-display/generated.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