esphome 2025.9.3__py3-none-any.whl → 2025.10.0b1__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 (343) hide show
  1. esphome/__main__.py +87 -31
  2. esphome/address_cache.py +142 -0
  3. esphome/automation.py +130 -32
  4. esphome/build_gen/platformio.py +1 -3
  5. esphome/codegen.py +1 -0
  6. esphome/components/animation/animation.cpp +2 -2
  7. esphome/components/api/__init__.py +166 -3
  8. esphome/components/api/api_connection.cpp +84 -41
  9. esphome/components/api/api_connection.h +22 -16
  10. esphome/components/api/api_frame_helper.cpp +33 -19
  11. esphome/components/api/api_frame_helper.h +19 -4
  12. esphome/components/api/api_frame_helper_noise.cpp +41 -53
  13. esphome/components/api/api_frame_helper_noise.h +1 -1
  14. esphome/components/api/api_frame_helper_plaintext.cpp +22 -31
  15. esphome/components/api/api_frame_helper_plaintext.h +1 -1
  16. esphome/components/api/api_pb2.cpp +189 -15
  17. esphome/components/api/api_pb2.h +132 -20
  18. esphome/components/api/api_pb2_dump.cpp +97 -9
  19. esphome/components/api/api_pb2_service.cpp +118 -160
  20. esphome/components/api/api_pb2_service.h +31 -3
  21. esphome/components/api/api_server.cpp +68 -10
  22. esphome/components/api/api_server.h +32 -4
  23. esphome/components/api/custom_api_device.h +8 -8
  24. esphome/components/api/homeassistant_service.h +123 -6
  25. esphome/components/api/proto.h +6 -2
  26. esphome/components/api/user_services.h +2 -2
  27. esphome/components/as7341/sensor.py +1 -1
  28. esphome/components/audio/__init__.py +1 -1
  29. esphome/components/audio/audio.cpp +1 -1
  30. esphome/components/audio/audio_decoder.cpp +9 -9
  31. esphome/components/bl0906/bl0906.cpp +2 -2
  32. esphome/components/bl0942/bl0942.cpp +2 -2
  33. esphome/components/ble_client/__init__.py +1 -1
  34. esphome/components/bluetooth_proxy/__init__.py +4 -30
  35. esphome/components/bluetooth_proxy/bluetooth_connection.cpp +11 -4
  36. esphome/components/bluetooth_proxy/bluetooth_connection.h +2 -2
  37. esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +2 -2
  38. esphome/components/camera_encoder/__init__.py +2 -4
  39. esphome/components/camera_encoder/esp32_camera_jpeg_encoder.cpp +4 -2
  40. esphome/components/camera_encoder/esp32_camera_jpeg_encoder.h +3 -1
  41. esphome/components/canbus/canbus.cpp +7 -5
  42. esphome/components/canbus/canbus.h +4 -4
  43. esphome/components/captive_portal/__init__.py +18 -1
  44. esphome/components/captive_portal/captive_portal.cpp +40 -46
  45. esphome/components/captive_portal/captive_portal.h +20 -22
  46. esphome/components/captive_portal/dns_server_esp32_idf.cpp +205 -0
  47. esphome/components/captive_portal/dns_server_esp32_idf.h +27 -0
  48. esphome/components/ccs811/ccs811.cpp +1 -1
  49. esphome/components/climate/climate.cpp +10 -7
  50. esphome/components/cm1106/cm1106.cpp +1 -1
  51. esphome/components/copy/lock/copy_lock.cpp +1 -1
  52. esphome/components/cover/cover.cpp +1 -0
  53. esphome/components/daikin_arc/daikin_arc.cpp +19 -12
  54. esphome/components/deep_sleep/__init__.py +9 -2
  55. esphome/components/deep_sleep/deep_sleep_component.h +11 -9
  56. esphome/components/deep_sleep/deep_sleep_esp32.cpp +51 -27
  57. esphome/components/ektf2232/touchscreen/__init__.py +8 -5
  58. esphome/components/ektf2232/touchscreen/ektf2232.cpp +4 -4
  59. esphome/components/ektf2232/touchscreen/ektf2232.h +2 -2
  60. esphome/components/epaper_spi/__init__.py +1 -0
  61. esphome/components/epaper_spi/display.py +80 -0
  62. esphome/components/epaper_spi/epaper_spi.cpp +227 -0
  63. esphome/components/epaper_spi/epaper_spi.h +93 -0
  64. esphome/components/epaper_spi/epaper_spi_model_7p3in_spectra_e6.cpp +42 -0
  65. esphome/components/epaper_spi/epaper_spi_model_7p3in_spectra_e6.h +45 -0
  66. esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +135 -0
  67. esphome/components/epaper_spi/epaper_spi_spectra_e6.h +23 -0
  68. esphome/components/es7210/es7210.cpp +3 -3
  69. esphome/components/esp32/__init__.py +254 -339
  70. esphome/components/esp32/boards.py +81 -0
  71. esphome/components/esp32/preferences.cpp +23 -17
  72. esphome/components/esp32_ble/__init__.py +159 -44
  73. esphome/components/esp32_ble/ble.cpp +47 -3
  74. esphome/components/esp32_ble/ble.h +18 -0
  75. esphome/components/esp32_ble/ble_advertising.cpp +7 -3
  76. esphome/components/esp32_ble/ble_advertising.h +4 -0
  77. esphome/components/esp32_ble/ble_uuid.cpp +16 -42
  78. esphome/components/esp32_ble_beacon/__init__.py +3 -4
  79. esphome/components/esp32_ble_client/ble_client_base.cpp +14 -12
  80. esphome/components/esp32_ble_server/__init__.py +28 -14
  81. esphome/components/esp32_ble_server/ble_characteristic.cpp +67 -57
  82. esphome/components/esp32_ble_server/ble_characteristic.h +27 -16
  83. esphome/components/esp32_ble_server/ble_descriptor.cpp +4 -3
  84. esphome/components/esp32_ble_server/ble_descriptor.h +13 -9
  85. esphome/components/esp32_ble_server/ble_server.cpp +59 -24
  86. esphome/components/esp32_ble_server/ble_server.h +38 -20
  87. esphome/components/esp32_ble_server/ble_server_automations.cpp +49 -33
  88. esphome/components/esp32_ble_server/ble_server_automations.h +39 -24
  89. esphome/components/esp32_ble_tracker/__init__.py +25 -80
  90. esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +2 -4
  91. esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +0 -3
  92. esphome/components/esp32_camera/__init__.py +1 -3
  93. esphome/components/esp32_can/esp32_can.cpp +22 -4
  94. esphome/components/esp32_can/esp32_can.h +3 -0
  95. esphome/components/esp32_hosted/__init__.py +2 -1
  96. esphome/components/esp32_improv/esp32_improv_component.cpp +102 -44
  97. esphome/components/esp32_improv/esp32_improv_component.h +6 -1
  98. esphome/components/esp32_rmt_led_strip/led_strip.cpp +1 -1
  99. esphome/components/esp8266/__init__.py +3 -3
  100. esphome/components/esphome/ota/__init__.py +21 -2
  101. esphome/components/esphome/ota/ota_esphome.cpp +455 -145
  102. esphome/components/esphome/ota/ota_esphome.h +49 -2
  103. esphome/components/ethernet/__init__.py +39 -22
  104. esphome/components/ethernet/ethernet_component.cpp +28 -5
  105. esphome/components/ethernet/ethernet_component.h +5 -1
  106. esphome/components/external_components/__init__.py +8 -6
  107. esphome/components/fingerprint_grow/fingerprint_grow.cpp +1 -1
  108. esphome/components/fingerprint_grow/fingerprint_grow.h +2 -1
  109. esphome/components/font/__init__.py +5 -5
  110. esphome/components/graph/graph.cpp +1 -1
  111. esphome/components/graphical_display_menu/graphical_display_menu.cpp +3 -2
  112. esphome/components/haier/hon_climate.cpp +2 -2
  113. esphome/components/haier/hon_climate.h +1 -1
  114. esphome/components/hdc1080/hdc1080.cpp +42 -34
  115. esphome/components/hdc1080/hdc1080.h +1 -3
  116. esphome/components/homeassistant/number/homeassistant_number.cpp +2 -2
  117. esphome/components/homeassistant/switch/homeassistant_switch.cpp +2 -2
  118. esphome/components/http_request/__init__.py +3 -3
  119. esphome/components/htu21d/htu21d.cpp +13 -18
  120. esphome/components/htu21d/htu21d.h +1 -1
  121. esphome/components/i2s_audio/__init__.py +1 -2
  122. esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +1 -1
  123. esphome/components/ili9xxx/ili9xxx_display.cpp +2 -2
  124. esphome/components/improv_serial/improv_serial_component.cpp +12 -15
  125. esphome/components/improv_serial/improv_serial_component.h +6 -8
  126. esphome/components/json/json_util.cpp +35 -43
  127. esphome/components/json/json_util.h +57 -0
  128. esphome/components/kamstrup_kmp/kamstrup_kmp.cpp +2 -2
  129. esphome/components/key_collector/key_collector.h +4 -4
  130. esphome/components/libretiny/__init__.py +6 -6
  131. esphome/components/libretiny/preferences.cpp +23 -16
  132. esphome/components/light/light_call.cpp +98 -120
  133. esphome/components/light/light_call.h +17 -7
  134. esphome/components/lm75b/__init__.py +0 -0
  135. esphome/components/lm75b/lm75b.cpp +39 -0
  136. esphome/components/lm75b/lm75b.h +19 -0
  137. esphome/components/lm75b/sensor.py +34 -0
  138. esphome/components/lock/lock.h +12 -6
  139. esphome/components/logger/__init__.py +15 -27
  140. esphome/components/logger/logger.cpp +10 -20
  141. esphome/components/logger/logger.h +105 -62
  142. esphome/components/logger/logger_esp32.cpp +0 -48
  143. esphome/components/logger/logger_zephyr.cpp +2 -3
  144. esphome/components/logger/select/logger_level_select.cpp +6 -7
  145. esphome/components/logger/select/logger_level_select.h +7 -0
  146. esphome/components/ltr501/ltr501.cpp +7 -6
  147. esphome/components/ltr_als_ps/ltr_als_ps.cpp +7 -6
  148. esphome/components/matrix_keypad/matrix_keypad.h +4 -4
  149. esphome/components/max7219digit/max7219digit.cpp +1 -1
  150. esphome/components/mcp2515/mcp2515.cpp +31 -3
  151. esphome/components/mcp2515/mcp2515_defs.h +3 -1
  152. esphome/components/md5/md5.cpp +0 -26
  153. esphome/components/md5/md5.h +10 -20
  154. esphome/components/mdns/__init__.py +19 -6
  155. esphome/components/mdns/mdns_component.cpp +27 -59
  156. esphome/components/mdns/mdns_component.h +23 -10
  157. esphome/components/mdns/mdns_esp32.cpp +7 -7
  158. esphome/components/mdns/mdns_esp8266.cpp +6 -6
  159. esphome/components/mdns/mdns_libretiny.cpp +3 -3
  160. esphome/components/mdns/mdns_rp2040.cpp +3 -3
  161. esphome/components/mipi/__init__.py +1 -5
  162. esphome/components/mipi_spi/display.py +24 -8
  163. esphome/components/mipi_spi/mipi_spi.h +3 -3
  164. esphome/components/mixer/speaker/mixer_speaker.cpp +3 -3
  165. esphome/components/mmc5603/mmc5603.cpp +3 -3
  166. esphome/components/modbus/modbus.cpp +27 -13
  167. esphome/components/modbus/modbus.h +5 -3
  168. esphome/components/modbus/modbus_definitions.h +86 -0
  169. esphome/components/modbus_controller/__init__.py +29 -1
  170. esphome/components/modbus_controller/const.py +4 -0
  171. esphome/components/modbus_controller/modbus_controller.cpp +38 -13
  172. esphome/components/modbus_controller/modbus_controller.h +18 -29
  173. esphome/components/mpr121/mpr121.cpp +41 -42
  174. esphome/components/mpr121/mpr121.h +0 -1
  175. esphome/components/nau7802/nau7802.cpp +2 -2
  176. esphome/components/network/__init__.py +7 -3
  177. esphome/components/nextion/display.py +4 -4
  178. esphome/components/nextion/nextion.cpp +8 -8
  179. esphome/components/number/__init__.py +2 -0
  180. esphome/components/number/number_call.cpp +23 -12
  181. esphome/components/number/number_call.h +5 -0
  182. esphome/components/online_image/bmp_image.cpp +2 -1
  183. esphome/components/online_image/jpeg_image.cpp +4 -2
  184. esphome/components/openthread/openthread.cpp +6 -7
  185. esphome/components/openthread/openthread.h +0 -1
  186. esphome/components/ota/ota_backend.h +1 -0
  187. esphome/components/packages/__init__.py +10 -8
  188. esphome/components/packet_transport/packet_transport.cpp +2 -0
  189. esphome/components/pid/pid_controller.cpp +1 -1
  190. esphome/components/prometheus/prometheus_handler.cpp +239 -239
  191. esphome/components/psram/__init__.py +30 -28
  192. esphome/components/qmc5883l/qmc5883l.cpp +15 -0
  193. esphome/components/qmc5883l/qmc5883l.h +3 -0
  194. esphome/components/qmc5883l/sensor.py +31 -12
  195. esphome/components/remote_base/gobox_protocol.cpp +3 -3
  196. esphome/components/remote_receiver/__init__.py +14 -2
  197. esphome/components/remote_receiver/{remote_receiver_esp8266.cpp → remote_receiver.cpp} +2 -2
  198. esphome/components/remote_receiver/remote_receiver.h +4 -0
  199. esphome/components/remote_receiver/remote_receiver_esp32.cpp +18 -1
  200. esphome/components/remote_transmitter/__init__.py +2 -2
  201. esphome/components/remote_transmitter/remote_transmitter.cpp +103 -0
  202. esphome/components/rp2040/__init__.py +11 -11
  203. esphome/components/rtttl/rtttl.cpp +2 -2
  204. esphome/components/scd30/sensor.py +1 -1
  205. esphome/components/script/__init__.py +1 -1
  206. esphome/components/script/script.h +7 -7
  207. esphome/components/select/select.cpp +5 -4
  208. esphome/components/select/select_call.cpp +1 -1
  209. esphome/components/sensirion_common/i2c_sensirion.cpp +2 -1
  210. esphome/components/sensor/__init__.py +2 -0
  211. esphome/components/sha256/__init__.py +22 -0
  212. esphome/components/sha256/sha256.cpp +116 -0
  213. esphome/components/sha256/sha256.h +60 -0
  214. esphome/components/socket/lwip_raw_tcp_impl.cpp +34 -6
  215. esphome/components/sonoff_d1/sonoff_d1.cpp +1 -1
  216. esphome/components/spi/__init__.py +0 -3
  217. esphome/components/split_buffer/__init__.py +5 -0
  218. esphome/components/split_buffer/split_buffer.cpp +133 -0
  219. esphome/components/split_buffer/split_buffer.h +40 -0
  220. esphome/components/sps30/sps30.cpp +14 -10
  221. esphome/components/sps30/sps30.h +2 -0
  222. esphome/components/st7567_i2c/st7567_i2c.cpp +3 -1
  223. esphome/components/st7789v/st7789v.cpp +3 -2
  224. esphome/components/statsd/statsd.cpp +1 -1
  225. esphome/components/substitutions/__init__.py +3 -1
  226. esphome/components/substitutions/jinja.py +13 -3
  227. esphome/components/sx126x/__init__.py +16 -0
  228. esphome/components/sx126x/sx126x.cpp +15 -1
  229. esphome/components/sx126x/sx126x.h +9 -1
  230. esphome/components/sx126x/sx126x_reg.h +2 -0
  231. esphome/components/text_sensor/text_sensor.cpp +16 -0
  232. esphome/components/text_sensor/text_sensor.h +3 -10
  233. esphome/components/tormatic/tormatic_cover.cpp +1 -1
  234. esphome/components/tuya/select/tuya_select.cpp +1 -1
  235. esphome/components/tuya/tuya.cpp +29 -4
  236. esphome/components/uart/__init__.py +36 -26
  237. esphome/components/uart/uart.h +6 -0
  238. esphome/components/uart/uart_component.cpp +8 -0
  239. esphome/components/uart/uart_component.h +28 -0
  240. esphome/components/uart/uart_component_esp_idf.cpp +64 -10
  241. esphome/components/uart/uart_component_esp_idf.h +5 -2
  242. esphome/components/uponor_smatrix/climate/uponor_smatrix_climate.cpp +1 -1
  243. esphome/components/uponor_smatrix/sensor/uponor_smatrix_sensor.cpp +1 -1
  244. esphome/components/uponor_smatrix/uponor_smatrix.cpp +3 -3
  245. esphome/components/usb_host/__init__.py +2 -1
  246. esphome/components/usb_host/usb_host.h +82 -13
  247. esphome/components/usb_host/usb_host_client.cpp +180 -24
  248. esphome/components/usb_host/usb_host_component.cpp +1 -1
  249. esphome/components/usb_uart/__init__.py +0 -1
  250. esphome/components/usb_uart/ch34x.cpp +4 -4
  251. esphome/components/usb_uart/cp210x.cpp +3 -3
  252. esphome/components/usb_uart/usb_uart.cpp +88 -32
  253. esphome/components/usb_uart/usb_uart.h +30 -6
  254. esphome/components/valve/valve.cpp +1 -0
  255. esphome/components/veml7700/veml7700.cpp +7 -6
  256. esphome/components/version/version_text_sensor.cpp +2 -1
  257. esphome/components/voice_assistant/voice_assistant.cpp +3 -2
  258. esphome/components/waveshare_epaper/waveshare_epaper.cpp +4 -4
  259. esphome/components/web_server/list_entities.cpp +3 -4
  260. esphome/components/web_server/list_entities.h +8 -10
  261. esphome/components/web_server/ota/__init__.py +1 -1
  262. esphome/components/web_server/ota/ota_web_server.cpp +9 -3
  263. esphome/components/web_server/web_server.cpp +509 -404
  264. esphome/components/web_server/web_server.h +5 -6
  265. esphome/components/web_server/web_server_v1.cpp +21 -19
  266. esphome/components/web_server_base/__init__.py +5 -2
  267. esphome/components/web_server_base/web_server_base.h +27 -7
  268. esphome/components/web_server_idf/__init__.py +1 -1
  269. esphome/components/web_server_idf/multipart.cpp +2 -2
  270. esphome/components/web_server_idf/multipart.h +2 -2
  271. esphome/components/web_server_idf/utils.cpp +2 -2
  272. esphome/components/web_server_idf/utils.h +2 -2
  273. esphome/components/web_server_idf/web_server_idf.cpp +118 -26
  274. esphome/components/web_server_idf/web_server_idf.h +12 -10
  275. esphome/components/wifi/__init__.py +13 -11
  276. esphome/components/wifi/wifi_component.cpp +73 -56
  277. esphome/components/wifi/wifi_component.h +4 -4
  278. esphome/components/wifi/wifi_component_esp8266.cpp +1 -1
  279. esphome/components/wifi/wifi_component_esp_idf.cpp +24 -4
  280. esphome/components/wireguard/__init__.py +1 -1
  281. esphome/components/wts01/__init__.py +0 -0
  282. esphome/components/wts01/sensor.py +41 -0
  283. esphome/components/wts01/wts01.cpp +91 -0
  284. esphome/components/wts01/wts01.h +27 -0
  285. esphome/components/zephyr/__init__.py +5 -5
  286. esphome/components/zwave_proxy/__init__.py +43 -0
  287. esphome/components/zwave_proxy/zwave_proxy.cpp +346 -0
  288. esphome/components/zwave_proxy/zwave_proxy.h +93 -0
  289. esphome/config.py +79 -24
  290. esphome/config_validation.py +13 -15
  291. esphome/const.py +9 -2
  292. esphome/core/__init__.py +31 -22
  293. esphome/core/component.cpp +28 -18
  294. esphome/core/component_iterator.h +2 -1
  295. esphome/core/config.py +15 -15
  296. esphome/core/defines.h +19 -0
  297. esphome/core/hash_base.h +56 -0
  298. esphome/core/helpers.cpp +19 -3
  299. esphome/core/helpers.h +26 -0
  300. esphome/core/scheduler.cpp +5 -21
  301. esphome/core/scheduler.h +19 -8
  302. esphome/core/string_ref.h +1 -1
  303. esphome/core/time.cpp +5 -5
  304. esphome/cpp_generator.py +4 -29
  305. esphome/dashboard/const.py +21 -4
  306. esphome/dashboard/core.py +10 -8
  307. esphome/dashboard/dns.py +15 -0
  308. esphome/dashboard/entries.py +15 -21
  309. esphome/dashboard/models.py +76 -0
  310. esphome/dashboard/settings.py +7 -7
  311. esphome/dashboard/status/mdns.py +46 -2
  312. esphome/dashboard/web_server.py +367 -93
  313. esphome/espota2.py +111 -31
  314. esphome/external_files.py +6 -7
  315. esphome/git.py +8 -0
  316. esphome/helpers.py +124 -77
  317. esphome/loader.py +8 -9
  318. esphome/platformio_api.py +25 -18
  319. esphome/storage_json.py +26 -21
  320. esphome/types.py +30 -2
  321. esphome/util.py +32 -16
  322. esphome/vscode.py +8 -8
  323. esphome/wizard.py +10 -10
  324. esphome/writer.py +50 -15
  325. esphome/yaml_util.py +37 -31
  326. esphome/zeroconf.py +12 -3
  327. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/METADATA +11 -11
  328. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/RECORD +332 -312
  329. esphome/components/event_emitter/__init__.py +0 -5
  330. esphome/components/event_emitter/event_emitter.cpp +0 -14
  331. esphome/components/event_emitter/event_emitter.h +0 -63
  332. esphome/components/remote_receiver/remote_receiver_libretiny.cpp +0 -125
  333. esphome/components/remote_transmitter/remote_transmitter_esp8266.cpp +0 -107
  334. esphome/components/remote_transmitter/remote_transmitter_libretiny.cpp +0 -110
  335. esphome/components/uart/uart_component_esp32_arduino.cpp +0 -214
  336. esphome/components/uart/uart_component_esp32_arduino.h +0 -60
  337. esphome/components/wifi/wifi_component_esp32_arduino.cpp +0 -860
  338. esphome/core/string_ref.cpp +0 -12
  339. esphome/dashboard/util/file.py +0 -63
  340. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/WHEEL +0 -0
  341. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/entry_points.txt +0 -0
  342. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/licenses/LICENSE +0 -0
  343. {esphome-2025.9.3.dist-info → esphome-2025.10.0b1.dist-info}/top_level.txt +0 -0
