esphome 2024.12.4__py3-none-any.whl → 2025.2.0b2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (358) hide show
  1. esphome/__main__.py +16 -3
  2. esphome/components/adc/__init__.py +17 -11
  3. esphome/components/adc/adc_sensor.h +17 -0
  4. esphome/components/adc/adc_sensor_common.cpp +55 -0
  5. esphome/components/adc/adc_sensor_esp32.cpp +8 -5
  6. esphome/components/adc/adc_sensor_esp8266.cpp +10 -6
  7. esphome/components/adc/adc_sensor_libretiny.cpp +11 -6
  8. esphome/components/adc/adc_sensor_rp2040.cpp +13 -10
  9. esphome/components/adc/sensor.py +9 -3
  10. esphome/components/ads1115/ads1115.cpp +56 -7
  11. esphome/components/ads1115/ads1115.h +13 -1
  12. esphome/components/ads1115/sensor/__init__.py +16 -0
  13. esphome/components/ads1115/sensor/ads1115_sensor.cpp +2 -1
  14. esphome/components/ads1115/sensor/ads1115_sensor.h +2 -0
  15. esphome/components/animation/__init__.py +23 -261
  16. esphome/components/animation/animation.cpp +2 -2
  17. esphome/components/animation/animation.h +2 -1
  18. esphome/components/api/api_pb2.cpp +14 -0
  19. esphome/components/api/api_pb2.h +1 -0
  20. esphome/components/audio/__init__.py +112 -0
  21. esphome/components/audio/audio.cpp +67 -0
  22. esphome/components/audio/audio.h +125 -7
  23. esphome/components/audio/audio_decoder.cpp +361 -0
  24. esphome/components/audio/audio_decoder.h +135 -0
  25. esphome/components/audio/audio_reader.cpp +308 -0
  26. esphome/components/audio/audio_reader.h +85 -0
  27. esphome/components/audio/audio_resampler.cpp +159 -0
  28. esphome/components/audio/audio_resampler.h +101 -0
  29. esphome/components/audio/audio_transfer_buffer.cpp +165 -0
  30. esphome/components/audio/audio_transfer_buffer.h +139 -0
  31. esphome/components/audio_adc/__init__.py +41 -0
  32. esphome/components/audio_adc/audio_adc.h +17 -0
  33. esphome/components/audio_adc/automation.h +23 -0
  34. esphome/components/bk72xx/__init__.py +1 -0
  35. esphome/components/ble_client/ble_client.cpp +1 -2
  36. esphome/components/ble_client/sensor/__init__.py +1 -1
  37. esphome/components/ble_client/text_sensor/__init__.py +1 -1
  38. esphome/components/bluetooth_proxy/bluetooth_connection.cpp +5 -0
  39. esphome/components/bluetooth_proxy/bluetooth_connection.h +1 -0
  40. esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +5 -0
  41. esphome/components/ch422g/ch422g.h +2 -0
  42. esphome/components/climate/__init__.py +1 -1
  43. esphome/components/climate_ir/climate_ir.cpp +2 -1
  44. esphome/components/coolix/coolix.cpp +2 -1
  45. esphome/components/cse7766/cse7766.cpp +8 -16
  46. esphome/components/custom/__init__.py +0 -3
  47. esphome/components/custom/binary_sensor/__init__.py +2 -28
  48. esphome/components/custom/climate/__init__.py +2 -27
  49. esphome/components/custom/cover/__init__.py +2 -27
  50. esphome/components/custom/light/__init__.py +2 -27
  51. esphome/components/custom/output/__init__.py +2 -58
  52. esphome/components/custom/sensor/__init__.py +2 -24
  53. esphome/components/custom/switch/__init__.py +2 -24
  54. esphome/components/custom/text_sensor/__init__.py +2 -29
  55. esphome/components/custom_component/__init__.py +3 -27
  56. esphome/components/daly_bms/daly_bms.cpp +6 -0
  57. esphome/components/daly_bms/daly_bms.h +2 -0
  58. esphome/components/daly_bms/sensor.py +6 -0
  59. esphome/components/debug/debug_component.cpp +4 -0
  60. esphome/components/debug/debug_component.h +14 -0
  61. esphome/components/debug/debug_esp32.cpp +154 -74
  62. esphome/components/dfplayer/dfplayer.cpp +15 -2
  63. esphome/components/dfrobot_sen0395/dfrobot_sen0395.cpp +2 -1
  64. esphome/components/dht/dht.cpp +2 -1
  65. esphome/components/display/__init__.py +18 -5
  66. esphome/components/display/display.cpp +2 -1
  67. esphome/components/display/rect.cpp +2 -1
  68. esphome/components/es7210/__init__.py +0 -0
  69. esphome/components/es7210/audio_adc.py +51 -0
  70. esphome/components/es7210/es7210.cpp +228 -0
  71. esphome/components/es7210/es7210.h +62 -0
  72. esphome/components/es7210/es7210_const.h +129 -0
  73. esphome/components/es7243e/__init__.py +0 -0
  74. esphome/components/es7243e/audio_adc.py +34 -0
  75. esphome/components/es7243e/es7243e.cpp +125 -0
  76. esphome/components/es7243e/es7243e.h +37 -0
  77. esphome/components/es7243e/es7243e_const.h +54 -0
  78. esphome/components/es8156/__init__.py +0 -0
  79. esphome/components/es8156/audio_dac.py +27 -0
  80. esphome/components/es8156/es8156.cpp +87 -0
  81. esphome/components/es8156/es8156.h +51 -0
  82. esphome/components/es8156/es8156_const.h +68 -0
  83. esphome/components/es8311/audio_dac.py +1 -2
  84. esphome/components/esp32/__init__.py +1 -0
  85. esphome/components/esp32/core.cpp +5 -1
  86. esphome/components/esp32/gpio.h +2 -0
  87. esphome/components/esp32_ble/__init__.py +39 -0
  88. esphome/components/esp32_ble/queue.h +4 -4
  89. esphome/components/esp32_ble_client/ble_client_base.cpp +46 -0
  90. esphome/components/esp32_ble_client/ble_client_base.h +2 -0
  91. esphome/components/esp32_ble_server/__init__.py +582 -12
  92. esphome/components/esp32_ble_server/ble_characteristic.cpp +48 -60
  93. esphome/components/esp32_ble_server/ble_characteristic.h +24 -17
  94. esphome/components/esp32_ble_server/ble_descriptor.cpp +21 -9
  95. esphome/components/esp32_ble_server/ble_descriptor.h +17 -6
  96. esphome/components/esp32_ble_server/ble_server.cpp +62 -67
  97. esphome/components/esp32_ble_server/ble_server.h +28 -32
  98. esphome/components/esp32_ble_server/ble_server_automations.cpp +77 -0
  99. esphome/components/esp32_ble_server/ble_server_automations.h +115 -0
  100. esphome/components/esp32_ble_server/ble_service.cpp +17 -15
  101. esphome/components/esp32_ble_server/ble_service.h +10 -14
  102. esphome/components/esp32_ble_tracker/__init__.py +6 -39
  103. esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +33 -10
  104. esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +8 -4
  105. esphome/components/esp32_improv/__init__.py +2 -8
  106. esphome/components/esp32_improv/esp32_improv_component.cpp +21 -20
  107. esphome/components/esp32_improv/esp32_improv_component.h +3 -4
  108. esphome/components/esp32_rmt/__init__.py +28 -3
  109. esphome/components/esp32_rmt_led_strip/led_strip.cpp +73 -6
  110. esphome/components/esp32_rmt_led_strip/led_strip.h +21 -3
  111. esphome/components/esp32_rmt_led_strip/light.py +72 -7
  112. esphome/components/esp32_touch/esp32_touch.cpp +5 -0
  113. esphome/components/esp8266/__init__.py +1 -0
  114. esphome/components/esp8266/gpio.h +1 -0
  115. esphome/components/ethernet/__init__.py +10 -10
  116. esphome/components/event/event.cpp +4 -2
  117. esphome/components/event/event.h +2 -0
  118. esphome/components/event_emitter/__init__.py +5 -0
  119. esphome/components/event_emitter/event_emitter.cpp +14 -0
  120. esphome/components/event_emitter/event_emitter.h +63 -0
  121. esphome/components/gcja5/gcja5.cpp +2 -1
  122. esphome/components/graph/graph.cpp +4 -9
  123. esphome/components/haier/haier_base.cpp +2 -1
  124. esphome/components/haier/hon_climate.cpp +2 -1
  125. esphome/components/heatpumpir/heatpumpir.cpp +2 -1
  126. esphome/components/host/__init__.py +1 -0
  127. esphome/components/host/gpio.h +1 -0
  128. esphome/components/http_request/http_request.h +2 -2
  129. esphome/components/http_request/http_request_arduino.cpp +1 -1
  130. esphome/components/http_request/http_request_idf.cpp +1 -1
  131. esphome/components/i2c/i2c_bus_esp_idf.cpp +4 -0
  132. esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +7 -5
  133. esphome/components/i2s_audio/speaker/__init__.py +53 -6
  134. esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +92 -46
  135. esphome/components/i2s_audio/speaker/i2s_audio_speaker.h +8 -0
  136. esphome/components/ili9xxx/display.py +29 -11
  137. esphome/components/ili9xxx/ili9xxx_display.cpp +2 -5
  138. esphome/components/ili9xxx/ili9xxx_display.h +2 -1
  139. esphome/components/image/__init__.py +443 -255
  140. esphome/components/image/image.cpp +115 -61
  141. esphome/components/image/image.h +15 -24
  142. esphome/components/json/json_util.cpp +8 -34
  143. esphome/components/libretiny/__init__.py +1 -0
  144. esphome/components/libretiny/gpio_arduino.h +1 -0
  145. esphome/components/light/light_color_values.h +1 -1
  146. esphome/components/logger/__init__.py +43 -7
  147. esphome/components/logger/logger.cpp +16 -11
  148. esphome/components/logger/logger.h +11 -7
  149. esphome/components/logger/select/__init__.py +29 -0
  150. esphome/components/logger/select/logger_level_select.cpp +27 -0
  151. esphome/components/logger/select/logger_level_select.h +15 -0
  152. esphome/components/lvgl/__init__.py +96 -73
  153. esphome/components/lvgl/automation.py +39 -7
  154. esphome/components/lvgl/defines.py +8 -2
  155. esphome/components/lvgl/lvgl_esphome.cpp +8 -15
  156. esphome/components/lvgl/lvgl_esphome.h +20 -5
  157. esphome/components/lvgl/schemas.py +25 -14
  158. esphome/components/lvgl/trigger.py +27 -3
  159. esphome/components/lvgl/widgets/dropdown.py +1 -1
  160. esphome/components/lvgl/widgets/keyboard.py +8 -1
  161. esphome/components/lvgl/widgets/meter.py +2 -1
  162. esphome/components/lvgl/widgets/msgbox.py +1 -1
  163. esphome/components/lvgl/widgets/obj.py +1 -12
  164. esphome/components/lvgl/widgets/page.py +37 -2
  165. esphome/components/lvgl/widgets/tabview.py +1 -1
  166. esphome/components/max6956/max6956.h +2 -0
  167. esphome/components/mcp23016/mcp23016.h +2 -0
  168. esphome/components/mcp23xxx_base/mcp23xxx_base.h +2 -0
  169. esphome/components/mdns/__init__.py +1 -1
  170. esphome/components/media_player/__init__.py +37 -8
  171. esphome/components/media_player/automation.h +11 -2
  172. esphome/components/media_player/media_player.cpp +8 -0
  173. esphome/components/media_player/media_player.h +8 -4
  174. esphome/components/micronova/switch/micronova_switch.cpp +4 -2
  175. esphome/components/midea/ac_automations.h +3 -1
  176. esphome/components/midea/air_conditioner.cpp +7 -5
  177. esphome/components/midea/air_conditioner.h +1 -1
  178. esphome/components/midea/climate.py +4 -2
  179. esphome/components/midea/ir_transmitter.h +36 -5
  180. esphome/components/mixer/__init__.py +0 -0
  181. esphome/components/mixer/speaker/__init__.py +172 -0
  182. esphome/components/mixer/speaker/automation.h +19 -0
  183. esphome/components/mixer/speaker/mixer_speaker.cpp +624 -0
  184. esphome/components/mixer/speaker/mixer_speaker.h +207 -0
  185. esphome/components/modbus_controller/text_sensor/modbus_textsensor.cpp +7 -13
  186. esphome/components/mpr121/mpr121.h +2 -0
  187. esphome/components/mqtt/__init__.py +1 -1
  188. esphome/components/mqtt/mqtt_client.cpp +7 -1
  189. esphome/components/mqtt/mqtt_client.h +1 -1
  190. esphome/components/mqtt/mqtt_climate.cpp +2 -2
  191. esphome/components/network/ip_address.h +2 -0
  192. esphome/components/nextion/automation.h +17 -0
  193. esphome/components/nextion/display.py +42 -17
  194. esphome/components/nextion/nextion.cpp +4 -10
  195. esphome/components/nextion/nextion.h +89 -82
  196. esphome/components/nextion/nextion_commands.cpp +10 -10
  197. esphome/components/ntc/sensor.py +2 -4
  198. esphome/components/online_image/__init__.py +98 -46
  199. esphome/components/online_image/bmp_image.cpp +101 -0
  200. esphome/components/online_image/bmp_image.h +40 -0
  201. esphome/components/online_image/image_decoder.cpp +28 -2
  202. esphome/components/online_image/image_decoder.h +24 -15
  203. esphome/components/online_image/jpeg_image.cpp +90 -0
  204. esphome/components/online_image/jpeg_image.h +34 -0
  205. esphome/components/online_image/online_image.cpp +112 -53
  206. esphome/components/online_image/online_image.h +24 -7
  207. esphome/components/online_image/png_image.cpp +7 -3
  208. esphome/components/online_image/png_image.h +2 -1
  209. esphome/components/opentherm/__init__.py +73 -7
  210. esphome/components/opentherm/automation.h +25 -0
  211. esphome/components/opentherm/const.py +1 -0
  212. esphome/components/opentherm/generate.py +39 -6
  213. esphome/components/opentherm/hub.cpp +117 -79
  214. esphome/components/opentherm/hub.h +31 -15
  215. esphome/components/opentherm/opentherm.cpp +47 -23
  216. esphome/components/opentherm/opentherm.h +27 -6
  217. esphome/components/opentherm/opentherm_macros.h +11 -0
  218. esphome/components/opentherm/schema.py +78 -1
  219. esphome/components/opentherm/validate.py +7 -2
  220. esphome/components/pca6416a/pca6416a.h +2 -0
  221. esphome/components/pca9554/pca9554.h +2 -0
  222. esphome/components/pcf8574/pcf8574.h +2 -0
  223. esphome/components/preferences/__init__.py +2 -4
  224. esphome/components/preferences/syncer.h +10 -3
  225. esphome/components/prometheus/prometheus_handler.cpp +313 -0
  226. esphome/components/prometheus/prometheus_handler.h +48 -7
  227. esphome/components/psram/psram.cpp +8 -1
  228. esphome/components/pulse_counter/pulse_counter_sensor.cpp +14 -9
  229. esphome/components/pulse_counter/pulse_counter_sensor.h +4 -4
  230. esphome/components/pulse_meter/pulse_meter_sensor.cpp +2 -0
  231. esphome/components/qspi_dbi/__init__.py +3 -0
  232. esphome/components/qspi_dbi/display.py +74 -47
  233. esphome/components/qspi_dbi/models.py +245 -2
  234. esphome/components/qspi_dbi/qspi_dbi.cpp +9 -16
  235. esphome/components/qspi_dbi/qspi_dbi.h +2 -2
  236. esphome/components/remote_base/__init__.py +77 -25
  237. esphome/components/remote_base/remote_base.cpp +1 -1
  238. esphome/components/remote_base/remote_base.h +20 -2
  239. esphome/components/remote_base/toto_protocol.cpp +100 -0
  240. esphome/components/remote_base/toto_protocol.h +45 -0
  241. esphome/components/remote_receiver/__init__.py +55 -10
  242. esphome/components/remote_receiver/remote_receiver.h +36 -3
  243. esphome/components/remote_receiver/remote_receiver_esp32.cpp +145 -6
  244. esphome/components/remote_transmitter/__init__.py +62 -4
  245. esphome/components/remote_transmitter/remote_transmitter.h +21 -2
  246. esphome/components/remote_transmitter/remote_transmitter_esp32.cpp +140 -4
  247. esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp +3 -3
  248. esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp +3 -3
  249. esphome/components/resampler/__init__.py +0 -0
  250. esphome/components/resampler/speaker/__init__.py +103 -0
  251. esphome/components/resampler/speaker/resampler_speaker.cpp +318 -0
  252. esphome/components/resampler/speaker/resampler_speaker.h +107 -0
  253. esphome/components/resistance/resistance_sensor.h +2 -3
  254. esphome/components/resistance/sensor.py +2 -9
  255. esphome/components/rotary_encoder/rotary_encoder.cpp +8 -4
  256. esphome/components/rp2040/__init__.py +1 -0
  257. esphome/components/rp2040/gpio.h +1 -0
  258. esphome/components/rtl87xx/__init__.py +2 -0
  259. esphome/components/sdl/binary_sensor.py +270 -0
  260. esphome/components/sdl/sdl_esphome.cpp +16 -0
  261. esphome/components/sdl/sdl_esphome.h +9 -0
  262. esphome/components/seeed_mr60bha2/binary_sensor.py +25 -0
  263. esphome/components/seeed_mr60bha2/seeed_mr60bha2.cpp +26 -2
  264. esphome/components/seeed_mr60bha2/seeed_mr60bha2.h +9 -20
  265. esphome/components/seeed_mr60bha2/sensor.py +9 -1
  266. esphome/components/sn74hc165/sn74hc165.h +3 -0
  267. esphome/components/sn74hc595/sn74hc595.h +3 -0
  268. esphome/components/speaker/__init__.py +5 -4
  269. esphome/components/speaker/media_player/__init__.py +458 -0
  270. esphome/components/speaker/media_player/audio_pipeline.cpp +568 -0
  271. esphome/components/speaker/media_player/audio_pipeline.h +159 -0
  272. esphome/components/speaker/media_player/automation.h +26 -0
  273. esphome/components/speaker/media_player/speaker_media_player.cpp +577 -0
  274. esphome/components/speaker/media_player/speaker_media_player.h +160 -0
  275. esphome/components/speaker/speaker.h +20 -0
  276. esphome/components/spi/__init__.py +1 -5
  277. esphome/components/spi/spi.cpp +7 -1
  278. esphome/components/spi/spi.h +21 -2
  279. esphome/components/spi_led_strip/light.py +3 -5
  280. esphome/components/spi_led_strip/spi_led_strip.cpp +67 -0
  281. esphome/components/spi_led_strip/spi_led_strip.h +8 -60
  282. esphome/components/sprinkler/sprinkler.cpp +3 -1
  283. esphome/components/sx1509/sx1509_gpio_pin.h +2 -0
  284. esphome/components/tca9555/tca9555.h +2 -0
  285. esphome/components/toshiba/toshiba.cpp +2 -1
  286. esphome/components/tuya/light/tuya_light.cpp +4 -2
  287. esphome/components/uart/uart_component_esp32_arduino.cpp +2 -2
  288. esphome/components/uart/uart_component_esp_idf.cpp +2 -2
  289. esphome/components/udp/__init__.py +8 -2
  290. esphome/components/udp/udp_component.cpp +25 -56
  291. esphome/components/udp/udp_component.h +3 -0
  292. esphome/components/uponor_smatrix/sensor/__init__.py +14 -4
  293. esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +5 -0
  294. esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.h +1 -0
  295. esphome/components/uptime/text_sensor/__init__.py +19 -0
  296. esphome/components/uptime/text_sensor/uptime_text_sensor.cpp +63 -0
  297. esphome/components/uptime/text_sensor/uptime_text_sensor.h +25 -0
  298. esphome/components/voice_assistant/voice_assistant.cpp +24 -14
  299. esphome/components/voice_assistant/voice_assistant.h +8 -0
  300. esphome/components/waveshare_epaper/display.py +22 -1
  301. esphome/components/waveshare_epaper/waveshare_213v3.cpp +9 -3
  302. esphome/components/waveshare_epaper/waveshare_epaper.cpp +1155 -44
  303. esphome/components/waveshare_epaper/waveshare_epaper.h +208 -7
  304. esphome/components/web_server/web_server.cpp +28 -6
  305. esphome/components/weikai/weikai.h +2 -0
  306. esphome/components/wifi/__init__.py +6 -6
  307. esphome/components/wifi/wifi_component.cpp +1 -1
  308. esphome/components/wifi/wifi_component_esp32_arduino.cpp +30 -1
  309. esphome/components/wireguard/__init__.py +2 -2
  310. esphome/components/xl9535/xl9535.h +2 -0
  311. esphome/components/xxtea/__init__.py +3 -0
  312. esphome/components/xxtea/xxtea.cpp +46 -0
  313. esphome/components/xxtea/xxtea.h +26 -0
  314. esphome/components/yashima/yashima.cpp +2 -1
  315. esphome/config.py +9 -5
  316. esphome/config_validation.py +55 -17
  317. esphome/const.py +7 -10
  318. esphome/core/__init__.py +6 -13
  319. esphome/core/base_automation.h +1 -0
  320. esphome/core/config.py +59 -72
  321. esphome/core/defines.h +9 -1
  322. esphome/core/gpio.h +7 -0
  323. esphome/core/helpers.cpp +19 -15
  324. esphome/core/helpers.h +57 -8
  325. esphome/core/log.h +9 -7
  326. esphome/cpp_generator.py +2 -2
  327. esphome/espota2.py +3 -2
  328. esphome/loader.py +12 -4
  329. esphome/log.py +5 -7
  330. esphome/yaml_util.py +2 -2
  331. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/METADATA +12 -7
  332. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/RECORD +341 -292
  333. esphome/components/custom/binary_sensor/custom_binary_sensor.cpp +0 -16
  334. esphome/components/custom/binary_sensor/custom_binary_sensor.h +0 -26
  335. esphome/components/custom/climate/custom_climate.h +0 -22
  336. esphome/components/custom/cover/custom_cover.h +0 -21
  337. esphome/components/custom/light/custom_light_output.h +0 -24
  338. esphome/components/custom/output/custom_output.h +0 -37
  339. esphome/components/custom/sensor/custom_sensor.cpp +0 -16
  340. esphome/components/custom/sensor/custom_sensor.h +0 -24
  341. esphome/components/custom/switch/custom_switch.cpp +0 -16
  342. esphome/components/custom/switch/custom_switch.h +0 -24
  343. esphome/components/custom/text_sensor/custom_text_sensor.cpp +0 -16
  344. esphome/components/custom/text_sensor/custom_text_sensor.h +0 -26
  345. esphome/components/custom_component/custom_component.h +0 -28
  346. esphome/components/esp32_ble_server/ble_2901.cpp +0 -18
  347. esphome/components/esp32_ble_server/ble_2901.h +0 -19
  348. esphome/components/resistance_sampler/__init__.py +0 -6
  349. esphome/components/resistance_sampler/resistance_sampler.h +0 -10
  350. esphome/components/uptime/{sensor.py → sensor/__init__.py} +3 -3
  351. /esphome/components/uptime/{uptime_seconds_sensor.cpp → sensor/uptime_seconds_sensor.cpp} +0 -0
  352. /esphome/components/uptime/{uptime_seconds_sensor.h → sensor/uptime_seconds_sensor.h} +0 -0
  353. /esphome/components/uptime/{uptime_timestamp_sensor.cpp → sensor/uptime_timestamp_sensor.cpp} +0 -0
  354. /esphome/components/uptime/{uptime_timestamp_sensor.h → sensor/uptime_timestamp_sensor.h} +0 -0
  355. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/LICENSE +0 -0
  356. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/WHEEL +0 -0
  357. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/entry_points.txt +0 -0
  358. {esphome-2024.12.4.dist-info → esphome-2025.2.0b2.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,7 @@ import logging
6
6
  from pathlib import Path
7
7
  import re
8
8
 
9
- import puremagic
9
+ from PIL import Image, UnidentifiedImageError
10
10
 
11
11
  from esphome import core, external_files
12
12
  import esphome.codegen as cg
@@ -29,22 +29,258 @@ _LOGGER = logging.getLogger(__name__)
29
29
 
30
30
  DOMAIN = "image"
31
31
  DEPENDENCIES = ["display"]
32
- MULTI_CONF = True
33
- MULTI_CONF_NO_DEFAULT = True
34
32
 
35
33
  image_ns = cg.esphome_ns.namespace("image")
36
34
 
37
35
  ImageType = image_ns.enum("ImageType")
36
+
37
+ CONF_OPAQUE = "opaque"
38
+ CONF_CHROMA_KEY = "chroma_key"
39
+ CONF_ALPHA_CHANNEL = "alpha_channel"
40
+ CONF_INVERT_ALPHA = "invert_alpha"
41
+
42
+ TRANSPARENCY_TYPES = (
43
+ CONF_OPAQUE,
44
+ CONF_CHROMA_KEY,
45
+ CONF_ALPHA_CHANNEL,
46
+ )
47
+
48
+
49
+ def get_image_type_enum(type):
50
+ return getattr(ImageType, f"IMAGE_TYPE_{type.upper()}")
51
+
52
+
53
+ def get_transparency_enum(transparency):
54
+ return getattr(TransparencyType, f"TRANSPARENCY_{transparency.upper()}")
55
+
56
+
57
+ class ImageEncoder:
58
+ """
59
+ Superclass of image type encoders
60
+ """
61
+
62
+ # Control which transparency options are available for a given type
63
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
64
+
65
+ # All imageencoder types are valid
66
+ @staticmethod
67
+ def validate(value):
68
+ return value
69
+
70
+ def __init__(self, width, height, transparency, dither, invert_alpha):
71
+ """
72
+ :param width: The image width in pixels
73
+ :param height: The image height in pixels
74
+ :param transparency: Transparency type
75
+ :param dither: Dither method
76
+ :param invert_alpha: True if the alpha channel should be inverted; for monochrome formats inverts the colours.
77
+ """
78
+ self.transparency = transparency
79
+ self.width = width
80
+ self.height = height
81
+ self.data = [0 for _ in range(width * height)]
82
+ self.dither = dither
83
+ self.index = 0
84
+ self.invert_alpha = invert_alpha
85
+ self.path = ""
86
+
87
+ def convert(self, image, path):
88
+ """
89
+ Convert the image format
90
+ :param image: Input image
91
+ :param path: Path to the image file
92
+ :return: converted image
93
+ """
94
+ return image
95
+
96
+ def encode(self, pixel):
97
+ """
98
+ Encode a single pixel
99
+ """
100
+
101
+ def end_row(self):
102
+ """
103
+ Marks the end of a pixel row
104
+ :return:
105
+ """
106
+
107
+
108
+ def is_alpha_only(image: Image):
109
+ """
110
+ Check if an image (assumed to be RGBA) is only alpha
111
+ """
112
+ # Any alpha data?
113
+ if image.split()[-1].getextrema()[0] == 0xFF:
114
+ return False
115
+ return all(b.getextrema()[1] == 0 for b in image.split()[:-1])
116
+
117
+
118
+ class ImageBinary(ImageEncoder):
119
+ allow_config = {CONF_OPAQUE, CONF_INVERT_ALPHA, CONF_CHROMA_KEY}
120
+
121
+ def __init__(self, width, height, transparency, dither, invert_alpha):
122
+ self.width8 = (width + 7) // 8
123
+ super().__init__(self.width8, height, transparency, dither, invert_alpha)
124
+ self.bitno = 0
125
+
126
+ def convert(self, image, path):
127
+ if is_alpha_only(image):
128
+ image = image.split()[-1]
129
+ return image.convert("1", dither=self.dither)
130
+
131
+ def encode(self, pixel):
132
+ if self.invert_alpha:
133
+ pixel = not pixel
134
+ if pixel:
135
+ self.data[self.index] |= 0x80 >> (self.bitno % 8)
136
+ self.bitno += 1
137
+ if self.bitno == 8:
138
+ self.bitno = 0
139
+ self.index += 1
140
+
141
+ def end_row(self):
142
+ """
143
+ Pad rows to a byte boundary
144
+ """
145
+ if self.bitno != 0:
146
+ self.bitno = 0
147
+ self.index += 1
148
+
149
+
150
+ class ImageGrayscale(ImageEncoder):
151
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_INVERT_ALPHA, CONF_OPAQUE}
152
+
153
+ def convert(self, image, path):
154
+ if is_alpha_only(image):
155
+ if self.transparency != CONF_ALPHA_CHANNEL:
156
+ _LOGGER.warning(
157
+ "Grayscale image %s is alpha only, but transparency is set to %s",
158
+ path,
159
+ self.transparency,
160
+ )
161
+ self.transparency = CONF_ALPHA_CHANNEL
162
+ image = image.split()[-1]
163
+ return image.convert("LA")
164
+
165
+ def encode(self, pixel):
166
+ b, a = pixel
167
+ if self.transparency == CONF_CHROMA_KEY:
168
+ if b == 1:
169
+ b = 0
170
+ if a != 0xFF:
171
+ b = 1
172
+ if self.invert_alpha:
173
+ b ^= 0xFF
174
+ if self.transparency == CONF_ALPHA_CHANNEL:
175
+ if a != 0xFF:
176
+ b = a
177
+ self.data[self.index] = b
178
+ self.index += 1
179
+
180
+
181
+ class ImageRGB565(ImageEncoder):
182
+ def __init__(self, width, height, transparency, dither, invert_alpha):
183
+ stride = 3 if transparency == CONF_ALPHA_CHANNEL else 2
184
+ super().__init__(
185
+ width * stride,
186
+ height,
187
+ transparency,
188
+ dither,
189
+ invert_alpha,
190
+ )
191
+
192
+ def convert(self, image, path):
193
+ return image.convert("RGBA")
194
+
195
+ def encode(self, pixel):
196
+ r, g, b, a = pixel
197
+ r = r >> 3
198
+ g = g >> 2
199
+ b = b >> 3
200
+ if self.transparency == CONF_CHROMA_KEY:
201
+ if r == 0 and g == 1 and b == 0:
202
+ g = 0
203
+ elif a < 128:
204
+ r = 0
205
+ g = 1
206
+ b = 0
207
+ rgb = (r << 11) | (g << 5) | b
208
+ self.data[self.index] = rgb >> 8
209
+ self.index += 1
210
+ self.data[self.index] = rgb & 0xFF
211
+ self.index += 1
212
+ if self.transparency == CONF_ALPHA_CHANNEL:
213
+ if self.invert_alpha:
214
+ a ^= 0xFF
215
+ self.data[self.index] = a
216
+ self.index += 1
217
+
218
+
219
+ class ImageRGB(ImageEncoder):
220
+ def __init__(self, width, height, transparency, dither, invert_alpha):
221
+ stride = 4 if transparency == CONF_ALPHA_CHANNEL else 3
222
+ super().__init__(
223
+ width * stride,
224
+ height,
225
+ transparency,
226
+ dither,
227
+ invert_alpha,
228
+ )
229
+
230
+ def convert(self, image, path):
231
+ return image.convert("RGBA")
232
+
233
+ def encode(self, pixel):
234
+ r, g, b, a = pixel
235
+ if self.transparency == CONF_CHROMA_KEY:
236
+ if r == 0 and g == 1 and b == 0:
237
+ g = 0
238
+ elif a < 128:
239
+ r = 0
240
+ g = 1
241
+ b = 0
242
+ self.data[self.index] = r
243
+ self.index += 1
244
+ self.data[self.index] = g
245
+ self.index += 1
246
+ self.data[self.index] = b
247
+ self.index += 1
248
+ if self.transparency == CONF_ALPHA_CHANNEL:
249
+ if self.invert_alpha:
250
+ a ^= 0xFF
251
+ self.data[self.index] = a
252
+ self.index += 1
253
+
254
+
255
+ class ReplaceWith:
256
+ """
257
+ Placeholder class to provide feedback on deprecated features
258
+ """
259
+
260
+ allow_config = {CONF_ALPHA_CHANNEL, CONF_CHROMA_KEY, CONF_OPAQUE}
261
+
262
+ def __init__(self, replace_with):
263
+ self.replace_with = replace_with
264
+
265
+ def validate(self, value):
266
+ raise cv.Invalid(
267
+ f"Image type {value} is removed; replace with {self.replace_with}"
268
+ )
269
+
270
+
38
271
  IMAGE_TYPE = {
39
- "BINARY": ImageType.IMAGE_TYPE_BINARY,
40
- "TRANSPARENT_BINARY": ImageType.IMAGE_TYPE_BINARY,
41
- "GRAYSCALE": ImageType.IMAGE_TYPE_GRAYSCALE,
42
- "RGB565": ImageType.IMAGE_TYPE_RGB565,
43
- "RGB24": ImageType.IMAGE_TYPE_RGB24,
44
- "RGBA": ImageType.IMAGE_TYPE_RGBA,
272
+ "BINARY": ImageBinary,
273
+ "GRAYSCALE": ImageGrayscale,
274
+ "RGB565": ImageRGB565,
275
+ "RGB": ImageRGB,
276
+ "TRANSPARENT_BINARY": ReplaceWith("'type: BINARY' and 'transparency: chroma_key'"),
277
+ "RGB24": ReplaceWith("'type: RGB'"),
278
+ "RGBA": ReplaceWith("'type: RGB' and 'transparency: alpha_channel'"),
45
279
  }
