diff --git a/content/components/_index.md b/content/components/_index.md index e1703694a3..fe67e40dbb 100644 --- a/content/components/_index.md +++ b/content/components/_index.md @@ -722,6 +722,7 @@ Often known as "tag" or "card" readers within the community. {{< imgtable >}} "Addressable Light","components/display/addressable_light","addressable_light.jpg" "MIPI DSI Displays","components/display/mipi_dsi","tab5.jpg" +"MIPI RGB Displays","components/display/mipi_rgb","indicator.jpg" "MIPI SPI Displays","components/display/mipi_spi","t4-s3.jpg" "ILI9xxx","components/display/ili9xxx","ili9341.jpg" "ILI9341","components/display/ili9xxx","ili9341.svg" @@ -1038,6 +1039,7 @@ ESPHome to cellular networks. **Does not encompass Wi-Fi.** ## Miscellaneous Components {{< imgtable >}} +"Camera Encoder","components/camera/camera_encoder","camera.svg","dark-invert" "ESP32 Camera","components/esp32_camera","camera.svg","dark-invert" "Exposure Notifications","components/exposure_notifications","exposure_notifications.png","" "GPS","components/gps","crosshairs-gps.svg","dark-invert" diff --git a/content/components/bluetooth_proxy.md b/content/components/bluetooth_proxy.md index 63af12d58a..dc7e618001 100644 --- a/content/components/bluetooth_proxy.md +++ b/content/components/bluetooth_proxy.md @@ -45,9 +45,12 @@ please search for it in the [Home Assistant Integrations](https://www.home-assis ```yaml bluetooth_proxy: + # Active connections are now enabled by default + # To disable active connections (previous default behavior), use: + # active: false ``` -- **active** (*Optional*, boolean): Enables proxying active connections. Defaults to `false`. +- **active** (*Optional*, boolean): Enables proxying active connections. Defaults to `true`. - **cache_services** (*Optional*, boolean): Enables caching GATT services in NVS flash storage which significantly speeds up active connections. Defaults to `true` when using the ESP-IDF framework. - **connection_slots** (*Optional*, int): The maximum number of BLE connection slots to use. Each configured slot consumes ~1KB of RAM. This can only be adjusted when using diff --git a/content/components/camera/camera_encoder.md b/content/components/camera/camera_encoder.md new file mode 100644 index 0000000000..18277bed26 --- /dev/null +++ b/content/components/camera/camera_encoder.md @@ -0,0 +1,50 @@ +--- +description: "Instructions for setting up the camera encoder component in ESPHome." +title: "Camera Encoder" +params: + seo: + description: Instructions for setting up the camera encoder component in ESPHome. + image: camera.svg +--- + +The ``camera_encoder`` component provides image compression support for software-based cameras or cameras without +internal compression. It allows raw camera frames to be compressed into a format suitable for transmission to API +clients, such as Home Assistant, which expect JPEG-compressed images. + +It supports different encoder implementations, such as a ESP32 Camera software JPEG encoder that can be configured with +options like image quality and incremental encoding. These settings make it possible to balance image +quality and performance depending on the use case. + +{{< note >}} +The default software JPEG encoder enables devices like the ESP32-S3 to stream images. +It is primarily intended for smallar images due to limited processing power and memory, +and supports only devices from the ESP32 family. +{{< /note >}} + +```yaml +# Example configuration entry +camera_encoder: +``` + +## Configuration variables + +- **type** (*Optional*): ``esp32_camera`` + +## esp32_camera Options + +- **quality** (*Optional*, int): Sets JPEG compression quality. + Valid values range from ``1`` (lowest quality, highest compression) to ``100`` (best quality, least compression). Defaults: ``80``. + +- **buffer_size** (*Optional*, int): Initial size of the output buffer in bytes, used to store the JPEG-encoded image data. + - Minimum: 1024 bytes + - Maximum: 2097152 bytes (2 MB), sufficient for ESP32-S3 and ESP32-P4 + - Default: ``4096``. + +- **buffer_expand_size** (*Optional*, int): Number of bytes to expand the output buffer if it is too small to hold the JPEG-encoded image. A value of ``0`` disables expansion. + - Maximum: 2097152 bytes (2 MB), sufficient for ESP32-S3 and ESP32-P4 + - Default: ``1024``. + +## See Also + +- {{< apiref "camera/encoder.h" "camera/encoder.h" >}} +- {{< apiref "camera_encoder/esp32_camera_jpeg_encoder.h" "camera_encoder/esp32_camera_jpeg_encoder.h" >}} diff --git a/content/components/display/_index.md b/content/components/display/_index.md index 990cd743f2..ee6c88a314 100644 --- a/content/components/display/_index.md +++ b/content/components/display/_index.md @@ -7,15 +7,18 @@ params: image: folder-open.svg --- -The `display` component houses ESPHome's powerful rendering and display -engine. Fundamentally, there are these types of displays: +The `display` component houses ESPHome's graphical rendering and display +engine. It caters for a wide range of different display types, from simple character displays to +graphical displays with fully addressable pixels. - Character displays like {{< docref "max7219" "7-Segment displays" >}} or {{< docref "lcd_display" "LCD displays" >}}. - Serial displays like {{< docref "nextion/" >}} that have their own processors for graphics rendering. -- Graphical displays with fully addressable pixels, like {{< docref "waveshare_epaper" "E-Paper" >}}, - {{< docref "ssd1306" "OLED" >}} or {{< docref "ili9xxx" "TFT" >}} displays. +- Graphical displays with fully addressable pixels, such as + {{< docref "mipi_spi" "SPI interfaced LCDs" >}}, + {{< docref "waveshare_epaper" "E-Paper" >}}, + and {{< docref "ssd1306" "OLED" >}}. For graphical displays, which offer the greatest flexibility, there are two options for displaying content: @@ -587,7 +590,7 @@ display: - **from** (*Optional*, [ID](#config-id)): A page id. If set the automation is only triggered if changing from this page. Defaults to all pages. - **to** (*Optional*, [ID](#config-id)): A page id. If set the automation is only triggered if changing to this page. Defaults to all pages. -Additionally the old page will be given as the variable `from` and the new one as the variable `to`. +Additionally, the old page will be given as the variable `from` and the new one as the variable `to`. ### Troubleshooting @@ -619,7 +622,10 @@ For displays in 8 bit mode you will see distinct color blocks rather than a smoo ### See Also - {{< apiref "display/display_buffer.h" "display/display_buffer.h" >}} -- {{< docref "/components/lvgl/index" "LVGL" >}} +- {{< docref "/components/lvgl/index" >}} +- {{< docref "/components/display/mipi_spi" >}} +- {{< docref "/components/display/mipi_rgb" >}} +- {{< docref "/components/display/mipi_dsi" >}} - [Fonts](#display-fonts) - [Graph Component](#display-graphs) - [QR Code Component](#display-qrcode) diff --git a/content/components/display/ili9xxx.md b/content/components/display/ili9xxx.md index fb0f9d9d8c..b8335c4799 100644 --- a/content/components/display/ili9xxx.md +++ b/content/components/display/ili9xxx.md @@ -47,8 +47,8 @@ beyond the basic SPI connections, and a reasonable amount of RAM, it is not well {{< warning >}} This component has been made redundant since this class of displays is now supported by the [MIPI SPI Display Driver](#mipi_spi). This component may be removed in a future release. - {{< /warning >}} + {{< note >}} PSRAM is not automatically enabled on the ESP32 (this changed with the 2025.2 release.) If PSRAM is available, you should enable it with the {{< docref "/components/psram" "PSRAM configuration" >}}. diff --git a/content/components/display/mipi_rgb.md b/content/components/display/mipi_rgb.md new file mode 100644 index 0000000000..bd877b8249 --- /dev/null +++ b/content/components/display/mipi_rgb.md @@ -0,0 +1,207 @@ +--- +description: "Instructions for setting up 16 bit \"RGB\" parallel displays" +title: "MIPI RGB Display Driver" +params: + seo: + description: Instructions for setting up 16 bit "RGB" parallel displays + image: indicator.jpg +--- + +## Types of Display + +This display driver supports displays with 16 bit parallel interfaces, often referred to as "RGB". +Two classes of display fall under this category, the first are those that only have the RGB interface and require +no special configuration of the driver chip. The second are those that have both the RGB interface and an SPI interface +which is used to configure the driver chip. + +## Supported boards and driver chips + +The driver supports a number of display driver chips, and can be configured for custom displays. As well as support for +driver chips, there are also specific configurations for several ESP32 boards with integrated displays. For those boards +the predefined configuration will set the correct pins and dimensions for the display. + +For custom displays, the driver can be configured with the correct pins and dimensions, and the driver chip can be +specified, or a custom init sequence can be provided. + +### Driver chips + +| Driver Chip | Typical Dimensions | +| ----------- | ------------------ | +| ST7701S | 480x480 | +| RPI | varies | + +The `RPI` driver chip represents displays without an SPI interface, so no init sequence is required. + +### Supported integrated display boards + +These boards have completely pre-filled configurations for the display driver, so the only required configuration +option is `model`. + +| Board | Driver Chip | Manufacturer | Product link | +| ---------------------------- | ----------- | ------------ | ---------------------------------------------------------------- | +| GUITION-4848S040 | ST7701s | Guition | | +| T-PANEL-S3 | ST7701s | Lilygo | | +| T-RGB-2.1 | ST7701s | Lilygo | | +| T-RGB-2.8 | ST7701s | Lilygo | | +| SEEED-INDICATOR-D1 | ST7701s | Seeed Studio | | +| ESP32-S3-TOUCH-LCD-4.3 | RPI | Waveshare | | +| ESP32-S3-TOUCH-LCD-7-800X480 | RPI | Waveshare | | +| WAVESHARE-4-480x480 | RPI | Waveshare | | + +## Usage + +This component requires an ESP32 (usually an ESP32-S3 because of the number of GPIO pins required) and the use of +ESP-IDF. PSRAM is a requirement due to the size of the display buffer. + +{{< img src="indicator.jpg" alt="Image" caption="Sensecap Indicator display" width="75.0%" class="align-center" >}} + +```yaml +# Example minimal configuration entry +display: + - platform: mipi_rgb + model: WAVESHARE-4-480x480 + id: my_display +``` + +## Configuration variables + +- **invert_colors** (*Optional*): With this boolean option you can invert the display colors. **Note** some of the + displays have this option set automatically to true and can't be changed. +- **rotation** (*Optional*): Rotate the display presentation in software. Choose one of `0°`, `90°`, `180°`, or `270°`. + This option cannot be used with `transform`. +- **transform** (*Optional*): Transform the display presentation using hardware. All defaults are `false`. + This option cannot be used with `rotation`. + + - **mirror_x** (*Optional*, boolean): If true, mirror the x-axis. + - **mirror_y** (*Optional*, boolean): If true, mirror the y-axis. + **Note:** To rotate the display in hardware by 180 degrees set both `mirror_x` and `mirror_y` to `true`. + +- **update_interval** (*Optional*, [Time](#config-time)): The interval to re-draw the screen. Defaults to `5s`. +- **auto_clear_enabled** (*Optional*, boolean): If the display should be cleared before each update. Defaults to `true` + if a lambda or pages are configured, false otherwise. +- **lambda** (*Optional*, [lambda](#config-lambda)): The lambda to use for rendering the content on the display. + See [Display Rendering Engine](#display-engine) for more information. +- **pages** (*Optional*, list): Show pages instead of a single lambda. See [Display Pages](#display-pages). +- **id** (*Optional*, [ID](#config-id)): Manually specify the ID used for code generation. +- **color_order** (*Optional*): Should be one of `bgr` (default) or `rgb`. +- **dimensions** (**Required**): Dimensions of the screen, specified either as *width* **x** *height* (e.g `320x240`) + or with separate config keys. + + - **height** (**Required**, int): Specifies height of display in pixels. + - **width** (**Required**, int): Specifies width of display. + - **offset_width** (*Optional*, int): Specify an offset for the x-direction of the display, typically used when an + LCD is smaller than the maximum supported by the driver chip. Default is 0 + - **offset_height** (*Optional*, int): Specify an offset for the y-direction of the display. Default is 0. + +- **data_pins** (**Required**): A list of pins used for the databus. Specified in 3 groups. + + - **red** (**Required**, [Pin Schema](#config-pin_schema)): Exactly 5 pins for the red databits, listed from least + to most significant bit. + - **green** (**Required**, [Pin Schema](#config-pin_schema)): Exactly 6 pins for the green databits, listed from + least to most significant bit. + - **blue** (**Required**, [Pin Schema](#config-pin_schema)): Exactly 5 pins for the blue databits, listed from + least to most significant bit. + +- **de_pin** (**Required**, [Pin Schema](#config-pin_schema)): The DE pin. +- **pclk_pin** (**Required**, [Pin Schema](#config-pin_schema)): The PCLK pin. +- **hsync_pin** (**Required**, [Pin Schema](#config-pin_schema)): The Horizontal sync pin. +- **vsync_pin** (**Required**, [Pin Schema](#config-pin_schema)): The Vertical sync pin. +- **reset_pin** (*Optional*, [Pin Schema](#config-pin_schema)): The RESET pin. +- **hsync_pulse_width** (*Optional*, int): The horizontal sync pulse width. +- **hsync_front_porch** (*Optional*, int): The horizontal front porch length. +- **hsync_back_porch** (*Optional*, int): The horizontal back porch length. +- **vsync_pulse_width** (*Optional*, int): The vertical sync pulse width. +- **vsync_front_porch** (*Optional*, int): The vertical front porch length. +- **vsync_back_porch** (*Optional*, int): The vertical back porch length. +- **pclk_frequency** (*Optional*): Set the pixel clock speed. Default is 8MHz. +- **pclk_inverted** (*Optional*, bool): If the pclk is active negative (default is True) + +The horizontal and vertical `pulse_width` , `front_porch` and `back_porch` values are optional, but will +likely require changing from the default values for a specific display. Refer to the manufacturer's sample code +for suitable values. These specify timing requirements for the display. + +## Additional Configuration for non-RPI displays + +Displays needing a custom init sequence require an SPI bus to be configured, plus these options: + +- **dc_pin** (*Optional*, [Pin Schema](#config-pin_schema)): The DC pin. +- **data_rate** (*Optional*): Set the data rate of the SPI interface to the display. One of `80MHz` , `40MHz` , + `20MHz` , `10MHz` , `5MHz` , `2MHz` , `1MHz` (default), `200kHz` , `75kHz` or `1kHz` . +- **spi_mode** (*Optional*): Set the mode for the SPI interface to the display. Default is `MODE0` but some displays + require `MODE3` . +- **spi_id** (*Optional*, [ID](#config-id)): The ID of the SPI interface to use - may be omitted if only one SPI bus + is configured. +- **init_sequence** (*Optional*, A list of byte arrays): Specifies the init sequence for the display. Predefined boards + have a default init sequence, which can be overridden. A custom board can specify the init sequence using this + variable. RPI displays should provide an empty sequence in which case the SPI bus is not required. + +The `init_sequence` requires a list of elements, one of which may be a single integer selecting a canned init +sequence (the default and currently the only sequence is 1), the remainder must be byte arrays providing additional +init commands, each consisting of a command byte followed by zero or more data bytes. + +A delay may be specified with `delay ms` + +These will be collected and sent to the display via SPI during initialisation. The SPI bus need not be implemented +in hardware (i.e. it may use `interface: software`) and it will be released after initialisation, before the RGB +driver is configured. This caters for boards that use the SPI bus pins as RGB pins. + +## Example configurations + +This is an example of a full custom configuration. + +```yaml +display: + - platform: mipi_rgb + update_interval: never + spi_mode: MODE3 + color_order: RGB + dimensions: + width: 480 + height: 480 + invert_colors: true + transform: + mirror_x: true + mirror_y: true + cs_pin: + pca9554: p_c_a + number: 4 + reset_pin: + pca9554: p_c_a + number: 5 + de_pin: 18 + hsync_pin: 16 + vsync_pin: 17 + pclk_pin: 21 + init_sequence: + - 1 # select canned init sequence number 1 + - delay 5ms + - [ 0xE0, 0x1F ] # Set sunlight readable enhancement + data_pins: + red: + - 4 #r1 + - 3 #r2 + - 2 #r3 + - 1 #r4 + - 0 #r5 + green: + - 10 #g0 + - 9 #g1 + - 8 #g2 + - 7 #g3 + - 6 #g4 + - 5 #g5 + blue: + - 15 #b1 + - 14 #b2 + - 13 #b3 + - 12 #b4 + - 11 #b5 + lambda: |- + it.fill(COLOR_BLACK); + it.print(0, 0, id(my_font), id(my_red), TextAlign::TOP_LEFT, "Hello World!"); +``` + +## See Also + +- {{< docref "index/" >}} +- {{< apiref "mipi_rgb/mipi_rgb.h" "mipi_rgb/mipi_rgb.h" >}} diff --git a/content/components/display/mipi_spi.md b/content/components/display/mipi_spi.md index 91906f338c..e1d790a452 100644 --- a/content/components/display/mipi_spi.md +++ b/content/components/display/mipi_spi.md @@ -58,27 +58,28 @@ using an octal SPI bus, so references here to parallel and octal SPI are equival ### Boards with integrated displays | Model | Manufacturer | Product Description | -| ------------------------------------ | ------------ | ----------------------------------------------------------------- | +|--------------------------------------| ------------ | ----------------------------------------------------------------- | | ADAFRUIT-S2-TFT-FEATHER | Adafruit | | | ADAFRUIT-FUNHOUSE | Adafruit | | -| M5CORE | M5Stack | | -| S3BOX | Espressif | | -| S3BOXLITE | Espressif | | -| WAVESHARE-4-TFT | Waveshare | | -| PICO-RESTOUCH-LCD-3.5 | Waveshare | | +| M5CORE | M5Stack | | +| S3BOX | Espressif | | +| S3BOXLITE | Espressif | | +| WAVESHARE-4-TFT | Waveshare | | +| PICO-RESTOUCH-LCD-3.5 | Waveshare | | | WAVESHARE-ESP32-S3-TOUCH-AMOLED-1.75 | Waveshare | | -| WT32-SC01-PLUS | Wireless-Tag | | -| ESP32-2432S028 | Sunton | | -| JC3248W535 | Guition | | -| JC3636W518 | Guition | | -| LANBON-L8 | Lanbon | | -| T4-S3-AMOLED | Lilygo | | -| T-EMBED | Lilygo | | -| T-DISPLAY | Lilygo | | -| T-DISPLAY-S3 | Lilygo | | -| T-DISPLAY-S3-PRO | Lilygo | | -| T-DISPLAY-S3-AMOLED | Lilygo | | -| T-DISPLAY-S3-AMOLED-PLUS | Lilygo | | +| WT32-SC01-PLUS | Wireless-Tag | | +| ESP32-2432S028 | Sunton | | +| JC3248W535 | Guition | | +| JC3636W518 | Guition | | +| JC3636W518V2 | Guition | | +| LANBON-L8 | Lanbon | | +| T4-S3-AMOLED | Lilygo | | +| T-EMBED | Lilygo | | +| T-DISPLAY | Lilygo | | +| T-DISPLAY-S3 | Lilygo | | +| T-DISPLAY-S3-PRO | Lilygo | | +| T-DISPLAY-S3-AMOLED | Lilygo | | +| T-DISPLAY-S3-AMOLED-PLUS | Lilygo | | ## SPI Bus diff --git a/content/components/display/qspi_dbi.md b/content/components/display/qspi_dbi.md index 0b18fbd7ae..b5371b78a3 100644 --- a/content/components/display/qspi_dbi.md +++ b/content/components/display/qspi_dbi.md @@ -13,6 +13,11 @@ params: This display driver supports AMOLED and LCD displays with quad SPI interfaces, using the MIPI DBI interface. +{{< warning >}} +This component has been made redundant since this class of displays is now supported by the {{< docref "mipi_spi" >}} +This component will be removed in a future release. +{{< /warning >}} + This driver has been tested with the following displays: - Lilygo T4-S3 diff --git a/content/components/display/rpi_dpi_rgb.md b/content/components/display/rpi_dpi_rgb.md index 3bb36756ee..7eda61b499 100644 --- a/content/components/display/rpi_dpi_rgb.md +++ b/content/components/display/rpi_dpi_rgb.md @@ -14,6 +14,11 @@ params: This display driver supports displays with 16 bit parallel interfaces, often referred to as "RPI_DPI_RGB" type. These have a parallel interface but no SPI interface and require no configuration of the driver chip. +{{< warning >}} +This component has been made redundant since this class of displays is now supported by the {{< docref "mipi_rgb" >}} +This component will be removed in a future release. +{{< /warning >}} + This driver has been tested with the following displays: - Waveshare ESP32-S3-Touch-LCD-4.3 diff --git a/content/components/display/st7735.md b/content/components/display/st7735.md index 6242c52fb2..dbe39447eb 100644 --- a/content/components/display/st7735.md +++ b/content/components/display/st7735.md @@ -19,12 +19,10 @@ It uses the [SPI Bus](#spi) for communication. {{< img src="st7735.jpg" alt="Image" caption="ST7735 Display" width="75.0%" class="align-center" >}} {{< warning >}} -This component has been made redundant since the ST7735 is now supported by the [ILI9XXX component](#ili9xxx). It is recommended -that you use the `ili9xxx` component as it will be maintained, whereas this component may not be, or may be removed completely -in the future. If migrating from this component to `ili9xxx` you may need to set the `dimensions:` option to -specify the screen size and offsets in the `ili9xxx` config. - +This component has been made redundant since the ST7735 is now supported by the {{< docref "mipi_spi" >}}. +This component will be removed in a future release. {{< /warning >}} + There are numerous board types out there. Some initialize differently as well. This driver will take a few options to narrow down the right settings. ```yaml diff --git a/content/components/lvgl/_index.md b/content/components/lvgl/_index.md index 1af601b7e4..4a5e17016c 100644 --- a/content/components/lvgl/_index.md +++ b/content/components/lvgl/_index.md @@ -37,6 +37,8 @@ display: update_interval: never ``` +{{< img src="lvgl-hello.png" width="400" >}} + To make LVGL your own you will need to add widgets to the display. For example, to show a label with the text "Hello World!" in the center of the screen: ```yaml diff --git a/content/components/lvgl/widgets.md b/content/components/lvgl/widgets.md index c8a0865d07..33da07d0fb 100644 --- a/content/components/lvgl/widgets.md +++ b/content/components/lvgl/widgets.md @@ -119,6 +119,7 @@ In addition to visual styling, each widget supports some boolean **flags** to in LVGL only supports **integers** for numeric `value`. Visualizer widgets can't display floats directly, but they allow scaling by 10s. Some examples in the {{< docref "/cookbook/lvgl" "Cookbook" >}} cover how to do that. {{< /note >}} + {{< anchor "lvgl-widget-parts" >}} ## Widget parts @@ -1575,13 +1576,15 @@ The spinbox contains a numeric value (as text) which can be increased or decreas - **range_from** (*Optional*, float): The minimum value allowed to set the spinbox to. Defaults to `0`. - **range_to** (*Optional*, float): The maximum value allowed to set the spinbox to. Defaults to `100`. - **rollover** (*Optional*, boolean): While increasing or decreasing the value, if either the minimum or maximum value is reached with this option enabled, the value will change to the other limit. If disabled, the value will remain at the minimum or maximum value. Defaults to `false`. -- **step** (*Optional*, float): The granularity with which the value can be set. Defaults to `1.0`. +- **selected_digit** (*Optional*, int): The ordinal number of the digit to be initially focused. Defaults to `0` which + represents the least significant digit. This digit will + be incremented or decremented by one when `increment` or `decrement` actions are called. - **value** (*Optional*, float): Actual value to be shown by the spinbox at start. Defaults to `0`. {{< note >}} The sign character will only be shown if the set range contains negatives. - {{< /note >}} + **Actions:** - `lvgl.spinbox.update` [action](#actions-action) updates the widget styles and properties from the specific options above, just like the [lvgl.widget.update](#lvgl-automation-actions) action is used for the common styles, states or flags. diff --git a/content/components/mapping.md b/content/components/mapping.md index 57ab2dcc9b..6722d9f123 100644 --- a/content/components/mapping.md +++ b/content/components/mapping.md @@ -3,7 +3,8 @@ description: "Mapping Component" title: "Mapping Component" --- -The `mapping` component allows you to create a map or dictionary that allows a one-to-one translation from keys to values. This enables e.g. mapping a string to a number or vice versa, or mapping a string such as a weather condition to an image. +The `mapping` component allows you to create a map or dictionary that allows a one-to-one translation from keys to +values. This enables e.g. mapping a string to a number or vice versa, or mapping a string such as a weather condition to an image. ```yaml # Example configuration entry @@ -45,7 +46,11 @@ You can also map to a class. This is useful when you want to map to a more compl ## Using a mapping -A mapping defined in this component can be used in lambdas in other components. The mapping can be accessed using the `id` function, and the value can be looked up using the `[]` operator as per the above example. +A mapping defined in this component can be used in lambdas in other components. The mapping can be accessed using +the ``id`` function, and the value can be looked up using the ``[]`` operator as per the above example, or the ``get`` function. +A map may be updated at run time using a lambda call, e.g. ``map.set("key", value)``. + +Maps are stored in RAM, but will use PSRAM if available. A more complex example follows: @@ -84,8 +89,14 @@ display: - platform: ... # update the display drawing random text in random colors lambda: |- - auto color = color_map[random_uint32() % 3]; + auto color = color_map.get(random_uint32() % 3]); # Uses get() to index the color_map it.printf(100, 100, id(roboto20), color, id(string_map)[random_uint32() % 3].c_str(), Color(0)); + + on_...: + then: + - lambda: |- + id(color_map).set(2, Color::random_color()); + ``` ## See Also diff --git a/content/components/nrf52.md b/content/components/nrf52.md index b9a9056ea5..6331692c3d 100644 --- a/content/components/nrf52.md +++ b/content/components/nrf52.md @@ -71,6 +71,32 @@ There are two ways to reference GPIO pins: 1. By pin name, e.g., `P0.15` or `P1.11`. 1. By pin number, e.g., `15` or `43`. +## DFU (Device Firmware Update) + +The ``dfu`` component enables automatic entry into **DFU (Device Firmware Update)** mode by monitoring +the USB CDC serial connection. When a host opens the port at **1200 baud**, the component triggers +a reset via a GPIO pin to put the device into DFU mode. + +ESPHome uses this component internally when uploading firmware via: + +```bash +esphome upload d.yaml +``` + +### Example Configuration + +```yaml +nrf52: + dfu: + reset_pin: + number: 14 + inverted: true +``` + +### Configuration variables + +- **reset_pin** (*Required*, [Pin](#config-pin)): The pin to use for trigger a hardware reset. This pin should be connected to the MCU's reset line or to a circuit that causes the bootloader to enter DFU mode after reset. + ## See Also - {{< docref "esphome/" >}} diff --git a/content/components/sensor/sen5x.md b/content/components/sensor/sen5x.md index 062b9312f2..a5d2b562c4 100644 --- a/content/components/sensor/sen5x.md +++ b/content/components/sensor/sen5x.md @@ -7,7 +7,13 @@ params: image: sen54.jpg --- -The `sen5x` sensor platform allows you to use your Sensirion [SEN50](https://sensirion.com/products/catalog/SEN50/), [SEN54](https://sensirion.com/products/catalog/SEN54/) and [SEN55](https://sensirion.com/products/catalog/SEN55/) Environmental sensor ([datasheet](https://sensirion.com/media/documents/6791EFA0/62A1F68F/Sensirion_Datasheet_Environmental_Node_SEN5x.pdf)) sensors with ESPHome. +The `sen5x` sensor platform allows you to use your Sensirion +[SEN50](https://sensirion.com/products/catalog/SEN50/), +[SEN54](https://sensirion.com/products/catalog/SEN54/) and +[SEN55](https://sensirion.com/products/catalog/SEN55/) Environmental sensor +([datasheet](https://sensirion.com/media/documents/6791EFA0/62A1F68F/Sensirion_Datasheet_Environmental_Node_SEN5x.pdf)) +sensors with ESPHome. + The [I²C Bus](#i2c) is required to be set up in your configuration for this sensor to work. This sensor supports both UART and I²C communication. Only I²C communication is implemented in this component. @@ -19,102 +25,109 @@ sensor: - platform: sen5x id: sen54 pm_1_0: - name: " PM <1µm Weight concentration" - id: pm_1_0 - accuracy_decimals: 1 + name: PM <1µm Weight concentration pm_2_5: - name: " PM <2.5µm Weight concentration" - id: pm_2_5 - accuracy_decimals: 1 + name: PM <2.5µm Weight concentration pm_4_0: - name: " PM <4µm Weight concentration" - id: pm_4_0 - accuracy_decimals: 1 + name: PM <4µm Weight concentration pm_10_0: - name: " PM <10µm Weight concentration" - id: pm_10_0 - accuracy_decimals: 1 + name: PM <10µm Weight concentration temperature: - name: "Temperature" - accuracy_decimals: 1 + name: Temperature humidity: - name: "Humidity" - accuracy_decimals: 0 + name: Humidity voc: - name: "VOC" - algorithm_tuning: - index_offset: 100 - learning_time_offset_hours: 12 - learning_time_gain_hours: 12 - gating_max_duration_minutes: 180 - std_initial: 50 - gain_factor: 230 - temperature_compensation: - offset: 0 - normalized_offset_slope: 0 - time_constant: 0 - acceleration_mode: low - store_baseline: true - address: 0x69 - update_interval: 10s + name: VOC + nox: + name: NOX ``` ## Configuration variables -- **pm_1_0** (*Optional*): The information for the **Weight Concentration** sensor for fine particles up to 1μm. Readings in µg/m³. +- **pm_1_0** (*Optional*): The information for the **Weight Concentration** sensor for fine particles up to 1μm. + Readings in µg/m³. - All options from [Sensor](#config-sensor). -- **pm_2_5** (*Optional*): The information for the **Weight Concentration** sensor for fine particles up to 2.5μm. Readings in µg/m³. +- **pm_2_5** (*Optional*): The information for the **Weight Concentration** sensor for fine particles up to 2.5μm. + Readings in µg/m³. - All options from [Sensor](#config-sensor). -- **pm_4_0** (*Optional*): The information for the **Weight Concentration** sensor for coarse particles up to 4μm. Readings in µg/m³. +- **pm_4_0** (*Optional*): The information for the **Weight Concentration** sensor for coarse particles up to 4μm. + Readings in µg/m³. - All options from [Sensor](#config-sensor). -- **pm_10_0** (*Optional*): The information for the **Weight Concentration** sensor for coarse particles up to 10μm. Readings in µg/m³. +- **pm_10_0** (*Optional*): The information for the **Weight Concentration** sensor for coarse particles up to 10μm. + Readings in µg/m³. - All options from [Sensor](#config-sensor). - **auto_cleaning_interval** (*Optional*): Reads/Writes the interval in seconds of the periodic fan-cleaning. -- **temperature** (*Optional*): Temperature.Note only available with Sen54 or Sen55. The sensor will be ignored on unsupported models. +- **temperature** (*Optional*): Temperature.Note only available with Sen54 or Sen55. The sensor will be ignored on + unsupported models. - All options from [Sensor](#config-sensor). -- **humidity** (*Optional*): Relative Humidity. Note only available with Sen54 or Sen55. The sensor will be ignored on unsupported models. +- **humidity** (*Optional*): Relative Humidity. Note only available with Sen54 or Sen55. The sensor will be ignored on + unsupported models. - All options from [Sensor](#config-sensor). -- **voc** (*Optional*): VOC Index. Note only available with Sen54 or Sen55. The sensor will be ignored on unsupported models. - - - **algorithm_tuning** (*Optional*): The VOC algorithm can be customized by tuning 6 different parameters. For more details see [Engineering Guidelines for SEN5x](https://sensirion.com/media/documents/25AB572C/62B463AA/Sensirion_Engineering_Guidelines_SEN5x.pdf) - - - **index_offset** (*Optional*): VOC index representing typical (average) conditions. Allowed values are in range 1..250. The default value is 100. - - **learning_time_offset_hours** (*Optional*): Time constant to estimate the VOC algorithm offset from the history in hours. Past events will be forgotten after about twice the learning time. Allowed values are in range 1..1000. The default value is 12 hour - - **learning_time_gain_hours** (*Optional*): Time constant to estimate the VOC algorithm gain from the history in hours. Past events will be forgotten after about twice the learning time. Allowed values are in range 1..1000. The default value is 12 hours. - - **gating_max_duration_minutes** (*Optional*): Maximum duration of gating in minutes (freeze of estimator during high VOC index signal). Zero disables the gating. Allowed values are in range 0..3000. The default value is 180 minutes - - **std_initial** (*Optional*): Initial estimate for standard deviation. Lower value boosts events during initial learning period, but may result in larger device-todevice variations. Allowed values are in range 10..5000. The default value is 50. - - **gain_factor** (*Optional*): Gain factor to amplify or to attenuate the VOC index output. Allowed values are in range 1..1000. The default value is 230. +- **voc** (*Optional*): VOC Index. Note only available with Sen54 or Sen55. The sensor will be ignored on + unsupported models. + + - **algorithm_tuning** (*Optional*): The VOC algorithm can be customized by tuning 6 different parameters. + For more details see [Engineering Guidelines for SEN5x](https://sensirion.com/media/documents/25AB572C/62B463AA/Sensirion_Engineering_Guidelines_SEN5x.pdf) + + - **index_offset** (*Optional*): VOC index representing typical (average) conditions. + Allowed values are in range 1..250. The default value is 100. + - **learning_time_offset_hours** (*Optional*): Time constant to estimate the VOC algorithm offset from the + history in hours. Past events will be forgotten after about twice the learning time. + Allowed values are in range 1..1000. The default value is 12 hour + - **learning_time_gain_hours** (*Optional*): Time constant to estimate the VOC algorithm gain from the history + in hours. Past events will be forgotten after about twice the learning time. + Allowed values are in range 1..1000. The default value is 12 hours. + - **gating_max_duration_minutes** (*Optional*): Maximum duration of gating in minutes (freeze of estimator + during high VOC index signal). Zero disables the gating. Allowed values are in range 0..3000. + The default value is 180 minutes + - **std_initial** (*Optional*): Initial estimate for standard deviation. Lower value boosts events during + initial learning period, but may result in larger device-to-device variations. + Allowed values are in range 10..5000. The default value is 50. + - **gain_factor** (*Optional*): Gain factor to amplify or to attenuate the VOC index output. + Allowed values are in range 1..1000. The default value is 230. - All other options from [Sensor](#config-sensor). - **nox** (*Optional*): NOx Index. Note: Only available with Sen55. The sensor will be ignored on unsupported models. - - **algorithm_tuning** (*Optional*): The NOx algorithm can be customized by tuning 5 different parameters.For more details see [Engineering Guidelines for SEN5x](https://sensirion.com/media/documents/25AB572C/62B463AA/Sensirion_Engineering_Guidelines_SEN5x.pdf) - - - **index_offset** (*Optional*): NOx index representing typical (average) conditions. Allowed values are in range 1..250. The default value is 100. - - **learning_time_offset_hours** (*Optional*): Time constant to estimate the NOx algorithm offset from the history in hours. Past events will be forgotten after about twice the learning time. Allowed values are in range 1..1000. The default value is 12 hour - - **learning_time_gain_hours** (*Optional*): Time constant to estimate the NOx algorithm gain from the history in hours. Past events will be forgotten after about twice the learning time. Allowed values are in range 1..1000. The default value is 12 hours. - - **gating_max_duration_minutes** (*Optional*): Maximum duration of gating in minutes (freeze of estimator during high NOx index signal). Zero disables the gating. Allowed values are in range 0..3000. The default value is 180 minutes - - **std_initial** (*Optional*): The initial estimate for standard deviation parameter has no impact for NOx. This parameter is still in place for consistency reasons with the VOC tuning parameters command. This parameter must always be set to 50. - - **gain_factor** (*Optional*): Gain factor to amplify or to attenuate the VOC index output. Allowed values are in range 1..1000. The default value is 230. + - **algorithm_tuning** (*Optional*): The NOx algorithm can be customized by tuning 5 different parameters. + For more details see [Engineering Guidelines for SEN5x](https://sensirion.com/media/documents/25AB572C/62B463AA/Sensirion_Engineering_Guidelines_SEN5x.pdf) + + - **index_offset** (*Optional*): NOx index representing typical (average) conditions. + Allowed values are in range 1..250. The default value is 1. + - **learning_time_offset_hours** (*Optional*): Time constant to estimate the NOx algorithm offset from the + history in hours. Past events will be forgotten after about twice the learning time. The default value is 12 hour + - **learning_time_gain_hours** (*Optional*): Time constant to estimate the NOx algorithm gain from the history + in hours. Past events will be forgotten after about twice the learning time. + Allowed values are in range 1..1000. The default value is 12 hours. + - **gating_max_duration_minutes** (*Optional*): Maximum duration of gating in minutes (freeze of estimator + during high NOx index signal). Zero disables the gating. Allowed values are in range 0..3000. + The default value is 720 minutes + - **std_initial** (*Optional*): The initial estimate for standard deviation parameter has no impact + for NOx. This parameter is still in place for consistency reasons with the VOC tuning parameters command. + This parameter must always be set to 50. + - **gain_factor** (*Optional*): Gain factor to amplify or to attenuate the VOC index output. + Allowed values are in range 1..1000. The default value is 230. - All other options from [Sensor](#config-sensor). -- **store_baseline** (*Optional*, boolean): Stores and retrieves the baseline VOC and NOx information for quicker startups. Defaults to `true` -- **temperature_compensation** (*Optional*): These parameters allow to compensate temperature effects of the design-in at customer side by applying a custom temperature offset to the ambient temperature. +- **store_baseline** (*Optional*, boolean): Stores and retrieves the baseline VOC and NOx information for + quicker startups. Defaults to `true` +- **temperature_compensation** (*Optional*): These parameters allow to compensate temperature effects of the + design-in at customer side by applying a custom temperature offset to the ambient temperature. The compensated ambient temperature is calculated as follows: @@ -122,8 +135,11 @@ sensor: T_Ambient_Compensated = T_Ambient + (slope * T_Ambient) + offset ``` - Where slope and offset are the values set with this command, smoothed with the specified time constant. The time constant is how fast the slope and offset are applied. After the specified value in seconds, 63% of the new slope and offset are applied. - More details about the tuning of these parameters are included in the application note [Temperature Acceleration and Compensation Instructions for SEN5x.](https://sensirion.com/media/documents/9B9DE2A7/61E957EB/Sensirion_Temperature_Acceleration_and_Compensation_Instructions_SEN.pdf) + Where slope and offset are the values set with this command, smoothed with the specified time constant. + The time constant is how fast the slope and offset are applied. After the specified value in seconds, + 63% of the new slope and offset are applied. + More details about the tuning of these parameters are included in the application note + [Temperature Acceleration and Compensation Instructions for SEN5x.](https://sensirion.com/media/documents/9B9DE2A7/61E957EB/Sensirion_Temperature_Acceleration_and_Compensation_Instructions_SEN.pdf) - **offset** (*Optional*): Temperature offset [°C]. Defaults to `0` - **normalized_offset_slope** (*Optional*): Normalized temperature offset slope. Defaults to `0` @@ -131,17 +147,20 @@ sensor: - **acceleration_mode** (*Optional*): Allowed value are `low`, `medium` and `high`. (default is `low` ) - By default, the RH/T acceleration algorithm is optimized for a sensor which is positioned in free air. If the sensor is integrated into another device, the ambient RH/T output values might not be optimal due to different thermal behavior. - This parameter can be used to adapt the RH/T acceleration behavior for the actual use-case, leading in an improvement of the ambient RH/T output accuracy. There is a limited set of different modes available. - Medium and high accelerations are particularly indicated for air quality monitors which are subjected to large temperature changes. Low acceleration is advised for stationary devices not subject to large variations in temperature + By default, the RH/T acceleration algorithm is optimized for a sensor which is positioned in free air. + If the sensor is integrated into another device, the ambient RH/T output values might not be optimal + due to different thermal behavior. + This parameter can be used to adapt the RH/T acceleration behavior for the actual use-case, leading in an + improvement of the ambient RH/T output accuracy. There is a limited set of different modes available. + Medium and high accelerations are particularly indicated for air quality monitors which are subjected to large + temperature changes. Low acceleration is advised for stationary devices not subject to large variations in temperature. - **address** (*Optional*, int): Manually specify the I²C address of the sensor. Defaults to `0x69`. -{{< note >}} -The sensor needs about a minute "warm-up". The VOC and NOx gas index algorithm needs a number of samples before the values stabilize. - -{{< /note >}} +> [!NOTE] +> The sensor needs about a minute "warm-up". The VOC and NOx gas index algorithm needs a +> number of samples before the values stabilize. ## Wiring @@ -149,26 +168,30 @@ The sensor has a JST GHR-06V-S 6 pin type connector, with a 1.25mm pitch. The ca {{< img src="jst6pin.png" alt="Image" width="50.0%" class="align-center" >}} -To force the sensor into I²C mode, the SEL pin (Interface Select pin no.5) must be shorted to ground (pin no.2). Pin 6 is not used. +To force the sensor into I²C mode, the SEL pin (Interface Select pin no.5) must be shorted to ground (pin no.2). +Pin 6 is not used. For better stability, the SDA and SCL lines require suitable pull-up resistors. ## Automatic Cleaning -When the module is in Measurement-Mode an automatic fan-cleaning procedure will be triggered periodically following a defined cleaning interval. This will accelerate the fan to maximum speed for 10 seconds to blow out the accumulated dust inside the fan. +When the module is in Measurement-Mode an automatic fan-cleaning procedure will be triggered periodically following +a defined cleaning interval. This will accelerate the fan to maximum speed for 10 seconds +to blow out the accumulated dust inside the fan. - Measurement values are not updated while the fan-cleaning is running. - The cleaning interval is set to 604,800 seconds (i.e., 168 hours or 1 week). - The interval can be configured using the Set Automatic Cleaning Interval command. - Set the interval to 0 to disable the automatic cleaning. - A sensor reset, resets the cleaning interval to its default value -- If the sensor is switched off, the time counter is reset to 0. Make sure to trigger a cleaning cycle at least every week if the sensor is switched off and on periodically (e.g., once per day). +- If the sensor is switched off, the time counter is reset to 0. Make sure to trigger a cleaning cycle at least + every week if the sensor is switched off and on periodically (e.g., once per day). - The cleaning procedure can also be started manually with the `start_autoclean_fan` Action -The Sen5x sensor has an automatic fan-cleaning which will accelerate the built-in fan to maximum speed for 10 seconds in order to blow out the dust accumulated inside the fan. -The default automatic-cleaning interval is 168 hours (1 week) of uninterrupted use. Switching off the sensor resets this time counter. - -{{< anchor "start_autoclean_fan_action" >}} +The Sen5x sensor has an automatic fan-cleaning which will accelerate the built-in fan to maximum speed for 10 +seconds in order to blow out the dust accumulated inside the fan. +The default automatic-cleaning interval is 168 hours (1 week) of uninterrupted use. Switching off the sensor resets +this time counter. ## `sen5x.start_fan_autoclean` Action diff --git a/content/components/sml.md b/content/components/sml.md index 869972e6aa..eaed1cb616 100644 --- a/content/components/sml.md +++ b/content/components/sml.md @@ -212,6 +212,50 @@ sensor: state_class: measurement ``` +## Reading multiple meters + +If you are reading data from more meters than your controller has UARTs available (e.g. more than two for an ESP32), you can use multiplexing to switch between reading data from different meters. + +In order to do this, after each SML update, the used UART can be set to listen to a different pin. +An example on how to do this is this: + +```yaml +uart: +- baud_rate: 9600 + data_bits: 8 + rx_pin: + number: GPIO17 # Set to the first of the GPIO pins in multiplex_pins + id: uart_multiplex_rx_pin + stop_bits: 1 + rx_buffer_size: 512 + id: uart_multiplexed + +sml: +- id: sml_multiplexed + uart_id: uart_multiplexed + on_data: + - lambda: |- + std::vector multiplex_pins = {::GPIO_NUM_17,::GPIO_NUM_19}; + static size_t current_index = 0; + current_index = (current_index + 1) % multiplex_pins.size(); + gpio_num_t new_rx_pin = multiplex_pins[current_index]; + id(uart_multiplex_rx_pin).set_pin(new_rx_pin); + id(uart_multiplexed).load_settings(true); + +sensor: +- platform: sml + name: "Solar Roof" + sml_id: sml_multiplexed + server_id: "12345ab" # IMPORTANT! Set the correct server id + obis_code: "1-0:2.8.0" + +- platform: sml + name: "Solar Carport" + sml_id: sml_multiplexed + server_id: "67890cd" # IMPORTANT! Set the correct server id + obis_code: "1-0:2.8.0" +``` + ## See Also - {{< apiref "sml/sml.h" "sml/sml.h" >}} diff --git a/content/components/time/_index.md b/content/components/time/_index.md index 0c29b2a083..1699ad7728 100644 --- a/content/components/time/_index.md +++ b/content/components/time/_index.md @@ -239,8 +239,8 @@ to an external hardware real time clock chip. Components should trigger `on_time_sync` when they update the system clock. However, not all real time components behave exactly the same. Components could e.g. decide to trigger only when a significant time change has been observed, others could trigger whenever their time sync mechanism runs - even if that didn't effectively change -the system time. Some (such as SNTP) could even trigger when another real time component is responsible for the -change in time. +the system time. Some (such as SNTP in some cases) could even trigger when another real time component is +responsible for the change in time. {{< /note >}} diff --git a/content/components/time/sntp.md b/content/components/time/sntp.md index d73deafa8e..e643cbdc88 100644 --- a/content/components/time/sntp.md +++ b/content/components/time/sntp.md @@ -27,8 +27,8 @@ If your are using [Manual IPs](#wifi-manual_ip) make sure to configure a DNS Ser {{< /note >}} {{< warning >}} -Due to limitations of the SNTP implementation, this component will trigger `on_time_sync` only once when it detects that the -system clock has been set, even if the update was not done by the SNTP implementation! +Due to limitations of the SNTP implementation, on platforms other than ESP8266 and ESP32 this component will trigger `on_time_sync` +only once when it detects that the system clock has been set, even if the update was not done by the SNTP implementation! This must be taken into consideration when SNTP is used together with other real time components, where another time source could update the time before SNTP synchronizes. diff --git a/content/images/lvgl-hello.png b/content/images/lvgl-hello.png new file mode 100644 index 0000000000..882bc0dcca Binary files /dev/null and b/content/images/lvgl-hello.png differ diff --git a/data/version.yaml b/data/version.yaml index 785152fd45..f26bd810e2 100644 --- a/data/version.yaml +++ b/data/version.yaml @@ -1,2 +1,2 @@ -release: 2025.8.3 -version: '2025.8' +release: 2025.9.0-dev +version: '2025.9' diff --git a/schema_doc.py b/schema_doc.py deleted file mode 100644 index 0486c426e9..0000000000 --- a/schema_doc.py +++ /dev/null @@ -1,1316 +0,0 @@ -from genericpath import exists -import re -import json -import urllib - -from typing import MutableMapping -from sphinx.util import logging -from docutils import nodes - -# Instructions for building -# you must have checked out this project in the same folder of -# esphome and esphome-vscode so the SCHEMA_PATH below can find the source schemas - -# This file is not processed by default as extension unless added. -# To add this extension from command line use: -# -Dextensions=github,seo,sitemap,components,schema_doc" - -# also for improve performance running old version -# -d_build/.doctrees-schema -# will put caches in another dir and not overwrite the ones without schema - -SCHEMA_PATH = "../schema/" -CONFIGURATION_VARIABLES = "Configuration variables:" -CONFIGURATION_OPTIONS = "Configuration options:" -PIN_CONFIGURATION_VARIABLES = "Pin configuration variables:" -COMPONENT_HUB = "Component/Hub" - -JSON_DUMP_PRETTY = True - - -class Statistics: - props_documented = 0 - enums_good = 0 - enums_bad = 0 - - -statistics = Statistics() - -logger = logging.getLogger(__name__) - - -def setup(app): - import os - - if not os.path.isfile(SCHEMA_PATH + "esphome.json"): - logger.info(f"{SCHEMA_PATH} not found. Not documenting schema.") - return - - app.connect("doctree-resolved", doctree_resolved) - app.connect("build-finished", build_finished) - app.files = {} - - return {"version": "1.0.0", "parallel_read_safe": True, "parallel_write_safe": True} - - -def find_platform_component(app, platform, component): - file_data = get_component_file(app, component) - return file_data[f"{component}.{platform}"]["schemas"]["CONFIG_SCHEMA"] - - -def doctree_resolved(app, doctree, docname): - if docname == "components/index": - # nothing useful here - return - handle_component(app, doctree, docname) - - -PLATFORMS_TITLES = { - "Alarm Control Panel": "alarm_control_panel", - "Binary Sensor": "binary_sensor", - "Button": "button", - "CAN Bus": "canbus", - "Climate": "climate", - "Base Datetime Configuration": "datetime", - "Cover": "cover", - "Event": "event", - "Fan": "fan", - "I²C": "i2c", - "Lock": "lock", - "Media Player": "media_player", - "Microphone": "microphone", - "Number": "number", - "Output": "output", - "Select": "select", - "Sensor": "sensor", - "Speaker": "speaker", - "Stepper": "stepper", - "Switch": "switch", - "Text Sensor": "text_sensor", -} - -CUSTOM_DOCS = { - "automations/actions": {}, - # audio adc and audio dac needed because they don't define CONFIG_SCHEMA but they document it - "components/audio_adc/index": { - "Audio ADC Core": ["audio_adc.__IGNORE_SCHEMA"], - }, - "components/audio_dac/index": { - "Audio DAC Core": ["audio_dac.__IGNORE_SCHEMA"], - }, - "components/binary_sensor/index": { - "Binary Sensor Filters": "binary_sensor.registry.filter", - }, - "components/canbus": { - "_LoadSchema": False, - "Base CAN Bus Configuration": "canbus.schemas.CANBUS_SCHEMA", - }, - "components/climate/climate_ir": {"_LoadSchema": False, "IR Remote Climate": []}, - "components/display/index": { - "Images": "image.schemas.CONFIG_SCHEMA", - "Fonts": "font.schemas.CONFIG_SCHEMA", - "Color": "color.schemas.CONFIG_SCHEMA", - "Animation": "animation.schemas.CONFIG_SCHEMA", - }, - "components/globals": { - "Global Variables": "globals.schemas.CONFIG_SCHEMA", - }, - "components/light/index": { - "Base Light Configuration": [ - "light.schemas.ADDRESSABLE_LIGHT_SCHEMA", - "light.schemas.BINARY_LIGHT_SCHEMA", - "light.schemas.BRIGHTNESS_ONLY_LIGHT_SCHEMA", - "light.schemas.LIGHT_SCHEMA", - ], - "Light Effects": "light.registry.effects", - }, - "components/light/fastled": { - "_LoadSchema": False, - "Clockless": "fastled_clockless.platform.light.schemas.CONFIG_SCHEMA", - "SPI": "fastled_spi.platform.light.schemas.CONFIG_SCHEMA", - }, - "components/binary_sensor/ttp229": { - "_LoadSchema": False, - }, - "components/mcp230xx": { - "_LoadSchema": False, - PIN_CONFIGURATION_VARIABLES: "mcp23xxx.pin", - }, - "components/mqtt": { - "MQTT Component Base Configuration": "core.schemas.MQTT_COMMAND_COMPONENT_SCHEMA", - "MQTTMessage": "mqtt.schemas.MQTT_MESSAGE_BASE", - }, - "components/one_wire": { - "1-Wire Bus": ["one_wire.schemas"], - "GPIO": "gpio.platform.one_wire.schemas.CONFIG_SCHEMA", - }, - "components/ota/index": { - "Over-the-Air Updates": "ota.schemas.BASE_OTA_SCHEMA", - }, - "components/output/index": { - "Base Output Configuration": "output.schemas.FLOAT_OUTPUT_SCHEMA", - }, - "components/packet_transport/index": { - "Packet Transport Component": "packet_transport.schemas.TRANSPORT_SCHEMA", - }, - "components/remote_transmitter": { - "Remote Transmitter Actions": "remote_base.schemas.BASE_REMOTE_TRANSMITTER_SCHEMA", - }, - "components/sensor/index": { - "Sensor Filters": "sensor.registry.filter", - }, - "components/time": { - "_LoadSchema": False, - "Base Time Configuration": "time.schemas.TIME_SCHEMA", - "on_time Trigger": "time.schemas.TIME_SCHEMA.schema.config_vars.on_time.schema", - "Home Assistant Time Source": "homeassistant.platform.time.schemas.CONFIG_SCHEMA", - "SNTP Time Source": "sntp.platform.time.schemas.CONFIG_SCHEMA", - "GPS Time Source": "gps.platform.time.schemas.CONFIG_SCHEMA", - "DS1307 Time Source": "ds1307.platform.time.schemas.CONFIG_SCHEMA", - }, - "components/wifi": { - "Connecting to Multiple Networks": "wifi.schemas.CONFIG_SCHEMA.schema.config_vars.networks.schema", - "Enterprise Authentication": "wifi.schemas.EAP_AUTH_SCHEMA", - }, - "components/esp32": { - "Arduino framework": "esp32.schemas.CONFIG_SCHEMA.schema.config_vars.framework.types.arduino", - "ESP-IDF framework": "esp32.schemas.CONFIG_SCHEMA.schema.config_vars.framework.types.esp-idf", - }, - "components/sensor/airthings_ble": { - "_LoadSchema": False, - }, - "components/sensor/radon_eye_ble": { - "_LoadSchema": False, - }, - "components/sensor/xiaomi_ble": { - "_LoadSchema": False, - }, - "components/sensor/xiaomi_miscale2": { - "_LoadSchema": False, - }, - "components/mcp23Sxx": { - "_LoadSchema": False, - }, - "components/display/lcd_display": {"_LoadSchema": False}, - "components/display/ssd1306": {"_LoadSchema": False}, - "components/display/ssd1322": {"_LoadSchema": False}, - "components/display/ssd1325": {"_LoadSchema": False}, - "components/display/ssd1327": {"_LoadSchema": False}, - "components/display/ssd1351": {"_LoadSchema": False}, - "components/copy": {"_LoadSchema": False}, - "components/display_menu/index": { - "Display Menu": "display_menu_base.schemas.DISPLAY_MENU_BASE_SCHEMA", - "Select": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.select", - "Menu": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.menu", - "Number": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.number", - "Switch": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.switch", - "Custom": "display_menu_base.schemas.MENU_TYPES.schema.config_vars.items.types.custom", - }, - "components/display_menu/lcd_menu": { - "LCD Menu": "lcd_menu.schemas.CONFIG_SCHEMA", - }, - "components/alarm_control_panel/index": { - "Base Alarm Control Panel Configuration": "template.alarm_control_panel.schemas.CONFIG_SCHEMA", - }, - "components/vbus": { - "custom VBus sensors": "vbus.platform.sensor.schemas.CONFIG_SCHEMA.types.custom", - "custom VBus binary sensors": "vbus.platform.binary_sensor.schemas.CONFIG_SCHEMA.types.custom", - }, - "components/spi": { - "Generic SPI device component:": "spi_device.schemas.CONFIG_SCHEMA" - }, - "components/libretiny": {"LibreTiny Platform": "bk72xx.schemas.CONFIG_SCHEMA"}, - "guides/configuration-types": { - "Pin Schema": [ - "esp32.pin.schema", - "esp8266.pin.schema", - ], - }, -} - -REQUIRED_OPTIONAL_TYPE_REGEX = ( - r"(\(((\*\*(Required|Exclusive)\*\*)|(\*Optional\*))(,\s(.*))*)\):\s" -) - - -def get_node_title(node): - return list(node.traverse(nodes.title))[0].astext() - - -def read_file(fileName): - f = open(SCHEMA_PATH + fileName + ".json", "r", encoding="utf-8-sig") - str = f.read() - return json.loads(str) - - -def is_config_vars_title(title_text): - return title_text == CONFIGURATION_VARIABLES or title_text == CONFIGURATION_OPTIONS - - -class SchemaGeneratorVisitor(nodes.NodeVisitor): - def __init__(self, app, doctree, docname): - nodes.NodeVisitor.__init__(self, doctree) - self.app = app - self.doctree = doctree - self.docname = docname - self.path = docname.split("/") - self.json_component = None - self.props = None - self.platform = None - self.json_platform_component = None - self.title_id = None - self.props_section_title = None - self.find_registry = None - self.component = None - self.section_level = 0 - self.file_schema = None - self.custom_doc = CUSTOM_DOCS.get(docname) - if self.path[0] == "components": - if len(self.path) == 2: # root component, e.g. dfplayer, logger - self.component = docname[11:] - if not self.custom_doc or self.custom_doc.get("_LoadSchema", True): - self.file_schema = get_component_file(app, self.component) - schemas = self.file_schema[self.component]["schemas"] - # e.g. one_wire has no CONFIG_SCHEMA - self.json_component = schemas.get("CONFIG_SCHEMA") - elif self.path[1] == "display_menu": # weird folder naming - if self.path[2] == "index": - # weird component name mismatch - self.component = "display_menu_base" - else: - self.component = self.path[2] - - self.file_schema = get_component_file(app, self.component) - self.json_component = self.file_schema[self.component]["schemas"][ - "CONFIG_SCHEMA" - ] - - else: # sub component, e.g. output/esp8266_pwm - # components here might have a core / hub, eg. dallas, ads1115 - # and then they can be a binary_sensor, sensor, etc. - self.platform = self.path[1] - self.component = self.path[2] - - if self.component == "ssd1331": - self.component = "ssd1331_spi" - - if not self.custom_doc or self.custom_doc.get("_LoadSchema", True): - if self.component == "index": - # these are e.g. sensor, binary sensor etc. - self.component = self.platform.replace(" ", "_").lower() - self.file_schema = get_component_file(app, self.component) - self.json_component = self.file_schema[self.component][ - "schemas" - ].get(self.component.upper() + "_SCHEMA") - else: - self.json_component = get_component_file(app, self.component) - self.json_platform_component = find_platform_component( - app, self.platform, self.component - ) - - self.previous_title_text = "No title" - - self.is_component_hub = False - - # used in custom_docs when titles are mapped to array of components, this - # allows for same configuration text be applied to different json schemas - self.multi_component = None - - # a stack for props, used when there are nested props to save high level props. - self.prop_stack = [] - - # The prop just filled in, used when there are nested props and need to know which - # want to dig - self.current_prop = None - - # self.filled_props used to know when any prop is added to props, - # we dont invalidate props on exiting bullet lists but just when entering a new title - self.filled_props = False - - # Found a Configuration variables: heading, this is to increase docs consistency - self.accept_props = False - - self.bullet_list_level = 0 - - def set_component_description(self, description, componentName, platformName=None): - if platformName is not None: - platform = get_component_file(self.app, platformName) - platform[platformName]["components"][componentName.lower()]["docs"] = ( - description - ) - else: - core = get_component_file(self.app, "esphome")["core"] - if componentName in core["components"]: - core["components"][componentName]["docs"] = description - elif componentName in core["platforms"]: - core["platforms"][componentName]["docs"] = description - else: - if componentName != "display_menu_base": - raise ValueError( - "Cannot set description for component " + componentName - ) - - def visit_document(self, node): - # ESPHome page docs follows strict formatting guidelines which allows - # for docs to be parsed directly into yaml schema - - if self.docname in ["components/sensor/binary_sensor_map"]: - # temporarily not supported - raise nodes.SkipChildren - - if self.docname in ["components/climate/climate_ir"]: - # not much to do on the visit to the document, component will be found by title - return - - if len(list(node.traverse(nodes.paragraph))) == 0: - # this is empty, not much to do - raise nodes.SkipChildren - - self.props_section_title = get_node_title(node) - - # Document first paragraph is description of this thing - description = self.getMarkdownParagraph(node) - - if self.json_platform_component: - self.set_component_description(description, self.component, self.platform) - elif self.json_component: - self.set_component_description(description, self.component) - - # for most components / platforms get the props, this allows for a less restrictive - # first title on the page - if self.json_component or self.json_platform_component: - if is_component_file( - self.app, - self.component, - ): - self.props = self.find_props( - ( - self.json_platform_component - if self.json_platform_component - else self.json_component - ), - True, - ) - - def visit_table(self, node): - if ( - self.docname == "components/climate/climate_ir" - and len(CUSTOM_DOCS["components/climate/climate_ir"]["IR Remote Climate"]) - == 0 - ): - # figure out multi components from table - table_rows = node[0][4] - for row in table_rows: - components_paths = [ - components + ".platform.climate.schemas.CONFIG_SCHEMA" - for components in row[1].astext().split("\n") - ] - CUSTOM_DOCS["components/climate/climate_ir"]["IR Remote Climate"] += ( - components_paths - ) - - def depart_document(self, node): - pass - - def visit_section(self, node): - self.section_level += 1 - section_title = get_node_title(node) - if self.custom_doc and section_title in self.custom_doc: - r = self.custom_doc[section_title] - if ".registry." in r: - self.find_registry = r - - def depart_section(self, node): - self.section_level -= 1 - if self.section_level == 1: - self.find_registry = None - - def unknown_visit(self, node): - pass - - def unknown_departure(self, node): - pass - - def visit_title(self, node): - title_text = node.astext() - if self.custom_doc is not None and title_text in self.custom_doc: - if isinstance(self.custom_doc[title_text], list): - self.multi_component = self.custom_doc[title_text] - self.filled_props = False - self.props = None - - desc = self.getMarkdownParagraph(node.parent) - for c in self.multi_component: - if len(c.split(".")) == 2: - self.set_component_description(desc, c.split(".")[0]) - - return - else: - self.multi_component = None - - json_component = self.find_component(self.custom_doc[title_text]) - if not json_component: - return - if self.json_component is None: - self.json_component = json_component - - parts = self.custom_doc[title_text].split(".") - if parts[0] not in ["core", "remote_base"] and parts[-1] != "pin": - if parts[1] == "platform": - self.set_component_description( - self.getMarkdownParagraph(node.parent), parts[0], parts[2] - ) - else: - self.set_component_description( - self.getMarkdownParagraph(node.parent), - parts[0], - ) - self.props_section_title = title_text - self.props = self.find_props(json_component) - - return - - elif title_text == COMPONENT_HUB: - # here comes docs for the component, make sure we have props of the component - # Needed for e.g. ads1115 - self.props_section_title = f"{self.path[-1]} {title_text}" - json_component = self.get_component_schema( - self.path[-1] + ".CONFIG_SCHEMA" - ).get("schema", {}) - if json_component: - self.props = self.find_props(json_component) - - self.set_component_description( - self.getMarkdownParagraph(node.parent), self.path[-1] - ) - - # mark this to retrieve components instead of platforms - self.is_component_hub = True - - elif is_config_vars_title(title_text): - if not self.props and self.multi_component is None: - raise ValueError( - f'Found a "{title_text}": title after {self.previous_title_text}. Unknown object.' - ) - - elif title_text == "Over SPI" or title_text == "Over I²C": - suffix = "_spi" if "SPI" in title_text else "_i2c" - - # these could be platform components, like the display's ssd1306 - # but also there are components which are component/hub - # and there are non platform components with the SPI/I2C versions, - # like pn532, those need to be marked with the 'Component/Hub' title - component = self.path[-1] + suffix - - self.props_section_title = self.path[-1] + " " + title_text - - if self.platform is not None and not self.is_component_hub: - json_platform_component = find_platform_component( - self.app, self.platform, component - ) - if not json_platform_component: - raise ValueError( - f"Cannot find platform {self.platform} component '{component}' after found title: '{title_text}'." - ) - self.props = self.find_props(json_platform_component) - - # Document first paragraph is description of this thing - json_platform_component["docs"] = self.getMarkdownParagraph(node.parent) - - else: - json_component = self.get_component_schema( - component + ".CONFIG_SCHEMA" - ).get("schema", {}) - if not json_component: - raise ValueError( - f"Cannot find component '{component}' after found title: '{title_text}'." - ) - self.props = self.find_props(json_component) - - # Document first paragraph is description of this thing - self.set_component_description( - self.getMarkdownParagraph(node.parent), component - ) - - # Title is description of platform component, those ends with Sensor, Binary Sensor, Cover, etc. - elif ( - len( - list( - filter( - lambda x: title_text.endswith(x), list(PLATFORMS_TITLES.keys()) - ) - ) - ) - > 0 - ): - if title_text in PLATFORMS_TITLES: - # this omits the name of the component, but we know the platform - platform_name = PLATFORMS_TITLES[title_text] - if self.path[-1] == "index": - component_name = self.path[-2] - else: - component_name = self.path[-1] - self.props_section_title = component_name + " " + title_text - else: - # # title first word is the component name - # component_name = title_text.split(" ")[0] - # # and the rest is the platform - # platform_name = PLATFORMS_TITLES.get( - # title_text[len(component_name) + 1 :] - # ) - # if not platform_name: - # # Some general title which does not locate a component directly - # return - # self.props_section_title = title_text - - for t in sorted(PLATFORMS_TITLES, key=len, reverse=True): - if title_text.endswith(t): - component_name = title_text[ - 0 : len(title_text) - len(t) - 1 - ].replace(" ", "_") - platform_name = PLATFORMS_TITLES[t] - break # this matches Binary Sensor first than Sensor as PLATFORMS_TITLE is sorted - - if not platform_name: - # Some general title which does not locate a component directly - return - self.props_section_title = title_text - if not is_component_file(self.app, component_name): - return - - c = find_platform_component(self.app, platform_name, component_name.lower()) - if c: - self.json_platform_component = c - self.set_component_description( - self.getMarkdownParagraph(node.parent), - component_name, - platform_name, - ) - - # Now fill props for the platform element - try: - self.props = self.find_props(self.json_platform_component) - except KeyError as exc: - raise ValueError("Cannot find platform props") from exc - - elif title_text.endswith("Component") or title_text.endswith("Bus"): - # if len(path) == 3 and path[2] == 'index': - # # skip platforms index, e.g. sensors/index - # continue - split_text = title_text.split(" ") - self.props_section_title = title_text - - # some components are several components in a single platform doc - # e.g. ttp229 binary_sensor has two different named components. - component_name = ( - "_".join(split_text[:-1]).lower().replace(".", "").replace("i²c", "i2c") - ) - - if component_name != self.platform and is_component_file( - self.app, component_name - ): - f = get_component_file(self.app, component_name) - - # Document first paragraph is description of this thing - description = self.getMarkdownParagraph(node.parent) - - if component_name in f: - self.set_component_description(description, component_name) - - c = f[component_name] - if c: - self.json_component = c["schemas"]["CONFIG_SCHEMA"] - try: - self.props = self.find_props(self.json_component) - self.multi_component = None - except KeyError as exc: - raise ValueError( - "Cannot find props for component " + component_name - ) from exc - return - - # component which are platforms in doc, used by: stepper and canbus, lcd_pcf8574 - elif f"{component_name}.{self.path[1]}" in f: - self.set_component_description( - description, component_name, self.path[1] - ) - self.json_platform_component = f[ - f"{component_name}.{self.path[1]}" - ]["schemas"]["CONFIG_SCHEMA"] - try: - self.props = self.find_props(self.json_platform_component) - - except KeyError as exc: - raise ValueError( - f"Cannot find props for platform {self.path[1]} component {component_name}" - ) from exc - return - - elif title_text.endswith("Trigger"): - # Document first paragraph is description of this thing - description = self.getMarkdownParagraph(node.parent) - split_text = title_text.split(" ") - if len(split_text) != 2: - return - key = split_text[0] - - if ( - not self.props or not self.props.typed - ): # props are right for typed components so far - c = self.json_component - if c: - if self.component in c: - c = c[self.component]["schemas"][ - self.component.upper() + "_SCHEMA" - ] - trigger_schema = self.find_props(c).get(key) - if trigger_schema is not None: - self.props = self.find_props(trigger_schema) - self.props_section_title = title_text - - elif title_text == PIN_CONFIGURATION_VARIABLES: - self.component = self.find_component(self.path[-1] + ".pin") - self.props = self.find_props(self.component) - self.accept_props = True - if not self.component: - raise ValueError( - f'Found a "{PIN_CONFIGURATION_VARIABLES}" entry but could not find pin schema' - ) - - elif title_text.endswith("Action") or title_text.endswith("Condition"): - # Document first paragraph is description of this thing - description = self.getMarkdownParagraph(node.parent) - split_text = title_text.split(" ") - if len(split_text) != 2: - return - key = split_text[0] - - component_parts = split_text[0].split(".") - if len(component_parts) == 3: - try: - cv = get_component_file(self.app, component_parts[1])[ - component_parts[1] + "." + component_parts[0] - ][split_text[1].lower()][component_parts[2]] - except KeyError: - logger.warning( - f"In {self.docname} cannot found schema of {title_text}" - ) - cv = None - if cv is not None: - cv["docs"] = description - self.props = self.find_props(cv.get("schema", {})) - elif len(component_parts) == 2: - registry_name = ".".join( - [component_parts[0], "registry", split_text[1].lower()] - ) - key = component_parts[1] - self.find_registry_prop(registry_name, key, description) - else: - registry_name = f"core.registry.{split_text[1].lower()}" - # f"automation.{split_text[1].upper()}_REGISTRY" - self.find_registry_prop(registry_name, key, description) - - if self.section_level == 3 and self.find_registry: - name = title_text - if name.endswith(" Effect"): - name = title_text[: -len(" Effect")] - if name.endswith(" Light"): - name = name[: -len(" Light")] - key = name.replace(" ", "_").replace(".", "").lower() - description = self.getMarkdownParagraph(node.parent) - self.find_registry_prop(self.find_registry, key, description) - self.props_section_title = title_text - - def get_component_schema(self, name): - parts = name.split(".") - schema_file = get_component_file(self.app, parts[0]) - if parts[1] == "registry": - schema = schema_file.get(parts[0], {}).get(parts[2], {}) - elif len(parts) == 3: - schema = ( - schema_file.get(f"{parts[0]}.{parts[1]}") - .get("schemas", {}) - .get(parts[2], {}) - ) - else: - schema = schema_file.get(parts[0], {}).get("schemas", {}).get(parts[1], {}) - return schema - - def get_component_config_var(self, name, key): - c = self.get_component_schema(name) - if key in c: - return c[key] - if "config_vars" not in c: - return c - if key in c["config_vars"]: - return c["config_vars"][c] - - def find_registry_prop(self, registry_name, key, description): - c = self.get_component_schema(registry_name) - if key in c: - cv = c[key] - if cv is not None: - cv["docs"] = description - self.props = self.find_props(cv.get("schema", {})) - - def depart_title(self, node): - if self.filled_props: - self.filled_props = False - self.props = None - self.current_prop = None - self.accept_props = False - self.multi_component = None - self.previous_title_text = node.astext() - self.title_id = node.parent["ids"][0] - - def find_props_previous_title(self, fail_silently=False): - comp = self.json_component or self.json_platform_component - if comp: - props = self.find_props(comp, fail_silently) - - if self.previous_title_text in props: - prop = props[self.previous_title_text] - if prop: - self.props = self.find_props(prop, fail_silently) - else: - # return fake dict so better errors are printed - if fail_silently: - return # do not lose original props - self.props = {"__": "none"} - - def visit_Text(self, node): - if self.multi_component: - return - if is_config_vars_title(node.astext()): - if not self.props: - self.find_props_previous_title() - if not self.props: - raise ValueError( - f'Found a "{node.astext()}" entry for unknown object after {self.previous_title_text}' - ) - self.accept_props = True - - raise nodes.SkipChildren - - def depart_Text(self, node): - pass - - def visit_paragraph(self, node): - if is_config_vars_title(node.astext()): - if not self.props and not self.multi_component: - self.find_props_previous_title() - if not self.props and not self.multi_component: - logger.info( - f"In {self.docname} / {self.previous_title_text} found a {node.astext()} title and there are no props." - ) - # raise ValueError( - # f'Found a "{node.astext()}" entry for unknown object after {self.previous_title_text}' - # ) - self.accept_props = True - - raise nodes.SkipChildren - - def depart_paragraph(self, node): - pass - - def visit_bullet_list(self, node): - self.bullet_list_level = self.bullet_list_level + 1 - if ( - self.current_prop - and (self.props or self.multi_component) - and self.bullet_list_level > 1 - ): - self.prop_stack.append((self.current_prop, node)) - self.accept_props = True - return - - if not self.props and self.multi_component is None: - raise nodes.SkipChildren - - def depart_bullet_list(self, node): - self.bullet_list_level = self.bullet_list_level - 1 - if len(self.prop_stack) > 0: - stack_prop, stack_node = self.prop_stack[-1] - if stack_node == node: - self.prop_stack.pop() - self.filled_props = True - self.current_prop = stack_prop - - def visit_list_item(self, node): - if self.accept_props and self.props: - self.filled_props = True - self.current_prop, found = self.update_prop(node, self.props) - if self.current_prop and not found: - self.find_props_previous_title(True) - self.current_prop, found = self.update_prop(node, self.props) - if self.current_prop and not found: - logger.info( - f"In '{self.docname} {self.previous_title_text} Cannot find property {self.current_prop}" - ) - - elif self.multi_component: - # update prop for each component - found_any = False - self.current_prop = None - for c in self.multi_component: - if c.endswith("__IGNORE_SCHEMA"): - continue - props = self.find_props(self.find_component(c)) - self.current_prop, found = self.update_prop(node, props) - if self.current_prop and found: - found_any = True - if self.current_prop and not found_any: - logger.info( - f"In '{self.docname} {self.previous_title_text} Cannot find property {self.current_prop}" - ) - self.filled_props = True - - def depart_list_item(self, node): - pass - - def visit_literal(self, node): - raise nodes.SkipChildren - - def depart_literal(self, node): - pass - - def getMarkdown(self, node): - from markdown import Translator - - t = Translator( - urllib.parse.urljoin(self.app.config.html_baseurl, self.docname + ".html"), - self.doctree, - ) - node.walkabout(t) - return t.output.strip("\n") - - def getMarkdownParagraph(self, node): - paragraph = list(node.traverse(nodes.paragraph))[0] - markdown = self.getMarkdown(paragraph) - - param_type = None - # Check if there is type information for this item - try: - name_type = markdown[: markdown.index(": ") + 2] - ntr = re.search( - REQUIRED_OPTIONAL_TYPE_REGEX, - name_type, - re.IGNORECASE, - ) - if ntr: - param_type = ntr.group(6) - if param_type: - markdown = ( - f"**{param_type}**: {markdown[markdown.index(': ') + 2 :]}" - ) - except ValueError: - # ': ' not found - pass - - title = list(node.traverse(nodes.title))[0] - if len(title) > 0: - url = urllib.parse.urljoin( - self.app.config.html_baseurl, - self.docname + ".html#" + title.parent["ids"][0], - ) - if ( - self.props_section_title is not None - and self.props_section_title.endswith(title.astext()) - ): - markdown += f"\n\n*See also: [{self.props_section_title}]({url})*" - else: - markdown += f"\n\n*See also: [{self.getMarkdown(title)}]({url})*" - - return markdown - - def update_prop(self, node, props): - prop_name = None - - for s_prop, n in self.prop_stack: - inner = props.get(s_prop) - if inner is not None and "schema" in inner: - props = self.Props(self, inner["schema"]) - elif inner is not None and inner.get("type") == "typed": - # this is used in external_components - props = self.Props(self, inner) - elif inner is not None and inner.get("type") == "enum": - enum_raw = self.getMarkdown(node) - # the regex allow the format to have either a ":" or a " -" as the value / docs separator, value must be in `back ticks` - # also description is optional - enum_match = re.search( - r"\* `([^`]*)`((:| -) (.*))*", enum_raw, re.IGNORECASE - ) - if enum_match: - enum_value = enum_match.group(1) - enum_docs = enum_match.group(4) - found = False - for name in inner["values"]: - if enum_value.upper().replace(" ", "_") == str(name).upper(): - found = True - if enum_docs: - enum_docs = enum_docs.strip() - if inner["values"][name] is None: - inner["values"][name] = {"docs": enum_docs} - else: - inner["values"][name]["docs"] = enum_docs - statistics.props_documented += 1 - statistics.enums_good += 1 - if not found: - logger.info( - f"In '{self.docname} {self.previous_title_text} Property {s_prop} cannot find enum value {enum_value}" - ) - else: - statistics.enums_bad += 1 - logger.info( - f"In '{self.docname} {self.previous_title_text} Property {s_prop} unexpected enum member description format" - ) - - else: - # nothing to do? - return prop_name, False - - raw = node.rawsource # this has the full raw rst code for this property - - if not raw.startswith("**"): - # not bolded, most likely not a property definition, - # usually texts like 'All properties from...' etc - return prop_name, False - - markdown = self.getMarkdown(node) - - markdown += f"\n\n*See also: [{self.props_section_title}]({urllib.parse.urljoin(self.app.config.html_baseurl, self.docname + '.html#' + self.title_id)})*" - - try: - name_type = markdown[: markdown.index(": ") + 2] - except ValueError: - logger.info( - f"In '{self.docname} {self.previous_title_text} Property format error. Missing ': ' in {raw}'" - ) - return prop_name, False - - # Example properties formats are: - # **prop_name** (**Required**, string): Long Description... - # **prop_name** (*Optional*, string): Long Description... Defaults to ``value``. - # **prop_name** (*Optional*): Long Description... Defaults to ``value``. - # **prop_name** can be a list of names separated by / e.g. **name1/name2** (*Optional*) see climate/pid/ threshold_low/threshold_high - - PROP_NAME_REGEX = r"\*\*(\w*(?:/\w*)*)\*\*" - - FULL_ITEM_PROP_NAME_TYPE_REGEX = ( - r"\* " + PROP_NAME_REGEX + r"\s" + REQUIRED_OPTIONAL_TYPE_REGEX - ) - - ntr = re.search( - FULL_ITEM_PROP_NAME_TYPE_REGEX, - name_type, - re.IGNORECASE, - ) - - if ntr: - prop_name = ntr.group(1) - param_type = ntr.group(8) - else: - s2 = re.search( - FULL_ITEM_PROP_NAME_TYPE_REGEX, - markdown, - re.IGNORECASE, - ) - if s2: - # this is e.g. when a property has a list inside, and the list inside are the options. - # just validate **prop_name** - s3 = re.search(r"\* " + PROP_NAME_REGEX + r"*:\s", name_type) - if s3 is not None: - prop_name = s3.group(1) - else: - logger.info( - f"In '{self.docname}.rst:{node.children[0].line} {self.previous_title_text} Invalid list format: {node.rawsource}" - ) - param_type = None - else: - if "(*Deprecated*)" not in node.rawsource: - logger.info( - f"In '{self.docname}.rst:{node.children[0].line} {self.previous_title_text} Invalid property format: {node.rawsource}" - ) - return prop_name, False - - prop_names = str(prop_name) - for k in prop_names.split("/"): - config_var = props.get(k) - - if not config_var: - # Create docs for common properties when descriptions are overridden - # in the most specific component. - - if k in [ - "id", - "name", - "internal", - # i2c - "address", - "i2c_id", - # polling component - "update_interval", - # uart - "uart_id", - # light - "effects", - "gamma_correct", - "default_transition_length", - "flash_transition_length", - "color_correct", - # display - "lambda", - "pages", - "rotation", - # spi - "spi_id", - "cs_pin", - # output (binary/float output) - "inverted", - "power_supply", - # climate - "receiver_id", - ]: - config_var = props[k] = {} - else: - if self.path[1] == "esphome" and k in [ - # deprecated esphome - "platform", - "board", - "arduino_version", - "esp8266_restore_from_flash", - ]: - return prop_name, True - return prop_name, False - - desc = markdown[markdown.index(": ") + 2 :].strip() - if param_type: - desc = "**" + param_type + "**: " + desc - - config_var["docs"] = desc - - statistics.props_documented += 1 - - return prop_name, True - - def find_component(self, component_path): - path = component_path.split(".") - file_content = get_component_file(self.app, path[0]) - - if path[1] == "platform": - path[2] = f"{path[0]}.{path[2]}" - path = path[2:] - - component = file_content - for p in path: - component = component.get(p, {}) - - return component - - class Props(MutableMapping): - """Smarter props dict. - - Props are mostly a dict, however some constructs have two issues: - - An update is intended on an element which does not own a property, but it is based - on an schema that does have the property, those cases are handled by examining the extended - - """ - - def __init__(self, visitor, component, fail_silently=False): - self.visitor = visitor - self.component = component - self.store = self._get_props(component, fail_silently) - self.parent = None - self.typed = self.component.get("type") == "typed" - - def _get_props(self, component, fail_silently): - # component is a 'schema' dict which has 'config_vars' and 'extends' - if not ( - "config_vars" in component - or "extends" in component - or len(component) == 0 - or component.get("type") == "typed" - ): - if fail_silently: - return None - raise ValueError("Unexpected component data to get props") - - props = component.get("config_vars") - return props - - def _find_extended(self, component, key): - for extended in component.get("extends", []): - c = self.visitor.get_component_schema(extended) - if c.get("type") == "typed": - p = self.visitor.Props(self.visitor, c) - return p[key] - schema = c.get("schema", {}) - for k, cv in schema.get("config_vars", {}).items(): - if k == key: - return SetObservable( - cv, - setitem_callback=self._set_extended, - inner_key=key, - original_dict=schema.get("config_vars"), - ) - ex1 = self._find_extended(schema, key) - if ex1: - return ex1 - - def _set_extended(self, inner_key, original_dict, key, value): - original_dict[inner_key][key] = value - - def _iter_extended(self, component): - for extended in component.get("extends", []): - schema = self.visitor.get_component_schema(extended).get("schema", {}) - for s in self._iter_extended(schema): - yield s - yield schema - - def __getitem__(self, key): - if self.store and key in self.store: - return self.store[key] - - extended = self._find_extended(self.component, key) - if extended is not None: - return extended - - if self.component.get("type") == "typed": - return SetObservable( - {key: {"type": "string"}}, - setitem_callback=self._set_typed, - inner_key=key, - original_dict={}, - ) - - def _set_typed(self, inner_key, original_dict, key, value): - if inner_key == self.component.get("typed_key", "type"): - self.component[key] = value - else: - for tk, tv in self.component["types"].items(): - for cv_k, cv_v in tv["config_vars"].items(): - if cv_k == inner_key: - cv_v[key] = value - - def __setitem__(self, key, value): - self.store[key] = value - - def __delitem__(self, key): - self.store.pop(key) - - def __iter__(self): - return iter(self.store) - - def __len__(self): - len_extended = 0 - - if self.component.get("type"): - types = self.component.get("types") - for t, tv in types.items(): - for s in self._iter_extended(types.get(t, {})): - len_extended += len(s.get("config_vars", {})) - len_extended += len(tv.get("config_vars", {})) - return len_extended - - for s in self._iter_extended(self.component): - len_extended += len(s.get("config_vars", {})) - return len_extended + (len(self.store) if self.store else 0) - - def find_props(self, component, fail_silently=False): - if component.get("type") in ["trigger", "schema"]: - # can have schema - if "schema" not in component: - return None - component = component.get("schema") - - props = self.Props(self, component, fail_silently) - - if props: - self.filled_props = False - self.accept_props = False - self.current_prop = None - - return props - - -def handle_component(app, doctree, docname): - path = docname.split("/") - if path[0] == "components": - pass - elif docname not in CUSTOM_DOCS: - return - - try: - v = SchemaGeneratorVisitor(app, doctree, docname) - doctree.walkabout(v) - except Exception as e: - err_str = f"In {docname}.rst: {str(e)}" - # if you put a breakpoint here get call-stack in the console by entering - # import traceback - # traceback.print_exc() - logger.warning(err_str) - - -def sortedDeep(d): - if isinstance(d, list): - return sorted(sortedDeep(v) for v in d) - if isinstance(d, dict): - return {k: sortedDeep(d[k]) for k in sorted(d)} - return d - - -def build_finished(app, exception): - # TODO: create report of missing descriptions - - for fname, contents in app.files.items(): - f = open(SCHEMA_PATH + fname + ".json", "w", newline="\n") - # make sure all is sorted to minimize git diffs - contents = sortedDeep(contents) - if JSON_DUMP_PRETTY: - f.write(json.dumps(contents, indent=2)) - else: - f.write(json.dumps(contents, separators=(",", ":"))) - - str = f"Documented: {statistics.props_documented} Enums: {statistics.enums_good}/{statistics.enums_bad}" - logger.info(str) - - -class SetObservable(dict): - """ - a MyDict is like a dict except that when you set an item, before - doing so it will call a callback function that was passed in when the - MyDict instance was created - """ - - def __init__( - self, - value, - setitem_callback=None, - inner_key=None, - original_dict=None, - *args, - **kwargs, - ): - super(SetObservable, self).__init__(value, *args, **kwargs) - self._setitem_callback = setitem_callback - self.inner_key = inner_key - self.original_dict = original_dict - - def __setitem__(self, key, value): - if self._setitem_callback: - self._setitem_callback(self.inner_key, self.original_dict, key, value) - super(SetObservable, self).__setitem__(key, value) - - -def is_component_file(app: SchemaGeneratorVisitor, component): - if component == "core" or component == "automation": - component = "esphome" - return exists(SCHEMA_PATH + component + ".json") - - -def get_component_file(app: SchemaGeneratorVisitor, component): - if component == "core" or component == "automation": - component = "esphome" - if component not in app.files: - app.files[component] = read_file(component) - return app.files[component] diff --git a/script/schema_doc.py b/script/schema_doc.py new file mode 100755 index 0000000000..4a20a592e6 --- /dev/null +++ b/script/schema_doc.py @@ -0,0 +1,983 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +import unicodedata +import string +from pathlib import Path +from pprint import pprint +from inspect import getmembers +from types import FunctionType + +# cspell:ignore Clockless fastled apiclass apistruct classesphome dfrobot docref structesphome templatable + +DOC_CONFIGURATION_VARIABLES = "Configuration variables:" +DOC_CONFIGURATION_OPTIONS = "Configuration options:" +DOC_OVER_SPI = "Over SPI" +DOC_OVER_I2C = "Over I²C" + +JSON_CONFIG_VARS = "config_vars" +JSON_EXTENDS = "extends" +JSON_DOCS = "docs" +JSON_KEY = "key" +JSON_TEMPLATABLE = "templatable" +JSON_CV_TYPE = "type" +JSON_CV_TYPE_SCHEMA = "schema" +JSON_ACTION = "action" + +args = None + + +def is_configuration_variables_title_alike(title): + REGEX_CONFIGURATION_VARIABLES_TITLE = r"^#*\s?Configuration (variables|options):?$" + + return re.search(REGEX_CONFIGURATION_VARIABLES_TITLE, title, re.IGNORECASE) + + +def hugo_slugify(text: str) -> str: + # Normalize Unicode to ASCII (e.g., é → e) + text = unicodedata.normalize("NFKD", text) + text = text.encode("ascii", "ignore").decode("ascii") + # Lowercase + text = text.lower() + # Replace non-alphanumeric sequences with hyphen + text = re.sub(r"[^a-z0-9]+", "-", text) + # Trim hyphens from ends + text = text.strip("-") + return text + + +class SeeAlso: + title: str = None + file: Path = None + doc_slug_title: str = None + + def reset_doc(self, md_file: Path): + if "title" in md_docs[md_file]: + self.doc_slug_title = None + self.file = md_file + self.title = md_docs[md_file]["title"] + else: + assert "filter" == md_file.parent.stem + # doc title should be valid and file should not change + + def set_title_slug(self, title): + # TODO: if setting same title, the slug actually gets appended -1, -2 etc. + self.doc_slug_title = f"#{hugo_slugify(title)}" + + def set_title(self, title): + self.set_title_slug(title) + + def md(self): + url_path = "/" + "/".join(list(self.file.parts[1:-1])) + if self.file.stem != "_index": + url_path += f"/{self.file.stem}" + if self.doc_slug_title: + url_path += self.doc_slug_title + + return f"*See also: [{self.title}]({args.deploy_url}{url_path})*" + + +see_also = SeeAlso() + + +class Stats: + core_docs = 0 + core_platform_docs = 0 + platform_docs = 0 + props = 0 + enum_docs = 0 + action_docs = 0 + condition_docs = 0 + missing_anchors = [] + + +stats = Stats() + +anchors = {} +md_docs = {} +json_docs = {} + + +def unquote(s: str) -> str: + return re.sub(r"""^(['"])(.*)\1$""", r"\2", s) + + +def md_parse_frontmatter(md_file, lines): + if lines[0] == "---": + index = 1 + while lines[index] != "---": + if lines[index].startswith("title: "): + md_docs[md_file]["title"] = unquote( + lines[index][len("title:") :].strip() + ) + index += 1 + return index + 1 + return 0 + + +def open_file_lines(file): + if os.path.exists(file): + with open(file, "r", encoding="utf-8-sig") as file_f: + lines = file_f.read().split("\n") + return lines + else: + print(f"Error: File {file} not found") + + +REGEX_INCLUDE = r"^{{<\sinclude\s\"([^\"]*)\"\s>}}" + + +def mrkdwn_lines_includes(lines, md_file): + ret_lines = [] + for index in range(0, len(lines)): + line = lines[index] + search = re.search(REGEX_INCLUDE, line, re.IGNORECASE) + if search: + include_path = md_file.parent / search.group(1) + if not include_path.exists(): + print(f"{md_file}:{index + 1} cannot include {include_path}") + continue + + include_lines = open_file_lines(include_path) + include_index = md_parse_frontmatter(None, include_lines) + ret_lines.extend(include_lines[include_index:]) + else: + ret_lines.append(line) + return ret_lines + + +def mrkdwn_lines(md_file): + lines = md_docs.get(md_file, {}).get("lines") + if lines: + return lines + + if (lines := open_file_lines(md_file)) is not None: + lines = mrkdwn_lines_includes(lines, md_file) + # cache into md_docs dict + md_docs[md_file] = {"lines": lines} + return lines + + +def fill_anchors(md_files): + REGEX_ANCHOR = r"^{{<\sanchor\s\"([^\"]*)\"\s>}}" + for md_file in md_files: + lines = mrkdwn_lines(md_file) + for line in lines: + search = re.search(REGEX_ANCHOR, line, re.IGNORECASE) + if search: + anchor = search.group(1) + anchors[anchor] = md_file + + +def get_doc_title(md_file): + title = md_docs.get(md_file, {}).get("title") + if title: + return title + + lines = mrkdwn_lines(md_file) + md_parse_frontmatter(md_file, lines) + + return md_docs.get(md_file, {}).get("title") + + +def md_get_paragraph(lines, index): + # skip + while ( + not lines[index].strip() + or ( # whitespace + lines[index].strip().startswith("{{") + and lines[index].strip().endswith("}}") # anchors + ) + or (is_title(lines[index])) # titles + ): + index += 1 + if index >= len(lines): + return index, None + paragraph = "" + # get lines + if lines[index].startswith("```"): # got a code block, return None + return index, None + + while lines[index].strip(): + paragraph = paragraph + lines[index] + " " + index += 1 + return index, paragraph.strip() + + +def md_get_next_title(lines, index): + while True: + if index >= len(lines): + return index, None + line = lines[index] + if is_configuration_variables_title_alike(line): + if line.startswith("#"): + see_also.set_title_slug(line) + elif args.debug_level > 3: + print( + f"{md_file}:{index + 1} {DOC_CONFIGURATION_VARIABLES} title is not # marked. Cannot generate slug link" + ) + return index + 1, DOC_CONFIGURATION_VARIABLES + if is_title(line): + see_also.set_title(line) + return index + 1, line.replace("#", "").strip() + index += 1 + + +def md_get_next_config(lines, index): + # returns a - item from a list + ret = None + indent = 0 + in_code_block = False + while True: + if index >= len(lines): + return index, None, indent + line = lines[index] + + # skip code blocks inside properties (and complain??) + if line.startswith("```"): + in_code_block = not in_code_block + index += 1 + continue + if in_code_block: + index += 1 + continue + + if is_title(line): + if ret: + return index, ret, indent + return index, None, indent + + line = lines[index].strip() + + if line.startswith("- "): + if ret: + return index, ret, indent + ret = line[2:].strip() + indent = lines[index].find("-") + elif ret and line: + line_indent = len(lines[index]) - len(line) + if line_indent == indent + 2: + ret += " " + line + else: + return index, ret, indent + index += 1 + + +def json_exists(name): + json_file_name = os.path.join(args.schema_dir, name + ".json") + if os.path.exists(json_file_name): + return True + return False + + +def json_get(name): + if name == "core": + name = "esphome" + + json_doc = json_docs.get(name) + if json_doc: + return json_doc + + json_file_name = os.path.join(args.schema_dir, name + ".json") + if os.path.exists(json_file_name): + with open(json_file_name, "r", encoding="utf-8-sig") as f: + json_docs[name] = json_doc = json.loads(f.read()) + return json_doc + else: + print(f"Error: File {json_file_name} not found") + return + + +def json_save(): + for name, content in json_docs.items(): + json_file_name = os.path.join(args.schema_dir, name + ".json") + with open(json_file_name, "w", encoding="utf-8") as f: + f.write(json.dumps(content, indent=2)) + + +def make_doc_with_see_also(md_file, index, docs): + docs = convert_links_and_shortcodes(md_file, index, docs) + return f"{docs}\n\n{see_also.md()}" + + +def process_component(md_file, lines, index, name): + # This adds the doc to the esphome.json file / "components" + esphome_json = json_get("esphome") + core = esphome_json["core"] + if name not in core["components"]: + return index, False + index, docs = md_get_paragraph(lines, index) + core["components"][name][JSON_DOCS] = make_doc_with_see_also(md_file, index, docs) + stats.core_docs += 1 + return index, True + + +def process_platform_component(md_file, lines, index, platform, name): + # This adds the doc to the platform file / "components", e.g. sensor.json + platform_json = json_get(platform) + index, docs = md_get_paragraph(lines, index) + if name in platform_json[platform]["components"]: + platform_json[platform]["components"][name][JSON_DOCS] = make_doc_with_see_also( + md_file, index, docs + ) + stats.platform_docs += 1 + return index, True + else: + return index, False + + +def get_platform_from_title(title, config_component=None): + esphome_json = json_get("esphome") + title = title.lower().replace("`", "") + if config_component and title.startswith(config_component.lower()): + title = title[len(config_component) + 1 :] + name = title.replace(" ", "_") + if name in esphome_json["core"]["platforms"]: + return name + return None + + +REGEX_PROP = r"^\*\*(\w+)\*\*(?: \((.*?)\))?: (.*)" # **** (): ## group2 optional +REGEX_ENUM1 = r"^`([^`]*)`(?:(?: -|:) (.*)|\s\((.*)\))?" +REGEX_ENUM2 = r"^\*\*([^\*]*)\*\*(?:(?: -|:) (.*)|\s\((.*)\))?" +REGEX_PROP_TITLE = r"^#+ `([^`]+)`(.*)" + + +def find_schema_prop(schema, prop_name): + if JSON_CONFIG_VARS in schema: + matched_config = schema[JSON_CONFIG_VARS].get(prop_name) + if matched_config: + return matched_config + for extended in schema.get(JSON_EXTENDS, []): + parts = extended.split(".") + extended_json = json_get(parts[0]) + if len(parts) == 3: + extended = ( + extended_json.get(f"{parts[0]}.{parts[1]}", {}) + .get("schemas", {}) + .get(parts[2], {}) + ) + else: + extended = ( + extended_json.get(parts[0], {}).get("schemas", {}).get(parts[1], {}) + ) + if not extended: + print(f"Cannot find extended schema: {'.'.join(parts)}") + if extended.get(JSON_CV_TYPE) == JSON_CV_TYPE_SCHEMA: + matched_config = find_schema_prop(extended["schema"], prop_name) + if matched_config: + return matched_config + return None + + +DOXYGEN_LOOKUP = {} +for s in string.ascii_lowercase + string.digits: + DOXYGEN_LOOKUP[s] = s +for s in string.ascii_uppercase: + DOXYGEN_LOOKUP[s] = "_{}".format(s.lower()) +DOXYGEN_LOOKUP[":"] = "_1" +DOXYGEN_LOOKUP["_"] = "__" +DOXYGEN_LOOKUP["."] = "_8" + + +def encode_doxygen(value): + value = value.split("/")[-1] + try: + return "".join(DOXYGEN_LOOKUP[s] for s in value) + except KeyError as exc: + raise ValueError( + "Unknown character in doxygen string! '{}'".format(value) + ) from exc + + +def get_md_file_ref(md_file, ref): + if ref.startswith("/"): + md_parent = Path(".") / "content" + ref = ref[1:] + else: + md_parent = md_file.parent + if ref.endswith("/"): + ref = ref[:-1] + + ref_md_path = md_parent / (ref + ".md") + if ref_md_path.exists(): + return ref_md_path + ref_md_default = md_parent / ref / "_index.md" + if ref_md_default.exists(): + return ref_md_default + + +def convert_links_and_shortcodes(md_file, index, docs): + if docs is None: + return None + + # Matches [name-group-1](#local-link-group-2) + REGEX_LOCAL_LINK = r"\[([^\]]*)\]\(#([^\)]*)\)" + + def replacer_local(match): + title = match.group(1) + anchor = match.group(2) + if anchor not in anchors: + if anchor not in stats.missing_anchors: + stats.missing_anchors.append(anchor) + url = anchor + else: + anchor_file = anchors[anchor] + url = f"{args.deploy_url}/{'/'.join(anchor_file.parts[1:-1])}/{anchor_file.stem}#{anchor}" + + return f"[{title}]({url})" + + docs = re.sub(REGEX_LOCAL_LINK, replacer_local, docs) + + # Matches {{ shortcode-group-1 "group-2" "group-3" }} + REGEX_SHORTCODE = r"{{<\s([^\s]*)\s\"([^\"]*)\"(?:\s\"([^\"]*)\")?\s>}}" + + def replacer_shortcode(match): + if match.group(1) == "docref": + ref = match.group(2) + md_file_ref = get_md_file_ref(md_file, ref) + title = match.group(3) or get_doc_title(md_file_ref) + if ref.startswith("/"): + url = args.deploy_url + ref + else: + url = args.deploy_url + "/" + "/".join(md_file.parts[1:-1]) + "/" + ref + if url.endswith("/index"): + url = url[: -(len("/index"))] + elif match.group(1) == "apistruct": + title = match.group(2) + url = f"{args.api_docs_url}/structesphome_1_1{encode_doxygen(match.group(3))}.html" + elif match.group(1) == "apiclass": + title = match.group(2) + url = f"{args.api_docs_url}/classesphome_1_1{encode_doxygen(match.group(3))}.html" + else: + print(f"{md_file}:{index} unknown shortcode '{match.group(1)}'") + + return f"[{title}]({url})" + + return re.sub(REGEX_SHORTCODE, replacer_shortcode, docs) + + +def set_schema_doc(md_file, index, schema, prop_name, prop_types, doc): + TYPE_TEMPLATABLE = "[templatable](#config-templatable)" + + matched_config = find_schema_prop(schema, prop_name) + if matched_config: + converted_doc = make_doc_with_see_also(md_file, index, doc) + + if prop_types: + type_parts = [part.strip() for part in prop_types.split(",")] + optionality = type_parts[0].replace("*", "").lower() + config_optionality = matched_config.get(JSON_KEY, "") + if optionality != config_optionality.lower() and args.debug_level > 3: + print( + f"{md_file}:{index} {prop_name} Key {config_optionality} in ESPHome does not match {optionality} in docs" + ) + + templatable = TYPE_TEMPLATABLE in type_parts[1:] + config_templatable = matched_config.get(JSON_TEMPLATABLE, False) + if templatable != config_templatable and args.debug_level > 3: + print( + f"{md_file}:{index} {prop_name} Templatable {config_templatable} in ESPHome does not match {templatable} in docs" + ) + + # Document with type information, unless the type just says templatable + if len(type_parts) > 1 and type_parts[1] != TYPE_TEMPLATABLE: + prop_type = convert_links_and_shortcodes(md_file, index, type_parts[1]) + matched_config[JSON_DOCS] = f"**{prop_type}**: {converted_doc}" + stats.props += 1 + return matched_config + + matched_config[JSON_DOCS] = converted_doc + + stats.props += 1 + return matched_config + + +def md_skip_level(lines, index): + line = lines[index] + indent = len(line) - len(line.strip()) + while index + 1 < len(lines): + index += 1 + line = lines[index] + if indent < len(line) - len(line.strip()): + return index + return index + 1 + + +def is_title(title): + return title.startswith("#") + + +def is_break_title(title): + if is_title(title): + name = title.split(" ")[-1].lower() + if get_platform_from_title(name): + return True + if name in ["action", "condition", "component"]: + return True + return False + + +def process_schema( + md_file, + lines, + index, + schema, + indent, + parent_schema, + typed_var=None, + typed_value=None, +): + matched_config = None + while True: + if index >= len(lines): + return index + if is_title(lines[index]): + if is_break_title(lines[index]): + return index + else: + index += 1 + prev_index = index + index, item_config, item_indent = md_get_next_config(lines, index) + if index >= len(lines): + return index + + if not item_config: + search = re.search(REGEX_PROP_TITLE, lines[index], re.IGNORECASE) + if search: + prop_name = search.group(1) + matched_config = find_schema_prop(schema, prop_name) + if matched_config: + if args.debug_level > 6: + print( + f"{md_file}:{index} {lines[index]} : matched title for prop {prop_name} " + ) + index = process_config( + md_file, lines, index + 1, matched_config, 0, schema + ) + continue + elif parent_schema: + matched_config = find_schema_prop(parent_schema, prop_name) + if matched_config: + return index + elif lines[index].endswith("Action"): + continue # this is a breaking title, but many triggers are labeled action + + if item_indent < indent: + return prev_index + if item_indent > indent: + if not matched_config: + if args.debug_level > 6: + print( + f"{md_file}:{index} {lines[index]} an indentation increase for unknown" + ) + next_index = md_skip_level(lines, index) + continue + if matched_config.get(JSON_CV_TYPE, []) not in ["enum", "schema"]: + if args.debug_level > 2: + print( + f"{md_file}:{index} {lines[index]} : an indentation increase for a {matched_config.get(JSON_CV_TYPE, 'unknown')}" + ) + next_index = process_config( + md_file, lines, prev_index, matched_config, item_indent, schema + ) + if next_index == prev_index: + # no progress + next_index = index # skip ahead + index = next_index + continue + if not item_config: + continue + search = re.search(REGEX_PROP, item_config, re.IGNORECASE) + if search: + prop_name = search.group(1) + + if typed_var and typed_var.get("typed_key") == prop_name: + typed_var["docs"] = search.group(3) + else: + matched_config = set_schema_doc( + md_file, index, schema, prop_name, search.group(2), search.group(3) + ) + + +def process_config(md_file, lines, index, config_var, indent=0, parent_schema=None): + while True: + if index >= len(lines): + return index + if is_break_title(lines[index]): + return index + item_type = config_var.get(JSON_CV_TYPE) + if item_type in ["schema", "trigger"] and JSON_CV_TYPE_SCHEMA in config_var: + schema = config_var[JSON_CV_TYPE_SCHEMA] + return process_schema(md_file, lines, index, schema, indent, parent_schema) + + elif item_type == "typed": + for typed in config_var["types"]: + process_schema( + md_file, + lines, + index, + config_var["types"][typed], + indent, + None, + typed_var=config_var, + typed_value=typed, + ) + + return md_skip_level(lines, index + 1) + elif item_type == "enum": + prev_index = index + index, item_config, item_indent = md_get_next_config(lines, index) + if not item_config: + return index + if item_indent < indent: + return prev_index + search = re.search(REGEX_ENUM1, item_config, re.IGNORECASE) + if search: + enum_value = search.group(1) + enum_desc = search.group(2) or search.group(3) + values = config_var.get("values", {}) + if enum_value in values: + values[enum_value] = values.get(enum_value) or {} + values[enum_value][JSON_DOCS] = convert_links_and_shortcodes( + md_file, index, enum_desc + ) + stats.enum_docs += 1 + else: + search = re.search(REGEX_ENUM2, item_config, re.IGNORECASE) + if search: + enum_value = search.group(1) + enum_desc = search.group(2) or search.group(3) + values = config_var.get("values", {}) + if enum_value in values: + values[enum_value] = values.get(enum_value) or {} + values[enum_value][JSON_DOCS] = convert_links_and_shortcodes( + md_file, index, enum_desc + ) + stats.enum_docs += 1 + else: + print(f"{md_file}:{index} Cannot get enum expected here") + + elif item_type is None or item_type == "string": + # consume this level + prev_index = index + index, item_config, item_indent = md_get_next_config(lines, index) + if not item_config or item_indent != indent: + return prev_index if item_config else index + else: + return index + + +def oddities_doc_not_specific_component(folder, file): + # these are docs that the doc name does not directly correspond to a component + # may be a frontmatter flag could be set for these + if folder == "binary_sensor": + return file == "ttp229" + elif folder == "climate": + return file == "climate_ir" + elif folder == "display": + return file in [ + "lcd_display", + "ssd1306", + "ssd1322", + "ssd1325", + "ssd1327", + "ssd1331", + "ssd1351", + "st7567", + ] + elif folder == "light": + return file == "fastled" + + +def oddities_titles(folder, file, title): + # this replaces some titles which should be named otherwise + if folder == "light": + if file == "fastled": + if title == "Clockless": + return "fastled_clockless Component" + elif title == "SPI": + return "fastled_spi Component" + elif folder == "components": + if file == "dfrobot_sen0395": + if title == "Hub Component": + return "Component/Hub" + elif file == "sn74hc595": + if title == "Over SPI": + # this is actually a typed schema, something to better figure documenting later + return "" + + return title + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Add docs to ESPHome json schema") + parser.add_argument("schema_dir", help="Directory containing JSON files") + parser.add_argument("--single", help="Process a single json file", default=None) + parser.add_argument( + "--debug-level", + help="Print parsing issues level, 0 prints nothing", + default=0, + type=int, + ) + parser.add_argument( + "--deploy-url", + help="The base url for the deployment, e.g. https://esphome.io", + default="https://esphome.io", + ) + parser.add_argument( + "--api-docs-url", + help="The base url for api docs, e.g. https://api-docs.esphome.io", + default="https://api-docs.esphome.io", + ) + args = parser.parse_args() + + esphome_json = json_get("esphome") + core = esphome_json["core"] + + md_files = [] + for root, _, files in os.walk(Path(".") / "content" / "components"): + for file in files: + if file.endswith(".md"): + fullpath = Path(root, file) + md_files.append(fullpath) + md_files.append(Path(".") / "content" / "automations" / "actions.md") + + fill_anchors( + md_files + + [ + # config-lambda, config-templatable + Path(".") / "content" / "automations" / "templates.md", + # config-id, config-pin_schema + Path(".") / "content" / "guides" / "configuration-types.md", + # api-rest + Path(".") / "content" / "web-api" / "_index.md", + ] + ) + + if args.single: + md_files = [f for f in md_files if args.single in repr(f)] + + for md_file in md_files: + lines = mrkdwn_lines(md_file) + index = md_parse_frontmatter(md_file, lines) + see_also.reset_doc(md_file) + file_name = md_file.stem + content_folder = md_file.parent.name + is_platform = False + is_component = False + config_component = None + json_config = None + # component docs: + # some components have .md files on folders, e.g. http_request + # so for the root component (in core) we need to use the one in root, and ignore the one in subfolder, + # that one will be used in e.g. sensors.json (platform) + + if file_name == "_index" and content_folder == "components": + continue # nothing here + + if file_name in core["components"]: + # fill root component docs + index, is_component = process_component(md_file, lines, index, file_name) + if is_component: + config_component = file_name + elif content_folder != "content" and content_folder in core["platforms"]: + if file_name == "_index": + # fill core platform docs, from _index files in platforms folders + index, docs = md_get_paragraph(lines, index) + core["platforms"][content_folder][JSON_DOCS] = ( + convert_links_and_shortcodes(md_file, index, docs) + ) + stats.core_platform_docs += 1 + is_platform = True + config_component = content_folder + else: + # this is a component inside a folder + if not oddities_doc_not_specific_component(content_folder, file_name): + index, is_platform = process_platform_component( + md_file, lines, index, content_folder, file_name + ) + if is_platform: + config_component = file_name + elif content_folder == "automations": + config_component = "core" + + platform_name = content_folder if content_folder != "components" else None + title_config_vars = None + + while True: + index, title = md_get_next_title(lines, index) + if not title: + break + component_name = None + + title = oddities_titles(content_folder, file_name, title) + if title == "Component/Hub": + # Some files like pn523, rc522, as3935 are in a platform folder even + # though they are full components and their platform components are + # documented with the platform titles + platform_name = None + + elif title.endswith(" Component"): + component_name = ( + title.replace(" Component", "") + .replace("`", "") + .replace(".", "") + .lower() + ) + elif title.endswith(DOC_OVER_SPI): + component_name = f"{file_name}_spi" + elif title.endswith(DOC_OVER_I2C): + component_name = f"{file_name}_i2c" + elif ( + # Handle Platform titles, e.g. Sensor, Switch titles + file_name != "_index" + and get_platform_from_title(title, config_component or file_name) + is not None + ): + component_name = file_name + platform_name = get_platform_from_title( + title, config_component or file_name + ) + + if ( + title.endswith(" Action") or title.endswith(" Condition") + ) and title.startswith("`"): + config_type = title.split(" ")[-1].lower() # action / condition + parts = title.split(" ")[0].replace("`", "").split(".") + if len(parts) == 1: + # action; the component should be actual component + if not config_component: + print(f"{md_file}:{index} {title} with no config component.") + continue + if json_config != json_get(config_component): + print(f"{md_file}:{index} {title} set needed for this.") + json_config = json_get(config_component) + if not json_config: + print( + f"{md_file}:{index} Found title {title} in {config_component} cannot find config" + ) + else: + title_config_vars = ( + json_config.get(config_component, {}) + .get(config_type, {}) + .get(parts[0]) + ) + elif len(parts) == 2: + # component.action + title_config_vars = ( + (json_get(parts[0]) or {}) + .get(parts[0], {}) + .get(config_type, {}) + .get(parts[1]) + ) + elif len(parts) == 3: + # platform.component.action + if parts[1] not in core["components"]: + print( + f"{md_file}:{index} Found {config_type} {title} with invalid name format" + ) + title_config_vars = ( + (json_get(parts[1]) or {}) + .get(f"{parts[1]}.{parts[0]}", {}) + .get(config_type, {}) + .get(parts[2]) + ) + + else: + print(f"{md_file}:{index} Found title {title} too many parts") + + if title_config_vars is not None: + index, docs = md_get_paragraph(lines, index) + title_config_vars[JSON_DOCS] = convert_links_and_shortcodes( + md_file, index, docs + ) + if config_type == "action": + stats.action_docs += 1 + elif config_type == "condition": + stats.condition_docs += 1 + else: + print( + f"{md_file}:{index} Found title {title} in {config_component} config not found" + ) + + if component_name: + is_platform = platform_name in core["platforms"] + is_component = False + if is_platform: + index, is_platform = process_platform_component( + md_file, lines, index, platform_name, component_name + ) + + if not is_platform and component_name in core["components"]: + index, is_component = process_component( + md_file, lines, index, component_name + ) + + if not is_platform and not is_component: + print( + f"{md_file}:{index} {platform_name}/{file_name} {title} not processed." + ) + else: + config_component = component_name + + if title == DOC_CONFIGURATION_VARIABLES: + if not config_component: + print( + f"{md_file}:{index} TODO {platform_name}/{file_name} {title} not processed." + ) + continue + + if title_config_vars: + schema = title_config_vars + else: + json_config = json_get(config_component) + if not json_config: + print(f"{md_file}:{index} {config_component} no json_config") + schema = None + elif is_component: + schema = json_config[config_component]["schemas"][ + "CONFIG_SCHEMA" + ] + elif is_platform and config_component: + if config_component == platform_name: + schema = json_config[config_component]["schemas"].get( + f"{platform_name.upper()}_SCHEMA" + ) + else: + schema = json_config[f"{config_component}.{platform_name}"][ + "schemas" + ].get("CONFIG_SCHEMA") + else: + schema = None + if schema: + try: + index = process_config(md_file, lines, index + 1, schema) + except Exception as err: + print(f"{md_file}:{index} {title} failed {repr(err)}") + # if you put a breakpoint here get call-stack in the console by entering + # import traceback + # traceback.print_exc() + break + title_config_vars = None + + json_save() + + def attributes(obj): + disallowed_names = { + name + for name, value in getmembers(type(obj)) + if isinstance(value, FunctionType) + } + return { + name: getattr(obj, name) + for name in dir(obj) + if name[0] != "_" and name not in disallowed_names and hasattr(obj, name) + } + + def print_attributes(obj): + pprint(attributes(obj)) + + print_attributes(stats)