esphome/espota2.py CHANGED
@@ -1,19 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Callable
3
4
  import gzip
4
5
  import hashlib
5
6
  import io
6
7
  import logging
8
+ from pathlib import Path
7
9
  import random
8
10
  import socket
9
11
  import sys
10
12
  import time
13
+ from typing import Any
11
14
 
12
15
  from esphome.core import EsphomeError
13
16
  from esphome.helpers import resolve_ip_address
14
17
 
15
18
  RESPONSE_OK = 0x00
16
19
  RESPONSE_REQUEST_AUTH = 0x01
20
+ RESPONSE_REQUEST_SHA256_AUTH = 0x02
17
21
 
18
22
  RESPONSE_HEADER_OK = 0x40
19
23
  RESPONSE_AUTH_OK = 0x41
@@ -44,6 +48,7 @@ OTA_VERSION_2_0 = 2
44
48
  MAGIC_BYTES = [0x6C, 0x26, 0xF7, 0x5C, 0x45]
45
49
 
46
50
  FEATURE_SUPPORTS_COMPRESSION = 0x01
51
+ FEATURE_SUPPORTS_SHA256_AUTH = 0x02
47
52
 
48
53
 
49
54
  UPLOAD_BLOCK_SIZE = 8192
@@ -51,6 +56,12 @@ UPLOAD_BUFFER_SIZE = UPLOAD_BLOCK_SIZE * 8
51
56
 
