esphome 2025.2.2__py3-none-any.whl → 2025.3.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 (135) hide show
  1. esphome/__main__.py +9 -1
  2. esphome/components/api/api_connection.cpp +426 -70
  3. esphome/components/api/api_connection.h +117 -25
  4. esphome/components/api/api_pb2.cpp +9 -0
  5. esphome/components/api/api_pb2.h +1 -0
  6. esphome/components/api/api_server.cpp +2 -2
  7. esphome/components/api/list_entities.cpp +76 -22
  8. esphome/components/api/list_entities.h +1 -0
  9. esphome/components/api/subscribe_state.h +2 -0
  10. esphome/components/bluetooth_proxy/bluetooth_proxy.h +8 -0
  11. esphome/components/bmp085/bmp085.cpp +1 -1
  12. esphome/components/chsc6x/__init__.py +2 -0
  13. esphome/components/chsc6x/chsc6x_touchscreen.cpp +47 -0
  14. esphome/components/chsc6x/chsc6x_touchscreen.h +34 -0
  15. esphome/components/chsc6x/touchscreen.py +33 -0
  16. esphome/components/climate/__init__.py +0 -1
  17. esphome/components/cst816/binary_sensor/__init__.py +2 -25
  18. esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +3 -14
  19. esphome/components/cst816/touchscreen/cst816_touchscreen.h +0 -4
  20. esphome/components/esp32_ble_beacon/__init__.py +3 -1
  21. esphome/components/esp8266/gpio.py +1 -2
  22. esphome/components/font/__init__.py +185 -185
  23. esphome/components/font/font.cpp +4 -4
  24. esphome/components/font/font.h +1 -0
  25. esphome/components/haier/climate.py +11 -10
  26. esphome/components/hbridge/switch/hbridge_switch.cpp +2 -2
  27. esphome/components/heatpumpir/climate.py +2 -1
  28. esphome/components/heatpumpir/heatpumpir.cpp +1 -0
  29. esphome/components/heatpumpir/heatpumpir.h +1 -0
  30. esphome/components/i2c/__init__.py +6 -6
  31. esphome/components/i2c/i2c_bus_esp_idf.cpp +6 -2
  32. esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +1 -1
  33. esphome/components/ili9xxx/display.py +1 -0
  34. esphome/components/ili9xxx/ili9xxx_display.h +5 -0
  35. esphome/components/ili9xxx/ili9xxx_init.h +59 -0
  36. esphome/components/ld2450/__init__.py +51 -0
  37. esphome/components/ld2450/binary_sensor.py +47 -0
  38. esphome/components/ld2450/button/__init__.py +45 -0
  39. esphome/components/ld2450/button/reset_button.cpp +9 -0
  40. esphome/components/ld2450/button/reset_button.h +18 -0
  41. esphome/components/ld2450/button/restart_button.cpp +9 -0
  42. esphome/components/ld2450/button/restart_button.h +18 -0
  43. esphome/components/ld2450/ld2450.cpp +876 -0
  44. esphome/components/ld2450/ld2450.h +234 -0
  45. esphome/components/ld2450/number/__init__.py +121 -0
  46. esphome/components/ld2450/number/presence_timeout_number.cpp +12 -0
  47. esphome/components/ld2450/number/presence_timeout_number.h +18 -0
  48. esphome/components/ld2450/number/zone_coordinate_number.cpp +14 -0
  49. esphome/components/ld2450/number/zone_coordinate_number.h +19 -0
  50. esphome/components/ld2450/select/__init__.py +56 -0
  51. esphome/components/ld2450/select/baud_rate_select.cpp +12 -0
  52. esphome/components/ld2450/select/baud_rate_select.h +18 -0
  53. esphome/components/ld2450/select/zone_type_select.cpp +12 -0
  54. esphome/components/ld2450/select/zone_type_select.h +18 -0
  55. esphome/components/ld2450/sensor.py +156 -0
  56. esphome/components/ld2450/switch/__init__.py +45 -0
  57. esphome/components/ld2450/switch/bluetooth_switch.cpp +12 -0
  58. esphome/components/ld2450/switch/bluetooth_switch.h +18 -0
  59. esphome/components/ld2450/switch/multi_target_switch.cpp +12 -0
  60. esphome/components/ld2450/switch/multi_target_switch.h +18 -0
  61. esphome/components/ld2450/text_sensor.py +62 -0
  62. esphome/components/lvgl/defines.py +0 -2
  63. esphome/components/lvgl/font.cpp +1 -1
  64. esphome/components/lvgl/lvgl_esphome.cpp +27 -19
  65. esphome/components/lvgl/widgets/img.py +1 -3
  66. esphome/components/mcp2515/mcp2515.cpp +1 -0
  67. esphome/components/mlx90393/sensor.py +53 -33
  68. esphome/components/mlx90393/sensor_mlx90393.cpp +4 -0
  69. esphome/components/mlx90393/sensor_mlx90393.h +8 -3
  70. esphome/components/mqtt/__init__.py +2 -2
  71. esphome/components/msa3xx/__init__.py +189 -0
  72. esphome/components/msa3xx/binary_sensor.py +40 -0
  73. esphome/components/msa3xx/msa3xx.cpp +417 -0
  74. esphome/components/msa3xx/msa3xx.h +311 -0
  75. esphome/components/msa3xx/sensor.py +42 -0
  76. esphome/components/msa3xx/text_sensor.py +38 -0
  77. esphome/components/nfc/binary_sensor/__init__.py +4 -4
  78. esphome/components/opentherm/binary_sensor/__init__.py +4 -4
  79. esphome/components/opentherm/generate.py +6 -6
  80. esphome/components/opentherm/sensor/__init__.py +5 -6
  81. esphome/components/packages/__init__.py +35 -11
  82. esphome/components/pn532/binary_sensor.py +4 -4
  83. esphome/components/rc522/binary_sensor.py +4 -4
  84. esphome/components/socket/bsd_sockets_impl.cpp +1 -0
  85. esphome/components/socket/lwip_sockets_impl.cpp +1 -0
  86. esphome/components/socket/socket.h +3 -1
  87. esphome/components/ssd1306_base/__init__.py +7 -7
  88. esphome/components/thermostat/climate.py +1 -1
  89. esphome/components/tmp1075/tmp1075.cpp +7 -11
  90. esphome/components/tmp1075/tmp1075.h +1 -2
  91. esphome/components/tormatic/__init__.py +1 -0
  92. esphome/components/tormatic/cover.py +47 -0
  93. esphome/components/tormatic/tormatic_cover.cpp +355 -0
  94. esphome/components/tormatic/tormatic_cover.h +60 -0
  95. esphome/components/tormatic/tormatic_protocol.h +211 -0
  96. esphome/components/touchscreen/binary_sensor/__init__.py +3 -0
  97. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp +7 -1
  98. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +3 -1
  99. esphome/components/touchscreen/touchscreen.cpp +3 -4
  100. esphome/components/udp/udp_component.h +4 -1
  101. esphome/components/web_server/list_entities.cpp +70 -66
  102. esphome/components/web_server/list_entities.h +43 -22
  103. esphome/components/web_server/web_server.cpp +345 -68
  104. esphome/components/web_server/web_server.h +138 -6
  105. esphome/components/web_server_base/__init__.py +1 -1
  106. esphome/components/web_server_idf/__init__.py +2 -0
  107. esphome/components/web_server_idf/web_server_idf.cpp +177 -30
  108. esphome/components/web_server_idf/web_server_idf.h +53 -4
  109. esphome/config_validation.py +23 -125
  110. esphome/const.py +5 -1
  111. esphome/core/config.py +12 -4
  112. esphome/core/defines.h +1 -1
  113. esphome/core/helpers.h +5 -3
  114. esphome/core/time.cpp +1 -0
  115. esphome/cpp_generator.py +3 -3
  116. esphome/dashboard/core.py +30 -21
  117. esphome/dashboard/dns.py +7 -1
  118. esphome/dashboard/entries.py +83 -16
  119. esphome/dashboard/settings.py +0 -4
  120. esphome/dashboard/status/mdns.py +43 -14
  121. esphome/dashboard/status/mqtt.py +22 -9
  122. esphome/dashboard/status/ping.py +54 -10
  123. esphome/dashboard/web_server.py +56 -24
  124. esphome/storage_json.py +4 -0
  125. esphome/wizard.py +13 -17
  126. esphome/writer.py +1 -3
  127. esphome/yaml_util.py +36 -33
  128. esphome/zeroconf.py +9 -21
  129. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/METADATA +5 -5
  130. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/RECORD +134 -94
  131. esphome/components/cst816/binary_sensor/cst816_button.h +0 -27
  132. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/LICENSE +0 -0
  133. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/WHEEL +0 -0
  134. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/entry_points.txt +0 -0
  135. {esphome-2025.2.2.dist-info → esphome-2025.3.0b1.dist-info}/top_level.txt +0 -0