46
280
 
47
- CONF_USE_TRANSPARENCY = "use_transparency"
281
+ TransparencyType = image_ns.enum("TransparencyType")
282
+
283
+ CONF_TRANSPARENCY = "transparency"
48
284
 
49
285
  # If the MDI file cannot be downloaded within this time, abort.
50
286
  IMAGE_DOWNLOAD_TIMEOUT = 30 # seconds
@@ -53,17 +289,11 @@ SOURCE_LOCAL = "local"
53
289
  SOURCE_MDI = "mdi"
54
290
  SOURCE_WEB = "web"
55
291
 
56
-
57
292
  Image_ = image_ns.class_("Image")
58
293
 
59
294
 
60
- def _compute_local_icon_path(value: dict) -> Path:
61
- base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
62
- return base_dir / f"{value[CONF_ICON]}.svg"
63
-
64
-
65
- def compute_local_image_path(value: dict) -> Path:
66
- url = value[CONF_URL]
295
+ def compute_local_image_path(value) -> Path:
296
+ url = value[CONF_URL] if isinstance(value, dict) else value
67
297
  h = hashlib.new("sha256")
68
298
  h.update(url.encode())
69
299
  key = h.hexdigest()[:8]
@@ -71,30 +301,38 @@ def compute_local_image_path(value: dict) -> Path:
71
301
  return base_dir / key
72
302
 
73
303
 
74
- def download_mdi(value):
75
- validate_cairosvg_installed(value)
76
-
77
- mdi_id = value[CONF_ICON]
78
- path = _compute_local_icon_path(value)
304
+ def local_path(value):
305
+ value = value[CONF_PATH] if isinstance(value, dict) else value
306
+ return str(CORE.relative_config_path(value))
79
307
 
80
- url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg"
81
308
 
309
+ def download_file(url, path):
82
310
  external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
311
+ return str(path)
83
312
 
84
- return value
313
+
314
+ def download_mdi(value):
315
+ mdi_id = value[CONF_ICON] if isinstance(value, dict) else value
316
+ base_dir = external_files.compute_local_file_dir(DOMAIN) / "mdi"
317
+ path = base_dir / f"{mdi_id}.svg"
318
+
319
+ url = f"https://raw.githubusercontent.com/Templarian/MaterialDesign/master/svg/{mdi_id}.svg"
320
+ return download_file(url, path)
85
321
 
86
322
 
87
323
  def download_image(value):
88
- url = value[CONF_URL]
89
- path = compute_local_image_path(value)
324
+ value = value[CONF_URL] if isinstance(value, dict) else value
325
+ return download_file(value, compute_local_image_path(value))
90
326
 