52
57
  _LOGGER = logging.getLogger(__name__)
53
58
 
59
+ # Authentication method lookup table: response -> (hash_func, nonce_size, name)
60
+ _AUTH_METHODS: dict[int, tuple[Callable[..., Any], int, str]] = {
61
+ RESPONSE_REQUEST_SHA256_AUTH: (hashlib.sha256, 64, "SHA256"),
62
+ RESPONSE_REQUEST_AUTH: (hashlib.md5, 32, "MD5"),
63
+ }
64
+
54
65
 
55
66
  class ProgressBar:
56
67
  def __init__(self):
@@ -80,18 +91,43 @@ class OTAError(EsphomeError):
80
91
  pass
81
92
 
82
93
 
83
- def recv_decode(sock, amount, decode=True):
94
+ def recv_decode(
95
+ sock: socket.socket, amount: int, decode: bool = True
96
+ ) -> bytes | list[int]:
97
+ """Receive data from socket and optionally decode to list of integers.
98
+
99
+ :param sock: Socket to receive data from.
100
+ :param amount: Number of bytes to receive.
101
+ :param decode: If True, convert bytes to list of integers, otherwise return raw bytes.
102
+ :return: List of integers if decode=True, otherwise raw bytes.
103
+ """
84
104
  data = sock.recv(amount)