@@ -1,28 +1,5 @@
1
- import esphome.codegen as cg
2
1
  import esphome.config_validation as cv
3
- from esphome.components import binary_sensor
4
2
 
5
- from .. import cst816_ns
6
- from ..touchscreen import CST816Touchscreen, CST816ButtonListener
7
-
8
- CONF_CST816_ID = "cst816_id"
9
-
10
- CST816Button = cst816_ns.class_(
11
- "CST816Button",
12
- binary_sensor.BinarySensor,
13
- cg.Component,
14
- CST816ButtonListener,
15
- cg.Parented.template(CST816Touchscreen),
16
- )
17
-
18
- CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(CST816Button).extend(
19
- {
20
- cv.GenerateID(CONF_CST816_ID): cv.use_id(CST816Touchscreen),
21
- }
3
+ CONFIG_SCHEMA = cv.invalid(
4
+ "The CST816 binary sensor has been removed. Instead use the touchscreen binary sensor with the 'use_raw' flag set."
22
5
  )
23
-
24
-
25
- async def to_code(config):
26
- var = await binary_sensor.new_binary_sensor(config)
27
- await cg.register_component(var, config)
28
- await cg.register_parented(var, config[CONF_CST816_ID])
@@ -37,14 +37,6 @@ void CST816Touchscreen::continue_setup_() {
37
37
  ESP_LOGCONFIG(TAG, "CST816 Touchscreen setup complete");
38
38
  }
39
39
 
40
- void CST816Touchscreen::update_button_state_(bool state) {
41
- if (this->button_touched_ == state)
42
- return;
43
- this->button_touched_ = state;
44
- for (auto *listener : this->button_listeners_)
45
- listener->update_button(state);
46
- }
47
-
48
40
  void CST816Touchscreen::setup() {
49
41
  ESP_LOGCONFIG(TAG, "Setting up CST816 Touchscreen...");
50
42
  if (this->reset_pin_ != nullptr) {
@@ -68,18 +60,13 @@ void CST816Touchscreen::update_touches() {
68
60
  }
69
61
  uint8_t num_of_touches = data[REG_TOUCH_NUM] & 3;
70
62
  if (num_of_touches == 0) {
71
- this->update_button_state_(false);
72
63
  return;
73
64
  }
74
65
 
75
66
  uint16_t x = encode_uint16(data[REG_XPOS_HIGH] & 0xF, data[REG_XPOS_LOW]);
76
67
  uint16_t y = encode_uint16(data[REG_YPOS_HIGH] & 0xF, data[REG_YPOS_LOW]);
77
68
  ESP_LOGV(TAG, "Read touch %d/%d", x, y);
78
- if (x >= this->x_raw_max_) {
79
- this->update_button_state_(true);
80
- } else {
81
- this->add_raw_touch_position_(0, x, y);
82
- }
69
+ this->add_raw_touch_position_(0, x, y);
83
70
  }
84
71
 
85
72
  void CST816Touchscreen::dump_config() {
@@ -87,6 +74,8 @@ void CST816Touchscreen::dump_config() {
87
74
  LOG_I2C_DEVICE(this);
88
75
  LOG_PIN(" Interrupt Pin: ", this->interrupt_pin_);
89
76
  LOG_PIN(" Reset Pin: ", this->reset_pin_);
77
+ ESP_LOGCONFIG(TAG, " X Raw Min: %d, X Raw Max: %d", this->x_raw_min_, this->x_raw_max_);
78
+ ESP_LOGCONFIG(TAG, " Y Raw Min: %d, Y Raw Max: %d", this->y_raw_min_, this->y_raw_max_);
90
79
  const char *name;
91
80
  switch (this->chip_id_) {
92
81
  case CST820_CHIP_ID:
@@ -40,7 +40,6 @@ class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice
40
40
  public:
41
41
  void setup() override;
42
42
  void update_touches() override;
43
- void register_button_listener(CST816ButtonListener *listener) { this->button_listeners_.push_back(listener); }
44
43
  void dump_config() override;
45
44
 
46
45
  void set_interrupt_pin(InternalGPIOPin *pin) { this->interrupt_pin_ = pin; }
@@ -49,14 +48,11 @@ class CST816Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice
49
48
 
50
49
  protected:
51
50
  void continue_setup_();
52
- void update_button_state_(bool state);
53
51
 
54
52
  InternalGPIOPin *interrupt_pin_{};
55
53
  GPIOPin *reset_pin_{};
56
54
  uint8_t chip_id_{};
57
55
  bool skip_probe_{}; // if set, do not expect to be able to probe the controller on the i2c bus.
58
- std::vector<CST816ButtonListener *> button_listeners_;
59
- bool button_touched_{};
60
56
  };
61
57
 
62
58
  } // namespace cst816
@@ -66,7 +66,9 @@ FINAL_VALIDATE_SCHEMA = esp32_ble.validate_variant
66
66
 
67
67
  async def to_code(config):
68
68
  uuid = config[CONF_UUID].hex
69
- uuid_arr = [cg.RawExpression(f"0x{uuid[i:i + 2]}") for i in range(0, len(uuid), 2)]
69
+ uuid_arr = [
70
+ cg.RawExpression(f"0x{uuid[i : i + 2]}") for i in range(0, len(uuid), 2)
71
+ ]
70
72
  var = cg.new_Pvariable(config[CONF_ID], uuid_arr)
71
73
 
72
74
  parent = await cg.get_variable(config[esp32_ble.CONF_BLE_ID])
@@ -112,8 +112,7 @@ def validate_supports(value):
112
112
  )
113
113
  if is_pullup and num == 16:
114
114
  raise cv.Invalid(
115
- "GPIO Pin 16 does not support pullup pin mode. "
116
- "Please choose another pin.",
115
+ "GPIO Pin 16 does not support pullup pin mode. Please choose another pin.",
117
116
  [CONF_MODE, CONF_PULLUP],
118
117
  )
119
118
  if is_pulldown and num != 16:
@@ -1,3 +1,4 @@
1
+ from collections.abc import MutableMapping
1
2
  import functools
2
3
  import hashlib
3
4
  import logging
@@ -6,10 +7,10 @@ from pathlib import Path
6
7
  import re
7
8
 
8
9
  import esphome_glyphsets as glyphsets
9
- import freetype
10
+ from freetype import Face, ft_pixel_mode_grays, ft_pixel_mode_mono
10
11
  import requests
11
12
 
12
- from esphome import core, external_files
13
+ from esphome import external_files
13
14
  import esphome.codegen as cg
14
15
  import esphome.config_validation as cv
15
16
  from esphome.const import (
@@ -26,7 +27,7 @@ from esphome.const import (
26
27
  CONF_WEIGHT,
27
28
  )
28
29
  from esphome.core import CORE, HexInt
29
- from esphome.helpers import copy_file_if_changed, cpp_string_escape
30
+ from esphome.helpers import cpp_string_escape
30
31
 
31
32
  _LOGGER = logging.getLogger(__name__)
32
33
 
@@ -49,13 +50,42 @@ CONF_IGNORE_MISSING_GLYPHS = "ignore_missing_glyphs"
49
50
 
50
51
 
51
52
  # Cache loaded freetype fonts
52
- class FontCache(dict):
53
- def __missing__(self, key):
54
- try:
55
- res = self[key] = freetype.Face(key)
56
- return res
57
- except freetype.FT_Exception as e:
58
- raise cv.Invalid(f"Could not load Font file {key}: {e}") from e
53
+ class FontCache(MutableMapping):
54
+ @staticmethod
55
+ def get_name(value):
56
+ if CONF_FAMILY in value:
57
+ return (
58
+ f"{value[CONF_FAMILY]}:{int(value[CONF_ITALIC])}:{value[CONF_WEIGHT]}"
59
+ )
60
+ if CONF_URL in value:
61
+ return value[CONF_URL]
62
+ return value[CONF_PATH]
63
+
64
+ @staticmethod
65
+ def _keytransform(value):
66
+ if CONF_FAMILY in value:
67
+ return f"gfont:{value[CONF_FAMILY]}:{int(value[CONF_ITALIC])}:{value[CONF_WEIGHT]}"
68
+ if CONF_URL in value:
69
+ return f"url:{value[CONF_URL]}"
70
+ return f"file:{value[CONF_PATH]}"
71
+
72
+ def __init__(self):
73
+ self.store = {}
74
+
75
+ def __delitem__(self, key):
76
+ del self.store[self._keytransform(key)]
77
+
78
+ def __iter__(self):
79
+ return iter(self.store)
80
+
81
+ def __len__(self):
82
+ return len(self.store)
83
+
84
+ def __getitem__(self, item):
85
+ return self.store[self._keytransform(item)]
86
+
87
+ def __setitem__(self, key, value):
88
+ self.store[self._keytransform(key)] = Face(str(value))
59
89
 
60
90
 
61
91
  FONT_CACHE = FontCache()
@@ -109,14 +139,14 @@ def check_missing_glyphs(file, codepoints, warning: bool = False):
109
139
  )
110
140
  if count > 10:
111
141
  missing_str += f"\n and {count - 10} more."
112
- message = f"Font {Path(file).name} is missing {count} glyph{'s' if count != 1 else ''}:\n {missing_str}"
142
+ message = f"Font {FontCache.get_name(file)} is missing {count} glyph{'s' if count != 1 else ''}:\n {missing_str}"
113
143
  if warning:
114
144
  _LOGGER.warning(message)
115
145
  else:
116
146
  raise cv.Invalid(message)
117
147
 
118
148
 
119
- def validate_glyphs(config):
149
+ def validate_font_config(config):
120
150
  """
121
151
  Check for duplicate codepoints, then check that all requested codepoints actually
122
152
  have glyphs defined in the appropriate font file.
@@ -143,8 +173,6 @@ def validate_glyphs(config):
143
173
  # Make setpoints and glyphspoints disjoint
144
174
  setpoints.difference_update(glyphspoints)
145
175
  if fileconf[CONF_TYPE] == TYPE_LOCAL_BITMAP:
146
- # Pillow only allows 256 glyphs per bitmap font. Not sure if that is a Pillow limitation
147
- # or a file format limitation
148
176
  if any(x >= 256 for x in setpoints.copy().union(glyphspoints)):
149
177
  raise cv.Invalid("Codepoints in bitmap fonts must be in the range 0-255")
150
178
  else:
@@ -154,13 +182,14 @@ def validate_glyphs(config):
154
182
  points = {ord(x) for x in flatten(extra[CONF_GLYPHS])}
155
183
  glyphspoints.difference_update(points)
156
184
  setpoints.difference_update(points)
157
- check_missing_glyphs(extra[CONF_FILE][CONF_PATH], points)
185
+ check_missing_glyphs(extra[CONF_FILE], points)
158
186
 
159
187
  # A named glyph that can't be provided is an error
160
- check_missing_glyphs(fileconf[CONF_PATH], glyphspoints)
188
+
189
+ check_missing_glyphs(fileconf, glyphspoints)
161
190
  # A missing glyph from a set is a warning.
162
191
  if not config[CONF_IGNORE_MISSING_GLYPHS]:
163
- check_missing_glyphs(fileconf[CONF_PATH], setpoints, warning=True)
192
+ check_missing_glyphs(fileconf, setpoints, warning=True)
164
193
 
165
194
  # Populate the default after the above checks so that use of the default doesn't trigger errors
166
195
  if not config[CONF_GLYPHS] and not config[CONF_GLYPHSETS]:
@@ -168,17 +197,32 @@ def validate_glyphs(config):
168
197
  config[CONF_GLYPHS] = [DEFAULT_GLYPHS]
169
198
  else:
170
199
  # set a default glyphset, intersected with what the font actually offers
171
- font = FONT_CACHE[fileconf[CONF_PATH]]
200
+ font = FONT_CACHE[fileconf]
172
201
  config[CONF_GLYPHS] = [
173
202
  chr(x)
174
203
  for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET)
175
204
  if font.get_char_index(x) != 0
176
205
  ]
177
206
 
207
+ if config[CONF_FILE][CONF_TYPE] == TYPE_LOCAL_BITMAP:
208
+ if CONF_SIZE in config:
209
+ raise cv.Invalid(
210
+ "Size is not a valid option for bitmap fonts, which are inherently fixed size"
211
+ )
212
+ elif CONF_SIZE not in config:
213
+ config[CONF_SIZE] = 20
214
+
178
215
  return config
179
216
 
180
217
 
181
218
  FONT_EXTENSIONS = (".ttf", ".woff", ".otf")
219
+ BITMAP_EXTENSIONS = (".bdf", ".pcf")
220
+
221
+
222
+ def validate_bitmap_file(value):
223
+ if not any(map(value.lower().endswith, BITMAP_EXTENSIONS)):
224
+ raise cv.Invalid(f"Only {', '.join(BITMAP_EXTENSIONS)} files are supported.")
225
+ return CORE.relative_config_path(cv.file_(value))
182
226
 
183
227
 
184
228
  def validate_truetype_file(value):
@@ -187,24 +231,40 @@ def validate_truetype_file(value):
187
231
  f"Please unzip the font archive '{value}' first and then use the .ttf files inside."
188
232
  )
189
233
  if not any(map(value.lower().endswith, FONT_EXTENSIONS)):
190
- raise cv.Invalid(f"Only {FONT_EXTENSIONS} files are supported.")
234
+ raise cv.Invalid(f"Only {', '.join(FONT_EXTENSIONS)} files are supported.")
191
235
  return CORE.relative_config_path(cv.file_(value))
192
236
 
193
237
 
238
+ def add_local_file(value):
239
+ if value in FONT_CACHE:
240
+ return value
241
+ path = value[CONF_PATH]
242
+ if not os.path.isfile(path):
243
+ raise cv.Invalid(f"File '{path}' not found.")
244
+ FONT_CACHE[value] = path
245
+ return value
246
+
247
+
194
248
  TYPE_LOCAL = "local"
195
249
  TYPE_LOCAL_BITMAP = "local_bitmap"
196
250
  TYPE_GFONTS = "gfonts"
197
251
  TYPE_WEB = "web"
198
- LOCAL_SCHEMA = cv.Schema(
199
- {
200
- cv.Required(CONF_PATH): validate_truetype_file,
201
- }
252
+ LOCAL_SCHEMA = cv.All(
253
+ cv.Schema(
254
+ {
255
+ cv.Required(CONF_PATH): validate_truetype_file,
256
+ }
257
+ ),
258
+ add_local_file,
202
259
  )
203
260
 
204
- LOCAL_BITMAP_SCHEMA = cv.Schema(
205
- {
206
- cv.Required(CONF_PATH): cv.file_,
207
- }
261
+ LOCAL_BITMAP_SCHEMA = cv.All(
262
+ cv.Schema(
263
+ {
264
+ cv.Required(CONF_PATH): validate_bitmap_file,
265
+ }
266
+ ),
267
+ add_local_file,
208
268
  )
209
269
 
210
270
  FULLPATH_SCHEMA = cv.maybe_simple_value(
@@ -235,56 +295,59 @@ def _compute_local_font_path(value: dict) -> Path:
235
295
  h.update(url.encode())
236
296
  key = h.hexdigest()[:8]
237
297
  base_dir = external_files.compute_local_file_dir(DOMAIN)
238
- _LOGGER.debug("_compute_local_font_path: base_dir=%s", base_dir / key)
298
+ _LOGGER.debug("_compute_local_font_path: %s", base_dir / key)
239
299
  return base_dir / key
240
300
 
241
301
 
242
- def get_font_path(value, font_type) -> Path:
243
- if font_type == TYPE_GFONTS:
244
- name = f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1"
245
- return external_files.compute_local_file_dir(DOMAIN) / f"{name}.ttf"
246
- if font_type == TYPE_WEB:
247
- return _compute_local_font_path(value) / "font.ttf"
248
- assert False
249
-
250
-
251
302
  def download_gfont(value):
303
+ if value in FONT_CACHE:
304
+ return value
252
305
  name = (
253
306
  f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
254
307
  )
255
308
  url = f"https://fonts.googleapis.com/css2?family={name}"
256
- path = get_font_path(value, TYPE_GFONTS)
257
- _LOGGER.debug("download_gfont: path=%s", path)
258
-
259
- try:
260
- req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
261
- req.raise_for_status()
262
- except requests.exceptions.RequestException as e:
263
- raise cv.Invalid(
264
- f"Could not download font at {url}, please check the fonts exists "
265
- f"at google fonts ({e})"
266
- )
267
- match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
268
- if match is None:
269
- raise cv.Invalid(
270
- f"Could not extract ttf file from gfonts response for {name}, "
271
- f"please report this."
272
- )
309
+ path = (
310
+ external_files.compute_local_file_dir(DOMAIN)
311
+ / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
312
+ )
313
+ if not external_files.is_file_recent(str(path), value[CONF_REFRESH]):
314
+ _LOGGER.debug("download_gfont: path=%s", path)
315
+ try:
316
+ req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
317
+ req.raise_for_status()
318
+ except requests.exceptions.RequestException as e:
319
+ raise cv.Invalid(
320
+ f"Could not download font at {url}, please check the fonts exists "
321
+ f"at google fonts ({e})"
322
+ )
323
+ match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
324
+ if match is None:
325
+ raise cv.Invalid(
326
+ f"Could not extract ttf file from gfonts response for {name}, "
327
+ f"please report this."
328
+ )
273
329
 
274
- ttf_url = match.group(1)
275
- _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
330
+ ttf_url = match.group(1)
331
+ _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
276
332
 
277
- external_files.download_content(ttf_url, path)
278
- return FULLPATH_SCHEMA(path)
333
+ external_files.download_content(ttf_url, path)
334
+ # In case the remote file is not modified, the download_content function will return the existing file,
335
+ # so update the modification time to now.
336
+ path.touch()
337
+ FONT_CACHE[value] = path
338
+ return value
279
339
 
280
340
 
281
341
  def download_web_font(value):
342
+ if value in FONT_CACHE:
343
+ return value
282
344
  url = value[CONF_URL]
283
- path = get_font_path(value, TYPE_WEB)
345
+ path = _compute_local_font_path(value) / "font.ttf"
284
346
 
285
347
  external_files.download_content(url, path)
286
348
  _LOGGER.debug("download_web_font: path=%s", path)
287
- return FULLPATH_SCHEMA(path)
349
+ FONT_CACHE[value] = path
350
+ return value
288
351
 
289
352
 
290
353
  EXTERNAL_FONT_SCHEMA = cv.Schema(
@@ -340,14 +403,14 @@ def validate_file_shorthand(value):
340
403
  }
341
404
  )
342
405
 
343
- if value.endswith(".pcf") or value.endswith(".bdf"):
344
- value = convert_bitmap_to_pillow_font(
345
- CORE.relative_config_path(cv.file_(value))
406
+ extension = Path(value).suffix
407
+ if extension in BITMAP_EXTENSIONS:
408
+ return font_file_schema(
409
+ {
410
+ CONF_TYPE: TYPE_LOCAL_BITMAP,
411
+ CONF_PATH: value,
412
+ }
346
413
  )
347
- return {
348
- CONF_TYPE: TYPE_LOCAL_BITMAP,
349
- CONF_PATH: value,
350
- }
351
414
 
352
415
  return font_file_schema(
353
416
  {
@@ -391,7 +454,7 @@ FONT_SCHEMA = cv.Schema(
391
454
  cv.one_of(*glyphsets.defined_glyphsets())
392
455
  ),
393
456
  cv.Optional(CONF_IGNORE_MISSING_GLYPHS, default=False): cv.boolean,
394
- cv.Optional(CONF_SIZE, default=20): cv.int_range(min=1),
457
+ cv.Optional(CONF_SIZE): cv.int_range(min=1),
395
458
  cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8),
396
459
  cv.Optional(CONF_EXTRAS, default=[]): cv.ensure_list(
397
460
  cv.Schema(
@@ -406,114 +469,19 @@ FONT_SCHEMA = cv.Schema(
406
469
  },
407
470
  )
408
471
 
409
- CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_glyphs)
410
-
411
-
412
- # PIL doesn't provide a consistent interface for both TrueType and bitmap
413
- # fonts. So, we use our own wrappers to give us the consistency that we need.
414
-
415
-
416
- class TrueTypeFontWrapper:
417
- def __init__(self, font):
418
- self.font = font
419
-
420
- def getoffset(self, glyph):
421
- _, (offset_x, offset_y) = self.font.font.getsize(glyph)
422
- return offset_x, offset_y
423
-
424
- def getmask(self, glyph, **kwargs):
425
- return self.font.getmask(str(glyph), **kwargs)
426
-
427
- def getmetrics(self, glyphs):
428
- return self.font.getmetrics()
429
-
430
-
431
- class BitmapFontWrapper:
432
- def __init__(self, font):
433
- self.font = font
434
- self.max_height = 0
435
-
436
- def getoffset(self, glyph):
437
- return 0, 0
438
-
439
- def getmask(self, glyph, **kwargs):
440
- return self.font.getmask(str(glyph), **kwargs)
441
-
442
- def getmetrics(self, glyphs):
443
- max_height = 0
444
- for glyph in glyphs:
445
- mask = self.getmask(glyph, mode="1")
446
- _, height = mask.size
447
- max_height = max(max_height, height)
448
- return max_height, 0
472
+ CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_font_config)
449
473
 
450
474
 
451
475
  class EFont:
452
- def __init__(self, file, size, codepoints):
476
+ def __init__(self, file, codepoints):
453
477
  self.codepoints = codepoints
454
- path = file[CONF_PATH]
455
- self.name = Path(path).name
456
- ftype = file[CONF_TYPE]
457
- if ftype == TYPE_LOCAL_BITMAP:
458
- self.font = load_bitmap_font(path)
459
- else:
460
- self.font = load_ttf_font(path, size)
461
- self.ascent, self.descent = self.font.getmetrics(codepoints)
462
-
463
-
464
- def convert_bitmap_to_pillow_font(filepath):
465
- from PIL import BdfFontFile, PcfFontFile
466
-
467
- local_bitmap_font_file = external_files.compute_local_file_dir(
468
- DOMAIN,
469
- ) / os.path.basename(filepath)
470
-
471
- copy_file_if_changed(filepath, local_bitmap_font_file)
472
-
473
- local_pil_font_file = local_bitmap_font_file.with_suffix(".pil")
474
- with open(local_bitmap_font_file, "rb") as fp:
475
- try:
476
- try:
477
- p = PcfFontFile.PcfFontFile(fp)
478
- except SyntaxError:
479
- fp.seek(0)
480
- p = BdfFontFile.BdfFontFile(fp)
481
-
482
- # Convert to pillow-formatted fonts, which have a .pil and .pbm extension.
483
- p.save(local_pil_font_file)
484
- except (SyntaxError, OSError) as err:
485
- raise core.EsphomeError(
486
- f"Failed to parse as bitmap font: '{filepath}': {err}"
487
- )
488
-
489
- return str(local_pil_font_file)
490
-
491
-
492
- def load_bitmap_font(filepath):
493
- from PIL import ImageFont
494
-
495
- try:
496
- font = ImageFont.load(str(filepath))
497
- except Exception as e:
498
- raise core.EsphomeError(f"Failed to load bitmap font file: {filepath}: {e}")
499
-
500
- return BitmapFontWrapper(font)
501
-
502
-
503
- def load_ttf_font(path, size):
504
- from PIL import ImageFont
505
-
506
- try:
507
- font = ImageFont.truetype(str(path), size)
508
- except Exception as e:
509
- raise core.EsphomeError(f"Could not load TrueType file {path}: {e}")
510
-
511
- return TrueTypeFontWrapper(font)
478
+ self.font: Face = FONT_CACHE[file]
512
479
 
513
480
 
514
481
  class GlyphInfo:
515
- def __init__(self, data_len, offset_x, offset_y, width, height):
482
+ def __init__(self, data_len, advance, offset_x, offset_y, width, height):
516
483
  self.data_len = data_len
484
+ self.advance = advance
517
485
  self.offset_x = offset_x
518
486
  self.offset_y = offset_y
519
487
  self.width = width
@@ -537,15 +505,14 @@ async def to_code(config):
537
505
  }
538
506
  # get the codepoints from the glyphs key, flatten to a list of chrs and combine with the points from glyphsets
539
507
  point_set.update(flatten(config[CONF_GLYPHS]))
540
- size = config[CONF_SIZE]
541
508
  # Create the codepoint to font file map
542
- base_font = EFont(config[CONF_FILE], size, point_set)
543
- point_font_map: dict[str, EFont] = {c: base_font for c in point_set}
509
+ base_font = FONT_CACHE[config[CONF_FILE]]
510
+ point_font_map: dict[str, Face] = {c: base_font for c in point_set}
544
511
  # process extras, updating the map and extending the codepoint list
545
512
  for extra in config[CONF_EXTRAS]:
546
513
  extra_points = flatten(extra[CONF_GLYPHS])
547
514
  point_set.update(extra_points)
548
- extra_font = EFont(extra[CONF_FILE], size, extra_points)
515
+ extra_font = FONT_CACHE[extra[CONF_FILE]]
549
516
  point_font_map.update({c: extra_font for c in extra_points})
550
517
 
551
518
  codepoints = list(point_set)
@@ -553,28 +520,52 @@ async def to_code(config):
553
520
  glyph_args = {}
554
521
  data = []
555
522
  bpp = config[CONF_BPP]
556
- if bpp == 1:
557
- mode = "1"
558
- scale = 1
559
- else:
560
- mode = "L"
561
- scale = 256 // (1 << bpp)
523
+ mode = ft_pixel_mode_grays
524
+ scale = 256 // (1 << bpp)
562
525
  # create the data array for all glyphs
563
526
  for codepoint in codepoints:
564
527
  font = point_font_map[codepoint]
565
- mask = font.font.getmask(codepoint, mode=mode)
566
- offset_x, offset_y = font.font.getoffset(codepoint)
567
- width, height = mask.size
528
+ if not font.has_fixed_sizes:
529
+ font.set_pixel_sizes(config[CONF_SIZE], 0)
530
+ font.load_char(codepoint)
531
+ font.glyph.render(mode)
532
+ width = font.glyph.bitmap.width
533
+ height = font.glyph.bitmap.rows
534
+ buffer = font.glyph.bitmap.buffer
535
+ pitch = font.glyph.bitmap.pitch
568
536
  glyph_data = [0] * ((height * width * bpp + 7) // 8)
537
+ src_mode = font.glyph.bitmap.pixel_mode
569
538
  pos = 0
570
539
  for y in range(height):
571
540
  for x in range(width):
572
- pixel = mask.getpixel((x, y)) // scale
541
+ if src_mode == ft_pixel_mode_mono:
542
+ pixel = (
543
+ (1 << bpp) - 1
544
+ if buffer[y * pitch + x // 8] & (1 << (7 - x % 8))
545
+ else 0
546
+ )
547
+ else:
548
+ pixel = buffer[y * pitch + x] // scale
573
549
  for bit_num in range(bpp):
574
550
  if pixel & (1 << (bpp - bit_num - 1)):
575
551
  glyph_data[pos // 8] |= 0x80 >> (pos % 8)
576
552
  pos += 1
577
- glyph_args[codepoint] = GlyphInfo(len(data), offset_x, offset_y, width, height)
553
+ ascender = font.size.ascender // 64
554
+ if ascender == 0:
555
+ if font.has_fixed_sizes:
556
+ ascender = font.available_sizes[0].height
557
+ else:
558
+ _LOGGER.error(
559
+ "Unable to determine ascender of font %s", config[CONF_FILE]
560
+ )
561
+ glyph_args[codepoint] = GlyphInfo(
562
+ len(data),
563
+ font.glyph.metrics.horiAdvance // 64,
564
+ font.glyph.bitmap_left,
565
+ ascender - font.glyph.bitmap_top,
566
+ width,
567
+ height,
568
+ )
578
569
  data += glyph_data
579
570
 
580
571
  rhs = [HexInt(x) for x in data]
@@ -598,6 +589,7 @@ async def to_code(config):
598
589
  f"{str(prog_arr)} + {str(glyph_args[codepoint].data_len)}"
599
590
  ),
600
591
  ),
592
+ ("advance", glyph_args[codepoint].advance),
601
593
  ("offset_x", glyph_args[codepoint].offset_x),
602
594
  ("offset_y", glyph_args[codepoint].offset_y),
603
595
  ("width", glyph_args[codepoint].width),
@@ -607,11 +599,19 @@ async def to_code(config):
607
599
 
608
600
  glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
609
601
 
602
+ font_height = base_font.size.height // 64
603
+ ascender = base_font.size.ascender // 64
604
+ if font_height == 0:
605
+ if base_font.has_fixed_sizes:
606
+ font_height = base_font.available_sizes[0].height
607
+ ascender = font_height
608
+ else:
609
+ _LOGGER.error("Unable to determine height of font %s", config[CONF_FILE])
610
610
  cg.new_Pvariable(
611
611
  config[CONF_ID],
612
612
  glyphs,
613
613
  len(glyph_initializer),
614
- base_font.ascent,
615
- base_font.ascent + base_font.descent,
614
+ ascender,
615
+ font_height,
616
616
  bpp,
617
617
  )