91
- external_files.download_content(url, path, IMAGE_DOWNLOAD_TIMEOUT)
92
327
 
93
- return value
328
+ def is_svg_file(file):
329
+ if not file:
330
+ return False
331
+ with open(file, "rb") as f:
332
+ return "<svg" in str(f.read(1024))
94
333
 
95
334
 
96
- def validate_cairosvg_installed(value):
97
- """Validate that cairosvg is installed"""
335
+ def validate_cairosvg_installed():
98
336
  try:
99
337
  import cairosvg
100
338
  except ImportError as err:
@@ -110,73 +348,28 @@ def validate_cairosvg_installed(value):
110
348
  "(pip install -U cairosvg)"
111
349
  )
112
350
 
113
- return value
114
-
115
-
116
- def validate_cross_dependencies(config):
117
- """
118
- Validate fields whose possible values depend on other fields.
119
- For example, validate that explicitly transparent image types
120
- have "use_transparency" set to True.
121
- Also set the default value for those kind of dependent fields.
122
- """
123
- is_mdi = CONF_FILE in config and config[CONF_FILE][CONF_SOURCE] == SOURCE_MDI
124
- if CONF_TYPE not in config:
125
- if is_mdi:
126
- config[CONF_TYPE] = "TRANSPARENT_BINARY"
127
- else:
128
- config[CONF_TYPE] = "BINARY"
129
-
130
- image_type = config[CONF_TYPE]
131
- is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"]
132
-
133
- # If the use_transparency option was not specified, set the default depending on the image type
134
- if CONF_USE_TRANSPARENCY not in config:
135
- config[CONF_USE_TRANSPARENCY] = is_transparent_type
136
-
137
- if is_transparent_type and not config[CONF_USE_TRANSPARENCY]:
138
- raise cv.Invalid(f"Image type {image_type} must always be transparent.")
139
-
140
- if is_mdi and config[CONF_TYPE] not in ["BINARY", "TRANSPARENT_BINARY"]:
141
- raise cv.Invalid("MDI images must be binary images.")
142
-
143
- return config
144
-
145
351
 
146
352
  def validate_file_shorthand(value):
147
353
  value = cv.string_strict(value)
148
354
  if value.startswith("mdi:"):
149
- validate_cairosvg_installed(value)
150
-
151
355
  match = re.search(r"mdi:([a-zA-Z0-9\-]+)", value)
152
356
  if match is None:
153
357
  raise cv.Invalid("Could not parse mdi icon name.")
154
358
  icon = match.group(1)
155
- return FILE_SCHEMA(
156
- {
157
- CONF_SOURCE: SOURCE_MDI,
158
- CONF_ICON: icon,
159
- }
160
- )
359
+ return download_mdi(icon)
360
+
161
361
  if value.startswith("http://") or value.startswith("https://"):
162
- return FILE_SCHEMA(
163
- {
164
- CONF_SOURCE: SOURCE_WEB,
165
- CONF_URL: value,
166
- }
167
- )
168
- return FILE_SCHEMA(
169
- {
170
- CONF_SOURCE: SOURCE_LOCAL,
171
- CONF_PATH: value,
172
- }
173
- )
362
+ return download_image(value)
363
+
364
+ value = cv.file_(value)
365
+ return local_path(value)
174
366
 
175
367
 
176
- LOCAL_SCHEMA = cv.Schema(
368
+ LOCAL_SCHEMA = cv.All(
177
369
  {
178
370
  cv.Required(CONF_PATH): cv.file_,
179
- }
371
+ },
372
+ local_path,
180
373
  )