85
105
  if not decode:
86
106
  return data
87
107
  return list(data)
88
108
 
89
109
 
90
- def receive_exactly(sock, amount, msg, expect, decode=True):
91
- data = [] if decode else b""
110
+ def receive_exactly(
111
+ sock: socket.socket,
112
+ amount: int,
113
+ msg: str,
114
+ expect: int | list[int] | None,
115
+ decode: bool = True,
116
+ ) -> list[int] | bytes:
117
+ """Receive exactly the specified amount of data from socket with error checking.
118
+
119
+ :param sock: Socket to receive data from.
120
+ :param amount: Exact number of bytes to receive.
121
+ :param msg: Description of what is being received for error messages.
122
+ :param expect: Expected response code(s) for validation, None to skip validation.
123
+ :param decode: If True, return list of integers, otherwise return raw bytes.
124
+ :return: List of integers if decode=True, otherwise raw bytes.
125
+ :raises OTAError: If receiving fails or response doesn't match expected.
126
+ """
127
+ data: list[int] | bytes = [] if decode else b""
92
128
 
93
129
  try:
94
- data += recv_decode(sock, 1, decode=decode)
130
+ data += recv_decode(sock, 1, decode=decode) # type: ignore[operator]
95
131
  except OSError as err:
96
132
  raise OTAError(f"Error receiving acknowledge {msg}: {err}") from err
