From 6f1fa094c2c25c56d7c6c559c48da9e84b33861f Mon Sep 17 00:00:00 2001 From: esphomebot Date: Thu, 11 Sep 2025 03:41:18 +1200 Subject: [PATCH 01/21] Update webserver local assets to 20250910-110003 (#10668) --- .../components/captive_portal/captive_index.h | 174 +++++----- .../components/web_server/server_index_v2.h | 298 +++++++++--------- 2 files changed, 226 insertions(+), 246 deletions(-) diff --git a/esphome/components/captive_portal/captive_index.h b/esphome/components/captive_portal/captive_index.h index 8835762fb3..3122f27558 100644 --- a/esphome/components/captive_portal/captive_index.h +++ b/esphome/components/captive_portal/captive_index.h @@ -7,103 +7,83 @@ namespace esphome { namespace captive_portal { const uint8_t INDEX_GZ[] PROGMEM = { - 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xdd, 0x58, 0x6d, 0x6f, 0xdb, 0x38, 0x12, 0xfe, 0xde, - 0x5f, 0x31, 0xa7, 0x36, 0x6b, 0x6b, 0x1b, 0x51, 0x22, 0xe5, 0xb7, 0xd8, 0x92, 0x16, 0x69, 0xae, 0x8b, 0x5d, 0xa0, - 0xdd, 0x2d, 0x90, 0x6c, 0xef, 0x43, 0x51, 0x20, 0xb4, 0x34, 0xb2, 0xd8, 0x48, 0xa4, 0x4e, 0xa4, 0x5f, 0x52, 0xc3, - 0xf7, 0xdb, 0x0f, 0x94, 0x6c, 0xc7, 0xe9, 0x35, 0x87, 0xeb, 0xe2, 0x0e, 0x87, 0xdd, 0x18, 0x21, 0x86, 0xe4, 0xcc, - 0x70, 0xe6, 0xf1, 0x0c, 0x67, 0xcc, 0xe8, 0x2f, 0x99, 0x4a, 0xcd, 0x7d, 0x8d, 0x50, 0x98, 0xaa, 0x4c, 0x22, 0x3b, - 0x42, 0xc9, 0xe5, 0x22, 0x46, 0x99, 0x44, 0x05, 0xf2, 0x2c, 0x89, 0x2a, 0x34, 0x1c, 0xd2, 0x82, 0x37, 0x1a, 0x4d, - 0xfc, 0xdb, 0xcd, 0x8f, 0xde, 0x04, 0xfc, 0x24, 0x2a, 0x85, 0xbc, 0x83, 0x06, 0xcb, 0x58, 0xa4, 0x4a, 0x42, 0xd1, - 0x60, 0x1e, 0x67, 0xdc, 0xf0, 0xa9, 0xa8, 0xf8, 0x02, 0x2d, 0x43, 0x2b, 0x26, 0x79, 0x85, 0xf1, 0x4a, 0xe0, 0xba, - 0x56, 0x8d, 0x81, 0x54, 0x49, 0x83, 0xd2, 0xc4, 0xce, 0x5a, 0x64, 0xa6, 0x88, 0x33, 0x5c, 0x89, 0x14, 0xbd, 0x76, - 0x72, 0x2e, 0xa4, 0x30, 0x82, 0x97, 0x9e, 0x4e, 0x79, 0x89, 0x31, 0x3d, 0x5f, 0x6a, 0x6c, 0xda, 0x09, 0x9f, 0x97, - 0x18, 0x4b, 0xe5, 0xf8, 0x49, 0xa4, 0xd3, 0x46, 0xd4, 0x06, 0xac, 0xbd, 0x71, 0xa5, 0xb2, 0x65, 0x89, 0x89, 0xef, - 0x73, 0xad, 0xd1, 0x68, 0x5f, 0xc8, 0x0c, 0x37, 0x64, 0x14, 0x86, 0x29, 0xe3, 0xe3, 0x9c, 0x7c, 0xd2, 0xcf, 0x32, - 0x95, 0x2e, 0x2b, 0x94, 0x86, 0x94, 0x2a, 0xe5, 0x46, 0x28, 0x49, 0x34, 0xf2, 0x26, 0x2d, 0xe2, 0x38, 0x76, 0x7e, - 0xd0, 0x7c, 0x85, 0xce, 0x77, 0xdf, 0xf5, 0x8f, 0x4c, 0x0b, 0x34, 0xaf, 0x4b, 0xb4, 0xa4, 0x7e, 0x75, 0x7f, 0xc3, - 0x17, 0xbf, 0xf0, 0x0a, 0xfb, 0x0e, 0xd7, 0x22, 0x43, 0xc7, 0xfd, 0x10, 0x7c, 0x24, 0xda, 0xdc, 0x97, 0x48, 0x32, - 0xa1, 0xeb, 0x92, 0xdf, 0xc7, 0xce, 0xbc, 0x54, 0xe9, 0x9d, 0xe3, 0xce, 0xf2, 0xa5, 0x4c, 0xad, 0x72, 0xd0, 0x7d, - 0x74, 0xb7, 0x25, 0x1a, 0x30, 0xf1, 0x5b, 0x6e, 0x0a, 0x52, 0xf1, 0x4d, 0xbf, 0x23, 0x84, 0xec, 0xb3, 0xef, 0xfb, - 0xf8, 0x92, 0x06, 0x81, 0x7b, 0xde, 0x0e, 0x81, 0xeb, 0xd3, 0x20, 0x98, 0x35, 0x68, 0x96, 0x8d, 0x04, 0xde, 0xbf, - 0x8d, 0x6a, 0x6e, 0x0a, 0xc8, 0x62, 0xa7, 0xa2, 0x8c, 0x04, 0xc1, 0x04, 0xe8, 0x05, 0x61, 0x43, 0x8f, 0x52, 0x12, - 0x7a, 0x74, 0x98, 0x8e, 0xbd, 0x21, 0xd0, 0x81, 0x37, 0x04, 0xc6, 0xc8, 0x10, 0x82, 0xcf, 0x0e, 0xe4, 0xa2, 0x2c, - 0x63, 0x47, 0x2a, 0x89, 0x0e, 0x68, 0xd3, 0xa8, 0x3b, 0x8c, 0x9d, 0x74, 0xd9, 0x34, 0x28, 0xcd, 0x95, 0x2a, 0x55, - 0xe3, 0xf8, 0xc9, 0x33, 0x78, 0xf4, 0xf7, 0xcd, 0x47, 0x98, 0x86, 0x4b, 0x9d, 0xab, 0xa6, 0x8a, 0x9d, 0xf6, 0x4b, - 0xe9, 0xbf, 0xd8, 0x9a, 0x1d, 0xd8, 0xc1, 0x3d, 0xd9, 0xf4, 0x54, 0x23, 0x16, 0x42, 0xc6, 0x0e, 0x65, 0x40, 0x27, - 0x8e, 0x9f, 0xdc, 0xba, 0xbb, 0x23, 0x26, 0xdc, 0x62, 0xb2, 0xf7, 0x52, 0xf5, 0x3f, 0xdc, 0x46, 0x7a, 0xb5, 0x80, - 0x4d, 0x55, 0x4a, 0x1d, 0x3b, 0x85, 0x31, 0xf5, 0xd4, 0xf7, 0xd7, 0xeb, 0x35, 0x59, 0x87, 0x44, 0x35, 0x0b, 0x9f, - 0x05, 0x41, 0xe0, 0xeb, 0xd5, 0xc2, 0x81, 0x2e, 0x3e, 0x1c, 0x36, 0x70, 0xa0, 0x40, 0xb1, 0x28, 0x4c, 0x4b, 0x27, - 0x2f, 0xb6, 0xb8, 0x8b, 0x2c, 0x47, 0x72, 0xfb, 0xf1, 0xe4, 0x14, 0x71, 0x72, 0x0a, 0xfe, 0x70, 0x82, 0x66, 0xef, - 0xad, 0x35, 0x6a, 0xcc, 0x19, 0x30, 0x08, 0xda, 0x0f, 0xf3, 0x2c, 0xbd, 0x9f, 0x79, 0x5f, 0xcc, 0xe0, 0x64, 0x06, - 0x0c, 0x9e, 0x01, 0xb0, 0x6a, 0xe4, 0x5d, 0x1c, 0xc5, 0xa9, 0xdd, 0x5e, 0xd1, 0xe0, 0x61, 0xc1, 0xca, 0xfc, 0x34, - 0x3a, 0x9d, 0x7b, 0xec, 0xbd, 0x65, 0xb0, 0xd8, 0x1f, 0x85, 0x3c, 0x56, 0xd0, 0xf7, 0x23, 0x3e, 0x84, 0xe1, 0x7e, - 0x65, 0xe8, 0x59, 0xfa, 0x38, 0xb3, 0x27, 0xc1, 0x70, 0xc5, 0x0a, 0x5a, 0x79, 0x23, 0x6f, 0xc8, 0x43, 0x08, 0xf7, - 0x26, 0x85, 0x10, 0xae, 0x58, 0x31, 0x7a, 0x3f, 0x3a, 0x5d, 0xf3, 0xc2, 0xcf, 0x3d, 0x0b, 0xf3, 0xd4, 0x71, 0x1e, - 0x30, 0x50, 0xa7, 0x18, 0x90, 0x4f, 0x4a, 0xc8, 0xbe, 0xe3, 0xb8, 0xbb, 0x1c, 0x4d, 0x5a, 0xf4, 0x1d, 0x3f, 0x55, - 0x32, 0x17, 0x0b, 0xf2, 0x49, 0x2b, 0xe9, 0xb8, 0xc4, 0x14, 0x28, 0xfb, 0x07, 0x51, 0x2b, 0x88, 0xed, 0x4e, 0xff, - 0xcb, 0x1d, 0xe3, 0x6e, 0x8f, 0xf9, 0x61, 0x84, 0x29, 0x31, 0x36, 0xc4, 0x66, 0xf4, 0xf9, 0x71, 0x75, 0xae, 0xb2, - 0xfb, 0x27, 0x52, 0xa7, 0xa0, 0x5d, 0xde, 0x08, 0x29, 0xb1, 0xb9, 0xc1, 0x8d, 0x89, 0x9d, 0xb7, 0x97, 0x57, 0x70, - 0x99, 0x65, 0x0d, 0x6a, 0x3d, 0x05, 0xe7, 0xa5, 0x21, 0x15, 0x4f, 0xff, 0x73, 0x5d, 0xf4, 0x91, 0xae, 0xbf, 0x89, - 0x1f, 0x05, 0xfc, 0x82, 0x66, 0xad, 0x9a, 0xbb, 0xbd, 0x36, 0x6b, 0xda, 0xcc, 0x66, 0x60, 0x13, 0x1b, 0xc2, 0x6b, - 0x4d, 0x74, 0x29, 0x52, 0xec, 0x53, 0x97, 0x54, 0xbc, 0x7e, 0xf0, 0x4a, 0x1e, 0x80, 0xba, 0x8d, 0x32, 0xb1, 0x82, - 0xb4, 0xe4, 0x5a, 0xc7, 0x8e, 0xec, 0x54, 0x39, 0xb0, 0x4f, 0x1b, 0x25, 0xd3, 0x52, 0xa4, 0x77, 0xb1, 0xf3, 0x95, - 0x1b, 0xe2, 0xd5, 0xfd, 0xcf, 0x59, 0xbf, 0xa7, 0xb5, 0xc8, 0x7a, 0x2e, 0x59, 0xf1, 0x72, 0x89, 0x10, 0x83, 0x29, - 0x84, 0x7e, 0x30, 0x70, 0xf6, 0xa4, 0x58, 0xad, 0xef, 0x7a, 0x2e, 0xc9, 0x55, 0xba, 0xd4, 0x7d, 0xd7, 0x39, 0x64, - 0x69, 0xc4, 0xbb, 0x3b, 0xd4, 0x79, 0xee, 0x7c, 0x61, 0x91, 0x57, 0x62, 0x6e, 0x9c, 0x87, 0x6c, 0x7e, 0xb1, 0xd5, - 0x7d, 0x49, 0x1a, 0xad, 0x85, 0xbb, 0x3b, 0x2e, 0x46, 0xba, 0xe6, 0xf2, 0x4b, 0x41, 0x6b, 0xa0, 0x4d, 0x1a, 0x49, - 0x2c, 0x65, 0x33, 0xa7, 0xe6, 0xf2, 0x78, 0xa0, 0xcf, 0x0f, 0xe4, 0x8b, 0xad, 0xe8, 0x4b, 0x7b, 0x4b, 0xde, 0x1d, - 0x35, 0x46, 0x7e, 0x26, 0x56, 0xc9, 0xed, 0xce, 0x7d, 0xf0, 0xe3, 0xef, 0x4b, 0x6c, 0xee, 0xaf, 0xb1, 0xc4, 0xd4, - 0xa8, 0xa6, 0xef, 0x3c, 0x97, 0x68, 0x1c, 0xb7, 0x73, 0xf8, 0xa7, 0x9b, 0xb7, 0x6f, 0x62, 0xd5, 0x6f, 0xdc, 0xf3, - 0xa7, 0xb8, 0x6d, 0xb5, 0xf8, 0xd0, 0x60, 0xf9, 0x8f, 0xb8, 0x67, 0xeb, 0x45, 0xef, 0xa3, 0xe3, 0x92, 0xd6, 0xdf, - 0xdb, 0x87, 0xa2, 0x61, 0x13, 0xfb, 0xe5, 0xa6, 0x2a, 0xcf, 0xad, 0x87, 0xde, 0x68, 0xe8, 0xee, 0x6e, 0x77, 0xee, - 0xce, 0x9d, 0x45, 0x7e, 0x77, 0xef, 0x27, 0x51, 0x7b, 0x05, 0x27, 0xdf, 0x6f, 0xe7, 0x6a, 0xe3, 0x69, 0xf1, 0x59, - 0xc8, 0xc5, 0x54, 0xc8, 0x02, 0x1b, 0x61, 0x76, 0x99, 0x58, 0x9d, 0x0b, 0x59, 0x2f, 0xcd, 0xb6, 0xe6, 0x59, 0x66, - 0x77, 0x86, 0xf5, 0x66, 0x96, 0x2b, 0x69, 0x2c, 0x27, 0x4e, 0x29, 0x56, 0xbb, 0x6e, 0xbf, 0xbd, 0x5b, 0xa6, 0x17, - 0xc3, 0xb3, 0x9d, 0x0d, 0xb8, 0xad, 0xc1, 0x8d, 0xf1, 0x78, 0x29, 0x16, 0x72, 0x9a, 0xa2, 0x34, 0xd8, 0x74, 0x42, - 0x39, 0xaf, 0x44, 0x79, 0x3f, 0xd5, 0x5c, 0x6a, 0x4f, 0x63, 0x23, 0xf2, 0xdd, 0x7c, 0x69, 0x8c, 0x92, 0xdb, 0xb9, - 0x6a, 0x32, 0x6c, 0xa6, 0xc1, 0xac, 0x23, 0xbc, 0x86, 0x67, 0x62, 0xa9, 0xa7, 0x24, 0x6c, 0xb0, 0x9a, 0xcd, 0x79, - 0x7a, 0xb7, 0x68, 0xd4, 0x52, 0x66, 0x5e, 0x6a, 0x6f, 0xe1, 0xe9, 0x73, 0x9a, 0xf3, 0x10, 0xd3, 0xd9, 0x7e, 0x96, - 0xe7, 0xf9, 0xac, 0x14, 0x12, 0xbd, 0xee, 0x56, 0x9b, 0x32, 0x32, 0xb0, 0x62, 0x27, 0x66, 0x12, 0x66, 0x17, 0x3a, - 0x1b, 0x69, 0x10, 0x9c, 0xcd, 0x0e, 0xee, 0x04, 0xb3, 0x74, 0xd9, 0x68, 0xd5, 0x4c, 0x6b, 0x25, 0xac, 0x99, 0xbb, - 0x8a, 0x0b, 0x79, 0x6a, 0xbd, 0x0d, 0x93, 0xd9, 0xbe, 0x3c, 0x4d, 0x85, 0x6c, 0x8f, 0x69, 0x8b, 0xd4, 0xac, 0x12, - 0xb2, 0x2b, 0xb2, 0x53, 0x36, 0x0a, 0xea, 0xcd, 0x8e, 0xec, 0x03, 0x64, 0x7b, 0xe0, 0xce, 0x4b, 0xdc, 0xcc, 0x3e, - 0x2d, 0xb5, 0x11, 0xf9, 0xbd, 0xb7, 0x2f, 0xd2, 0x53, 0x5d, 0xf3, 0x14, 0xbd, 0x39, 0x9a, 0x35, 0xa2, 0x9c, 0xb5, - 0x67, 0x78, 0xc2, 0x60, 0xa5, 0xf7, 0x38, 0x1d, 0xd5, 0xb4, 0x01, 0xfa, 0x58, 0xd7, 0xbf, 0xe3, 0xb6, 0xb1, 0xb8, - 0xad, 0x78, 0xb3, 0x10, 0xd2, 0x9b, 0x2b, 0x63, 0x54, 0x35, 0xf5, 0xc6, 0xf5, 0x66, 0xb6, 0x5f, 0xb2, 0xca, 0xa6, - 0xd4, 0x9a, 0xd9, 0xd6, 0xde, 0x03, 0xde, 0xb4, 0xde, 0x80, 0x56, 0xa5, 0xc8, 0xf6, 0x7c, 0x2d, 0x0b, 0x04, 0x47, - 0x78, 0xe8, 0xb0, 0xde, 0x80, 0x5d, 0x3b, 0x40, 0x3d, 0xc8, 0x27, 0x9c, 0x06, 0x5f, 0xf9, 0x46, 0xb2, 0x3c, 0x67, - 0xf3, 0xfc, 0x88, 0x94, 0x2d, 0xa1, 0x3b, 0xb1, 0x8f, 0x0a, 0x36, 0xa8, 0x37, 0xb3, 0xc3, 0x77, 0x33, 0xa8, 0x37, - 0x3b, 0xd1, 0xa6, 0xc5, 0xf6, 0x44, 0x4b, 0x1b, 0xaa, 0xd3, 0x65, 0x53, 0xf6, 0x9d, 0xaf, 0x84, 0xee, 0x59, 0x78, - 0xf5, 0x50, 0xe2, 0x7a, 0x4f, 0x97, 0xb8, 0x1e, 0xd8, 0xa6, 0xe8, 0x95, 0xda, 0xc4, 0xbd, 0xb6, 0xd8, 0x0c, 0x80, - 0x0d, 0x7a, 0x67, 0xe1, 0xeb, 0xb3, 0xf0, 0xea, 0xbf, 0x52, 0xbb, 0x7e, 0x77, 0xe1, 0xfa, 0x86, 0xaa, 0xf5, 0x8d, - 0x15, 0xab, 0xf3, 0xce, 0x3a, 0x7f, 0x16, 0xbe, 0x76, 0xdc, 0x9d, 0x20, 0x5a, 0x2c, 0xe8, 0xff, 0x02, 0xda, 0x7f, - 0xc5, 0x31, 0xbc, 0xa4, 0x13, 0x72, 0x01, 0xed, 0xd0, 0x41, 0x44, 0xc2, 0x09, 0x8c, 0xaf, 0x06, 0x64, 0x40, 0xc1, - 0xb6, 0x43, 0x23, 0x18, 0x93, 0xc9, 0x05, 0xd0, 0x11, 0x09, 0xc7, 0x40, 0x19, 0x30, 0x4a, 0x86, 0x6f, 0x58, 0x48, - 0x46, 0x43, 0x18, 0x5f, 0xb1, 0x80, 0x84, 0x0c, 0x3a, 0xde, 0x11, 0x61, 0x0c, 0x42, 0xcb, 0x12, 0x56, 0x01, 0xb0, - 0x34, 0x24, 0xc1, 0x18, 0x02, 0x18, 0x91, 0xe0, 0x82, 0x4c, 0x46, 0x30, 0x21, 0x63, 0x0a, 0x8c, 0x0c, 0x86, 0xa5, - 0x37, 0x24, 0x14, 0x46, 0x24, 0x1c, 0xf1, 0x09, 0x19, 0x84, 0xd0, 0x0e, 0x1d, 0x1c, 0x63, 0xc2, 0x98, 0x47, 0x02, - 0xfa, 0x26, 0x24, 0x6c, 0x0c, 0x63, 0x32, 0x18, 0x5c, 0xd2, 0x11, 0xb9, 0x18, 0x40, 0x37, 0x76, 0xf0, 0x52, 0x06, - 0xc3, 0xa7, 0x40, 0x63, 0x7f, 0x5e, 0xd0, 0x42, 0xc2, 0x28, 0x84, 0xe4, 0x62, 0xc2, 0x6d, 0x5f, 0xca, 0xa0, 0x1b, - 0x3b, 0xdc, 0x28, 0x85, 0xe0, 0x77, 0x63, 0x16, 0xfe, 0x79, 0x31, 0xa3, 0x16, 0x01, 0x46, 0x06, 0xe1, 0x25, 0x0d, - 0xc9, 0x08, 0xda, 0xa1, 0x3b, 0x9b, 0x32, 0x98, 0x5c, 0x5d, 0xc0, 0x04, 0x46, 0x64, 0x34, 0x81, 0x0b, 0x18, 0x5a, - 0x74, 0x2f, 0xc8, 0x64, 0xd0, 0x09, 0x79, 0x8c, 0x7c, 0x2b, 0x8c, 0x83, 0x3f, 0x30, 0x8c, 0x4f, 0xf9, 0xf4, 0x07, - 0x76, 0xe9, 0xff, 0x71, 0x05, 0x45, 0x7e, 0xd7, 0x86, 0x45, 0x7e, 0xf7, 0x3c, 0x60, 0xbb, 0xa8, 0x24, 0xb2, 0xdd, - 0x48, 0x12, 0x15, 0x14, 0x44, 0x16, 0x57, 0x3c, 0x4d, 0x4e, 0x5a, 0xfd, 0xc8, 0x2f, 0xe8, 0x61, 0xab, 0xa0, 0xc9, - 0xa3, 0xc6, 0xbd, 0xdb, 0x6b, 0x2b, 0x7d, 0x72, 0x53, 0x20, 0xbc, 0xbe, 0x7e, 0x07, 0x6b, 0x51, 0x96, 0x20, 0xd5, - 0x1a, 0x4c, 0x73, 0x0f, 0x46, 0xd9, 0x57, 0x03, 0x89, 0xa9, 0xb1, 0xa4, 0x29, 0x10, 0xf6, 0x7d, 0x04, 0x21, 0x24, - 0x9a, 0x37, 0xc9, 0xbb, 0x12, 0xb9, 0x46, 0x58, 0x88, 0x15, 0x82, 0x30, 0xa0, 0x55, 0x85, 0x60, 0x84, 0x1d, 0x8e, - 0x82, 0x2d, 0x5f, 0xe4, 0x77, 0x87, 0x74, 0x8d, 0xb2, 0xc8, 0x62, 0x89, 0x26, 0xd9, 0x77, 0xc4, 0x51, 0x11, 0x76, - 0x56, 0x5d, 0xa3, 0x31, 0x42, 0x2e, 0xac, 0x55, 0x61, 0x12, 0xd9, 0x5f, 0xb7, 0xc0, 0xdb, 0xdf, 0x0c, 0xb1, 0xbf, - 0x16, 0xb9, 0xb0, 0x6f, 0x06, 0x49, 0xd4, 0x76, 0x91, 0x56, 0x83, 0x6d, 0x64, 0xba, 0x07, 0x8e, 0x96, 0x2a, 0x51, - 0x2e, 0x4c, 0x11, 0x87, 0x0c, 0xea, 0x92, 0xa7, 0x58, 0xa8, 0x32, 0xc3, 0x26, 0xbe, 0xbe, 0xfe, 0xf9, 0xaf, 0xf6, - 0x35, 0xc4, 0x9a, 0x70, 0x94, 0xac, 0xf5, 0x5d, 0x27, 0x68, 0x89, 0xbd, 0xdc, 0x68, 0xd0, 0xbd, 0x6b, 0xd4, 0x5c, - 0xeb, 0xb5, 0x6a, 0xb2, 0x47, 0x5a, 0xde, 0x1d, 0x16, 0xf7, 0x9a, 0xda, 0xff, 0xb6, 0x1f, 0xed, 0x84, 0xf4, 0x72, - 0x5e, 0x09, 0x93, 0x5c, 0xf3, 0x15, 0x46, 0x7e, 0xb7, 0x91, 0x44, 0xbe, 0x75, 0xa0, 0xe3, 0x2d, 0xf6, 0x32, 0x05, - 0x4d, 0x7e, 0xbd, 0xb9, 0x84, 0xdf, 0xea, 0x8c, 0x1b, 0xec, 0xb0, 0x6f, 0xbd, 0xac, 0xd0, 0x14, 0x2a, 0x8b, 0xdf, - 0xfd, 0x7a, 0x7d, 0x73, 0xf4, 0x78, 0xd9, 0x32, 0x01, 0xca, 0xb4, 0x7b, 0x6f, 0x59, 0x96, 0x46, 0xd4, 0xbc, 0x31, - 0xad, 0x5a, 0xcf, 0x66, 0xc7, 0xc1, 0xa3, 0x76, 0x3f, 0x17, 0x25, 0x76, 0x4e, 0xed, 0x05, 0xfd, 0x04, 0xbe, 0x66, - 0xe3, 0xe1, 0xec, 0x2f, 0xac, 0xf4, 0xbb, 0x00, 0xf2, 0xbb, 0x68, 0xf2, 0xdb, 0xd7, 0xa8, 0x7f, 0x02, 0x14, 0xee, - 0xbc, 0x64, 0x9d, 0x12, 0x00, 0x00}; + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x95, 0x16, 0x6b, 0x8f, 0xdb, 0x36, 0xf2, 0x7b, 0x7e, + 0x05, 0x8f, 0x49, 0xbb, 0x52, 0xb3, 0x7a, 0x7a, 0xed, 0x6c, 0x24, 0x51, 0x45, 0x9a, 0xbb, 0xa2, 0x05, 0x9a, 0x36, + 0xc0, 0x6e, 0x73, 0x1f, 0x82, 0x00, 0x4b, 0x53, 0x23, 0x8b, 0x31, 0x45, 0xea, 0x48, 0xca, 0x8f, 0x18, 0xbe, 0xdf, + 0x7e, 0xa0, 0x24, 0x7b, 0x9d, 0x45, 0x73, 0xb8, 0xb3, 0x60, 0x61, 0x38, 0xef, 0x19, 0xcd, 0x83, 0xc5, 0xdf, 0x2a, + 0xc5, 0xec, 0xbe, 0x03, 0xd4, 0xd8, 0x56, 0x94, 0x85, 0x7b, 0x23, 0x41, 0xe5, 0x8a, 0x80, 0x2c, 0x8b, 0x06, 0x68, + 0x55, 0x16, 0x2d, 0x58, 0x8a, 0x58, 0x43, 0xb5, 0x01, 0x4b, 0xfe, 0xbc, 0xff, 0x39, 0xb8, 0x2d, 0x0b, 0xc1, 0xe5, + 0x1a, 0x69, 0x10, 0x84, 0x33, 0x25, 0x51, 0xa3, 0xa1, 0x26, 0x15, 0xb5, 0x34, 0xe3, 0x2d, 0x5d, 0xc1, 0x24, 0x22, + 0x69, 0x0b, 0x64, 0xc3, 0x61, 0xdb, 0x29, 0x6d, 0x11, 0x53, 0xd2, 0x82, 0xb4, 0x04, 0x6f, 0x79, 0x65, 0x1b, 0x52, + 0xc1, 0x86, 0x33, 0x08, 0x86, 0xc3, 0x35, 0x97, 0xdc, 0x72, 0x2a, 0x02, 0xc3, 0xa8, 0x00, 0x92, 0x5c, 0xf7, 0x06, + 0xf4, 0x70, 0xa0, 0x4b, 0x01, 0x44, 0x2a, 0x5c, 0x16, 0x86, 0x69, 0xde, 0x59, 0xe4, 0x5c, 0x25, 0xad, 0xaa, 0x7a, + 0x01, 0x65, 0x14, 0x51, 0x63, 0xc0, 0x9a, 0x88, 0xcb, 0x0a, 0x76, 0xe1, 0x32, 0x66, 0x2c, 0x86, 0xdb, 0xdb, 0xf0, + 0xb3, 0x79, 0x56, 0x29, 0xd6, 0xb7, 0x20, 0x6d, 0x28, 0x14, 0xa3, 0x96, 0x2b, 0x19, 0x1a, 0xa0, 0x9a, 0x35, 0x84, + 0x10, 0xfc, 0xa3, 0xa1, 0x1b, 0xc0, 0xdf, 0x7f, 0xef, 0x9d, 0x99, 0x56, 0x60, 0xff, 0x21, 0xc0, 0x81, 0xe6, 0xa7, + 0xfd, 0x3d, 0x5d, 0xfd, 0x4e, 0x5b, 0xf0, 0x30, 0x35, 0xbc, 0x02, 0xec, 0x7f, 0x8c, 0x3f, 0x85, 0xc6, 0xee, 0x05, + 0x84, 0x15, 0x37, 0x9d, 0xa0, 0x7b, 0x82, 0x97, 0x42, 0xb1, 0x35, 0xf6, 0xf3, 0xba, 0x97, 0xcc, 0x29, 0x47, 0xc6, + 0x03, 0xff, 0x20, 0xc0, 0x22, 0x4b, 0xde, 0x51, 0xdb, 0x84, 0x2d, 0xdd, 0x79, 0x23, 0xc0, 0xa5, 0x97, 0xfe, 0xe0, + 0xc1, 0xcb, 0x24, 0x8e, 0xfd, 0xeb, 0xe1, 0x15, 0xfb, 0x51, 0x12, 0xc7, 0xb9, 0x06, 0xdb, 0x6b, 0x89, 0xa8, 0xf7, + 0x50, 0x74, 0xd4, 0x36, 0xa8, 0x22, 0xf8, 0x5d, 0x92, 0xa2, 0xe4, 0x75, 0x98, 0xce, 0x7f, 0x0b, 0x5f, 0xa1, 0x9b, + 0x30, 0x9d, 0xb3, 0x57, 0xc1, 0x1c, 0x25, 0x37, 0xc1, 0x1c, 0xa5, 0x69, 0x38, 0x47, 0xf1, 0x17, 0x8c, 0x6a, 0x2e, + 0x04, 0xc1, 0x52, 0x49, 0xc0, 0xc8, 0x58, 0xad, 0xd6, 0x40, 0x30, 0xeb, 0xb5, 0x06, 0x69, 0xdf, 0x2a, 0xa1, 0x34, + 0x8e, 0xca, 0x67, 0xff, 0x97, 0x42, 0xab, 0xa9, 0x34, 0xb5, 0xd2, 0x2d, 0xc1, 0x43, 0xf6, 0xbd, 0x17, 0x07, 0x7b, + 0x44, 0xee, 0xe5, 0x5f, 0x10, 0x03, 0xa5, 0xf9, 0x8a, 0x4b, 0x82, 0x9d, 0xc6, 0x5b, 0x1c, 0x95, 0x0f, 0xfe, 0xf1, + 0x1c, 0x3d, 0x75, 0xd1, 0x4f, 0xf1, 0x28, 0xef, 0xe3, 0x43, 0x61, 0x36, 0x2b, 0xb4, 0x6b, 0x85, 0x34, 0x04, 0x37, + 0xd6, 0x76, 0x59, 0x14, 0x6d, 0xb7, 0xdb, 0x70, 0x3b, 0x0b, 0x95, 0x5e, 0x45, 0x69, 0x1c, 0xc7, 0x91, 0xd9, 0xac, + 0x30, 0x1a, 0x0b, 0x01, 0xa7, 0x37, 0x18, 0x35, 0xc0, 0x57, 0x8d, 0x1d, 0xe0, 0xf2, 0xc5, 0x01, 0x8e, 0x85, 0xe3, + 0x28, 0x1f, 0x3e, 0x5d, 0x58, 0xe1, 0x17, 0x56, 0xe0, 0x47, 0xea, 0xe1, 0x53, 0x98, 0x57, 0x43, 0x98, 0xaf, 0x68, + 0x8a, 0x52, 0x14, 0x0f, 0x4f, 0x1a, 0x38, 0x78, 0x3a, 0x05, 0x4f, 0x4e, 0xe8, 0xe2, 0xe4, 0xa0, 0x76, 0x11, 0xbc, + 0x3e, 0xcb, 0x26, 0x0e, 0xb3, 0x49, 0xe2, 0x47, 0x84, 0x13, 0xf8, 0x65, 0x71, 0x79, 0x0e, 0xd2, 0x0f, 0x97, 0x0c, + 0xce, 0x5a, 0x93, 0x7c, 0x58, 0xd0, 0x39, 0x9a, 0x4f, 0x98, 0x79, 0xe0, 0xe0, 0xf3, 0x09, 0xcd, 0x37, 0x69, 0x93, + 0xb4, 0xc1, 0x22, 0x98, 0xd3, 0x19, 0x9a, 0x4d, 0x8e, 0xcc, 0xd0, 0x6c, 0x93, 0x36, 0x8b, 0x0f, 0x8b, 0x4b, 0x5c, + 0x30, 0xfb, 0x72, 0x15, 0x95, 0xd8, 0xcf, 0x30, 0x7e, 0x8c, 0x5c, 0x5d, 0x46, 0x1e, 0x7e, 0x56, 0x5c, 0x7a, 0x18, + 0xfb, 0xc7, 0x1a, 0x2c, 0x6b, 0x3c, 0x1c, 0x31, 0x25, 0x6b, 0xbe, 0x0a, 0x3f, 0x1b, 0x25, 0xb1, 0x1f, 0xda, 0x06, + 0xa4, 0x77, 0x12, 0x75, 0x82, 0x30, 0x50, 0xbc, 0xa7, 0x14, 0xeb, 0x1f, 0xce, 0xf5, 0x6f, 0xb9, 0x15, 0x40, 0x6c, + 0xe8, 0x1a, 0xf6, 0xfa, 0x8c, 0x5d, 0xaa, 0x6a, 0xff, 0x8d, 0xd6, 0x68, 0x92, 0xb1, 0x2f, 0xb8, 0x94, 0xa0, 0xef, + 0x61, 0x67, 0x09, 0x7e, 0xf7, 0xe6, 0x2d, 0x7a, 0x53, 0x55, 0x1a, 0x8c, 0xc9, 0x10, 0x7e, 0x69, 0xc3, 0x96, 0xb2, + 0xff, 0x5d, 0x57, 0xf2, 0x95, 0xae, 0x7f, 0xf2, 0x9f, 0x39, 0xfa, 0x1d, 0xec, 0x56, 0xe9, 0xf5, 0xa4, 0xcd, 0xb9, + 0x96, 0xbb, 0x0e, 0xd3, 0xc4, 0x86, 0xb4, 0x33, 0xa1, 0x11, 0x9c, 0x81, 0x97, 0xf8, 0x61, 0x4b, 0xbb, 0xc7, 0xa8, + 0xe4, 0x29, 0x51, 0x0f, 0x45, 0xc5, 0x37, 0x88, 0x09, 0x6a, 0x0c, 0xc1, 0x72, 0x54, 0x85, 0xd1, 0x33, 0x34, 0xfc, + 0x94, 0x64, 0x82, 0xb3, 0x35, 0xc1, 0x7f, 0x31, 0x01, 0x7e, 0xda, 0xff, 0x5a, 0x79, 0x57, 0xc6, 0xf0, 0xea, 0xca, + 0x0f, 0x37, 0x54, 0xf4, 0x80, 0x08, 0xb2, 0x0d, 0x37, 0x8f, 0x0e, 0xe6, 0xdf, 0x14, 0xeb, 0xcc, 0xfa, 0xca, 0x0f, + 0x6b, 0xc5, 0x7a, 0xe3, 0xf9, 0xb8, 0x9c, 0xcc, 0x15, 0x74, 0x1c, 0x90, 0xf8, 0x39, 0x7e, 0xe2, 0x51, 0x20, 0xa0, + 0xb6, 0x67, 0x3e, 0x84, 0x5e, 0x1c, 0x8c, 0x27, 0x43, 0x6d, 0x0c, 0xf7, 0x8f, 0x67, 0x64, 0x61, 0x3a, 0x2a, 0x9f, + 0x0a, 0x3a, 0x07, 0x5d, 0xab, 0xc8, 0xd0, 0x41, 0xae, 0x5f, 0x3a, 0x2a, 0xcf, 0x06, 0x23, 0x7a, 0x02, 0x5f, 0x1c, + 0xb8, 0x27, 0xdd, 0x14, 0x5c, 0x9f, 0x35, 0x16, 0x51, 0xc5, 0x37, 0xe5, 0xc3, 0xd1, 0x7f, 0x8c, 0xe3, 0x5f, 0x3d, + 0xe8, 0xfd, 0x1d, 0x08, 0x60, 0x56, 0x69, 0x0f, 0x3f, 0x97, 0x60, 0xb1, 0x3f, 0x06, 0xfc, 0xcb, 0xfd, 0xbb, 0xdf, + 0x88, 0xf2, 0xb4, 0x7f, 0xfd, 0x2d, 0x6e, 0xb7, 0x0a, 0x3e, 0x6a, 0x10, 0xff, 0x26, 0x57, 0x6e, 0x19, 0x5c, 0x7d, + 0xc2, 0x7e, 0x38, 0xc4, 0xfb, 0xf0, 0xb8, 0x11, 0x5c, 0x3b, 0xbf, 0xdc, 0xb5, 0xe2, 0xda, 0x45, 0x18, 0x2c, 0xe6, + 0xfe, 0xf1, 0xe1, 0xe8, 0x1f, 0xfd, 0xbc, 0x88, 0xc6, 0xb9, 0x5e, 0x16, 0xc3, 0x88, 0x2d, 0x7f, 0x38, 0x2c, 0xd5, + 0x2e, 0x30, 0xfc, 0x0b, 0x97, 0xab, 0x8c, 0xcb, 0x06, 0x34, 0xb7, 0xc7, 0x8a, 0x6f, 0xae, 0xb9, 0xec, 0x7a, 0x7b, + 0xe8, 0x68, 0x55, 0x39, 0xca, 0xbc, 0xdb, 0xe5, 0xb5, 0x92, 0xd6, 0x71, 0x42, 0x96, 0x40, 0x7b, 0x1c, 0xe9, 0xc3, + 0x44, 0xc9, 0x5e, 0xcf, 0xbf, 0x3b, 0xba, 0x82, 0x3b, 0x58, 0xd8, 0xd9, 0x80, 0x0a, 0xbe, 0x92, 0x19, 0x03, 0x69, + 0x41, 0x8f, 0x42, 0x35, 0x6d, 0xb9, 0xd8, 0x67, 0x86, 0x4a, 0x13, 0x18, 0xd0, 0xbc, 0x3e, 0x2e, 0x7b, 0x6b, 0x95, + 0x3c, 0x2c, 0x95, 0xae, 0x40, 0x67, 0x71, 0x3e, 0x02, 0x81, 0xa6, 0x15, 0xef, 0x4d, 0x16, 0xce, 0x34, 0xb4, 0xf9, + 0x92, 0xb2, 0xf5, 0x4a, 0xab, 0x5e, 0x56, 0x01, 0x73, 0x93, 0x36, 0x7b, 0x9e, 0xd4, 0x74, 0x06, 0x2c, 0x9f, 0x4e, + 0x75, 0x5d, 0xe7, 0x82, 0x4b, 0x08, 0xc6, 0x59, 0x96, 0xa5, 0xe1, 0x8d, 0x13, 0xbb, 0x70, 0x33, 0x4c, 0x1d, 0x62, + 0xf4, 0x31, 0x89, 0xe3, 0xef, 0xf2, 0x53, 0x38, 0x71, 0xce, 0x7a, 0x6d, 0x94, 0xce, 0x3a, 0xc5, 0x9d, 0x9b, 0xc7, + 0x96, 0x72, 0x79, 0xe9, 0xbd, 0x2b, 0x93, 0x7c, 0x5a, 0x3f, 0x19, 0x97, 0x83, 0x99, 0x61, 0x09, 0xe5, 0x2d, 0x97, + 0xe3, 0x0e, 0xcd, 0xd2, 0x45, 0xdc, 0xed, 0x8e, 0xe1, 0x54, 0x20, 0x87, 0x13, 0x77, 0x2d, 0x60, 0x97, 0x7f, 0xee, + 0x8d, 0xe5, 0xf5, 0x3e, 0x98, 0x76, 0x70, 0x66, 0x3a, 0xca, 0x20, 0x58, 0x82, 0xdd, 0x02, 0xc8, 0x7c, 0xb0, 0x11, + 0x70, 0x0b, 0xad, 0x99, 0xf2, 0x74, 0x56, 0x33, 0x14, 0xe8, 0xd7, 0xba, 0xfe, 0x1b, 0xb7, 0xab, 0xc5, 0x43, 0x4b, + 0xf5, 0x8a, 0xcb, 0x60, 0xa9, 0xac, 0x55, 0x6d, 0x16, 0xbc, 0xea, 0x76, 0xf9, 0x84, 0x72, 0xca, 0xb2, 0xc4, 0xb9, + 0x39, 0xec, 0xd6, 0x53, 0xbe, 0x93, 0x6e, 0x87, 0x8c, 0x12, 0xbc, 0x9a, 0xf8, 0x06, 0x16, 0x14, 0x9f, 0xd3, 0x93, + 0xcc, 0xbb, 0x1d, 0x72, 0xb8, 0x53, 0xaa, 0x6f, 0xea, 0x5b, 0x9a, 0xc4, 0x7f, 0xf1, 0x45, 0xaa, 0xba, 0x4e, 0x97, + 0xf5, 0x39, 0x53, 0x6e, 0x4d, 0xba, 0xd6, 0x18, 0x4a, 0xab, 0x88, 0xc6, 0xdb, 0x8c, 0xab, 0x8c, 0xb2, 0x70, 0x19, + 0x2e, 0x8b, 0x26, 0x41, 0xbc, 0x22, 0x2d, 0x65, 0xe5, 0xc5, 0xf8, 0x2a, 0xa2, 0x26, 0x39, 0x91, 0x9a, 0xa4, 0xfc, + 0x6a, 0x18, 0x8d, 0xb4, 0xc1, 0xfb, 0xf2, 0xad, 0x92, 0x12, 0x98, 0xe5, 0x72, 0x85, 0xac, 0x42, 0x53, 0x0a, 0xc2, + 0x30, 0x2c, 0x96, 0xba, 0x7c, 0x2f, 0x80, 0x1a, 0x40, 0x5b, 0xca, 0x6d, 0x58, 0x44, 0x23, 0xff, 0xd8, 0xc7, 0xbc, + 0x22, 0x12, 0x6c, 0x39, 0x35, 0x6c, 0xd1, 0xcc, 0x46, 0x03, 0x77, 0x60, 0x9d, 0x26, 0x67, 0x60, 0x56, 0x16, 0x6e, + 0xe5, 0x22, 0x3a, 0x8c, 0x34, 0x12, 0x6d, 0x79, 0xcd, 0xdd, 0x95, 0xa5, 0x2c, 0x86, 0x22, 0x77, 0x1a, 0x5c, 0x9e, + 0xc7, 0xeb, 0xd5, 0x00, 0x09, 0x90, 0x2b, 0xdb, 0x90, 0x59, 0x8a, 0x3a, 0x41, 0x19, 0x34, 0x4a, 0x54, 0xa0, 0xc9, + 0xdd, 0xdd, 0xaf, 0x7f, 0x2f, 0x9d, 0x33, 0x8f, 0x72, 0x9d, 0x59, 0x8f, 0x62, 0x0e, 0x98, 0xa4, 0x16, 0x37, 0xe3, + 0xa5, 0xaa, 0xa3, 0xc6, 0x6c, 0x95, 0xae, 0xbe, 0xd2, 0xf1, 0x7e, 0x42, 0x8e, 0x7a, 0x86, 0xff, 0xd0, 0x2a, 0xe5, + 0x1d, 0xdd, 0x40, 0x11, 0x4d, 0x87, 0x22, 0x72, 0x0e, 0x8f, 0xf4, 0x66, 0xe2, 0x6b, 0x92, 0xf2, 0x8f, 0xfb, 0x37, + 0xe8, 0xcf, 0xae, 0xa2, 0x16, 0xc6, 0xb4, 0x0d, 0x51, 0xb5, 0x60, 0x1b, 0x55, 0x91, 0xf7, 0x7f, 0xdc, 0xdd, 0x9f, + 0x23, 0xec, 0x07, 0x26, 0x04, 0x92, 0x8d, 0xd7, 0xbb, 0x5e, 0x58, 0xde, 0x51, 0x6d, 0x07, 0xb5, 0x81, 0x9b, 0x22, + 0xa7, 0x18, 0x06, 0x7a, 0xcd, 0x05, 0x8c, 0x61, 0x8c, 0x82, 0x25, 0x3a, 0x79, 0x75, 0xb2, 0xf6, 0xc4, 0xaf, 0x68, + 0xfc, 0xda, 0xd1, 0xf8, 0xe9, 0xa3, 0xe1, 0xa6, 0xfb, 0x1f, 0x53, 0x58, 0x46, 0xb2, 0xf9, 0x0a, 0x00, 0x00}; } // namespace captive_portal } // namespace esphome diff --git a/esphome/components/web_server/server_index_v2.h b/esphome/components/web_server/server_index_v2.h index ec093d3186..e675d81552 100644 --- a/esphome/components/web_server/server_index_v2.h +++ b/esphome/components/web_server/server_index_v2.h @@ -494,155 +494,155 @@ const uint8_t INDEX_GZ[] PROGMEM = { 0x1c, 0x40, 0xc8, 0x12, 0x7c, 0xa6, 0xc1, 0x29, 0x21, 0xa4, 0xd5, 0x9f, 0x05, 0x5f, 0xe2, 0x9b, 0x98, 0xa6, 0xc1, 0xbc, 0xe8, 0x96, 0x04, 0xa0, 0x22, 0xa6, 0x6f, 0x45, 0x79, 0x6f, 0x9c, 0xa4, 0x8a, 0xea, 0xb5, 0x82, 0xb3, 0x59, 0x52, 0xcf, 0x96, 0x58, 0x9a, 0xe5, 0x93, 0x19, 0x25, 0xfc, 0xa6, 0x79, 0xeb, 0xf6, 0x36, 0xc7, 0xd7, 0x60, 0x76, - 0x65, 0x7c, 0x4d, 0x02, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, 0x3d, - 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, 0xf5, - 0x7a, 0x74, 0x4d, 0x0e, 0x5f, 0x2f, 0x12, 0xe1, 0xb8, 0x2a, 0x38, 0x99, 0x66, 0x54, 0x96, 0x2a, 0x34, 0x16, 0x27, - 0xfb, 0x50, 0xa0, 0x5e, 0x6f, 0x89, 0xa2, 0x19, 0x07, 0xca, 0xf6, 0x58, 0x9a, 0x63, 0xa2, 0x68, 0x76, 0xa2, 0x52, - 0x99, 0xa5, 0xb4, 0x1e, 0xbb, 0xf9, 0xbc, 0x3d, 0x84, 0x3f, 0x3a, 0x32, 0xf4, 0xf9, 0x68, 0x34, 0xba, 0x37, 0xaa, - 0xf6, 0x79, 0x34, 0xa2, 0x1d, 0x7a, 0xd4, 0x85, 0x24, 0x96, 0xa6, 0x8e, 0xc5, 0xb4, 0x0b, 0x89, 0xbb, 0xc5, 0xc3, - 0x2a, 0x43, 0xd8, 0x46, 0xc4, 0x8b, 0x87, 0x47, 0xd8, 0x8a, 0x69, 0x46, 0x17, 0x93, 0x30, 0x1b, 0xb3, 0x34, 0x68, - 0x15, 0xfe, 0x5c, 0x87, 0xa4, 0x3e, 0x3f, 0x3e, 0x3e, 0x2e, 0xfc, 0xc8, 0x3c, 0xb5, 0xa2, 0xa8, 0xf0, 0x87, 0x8b, - 0x72, 0x1a, 0xad, 0xd6, 0x68, 0x54, 0xf8, 0xcc, 0x14, 0x1c, 0x74, 0x86, 0xd1, 0x41, 0xa7, 0xf0, 0x6f, 0xac, 0x1a, - 0x85, 0x4f, 0xf5, 0x53, 0x46, 0xa3, 0x5a, 0x26, 0xcc, 0xe3, 0x56, 0xab, 0xf0, 0x15, 0xa1, 0x2d, 0xc0, 0x2c, 0x55, - 0x3f, 0x83, 0x70, 0x26, 0x38, 0x30, 0xf7, 0x6e, 0x22, 0xbc, 0xc1, 0xa5, 0xbe, 0x65, 0x44, 0x7d, 0x93, 0xa3, 0x40, - 0x17, 0xf8, 0x67, 0x3b, 0x78, 0x04, 0xc4, 0x2c, 0x83, 0x46, 0x89, 0x89, 0x2d, 0xd5, 0x5e, 0x03, 0x65, 0xc9, 0xd7, - 0x3f, 0x93, 0xa4, 0x8a, 0x29, 0x01, 0x27, 0x83, 0x9a, 0xea, 0x32, 0x3c, 0x4a, 0xb7, 0xc8, 0x0f, 0xf6, 0x69, 0xf9, - 0x71, 0xf7, 0x10, 0xf1, 0xc1, 0xfe, 0x70, 0xf1, 0x41, 0xa9, 0x25, 0x3e, 0x14, 0xf3, 0xb8, 0x13, 0xc4, 0x1d, 0xc6, - 0x74, 0xf8, 0xf1, 0x9a, 0xdf, 0x36, 0x61, 0x4b, 0x64, 0xae, 0x14, 0x2c, 0xbb, 0xbf, 0x35, 0x6b, 0xc6, 0x74, 0x66, - 0x7d, 0xd1, 0x43, 0xaa, 0x0f, 0x6f, 0x52, 0xe2, 0xbe, 0x31, 0xb6, 0xad, 0x2a, 0x19, 0x8d, 0x88, 0xfb, 0x66, 0x34, - 0x72, 0xcd, 0x59, 0xc9, 0x50, 0x50, 0x59, 0xeb, 0x75, 0xad, 0x44, 0xd6, 0xfa, 0xf2, 0x4b, 0xbb, 0xcc, 0x2e, 0xd0, - 0xa1, 0x27, 0x3b, 0xcc, 0xa4, 0xdf, 0x44, 0x2c, 0x87, 0xad, 0x06, 0x1f, 0x1a, 0xa9, 0xdf, 0xd5, 0x98, 0xd6, 0xae, - 0xd5, 0x2e, 0x01, 0xde, 0x70, 0x17, 0xf8, 0xea, 0x45, 0x01, 0x63, 0x6a, 0xf2, 0x16, 0x9f, 0xde, 0x7d, 0x15, 0x79, - 0x77, 0x02, 0x15, 0x2c, 0x7f, 0x93, 0xae, 0x1c, 0x02, 0x52, 0x30, 0x12, 0x62, 0x4f, 0xab, 0x10, 0x7c, 0x3c, 0x4e, - 0xe0, 0x5b, 0x2f, 0x8b, 0xda, 0xfd, 0xb1, 0xaa, 0x79, 0xbf, 0x36, 0xdf, 0xc0, 0x6e, 0xa8, 0x6f, 0x5b, 0x95, 0x9f, - 0x9e, 0x52, 0xc9, 0xe3, 0x73, 0xfd, 0x0d, 0x22, 0x69, 0x16, 0x2f, 0x34, 0x93, 0x5f, 0xa8, 0x94, 0x63, 0x01, 0xe9, - 0x36, 0xaa, 0xe3, 0xa8, 0x28, 0xf4, 0x61, 0x8d, 0x88, 0xe5, 0x53, 0xb8, 0xd7, 0x54, 0xb5, 0xa4, 0x9f, 0x62, 0xe1, - 0xf9, 0x8d, 0x15, 0xdf, 0xa9, 0x2d, 0x57, 0x61, 0x02, 0x3c, 0xca, 0x61, 0x7e, 0x27, 0x0a, 0x57, 0xfb, 0xdd, 0x0d, - 0x12, 0x5d, 0x47, 0xe1, 0x53, 0x45, 0x9e, 0xac, 0x19, 0x82, 0xf3, 0xbb, 0x5c, 0x10, 0xf3, 0xca, 0x14, 0x14, 0x76, - 0xfc, 0x52, 0xbe, 0x51, 0xd8, 0x92, 0xd1, 0x92, 0x7c, 0x1a, 0xa6, 0x8a, 0x8d, 0x12, 0x57, 0xf1, 0x83, 0xdd, 0x45, - 0xb5, 0xf2, 0x85, 0x6b, 0xc0, 0x56, 0xc4, 0xdb, 0x3b, 0xd9, 0x87, 0x06, 0x3d, 0xa7, 0x06, 0x7a, 0xba, 0x16, 0x64, - 0xf9, 0x44, 0xba, 0xc3, 0x95, 0x9f, 0xdf, 0x60, 0x3f, 0xbf, 0x71, 0xfe, 0xbc, 0x68, 0xde, 0xd0, 0xeb, 0x8f, 0x4c, - 0x34, 0x45, 0x38, 0x6d, 0x82, 0xe1, 0x23, 0x9d, 0xa3, 0x9a, 0x3d, 0xcb, 0x2c, 0x3f, 0x75, 0xd5, 0x41, 0x77, 0x96, - 0x43, 0x56, 0x84, 0x54, 0xdf, 0x83, 0x94, 0xa7, 0xb4, 0x5b, 0xcf, 0xe6, 0xb4, 0x83, 0xec, 0x06, 0x5b, 0x17, 0x0b, - 0x0e, 0x59, 0x14, 0xe2, 0x2e, 0x68, 0x69, 0xb6, 0xde, 0x32, 0x11, 0xf4, 0xd6, 0xc6, 0xfa, 0x81, 0x46, 0x6e, 0x43, - 0x4a, 0xaf, 0x6c, 0x3d, 0x93, 0x60, 0x5b, 0x26, 0xc0, 0xa7, 0x72, 0x1b, 0xc1, 0xa5, 0x6a, 0xfe, 0x5a, 0x49, 0xa1, - 0xab, 0xc5, 0x32, 0xb7, 0xf1, 0x21, 0x90, 0x05, 0xe1, 0x48, 0xd0, 0x0c, 0x3f, 0xa4, 0xe6, 0xb5, 0x3c, 0x86, 0xb4, - 0x00, 0x31, 0x13, 0xb4, 0x8f, 0xa7, 0xb7, 0x0f, 0xef, 0xfe, 0xfe, 0xe9, 0x17, 0x1a, 0x47, 0xe6, 0x5a, 0x1e, 0xd7, - 0xed, 0xc2, 0x46, 0x48, 0xc2, 0xbb, 0x80, 0xa5, 0x52, 0xe6, 0x5d, 0x83, 0x5f, 0xb4, 0x3b, 0xe5, 0x3a, 0x49, 0x37, - 0xa3, 0x89, 0xfc, 0x0a, 0x9f, 0x5e, 0x8a, 0x83, 0x47, 0xd3, 0x5b, 0xb3, 0x1a, 0xed, 0x95, 0xe4, 0xdb, 0x3f, 0x34, - 0xc7, 0x76, 0x7b, 0x52, 0x6f, 0x3d, 0x4f, 0xf4, 0x68, 0x7a, 0xdb, 0x55, 0x82, 0xb6, 0x99, 0x29, 0xa8, 0x5a, 0xd3, - 0x5b, 0x3b, 0xcb, 0xb8, 0xea, 0xc8, 0xf1, 0x0f, 0x72, 0x87, 0x86, 0x39, 0xed, 0xc2, 0xbd, 0xe3, 0x6c, 0x18, 0x26, - 0x5a, 0x98, 0x4f, 0x58, 0x14, 0x25, 0xb4, 0x6b, 0xe4, 0xb5, 0xd3, 0x7e, 0x04, 0x49, 0xba, 0xf6, 0x92, 0xd5, 0x57, - 0xc5, 0x42, 0x5e, 0x89, 0xa7, 0xf0, 0x3a, 0xe7, 0x09, 0x7c, 0xf4, 0x63, 0x23, 0x3a, 0x75, 0xf6, 0x6a, 0xab, 0x42, - 0x9e, 0xfc, 0x5d, 0x9f, 0xcb, 0x51, 0xeb, 0x4f, 0x5d, 0xb9, 0xe0, 0xad, 0xae, 0xe0, 0xd3, 0xa0, 0x79, 0x50, 0x9f, - 0x08, 0xbc, 0x2a, 0xa7, 0x80, 0x37, 0x4c, 0x0b, 0x83, 0xb4, 0x52, 0x7c, 0xda, 0xf1, 0xdb, 0xba, 0x4c, 0x76, 0x00, - 0x79, 0x61, 0x65, 0x51, 0x51, 0x9f, 0xcc, 0xbf, 0xcd, 0x6e, 0x79, 0xb2, 0x79, 0xb7, 0x3c, 0x31, 0xbb, 0xe5, 0x7e, - 0x8a, 0xfd, 0x7c, 0xd4, 0x86, 0x3f, 0xdd, 0x6a, 0x42, 0x41, 0xcb, 0x39, 0x98, 0xde, 0x3a, 0xa0, 0xa7, 0x35, 0x3b, - 0xd3, 0x5b, 0x95, 0x63, 0x0d, 0xb1, 0x9b, 0x16, 0x64, 0x1d, 0xe3, 0x96, 0x03, 0x85, 0xf0, 0xb7, 0x55, 0x7b, 0xd5, - 0x3e, 0x84, 0x77, 0xd0, 0xea, 0x68, 0xfd, 0x5d, 0xe7, 0xfe, 0x4d, 0x1b, 0xa4, 0x5c, 0x78, 0x81, 0xe1, 0xc6, 0xc8, - 0x17, 0xe1, 0xf5, 0x35, 0x8d, 0x82, 0x11, 0x1f, 0xce, 0xf2, 0x7f, 0xd2, 0xf0, 0x6b, 0x24, 0xde, 0xbb, 0xa5, 0x57, - 0xfa, 0x31, 0x4d, 0x55, 0xc6, 0xb7, 0xe9, 0x61, 0x51, 0xae, 0x53, 0x90, 0x0f, 0xc3, 0x84, 0x7a, 0x1d, 0xff, 0x70, - 0xc3, 0x26, 0xf8, 0x77, 0x59, 0x9b, 0x8d, 0x93, 0xf9, 0xbd, 0xc8, 0xb8, 0x17, 0x09, 0xbf, 0x0a, 0x07, 0xf6, 0x1a, - 0xb6, 0x8e, 0x37, 0x83, 0x3b, 0x30, 0x23, 0x5d, 0x18, 0xa1, 0xa0, 0xe5, 0x4e, 0x44, 0x47, 0xe1, 0x2c, 0x11, 0xf7, - 0xf7, 0xba, 0x8d, 0x32, 0xd6, 0x7a, 0xbd, 0x87, 0xa1, 0x57, 0x75, 0x1f, 0xc8, 0xa5, 0x3f, 0x7f, 0x72, 0x08, 0x7f, - 0x54, 0xfe, 0xd7, 0x5d, 0xa5, 0xab, 0x2b, 0xbb, 0x17, 0x74, 0xf5, 0xdd, 0x9a, 0x32, 0xae, 0x44, 0xb8, 0xd4, 0xc7, - 0x1f, 0x5a, 0x1b, 0xb4, 0xca, 0x07, 0x55, 0xd7, 0x5a, 0xd6, 0xaf, 0xaa, 0xfd, 0xeb, 0x3a, 0x7f, 0x60, 0xdd, 0xa1, - 0xd2, 0x5c, 0xeb, 0x75, 0xf5, 0x67, 0x08, 0xd7, 0x2a, 0x1b, 0x8c, 0xcb, 0xfa, 0xbb, 0xe4, 0xae, 0x34, 0x51, 0x54, - 0x34, 0x16, 0xac, 0x94, 0x5d, 0x65, 0xa5, 0xe4, 0x94, 0x5c, 0x9d, 0xf4, 0x6f, 0x27, 0x89, 0x33, 0x57, 0xc7, 0x25, - 0x89, 0xdb, 0xf6, 0x5b, 0xae, 0x23, 0xf3, 0x00, 0xe0, 0xd6, 0x76, 0x57, 0x7e, 0xde, 0xd6, 0xed, 0x83, 0xa6, 0x35, - 0x1f, 0x4b, 0xcd, 0xee, 0x65, 0x78, 0x47, 0xb3, 0xcb, 0x8e, 0xeb, 0x80, 0x9f, 0xa6, 0xa9, 0x52, 0x26, 0x64, 0x99, - 0xd3, 0x71, 0x9d, 0xdb, 0x49, 0x92, 0xe6, 0xc4, 0x8d, 0x85, 0x98, 0x06, 0xea, 0xfb, 0xb7, 0x37, 0x07, 0x3e, 0xcf, - 0xc6, 0xfb, 0x9d, 0x56, 0xab, 0x05, 0x17, 0xc0, 0xba, 0xce, 0x9c, 0xd1, 0x9b, 0xa7, 0xfc, 0x96, 0xb8, 0x2d, 0xa7, - 0xe5, 0xb4, 0x3b, 0xc7, 0x4e, 0xbb, 0x73, 0xe8, 0x3f, 0x3a, 0x76, 0x7b, 0x9f, 0x39, 0xce, 0x49, 0x44, 0x47, 0x39, - 0xfc, 0x70, 0x9c, 0x13, 0xa9, 0x78, 0xa9, 0xdf, 0x8e, 0xe3, 0x0f, 0x93, 0xbc, 0xd9, 0x76, 0x16, 0xfa, 0xd1, 0x71, - 0xe0, 0x50, 0x69, 0xe0, 0x7c, 0x3e, 0xea, 0x8c, 0x0e, 0x47, 0x4f, 0xba, 0xba, 0xb8, 0xf8, 0xac, 0x56, 0x1d, 0xab, - 0xff, 0x3b, 0x56, 0xb3, 0x5c, 0x64, 0xfc, 0x23, 0xd5, 0x39, 0x89, 0x0e, 0x88, 0x9e, 0x8d, 0x4d, 0x3b, 0xeb, 0x23, - 0xb5, 0x8f, 0xaf, 0x87, 0xa3, 0x4e, 0x55, 0x5d, 0xc2, 0xb8, 0x5f, 0x02, 0x79, 0xb2, 0x6f, 0x40, 0x3f, 0xb1, 0xd1, - 0xd4, 0x6e, 0x6e, 0x42, 0x54, 0xdb, 0xd5, 0x73, 0x1c, 0x9b, 0xf9, 0x9d, 0xc0, 0x19, 0x06, 0xa3, 0xab, 0x4a, 0x08, - 0x5c, 0x27, 0x22, 0xee, 0xab, 0x76, 0xe7, 0x18, 0xb7, 0xdb, 0x8f, 0xfc, 0x47, 0xc7, 0xc3, 0x16, 0x3e, 0xf4, 0x0f, - 0x9b, 0x07, 0xfe, 0x23, 0x7c, 0xdc, 0x3c, 0xc6, 0xc7, 0x2f, 0x8e, 0x87, 0xcd, 0x43, 0xff, 0x10, 0xb7, 0x9a, 0xc7, - 0x50, 0xd8, 0x3c, 0x6e, 0x1e, 0xcf, 0x9b, 0x87, 0xc7, 0xc3, 0x96, 0x2c, 0xed, 0xf8, 0x47, 0x47, 0xcd, 0x76, 0xcb, - 0x3f, 0x3a, 0xc2, 0x47, 0xfe, 0xa3, 0x47, 0xcd, 0xf6, 0x81, 0xff, 0xe8, 0xd1, 0xcb, 0xa3, 0x63, 0xff, 0x00, 0xde, - 0x1d, 0x1c, 0x0c, 0x0f, 0xfc, 0x76, 0xbb, 0x09, 0xff, 0xe0, 0x63, 0xbf, 0xa3, 0x7e, 0xb4, 0xdb, 0xfe, 0x41, 0x1b, - 0xb7, 0x92, 0xa3, 0x8e, 0xff, 0xe8, 0x09, 0x96, 0xff, 0xca, 0x6a, 0x58, 0xfe, 0x03, 0xdd, 0xe0, 0x27, 0x7e, 0xe7, - 0x91, 0xfa, 0x25, 0x3b, 0x9c, 0x1f, 0x1e, 0xff, 0xe0, 0xee, 0x6f, 0x9d, 0x43, 0x5b, 0xcd, 0xe1, 0xf8, 0xc8, 0x3f, - 0x38, 0xc0, 0x87, 0x6d, 0xff, 0xf8, 0x20, 0x6e, 0x1e, 0x76, 0xfc, 0x47, 0x8f, 0x87, 0xcd, 0xb6, 0xff, 0xf8, 0x31, - 0x6e, 0x35, 0x0f, 0xfc, 0x0e, 0x6e, 0xfb, 0x87, 0x07, 0xf2, 0xc7, 0x81, 0xdf, 0x99, 0x3f, 0x7e, 0xe2, 0x3f, 0x3a, - 0x8a, 0x1f, 0xf9, 0x87, 0xdf, 0x1e, 0x1e, 0xfb, 0x9d, 0x83, 0xf8, 0xe0, 0x91, 0xdf, 0x79, 0x3c, 0x7f, 0xe4, 0x1f, - 0xc6, 0xcd, 0xce, 0xa3, 0x7b, 0x5b, 0xb6, 0x3b, 0x3e, 0xe0, 0x48, 0xbe, 0x86, 0x17, 0x58, 0xbf, 0x80, 0xbf, 0xb1, - 0x6c, 0xfb, 0xef, 0xd8, 0x4d, 0xbe, 0xde, 0xf4, 0x89, 0x7f, 0xfc, 0x78, 0xa8, 0xaa, 0x43, 0x41, 0xd3, 0xd4, 0x80, - 0x26, 0xf3, 0xa6, 0x1a, 0x56, 0x76, 0xd7, 0x34, 0x1d, 0x99, 0xbf, 0x7a, 0xb0, 0x79, 0x13, 0x06, 0x56, 0xe3, 0xfe, - 0x87, 0xf6, 0x53, 0x2e, 0xf9, 0xc9, 0xfe, 0x58, 0x91, 0xfe, 0xb8, 0xf7, 0x99, 0xba, 0xdd, 0xf9, 0xb3, 0x2b, 0x9c, - 0x6e, 0x73, 0x7c, 0x64, 0x9f, 0x76, 0x7c, 0x70, 0xfa, 0x10, 0xcf, 0x47, 0xf6, 0x87, 0x7b, 0x3e, 0x52, 0xba, 0xe2, - 0x38, 0xbf, 0x16, 0x6b, 0x0e, 0x8e, 0x55, 0xab, 0xf8, 0xa9, 0xf0, 0x06, 0x39, 0x7c, 0x47, 0xac, 0xe8, 0x5e, 0x0b, - 0xc2, 0xa9, 0xed, 0x07, 0xe2, 0xc0, 0x62, 0xaf, 0x85, 0xe2, 0xb1, 0xc9, 0x36, 0x84, 0x84, 0x9f, 0x46, 0xc8, 0xb7, - 0x0f, 0xc1, 0x47, 0xf8, 0x87, 0xe3, 0x23, 0xb1, 0xf1, 0x51, 0xf3, 0xe5, 0x4b, 0x4f, 0x83, 0xf4, 0x14, 0x9c, 0xcb, - 0x67, 0x0f, 0x0e, 0x51, 0x35, 0xdc, 0x7d, 0x0a, 0x45, 0xb9, 0xab, 0x22, 0x5f, 0xef, 0x7e, 0x4d, 0xd8, 0x41, 0x9d, - 0x98, 0x24, 0xae, 0x76, 0xcb, 0x4c, 0xa5, 0xd4, 0xd1, 0x0f, 0xa5, 0x50, 0xea, 0xf8, 0x2d, 0xbf, 0x55, 0xba, 0x74, - 0xe0, 0x94, 0x2c, 0x59, 0x70, 0x11, 0xc2, 0x17, 0x6b, 0x13, 0x3e, 0x96, 0xdf, 0xb6, 0x85, 0xaf, 0x09, 0x40, 0xd2, - 0xcf, 0x50, 0x7d, 0xc8, 0x21, 0x70, 0x5d, 0x7d, 0xb7, 0x06, 0x9c, 0xc2, 0xfc, 0x06, 0x4e, 0xaa, 0x9a, 0xa8, 0xc4, - 0x04, 0xbc, 0x1d, 0xaf, 0x68, 0xc4, 0x42, 0xcf, 0xf5, 0xa6, 0x19, 0x1d, 0xd1, 0x2c, 0x6f, 0xd6, 0x8e, 0x6f, 0xca, - 0x93, 0x9b, 0xc8, 0x35, 0x9f, 0x46, 0xcd, 0xe0, 0x76, 0x6c, 0x32, 0xd0, 0xfe, 0x46, 0x57, 0x1b, 0x60, 0x6e, 0x81, - 0x4d, 0x49, 0x06, 0xb2, 0xb6, 0x52, 0xda, 0x5c, 0xa5, 0xb5, 0xb5, 0xfd, 0xce, 0x11, 0x72, 0x64, 0x31, 0xdc, 0x3b, - 0xfc, 0xbd, 0xd7, 0x3c, 0x68, 0xfd, 0x09, 0x59, 0xcd, 0xca, 0x8e, 0x2e, 0xb4, 0xbb, 0x2d, 0xad, 0xbe, 0x29, 0x5d, - 0x3f, 0x5b, 0xeb, 0x2a, 0x8a, 0xf8, 0x5c, 0xcd, 0xdd, 0x45, 0xdd, 0x54, 0x47, 0xb8, 0xd5, 0x0d, 0x11, 0x23, 0x36, - 0xf6, 0xec, 0x2f, 0x06, 0xab, 0x7b, 0x8d, 0xe5, 0x87, 0xc6, 0x51, 0x51, 0x55, 0x49, 0xd1, 0x42, 0xc6, 0x5b, 0x58, - 0xea, 0xa4, 0xcb, 0xa5, 0x97, 0x82, 0x8b, 0x9c, 0x58, 0x38, 0x85, 0x67, 0x54, 0x43, 0x72, 0x8a, 0x4b, 0x80, 0x24, - 0x82, 0x49, 0xaa, 0xfe, 0xaf, 0x8a, 0xcd, 0x0f, 0xed, 0xf8, 0xf2, 0x93, 0x30, 0x1d, 0x03, 0x15, 0x86, 0xe9, 0x78, - 0xcd, 0xad, 0xa6, 0x42, 0x46, 0x2b, 0xa5, 0x55, 0x57, 0x95, 0xfb, 0x2c, 0x7f, 0x7a, 0xf7, 0x5e, 0x5f, 0x80, 0xe6, - 0x82, 0x77, 0x5a, 0x46, 0x38, 0xaa, 0xcb, 0x9a, 0x1b, 0xe4, 0x8b, 0x93, 0x09, 0x15, 0xa1, 0xca, 0xd7, 0x04, 0x7d, - 0x02, 0x4e, 0xcd, 0x3a, 0xda, 0x1a, 0x25, 0xae, 0x94, 0xee, 0x24, 0xa2, 0x73, 0x36, 0xd4, 0xa2, 0x1e, 0x3b, 0xfa, - 0xe6, 0x80, 0xa6, 0x5c, 0x1a, 0xd2, 0xc6, 0xca, 0x1f, 0x33, 0x0c, 0x65, 0x46, 0x3e, 0x49, 0xb9, 0xdb, 0xfb, 0xa2, - 0xfc, 0xfa, 0xe9, 0xb6, 0x45, 0x48, 0x58, 0xfa, 0x71, 0x90, 0xd1, 0xe4, 0x9f, 0xc8, 0x17, 0x6c, 0xc8, 0xd3, 0x2f, - 0x2e, 0xe0, 0xab, 0xf4, 0x7e, 0x9c, 0xd1, 0x11, 0xf9, 0x02, 0x64, 0x7c, 0x20, 0xad, 0x0f, 0x60, 0x84, 0x8d, 0xdb, - 0x49, 0x82, 0xa5, 0xc6, 0xf4, 0x00, 0x85, 0x48, 0x81, 0xeb, 0x76, 0x8e, 0x5c, 0x47, 0xd9, 0xc4, 0xf2, 0x77, 0x4f, - 0x89, 0x53, 0xa9, 0x04, 0x38, 0xed, 0x8e, 0x7f, 0x14, 0x77, 0xfc, 0x27, 0xf3, 0xc7, 0xfe, 0x71, 0xdc, 0x7e, 0x3c, - 0x6f, 0xc2, 0xff, 0x1d, 0xff, 0x49, 0xd2, 0xec, 0xf8, 0x4f, 0xe0, 0xef, 0xb7, 0x87, 0xfe, 0x51, 0xdc, 0x6c, 0xfb, - 0xc7, 0xf3, 0x03, 0xff, 0xe0, 0x65, 0xbb, 0xe3, 0x1f, 0x38, 0x6d, 0x47, 0xb5, 0x03, 0x76, 0xad, 0xb8, 0xf3, 0x17, - 0x2b, 0x1b, 0x62, 0x43, 0x38, 0x4e, 0xe5, 0x9c, 0xba, 0xd8, 0x2b, 0xbf, 0xb1, 0xa8, 0xf7, 0xa7, 0x76, 0xd6, 0x3d, - 0x0b, 0x33, 0xf8, 0xd0, 0x4d, 0x7d, 0xef, 0xd6, 0xde, 0xe1, 0x1a, 0xbf, 0xd8, 0x30, 0x04, 0xec, 0x70, 0x17, 0xdb, - 0x47, 0xef, 0xe1, 0xdc, 0xba, 0xbc, 0x17, 0xdc, 0x5c, 0x8f, 0xb8, 0x9d, 0xb4, 0x55, 0x45, 0x73, 0x05, 0xa3, 0x64, - 0x16, 0x4c, 0x7e, 0x81, 0x41, 0x0e, 0xf2, 0x55, 0x54, 0xac, 0x8e, 0x0f, 0xa9, 0xaf, 0x19, 0xb7, 0x6e, 0x1f, 0xa0, - 0xd5, 0x81, 0x8d, 0x88, 0xc1, 0x7d, 0x11, 0x45, 0x61, 0x40, 0xaf, 0xb9, 0x69, 0x2b, 0x2c, 0x49, 0x7e, 0x41, 0xf3, - 0xbe, 0x0b, 0x45, 0x6e, 0xe0, 0x4a, 0x17, 0x9f, 0x5b, 0x7e, 0xec, 0xa7, 0x24, 0xec, 0xaa, 0x00, 0xcb, 0x43, 0x57, - 0xb0, 0x6b, 0x01, 0x3f, 0x2e, 0xda, 0xdb, 0xdb, 0xba, 0x5f, 0xa4, 0x02, 0x09, 0x73, 0xad, 0xbe, 0x11, 0x62, 0xb3, - 0x22, 0xd7, 0x46, 0x74, 0xd9, 0xaf, 0x44, 0x21, 0xd2, 0x78, 0xba, 0xa6, 0xa1, 0xf0, 0xc3, 0x54, 0x25, 0xd1, 0x58, - 0x0c, 0x0b, 0xb7, 0xe9, 0x01, 0x2a, 0xb8, 0x08, 0xad, 0xef, 0x00, 0xeb, 0x7d, 0xce, 0x45, 0x68, 0xce, 0xd2, 0x5a, - 0xd7, 0x06, 0x81, 0xa3, 0x37, 0xee, 0xf4, 0xde, 0xbc, 0x3f, 0x75, 0xd4, 0xf6, 0x3c, 0xd9, 0x8f, 0x3b, 0xbd, 0x13, - 0xe9, 0x33, 0x51, 0x27, 0xf1, 0x88, 0x3a, 0x89, 0xe7, 0xe8, 0x53, 0x99, 0x10, 0x49, 0x2b, 0xf6, 0xd5, 0xb4, 0xa5, - 0xcd, 0xa0, 0xbc, 0xbd, 0x93, 0x59, 0x22, 0x18, 0xdc, 0x71, 0xbd, 0x2f, 0x8f, 0xe1, 0xc1, 0x82, 0x95, 0x79, 0xd8, - 0x5a, 0x3b, 0xbc, 0x16, 0xa9, 0xf1, 0x0d, 0x8f, 0x58, 0x42, 0x4d, 0xe6, 0xb5, 0xee, 0xaa, 0x3c, 0x29, 0xb0, 0x5e, - 0x3b, 0x9f, 0x5d, 0x4f, 0x98, 0x70, 0xcd, 0x79, 0x86, 0x0f, 0xba, 0xc1, 0x89, 0x1c, 0xaa, 0x77, 0x55, 0x68, 0xe7, - 0xb5, 0xf9, 0x9a, 0x4f, 0x7d, 0x49, 0xf5, 0xec, 0xb5, 0x84, 0x80, 0x13, 0x72, 0xf1, 0x41, 0xaf, 0x74, 0x17, 0xdb, - 0xef, 0x8a, 0x93, 0xfd, 0xf8, 0xa0, 0x77, 0x15, 0x4c, 0x75, 0x7f, 0x2f, 0xf9, 0x78, 0x73, 0x5f, 0x09, 0x1f, 0xf7, - 0xe5, 0x51, 0x10, 0x75, 0x48, 0xd9, 0x28, 0xbf, 0x3c, 0x71, 0x7b, 0x27, 0x5a, 0x19, 0x70, 0x64, 0x60, 0xdd, 0x3d, - 0x6a, 0x99, 0xd3, 0x25, 0x09, 0x1f, 0xc3, 0x86, 0x54, 0x4d, 0xac, 0x41, 0x6a, 0x1e, 0xf7, 0xb8, 0xdd, 0x3b, 0x09, - 0x1d, 0xc9, 0x5b, 0x24, 0xf3, 0xc8, 0x83, 0x7d, 0x68, 0x1c, 0xf3, 0x09, 0xf5, 0x19, 0xdf, 0xbf, 0xa1, 0xd7, 0xcd, - 0x70, 0xca, 0x2a, 0xf7, 0x36, 0x28, 0x1d, 0xe5, 0x90, 0xdc, 0x78, 0xc4, 0xf5, 0xd9, 0xab, 0x4e, 0xe5, 0x6e, 0x3b, - 0x04, 0x9b, 0xc7, 0xb8, 0xe6, 0xa4, 0x4f, 0xce, 0x02, 0x8b, 0xf7, 0x4e, 0xf6, 0xc3, 0x15, 0x8c, 0x48, 0x7e, 0x5f, - 0x68, 0x47, 0x3b, 0x18, 0x36, 0x40, 0x6f, 0xae, 0xa3, 0xc4, 0x81, 0x71, 0xc8, 0x6b, 0x41, 0x5d, 0xb8, 0xbd, 0x7f, - 0xfd, 0x1f, 0xff, 0x4b, 0xfb, 0xd8, 0x4f, 0xf6, 0xe3, 0xb6, 0xe9, 0x6b, 0x65, 0x55, 0x8a, 0x13, 0x38, 0xee, 0x59, - 0x05, 0x85, 0xe9, 0x6d, 0x73, 0x9c, 0xb1, 0xa8, 0x19, 0x87, 0xc9, 0xc8, 0xed, 0x6d, 0xc7, 0xa6, 0x7d, 0x6c, 0x4b, - 0x43, 0x5d, 0x2f, 0x02, 0x7a, 0xfd, 0x4d, 0x07, 0x8f, 0xcc, 0xf9, 0x15, 0xb9, 0xb5, 0xed, 0x63, 0x48, 0xd5, 0xee, - 0xab, 0x1d, 0x45, 0x4a, 0xf5, 0x27, 0xc2, 0x34, 0x07, 0x4c, 0x6b, 0x27, 0x90, 0x0a, 0xd7, 0x29, 0x83, 0x5a, 0xff, - 0xf7, 0x7f, 0xfe, 0x97, 0xff, 0x66, 0x1e, 0x21, 0x56, 0xf5, 0xaf, 0xff, 0xfd, 0x3f, 0xff, 0x9f, 0xff, 0xfd, 0x5f, - 0xe1, 0xd4, 0x8a, 0x8e, 0x67, 0x49, 0xa6, 0xe2, 0x54, 0xc1, 0x2c, 0xc5, 0x5d, 0x1c, 0x48, 0xec, 0x9c, 0xb0, 0x5c, - 0xb0, 0x61, 0xfd, 0x4c, 0xd2, 0xb9, 0x1c, 0x50, 0xee, 0x4c, 0x0d, 0x9d, 0xdc, 0xe1, 0x45, 0x45, 0x50, 0x35, 0x94, - 0x4b, 0xc2, 0x2d, 0x4e, 0xf6, 0x01, 0xdf, 0x0f, 0x3b, 0xc6, 0xe9, 0x97, 0xcb, 0xb1, 0x30, 0x64, 0x02, 0x25, 0x45, - 0x55, 0xee, 0x40, 0x6c, 0x65, 0x01, 0x8f, 0x41, 0xc7, 0x2a, 0x96, 0xab, 0x57, 0x6b, 0xd3, 0xfd, 0x69, 0x96, 0x0b, - 0x36, 0x02, 0x94, 0x2b, 0x3f, 0xb1, 0x0c, 0x63, 0x37, 0x41, 0x57, 0x4c, 0xee, 0x0a, 0xd9, 0x8b, 0x22, 0xd0, 0xc3, - 0xe3, 0x3f, 0x15, 0x7f, 0x99, 0x80, 0x46, 0xe6, 0x78, 0x93, 0xf0, 0x56, 0x9b, 0xe7, 0x8f, 0x5a, 0xad, 0xe9, 0x2d, - 0x5a, 0x54, 0x23, 0xe0, 0x6d, 0x83, 0x49, 0x3a, 0xb6, 0x3b, 0x94, 0xf1, 0xef, 0xd2, 0x8d, 0xdd, 0x72, 0xc0, 0x17, - 0xee, 0xb4, 0x8a, 0xe2, 0xcf, 0x0b, 0xe9, 0x49, 0x65, 0xbf, 0x40, 0x9c, 0x5a, 0x3b, 0x9d, 0xaf, 0xb9, 0x3d, 0xb9, - 0x85, 0xd5, 0xaa, 0xa3, 0x5a, 0xc5, 0xed, 0xf5, 0xd3, 0x89, 0x76, 0x9c, 0xdd, 0x8e, 0x90, 0x1f, 0x42, 0xcc, 0x3b, - 0x6e, 0xe3, 0xb8, 0xb3, 0x28, 0xbb, 0x17, 0x82, 0x4f, 0xec, 0xc0, 0x3a, 0x0d, 0xe9, 0x90, 0x8e, 0x8c, 0xb3, 0x5e, - 0xbf, 0x57, 0x41, 0xf3, 0x22, 0x3e, 0xd8, 0x30, 0x96, 0x06, 0x49, 0x06, 0xd4, 0x9d, 0x56, 0xf1, 0x39, 0xec, 0xc0, - 0xc5, 0x28, 0xe1, 0xa1, 0x08, 0x24, 0xc1, 0x76, 0xed, 0xf0, 0x7c, 0x08, 0x3c, 0x89, 0x2f, 0x2c, 0x78, 0xba, 0xaa, - 0x2a, 0xb8, 0xcd, 0xeb, 0x67, 0x48, 0x0b, 0x5f, 0x36, 0xb7, 0xbb, 0x52, 0x5e, 0xb7, 0x6f, 0x75, 0xd4, 0xfb, 0x5d, - 0xcd, 0x5d, 0xa5, 0x05, 0x52, 0x07, 0x6d, 0x7e, 0xaf, 0xe4, 0xba, 0x7a, 0xfb, 0xb5, 0xf0, 0x5c, 0x09, 0xa6, 0xbb, - 0x5a, 0x4b, 0x16, 0x42, 0xad, 0x77, 0xe4, 0xdb, 0xd2, 0x64, 0x0a, 0xa7, 0x53, 0x59, 0x11, 0x75, 0x4f, 0xf6, 0x95, - 0xa6, 0x0b, 0xdc, 0x43, 0xa6, 0x74, 0xa8, 0x0c, 0x0a, 0x5d, 0x49, 0x6f, 0x05, 0xf5, 0x4b, 0xe7, 0x56, 0xc0, 0xa7, - 0xe3, 0x7a, 0xff, 0x0f, 0x82, 0x7a, 0x0b, 0xa7, 0xcf, 0x89, 0x00, 0x00}; + 0x65, 0x7c, 0xed, 0x25, 0x00, 0x5b, 0x3e, 0xbd, 0x0f, 0xc7, 0xe5, 0xef, 0x57, 0x34, 0xcf, 0xc3, 0xb1, 0xae, 0xb9, + 0x3d, 0x9e, 0x26, 0x41, 0xb4, 0x63, 0x69, 0x06, 0x08, 0x88, 0x89, 0x01, 0x46, 0xc0, 0xa7, 0xa1, 0x43, 0x64, 0x30, + 0xf5, 0x7a, 0x74, 0x4d, 0xe2, 0xaa, 0x5e, 0x24, 0xc2, 0x71, 0x55, 0x70, 0x32, 0xcd, 0xa8, 0x2c, 0x55, 0x68, 0x2c, + 0x4e, 0xf6, 0xa1, 0x40, 0xbd, 0xde, 0x12, 0x45, 0x33, 0x0e, 0x94, 0xed, 0xb1, 0x34, 0xc7, 0x44, 0xd1, 0xec, 0x44, + 0xa5, 0x32, 0x4b, 0x69, 0x3d, 0x76, 0xf3, 0x79, 0x7b, 0x08, 0x7f, 0x74, 0x64, 0xe8, 0xf3, 0xd1, 0x68, 0x74, 0x6f, + 0x54, 0xed, 0xf3, 0x68, 0x44, 0x3b, 0xf4, 0xa8, 0x0b, 0x49, 0x2c, 0x4d, 0x1d, 0x8b, 0x69, 0x17, 0x12, 0x77, 0x8b, + 0x87, 0x55, 0x86, 0xb0, 0x8d, 0x88, 0x17, 0x0f, 0x8f, 0xb0, 0x15, 0xd3, 0x8c, 0x2e, 0x26, 0x61, 0x36, 0x66, 0x69, + 0xd0, 0x2a, 0xfc, 0xb9, 0x0e, 0x49, 0x7d, 0x7e, 0x7c, 0x7c, 0x5c, 0xf8, 0x91, 0x79, 0x6a, 0x45, 0x51, 0xe1, 0x0f, + 0x17, 0xe5, 0x34, 0x5a, 0xad, 0xd1, 0xa8, 0xf0, 0x99, 0x29, 0x38, 0xe8, 0x0c, 0xa3, 0x83, 0x4e, 0xe1, 0xdf, 0x58, + 0x35, 0x0a, 0x9f, 0xea, 0xa7, 0x8c, 0x46, 0xb5, 0x4c, 0x98, 0xc7, 0xad, 0x56, 0xe1, 0x2b, 0x42, 0x5b, 0x80, 0x59, + 0xaa, 0x7e, 0x06, 0xe1, 0x4c, 0x70, 0x60, 0xee, 0xdd, 0x44, 0x78, 0x83, 0x4b, 0x7d, 0xcb, 0x88, 0xfa, 0x26, 0x47, + 0x81, 0x2e, 0xf0, 0xcf, 0x76, 0xf0, 0x08, 0x88, 0x59, 0x06, 0x8d, 0x12, 0x13, 0x5b, 0xaa, 0xbd, 0x06, 0xca, 0x92, + 0xaf, 0x7f, 0x26, 0x49, 0x15, 0x53, 0x02, 0x4e, 0x06, 0x35, 0xd5, 0x65, 0x78, 0x94, 0x6e, 0x91, 0x1f, 0xec, 0xd3, + 0xf2, 0xe3, 0xee, 0x21, 0xe2, 0x83, 0xfd, 0xe1, 0xe2, 0x83, 0x52, 0x4b, 0x7c, 0x28, 0xe6, 0x71, 0x27, 0x88, 0x3b, + 0x8c, 0xe9, 0xf0, 0xe3, 0x35, 0xbf, 0x6d, 0xc2, 0x96, 0xc8, 0x5c, 0x29, 0x58, 0x76, 0x7f, 0x6b, 0xd6, 0x8c, 0xe9, + 0xcc, 0xfa, 0xa2, 0x87, 0x54, 0x1f, 0xde, 0xa4, 0xc4, 0x7d, 0x63, 0x6c, 0x5b, 0x55, 0x32, 0x1a, 0x11, 0xf7, 0xcd, + 0x68, 0xe4, 0x9a, 0xb3, 0x92, 0xa1, 0xa0, 0xb2, 0xd6, 0xeb, 0x5a, 0x89, 0xac, 0xf5, 0xe5, 0x97, 0x76, 0x99, 0x5d, + 0xa0, 0x43, 0x4f, 0x76, 0x98, 0x49, 0xbf, 0x89, 0x58, 0x0e, 0x5b, 0x0d, 0x3e, 0x34, 0x52, 0xbf, 0xab, 0x31, 0xad, + 0x5d, 0xab, 0x5d, 0x02, 0xbc, 0xe1, 0x2e, 0xf0, 0xd5, 0x8b, 0x02, 0xc6, 0xd4, 0xe4, 0x2d, 0x3e, 0xbd, 0xfb, 0x2a, + 0xf2, 0xee, 0x04, 0x2a, 0x58, 0xfe, 0x26, 0x5d, 0x39, 0x04, 0xa4, 0x60, 0x24, 0xc4, 0x9e, 0x56, 0x21, 0xf8, 0x78, + 0x9c, 0xc0, 0xb7, 0x5e, 0x16, 0xb5, 0xfb, 0x63, 0x55, 0xf3, 0x7e, 0x6d, 0xbe, 0x81, 0xdd, 0x50, 0xdf, 0xb6, 0x2a, + 0x3f, 0x3d, 0xa5, 0x92, 0xc7, 0xe7, 0xfa, 0x1b, 0x44, 0xd2, 0x2c, 0x5e, 0x68, 0x26, 0xbf, 0x50, 0x29, 0xc7, 0x02, + 0xd2, 0x6d, 0x54, 0xc7, 0x51, 0x51, 0xe8, 0xc3, 0x1a, 0x11, 0xcb, 0xa7, 0x70, 0xaf, 0xa9, 0x6a, 0x49, 0x3f, 0xc5, + 0xc2, 0xf3, 0x1b, 0x2b, 0xbe, 0x53, 0x5b, 0xae, 0xc2, 0x04, 0x78, 0x94, 0xc3, 0xfc, 0x4e, 0x14, 0xae, 0xf6, 0xbb, + 0x1b, 0x24, 0xba, 0x8e, 0xc2, 0xa7, 0x8a, 0x3c, 0x59, 0x33, 0x04, 0xe7, 0x77, 0xb9, 0x20, 0xe6, 0x95, 0x29, 0x28, + 0xec, 0xf8, 0xa5, 0x7c, 0xa3, 0xb0, 0x25, 0xa3, 0x25, 0xf9, 0x34, 0x4c, 0x15, 0x1b, 0x25, 0xae, 0xe2, 0x07, 0xbb, + 0x8b, 0x6a, 0xe5, 0x0b, 0xd7, 0x80, 0xad, 0x88, 0xb7, 0x77, 0xb2, 0x0f, 0x0d, 0x7a, 0x4e, 0x0d, 0xf4, 0x74, 0x2d, + 0xc8, 0xf2, 0x89, 0x74, 0x87, 0x2b, 0x3f, 0xbf, 0xc1, 0x7e, 0x7e, 0xe3, 0xfc, 0x79, 0xd1, 0xbc, 0xa1, 0xd7, 0x1f, + 0x99, 0x68, 0x8a, 0x70, 0xda, 0x04, 0xc3, 0x47, 0x3a, 0x47, 0x35, 0x7b, 0x96, 0x59, 0x7e, 0xea, 0xaa, 0x83, 0xee, + 0x2c, 0x87, 0xac, 0x08, 0xa9, 0xbe, 0x07, 0x29, 0x4f, 0x69, 0xb7, 0x9e, 0xcd, 0x69, 0x07, 0xd9, 0x0d, 0xb6, 0x2e, + 0x16, 0x1c, 0xb2, 0x28, 0xc4, 0x5d, 0xd0, 0xd2, 0x6c, 0xbd, 0x65, 0x22, 0xe8, 0xad, 0x8d, 0xf5, 0x03, 0x8d, 0xdc, + 0x86, 0x94, 0x5e, 0xd9, 0x7a, 0x26, 0xc1, 0xb6, 0x4c, 0x80, 0x4f, 0xe5, 0x36, 0x82, 0x4b, 0xd5, 0xfc, 0xb5, 0x92, + 0x42, 0x57, 0x8b, 0x65, 0x6e, 0xe3, 0x43, 0x20, 0x0b, 0xc2, 0x91, 0xa0, 0x19, 0x7e, 0x48, 0xcd, 0x6b, 0x79, 0x0c, + 0x69, 0x01, 0x62, 0x26, 0x68, 0x1f, 0x4f, 0x6f, 0x1f, 0xde, 0xfd, 0xfd, 0xd3, 0x2f, 0x34, 0x8e, 0xcc, 0xb5, 0x3c, + 0xae, 0xdb, 0x85, 0x8d, 0x90, 0x84, 0x77, 0x01, 0x4b, 0xa5, 0xcc, 0xbb, 0x06, 0xbf, 0x68, 0x77, 0xca, 0x75, 0x92, + 0x6e, 0x46, 0x13, 0xf9, 0x15, 0x3e, 0xbd, 0x14, 0x07, 0x8f, 0xa6, 0xb7, 0x66, 0x35, 0xda, 0x2b, 0xc9, 0xb7, 0x7f, + 0x68, 0x8e, 0xed, 0xf6, 0xa4, 0xde, 0x7a, 0x9e, 0xe8, 0xd1, 0xf4, 0xb6, 0xab, 0x04, 0x6d, 0x33, 0x53, 0x50, 0xb5, + 0xa6, 0xb7, 0x76, 0x96, 0x71, 0xd5, 0x91, 0xe3, 0x1f, 0xe4, 0x0e, 0x0d, 0x73, 0xda, 0x85, 0x7b, 0xc7, 0xd9, 0x30, + 0x4c, 0xb4, 0x30, 0x9f, 0xb0, 0x28, 0x4a, 0x68, 0xd7, 0xc8, 0x6b, 0xa7, 0xfd, 0x08, 0x92, 0x74, 0xed, 0x25, 0xab, + 0xaf, 0x8a, 0x85, 0xbc, 0x12, 0x4f, 0xe1, 0x75, 0xce, 0x13, 0xf8, 0xe8, 0xc7, 0x46, 0x74, 0xea, 0xec, 0xd5, 0x56, + 0x85, 0x3c, 0xf9, 0xbb, 0x3e, 0x97, 0xa3, 0xd6, 0x9f, 0xba, 0x72, 0xc1, 0x5b, 0x5d, 0xc1, 0xa7, 0x41, 0xf3, 0xa0, + 0x3e, 0x11, 0x78, 0x55, 0x4e, 0x01, 0x6f, 0x98, 0x16, 0x06, 0x69, 0xa5, 0xf8, 0xb4, 0xe3, 0xb7, 0x75, 0x99, 0xec, + 0x00, 0xf2, 0xc2, 0xca, 0xa2, 0xa2, 0x3e, 0x99, 0x7f, 0x9b, 0xdd, 0xf2, 0x64, 0xf3, 0x6e, 0x79, 0x62, 0x76, 0xcb, + 0xfd, 0x14, 0xfb, 0xf9, 0xa8, 0x0d, 0x7f, 0xba, 0xd5, 0x84, 0x82, 0x96, 0x73, 0x30, 0xbd, 0x75, 0x40, 0x4f, 0x6b, + 0x76, 0xa6, 0xb7, 0x2a, 0xc7, 0x1a, 0x62, 0x37, 0x2d, 0xc8, 0x3a, 0xc6, 0x2d, 0x07, 0x0a, 0xe1, 0x6f, 0xab, 0xf6, + 0xaa, 0x7d, 0x08, 0xef, 0xa0, 0xd5, 0xd1, 0xfa, 0xbb, 0xce, 0xfd, 0x9b, 0x36, 0x48, 0xb9, 0xf0, 0x02, 0xc3, 0x8d, + 0x91, 0x2f, 0xc2, 0xeb, 0x6b, 0x1a, 0x05, 0x23, 0x3e, 0x9c, 0xe5, 0xff, 0xa4, 0xe1, 0xd7, 0x48, 0xbc, 0x77, 0x4b, + 0xaf, 0xf4, 0x63, 0x9a, 0xaa, 0x8c, 0x6f, 0xd3, 0xc3, 0xa2, 0x5c, 0xa7, 0x20, 0x1f, 0x86, 0x09, 0xf5, 0x3a, 0xfe, + 0xe1, 0x86, 0x4d, 0xf0, 0xef, 0xb2, 0x36, 0x1b, 0x27, 0xf3, 0x7b, 0x91, 0x71, 0x2f, 0x12, 0x7e, 0x15, 0x0e, 0xec, + 0x35, 0x6c, 0x1d, 0x6f, 0x06, 0x77, 0x60, 0x46, 0xba, 0x30, 0x42, 0x41, 0xcb, 0x9d, 0x88, 0x8e, 0xc2, 0x59, 0x22, + 0xee, 0xef, 0x75, 0x1b, 0x65, 0xac, 0xf5, 0x7a, 0x0f, 0x43, 0xaf, 0xea, 0x3e, 0x90, 0x4b, 0x7f, 0xfe, 0xe4, 0x10, + 0xfe, 0xa8, 0xfc, 0xaf, 0xbb, 0x4a, 0x57, 0x57, 0x76, 0x2f, 0xe8, 0xea, 0xbb, 0x35, 0x65, 0x5c, 0x89, 0x70, 0xa9, + 0x8f, 0x3f, 0xb4, 0x36, 0x68, 0x95, 0x0f, 0xaa, 0xae, 0xb5, 0xac, 0x5f, 0x55, 0xfb, 0xd7, 0x75, 0xfe, 0xc0, 0xba, + 0x43, 0xa5, 0xb9, 0xd6, 0xeb, 0xea, 0xcf, 0x10, 0xae, 0x55, 0x36, 0x18, 0x97, 0xf5, 0x77, 0xc9, 0x5d, 0x69, 0xa2, + 0xa8, 0x68, 0x2c, 0x58, 0x29, 0xbb, 0xca, 0x4a, 0xc9, 0x29, 0xb9, 0x3a, 0xe9, 0xdf, 0x4e, 0x12, 0x67, 0xae, 0x8e, + 0x4b, 0x12, 0xb7, 0xed, 0xb7, 0x5c, 0x47, 0xe6, 0x01, 0xc0, 0xad, 0xed, 0xae, 0xfc, 0xbc, 0xad, 0xdb, 0x07, 0x4d, + 0x6b, 0x3e, 0x96, 0x9a, 0xdd, 0xcb, 0xf0, 0x8e, 0x66, 0x97, 0x1d, 0xd7, 0x01, 0x3f, 0x4d, 0x53, 0xa5, 0x4c, 0xc8, + 0x32, 0xa7, 0xe3, 0x3a, 0xb7, 0x93, 0x24, 0xcd, 0x89, 0x1b, 0x0b, 0x31, 0x0d, 0xd4, 0xf7, 0x6f, 0x6f, 0x0e, 0x7c, + 0x9e, 0x8d, 0xf7, 0x3b, 0xad, 0x56, 0x0b, 0x2e, 0x80, 0x75, 0x9d, 0x39, 0xa3, 0x37, 0x4f, 0xf9, 0x2d, 0x71, 0x5b, + 0x4e, 0xcb, 0x69, 0x77, 0x8e, 0x9d, 0x76, 0xe7, 0xd0, 0x7f, 0x74, 0xec, 0xf6, 0x3e, 0x73, 0x9c, 0x93, 0x88, 0x8e, + 0x72, 0xf8, 0xe1, 0x38, 0x27, 0x52, 0xf1, 0x52, 0xbf, 0x1d, 0xc7, 0x1f, 0x26, 0x79, 0xb3, 0xed, 0x2c, 0xf4, 0xa3, + 0xe3, 0xc0, 0xa1, 0xd2, 0xc0, 0xf9, 0x7c, 0xd4, 0x19, 0x1d, 0x8e, 0x9e, 0x74, 0x75, 0x71, 0xf1, 0x59, 0xad, 0x3a, + 0x56, 0xff, 0x77, 0xac, 0x66, 0xb9, 0xc8, 0xf8, 0x47, 0xaa, 0x73, 0x12, 0x1d, 0x10, 0x3d, 0x1b, 0x9b, 0x76, 0xd6, + 0x47, 0x6a, 0x1f, 0x5f, 0x0f, 0x47, 0x9d, 0xaa, 0xba, 0x84, 0x71, 0xbf, 0x04, 0xf2, 0x64, 0xdf, 0x80, 0x7e, 0x62, + 0xa3, 0xa9, 0xdd, 0xdc, 0x84, 0xa8, 0xb6, 0xab, 0xe7, 0x38, 0x36, 0xf3, 0x3b, 0x81, 0x33, 0x0c, 0x46, 0x57, 0x95, + 0x10, 0xb8, 0x4e, 0x44, 0xdc, 0x57, 0xed, 0xce, 0x31, 0x6e, 0xb7, 0x1f, 0xf9, 0x8f, 0x8e, 0x87, 0x2d, 0x7c, 0xe8, + 0x1f, 0x36, 0x0f, 0xfc, 0x47, 0xf8, 0xb8, 0x79, 0x8c, 0x8f, 0x5f, 0x1c, 0x0f, 0x9b, 0x87, 0xfe, 0x21, 0x6e, 0x35, + 0x8f, 0xa1, 0xb0, 0x79, 0xdc, 0x3c, 0x9e, 0x37, 0x0f, 0x8f, 0x87, 0x2d, 0x59, 0xda, 0xf1, 0x8f, 0x8e, 0x9a, 0xed, + 0x96, 0x7f, 0x74, 0x84, 0x8f, 0xfc, 0x47, 0x8f, 0x9a, 0xed, 0x03, 0xff, 0xd1, 0xa3, 0x97, 0x47, 0xc7, 0xfe, 0x01, + 0xbc, 0x3b, 0x38, 0x18, 0x1e, 0xf8, 0xed, 0x76, 0x13, 0xfe, 0xc1, 0xc7, 0x7e, 0x47, 0xfd, 0x68, 0xb7, 0xfd, 0x83, + 0x36, 0x6e, 0x25, 0x47, 0x1d, 0xff, 0xd1, 0x13, 0x2c, 0xff, 0x95, 0xd5, 0xb0, 0xfc, 0x07, 0xba, 0xc1, 0x4f, 0xfc, + 0xce, 0x23, 0xf5, 0x4b, 0x76, 0x38, 0x3f, 0x3c, 0xfe, 0xc1, 0xdd, 0xdf, 0x3a, 0x87, 0xb6, 0x9a, 0xc3, 0xf1, 0x91, + 0x7f, 0x70, 0x80, 0x0f, 0xdb, 0xfe, 0xf1, 0x41, 0xdc, 0x3c, 0xec, 0xf8, 0x8f, 0x1e, 0x0f, 0x9b, 0x6d, 0xff, 0xf1, + 0x63, 0xdc, 0x6a, 0x1e, 0xf8, 0x1d, 0xdc, 0xf6, 0x0f, 0x0f, 0xe4, 0x8f, 0x03, 0xbf, 0x33, 0x7f, 0xfc, 0xc4, 0x7f, + 0x74, 0x14, 0x3f, 0xf2, 0x0f, 0xbf, 0x3d, 0x3c, 0xf6, 0x3b, 0x07, 0xf1, 0xc1, 0x23, 0xbf, 0xf3, 0x78, 0xfe, 0xc8, + 0x3f, 0x8c, 0x9b, 0x9d, 0x47, 0xf7, 0xb6, 0x6c, 0x77, 0x7c, 0xc0, 0x91, 0x7c, 0x0d, 0x2f, 0xb0, 0x7e, 0x01, 0x7f, + 0x63, 0xd9, 0xf6, 0xdf, 0xb1, 0x9b, 0x7c, 0xbd, 0xe9, 0x13, 0xff, 0xf8, 0xf1, 0x50, 0x55, 0x87, 0x82, 0xa6, 0xa9, + 0x01, 0x4d, 0xe6, 0x4d, 0x35, 0xac, 0xec, 0xae, 0x69, 0x3a, 0x32, 0x7f, 0xf5, 0x60, 0xf3, 0x26, 0x0c, 0xac, 0xc6, + 0xfd, 0x0f, 0xed, 0xa7, 0x5c, 0xf2, 0x93, 0xfd, 0xb1, 0x22, 0xfd, 0x71, 0xef, 0x33, 0x75, 0xbb, 0xf3, 0x67, 0x57, + 0x38, 0xdd, 0xe6, 0xf8, 0xc8, 0x3e, 0xed, 0xf8, 0xe0, 0xf4, 0x21, 0x9e, 0x8f, 0xec, 0x0f, 0xf7, 0x7c, 0xa4, 0x74, + 0xc5, 0x71, 0x7e, 0x2d, 0xd6, 0x1c, 0x1c, 0xab, 0x56, 0xf1, 0x53, 0xe1, 0x0d, 0x72, 0xf8, 0x8e, 0x58, 0xd1, 0xbd, + 0x16, 0x84, 0x53, 0xdb, 0x0f, 0xc4, 0x81, 0xc5, 0x5e, 0x0b, 0xc5, 0x63, 0x93, 0x6d, 0x08, 0x09, 0x3f, 0x8d, 0x90, + 0x6f, 0x1f, 0x82, 0x8f, 0xf0, 0x0f, 0xc7, 0x47, 0x62, 0xe3, 0xa3, 0xe6, 0xcb, 0x97, 0x9e, 0x06, 0xe9, 0x29, 0x38, + 0x97, 0xcf, 0x1e, 0x1c, 0xa2, 0x6a, 0xb8, 0xfb, 0x14, 0x8a, 0x72, 0x57, 0x45, 0xbe, 0xde, 0xfd, 0x9a, 0xb0, 0x83, + 0x3a, 0x31, 0x49, 0x5c, 0xed, 0x96, 0x99, 0x4a, 0xa9, 0xa3, 0x1f, 0x4a, 0xa1, 0xd4, 0xf1, 0x5b, 0x7e, 0xab, 0x74, + 0xe9, 0xc0, 0x29, 0x59, 0xb2, 0xe0, 0x22, 0x84, 0x2f, 0xd6, 0x26, 0x7c, 0x2c, 0xbf, 0x6d, 0x0b, 0x5f, 0x13, 0x80, + 0xa4, 0x9f, 0xa1, 0xfa, 0x90, 0x43, 0xe0, 0xba, 0xfa, 0x6e, 0x0d, 0x38, 0x85, 0xf9, 0x0d, 0x9c, 0x54, 0x35, 0x51, + 0x89, 0x09, 0x78, 0x3b, 0x5e, 0xd1, 0x88, 0x85, 0x9e, 0xeb, 0x4d, 0x33, 0x3a, 0xa2, 0x59, 0xde, 0xac, 0x1d, 0xdf, + 0x94, 0x27, 0x37, 0x91, 0x6b, 0x3e, 0x8d, 0x9a, 0xc1, 0xed, 0xd8, 0x64, 0xa0, 0xfd, 0x8d, 0xae, 0x36, 0xc0, 0xdc, + 0x02, 0x9b, 0x92, 0x0c, 0x64, 0x6d, 0xa5, 0xb4, 0xb9, 0x4a, 0x6b, 0x6b, 0xfb, 0x9d, 0x23, 0xe4, 0xc8, 0x62, 0xb8, + 0x77, 0xf8, 0x7b, 0xaf, 0x79, 0xd0, 0xfa, 0x13, 0xb2, 0x9a, 0x95, 0x1d, 0x5d, 0x68, 0x77, 0x5b, 0x5a, 0x7d, 0x53, + 0xba, 0x7e, 0xb6, 0xd6, 0x55, 0x14, 0xf1, 0xb9, 0x9a, 0xbb, 0x8b, 0xba, 0xa9, 0x8e, 0x70, 0xab, 0x1b, 0x22, 0x46, + 0x6c, 0xec, 0xd9, 0x5f, 0x0c, 0x56, 0xf7, 0x1a, 0xcb, 0x0f, 0x8d, 0xa3, 0xa2, 0xaa, 0x92, 0xa2, 0x85, 0x8c, 0xb7, + 0xb0, 0xd4, 0x49, 0x97, 0x4b, 0x2f, 0x05, 0x17, 0x39, 0xb1, 0x70, 0x0a, 0xcf, 0xa8, 0x86, 0xe4, 0x14, 0x97, 0x00, + 0x49, 0x04, 0x93, 0x54, 0xfd, 0x5f, 0x15, 0x9b, 0x1f, 0xda, 0xf1, 0xe5, 0x27, 0x61, 0x3a, 0x06, 0x2a, 0x0c, 0xd3, + 0xf1, 0x9a, 0x5b, 0x4d, 0x85, 0x8c, 0x56, 0x4a, 0xab, 0xae, 0x2a, 0xf7, 0x59, 0xfe, 0xf4, 0xee, 0xbd, 0xbe, 0x00, + 0xcd, 0x05, 0xef, 0xb4, 0x8c, 0x70, 0x54, 0x97, 0x35, 0x37, 0xc8, 0x17, 0x27, 0x13, 0x2a, 0x42, 0x95, 0xaf, 0x09, + 0xfa, 0x04, 0x9c, 0x9a, 0x75, 0xb4, 0x35, 0x4a, 0x5c, 0x29, 0xdd, 0x49, 0x44, 0xe7, 0x6c, 0xa8, 0x45, 0x3d, 0x76, + 0xf4, 0xcd, 0x01, 0x4d, 0xb9, 0x34, 0xa4, 0x8d, 0x95, 0x3f, 0x66, 0x18, 0xca, 0x8c, 0x7c, 0x92, 0x72, 0xb7, 0xf7, + 0x45, 0xf9, 0xf5, 0xd3, 0x6d, 0x8b, 0x90, 0xb0, 0xf4, 0xe3, 0x20, 0xa3, 0xc9, 0x3f, 0x91, 0x2f, 0xd8, 0x90, 0xa7, + 0x5f, 0x5c, 0xc0, 0x57, 0xe9, 0xfd, 0x38, 0xa3, 0x23, 0xf2, 0x05, 0xc8, 0xf8, 0x40, 0x5a, 0x1f, 0xc0, 0x08, 0x1b, + 0xb7, 0x93, 0x04, 0x4b, 0x8d, 0xe9, 0x01, 0x0a, 0x91, 0x02, 0xd7, 0xed, 0x1c, 0xb9, 0x8e, 0xb2, 0x89, 0xe5, 0xef, + 0x9e, 0x12, 0xa7, 0x52, 0x09, 0x70, 0xda, 0x1d, 0xff, 0x28, 0xee, 0xf8, 0x4f, 0xe6, 0x8f, 0xfd, 0xe3, 0xb8, 0xfd, + 0x78, 0xde, 0x84, 0xff, 0x3b, 0xfe, 0x93, 0xa4, 0xd9, 0xf1, 0x9f, 0xc0, 0xdf, 0x6f, 0x0f, 0xfd, 0xa3, 0xb8, 0xd9, + 0xf6, 0x8f, 0xe7, 0x07, 0xfe, 0xc1, 0xcb, 0x76, 0xc7, 0x3f, 0x70, 0xda, 0x8e, 0x6a, 0x07, 0xec, 0x5a, 0x71, 0xe7, + 0x2f, 0x56, 0x36, 0xc4, 0x86, 0x70, 0x9c, 0xca, 0x39, 0x75, 0xb1, 0x57, 0x7e, 0x63, 0x51, 0xef, 0x4f, 0xed, 0xac, + 0x7b, 0x16, 0x66, 0xf0, 0xa1, 0x9b, 0xfa, 0xde, 0xad, 0xbd, 0xc3, 0x35, 0x7e, 0xb1, 0x61, 0x08, 0xd8, 0xe1, 0x2e, + 0xb6, 0x8f, 0xde, 0xc3, 0xb9, 0x75, 0x79, 0x2f, 0xb8, 0xb9, 0x1e, 0x71, 0x3b, 0x69, 0xab, 0x8a, 0xe6, 0x0a, 0x46, + 0xc9, 0x2c, 0x98, 0xfc, 0x02, 0x83, 0x1c, 0xe4, 0xab, 0xa8, 0x58, 0x1d, 0x1f, 0x52, 0x5f, 0x33, 0x6e, 0xdd, 0x3e, + 0x40, 0xab, 0x03, 0x1b, 0x11, 0x83, 0xfb, 0x22, 0x8a, 0xc2, 0x80, 0x5e, 0x73, 0xd3, 0x56, 0x58, 0x92, 0xfc, 0x82, + 0xe6, 0x7d, 0x17, 0x8a, 0xdc, 0xc0, 0x95, 0x2e, 0x3e, 0xb7, 0xfc, 0xd8, 0x4f, 0x49, 0xd8, 0x55, 0x01, 0x96, 0x87, + 0xae, 0x60, 0xd7, 0x02, 0x7e, 0x5c, 0xb4, 0xb7, 0xb7, 0x75, 0xbf, 0x48, 0x05, 0x12, 0xe6, 0x5a, 0x7d, 0x23, 0xc4, + 0x66, 0x45, 0xae, 0x8d, 0xe8, 0xb2, 0x5f, 0x89, 0x42, 0xa4, 0xf1, 0x74, 0x4d, 0x43, 0xe1, 0x87, 0xa9, 0x4a, 0xa2, + 0xb1, 0x18, 0x16, 0x6e, 0xd3, 0x03, 0x54, 0x70, 0x11, 0x5a, 0xdf, 0x01, 0xd6, 0xfb, 0x9c, 0x8b, 0xd0, 0x9c, 0xa5, + 0xb5, 0xae, 0x0d, 0x02, 0x47, 0x6f, 0xdc, 0xe9, 0xbd, 0x79, 0x7f, 0xea, 0xa8, 0xed, 0x79, 0xb2, 0x1f, 0x77, 0x7a, + 0x27, 0xd2, 0x67, 0xa2, 0x4e, 0xe2, 0x11, 0x75, 0x12, 0xcf, 0xd1, 0xa7, 0x32, 0x21, 0x92, 0x56, 0xec, 0xab, 0x69, + 0x4b, 0x9b, 0x41, 0x79, 0x7b, 0x27, 0xb3, 0x44, 0x30, 0xb8, 0xe3, 0x7a, 0x5f, 0x1e, 0xc3, 0x83, 0x05, 0x2b, 0xf3, + 0xb0, 0xb5, 0x76, 0x78, 0x2d, 0x52, 0xe3, 0x1b, 0x1e, 0xb1, 0x84, 0x9a, 0xcc, 0x6b, 0xdd, 0x55, 0x79, 0x52, 0x60, + 0xbd, 0x76, 0x3e, 0xbb, 0x9e, 0x30, 0xe1, 0x9a, 0xf3, 0x0c, 0x1f, 0x74, 0x83, 0x13, 0x39, 0x54, 0xef, 0xaa, 0xd0, + 0xce, 0x6b, 0xf3, 0x35, 0x9f, 0xfa, 0x92, 0xea, 0xd9, 0x6b, 0x09, 0x01, 0x27, 0xe4, 0xe2, 0x83, 0x5e, 0xe9, 0x2e, + 0xb6, 0xdf, 0x15, 0x27, 0xfb, 0xf1, 0x41, 0xef, 0x2a, 0x98, 0xea, 0xfe, 0x5e, 0xf2, 0xf1, 0xe6, 0xbe, 0x12, 0x3e, + 0xee, 0xcb, 0xa3, 0x20, 0xea, 0x90, 0xb2, 0x51, 0x7e, 0x79, 0xe2, 0xf6, 0x4e, 0xb4, 0x32, 0xe0, 0xc8, 0xc0, 0xba, + 0x7b, 0xd4, 0x32, 0xa7, 0x4b, 0x12, 0x3e, 0x86, 0x0d, 0xa9, 0x9a, 0x58, 0x83, 0xd4, 0x3c, 0xee, 0x71, 0xbb, 0x77, + 0x12, 0x3a, 0x92, 0xb7, 0x48, 0xe6, 0x91, 0x07, 0xfb, 0xd0, 0x38, 0xe6, 0x13, 0xea, 0x33, 0xbe, 0x7f, 0x43, 0xaf, + 0x9b, 0xe1, 0x94, 0x55, 0xee, 0x6d, 0x50, 0x3a, 0xca, 0x21, 0xb9, 0xf1, 0x88, 0xeb, 0xb3, 0x57, 0x9d, 0xca, 0xdd, + 0x76, 0x08, 0x36, 0x8f, 0x71, 0xcd, 0x49, 0x9f, 0x9c, 0x05, 0x16, 0xef, 0x9d, 0xec, 0x87, 0x2b, 0x18, 0x91, 0xfc, + 0xbe, 0xd0, 0x8e, 0x76, 0x30, 0x6c, 0x80, 0xde, 0x5c, 0x47, 0x89, 0x03, 0xe3, 0x90, 0xd7, 0x82, 0xba, 0x70, 0x7b, + 0xff, 0xfa, 0x3f, 0xfe, 0x97, 0xf6, 0xb1, 0x9f, 0xec, 0xc7, 0x6d, 0xd3, 0xd7, 0xca, 0xaa, 0x14, 0x27, 0x70, 0xdc, + 0xb3, 0x0a, 0x0a, 0xd3, 0xdb, 0xe6, 0x38, 0x63, 0x51, 0x33, 0x0e, 0x93, 0x91, 0xdb, 0xdb, 0x8e, 0x4d, 0xfb, 0xd8, + 0x96, 0x86, 0xba, 0x5e, 0x04, 0xf4, 0xfa, 0x9b, 0x0e, 0x1e, 0x99, 0xf3, 0x2b, 0x72, 0x6b, 0xdb, 0xc7, 0x90, 0xaa, + 0xdd, 0x57, 0x3b, 0x8a, 0x94, 0xea, 0x4f, 0x84, 0x69, 0x0e, 0x98, 0xd6, 0x4e, 0x20, 0x15, 0xae, 0x53, 0x06, 0xb5, + 0xfe, 0xef, 0xff, 0xfc, 0x2f, 0xff, 0xcd, 0x3c, 0x42, 0xac, 0xea, 0x5f, 0xff, 0xfb, 0x7f, 0xfe, 0x3f, 0xff, 0xfb, + 0xbf, 0xc2, 0xa9, 0x15, 0x1d, 0xcf, 0x92, 0x4c, 0xc5, 0xa9, 0x82, 0x59, 0x8a, 0xbb, 0x38, 0x90, 0xd8, 0x39, 0x61, + 0xb9, 0x60, 0xc3, 0xfa, 0x99, 0xa4, 0x73, 0x39, 0xa0, 0xdc, 0x99, 0x1a, 0x3a, 0xb9, 0xc3, 0x8b, 0x8a, 0xa0, 0x6a, + 0x28, 0x97, 0x84, 0x5b, 0x9c, 0xec, 0x03, 0xbe, 0x1f, 0x76, 0x8c, 0xd3, 0x2f, 0x97, 0x63, 0x61, 0xc8, 0x04, 0x4a, + 0x8a, 0xaa, 0xdc, 0x81, 0xd8, 0xca, 0x02, 0x1e, 0x83, 0x8e, 0x55, 0x2c, 0x57, 0xaf, 0xd6, 0xa6, 0xfb, 0xd3, 0x2c, + 0x17, 0x6c, 0x04, 0x28, 0x57, 0x7e, 0x62, 0x19, 0xc6, 0x6e, 0x82, 0xae, 0x98, 0xdc, 0x15, 0xb2, 0x17, 0x45, 0xa0, + 0x87, 0xc7, 0x7f, 0x2a, 0xfe, 0x32, 0x01, 0x8d, 0xcc, 0xf1, 0x26, 0xe1, 0xad, 0x36, 0xcf, 0x1f, 0xb5, 0x5a, 0xd3, + 0x5b, 0xb4, 0xa8, 0x46, 0xc0, 0xdb, 0x06, 0x93, 0x74, 0x6c, 0x77, 0x28, 0xe3, 0xdf, 0xa5, 0x1b, 0xbb, 0xe5, 0x80, + 0x2f, 0xdc, 0x69, 0x15, 0xc5, 0x9f, 0x17, 0xd2, 0x93, 0xca, 0x7e, 0x81, 0x38, 0xb5, 0x76, 0x3a, 0x5f, 0x73, 0x7b, + 0x72, 0x0b, 0xab, 0x55, 0x47, 0xb5, 0x8a, 0xdb, 0xeb, 0xa7, 0x13, 0xed, 0x38, 0xbb, 0x1d, 0x21, 0x3f, 0x84, 0x98, + 0x77, 0xdc, 0xc6, 0x71, 0x67, 0x51, 0x76, 0x2f, 0x04, 0x9f, 0xd8, 0x81, 0x75, 0x1a, 0xd2, 0x21, 0x1d, 0x19, 0x67, + 0xbd, 0x7e, 0xaf, 0x82, 0xe6, 0x45, 0x7c, 0xb0, 0x61, 0x2c, 0x0d, 0x92, 0x0c, 0xa8, 0x3b, 0xad, 0xe2, 0x73, 0xd8, + 0x81, 0x8b, 0x51, 0xc2, 0x43, 0x11, 0x48, 0x82, 0xed, 0xda, 0xe1, 0xf9, 0x10, 0x78, 0x12, 0x5f, 0x58, 0xf0, 0x74, + 0x55, 0x55, 0x70, 0x9b, 0xd7, 0xcf, 0x90, 0x16, 0xbe, 0x6c, 0x6e, 0x77, 0xa5, 0xbc, 0x6e, 0xdf, 0xea, 0xa8, 0xf7, + 0xbb, 0x9a, 0xbb, 0x4a, 0x0b, 0xa4, 0x0e, 0xda, 0xfc, 0x5e, 0xc9, 0x75, 0xf5, 0xf6, 0x6b, 0xe1, 0xb9, 0x12, 0x4c, + 0x77, 0xb5, 0x96, 0x2c, 0x84, 0x5a, 0xef, 0xc8, 0xb7, 0xa5, 0xc9, 0x14, 0x4e, 0xa7, 0xb2, 0x22, 0xea, 0x9e, 0xec, + 0x2b, 0x4d, 0x17, 0xb8, 0x87, 0x4c, 0xe9, 0x50, 0x19, 0x14, 0xba, 0x92, 0xde, 0x0a, 0xea, 0x97, 0xce, 0xad, 0x80, + 0x4f, 0xc7, 0xf5, 0xfe, 0x1f, 0xe7, 0xe0, 0x1c, 0x12, 0xcf, 0x89, 0x00, 0x00}; } // namespace web_server } // namespace esphome From c6a039a72f0be181987b4343fd7c89dba1dbb348 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:32:04 +1200 Subject: [PATCH 02/21] [adc] Fix `FILTER_SOURCE_FILES` location (#10673) --- esphome/components/adc/__init__.py | 27 +-------------------------- esphome/components/adc/sensor.py | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index f260e13242..15dc447b6c 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -11,15 +11,8 @@ from esphome.components.esp32.const import ( VARIANT_ESP32S2, VARIANT_ESP32S3, ) -from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.const import ( - CONF_ANALOG, - CONF_INPUT, - CONF_NUMBER, - PLATFORM_ESP8266, - PlatformFramework, -) +from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 from esphome.core import CORE CODEOWNERS = ["@esphome/core"] @@ -273,21 +266,3 @@ def validate_adc_pin(value): )(value) raise NotImplementedError - - -FILTER_SOURCE_FILES = filter_source_files_from_platform( - { - "adc_sensor_esp32.cpp": { - PlatformFramework.ESP32_ARDUINO, - PlatformFramework.ESP32_IDF, - }, - "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, - "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, - "adc_sensor_libretiny.cpp": { - PlatformFramework.BK72XX_ARDUINO, - PlatformFramework.RTL87XX_ARDUINO, - PlatformFramework.LN882X_ARDUINO, - }, - "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, - } -) diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py index 49970c5e3d..607609bbc7 100644 --- a/esphome/components/adc/sensor.py +++ b/esphome/components/adc/sensor.py @@ -9,6 +9,7 @@ from esphome.components.zephyr import ( zephyr_add_prj_conf, zephyr_add_user, ) +from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( CONF_ATTENUATION, @@ -20,6 +21,7 @@ from esphome.const import ( PLATFORM_NRF52, STATE_CLASS_MEASUREMENT, UNIT_VOLT, + PlatformFramework, ) from esphome.core import CORE @@ -174,3 +176,21 @@ async def to_code(config): }}; """ ) + + +FILTER_SOURCE_FILES = filter_source_files_from_platform( + { + "adc_sensor_esp32.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, + "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, + "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, + "adc_sensor_libretiny.cpp": { + PlatformFramework.BK72XX_ARDUINO, + PlatformFramework.RTL87XX_ARDUINO, + PlatformFramework.LN882X_ARDUINO, + }, + "adc_sensor_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, + } +) From 79b0025fe6492e57bf15fba4f2ce1a05ea14a16d Mon Sep 17 00:00:00 2001 From: rwrozelle Date: Thu, 11 Sep 2025 01:23:58 -0400 Subject: [PATCH 03/21] Openthread Fix Factory Reset (#9281) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../button/factory_reset_button.cpp | 19 +++++++- .../button/factory_reset_button.h | 7 ++- .../switch/factory_reset_switch.cpp | 19 +++++++- .../switch/factory_reset_switch.h | 6 ++- esphome/components/openthread/openthread.cpp | 48 ++++++++++++++++--- esphome/components/openthread/openthread.h | 11 +++++ 6 files changed, 99 insertions(+), 11 deletions(-) diff --git a/esphome/components/factory_reset/button/factory_reset_button.cpp b/esphome/components/factory_reset/button/factory_reset_button.cpp index 585975c043..d582317767 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.cpp +++ b/esphome/components/factory_reset/button/factory_reset_button.cpp @@ -1,7 +1,13 @@ #include "factory_reset_button.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -13,9 +19,20 @@ void FactoryResetButton::press_action() { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetButton::factory_reset_callback); +#else + global_preferences->reset(); + App.safe_reboot(); +#endif +} + +#ifdef USE_OPENTHREAD +void FactoryResetButton::factory_reset_callback() { global_preferences->reset(); App.safe_reboot(); } +#endif } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/button/factory_reset_button.h b/esphome/components/factory_reset/button/factory_reset_button.h index 9996a860d9..c68da2ca74 100644 --- a/esphome/components/factory_reset/button/factory_reset_button.h +++ b/esphome/components/factory_reset/button/factory_reset_button.h @@ -1,7 +1,9 @@ #pragma once -#include "esphome/core/component.h" +#include "esphome/core/defines.h" + #include "esphome/components/button/button.h" +#include "esphome/core/component.h" namespace esphome { namespace factory_reset { @@ -9,6 +11,9 @@ namespace factory_reset { class FactoryResetButton : public button::Button, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void press_action() override; diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.cpp b/esphome/components/factory_reset/switch/factory_reset_switch.cpp index 1282c73f4e..75449aa526 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.cpp +++ b/esphome/components/factory_reset/switch/factory_reset_switch.cpp @@ -1,7 +1,13 @@ #include "factory_reset_switch.h" + +#include "esphome/core/defines.h" + +#ifdef USE_OPENTHREAD +#include "esphome/components/openthread/openthread.h" +#endif +#include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" -#include "esphome/core/application.h" namespace esphome { namespace factory_reset { @@ -17,10 +23,21 @@ void FactoryResetSwitch::write_state(bool state) { ESP_LOGI(TAG, "Resetting"); // Let MQTT settle a bit delay(100); // NOLINT +#ifdef USE_OPENTHREAD + openthread::global_openthread_component->on_factory_reset(FactoryResetSwitch::factory_reset_callback); +#else global_preferences->reset(); App.safe_reboot(); +#endif } } +#ifdef USE_OPENTHREAD +void FactoryResetSwitch::factory_reset_callback() { + global_preferences->reset(); + App.safe_reboot(); +} +#endif + } // namespace factory_reset } // namespace esphome diff --git a/esphome/components/factory_reset/switch/factory_reset_switch.h b/esphome/components/factory_reset/switch/factory_reset_switch.h index 2c914ea76d..8ea0c79108 100644 --- a/esphome/components/factory_reset/switch/factory_reset_switch.h +++ b/esphome/components/factory_reset/switch/factory_reset_switch.h @@ -1,7 +1,8 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/components/switch/switch.h" +#include "esphome/core/component.h" +#include "esphome/core/defines.h" namespace esphome { namespace factory_reset { @@ -9,6 +10,9 @@ namespace factory_reset { class FactoryResetSwitch : public switch_::Switch, public Component { public: void dump_config() override; +#ifdef USE_OPENTHREAD + static void factory_reset_callback(); +#endif protected: void write_state(bool state) override; diff --git a/esphome/components/openthread/openthread.cpp b/esphome/components/openthread/openthread.cpp index 322ff43238..5b5c113f83 100644 --- a/esphome/components/openthread/openthread.cpp +++ b/esphome/components/openthread/openthread.cpp @@ -11,8 +11,6 @@ #include #include #include -#include -#include #include #include @@ -77,8 +75,14 @@ std::optional OpenThreadComponent::get_omr_address_(InstanceLock & return {}; } -void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, - const otSrpClientService *removed_services, void *context) { +void OpenThreadComponent::defer_factory_reset_external_callback() { + ESP_LOGD(TAG, "Defer factory_reset_external_callback_"); + this->defer([this]() { this->factory_reset_external_callback_(); }); +} + +void OpenThreadSrpComponent::srp_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { if (err != 0) { ESP_LOGW(TAG, "SRP client reported an error: %s", otThreadErrorToString(err)); for (const otSrpClientHostInfo *host = host_info; host; host = nullptr) { @@ -90,16 +94,30 @@ void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrp } } -void srp_start_callback(const otSockAddr *server_socket_address, void *context) { +void OpenThreadSrpComponent::srp_start_callback(const otSockAddr *server_socket_address, void *context) { ESP_LOGI(TAG, "SRP client has started"); } +void OpenThreadSrpComponent::srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, + const otSrpClientService *removed_services, void *context) { + OpenThreadComponent *obj = (OpenThreadComponent *) context; + if (err == OT_ERROR_NONE && removed_services != NULL && host_info != NULL && + host_info->mState == OT_SRP_CLIENT_ITEM_STATE_REMOVED) { + ESP_LOGD(TAG, "Successful Removal SRP Host and Services"); + } else if (err != OT_ERROR_NONE) { + // Handle other SRP client events or errors + ESP_LOGW(TAG, "SRP client event/error: %s", otThreadErrorToString(err)); + } + obj->defer_factory_reset_external_callback(); +} + void OpenThreadSrpComponent::setup() { otError error; InstanceLock lock = InstanceLock::acquire(); otInstance *instance = lock.get_instance(); - otSrpClientSetCallback(instance, srp_callback, nullptr); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_callback, nullptr); // set the host name uint16_t size; @@ -179,7 +197,8 @@ void OpenThreadSrpComponent::setup() { ESP_LOGD(TAG, "Added service: %s", full_service.c_str()); } - otSrpClientEnableAutoStartMode(instance, srp_start_callback, nullptr); + otSrpClientEnableAutoStartMode(instance, OpenThreadSrpComponent::srp_start_callback, nullptr); + ESP_LOGD(TAG, "Finished SRP setup"); } void *OpenThreadSrpComponent::pool_alloc_(size_t size) { @@ -217,6 +236,21 @@ bool OpenThreadComponent::teardown() { return this->teardown_complete_; } +void OpenThreadComponent::on_factory_reset(std::function callback) { + factory_reset_external_callback_ = callback; + ESP_LOGD(TAG, "Start Removal SRP Host and Services"); + otError error; + InstanceLock lock = InstanceLock::acquire(); + otInstance *instance = lock.get_instance(); + otSrpClientSetCallback(instance, OpenThreadSrpComponent::srp_factory_reset_callback, this); + error = otSrpClientRemoveHostAndServices(instance, true, true); + if (error != OT_ERROR_NONE) { + ESP_LOGW(TAG, "Failed to Remove SRP Host and Services"); + return; + } + ESP_LOGD(TAG, "Waiting on Confirmation Removal SRP Host and Services"); +} + } // namespace openthread } // namespace esphome diff --git a/esphome/components/openthread/openthread.h b/esphome/components/openthread/openthread.h index a0ea1b3f3a..a9aff78e56 100644 --- a/esphome/components/openthread/openthread.h +++ b/esphome/components/openthread/openthread.h @@ -6,6 +6,8 @@ #include "esphome/components/network/ip_address.h" #include "esphome/core/component.h" +#include +#include #include #include @@ -28,11 +30,14 @@ class OpenThreadComponent : public Component { network::IPAddresses get_ip_addresses(); std::optional get_omr_address(); void ot_main(); + void on_factory_reset(std::function callback); + void defer_factory_reset_external_callback(); protected: std::optional get_omr_address_(InstanceLock &lock); bool teardown_started_{false}; bool teardown_complete_{false}; + std::function factory_reset_external_callback_; }; extern OpenThreadComponent *global_openthread_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -43,6 +48,12 @@ class OpenThreadSrpComponent : public Component { // This has to run after the mdns component or else no services are available to advertise float get_setup_priority() const override { return this->mdns_->get_setup_priority() - 1.0; } void setup() override; + static void srp_callback(otError err, const otSrpClientHostInfo *host_info, const otSrpClientService *services, + const otSrpClientService *removed_services, void *context); + static void srp_start_callback(const otSockAddr *server_socket_address, void *context); + static void srp_factory_reset_callback(otError err, const otSrpClientHostInfo *host_info, + const otSrpClientService *services, const otSrpClientService *removed_services, + void *context); protected: esphome::mdns::MDNSComponent *mdns_{nullptr}; From f43fb3c3a3d331273bb7f953022f94a3c64e4333 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 14:25:55 -0500 Subject: [PATCH 04/21] [core] Add millisecond precision to logging timestamps (#10677) --- esphome/__main__.py | 4 +++- esphome/components/api/client.py | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index 280f491924..bba254436e 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -226,7 +226,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int: .replace(b"\n", b"") .decode("utf8", "backslashreplace") ) - time_str = datetime.now().time().strftime("[%H:%M:%S]") + time_ = datetime.now() + nanoseconds = time_.microsecond // 1000 + time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" safe_print(parser.parse_line(line, time_str)) backtrace_state = platformio_api.process_stacktrace( diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py index ce018b3b98..ca1fc089fa 100644 --- a/esphome/components/api/client.py +++ b/esphome/components/api/client.py @@ -62,9 +62,11 @@ async def async_run_logs(config: dict[str, Any], addresses: list[str]) -> None: time_ = datetime.now() message: bytes = msg.message text = message.decode("utf8", "backslashreplace") - for parsed_msg in parse_log_message( - text, f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}]" - ): + nanoseconds = time_.microsecond // 1000 + timestamp = ( + f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]" + ) + for parsed_msg in parse_log_message(text, timestamp): print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg) stop = await async_run(cli, on_log, name=name) From eee64cc3a62f5c260ceb03d25f1baaa47b03d016 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:51:58 -0500 Subject: [PATCH 05/21] Add comprehensive tests for choose_upload_log_host to prevent regressions (#10679) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> --- tests/unit_tests/test_main.py | 512 ++++++++++++++++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 tests/unit_tests/test_main.py diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py new file mode 100644 index 0000000000..2c7236c7f8 --- /dev/null +++ b/tests/unit_tests/test_main.py @@ -0,0 +1,512 @@ +"""Unit tests for esphome.__main__ module.""" + +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from esphome.__main__ import choose_upload_log_host +from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI +from esphome.core import CORE + + +@dataclass +class MockSerialPort: + """Mock serial port for testing. + + Attributes: + path (str): The device path of the mock serial port (e.g., '/dev/ttyUSB0'). + description (str): A human-readable description of the mock serial port. + """ + + path: str + description: str + + +def setup_core( + config: dict[str, Any] | None = None, address: str | None = None +) -> None: + """ + Helper to set up CORE configuration with optional address. + + Args: + config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. + address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + """ + if config is None: + config = {} + + if address is not None: + # Set address via wifi config (could also use ethernet) + config[CONF_WIFI] = {CONF_USE_ADDRESS: address} + + CORE.config = config + + +@pytest.fixture +def mock_no_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return no ports.""" + with patch("esphome.__main__.get_serial_ports", return_value=[]) as mock: + yield mock + + +@pytest.fixture +def mock_serial_ports() -> Generator[Mock]: + """Mock get_serial_ports to return test ports.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + MockSerialPort("/dev/ttyUSB1", "Another USB Serial"), + ] + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports) as mock: + yield mock + + +@pytest.fixture +def mock_choose_prompt() -> Generator[Mock]: + """Mock choose_prompt to return default selection.""" + with patch("esphome.__main__.choose_prompt", return_value="/dev/ttyUSB0") as mock: + yield mock + + +@pytest.fixture +def mock_no_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return False.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=False) as mock: + yield mock + + +@pytest.fixture +def mock_has_mqtt_logging() -> Generator[Mock]: + """Mock has_mqtt_logging to return True.""" + with patch("esphome.__main__.has_mqtt_logging", return_value=True) as mock: + yield mock + + +def test_choose_upload_log_host_with_string_default() -> None: + """Test with a single string default device.""" + result = choose_upload_log_host( + default="192.168.1.100", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_list_default() -> None: + """Test with a list of default devices.""" + result = choose_upload_log_host( + default=["192.168.1.100", "192.168.1.101"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100", "192.168.1.101"] + + +def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: + """Test with multiple IP addresses as defaults.""" + result = choose_upload_log_host( + default=["1.2.3.4", "4.5.5.6"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["1.2.3.4", "4.5.5.6"] + + +def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: + """Test with a mix of hostnames and IP addresses.""" + result = choose_upload_log_host( + default=["host.one", "host.one.local", "1.2.3.4"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["host.one", "host.one.local", "1.2.3.4"] + + +def test_choose_upload_log_host_with_ota_list() -> None: + """Test with OTA as the only item in the list.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: + """Test with OTA list falling back to MQTT when no address.""" + setup_core() + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_with_serial_device_no_ports( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SERIAL device when no serial ports are found.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert "No serial ports found, skipping SERIAL device" in caplog.text + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_with_serial_device_with_ports( + mock_choose_prompt: Mock, +) -> None: + """Test SERIAL device when serial ports are available.""" + result = choose_upload_log_host( + default="SERIAL", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), + ], + purpose="testing", + ) + + +def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: + """Test OTA device when OTA is configured.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: + """Test OTA device when API is configured.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: + """Test OTA device fallback to MQTT when no OTA/API config.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: + """Test OTA device with no valid fallback options.""" + setup_core() + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=False, + ) + assert result == [] + + +@pytest.mark.usefixtures("mock_choose_prompt") +def test_choose_upload_log_host_multiple_devices() -> None: + """Test with multiple devices including special identifiers.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=["192.168.1.50", "OTA", "SERIAL"], + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] + + +def test_choose_upload_log_host_no_defaults_with_serial_ports( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with serial ports available.""" + mock_ports = [ + MockSerialPort("/dev/ttyUSB0", "USB Serial"), + ] + + setup_core() + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + purpose="uploading", + ) + assert result == ["/dev/ttyUSB0"] + mock_choose_prompt.assert_called_once_with( + [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], + purpose="uploading", + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_ota() -> None: + """Test interactive mode with OTA option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_no_defaults_with_api() -> None: + """Test interactive mode with API option.""" + setup_core(config={"api": {}}, address="192.168.1.100") + + with patch( + "esphome.__main__.choose_prompt", return_value="192.168.1.100" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + mock_prompt.assert_called_once_with( + [("Over The Air (192.168.1.100)", "192.168.1.100")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: + """Test interactive mode with MQTT option.""" + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + + with patch("esphome.__main__.choose_prompt", return_value="MQTT") as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=False, + show_mqtt=True, + show_api=False, + ) + assert result == ["MQTT"] + mock_prompt.assert_called_once_with( + [("MQTT (mqtt.local)", "MQTT")], + purpose=None, + ) + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_no_defaults_with_all_options( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + show_ota=True, + show_mqtt=True, + show_api=True, + purpose="testing", + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("MQTT (mqtt.local)", "MQTT"), + ] + mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_matches() -> None: + """Test when check_default matches an available option.""" + setup_core(config={"ota": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_check_default_no_match() -> None: + """Test when check_default doesn't match any available option.""" + setup_core() + + with patch( + "esphome.__main__.choose_prompt", return_value="fallback" + ) as mock_prompt: + result = choose_upload_log_host( + default=None, + check_default="192.168.1.100", + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["fallback"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports") +def test_choose_upload_log_host_empty_defaults_list() -> None: + """Test with an empty list as default.""" + with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: + result = choose_upload_log_host( + default=[], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["chosen"] + mock_prompt.assert_called_once() + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_all_devices_unresolved( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when all specified devices cannot be resolved.""" + setup_core() + + result = choose_upload_log_host( + default=["SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == [] + assert ( + "All specified devices: ['SERIAL', 'OTA'] could not be resolved." in caplog.text + ) + + +@pytest.mark.usefixtures("mock_no_serial_ports", "mock_no_mqtt_logging") +def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: + """Test with a mix of resolved and unresolved devices.""" + setup_core() + + result = choose_upload_log_host( + default=["192.168.1.50", "SERIAL", "OTA"], + check_default=None, + show_ota=False, + show_mqtt=False, + show_api=False, + ) + assert result == ["192.168.1.50"] + + +def test_choose_upload_log_host_ota_both_conditions() -> None: + """Test OTA device when both OTA and API are configured and enabled.""" + setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=True, + ) + assert result == ["192.168.1.100"] + + +@pytest.mark.usefixtures("mock_no_mqtt_logging") +def test_choose_upload_log_host_no_address_with_ota_config() -> None: + """Test OTA device when OTA is configured but no address is set.""" + setup_core(config={"ota": {}}) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + show_ota=True, + show_mqtt=False, + show_api=False, + ) + assert result == [] From 65f15a706f5bcb5ad344bfe39287dd3b381e4ec5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 15:52:46 -0500 Subject: [PATCH 06/21] Add some more coverage for dashboard web_server (#10682) --- tests/dashboard/test_web_server.py | 503 ++++++++++++++++++++++++++++- 1 file changed, 501 insertions(+), 2 deletions(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index b77ab7a7a3..a22f4a8b2a 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1,13 +1,16 @@ from __future__ import annotations import asyncio +from collections.abc import Generator +import gzip import json import os -from unittest.mock import Mock +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch import pytest import pytest_asyncio -from tornado.httpclient import AsyncHTTPClient, HTTPResponse +from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPResponse from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop from tornado.testing import bind_unused_port @@ -34,6 +37,66 @@ class DashboardTestHelper: return await future +@pytest.fixture +def mock_async_run_system_command() -> Generator[MagicMock]: + """Fixture to mock async_run_system_command.""" + with patch("esphome.dashboard.web_server.async_run_system_command") as mock: + yield mock + + +@pytest.fixture +def mock_trash_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock trash_storage_path.""" + trash_dir = tmp_path / "trash" + with patch( + "esphome.dashboard.web_server.trash_storage_path", return_value=str(trash_dir) + ) as mock: + yield mock + + +@pytest.fixture +def mock_archive_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock archive_storage_path.""" + archive_dir = tmp_path / "archive" + with patch( + "esphome.dashboard.web_server.archive_storage_path", + return_value=str(archive_dir), + ) as mock: + yield mock + + +@pytest.fixture +def mock_dashboard_settings() -> Generator[MagicMock]: + """Fixture to mock dashboard settings.""" + with patch("esphome.dashboard.web_server.settings") as mock_settings: + # Set default auth settings to avoid authentication issues + mock_settings.using_auth = False + mock_settings.on_ha_addon = False + yield mock_settings + + +@pytest.fixture +def mock_ext_storage_path(tmp_path: Path) -> Generator[MagicMock]: + """Fixture to mock ext_storage_path.""" + with patch("esphome.dashboard.web_server.ext_storage_path") as mock: + mock.return_value = str(tmp_path / "storage.json") + yield mock + + +@pytest.fixture +def mock_storage_json() -> Generator[MagicMock]: + """Fixture to mock StorageJSON.""" + with patch("esphome.dashboard.web_server.StorageJSON") as mock: + yield mock + + +@pytest.fixture +def mock_idedata() -> Generator[MagicMock]: + """Fixture to mock platformio_api.IDEData.""" + with patch("esphome.dashboard.web_server.platformio_api.IDEData") as mock: + yield mock + + @pytest_asyncio.fixture() async def dashboard() -> DashboardTestHelper: sock, port = bind_unused_port() @@ -80,3 +143,439 @@ async def test_devices_page(dashboard: DashboardTestHelper) -> None: first_device = configured_devices[0] assert first_device["name"] == "pico" assert first_device["configuration"] == "pico.yaml" + + +@pytest.mark.asyncio +async def test_wizard_handler_invalid_input(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post method with invalid inputs.""" + # Test with missing name (should fail with 422) + body_no_name = json.dumps( + { + "name": "", # Empty name + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_no_name, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + # Test with invalid wizard type (should fail with 422) + body_invalid_type = json.dumps( + { + "name": "test_device", + "type": "invalid_type", + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body_invalid_type, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_wizard_handler_conflict(dashboard: DashboardTestHelper) -> None: + """Test the WizardRequestHandler.post when config already exists.""" + # Try to create a wizard for existing pico.yaml (should conflict) + body = json.dumps( + { + "name": "pico", # This already exists in fixtures + "platform": "ESP32", + "board": "esp32dev", + } + ) + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/wizard", + method="POST", + body=body, + headers={"Content-Type": "application/json"}, + ) + assert exc_info.value.code == 409 + + +@pytest.mark.asyncio +async def test_download_binary_handler_not_found( + dashboard: DashboardTestHelper, +) -> None: + """Test the DownloadBinaryRequestHandler.get with non-existent config.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=nonexistent.yaml", + method="GET", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_no_file_param( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get without file parameter.""" + # Mock storage to exist, but still should fail without file param + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(tmp_path / "firmware.bin") + mock_storage_json.load.return_value = mock_storage + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/download.bin?configuration=pico.yaml", + method="GET", + ) + assert exc_info.value.code == 400 + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with existing binary file.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"fake firmware content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"fake firmware content" + assert response.headers["Content-Type"] == "application/octet-stream" + assert "attachment" in response.headers["Content-Disposition"] + assert "test_device-firmware.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_compressed( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with compression.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + original_content = b"fake firmware content for compression test" + firmware_file.write_bytes(original_content) + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&compressed=1", + method="GET", + ) + assert response.code == 200 + # Decompress and verify content + decompressed = gzip.decompress(response.body) + assert decompressed == original_content + assert response.headers["Content-Type"] == "application/octet-stream" + assert "firmware.bin.gz" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_custom_download_name( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_storage_json: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get with custom download name.""" + # Create a fake binary file + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=firmware.bin&download=custom_name.bin", + method="GET", + ) + assert response.code == 200 + assert "custom_name.bin" in response.headers["Content-Disposition"] + + +@pytest.mark.asyncio +@pytest.mark.usefixtures("mock_ext_storage_path") +async def test_download_binary_handler_idedata_fallback( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_async_run_system_command: MagicMock, + mock_storage_json: MagicMock, + mock_idedata: MagicMock, +) -> None: + """Test the DownloadBinaryRequestHandler.get falling back to idedata for extra images.""" + # Create build directory but no bootloader file initially + build_dir = tmp_path / ".esphome" / "build" / "test" + build_dir.mkdir(parents=True) + firmware_file = build_dir / "firmware.bin" + firmware_file.write_bytes(b"firmware") + + # Create bootloader file that idedata will find + bootloader_file = tmp_path / "bootloader.bin" + bootloader_file.write_bytes(b"bootloader content") + + # Mock storage JSON + mock_storage = Mock() + mock_storage.name = "test_device" + mock_storage.firmware_bin_path = str(firmware_file) + mock_storage_json.load.return_value = mock_storage + + # Mock idedata response + mock_image = Mock() + mock_image.path = str(bootloader_file) + mock_idedata_instance = Mock() + mock_idedata_instance.extra_flash_images = [mock_image] + mock_idedata.return_value = mock_idedata_instance + + # Mock async_run_system_command to return idedata JSON + mock_async_run_system_command.return_value = (0, '{"extra_flash_images": []}', "") + + response = await dashboard.fetch( + "/download.bin?configuration=test.yaml&file=bootloader.bin", + method="GET", + ) + assert response.code == 200 + assert response.body == b"bootloader content" + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_invalid_file( + dashboard: DashboardTestHelper, +) -> None: + """Test the EditRequestHandler.post with non-yaml file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/edit?configuration=test.txt", + method="POST", + body=b"content", + ) + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_edit_request_handler_post_existing( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the EditRequestHandler.post with existing yaml file.""" + # Create a temporary yaml file to edit (don't modify fixtures) + test_file = tmp_path / "test_edit.yaml" + test_file.write_text("esphome:\n name: original\n") + + # Configure the mock settings + mock_dashboard_settings.rel_path.return_value = str(test_file) + mock_dashboard_settings.absolute_config_dir = test_file.parent + + new_content = "esphome:\n name: modified\n" + response = await dashboard.fetch( + "/edit?configuration=test_edit.yaml", + method="POST", + body=new_content.encode(), + ) + assert response.code == 200 + + # Verify the file was actually modified + assert test_file.read_text() == new_content + + +@pytest.mark.asyncio +async def test_unarchive_request_handler( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_dashboard_settings: MagicMock, + tmp_path: Path, +) -> None: + """Test the UnArchiveRequestHandler.post method.""" + # Set up an archived file + archive_dir = Path(mock_archive_storage_path.return_value) + archive_dir.mkdir(parents=True, exist_ok=True) + archived_file = archive_dir / "archived.yaml" + archived_file.write_text("test content") + + # Set up the destination path where the file should be moved + config_dir = tmp_path / "config" + config_dir.mkdir(parents=True, exist_ok=True) + destination_file = config_dir / "archived.yaml" + mock_dashboard_settings.rel_path.return_value = str(destination_file) + + response = await dashboard.fetch( + "/unarchive?configuration=archived.yaml", + method="POST", + body=b"", + ) + assert response.code == 200 + + # Verify the file was actually moved from archive to config + assert not archived_file.exists() # File should be gone from archive + assert destination_file.exists() # File should now be in config + assert destination_file.read_text() == "test content" # Content preserved + + +@pytest.mark.asyncio +async def test_secret_keys_handler_no_file(dashboard: DashboardTestHelper) -> None: + """Test the SecretKeysRequestHandler.get when no secrets file exists.""" + # By default, there's no secrets file in the test fixtures + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/secret_keys", method="GET") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_secret_keys_handler_with_file( + dashboard: DashboardTestHelper, + tmp_path: Path, + mock_dashboard_settings: MagicMock, +) -> None: + """Test the SecretKeysRequestHandler.get when secrets file exists.""" + # Create a secrets file in temp directory + secrets_file = tmp_path / "secrets.yaml" + secrets_file.write_text( + "wifi_ssid: TestNetwork\nwifi_password: TestPass123\napi_key: test_key\n" + ) + + # Configure mock to return our temp secrets file + # Since the file actually exists, os.path.isfile will return True naturally + mock_dashboard_settings.rel_path.return_value = str(secrets_file) + + response = await dashboard.fetch("/secret_keys", method="GET") + assert response.code == 200 + data = json.loads(response.body.decode()) + assert "wifi_ssid" in data + assert "wifi_password" in data + assert "api_key" in data + + +@pytest.mark.asyncio +async def test_json_config_handler( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get method.""" + # This will actually run the esphome config command on pico.yaml + mock_output = json.dumps( + { + "esphome": {"name": "pico"}, + "esp32": {"board": "esp32dev"}, + } + ) + mock_async_run_system_command.return_value = (0, mock_output, "") + + response = await dashboard.fetch( + "/json-config?configuration=pico.yaml", method="GET" + ) + assert response.code == 200 + data = json.loads(response.body.decode()) + assert data["esphome"]["name"] == "pico" + + +@pytest.mark.asyncio +async def test_json_config_handler_invalid_config( + dashboard: DashboardTestHelper, + mock_async_run_system_command: MagicMock, +) -> None: + """Test the JsonConfigRequestHandler.get with invalid config.""" + # Simulate esphome config command failure + mock_async_run_system_command.return_value = (1, "", "Error: Invalid configuration") + + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/json-config?configuration=pico.yaml", method="GET") + assert exc_info.value.code == 422 + + +@pytest.mark.asyncio +async def test_json_config_handler_not_found(dashboard: DashboardTestHelper) -> None: + """Test the JsonConfigRequestHandler.get with non-existent file.""" + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch( + "/json-config?configuration=nonexistent.yaml", method="GET" + ) + assert exc_info.value.code == 404 + + +def test_start_web_server_with_address_port( + tmp_path: Path, + mock_trash_storage_path: MagicMock, + mock_archive_storage_path: MagicMock, +) -> None: + """Test the start_web_server function with address and port.""" + app = Mock() + trash_dir = Path(mock_trash_storage_path.return_value) + archive_dir = Path(mock_archive_storage_path.return_value) + + # Create trash dir to test migration + trash_dir.mkdir() + (trash_dir / "old.yaml").write_text("old") + + web_server.start_web_server(app, None, "127.0.0.1", 6052, str(tmp_path / "config")) + + # The function calls app.listen directly for non-socket mode + app.listen.assert_called_once_with(6052, "127.0.0.1") + + # Verify trash was moved to archive + assert not trash_dir.exists() + assert archive_dir.exists() + assert (archive_dir / "old.yaml").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") +@pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") +def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: + """Test the start_web_server function with unix socket.""" + app = Mock() + socket_path = tmp_path / "test.sock" + + # Don't create trash_dir - it doesn't exist, so no migration needed + with ( + patch("tornado.httpserver.HTTPServer") as mock_server_class, + patch("tornado.netutil.bind_unix_socket") as mock_bind, + ): + server = Mock() + mock_server_class.return_value = server + mock_bind.return_value = Mock() + + web_server.start_web_server( + app, str(socket_path), None, None, str(tmp_path / "config") + ) + + mock_server_class.assert_called_once_with(app) + mock_bind.assert_called_once_with(str(socket_path), mode=0o666) + server.add_socket.assert_called_once() From 56e9fd2e38549f39968b5a288b39ae190575772c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 00:04:22 -0500 Subject: [PATCH 07/21] [tests] Add upload_program and show_logs test coverage to prevent regressions (#10684) --- tests/unit_tests/test_main.py | 553 +++++++++++++++++++++++++++++++++- 1 file changed, 548 insertions(+), 5 deletions(-) diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 2c7236c7f8..41b62ec196 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -4,14 +4,33 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import dataclass +from pathlib import Path from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import MagicMock, Mock, patch import pytest -from esphome.__main__ import choose_upload_log_host -from esphome.const import CONF_BROKER, CONF_MQTT, CONF_USE_ADDRESS, CONF_WIFI -from esphome.core import CORE +from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.const import ( + CONF_BROKER, + CONF_DISABLED, + CONF_ESPHOME, + CONF_MDNS, + CONF_MQTT, + CONF_OTA, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_PORT, + CONF_USE_ADDRESS, + CONF_WIFI, + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_BK72XX, + PLATFORM_ESP32, + PLATFORM_ESP8266, + PLATFORM_RP2040, +) +from esphome.core import CORE, EsphomeError @dataclass @@ -28,7 +47,11 @@ class MockSerialPort: def setup_core( - config: dict[str, Any] | None = None, address: str | None = None + config: dict[str, Any] | None = None, + address: str | None = None, + platform: str | None = None, + tmp_path: Path | None = None, + name: str = "test", ) -> None: """ Helper to set up CORE configuration with optional address. @@ -36,6 +59,9 @@ def setup_core( Args: config (dict[str, Any] | None): The configuration dictionary to set for CORE. If None, an empty dict is used. address (str | None): Optional network address to set in the configuration. If provided, it is set under the wifi config. + platform (str | None): Optional target platform to set in CORE.data. + tmp_path (Path | None): Optional temp path for setting up build paths. + name (str): The name of the device (defaults to "test"). """ if config is None: config = {} @@ -46,6 +72,15 @@ def setup_core( CORE.config = config + if platform is not None: + CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] = platform + + if tmp_path is not None: + CORE.config_path = str(tmp_path / f"{name}.yaml") + CORE.name = name + CORE.build_path = str(tmp_path / ".esphome" / "build" / name) + @pytest.fixture def mock_no_serial_ports() -> Generator[Mock]: @@ -54,6 +89,55 @@ def mock_no_serial_ports() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_get_port_type() -> Generator[Mock]: + """Mock get_port_type for testing.""" + with patch("esphome.__main__.get_port_type") as mock: + yield mock + + +@pytest.fixture +def mock_check_permissions() -> Generator[Mock]: + """Mock check_permissions for testing.""" + with patch("esphome.__main__.check_permissions") as mock: + yield mock + + +@pytest.fixture +def mock_run_miniterm() -> Generator[Mock]: + """Mock run_miniterm for testing.""" + with patch("esphome.__main__.run_miniterm") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_esptool() -> Generator[Mock]: + """Mock upload_using_esptool for testing.""" + with patch("esphome.__main__.upload_using_esptool") as mock: + yield mock + + +@pytest.fixture +def mock_upload_using_platformio() -> Generator[Mock]: + """Mock upload_using_platformio for testing.""" + with patch("esphome.__main__.upload_using_platformio") as mock: + yield mock + + +@pytest.fixture +def mock_run_ota() -> Generator[Mock]: + """Mock espota2.run_ota for testing.""" + with patch("esphome.espota2.run_ota") as mock: + yield mock + + +@pytest.fixture +def mock_is_ip_address() -> Generator[Mock]: + """Mock is_ip_address for testing.""" + with patch("esphome.__main__.is_ip_address") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -510,3 +594,462 @@ def test_choose_upload_log_host_no_address_with_ota_config() -> None: show_api=False, ) assert result == [] + + +@dataclass +class MockArgs: + """Mock args for testing.""" + + file: str | None = None + upload_speed: int = 460800 + username: str | None = None + password: str | None = None + client_id: str | None = None + topic: str | None = None + + +def test_upload_program_serial_esp32( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP32.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_serial_esp8266_with_file( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program with serial port for ESP8266 with custom file.""" + setup_core(platform=PLATFORM_ESP8266) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 0 + + config = {} + args = MockArgs(file="firmware.bin") + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "/dev/ttyUSB0" + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once_with( + config, "/dev/ttyUSB0", "firmware.bin", 460800 + ) + + +@pytest.mark.parametrize( + "platform,device", + [ + (PLATFORM_RP2040, "/dev/ttyACM0"), + (PLATFORM_BK72XX, "/dev/ttyUSB0"), # LibreTiny platform + ], +) +def test_upload_program_serial_platformio_platforms( + mock_upload_using_platformio: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, + platform: str, + device: str, +) -> None: + """Test upload_program with serial port for platformio platforms (RP2040/LibreTiny).""" + setup_core(platform=platform) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_platformio.return_value = 0 + + config = {} + args = MockArgs() + devices = [device] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == device + mock_check_permissions.assert_called_once_with(device) + mock_upload_using_platformio.assert_called_once_with(config, device) + + +def test_upload_program_serial_upload_failed( + mock_upload_using_esptool: Mock, + mock_get_port_type: Mock, + mock_check_permissions: Mock, +) -> None: + """Test upload_program when serial upload fails.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_upload_using_esptool.return_value = 1 # Failed + + config = {} + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 1 + assert host is None + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_upload_using_esptool.assert_called_once() + + +def test_upload_program_ota_success( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + CONF_PASSWORD: "secret", + } + ] + } + args = MockArgs() + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.100"], 3232, "secret", expected_firmware + ) + + +def test_upload_program_ota_with_file_arg( + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA and custom file.""" + setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.return_value = "NETWORK" + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ] + } + args = MockArgs(file="custom.bin") + devices = ["192.168.1.100"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", "custom.bin") + + +def test_upload_program_ota_no_config( + mock_get_port_type: Mock, +) -> None: + """Test upload_program with OTA but no OTA config.""" + setup_core(platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "NETWORK" + + config = {} # No OTA config + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises(EsphomeError, match="Cannot upload Over the Air"): + upload_program(config, args, devices) + + +@patch("esphome.mqtt.get_esphome_device_ip") +def test_upload_program_ota_with_mqtt_resolution( + mock_mqtt_get_ip: Mock, + mock_is_ip_address: Mock, + mock_run_ota: Mock, + mock_get_port_type: Mock, + tmp_path: Path, +) -> None: + """Test upload_program with OTA using MQTT for address resolution.""" + setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_get_port_type.side_effect = ["MQTT", "NETWORK"] + mock_is_ip_address.return_value = False + mock_mqtt_get_ip.return_value = ["192.168.1.100"] + mock_run_ota.return_value = (0, "192.168.1.100") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["MQTT"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.100" + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + expected_firmware = str( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + [["192.168.1.100"]], 3232, "", expected_firmware + ) + + +@patch("esphome.__main__.importlib.import_module") +def test_upload_program_platform_specific_handler( + mock_import: Mock, + mock_get_port_type: Mock, +) -> None: + """Test upload_program with platform-specific upload handler.""" + setup_core(platform="custom_platform") + mock_get_port_type.return_value = "CUSTOM" + + mock_module = MagicMock() + mock_module.upload_program.return_value = True + mock_import.return_value = mock_module + + config = {} + args = MockArgs() + devices = ["custom_device"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "custom_device" + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.upload_program.assert_called_once_with(config, args, "custom_device") + + +def test_show_logs_serial( + mock_get_port_type: Mock, + mock_check_permissions: Mock, + mock_run_miniterm: Mock, +) -> None: + """Test show_logs with serial port.""" + setup_core(config={"logger": {}}, platform=PLATFORM_ESP32) + mock_get_port_type.return_value = "SERIAL" + mock_run_miniterm.return_value = 0 + + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_check_permissions.assert_called_once_with("/dev/ttyUSB0") + mock_run_miniterm.assert_called_once_with(CORE.config, "/dev/ttyUSB0", args) + + +def test_show_logs_no_logger() -> None: + """Test show_logs when logger is not configured.""" + setup_core(config={}, platform=PLATFORM_ESP32) # No logger config + args = MockArgs() + devices = ["/dev/ttyUSB0"] + + with pytest.raises(EsphomeError, match="Logger is not configured"): + show_logs(CORE.config, args, devices) + + +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api( + mock_run_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: False}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + + args = MockArgs() + devices = ["192.168.1.100", "192.168.1.101"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_run_logs.assert_called_once_with( + CORE.config, ["192.168.1.100", "192.168.1.101"] + ) + + +@patch("esphome.mqtt.get_esphome_device_ip") +@patch("esphome.components.api.client.run_logs") +def test_show_logs_api_with_mqtt_fallback( + mock_run_logs: Mock, + mock_mqtt_get_ip: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with API using MQTT for address resolution.""" + setup_core( + config={ + "logger": {}, + "api": {}, + CONF_MDNS: {CONF_DISABLED: True}, + CONF_MQTT: {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_run_logs.return_value = 0 + mock_mqtt_get_ip.return_value = ["192.168.1.200"] + + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["device.local"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_get_ip.assert_called_once_with(CORE.config, "user", "pass", "client") + mock_run_logs.assert_called_once_with(CORE.config, ["192.168.1.200"]) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_mqtt( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with MQTT.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "MQTT" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["MQTT"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +@patch("esphome.mqtt.show_logs") +def test_show_logs_network_with_mqtt_only( + mock_mqtt_show_logs: Mock, + mock_get_port_type: Mock, +) -> None: + """Test show_logs with network port but only MQTT configured.""" + setup_core( + config={ + "logger": {}, + "mqtt": {CONF_BROKER: "mqtt.local"}, + # No API configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + mock_mqtt_show_logs.return_value = 0 + + args = MockArgs( + topic="esphome/logs", + username="user", + password="pass", + client_id="client", + ) + devices = ["192.168.1.100"] + + result = show_logs(CORE.config, args, devices) + + assert result == 0 + mock_mqtt_show_logs.assert_called_once_with( + CORE.config, "esphome/logs", "user", "pass", "client" + ) + + +def test_show_logs_no_method_configured( + mock_get_port_type: Mock, +) -> None: + """Test show_logs when no remote logging method is configured.""" + setup_core( + config={ + "logger": {}, + # No API or MQTT configured + }, + platform=PLATFORM_ESP32, + ) + mock_get_port_type.return_value = "NETWORK" + + args = MockArgs() + devices = ["192.168.1.100"] + + with pytest.raises( + EsphomeError, match="No remote or local logging method configured" + ): + show_logs(CORE.config, args, devices) + + +@patch("esphome.__main__.importlib.import_module") +def test_show_logs_platform_specific_handler( + mock_import: Mock, +) -> None: + """Test show_logs with platform-specific logs handler.""" + setup_core(platform="custom_platform", config={"logger": {}}) + + mock_module = MagicMock() + mock_module.show_logs.return_value = True + mock_import.return_value = mock_module + + config = {"logger": {}} + args = MockArgs() + devices = ["custom_device"] + + result = show_logs(config, args, devices) + + assert result == 0 + mock_import.assert_called_once_with("esphome.components.custom_platform") + mock_module.show_logs.assert_called_once_with(config, args, devices) From 5b702a1efaeef42cad71ff4acf52e0b798aefa90 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 12 Sep 2025 16:04:56 -0500 Subject: [PATCH 08/21] Add additional dashboard and main tests (#10688) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/dashboard/test_web_server.py | 60 +++++++++ tests/unit_tests/test_main.py | 197 ++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 1 deletion(-) diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index a22f4a8b2a..e206090ac0 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -556,6 +556,66 @@ def test_start_web_server_with_address_port( assert (archive_dir / "old.yaml").exists() +@pytest.mark.asyncio +async def test_edit_request_handler_get(dashboard: DashboardTestHelper) -> None: + """Test EditRequestHandler.get method.""" + # Test getting a valid yaml file + response = await dashboard.fetch("/edit?configuration=pico.yaml") + assert response.code == 200 + assert response.headers["content-type"] == "application/yaml" + content = response.body.decode() + assert "esphome:" in content # Verify it's a valid ESPHome config + + # Test getting a non-existent file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=nonexistent.yaml") + assert exc_info.value.code == 404 + + # Test getting a non-yaml file + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=test.txt") + assert exc_info.value.code == 404 + + # Test path traversal attempt + with pytest.raises(HTTPClientError) as exc_info: + await dashboard.fetch("/edit?configuration=../../../etc/passwd") + assert exc_info.value.code == 404 + + +@pytest.mark.asyncio +async def test_archive_request_handler_post( + dashboard: DashboardTestHelper, + mock_archive_storage_path: MagicMock, + mock_ext_storage_path: MagicMock, + tmp_path: Path, +) -> None: + """Test ArchiveRequestHandler.post method.""" + + # Set up temp directories + config_dir = Path(get_fixture_path("conf")) + archive_dir = tmp_path / "archive" + + # Create a test configuration file + test_config = config_dir / "test_archive.yaml" + test_config.write_text("esphome:\n name: test_archive\n") + + # Archive the configuration + response = await dashboard.fetch( + "/archive", + method="POST", + body="configuration=test_archive.yaml", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.code == 200 + + # Verify file was moved to archive + assert not test_config.exists() + assert (archive_dir / "test_archive.yaml").exists() + assert ( + archive_dir / "test_archive.yaml" + ).read_text() == "esphome:\n name: test_archive\n" + + @pytest.mark.skipif(os.name == "nt", reason="Unix sockets are not supported on Windows") @pytest.mark.usefixtures("mock_trash_storage_path", "mock_archive_storage_path") def test_start_web_server_with_unix_socket(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 41b62ec196..96ee43a55f 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -9,18 +9,27 @@ from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest +from pytest import CaptureFixture -from esphome.__main__ import choose_upload_log_host, show_logs, upload_program +from esphome.__main__ import ( + choose_upload_log_host, + command_rename, + command_wizard, + show_logs, + upload_program, +) from esphome.const import ( CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, CONF_MDNS, CONF_MQTT, + CONF_NAME, CONF_OTA, CONF_PASSWORD, CONF_PLATFORM, CONF_PORT, + CONF_SUBSTITUTIONS, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -170,6 +179,14 @@ def mock_has_mqtt_logging() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_run_external_process() -> Generator[Mock]: + """Mock run_external_process for testing.""" + with patch("esphome.__main__.run_external_process") as mock: + mock.return_value = 0 # Default to success + yield mock + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" result = choose_upload_log_host( @@ -606,6 +623,9 @@ class MockArgs: password: str | None = None client_id: str | None = None topic: str | None = None + configuration: str | None = None + name: str | None = None + dashboard: bool = False def test_upload_program_serial_esp32( @@ -1053,3 +1073,178 @@ def test_show_logs_platform_specific_handler( assert result == 0 mock_import.assert_called_once_with("esphome.components.custom_platform") mock_module.show_logs.assert_called_once_with(config, args, devices) + + +def test_command_wizard(tmp_path: Path) -> None: + """Test command_wizard function.""" + config_file = tmp_path / "test.yaml" + + # Mock wizard.wizard to avoid interactive prompts + with patch("esphome.wizard.wizard") as mock_wizard: + mock_wizard.return_value = 0 + + args = MockArgs(configuration=str(config_file)) + result = command_wizard(args) + + assert result == 0 + mock_wizard.assert_called_once_with(str(config_file)) + + +def test_command_rename_invalid_characters( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with invalid characters in name.""" + setup_core(tmp_path=tmp_path) + + # Test with invalid character (space) + args = MockArgs(name="invalid name") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "invalid character" in captured.out.lower() + + +def test_command_rename_complex_yaml( + tmp_path: Path, capfd: CaptureFixture[str] +) -> None: + """Test command_rename with complex YAML that cannot be renamed.""" + config_file = tmp_path / "test.yaml" + config_file.write_text("# Complex YAML without esphome section\nsome_key: value\n") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname") + result = command_rename(args, {}) + + assert result == 1 + captured = capfd.readouterr() + assert "complex yaml" in captured.out.lower() + + +def test_command_rename_success( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test successful rename of a simple configuration.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s + +wifi: + ssid: "test" + password: "test1234" +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = {CONF_ESPHOME: {CONF_NAME: "oldname"}} + + args = MockArgs(name="newname", dashboard=False) + + # Simulate successful validation and upload + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify new file was created + new_file = tmp_path / "newname.yaml" + assert new_file.exists() + + # Verify old file was removed + assert not config_file.exists() + + # Verify content was updated + content = new_file.read_text() + assert ( + 'name: "newname"' in content + or "name: 'newname'" in content + or "name: newname" in content + ) + + captured = capfd.readouterr() + assert "SUCCESS" in captured.out + + +def test_command_rename_with_substitutions( + tmp_path: Path, + mock_run_external_process: Mock, +) -> None: + """Test rename with substitutions in YAML.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +substitutions: + device_name: oldname + +esphome: + name: ${device_name} + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + # Set up CORE.config to avoid ValueError when accessing CORE.address + CORE.config = { + CONF_ESPHOME: {CONF_NAME: "oldname"}, + CONF_SUBSTITUTIONS: {"device_name": "oldname"}, + } + + args = MockArgs(name="newname", dashboard=False) + + mock_run_external_process.return_value = 0 + + result = command_rename(args, {}) + + assert result == 0 + + # Verify substitution was updated + new_file = tmp_path / "newname.yaml" + content = new_file.read_text() + assert 'device_name: "newname"' in content + + +def test_command_rename_validation_failure( + tmp_path: Path, + capfd: CaptureFixture[str], + mock_run_external_process: Mock, +) -> None: + """Test rename when validation fails.""" + config_file = tmp_path / "oldname.yaml" + config_file.write_text(""" +esphome: + name: oldname + +esp32: + board: nodemcu-32s +""") + setup_core(tmp_path=tmp_path) + CORE.config_path = str(config_file) + + args = MockArgs(name="newname", dashboard=False) + + # First call for validation fails + mock_run_external_process.return_value = 1 + + result = command_rename(args, {}) + + assert result == 1 + + # Verify new file was created but then removed due to failure + new_file = tmp_path / "newname.yaml" + assert not new_file.exists() + + # Verify old file still exists (not removed on failure) + assert config_file.exists() + + captured = capfd.readouterr() + assert "Rename failed" in captured.out From 46235684b14c176e36b0ab1ad77d6c8d2cd7fdc8 Mon Sep 17 00:00:00 2001 From: Markus <974709+Links2004@users.noreply.github.com> Date: Fri, 12 Sep 2025 23:31:53 +0200 Subject: [PATCH 09/21] [core] fix upload to device via MQTT IP lookup (e.g. when mDNS is disable) (#10632) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/__main__.py | 210 +++++++++----- esphome/const.py | 1 + tests/unit_tests/test_main.py | 523 ++++++++++++++++++++++++++-------- 3 files changed, 547 insertions(+), 187 deletions(-) diff --git a/esphome/__main__.py b/esphome/__main__.py index bba254436e..0147a82530 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -15,9 +15,11 @@ import argcomplete from esphome import const, writer, yaml_util import esphome.codegen as cg +from esphome.components.mqtt import CONF_DISCOVER_IP from esphome.config import iter_component_configs, read_config, strip_default_ids from esphome.const import ( ALLOWED_NAME_CHARS, + CONF_API, CONF_BAUD_RATE, CONF_BROKER, CONF_DEASSERT_RTS_DTR, @@ -43,6 +45,7 @@ from esphome.const import ( SECRETS_FILES, ) from esphome.core import CORE, EsphomeError, coroutine +from esphome.enum import StrEnum from esphome.helpers import get_bool_env, indent, is_ip_address from esphome.log import AnsiFore, color, setup_log from esphome.types import ConfigType @@ -106,13 +109,15 @@ def choose_prompt(options, purpose: str = None): return options[opt - 1][1] +class Purpose(StrEnum): + UPLOADING = "uploading" + LOGGING = "logging" + + def choose_upload_log_host( default: list[str] | str | None, check_default: str | None, - show_ota: bool, - show_mqtt: bool, - show_api: bool, - purpose: str | None = None, + purpose: Purpose, ) -> list[str]: # Convert to list for uniform handling defaults = [default] if isinstance(default, str) else default or [] @@ -132,13 +137,30 @@ def choose_upload_log_host( ] resolved.append(choose_prompt(options, purpose=purpose)) elif device == "OTA": - if CORE.address and ( - (show_ota and "ota" in CORE.config) - or (show_api and "api" in CORE.config) + # ensure IP adresses are used first + if is_ip_address(CORE.address) and ( + (purpose == Purpose.LOGGING and has_api()) + or (purpose == Purpose.UPLOADING and has_ota()) ): resolved.append(CORE.address) - elif show_mqtt and has_mqtt_logging(): - resolved.append("MQTT") + + if purpose == Purpose.LOGGING: + if has_api() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_mqtt_logging(): + resolved.append("MQTT") + + if has_api() and has_non_ip_address(): + resolved.append(CORE.address) + + elif purpose == Purpose.UPLOADING: + if has_ota() and has_mqtt_ip_lookup(): + resolved.append("MQTTIP") + + if has_ota() and has_non_ip_address(): + resolved.append(CORE.address) + else: resolved.append(device) if not resolved: @@ -149,39 +171,111 @@ def choose_upload_log_host( options = [ (f"{port.path} ({port.description})", port.path) for port in get_serial_ports() ] - if (show_ota and "ota" in CORE.config) or (show_api and "api" in CORE.config): - options.append((f"Over The Air ({CORE.address})", CORE.address)) - if show_mqtt and has_mqtt_logging(): - mqtt_config = CORE.config[CONF_MQTT] - options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if purpose == Purpose.LOGGING: + if has_mqtt_logging(): + mqtt_config = CORE.config[CONF_MQTT] + options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) + + if has_api(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) + + elif purpose == Purpose.UPLOADING and has_ota(): + if has_resolvable_address(): + options.append((f"Over The Air ({CORE.address})", CORE.address)) + if has_mqtt_ip_lookup(): + options.append(("Over The Air (MQTT IP lookup)", "MQTTIP")) if check_default is not None and check_default in [opt[1] for opt in options]: return [check_default] return [choose_prompt(options, purpose=purpose)] -def mqtt_logging_enabled(mqtt_config): +def has_mqtt_logging() -> bool: + """Check if MQTT logging is available.""" + if CONF_MQTT not in CORE.config: + return False + + mqtt_config = CORE.config[CONF_MQTT] + + # enabled by default + if CONF_LOG_TOPIC not in mqtt_config: + return True + log_topic = mqtt_config[CONF_LOG_TOPIC] if log_topic is None: return False + if CONF_TOPIC not in log_topic: return False - return log_topic.get(CONF_LEVEL, None) != "NONE" + + return log_topic[CONF_LEVEL] != "NONE" -def has_mqtt_logging() -> bool: - """Check if MQTT logging is available.""" - return (mqtt_config := CORE.config.get(CONF_MQTT)) and mqtt_logging_enabled( - mqtt_config - ) +def has_mqtt() -> bool: + """Check if MQTT is available.""" + return CONF_MQTT in CORE.config + + +def has_api() -> bool: + """Check if API is available.""" + return CONF_API in CORE.config + + +def has_ota() -> bool: + """Check if OTA is available.""" + return CONF_OTA in CORE.config + + +def has_mqtt_ip_lookup() -> bool: + """Check if MQTT is available and IP lookup is supported.""" + if CONF_MQTT not in CORE.config: + return False + # Default Enabled + if CONF_DISCOVER_IP not in CORE.config[CONF_MQTT]: + return True + return CORE.config[CONF_MQTT][CONF_DISCOVER_IP] + + +def has_mdns() -> bool: + """Check if MDNS is available.""" + return CONF_MDNS not in CORE.config or not CORE.config[CONF_MDNS][CONF_DISABLED] + + +def has_non_ip_address() -> bool: + """Check if CORE.address is set and is not an IP address.""" + return CORE.address is not None and not is_ip_address(CORE.address) + + +def has_ip_address() -> bool: + """Check if CORE.address is a valid IP address.""" + return CORE.address is not None and is_ip_address(CORE.address) + + +def has_resolvable_address() -> bool: + """Check if CORE.address is resolvable (via mDNS or is an IP address).""" + return has_mdns() or has_ip_address() + + +def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str): + from esphome import mqtt + + return mqtt.get_esphome_device_ip(config, username, password, client_id) + + +_PORT_TO_PORT_TYPE = { + "MQTT": "MQTT", + "MQTTIP": "MQTTIP", +} def get_port_type(port: str) -> str: if port.startswith("/") or port.startswith("COM"): return "SERIAL" - if port == "MQTT": - return "MQTT" - return "NETWORK" + return _PORT_TO_PORT_TYPE.get(port, "NETWORK") def run_miniterm(config: ConfigType, port: str, args) -> int: @@ -439,23 +533,9 @@ def upload_program( password = ota_conf.get(CONF_PASSWORD, "") binary = args.file if getattr(args, "file", None) is not None else CORE.firmware_bin - # Check if we should use MQTT for address resolution - # This happens when no device was specified, or the current host is "MQTT"/"OTA" - if ( - CONF_MQTT in config # pylint: disable=too-many-boolean-expressions - and (not devices or host in ("MQTT", "OTA")) - and ( - ((config[CONF_MDNS][CONF_DISABLED]) and not is_ip_address(CORE.address)) - or get_port_type(host) == "MQTT" - ) - ): - from esphome import mqtt - - devices = [ - mqtt.get_esphome_device_ip( - config, args.username, args.password, args.client_id - ) - ] + # MQTT address resolution + if get_port_type(host) in ("MQTT", "MQTTIP"): + devices = mqtt_get_ip(config, args.username, args.password, args.client_id) return espota2.run_ota(devices, remote_port, password, binary) @@ -476,20 +556,28 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int if get_port_type(port) == "SERIAL": check_permissions(port) return run_miniterm(config, port, args) - if get_port_type(port) == "NETWORK" and "api" in config: - addresses_to_use = devices - if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: - from esphome import mqtt - mqtt_address = mqtt.get_esphome_device_ip( + port_type = get_port_type(port) + + # Check if we should use API for logging + if has_api(): + addresses_to_use: list[str] | None = None + + if port_type == "NETWORK" and (has_mdns() or is_ip_address(port)): + addresses_to_use = devices + elif port_type in ("NETWORK", "MQTT", "MQTTIP") and has_mqtt_ip_lookup(): + # Only use MQTT IP lookup if the first condition didn't match + # (for MQTT/MQTTIP types, or for NETWORK when mdns/ip check fails) + addresses_to_use = mqtt_get_ip( config, args.username, args.password, args.client_id - )[0] - addresses_to_use = [mqtt_address] + ) - from esphome.components.api.client import run_logs + if addresses_to_use is not None: + from esphome.components.api.client import run_logs - return run_logs(config, addresses_to_use) - if get_port_type(port) in ("NETWORK", "MQTT") and "mqtt" in config: + return run_logs(config, addresses_to_use) + + if port_type in ("NETWORK", "MQTT") and has_mqtt_logging(): from esphome import mqtt return mqtt.show_logs( @@ -555,10 +643,7 @@ def command_upload(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, _ = upload_program(config, args, devices) @@ -583,10 +668,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) @@ -612,10 +694,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=args.device, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, - purpose="uploading", + purpose=Purpose.UPLOADING, ) exit_code, successful_device = upload_program(config, args, devices) @@ -632,10 +711,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None: devices = choose_upload_log_host( default=successful_device, check_default=successful_device, - show_ota=False, - show_mqtt=True, - show_api=True, - purpose="logging", + purpose=Purpose.LOGGING, ) return show_logs(config, args, devices) diff --git a/esphome/const.py b/esphome/const.py index a77f98c292..4655931823 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -114,6 +114,7 @@ CONF_AND = "and" CONF_ANGLE = "angle" CONF_ANY = "any" CONF_AP = "ap" +CONF_API = "api" CONF_APPARENT_POWER = "apparent_power" CONF_ARDUINO_VERSION = "arduino_version" CONF_AREA = "area" diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 96ee43a55f..bfebb44545 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -12,16 +12,28 @@ import pytest from pytest import CaptureFixture from esphome.__main__ import ( + Purpose, choose_upload_log_host, command_rename, command_wizard, + get_port_type, + has_ip_address, + has_mqtt, + has_mqtt_ip_lookup, + has_mqtt_logging, + has_non_ip_address, + has_resolvable_address, + mqtt_get_ip, show_logs, upload_program, ) from esphome.const import ( + CONF_API, CONF_BROKER, CONF_DISABLED, CONF_ESPHOME, + CONF_LEVEL, + CONF_LOG_TOPIC, CONF_MDNS, CONF_MQTT, CONF_NAME, @@ -30,6 +42,7 @@ from esphome.const import ( CONF_PLATFORM, CONF_PORT, CONF_SUBSTITUTIONS, + CONF_TOPIC, CONF_USE_ADDRESS, CONF_WIFI, KEY_CORE, @@ -147,6 +160,13 @@ def mock_is_ip_address() -> Generator[Mock]: yield mock +@pytest.fixture +def mock_mqtt_get_ip() -> Generator[Mock]: + """Mock mqtt_get_ip for testing.""" + with patch("esphome.__main__.mqtt_get_ip") as mock: + yield mock + + @pytest.fixture def mock_serial_ports() -> Generator[Mock]: """Mock get_serial_ports to return test ports.""" @@ -189,62 +209,56 @@ def mock_run_external_process() -> Generator[Mock]: def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" + setup_core() result = choose_upload_log_host( default="192.168.1.100", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_list_default() -> None: """Test with a list of default devices.""" + setup_core() result = choose_upload_log_host( default=["192.168.1.100", "192.168.1.101"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100", "192.168.1.101"] def test_choose_upload_log_host_with_multiple_ip_addresses() -> None: """Test with multiple IP addresses as defaults.""" + setup_core() result = choose_upload_log_host( default=["1.2.3.4", "4.5.5.6"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["1.2.3.4", "4.5.5.6"] def test_choose_upload_log_host_with_mixed_hostnames_and_ips() -> None: """Test with a mix of hostnames and IP addresses.""" + setup_core() result = choose_upload_log_host( default=["host.one", "host.one.local", "1.2.3.4"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["host.one", "host.one.local", "1.2.3.4"] def test_choose_upload_log_host_with_ota_list() -> None: """Test with OTA as the only item in the list.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -252,16 +266,27 @@ def test_choose_upload_log_host_with_ota_list() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_list_mqtt_fallback() -> None: """Test with OTA list falling back to MQTT when no address.""" - setup_core() + setup_core(config={CONF_OTA: {}, "mqtt": {}}) result = choose_upload_log_host( default=["OTA"], check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) - assert result == ["MQTT"] + assert result == ["MQTTIP"] + + +@pytest.mark.usefixtures("mock_has_mqtt_logging") +def test_choose_upload_log_host_with_ota_list_mqtt_fallback_logging() -> None: + """Test with OTA list with API and MQTT when no address.""" + setup_core(config={CONF_API: {}, "mqtt": {}}) + + result = choose_upload_log_host( + default=["OTA"], + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT"] @pytest.mark.usefixtures("mock_no_serial_ports") @@ -269,12 +294,11 @@ def test_choose_upload_log_host_with_serial_device_no_ports( caplog: pytest.LogCaptureFixture, ) -> None: """Test SERIAL device when no serial ports are found.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert "No serial ports found, skipping SERIAL device" in caplog.text @@ -285,13 +309,11 @@ def test_choose_upload_log_host_with_serial_device_with_ports( mock_choose_prompt: Mock, ) -> None: """Test SERIAL device when serial ports are available.""" + setup_core() result = choose_upload_log_host( default="SERIAL", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( @@ -299,34 +321,42 @@ def test_choose_upload_log_host_with_serial_device_with_ports( ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("/dev/ttyUSB1 (Another USB Serial)", "/dev/ttyUSB1"), ], - purpose="testing", + purpose=Purpose.UPLOADING, ) def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None: """Test OTA device when OTA is configured.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: - """Test OTA device when API is configured.""" - setup_core(config={"api": {}}, address="192.168.1.100") + """Test OTA device when API is configured (no upload without OTA in config).""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, + ) + assert result == [] + + +def test_choose_upload_log_host_with_ota_device_with_api_config_logging() -> None: + """Test OTA device when API is configured.""" + setup_core(config={CONF_API: {}}, address="192.168.1.100") + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] @@ -334,14 +364,12 @@ def test_choose_upload_log_host_with_ota_device_with_api_config() -> None: @pytest.mark.usefixtures("mock_has_mqtt_logging") def test_choose_upload_log_host_with_ota_device_fallback_to_mqtt() -> None: """Test OTA device fallback to MQTT when no OTA/API config.""" - setup_core() + setup_core(config={"mqtt": {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] @@ -354,9 +382,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=True, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -364,7 +390,7 @@ def test_choose_upload_log_host_with_ota_device_no_fallback() -> None: @pytest.mark.usefixtures("mock_choose_prompt") def test_choose_upload_log_host_multiple_devices() -> None: """Test with multiple devices including special identifiers.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] @@ -372,9 +398,7 @@ def test_choose_upload_log_host_multiple_devices() -> None: result = choose_upload_log_host( default=["192.168.1.50", "OTA", "SERIAL"], check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50", "192.168.1.100", "/dev/ttyUSB0"] @@ -393,22 +417,19 @@ def test_choose_upload_log_host_no_defaults_with_serial_ports( result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, - purpose="uploading", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] mock_choose_prompt.assert_called_once_with( [("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0")], - purpose="uploading", + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_ota() -> None: """Test interactive mode with OTA option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -416,21 +437,19 @@ def test_choose_upload_log_host_no_defaults_with_ota() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.UPLOADING, ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_no_defaults_with_api() -> None: """Test interactive mode with API option.""" - setup_core(config={"api": {}}, address="192.168.1.100") + setup_core(config={CONF_API: {}}, address="192.168.1.100") with patch( "esphome.__main__.choose_prompt", return_value="192.168.1.100" @@ -438,14 +457,12 @@ def test_choose_upload_log_host_no_defaults_with_api() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=False, - show_api=True, + purpose=Purpose.LOGGING, ) assert result == ["192.168.1.100"] mock_prompt.assert_called_once_with( [("Over The Air (192.168.1.100)", "192.168.1.100")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -458,14 +475,12 @@ def test_choose_upload_log_host_no_defaults_with_mqtt() -> None: result = choose_upload_log_host( default=None, check_default=None, - show_ota=False, - show_mqtt=True, - show_api=False, + purpose=Purpose.LOGGING, ) assert result == ["MQTT"] mock_prompt.assert_called_once_with( [("MQTT (mqtt.local)", "MQTT")], - purpose=None, + purpose=Purpose.LOGGING, ) @@ -475,7 +490,7 @@ def test_choose_upload_log_host_no_defaults_with_all_options( ) -> None: """Test interactive mode with all options available.""" setup_core( - config={"ota": {}, "api": {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, address="192.168.1.100", ) @@ -485,32 +500,59 @@ def test_choose_upload_log_host_no_defaults_with_all_options( result = choose_upload_log_host( default=None, check_default=None, - show_ota=True, - show_mqtt=True, - show_api=True, - purpose="testing", + purpose=Purpose.UPLOADING, ) assert result == ["/dev/ttyUSB0"] expected_options = [ ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), ("Over The Air (192.168.1.100)", "192.168.1.100"), - ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), ] - mock_choose_prompt.assert_called_once_with(expected_options, purpose="testing") + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.UPLOADING + ) + + +def test_choose_upload_log_host_no_defaults_with_all_options_logging( + mock_choose_prompt: Mock, +) -> None: + """Test interactive mode with all options available.""" + setup_core( + config={CONF_OTA: {}, CONF_API: {}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}}, + address="192.168.1.100", + ) + + mock_ports = [MockSerialPort("/dev/ttyUSB0", "USB Serial")] + + with patch("esphome.__main__.get_serial_ports", return_value=mock_ports): + result = choose_upload_log_host( + default=None, + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["/dev/ttyUSB0"] + + expected_options = [ + ("/dev/ttyUSB0 (USB Serial)", "/dev/ttyUSB0"), + ("MQTT (mqtt.local)", "MQTT"), + ("Over The Air (192.168.1.100)", "192.168.1.100"), + ("Over The Air (MQTT IP lookup)", "MQTTIP"), + ] + mock_choose_prompt.assert_called_once_with( + expected_options, purpose=Purpose.LOGGING + ) @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_check_default_matches() -> None: """Test when check_default matches an available option.""" - setup_core(config={"ota": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}}, address="192.168.1.100") result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] @@ -526,9 +568,7 @@ def test_choose_upload_log_host_check_default_no_match() -> None: result = choose_upload_log_host( default=None, check_default="192.168.1.100", - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["fallback"] mock_prompt.assert_called_once() @@ -537,13 +577,12 @@ def test_choose_upload_log_host_check_default_no_match() -> None: @pytest.mark.usefixtures("mock_no_serial_ports") def test_choose_upload_log_host_empty_defaults_list() -> None: """Test with an empty list as default.""" + setup_core() with patch("esphome.__main__.choose_prompt", return_value="chosen") as mock_prompt: result = choose_upload_log_host( default=[], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["chosen"] mock_prompt.assert_called_once() @@ -559,9 +598,7 @@ def test_choose_upload_log_host_all_devices_unresolved( result = choose_upload_log_host( default=["SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] assert ( @@ -577,38 +614,132 @@ def test_choose_upload_log_host_mixed_resolved_unresolved() -> None: result = choose_upload_log_host( default=["192.168.1.50", "SERIAL", "OTA"], check_default=None, - show_ota=False, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.50"] def test_choose_upload_log_host_ota_both_conditions() -> None: """Test OTA device when both OTA and API are configured and enabled.""" - setup_core(config={"ota": {}, "api": {}}, address="192.168.1.100") + setup_core(config={CONF_OTA: {}, CONF_API: {}}, address="192.168.1.100") result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=True, + purpose=Purpose.UPLOADING, ) assert result == ["192.168.1.100"] +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["192.168.1.100", "MQTTIP"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.UPLOADING, + ) + assert result == ["MQTTIP", "test.local"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_ip_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="192.168.1.100", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["192.168.1.100", "MQTTIP", "MQTT"] + + +@pytest.mark.usefixtures("mock_serial_ports") +def test_choose_upload_log_host_ota_local_all_options_logging() -> None: + """Test OTA device when both static IP, OTA, API and MQTT are configured and enabled but MDNS not.""" + setup_core( + config={ + CONF_OTA: {}, + CONF_API: {}, + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + }, + address="test.local", + ) + + result = choose_upload_log_host( + default="OTA", + check_default=None, + purpose=Purpose.LOGGING, + ) + assert result == ["MQTTIP", "MQTT", "test.local"] + + @pytest.mark.usefixtures("mock_no_mqtt_logging") def test_choose_upload_log_host_no_address_with_ota_config() -> None: """Test OTA device when OTA is configured but no address is set.""" - setup_core(config={"ota": {}}) + setup_core(config={CONF_OTA: {}}) result = choose_upload_log_host( default="OTA", check_default=None, - show_ota=True, - show_mqtt=False, - show_api=False, + purpose=Purpose.UPLOADING, ) assert result == [] @@ -806,18 +937,15 @@ def test_upload_program_ota_no_config( upload_program(config, args, devices) -@patch("esphome.mqtt.get_esphome_device_ip") def test_upload_program_ota_with_mqtt_resolution( mock_mqtt_get_ip: Mock, mock_is_ip_address: Mock, mock_run_ota: Mock, - mock_get_port_type: Mock, tmp_path: Path, ) -> None: """Test upload_program with OTA using MQTT for address resolution.""" setup_core(address="device.local", platform=PLATFORM_ESP32, tmp_path=tmp_path) - mock_get_port_type.side_effect = ["MQTT", "NETWORK"] mock_is_ip_address.return_value = False mock_mqtt_get_ip.return_value = ["192.168.1.100"] mock_run_ota.return_value = (0, "192.168.1.100") @@ -847,9 +975,7 @@ def test_upload_program_ota_with_mqtt_resolution( expected_firmware = str( tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" ) - mock_run_ota.assert_called_once_with( - [["192.168.1.100"]], 3232, "", expected_firmware - ) + mock_run_ota.assert_called_once_with(["192.168.1.100"], 3232, "", expected_firmware) @patch("esphome.__main__.importlib.import_module") @@ -910,18 +1036,16 @@ def test_show_logs_no_logger() -> None: @patch("esphome.components.api.client.run_logs") def test_show_logs_api( mock_run_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: False}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 args = MockArgs() @@ -935,24 +1059,21 @@ def test_show_logs_api( ) -@patch("esphome.mqtt.get_esphome_device_ip") @patch("esphome.components.api.client.run_logs") def test_show_logs_api_with_mqtt_fallback( mock_run_logs: Mock, mock_mqtt_get_ip: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with API using MQTT for address resolution.""" setup_core( config={ "logger": {}, - "api": {}, + CONF_API: {}, CONF_MDNS: {CONF_DISABLED: True}, CONF_MQTT: {CONF_BROKER: "mqtt.local"}, }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_run_logs.return_value = 0 mock_mqtt_get_ip.return_value = ["192.168.1.200"] @@ -969,7 +1090,6 @@ def test_show_logs_api_with_mqtt_fallback( @patch("esphome.mqtt.show_logs") def test_show_logs_mqtt( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with MQTT.""" setup_core( @@ -979,7 +1099,6 @@ def test_show_logs_mqtt( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "MQTT" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1001,7 +1120,6 @@ def test_show_logs_mqtt( @patch("esphome.mqtt.show_logs") def test_show_logs_network_with_mqtt_only( mock_mqtt_show_logs: Mock, - mock_get_port_type: Mock, ) -> None: """Test show_logs with network port but only MQTT configured.""" setup_core( @@ -1012,7 +1130,6 @@ def test_show_logs_network_with_mqtt_only( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" mock_mqtt_show_logs.return_value = 0 args = MockArgs( @@ -1031,9 +1148,7 @@ def test_show_logs_network_with_mqtt_only( ) -def test_show_logs_no_method_configured( - mock_get_port_type: Mock, -) -> None: +def test_show_logs_no_method_configured() -> None: """Test show_logs when no remote logging method is configured.""" setup_core( config={ @@ -1042,7 +1157,6 @@ def test_show_logs_no_method_configured( }, platform=PLATFORM_ESP32, ) - mock_get_port_type.return_value = "NETWORK" args = MockArgs() devices = ["192.168.1.100"] @@ -1075,6 +1189,175 @@ def test_show_logs_platform_specific_handler( mock_module.show_logs.assert_called_once_with(config, args, devices) +def test_has_mqtt_logging_no_log_topic() -> None: + """Test has_mqtt_logging returns True when CONF_LOG_TOPIC is not in mqtt_config.""" + + # Setup MQTT config without CONF_LOG_TOPIC (defaults to enabled - this is the missing test case) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set to None (explicitly disabled) + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_LOG_TOPIC: None}}) + assert has_mqtt_logging() is False + + # Setup MQTT config with CONF_LOG_TOPIC set with topic and level (explicitly enabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "DEBUG"}, + } + } + ) + assert has_mqtt_logging() is True + + # Setup MQTT config with CONF_LOG_TOPIC set but level is NONE (disabled) + setup_core( + config={ + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + CONF_LOG_TOPIC: {CONF_TOPIC: "esphome/logs", CONF_LEVEL: "NONE"}, + } + } + ) + assert has_mqtt_logging() is False + + # Setup without MQTT config at all + setup_core(config={}) + assert has_mqtt_logging() is False + + +def test_has_mqtt() -> None: + """Test has_mqtt function.""" + + # Test with MQTT configured + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt() is True + + # Test without MQTT configured + setup_core(config={}) + assert has_mqtt() is False + + # Test with other components but no MQTT + setup_core(config={CONF_API: {}, CONF_OTA: {}}) + assert has_mqtt() is False + + +def test_get_port_type() -> None: + """Test get_port_type function.""" + + assert get_port_type("/dev/ttyUSB0") == "SERIAL" + assert get_port_type("/dev/ttyACM0") == "SERIAL" + assert get_port_type("COM1") == "SERIAL" + assert get_port_type("COM10") == "SERIAL" + + assert get_port_type("MQTT") == "MQTT" + assert get_port_type("MQTTIP") == "MQTTIP" + + assert get_port_type("192.168.1.100") == "NETWORK" + assert get_port_type("esphome-device.local") == "NETWORK" + assert get_port_type("10.0.0.1") == "NETWORK" + + +def test_has_mqtt_ip_lookup() -> None: + """Test has_mqtt_ip_lookup function.""" + + CONF_DISCOVER_IP = "discover_ip" + + setup_core(config={}) + assert has_mqtt_ip_lookup() is False + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local"}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: True}}) + assert has_mqtt_ip_lookup() is True + + setup_core(config={CONF_MQTT: {CONF_BROKER: "mqtt.local", CONF_DISCOVER_IP: False}}) + assert has_mqtt_ip_lookup() is False + + +def test_has_non_ip_address() -> None: + """Test has_non_ip_address function.""" + + setup_core(address=None) + assert has_non_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_non_ip_address() is False + + setup_core(address="10.0.0.1") + assert has_non_ip_address() is False + + setup_core(address="esphome-device.local") + assert has_non_ip_address() is True + + setup_core(address="my-device") + assert has_non_ip_address() is True + + +def test_has_ip_address() -> None: + """Test has_ip_address function.""" + + setup_core(address=None) + assert has_ip_address() is False + + setup_core(address="192.168.1.100") + assert has_ip_address() is True + + setup_core(address="10.0.0.1") + assert has_ip_address() is True + + setup_core(address="esphome-device.local") + assert has_ip_address() is False + + setup_core(address="my-device") + assert has_ip_address() is False + + +def test_mqtt_get_ip() -> None: + """Test mqtt_get_ip function.""" + config = {CONF_MQTT: {CONF_BROKER: "mqtt.local"}} + + with patch("esphome.mqtt.get_esphome_device_ip") as mock_get_ip: + mock_get_ip.return_value = ["192.168.1.100", "192.168.1.101"] + + result = mqtt_get_ip(config, "user", "pass", "client-id") + + assert result == ["192.168.1.100", "192.168.1.101"] + mock_get_ip.assert_called_once_with(config, "user", "pass", "client-id") + + +def test_has_resolvable_address() -> None: + """Test has_resolvable_address function.""" + + # Test with mDNS enabled and hostname address + setup_core(config={}, address="esphome-device.local") + assert has_resolvable_address() is True + + # Test with mDNS disabled and hostname address + setup_core( + config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local" + ) + assert has_resolvable_address() is False + + # Test with IP address (mDNS doesn't matter) + setup_core(config={}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with IP address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address="192.168.1.100") + assert has_resolvable_address() is True + + # Test with no address but mDNS enabled (can still resolve mDNS names) + setup_core(config={}, address=None) + assert has_resolvable_address() is True + + # Test with no address and mDNS disabled + setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None) + assert has_resolvable_address() is False + + def test_command_wizard(tmp_path: Path) -> None: """Test command_wizard function.""" config_file = tmp_path / "test.yaml" From 5b5e5c213ce1b467919a7a4705a075331da40b56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 23:25:19 +0000 Subject: [PATCH 10/21] Bump aioesphomeapi from 40.1.0 to 40.2.0 (#10703) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 0b9a37005d..4736e2a024 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.1.0 +aioesphomeapi==40.2.0 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 646f4e66bea1e85c2e06267fe68bb8e8dea979c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:45:22 -0500 Subject: [PATCH 11/21] [ethernet] Fix permanent component failure from undocumented ESP_FAIL in IPv6 setup (#10708) --- .../ethernet/ethernet_component.cpp | 46 ++++++++++++++++++- .../components/ethernet/ethernet_component.h | 2 + 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 844a30bd8b..cd73333222 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -300,6 +300,7 @@ void EthernetComponent::loop() { this->state_ = EthernetComponentState::CONNECTING; this->start_connect_(); } else { + this->finish_connect_(); // When connected and stable, disable the loop to save CPU cycles this->disable_loop(); } @@ -486,10 +487,35 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +void EthernetComponent::finish_connect_() { +#if USE_NETWORK_IPV6 + // Retry IPv6 link-local setup if it failed during initial connect + // This handles the case where min_ipv6_addr_count is NOT set (or is 0), + // allowing us to reach CONNECTED state with just IPv4. + // If IPv6 setup failed in start_connect_() because the interface wasn't ready: + // - Bootup timing issues (#10281) + // - Cable unplugged/network interruption (#10705) + // We can now retry since we're in CONNECTED state and the interface is definitely up. + if (!this->ipv6_setup_done_) { + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); + } + // Always set the flag to prevent continuous retries + // If IPv6 setup fails here with the interface up and stable, it's + // likely a persistent issue (IPv6 disabled at router, hardware + // limitation, etc.) that won't be resolved by further retries. + // The device continues to work with IPv4. + this->ipv6_setup_done_ = true; + } +#endif /* USE_NETWORK_IPV6 */ +} + void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 global_eth_component->ipv6_count_ = 0; + this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); this->status_set_warning(LOG_STR("waiting for IP configuration")); @@ -545,9 +571,27 @@ void EthernetComponent::start_connect_() { } } #if USE_NETWORK_IPV6 + // Attempt to create IPv6 link-local address + // We MUST attempt this here, not just in finish_connect_(), because with + // min_ipv6_addr_count set, the component won't reach CONNECTED state without IPv6. + // However, this may fail with ESP_FAIL if the interface is not up yet: + // - At bootup when link isn't ready (#10281) + // - After disconnection/cable unplugged (#10705) + // We'll retry in finish_connect_() if it fails here. err = esp_netif_create_ip6_linklocal(this->eth_netif_); if (err != ESP_OK) { - ESPHL_ERROR_CHECK(err, "Enable IPv6 link local failed"); + if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { + // This is a programming error, not a transient failure + ESPHL_ERROR_CHECK(err, "esp_netif_create_ip6_linklocal invalid parameters"); + } else { + // ESP_FAIL means the interface isn't up yet + // This is expected and non-fatal, happens in multiple scenarios: + // - During reconnection after network interruptions (#10705) + // - At bootup when the link isn't ready yet (#10281) + // We'll retry once we reach CONNECTED state and the interface is up + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed: %s", esp_err_to_name(err)); + // Don't mark component as failed - this is a transient error + } } #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index bdcda6afb4..3d2713ee5c 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -102,6 +102,7 @@ class EthernetComponent : public Component { #endif /* LWIP_IPV6 */ void start_connect_(); + void finish_connect_(); void dump_connect_params_(); /// @brief Set `RMII Reference Clock Select` bit for KSZ8081. void ksz8081_set_clock_reference_(esp_eth_mac_t *mac); @@ -144,6 +145,7 @@ class EthernetComponent : public Component { bool got_ipv4_address_{false}; #if LWIP_IPV6 uint8_t ipv6_count_{0}; + bool ipv6_setup_done_{false}; #endif /* LWIP_IPV6 */ // Pointers at the end (naturally aligned) From c60149477969e5581f81927966f1dbd601cc70db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:50:38 -0500 Subject: [PATCH 12/21] [core] Optimize MAC address formatting to eliminate sprintf dependency (#10713) --- .../ethernet/ethernet_component.cpp | 4 ++- esphome/core/helpers.cpp | 15 +++++----- esphome/core/helpers.h | 29 +++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index cd73333222..a48fd27383 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -682,7 +682,9 @@ void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { std::string EthernetComponent::get_eth_mac_address_pretty() { uint8_t mac[6]; get_eth_mac_address_raw(mac); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } eth_duplex_t EthernetComponent::get_duplex_mode() { diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 43d6f1153c..f1560711ef 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -255,23 +255,22 @@ size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { } std::string format_mac_address_pretty(const uint8_t *mac) { - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } -static char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } std::string format_hex(const uint8_t *data, size_t length) { std::string ret; ret.resize(length * 2); for (size_t i = 0; i < length; i++) { - ret[2 * i] = format_hex_char((data[i] & 0xF0) >> 4); + ret[2 * i] = format_hex_char(data[i] >> 4); ret[2 * i + 1] = format_hex_char(data[i] & 0x0F); } return ret; } std::string format_hex(const std::vector &data) { return format_hex(data.data(), data.size()); } -static char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } - // Shared implementation for uint8_t and string hex formatting static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, char separator, bool show_length) { if (data == nullptr || length == 0) @@ -280,7 +279,7 @@ static std::string format_hex_pretty_uint8(const uint8_t *data, size_t length, c uint8_t multiple = separator ? 3 : 2; // 3 if separator is not \0, 2 otherwise ret.resize(multiple * length - (separator ? 1 : 0)); for (size_t i = 0; i < length; i++) { - ret[multiple * i] = format_hex_pretty_char((data[i] & 0xF0) >> 4); + ret[multiple * i] = format_hex_pretty_char(data[i] >> 4); ret[multiple * i + 1] = format_hex_pretty_char(data[i] & 0x0F); if (separator && i != length - 1) ret[multiple * i + 2] = separator; @@ -591,7 +590,9 @@ bool HighFrequencyLoopRequester::is_high_frequency() { return num_requests > 0; std::string get_mac_address() { uint8_t mac[6]; get_mac_address_raw(mac); - return str_snprintf("%02x%02x%02x%02x%02x%02x", 12, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[13]; + format_mac_addr_lower_no_sep(mac, buf); + return std::string(buf); } std::string get_mac_address_pretty() { diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index a6741925d0..21aa159b25 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -380,6 +380,35 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Convert a nibble (0-15) to lowercase hex char +inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } + +/// Convert a nibble (0-15) to uppercase hex char (used for pretty printing) +/// This always uses uppercase (A-F) for pretty/human-readable output +inline char format_hex_pretty_char(uint8_t v) { return v >= 10 ? 'A' + (v - 10) : '0' + v; } + +/// Format MAC address as XX:XX:XX:XX:XX:XX (uppercase) +inline void format_mac_addr_upper(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 3] = format_hex_pretty_char(byte >> 4); + output[i * 3 + 1] = format_hex_pretty_char(byte & 0x0F); + if (i < 5) + output[i * 3 + 2] = ':'; + } + output[17] = '\0'; +} + +/// Format MAC address as xxxxxxxxxxxxxx (lowercase, no separators) +inline void format_mac_addr_lower_no_sep(const uint8_t *mac, char *output) { + for (size_t i = 0; i < 6; i++) { + uint8_t byte = mac[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); + } + output[12] = '\0'; +} + /// Format the six-byte array \p mac into a MAC address. std::string format_mac_address_pretty(const uint8_t mac[6]); /// Format the byte array \p data of length \p len in lowercased hex. From ae158179bd161127070a48fd0cc0bc759518efed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:52:19 -0500 Subject: [PATCH 13/21] [api] Revert unneeded GetTime bidirectional support added in #9790 (#10702) --- esphome/components/api/api.proto | 7 ++----- esphome/components/api/api_connection.cpp | 6 ------ esphome/components/api/api_connection.h | 1 - esphome/components/api/api_pb2.cpp | 8 -------- esphome/components/api/api_pb2.h | 4 ---- esphome/components/api/api_pb2_dump.cpp | 8 +------- esphome/components/api/api_pb2_service.cpp | 14 -------------- esphome/components/api/api_pb2_service.h | 4 +--- 8 files changed, 4 insertions(+), 48 deletions(-) diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 208187d598..471127e93a 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -27,9 +27,6 @@ service APIConnection { rpc subscribe_logs (SubscribeLogsRequest) returns (void) {} rpc subscribe_homeassistant_services (SubscribeHomeassistantServicesRequest) returns (void) {} rpc subscribe_home_assistant_states (SubscribeHomeAssistantStatesRequest) returns (void) {} - rpc get_time (GetTimeRequest) returns (GetTimeResponse) { - option (needs_authentication) = false; - } rpc execute_service (ExecuteServiceRequest) returns (void) {} rpc noise_encryption_set_key (NoiseEncryptionSetKeyRequest) returns (NoiseEncryptionSetKeyResponse) {} @@ -809,12 +806,12 @@ message HomeAssistantStateResponse { // ==================== IMPORT TIME ==================== message GetTimeRequest { option (id) = 36; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_SERVER; } message GetTimeResponse { option (id) = 37; - option (source) = SOURCE_BOTH; + option (source) = SOURCE_CLIENT; option (no_delay) = true; fixed32 epoch_seconds = 1; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 99a0bc9044..1fb65f3a2b 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -1081,12 +1081,6 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) { } #endif -bool APIConnection::send_get_time_response(const GetTimeRequest &msg) { - GetTimeResponse resp; - resp.epoch_seconds = ::time(nullptr); - return this->send_message(resp, GetTimeResponse::MESSAGE_TYPE); -} - #ifdef USE_BLUETOOTH_PROXY void APIConnection::subscribe_bluetooth_le_advertisements(const SubscribeBluetoothLEAdvertisementsRequest &msg) { bluetooth_proxy::global_bluetooth_proxy->subscribe_api_connection(this, msg.flags); diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 7ee82e0c68..8f93f38203 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -219,7 +219,6 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - bool send_get_time_response(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void execute_service(const ExecuteServiceRequest &msg) override; #endif diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 022ac55cf3..a92fca70d6 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -921,14 +921,6 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -void GetTimeResponse::encode(ProtoWriteBuffer buffer) const { - buffer.encode_fixed32(1, this->epoch_seconds); - buffer.encode_string(2, this->timezone_ref_); -} -void GetTimeResponse::calculate_size(ProtoSize &size) const { - size.add_fixed32(1, this->epoch_seconds); - size.add_length(1, this->timezone_ref_.size()); -} #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index fd124e7bfe..5b6d694e3b 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -1180,10 +1180,6 @@ class GetTimeResponse final : public ProtoDecodableMessage { #endif uint32_t epoch_seconds{0}; std::string timezone{}; - StringRef timezone_ref_{}; - void set_timezone(const StringRef &ref) { this->timezone_ref_ = ref; } - void encode(ProtoWriteBuffer buffer) const override; - void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index 9795999953..b5e98a9f28 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -1113,13 +1113,7 @@ void GetTimeRequest::dump_to(std::string &out) const { out.append("GetTimeReques void GetTimeResponse::dump_to(std::string &out) const { MessageDumpHelper helper(out, "GetTimeResponse"); dump_field(out, "epoch_seconds", this->epoch_seconds); - out.append(" timezone: "); - if (!this->timezone_ref_.empty()) { - out.append("'").append(this->timezone_ref_.c_str()).append("'"); - } else { - out.append("'").append(this->timezone).append("'"); - } - out.append("\n"); + dump_field(out, "timezone", this->timezone); } #ifdef USE_API_SERVICES void ListEntitiesServicesArgument::dump_to(std::string &out) const { diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 6b7b8b9ebd..2598e9a0fb 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -160,15 +160,6 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif - case GetTimeRequest::MESSAGE_TYPE: { - GetTimeRequest msg; - // Empty message: no decode needed -#ifdef HAS_PROTO_MESSAGE_DUMP - ESP_LOGVV(TAG, "on_get_time_request: %s", msg.dump().c_str()); -#endif - this->on_get_time_request(msg); - break; - } case GetTimeResponse::MESSAGE_TYPE: { GetTimeResponse msg; msg.decode(msg_data, msg_size); @@ -656,11 +647,6 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc } } #endif -void APIServerConnection::on_get_time_request(const GetTimeRequest &msg) { - if (this->check_connection_setup_() && !this->send_get_time_response(msg)) { - this->on_fatal_error(); - } -} #ifdef USE_API_SERVICES void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { if (this->check_authenticated_()) { diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 6172e33bf6..5b7508e786 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -71,7 +71,7 @@ class APIServerConnectionBase : public ProtoService { #ifdef USE_API_HOMEASSISTANT_STATES virtual void on_home_assistant_state_response(const HomeAssistantStateResponse &value){}; #endif - virtual void on_get_time_request(const GetTimeRequest &value){}; + virtual void on_get_time_response(const GetTimeResponse &value){}; #ifdef USE_API_SERVICES @@ -226,7 +226,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif - virtual bool send_get_time_response(const GetTimeRequest &msg) = 0; #ifdef USE_API_SERVICES virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif @@ -348,7 +347,6 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif - void on_get_time_request(const GetTimeRequest &msg) override; #ifdef USE_API_SERVICES void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif From 1750f02ef3a31f7fdb36a5992194bc9e48650fdc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 16:54:42 -0500 Subject: [PATCH 14/21] [api] Optimize HelloResponse server_info to reduce memory usage (#10701) --- esphome/components/api/api_connection.cpp | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 1fb65f3a2b..7b7853f040 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -42,6 +42,8 @@ static constexpr uint8_t MAX_PING_RETRIES = 60; static constexpr uint16_t PING_RETRY_INTERVAL = 1000; static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2; +static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); + static const char *const TAG = "api.connection"; #ifdef USE_CAMERA static const int CAMERA_STOP_STREAM = 5000; @@ -1370,9 +1372,8 @@ bool APIConnection::send_hello_response(const HelloRequest &msg) { HelloResponse resp; resp.api_version_major = 1; resp.api_version_minor = 12; - // Temporary string for concatenation - will be valid during send_message call - std::string server_info = App.get_name() + " (esphome v" ESPHOME_VERSION ")"; - resp.set_server_info(StringRef(server_info)); + // Send only the version string - the client only logs this for debugging and doesn't use it otherwise + resp.set_server_info(ESPHOME_VERSION_REF); resp.set_name(StringRef(App.get_name())); #ifdef USE_API_PASSWORD @@ -1419,8 +1420,6 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { std::string mac_address = get_mac_address_pretty(); resp.set_mac_address(StringRef(mac_address)); - // Compile-time StringRef constants - static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION); resp.set_esphome_version(ESPHOME_VERSION_REF); resp.set_compilation_time(App.get_compilation_time_ref()); From 4e17d14acca044d452b0ec0516659a47c907b121 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:05:56 -0500 Subject: [PATCH 15/21] [scheduler] Fix timing accumulation in scheduler causing incorrect execution measurements (#10719) --- esphome/core/scheduler.cpp | 8 ++++---- esphome/core/scheduler.h | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 68da0a56ca..71e2a00fbe 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -345,7 +345,7 @@ void HOT Scheduler::call(uint32_t now) { // Execute callback without holding lock to prevent deadlocks // if the callback tries to call defer() again if (!this->should_skip_item_(item.get())) { - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution this->recycle_item_(std::move(item)); @@ -483,7 +483,7 @@ void HOT Scheduler::call(uint32_t now) { // Warning: During callback(), a lot of stuff can happen, including: // - timeouts/intervals get added, potentially invalidating vector pointers // - timeouts/intervals get cancelled - this->execute_item_(item.get(), now); + now = this->execute_item_(item.get(), now); LockGuard guard{this->lock_}; @@ -568,11 +568,11 @@ void HOT Scheduler::pop_raw_() { } // Helper to execute a scheduler item -void HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { +uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) { App.set_current_component(item->component); WarnIfComponentBlockingGuard guard{item->component, now}; item->callback(); - guard.finish(); + return guard.finish(); } // Common implementation for cancel operations diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 301342e8c2..885ee13754 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -254,7 +254,7 @@ class Scheduler { } // Helper to execute a scheduler item - void execute_item_(SchedulerItem *item, uint32_t now); + uint32_t execute_item_(SchedulerItem *item, uint32_t now); // Helper to check if item should be skipped bool should_skip_item_(SchedulerItem *item) const { From 3427aaab8c8912799f3c5af3737b29582154433f Mon Sep 17 00:00:00 2001 From: Big Mike Date: Sun, 14 Sep 2025 17:16:01 -0500 Subject: [PATCH 16/21] ina2xx should be total increasing for energy sensor (#10711) --- esphome/components/ina2xx_base/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/esphome/components/ina2xx_base/__init__.py b/esphome/components/ina2xx_base/__init__.py index ff70f217ec..fef88e72e9 100644 --- a/esphome/components/ina2xx_base/__init__.py +++ b/esphome/components/ina2xx_base/__init__.py @@ -18,6 +18,7 @@ from esphome.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, UNIT_AMPERE, UNIT_CELSIUS, UNIT_VOLT, @@ -162,7 +163,7 @@ INA2XX_SCHEMA = cv.Schema( unit_of_measurement=UNIT_WATT_HOURS, accuracy_decimals=8, device_class=DEVICE_CLASS_ENERGY, - state_class=STATE_CLASS_MEASUREMENT, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), @@ -170,7 +171,8 @@ INA2XX_SCHEMA = cv.Schema( sensor.sensor_schema( unit_of_measurement=UNIT_JOULE, accuracy_decimals=8, - state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_TOTAL_INCREASING, ), key=CONF_NAME, ), From 24f9550ce5df391f00706727a7814998c5a0ea7a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 14 Sep 2025 22:33:07 +0000 Subject: [PATCH 17/21] Bump aioesphomeapi from 40.2.0 to 40.2.1 (#10721) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4736e2a024..296485bdae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.0.2 click==8.1.7 esphome-dashboard==20250904.0 -aioesphomeapi==40.2.0 +aioesphomeapi==40.2.1 zeroconf==0.147.2 puremagic==1.30 ruamel.yaml==0.18.15 # dashboard_import From 2d9152d9b9f035f5bdad3a3160509dae8fd46d9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:18 -0500 Subject: [PATCH 18/21] [md5] Optimize MD5::get_hex() to eliminate sprintf dependency (#10710) --- esphome/components/md5/md5.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 980cb98699..21bd2e1cab 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -1,4 +1,3 @@ -#include #include #include "md5.h" #ifdef USE_MD5 @@ -44,7 +43,9 @@ void MD5Digest::get_bytes(uint8_t *output) { memcpy(output, this->digest_, 16); void MD5Digest::get_hex(char *output) { for (size_t i = 0; i < 16; i++) { - sprintf(output + i * 2, "%02x", this->digest_[i]); + uint8_t byte = this->digest_[i]; + output[i * 2] = format_hex_char(byte >> 4); + output[i * 2 + 1] = format_hex_char(byte & 0x0F); } } From 6b147312cdec2421b8983c97c36d5d5df55f960b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 17:35:27 -0500 Subject: [PATCH 19/21] [wifi] Optimize WiFi MAC formatting to eliminate sprintf dependency (#10715) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- esphome/components/wifi/wifi_component.cpp | 2 +- esphome/components/wifi_info/wifi_info_text_sensor.h | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index e57bf25b8c..43ece636e5 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -593,7 +593,7 @@ void WiFiComponent::check_scanning_finished() { for (auto &res : this->scan_result_) { char bssid_s[18]; auto bssid = res.get_bssid(); - sprintf(bssid_s, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + format_mac_addr_upper(bssid.data(), bssid_s); if (res.get_matches()) { ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 68b5f438e4..2cb96123a0 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -1,6 +1,7 @@ #pragma once #include "esphome/core/component.h" +#include "esphome/core/helpers.h" #include "esphome/components/text_sensor/text_sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI @@ -106,8 +107,8 @@ class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[30]; - sprintf(buf, "%02X:%02X:%02X:%02X:%02X:%02X", bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]); + char buf[18]; + format_mac_addr_upper(bssid.data(), buf); this->publish_state(buf); } } From 926fdcbecd9b05790f178fe5ead826d2cf7c0065 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 14 Sep 2025 19:04:45 -0500 Subject: [PATCH 20/21] [esp32_ble] Optimize BLE hex formatting to eliminate sprintf dependency (#10714) Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com> --- .../bluetooth_proxy/bluetooth_proxy.h | 4 +- esphome/components/esp32_ble/ble_uuid.cpp | 39 ++++++++++++++----- .../esp32_ble_beacon/esp32_ble_beacon.cpp | 7 ++-- .../esp32_ble_client/ble_client_base.h | 13 ++++--- .../esp32_ble_tracker/esp32_ble_tracker.cpp | 5 +-- 5 files changed, 47 insertions(+), 21 deletions(-) diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index 4b262dbe86..1ce2321bee 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -130,7 +130,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ std::string get_bluetooth_mac_address_pretty() { const uint8_t *mac = esp_bt_dev_get_address(); - return str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + char buf[18]; + format_mac_addr_upper(mac, buf); + return std::string(buf); } protected: diff --git a/esphome/components/esp32_ble/ble_uuid.cpp b/esphome/components/esp32_ble/ble_uuid.cpp index be9c6945d7..5f83e2ba0b 100644 --- a/esphome/components/esp32_ble/ble_uuid.cpp +++ b/esphome/components/esp32_ble/ble_uuid.cpp @@ -7,6 +7,7 @@ #include #include #include "esphome/core/log.h" +#include "esphome/core/helpers.h" namespace esphome::esp32_ble { @@ -169,22 +170,42 @@ bool ESPBTUUID::operator==(const ESPBTUUID &uuid) const { } esp_bt_uuid_t ESPBTUUID::get_uuid() const { return this->uuid_; } std::string ESPBTUUID::to_string() const { + char buf[40]; // Enough for 128-bit UUID with dashes + char *pos = buf; + switch (this->uuid_.len) { case ESP_UUID_LEN_16: - return str_snprintf("0x%02X%02X", 6, this->uuid_.uuid.uuid16 >> 8, this->uuid_.uuid.uuid16 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 >> 12); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 8) & 0x0F); + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid16 >> 4) & 0x0F); + *pos++ = format_hex_pretty_char(this->uuid_.uuid.uuid16 & 0x0F); + *pos = '\0'; + return std::string(buf); + case ESP_UUID_LEN_32: - return str_snprintf("0x%02" PRIX32 "%02" PRIX32 "%02" PRIX32 "%02" PRIX32, 10, (this->uuid_.uuid.uuid32 >> 24), - (this->uuid_.uuid.uuid32 >> 16 & 0xff), (this->uuid_.uuid.uuid32 >> 8 & 0xff), - this->uuid_.uuid.uuid32 & 0xff); + *pos++ = '0'; + *pos++ = 'x'; + for (int shift = 28; shift >= 0; shift -= 4) { + *pos++ = format_hex_pretty_char((this->uuid_.uuid.uuid32 >> shift) & 0x0F); + } + *pos = '\0'; + return std::string(buf); + default: case ESP_UUID_LEN_128: - std::string buf; + // Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX for (int8_t i = 15; i >= 0; i--) { - buf += str_snprintf("%02X", 2, this->uuid_.uuid.uuid128[i]); - if (i == 6 || i == 8 || i == 10 || i == 12) - buf += "-"; + uint8_t byte = this->uuid_.uuid.uuid128[i]; + *pos++ = format_hex_pretty_char(byte >> 4); + *pos++ = format_hex_pretty_char(byte & 0x0F); + if (i == 12 || i == 10 || i == 8 || i == 6) { + *pos++ = '-'; + } } - return buf; + *pos = '\0'; + return std::string(buf); } return ""; } diff --git a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp index 423fe61592..259628e00f 100644 --- a/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp +++ b/esphome/components/esp32_ble_beacon/esp32_ble_beacon.cpp @@ -31,12 +31,13 @@ void ESP32BLEBeacon::dump_config() { char uuid[37]; char *bpos = uuid; for (int8_t ii = 0; ii < 16; ++ii) { - bpos += sprintf(bpos, "%02X", this->uuid_[ii]); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] >> 4); + *bpos++ = format_hex_pretty_char(this->uuid_[ii] & 0x0F); if (ii == 3 || ii == 5 || ii == 7 || ii == 9) { - bpos += sprintf(bpos, "-"); + *bpos++ = '-'; } } - uuid[36] = '\0'; + *bpos = '\0'; ESP_LOGCONFIG(TAG, " UUID: %s, Major: %u, Minor: %u, Min Interval: %ums, Max Interval: %ums, Measured Power: %d" ", TX Power: %ddBm", diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index acfad9e9b0..f2edd6c2b3 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -60,11 +60,14 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { if (address == 0) { this->address_str_ = ""; } else { - this->address_str_ = - str_snprintf("%02X:%02X:%02X:%02X:%02X:%02X", 17, (uint8_t) (this->address_ >> 40) & 0xff, - (uint8_t) (this->address_ >> 32) & 0xff, (uint8_t) (this->address_ >> 24) & 0xff, - (uint8_t) (this->address_ >> 16) & 0xff, (uint8_t) (this->address_ >> 8) & 0xff, - (uint8_t) (this->address_ >> 0) & 0xff); + char buf[18]; + uint8_t mac[6] = { + (uint8_t) ((this->address_ >> 40) & 0xff), (uint8_t) ((this->address_ >> 32) & 0xff), + (uint8_t) ((this->address_ >> 24) & 0xff), (uint8_t) ((this->address_ >> 16) & 0xff), + (uint8_t) ((this->address_ >> 8) & 0xff), (uint8_t) ((this->address_ >> 0) & 0xff), + }; + format_mac_addr_upper(mac, buf); + this->address_str_ = buf; } } const std::string &address_str() const { return this->address_str_; } diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 0edde169eb..63fb3b8b32 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -607,9 +607,8 @@ void ESPBTDevice::parse_adv_(const uint8_t *payload, uint8_t len) { } std::string ESPBTDevice::address_str() const { - char mac[24]; - snprintf(mac, sizeof(mac), "%02X:%02X:%02X:%02X:%02X:%02X", this->address_[0], this->address_[1], this->address_[2], - this->address_[3], this->address_[4], this->address_[5]); + char mac[18]; + format_mac_addr_upper(this->address_, mac); return mac; } From 971de64494ad949a471e017ae03d54b4c9527867 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:34:56 +1200 Subject: [PATCH 21/21] Bump version to 2025.9.0b2 --- Doxyfile | 2 +- esphome/const.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Doxyfile b/Doxyfile index a7f591cbf5..d35c01b144 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.9.0b1 +PROJECT_NUMBER = 2025.9.0b2 # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/const.py b/esphome/const.py index 4655931823..03dc33df89 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.9.0b1" +__version__ = "2025.9.0b2" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = (