diff --git a/.gitattributes b/.gitattributes index dad0966222..1b3fd332b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ # Normalize line endings to LF in the repository * text eol=lf +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index affdf944a7..2b04d04672 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,60 +12,266 @@ on: permissions: contents: read +env: + DEFAULT_PYTHON: "3.9" + PYUPGRADE_TARGET: "--py39-plus" + CLANG_FORMAT_VERSION: "13.0.1" + concurrency: # yamllint disable-line rule:line-length group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: - ci: - name: ${{ matrix.name }} + common: + name: Create common environment runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Set up Python ${{ env.DEFAULT_PYTHON }} + uses: actions/setup-python@v4.6.0 + with: + python-version: ${{ env.DEFAULT_PYTHON }} + - name: Restore Python virtual environment + id: cache-venv + uses: actions/cache@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Create Python virtual environment + if: steps.cache-venv.outputs.cache-hit != 'true' + run: | + python -m venv venv + . venv/bin/activate + python --version + pip install -r requirements.txt -r requirements_optional.txt -r requirements_test.txt + pip install -e . + + yamllint: + name: yamllint + runs-on: ubuntu-latest + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Run yamllint + uses: frenck/action-yamllint@v1.4.0 + + black: + name: Check black + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Run black + run: | + . venv/bin/activate + black --verbose esphome tests + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + flake8: + name: Check flake8 + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Run flake8 + run: | + . venv/bin/activate + flake8 esphome + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + pylint: + name: Check pylint + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Run pylint + run: | + . venv/bin/activate + pylint -f parseable --persistent=n esphome + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + pyupgrade: + name: Check pyupgrade + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Run pyupgrade + run: | + . venv/bin/activate + pyupgrade ${{ env.PYUPGRADE_TARGET }} `find esphome -name "*.py" -type f` + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + ci-custom: + name: Run script/ci-custom + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json" + - name: Run script/ci-custom + run: | + . venv/bin/activate + script/ci-custom.py + script/build_codeowners.py --check + + pytest: + name: Run pytest + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Register matcher + run: echo "::add-matcher::.github/workflows/matchers/pytest.json" + - name: Run pytest + run: | + . venv/bin/activate + pytest -vv --tb=native tests + + clang-format: + name: Check clang-format + runs-on: ubuntu-latest + needs: + - common + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Install clang-format + run: | + . venv/bin/activate + pip install clang-format==${{ env.CLANG_FORMAT_VERSION }} + - name: Run clang-format + run: | + . venv/bin/activate + script/clang-format -i + git diff-index --quiet HEAD -- + - name: Suggested changes + run: script/ci-suggest-changes + if: always() + + compile-tests: + name: Run YAML test ${{ matrix.file }} + runs-on: ubuntu-latest + needs: + - common + - black + - ci-custom + - clang-format + - flake8 + - pylint + - pytest + - pyupgrade + - yamllint strategy: fail-fast: false - max-parallel: 5 + max-parallel: 2 + matrix: + file: [1, 2, 3, 3.1, 4, 5, 6, 7, 8] + steps: + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 + with: + path: venv + # yamllint disable-line rule:line-length + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + - name: Cache platformio + uses: actions/cache@v3.3.1 + with: + path: ~/.platformio + # yamllint disable-line rule:line-length + key: platformio-test${{ matrix.file }}-${{ hashFiles('platformio.ini') }} + - name: Run esphome compile tests/test${{ matrix.file }}.yaml + run: | + . venv/bin/activate + esphome compile tests/test${{ matrix.file }}.yaml + + clang-tidy: + name: ${{ matrix.name }} + runs-on: ubuntu-latest + needs: + - common + - black + - ci-custom + - clang-format + - flake8 + - pylint + - pytest + - pyupgrade + - yamllint + strategy: + fail-fast: false + max-parallel: 2 matrix: include: - - id: ci-custom - name: Run script/ci-custom - - id: lint-python - name: Run script/lint-python - - id: test - file: tests/test1.yaml - name: Test tests/test1.yaml - pio_cache_key: test1 - - id: test - file: tests/test2.yaml - name: Test tests/test2.yaml - pio_cache_key: test2 - - id: test - file: tests/test3.yaml - name: Test tests/test3.yaml - pio_cache_key: test3 - - id: test - file: tests/test3.1.yaml - name: Test tests/test3.1.yaml - pio_cache_key: test3.1 - - id: test - file: tests/test4.yaml - name: Test tests/test4.yaml - pio_cache_key: test4 - - id: test - file: tests/test5.yaml - name: Test tests/test5.yaml - pio_cache_key: test5 - - id: test - file: tests/test6.yaml - name: Test tests/test6.yaml - pio_cache_key: test6 - - id: test - file: tests/test7.yaml - name: Test tests/test7.yaml - pio_cache_key: test7 - - id: pytest - name: Run pytest - - id: clang-format - name: Run script/clang-format - id: clang-tidy name: Run script/clang-tidy for ESP8266 options: --environment esp8266-arduino-tidy --grep USE_ESP8266 @@ -90,119 +296,65 @@ jobs: name: Run script/clang-tidy for ESP32 IDF options: --environment esp32-idf-tidy --grep USE_ESP_IDF pio_cache_key: tidyesp32-idf - - id: yamllint - name: Run yamllint steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - id: python + - name: Check out code from GitHub + uses: actions/checkout@v3.5.2 + - name: Restore Python virtual environment + uses: actions/cache/restore@v3.3.1 with: - python-version: "3.9" - - - name: Cache virtualenv - uses: actions/cache@v3 - with: - path: .venv + path: venv # yamllint disable-line rule:line-length - key: venv-${{ steps.python.outputs.python-version }}-${{ hashFiles('requirements*.txt') }} - restore-keys: | - venv-${{ steps.python.outputs.python-version }}- - - - name: Set up virtualenv - # yamllint disable rule:line-length - run: | - python -m venv .venv - source .venv/bin/activate - pip install -U pip - pip install -r requirements.txt -r requirements_optional.txt -r requirements_test.txt - pip install -e . - echo "$GITHUB_WORKSPACE/.venv/bin" >> $GITHUB_PATH - echo "VIRTUAL_ENV=$GITHUB_WORKSPACE/.venv" >> $GITHUB_ENV - # yamllint enable rule:line-length - - # Use per check platformio cache because checks use different parts + key: ${{ runner.os }}-${{ env.DEFAULT_PYTHON }}-venv-${{ hashFiles('requirements.txt', 'requirements_optional.txt', 'requirements_test.txt') }} + # Use per check platformio cache because checks use different parts - name: Cache platformio - uses: actions/cache@v3 + uses: actions/cache@v3.3.1 with: path: ~/.platformio # yamllint disable-line rule:line-length key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - if: matrix.id == 'test' || matrix.id == 'clang-tidy' - - name: Install clang tools - run: | - sudo apt-get install \ - clang-format-13 \ - clang-tidy-11 - if: matrix.id == 'clang-tidy' || matrix.id == 'clang-format' + - name: Install clang-tidy + run: sudo apt-get install clang-tidy-11 - name: Register problem matchers run: | - echo "::add-matcher::.github/workflows/matchers/ci-custom.json" - echo "::add-matcher::.github/workflows/matchers/lint-python.json" - echo "::add-matcher::.github/workflows/matchers/python.json" - echo "::add-matcher::.github/workflows/matchers/pytest.json" echo "::add-matcher::.github/workflows/matchers/gcc.json" echo "::add-matcher::.github/workflows/matchers/clang-tidy.json" - - name: Lint Custom - run: | - script/ci-custom.py - script/build_codeowners.py --check - if: matrix.id == 'ci-custom' - - - name: Lint Python - run: script/lint-python -a - if: matrix.id == 'lint-python' - - - run: esphome compile ${{ matrix.file }} - if: matrix.id == 'test' - env: - # Also cache libdeps, store them in a ~/.platformio subfolder - PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps - - - name: Run pytest - run: | - pytest -vv --tb=native tests - if: matrix.id == 'pytest' - - # Also run git-diff-index so that the step is marked as failed on - # formatting errors, since clang-format doesn't do anything but - # change files if -i is passed. - - name: Run clang-format - run: | - script/clang-format -i - git diff-index --quiet HEAD -- - if: matrix.id == 'clang-format' - - name: Run clang-tidy run: | + . venv/bin/activate script/clang-tidy --all-headers --fix ${{ matrix.options }} - if: matrix.id == 'clang-tidy' env: # Also cache libdeps, store them in a ~/.platformio subfolder PLATFORMIO_LIBDEPS_DIR: ~/.platformio/libdeps - - name: Run yamllint - if: matrix.id == 'yamllint' - uses: frenck/action-yamllint@v1.4.0 - - name: Suggested changes run: script/ci-suggest-changes # yamllint disable-line rule:line-length - if: always() && (matrix.id == 'clang-tidy' || matrix.id == 'clang-format' || matrix.id == 'lint-python') + if: always() ci-status: name: CI Status runs-on: ubuntu-latest - needs: [ci] + needs: + - common + - black + - ci-custom + - clang-format + - flake8 + - pylint + - pytest + - pyupgrade + - yamllint + - compile-tests + - clang-tidy if: always() steps: - - name: Successful deploy + - name: Success if: ${{ !(contains(needs.*.result, 'failure')) }} run: exit 0 - - name: Failing deploy + - name: Failure if: ${{ contains(needs.*.result, 'failure') }} run: exit 1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b858b40e6f..617d6f5d9f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,7 @@ repos: - --branch=release - --branch=beta - repo: https://github.com/asottile/pyupgrade - rev: v3.3.2 + rev: v3.4.0 hooks: - id: pyupgrade args: [--py39-plus] diff --git a/CODEOWNERS b/CODEOWNERS index 8b347460b2..82195d92da 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -107,6 +107,7 @@ esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal +esphome/components/hm3301/* @freekode esphome/components/homeassistant/* @OttoWinter esphome/components/honeywellabp/* @RubyBailey esphome/components/host/* @esphome/core @@ -221,6 +222,7 @@ esphome/components/restart/* @esphome/core esphome/components/rf_bridge/* @jesserockz esphome/components/rgbct/* @jesserockz esphome/components/rp2040/* @jesserockz +esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pwm/* @jesserockz esphome/components/rtttl/* @glmnet esphome/components/safe_mode/* @jsuanet @paulmonigatti @@ -282,6 +284,7 @@ esphome/components/tlc5947/* @rnauber esphome/components/tm1621/* @Philippe12 esphome/components/tm1637/* @glmnet esphome/components/tm1638/* @skykingjwc +esphome/components/tm1651/* @freekode esphome/components/tmp102/* @timsavage esphome/components/tmp117/* @Azimath esphome/components/tof10120/* @wstrzalka diff --git a/docker/Dockerfile b/docker/Dockerfile index 720241242f..95b6677815 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -52,7 +52,7 @@ RUN \ # Ubuntu python3-pip is missing wheel pip3 install --no-cache-dir \ wheel==0.37.1 \ - platformio==6.1.6 \ + platformio==6.1.7 \ # Change some platformio settings && platformio settings set enable_telemetry No \ && platformio settings set check_platformio_interval 1000000 \ diff --git a/esphome/__main__.py b/esphome/__main__.py index 78320a05f0..603a06bdd2 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -18,6 +18,9 @@ from esphome.const import ( CONF_LOGGER, CONF_NAME, CONF_OTA, + CONF_MQTT, + CONF_MDNS, + CONF_DISABLED, CONF_PASSWORD, CONF_PORT, CONF_ESPHOME, @@ -42,7 +45,7 @@ from esphome.log import color, setup_log, Fore _LOGGER = logging.getLogger(__name__) -def choose_prompt(options): +def choose_prompt(options, purpose: str = None): if not options: raise EsphomeError( "Found no valid options for upload/logging, please make sure relevant " @@ -53,7 +56,9 @@ def choose_prompt(options): if len(options) == 1: return options[0][1] - safe_print("Found multiple options, please choose one:") + safe_print( + f'Found multiple options{f" for {purpose}" if purpose else ""}, please choose one:' + ) for i, (desc, _) in enumerate(options): safe_print(f" [{i+1}] {desc}") @@ -72,7 +77,9 @@ def choose_prompt(options): return options[opt - 1][1] -def choose_upload_log_host(default, check_default, show_ota, show_mqtt, show_api): +def choose_upload_log_host( + default, check_default, show_ota, show_mqtt, show_api, purpose: str = None +): options = [] for port in get_serial_ports(): options.append((f"{port.path} ({port.description})", port.path)) @@ -80,7 +87,7 @@ def choose_upload_log_host(default, check_default, show_ota, show_mqtt, show_api options.append((f"Over The Air ({CORE.address})", CORE.address)) if default == "OTA": return CORE.address - if show_mqtt and "mqtt" in CORE.config: + if show_mqtt and CONF_MQTT in CORE.config: options.append((f"MQTT ({CORE.config['mqtt'][CONF_BROKER]})", "MQTT")) if default == "OTA": return "MQTT" @@ -88,7 +95,7 @@ def choose_upload_log_host(default, check_default, show_ota, show_mqtt, show_api return default if check_default is not None and check_default in [opt[1] for opt in options]: return check_default - return choose_prompt(options) + return choose_prompt(options, purpose=purpose) def get_port_type(port): @@ -288,19 +295,30 @@ def upload_program(config, args, host): return 1 # Unknown target platform - from esphome import espota2 - if CONF_OTA not in config: raise EsphomeError( "Cannot upload Over the Air as the config does not include the ota: " "component" ) + from esphome import espota2 + ota_conf = config[CONF_OTA] remote_port = ota_conf[CONF_PORT] password = ota_conf.get(CONF_PASSWORD, "") + + if ( + get_port_type(host) == "MQTT" or config[CONF_MDNS][CONF_DISABLED] + ) and CONF_MQTT in config: + from esphome import mqtt + + host = mqtt.get_esphome_device_ip( + config, args.username, args.password, args.client_id + ) + if getattr(args, "file", None) is not None: return espota2.run_ota(host, remote_port, password, args.file) + return espota2.run_ota(host, remote_port, password, CORE.firmware_bin) @@ -310,6 +328,13 @@ def show_logs(config, args, port): if get_port_type(port) == "SERIAL": return run_miniterm(config, port) if get_port_type(port) == "NETWORK" and "api" in config: + if config[CONF_MDNS][CONF_DISABLED] and CONF_MQTT in config: + from esphome import mqtt + + port = mqtt.get_esphome_device_ip( + config, args.username, args.password, args.client_id + ) + from esphome.components.api.client import run_logs return run_logs(config, port) @@ -374,6 +399,7 @@ def command_upload(args, config): show_ota=True, show_mqtt=False, show_api=False, + purpose="uploading", ) exit_code = upload_program(config, args, port) if exit_code != 0: @@ -382,6 +408,15 @@ def command_upload(args, config): return 0 +def command_discover(args, config): + if "mqtt" in config: + from esphome import mqtt + + return mqtt.show_discover(config, args.username, args.password, args.client_id) + + raise EsphomeError("No discover method configured (mqtt)") + + def command_logs(args, config): port = choose_upload_log_host( default=args.device, @@ -389,6 +424,7 @@ def command_logs(args, config): show_ota=False, show_mqtt=True, show_api=True, + purpose="logging", ) return show_logs(config, args, port) @@ -407,6 +443,7 @@ def command_run(args, config): show_ota=True, show_mqtt=False, show_api=True, + purpose="uploading", ) exit_code = upload_program(config, args, port) if exit_code != 0: @@ -420,6 +457,7 @@ def command_run(args, config): show_ota=False, show_mqtt=True, show_api=True, + purpose="logging", ) return show_logs(config, args, port) @@ -623,6 +661,7 @@ POST_CONFIG_ACTIONS = { "clean": command_clean, "idedata": command_idedata, "rename": command_rename, + "discover": command_discover, } @@ -711,6 +750,15 @@ def parse_args(argv): help="Manually specify the serial port/address to use, for example /dev/ttyUSB0.", ) + parser_discover = subparsers.add_parser( + "discover", + help="Validate the configuration and show all discovered devices.", + parents=[mqtt_options], + ) + parser_discover.add_argument( + "configuration", help="Your YAML configuration file.", nargs=1 + ) + parser_run = subparsers.add_parser( "run", help="Validate the configuration, create a binary, upload it, and start logs.", @@ -932,6 +980,8 @@ def run_esphome(argv): _LOGGER.error(e, exc_info=args.verbose) return 1 + safe_print(f"ESPHome {const.__version__}") + for conf_path in args.configuration: if any(os.path.basename(conf_path) == x for x in SECRETS_FILES): _LOGGER.warning("Skipping secrets file %s", conf_path) diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py index 68c3eee132..1b804bd527 100644 --- a/esphome/components/animation/__init__.py +++ b/esphome/components/animation/__init__.py @@ -3,6 +3,7 @@ import logging from esphome import core from esphome.components import display, font import esphome.components.image as espImage +from esphome.components.image import CONF_USE_TRANSPARENCY import esphome.config_validation as cv import esphome.codegen as cg from esphome.const import CONF_FILE, CONF_ID, CONF_RAW_DATA_ID, CONF_RESIZE, CONF_TYPE @@ -15,16 +16,42 @@ MULTI_CONF = True Animation_ = display.display_ns.class_("Animation", espImage.Image_) + +def validate_cross_dependencies(config): + """ + Validate fields whose possible values depend on other fields. + For example, validate that explicitly transparent image types + have "use_transparency" set to True. + Also set the default value for those kind of dependent fields. + """ + image_type = config[CONF_TYPE] + is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"] + # If the use_transparency option was not specified, set the default depending on the image type + if CONF_USE_TRANSPARENCY not in config: + config[CONF_USE_TRANSPARENCY] = is_transparent_type + + if is_transparent_type and not config[CONF_USE_TRANSPARENCY]: + raise cv.Invalid(f"Image type {image_type} must always be transparent.") + + return config + + ANIMATION_SCHEMA = cv.Schema( - { - cv.Required(CONF_ID): cv.declare_id(Animation_), - cv.Required(CONF_FILE): cv.file_, - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_TYPE, default="BINARY"): cv.enum( - espImage.IMAGE_TYPE, upper=True - ), - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - } + cv.All( + { + cv.Required(CONF_ID): cv.declare_id(Animation_), + cv.Required(CONF_FILE): cv.file_, + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_TYPE, default="BINARY"): cv.enum( + espImage.IMAGE_TYPE, upper=True + ), + # Not setting default here on purpose; the default depends on the image type, + # and thus will be set in the "validate_cross_dependencies" validator. + cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean, + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + }, + validate_cross_dependencies, + ) ) CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, ANIMATION_SCHEMA) @@ -50,16 +77,19 @@ async def to_code(config): else: if width > 500 or height > 500: _LOGGER.warning( - "The image you requested is very big. Please consider using" - " the resize parameter." + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, ) + transparent = config[CONF_USE_TRANSPARENCY] + if config[CONF_TYPE] == "GRAYSCALE": data = [0 for _ in range(height * width * frames)] pos = 0 for frameIndex in range(frames): image.seek(frameIndex) - frame = image.convert("L", dither=Image.NONE) + frame = image.convert("LA", dither=Image.NONE) if CONF_RESIZE in config: frame = frame.resize([width, height]) pixels = list(frame.getdata()) @@ -67,16 +97,22 @@ async def to_code(config): raise core.EsphomeError( f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height*width})" ) - for pix in pixels: + for pix, a in pixels: + if transparent: + if pix == 1: + pix = 0 + if a < 0x80: + pix = 1 + data[pos] = pix pos += 1 - elif config[CONF_TYPE] == "RGB24": - data = [0 for _ in range(height * width * 3 * frames)] + elif config[CONF_TYPE] == "RGBA": + data = [0 for _ in range(height * width * 4 * frames)] pos = 0 for frameIndex in range(frames): image.seek(frameIndex) - frame = image.convert("RGB") + frame = image.convert("RGBA") if CONF_RESIZE in config: frame = frame.resize([width, height]) pixels = list(frame.getdata()) @@ -91,13 +127,15 @@ async def to_code(config): pos += 1 data[pos] = pix[2] pos += 1 + data[pos] = pix[3] + pos += 1 - elif config[CONF_TYPE] == "RGB565": - data = [0 for _ in range(height * width * 2 * frames)] + elif config[CONF_TYPE] == "RGB24": + data = [0 for _ in range(height * width * 3 * frames)] pos = 0 for frameIndex in range(frames): image.seek(frameIndex) - frame = image.convert("RGB") + frame = image.convert("RGBA") if CONF_RESIZE in config: frame = frame.resize([width, height]) pixels = list(frame.getdata()) @@ -105,14 +143,50 @@ async def to_code(config): raise core.EsphomeError( f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height*width})" ) - for pix in pixels: - R = pix[0] >> 3 - G = pix[1] >> 2 - B = pix[2] >> 3 + for r, g, b, a in pixels: + if transparent: + if r == 0 and g == 0 and b == 1: + b = 0 + if a < 0x80: + r = 0 + g = 0 + b = 1 + + data[pos] = r + pos += 1 + data[pos] = g + pos += 1 + data[pos] = b + pos += 1 + + elif config[CONF_TYPE] in ["RGB565", "TRANSPARENT_IMAGE"]: + data = [0 for _ in range(height * width * 2 * frames)] + pos = 0 + for frameIndex in range(frames): + image.seek(frameIndex) + frame = image.convert("RGBA") + if CONF_RESIZE in config: + frame = frame.resize([width, height]) + pixels = list(frame.getdata()) + if len(pixels) != height * width: + raise core.EsphomeError( + f"Unexpected number of pixels in {path} frame {frameIndex}: ({len(pixels)} != {height*width})" + ) + for r, g, b, a in pixels: + R = r >> 3 + G = g >> 2 + B = b >> 3 rgb = (R << 11) | (G << 5) | B + + if transparent: + if rgb == 0x0020: + rgb = 0 + if a < 0x80: + rgb = 0x0020 + data[pos] = rgb >> 8 pos += 1 - data[pos] = rgb & 255 + data[pos] = rgb & 0xFF pos += 1 elif config[CONF_TYPE] in ["BINARY", "TRANSPARENT_BINARY"]: @@ -120,19 +194,31 @@ async def to_code(config): data = [0 for _ in range((height * width8 // 8) * frames)] for frameIndex in range(frames): image.seek(frameIndex) + if transparent: + alpha = image.split()[-1] + has_alpha = alpha.getextrema()[0] < 0xFF frame = image.convert("1", dither=Image.NONE) if CONF_RESIZE in config: frame = frame.resize([width, height]) - for y in range(height): - for x in range(width): - if frame.getpixel((x, y)): + if transparent: + alpha = alpha.resize([width, height]) + for x, y in [(i, j) for i in range(width) for j in range(height)]: + if transparent and has_alpha: + if not alpha.getpixel((x, y)): continue - pos = x + y * width8 + (height * width8 * frameIndex) - data[pos // 8] |= 0x80 >> (pos % 8) + elif frame.getpixel((x, y)): + continue + + pos = x + y * width8 + (height * width8 * frameIndex) + data[pos // 8] |= 0x80 >> (pos % 8) + else: + raise core.EsphomeError( + f"Animation f{config[CONF_ID]} has not supported type {config[CONF_TYPE]}." + ) rhs = [HexInt(x) for x in data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) - cg.new_Pvariable( + var = cg.new_Pvariable( config[CONF_ID], prog_arr, width, @@ -140,3 +226,4 @@ async def to_code(config): frames, espImage.IMAGE_TYPE[config[CONF_TYPE]], ) + cg.add(var.set_transparency(transparent)) diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d21fbe02be..89598a9636 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -140,8 +140,9 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { /** Stop the cover. * * This is a legacy method and may be removed later, please use `.make_call()` instead. + * As per solution from issue #2885 the call should include perform() */ - ESPDEPRECATED("stop() is deprecated, use make_call().set_command_stop() instead.", "2021.9") + ESPDEPRECATED("stop() is deprecated, use make_call().set_command_stop().perform() instead.", "2021.9") void stop(); void add_on_state_callback(std::function &&f); diff --git a/esphome/components/display/display_buffer.cpp b/esphome/components/display/display_buffer.cpp index f4e7785b5e..35e55bc1ba 100644 --- a/esphome/components/display/display_buffer.cpp +++ b/esphome/components/display/display_buffer.cpp @@ -12,7 +12,7 @@ namespace display { static const char *const TAG = "display"; -const Color COLOR_OFF(0, 0, 0, 0); +const Color COLOR_OFF(0, 0, 0, 255); const Color COLOR_ON(255, 255, 255, 255); void Rect::expand(int16_t horizontal, int16_t vertical) { @@ -307,40 +307,58 @@ void DisplayBuffer::vprintf_(int x, int y, Font *font, Color color, TextAlign al } void DisplayBuffer::image(int x, int y, Image *image, Color color_on, Color color_off) { + bool transparent = image->has_transparency(); + switch (image->get_type()) { - case IMAGE_TYPE_BINARY: + case IMAGE_TYPE_BINARY: { for (int img_x = 0; img_x < image->get_width(); img_x++) { for (int img_y = 0; img_y < image->get_height(); img_y++) { - this->draw_pixel_at(x + img_x, y + img_y, image->get_pixel(img_x, img_y) ? color_on : color_off); + if (image->get_pixel(img_x, img_y)) { + this->draw_pixel_at(x + img_x, y + img_y, color_on); + } else if (!transparent) { + this->draw_pixel_at(x + img_x, y + img_y, color_off); + } } } break; + } case IMAGE_TYPE_GRAYSCALE: for (int img_x = 0; img_x < image->get_width(); img_x++) { for (int img_y = 0; img_y < image->get_height(); img_y++) { - this->draw_pixel_at(x + img_x, y + img_y, image->get_grayscale_pixel(img_x, img_y)); - } - } - break; - case IMAGE_TYPE_RGB24: - for (int img_x = 0; img_x < image->get_width(); img_x++) { - for (int img_y = 0; img_y < image->get_height(); img_y++) { - this->draw_pixel_at(x + img_x, y + img_y, image->get_color_pixel(img_x, img_y)); - } - } - break; - case IMAGE_TYPE_TRANSPARENT_BINARY: - for (int img_x = 0; img_x < image->get_width(); img_x++) { - for (int img_y = 0; img_y < image->get_height(); img_y++) { - if (image->get_pixel(img_x, img_y)) - this->draw_pixel_at(x + img_x, y + img_y, color_on); + auto color = image->get_grayscale_pixel(img_x, img_y); + if (color.w >= 0x80) { + this->draw_pixel_at(x + img_x, y + img_y, color); + } } } break; case IMAGE_TYPE_RGB565: for (int img_x = 0; img_x < image->get_width(); img_x++) { for (int img_y = 0; img_y < image->get_height(); img_y++) { - this->draw_pixel_at(x + img_x, y + img_y, image->get_rgb565_pixel(img_x, img_y)); + auto color = image->get_rgb565_pixel(img_x, img_y); + if (color.w >= 0x80) { + this->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + case IMAGE_TYPE_RGB24: + for (int img_x = 0; img_x < image->get_width(); img_x++) { + for (int img_y = 0; img_y < image->get_height(); img_y++) { + auto color = image->get_color_pixel(img_x, img_y); + if (color.w >= 0x80) { + this->draw_pixel_at(x + img_x, y + img_y, color); + } + } + } + break; + case IMAGE_TYPE_RGBA: + for (int img_x = 0; img_x < image->get_width(); img_x++) { + for (int img_y = 0; img_y < image->get_height(); img_y++) { + auto color = image->get_rgba_pixel(img_x, img_y); + if (color.w >= 0x80) { + this->draw_pixel_at(x + img_x, y + img_y, color); + } } } break; @@ -629,14 +647,27 @@ bool Image::get_pixel(int x, int y) const { const uint32_t pos = x + y * width_8; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Image::get_rgba_pixel(int x, int y) const { + if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) + return Color::BLACK; + const uint32_t pos = (x + y * this->width_) * 4; + return Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1), + progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 3)); +} Color Image::get_color_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) return Color::BLACK; const uint32_t pos = (x + y * this->width_) * 3; - const uint32_t color32 = (progmem_read_byte(this->data_start_ + pos + 2) << 0) | - (progmem_read_byte(this->data_start_ + pos + 1) << 8) | - (progmem_read_byte(this->data_start_ + pos + 0) << 16); - return Color(color32); + Color color = Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1), + progmem_read_byte(this->data_start_ + pos + 2)); + if (color.b == 1 && color.r == 0 && color.g == 0 && transparent_) { + // (0, 0, 1) has been defined as transparent color for non-alpha images. + // putting blue == 1 as a first condition for performance reasons (least likely value to short-cut the if) + color.w = 0; + } else { + color.w = 0xFF; + } + return color; } Color Image::get_rgb565_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) @@ -647,14 +678,22 @@ Color Image::get_rgb565_pixel(int x, int y) const { auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; - return Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)); + Color color = Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)); + if (rgb565 == 0x0020 && transparent_) { + // darkest green has been defined as transparent color for transparent RGB565 images. + color.w = 0; + } else { + color.w = 0xFF; + } + return color; } Color Image::get_grayscale_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) return Color::BLACK; const uint32_t pos = (x + y * this->width_); const uint8_t gray = progmem_read_byte(this->data_start_ + pos); - return Color(gray | gray << 8 | gray << 16 | gray << 24); + uint8_t alpha = (gray == 1 && transparent_) ? 0 : 0xFF; + return Color(gray, gray, gray, alpha); } int Image::get_width() const { return this->width_; } int Image::get_height() const { return this->height_; } @@ -673,6 +712,16 @@ bool Animation::get_pixel(int x, int y) const { const uint32_t pos = x + y * width_8 + frame_index; return progmem_read_byte(this->data_start_ + (pos / 8u)) & (0x80 >> (pos % 8u)); } +Color Animation::get_rgba_pixel(int x, int y) const { + if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) + return Color::BLACK; + const uint32_t frame_index = this->width_ * this->height_ * this->current_frame_; + if (frame_index >= (uint32_t) (this->width_ * this->height_ * this->animation_frame_count_)) + return Color::BLACK; + const uint32_t pos = (x + y * this->width_ + frame_index) * 4; + return Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1), + progmem_read_byte(this->data_start_ + pos + 2), progmem_read_byte(this->data_start_ + pos + 3)); +} Color Animation::get_color_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) return Color::BLACK; @@ -680,10 +729,16 @@ Color Animation::get_color_pixel(int x, int y) const { if (frame_index >= (uint32_t) (this->width_ * this->height_ * this->animation_frame_count_)) return Color::BLACK; const uint32_t pos = (x + y * this->width_ + frame_index) * 3; - const uint32_t color32 = (progmem_read_byte(this->data_start_ + pos + 2) << 0) | - (progmem_read_byte(this->data_start_ + pos + 1) << 8) | - (progmem_read_byte(this->data_start_ + pos + 0) << 16); - return Color(color32); + Color color = Color(progmem_read_byte(this->data_start_ + pos + 0), progmem_read_byte(this->data_start_ + pos + 1), + progmem_read_byte(this->data_start_ + pos + 2)); + if (color.b == 1 && color.r == 0 && color.g == 0 && transparent_) { + // (0, 0, 1) has been defined as transparent color for non-alpha images. + // putting blue == 1 as a first condition for performance reasons (least likely value to short-cut the if) + color.w = 0; + } else { + color.w = 0xFF; + } + return color; } Color Animation::get_rgb565_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) @@ -697,7 +752,14 @@ Color Animation::get_rgb565_pixel(int x, int y) const { auto r = (rgb565 & 0xF800) >> 11; auto g = (rgb565 & 0x07E0) >> 5; auto b = rgb565 & 0x001F; - return Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)); + Color color = Color((r << 3) | (r >> 2), (g << 2) | (g >> 4), (b << 3) | (b >> 2)); + if (rgb565 == 0x0020 && transparent_) { + // darkest green has been defined as transparent color for transparent RGB565 images. + color.w = 0; + } else { + color.w = 0xFF; + } + return color; } Color Animation::get_grayscale_pixel(int x, int y) const { if (x < 0 || x >= this->width_ || y < 0 || y >= this->height_) @@ -707,7 +769,8 @@ Color Animation::get_grayscale_pixel(int x, int y) const { return Color::BLACK; const uint32_t pos = (x + y * this->width_ + frame_index); const uint8_t gray = progmem_read_byte(this->data_start_ + pos); - return Color(gray | gray << 8 | gray << 16 | gray << 24); + uint8_t alpha = (gray == 1 && transparent_) ? 0 : 0xFF; + return Color(gray, gray, gray, alpha); } Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, ImageType type) : Image(data_start, width, height, type), current_frame_(0), animation_frame_count_(animation_frame_count) {} diff --git a/esphome/components/display/display_buffer.h b/esphome/components/display/display_buffer.h index 4477685e1b..a8ec0e588f 100644 --- a/esphome/components/display/display_buffer.h +++ b/esphome/components/display/display_buffer.h @@ -82,8 +82,8 @@ enum ImageType { IMAGE_TYPE_BINARY = 0, IMAGE_TYPE_GRAYSCALE = 1, IMAGE_TYPE_RGB24 = 2, - IMAGE_TYPE_TRANSPARENT_BINARY = 3, - IMAGE_TYPE_RGB565 = 4, + IMAGE_TYPE_RGB565 = 3, + IMAGE_TYPE_RGBA = 4, }; enum DisplayType { @@ -540,6 +540,7 @@ class Image { Image(const uint8_t *data_start, int width, int height, ImageType type); virtual bool get_pixel(int x, int y) const; virtual Color get_color_pixel(int x, int y) const; + virtual Color get_rgba_pixel(int x, int y) const; virtual Color get_rgb565_pixel(int x, int y) const; virtual Color get_grayscale_pixel(int x, int y) const; int get_width() const; @@ -548,11 +549,15 @@ class Image { virtual int get_current_frame() const; + void set_transparency(bool transparent) { transparent_ = transparent; } + bool has_transparency() const { return transparent_; } + protected: int width_; int height_; ImageType type_; const uint8_t *data_start_; + bool transparent_; }; class Animation : public Image { @@ -560,6 +565,7 @@ class Animation : public Image { Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, ImageType type); bool get_pixel(int x, int y) const override; Color get_color_pixel(int x, int y) const override; + Color get_rgba_pixel(int x, int y) const override; Color get_rgb565_pixel(int x, int y) const override; Color get_grayscale_pixel(int x, int y) const override; diff --git a/esphome/components/e131/__init__.py b/esphome/components/e131/__init__.py index bb662e0989..cec0bdf4fa 100644 --- a/esphome/components/e131/__init__.py +++ b/esphome/components/e131/__init__.py @@ -4,6 +4,7 @@ from esphome.components.light.types import AddressableLightEffect from esphome.components.light.effects import register_addressable_effect from esphome.const import CONF_ID, CONF_NAME, CONF_METHOD, CONF_CHANNELS +AUTO_LOAD = ["socket"] DEPENDENCIES = ["network"] e131_ns = cg.esphome_ns.namespace("e131") @@ -23,16 +24,11 @@ CHANNELS = { CONF_UNIVERSE = "universe" CONF_E131_ID = "e131_id" -CONFIG_SCHEMA = cv.All( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(E131Component), - cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of( - *METHODS, upper=True - ), - } - ), - cv.only_with_arduino, +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(E131Component), + cv.Optional(CONF_METHOD, default="MULTICAST"): cv.one_of(*METHODS, upper=True), + } ) diff --git a/esphome/components/e131/e131.cpp b/esphome/components/e131/e131.cpp index 6d584687ce..818006ced7 100644 --- a/esphome/components/e131/e131.cpp +++ b/esphome/components/e131/e131.cpp @@ -1,18 +1,7 @@ -#ifdef USE_ARDUINO - #include "e131.h" #include "e131_addressable_light_effect.h" #include "esphome/core/log.h" -#ifdef USE_ESP32 -#include -#endif - -#ifdef USE_ESP8266 -#include -#include -#endif - namespace esphome { namespace e131 { @@ -22,17 +11,41 @@ static const int PORT = 5568; E131Component::E131Component() {} E131Component::~E131Component() { - if (udp_) { - udp_->stop(); + if (this->socket_) { + this->socket_->close(); } } void E131Component::setup() { - udp_ = make_unique(); + this->socket_ = socket::socket_ip(SOCK_DGRAM, IPPROTO_IP); - if (!udp_->begin(PORT)) { - ESP_LOGE(TAG, "Cannot bind E131 to %d.", PORT); - mark_failed(); + int enable = 1; + int err = this->socket_->setsockopt(SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(int)); + if (err != 0) { + ESP_LOGW(TAG, "Socket unable to set reuseaddr: errno %d", err); + // we can still continue + } + err = this->socket_->setblocking(false); + if (err != 0) { + ESP_LOGW(TAG, "Socket unable to set nonblocking mode: errno %d", err); + this->mark_failed(); + return; + } + + struct sockaddr_storage server; + + socklen_t sl = socket::set_sockaddr_any((struct sockaddr *) &server, sizeof(server), PORT); + if (sl == 0) { + ESP_LOGW(TAG, "Socket unable to set sockaddr: errno %d", errno); + this->mark_failed(); + return; + } + server.ss_family = AF_INET; + + err = this->socket_->bind((struct sockaddr *) &server, sizeof(server)); + if (err != 0) { + ESP_LOGW(TAG, "Socket unable to bind: errno %d", errno); + this->mark_failed(); return; } @@ -43,22 +56,22 @@ void E131Component::loop() { std::vector payload; E131Packet packet; int universe = 0; + uint8_t buf[1460]; - while (uint16_t packet_size = udp_->parsePacket()) { - payload.resize(packet_size); + ssize_t len = this->socket_->read(buf, sizeof(buf)); + if (len == -1) { + return; + } + payload.resize(len); + memmove(&payload[0], buf, len); - if (!udp_->read(&payload[0], payload.size())) { - continue; - } + if (!this->packet_(payload, universe, packet)) { + ESP_LOGV(TAG, "Invalid packet received of size %zu.", payload.size()); + return; + } - if (!packet_(payload, universe, packet)) { - ESP_LOGV(TAG, "Invalid packet received of size %zu.", payload.size()); - continue; - } - - if (!process_(universe, packet)) { - ESP_LOGV(TAG, "Ignored packet for %d universe of size %d.", universe, packet.count); - } + if (!this->process_(universe, packet)) { + ESP_LOGV(TAG, "Ignored packet for %d universe of size %d.", universe, packet.count); } } @@ -106,5 +119,3 @@ bool E131Component::process_(int universe, const E131Packet &packet) { } // namespace e131 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/e131/e131.h b/esphome/components/e131/e131.h index 8bf8999c21..364a05af75 100644 --- a/esphome/components/e131/e131.h +++ b/esphome/components/e131/e131.h @@ -1,7 +1,6 @@ #pragma once -#ifdef USE_ARDUINO - +#include "esphome/components/socket/socket.h" #include "esphome/core/component.h" #include @@ -9,8 +8,6 @@ #include #include -class UDP; - namespace esphome { namespace e131 { @@ -47,7 +44,7 @@ class E131Component : public esphome::Component { void leave_(int universe); E131ListenMethod listen_method_{E131_MULTICAST}; - std::unique_ptr udp_; + std::unique_ptr socket_; std::set light_effects_; std::map universe_consumers_; std::map universe_packets_; @@ -55,5 +52,3 @@ class E131Component : public esphome::Component { } // namespace e131 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/e131/e131_addressable_light_effect.cpp b/esphome/components/e131/e131_addressable_light_effect.cpp index 7a3e71808e..42eb0fc56b 100644 --- a/esphome/components/e131/e131_addressable_light_effect.cpp +++ b/esphome/components/e131/e131_addressable_light_effect.cpp @@ -1,7 +1,5 @@ -#ifdef USE_ARDUINO - -#include "e131.h" #include "e131_addressable_light_effect.h" +#include "e131.h" #include "esphome/core/log.h" namespace esphome { @@ -92,5 +90,3 @@ bool E131AddressableLightEffect::process_(int universe, const E131Packet &packet } // namespace e131 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/e131/e131_addressable_light_effect.h b/esphome/components/e131/e131_addressable_light_effect.h index b3e481e43b..56df9cd80f 100644 --- a/esphome/components/e131/e131_addressable_light_effect.h +++ b/esphome/components/e131/e131_addressable_light_effect.h @@ -1,7 +1,5 @@ #pragma once -#ifdef USE_ARDUINO - #include "esphome/core/component.h" #include "esphome/components/light/addressable_light_effect.h" @@ -44,5 +42,3 @@ class E131AddressableLightEffect : public light::AddressableLightEffect { } // namespace e131 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/e131/e131_packet.cpp b/esphome/components/e131/e131_packet.cpp index f199d3574b..ac8b72f6e7 100644 --- a/esphome/components/e131/e131_packet.cpp +++ b/esphome/components/e131/e131_packet.cpp @@ -1,15 +1,13 @@ -#ifdef USE_ARDUINO - +#include #include "e131.h" +#include "esphome/components/network/ip_address.h" #include "esphome/core/log.h" #include "esphome/core/util.h" -#include "esphome/components/network/ip_address.h" -#include -#include -#include -#include #include +#include +#include +#include namespace esphome { namespace e131 { @@ -62,7 +60,7 @@ const size_t E131_MIN_PACKET_SIZE = reinterpret_cast(&((E131RawPacket *) bool E131Component::join_igmp_groups_() { if (listen_method_ != E131_MULTICAST) return false; - if (!udp_) + if (this->socket_ == nullptr) return false; for (auto universe : universe_consumers_) { @@ -140,5 +138,3 @@ bool E131Component::packet_(const std::vector &data, int &universe, E13 } // namespace e131 } // namespace esphome - -#endif // USE_ARDUINO diff --git a/esphome/components/esp32_rmt_led_strip/led_strip.h b/esphome/components/esp32_rmt_led_strip/led_strip.h index 508f784ec8..11d61b07e1 100644 --- a/esphome/components/esp32_rmt_led_strip/led_strip.h +++ b/esphome/components/esp32_rmt_led_strip/led_strip.h @@ -34,7 +34,7 @@ class ESP32RMTLEDStripLightOutput : public light::AddressableLight { light::LightTraits get_traits() override { auto traits = light::LightTraits(); if (this->is_rgbw_) { - traits.set_supported_color_modes({light::ColorMode::RGB, light::ColorMode::RGB_WHITE}); + traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}); } else { traits.set_supported_color_modes({light::ColorMode::RGB}); } diff --git a/esphome/components/hm3301/sensor.py b/esphome/components/hm3301/sensor.py index 8e9ee4c6fb..27af0b5b6b 100644 --- a/esphome/components/hm3301/sensor.py +++ b/esphome/components/hm3301/sensor.py @@ -16,6 +16,7 @@ from esphome.const import ( ) DEPENDENCIES = ["i2c"] +CODEOWNERS = ["@freekode"] hm3301_ns = cg.esphome_ns.namespace("hm3301") HM3301Component = hm3301_ns.class_( diff --git a/esphome/components/i2c/i2c.cpp b/esphome/components/i2c/i2c.cpp index 82ab7bd09a..fdc9fd1ddf 100644 --- a/esphome/components/i2c/i2c.cpp +++ b/esphome/components/i2c/i2c.cpp @@ -7,6 +7,30 @@ namespace i2c { static const char *const TAG = "i2c"; +ErrorCode I2CDevice::read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop) { + ErrorCode err = this->write(&a_register, 1, stop); + if (err != ERROR_OK) + return err; + return bus_->read(address_, data, len); +} + +ErrorCode I2CDevice::write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop) { + WriteBuffer buffers[2]; + buffers[0].data = &a_register; + buffers[0].len = 1; + buffers[1].data = data; + buffers[1].len = len; + return bus_->writev(address_, buffers, 2, stop); +} + +bool I2CDevice::read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { + if (read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) + return false; + for (size_t i = 0; i < len; i++) + data[i] = i2ctohs(data[i]); + return true; +} + bool I2CDevice::write_bytes_16(uint8_t a_register, const uint16_t *data, uint8_t len) { // we have to copy in order to be able to change byte order std::unique_ptr temp{new uint16_t[len]}; diff --git a/esphome/components/i2c/i2c.h b/esphome/components/i2c/i2c.h index ffc0dadf81..780528a5c7 100644 --- a/esphome/components/i2c/i2c.h +++ b/esphome/components/i2c/i2c.h @@ -46,22 +46,10 @@ class I2CDevice { I2CRegister reg(uint8_t a_register) { return {this, a_register}; } ErrorCode read(uint8_t *data, size_t len) { return bus_->read(address_, data, len); } - ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop = true) { - ErrorCode err = this->write(&a_register, 1, stop); - if (err != ERROR_OK) - return err; - return this->read(data, len); - } + ErrorCode read_register(uint8_t a_register, uint8_t *data, size_t len, bool stop = true); ErrorCode write(const uint8_t *data, uint8_t len, bool stop = true) { return bus_->write(address_, data, len, stop); } - ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop = true) { - WriteBuffer buffers[2]; - buffers[0].data = &a_register; - buffers[0].len = 1; - buffers[1].data = data; - buffers[1].len = len; - return bus_->writev(address_, buffers, 2, stop); - } + ErrorCode write_register(uint8_t a_register, const uint8_t *data, size_t len, bool stop = true); // Compat APIs @@ -85,13 +73,7 @@ class I2CDevice { return res; } - bool read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len) { - if (read_register(a_register, reinterpret_cast(data), len * 2) != ERROR_OK) - return false; - for (size_t i = 0; i < len; i++) - data[i] = i2ctohs(data[i]); - return true; - } + bool read_bytes_16(uint8_t a_register, uint16_t *data, uint8_t len); bool read_byte(uint8_t a_register, uint8_t *data, bool stop = true) { return read_register(a_register, data, 1, stop) == ERROR_OK; diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 39d81ef1a1..d72e13630f 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -18,6 +18,7 @@ MULTI_CONF = True CONF_I2S_DOUT_PIN = "i2s_dout_pin" CONF_I2S_DIN_PIN = "i2s_din_pin" +CONF_I2S_MCLK_PIN = "i2s_mclk_pin" CONF_I2S_BCLK_PIN = "i2s_bclk_pin" CONF_I2S_LRCLK_PIN = "i2s_lrclk_pin" @@ -44,6 +45,7 @@ CONFIG_SCHEMA = cv.Schema( cv.GenerateID(): cv.declare_id(I2SAudioComponent), cv.Required(CONF_I2S_LRCLK_PIN): pins.internal_gpio_output_pin_number, cv.Optional(CONF_I2S_BCLK_PIN): pins.internal_gpio_output_pin_number, + cv.Optional(CONF_I2S_MCLK_PIN): pins.internal_gpio_output_pin_number, } ) @@ -69,3 +71,5 @@ async def to_code(config): cg.add(var.set_lrclk_pin(config[CONF_I2S_LRCLK_PIN])) if CONF_I2S_BCLK_PIN in config: cg.add(var.set_bclk_pin(config[CONF_I2S_BCLK_PIN])) + if CONF_I2S_MCLK_PIN in config: + cg.add(var.set_mclk_pin(config[CONF_I2S_MCLK_PIN])) diff --git a/esphome/components/i2s_audio/i2s_audio.h b/esphome/components/i2s_audio/i2s_audio.h index f030ed4e75..d8d4a23dde 100644 --- a/esphome/components/i2s_audio/i2s_audio.h +++ b/esphome/components/i2s_audio/i2s_audio.h @@ -21,7 +21,7 @@ class I2SAudioComponent : public Component { i2s_pin_config_t get_pin_config() const { return { - .mck_io_num = I2S_PIN_NO_CHANGE, + .mck_io_num = this->mclk_pin_, .bck_io_num = this->bclk_pin_, .ws_io_num = this->lrclk_pin_, .data_out_num = I2S_PIN_NO_CHANGE, @@ -29,6 +29,7 @@ class I2SAudioComponent : public Component { }; } + void set_mclk_pin(int pin) { this->mclk_pin_ = pin; } void set_bclk_pin(int pin) { this->bclk_pin_ = pin; } void set_lrclk_pin(int pin) { this->lrclk_pin_ = pin; } @@ -44,6 +45,7 @@ class I2SAudioComponent : public Component { I2SAudioIn *audio_in_{nullptr}; I2SAudioOut *audio_out_{nullptr}; + int mclk_pin_{I2S_PIN_NO_CHANGE}; int bclk_pin_{I2S_PIN_NO_CHANGE}; int lrclk_pin_; i2s_port_t port_{}; diff --git a/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp b/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp index 4de1136987..6eaa32c23c 100644 --- a/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp +++ b/esphome/components/i2s_audio/media_player/i2s_audio_media_player.cpp @@ -133,7 +133,7 @@ void I2SAudioMediaPlayer::play_() { void I2SAudioMediaPlayer::start() { this->i2s_state_ = I2S_STATE_STARTING; } void I2SAudioMediaPlayer::start_() { - if (this->parent_->try_lock()) { + if (!this->parent_->try_lock()) { return; // Waiting for another i2s to return lock } @@ -156,6 +156,7 @@ void I2SAudioMediaPlayer::start_() { #if SOC_I2S_SUPPORTS_DAC } #endif + this->i2s_state_ = I2S_STATE_RUNNING; this->high_freq_.start(); this->audio_->setVolume(remap(this->volume, 0.0f, 1.0f, 0, 21)); @@ -218,6 +219,12 @@ void I2SAudioMediaPlayer::dump_config() { default: break; } + } else { +#endif + ESP_LOGCONFIG(TAG, " External DAC channels: %d", this->external_dac_channels_); + ESP_LOGCONFIG(TAG, " I2S DOUT Pin: %d", this->dout_pin_); + LOG_PIN(" Mute Pin: ", this->mute_pin_); +#if SOC_I2S_SUPPORTS_DAC } #endif } diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index 48d4d28f8e..089e796ae0 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -2,7 +2,7 @@ import esphome.config_validation as cv import esphome.codegen as cg from esphome import pins -from esphome.const import CONF_ID, CONF_NUMBER +from esphome.const import CONF_CHANNEL, CONF_ID, CONF_NUMBER from esphome.components import microphone, esp32 from esphome.components.adc import ESP32_VARIANT_ADC1_PIN_TO_CHANNEL, validate_adc_pin @@ -25,6 +25,12 @@ I2SAudioMicrophone = i2s_audio_ns.class_( "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component ) +i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") +CHANNELS = { + "left": i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_LEFT, + "right": i2s_channel_fmt_t.I2S_CHANNEL_FMT_ONLY_RIGHT, +} + INTERNAL_ADC_VARIANTS = [esp32.const.VARIANT_ESP32] PDM_VARIANTS = [esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S3] @@ -47,6 +53,7 @@ BASE_SCHEMA = microphone.MICROPHONE_SCHEMA.extend( { cv.GenerateID(): cv.declare_id(I2SAudioMicrophone), cv.GenerateID(CONF_I2S_AUDIO_ID): cv.use_id(I2SAudioComponent), + cv.Optional(CONF_CHANNEL, default="right"): cv.enum(CHANNELS), } ).extend(cv.COMPONENT_SCHEMA) @@ -86,4 +93,6 @@ async def to_code(config): cg.add(var.set_din_pin(config[CONF_I2S_DIN_PIN])) cg.add(var.set_pdm(config[CONF_PDM])) + cg.add(var.set_channel(CHANNELS[config[CONF_CHANNEL]])) + await microphone.register_microphone(var, config) diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp index 0f45cf95c6..9452762e94 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.cpp @@ -49,7 +49,7 @@ void I2SAudioMicrophone::start_() { .mode = (i2s_mode_t) (I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = 16000, .bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT, - .channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT, + .channel_format = this->channel_, .communication_format = I2S_COMM_FORMAT_STAND_I2S, .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1, .dma_buf_count = 4, diff --git a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h index e704ed2915..acc7d2b45a 100644 --- a/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h +++ b/esphome/components/i2s_audio/microphone/i2s_audio_microphone.h @@ -28,6 +28,8 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub } #endif + void set_channel(i2s_channel_fmt_t channel) { this->channel_ = channel; } + protected: void start_(); void stop_(); @@ -40,6 +42,7 @@ class I2SAudioMicrophone : public I2SAudioIn, public microphone::Microphone, pub #endif bool pdm_{false}; std::vector buffer_; + i2s_channel_fmt_t channel_; HighFrequencyLoopRequester high_freq_; }; diff --git a/esphome/components/ili9xxx/ili9xxx_init.h b/esphome/components/ili9xxx/ili9xxx_init.h index e8d3614a1d..593b9a79ce 100644 --- a/esphome/components/ili9xxx/ili9xxx_init.h +++ b/esphome/components/ili9xxx/ili9xxx_init.h @@ -116,8 +116,8 @@ static const uint8_t PROGMEM INITCMD_ILI9486[] = { }; static const uint8_t PROGMEM INITCMD_ILI9488[] = { - ILI9XXX_GMCTRP1,15, 0x00, 0x03, 0x09, 0x08, 0x16, 0x0A, 0x3F, 0x78, 0x4C, 0x09, 0x0A, 0x08, 0x16, 0x1A, 0x0F, - ILI9XXX_GMCTRN1,15, 0x00, 0x16, 0x19, 0x03, 0x0F, 0x05, 0x32, 0x45, 0x46, 0x04, 0x0E, 0x0D, 0x35, 0x37, 0x0F, + ILI9XXX_GMCTRP1,15, 0x0f, 0x24, 0x1c, 0x0a, 0x0f, 0x08, 0x43, 0x88, 0x32, 0x0f, 0x10, 0x06, 0x0f, 0x07, 0x00, + ILI9XXX_GMCTRN1,15, 0x0F, 0x38, 0x30, 0x09, 0x0f, 0x0f, 0x4e, 0x77, 0x3c, 0x07, 0x10, 0x05, 0x23, 0x1b, 0x00, ILI9XXX_PWCTR1, 2, 0x17, 0x15, // VRH1 VRH2 ILI9XXX_PWCTR2, 1, 0x41, // VGH, VGL diff --git a/esphome/components/image/__init__.py b/esphome/components/image/__init__.py index 88c625961b..4260636fa9 100644 --- a/esphome/components/image/__init__.py +++ b/esphome/components/image/__init__.py @@ -22,26 +22,55 @@ MULTI_CONF = True ImageType = display.display_ns.enum("ImageType") IMAGE_TYPE = { "BINARY": ImageType.IMAGE_TYPE_BINARY, + "TRANSPARENT_BINARY": ImageType.IMAGE_TYPE_BINARY, "GRAYSCALE": ImageType.IMAGE_TYPE_GRAYSCALE, - "RGB24": ImageType.IMAGE_TYPE_RGB24, - "TRANSPARENT_BINARY": ImageType.IMAGE_TYPE_TRANSPARENT_BINARY, "RGB565": ImageType.IMAGE_TYPE_RGB565, - "TRANSPARENT_IMAGE": ImageType.IMAGE_TYPE_TRANSPARENT_BINARY, + "RGB24": ImageType.IMAGE_TYPE_RGB24, + "RGBA": ImageType.IMAGE_TYPE_RGBA, } +CONF_USE_TRANSPARENCY = "use_transparency" + Image_ = display.display_ns.class_("Image") + +def validate_cross_dependencies(config): + """ + Validate fields whose possible values depend on other fields. + For example, validate that explicitly transparent image types + have "use_transparency" set to True. + Also set the default value for those kind of dependent fields. + """ + image_type = config[CONF_TYPE] + is_transparent_type = image_type in ["TRANSPARENT_BINARY", "RGBA"] + + # If the use_transparency option was not specified, set the default depending on the image type + if CONF_USE_TRANSPARENCY not in config: + config[CONF_USE_TRANSPARENCY] = is_transparent_type + + if is_transparent_type and not config[CONF_USE_TRANSPARENCY]: + raise cv.Invalid(f"Image type {image_type} must always be transparent.") + + return config + + IMAGE_SCHEMA = cv.Schema( - { - cv.Required(CONF_ID): cv.declare_id(Image_), - cv.Required(CONF_FILE): cv.file_, - cv.Optional(CONF_RESIZE): cv.dimensions, - cv.Optional(CONF_TYPE, default="BINARY"): cv.enum(IMAGE_TYPE, upper=True), - cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( - "NONE", "FLOYDSTEINBERG", upper=True - ), - cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - } + cv.All( + { + cv.Required(CONF_ID): cv.declare_id(Image_), + cv.Required(CONF_FILE): cv.file_, + cv.Optional(CONF_RESIZE): cv.dimensions, + cv.Optional(CONF_TYPE, default="BINARY"): cv.enum(IMAGE_TYPE, upper=True), + # Not setting default here on purpose; the default depends on the image type, + # and thus will be set in the "validate_cross_dependencies" validator. + cv.Optional(CONF_USE_TRANSPARENCY): cv.boolean, + cv.Optional(CONF_DITHER, default="NONE"): cv.one_of( + "NONE", "FLOYDSTEINBERG", upper=True + ), + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + }, + validate_cross_dependencies, + ) ) CONFIG_SCHEMA = cv.All(font.validate_pillow_installed, IMAGE_SCHEMA) @@ -64,72 +93,113 @@ async def to_code(config): else: if width > 500 or height > 500: _LOGGER.warning( - "The image you requested is very big. Please consider using" - " the resize parameter." + 'The image "%s" you requested is very big. Please consider' + " using the resize parameter.", + path, ) + transparent = config[CONF_USE_TRANSPARENCY] + dither = Image.NONE if config[CONF_DITHER] == "NONE" else Image.FLOYDSTEINBERG if config[CONF_TYPE] == "GRAYSCALE": - image = image.convert("L", dither=dither) + image = image.convert("LA", dither=dither) pixels = list(image.getdata()) data = [0 for _ in range(height * width)] pos = 0 - for pix in pixels: - data[pos] = pix + for g, a in pixels: + if transparent: + if g == 1: + g = 0 + if a < 0x80: + g = 1 + + data[pos] = g + pos += 1 + + elif config[CONF_TYPE] == "RGBA": + image = image.convert("RGBA") + pixels = list(image.getdata()) + data = [0 for _ in range(height * width * 4)] + pos = 0 + for r, g, b, a in pixels: + data[pos] = r + pos += 1 + data[pos] = g + pos += 1 + data[pos] = b + pos += 1 + data[pos] = a pos += 1 elif config[CONF_TYPE] == "RGB24": - image = image.convert("RGB") + image = image.convert("RGBA") pixels = list(image.getdata()) data = [0 for _ in range(height * width * 3)] pos = 0 - for pix in pixels: - data[pos] = pix[0] + for r, g, b, a in pixels: + if transparent: + if r == 0 and g == 0 and b == 1: + b = 0 + if a < 0x80: + r = 0 + g = 0 + b = 1 + + data[pos] = r pos += 1 - data[pos] = pix[1] + data[pos] = g pos += 1 - data[pos] = pix[2] + data[pos] = b pos += 1 - elif config[CONF_TYPE] == "RGB565": - image = image.convert("RGB") + elif config[CONF_TYPE] in ["RGB565"]: + image = image.convert("RGBA") pixels = list(image.getdata()) - data = [0 for _ in range(height * width * 3)] + data = [0 for _ in range(height * width * 2)] pos = 0 - for pix in pixels: - R = pix[0] >> 3 - G = pix[1] >> 2 - B = pix[2] >> 3 + for r, g, b, a in pixels: + R = r >> 3 + G = g >> 2 + B = b >> 3 rgb = (R << 11) | (G << 5) | B + + if transparent: + if rgb == 0x0020: + rgb = 0 + if a < 0x80: + rgb = 0x0020 + data[pos] = rgb >> 8 pos += 1 - data[pos] = rgb & 255 + data[pos] = rgb & 0xFF pos += 1 - elif (config[CONF_TYPE] == "BINARY") or (config[CONF_TYPE] == "TRANSPARENT_BINARY"): + elif config[CONF_TYPE] in ["BINARY", "TRANSPARENT_BINARY"]: + if transparent: + alpha = image.split()[-1] + has_alpha = alpha.getextrema()[0] < 0xFF + _LOGGER.debug("%s Has alpha: %s", config[CONF_ID], has_alpha) image = image.convert("1", dither=dither) width8 = ((width + 7) // 8) * 8 data = [0 for _ in range(height * width8 // 8)] for y in range(height): for x in range(width): - if image.getpixel((x, y)): - continue - pos = x + y * width8 - data[pos // 8] |= 0x80 >> (pos % 8) - - elif config[CONF_TYPE] == "TRANSPARENT_IMAGE": - image = image.convert("RGBA") - width8 = ((width + 7) // 8) * 8 - data = [0 for _ in range(height * width8 // 8)] - for y in range(height): - for x in range(width): - if not image.getpixel((x, y))[3]: + if transparent and has_alpha: + a = alpha.getpixel((x, y)) + if not a: + continue + elif image.getpixel((x, y)): continue pos = x + y * width8 data[pos // 8] |= 0x80 >> (pos % 8) + else: + raise core.EsphomeError( + f"Image f{config[CONF_ID]} has an unsupported type: {config[CONF_TYPE]}." + ) rhs = [HexInt(x) for x in data] prog_arr = cg.progmem_array(config[CONF_RAW_DATA_ID], rhs) - cg.new_Pvariable( + var = cg.new_Pvariable( config[CONF_ID], prog_arr, width, height, IMAGE_TYPE[config[CONF_TYPE]] ) + cg.add(var.set_transparency(transparent)) diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 9a22a77f63..a387708263 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -33,6 +33,10 @@ void InternalTemperatureSensor::update() { temp_sensor_config_t tsens = TSENS_CONFIG_DEFAULT(); temp_sensor_set_config(tsens); temp_sensor_start(); +#if defined(USE_ESP32_VARIANT_ESP32S3) && (ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(4, 4, 3)) +#error \ + "ESP32-S3 internal temperature sensor requires ESP IDF V4.4.3 or higher. See https://github.com/esphome/issues/issues/4271" +#endif esp_err_t result = temp_sensor_read_celsius(&temperature); temp_sensor_stop(); success = (result == ESP_OK); diff --git a/esphome/components/internal_temperature/sensor.py b/esphome/components/internal_temperature/sensor.py index 2655711bb5..8d462bd801 100644 --- a/esphome/components/internal_temperature/sensor.py +++ b/esphome/components/internal_temperature/sensor.py @@ -1,18 +1,45 @@ import esphome.codegen as cg import esphome.config_validation as cv from esphome.components import sensor +from esphome.components.esp32 import get_esp32_variant +from esphome.components.esp32.const import ( + VARIANT_ESP32S3, +) from esphome.const import ( STATE_CLASS_MEASUREMENT, UNIT_CELSIUS, DEVICE_CLASS_TEMPERATURE, ENTITY_CATEGORY_DIAGNOSTIC, + KEY_CORE, + KEY_FRAMEWORK_VERSION, ) +from esphome.core import CORE internal_temperature_ns = cg.esphome_ns.namespace("internal_temperature") InternalTemperatureSensor = internal_temperature_ns.class_( "InternalTemperatureSensor", sensor.Sensor, cg.PollingComponent ) + +def validate_config(config): + if CORE.is_esp32: + variant = get_esp32_variant() + if variant == VARIANT_ESP32S3: + if CORE.using_arduino and CORE.data[KEY_CORE][ + KEY_FRAMEWORK_VERSION + ] < cv.Version(2, 0, 6): + raise cv.Invalid( + "ESP32-S3 Internal Temperature Sensor requires framework version 2.0.6 or higher. See ." + ) + if CORE.using_esp_idf and CORE.data[KEY_CORE][ + KEY_FRAMEWORK_VERSION + ] < cv.Version(4, 4, 3): + raise cv.Invalid( + "ESP32-S3 Internal Temperature Sensor requires framework version 4.4.3 or higher. See ." + ) + return config + + CONFIG_SCHEMA = cv.All( sensor.sensor_schema( InternalTemperatureSensor, @@ -23,6 +50,7 @@ CONFIG_SCHEMA = cv.All( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ).extend(cv.polling_component_schema("60s")), cv.only_on(["esp32", "rp2040"]), + validate_config, ) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 66c84da8d8..d9b36c7b09 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_SERVICE, KEY_CORE, KEY_FRAMEWORK_VERSION, + CONF_DISABLED, ) import esphome.codegen as cg import esphome.config_validation as cv @@ -39,7 +40,6 @@ SERVICE_SCHEMA = cv.Schema( } ) -CONF_DISABLED = "disabled" CONFIG_SCHEMA = cv.All( cv.Schema( { diff --git a/esphome/components/modbus_controller/modbus_controller.cpp b/esphome/components/modbus_controller/modbus_controller.cpp index 57f714f233..79c13e3f68 100644 --- a/esphome/components/modbus_controller/modbus_controller.cpp +++ b/esphome/components/modbus_controller/modbus_controller.cpp @@ -506,12 +506,12 @@ void number_to_payload(std::vector &data, int64_t value, SensorValueTy case SensorValueType::U_DWORD: case SensorValueType::S_DWORD: case SensorValueType::FP32: - case SensorValueType::FP32_R: data.push_back((value & 0xFFFF0000) >> 16); data.push_back(value & 0xFFFF); break; case SensorValueType::U_DWORD_R: case SensorValueType::S_DWORD_R: + case SensorValueType::FP32_R: data.push_back(value & 0xFFFF); data.push_back((value & 0xFFFF0000) >> 16); break; diff --git a/esphome/components/mopeka_pro_check/sensor.py b/esphome/components/mopeka_pro_check/sensor.py index 4cd90227ab..51a515ef0c 100644 --- a/esphome/components/mopeka_pro_check/sensor.py +++ b/esphome/components/mopeka_pro_check/sensor.py @@ -44,6 +44,9 @@ CONF_SUPPORTED_TANKS_MAP = { "20LB_V": (38, 254), # empty/full readings for 20lb US tank "30LB_V": (38, 381), "40LB_V": (38, 508), + "EUROPE_6KG": (38, 336), + "EUROPE_11KG": (38, 366), + "EUROPE_14KG": (38, 467), } CODEOWNERS = ["@spbrogan"] diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index af2828ff15..cb5d306976 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -7,6 +7,7 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/core/version.h" #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -14,6 +15,13 @@ #include "lwip/err.h" #include "mqtt_component.h" +#ifdef USE_API +#include "esphome/components/api/api_server.h" +#endif +#ifdef USE_DASHBOARD_IMPORT +#include "esphome/components/dashboard_import/dashboard_import.h" +#endif + namespace esphome { namespace mqtt { @@ -58,9 +66,63 @@ void MQTTClientComponent::setup() { } #endif + this->subscribe( + "esphome/discover", [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, + 2); + + std::string topic = "esphome/ping/"; + topic.append(App.get_name()); + this->subscribe( + topic, [this](const std::string &topic, const std::string &payload) { this->send_device_info_(); }, 2); + this->last_connected_ = millis(); this->start_dnslookup_(); } + +void MQTTClientComponent::send_device_info_() { + if (!this->is_connected()) { + return; + } + std::string topic = "esphome/discover/"; + topic.append(App.get_name()); + this->publish_json( + topic, + [](JsonObject root) { + auto ip = network::get_ip_address(); + root["ip"] = ip.str(); + root["name"] = App.get_name(); +#ifdef USE_API + root["port"] = api::global_api_server->get_port(); +#endif + root["version"] = ESPHOME_VERSION; + root["mac"] = get_mac_address(); + +#ifdef USE_ESP8266 + root["platform"] = "ESP8266"; +#endif +#ifdef USE_ESP32 + root["platform"] = "ESP32"; +#endif + + root["board"] = ESPHOME_BOARD; +#if defined(USE_WIFI) + root["network"] = "wifi"; +#elif defined(USE_ETHERNET) + root["network"] = "ethernet"; +#endif + +#ifdef ESPHOME_PROJECT_NAME + root["project_name"] = ESPHOME_PROJECT_NAME; + root["project_version"] = ESPHOME_PROJECT_VERSION; +#endif // ESPHOME_PROJECT_NAME + +#ifdef USE_DASHBOARD_IMPORT + root["package_import_url"] = dashboard_import::get_package_import_url(); +#endif + }, + 2, this->discovery_info_.retain); +} + void MQTTClientComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT:"); ESP_LOGCONFIG(TAG, " Server Address: %s:%u (%s)", this->credentials_.address.c_str(), this->credentials_.port, @@ -226,6 +288,7 @@ void MQTTClientComponent::check_connected() { delay(100); // NOLINT this->resubscribe_subscriptions_(); + this->send_device_info_(); for (MQTTComponent *component : this->children_) component->schedule_resend_state(); diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 188a027b91..83ed3cc645 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -251,6 +251,8 @@ class MQTTClientComponent : public Component { void set_on_disconnect(mqtt_on_disconnect_callback_t &&callback); protected: + void send_device_info_(); + /// Reconnect to the MQTT broker if not already connected. void start_connect_(); void start_dnslookup_(); diff --git a/esphome/components/psram/psram.cpp b/esphome/components/psram/psram.cpp index 8325709632..68d8dfd697 100644 --- a/esphome/components/psram/psram.cpp +++ b/esphome/components/psram/psram.cpp @@ -21,7 +21,7 @@ void PsramComponent::dump_config() { ESP_LOGCONFIG(TAG, " Available: %s", YESNO(available)); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 1, 0) if (available) { - ESP_LOGCONFIG(TAG, " Size: %d MB", heap_caps_get_total_size(MALLOC_CAP_SPIRAM) / 1024 / 1024); + ESP_LOGCONFIG(TAG, " Size: %d KB", heap_caps_get_total_size(MALLOC_CAP_SPIRAM) / 1024); } #endif } diff --git a/esphome/components/rp2040/__init__.py b/esphome/components/rp2040/__init__.py index 3d0d6ec060..1eba0bf192 100644 --- a/esphome/components/rp2040/__init__.py +++ b/esphome/components/rp2040/__init__.py @@ -1,4 +1,7 @@ import logging +import os + +from string import ascii_letters, digits import esphome.codegen as cg import esphome.config_validation as cv @@ -12,9 +15,11 @@ from esphome.const import ( KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM, ) -from esphome.core import CORE, coroutine_with_priority +from esphome.core import CORE, coroutine_with_priority, EsphomeError +from esphome.helpers import mkdir_p, write_file +import esphome.platformio_api as api -from .const import KEY_BOARD, KEY_RP2040, rp2040_ns +from .const import KEY_BOARD, KEY_PIO_FILES, KEY_RP2040, rp2040_ns # force import gpio to register pin schema from .gpio import rp2040_pin_to_code # noqa @@ -33,6 +38,8 @@ def set_core_data(config): ) CORE.data[KEY_RP2040][KEY_BOARD] = config[CONF_BOARD] + CORE.data[KEY_RP2040][KEY_PIO_FILES] = {} + return config @@ -148,7 +155,10 @@ async def to_code(config): cg.add_platformio_option("platform", conf[CONF_PLATFORM_VERSION]) cg.add_platformio_option( "platform_packages", - [f"earlephilhower/framework-arduinopico@{conf[CONF_SOURCE]}"], + [ + f"earlephilhower/framework-arduinopico@{conf[CONF_SOURCE]}", + "earlephilhower/tool-pioasm-rp2040-earlephilhower", + ], ) cg.add_platformio_option("board_build.core", "earlephilhower") @@ -159,3 +169,53 @@ async def to_code(config): "USE_ARDUINO_VERSION_CODE", cg.RawExpression(f"VERSION_CODE({ver.major}, {ver.minor}, {ver.patch})"), ) + + +def add_pio_file(component: str, key: str, data: str): + try: + cv.validate_id_name(key) + except cv.Invalid as e: + raise EsphomeError( + f"[{component}] Invalid PIO key: {key}. Allowed characters: [{ascii_letters}{digits}_]\nPlease report an issue https://github.com/esphome/issues" + ) from e + CORE.data[KEY_RP2040][KEY_PIO_FILES][key] = data + + +def generate_pio_files() -> bool: + import shutil + + shutil.rmtree(CORE.relative_build_path("src/pio"), ignore_errors=True) + + includes: list[str] = [] + files = CORE.data[KEY_RP2040][KEY_PIO_FILES] + if not files: + return False + for key, data in files.items(): + pio_path = CORE.relative_build_path(f"src/pio/{key}.pio") + mkdir_p(os.path.dirname(pio_path)) + write_file(pio_path, data) + _LOGGER.info("Assembling PIO assembly code") + retval = api.run_platformio_cli( + "pkg", + "exec", + "--package", + "earlephilhower/tool-pioasm-rp2040-earlephilhower", + "--", + "pioasm", + pio_path, + pio_path + ".h", + ) + includes.append(f"pio/{key}.pio.h") + if retval != 0: + raise EsphomeError("PIO assembly failed") + + write_file( + CORE.relative_build_path("src/pio_includes.h"), + "#pragma once\n" + "\n".join([f'#include "{include}"' for include in includes]), + ) + return True + + +# Called by writer.py +def copy_files() -> bool: + return generate_pio_files() diff --git a/esphome/components/rp2040/const.py b/esphome/components/rp2040/const.py index e09016ca31..ab5f42d757 100644 --- a/esphome/components/rp2040/const.py +++ b/esphome/components/rp2040/const.py @@ -2,5 +2,6 @@ import esphome.codegen as cg KEY_BOARD = "board" KEY_RP2040 = "rp2040" +KEY_PIO_FILES = "pio_files" rp2040_ns = cg.esphome_ns.namespace("rp2040") diff --git a/esphome/components/rp2040_pio_led_strip/__init__.py b/esphome/components/rp2040_pio_led_strip/__init__.py new file mode 100644 index 0000000000..4c9aa2d155 --- /dev/null +++ b/esphome/components/rp2040_pio_led_strip/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Papa-DMan"] diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.cpp b/esphome/components/rp2040_pio_led_strip/led_strip.cpp new file mode 100644 index 0000000000..ce1836306f --- /dev/null +++ b/esphome/components/rp2040_pio_led_strip/led_strip.cpp @@ -0,0 +1,139 @@ +#include "led_strip.h" + +#ifdef USE_RP2040 + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome { +namespace rp2040_pio_led_strip { + +static const char *TAG = "rp2040_pio_led_strip"; + +void RP2040PIOLEDStripLightOutput::setup() { + ESP_LOGCONFIG(TAG, "Setting up RP2040 LED Strip..."); + + size_t buffer_size = this->get_buffer_size_(); + + ExternalRAMAllocator allocator(ExternalRAMAllocator::ALLOW_FAILURE); + this->buf_ = allocator.allocate(buffer_size); + if (this->buf_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate buffer of size %u", buffer_size); + this->mark_failed(); + return; + } + + this->effect_data_ = allocator.allocate(this->num_leds_); + if (this->effect_data_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate effect data of size %u", this->num_leds_); + this->mark_failed(); + return; + } + + // Select PIO instance to use (0 or 1) + this->pio_ = pio0; + if (this->pio_ == nullptr) { + ESP_LOGE(TAG, "Failed to claim PIO instance"); + this->mark_failed(); + return; + } + + // Load the assembled program into the PIO and get its location in the PIO's instruction memory + uint offset = pio_add_program(this->pio_, this->program_); + + // Configure the state machine's PIO, and start it + this->sm_ = pio_claim_unused_sm(this->pio_, true); + if (this->sm_ < 0) { + ESP_LOGE(TAG, "Failed to claim PIO state machine"); + this->mark_failed(); + return; + } + this->init_(this->pio_, this->sm_, offset, this->pin_, this->max_refresh_rate_); +} + +void RP2040PIOLEDStripLightOutput::write_state(light::LightState *state) { + ESP_LOGVV(TAG, "Writing state..."); + + if (this->is_failed()) { + ESP_LOGW(TAG, "Light is in failed state, not writing state."); + return; + } + + if (this->buf_ == nullptr) { + ESP_LOGW(TAG, "Buffer is null, not writing state."); + return; + } + + // assemble bits in buffer to 32 bit words with ex for GBR: 0bGGGGGGGGRRRRRRRRBBBBBBBB00000000 + for (int i = 0; i < this->num_leds_; i++) { + uint8_t c1 = this->buf_[(i * 3) + 0]; + uint8_t c2 = this->buf_[(i * 3) + 1]; + uint8_t c3 = this->buf_[(i * 3) + 2]; + uint8_t w = this->is_rgbw_ ? this->buf_[(i * 4) + 3] : 0; + uint32_t color = encode_uint32(c1, c2, c3, w); + pio_sm_put_blocking(this->pio_, this->sm_, color); + } +} + +light::ESPColorView RP2040PIOLEDStripLightOutput::get_view_internal(int32_t index) const { + int32_t r = 0, g = 0, b = 0, w = 0; + switch (this->rgb_order_) { + case ORDER_RGB: + r = 0; + g = 1; + b = 2; + break; + case ORDER_RBG: + r = 0; + g = 2; + b = 1; + break; + case ORDER_GRB: + r = 1; + g = 0; + b = 2; + break; + case ORDER_GBR: + r = 2; + g = 0; + b = 1; + break; + case ORDER_BGR: + r = 2; + g = 1; + b = 0; + break; + case ORDER_BRG: + r = 1; + g = 2; + b = 0; + break; + } + uint8_t multiplier = this->is_rgbw_ ? 4 : 3; + return {this->buf_ + (index * multiplier) + r, + this->buf_ + (index * multiplier) + g, + this->buf_ + (index * multiplier) + b, + this->is_rgbw_ ? this->buf_ + (index * multiplier) + 3 : nullptr, + &this->effect_data_[index], + &this->correction_}; +} + +void RP2040PIOLEDStripLightOutput::dump_config() { + ESP_LOGCONFIG(TAG, "RP2040 PIO LED Strip Light Output:"); + ESP_LOGCONFIG(TAG, " Pin: GPIO%d", this->pin_); + ESP_LOGCONFIG(TAG, " Number of LEDs: %d", this->num_leds_); + ESP_LOGCONFIG(TAG, " RGBW: %s", YESNO(this->is_rgbw_)); + ESP_LOGCONFIG(TAG, " RGB Order: %s", rgb_order_to_string(this->rgb_order_)); + ESP_LOGCONFIG(TAG, " Max Refresh Rate: %f Hz", this->max_refresh_rate_); +} + +float RP2040PIOLEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; } + +} // namespace rp2040_pio_led_strip +} // namespace esphome + +#endif diff --git a/esphome/components/rp2040_pio_led_strip/led_strip.h b/esphome/components/rp2040_pio_led_strip/led_strip.h new file mode 100644 index 0000000000..25ef9ca55f --- /dev/null +++ b/esphome/components/rp2040_pio_led_strip/led_strip.h @@ -0,0 +1,108 @@ +#pragma once + +#ifdef USE_RP2040 + +#include "esphome/core/color.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" + +#include "esphome/components/light/addressable_light.h" +#include "esphome/components/light/light_output.h" + +#include +#include +#include + +namespace esphome { +namespace rp2040_pio_led_strip { + +enum RGBOrder : uint8_t { + ORDER_RGB, + ORDER_RBG, + ORDER_GRB, + ORDER_GBR, + ORDER_BGR, + ORDER_BRG, +}; + +inline const char *rgb_order_to_string(RGBOrder order) { + switch (order) { + case ORDER_RGB: + return "RGB"; + case ORDER_RBG: + return "RBG"; + case ORDER_GRB: + return "GRB"; + case ORDER_GBR: + return "GBR"; + case ORDER_BGR: + return "BGR"; + case ORDER_BRG: + return "BRG"; + default: + return "UNKNOWN"; + } +} + +using init_fn = void (*)(PIO pio, uint sm, uint offset, uint pin, float freq); + +class RP2040PIOLEDStripLightOutput : public light::AddressableLight { + public: + void setup() override; + void write_state(light::LightState *state) override; + float get_setup_priority() const override; + + int32_t size() const override { return this->num_leds_; } + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + this->is_rgbw_ ? traits.set_supported_color_modes({light::ColorMode::RGB_WHITE, light::ColorMode::WHITE}) + : traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + void set_pin(uint8_t pin) { this->pin_ = pin; } + void set_num_leds(uint32_t num_leds) { this->num_leds_ = num_leds; } + void set_is_rgbw(bool is_rgbw) { this->is_rgbw_ = is_rgbw; } + + void set_max_refresh_rate(float interval_us) { this->max_refresh_rate_ = interval_us; } + + void set_pio(int pio_num) { pio_num ? this->pio_ = pio1 : this->pio_ = pio0; } + void set_program(const pio_program_t *program) { this->program_ = program; } + void set_init_function(init_fn init) { this->init_ = init; } + + void set_rgb_order(RGBOrder rgb_order) { this->rgb_order_ = rgb_order; } + void clear_effect_data() override { + for (int i = 0; i < this->size(); i++) { + this->effect_data_[i] = 0; + } + } + + void dump_config() override; + + protected: + light::ESPColorView get_view_internal(int32_t index) const override; + + size_t get_buffer_size_() const { return this->num_leds_ * (3 + this->is_rgbw_); } + + uint8_t *buf_{nullptr}; + uint8_t *effect_data_{nullptr}; + + uint8_t pin_; + uint32_t num_leds_; + bool is_rgbw_; + + pio_hw_t *pio_; + uint sm_; + + RGBOrder rgb_order_{ORDER_RGB}; + + uint32_t last_refresh_{0}; + float max_refresh_rate_; + + const pio_program_t *program_; + init_fn init_; +}; + +} // namespace rp2040_pio_led_strip +} // namespace esphome + +#endif // USE_RP2040 diff --git a/esphome/components/rp2040_pio_led_strip/light.py b/esphome/components/rp2040_pio_led_strip/light.py new file mode 100644 index 0000000000..432ff6935a --- /dev/null +++ b/esphome/components/rp2040_pio_led_strip/light.py @@ -0,0 +1,267 @@ +from dataclasses import dataclass + +from esphome import pins +from esphome.components import light, rp2040 +from esphome.const import ( + CONF_CHIPSET, + CONF_ID, + CONF_NUM_LEDS, + CONF_OUTPUT_ID, + CONF_PIN, + CONF_RGB_ORDER, +) + +import esphome.codegen as cg +import esphome.config_validation as cv + +from esphome.util import _LOGGER + + +def get_nops(timing): + """ + Calculate the number of NOP instructions required to wait for a given amount of time. + """ + time_remaining = timing + nops = [] + if time_remaining < 32: + nops.append(time_remaining - 1) + return nops + nops.append(31) + time_remaining -= 32 + while time_remaining > 0: + if time_remaining >= 32: + nops.append("nop [31]") + time_remaining -= 32 + else: + nops.append("nop [" + str(time_remaining) + " - 1 ]") + time_remaining = 0 + return nops + + +def generate_assembly_code(id, rgbw, t0h, t0l, t1h, t1l): + """ + Generate assembly code with the given timing values. + """ + nops_t0h = get_nops(t0h) + nops_t0l = get_nops(t0l) + nops_t1h = get_nops(t1h) + nops_t1l = get_nops(t1l) + + t0h = nops_t0h.pop(0) + t0l = nops_t0l.pop(0) + t1h = nops_t1h.pop(0) + t1l = nops_t1l.pop(0) + + nops_t0h = "\n".join(" " * 4 + nop for nop in nops_t0h) + nops_t0l = "\n".join(" " * 4 + nop for nop in nops_t0l) + nops_t1h = "\n".join(" " * 4 + nop for nop in nops_t1h) + nops_t1l = "\n".join(" " * 4 + nop for nop in nops_t1l) + + const_csdk_code = f""" +% c-sdk {{ +#include "hardware/clocks.h" + +static inline void rp2040_pio_led_strip_driver_{id}_init(PIO pio, uint sm, uint offset, uint pin, float freq) {{ + pio_gpio_init(pio, pin); + pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true); + + pio_sm_config c = rp2040_pio_led_strip_{id}_program_get_default_config(offset); + sm_config_set_set_pins(&c, pin, 1); + sm_config_set_out_shift(&c, false, true, {32 if rgbw else 24}); + sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); + + int cycles_per_bit = 69; + float div = 2.409; + sm_config_set_clkdiv(&c, div); + + + pio_sm_init(pio, sm, offset, &c); + pio_sm_set_enabled(pio, sm, true); +}} +%}}""" + + assembly_template = f""".program rp2040_pio_led_strip_{id} + +.wrap_target +awaiting_data: + ; Wait for data in FIFO queue + pull block ; this will block until there is data in the FIFO queue and then it will pull it into the shift register + set y, {31 if rgbw else 23} ; set y to the number of bits to write counting 0, (23 if RGB, 31 if RGBW) + +mainloop: + ; go through each bit in the shift register and jump to the appropriate label + ; depending on the value of the bit + + out x, 1 + jmp !x, writezero + jmp writeone + +writezero: + ; Write T0H and T0L bits to the output pin + set pins, 1 [{t0h}] +{nops_t0h} + set pins, 0 [{t0l}] +{nops_t0l} + jmp y--, mainloop + jmp awaiting_data + +writeone: + ; Write T1H and T1L bits to the output pin + set pins, 1 [{t1h}] +{nops_t1h} + set pins, 0 [{t1l}] +{nops_t1l} + jmp y--, mainloop + jmp awaiting_data + +.wrap""" + + return assembly_template + const_csdk_code + + +def time_to_cycles(time_us): + cycles_per_us = 57.5 + cycles = round(float(time_us) * cycles_per_us) + return cycles + + +CONF_PIO = "pio" + +CODEOWNERS = ["@Papa-DMan"] +DEPENDENCIES = ["rp2040"] + +rp2040_pio_led_strip_ns = cg.esphome_ns.namespace("rp2040_pio_led_strip") +RP2040PIOLEDStripLightOutput = rp2040_pio_led_strip_ns.class_( + "RP2040PIOLEDStripLightOutput", light.AddressableLight +) + +RGBOrder = rp2040_pio_led_strip_ns.enum("RGBOrder") + +Chipsets = rp2040_pio_led_strip_ns.enum("Chipset") + + +@dataclass +class LEDStripTimings: + T0H: int + T0L: int + T1H: int + T1L: int + + +RGB_ORDERS = { + "RGB": RGBOrder.ORDER_RGB, + "RBG": RGBOrder.ORDER_RBG, + "GRB": RGBOrder.ORDER_GRB, + "GBR": RGBOrder.ORDER_GBR, + "BGR": RGBOrder.ORDER_BGR, + "BRG": RGBOrder.ORDER_BRG, +} + +CHIPSETS = { + "WS2812": LEDStripTimings(20, 43, 41, 31), + "WS2812B": LEDStripTimings(23, 46, 46, 23), + "SK6812": LEDStripTimings(17, 52, 31, 31), + "SM16703": LEDStripTimings(17, 52, 52, 17), +} + +CONF_IS_RGBW = "is_rgbw" +CONF_BIT0_HIGH = "bit0_high" +CONF_BIT0_LOW = "bit0_low" +CONF_BIT1_HIGH = "bit1_high" +CONF_BIT1_LOW = "bit1_low" + + +def _validate_timing(value): + # if doesn't end with us, raise error + if not value.endswith("us"): + raise cv.Invalid("Timing must be in microseconds (us)") + value = float(value[:-2]) + nops = get_nops(value) + nops.pop(0) + if len(nops) > 3: + raise cv.Invalid("Timing is too long, please try again.") + return value + + +CONFIG_SCHEMA = cv.All( + light.ADDRESSABLE_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(RP2040PIOLEDStripLightOutput), + cv.Required(CONF_PIN): pins.internal_gpio_output_pin_number, + cv.Required(CONF_NUM_LEDS): cv.positive_not_null_int, + cv.Required(CONF_RGB_ORDER): cv.enum(RGB_ORDERS, upper=True), + cv.Required(CONF_PIO): cv.one_of(0, 1, int=True), + cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), + cv.Optional(CONF_IS_RGBW, default=False): cv.boolean, + cv.Inclusive( + CONF_BIT0_HIGH, + "custom", + ): _validate_timing, + cv.Inclusive( + CONF_BIT0_LOW, + "custom", + ): _validate_timing, + cv.Inclusive( + CONF_BIT1_HIGH, + "custom", + ): _validate_timing, + cv.Inclusive( + CONF_BIT1_LOW, + "custom", + ): _validate_timing, + } + ), + cv.has_exactly_one_key(CONF_CHIPSET, CONF_BIT0_HIGH), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + id = config[CONF_ID].id + await light.register_light(var, config) + await cg.register_component(var, config) + + cg.add(var.set_num_leds(config[CONF_NUM_LEDS])) + cg.add(var.set_pin(config[CONF_PIN])) + + cg.add(var.set_rgb_order(config[CONF_RGB_ORDER])) + cg.add(var.set_is_rgbw(config[CONF_IS_RGBW])) + + cg.add(var.set_pio(config[CONF_PIO])) + cg.add(var.set_program(cg.RawExpression(f"&rp2040_pio_led_strip_{id}_program"))) + cg.add( + var.set_init_function( + cg.RawExpression(f"rp2040_pio_led_strip_driver_{id}_init") + ) + ) + + key = f"led_strip_{id}" + + if CONF_CHIPSET in config: + _LOGGER.info("Generating PIO assembly code") + rp2040.add_pio_file( + __name__, + key, + generate_assembly_code( + id, + config[CONF_IS_RGBW], + CHIPSETS[config[CONF_CHIPSET]].T0H, + CHIPSETS[config[CONF_CHIPSET]].T0L, + CHIPSETS[config[CONF_CHIPSET]].T1H, + CHIPSETS[config[CONF_CHIPSET]].T1L, + ), + ) + else: + _LOGGER.info("Generating custom PIO assembly code") + rp2040.add_pio_file( + __name__, + key, + generate_assembly_code( + id, + config[CONF_IS_RGBW], + time_to_cycles(config[CONF_BIT0_HIGH]), + time_to_cycles(config[CONF_BIT0_LOW]), + time_to_cycles(config[CONF_BIT1_HIGH]), + time_to_cycles(config[CONF_BIT1_LOW]), + ), + ) diff --git a/esphome/components/sprinkler/__init__.py b/esphome/components/sprinkler/__init__.py index 5097abc7e7..e1d855778a 100644 --- a/esphome/components/sprinkler/__init__.py +++ b/esphome/components/sprinkler/__init__.py @@ -599,15 +599,6 @@ async def to_code(config): ) cg.add(var.set_controller_auto_adv_switch(sw_aa_var)) - if CONF_QUEUE_ENABLE_SWITCH in sprinkler_controller: - sw_qen_var = await switch.new_switch( - sprinkler_controller[CONF_QUEUE_ENABLE_SWITCH] - ) - await cg.register_component( - sw_qen_var, sprinkler_controller[CONF_QUEUE_ENABLE_SWITCH] - ) - cg.add(var.set_controller_queue_enable_switch(sw_qen_var)) - if CONF_REVERSE_SWITCH in sprinkler_controller: sw_rev_var = await switch.new_switch( sprinkler_controller[CONF_REVERSE_SWITCH] @@ -617,78 +608,83 @@ async def to_code(config): ) cg.add(var.set_controller_reverse_switch(sw_rev_var)) - if CONF_STANDBY_SWITCH in sprinkler_controller: - sw_stb_var = await switch.new_switch( - sprinkler_controller[CONF_STANDBY_SWITCH] - ) - await cg.register_component( - sw_stb_var, sprinkler_controller[CONF_STANDBY_SWITCH] - ) - cg.add(var.set_controller_standby_switch(sw_stb_var)) + if CONF_STANDBY_SWITCH in sprinkler_controller: + sw_stb_var = await switch.new_switch( + sprinkler_controller[CONF_STANDBY_SWITCH] + ) + await cg.register_component( + sw_stb_var, sprinkler_controller[CONF_STANDBY_SWITCH] + ) + cg.add(var.set_controller_standby_switch(sw_stb_var)) - if CONF_MULTIPLIER_NUMBER in sprinkler_controller: - num_mult_var = await number.new_number( - sprinkler_controller[CONF_MULTIPLIER_NUMBER], - min_value=sprinkler_controller[CONF_MULTIPLIER_NUMBER][ - CONF_MIN_VALUE - ], - max_value=sprinkler_controller[CONF_MULTIPLIER_NUMBER][ - CONF_MAX_VALUE - ], - step=sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_STEP], + if CONF_QUEUE_ENABLE_SWITCH in sprinkler_controller: + sw_qen_var = await switch.new_switch( + sprinkler_controller[CONF_QUEUE_ENABLE_SWITCH] + ) + await cg.register_component( + sw_qen_var, sprinkler_controller[CONF_QUEUE_ENABLE_SWITCH] + ) + cg.add(var.set_controller_queue_enable_switch(sw_qen_var)) + + if CONF_MULTIPLIER_NUMBER in sprinkler_controller: + num_mult_var = await number.new_number( + sprinkler_controller[CONF_MULTIPLIER_NUMBER], + min_value=sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_MIN_VALUE], + max_value=sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_MAX_VALUE], + step=sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_STEP], + ) + await cg.register_component( + num_mult_var, sprinkler_controller[CONF_MULTIPLIER_NUMBER] + ) + cg.add( + num_mult_var.set_initial_value( + sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_INITIAL_VALUE] ) - await cg.register_component( - num_mult_var, sprinkler_controller[CONF_MULTIPLIER_NUMBER] + ) + cg.add( + num_mult_var.set_restore_value( + sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_RESTORE_VALUE] ) - cg.add( - num_mult_var.set_initial_value( - sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_INITIAL_VALUE] - ) - ) - cg.add( - num_mult_var.set_restore_value( - sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_RESTORE_VALUE] - ) + ) + + if CONF_SET_ACTION in sprinkler_controller[CONF_MULTIPLIER_NUMBER]: + await automation.build_automation( + num_mult_var.get_set_trigger(), + [(float, "x")], + sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_SET_ACTION], ) - if CONF_SET_ACTION in sprinkler_controller[CONF_MULTIPLIER_NUMBER]: - await automation.build_automation( - num_mult_var.get_set_trigger(), - [(float, "x")], - sprinkler_controller[CONF_MULTIPLIER_NUMBER][CONF_SET_ACTION], - ) + cg.add(var.set_controller_multiplier_number(num_mult_var)) - cg.add(var.set_controller_multiplier_number(num_mult_var)) + if CONF_REPEAT_NUMBER in sprinkler_controller: + num_repeat_var = await number.new_number( + sprinkler_controller[CONF_REPEAT_NUMBER], + min_value=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_MIN_VALUE], + max_value=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_MAX_VALUE], + step=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_STEP], + ) + await cg.register_component( + num_repeat_var, sprinkler_controller[CONF_REPEAT_NUMBER] + ) + cg.add( + num_repeat_var.set_initial_value( + sprinkler_controller[CONF_REPEAT_NUMBER][CONF_INITIAL_VALUE] + ) + ) + cg.add( + num_repeat_var.set_restore_value( + sprinkler_controller[CONF_REPEAT_NUMBER][CONF_RESTORE_VALUE] + ) + ) - if CONF_REPEAT_NUMBER in sprinkler_controller: - num_repeat_var = await number.new_number( - sprinkler_controller[CONF_REPEAT_NUMBER], - min_value=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_MIN_VALUE], - max_value=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_MAX_VALUE], - step=sprinkler_controller[CONF_REPEAT_NUMBER][CONF_STEP], - ) - await cg.register_component( - num_repeat_var, sprinkler_controller[CONF_REPEAT_NUMBER] - ) - cg.add( - num_repeat_var.set_initial_value( - sprinkler_controller[CONF_REPEAT_NUMBER][CONF_INITIAL_VALUE] - ) - ) - cg.add( - num_repeat_var.set_restore_value( - sprinkler_controller[CONF_REPEAT_NUMBER][CONF_RESTORE_VALUE] - ) + if CONF_SET_ACTION in sprinkler_controller[CONF_REPEAT_NUMBER]: + await automation.build_automation( + num_repeat_var.get_set_trigger(), + [(float, "x")], + sprinkler_controller[CONF_REPEAT_NUMBER][CONF_SET_ACTION], ) - if CONF_SET_ACTION in sprinkler_controller[CONF_REPEAT_NUMBER]: - await automation.build_automation( - num_repeat_var.get_set_trigger(), - [(float, "x")], - sprinkler_controller[CONF_REPEAT_NUMBER][CONF_SET_ACTION], - ) - - cg.add(var.set_controller_repeat_number(num_repeat_var)) + cg.add(var.set_controller_repeat_number(num_repeat_var)) for valve in sprinkler_controller[CONF_VALVES]: sw_valve_var = await switch.new_switch(valve[CONF_VALVE_SWITCH]) diff --git a/esphome/components/sprinkler/sprinkler.cpp b/esphome/components/sprinkler/sprinkler.cpp index 52a6cd2af4..095884997c 100644 --- a/esphome/components/sprinkler/sprinkler.cpp +++ b/esphome/components/sprinkler/sprinkler.cpp @@ -147,22 +147,22 @@ SprinklerValveOperator::SprinklerValveOperator(SprinklerValve *valve, Sprinkler : controller_(controller), valve_(valve) {} void SprinklerValveOperator::loop() { - if (millis() >= this->pinned_millis_) { // dummy check + if (millis() >= this->start_millis_) { // dummy check switch (this->state_) { case STARTING: - if (millis() > (this->pinned_millis_ + this->start_delay_)) { + if (millis() > (this->start_millis_ + this->start_delay_)) { this->run_(); // start_delay_ has been exceeded, so ensure both valves are on and update the state } break; case ACTIVE: - if (millis() > (this->pinned_millis_ + this->start_delay_ + this->run_duration_)) { + if (millis() > (this->start_millis_ + this->start_delay_ + this->run_duration_)) { this->stop(); // start_delay_ + run_duration_ has been exceeded, start shutting down } break; case STOPPING: - if (millis() > (this->pinned_millis_ + this->stop_delay_)) { + if (millis() > (this->stop_millis_ + this->stop_delay_)) { this->kill_(); // stop_delay_has been exceeded, ensure all valves are off } break; @@ -183,11 +183,12 @@ void SprinklerValveOperator::set_controller(Sprinkler *controller) { void SprinklerValveOperator::set_valve(SprinklerValve *valve) { if (valve != nullptr) { - this->state_ = IDLE; // reset state - this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it - this->pinned_millis_ = 0; // reset because (new) valve has not been started yet - this->kill_(); // ensure everything is off before we let go! - this->valve_ = valve; // finally, set the pointer to the new valve + this->state_ = IDLE; // reset state + this->run_duration_ = 0; // reset to ensure the valve isn't started without updating it + this->start_millis_ = 0; // reset because (new) valve has not been started yet + this->stop_millis_ = 0; // reset because (new) valve has not been started yet + this->kill_(); // ensure everything is off before we let go! + this->valve_ = valve; // finally, set the pointer to the new valve } } @@ -221,7 +222,8 @@ void SprinklerValveOperator::start() { } else { this->run_(); // there is no start_delay_, so just start the pump and valve } - this->pinned_millis_ = millis(); // save the time the start request was made + this->stop_millis_ = 0; + this->start_millis_ = millis(); // save the time the start request was made } void SprinklerValveOperator::stop() { @@ -238,19 +240,33 @@ void SprinklerValveOperator::stop() { if (this->pump_switch()->state()) { // if the pump is still on at this point, it may be in use... this->valve_off_(); // ...so just switch the valve off now to ensure consistent run time } - this->pinned_millis_ = millis(); // save the time the stop request was made } else { this->kill_(); // there is no stop_delay_, so just stop the pump and valve } + this->stop_millis_ = millis(); // save the time the stop request was made } -uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_; } +uint32_t SprinklerValveOperator::run_duration() { return this->run_duration_ / 1000; } uint32_t SprinklerValveOperator::time_remaining() { - if ((this->state_ == STARTING) || (this->state_ == ACTIVE)) { - return (this->pinned_millis_ + this->start_delay_ + this->run_duration_ - millis()) / 1000; + if (this->start_millis_ == 0) { + return this->run_duration(); // hasn't been started yet } - return 0; + + if (this->stop_millis_) { + if (this->stop_millis_ - this->start_millis_ >= this->start_delay_ + this->run_duration_) { + return 0; // valve was active for more than its configured duration, so we are done + } else { + // we're stopped; return time remaining + return (this->run_duration_ - (this->stop_millis_ - this->start_millis_)) / 1000; + } + } + + auto completed_millis = this->start_millis_ + this->start_delay_ + this->run_duration_; + if (completed_millis > millis()) { + return (completed_millis - millis()) / 1000; // running now + } + return 0; // run completed } SprinklerState SprinklerValveOperator::state() { return this->state_; } @@ -386,6 +402,9 @@ void Sprinkler::loop() { for (auto &vo : this->valve_op_) { vo.loop(); } + if (this->prev_req_.has_request() && this->prev_req_.valve_operator()->state() == IDLE) { + this->prev_req_.reset(); + } } void Sprinkler::add_valve(SprinklerControllerSwitch *valve_sw, SprinklerControllerSwitch *enable_sw) { @@ -732,7 +751,7 @@ bool Sprinkler::auto_advance() { if (this->auto_adv_sw_ != nullptr) { return this->auto_adv_sw_->state; } - return false; + return true; } float Sprinkler::multiplier() { @@ -972,7 +991,14 @@ optional Sprinkler::active_valve_request_is_from return nullopt; } -optional Sprinkler::active_valve() { return this->active_req_.valve_as_opt(); } +optional Sprinkler::active_valve() { + if (!this->valve_overlap_ && this->prev_req_.has_request() && + (this->prev_req_.valve_operator()->state() == STARTING || this->prev_req_.valve_operator()->state() == ACTIVE)) { + return this->prev_req_.valve_as_opt(); + } + return this->active_req_.valve_as_opt(); +} + optional Sprinkler::paused_valve() { return this->paused_valve_; } optional Sprinkler::queued_valve() { @@ -1097,22 +1123,35 @@ uint32_t Sprinkler::total_cycle_time_enabled_valves() { uint32_t Sprinkler::total_cycle_time_enabled_incomplete_valves() { uint32_t total_time_remaining = 0; - uint32_t valve_count = 0; + uint32_t enabled_valve_count = 0; + uint32_t incomplete_valve_count = 0; for (size_t valve = 0; valve < this->number_of_valves(); valve++) { - if (this->valve_is_enabled_(valve) && !this->valve_cycle_complete_(valve)) { - if (!this->active_valve().has_value() || (valve != this->active_valve().value())) { - total_time_remaining += this->valve_run_duration_adjusted(valve); - valve_count++; + if (this->valve_is_enabled_(valve)) { + enabled_valve_count++; + if (!this->valve_cycle_complete_(valve)) { + if (!this->active_valve().has_value() || (valve != this->active_valve().value())) { + total_time_remaining += this->valve_run_duration_adjusted(valve); + incomplete_valve_count++; + } else { + // to get here, there must be an active valve and this valve must be equal to 'valve' + if (this->active_req_.valve_operator() == nullptr) { // no SVO has been assigned yet so it hasn't started + total_time_remaining += this->valve_run_duration_adjusted(valve); + incomplete_valve_count++; + } + } } } } - if (valve_count) { + if (incomplete_valve_count >= enabled_valve_count) { + incomplete_valve_count--; + } + if (incomplete_valve_count) { if (this->valve_overlap_) { - total_time_remaining -= this->switching_delay_.value_or(0) * (valve_count - 1); + total_time_remaining -= this->switching_delay_.value_or(0) * incomplete_valve_count; } else { - total_time_remaining += this->switching_delay_.value_or(0) * (valve_count - 1); + total_time_remaining += this->switching_delay_.value_or(0) * incomplete_valve_count; } } @@ -1149,31 +1188,32 @@ optional Sprinkler::time_remaining_active_valve() { return this->active_req_.valve_operator()->time_remaining(); } } - for (auto &vo : this->valve_op_) { // ...else return the value from the first non-IDLE SprinklerValveOperator - if (vo.state() != IDLE) { - return vo.time_remaining(); + if (this->prev_req_.has_request()) { // try to return the value based on prev_req_... + if (this->prev_req_.valve_operator() != nullptr) { + return this->prev_req_.valve_operator()->time_remaining(); } } return nullopt; } optional Sprinkler::time_remaining_current_operation() { - auto total_time_remaining = this->time_remaining_active_valve(); + if (!this->time_remaining_active_valve().has_value() && this->state_ == IDLE) { + return nullopt; + } - if (total_time_remaining.has_value()) { - if (this->auto_advance()) { - total_time_remaining = total_time_remaining.value() + this->total_cycle_time_enabled_incomplete_valves(); - total_time_remaining = - total_time_remaining.value() + + auto total_time_remaining = this->time_remaining_active_valve().value_or(0); + if (this->auto_advance()) { + total_time_remaining += this->total_cycle_time_enabled_incomplete_valves(); + if (this->repeat().value_or(0) > 0) { + total_time_remaining += (this->total_cycle_time_enabled_valves() * (this->repeat().value_or(0) - this->repeat_count().value_or(0))); } - - if (this->queue_enabled()) { - total_time_remaining = total_time_remaining.value() + this->total_queue_time(); - } - return total_time_remaining; } - return nullopt; + + if (this->queue_enabled()) { + total_time_remaining += this->total_queue_time(); + } + return total_time_remaining; } bool Sprinkler::any_controller_is_active() { @@ -1305,6 +1345,12 @@ optional Sprinkler::next_valve_number_in_cycle_(const optional f } void Sprinkler::load_next_valve_run_request_(const optional first_valve) { + if (this->active_req_.has_request()) { + this->prev_req_ = this->active_req_; + } else { + this->prev_req_.reset(); + } + if (this->next_req_.has_request()) { if (!this->next_req_.run_duration()) { // ensure the run duration is set correctly for consumption later on this->next_req_.set_run_duration(this->valve_run_duration_adjusted(this->next_req_.valve())); diff --git a/esphome/components/sprinkler/sprinkler.h b/esphome/components/sprinkler/sprinkler.h index 7a8285ae73..ae7554d3af 100644 --- a/esphome/components/sprinkler/sprinkler.h +++ b/esphome/components/sprinkler/sprinkler.h @@ -170,7 +170,8 @@ class SprinklerValveOperator { uint32_t start_delay_{0}; uint32_t stop_delay_{0}; uint32_t run_duration_{0}; - uint64_t pinned_millis_{0}; + uint64_t start_millis_{0}; + uint64_t stop_millis_{0}; Sprinkler *controller_{nullptr}; SprinklerValve *valve_{nullptr}; SprinklerState state_{IDLE}; @@ -538,15 +539,18 @@ class Sprinkler : public Component { /// The valve run request that is currently active SprinklerValveRunRequest active_req_; + /// The next run request for the controller to consume after active_req_ is complete + SprinklerValveRunRequest next_req_; + + /// The previous run request the controller processed + SprinklerValveRunRequest prev_req_; + /// The number of the manually selected valve currently selected optional manual_valve_; /// The number of the valve to resume from (if paused) optional paused_valve_; - /// The next run request for the controller to consume after active_req_ is complete - SprinklerValveRunRequest next_req_; - /// Set the number of times to repeat a full cycle optional target_repeats_; diff --git a/esphome/components/substitutions/__init__.py b/esphome/components/substitutions/__init__.py index b65410cbed..ef368015b1 100644 --- a/esphome/components/substitutions/__init__.py +++ b/esphome/components/substitutions/__init__.py @@ -1,19 +1,14 @@ import logging -import re import esphome.config_validation as cv from esphome import core -from esphome.const import CONF_SUBSTITUTIONS +from esphome.const import CONF_SUBSTITUTIONS, VALID_SUBSTITUTIONS_CHARACTERS from esphome.yaml_util import ESPHomeDataBase, make_data_base from esphome.config_helpers import merge_config CODEOWNERS = ["@esphome/core"] _LOGGER = logging.getLogger(__name__) -VALID_SUBSTITUTIONS_CHARACTERS = ( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" -) - def validate_substitution_key(value): value = cv.string(value) @@ -42,12 +37,6 @@ async def to_code(config): pass -# pylint: disable=consider-using-f-string -VARIABLE_PROG = re.compile( - "\\$([{0}]+|\\{{[{0}]*\\}})".format(VALID_SUBSTITUTIONS_CHARACTERS) -) - - def _expand_substitutions(substitutions, value, path, ignore_missing): if "$" not in value: return value @@ -56,7 +45,7 @@ def _expand_substitutions(substitutions, value, path, ignore_missing): i = 0 while True: - m = VARIABLE_PROG.search(value, i) + m = cv.VARIABLE_PROG.search(value, i) if not m: # Nothing more to match. Done break diff --git a/esphome/components/tm1651/__init__.py b/esphome/components/tm1651/__init__.py index 9d2b17afdc..a6b2189eb6 100644 --- a/esphome/components/tm1651/__init__.py +++ b/esphome/components/tm1651/__init__.py @@ -10,6 +10,8 @@ from esphome.const import ( CONF_BRIGHTNESS, ) +CODEOWNERS = ["@freekode"] + tm1651_ns = cg.esphome_ns.namespace("tm1651") TM1651Display = tm1651_ns.class_("TM1651Display", cg.Component) diff --git a/esphome/components/ttp229_bsf/__init__.py b/esphome/components/ttp229_bsf/__init__.py index f1f86c929e..9c8208df83 100644 --- a/esphome/components/ttp229_bsf/__init__.py +++ b/esphome/components/ttp229_bsf/__init__.py @@ -3,7 +3,6 @@ import esphome.config_validation as cv from esphome import pins from esphome.const import CONF_ID, CONF_SDO_PIN, CONF_SCL_PIN -DEPENDENCIES = ["i2c"] AUTO_LOAD = ["binary_sensor"] CONF_TTP229_ID = "ttp229_id" diff --git a/esphome/components/wiegand/wiegand.cpp b/esphome/components/wiegand/wiegand.cpp index c4e834c85a..10c77a8aa2 100644 --- a/esphome/components/wiegand/wiegand.cpp +++ b/esphome/components/wiegand/wiegand.cpp @@ -102,6 +102,16 @@ void Wiegand::loop() { uint8_t key = KEYS[value]; this->send_key_(key); } + } else if (count == 8) { + if ((value ^ 0xf0) >> 4 == (value & 0xf)) { + value &= 0xf; + for (auto *trigger : this->key_triggers_) + trigger->trigger(value); + if (value < 12) { + uint8_t key = KEYS[value]; + this->send_key_(key); + } + } } else { ESP_LOGD(TAG, "received unknown %d-bit value: %llx", count, value); } diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 9f047dd5ed..db19f9bcc0 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -183,6 +183,11 @@ network::IPAddress WiFiComponent::get_ip_address() { return this->wifi_soft_ap_ip(); return {}; } +network::IPAddress WiFiComponent::get_dns_address(int num) { + if (this->has_sta()) + return this->wifi_dns_ip_(num); + return {}; +} std::string WiFiComponent::get_use_address() const { if (this->use_address_.empty()) { return App.get_name() + ".local"; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 3f81b94cce..d42be43b2d 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -240,6 +240,7 @@ class WiFiComponent : public Component { void set_rrm(bool rrm); #endif + network::IPAddress get_dns_address(int num); network::IPAddress get_ip_address(); std::string get_use_address() const; void set_use_address(const std::string &use_address); diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index 54993d48ee..659fd08db1 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -7,6 +7,7 @@ from esphome.const import ( CONF_SCAN_RESULTS, CONF_SSID, CONF_MAC_ADDRESS, + CONF_DNS_ADDRESS, ENTITY_CATEGORY_DIAGNOSTIC, ) @@ -28,6 +29,9 @@ BSSIDWiFiInfo = wifi_info_ns.class_( MacAddressWifiInfo = wifi_info_ns.class_( "MacAddressWifiInfo", text_sensor.TextSensor, cg.Component ) +DNSAddressWifiInfo = wifi_info_ns.class_( + "DNSAddressWifiInfo", text_sensor.TextSensor, cg.PollingComponent +) CONFIG_SCHEMA = cv.Schema( { @@ -46,6 +50,9 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_MAC_ADDRESS): text_sensor.text_sensor_schema( MacAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), + cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( + DNSAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC + ).extend(cv.polling_component_schema("1s")), } ) @@ -63,3 +70,4 @@ async def to_code(config): await setup_conf(config, CONF_BSSID) await setup_conf(config, CONF_MAC_ADDRESS) await setup_conf(config, CONF_SCAN_RESULTS) + await setup_conf(config, CONF_DNS_ADDRESS) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 0b73de68de..eeb4985398 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -11,6 +11,7 @@ void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "WifiInfo Scan Res void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "WifiInfo SSID", this); } void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "WifiInfo BSSID", this); } void MacAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "WifiInfo Mac Address", this); } +void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "WifiInfo DNS Address", this); } } // namespace wifi_info } // namespace esphome diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index e5b0fa3223..35ce108c86 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -24,6 +24,32 @@ class IPAddressWiFiInfo : public PollingComponent, public text_sensor::TextSenso network::IPAddress last_ip_; }; +class DNSAddressWifiInfo : public PollingComponent, public text_sensor::TextSensor { + public: + void update() override { + std::string dns_results; + + auto dns_one = wifi::global_wifi_component->get_dns_address(0); + auto dns_two = wifi::global_wifi_component->get_dns_address(1); + + dns_results += "DNS1: "; + dns_results += dns_one.str(); + dns_results += " DNS2: "; + dns_results += dns_two.str(); + + if (dns_results != this->last_results_) { + this->last_results_ = dns_results; + this->publish_state(dns_results); + } + } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + std::string unique_id() override { return get_mac_address() + "-wifiinfo-dns"; } + void dump_config() override; + + protected: + std::string last_results_; +}; + class ScanResultsWiFiInfo : public PollingComponent, public text_sensor::TextSensor { public: void update() override { diff --git a/esphome/config_validation.py b/esphome/config_validation.py index 2482e5471c..0a6b2dfbb0 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -53,6 +53,7 @@ from esphome.const import ( KEY_TARGET_PLATFORM, TYPE_GIT, TYPE_LOCAL, + VALID_SUBSTITUTIONS_CHARACTERS, ) from esphome.core import ( CORE, @@ -79,6 +80,11 @@ from esphome.yaml_util import make_data_base _LOGGER = logging.getLogger(__name__) +# pylint: disable=consider-using-f-string +VARIABLE_PROG = re.compile( + "\\$([{0}]+|\\{{[{0}]*\\}})".format(VALID_SUBSTITUTIONS_CHARACTERS) +) + # pylint: disable=invalid-name Schema = _Schema @@ -265,6 +271,14 @@ def alphanumeric(value): def valid_name(value): value = string_strict(value) + + if CORE.vscode: + # If the value is a substitution, it can't be validated until the substitution + # is actually made. + sub_match = VARIABLE_PROG.search(value) + if sub_match: + return value + for c in value: if c not in ALLOWED_NAME_CHARS: raise Invalid( @@ -447,6 +461,14 @@ def validate_id_name(value): raise Invalid( "Dashes are not supported in IDs, please use underscores instead." ) + + if CORE.vscode: + # If the value is a substitution, it can't be validated until the substitution + # is actually made + sub_match = VARIABLE_PROG.match(value) + if sub_match: + return value + valid_chars = f"{ascii_letters + digits}_" for char in value: if char not in valid_chars: diff --git a/esphome/const.py b/esphome/const.py index b215619d23..cbc8f428f5 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -3,6 +3,9 @@ __version__ = "2023.6.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" +VALID_SUBSTITUTIONS_CHARACTERS = ( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" +) PLATFORM_ESP32 = "esp32" PLATFORM_ESP8266 = "esp8266" @@ -180,6 +183,7 @@ CONF_DIR_PIN = "dir_pin" CONF_DIRECTION = "direction" CONF_DIRECTION_OUTPUT = "direction_output" CONF_DISABLE_CRC = "disable_crc" +CONF_DISABLED = "disabled" CONF_DISABLED_BY_DEFAULT = "disabled_by_default" CONF_DISCONNECT_DELAY = "disconnect_delay" CONF_DISCOVERY = "discovery" @@ -190,6 +194,7 @@ CONF_DISCOVERY_UNIQUE_ID_GENERATOR = "discovery_unique_id_generator" CONF_DISTANCE = "distance" CONF_DITHER = "dither" CONF_DIV_RATIO = "div_ratio" +CONF_DNS_ADDRESS = "dns_address" CONF_DNS1 = "dns1" CONF_DNS2 = "dns2" CONF_DOMAIN = "domain" @@ -391,6 +396,7 @@ CONF_MAX_SPEED = "max_speed" CONF_MAX_TEMPERATURE = "max_temperature" CONF_MAX_VALUE = "max_value" CONF_MAX_VOLTAGE = "max_voltage" +CONF_MDNS = "mdns" CONF_MEASUREMENT_DURATION = "measurement_duration" CONF_MEASUREMENT_SEQUENCE_NUMBER = "measurement_sequence_number" CONF_MEDIUM = "medium" diff --git a/esphome/dashboard/dashboard.py b/esphome/dashboard/dashboard.py index 1a50592a2d..8d8eb74b4b 100644 --- a/esphome/dashboard/dashboard.py +++ b/esphome/dashboard/dashboard.py @@ -1,4 +1,5 @@ import base64 +import binascii import codecs import collections import functools @@ -76,6 +77,10 @@ class DashboardSettings: def status_use_ping(self): return get_bool_env("ESPHOME_DASHBOARD_USE_PING") + @property + def status_use_mqtt(self): + return get_bool_env("ESPHOME_DASHBOARD_USE_MQTT") + @property def using_ha_addon_auth(self): if not self.on_ha_addon: @@ -583,6 +588,12 @@ class DashboardEntry: return None return self.storage.address + @property + def no_mdns(self): + if self.storage is None: + return None + return self.storage.no_mdns + @property def web_port(self): if self.storage is None: @@ -775,9 +786,12 @@ class MDNSStatusThread(threading.Thread): stat.start() while not STOP_EVENT.is_set(): entries = _list_dashboard_entries() - stat.request_query( - {entry.filename: f"{entry.name}.local." for entry in entries} - ) + hosts = {} + for entry in entries: + if entry.no_mdns is not True: + hosts[entry.filename] = f"{entry.name}.local." + + stat.request_query(hosts) IMPORT_RESULT = imports.import_state PING_REQUEST.wait() @@ -801,6 +815,9 @@ class PingStatusThread(threading.Thread): entries = _list_dashboard_entries() queue = collections.deque() for entry in entries: + if entry.no_mdns is True: + continue + if entry.address is None: PING_RESULT[entry.filename] = None continue @@ -832,10 +849,67 @@ class PingStatusThread(threading.Thread): PING_REQUEST.clear() +class MqttStatusThread(threading.Thread): + def run(self): + from esphome import mqtt + + entries = _list_dashboard_entries() + + config = mqtt.config_from_env() + topic = "esphome/discover/#" + + def on_message(client, userdata, msg): + nonlocal entries + + payload = msg.payload.decode(errors="backslashreplace") + if len(payload) > 0: + data = json.loads(payload) + if "name" not in data: + return + for entry in entries: + if entry.name == data["name"]: + PING_RESULT[entry.filename] = True + return + + def on_connect(client, userdata, flags, return_code): + client.publish("esphome/discover", None, retain=False) + + mqttid = str(binascii.hexlify(os.urandom(6)).decode()) + + client = mqtt.prepare( + config, + [topic], + on_message, + on_connect, + None, + None, + f"esphome-dashboard-{mqttid}", + ) + client.loop_start() + + while not STOP_EVENT.wait(2): + # update entries + entries = _list_dashboard_entries() + + # will be set to true on on_message + for entry in entries: + if entry.no_mdns: + PING_RESULT[entry.filename] = False + + client.publish("esphome/discover", None, retain=False) + MQTT_PING_REQUEST.wait() + MQTT_PING_REQUEST.clear() + + client.disconnect() + client.loop_stop() + + class PingRequestHandler(BaseHandler): @authenticated def get(self): PING_REQUEST.set() + if settings.status_use_mqtt: + MQTT_PING_REQUEST.set() self.set_header("content-type", "application/json") self.write(json.dumps(PING_RESULT)) @@ -910,6 +984,7 @@ PING_RESULT: dict = {} IMPORT_RESULT = {} STOP_EVENT = threading.Event() PING_REQUEST = threading.Event() +MQTT_PING_REQUEST = threading.Event() class LoginHandler(BaseHandler): @@ -1197,6 +1272,11 @@ def start_web_server(args): else: status_thread = MDNSStatusThread() status_thread.start() + + if settings.status_use_mqtt: + status_thread_mqtt = MqttStatusThread() + status_thread_mqtt.start() + try: tornado.ioloop.IOLoop.current().start() except KeyboardInterrupt: @@ -1204,5 +1284,8 @@ def start_web_server(args): STOP_EVENT.set() PING_REQUEST.set() status_thread.join() + if settings.status_use_mqtt: + status_thread_mqtt.join() + MQTT_PING_REQUEST.set() if args.socket is not None: os.remove(args.socket) diff --git a/esphome/helpers.py b/esphome/helpers.py index 884f640d7b..fd8893ad99 100644 --- a/esphome/helpers.py +++ b/esphome/helpers.py @@ -147,6 +147,14 @@ def get_bool_env(var, default=False): return bool(os.getenv(var, default)) +def get_str_env(var, default=None): + return str(os.getenv(var, default)) + + +def get_int_env(var, default=0): + return int(os.getenv(var, default)) + + def is_ha_addon(): return get_bool_env("ESPHOME_IS_HA_ADDON") diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 0ddd976072..166301005d 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -4,6 +4,7 @@ import logging import ssl import sys import time +import json import paho.mqtt.client as mqtt @@ -24,15 +25,45 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.log import color, Fore from esphome.util import safe_print +from esphome.helpers import get_str_env, get_int_env _LOGGER = logging.getLogger(__name__) -def initialize(config, subscriptions, on_message, username, password, client_id): - def on_connect(client, userdata, flags, return_code): +def config_from_env(): + config = { + CONF_MQTT: { + CONF_USERNAME: get_str_env("ESPHOME_DASHBOARD_MQTT_USERNAME"), + CONF_PASSWORD: get_str_env("ESPHOME_DASHBOARD_MQTT_PASSWORD"), + CONF_BROKER: get_str_env("ESPHOME_DASHBOARD_MQTT_BROKER"), + CONF_PORT: get_int_env("ESPHOME_DASHBOARD_MQTT_PORT", 1883), + }, + } + return config + + +def initialize( + config, subscriptions, on_message, on_connect, username, password, client_id +): + client = prepare( + config, subscriptions, on_message, on_connect, username, password, client_id + ) + try: + client.loop_forever() + except KeyboardInterrupt: + pass + return 0 + + +def prepare( + config, subscriptions, on_message, on_connect, username, password, client_id +): + def on_connect_(client, userdata, flags, return_code): _LOGGER.info("Connected to MQTT broker!") for topic in subscriptions: client.subscribe(topic) + if on_connect is not None: + on_connect(client, userdata, flags, return_code) def on_disconnect(client, userdata, result_code): if result_code == 0: @@ -57,7 +88,7 @@ def initialize(config, subscriptions, on_message, username, password, client_id) tries += 1 client = mqtt.Client(client_id or "") - client.on_connect = on_connect + client.on_connect = on_connect_ client.on_message = on_message client.on_disconnect = on_disconnect if username is None: @@ -89,11 +120,88 @@ def initialize(config, subscriptions, on_message, username, password, client_id) except OSError as err: raise EsphomeError(f"Cannot connect to MQTT broker: {err}") from err - try: - client.loop_forever() - except KeyboardInterrupt: - pass - return 0 + return client + + +def show_discover(config, username=None, password=None, client_id=None): + topic = "esphome/discover/#" + _LOGGER.info("Starting log output from %s", topic) + + def on_message(client, userdata, msg): + time_ = datetime.now().time().strftime("[%H:%M:%S]") + payload = msg.payload.decode(errors="backslashreplace") + if len(payload) > 0: + message = time_ + " " + payload + safe_print(message) + + def on_connect(client, userdata, flags, return_code): + _LOGGER.info("Send discover via MQTT broker") + client.publish("esphome/discover", None, retain=False) + + return initialize( + config, [topic], on_message, on_connect, username, password, client_id + ) + + +def get_esphome_device_ip( + config, username=None, password=None, client_id=None, timeout=25 +): + if CONF_MQTT not in config: + raise EsphomeError( + "Cannot discover IP via MQTT as the config does not include the mqtt: " + "component" + ) + if CONF_ESPHOME not in config or CONF_NAME not in config[CONF_ESPHOME]: + raise EsphomeError( + "Cannot discover IP via MQTT as the config does not include the device name: " + "component" + ) + + dev_name = config[CONF_ESPHOME][CONF_NAME] + dev_ip = None + + topic = "esphome/discover/" + dev_name + _LOGGER.info("Starting looking for IP in topic %s", topic) + + def on_message(client, userdata, msg): + nonlocal dev_ip + time_ = datetime.now().time().strftime("[%H:%M:%S]") + payload = msg.payload.decode(errors="backslashreplace") + if len(payload) > 0: + message = time_ + " " + payload + _LOGGER.debug(message) + + data = json.loads(payload) + if "name" not in data or data["name"] != dev_name: + _LOGGER.Warn("Wrong device answer") + return + + if "ip" in data: + dev_ip = data["ip"] + client.disconnect() + + def on_connect(client, userdata, flags, return_code): + topic = "esphome/ping/" + dev_name + _LOGGER.info("Send discover via MQTT broker topic: %s", topic) + client.publish(topic, None, retain=False) + + mqtt_client = prepare( + config, [topic], on_message, on_connect, username, password, client_id + ) + + mqtt_client.loop_start() + while timeout > 0: + if dev_ip is not None: + break + timeout -= 0.250 + time.sleep(0.250) + mqtt_client.loop_stop() + + if dev_ip is None: + raise EsphomeError("Failed to find IP via MQTT") + + _LOGGER.info("Found IP: %s", dev_ip) + return dev_ip def show_logs(config, topic=None, username=None, password=None, client_id=None): @@ -118,7 +226,7 @@ def show_logs(config, topic=None, username=None, password=None, client_id=None): message = time_ + payload safe_print(message) - return initialize(config, [topic], on_message, username, password, client_id) + return initialize(config, [topic], on_message, None, username, password, client_id) def clear_topic(config, topic, username=None, password=None, client_id=None): @@ -142,7 +250,7 @@ def clear_topic(config, topic, username=None, password=None, client_id=None): return client.publish(msg.topic, None, retain=True) - return initialize(config, [topic], on_message, username, password, client_id) + return initialize(config, [topic], on_message, None, username, password, client_id) # From marvinroger/async-mqtt-client -> scripts/get-fingerprint/get-fingerprint.py diff --git a/esphome/storage_json.py b/esphome/storage_json.py index bbdfbbc8a2..acf525203d 100644 --- a/esphome/storage_json.py +++ b/esphome/storage_json.py @@ -10,6 +10,12 @@ from esphome import const from esphome.core import CORE from esphome.helpers import write_file_if_changed + +from esphome.const import ( + CONF_MDNS, + CONF_DISABLED, +) + from esphome.types import CoreType _LOGGER = logging.getLogger(__name__) @@ -46,6 +52,7 @@ class StorageJSON: build_path, firmware_bin_path, loaded_integrations, + no_mdns, ): # Version of the storage JSON schema assert storage_version is None or isinstance(storage_version, int) @@ -75,6 +82,8 @@ class StorageJSON: # A list of strings of names of loaded integrations self.loaded_integrations: list[str] = loaded_integrations self.loaded_integrations.sort() + # Is mDNS disabled + self.no_mdns = no_mdns def as_dict(self): return { @@ -90,6 +99,7 @@ class StorageJSON: "build_path": self.build_path, "firmware_bin_path": self.firmware_bin_path, "loaded_integrations": self.loaded_integrations, + "no_mdns": self.no_mdns, } def to_json(self): @@ -120,6 +130,11 @@ class StorageJSON: build_path=esph.build_path, firmware_bin_path=esph.firmware_bin, loaded_integrations=list(esph.loaded_integrations), + no_mdns=( + CONF_MDNS in esph.config + and CONF_DISABLED in esph.config[CONF_MDNS] + and esph.config[CONF_MDNS][CONF_DISABLED] is True + ), ) @staticmethod @@ -139,6 +154,7 @@ class StorageJSON: build_path=None, firmware_bin_path=None, loaded_integrations=[], + no_mdns=False, ) @staticmethod @@ -159,6 +175,7 @@ class StorageJSON: build_path = storage.get("build_path") firmware_bin_path = storage.get("firmware_bin_path") loaded_integrations = storage.get("loaded_integrations", []) + no_mdns = storage.get("no_mdns", False) return StorageJSON( storage_version, name, @@ -172,6 +189,7 @@ class StorageJSON: build_path, firmware_bin_path, loaded_integrations, + no_mdns, ) @staticmethod diff --git a/esphome/writer.py b/esphome/writer.py index 2bf665c2b2..ad506b6ae6 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -299,6 +299,16 @@ def copy_src_tree(): copy_files() + elif CORE.is_rp2040: + from esphome.components.rp2040 import copy_files + + (pio) = copy_files() + if pio: + write_file_if_changed( + CORE.relative_src_path("esphome.h"), + ESPHOME_H_FORMAT.format(include_s + '\n#include "pio_includes.h"'), + ) + def generate_defines_h(): define_content_l = [x.as_macro for x in CORE.defines] diff --git a/requirements.txt b/requirements.txt index 0da1d8a812..2b5b2c7e1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,16 +2,16 @@ voluptuous==0.13.1 PyYAML==6.0 paho-mqtt==1.6.1 colorama==0.4.6 -tornado==6.3.1 +tornado==6.3.2 tzlocal==5.0.1 # from time tzdata>=2021.1 # from time pyserial==3.5 -platformio==6.1.6 # When updating platformio, also update Dockerfile +platformio==6.1.7 # When updating platformio, also update Dockerfile esptool==4.5.1 click==8.1.3 esphome-dashboard==20230516.0 aioesphomeapi==13.7.5 -zeroconf==0.60.0 +zeroconf==0.62.0 # esp-idf requires this, but doesn't bundle it by default # https://github.com/espressif/esp-idf/blob/220590d599e134d7a5e7f1e683cc4550349ffbf8/requirements.txt#L24 diff --git a/requirements_test.txt b/requirements_test.txt index 55f8da245e..42aba7ddcf 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ -pylint==2.17.3 +pylint==2.17.4 flake8==6.0.0 # also change in .pre-commit-config.yaml when updating black==23.3.0 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.3.2 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.4.0 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests diff --git a/script/ci-custom.py b/script/ci-custom.py index 20f607f987..44ed83f392 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -66,6 +66,7 @@ file_types = ( ".txt", ".ico", ".svg", + ".png", ".py", ".html", ".js", @@ -80,7 +81,7 @@ file_types = ( "", ) cpp_include = ("*.h", "*.c", "*.cpp", "*.tcc") -ignore_types = (".ico", ".woff", ".woff2", "") +ignore_types = (".ico", ".png", ".woff", ".woff2", "") LINT_FILE_CHECKS = [] LINT_CONTENT_CHECKS = [] diff --git a/tests/pnglogo.png b/tests/pnglogo.png new file mode 100644 index 0000000000..bd2fd54783 Binary files /dev/null and b/tests/pnglogo.png differ diff --git a/tests/test1.yaml b/tests/test1.yaml index a792c03c45..a5786d4eec 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -3099,6 +3099,8 @@ text_sensor: name: BSSID mac_address: name: Mac Address + dns_address: + name: DNS ADdress - platform: version name: ESPHome Version No Timestamp hide_timestamp: true diff --git a/tests/test2.yaml b/tests/test2.yaml index 51ec69ef34..8f56336848 100644 --- a/tests/test2.yaml +++ b/tests/test2.yaml @@ -659,6 +659,27 @@ interval: display: +image: + - id: binary_image + file: pnglogo.png + type: BINARY + dither: FloydSteinberg + - id: transparent_transparent_image + file: pnglogo.png + type: TRANSPARENT_BINARY + - id: rgba_image + file: pnglogo.png + type: RGBA + resize: 50x50 + - id: rgb24_image + file: pnglogo.png + type: RGB24 + use_transparency: yes + - id: rgb565_image + file: pnglogo.png + type: RGB565 + use_transparency: no + cap1188: id: cap1188_component address: 0x29 diff --git a/tests/test4.yaml b/tests/test4.yaml index c1d49a4349..8e76a5fd66 100644 --- a/tests/test4.yaml +++ b/tests/test4.yaml @@ -666,6 +666,7 @@ touchscreen: i2s_audio: i2s_lrclk_pin: GPIO26 i2s_bclk_pin: GPIO27 + i2s_mclk_pin: GPIO25 media_player: - platform: i2s_audio diff --git a/tests/test6.yaml b/tests/test6.yaml index 7d4bd7bb19..6d956aa9c8 100644 --- a/tests/test6.yaml +++ b/tests/test6.yaml @@ -37,6 +37,26 @@ switch: output: pin_4 id: pin_4_switch +light: + - platform: rp2040_pio_led_strip + id: led_strip + pin: GPIO13 + num_leds: 60 + pio: 0 + rgb_order: GRB + chipset: WS2812 + - platform: rp2040_pio_led_strip + id: led_strip_custom_timings + pin: GPIO13 + num_leds: 60 + pio: 1 + rgb_order: GRB + bit0_high: .1us + bit0_low: 1.2us + bit1_high: .69us + bit1_low: .4us + + sensor: - platform: internal_temperature name: Internal Temperature diff --git a/tests/test8.yaml b/tests/test8.yaml index 1d3c8a31f4..2430a0d1e6 100644 --- a/tests/test8.yaml +++ b/tests/test8.yaml @@ -4,7 +4,7 @@ wifi: ssid: "ssid" esp32: - board: esp32-c3-devkitm-1 + board: esp32s3box variant: ESP32S3 framework: type: arduino diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 79d00adc29..34f70be2fb 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -6,7 +6,7 @@ from hypothesis.strategies import one_of, text, integers, builds from esphome import config_validation from esphome.config_validation import Invalid -from esphome.core import Lambda, HexInt +from esphome.core import CORE, Lambda, HexInt def test_check_not_templatable__invalid(): @@ -40,6 +40,47 @@ def test_valid_name__invalid(value): config_validation.valid_name(value) +@pytest.mark.parametrize("value", ("${name}", "${NAME}", "$NAME", "${name}_name")) +def test_valid_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.valid_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + actual = config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("{NAME}", "${A NAME}")) +def test_valid_name__substitution_like_invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@pytest.mark.parametrize("value", ("myid", "anID", "SOME_ID_test", "MYID_99")) +def test_validate_id_name__valid(value): + actual = config_validation.validate_id_name(value) + + assert actual == value + + +@pytest.mark.parametrize("value", ("id of mine", "id-4", "{name_id}", "id::name")) +def test_validate_id_name__invalid(value): + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + +@pytest.mark.parametrize("value", ("${id}", "${ID}", "${ID}_test_1", "$MYID")) +def test_validate_id_name__substitution_valid(value): + CORE.vscode = True + actual = config_validation.validate_id_name(value) + assert actual == value + + CORE.vscode = False + with pytest.raises(Invalid): + config_validation.validate_id_name(value) + + @given(one_of(integers(), text())) def test_string__valid(value): actual = config_validation.string(value)