97
133
 
@@ -103,13 +139,19 @@ def receive_exactly(sock, amount, msg, expect, decode=True):
103
139
 
104
140
  while len(data) < amount:
105
141
  try:
106
- data += recv_decode(sock, amount - len(data), decode=decode)
142
+ data += recv_decode(sock, amount - len(data), decode=decode) # type: ignore[operator]
107
143
  except OSError as err:
108
144
  raise OTAError(f"Error receiving {msg}: {err}") from err
109
145
  return data
110
146
 
111
147
 
112
- def check_error(data, expect):
148
+ def check_error(data: list[int] | bytes, expect: int | list[int] | None) -> None:
149
+ """Check response data for error codes and validate against expected response.
150
+
151
+ :param data: Response data from device (first byte is the response code).
152
+ :param expect: Expected response code(s), None to skip validation.
153
+ :raises OTAError: If an error code is detected or response doesn't match expected.
154
+ """
113
155
  if not expect:
114
156
  return
115
157
  dat = data[0]
@@ -124,7 +166,7 @@ def check_error(data, expect):
124
166
  raise OTAError("Error: Authentication invalid. Is the password correct?")
125
167
  if dat == RESPONSE_ERROR_WRITING_FLASH:
126
168
  raise OTAError(
127
- "Error: Wring OTA data to flash memory failed. See USB logs for more "
169
+ "Error: Writing OTA data to flash memory failed. See USB logs for more "
128
170
  "information."
129
171
  )
130
172
  if dat == RESPONSE_ERROR_UPDATE_END:
@@ -176,7 +218,16 @@ def check_error(data, expect):
176
218
  raise OTAError(f"Unexpected response from ESP: 0x{data[0]:02X}")
177
219
 
178
220
 
179
- def send_check(sock, data, msg):
221
+ def send_check(
222
+ sock: socket.socket, data: list[int] | tuple[int, ...] | int | str | bytes, msg: str
223
+ ) -> None:
224
+ """Send data to socket with error handling.
225
+
226
+ :param sock: Socket to send data to.
227
+ :param data: Data to send (can be list/tuple of ints, single int, string, or bytes).
228
+ :param msg: Description of what is being sent for error messages.
229
+ :raises OTAError: If sending fails.
230
+ """
180
231
  try:
181
232
  if isinstance(data, (list, tuple)):
182
233
  data = bytes(data)
@@ -191,7 +242,7 @@ def send_check(sock, data, msg):
191
242
 
192
243
 
193
244
  def perform_ota(
194
- sock: socket.socket, password: str, file_handle: io.IOBase, filename: str
245
+ sock: socket.socket, password: str, file_handle: io.IOBase, filename: Path
195
246
  ) -> None:
196
247
  file_contents = file_handle.read()
197
248
  file_size = len(file_contents)
@@ -209,10 +260,14 @@ def perform_ota(
209
260
  f"Device uses unsupported OTA version {version}, this ESPHome supports {supported_versions}"
210
261
  )
211
262
 
212
- # Features
213
- send_check(sock, FEATURE_SUPPORTS_COMPRESSION, "features")
263
+ # Features - send both compression and SHA256 auth support
264
+ features_to_send = FEATURE_SUPPORTS_COMPRESSION | FEATURE_SUPPORTS_SHA256_AUTH
265
+ send_check(sock, features_to_send, "features")
214
266
  features = receive_exactly(
215
- sock, 1, "features", [RESPONSE_HEADER_OK, RESPONSE_SUPPORTS_COMPRESSION]
267
+ sock,
268
+ 1,
269
+ "features",
270
+ None, # Accept any response
216
271
  )[0]
217
272
 
218
273
  if features == RESPONSE_SUPPORTS_COMPRESSION:
