Compare commits

...
26 changed files with 497 additions and 242 deletions

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
name: Install apt packages (cached)
description: >-
Installs libsdl2-dev, libmbedtls-dev and ccache from one shared cache. The cache key comes
only from the package list and version, so both live here and nowhere else.
runs:
using: "composite"
steps:
- name: Keep a failed install out of the cache
# cache-apt-pkgs ignores install failures and saved an empty cache when a stale package
# list got a 404 (upstream awalsh128/cache-apt-pkgs-action#221). It uses an apt-fast
# already on PATH: this one refreshes the lists and retries when the install fails and,
# if that fails too, removes the folder the action would save. Only a cache miss installs.
shell: bash
run: |
sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
Acquire::Retries "1";
Acquire::http::Timeout "15";
Acquire::https::Timeout "15";
EOF
sudo tee /usr/local/bin/apt-fast > /dev/null <<'EOF'
#!/bin/bash
if [[ " $* " != *" install "* ]]; then
exec apt-get "$@"
fi
touch /tmp/apt-fast-standin-ran
apt-get "$@" && exit 0
apt-get update -qq >&2
# The action pins each package to the version the stale lists named
args=()
for arg in "$@"; do
if [[ "${arg}" == -* ]]; then args+=("${arg}"); else args+=("${arg%%=*}"); fi
done
apt-get "${args[@]}" && exit 0
# The action writes our output to install.log inside the folder it saves
log="$(readlink -f "/proc/$$/fd/1")"
if [ "$(basename "${log}")" = install.log ]; then
rm -rf "$(dirname "${log}")"
else
touch /tmp/apt-fast-cleanup-missed
fi
exit 1
EOF
sudo chmod +x /usr/local/bin/apt-fast
- name: Install apt packages (cached)
id: apt
# Pinned on purpose and not watched by Dependabot: the step above relies on how this
# release calls apt-fast and where it writes install.log.
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
# 1.3 drops the empty caches saved before the step above existed
version: 1.3
- name: Check the packages are installed
shell: bash
env:
CACHE_HIT: ${{ steps.apt.outputs.cache-hit }}
run: |
if [ "${CACHE_HIT}" != "true" ] && [ ! -e /tmp/apt-fast-standin-ran ]; then
echo "::error::cache-apt-pkgs did not use the apt-fast stand-in; check the pinned release"
exit 1
fi
if [ -e /tmp/apt-fast-cleanup-missed ]; then
echo "::error::the failed install may have been cached; check the pinned release and bump the version"
exit 1
fi
# The action restores files without registering them with dpkg
for cmd in ccache sdl2-config; do
if ! command -v "${cmd}" > /dev/null; then
echo "::error::${cmd} is missing after the cached install"
exit 1
fi
done
if [ ! -f /usr/include/mbedtls/version.h ]; then
echo "::error::libmbedtls-dev is missing after the cached install"
exit 1
fi
+18 -29
View File
@@ -73,16 +73,17 @@ jobs:
runs-on: ubuntu-24.04
# PR-branch cache saves are invisible to other PRs, so dev/beta/release
# pushes seed the one shared entry PR jobs restore. The key is derived
# only from the package list and version; keep both identical in every
# step that restores it. In ci-status needs so a broken seed fails dev.
# only from the package list and version, kept in the apt-packages action.
# In ci-status needs so a broken seed fails dev.
if: github.event_name == 'push'
timeout-minutes: 10
steps:
- name: Install apt packages (cached)
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
sparse-checkout: .github
- name: Install apt packages (cached)
uses: ./.github/actions/apt-packages
determine-jobs:
name: Determine which jobs to run
@@ -412,14 +413,10 @@ jobs:
- name: Install apt packages (cached)
# ccache speeds up the host compiles. A cache hit never touches apt
# (mirror outages cannot hang the job); the timeout bounds the cold
# path. Packages and version must match seed-apt-cache exactly.
# libsdl2-dev is needed by the headless display tests, which capture
# screenshots.
# path. libsdl2-dev is needed by the headless display tests, which
# capture screenshots.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
uses: ./.github/actions/apt-packages
- name: Set up Python 3.13
id: python
uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
@@ -718,13 +715,9 @@ jobs:
- name: Install apt packages (cached)
if: matrix.pio_cache_key == 'tidyhost'
# libsdl2-dev is needed by the sdl component on the host, libmbedtls-dev
# by dsmr and dlms_meter. Packages and version must match seed-apt-cache
# exactly.
# by dsmr and dlms_meter.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
uses: ./.github/actions/apt-packages
- name: Restore Python
id: restore-python
@@ -1111,20 +1104,16 @@ jobs:
- name: List components
run: echo ${{ matrix.batch.components }}
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install apt packages (cached)
# A cache hit (seeded on dev by seed-apt-cache) never touches apt,
# so mirror outages cannot hang this PR-only job; the timeout bounds
# the cold path. Packages and version must match seed-apt-cache
# exactly. The action has no --no-install-recommends; same package
# set this job used before #17463.
# the cold path. The action has no --no-install-recommends; same
# package set this job used before #17463.
timeout-minutes: 10
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
packages: libsdl2-dev libmbedtls-dev ccache
version: 1.2
- name: Check out code from GitHub
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: ./.github/actions/apt-packages
- name: Restore Python
uses: ./.github/actions/restore-python
with:
+27 -2
View File
@@ -1,5 +1,4 @@
#include "ld2410s.h"
#include "ld2410s_number.h"
#include <cstring>
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
@@ -40,6 +39,11 @@ static constexpr uint8_t CMD_FRAME_MAX_SIZE = LONG_PAYLOAD_POS + 2 + PARAMETERS_
#ifdef USE_LD2410S_PARAMETERS
static constexpr uint16_t PARAMETER_KEYS[] PROGMEM = {0x0005, 0x000A, 0x0006, 0x0002, 0x000C, 0x000B};
static_assert(sizeof(PARAMETER_KEYS) / sizeof(PARAMETER_KEYS[0]) == PARAMETER_COUNT, "one key per parameter");
#ifdef USE_NUMBER
// Module units to number units: 0.7 m gates, seconds, tenths of a hertz (number.py uses the same)
static constexpr float PARAMETER_SCALES[] PROGMEM = {0.7f, 0.7f, 1.0f, 0.1f, 0.1f, 1.0f};
static_assert(sizeof(PARAMETER_SCALES) / sizeof(PARAMETER_SCALES[0]) == PARAMETER_COUNT, "one scale per parameter");
#endif
#endif
// The version answer: 4 bytes device type, then major, minor and patch as 16-bit words
static constexpr uint8_t VERSION_TYPE_SIZE = 4;
@@ -136,6 +140,27 @@ bool LD2410SComponent::set_parameter(Parameter parameter, uint8_t value) {
this->parameters_dirty_ = true; // written by loop() once no sequence is running
return true;
}
#ifdef USE_NUMBER
void LD2410SComponent::control_number(number::Number *number, float value) {
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != number)
continue;
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / PARAMETER_SCALES[i]); // the schema keeps every parameter within a byte
if (this->set_parameter(static_cast<Parameter>(i), raw))
number->publish_state(raw * PARAMETER_SCALES[i]);
return;
}
}
#endif
#ifdef USE_SELECT
void LD2410SComponent::control_response_speed(select::Select *select, size_t index) {
if (this->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
select->publish_state(index);
}
#endif
#endif
void LD2410SComponent::restart_init_(uint32_t at) {
@@ -502,7 +527,7 @@ void LD2410SComponent::publish_parameters_() {
#ifdef USE_NUMBER
for (uint8_t i = 0; i < PARAMETER_COUNT; i++) {
if (this->numbers_[i] != nullptr) {
this->numbers_[i]->publish_raw(this->parameters_[i]);
this->numbers_[i]->publish_state(this->parameters_[i] * PARAMETER_SCALES[i]);
}
}
#endif
+10 -6
View File
@@ -14,6 +14,9 @@
#ifdef USE_LD2410S_VERSION
#include "esphome/components/text_sensor/text_sensor.h"
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/number/number.h"
#endif
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "esphome/components/select/select.h"
#endif
@@ -39,10 +42,6 @@ static constexpr uint8_t PARAMETER_COUNT = static_cast<uint8_t>(Parameter::PARAM
static constexpr uint8_t RESPONSE_SPEEDS[] PROGMEM = {5, 10};
static constexpr uint8_t RESPONSE_SPEED_COUNT = sizeof(RESPONSE_SPEEDS);
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
class LD2410SNumber;
#endif
class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_BINARY_SENSOR
SUB_BINARY_SENSOR(target)
@@ -67,9 +66,14 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#ifdef USE_LD2410S_PARAMETERS
bool set_parameter(Parameter parameter, uint8_t value);
#ifdef USE_NUMBER
void set_number(Parameter parameter, LD2410SNumber *number) {
void set_number(Parameter parameter, number::Number *number) {
this->numbers_[static_cast<uint8_t>(parameter)] = number;
}
/// Writes the number's parameter and publishes the step the module got.
void control_number(number::Number *number, float value);
#endif
#ifdef USE_SELECT
void control_response_speed(select::Select *select, size_t index);
#endif
#endif
@@ -109,7 +113,7 @@ class LD2410SComponent final : public Component, public uart::UARTDevice {
#endif
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
LD2410SNumber *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
number::Number *numbers_[PARAMETER_COUNT]{}; // null for parameters without a number
#endif
const uint16_t *sequence_{nullptr}; // PROGMEM; null while nothing is being sent
@@ -1,32 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NUMBER) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/number/number.h"
namespace esphome::ld2410s {
/// Edits one common parameter; scale converts the module's units to the number's.
class LD2410SNumber final : public number::Number, public Parented<LD2410SComponent> {
public:
LD2410SNumber(Parameter parameter, float scale) : parameter_(parameter), scale_(scale) {}
void publish_raw(uint8_t raw) { this->publish_state(raw * this->scale_); }
protected:
void control(float value) override {
// Show the step the module got, not a value between two steps
const uint8_t raw = lroundf(value / this->scale_); // the schema keeps every parameter within a byte
if (this->parent_->set_parameter(this->parameter_, raw))
this->publish_raw(raw);
}
const Parameter parameter_;
const float scale_;
};
} // namespace esphome::ld2410s
#endif // USE_NUMBER && USE_LD2410S_PARAMETERS
@@ -1,21 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_SELECT) && defined(USE_LD2410S_PARAMETERS)
#include "ld2410s.h"
#include "esphome/components/select/select.h"
namespace esphome::ld2410s {
class LD2410SSelect final : public select::Select, public Parented<LD2410SComponent> {
protected:
void control(size_t index) override {
if (this->parent_->set_parameter(Parameter::PARAMETER_RESPONSE_SPEED, progmem_read_byte(&RESPONSE_SPEEDS[index])))
this->publish_state(index);
}
};
} // namespace esphome::ld2410s
#endif // USE_SELECT && USE_LD2410S_PARAMETERS
+9 -15
View File
@@ -16,7 +16,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter, ld2410s_ns
from . import CONF_LD2410S_ID, LD2410SComponent, Parameter
DEPENDENCIES = ["ld2410s"]
@@ -26,9 +26,8 @@ CONF_MIN_DISTANCE = "min_distance"
CONF_NO_DELAY = "no_delay"
CONF_STATUS_REPORTING_FREQUENCY = "status_reporting_frequency"
LD2410SNumber = ld2410s_ns.class_("LD2410SNumber", number.Number)
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz
# The module counts distances in 0.7 m gates and frequencies in tenths of a hertz;
# PARAMETER_SCALES in ld2410s.cpp converts with the same factors
GATE_WIDTH_M = 0.7
FREQUENCY_SCALE = 0.1
_DISTANCE_RANGE = {"min_value": 0, "max_value": 16 * GATE_WIDTH_M, "step": GATE_WIDTH_M}
@@ -37,7 +36,7 @@ _FREQUENCY_RANGE = {"min_value": 0.5, "max_value": 8, "step": 0.5}
def _number_schema(device_class: str, unit: str, icon: str) -> cv.Schema:
return number.number_schema(
LD2410SNumber,
number.ApplyNumber,
device_class=device_class,
entity_category=ENTITY_CATEGORY_CONFIG,
unit_of_measurement=unit,
@@ -72,22 +71,19 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_MAX_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MAX_DISTANCE),
Parameter.PARAMETER_MAX_DISTANCE,
GATE_WIDTH_M,
apply="control_number(entity, value)",
**_DISTANCE_RANGE,
)
await numbers(
CONF_MIN_DISTANCE,
partial(hub.set_number, Parameter.PARAMETER_MIN_DISTANCE),
Parameter.PARAMETER_MIN_DISTANCE,
GATE_WIDTH_M,
apply="control_number(entity, value)",
**_DISTANCE_RANGE,
)
await numbers(
CONF_NO_DELAY,
partial(hub.set_number, Parameter.PARAMETER_NO_DELAY),
Parameter.PARAMETER_NO_DELAY,
1,
apply="control_number(entity, value)",
min_value=1,
max_value=120,
step=1,
@@ -95,14 +91,12 @@ async def to_code(config: ConfigType) -> None:
await numbers(
CONF_STATUS_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_STATUS_FREQUENCY),
Parameter.PARAMETER_STATUS_FREQUENCY,
FREQUENCY_SCALE,
apply="control_number(entity, value)",
**_FREQUENCY_RANGE,
)
await numbers(
CONF_DISTANCE_REPORTING_FREQUENCY,
partial(hub.set_number, Parameter.PARAMETER_DISTANCE_FREQUENCY),
Parameter.PARAMETER_DISTANCE_FREQUENCY,
FREQUENCY_SCALE,
apply="control_number(entity, value)",
**_FREQUENCY_RANGE,
)
+6 -5
View File
@@ -4,18 +4,16 @@ import esphome.config_validation as cv
from esphome.const import ENTITY_CATEGORY_CONFIG
from esphome.types import ConfigType
from . import CONF_LD2410S_ID, LD2410SComponent, ld2410s_ns
from . import CONF_LD2410S_ID, LD2410SComponent
DEPENDENCIES = ["ld2410s"]
CONF_RESPONSE_SPEED = "response_speed"
LD2410SSelect = ld2410s_ns.class_("LD2410SSelect", select.Select)
CONFIG_SCHEMA = {
cv.GenerateID(CONF_LD2410S_ID): cv.use_id(LD2410SComponent),
cv.Required(CONF_RESPONSE_SPEED): select.select_schema(
LD2410SSelect,
select.ApplySelect,
entity_category=ENTITY_CATEGORY_CONFIG,
icon="mdi:speedometer",
),
@@ -27,5 +25,8 @@ async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_LD2410S_ID])
selects = select.sub_selects(config, parent=hub)
await selects(
CONF_RESPONSE_SPEED, hub.set_response_speed_select, options=["Normal", "Fast"]
CONF_RESPONSE_SPEED,
hub.set_response_speed_select,
options=["Normal", "Fast"],
apply="control_response_speed(entity, index)",
)
@@ -12,7 +12,7 @@ void PipsolarOutput::write_state(float state) {
if (std::find(this->possible_values_.begin(), this->possible_values_.end(), state) != this->possible_values_.end()) {
ESP_LOGD(TAG, "Will write: %s out of value %f / %02.0f", tmp, state, state);
this->parent_->queue_command(std::string(tmp));
this->parent_->queue_command(tmp);
} else {
ESP_LOGD(TAG, "Will not write: %s as it is not in list of allowed values", tmp);
}
+9 -3
View File
@@ -224,17 +224,23 @@ bool Pipsolar::send_next_poll_() {
return false;
}
void Pipsolar::queue_command(const std::string &command) {
void Pipsolar::write_switch(bool state, const char *on, const char *off) {
const char *command = state ? on : off;
if (command != nullptr)
this->queue_command(command);
}
void Pipsolar::queue_command(const char *command) {
uint8_t next_position = command_queue_position_;
for (uint8_t i = 0; i < COMMAND_QUEUE_LENGTH; i++) {
uint8_t testposition = (next_position + i) % COMMAND_QUEUE_LENGTH;
if (command_queue_[testposition].empty()) {
command_queue_[testposition] = command;
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command.c_str(), testposition);
ESP_LOGD(TAG, "Command queued successfully: %s at position %d", command, testposition);
return;
}
}
ESP_LOGD(TAG, "Command queue full dropping command: %s", command.c_str());
ESP_LOGD(TAG, "Command queue full dropping command: %s", command);
}
void Pipsolar::handle_poll_response_(ENUMPollingCommand polling_command, const char *message) {
+4 -1
View File
@@ -194,7 +194,10 @@ class Pipsolar final : public uart::UARTDevice, public PollingComponent {
PIPSOLAR_SWITCH(pv_ok_condition_for_parallel_switch, QPIRI)
PIPSOLAR_SWITCH(pv_power_balance_switch, QPIRI)
void queue_command(const std::string &command);
void queue_command(const char *command);
void queue_command(const std::string &command) { this->queue_command(command.c_str()); }
// A switch write; off is nullptr when the inverter has no off command
void write_switch(bool state, const char *on, const char *off);
void setup() override;
void loop() override;
void dump_config() override;
+58 -24
View File
@@ -1,10 +1,14 @@
from collections.abc import Callable
from typing import NamedTuple
import esphome.codegen as cg
from esphome.components import switch
import esphome.config_validation as cv
from esphome.const import ICON_POWER
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA, pipsolar_ns
from .. import CONF_PIPSOLAR_ID, PIPSOLAR_COMPONENT_SCHEMA
DEPENDENCIES = ["uart"]
@@ -16,21 +20,55 @@ CONF_INPUT_VOLTAGE_RANGE = "input_voltage_range"
CONF_PV_OK_CONDITION_FOR_PARALLEL = "pv_ok_condition_for_parallel"
CONF_PV_POWER_BALANCE = "pv_power_balance"
class _PipSwitch(NamedTuple):
setter: Callable[[MockObj], MockObj]
on: str
off: str | None # None when the inverter has no off command
# Each switch's hub setter and its on and off commands
TYPES = {
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: ("POP00", None),
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: ("POP01", None),
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: ("POP02", None),
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: ("POP03", None),
CONF_INPUT_VOLTAGE_RANGE: ("PGR01", "PGR00"),
CONF_PV_OK_CONDITION_FOR_PARALLEL: ("PPVOKC1", "PPVOKC0"),
CONF_PV_POWER_BALANCE: ("PSPB1", "PSPB0"),
CONF_OUTPUT_SOURCE_PRIORITY_UTILITY: _PipSwitch(
lambda hub: hub.set_output_source_priority_utility_switch,
"POP00",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_SOLAR: _PipSwitch(
lambda hub: hub.set_output_source_priority_solar_switch,
"POP01",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_BATTERY: _PipSwitch(
lambda hub: hub.set_output_source_priority_battery_switch,
"POP02",
None,
),
CONF_OUTPUT_SOURCE_PRIORITY_HYBRID: _PipSwitch(
lambda hub: hub.set_output_source_priority_hybrid_switch,
"POP03",
None,
),
CONF_INPUT_VOLTAGE_RANGE: _PipSwitch(
lambda hub: hub.set_input_voltage_range_switch,
"PGR01",
"PGR00",
),
CONF_PV_OK_CONDITION_FOR_PARALLEL: _PipSwitch(
lambda hub: hub.set_pv_ok_condition_for_parallel_switch,
"PPVOKC1",
"PPVOKC0",
),
CONF_PV_POWER_BALANCE: _PipSwitch(
lambda hub: hub.set_pv_power_balance_switch,
"PSPB1",
"PSPB0",
),
}
PipsolarSwitch = pipsolar_ns.class_("PipsolarSwitch", switch.Switch, cg.Component)
PIPSWITCH_SCHEMA = switch.switch_schema(
PipsolarSwitch, icon=ICON_POWER, block_inverted=True
).extend(cv.COMPONENT_SCHEMA)
switch.ApplySwitch, icon=ICON_POWER, block_inverted=True
)
CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
{cv.Optional(type): PIPSWITCH_SCHEMA for type in TYPES}
@@ -38,15 +76,11 @@ CONFIG_SCHEMA = PIPSOLAR_COMPONENT_SCHEMA.extend(
async def to_code(config: ConfigType) -> None:
paren = await cg.get_variable(config[CONF_PIPSOLAR_ID])
for type, (on, off) in TYPES.items():
if type in config:
conf = config[type]
var = await switch.new_switch(conf)
await cg.register_component(var, conf)
cg.add(getattr(paren, f"set_{type}_switch")(var))
cg.add(var.set_parent(paren))
cg.add(var.set_on_command(on))
if off is not None:
cg.add(var.set_off_command(off))
hub = await cg.get_variable(config[CONF_PIPSOLAR_ID])
switches = switch.sub_switches(config, parent=hub)
# The hub polls the inverter and publishes each switch's state
for key, sw in TYPES.items():
off_arg = "nullptr" if sw.off is None else f'"{sw.off}"'
await switches(
key, sw.setter(hub), apply=f'write_switch(state, "{sw.on}", {off_arg})'
)
@@ -1,17 +0,0 @@
#include "pipsolar_switch.h"
#include "esphome/core/log.h"
#include "esphome/core/application.h"
namespace esphome::pipsolar {
ESPHOME_LOG_TAG(TAG, "pipsolar.switch");
void PipsolarSwitch::dump_config() { LOG_SWITCH("", "Pipsolar Switch", this); }
void PipsolarSwitch::write_state(bool state) {
const char *command = state ? this->on_command_ : this->off_command_;
if (command != nullptr) {
this->parent_->queue_command(command);
}
}
} // namespace esphome::pipsolar
@@ -1,26 +0,0 @@
#pragma once
#include "../pipsolar.h"
#include "esphome/components/switch/switch.h"
#include "esphome/core/component.h"
namespace esphome::pipsolar {
class Pipsolar;
class PipsolarSwitch final : public switch_::Switch, public Component {
public:
void set_parent(Pipsolar *parent) { this->parent_ = parent; }
void set_on_command(const char *command) { this->on_command_ = command; }
void set_off_command(const char *command) { this->off_command_ = command; }
/// Prevent accidental use of std::string which would dangle
void set_on_command(const std::string &command) = delete;
void set_off_command(const std::string &command) = delete;
void dump_config() override;
protected:
void write_state(bool state) override;
const char *on_command_{nullptr};
const char *off_command_{nullptr};
Pipsolar *parent_;
};
} // namespace esphome::pipsolar
@@ -162,6 +162,11 @@ void SendspinHub::setup() {
if (this->unpaired_access_.has_value()) {
this->client_->set_unpaired_access_enabled(*this->unpaired_access_);
}
#ifdef USE_SENDSPIN_ENABLED_SWITCH
// loop() waits for this, so a restore mode without a state still has to answer.
this->enabled_switch_->restore(true);
#endif
}
void SendspinHub::loop() {
@@ -211,6 +216,9 @@ void SendspinHub::dump_config() {
this->firmware_version_, YESNO(this->task_stack_in_psram_),
YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()),
this->pairing_code_method_());
#ifdef USE_SENDSPIN_ENABLED_SWITCH
LOG_SWITCH(" ", "Enabled switch", this->enabled_switch_);
#endif
#ifdef USE_SENDSPIN_ARTWORK
// Slot indices come from the order the image platform entries were declared, so the log is the
@@ -14,6 +14,9 @@
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
#include "esphome/components/mdns/mdns_component.h"
#endif
#ifdef USE_SENDSPIN_ENABLED_SWITCH
#include "esphome/components/switch/switch.h"
#endif
#include <sendspin/client.h>
#include <sendspin/config.h>
@@ -178,6 +181,9 @@ class SendspinHub final : public Component,
/// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the
/// enabled switch. Must be called from the main loop thread.
void set_enabled(bool enabled);
#ifdef USE_SENDSPIN_ENABLED_SWITCH
void set_enabled_switch(switch_::Switch *sw) { this->enabled_switch_ = sw; }
#endif
/// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired
/// access switch, before its first start. May be called before setup(). Main loop only.
@@ -407,6 +413,9 @@ class SendspinHub final : public Component,
LazyCallbackManager<void(const std::string &, StringRef)> pairing_failed_callbacks_{};
const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured
#ifdef USE_SENDSPIN_ENABLED_SWITCH
switch_::Switch *enabled_switch_{nullptr};
#endif
bool pairing_code_display_supported_{false};
bool task_stack_in_psram_{false};
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
+19 -15
View File
@@ -25,25 +25,18 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
SendspinEnabledSwitch = sendspin_ns.class_(
"SendspinEnabledSwitch", switch.Switch, cg.Component
)
SendspinUnpairedAccessSwitch = sendspin_ns.class_(
"SendspinUnpairedAccessSwitch", switch.Switch, cg.Component
)
def _switch_schema(class_: cg.MockObjClass) -> cv.Schema:
return (
switch.switch_schema(
class_,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
)
.extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
.extend(cv.COMPONENT_SCHEMA)
)
return switch.switch_schema(
class_,
block_inverted=True,
default_restore_mode="RESTORE_DEFAULT_ON",
entity_category=ENTITY_CATEGORY_CONFIG,
).extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
def _request_switch(config: ConfigType) -> ConfigType:
@@ -55,8 +48,10 @@ def _request_switch(config: ConfigType) -> ConfigType:
CONFIG_SCHEMA = cv.All(
cv.typed_schema(
{
CONF_ENABLED: _switch_schema(SendspinEnabledSwitch),
CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch),
CONF_ENABLED: _switch_schema(switch.ApplySwitch),
CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch).extend(
cv.COMPONENT_SCHEMA
),
},
key=CONF_TYPE,
),
@@ -98,6 +93,15 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config: ConfigType) -> None:
if config[CONF_TYPE] == CONF_ENABLED:
# The hub restores this switch in its setup() and logs it.
hub = await cg.get_variable(config[CONF_SENDSPIN_ID])
switches = switch.sub_switches(config, parent=hub)
await switches.create(
config, hub.set_enabled_switch, apply="set_enabled(state)", publish="after"
)
cg.add_define("USE_SENDSPIN_ENABLED_SWITCH", True)
return
var = await switch.new_switch(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
@@ -9,21 +9,6 @@ namespace esphome::sendspin_ {
ESPHOME_LOG_TAG(TAG, "sendspin.switch");
// --- SendspinEnabledSwitch ---
void SendspinEnabledSwitch::setup() {
// The hub waits for this request, so a restore mode without a state still has to answer.
this->restore(true);
}
void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); }
// THREAD CONTEXT: Main loop
void SendspinEnabledSwitch::write_state(bool state) {
this->parent_->set_enabled(state);
this->publish_state(state);
}
// --- SendspinUnpairedAccessSwitch ---
void SendspinUnpairedAccessSwitch::setup() {
@@ -9,16 +9,6 @@
namespace esphome::sendspin_ {
/// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()).
class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild {
public:
void setup() override;
void dump_config() override;
protected:
void write_state(bool state) override;
};
/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see
/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore
/// mode does.
+1
View File
@@ -527,6 +527,7 @@
#define USE_SENDSPIN
#define USE_SENDSPIN_ARTWORK
#define USE_SENDSPIN_CONTROLLER
#define USE_SENDSPIN_ENABLED_SWITCH
#define USE_SENDSPIN_METADATA
#define USE_SENDSPIN_PLAYER
#define USE_SENDSPIN_SWITCH
@@ -0,0 +1,18 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
@@ -0,0 +1,19 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf
wifi:
ap:
sendspin:
switch:
- platform: sendspin
type: enabled
id: sendspin_enabled
name: Sendspin Enabled
restore_mode: DISABLED
@@ -0,0 +1,59 @@
"""Code generation tests for the enabled switch.
The client stays stopped until the hub has an enabled state: codegen sets it when
no enabled switch is configured, and the switch answers from its restore otherwise.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import re
import pytest
GenerateMain = Callable[[str | Path], str]
ConfigPath = Callable[[str], Path]
def test_without_switch_codegen_enables_the_client(
generate_main: GenerateMain, component_config_path: ConfigPath
) -> None:
"""With no enabled switch, nothing else would start the client."""
main_cpp = generate_main(component_config_path("device_info_default.yaml"))
assert "->set_enabled(true);" in main_cpp
@pytest.mark.parametrize(
("config", "restore_mode"),
[
("switch_enabled.yaml", "SWITCH_RESTORE_DEFAULT_ON"),
("switch_enabled_disabled_restore.yaml", "SWITCH_RESTORE_DISABLED"),
],
)
def test_switch_answers_the_hub(
generate_main: GenerateMain,
component_config_path: ConfigPath,
config: str,
restore_mode: str,
) -> None:
"""The hub waits for the switch's restore instead of being enabled by codegen."""
main_cpp = generate_main(component_config_path(config))
assert "->set_enabled(true);" not in main_cpp
assert f"sendspin_enabled->set_restore_mode(switch_::{restore_mode});" in main_cpp
assert "App.register_switch(sendspin_enabled," in main_cpp
def test_switch_sets_the_hub_then_publishes(
generate_main: GenerateMain, component_config_path: ConfigPath
) -> None:
"""Turning the switch sets the hub first, then publishes."""
main_cpp = generate_main(component_config_path("switch_enabled.yaml"))
# The generated body is the hub call followed directly by the publish
assert re.search(
r"->set_enabled\(state\);\s*entity->publish_state\(state\);", main_cpp
)
assert "->set_enabled_switch(sendspin_enabled);" in main_cpp
@@ -74,6 +74,22 @@ uart_mock:
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
- expect_tx: [0xFD, 0xFC, 0xFB, 0xFA, 0x02, 0x00, 0xFE, 0x00, 0x04, 0x03, 0x02, 0x01]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0xFE, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then the response speed Fast: the same values with speed 10, acknowledged
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01]
# Then no delay 20 s, rejected with ack 1: the component reads the module's values back
- expect_tx:
[
0xFD, 0xFC, 0xFB, 0xFA, 0x26, 0x00, 0x70, 0x00, 0x05, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x0A, 0x00,
0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x14, 0x00, 0x00, 0x00, 0x02, 0x00, 0x50, 0x00, 0x00, 0x00,
0x0C, 0x00, 0x50, 0x00, 0x00, 0x00, 0x0B, 0x00, 0x0A, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01,
]
inject_rx: [0xFD, 0xFC, 0xFB, 0xFA, 0x04, 0x00, 0x70, 0x01, 0x01, 0x00, 0x04, 0x03, 0x02, 0x01]
injections:
# Short frame, state 2 (target), distance 300 -> presence on
- delay: 100ms
@@ -137,6 +153,14 @@ number:
name: "Max Distance"
no_delay:
name: "No Delay"
status_reporting_frequency:
name: "Status Reporting Frequency"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button:
- platform: template
@@ -92,6 +92,19 @@ binary_sensor:
has_target:
name: "Has Target"
# Commanded before the module answered: the changes are ignored and nothing is published
number:
- platform: ld2410s
ld2410s_id: ld2410s_dev
max_distance:
name: "Max Distance"
select:
- platform: ld2410s
ld2410s_id: ld2410s_dev
response_speed:
name: "Response Speed"
button:
- platform: template
name: "Start Scenario"
+109 -20
View File
@@ -5,9 +5,15 @@ test_uart_mock_ld2410s:
switch and config-mode end go out in order, each after the previous one is
acknowledged, then the component logs "Setup done" and clears its warning;
the version and parameter answers are parsed and logged
1b. Setting the max distance number to 7 m writes all six parameters (gate 10
1a. The numbers show the values read at init in their own units (gates of 0.7 m,
seconds, tenths of a hertz) and the response speed select shows Normal
1b. Setting the max distance number to 7.3 m writes all six parameters (gate 10
plus the values read at init) inside a configuration-mode session, and the
number reports 7 m
number reports the 7 m the module got, not 7.3 m
1c. Selecting the Fast response speed writes speed 10 with the same values and
the select reports Fast
1d. Setting no delay to 20 s reports 20 s at once; the module rejects the write,
so the values are read back and every entity shows the module's own value again
2. Presence from short frames (states 0/1 off, 2/3 on) and from a standard
data frame
3. Resync - a noise byte or a partial header directly before a short frame
@@ -16,6 +22,8 @@ test_uart_mock_ld2410s:
dropped without publishing, and the short frame after each still publishes
test_uart_mock_ld2410s_recovery (the mock never answers the init commands):
0. A number or select changed before the module answered logs that the change
is ignored and publishes nothing
1. Config-mode start is resent after the acknowledgement timeout
2. Once the retries run out, config-mode end is sent and the component warns
that it is starting over
@@ -36,9 +44,19 @@ test_uart_mock_ld2410s_link_lost:
from __future__ import annotations
import asyncio
from collections.abc import Callable
import re
from aioesphomeapi import APIClient, ButtonInfo, EntityState, NumberInfo, NumberState
from aioesphomeapi import (
APIClient,
ButtonInfo,
EntityInfo,
EntityState,
NumberInfo,
NumberState,
SelectInfo,
SelectState,
)
import pytest
from .log_utils import LineWaiter
@@ -66,12 +84,20 @@ INIT_TX = [
OUTPUT_MODE_TX,
CONFIG_END_TX,
]
# All six parameters: max gate 10, min gate 0, no delay 10, both frequencies 80, speed 5
PARAMETERS_WRITE_TX = (
"FD:FC:FB:FA:26:00:70:00:05:00:0A:00:00:00:0A:00:00:00:00:00:06:00:0A:00:00:00:02:00:50:00:00:00:"
"0C:00:50:00:00:00:0B:00:05:00:00:00:04:03:02:01"
)
WRITE_TX = [CONFIG_START_TX, PARAMETERS_WRITE_TX, CONFIG_END_TX]
def _parameters_write_tx(no_delay: int, speed: int) -> str:
"""The 0x0070 write with max gate 10, min gate 0 and both frequencies 80."""
values = [(0x05, 10), (0x0A, 0), (0x06, no_delay), (0x02, 80), (0x0C, 80)]
body = "".join(f"{key:02X}:00:{value:02X}:00:00:00:" for key, value in values)
return f"FD:FC:FB:FA:26:00:70:00:{body}0B:00:{speed:02X}:00:00:00:04:03:02:01"
WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 5), CONFIG_END_TX]
# The same values with response speed 10 (Fast)
SPEED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(10, 10), CONFIG_END_TX]
# No delay 20 s; the fixture rejects it
REJECTED_WRITE_TX = [CONFIG_START_TX, _parameters_write_tx(20, 10), CONFIG_END_TX]
# Logged once the version and parameter answers are parsed
PARSED_ANSWERS = [
"Firmware 1.2.3",
@@ -117,13 +143,20 @@ def _tx_frame(line: str) -> str | None:
return ANSI_ESCAPE.sub("", line.split("bytes: ", 1)[1]).strip()
async def _start_scenario(client: APIClient, collector: SensorStateCollector) -> None:
"""Subscribe the collector past the initial states, then start the mock scenario."""
async def _start_scenario(
client: APIClient,
collector: SensorStateCollector,
on_state: Callable[[EntityState], None] | None = None,
) -> list[EntityInfo]:
"""Subscribe past the initial states, then start the mock scenario; returns the entities."""
entities, _ = await client.list_entities_services()
collector.build_key_mapping(entities)
await subscribe_after_initial_states(client, entities, on_state=collector.on_state)
await subscribe_after_initial_states(
client, entities, on_state=on_state or collector.on_state
)
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
return entities
async def _wait_for_presence(
@@ -156,12 +189,15 @@ async def test_uart_mock_ld2410s(
collector = SensorStateCollector(
sensor_names=["distance"], binary_sensor_names=["has_target"]
)
number_states: list[float] = []
number_states: dict[int, list[float]] = {}
select_states: list[str] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, NumberState) and state.key == max_distance_key:
number_states.append(state.state)
if isinstance(state, NumberState):
number_states.setdefault(state.key, []).append(state.state)
if isinstance(state, SelectState):
select_states.append(state.state)
all_presence = collector.add_waiter(
lambda: len(collector.binary_states["has_target"]) >= len(EXPECTED_PRESENCE)
@@ -191,16 +227,28 @@ async def test_uart_mock_ld2410s(
collector.build_key_mapping(entities)
max_distance_key = require_entity(entities, "max_distance", NumberInfo).key
sub = await subscribe_after_initial_states(client, entities, on_state=on_state)
# The values read from the module: gate 16 and 10 s
# The values read from the module: gate 16, 10 s, 80 tenths of a hertz, speed 5
no_delay_key = require_entity(entities, "no_delay", NumberInfo).key
frequency_key = require_entity(
entities, "status_reporting_frequency", NumberInfo
).key
speed_key = require_entity(entities, "response_speed", SelectInfo).key
initial = sub.initial_states
assert initial[max_distance_key].state == pytest.approx(11.2)
assert initial[no_delay_key].state == pytest.approx(10)
assert initial[frequency_key].state == pytest.approx(8.0)
assert initial[speed_key].state == "Normal"
client.number_command(max_distance_key, 7.0)
# 7.3 m is between gates 10 and 11: gate 10 is written and 7 m reported
client.number_command(max_distance_key, 7.3)
await waiter.wait_count(TX_LINE, 9, 5.0, "Parameter write not sent")
assert tx_frames()[6:9] == WRITE_TX, tx_frames()
assert number_states == [pytest.approx(7.0)]
assert number_states[max_distance_key] == [pytest.approx(7.0)]
client.select_command(speed_key, "Fast")
await waiter.wait_count(TX_LINE, 12, 5.0, "Response speed write not sent")
assert tx_frames()[9:12] == SPEED_WRITE_TX, tx_frames()
assert select_states == ["Fast"]
start_btn = require_entity(entities, "start_scenario", ButtonInfo)
client.button_command(start_btn.key)
@@ -218,7 +266,29 @@ async def test_uart_mock_ld2410s(
# A target 300 cm away, then none: the distance follows the presence
assert collector.sensor_states["distance"][:2] == [300, 0]
# Data frames must not restart the init sequence
assert len(tx_frames()) == 9, tx_frames()
assert len(tx_frames()) == 12, tx_frames()
# A rejected write leaves configuration mode and reads the module's values back
read_back = waiter.expect("Parameters: gates 0 to 16, no delay 10 s")
client.number_command(no_delay_key, 20)
await wait_or_fail(read_back, 5.0, "Values not read back", tx_frames=tx_frames)
async def republished() -> None:
while (
len(number_states.get(no_delay_key, ())) < 2
or len(number_states.get(max_distance_key, ())) < 2
or len(select_states) < 2
):
await asyncio.sleep(0.01)
await wait_or_fail(republished(), 5.0, "Read-back values not published")
assert tx_frames()[12:15] == REJECTED_WRITE_TX, tx_frames()
assert number_states[no_delay_key] == [pytest.approx(20), pytest.approx(10)]
assert number_states[max_distance_key] == [
pytest.approx(7.0),
pytest.approx(11.2),
]
assert select_states == ["Fast", "Normal"]
@pytest.mark.asyncio
@@ -234,10 +304,14 @@ async def test_uart_mock_ld2410s_recovery(
gave_up = loop.create_future()
oversized_dropped = loop.create_future()
footer_mismatches: list[str] = []
ignored_changes = 0
def line_callback(line: str) -> None:
nonlocal ignored_changes
if (frame := _tx_frame(line)) is not None:
tx_frames.append((loop.time(), frame))
if "Parameters not read yet, change ignored" in line:
ignored_changes += 1
if "No acknowledgement for command 00FF, resending" in line and not (
resent.done()
):
@@ -267,12 +341,25 @@ async def test_uart_mock_ld2410s_recovery(
>= len(EXPECTED_RECOVERY_PRESENCE)
)
)
setting_states: list[EntityState] = []
def on_state(state: EntityState) -> None:
collector.on_state(state)
if isinstance(state, (NumberState, SelectState)):
setting_states.append(state)
async with (
run_compiled(yaml_config, line_callback=line_callback),
api_client_connected() as client,
):
await _start_scenario(client, collector)
entities = await _start_scenario(client, collector, on_state)
# The module never answered, so both changes are refused and nothing is published
client.number_command(
require_entity(entities, "max_distance", NumberInfo).key, 7.0
)
client.select_command(
require_entity(entities, "response_speed", SelectInfo).key, "Fast"
)
await _wait_for_presence(collector, all_presence)
await wait_or_fail(
asyncio.gather(resent, gave_up),
@@ -286,6 +373,8 @@ async def test_uart_mock_ld2410s_recovery(
# Recovery shows in the published states alone; the footer log below is
# only a cross-check that needs the fixture's VERBOSE logger level
assert collector.binary_states["has_target"] == EXPECTED_RECOVERY_PRESENCE
assert ignored_changes == 2
assert setting_states == []
assert oversized_dropped.done(), "Oversized length field was not dropped"
assert footer_mismatches == [TRUNCATED_FRAME_HEX, BAD_COMMAND_FRAME_HEX]