Compare commits
10
Commits
4104e995c0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7f268888 | ||
|
|
994cfb6b60 | ||
|
|
12f8ba2def | ||
|
|
5b8a3f24c2 | ||
|
|
c5b7408b18 | ||
|
|
360e86e46a | ||
|
|
4626019a3e | ||
|
|
43f416ab69 | ||
|
|
0ec94a909e | ||
|
|
5e50c9a296 |
+16
-56
@@ -1,35 +1,7 @@
|
|||||||
- id: '1792441676327'
|
- id: '1792458749185'
|
||||||
alias: NestQuest UniFi quest access
|
alias: Declan - NestQuest Access Block
|
||||||
description: ''
|
description: Blocks network access for Declan's devices once the remaining daily
|
||||||
use_blueprint:
|
quest sensor reaches zero.
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
|
||||||
input:
|
|
||||||
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
|
|
||||||
client_switches:
|
|
||||||
- switch.default_blocked
|
|
||||||
- switch.iphone_blocked_2
|
|
||||||
- id: '1792441738406'
|
|
||||||
alias: NestQuest UniFi quest access
|
|
||||||
description: ''
|
|
||||||
use_blueprint:
|
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
|
||||||
input:
|
|
||||||
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
|
|
||||||
client_switches:
|
|
||||||
- switch.iphone_blocked
|
|
||||||
- id: '1792453243454'
|
|
||||||
alias: NestQuest UniFi quest access
|
|
||||||
description: ''
|
|
||||||
use_blueprint:
|
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
|
||||||
input:
|
|
||||||
remaining_sensor: sensor.nestquest_declan_quests_remaining_today
|
|
||||||
client_switches:
|
|
||||||
- switch.default_blocked_2
|
|
||||||
- id: '1792453338330'
|
|
||||||
alias: Declan NestQuest Access Control
|
|
||||||
description: Manages internet access for Declan by unblocking devices based on the
|
|
||||||
number of remaining daily quests.
|
|
||||||
use_blueprint:
|
use_blueprint:
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
path: Maniacs-Mansion/unifi_quest_access.yaml
|
||||||
input:
|
input:
|
||||||
@@ -37,36 +9,24 @@
|
|||||||
client_switches:
|
client_switches:
|
||||||
- switch.declan_iphone_blocked
|
- switch.declan_iphone_blocked
|
||||||
- switch.default_blocked_2
|
- switch.default_blocked_2
|
||||||
- id: '1792453462434'
|
- id: '1792458829477'
|
||||||
alias: Jordyn NestQuest Access Control
|
alias: Jordyn - NestQuest Access Block
|
||||||
description: Manages network access for Jordyn's devices based on the number of
|
description: Automatically blocks network access for Jordyn's devices once the remaining
|
||||||
remaining daily quests.
|
daily quests sensor reaches zero.
|
||||||
use_blueprint:
|
use_blueprint:
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
path: nestquest/unifi_quest_access.yaml
|
||||||
input:
|
input:
|
||||||
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
|
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
|
||||||
client_switches:
|
client_switches:
|
||||||
- switch.jordyn_iphone_blocked
|
- switch.jordyn_iphone_blocked
|
||||||
- switch.default_blocked_3
|
- switch.default_blocked_3
|
||||||
- id: '1792453576643'
|
- id: '1792458886560'
|
||||||
alias: Jordyn Quest Access Control
|
alias: Chloe - NestQuest Access Block
|
||||||
description: Manages network access for Jordyn's devices based on the number of
|
description: Automatically blocks network access for Chloe's device once the daily
|
||||||
remaining daily quests.
|
NestQuest allowance has been exhausted.
|
||||||
use_blueprint:
|
use_blueprint:
|
||||||
path: Maniacs-Mansion/unifi_quest_access.yaml
|
path: nestquest/unifi_quest_access.yaml
|
||||||
input:
|
input:
|
||||||
remaining_sensor: sensor.nestquest_jordyn_quests_remaining_today
|
remaining_sensor: sensor.nestquest_chloe_quests_remaining_today
|
||||||
client_switches:
|
client_switches:
|
||||||
- switch.jordyn_iphone_blocked
|
- switch.switch_v1_blocked
|
||||||
- switch.default_blocked_3
|
|
||||||
- id: '1792453896291'
|
|
||||||
alias: Declan NestQuest Access Control
|
|
||||||
description: Monitors Declan's remaining daily quests and automatically blocks his
|
|
||||||
iPhone and the default device once the limit is reached.
|
|
||||||
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
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -13,6 +13,37 @@
|
|||||||
"mac_address": "A0:85:E3:0F:DC:F4"
|
"mac_address": "A0:85:E3:0F:DC:F4"
|
||||||
},
|
},
|
||||||
"_firmware_jobs": [
|
"_firmware_jobs": [
|
||||||
|
{
|
||||||
|
"job_id": "b5a2e3675005",
|
||||||
|
"configuration": "bambu-printer-display-2.yaml",
|
||||||
|
"job_type": "compile",
|
||||||
|
"status": "completed",
|
||||||
|
"created_at": "2026-10-20T18:14:44.512443+00:00",
|
||||||
|
"started_at": "2026-10-20T18:14:44.512752+00:00",
|
||||||
|
"completed_at": "2026-10-20T18:15:31.213919+00:00",
|
||||||
|
"compile_started_at": "2026-10-20T18:14:50.121007+00:00",
|
||||||
|
"compile_ended_at": "2026-10-20T18:15:30.642966+00:00",
|
||||||
|
"exit_code": 0,
|
||||||
|
"error": null,
|
||||||
|
"port": "",
|
||||||
|
"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": "bb19d223ab5c",
|
"job_id": "bb19d223ab5c",
|
||||||
"configuration": "bambu-printer-display.yaml",
|
"configuration": "bambu-printer-display.yaml",
|
||||||
@@ -830,5 +861,8 @@
|
|||||||
},
|
},
|
||||||
"family-room-control.yaml": {
|
"family-room-control.yaml": {
|
||||||
"mac_address": "A4:F0:0F:68:A1:8C"
|
"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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user