esphome 2025.2.1__py3-none-any.whl → 2025.3.0__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 (152) 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 +33 -0
  5. esphome/components/api/api_pb2.h +4 -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/audio/__init__.py +1 -1
  11. esphome/components/audio/audio_decoder.cpp +43 -11
  12. esphome/components/audio/audio_reader.cpp +9 -9
  13. esphome/components/audio/audio_reader.h +1 -1
  14. esphome/components/audio/audio_resampler.cpp +4 -2
  15. esphome/components/audio/audio_transfer_buffer.cpp +19 -9
  16. esphome/components/audio/audio_transfer_buffer.h +7 -2
  17. esphome/components/bluetooth_proxy/bluetooth_proxy.h +8 -0
  18. esphome/components/bmp085/bmp085.cpp +1 -1
  19. esphome/components/chsc6x/__init__.py +2 -0
  20. esphome/components/chsc6x/chsc6x_touchscreen.cpp +47 -0
  21. esphome/components/chsc6x/chsc6x_touchscreen.h +34 -0
  22. esphome/components/chsc6x/touchscreen.py +33 -0
  23. esphome/components/climate/__init__.py +0 -1
  24. esphome/components/cst816/binary_sensor/__init__.py +2 -25
  25. esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +3 -14
  26. esphome/components/cst816/touchscreen/cst816_touchscreen.h +0 -4
  27. esphome/components/esp32_ble_beacon/__init__.py +3 -1
  28. esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +2 -2
  29. esphome/components/esp8266/gpio.py +1 -2
  30. esphome/components/font/__init__.py +198 -215
  31. esphome/components/font/font.cpp +4 -4
  32. esphome/components/font/font.h +1 -0
  33. esphome/components/graph/graph.cpp +4 -0
  34. esphome/components/graph/graph.h +4 -0
  35. esphome/components/haier/climate.py +11 -10
  36. esphome/components/hbridge/switch/hbridge_switch.cpp +2 -2
  37. esphome/components/heatpumpir/climate.py +2 -1
  38. esphome/components/heatpumpir/heatpumpir.cpp +1 -0
  39. esphome/components/heatpumpir/heatpumpir.h +1 -0
  40. esphome/components/i2c/__init__.py +6 -6
  41. esphome/components/i2c/i2c_bus_esp_idf.cpp +6 -2
  42. esphome/components/i2s_audio/speaker/i2s_audio_speaker.cpp +1 -1
  43. esphome/components/ili9xxx/display.py +1 -0
  44. esphome/components/ili9xxx/ili9xxx_display.h +5 -0
  45. esphome/components/ili9xxx/ili9xxx_init.h +59 -0
  46. esphome/components/ld2450/__init__.py +51 -0
  47. esphome/components/ld2450/binary_sensor.py +47 -0
  48. esphome/components/ld2450/button/__init__.py +45 -0
  49. esphome/components/ld2450/button/reset_button.cpp +9 -0
  50. esphome/components/ld2450/button/reset_button.h +18 -0
  51. esphome/components/ld2450/button/restart_button.cpp +9 -0
  52. esphome/components/ld2450/button/restart_button.h +18 -0
  53. esphome/components/ld2450/ld2450.cpp +876 -0
  54. esphome/components/ld2450/ld2450.h +234 -0
  55. esphome/components/ld2450/number/__init__.py +121 -0
  56. esphome/components/ld2450/number/presence_timeout_number.cpp +12 -0
  57. esphome/components/ld2450/number/presence_timeout_number.h +18 -0
  58. esphome/components/ld2450/number/zone_coordinate_number.cpp +14 -0
  59. esphome/components/ld2450/number/zone_coordinate_number.h +19 -0
  60. esphome/components/ld2450/select/__init__.py +56 -0
  61. esphome/components/ld2450/select/baud_rate_select.cpp +12 -0
  62. esphome/components/ld2450/select/baud_rate_select.h +18 -0
  63. esphome/components/ld2450/select/zone_type_select.cpp +12 -0
  64. esphome/components/ld2450/select/zone_type_select.h +18 -0
  65. esphome/components/ld2450/sensor.py +156 -0
  66. esphome/components/ld2450/switch/__init__.py +45 -0
  67. esphome/components/ld2450/switch/bluetooth_switch.cpp +12 -0
  68. esphome/components/ld2450/switch/bluetooth_switch.h +18 -0
  69. esphome/components/ld2450/switch/multi_target_switch.cpp +12 -0
  70. esphome/components/ld2450/switch/multi_target_switch.h +18 -0
  71. esphome/components/ld2450/text_sensor.py +62 -0
  72. esphome/components/ltr390/ltr390.cpp +7 -7
  73. esphome/components/ltr390/ltr390.h +0 -1
  74. esphome/components/lvgl/defines.py +0 -2
  75. esphome/components/lvgl/font.cpp +1 -1
  76. esphome/components/lvgl/lvgl_esphome.cpp +27 -19
  77. esphome/components/lvgl/widgets/img.py +1 -3
  78. esphome/components/mcp2515/mcp2515.cpp +1 -0
  79. esphome/components/mdns/__init__.py +1 -1
  80. esphome/components/mixer/speaker/mixer_speaker.cpp +6 -1
  81. esphome/components/mixer/speaker/mixer_speaker.h +2 -0
  82. esphome/components/mlx90393/sensor.py +53 -33
  83. esphome/components/mlx90393/sensor_mlx90393.cpp +4 -0
  84. esphome/components/mlx90393/sensor_mlx90393.h +8 -3
  85. esphome/components/mqtt/__init__.py +2 -2
  86. esphome/components/msa3xx/__init__.py +189 -0
  87. esphome/components/msa3xx/binary_sensor.py +40 -0
  88. esphome/components/msa3xx/msa3xx.cpp +417 -0
  89. esphome/components/msa3xx/msa3xx.h +311 -0
  90. esphome/components/msa3xx/sensor.py +42 -0
  91. esphome/components/msa3xx/text_sensor.py +38 -0
  92. esphome/components/nfc/binary_sensor/__init__.py +4 -4
  93. esphome/components/opentherm/binary_sensor/__init__.py +4 -4
  94. esphome/components/opentherm/generate.py +6 -6
  95. esphome/components/opentherm/sensor/__init__.py +5 -6
  96. esphome/components/packages/__init__.py +35 -11
  97. esphome/components/pn532/binary_sensor.py +4 -4
  98. esphome/components/rc522/binary_sensor.py +4 -4
  99. esphome/components/resampler/speaker/resampler_speaker.h +2 -0
  100. esphome/components/socket/bsd_sockets_impl.cpp +1 -0
  101. esphome/components/socket/lwip_sockets_impl.cpp +1 -0
  102. esphome/components/socket/socket.h +3 -1
  103. esphome/components/speaker/speaker.h +2 -2
  104. esphome/components/ssd1306_base/__init__.py +7 -7
  105. esphome/components/thermostat/climate.py +1 -1
  106. esphome/components/tmp1075/tmp1075.cpp +7 -11
  107. esphome/components/tmp1075/tmp1075.h +1 -2
  108. esphome/components/tormatic/__init__.py +1 -0
  109. esphome/components/tormatic/cover.py +47 -0
  110. esphome/components/tormatic/tormatic_cover.cpp +355 -0
  111. esphome/components/tormatic/tormatic_cover.h +60 -0
  112. esphome/components/tormatic/tormatic_protocol.h +211 -0
  113. esphome/components/touchscreen/binary_sensor/__init__.py +3 -0
  114. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.cpp +7 -1
  115. esphome/components/touchscreen/binary_sensor/touchscreen_binary_sensor.h +3 -1
  116. esphome/components/touchscreen/touchscreen.cpp +3 -4
  117. esphome/components/udp/udp_component.h +4 -1
  118. esphome/components/web_server/list_entities.cpp +70 -66
  119. esphome/components/web_server/list_entities.h +43 -22
  120. esphome/components/web_server/web_server.cpp +345 -68
  121. esphome/components/web_server/web_server.h +138 -6
  122. esphome/components/web_server_base/__init__.py +1 -1
  123. esphome/components/web_server_idf/__init__.py +2 -0
  124. esphome/components/web_server_idf/web_server_idf.cpp +177 -30
  125. esphome/components/web_server_idf/web_server_idf.h +53 -4
  126. esphome/config_validation.py +23 -125
  127. esphome/const.py +5 -1
  128. esphome/core/config.py +15 -6
  129. esphome/core/defines.h +1 -1
  130. esphome/core/helpers.h +24 -3
  131. esphome/core/time.cpp +1 -0
  132. esphome/cpp_generator.py +3 -3
  133. esphome/dashboard/core.py +30 -21
  134. esphome/dashboard/dns.py +7 -1
  135. esphome/dashboard/entries.py +83 -16
  136. esphome/dashboard/settings.py +0 -4
  137. esphome/dashboard/status/mdns.py +43 -14
  138. esphome/dashboard/status/mqtt.py +22 -9
  139. esphome/dashboard/status/ping.py +54 -10
  140. esphome/dashboard/web_server.py +56 -24
  141. esphome/storage_json.py +4 -0
  142. esphome/wizard.py +13 -17
  143. esphome/writer.py +1 -3
  144. esphome/yaml_util.py +36 -33
  145. esphome/zeroconf.py +9 -21
  146. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/METADATA +7 -7
  147. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/RECORD +151 -111
  148. esphome/components/cst816/binary_sensor/cst816_button.h +0 -27
  149. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/LICENSE +0 -0
  150. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/WHEEL +0 -0
  151. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/entry_points.txt +0 -0
  152. {esphome-2025.2.1.dist-info → esphome-2025.3.0.dist-info}/top_level.txt +0 -0