181
374
 
182
375
  MDI_SCHEMA = cv.All(
@@ -203,205 +396,200 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
203
396
  )
204
397
 
205
398
 
206
- def _file_schema(value):
207
- if isinstance(value, str):
208
- return validate_file_shorthand(value)
209
- return TYPED_FILE_SCHEMA(value)
210
-
211
-
212
- FILE_SCHEMA = cv.Schema(_file_schema)
213
-
214
- IMAGE_SCHEMA = cv.Schema(
215
- cv.All(
216
- {
217
- cv.Required(CONF_ID): cv.declare_id(Image_),
218
- cv.Required(CONF_FILE): FILE_SCHEMA,
219
- cv.Optional(CONF_RESIZE): cv.dimensions,
220
- # Not setting default here on purpose; the default depends on the source type
221
- # (file or mdi), and will be set in the "validate_cross_dependencies" validator.
222
- cv.Optional(CONF_TYPE): cv.enum(IMAGE_TYPE, upper=True),
223
- # Not setting default here on purpose; the default depends on the image type,
224
- # and thus will be set in the "validate_cross_dependencies" validator.
225
- cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean,
226
- cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
227
- "NONE", "FLOYDSTEINBERG", upper=True
228
- ),
229
- cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
230
- },
231
- validate_cross_dependencies,
232
- )
233
- )
399
+ def validate_transparency(choices=TRANSPARENCY_TYPES):
400
+ def validate(value):
401
+ if isinstance(value, bool):
402
+ value = str(value)
403
+ return cv.one_of(*choices, lower=True)(value)
234
404
 
