Compare commits

...
84 changed files with 2399 additions and 372 deletions
+7 -1
View File
@@ -175,6 +175,11 @@ esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/ds3231/* @linkedupbits
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duco/* @kokx
esphome/components/duco/number/* @kokx
esphome/components/duco/select/* @kokx
esphome/components/duco/sensor/* @kokx
esphome/components/duco/text_sensor/* @kokx
esphome/components/duty_time/* @dudanov
esphome/components/ecocomfort2/* @gledian
esphome/components/ee895/* @Stock-M
@@ -374,7 +379,8 @@ esphome/components/m5stack_8angle/* @rnauber
esphome/components/mapping/* @clydebarrow
esphome/components/matrix_keypad/* @ssieb
esphome/components/max17043/* @blacknell
esphome/components/max17048/* @B48D81EFCC
esphome/components/max17048/* @B48D81EFCC @ryansch
esphome/components/max170xx/* @ryansch
esphome/components/max31865/* @DAVe3283
esphome/components/max31888/* @JiriPrchal
esphome/components/max44009/* @berfenger
+2 -5
View File
@@ -1,4 +1,5 @@
#include "ch422g.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::ch422g {
@@ -59,11 +60,7 @@ bool CH422GComponent::digital_read(uint8_t pin) {
}
void CH422GComponent::digital_write(uint8_t pin, bool value) {
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
assign_bits(this->output_bits_, 1 << pin, value);
this->write_outputs_();
}
+2 -5
View File
@@ -1,4 +1,5 @@
#include "ch423.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
@@ -62,11 +63,7 @@ bool CH423Component::digital_read(uint8_t pin) {
}
void CH423Component::digital_write(uint8_t pin, bool value) {
if (value) {
this->output_bits_ |= (1 << pin);
} else {
this->output_bits_ &= ~(1 << pin);
}
assign_bits(this->output_bits_, 1 << pin, value);
this->write_outputs_();
}
+87
View File
@@ -0,0 +1,87 @@
import esphome.codegen as cg
from esphome.components import time, uart
import esphome.config_validation as cv
from esphome.const import CONF_DISCOVERY, CONF_ID, CONF_TIME, CONF_TIME_ID
from esphome.core import ID
from esphome.types import ConfigType
CODEOWNERS = ["@kokx"]
DEPENDENCIES = ["uart"]
DOMAIN = "duco"
MULTI_CONF = True
CONF_DISABLE_CRC = "disable_crc"
CONF_DUCO_ID = "duco_id"
duco_ns = cg.esphome_ns.namespace("duco")
Duco = duco_ns.class_("Duco", cg.Component, uart.UARTDevice)
# Base of every class that sends requests through the hub
DucoDevice = duco_ns.class_(
"DucoDevice", cg.PollingComponent, cg.Parented.template(Duco)
)
DucoDiscovery = duco_ns.class_("DucoDiscovery", DucoDevice)
DucoTime = duco_ns.class_("DucoTime", cg.PollingComponent, cg.Parented.template(Duco))
# Entities of the platforms take the hub with this
DUCO_COMPONENT_SCHEMA = cv.Schema({cv.GenerateID(CONF_DUCO_ID): cv.use_id(Duco)})
DISCOVERY_SCHEMA = cv.Schema({cv.GenerateID(): cv.declare_id(DucoDiscovery)}).extend(
cv.COMPONENT_SCHEMA
)
TIME_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(DucoTime),
cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock),
}
).extend(cv.polling_component_schema("15min"))
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(Duco),
cv.Optional(CONF_DISABLE_CRC, default=False): cv.boolean,
cv.Optional(CONF_DISCOVERY): DISCOVERY_SCHEMA,
cv.Optional(CONF_TIME): TIME_SCHEMA,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
DOMAIN,
baud_rate=57600,
require_tx=True,
require_rx=True,
data_bits=8,
parity="NONE",
stop_bits=1,
)
_request_device_slot = cg.slot_counter("DUCO_DEVICE_COUNT")
async def register_duco_device(var: cg.MockObj, config: ConfigType, hub_id: ID) -> None:
"""Register a device that sends requests, and give it an open request slot on its hub."""
await cg.register_component(var, config)
hub = await cg.get_variable(hub_id)
await cg.register_parented(var, hub)
_request_device_slot(str(hub_id))
cg.add(hub.register_device(var))
async def to_code(config: ConfigType) -> None:
var = await uart.new_uart_component(config)
cg.add(var.set_disable_crc(config[CONF_DISABLE_CRC]))
if (discovery := config.get(CONF_DISCOVERY)) is not None:
discovery_var = cg.new_Pvariable(discovery[CONF_ID])
await register_duco_device(discovery_var, discovery, config[CONF_ID])
if (time_config := config.get(CONF_TIME)) is not None:
clock = await cg.get_variable(time_config[CONF_TIME_ID])
time_var = cg.new_Pvariable(time_config[CONF_ID], clock)
await cg.register_component(time_var, time_config)
await cg.register_parented(time_var, var)
+273
View File
@@ -0,0 +1,273 @@
#include "duco.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco");
static constexpr uint8_t HEADER_START = 0xAA;
static constexpr uint8_t HEADER_END = 0x55;
static constexpr uint8_t STUFFING = 0x01; // follows an 0xAA inside a message, not part of it
static constexpr uint32_t FRAME_GAP_MS = 50;
static constexpr uint32_t DISCOVERY_START_ID = 0;
static constexpr uint32_t DISCOVERY_STEP_ID = 1;
static constexpr uint32_t DISCOVERY_START_MS = 16000;
static constexpr uint32_t DISCOVERY_STEP_MS = 1600;
static constexpr uint8_t DISCOVERY_MAX_ATTEMPTS = 3;
void Duco::loop() {
const uint32_t now = App.get_loop_component_start_time();
// Read first: after a slow loop pass the rest of a message is already waiting, which is not silence
this->read_available([this, now](uint8_t byte) {
if (this->parse_byte_(byte)) {
this->last_byte_ = now;
} else {
this->reset_frame_();
}
});
// The last message of a burst has no header after it, so it ends on silence
if (this->rx_size_ != 0 && now - this->last_byte_ > FRAME_GAP_MS) {
this->finalize_message_();
this->reset_frame_();
}
}
void Duco::reset_frame_() {
this->rx_size_ = 0;
this->escaped_ = false;
}
void Duco::close_request(DucoDevice *device) {
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
return;
}
}
#endif
}
bool Duco::parse_byte_(uint8_t byte) {
const size_t at = this->rx_size_;
if (at >= MAX_FRAME_SIZE) {
ESP_LOGV(TAG, "Message longer than %u bytes dropped", static_cast<unsigned>(MAX_FRAME_SIZE));
return false;
}
const uint8_t prev = at > 0 ? this->rx_buffer_[at - 1] : 0;
if (at == 1 && prev == HEADER_START && byte == HEADER_END)
return false; // header of the next message
if (at >= 2) {
const bool escaped = this->escaped_;
this->escaped_ = false;
if (prev == HEADER_START && !escaped) {
if (byte == STUFFING) {
this->escaped_ = true;
return true;
}
if (at >= 3 && byte == HEADER_END) {
this->rx_size_--; // drop the 0xAA of the header
this->finalize_message_();
return false;
}
}
}
this->rx_buffer_[this->rx_size_++] = byte;
return true;
}
void Duco::finalize_message_() {
const uint8_t *raw = this->rx_buffer_.data();
// length (function, id and data), function, id, data, CRC low, CRC high
if (this->rx_size_ < 5 || raw[0] < 2 || this->rx_size_ < raw[0] + 3u) {
ESP_LOGV(TAG, "Malformed message dropped: %u bytes, length byte %u", this->rx_size_, raw[0]);
return;
}
const uint8_t length = raw[0];
const uint16_t computed_crc = crc16(raw, length + 1);
const uint16_t remote_crc = encode_uint16(raw[length + 2], raw[length + 1]);
if (computed_crc != remote_crc) {
if (!this->disable_crc_) {
ESP_LOGV(TAG, "CRC check failed: %04X != %04X", computed_crc, remote_crc);
return;
}
ESP_LOGV(TAG, "CRC check failed, ignored: %04X != %04X", computed_crc, remote_crc);
}
#ifdef DUCO_DEVICE_COUNT
// The box first acknowledges a request with function + 1, then answers with function + 2
const DucoMessage message{raw[2], raw + 3, static_cast<uint8_t>(length - 2)};
for (auto &pending : this->pending_) {
if (pending.function != 0 && pending.id == message.id && pending.function + 2 == raw[1]) {
pending.function = 0;
pending.missed = false;
this->update_warning_();
pending.device->receive_response(message);
return;
}
}
// Acknowledgements (odd function codes) are expected and never match
if ((raw[1] & 1) == 0) {
ESP_LOGV(TAG, "Answer %02X with id %u matches no open request", raw[1], message.id);
}
#endif
}
#ifdef DUCO_DEVICE_COUNT
void Duco::update_warning_() {
for (const auto &pending : this->pending_) {
if (pending.missed) {
this->status_set_warning(LOG_STR("No answer from the box"));
return;
}
}
this->status_clear_warning();
}
#endif
void Duco::dump_config() {
ESP_LOGCONFIG(TAG,
"Duco:\n"
" CRC Disabled: %s",
YESNO(this->disable_crc_));
}
uint8_t Duco::next_id_() {
do {
this->last_id_++;
} while (this->last_id_ == HEADER_START || this->last_id_ == HEADER_END);
return this->last_id_;
}
void Duco::send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data) {
if (data.size() > MAX_REQUEST_DATA) {
ESP_LOGE(TAG, "Request of %u bytes is too long", static_cast<unsigned>(data.size()));
return;
}
// Not byte stuffed, as in the author's original and no capture shows a request with 0xAA. An 0xAA followed by any
// byte but 0x01 or 0x55 reads the same with or without unstuffing on the box, so only those rare pairs are
// unverified.
const size_t size = data.size();
const uint8_t id = this->next_id_();
uint8_t frame[5 + MAX_REQUEST_DATA + 2] = {HEADER_START, HEADER_END, static_cast<uint8_t>(size + 2), function, id};
std::copy_n(data.begin(), size, frame + 5);
const uint16_t crc = crc16(frame + 2, size + 3);
frame[size + 5] = crc & 0xFF;
frame[size + 6] = crc >> 8;
this->write_array(frame, size + 7);
this->flush();
#ifdef DUCO_DEVICE_COUNT
for (auto &pending : this->pending_) {
if (pending.device == device) {
pending.missed = pending.function != 0;
if (pending.missed) {
ESP_LOGV(TAG, "No answer to request %02X with id %u", pending.function, pending.id);
this->update_warning_();
}
pending.id = id;
pending.function = function;
break;
}
}
#endif
ESP_LOGV_HEX(TAG, frame + 2, size + 5, sizeof(frame), "Sent %s");
}
static const LogString *node_type_name(uint8_t type) {
switch (type) {
case 8:
return LOG_STR("UCBAT");
case 9:
return LOG_STR("UC");
case 10:
return LOG_STR("UCRH");
case 12:
return LOG_STR("UCCO2");
case 13:
return LOG_STR("VLV");
case 17:
return LOG_STR("BOX");
case 18:
return LOG_STR("SWITCH");
default:
return LOG_STR("UNKNOWN");
}
}
void DucoDiscovery::setup() {
// Give the box time to start before walking its node addresses
this->set_timeout(DISCOVERY_START_ID, DISCOVERY_START_MS, [this]() {
this->set_interval(DISCOVERY_STEP_ID, DISCOVERY_STEP_MS, [this]() { this->update(); });
});
}
void DucoDiscovery::update() {
if (this->attempts_ == DISCOVERY_MAX_ATTEMPTS) {
ESP_LOGV(TAG, "No answer for node %u", this->next_node_);
this->next_node_++;
this->attempts_ = 0;
}
if (this->next_node_ >= NODE_COUNT) {
this->cancel_interval(DISCOVERY_STEP_ID);
this->parent_->close_request(this);
return;
}
// Without an answer by the next step the same node is asked again; a retry is not a missed answer
if (this->attempts_ > 0)
this->parent_->close_request(this);
ESP_LOGV(TAG, "Discovering node %u", this->next_node_);
this->attempts_++;
this->parent_->send(this, FUNCTION_NODE, {0x01, this->next_node_});
}
void DucoDiscovery::receive_response(const DucoMessage &message) {
// Only the latest attempt is open, so a late answer to an earlier one never arrives here
if (message.size >= 1 && this->next_node_ < NODE_COUNT) {
if (message.data[0] != 0) {
ESP_LOGV(TAG, "Found node %u: type %u", this->next_node_, message.data[0]);
}
this->node_types_[this->next_node_++] = message.data[0];
this->attempts_ = 0;
}
}
void DucoDiscovery::dump_config() {
ESP_LOGCONFIG(TAG, "Discovered nodes:");
for (uint8_t node = 0; node < NODE_COUNT; node++) {
const uint8_t type = this->node_types_[node];
if (type != 0) {
ESP_LOGCONFIG(TAG, " Node %u: type %u (%s)", node, type, LOG_STR_ARG(node_type_name(type)));
}
}
if (this->next_node_ < NODE_COUNT) {
ESP_LOGCONFIG(TAG, " Still discovering, at node %u", this->next_node_);
}
}
#ifdef USE_TIME
void DucoTime::update() {
const ESPTime now = this->clock_->now();
if (!now.is_valid()) {
ESP_LOGV(TAG, "Time is not valid yet");
return;
}
const uint32_t ts = now.timestamp;
this->parent_->send(nullptr, FUNCTION_PARAMETER,
{0x05, static_cast<uint8_t>(ts), static_cast<uint8_t>(ts >> 8), static_cast<uint8_t>(ts >> 16),
static_cast<uint8_t>(ts >> 24), 0x00, 0x00, 0x00, 0x00});
}
#endif
} // namespace esphome::duco
+113
View File
@@ -0,0 +1,113 @@
#pragma once
#include <array>
#include <initializer_list>
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#ifdef USE_TIME
#include "esphome/components/time/real_time_clock.h"
#endif
namespace esphome::duco {
// Function codes of the requests; the box answers a request with function + 2
static constexpr uint8_t FUNCTION_NODE = 0x0c; // discovery, status and mode of a node
static constexpr uint8_t FUNCTION_NODE_DATA = 0x10; // a block of a node's data (sensors, serial number)
static constexpr uint8_t FUNCTION_PARAMETER = 0x24; // read or write a box parameter, set the clock
/// A received message; data points into the receive buffer and is valid only during the callback.
struct DucoMessage {
uint8_t id;
const uint8_t *data;
uint8_t size;
};
class DucoDevice;
/// The open request of one device: the answer to it is function + 2 with the same message id.
struct DucoPendingRequest {
DucoDevice *device;
uint8_t id;
uint8_t function; // 0 while nothing is open
bool missed; // the request before this one got no answer; sits in padding
};
class Duco : public uart::UARTDevice, public Component {
public:
void loop() override;
void dump_config() override;
void set_disable_crc(bool disable_crc) { this->disable_crc_ = disable_crc; }
#ifdef DUCO_DEVICE_COUNT
void register_device(DucoDevice *device) { this->pending_.push_back({device, 0, 0, false}); }
#endif
/// Forget device's open request, for a device that will not send again.
void close_request(DucoDevice *device);
/// Send a request; its answer goes to device, or nowhere if nullptr. A device's new request replaces its
/// open one, so an answer is never lost to another device's traffic.
void send(DucoDevice *device, uint8_t function, std::initializer_list<uint8_t> data);
protected:
// The longest answer to a request sent here is 25 bytes (node status), the longest request 9
static constexpr size_t MAX_FRAME_SIZE = 32;
static constexpr size_t MAX_REQUEST_DATA = 9;
bool parse_byte_(uint8_t byte);
void reset_frame_();
#ifdef DUCO_DEVICE_COUNT
void update_warning_(); // warn while any device's previous request went unanswered
#endif
void finalize_message_();
uint8_t next_id_();
#ifdef DUCO_DEVICE_COUNT
StaticVector<DucoPendingRequest, DUCO_DEVICE_COUNT> pending_;
#endif
std::array<uint8_t, MAX_FRAME_SIZE> rx_buffer_{};
uint32_t last_byte_{0};
uint8_t rx_size_{0};
uint8_t last_id_{10};
bool escaped_{false}; // the last 0xAA was followed by a stuffing byte, so it starts no header
bool disable_crc_{false};
};
/// Every entity polls the box; deriving from PollingComponent shares its vtable.
class DucoDevice : public PollingComponent, public Parented<Duco> {
public:
virtual void receive_response(const DucoMessage &message) = 0;
};
/// Walks the node addresses once after boot; the nodes found are listed in the config dump.
class DucoDiscovery : public DucoDevice {
public:
void setup() override;
void dump_config() override;
void update() override; // one discovery step, run by the step interval
void receive_response(const DucoMessage &message) override;
protected:
static constexpr uint8_t NODE_COUNT = 0x45;
std::array<uint8_t, NODE_COUNT> node_types_{}; // 0 when no node answered at that address
uint8_t next_node_{0};
uint8_t attempts_{0};
};
#ifdef USE_TIME
class DucoTime : public PollingComponent, public Parented<Duco> {
public:
explicit DucoTime(time::RealTimeClock *clock) : clock_(clock) {}
void update() override;
protected:
time::RealTimeClock *clock_;
};
#endif
} // namespace esphome::duco
@@ -0,0 +1,34 @@
from esphome.components import number
import esphome.config_validation as cv
from esphome.const import DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
DucoComfortTemperature = duco_ns.class_(
"DucoComfortTemperature", DucoDevice, number.Number
)
CONFIG_SCHEMA = (
number.number_schema(
DucoComfortTemperature,
unit_of_measurement=UNIT_CELSIUS,
device_class=DEVICE_CLASS_TEMPERATURE,
)
.extend(DUCO_COMPONENT_SCHEMA)
.extend(cv.polling_component_schema("60s"))
)
async def to_code(config: ConfigType) -> None:
var = await number.new_number(config, min_value=10.0, max_value=25.5, step=0.1)
await register_duco_device(var, config, config[CONF_DUCO_ID])
+26
View File
@@ -0,0 +1,26 @@
#include "number.h"
#include <cmath>
namespace esphome::duco {
static constexpr uint8_t PARAMETER_COMFORT_TEMPERATURE = 0x12;
static constexpr uint8_t GROUP_COMFORT_TEMPERATURE = 0x0a;
void DucoComfortTemperature::update() {
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE});
}
void DucoComfortTemperature::receive_response(const DucoMessage &message) {
// Reads and writes get the same answer: one byte in tenths of a degree, below 10.0 is invalid
if (message.size >= 4 && message.data[3] >= 100)
this->publish_state(message.data[3] / 10.0f);
}
void DucoComfortTemperature::control(float value) {
const auto tenths = static_cast<uint8_t>(lroundf(value * 10.0f));
this->parent_->send(this, FUNCTION_PARAMETER,
{0x01, PARAMETER_COMFORT_TEMPERATURE, GROUP_COMFORT_TEMPERATURE, tenths, 0x00, 0x00, 0x00});
}
} // namespace esphome::duco
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/number/number.h"
#include "../duco.h"
namespace esphome::duco {
class DucoComfortTemperature : public DucoDevice, public number::Number {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
void control(float value) override;
};
} // namespace esphome::duco
@@ -0,0 +1,45 @@
from esphome.components import select
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
# Same order as MODE_CODES in select.cpp
DUCO_MODE_OPTIONS = [
"AUTO",
"MAN1",
"MAN2",
"MAN3",
"EMPT",
"CNT1",
"CNT2",
"CNT3",
"MAN1x2",
"MAN2x2",
"MAN3x2",
"MAN1x3",
"MAN2x3",
"MAN3x3",
]
DucoSelect = duco_ns.class_("DucoSelect", DucoDevice, select.Select)
CONFIG_SCHEMA = (
select.select_schema(DucoSelect)
.extend(cv.polling_component_schema("3s"))
.extend(DUCO_COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await select.new_select(config, options=DUCO_MODE_OPTIONS)
await register_duco_device(var, config, config[CONF_DUCO_ID])
+47
View File
@@ -0,0 +1,47 @@
#include "select.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco.select");
// Box code of each option, in the order of DUCO_MODE_OPTIONS in __init__.py
static constexpr uint8_t MODE_CODES[] PROGMEM = {
0x00, // AUTO
0x04, // MAN1
0x05, // MAN2
0x06, // MAN3
0x07, // EMPT
0x08, // CNT1
0x09, // CNT2
0x0a, // CNT3
0x84, // MAN1x2
0x85, // MAN2x2
0x86, // MAN3x2
0xc4, // MAN1x3
0xc5, // MAN2x3
0xc6, // MAN3x3
};
void DucoSelect::update() { this->parent_->send(this, FUNCTION_NODE, {0x02, 0x01}); }
void DucoSelect::receive_response(const DucoMessage &message) {
if (message.size < 1)
return;
for (size_t i = 0; i < sizeof(MODE_CODES); i++) {
if (progmem_read_byte(&MODE_CODES[i]) == message.data[0]) {
this->publish_state(i);
return;
}
}
ESP_LOGV(TAG, "Unknown mode %02X", message.data[0]);
}
void DucoSelect::control(size_t index) {
// The next update reads the new mode back
this->parent_->send(nullptr, FUNCTION_NODE, {0x04, 0x01, progmem_read_byte(&MODE_CODES[index])});
}
} // namespace esphome::duco
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "esphome/components/select/select.h"
#include "../duco.h"
namespace esphome::duco {
class DucoSelect : public DucoDevice, public select::Select {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
void control(size_t index) override;
};
} // namespace esphome::duco
+156
View File
@@ -0,0 +1,156 @@
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_CO2,
CONF_HUMIDITY,
CONF_TEMPERATURE,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
STATE_CLASS_MEASUREMENT,
UNIT_CELSIUS,
UNIT_PARTS_PER_MILLION,
UNIT_PERCENT,
UNIT_SECOND,
)
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
UNIT_DAYS = "d"
CONF_BYPASS = "bypass"
CONF_FILTER_REMAINING = "filter_remaining"
CONF_FLOW_LEVEL = "flow_level"
CONF_TEMPERATURE_EHA = "temperature_eha"
CONF_TEMPERATURE_ETA = "temperature_eta"
CONF_TEMPERATURE_ODA = "temperature_oda"
CONF_TEMPERATURE_SUP = "temperature_sup"
CONF_TIME_REMAINING = "time_remaining"
DucoSensor = duco_ns.class_("DucoSensor", DucoDevice, sensor.Sensor)
DucoReading = duco_ns.enum("DucoReading", is_class=True)
BOX_NODE = 1
def _schema(update_interval: str = "60s", **kwargs) -> cv.Schema:
return sensor.sensor_schema(
DucoSensor, state_class=STATE_CLASS_MEASUREMENT, **kwargs
).extend(cv.polling_component_schema(update_interval))
def _node_schema(**kwargs) -> cv.All:
"""A list of sensors, one per node address."""
return cv.ensure_list(
_schema(**kwargs).extend({cv.Required(CONF_ADDRESS): cv.int_range(0, 68)})
)
_TEMPERATURE = {
"unit_of_measurement": UNIT_CELSIUS,
"accuracy_decimals": 1,
"device_class": DEVICE_CLASS_TEMPERATURE,
}
# key: (what is read, request argument or None for the node address, schema)
SENSORS = {
CONF_CO2: (
DucoReading.DUCO_READING_CO2,
None,
_node_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
),
),
CONF_TEMPERATURE: (
DucoReading.DUCO_READING_NODE_TEMPERATURE,
None,
_node_schema(**_TEMPERATURE),
),
CONF_HUMIDITY: (
DucoReading.DUCO_READING_HUMIDITY,
None,
_node_schema(
unit_of_measurement=UNIT_PERCENT,
accuracy_decimals=1,
device_class=DEVICE_CLASS_HUMIDITY,
),
),
CONF_FILTER_REMAINING: (
DucoReading.DUCO_READING_FILTER_REMAINING,
0x30,
_schema(
"1200s",
unit_of_measurement=UNIT_DAYS,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
),
),
CONF_FLOW_LEVEL: (
DucoReading.DUCO_READING_FLOW_LEVEL,
BOX_NODE,
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
),
CONF_TIME_REMAINING: (
DucoReading.DUCO_READING_TIME_REMAINING,
BOX_NODE,
_schema(
unit_of_measurement=UNIT_SECOND,
accuracy_decimals=0,
device_class=DEVICE_CLASS_DURATION,
),
),
CONF_TEMPERATURE_ODA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x00,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_SUP: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x01,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_ETA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x02,
_schema(**_TEMPERATURE),
),
CONF_TEMPERATURE_EHA: (
DucoReading.DUCO_READING_BOX_TEMPERATURE,
0x03,
_schema(**_TEMPERATURE),
),
CONF_BYPASS: (
DucoReading.DUCO_READING_BYPASS,
0x10,
_schema(unit_of_measurement=UNIT_PERCENT, accuracy_decimals=0),
),
}
CONFIG_SCHEMA = DUCO_COMPONENT_SCHEMA.extend(
{cv.Optional(key): schema for key, (_, _, schema) in SENSORS.items()}
)
async def to_code(config: ConfigType) -> None:
for key, (reading, argument, _) in SENSORS.items():
if (confs := config.get(key)) is None:
continue
for conf in confs if isinstance(confs, list) else [confs]:
var = await sensor.new_sensor(
conf, reading, conf.get(CONF_ADDRESS, argument)
)
await register_duco_device(var, conf, config[CONF_DUCO_ID])
+78
View File
@@ -0,0 +1,78 @@
#include "sensor.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome::duco {
ESPHOME_LOG_TAG(TAG, "duco.sensor");
enum class DucoValueFormat : uint8_t {
DUCO_VALUE_FORMAT_UINT8,
DUCO_VALUE_FORMAT_UINT16,
DUCO_VALUE_FORMAT_INT16,
};
/// A little endian field at offset in the answer. Values outside [minimum, maximum] are dropped;
/// the box reports those for sensors that are missing or still starting.
struct ReadingLayout {
uint8_t function;
uint8_t offset;
DucoValueFormat format;
uint8_t divisor;
int16_t minimum;
uint16_t maximum;
};
// Indexed by DucoReading
static constexpr ReadingLayout READINGS[] PROGMEM = {
{FUNCTION_NODE_DATA, 4, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 300, 10000}, // CO2
{FUNCTION_NODE_DATA, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 10, 0, 1000}, // node temperature
{FUNCTION_NODE_DATA, 6, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 100, 0, 10000}, // humidity
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // filter remaining
{FUNCTION_NODE, 2, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 255}, // flow level
{FUNCTION_NODE, 12, DucoValueFormat::DUCO_VALUE_FORMAT_UINT16, 1, 0, 65535}, // time remaining
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_INT16, 10, -999, 1000}, // box temperature
{FUNCTION_PARAMETER, 3, DucoValueFormat::DUCO_VALUE_FORMAT_UINT8, 1, 0, 100}, // bypass
};
static ReadingLayout layout_of(DucoReading reading) {
ReadingLayout layout;
progmem_memcpy(&layout, &READINGS[static_cast<uint8_t>(reading)], sizeof(layout));
return layout;
}
void DucoSensor::update() {
switch (layout_of(this->reading_).function) {
case FUNCTION_NODE_DATA:
this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, this->argument_, 0x00, 0x49, 0x04});
break;
case FUNCTION_PARAMETER:
this->parent_->send(this, FUNCTION_PARAMETER, {0x00, this->argument_, 0x09});
break;
case FUNCTION_NODE:
this->parent_->send(this, FUNCTION_NODE, {0x02, this->argument_});
break;
}
}
void DucoSensor::receive_response(const DucoMessage &message) {
const ReadingLayout layout = layout_of(this->reading_);
const bool wide = layout.format != DucoValueFormat::DUCO_VALUE_FORMAT_UINT8;
if (message.size < layout.offset + (wide ? 2 : 1)) {
ESP_LOGV(TAG, "Answer of %u bytes is too short", message.size);
return;
}
const uint8_t *field = message.data + layout.offset;
int32_t value = field[0];
if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_UINT16) {
value = encode_value_le<uint16_t>(field);
} else if (layout.format == DucoValueFormat::DUCO_VALUE_FORMAT_INT16) {
value = encode_value_le<int16_t>(field);
}
if (value >= layout.minimum && value <= layout.maximum)
this->publish_state(static_cast<float>(value) / layout.divisor);
}
} // namespace esphome::duco
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include "esphome/components/sensor/sensor.h"
#include "../duco.h"
namespace esphome::duco {
/// What a sensor reads; the request and where the value is in the answer are in READINGS in sensor.cpp.
enum class DucoReading : uint8_t {
DUCO_READING_CO2,
DUCO_READING_NODE_TEMPERATURE,
DUCO_READING_HUMIDITY,
DUCO_READING_FILTER_REMAINING,
DUCO_READING_FLOW_LEVEL,
DUCO_READING_TIME_REMAINING,
DUCO_READING_BOX_TEMPERATURE,
DUCO_READING_BYPASS,
};
class DucoSensor : public DucoDevice, public sensor::Sensor {
public:
/// argument is the node address, or the parameter number for a box parameter.
DucoSensor(DucoReading reading, uint8_t argument) : reading_(reading), argument_(argument) {}
void update() override;
void receive_response(const DucoMessage &message) override;
protected:
DucoReading reading_;
uint8_t argument_;
};
} // namespace esphome::duco
@@ -0,0 +1,27 @@
from esphome.components import text_sensor
import esphome.config_validation as cv
from esphome.types import ConfigType
from .. import (
CONF_DUCO_ID,
DUCO_COMPONENT_SCHEMA,
DucoDevice,
duco_ns,
register_duco_device,
)
DEPENDENCIES = ["duco"]
CODEOWNERS = ["@kokx"]
DucoSerial = duco_ns.class_("DucoSerial", DucoDevice, text_sensor.TextSensor)
CONFIG_SCHEMA = (
text_sensor.text_sensor_schema(DucoSerial)
.extend(cv.polling_component_schema("300s"))
.extend(DUCO_COMPONENT_SCHEMA)
)
async def to_code(config: ConfigType) -> None:
var = await text_sensor.new_text_sensor(config)
await register_duco_device(var, config, config[CONF_DUCO_ID])
@@ -0,0 +1,17 @@
#include "serial.h"
#include <cstring>
namespace esphome::duco {
void DucoSerial::update() { this->parent_->send(this, FUNCTION_NODE_DATA, {0x01, 0x01, 0x00, 0x1a, 0x10}); }
void DucoSerial::receive_response(const DucoMessage &message) {
if (message.size > 2) {
// The serial number starts at byte 2 and is padded with zeros
const char *serial = reinterpret_cast<const char *>(message.data + 2);
this->publish_state(serial, strnlen(serial, message.size - 2));
}
}
} // namespace esphome::duco
@@ -0,0 +1,15 @@
#pragma once
#include "esphome/components/text_sensor/text_sensor.h"
#include "../duco.h"
namespace esphome::duco {
class DucoSerial : public DucoDevice, public text_sensor::TextSensor {
public:
void update() override;
void receive_response(const DucoMessage &message) override;
};
} // namespace esphome::duco
+64 -8
View File
@@ -1,4 +1,5 @@
#include "epaper_spi.h"
#include <algorithm>
#include <cinttypes>
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
@@ -37,6 +38,42 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
return true;
}
bool EPaperBase::init_sent_frame_(size_t length) {
if (!this->is_using_partial_update_())
return false;
if (this->sent_.init(length))
return true;
ESP_LOGW(TAG, "No memory for the previous frame; partial updates will send the whole drawn area");
return false;
}
uint32_t EPaperBase::buffer_hash_() const {
uint32_t hash = FNV1_OFFSET_BASIS;
size_t index = 0;
while (index != this->buffer_.size()) {
size_t length;
const uint8_t *data = this->buffer_.get_span(index, length);
for (size_t i = 0; i != length; i++)
hash = fnv1a_hash_extend(hash, data[i]);
index += length;
}
return hash;
}
void EPaperBase::set_full_bounds_() {
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
void EPaperBase::reset_bounds_() {
this->x_low_ = this->width_;
this->x_high_ = 0;
this->y_low_ = this->height_;
this->y_high_ = 0;
}
void EPaperBase::setup_pins_() const {
for (auto *pin : this->enable_pins_) {
pin->setup();
@@ -233,17 +270,28 @@ void EPaperBase::process_state_() {
// Refresh the whole panel even if nothing was drawn
this->full_update_requested_ = false;
this->update_count_ = 0;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->set_full_bounds_();
}
if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) {
this->set_state_(EPaperState::IDLE);
return;
}
if (this->is_using_partial_update_()) {
const uint32_t frame_hash = this->buffer_hash_();
if (this->update_count_ != 0 && this->shown_hash_valid_ && frame_hash == this->shown_hash_) {
ESP_LOGD(TAG, "Frame unchanged, refresh skipped");
this->reset_bounds_();
this->set_state_(EPaperState::IDLE);
return;
}
this->shown_hash_ = frame_hash;
}
this->drawn_since_hash_ = false;
// Valid again only once this frame is on the panel, so an abandoned update leaves nothing to match
this->shown_hash_valid_ = false;
// A full update resets update_count_ to zero, so a non-zero count marks a partial one
this->partial_ = this->update_count_ != 0;
this->sent_count_ = 0;
this->set_state_(EPaperState::RESET);
break;
case EPaperState::INITIALISE:
@@ -256,10 +304,15 @@ void EPaperBase::process_state_() {
if (!this->transfer_data()) {
return; // Not done yet, come back next loop
}
this->x_low_ = this->width_;
this->x_high_ = 0;
this->y_low_ = this->height_;
this->y_high_ = 0;
if (this->sent_.is_valid() && this->sent_count_ == this->sent_.size())
this->sent_valid_ = true;
// A draw during the chunked push may not have reached the panel, so the next update sends the whole frame
this->sent_hash_intact_ = this->is_using_partial_update_() && !this->drawn_since_hash_;
if (this->drawn_since_hash_) {
this->set_full_bounds_();
} else {
this->reset_bounds_();
}
this->set_state_(EPaperState::POWER_ON);
break;
case EPaperState::POWER_ON:
@@ -280,6 +333,7 @@ void EPaperBase::process_state_() {
break;
case EPaperState::DEEP_SLEEP:
this->deep_sleep();
this->shown_hash_valid_ = this->sent_hash_intact_;
this->status_clear_warning();
this->set_state_(EPaperState::IDLE);
ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_);
@@ -365,6 +419,7 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) {
this->x_high_ = clamp_at_least(this->x_high_, x + 1);
this->y_low_ = clamp_at_most(this->y_low_, y);
this->y_high_ = clamp_at_least(this->y_high_, y + 1);
this->drawn_since_hash_ = true;
return true;
}
@@ -486,6 +541,7 @@ void HOT EPaperBase::draw_rgb565_block_(uint8_t *buffer, int x_start, int y_star
this->x_high_ = clamp_at_least(this->x_high_, x_high);
this->y_low_ = clamp_at_most(this->y_low_, y_low);
this->y_high_ = clamp_at_least(this->y_high_, y_high);
this->drawn_since_hash_ = true;
}
// Writes an RGB565 block of pixels, as LVGL sends them, straight into a 1 bit per pixel buffer that is one block (the
+27 -4
View File
@@ -99,10 +99,7 @@ class EPaperBase : public Display,
// We store 8 pixels per byte
this->buffer_.fill(pixel_color);
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->x_low_ = 0;
this->y_low_ = 0;
this->mark_all_drawn_();
}
void clear() override {
@@ -139,6 +136,25 @@ class EPaperBase : public Display,
void wait_for_idle_(bool should_wait);
void abandon_update_();
bool init_buffer_(size_t buffer_length);
// Allocates the copy of the previous frame; only drivers that diff against it (needs_previous_frame) call this
bool init_sent_frame_(size_t length);
void record_sent_(size_t index, const uint8_t *data, size_t length) {
if (this->sent_.is_valid()) {
this->sent_.write(index, data, length);
this->sent_count_ += length;
}
}
// Whether the driver reads the previous frame (sent_) for its differential refresh
virtual bool needs_previous_frame() const { return false; }
// FNV-1a hash of the current frame buffer, to detect an unchanged frame without a full copy
uint32_t buffer_hash_() const;
void reset_bounds_();
void set_full_bounds_();
// For a draw that writes the whole buffer without going through rotate_coordinates_()
void mark_all_drawn_() {
this->set_full_bounds_();
this->drawn_since_hash_ = true;
}
void update_effective_transform_();
bool rotate_coordinates_(int &x, int &y);
void draw_rgb565_block_(uint8_t *buffer, int x_start, int y_start, int w, int h, const uint8_t *ptr, bool big_endian,
@@ -200,6 +216,13 @@ class EPaperBase : public Display,
size_t buffer_length_;
size_t current_data_index_{}; // used by data transfer to track progress
split_buffer::SplitBuffer buffer_{};
split_buffer::SplitBuffer sent_{}; // the previous frame, kept only by drivers that diff against it
bool sent_valid_{}; // sent_ holds a whole frame
size_t sent_count_{}; // bytes recorded into sent_ by the update in progress
uint32_t shown_hash_{}; // hash of the frame on the panel, to skip an unchanged update
bool shown_hash_valid_{}; // shown_hash_ holds the hash of a frame whose refresh completed
bool sent_hash_intact_{}; // the buffer was unchanged by the end of the push of shown_hash_
bool drawn_since_hash_{}; // something was drawn after shown_hash_ was taken
GPIOPin *dc_pin_{};
GPIOPin *busy_pin_{};
GPIOPin *reset_pin_{};
@@ -18,11 +18,7 @@ void EPaper4bpp::fill(Color color) {
// We store 2 pixels per byte
this->buffer_.fill(pixel_color + (pixel_color << 4));
// Whole buffer just changed; mark the entire canvas dirty.
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->mark_all_drawn_();
}
void EPaper4bpp::clear() {
@@ -71,10 +71,7 @@ void EPaperInkplate2::fill(Color color) {
for (size_t i = half_buffer; i < this->buffer_length_; i++)
this->buffer_[i] = red_byte;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->mark_all_drawn_();
}
void EPaperInkplate2::clear() { this->fill(COLOR_ON); }
@@ -30,6 +30,7 @@ void EPaperJD79660::fill(Color color) {
// We store 4 pixels per byte
this->buffer_.fill(pixel_color | (pixel_color << 2) | (pixel_color << 4) | (pixel_color << 6));
this->drawn_since_hash_ = true;
}
void HOT EPaperJD79660::draw_pixel_at(int x, int y, Color color) {
@@ -7,6 +7,12 @@
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.mono");
void EPaperMono::setup() {
EPaperBase::setup();
if (!this->is_failed() && this->needs_previous_frame())
this->init_sent_frame_(this->sent_frame_length());
}
void EPaperMono::refresh_screen(bool partial) {
ESP_LOGV(TAG, "Refresh screen");
this->cmd_data(0x22, {partial ? (uint8_t) 0xFF : (uint8_t) 0xF7});
@@ -66,7 +72,8 @@ bool HOT EPaperMono::transfer_data() {
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_ / 8;
size_t data_idx = row_start;
for (size_t i = 0; i != row_length; i++) {
bytes_to_send[i] = this->send_red_ && !this->send_red_as_image_ ? 0 : this->buffer_[data_idx++];
}
@@ -12,6 +12,8 @@ class EPaperMono : public EPaperBase {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
: EPaperBase(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
void setup() override;
protected:
void refresh_screen(bool partial) override;
void power_on() override {}
@@ -19,6 +21,8 @@ class EPaperMono : public EPaperBase {
void deep_sleep() override;
bool reset() override;
virtual void set_window();
// Bytes in the comparison frame; the buffer's size unless a subclass sends the frame in another layout
virtual size_t sent_frame_length() const { return this->buffer_length_; }
bool transfer_data() override;
bool send_red_{true};
bool software_reset_{true}; // send 0x12 after the hardware reset
@@ -6,21 +6,6 @@
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1677");
void EPaperSSD1677::setup() {
EPaperMono::setup();
if (!this->is_failed())
this->init_comparison_frame_();
}
void EPaperSSD1677::init_comparison_frame_() {
if (!this->is_using_partial_update_())
return;
if (!this->sent_.init(this->plane_row_length_() * this->height_)) {
ESP_LOGW(TAG, "No memory for the comparison frame; partial updates will degrade unchanged areas");
}
}
void EPaperSSD1677::plane_row(size_t y, uint8_t *out) {
const size_t row_length = this->plane_row_length_();
@@ -82,7 +67,7 @@ bool HOT EPaperSSD1677::transfer_data() {
}
this->write_array(data, length);
if (this->plane_ == 1)
this->sent_.write(this->current_data_index_, data, length);
this->record_sent_(this->current_data_index_, data, length);
this->current_data_index_ += length;
if (this->current_data_index_ != plane_length && millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
@@ -26,12 +26,12 @@ class EPaperSSD1677 : public EPaperMono {
size_t init_sequence_length, DisplayType display_type = DISPLAY_TYPE_BINARY, uint8_t bits_per_pixel = 1)
: EPaperMono(name, width, height, init_sequence, init_sequence_length, display_type, bits_per_pixel) {}
void setup() override;
protected:
// Allocates the comparison frame when partial updates are enabled. Separate from setup() so it
// can run without a bus.
void init_comparison_frame_();
// Reads the previous frame to copy it into the other RAM bank on a full refresh
bool needs_previous_frame() const override { return true; }
// Allocates the comparison frame as setup() would, for tests that run without a bus.
void init_comparison_frame_() { this->init_sent_frame_(this->sent_frame_length()); }
size_t sent_frame_length() const override { return this->plane_row_length_() * this->height_; }
// Bytes in one row of a RAM plane: 8 pixels per byte, whatever the buffer holds.
size_t plane_row_length_() const { return (this->width_ + 7) / 8; }
// Row y of the frame as a RAM plane holds it, 1 bit per pixel with 1 = white.
@@ -39,8 +39,7 @@ class EPaperSSD1677 : public EPaperMono {
bool reset() override;
bool transfer_data() override;
split_buffer::SplitBuffer sent_{}; // the frame last sent to 0x24, i.e. what the panel shows
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
uint8_t plane_{0}; // 0 while sending 0x26, 1 while sending 0x24
};
} // namespace esphome::epaper_spi
@@ -29,10 +29,7 @@ void EPaperSSD1677Gray4::fill(Color color) {
}
const uint8_t level = color_to_gray4(color);
this->buffer_.fill((uint8_t) (level | (level << 2) | (level << 4) | (level << 6)));
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
this->mark_all_drawn_();
}
void HOT EPaperSSD1677Gray4::draw_pixel_at(int x, int y, Color color) {
@@ -83,14 +80,14 @@ bool HOT EPaperSSD1677Gray4::transfer_data() {
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
const size_t src_row = this->current_data_index_ * this->row_width_ + this->x_low_ / 4;
for (size_t i = 0; i != plane_row_length; i++) {
const uint8_t plane = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// The OTP grayscale waveform treats data as inverted relative to monochrome
bytes_to_send[i] = (uint8_t) ~plane;
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass && this->sent_.is_valid())
this->sent_[this->current_data_index_ * plane_row_length + i] = plane;
}
for (size_t i = 0; i != plane_row_length; i++)
bytes_to_send[i] = plane_byte(this->buffer_[src_row + 2 * i], this->buffer_[src_row + 2 * i + 1], first_pass);
// What the next partial update compares against: the high bits, as a black-and-white frame.
if (first_pass)
this->record_sent_(this->current_data_index_ * plane_row_length, bytes_to_send, plane_row_length);
// The OTP grayscale waveform treats data as inverted relative to monochrome
for (size_t i = 0; i != plane_row_length; i++)
bytes_to_send[i] = (uint8_t) ~bytes_to_send[i];
++this->current_data_index_;
this->write_array(bytes_to_send, plane_row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
@@ -1,10 +1,50 @@
#include "epaper_spi_ssd1681.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/log.h"
namespace esphome::epaper_spi {
ESPHOME_LOG_TAG(TAG, "epaper_spi.ssd1681");
void EPaperSSD1681::bounds_from_changes_() {
if (!this->sent_valid_ || this->sent_.size() != this->buffer_.size())
return;
bool changed = false;
uint16_t row_low = this->height_, row_high = 0, col_low = this->row_width_, col_high = 0;
for (uint16_t row = 0; row != this->height_; row++) {
const size_t base = row * this->row_width_;
const size_t end = base + this->row_width_;
size_t index = base;
while (index != end) {
size_t new_length, old_length;
const uint8_t *new_bytes = this->buffer_.get_span(index, new_length);
const uint8_t *old_bytes = this->sent_.get_span(index, old_length);
const size_t length = std::min({new_length, old_length, end - index});
if (std::memcmp(new_bytes, old_bytes, length) != 0) {
for (size_t i = 0; i != length; i++) {
if (new_bytes[i] == old_bytes[i])
continue;
const auto col = static_cast<uint16_t>(index + i - base);
changed = true;
row_low = std::min(row_low, row);
row_high = std::max<uint16_t>(row_high, row + 1);
col_low = std::min(col_low, col);
col_high = std::max<uint16_t>(col_high, col + 1);
}
}
index += length;
}
}
if (!changed)
return;
this->x_low_ = col_low * 8;
this->x_high_ = std::min<uint16_t>(col_high * 8, this->width_);
this->y_low_ = row_low;
this->y_high_ = row_high;
}
bool HOT EPaperSSD1681::transfer_data() {
if (this->current_data_index_ == 0 && this->send_red_) {
if (!this->partial_) {
@@ -15,6 +55,7 @@ bool HOT EPaperSSD1681::transfer_data() {
this->y_high_ = this->height_;
} else {
// 0x26 already holds the image on the panel: the controller copies 0x24 into it after each refresh
this->bounds_from_changes_();
this->set_window();
this->send_red_ = false;
}
@@ -16,6 +16,10 @@ class EPaperSSD1681 : public EPaperSSD1683 {
}
protected:
// Reads the previous frame to compute the changed window for a partial refresh
bool needs_previous_frame() const override { return true; }
// Shrinks the update bounds to the bytes that differ from the frame last sent
void bounds_from_changes_();
bool transfer_data() override;
void refresh_screen(bool partial) override;
};
@@ -71,12 +71,15 @@ bool HOT EPaperSSD1683::transfer_data() {
ESP_LOGV(TAG, "Writing %u bytes at line %zu at %ums", row_length, this->current_data_index_, (unsigned) millis());
this->start_data_();
while (this->current_data_index_ != this->y_high_) {
size_t data_idx = this->current_data_index_ * this->row_width_ + this->x_low_;
const size_t row_start = this->current_data_index_ * this->row_width_ + this->x_low_;
size_t data_idx = row_start;
for (size_t i = 0; i != row_length; i++) {
bytes_to_send[i] = this->buffer_[data_idx++];
}
++this->current_data_index_;
this->write_array(&bytes_to_send.front(), row_length); // NOLINT
if (!this->send_red_)
this->record_sent_(row_start, &bytes_to_send.front(), row_length);
if (millis() - start_time > MAX_TRANSFER_TIME) {
// Let the main loop run and come back next loop
this->disable();
@@ -232,6 +232,7 @@ void EPaperT133A01::fill(Color color) {
}
auto pixel_color = color_to_index(color);
this->buffer_.fill(pixel_color + (pixel_color << 4));
this->drawn_since_hash_ = true;
}
void EPaperT133A01::draw_pixel_at(int x, int y, Color color) {
@@ -1,4 +1,5 @@
#include "epaper_waveshare_bwr.h"
#include "esphome/core/helpers.h"
#include <algorithm>
@@ -36,17 +37,9 @@ void EPaperWaveshareBWR::draw_pixel_at(int x, int y, Color color) {
const auto bwr = color_to_bwr(color);
if (bwr == BwrState::BWR_BLACK) {
this->buffer_[pos] |= bit;
} else {
this->buffer_[pos] &= ~bit;
}
assign_bits(this->buffer_[pos], bit, bwr == BwrState::BWR_BLACK);
if (bwr == BwrState::BWR_RED) {
this->buffer_[red_offset + pos] |= bit;
} else {
this->buffer_[red_offset + pos] &= ~bit;
}
assign_bits(this->buffer_[red_offset + pos], bit, bwr == BwrState::BWR_RED);
}
void EPaperWaveshareBWR::fill(Color color) {
+2 -5
View File
@@ -1,6 +1,7 @@
#include "es8156.h"
#include "es8156_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -100,11 +101,7 @@ bool ES8156::set_mute_state_(bool mute_state) {
ESP_LOGV(TAG, "Read ES8156_REG13_DAC_MUTE: %u", reg13);
if (mute_state) {
reg13 |= BIT(1) | BIT(2);
} else {
reg13 &= ~(BIT(1) | BIT(2));
}
assign_bits(reg13, BIT(1) | BIT(2), mute_state);
ESP_LOGV(TAG, "Setting ES8156_REG13_DAC_MUTE to %u (muted: %s)", reg13, YESNO(mute_state));
return this->write_byte(ES8156_REG13_DAC_MUTE, reg13);
+3 -10
View File
@@ -1,6 +1,7 @@
#include "es8311.h"
#include "es8311_const.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cinttypes>
@@ -154,11 +155,7 @@ bool ES8311::configure_clock_() {
// Register 0x06
uint8_t reg06;
ES8311_ERROR_CHECK(this->read_byte(ES8311_REG06_CLK_MANAGER, &reg06));
if (this->sclk_inverted_) {
reg06 |= BIT(5);
} else {
reg06 &= ~BIT(5);
}
assign_bits(reg06, BIT(5), this->sclk_inverted_);
reg06 &= 0xE0;
if (coefficient->bclk_div < 19) {
reg06 |= (coefficient->bclk_div - 1) << 0;
@@ -223,11 +220,7 @@ bool ES8311::set_mute_state_(bool mute_state) {
return false;
}
if (mute_state) {
reg31 |= BIT(6) | BIT(5);
} else {
reg31 &= ~(BIT(6) | BIT(5));
}
assign_bits(reg31, BIT(6) | BIT(5), mute_state);
return this->write_byte(ES8311_REG31_DAC, reg31);
}
+2 -5
View File
@@ -2,6 +2,7 @@
#include <cinttypes>
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::es8388 {
@@ -184,11 +185,7 @@ bool ES8388::set_mute_state_(bool mute_state) {
// DAC settings that must be preserved. Previously muting overwrote the whole
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
// could not be unmuted again.
if (mute_state) {
value |= ES8388_DACCONTROL3_DAC_MUTE;
} else {
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
}
assign_bits(value, ES8388_DACCONTROL3_DAC_MUTE, mute_state);
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
return this->write_byte(ES8388_DACCONTROL3, value);
+2 -5
View File
@@ -1,5 +1,6 @@
#include "gree.h"
#include "esphome/components/remote_base/remote_base.h"
#include "esphome/core/helpers.h"
namespace esphome::gree {
@@ -24,11 +25,7 @@ void GreeClimate::set_model(Model model) {
}
void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) {
if (enabled) {
this->mode_bits_ |= bit_mask;
} else {
this->mode_bits_ &= ~bit_mask;
}
assign_bits(this->mode_bits_, bit_mask, enabled);
this->transmit_state();
}
@@ -1,4 +1,5 @@
#include "hitachi_ac344.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac344 {
@@ -16,15 +17,6 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -124,7 +116,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC3
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
set_bit(&remote_state_[HITACHI_AC344_SWINGV_BYTE], HITACHI_AC344_SWINGV_OFFSET, on);
assign_bits(this->remote_state_[HITACHI_AC344_SWINGV_BYTE], 1 << HITACHI_AC344_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
@@ -1,4 +1,5 @@
#include "hitachi_ac424.h"
#include "esphome/core/helpers.h"
namespace esphome::hitachi_ac424 {
@@ -16,15 +17,6 @@ void set_bits(uint8_t *const dst, const uint8_t offset, const uint8_t nbits, con
*dst |= ((data & mask) << offset);
}
void set_bit(uint8_t *const data, const uint8_t position, const bool on) {
uint8_t mask = 1 << position;
if (on) {
*data |= mask;
} else {
*data &= ~mask;
}
}
uint8_t *invert_byte_pairs(uint8_t *ptr, const uint16_t length) {
for (uint16_t i = 1; i < length; i += 2) {
// Code done this way to avoid a compiler warning bug.
@@ -124,7 +116,7 @@ bool HitachiClimate::get_swing_v_toggle_() { return get_button_() == HITACHI_AC4
void HitachiClimate::set_swing_v_(bool on) {
set_swing_v_toggle_(on); // Set the button value.
set_bit(&remote_state_[HITACHI_AC424_SWINGV_BYTE], HITACHI_AC424_SWINGV_OFFSET, on);
assign_bits(this->remote_state_[HITACHI_AC424_SWINGV_BYTE], 1 << HITACHI_AC424_SWINGV_OFFSET, on);
}
bool HitachiClimate::get_swing_v_() {
+15 -55
View File
@@ -3,67 +3,36 @@
namespace esphome::max17043 {
// MAX174043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// MAX17043 is a 1-Cell Fuel Gauge with ModelGauge and Low-Battery Alert
// Consult the datasheet at https://www.analog.com/en/products/max17043.html
ESPHOME_LOG_TAG(TAG, "max17043");
static const uint8_t MAX17043_VCELL = 0x02;
static const uint8_t MAX17043_SOC = 0x04;
static const uint8_t MAX17043_CONFIG = 0x0c;
static const uint16_t MAX17043_CONFIG_POWER_UP_DEFAULT = 0x971C;
static const uint16_t MAX17043_CONFIG_SAFE_MASK = 0xFF1F; // mask out sleep bit (7), unused bit (6) and alert bit (4)
static const uint16_t MAX17043_CONFIG_SLEEP_MASK = 0x0080;
void MAX17043Component::update() {
uint16_t raw_voltage, raw_percent;
if (!this->ready_for_update_())
return;
uint16_t raw;
bool ok = true;
if (this->voltage_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_VCELL, &raw_voltage)) {
this->status_set_warning(LOG_STR("Unable to read MAX17043_VCELL"));
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
// 12 bits in the top of the word at 1.25 mV
this->voltage_sensor_->publish_state((raw >> 4) * 1.25e-3f);
} else {
float voltage = (1.25f * (float) (raw_voltage >> 4)) / 1000.0f;
this->voltage_sensor_->publish_state(voltage);
this->status_clear_warning();
ok = false;
}
}
if (this->battery_remaining_sensor_ != nullptr) {
if (!this->read_byte_16(MAX17043_SOC, &raw_percent)) {
this->status_set_warning(LOG_STR("Unable to read MAX17043_SOC"));
} else {
float percent = (float) ((raw_percent >> 8) + 0.003906f * (raw_percent & 0x00ff));
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
float percent = (float) ((raw >> 8) + 0.003906f * (raw & 0x00ff));
this->battery_remaining_sensor_->publish_state(percent);
this->status_clear_warning();
} else {
ok = false;
}
}
}
void MAX17043Component::setup() {
uint16_t config_reg;
if (this->write(&MAX17043_CONFIG, 1) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
if (this->read(reinterpret_cast<uint8_t *>(&config_reg), 2) != i2c::ERROR_OK) {
this->status_set_warning();
return;
}
config_reg = i2c::i2ctohs(config_reg) & MAX17043_CONFIG_SAFE_MASK;
ESP_LOGV(TAG, "MAX17043 CONFIG register reads 0x%X", config_reg);
if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) {
this->mark_failed(LOG_STR("Device does not appear to be a MAX17043"));
return;
}
// need to write back to config register to reset the sleep bit
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) {
this->mark_failed(LOG_STR("sleep reset failed"));
return;
}
this->status_comm_result(ok, LOG_STR("Register read failed"));
}
void MAX17043Component::dump_config() {
@@ -74,13 +43,4 @@ void MAX17043Component::dump_config() {
LOG_SENSOR(" ", "Battery Level", this->battery_remaining_sensor_);
}
void MAX17043Component::sleep_mode() {
if (!this->is_failed()) {
if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT | MAX17043_CONFIG_SLEEP_MASK)) {
ESP_LOGW(TAG, "Unable to write the sleep bit to config register");
this->status_set_warning();
}
}
}
} // namespace esphome::max17043
+4 -7
View File
@@ -1,21 +1,18 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/max170xx/max170xx.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max17043 {
class MAX17043Component final : public PollingComponent, public i2c::I2CDevice {
class MAX17043Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17043> {
public:
void setup() override;
void dump_config() override;
void update() override;
void sleep_mode();
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { voltage_sensor_ = voltage_sensor; }
void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; }
void set_battery_remaining_sensor(sensor::Sensor *battery_remaining_sensor) {
battery_remaining_sensor_ = battery_remaining_sensor;
this->battery_remaining_sensor_ = battery_remaining_sensor;
}
protected:
+1
View File
@@ -16,6 +16,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
AUTO_LOAD = ["max170xx"]
DEPENDENCIES = ["i2c"]
max17043_ns = cg.esphome_ns.namespace("max17043")
+1 -1
View File
@@ -1,2 +1,2 @@
CODEOWNERS = ["@B48D81EFCC"]
CODEOWNERS = ["@B48D81EFCC", "@ryansch"]
DOMAIN = "max17048"
+6 -20
View File
@@ -8,41 +8,27 @@ namespace esphome::max17048 {
ESPHOME_LOG_TAG(TAG, "max17048");
static constexpr uint8_t REG_VCELL = 0x02; // cell voltage
static constexpr uint8_t REG_SOC = 0x04; // state of charge
static constexpr uint8_t REG_VERSION = 0x08; // 0x001x
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
static constexpr uint16_t VERSION_MASK = 0xFFF0;
static constexpr uint16_t VERSION_FAMILY = 0x0010;
static constexpr uint8_t REG_CRATE = 0x16; // charge rate, signed
static constexpr float VCELL_VOLTS_PER_LSB = 78.125e-6f;
static constexpr float SOC_PERCENT_PER_LSB = 1.0f / 256.0f;
static constexpr float CRATE_PERCENT_PER_HOUR_PER_LSB = 0.208f;
void MAX17048Component::setup() {
uint16_t version;
if (!this->read_byte_16(REG_VERSION, &version)) {
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
return;
}
if ((version & VERSION_MASK) != VERSION_FAMILY) {
ESP_LOGW(TAG, "Unexpected version 0x%04X", version);
}
}
void MAX17048Component::update() {
if (!this->ready_for_update_())
return;
bool ok = true;
uint16_t raw;
if (this->battery_voltage_sensor_ != nullptr) {
if (this->read_byte_16(REG_VCELL, &raw)) {
if (this->read_byte_16(max170xx::REG_VCELL, &raw)) {
this->battery_voltage_sensor_->publish_state(raw * VCELL_VOLTS_PER_LSB);
} else {
ok = false;
}
}
if (this->battery_level_sensor_ != nullptr) {
if (this->read_byte_16(REG_SOC, &raw)) {
if (this->read_byte_16(max170xx::REG_SOC, &raw)) {
this->battery_level_sensor_->publish_state(raw * SOC_PERCENT_PER_LSB);
} else {
ok = false;
+2 -4
View File
@@ -1,18 +1,16 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/max170xx/max170xx.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max17048 {
class MAX17048Component final : public PollingComponent, public i2c::I2CDevice {
class MAX17048Component final : public max170xx::MAX170xxComponent<max170xx::Model::MODEL_MAX17048> {
SUB_SENSOR(battery_voltage)
SUB_SENSOR(battery_level)
SUB_SENSOR(rate)
public:
void setup() override;
void dump_config() override;
void update() override;
};
+16
View File
@@ -1,9 +1,12 @@
from esphome import automation
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
CONF_BATTERY_VOLTAGE,
CONF_ID,
CONF_RATE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_VOLTAGE,
@@ -14,6 +17,7 @@ from esphome.const import (
)
from esphome.types import ConfigType
AUTO_LOAD = ["max170xx"]
DEPENDENCIES = ["i2c"]
UNIT_PERCENT_PER_HOUR = "%/h"
@@ -61,3 +65,15 @@ async def to_code(config: ConfigType) -> None:
await sensors(CONF_BATTERY_VOLTAGE, var.set_battery_voltage_sensor)
await sensors(CONF_BATTERY_LEVEL, var.set_battery_level_sensor)
await sensors(CONF_RATE, var.set_rate_sensor)
MAX17048_ACTION_SCHEMA = maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(MAX17048Component),
}
)
automation.register_apply_action(
"max17048.sleep_mode", MAX17048_ACTION_SCHEMA, automation.ApplyCall("sleep_mode()")
)
+3
View File
@@ -0,0 +1,3 @@
CODEOWNERS = ["@ryansch"]
DEPENDENCIES = ["i2c"]
DOMAIN = "max170xx"
+87
View File
@@ -0,0 +1,87 @@
#include "max170xx.h"
#include "esphome/core/log.h"
namespace esphome::max170xx {
ESPHOME_LOG_TAG(TAG, "max170xx");
template<Model MODEL> void MAX170xxComponent<MODEL>::setup() {
// A silent bus is retried from update(): some boards gate the I2C rail until after setup.
// An early on_boot may already have configured the gauge and put it to sleep, so this must not configure again.
this->ready_for_update_();
}
template<Model MODEL> bool MAX170xxComponent<MODEL>::ready_for_update_() {
if (this->configured_ || this->configure_())
return true;
this->status_set_warning(LOG_STR("Gauge did not answer"));
return false;
}
template<Model MODEL> bool MAX170xxComponent<MODEL>::configure_() {
uint16_t config;
if (!this->read_byte_16(REG_CONFIG, &config))
return false;
const bool was_asleep = (config & CONFIG_SLEEP) != 0;
// RCOMP and ATHD survive on the battery, so an earlier firmware's values are reset here, not treated as a fault
if ((config & CONFIG_SAFE_MASK) != CONFIG_POWER_UP_DEFAULT) {
ESP_LOGW(TAG, "CONFIG reads 0x%04X, expected 0x%04X; resetting it", config & CONFIG_SAFE_MASK,
CONFIG_POWER_UP_DEFAULT);
}
uint16_t version;
if (this->read_byte_16(REG_VERSION, &version)) {
ESP_LOGCONFIG(TAG, "IC version: 0x%04X", version);
}
// Write back the power-up value to clear a stale sleep bit; a failed write is retried like a silent bus
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT))
return false;
this->configured_ = true;
// Waking from sleep raises a false low-battery alert; a real one at an ordinary boot is left alone
if (was_asleep)
this->set_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID, WAKE_ALERT_CLEAR_DELAY_MS, [this]() { this->clear_wake_alert_(); });
return true;
}
template<Model MODEL> void MAX170xxComponent<MODEL>::clear_wake_alert_() {
uint16_t config;
if (!this->read_byte_16(REG_CONFIG, &config) || !this->write_byte_16(REG_CONFIG, config & ~CONFIG_ALRT)) {
this->status_set_warning(LOG_STR("Wake alert clear failed"));
return;
}
if constexpr (MODEL == Model::MODEL_MAX17048) {
uint16_t status;
if (!this->read_byte_16(REG_STATUS, &status) || !this->write_byte_16(REG_STATUS, status & ~STATUS_HD))
this->status_set_warning(LOG_STR("Wake alert clear failed"));
}
}
template<Model MODEL> void MAX170xxComponent<MODEL>::sleep_mode() {
if (this->is_failed())
return;
// The deferred configuration clears the sleep bit, so it has to run before the gauge is put to sleep
if (!this->ready_for_update_())
return;
// A clear still pending would otherwise run against a sleeping gauge
if (this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID))
this->clear_wake_alert_();
if constexpr (MODEL == Model::MODEL_MAX17048) {
if (!this->write_byte_16(REG_MODE, MODE_EN_SLEEP)) {
this->status_set_warning(LOG_STR("Unable to enable sleep mode in the mode register"));
return;
}
}
if (!this->write_byte_16(REG_CONFIG, CONFIG_POWER_UP_DEFAULT | CONFIG_SLEEP))
this->status_set_warning(LOG_STR("Unable to write the sleep bit to config register"));
}
template class MAX170xxComponent<Model::MODEL_MAX17043>;
template class MAX170xxComponent<Model::MODEL_MAX17048>;
} // namespace esphome::max170xx
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome::max170xx {
// Shared base for the ModelGauge fuel gauges.
// MAX17043: https://www.analog.com/en/products/max17043.html
// MAX17048: https://www.analog.com/en/products/max17048.html
// The MAX17048 is register-compatible with the MAX17043, and adds a STATUS register and a MODE.EnSleep gate in
// front of CONFIG.SLEEP.
static constexpr uint8_t REG_VCELL = 0x02;
static constexpr uint8_t REG_SOC = 0x04;
static constexpr uint8_t REG_MODE = 0x06;
static constexpr uint8_t REG_VERSION = 0x08;
static constexpr uint8_t REG_CONFIG = 0x0C;
static constexpr uint8_t REG_STATUS = 0x1A;
static constexpr uint16_t CONFIG_POWER_UP_DEFAULT = 0x971C;
// Mask out SLEEP (7), ALRT (5), and bit 6, which is a don't-care on the MAX17043 but ALSC on the MAX17048.
static constexpr uint16_t CONFIG_SAFE_MASK = 0xFF1F;
static constexpr uint16_t CONFIG_SLEEP = 0x0080;
static constexpr uint16_t CONFIG_ALRT = 0x0020;
// CONFIG.SLEEP is ignored unless MODE.EnSleep (bit 13) is set first.
static constexpr uint16_t MODE_EN_SLEEP = 0x2000;
static constexpr uint16_t STATUS_HD = 0x1000;
static constexpr uint32_t WAKE_ALERT_CLEAR_TIMEOUT_ID = 0;
// The gauge raises its false wake alert some time after the wake write; on hardware it rose 0.8 to 1.0 s later
static constexpr uint32_t WAKE_ALERT_CLEAR_DELAY_MS = 2000;
enum class Model : uint8_t {
MODEL_MAX17043,
MODEL_MAX17048,
};
/// Defined in max170xx.cpp, which instantiates both models.
template<Model MODEL> class MAX170xxComponent : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void sleep_mode();
protected:
/// True once the gauge is configured; otherwise retries the configuration and warns if the gauge did not answer.
/// Call first in update().
bool ready_for_update_();
/// Verify and wake the gauge; false if it did not answer.
bool configure_();
/// Clear the false alert raised on waking, leaving every other CONFIG and STATUS bit as it is.
void clear_wake_alert_();
bool configured_{false};
};
extern template class MAX170xxComponent<Model::MODEL_MAX17043>;
extern template class MAX170xxComponent<Model::MODEL_MAX17048>;
} // namespace esphome::max170xx
+2 -5
View File
@@ -1,4 +1,5 @@
#include "mcp23016.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <cstdio>
@@ -79,11 +80,7 @@ void MCP23016::update_reg_(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg_(reg_addr, &reg_value);
}
if (pin_value) {
reg_value |= 1 << pin;
} else {
reg_value &= ~(1 << pin);
}
assign_bits(reg_value, 1 << pin, pin_value);
this->write_reg_(reg_addr, reg_value);
@@ -1,4 +1,5 @@
#include "mcp23x08_base.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::mcp23x08_base {
@@ -78,11 +79,7 @@ void MCP23X08Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg(reg_addr, &reg_value);
}
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
assign_bits(reg_value, 1 << bit, pin_value);
this->write_reg(reg_addr, reg_value);
@@ -92,11 +92,7 @@ void MCP23X17Base::update_reg(uint8_t pin, bool pin_value, uint8_t reg_addr) {
this->read_reg(reg_addr, &reg_value);
}
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
assign_bits(reg_value, 1 << bit, pin_value);
this->write_reg(reg_addr, reg_value);
+16 -16
View File
@@ -33,22 +33,22 @@ uint16_t parse_u8_hb_60(OpenthermData &data) { return data.valueHB * 60; }
int16_t parse_s16(OpenthermData &data) { return data.get_s16(); }
float parse_f88(OpenthermData &data) { return data.get_f88(); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { data.valueLB = write_bit(data.valueLB, 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { data.valueHB = write_bit(data.valueHB, 7, value); }
void write_flag8_lb_0(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 0, value); }
void write_flag8_lb_1(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 1, value); }
void write_flag8_lb_2(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 2, value); }
void write_flag8_lb_3(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 3, value); }
void write_flag8_lb_4(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 4, value); }
void write_flag8_lb_5(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 5, value); }
void write_flag8_lb_6(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 6, value); }
void write_flag8_lb_7(const bool value, OpenthermData &data) { assign_bits(data.valueLB, 1 << 7, value); }
void write_flag8_hb_0(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 0, value); }
void write_flag8_hb_1(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 1, value); }
void write_flag8_hb_2(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 2, value); }
void write_flag8_hb_3(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 3, value); }
void write_flag8_hb_4(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 4, value); }
void write_flag8_hb_5(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 5, value); }
void write_flag8_hb_6(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 6, value); }
void write_flag8_hb_7(const bool value, OpenthermData &data) { assign_bits(data.valueHB, 1 << 7, value); }
void write_u8_lb(const uint8_t value, OpenthermData &data) { data.valueLB = value; }
void write_u8_hb(const uint8_t value, OpenthermData &data) { data.valueHB = value; }
void write_s8_lb(const int8_t value, OpenthermData &data) { data.valueLB = (uint8_t) value; }
-8
View File
@@ -20,14 +20,6 @@ namespace esphome::opentherm {
template<class T> constexpr T read_bit(T value, uint8_t bit) { return (value >> bit) & 0x01; }
template<class T> constexpr T set_bit(T value, uint8_t bit) { return value |= (1UL << bit); }
template<class T> constexpr T clear_bit(T value, uint8_t bit) { return value &= ~(1UL << bit); }
template<class T> constexpr T write_bit(T value, uint8_t bit, uint8_t bit_value) {
return bit_value ? set_bit(value, bit) : clear_bit(value, bit);
}
enum OperationMode {
IDLE = 0, // no operation
+2 -5
View File
@@ -1,4 +1,5 @@
#include "pca6416a.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca6416a {
@@ -170,11 +171,7 @@ void PCA6416AComponent::update_register_(uint8_t pin, bool pin_value, uint8_t re
this->read_register_(reg_addr, &reg_value);
}
if (pin_value) {
reg_value |= 1 << bit;
} else {
reg_value &= ~(1 << bit);
}
assign_bits(reg_value, 1 << bit, pin_value);
this->write_register_(reg_addr, reg_value);
+2 -5
View File
@@ -1,4 +1,5 @@
#include "pca9505.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca9505 {
@@ -74,11 +75,7 @@ bool PCA9505Component::digital_read_cache(uint8_t pin) { return this->input_[pin
void PCA9505Component::digital_write_hw(uint8_t pin, bool value) {
const uint8_t bank = pin / 8;
const uint8_t mask = 1 << (pin % 8);
if (value) {
this->output_[bank] |= mask;
} else {
this->output_[bank] &= ~mask;
}
assign_bits(this->output_[bank], mask, value);
this->write_bank_(OUTPUT_REG, bank, this->output_[bank]);
}
+2 -5
View File
@@ -1,4 +1,5 @@
#include "pca9554.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pca9554 {
@@ -99,11 +100,7 @@ bool PCA9554Component::digital_read_cache(uint8_t pin) {
}
void PCA9554Component::digital_write_hw(uint8_t pin, bool value) {
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
assign_bits(this->output_mask_, 1 << pin, value);
#ifdef USE_PCA9554_PCAL
if (this->write_register_(OUTPUT_REG, this->output_mask_))
this->retry_pcal_shadows_(); // an output-only hub never reads, so it retries here
+1 -5
View File
@@ -90,11 +90,7 @@ void HOT PCD8544::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y % 8;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void PCD8544::dump_config() {
+1 -5
View File
@@ -60,11 +60,7 @@ bool PCF8574Component::digital_read_hw(uint8_t pin) {
bool PCF8574Component::digital_read_cache(uint8_t pin) { return this->input_mask_ & (1 << pin); }
void PCF8574Component::digital_write_hw(uint8_t pin, bool value) {
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
assign_bits(this->output_mask_, 1 << pin, value);
this->write_gpio_();
}
void PCF8574Component::pin_mode(uint8_t pin, gpio::Flags flags) {
@@ -1,4 +1,5 @@
#include "pi4ioe5v6408.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::pi4ioe5v6408 {
@@ -129,11 +130,7 @@ void PI4IOE5V6408Component::digital_write_hw(uint8_t pin, bool value) {
if (this->is_failed())
return;
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
assign_bits(this->output_mask_, 1 << pin, value);
if (!this->status_comm_result(this->write_byte(PI4IOE5V6408_REGISTER_OUT_SET, this->output_mask_),
LOG_STR("Failed to write output register")))
return;
@@ -1,5 +1,6 @@
#include "runtime_image.h"
#include "image_decoder.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cstdint>
@@ -91,11 +92,7 @@ void RuntimeImage::draw_pixel(int x, int y, const Color &color) {
auto on = is_color_on(color);
if (this->has_transparency() && color.w < 0x80)
on = false;
if (on) {
this->buffer_[pos] |= bitno;
} else {
this->buffer_[pos] &= ~bitno;
}
assign_bits(this->buffer_[pos], bitno, on);
break;
}
case image::IMAGE_TYPE_RGB111: {
@@ -342,11 +342,7 @@ void HOT SSD1306::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void SSD1306::fill(Color color) {
// If clipping is active, fall back to base implementation
+15 -26
View File
@@ -53,6 +53,13 @@ MODELS = DriverChip.get_models()
DEFAULT_MODEL = "WAVESHARE-400X300"
def _model_key(model: DriverChip, key: str) -> cv.Optional | cv.Required:
"""Default the key from the model, or require it when the model has none."""
if model.get_default(key, None) is None:
return cv.Required(key)
return model.option(key)
def _model_schema(model: DriverChip) -> cv.Schema:
iseqconf = (
cv.Required(CONF_INIT_SEQUENCE)
@@ -72,12 +79,12 @@ def _model_schema(model: DriverChip) -> cv.Schema:
{
cv.GenerateID(): cv.declare_id(ST7305),
cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True),
model.option(CONF_CS_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
model.option(CONF_DC_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
_model_key(model, CONF_CS_PIN): pins.gpio_output_pin_schema,
_model_key(model, CONF_DC_PIN): pins.gpio_output_pin_schema,
model.option(CONF_RESET_PIN, cv.UNDEFINED): pins.gpio_output_pin_schema,
cv.Optional(CONF_WIDTH): cv.int_range(min=8, max=1024),
cv.Optional(CONF_HEIGHT): cv.int_range(min=8, max=1024),
cv.Optional(CONF_PORTRAIT): cv.boolean,
_model_key(model, CONF_WIDTH): cv.int_range(min=8, max=1024),
_model_key(model, CONF_HEIGHT): cv.int_range(min=8, max=1024),
_model_key(model, CONF_PORTRAIT): cv.boolean,
cv.Optional(CONF_COLUMN_START): cv.int_range(min=0, max=255),
cv.Optional(CONF_COLUMN_END): cv.int_range(min=0, max=255),
cv.Optional(CONF_ROW_START): cv.int_range(min=0, max=255),
@@ -89,23 +96,9 @@ def _model_schema(model: DriverChip) -> cv.Schema:
def _resolve(config: ConfigType, model: DriverChip) -> None:
"""Fill in model defaults and validate the geometry and address window."""
if (width := config.get(CONF_WIDTH)) is None:
if (width := model.get_default(CONF_WIDTH, None)) is None:
raise cv.Invalid("'width' is required for this model", path=[CONF_WIDTH])
config[CONF_WIDTH] = width
if (height := config.get(CONF_HEIGHT)) is None:
if (height := model.get_default(CONF_HEIGHT, None)) is None:
raise cv.Invalid("'height' is required for this model", path=[CONF_HEIGHT])
config[CONF_HEIGHT] = height
if config.get(CONF_PORTRAIT) is None:
if (default := model.get_default(CONF_PORTRAIT, None)) is None:
raise cv.Invalid(
"'portrait' is required for this model", path=[CONF_PORTRAIT]
)
config[CONF_PORTRAIT] = default
"""Derive and validate the address window, and default the init sequence."""
width = config[CONF_WIDTH]
height = config[CONF_HEIGHT]
if config[CONF_PORTRAIT]:
if width % 12 or height % 2:
raise cv.Invalid(
@@ -152,10 +145,6 @@ def _resolve(config: ConfigType, model: DriverChip) -> None:
if config.get(CONF_INIT_SEQUENCE) is None:
config[CONF_INIT_SEQUENCE] = model.initsequence
for key, label in ((CONF_CS_PIN, "cs_pin"), (CONF_DC_PIN, "dc_pin")):
if key not in config:
raise cv.Invalid(f"'{label}' is required for this model", path=[key])
def model_schema(config: ConfigType) -> cv.Schema:
"""Return the schema for the model selected in the config."""
+2 -7
View File
@@ -1,8 +1,8 @@
#include "st7305.h"
#include <algorithm>
#include <cstring>
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::st7305 {
@@ -27,7 +27,6 @@ bool ST7305::init_buffer_() {
this->mark_failed(LOG_STR("Buffer allocation failed"));
return false;
}
memset(this->buffer_, 0x00, this->buffer_length_);
this->clear_dirty_();
return true;
}
@@ -191,11 +190,7 @@ void HOT ST7305::draw_absolute_pixel_internal(int x, int y, Color color) {
// The init sequence enables INVON, so a set bit is white and a clear bit is black.
uint8_t &value = this->buffer_[index];
const uint8_t previous = value;
if (color.is_on()) {
value |= mask;
} else {
value &= ~mask;
}
assign_bits(value, mask, color.is_on());
if (value == previous)
return;
this->extend_dirty_(x, y, x, y);
@@ -123,11 +123,7 @@ void HOT ST7567::draw_absolute_pixel_internal(int x, int y, Color color) {
uint16_t pos = x + (y / 8) * this->get_width_internal();
uint8_t subpos = y & 0x07;
if (color.is_on()) {
this->buffer_[pos] |= (1 << subpos);
} else {
this->buffer_[pos] &= ~(1 << subpos);
}
assign_bits(this->buffer_[pos], 1 << subpos, color.is_on());
}
void ST7567::fill(Color color) {
+5 -24
View File
@@ -92,20 +92,9 @@ void SX1509Component::digital_write_hw(uint8_t pin, bool bit_value) {
// If the pin is an output, write high/low
uint16_t temp_reg_data = 0;
this->read_byte_16(REG_DATA_B, &temp_reg_data);
if (bit_value) {
output_state_ |= (1 << pin); // set bit in shadow register
} else {
output_state_ &= ~(1 << pin); // reset bit shadow register
}
for (uint16_t b = 0x8000; b; b >>= 1) {
if ((~ddr_mask_) & b) { // transfer bits of outputs, but don't mess with inputs
if (output_state_ & b) {
temp_reg_data |= b;
} else {
temp_reg_data &= ~b;
}
}
}
assign_bits(this->output_state_, 1 << pin, bit_value);
// Outputs take the shadow state, inputs keep what was read
temp_reg_data = (temp_reg_data & this->ddr_mask_) | (this->output_state_ & ~this->ddr_mask_);
this->write_byte_16(REG_DATA_B, temp_reg_data);
}
}
@@ -148,20 +137,12 @@ void SX1509Component::pin_mode(uint8_t pin, gpio::Flags flags) {
// Pullup
this->read_byte_16(REG_PULL_UP_B, &temp_word);
if (flags & gpio::FLAG_PULLUP) {
temp_word |= (1 << pin);
} else {
temp_word &= ~(1 << pin);
}
assign_bits(temp_word, 1 << pin, flags & gpio::FLAG_PULLUP);
this->write_byte_16(REG_PULL_UP_B, temp_word);
// Pulldown
this->read_byte_16(REG_PULL_DOWN_B, &temp_word);
if (flags & gpio::FLAG_PULLDOWN) {
temp_word |= (1 << pin);
} else {
temp_word &= ~(1 << pin);
}
assign_bits(temp_word, 1 << pin, flags & gpio::FLAG_PULLDOWN);
this->write_byte_16(REG_PULL_DOWN_B, temp_word);
// Set direction to input
+2 -5
View File
@@ -1,4 +1,5 @@
#include "tca9555.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
static const uint8_t TCA9555_INPUT_PORT_REGISTER_0 = 0x00;
@@ -108,11 +109,7 @@ void TCA9555Component::digital_write_hw(uint8_t pin, bool value) {
if (this->is_failed())
return;
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
assign_bits(this->output_mask_, 1 << pin, value);
this->status_comm_result(this->write_byte_16_le(TCA9555_OUTPUT_PORT_REGISTER_0, this->output_mask_),
LOG_STR("Failed to write output register"));
@@ -1,4 +1,5 @@
#include "waveshare_io_ch32v003.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::waveshare_io_ch32v003 {
@@ -110,11 +111,7 @@ void WaveshareIOCH32V003Component::digital_write_hw(uint8_t pin, bool value) {
if (this->is_failed())
return;
if (value) {
this->output_mask_ |= (1 << pin);
} else {
this->output_mask_ &= ~(1 << pin);
}
assign_bits(this->output_mask_, 1 << pin, value);
uint8_t data = this->output_mask_;
this->status_comm_result(this->write_byte(IO_EXTENSION_IO_OUTPUT_ADDR, data),
+1 -5
View File
@@ -210,11 +210,7 @@ bool WeikaiComponent::read_pin_val_(uint8_t pin) {
}
void WeikaiComponent::write_pin_val_(uint8_t pin, bool value) {
if (value) {
this->output_state_ |= (1 << pin);
} else {
this->output_state_ &= ~(1 << pin);
}
assign_bits(this->output_state_, 1 << pin, value);
char bin_buf[9];
ESP_LOGVV(TAG, "writing output pin %d with %d out_state %s", pin, uint8_t(value),
format_bin_to(bin_buf, this->output_state_));
+1
View File
@@ -75,6 +75,7 @@
#define DFROBOT_SEN0465_COMPENSATION_RANGES 3
#define USE_DISPLAY
#define DISPLAY_PAGE_CHANGE_CALLBACK_COUNT 1
#define DUCO_DEVICE_COUNT 4
#define USE_EMONTX_VERSION
#define USE_EMONTX_VERSION_TEXT_SENSOR
#define USE_ENTITY_DEVICE_CLASS
@@ -9,6 +9,7 @@ from esphome.components.st7305.display import (
CONF_PORTRAIT,
CONF_ROW_END,
MODELS,
_model_schema,
_resolve,
)
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, CONF_HEIGHT, CONF_WIDTH
@@ -72,3 +73,22 @@ def test_portrait_rejects_inner_not_multiple_of_12(width: int) -> None:
def test_window_ends_must_fit_in_a_byte() -> None:
with pytest.raises(cv.Invalid, match="between 0 and 255"):
_resolve(_config(**{CONF_WIDTH: 1024, CONF_HEIGHT: 300}), MODELS["CUSTOM"])
MODEL_KEYS = (CONF_WIDTH, CONF_HEIGHT, CONF_PORTRAIT, CONF_CS_PIN, CONF_DC_PIN)
def _schema_key(model: str, key: str) -> cv.Optional | cv.Required:
return next(k for k in _model_schema(MODELS[model]).schema if k == key)
@pytest.mark.parametrize("key", MODEL_KEYS)
def test_custom_requires_model_keys(key: str) -> None:
assert isinstance(_schema_key("CUSTOM", key), cv.Required)
@pytest.mark.parametrize("key", MODEL_KEYS)
def test_model_defaults_model_keys(key: str) -> None:
schema_key = _schema_key("WAVESHARE-400X300", key)
assert not isinstance(schema_key, cv.Required)
assert schema_key.default() == MODELS["WAVESHARE-400X300"].get_default(key)
+58
View File
@@ -0,0 +1,58 @@
wifi:
ssid: MySSID
password: password1
time:
- platform: sntp
id: duco_sntp_time
duco:
id: duco_box
discovery: {}
time:
time_id: duco_sntp_time
text_sensor:
- platform: duco
name: Serial Number
select:
- platform: duco
name: Mode
sensor:
- platform: duco
co2:
- address: 3
name: CO2 Bedroom
- address: 4
name: CO2 Livingroom
temperature:
- address: 3
name: Temperature Bedroom
humidity:
- address: 2
name: Humidity Bathroom
filter_remaining:
name: Filter Time Remaining
flow_level:
name: Flow Level
update_interval: 5s
time_remaining:
name: Mode Time Remaining
update_interval: 5s
bypass:
name: Bypass
temperature_oda:
name: Temperature Outdoor Air
temperature_sup:
name: Temperature Supply To Room
temperature_eta:
name: Temperature Indoor Exhaust
temperature_eha:
name: Temperature Outdoor Exhaust
number:
- platform: duco
name: Comfort Temperature
update_interval: 10s
@@ -0,0 +1,3 @@
packages:
uart_57600: !include ../../test_build_components/common/uart_57600/esp32-idf.yaml
duco: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart_57600: !include ../../test_build_components/common/uart_57600/esp8266-ard.yaml
duco: !include common.yaml
@@ -0,0 +1,3 @@
packages:
uart_57600: !include ../../test_build_components/common/uart_57600/rp2040-ard.yaml
duco: !include common.yaml
@@ -0,0 +1,177 @@
#include <gtest/gtest.h>
#include <initializer_list>
#include <vector>
#include "../common.h"
#include "esphome/components/epaper_spi/epaper_spi_ssd1683.h"
namespace esphome::epaper_spi::testing {
/// Drives a driver through whole updates so the skip in the UPDATE state can be checked.
template<typename Driver> class TestableDriver : public Driver {
public:
TestableDriver(uint16_t width, uint16_t height) : Driver("test", width, height, nullptr, 0) {}
void install(spi::SPIDelegate *delegate, uint8_t full_update_every) {
this->delegate_ = delegate;
this->set_dc_pin(&this->dc);
this->set_full_update_every(full_update_every);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
}
void set_frame(std::initializer_list<uint8_t> bytes) {
size_t i = 0;
for (const uint8_t byte : bytes)
this->buffer_[i++] = byte;
this->x_low_ = 0;
this->y_low_ = 0;
this->x_high_ = this->width_;
this->y_high_ = this->height_;
}
/// A whole update, with auto clear and the writer as a YAML lambda runs if asked; false if skipped.
bool run_update(bool auto_clear = false) {
this->set_auto_clear(auto_clear);
this->state_ = EPaperState::UPDATE;
this->process_state_();
const bool pushed = this->state_ == EPaperState::RESET;
while (this->state_ != EPaperState::IDLE)
this->process_state_();
return pushed;
}
/// A whole update with a pixel drawn after the frame was hashed, while it is being sent.
void run_update_drawn_during_push(int x, int y) {
this->state_ = EPaperState::UPDATE;
while (this->state_ != EPaperState::TRANSFER_DATA)
this->process_state_();
this->draw_pixel_at(x, y, Color::BLACK);
while (this->state_ != EPaperState::IDLE)
this->process_state_();
}
/// An update in which nothing is drawn; false if it went straight back to idle.
bool run_update_drawing_nothing() {
this->state_ = EPaperState::UPDATE;
this->process_state_();
const bool pushed = this->state_ == EPaperState::RESET;
while (this->state_ != EPaperState::IDLE)
this->process_state_();
return pushed;
}
/// An update given up on with the busy pin stuck, after its data was sent.
void run_update_abandoned() {
this->state_ = EPaperState::UPDATE;
while (this->state_ != EPaperState::POWER_OFF)
this->process_state_();
this->abandon_update_();
}
uint8_t update_count() const { return this->update_count_; }
bool shown_hash_valid() const { return this->shown_hash_valid_; }
RecordingPin dc;
};
using Bytes = std::vector<uint8_t>;
TEST(EPaperSkipUnchanged, SSD1683SkipsAnUnchangedFrame) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
bus.clear();
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_FALSE(display.run_update());
EXPECT_TRUE(bus.commands.empty());
EXPECT_EQ(display.update_count(), 1);
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
EXPECT_TRUE(display.run_update());
EXPECT_EQ(bus.data[0x24], (Bytes{0x0F, 0xF0, 0x3C, 0x00}));
}
/// A frame drawn into while it was being sent is not trusted, so the frame it replaced is pushed again.
TEST(EPaperSkipUnchanged, FrameChangedDuringThePushIsNotTrusted) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.run_update_drawn_during_push(15, 1);
EXPECT_FALSE(display.shown_hash_valid());
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
EXPECT_TRUE(display.run_update()) << "frame that never reached the panel was skipped";
}
/// A draw during the push is sent by the next update even when that update draws nothing itself.
TEST(EPaperSkipUnchanged, DrawDuringThePushIsSentByAnUpdateThatDrawsNothing) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.run_update_drawn_during_push(15, 1);
bus.clear();
EXPECT_TRUE(display.run_update_drawing_nothing()) << "draw made during the push was dropped";
EXPECT_EQ(bus.data[0x24].size(), 4u) << "whole frame not sent";
}
/// An abandoned update leaves no hash to match, since its frame may never have been shown.
TEST(EPaperSkipUnchanged, AbandonedUpdateLeavesNoHash) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
EXPECT_TRUE(display.shown_hash_valid());
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.run_update_abandoned();
EXPECT_FALSE(display.shown_hash_valid());
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
EXPECT_TRUE(display.run_update());
}
/// With every update a full one, nothing is ever skipped: a full refresh always redraws.
TEST(EPaperSkipUnchanged, NothingSkippedWithoutPartialUpdates) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 1);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
EXPECT_TRUE(display.run_update());
}
/// The real update path: auto clear then a writer lambda, as a YAML lambda runs. The clear makes
/// the drawn area the whole panel every time, so only the frame contents can tell updates apart.
TEST(EPaperSkipUnchanged, UpdatesThroughTheWriterAreSkippedWhenTheyDrawTheSameFrame) {
TestableDriver<EPaperSSD1683> display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
int value = 1;
display.set_writer([&](Display &it) { it.draw_pixel_at(value, 0, Color::BLACK); });
EXPECT_TRUE(display.run_update(true));
EXPECT_FALSE(display.run_update(true)) << "same frame was pushed";
value = 2;
EXPECT_TRUE(display.run_update(true)) << "changed frame was skipped";
EXPECT_FALSE(display.run_update(true));
}
} // namespace esphome::epaper_spi::testing
@@ -19,8 +19,14 @@ class TestableSSD1681 : public EPaperSSD1681 {
this->set_reset_pin(&this->reset_pin);
ASSERT_TRUE(this->init_buffer_(this->buffer_length_));
this->set_full_update_every(full_update_every);
this->init_sent_frame_(this->buffer_length_);
}
bool has_comparison_frame() const { return this->sent_.is_valid(); }
EPaperState state() const { return this->state_; }
void step() { this->process_state_(); }
uint8_t update_count() const { return this->update_count_; }
void set_frame(std::initializer_list<uint8_t> bytes) {
size_t i = 0;
for (const uint8_t byte : bytes)
@@ -34,13 +40,20 @@ class TestableSSD1681 : public EPaperSSD1681 {
this->y_high_ = y_high;
}
/// Run an update from the point where the frame has been drawn through to idle.
void run_update() {
// What the UPDATE state decides before it hands over to RESET
this->partial_ = this->update_count_ != 0;
this->state_ = EPaperState::RESET;
/// Run the UPDATE state with the frame already drawn, and report whether a push follows.
bool run_update_state() {
this->set_auto_clear(false);
this->state_ = EPaperState::UPDATE;
this->process_state_();
return this->state_ == EPaperState::RESET;
}
/// Run an update from the point where the frame has been drawn through to idle; false if skipped.
bool run_update() {
const bool pushed = this->run_update_state();
while (this->state_ != EPaperState::IDLE)
this->process_state_();
return pushed;
}
RecordingPin dc;
@@ -134,4 +147,110 @@ TEST(EPaperSSD1681, EveryUpdateFullSleepsWithoutKeepingRAM) {
EXPECT_EQ(bus.data[0x10], (Bytes{0x03}));
}
/// An update whose frame is the one already on the panel sends nothing and does not count towards
/// the next full refresh. The first update after boot always pushes: nothing is known to be on
/// the panel yet.
TEST(EPaperSSD1681, UnchangedFrameSkipsTheRefresh) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
ASSERT_TRUE(display.has_comparison_frame());
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(0, 0, 16, 2);
EXPECT_TRUE(display.run_update()) << "first update was skipped";
EXPECT_EQ(display.update_count(), 1);
bus.clear();
display.set_dirty(0, 0, 16, 2); // redrawn, but with the same content
EXPECT_FALSE(display.run_update()) << "unchanged frame was pushed";
EXPECT_TRUE(bus.commands.empty());
EXPECT_EQ(display.update_count(), 1) << "skipped update counted towards the next full refresh";
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(0, 0, 16, 2);
EXPECT_TRUE(display.run_update()) << "changed frame was skipped";
EXPECT_EQ(display.update_count(), 2);
}
/// The first update sends the whole frame whatever was drawn, so the skip works without auto clear.
TEST(EPaperSSD1681, SkipWorksWhenOnlyPartOfThePanelIsDrawn) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(8, 1, 16, 2);
EXPECT_TRUE(display.run_update());
bus.clear();
display.set_dirty(8, 1, 16, 2);
EXPECT_FALSE(display.run_update()) << "unchanged frame was pushed";
EXPECT_TRUE(bus.commands.empty());
}
/// A requested full update pushes even if the frame is unchanged.
TEST(EPaperSSD1681, RequestedFullUpdateIsNotSkipped) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(0, 0, 16, 2);
display.run_update();
bus.clear();
display.request_full_update();
EXPECT_TRUE(display.run_update());
EXPECT_EQ(bus.data[0x22], (Bytes{0xF7}));
EXPECT_EQ(display.update_count(), 1);
}
/// A partial refresh sends only the bytes that differ from the frame on the panel, whatever area
/// was drawn: with auto clear on, the drawn area is always the whole panel.
TEST(EPaperSSD1681, PartialRefreshSendsOnlyTheBytesThatChanged) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(0, 0, 16, 2);
display.run_update();
bus.clear();
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(0, 0, 16, 2); // whole panel redrawn, only the last byte differs
display.run_update();
EXPECT_EQ(bus.data[0x24], (Bytes{0x00}));
EXPECT_EQ(bus.data[0x44], (Bytes{1, 1}));
EXPECT_EQ(bus.data[0x45], (Bytes{1, 0, 1, 0}));
}
/// The comparison frame holds what was sent, so a change that was drawn but not pushed yet
/// (because it was drawn during a push) is still sent by the next update.
TEST(EPaperSSD1681, ComparisonFrameIsWhatWasSent) {
TestableSSD1681 display(16, 2);
RecordingDelegate bus(&display.dc);
display.install(&bus, 5);
display.set_frame({0x0F, 0xF0, 0x3C, 0xC3});
display.set_dirty(0, 0, 16, 2);
display.run_update();
display.set_frame({0x0F, 0xF0, 0x3C, 0x00});
display.set_dirty(8, 1, 16, 2);
display.run_update_state();
while (display.state() != EPaperState::POWER_ON)
display.step();
display.draw_pixel_at(4, 0, Color::BLACK); // drawn during the refresh
while (display.state() != EPaperState::IDLE)
display.step();
bus.clear();
display.set_dirty(0, 0, 16, 2);
EXPECT_TRUE(display.run_update()) << "pixel drawn during the refresh was never sent";
EXPECT_EQ(bus.data[0x24], (Bytes{0x07}));
}
} // namespace esphome::epaper_spi::testing
+6
View File
@@ -1,6 +1,12 @@
esphome:
on_boot:
then:
- max17048.sleep_mode: max17048_id
sensor:
- platform: max17048
i2c_id: i2c_bus
id: max17048_id
battery_voltage:
name: Battery Voltage
battery_level:
+225
View File
@@ -0,0 +1,225 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <map>
#include <utility>
#include <vector>
#include "esphome/components/max170xx/max170xx.h"
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
namespace esphome::max170xx::testing {
static constexpr uint8_t ADDRESS = 0x36;
using Write = std::pair<uint8_t, uint16_t>;
// The gauge's 16-bit registers, big-endian on the wire.
class RegisterBus final : public i2c::I2CBus {
public:
i2c::ErrorCode write_readv(uint8_t address, const uint8_t *data, size_t size, uint8_t *out,
size_t out_size) override {
EXPECT_EQ(address, ADDRESS);
if (this->silent || (this->fail_writes && size > 1))
return i2c::ERROR_NOT_ACKNOWLEDGED;
if (size == 3) {
const uint16_t value = (data[1] << 8) | data[2];
this->registers[data[0]] = value;
this->writes.emplace_back(data[0], value);
}
if (out_size == 2) {
const uint16_t value = this->registers[data[0]];
out[0] = value >> 8;
out[1] = value;
}
return i2c::ERROR_OK;
}
bool has_write(const Write &write) const {
return std::find(this->writes.begin(), this->writes.end(), write) != this->writes.end();
}
std::map<uint8_t, uint16_t> registers;
std::vector<Write> writes;
bool silent{false};
bool fail_writes{false};
};
class Gauge43 final : public MAX170xxComponent<Model::MODEL_MAX17043> {
public:
void update() override {}
using MAX170xxComponent::clear_wake_alert_;
using MAX170xxComponent::configure_;
using MAX170xxComponent::configured_;
using MAX170xxComponent::ready_for_update_;
bool take_pending_clear() { return this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID); }
};
class Gauge48 final : public MAX170xxComponent<Model::MODEL_MAX17048> {
public:
void update() override {}
using MAX170xxComponent::clear_wake_alert_;
using MAX170xxComponent::configure_;
using MAX170xxComponent::configured_;
using MAX170xxComponent::ready_for_update_;
bool take_pending_clear() { return this->cancel_timeout(WAKE_ALERT_CLEAR_TIMEOUT_ID); }
};
template<typename G> struct Fixture {
RegisterBus bus;
G gauge;
Fixture() {
static char name[] = "test_max170xx";
static char friendly_name[] = "";
App.pre_setup(name, sizeof(name) - 1, friendly_name, 0);
this->gauge.set_i2c_bus(&this->bus);
this->gauge.set_i2c_address(ADDRESS);
}
~Fixture() {
// Drop a pending clear while the gauge is alive; App outlives each test
this->gauge.take_pending_clear();
App.scheduler.call(millis());
}
};
TEST(MAX170xxTest, SilentBusAtSetupIsRetried) {
Fixture<Gauge48> f;
f.bus.silent = true;
f.gauge.setup();
EXPECT_FALSE(f.gauge.is_failed());
EXPECT_FALSE(f.gauge.configured_);
EXPECT_TRUE(f.gauge.status_has_warning());
EXPECT_FALSE(f.gauge.ready_for_update_());
EXPECT_TRUE(f.gauge.status_has_warning());
f.bus.silent = false;
f.bus.registers[REG_CONFIG] = 0x971C;
EXPECT_TRUE(f.gauge.ready_for_update_());
EXPECT_TRUE(f.bus.has_write({0x0C, 0x971C}));
}
TEST(MAX170xxTest, WrongConfigIsReset) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x1234;
f.gauge.setup();
EXPECT_FALSE(f.gauge.is_failed());
EXPECT_TRUE(f.gauge.configured_);
EXPECT_TRUE(f.bus.has_write({0x0C, 0x971C}));
}
TEST(MAX170xxTest, WriteBackFailureIsRetried) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x971C;
f.bus.fail_writes = true;
f.gauge.setup();
EXPECT_FALSE(f.gauge.is_failed());
EXPECT_FALSE(f.gauge.configured_);
f.bus.fail_writes = false;
EXPECT_TRUE(f.gauge.ready_for_update_());
}
TEST(MAX170xxTest, WakeFromSleepSchedulesClear) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x979C;
f.gauge.setup();
EXPECT_TRUE(f.gauge.configured_);
EXPECT_TRUE(f.bus.has_write({0x0C, 0x971C}));
EXPECT_TRUE(f.gauge.take_pending_clear());
}
TEST(MAX170xxTest, NormalBootSchedulesNoClear) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x971C;
f.bus.registers[REG_STATUS] = 0x1100;
f.gauge.setup();
EXPECT_FALSE(f.gauge.take_pending_clear());
// A real low-battery alert at an ordinary boot is left alone
EXPECT_EQ(f.bus.registers[REG_STATUS], 0x1100);
EXPECT_EQ(f.bus.writes, (std::vector<Write>{{0x0C, 0x971C}}));
}
TEST(MAX170xxTest, ClearWakeAlertKeepsSleepAndOtherStatusBits) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x97BC;
f.bus.registers[REG_STATUS] = 0x5F00;
f.gauge.clear_wake_alert_();
EXPECT_EQ(f.bus.registers[REG_CONFIG], 0x979C);
EXPECT_EQ(f.bus.registers[REG_STATUS], 0x4F00);
}
TEST(MAX170xxTest, ClearWakeAlertOn43LeavesStatusAlone) {
Fixture<Gauge43> f;
f.bus.registers[REG_CONFIG] = 0x97BC;
f.gauge.clear_wake_alert_();
EXPECT_EQ(f.bus.registers[REG_CONFIG], 0x979C);
for (const Write &write : f.bus.writes)
EXPECT_NE(write.first, 0x1A);
}
TEST(MAX170xxTest, SleepModeOn48WritesEnSleepThenSleep) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x971C;
f.gauge.setup();
f.bus.writes.clear();
f.gauge.sleep_mode();
EXPECT_EQ(f.bus.writes, (std::vector<Write>{{0x06, 0x2000}, {0x0C, 0x979C}}));
}
TEST(MAX170xxTest, SleepModeOn43WritesSleepOnly) {
Fixture<Gauge43> f;
f.bus.registers[REG_CONFIG] = 0x971C;
f.gauge.setup();
f.bus.writes.clear();
f.gauge.sleep_mode();
EXPECT_EQ(f.bus.writes, (std::vector<Write>{{0x0C, 0x979C}}));
}
TEST(MAX170xxTest, SleepModeRunsPendingClearFirst) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x979C;
f.gauge.setup();
// The gauge raises the alert as it wakes
f.bus.registers[REG_CONFIG] = 0x973C;
f.bus.registers[REG_STATUS] = 0x1100;
f.gauge.sleep_mode();
EXPECT_FALSE(f.gauge.take_pending_clear());
EXPECT_EQ(f.bus.registers[REG_STATUS], 0x0100);
ASSERT_FALSE(f.bus.writes.empty());
EXPECT_EQ(f.bus.writes.back(), (Write{0x0C, 0x979C}));
}
TEST(MAX170xxTest, SleepModeOnSilentBusWritesNothing) {
Fixture<Gauge48> f;
f.bus.silent = true;
f.gauge.setup();
f.gauge.sleep_mode();
EXPECT_TRUE(f.bus.writes.empty());
EXPECT_TRUE(f.gauge.status_has_warning());
EXPECT_FALSE(f.gauge.is_failed());
}
TEST(MAX170xxTest, SetupAfterSleepModeKeepsGaugeAsleep) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x971C;
// An on_boot with priority above 600 runs before setup()
f.gauge.sleep_mode();
f.bus.writes.clear();
f.gauge.setup();
EXPECT_TRUE(f.bus.writes.empty());
EXPECT_EQ(f.bus.registers[REG_CONFIG], 0x979C);
}
TEST(MAX170xxTest, SleepModeWhenFailedWritesNothing) {
Fixture<Gauge48> f;
f.bus.registers[REG_CONFIG] = 0x971C;
f.gauge.mark_failed();
f.gauge.sleep_mode();
EXPECT_TRUE(f.bus.writes.empty());
}
} // namespace esphome::max170xx::testing
@@ -0,0 +1,202 @@
esphome:
name: uart-mock-duco
# More requests than the old fixed table held, all sent before the hub reads an answer
on_boot:
priority: -100
then:
- component.update: co2
- component.update: temperature
- component.update: humidity
- component.update: filter_remaining
- component.update: flow_level
- component.update: time_remaining
- component.update: bypass
- component.update: temperature_oda
- component.update: temperature_sup
- component.update: temperature_eta
- component.update: temperature_eha
- component.update: serial_number
- component.update: comfort_temperature
host:
api:
batch_delay: 0ms # Disable batching to receive all state updates
logger:
level: VERBOSE
external_components:
- source:
type: local
path: EXTERNAL_COMPONENT_PATH
# Dummy uart entry to satisfy DEPENDENCIES = ["uart"]
uart:
baud_rate: 57600
port: /dev/null
time:
- platform: host
id: host_time
uart_mock:
id: mock_uart
baud_rate: 57600
# A model of the Duco box: an acknowledgement, then the answer, both byte stuffed
on_tx:
- then:
- lambda: |-
static std::vector<uint8_t> rx;
static uint8_t mode = 0x04;
static uint8_t comfort = 210;
rx.insert(rx.end(), data.begin(), data.end());
while (rx.size() >= 3) {
if (rx[0] != 0xAA || rx[1] != 0x55) {
rx.erase(rx.begin());
continue;
}
const size_t total = 2 + rx[2] + 3;
if (rx.size() < total)
return;
std::vector<uint8_t> frame(rx.begin() + 2, rx.begin() + total);
rx.erase(rx.begin(), rx.begin() + total);
const uint8_t function = frame[1];
const uint8_t message_id = frame[2];
const uint8_t *d = frame.data() + 3;
auto reply = [message_id](uint8_t fn, std::vector<uint8_t> payload) {
std::vector<uint8_t> body{static_cast<uint8_t>(payload.size() + 2), fn, message_id};
body.insert(body.end(), payload.begin(), payload.end());
const uint16_t crc = crc16(body.data(), body.size());
body.push_back(crc & 0xFF);
body.push_back(crc >> 8);
std::vector<uint8_t> out{0xAA, 0x55};
for (uint8_t b : body) {
out.push_back(b);
if (b == 0xAA)
out.push_back(0x01);
}
id(mock_uart).inject_to_rx_buffer(out);
};
reply(function + 1, {});
if (function == 0x0c && d[0] == 0x01) {
static const uint8_t TYPES[] = {0, 17, 8, 12};
reply(0x0e, {d[1] < sizeof(TYPES) ? TYPES[d[1]] : uint8_t(0), 0, 0, 0, 0, 0, 0});
} else if (function == 0x0c && d[0] == 0x02) {
std::vector<uint8_t> status(20, 0);
status[0] = mode;
status[2] = 45;
status[12] = 0xAA; // 21930 s, sent as AA 01 55: an escaped header inside the data
status[13] = 0x55;
reply(0x0e, status);
} else if (function == 0x0c && d[0] == 0x04) {
mode = d[2];
ESP_LOGI("sim", "Mode set to %02X", mode);
reply(0x0e, {0x01});
} else if (function == 0x10 && d[3] == 0x49) {
// temperature 21.5, CO2 682 (0x02AA exercises the byte stuffing), humidity 54.32
reply(0x12, {0, 0, 215, 0, 0xAA, 0x02, 0x38, 0x15});
} else if (function == 0x10 && d[3] == 0x1a) {
std::vector<uint8_t> serial{0, 0, 'S', 'N', '1', '2', '3', '4'};
serial.resize(18, 0);
reply(0x12, serial);
} else if (function == 0x24 && d[0] == 0x00) {
int16_t value = 0;
if (d[2] == 0x0a && d[1] == 0x12)
value = comfort;
else if (d[1] == 0x00)
value = -35;
else if (d[1] == 0x01)
value = 195;
else if (d[1] == 0x02)
value = 220;
else if (d[1] == 0x03)
value = 80;
else if (d[1] == 0x10)
value = 50;
else if (d[1] == 0x30)
value = 120;
reply(0x26, {0x01, d[1], d[2], uint8_t(value & 0xFF), uint8_t((value >> 8) & 0xFF), 0, 0});
} else if (function == 0x24 && d[0] == 0x01) {
comfort = d[3];
ESP_LOGI("sim", "Comfort set to %u", comfort);
reply(0x26, {0x01, d[1], d[2], d[3], 0, 0, 0});
} else if (function == 0x24 && d[0] == 0x05) {
ESP_LOGI("sim", "Time set to %u", encode_uint32(d[4], d[3], d[2], d[1]));
reply(0x26, {0x05});
}
}
duco:
uart_id: mock_uart
discovery: {}
time:
time_id: host_time
update_interval: 2s
select:
- platform: duco
name: Mode
update_interval: 1s
number:
- platform: duco
id: comfort_temperature
name: Comfort Temperature
update_interval: never
text_sensor:
- platform: duco
id: serial_number
name: Serial Number
update_interval: never
sensor:
- platform: duco
co2:
- address: 3
id: co2
name: CO2
update_interval: never
temperature:
- address: 3
id: temperature
name: Temperature
update_interval: never
humidity:
- address: 3
id: humidity
name: Humidity
update_interval: never
filter_remaining:
id: filter_remaining
name: Filter Remaining
update_interval: never
flow_level:
id: flow_level
name: Flow Level
update_interval: never
time_remaining:
id: time_remaining
name: Time Remaining
update_interval: never
bypass:
id: bypass
name: Bypass
update_interval: never
temperature_oda:
id: temperature_oda
name: Temperature ODA
update_interval: never
temperature_sup:
id: temperature_sup
name: Temperature SUP
update_interval: never
temperature_eta:
id: temperature_eta
name: Temperature ETA
update_interval: never
temperature_eha:
id: temperature_eha
name: Temperature EHA
update_interval: never
+98
View File
@@ -0,0 +1,98 @@
"""Integration test for the Duco component against a model of the ventilation box.
Every entity reads its value from the box, a select or number change reaches the box, the clock
is sent and discovery reports the nodes. The CO2 answer holds an 0xAA byte and the status answer an
escaped AA 55, so the byte stuffing is exercised too. Most entities do not poll: their values come
from one burst of requests at boot, more than the hub could once keep open.
"""
from __future__ import annotations
import asyncio
from aioesphomeapi import (
EntityState,
NumberInfo,
SelectInfo,
SensorInfo,
TextSensorInfo,
)
import pytest
from .log_utils import LineWaiter
from .state_utils import require_entity
from .types import APIClientConnectedFactory, RunCompiledFunction
EXPECTED_SENSORS = {
"co2": 682.0,
"temperature": 21.5,
"humidity": 54.32,
"filter_remaining": 120.0,
"flow_level": 45.0,
"time_remaining": 21930.0,
"bypass": 50.0,
"temperature_oda": -3.5,
"temperature_sup": 19.5,
"temperature_eta": 22.0,
"temperature_eha": 8.0,
}
@pytest.mark.asyncio
async def test_uart_mock_duco(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""All entities read the box; mode and comfort temperature changes are written."""
waiter = LineWaiter()
states: dict[int, EntityState] = {}
async def wait_for(condition, what: str) -> None:
try:
async with asyncio.timeout(10.0):
while not condition():
await asyncio.sleep(0.05)
except TimeoutError:
pytest.fail(f"Timeout waiting for {what}; states: {states}")
async with (
run_compiled(yaml_config, line_callback=waiter.callback),
api_client_connected() as client,
):
entities, _ = await client.list_entities_services()
sensors = {
key: require_entity(entities, key, SensorInfo, exact=True)
for key in EXPECTED_SENSORS
}
mode = require_entity(entities, "mode", SelectInfo)
comfort = require_entity(entities, "comfort_temperature", NumberInfo)
serial = require_entity(entities, "serial_number", TextSensorInfo)
client.subscribe_states(lambda state: states.__setitem__(state.key, state))
def state_of(entity):
state = states.get(entity.key)
return None if state is None or state.missing_state else state.state
for key, expected in EXPECTED_SENSORS.items():
await wait_for(
lambda e=sensors[key], v=expected: state_of(e) == pytest.approx(v),
key,
)
await wait_for(lambda: state_of(mode) == "MAN1", "mode")
await wait_for(lambda: state_of(comfort) == pytest.approx(21.0), "comfort")
await wait_for(lambda: state_of(serial) == "SN1234", "serial number")
await waiter.wait_for("Time set to")
client.select_command(mode.key, "CNT2")
await waiter.wait_for("Mode set to 09")
await wait_for(lambda: state_of(mode) == "CNT2", "new mode")
client.number_command(comfort.key, 22.3)
await waiter.wait_for("Comfort set to 223")
await wait_for(lambda: state_of(comfort) == pytest.approx(22.3), "new comfort")
await waiter.wait_for_each(
"Found node 1: type 17", "Found node 3: type 12", timeout=40.0
)
@@ -0,0 +1,12 @@
# Common UART configuration for ESP32 IDF tests - 57600 baud
# For components that require UART baud rate 57600 (duco)
substitutions:
tx_pin: GPIO17
rx_pin: GPIO16
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 57600
@@ -0,0 +1,11 @@
# Common UART configuration for ESP8266 Arduino tests - 57600 baud
substitutions:
tx_pin: GPIO1
rx_pin: GPIO3
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 57600
@@ -0,0 +1,11 @@
# Common UART configuration for RP2040 Arduino tests - 57600 baud
substitutions:
tx_pin: GPIO0
rx_pin: GPIO1
uart:
- id: uart_bus
tx_pin: ${tx_pin}
rx_pin: ${rx_pin}
baud_rate: 57600