@@ -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,21 @@ 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 pt_to_px(pt):
150
+ """
151
+ Convert a point size to pixels, rounding up to the nearest pixel
152
+ """
153
+ return (pt + 63) // 64
154
+
155
+
156
+ def validate_font_config(config):
120
157
  """
121
158
  Check for duplicate codepoints, then check that all requested codepoints actually
122
159
  have glyphs defined in the appropriate font file.
@@ -142,43 +179,51 @@ def validate_glyphs(config):
142
179
  )
143
180
  # Make setpoints and glyphspoints disjoint
144
181
  setpoints.difference_update(glyphspoints)
145
- 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
- if any(x >= 256 for x in setpoints.copy().union(glyphspoints)):
149
- raise cv.Invalid("Codepoints in bitmap fonts must be in the range 0-255")
150
- else:
151
- # for TT fonts, check that glyphs are actually present
152
- # Check extras against their own font, exclude from parent font codepoints
153
- for extra in config[CONF_EXTRAS]:
154
- points = {ord(x) for x in flatten(extra[CONF_GLYPHS])}
155
- glyphspoints.difference_update(points)
156
- setpoints.difference_update(points)
157
- check_missing_glyphs(extra[CONF_FILE][CONF_PATH], points)
158
-
159
- # A named glyph that can't be provided is an error
160
- check_missing_glyphs(fileconf[CONF_PATH], glyphspoints)
161
- # A missing glyph from a set is a warning.
162
- if not config[CONF_IGNORE_MISSING_GLYPHS]:
163
- check_missing_glyphs(fileconf[CONF_PATH], setpoints, warning=True)
182
+ # check that glyphs are actually present
183
+ # Check extras against their own font, exclude from parent font codepoints
184
+ for extra in config[CONF_EXTRAS]:
185
+ points = {ord(x) for x in flatten(extra[CONF_GLYPHS])}
186
+ glyphspoints.difference_update(points)
187
+ setpoints.difference_update(points)
188
+ check_missing_glyphs(extra[CONF_FILE], points)
189
+
190
+ # A named glyph that can't be provided is an error
191
+
192
+ check_missing_glyphs(fileconf, glyphspoints)
193
+ # A missing glyph from a set is a warning.
194
+ if not config[CONF_IGNORE_MISSING_GLYPHS]:
195
+ check_missing_glyphs(fileconf, setpoints, warning=True)
164
196
 
165
197
  # Populate the default after the above checks so that use of the default doesn't trigger errors
198
+ font = FONT_CACHE[fileconf]
166
199
  if not config[CONF_GLYPHS] and not config[CONF_GLYPHSETS]:
167
- if fileconf[CONF_TYPE] == TYPE_LOCAL_BITMAP:
168
- config[CONF_GLYPHS] = [DEFAULT_GLYPHS]
169
- else:
170
- # set a default glyphset, intersected with what the font actually offers
171
- font = FONT_CACHE[fileconf[CONF_PATH]]
172
- config[CONF_GLYPHS] = [
173
- chr(x)
174
- for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET)
175
- if font.get_char_index(x) != 0
176
- ]
200
+ # set a default glyphset, intersected with what the font actually offers
201
+ config[CONF_GLYPHS] = [
202
+ chr(x)
203
+ for x in glyphsets.unicodes_per_glyphset(DEFAULT_GLYPHSET)
204
+ if font.get_char_index(x) != 0
205
+ ]
206
+
207
+ if font.has_fixed_sizes:
208
+ sizes = [pt_to_px(x.size) for x in font.available_sizes]
209
+ if not sizes:
210
+ raise cv.Invalid(
211
+ f"Font {FontCache.get_name(fileconf)} has no available sizes"
212
+ )
213
+ if CONF_SIZE not in config:
214
+ config[CONF_SIZE] = sizes[0]
215
+ elif config[CONF_SIZE] not in sizes:
216
+ sizes = ", ".join(str(x) for x in sizes)
217
+ raise cv.Invalid(
218
+ f"Font {FontCache.get_name(fileconf)} only has size{'s' if len(sizes) != 1 else ''} {sizes} available"
219
+ )
220
+ elif CONF_SIZE not in config:
221
+ config[CONF_SIZE] = 20
177
222
 
178
223
  return config
179
224
 
180
225
 
181
- FONT_EXTENSIONS = (".ttf", ".woff", ".otf")
226
+ FONT_EXTENSIONS = (".ttf", ".woff", ".otf", "bdf", ".pcf")
182
227
 
183
228
 
184
229
  def validate_truetype_file(value):
@@ -187,24 +232,30 @@ def validate_truetype_file(value):
187
232
  f"Please unzip the font archive '{value}' first and then use the .ttf files inside."
188
233
  )
189
234
  if not any(map(value.lower().endswith, FONT_EXTENSIONS)):
190
- raise cv.Invalid(f"Only {FONT_EXTENSIONS} files are supported.")
235
+ raise cv.Invalid(f"Only {', '.join(FONT_EXTENSIONS)} files are supported.")
191
236
  return CORE.relative_config_path(cv.file_(value))
192
237
 
193
238
 
239
+ def add_local_file(value):
240
+ if value in FONT_CACHE:
241
+ return value
242
+ path = value[CONF_PATH]
243
+ if not os.path.isfile(path):
244
+ raise cv.Invalid(f"File '{path}' not found.")
245
+ FONT_CACHE[value] = path
246
+ return value
247
+
248
+
194
249
  TYPE_LOCAL = "local"
195
- 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
- }
202
- )
203
-
204
- LOCAL_BITMAP_SCHEMA = cv.Schema(
205
- {
206
- cv.Required(CONF_PATH): cv.file_,
207
- }
252
+ LOCAL_SCHEMA = cv.All(
253
+ cv.Schema(
254
+ {
255
+ cv.Required(CONF_PATH): validate_truetype_file,
256
+ }
257
+ ),
258
+ add_local_file,
208
259
  )
209
260
 
210
261
  FULLPATH_SCHEMA = cv.maybe_simple_value(
@@ -235,56 +286,59 @@ def _compute_local_font_path(value: dict) -> Path:
235
286
  h.update(url.encode())
236
287
  key = h.hexdigest()[:8]
237
288
  base_dir = external_files.compute_local_file_dir(DOMAIN)
238
- _LOGGER.debug("_compute_local_font_path: base_dir=%s", base_dir / key)
289
+ _LOGGER.debug("_compute_local_font_path: %s", base_dir / key)
239
290
  return base_dir / key
240
291
 
241
292
 
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
293
  def download_gfont(value):
294
+ if value in FONT_CACHE:
295
+ return value
252
296
  name = (
253
297
  f"{value[CONF_FAMILY]}:ital,wght@{int(value[CONF_ITALIC])},{value[CONF_WEIGHT]}"
254
298
  )
255
299
  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
- )
300
+ path = (
301
+ external_files.compute_local_file_dir(DOMAIN)
302
+ / f"{value[CONF_FAMILY]}@{value[CONF_WEIGHT]}@{value[CONF_ITALIC]}@v1.ttf"
303
+ )
304
+ if not external_files.is_file_recent(str(path), value[CONF_REFRESH]):
305
+ _LOGGER.debug("download_gfont: path=%s", path)
306
+ try:
307
+ req = requests.get(url, timeout=external_files.NETWORK_TIMEOUT)
308
+ req.raise_for_status()
309
+ except requests.exceptions.RequestException as e:
310
+ raise cv.Invalid(
311
+ f"Could not download font at {url}, please check the fonts exists "
312
+ f"at google fonts ({e})"
313
+ )
314
+ match = re.search(r"src:\s+url\((.+)\)\s+format\('truetype'\);", req.text)
315
+ if match is None:
316
+ raise cv.Invalid(
317
+ f"Could not extract ttf file from gfonts response for {name}, "
318
+ f"please report this."
319
+ )
273
320
 
274
- ttf_url = match.group(1)
275
- _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
321
+ ttf_url = match.group(1)
322
+ _LOGGER.debug("download_gfont: ttf_url=%s", ttf_url)
276
323
 
277
- external_files.download_content(ttf_url, path)
278
- return FULLPATH_SCHEMA(path)
324
+ external_files.download_content(ttf_url, path)
325
+ # In case the remote file is not modified, the download_content function will return the existing file,
326
+ # so update the modification time to now.
327
+ path.touch()
328
+ FONT_CACHE[value] = path
329
+ return value
279
330
 
280
331
 
281
332
  def download_web_font(value):
333
+ if value in FONT_CACHE:
334
+ return value
282
335
  url = value[CONF_URL]
283
- path = get_font_path(value, TYPE_WEB)
336
+ path = _compute_local_font_path(value) / "font.ttf"
284
337
 
285
338
  external_files.download_content(url, path)
286
339
  _LOGGER.debug("download_web_font: path=%s", path)
287
- return FULLPATH_SCHEMA(path)
340
+ FONT_CACHE[value] = path
341
+ return value
288
342
 
289
343
 
290
344
  EXTERNAL_FONT_SCHEMA = cv.Schema(
@@ -340,15 +394,6 @@ def validate_file_shorthand(value):
340
394
  }
341
395
  )
342
396
 
343
- if value.endswith(".pcf") or value.endswith(".bdf"):
344
- value = convert_bitmap_to_pillow_font(
345
- CORE.relative_config_path(cv.file_(value))
346
- )
347
- return {
348
- CONF_TYPE: TYPE_LOCAL_BITMAP,
349
- CONF_PATH: value,
350
- }
351
-
352
397
  return font_file_schema(
353
398
  {
354
399
  CONF_TYPE: TYPE_LOCAL,
@@ -361,7 +406,6 @@ TYPED_FILE_SCHEMA = cv.typed_schema(
361
406
  {
362
407
  TYPE_LOCAL: LOCAL_SCHEMA,
363
408
  TYPE_GFONTS: GFONTS_SCHEMA,
364
- TYPE_LOCAL_BITMAP: LOCAL_BITMAP_SCHEMA,
365
409
  TYPE_WEB: WEB_FONT_SCHEMA,
366
410
  }
367
411
  )
@@ -391,7 +435,7 @@ FONT_SCHEMA = cv.Schema(
391
435
  cv.one_of(*glyphsets.defined_glyphsets())
392
436
  ),
393
437
  cv.Optional(CONF_IGNORE_MISSING_GLYPHS, default=False): cv.boolean,
394
- cv.Optional(CONF_SIZE, default=20): cv.int_range(min=1),
438
+ cv.Optional(CONF_SIZE): cv.int_range(min=1),
395
439
  cv.Optional(CONF_BPP, default=1): cv.one_of(1, 2, 4, 8),
396
440
  cv.Optional(CONF_EXTRAS, default=[]): cv.ensure_list(
397
441
  cv.Schema(
@@ -406,114 +450,19 @@ FONT_SCHEMA = cv.Schema(
406
450
  },
407
451
  )
408
452
 
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
453
+ CONFIG_SCHEMA = cv.All(FONT_SCHEMA, validate_font_config)
449
454
 
450
455
 
451
456
  class EFont:
452
- def __init__(self, file, size, codepoints):
457
+ def __init__(self, file, codepoints):
453
458
  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)
459
+ self.font: Face = FONT_CACHE[file]
512
460
 
513
461
 
514
462
  class GlyphInfo:
515
- def __init__(self, data_len, offset_x, offset_y, width, height):
463
+ def __init__(self, data_len, advance, offset_x, offset_y, width, height):
516
464
  self.data_len = data_len
465
+ self.advance = advance
517
466
  self.offset_x = offset_x
518
467
  self.offset_y = offset_y
519
468
  self.width = width
@@ -537,15 +486,14 @@ async def to_code(config):
537
486
  }
538
487
  # get the codepoints from the glyphs key, flatten to a list of chrs and combine with the points from glyphsets
539
488
  point_set.update(flatten(config[CONF_GLYPHS]))
540
- size = config[CONF_SIZE]
541
489
  # 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}
490
+ base_font = FONT_CACHE[config[CONF_FILE]]
491
+ point_font_map: dict[str, Face] = {c: base_font for c in point_set}
544
492
  # process extras, updating the map and extending the codepoint list
545
493
  for extra in config[CONF_EXTRAS]:
546
494
  extra_points = flatten(extra[CONF_GLYPHS])
547
495
  point_set.update(extra_points)
548
- extra_font = EFont(extra[CONF_FILE], size, extra_points)
496
+ extra_font = FONT_CACHE[extra[CONF_FILE]]
549
497
  point_font_map.update({c: extra_font for c in extra_points})
550
498
 
551
499
  codepoints = list(point_set)
@@ -553,28 +501,54 @@ async def to_code(config):
553
501
  glyph_args = {}
554
502
  data = []
555
503
  bpp = config[CONF_BPP]
556
- if bpp == 1:
557
- mode = "1"
558
- scale = 1
559
- else:
560
- mode = "L"
561
- scale = 256 // (1 << bpp)
504
+ mode = ft_pixel_mode_grays
505
+ scale = 256 // (1 << bpp)
506
+ size = config[CONF_SIZE]
562
507
  # create the data array for all glyphs
563
508
  for codepoint in codepoints:
564
509
  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
510
+ format = font.get_format().decode("utf-8")
511
+ if format != "PCF":
512
+ font.set_pixel_sizes(size, 0)
513
+ font.load_char(codepoint)
514
+ font.glyph.render(mode)
515
+ width = font.glyph.bitmap.width
516
+ height = font.glyph.bitmap.rows
517
+ buffer = font.glyph.bitmap.buffer
518
+ pitch = font.glyph.bitmap.pitch
568
519
  glyph_data = [0] * ((height * width * bpp + 7) // 8)
520
+ src_mode = font.glyph.bitmap.pixel_mode
569
521
  pos = 0
570
522
  for y in range(height):
571
523
  for x in range(width):
572
- pixel = mask.getpixel((x, y)) // scale
524
+ if src_mode == ft_pixel_mode_mono:
525
+ pixel = (
526
+ (1 << bpp) - 1
527
+ if buffer[y * pitch + x // 8] & (1 << (7 - x % 8))
528
+ else 0
529
+ )
530
+ else:
531
+ pixel = buffer[y * pitch + x] // scale
573
532
  for bit_num in range(bpp):
574
533
  if pixel & (1 << (bpp - bit_num - 1)):
575
534
  glyph_data[pos // 8] |= 0x80 >> (pos % 8)
576
535
  pos += 1
577
- glyph_args[codepoint] = GlyphInfo(len(data), offset_x, offset_y, width, height)
536
+ ascender = pt_to_px(font.size.ascender)
537
+ if ascender == 0:
538
+ if font.has_fixed_sizes:
539
+ ascender = size
540
+ else:
541
+ _LOGGER.error(
542
+ "Unable to determine ascender of font %s", config[CONF_FILE]
543
+ )
544
+ glyph_args[codepoint] = GlyphInfo(
545
+ len(data),
546
+ pt_to_px(font.glyph.metrics.horiAdvance),
547
+ font.glyph.bitmap_left,
548
+ ascender - font.glyph.bitmap_top,
549
+ width,
550
+ height,
551
+ )
578
552
  data += glyph_data
579
553
 
580
554
  rhs = [HexInt(x) for x in data]
@@ -598,6 +572,7 @@ async def to_code(config):
598
572
  f"{str(prog_arr)} + {str(glyph_args[codepoint].data_len)}"
599
573
  ),
600
574
  ),
575
+ ("advance", glyph_args[codepoint].advance),
601
576
  ("offset_x", glyph_args[codepoint].offset_x),
602
577
  ("offset_y", glyph_args[codepoint].offset_y),
603
578
  ("width", glyph_args[codepoint].width),
@@ -607,11 +582,19 @@ async def to_code(config):
607
582
 
608
583
  glyphs = cg.static_const_array(config[CONF_RAW_GLYPH_ID], glyph_initializer)
609
584
 
585
+ font_height = pt_to_px(base_font.size.height)
586
+ ascender = pt_to_px(base_font.size.ascender)
587
+ if font_height == 0:
588
+ if base_font.has_fixed_sizes:
589
+ font_height = size
590
+ ascender = font_height
591
+ else:
592
+ _LOGGER.error("Unable to determine height of font %s", config[CONF_FILE])
610
593
  cg.new_Pvariable(
611
594
  config[CONF_ID],
612
595
  glyphs,
613
596
  len(glyph_initializer),
614
- base_font.ascent,
615
- base_font.ascent + base_font.descent,
597
+ ascender,
598
+ font_height,
616
599
  bpp,
617
600
  )
@@ -81,7 +81,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in
81
81
  if (glyph_n < 0) {
82
82
  // Unknown char, skip
83
83
  if (!this->get_glyphs().empty())
84
- x += this->get_glyphs()[0].glyph_data_->width;
84
+ x += this->get_glyphs()[0].glyph_data_->advance;
85
85
  i++;
86
86
  continue;
87
87
  }
@@ -92,7 +92,7 @@ void Font::measure(const char *str, int *width, int *x_offset, int *baseline, in
92
92
  } else {
93
93
  min_x = std::min(min_x, x + glyph.glyph_data_->offset_x);
94
94
  }
95
- x += glyph.glyph_data_->width + glyph.glyph_data_->offset_x;
95
+ x += glyph.glyph_data_->advance;
96
96
 
97
97
  i += match_length;
98
98
  has_char = true;
@@ -111,7 +111,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
111
111
  // Unknown char, skip
112
112
  ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]);
113
113
  if (!this->get_glyphs().empty()) {
114
- uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->width;
114
+ uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance;
115
115
  display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color);
116
116
  x_at += glyph_width;
117
117
  }
@@ -161,7 +161,7 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo
161
161
  }
162
162
  }
163
163
  }
164
- x_at += glyph.glyph_data_->width + glyph.glyph_data_->offset_x;
164
+ x_at += glyph.glyph_data_->advance;
165
165
 
166
166
  i += match_length;
167
167
  }
@@ -15,6 +15,7 @@ class Font;
15
15
  struct GlyphData {
16
16
  const uint8_t *a_char;
17
17
  const uint8_t *data;
18
+ int advance;
18
19
  int offset_x;
19
20
  int offset_y;
20
21
  int width;
@@ -132,6 +132,10 @@ void Graph::draw(Display *buff, uint16_t x_offset, uint16_t y_offset, Color colo
132
132
  yrange = ymax - ymin;
133
133
  }
134
134
 
135
+ // Store graph limts
136
+ this->graph_limit_max_ = ymax;
137
+ this->graph_limit_min_ = ymin;
138
+
135
139
  /// Draw grid
136
140
  if (!std::isnan(this->gridspacing_y_)) {
137
141
  for (int y = yn; y <= ym; y++) {
@@ -161,11 +161,15 @@ class Graph : public Component {
161
161
  uint32_t get_duration() { return duration_; }
162
162
  uint32_t get_width() { return width_; }
163
163
  uint32_t get_height() { return height_; }
164
+ float get_graph_limit_min() { return graph_limit_min_; }
165
+ float get_graph_limit_max() { return graph_limit_max_; }
164
166
 
165
167
  protected:
166
168
  uint32_t duration_; /// in seconds
167
169
  uint32_t width_; /// in pixels
168
170
  uint32_t height_; /// in pixels
171
+ float graph_limit_min_{NAN};
172
+ float graph_limit_max_{NAN};
169
173
  float min_value_{NAN};
170
174
  float max_value_{NAN};
171
175
  float min_range_{1.0};