235
- CONFIG_SCHEMA = IMAGE_SCHEMA
405
+ return validate
236
406
 
237
407
 
238
- def load_svg_image(file: bytes, resize: tuple[int, int]):
239
- # Local imports only to allow "validate_pillow_installed" to run *before* importing it
240
- # cairosvg is only needed in case of SVG images; adding it
241
- # to the top would force configurations not using SVG to also have it
242
- # installed for no reason.
243
- from cairosvg import svg2png
244
- from PIL import Image
408
+ def validate_type(image_types):
409
+ def validate(value):
410
+ value = cv.one_of(*image_types, upper=True)(value)
411
+ return IMAGE_TYPE[value].validate(value)
412
+
413
+ return validate
245
414
 
246
- if resize:
247
- req_width, req_height = resize
248
- svg_image = svg2png(
249
- file,
250
- output_width=req_width,
251
- output_height=req_height,
252
- )
253
- else:
254
- svg_image = svg2png(file)
255
415
 
256
- return Image.open(io.BytesIO(svg_image))
416
+ def validate_settings(value):
417
+ type = value[CONF_TYPE]
418
+ transparency = value[CONF_TRANSPARENCY].lower()
419
+ allow_config = IMAGE_TYPE[type].allow_config
420
+ if transparency not in allow_config:
421
+ raise cv.Invalid(
422
+ f"Image format '{type}' cannot have transparency: {transparency}"
423
+ )
424
+ invert_alpha = value.get(CONF_INVERT_ALPHA, False)
425
+ if (
426
+ invert_alpha
427
+ and transparency != CONF_ALPHA_CHANNEL
428
+ and CONF_INVERT_ALPHA not in allow_config
429
+ ):
430
+ raise cv.Invalid("No alpha channel to invert")
431
+ if file := value.get(CONF_FILE):
432
+ file = Path(file)
433
+ if is_svg_file(file):
434
+ validate_cairosvg_installed()
435
+ else:
436
+ try:
437
+ Image.open(file)
438
+ except UnidentifiedImageError as exc:
439
+ raise cv.Invalid(f"File can't be opened as image: {file}") from exc
440
+ return value
257
441
 
258
442
 
259
- async def to_code(config):
260
- # Local import only to allow "validate_pillow_installed" to run *before* importing it
261
- from PIL import Image
443
+ BASE_SCHEMA = cv.Schema(
444
+ {
445
+ cv.Required(CONF_ID): cv.declare_id(Image_),
446
+ cv.Required(CONF_FILE): cv.Any(validate_file_shorthand, TYPED_FILE_SCHEMA),
447
+ cv.Optional(CONF_RESIZE): cv.dimensions,
448
+ cv.Optional(CONF_DITHER, default="NONE"): cv.one_of(
449
+ "NONE", "FLOYDSTEINBERG", upper=True
450
+ ),
451
+ cv.Optional(CONF_INVERT_ALPHA, default=False): cv.boolean,
452
+ cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
453
+ }
454
+ ).add_extra(validate_settings)
262
455
 
263
- conf_file = config[CONF_FILE]
456
+ IMAGE_SCHEMA = BASE_SCHEMA.extend(
457
+ {
458
+ cv.Required(CONF_TYPE): validate_type(IMAGE_TYPE),
459
+ cv.Optional(CONF_TRANSPARENCY, default=CONF_OPAQUE): validate_transparency(),
460
+ }
461
+ )
264
462
 
265
- if conf_file[CONF_SOURCE] == SOURCE_LOCAL:
266
- path = CORE.relative_config_path(conf_file[CONF_PATH])
267
463
 
268
- elif conf_file[CONF_SOURCE] == SOURCE_MDI:
269
- path = _compute_local_icon_path(conf_file).as_posix()
464
+ def typed_image_schema(image_type):
465
+ """
466
+ Construct a schema for a specific image type, allowing transparency options
467
+ """
468
+ return cv.Any(
469
+ cv.Schema(
470
+ {
471
+ cv.Optional(t.lower()): cv.ensure_list(
472
+ BASE_SCHEMA.extend(
473
+ {
474
+ cv.Optional(
475
+ CONF_TRANSPARENCY, default=t
476
+ ): validate_transparency((t,)),
477
+ cv.Optional(CONF_TYPE, default=image_type): validate_type(
478
+ (image_type,)
479
+ ),
480
+ }
481
+ )
482
+ )
483
+ for t in IMAGE_TYPE[image_type].allow_config.intersection(
484
+ TRANSPARENCY_TYPES
485
+ )
486
+ }
487
+ ),
488
+ # Allow a default configuration with no transparency preselected
489
+ cv.ensure_list(
490
+ BASE_SCHEMA.extend(
491
+ {
492
+ cv.Optional(
493
+ CONF_TRANSPARENCY, default=CONF_OPAQUE
494
+ ): validate_transparency(),
495
+ cv.Optional(CONF_TYPE, default=image_type): validate_type(
496
+ (image_type,)
497
+ ),
498
+ }
499
+ )
500
+ ),
501
+ )
270
502
 
271
- elif conf_file[CONF_SOURCE] == SOURCE_WEB:
272
- path = compute_local_image_path(conf_file).as_posix()
273
503
 
274
- else:
275
- raise core.EsphomeError(f"Unknown image source: {conf_file[CONF_SOURCE]}")
504
+ # The config schema can be a (possibly empty) single list of images,
505
+ # or a dictionary of image types each with a list of images
506
+ CONFIG_SCHEMA = cv.Any(
507
+ cv.Schema({cv.Optional(t.lower()): typed_image_schema(t) for t in IMAGE_TYPE}),
508
+ cv.ensure_list(IMAGE_SCHEMA),
509
+ )
276
510
 
277
- try:
278
- with open(path, "rb") as f:
279
- file_contents = f.read()
280
- except Exception as e:
281
- raise core.EsphomeError(f"Could not load image file {path}: {e}")
282
511
 
283
- file_type = puremagic.from_string(file_contents, mime=True)
512
+ async def write_image(config, all_frames=False):
513
+ path = Path(config[CONF_FILE])
514
+ if not path.is_file():
515
+ raise core.EsphomeError(f"Could not load image file {path}")
284
516
 
285
517
  resize = config.get(CONF_RESIZE)
286
- if "svg" in file_type:
287
- image = load_svg_image(file_contents, resize)
518
+ if is_svg_file(path):
519
+ # Local import so use of non-SVG files needn't require cairosvg installed
520
+ from cairosvg import svg2png
521
+
522
+ if not resize:
523
+ resize = (None, None)
524
+ with open(path, "rb") as file:
525
+ image = svg2png(
526
+ file_obj=file,
527
+ output_width=resize[0],
528
+ output_height=resize[1],
529
+ )
530
+ image = Image.open(io.BytesIO(image))
531
+ width, height = image.size
288
532
  else:
289
- image = Image.open(io.BytesIO(file_contents))
533
+ image = Image.open(path)
534
+ width, height = image.size
290
535
  if resize:
291
- image.thumbnail(resize)
292
-
293
- width, height = image.size
536
+ # Preserve aspect ratio
537
+ new_width_max = min(width, resize[0])
538
+ new_height_max = min(height, resize[1])
539
+ ratio = min(new_width_max / width, new_height_max / height)
540
+ width, height = int(width * ratio), int(height * ratio)
294
541
 
295
- if CONF_RESIZE not in config and (width > 500 or height > 500):
542
+ if not resize and (width > 500 or height > 500):
296
543
  _LOGGER.warning(
297
544
  'The image "%s" you requested is very big. Please consider'
298
545
  " using the resize parameter.",
299
546
  path,
300
547
  )
301
548
 
302
- transparent = config[CONF_USE_TRANSPARENCY]
303
-
304
549
  dither = (
305
550
  Image.Dither.NONE
306
551
  if config[CONF_DITHER] == "NONE"
307
552
  else Image.Dither.FLOYDSTEINBERG
308
553
  )
309
- if config[CONF_TYPE] == "GRAYSCALE":
310
- image = image.convert("LA", dither=dither)
311
- pixels = list(image.getdata())
312
- data = [0 for _ in range(height * width)]
313
- pos = 0
314
- for g, a in pixels:
315
- if transparent:
316
- if g == 1:
317
- g = 0
318
- if a < 0x80:
319
- g = 1
320
-
321
- data[pos] = g
322
- pos += 1
323
-
324
- elif config[CONF_TYPE] == "RGBA":
325
- image = image.convert("RGBA")
326
- pixels = list(image.getdata())
327
- data = [0 for _ in range(height * width * 4)]
328
- pos = 0
329
- for r, g, b, a in pixels:
330
- data[pos] = r
331
- pos += 1
332
- data[pos] = g
333
- pos += 1
334
- data[pos] = b
335
- pos += 1
336
- data[pos] = a
337
- pos += 1
338
-
339
- elif config[CONF_TYPE] == "RGB24":
340
- image = image.convert("RGBA")
341
- pixels = list(image.getdata())
342
- data = [0 for _ in range(height * width * 3)]
343
- pos = 0
344
- for r, g, b, a in pixels:
345
- if transparent:
346
- if r == 0 and g == 0 and b == 1:
347
- b = 0
348
- if a < 0x80:
349
- r = 0
350
- g = 0
351
- b = 1
352
-
353
- data[pos] = r
354
- pos += 1
355
- data[pos] = g
356
- pos += 1
357
- data[pos] = b
358
- pos += 1
359
-
360
- elif config[CONF_TYPE] in ["RGB565"]:
361
- image = image.convert("RGBA")
362
- pixels = list(image.getdata())
363
- bytes_per_pixel = 3 if transparent else 2
364
- data = [0 for _ in range(height * width * bytes_per_pixel)]
365
- pos = 0
366
- for r, g, b, a in pixels:
367
- R = r >> 3
368
- G = g >> 2
369
- B = b >> 3
370
- rgb = (R << 11) | (G << 5) | B
371
- data[pos] = rgb >> 8
372
- pos += 1
373
- data[pos] = rgb & 0xFF
374
- pos += 1
375
- if transparent:
376
- data[pos] = a
377
- pos += 1
378
-
379
- elif config[CONF_TYPE] in ["BINARY", "TRANSPARENT_BINARY"]:
380
- if transparent:
381
- alpha = image.split()[-1]
382
- has_alpha = alpha.getextrema()[0] < 0xFF
383
- _LOGGER.debug("%s Has alpha: %s", config[CONF_ID], has_alpha)
384
- image = image.convert("1", dither=dither)
385
- width8 = ((width + 7) // 8) * 8
386
- data = [0 for _ in range(height * width8 // 8)]
387
- for y in range(height):
388
- for x in range(width):
389
- if transparent and has_alpha:
390
- a = alpha.getpixel((x, y))
391
- if not a:
392
- continue
393
- elif image.getpixel((x, y)):
394
- continue
395
- pos = x + y * width8
396
- data[pos // 8] |= 0x80 >> (pos % 8)
554
+ type = config[CONF_TYPE]
555
+ transparency = config[CONF_TRANSPARENCY]
556
+ invert_alpha = config[CONF_INVERT_ALPHA]
557
+ frame_count = 1
558
+ if all_frames:
559
+ try:
560
+ frame_count = image.n_frames
561
+ except AttributeError:
562
+ pass
563
+ if frame_count <= 1:
564
+ _LOGGER.warning("Image file %s has no animation frames", path)
565
+
566
+ total_rows = height * frame_count
567
+ encoder = IMAGE_TYPE[type](width, total_rows, transparency, dither, invert_alpha)
568
+ for frame_index in range(frame_count):
569
+ image.seek(frame_index)
570
+ pixels = encoder.convert(image.resize((width, height)), path).getdata()
571
+ for row in range(height):
572
+ for col in range(width):
573
+ encoder.encode(pixels[row * width + col])
574
+ encoder.end_row()
575
+
576
+ rhs = [HexInt(x) for x in encoder.data]
577
+ prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
578
+ image_type = get_image_type_enum(type)
579
+ trans_value = get_transparency_enum(encoder.transparency)
580
+
581
+ return prog_arr, width, height, image_type, trans_value, frame_count
582
+
583
+
584
+ async def to_code(config):
585
+ if isinstance(config, list):
586
+ for entry in config:
587
+ await to_code(entry)
588
+ elif CONF_ID not in config:
589
+ for entry in config.values():
590
+ await to_code(entry)
397
591
  else:
398
- raise core.EsphomeError(
399
- f"Image f{config[CONF_ID]} has an unsupported type: {config[CONF_TYPE]}."
592
+ prog_arr, width, height, image_type, trans_value, _ = await write_image(config)
593
+ cg.new_Pvariable(
594
+ config[CONF_ID], prog_arr, width, height, image_type, trans_value
400
595
  )
401
-
402
- rhs = [HexInt(x) for x in data]
403
- prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs)
404
- var = cg.new_Pvariable(
405
- config[CONF_ID], prog_arr, width, height, IMAGE_TYPE[config[CONF_TYPE]]
406
- )
407
- cg.add(var.set_transparency(transparent))