@@ -221,31 +276,52 @@ def perform_ota(
221
276
  else:
222
277
  upload_contents = file_contents
223
278
 
224
- (auth,) = receive_exactly(
225
- sock, 1, "auth", [RESPONSE_REQUEST_AUTH, RESPONSE_AUTH_OK]
226
- )
227
- if auth == RESPONSE_REQUEST_AUTH:
279
+ def perform_auth(
280
+ sock: socket.socket,
281
+ password: str,
282
+ hash_func: Callable[..., Any],
283
+ nonce_size: int,
284
+ hash_name: str,
285
+ ) -> None:
286
+ """Perform challenge-response authentication using specified hash algorithm."""
228
287
  if not password:
229
288
  raise OTAError("ESP requests password, but no password given!")
230
- nonce = receive_exactly(
231
- sock, 32, "authentication nonce", [], decode=False
232
- ).decode()
233
- _LOGGER.debug("Auth: Nonce is %s", nonce)
234
- cnonce = hashlib.md5(str(random.random()).encode()).hexdigest()
235
- _LOGGER.debug("Auth: CNonce is %s", cnonce)
289
+
290
+ nonce_bytes = receive_exactly(
291
+ sock, nonce_size, f"{hash_name} authentication nonce", [], decode=False
292
+ )
293
+ assert isinstance(nonce_bytes, bytes)
294
+ nonce = nonce_bytes.decode()
295
+ _LOGGER.debug("Auth: %s Nonce is %s", hash_name, nonce)
296
+
297
+ # Generate cnonce
298
+ cnonce = hash_func(str(random.random()).encode()).hexdigest()
299
+ _LOGGER.debug("Auth: %s CNonce is %s", hash_name, cnonce)
236
300
 
237
301
  send_check(sock, cnonce, "auth cnonce")
238
302
 
239
- result_md5 = hashlib.md5()
240
- result_md5.update(password.encode("utf-8"))
241
- result_md5.update(nonce.encode())
242
- result_md5.update(cnonce.encode())
243
- result = result_md5.hexdigest()
244
- _LOGGER.debug("Auth: Result is %s", result)
303
+ # Calculate challenge response
304
+ hasher = hash_func()
305
+ hasher.update(password.encode("utf-8"))
306
+ hasher.update(nonce.encode())
307
+ hasher.update(cnonce.encode())
308
+ result = hasher.hexdigest()
309
+ _LOGGER.debug("Auth: %s Result is %s", hash_name, result)
245
310
 
246
311
  send_check(sock, result, "auth result")
247
312
  receive_exactly(sock, 1, "auth result", RESPONSE_AUTH_OK)
248
313
 
314
+ (auth,) = receive_exactly(
315
+ sock,
316
+ 1,
317
+ "auth",
318
+ [RESPONSE_REQUEST_AUTH, RESPONSE_REQUEST_SHA256_AUTH, RESPONSE_AUTH_OK],
319
+ )
320
+
321
+ if auth != RESPONSE_AUTH_OK:
322
+ hash_func, nonce_size, hash_name = _AUTH_METHODS[auth]
323
+ perform_auth(sock, password, hash_func, nonce_size, hash_name)
324
+
249
325
  # Set higher timeout during upload
250
326
  sock.settimeout(30.0)
251
327
 
@@ -309,12 +385,16 @@ def perform_ota(
309
385
 
310
386
 
311
387
  def run_ota_impl_(
312
- remote_host: str | list[str], remote_port: int, password: str, filename: str
388
+ remote_host: str | list[str], remote_port: int, password: str, filename: Path
313
389
  ) -> tuple[int, str | None]:
390
+ from esphome.core import CORE
391
+
314
392
  # Handle both single host and list of hosts
315
393
  try:
316
394
  # Resolve all hosts at once for parallel DNS resolution
317
- res = resolve_ip_address(remote_host, remote_port)
395
+ res = resolve_ip_address(
396
+ remote_host, remote_port, address_cache=CORE.address_cache
397
+ )
318
398
  except EsphomeError as err:
319
399
  _LOGGER.error(
320
400
  "Error resolving IP address of %s. Is it connected to WiFi?",
@@ -356,7 +436,7 @@ def run_ota_impl_(
356
436
 
357
437
 
358
438
  def run_ota(
359
- remote_host: str | list[str], remote_port: int, password: str, filename: str
439
+ remote_host: str | list[str], remote_port: int, password: str, filename: Path
360
440
  ) -> tuple[int, str | None]:
361
441
  try:
362
442
  return run_ota_impl_(remote_host, remote_port, password, filename)
esphome/external_files.py CHANGED
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
 
3
3
  from datetime import datetime
4
4
  import logging
5
- import os
6
5
  from pathlib import Path
7
6
 
8
7
  import requests
@@ -23,11 +22,11 @@ CONTENT_DISPOSITION = "content-disposition"
23
22
  TEMP_DIR = "temp"
24
23
 
25
24
 
26
- def has_remote_file_changed(url, local_file_path):
27
- if os.path.exists(local_file_path):
25
+ def has_remote_file_changed(url: str, local_file_path: Path) -> bool:
26
+ if local_file_path.exists():
28
27
  _LOGGER.debug("has_remote_file_changed: File exists at %s", local_file_path)
29
28
  try:
30
- local_modification_time = os.path.getmtime(local_file_path)
29
+ local_modification_time = local_file_path.stat().st_mtime
31
30
  local_modification_time_str = datetime.utcfromtimestamp(
32
31
  local_modification_time
33
32
  ).strftime("%a, %d %b %Y %H:%M:%S GMT")
@@ -65,9 +64,9 @@ def has_remote_file_changed(url, local_file_path):
65
64
  return True
66
65
 
67
66
 
68
- def is_file_recent(file_path: str, refresh: TimePeriodSeconds) -> bool:
69
- if os.path.exists(file_path):
70
- creation_time = os.path.getctime(file_path)
67
+ def is_file_recent(file_path: Path, refresh: TimePeriodSeconds) -> bool:
68
+ if file_path.exists():
69
+ creation_time = file_path.stat().st_ctime
71
70
  current_time = datetime.now().timestamp()
72
71
  return current_time - creation_time <= refresh.total_seconds
73
72
  return False
esphome/git.py CHANGED
@@ -13,6 +13,9 @@ from esphome.core import CORE, TimePeriodSeconds
13
13
 
14
14
  _LOGGER = logging.getLogger(__name__)
15
15
 
16
+ # Special value to indicate never refresh
17
+ NEVER_REFRESH = TimePeriodSeconds(seconds=-1)
18
+
16
19
 
17
20
  def run_git_command(cmd, cwd=None) -> str:
18
21
  _LOGGER.debug("Running git command: %s", " ".join(cmd))
@@ -85,6 +88,11 @@ def clone_or_update(
85
88
 
86
89
  else:
87
90
  # Check refresh needed
91
+ # Skip refresh if NEVER_REFRESH is specified
92
+ if refresh == NEVER_REFRESH:
93
+ _LOGGER.debug("Skipping update for %s (refresh disabled)", key)
94
+ return repo_dir, None
95
+
88
96
  file_timestamp = Path(repo_dir / ".git" / "FETCH_HEAD")
89
97
  # On first clone, FETCH_HEAD does not exists
90
98
  if not file_timestamp.exists():
esphome/helpers.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import codecs
4
3
  from contextlib import suppress
5
4
  import ipaddress
6
5
  import logging
@@ -8,11 +7,16 @@ import os
8
7
  from pathlib import Path
9
8
  import platform
10
9
  import re
10
+ import shutil
11
11
  import tempfile
12
+ from typing import TYPE_CHECKING
12
13
  from urllib.parse import urlparse
13
14
 
14
15
  from esphome.const import __version__ as ESPHOME_VERSION
15
16
 
17
+ if TYPE_CHECKING:
18
+ from esphome.address_cache import AddressCache
19
+
16
20
  # Type aliases for socket address information
17
21
  AddrInfo = tuple[
18
22
  int, # family (AF_INET, AF_INET6, etc.)
@@ -136,16 +140,16 @@ def run_system_command(*args):
136
140
  return rc, stdout, stderr
137
141
 
138
142
 
139
- def mkdir_p(path):
143
+ def mkdir_p(path: Path):
140
144
  if not path:
141
145
  # Empty path - means create current dir
142
146
  return
143
147
  try:
144
- os.makedirs(path)
148
+ path.mkdir(parents=True, exist_ok=True)
145
149
  except OSError as err:
146
150
  import errno
147
151
 
148
- if err.errno == errno.EEXIST and os.path.isdir(path):
152
+ if err.errno == errno.EEXIST and path.is_dir():
149
153
  pass
150
154
  else:
151
155
  from esphome.core import EsphomeError
@@ -173,7 +177,24 @@ def addr_preference_(res: AddrInfo) -> int:
173
177
  return 1
174
178
 
175
179
 
176
- def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]:
180
+ def _add_ip_addresses_to_addrinfo(
181
+ addresses: list[str], port: int, res: list[AddrInfo]
182
+ ) -> None:
183
+ """Helper to add IP addresses to addrinfo results with error handling."""
184
+ import socket
185
+
186
+ for addr in addresses:
187
+ try:
188
+ res += socket.getaddrinfo(
189
+ addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
190
+ )
191
+ except OSError:
192
+ _LOGGER.debug("Failed to parse IP address '%s'", addr)
193
+
194
+
195
+ def resolve_ip_address(
196
+ host: str | list[str], port: int, address_cache: AddressCache | None = None
197
+ ) -> list[AddrInfo]:
177
198
  import socket
178
199
 
179
200
  # There are five cases here. The host argument could be one of:
@@ -194,47 +215,69 @@ def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]:
194
215
  hosts = [host]
195
216
 
196
217
  res: list[AddrInfo] = []
218
+
219
+ # Fast path: if all hosts are already IP addresses
197
220
  if all(is_ip_address(h) for h in hosts):
198
- # Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST
199
- for addr in hosts:
200
- try:
201
- res += socket.getaddrinfo(
202
- addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
203
- )
204
- except OSError:
205
- _LOGGER.debug("Failed to parse IP address '%s'", addr)
221
+ _add_ip_addresses_to_addrinfo(hosts, port, res)
206
222
  # Sort by preference
207
223
  res.sort(key=addr_preference_)
208
224
  return res
209
225
 
210
- from esphome.resolver import AsyncResolver
211
-
212
- resolver = AsyncResolver(hosts, port)
213
- addr_infos = resolver.resolve()
214
- # Convert aioesphomeapi AddrInfo to our format
215
- for addr_info in addr_infos:
216
- sockaddr = addr_info.sockaddr
217
- if addr_info.family == socket.AF_INET6:
218
- # IPv6
219
- sockaddr_tuple = (
220
- sockaddr.address,
221
- sockaddr.port,
222
- sockaddr.flowinfo,
223
- sockaddr.scope_id,
224
- )
226
+ # Process hosts
227
+ cached_addresses: list[str] = []
228
+ uncached_hosts: list[str] = []
229
+ has_cache = address_cache is not None
230
+
231
+ for h in hosts:
232
+ if is_ip_address(h):
233
+ if has_cache:
234
+ # If we have a cache, treat IPs as cached
235
+ cached_addresses.append(h)
236
+ else:
237
+ # If no cache, pass IPs through to resolver with hostnames
238
+ uncached_hosts.append(h)
239
+ elif address_cache and (cached := address_cache.get_addresses(h)):
240
+ # Found in cache
241
+ cached_addresses.extend(cached)
225
242
  else:
226
- # IPv4
227
- sockaddr_tuple = (sockaddr.address, sockaddr.port)
228
-
229
- res.append(
230
- (
231
- addr_info.family,
232
- addr_info.type,
233
- addr_info.proto,
234
- "", # canonname
235
- sockaddr_tuple,
243
+ # Not cached, need to resolve
244
+ if address_cache and address_cache.has_cache():
245
+ _LOGGER.info("Host %s not in cache, will need to resolve", h)
246
+ uncached_hosts.append(h)
247
+
248
+ # Process cached addresses (includes direct IPs and cached lookups)
249
+ _add_ip_addresses_to_addrinfo(cached_addresses, port, res)
250
+
251
+ # If we have uncached hosts (only non-IP hostnames), resolve them
252
+ if uncached_hosts:
253
+ from esphome.resolver import AsyncResolver
254
+
255
+ resolver = AsyncResolver(uncached_hosts, port)
256
+ addr_infos = resolver.resolve()
257
+ # Convert aioesphomeapi AddrInfo to our format
258
+ for addr_info in addr_infos:
259
+ sockaddr = addr_info.sockaddr
260
+ if addr_info.family == socket.AF_INET6:
261
+ # IPv6
262
+ sockaddr_tuple = (
263
+ sockaddr.address,
264
+ sockaddr.port,
265
+ sockaddr.flowinfo,
266
+ sockaddr.scope_id,
267
+ )
268
+ else:
269
+ # IPv4
270
+ sockaddr_tuple = (sockaddr.address, sockaddr.port)
271
+
272
+ res.append(
273
+ (
274
+ addr_info.family,
275
+ addr_info.type,
276
+ addr_info.proto,
277
+ "", # canonname
278
+ sockaddr_tuple,
279
+ )
236
280
  )
237
- )
238
281
 
239
282
  # Sort by preference
240
283
  res.sort(key=addr_preference_)
@@ -256,14 +299,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]:
256
299
  # First "resolve" all the IP addresses to getaddrinfo() tuples of the form
257
300
  # (family, type, proto, canonname, sockaddr)
258
301
  res: list[AddrInfo] = []
259
- for addr in address_list:
260
- # This should always work as these are supposed to be IP addresses
261
- try:
262
- res += socket.getaddrinfo(
263
- addr, 0, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
264
- )
265
- except OSError:
266
- _LOGGER.info("Failed to parse IP address '%s'", addr)
302
+ _add_ip_addresses_to_addrinfo(address_list, 0, res)
267
303
 
268
304
  # Now use that information to sort them.
269
305
  res.sort(key=addr_preference_)
@@ -295,16 +331,15 @@ def is_ha_addon():
295
331
  return get_bool_env("ESPHOME_IS_HA_ADDON")
296
332
 
297
333
 
298
- def walk_files(path):
334
+ def walk_files(path: Path):
299
335
  for root, _, files in os.walk(path):
300
336
  for name in files:
301
- yield os.path.join(root, name)
337
+ yield Path(root) / name
302
338
 
303
339
 
304
- def read_file(path):
340
+ def read_file(path: Path) -> str:
305
341
  try:
306
- with codecs.open(path, "r", encoding="utf-8") as f_handle:
307
- return f_handle.read()
342
+ return path.read_text(encoding="utf-8")
308
343
  except OSError as err:
309
344
  from esphome.core import EsphomeError
310
345
 
@@ -315,13 +350,15 @@ def read_file(path):
315
350
  raise EsphomeError(f"Error reading file {path}: {err}") from err
316
351
 
317
352
 
318
- def _write_file(path: Path | str, text: str | bytes):
353
+ def _write_file(
354
+ path: Path,
355
+ text: str | bytes,
356
+ private: bool = False,
357
+ ) -> None:
319
358
  """Atomically writes `text` to the given path.
320
359
 
321
360
  Automatically creates all parent directories.
322
361
  """
323
- if not isinstance(path, Path):
324
- path = Path(path)
325
362
  data = text
326
363
  if isinstance(text, str):
327
364
  data = text.encode()
@@ -329,42 +366,54 @@ def _write_file(path: Path | str, text: str | bytes):
329
366
  directory = path.parent
330
367
  directory.mkdir(exist_ok=True, parents=True)
331
368
 
332
- tmp_path = None
369
+ tmp_filename: Path | None = None
370
+ missing_fchmod = False
333
371
  try:
372
+ # Modern versions of Python tempfile create this file with mode 0o600
334
373
  with tempfile.NamedTemporaryFile(
335
374
  mode="wb", dir=directory, delete=False
336
375
  ) as f_handle:
337
- tmp_path = f_handle.name
338
376
  f_handle.write(data)
339
- # Newer tempfile implementations create the file with mode 0o600
340
- os.chmod(tmp_path, 0o644)
341
- # If destination exists, will be overwritten
342
- os.replace(tmp_path, path)
377
+ tmp_filename = Path(f_handle.name)
378
+
379
+ if not private:
380
+ try:
381
+ os.fchmod(f_handle.fileno(), 0o644)
382
+ except AttributeError:
383
+ # os.fchmod is not available on Windows
384
+ missing_fchmod = True
385
+ shutil.move(tmp_filename, path)
386
+ if missing_fchmod:
387
+ path.chmod(0o644)
343
388
  finally:
344
- if tmp_path is not None and os.path.exists(tmp_path):
389
+ if tmp_filename and tmp_filename.exists():
345
390
  try:
346
- os.remove(tmp_path)
391
+ tmp_filename.unlink()
347
392
  except OSError as err:
348
- _LOGGER.error("Write file cleanup failed: %s", err)
393
+ # If we are cleaning up then something else went wrong, so
394
+ # we should suppress likely follow-on errors in the cleanup
395
+ _LOGGER.error(
396
+ "File replacement cleanup failed for %s while saving %s: %s",
397
+ tmp_filename,
398
+ path,
399
+ err,
400
+ )
349
401
 
350
402
 
351
- def write_file(path: Path | str, text: str):
403
+ def write_file(path: Path, text: str | bytes, private: bool = False) -> None:
352
404
  try:
353
- _write_file(path, text)
405
+ _write_file(path, text, private=private)
354
406
  except OSError as err:
355
407
  from esphome.core import EsphomeError
356
408
 
357
409
  raise EsphomeError(f"Could not write file at {path}") from err
358
410
 
359
411
 
360
- def write_file_if_changed(path: Path | str, text: str) -> bool:
412
+ def write_file_if_changed(path: Path, text: str) -> bool:
361
413
  """Write text to the given path, but not if the contents match already.
362
414
 
363
415
  Returns true if the file was changed.
364
416
  """
365
- if not isinstance(path, Path):
366
- path = Path(path)
367
-
368
417
  src_content = None
369
418
  if path.is_file():
370
419
  src_content = read_file(path)
@@ -374,12 +423,10 @@ def write_file_if_changed(path: Path | str, text: str) -> bool:
374
423
  return True
375
424
 
376
425
 
377
- def copy_file_if_changed(src: os.PathLike, dst: os.PathLike) -> None:
378
- import shutil
379
-
426
+ def copy_file_if_changed(src: Path, dst: Path) -> None:
380
427
  if file_compare(src, dst):
381
428
  return
382
- mkdir_p(os.path.dirname(dst))
429
+ dst.parent.mkdir(parents=True, exist_ok=True)
383
430
  try:
384
431
  shutil.copyfile(src, dst)
385
432
  except OSError as err:
@@ -404,12 +451,12 @@ def list_starts_with(list_, sub):
404
451
  return len(sub) <= len(list_) and all(list_[i] == x for i, x in enumerate(sub))
405
452
 
406
453
 
407
- def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool:
454
+ def file_compare(path1: Path, path2: Path) -> bool:
408
455
  """Return True if the files path1 and path2 have the same contents."""
409
456
  import stat
410
457
 
411
458
  try:
412
- stat1, stat2 = os.stat(path1), os.stat(path2)
459
+ stat1, stat2 = path1.stat(), path2.stat()
413
460
  except OSError:
414
461
  # File doesn't exist or another error -> not equal
415
462
  return False
@@ -426,7 +473,7 @@ def file_compare(path1: os.PathLike, path2: os.PathLike) -> bool:
426
473
 
427
474
  bufsize = 8 * 1024
428
475
  # Read files in blocks until a mismatch is found
429
- with open(path1, "rb") as fh1, open(path2, "rb") as fh2:
476
+ with path1.open("rb") as fh1, path2.open("rb") as fh2:
430
477
  while True:
431
478
  blob1, blob2 = fh1.read(bufsize), fh2.read(bufsize)
432
479
  if blob1 != blob2: