Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,20 @@ jobs:
strategy:
fail-fast: false
matrix:
name:
- wmbus
- panasonic_aquarea
config:
- wmbus/sx1276
- wmbus/sx1262
- panasonic_aquarea/test
variant:
- esp32
- esp32s3
- esp32c3
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Build firmware
id: esphome_build
uses: esphome/build-action@v8.1.0
with:
yaml-file: tests/${{ matrix.name }}.yaml
yaml-file: tests/${{ matrix.config }}.yaml
version: "2026.9.1"
substitutions: variant=${{ matrix.variant }}
37 changes: 30 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,30 +168,53 @@ When you use `sensor` and `text_sensor` platforms, required fields will be autom
For both `sensor` and `text_sensor`, all config from generic [Sensor](https://esphome.io/components/sensor/index.html) and [Text Sensor](https://esphome.io/components/text_sensor/index.html) components is available, so you can use filters, icons, etc.

## `wmbus_radio`
This component provides a radio interface LoRa 868MHz transceivers allowing to receive wM-Bus packets. At the moment it support only Semtech SX1276 (RFM95/RFM96).
As a dependency, it requires `spi` component to be configured in ESPHome.
This component provides a radio interface for 868 MHz LoRa transceivers, allowing it to receive wM-Bus packets. It supports Semtech SX1276 (RFM95/RFM96) and SX1262 modules.
It needs ESPHome 2026.8.0 or newer and an `spi` component configured.

**Example configuration:**
**SX1276 example:**

```yaml
spi:
clk_pin: GPIO1
clk_pin: GPIO0
mosi_pin: GPIO1
miso_pin: GPIO2
mosi_pin: GPIO3
id: spi_bus

wmbus_radio:
id: radio_component
radio_type: SX1276
cs_pin: GPIO3
reset_pin: GPIO4
irq_pin: GPIO5
dio1_pin: GPIO5
on_frame:
- wmbus_radio.send_frame_with_socket:
id: transmitter
format: rtlwmbus
```

For SX1276, `reset_pin` should be connected to the reset pin and `irq_pin` should be connected to the DIO1 pin of the radio module.
`reset_pin` and `dio1_pin` go to the RESET and DIO1 pins of the radio module. `irq_pin` is the old name of `dio1_pin`; it still works with a deprecation warning and will be removed in 2027.3.0.

**SX1262 example:**

```yaml
spi:
clk_pin: GPIO0
mosi_pin: GPIO1
miso_pin: GPIO2

wmbus_radio:
id: radio_component
radio_type: SX1262
cs_pin: GPIO3
reset_pin: GPIO4
busy_pin: GPIO5
dio1_pin: GPIO6
rf_switch: true
tcxo_voltage: 1.8V
use_dcdc: true
```

SX1262 also needs `busy_pin` wired to its BUSY pin. `tcxo_voltage` is optional and accepts the supported SX1262 voltages: `1.6V`, `1.7V`, `1.8V`, `2.2V`, `2.4V`, `2.7V`, `3.0V`, or `3.3V`. It defaults to `0V`, for modules where DIO3 does not control a TCXO. `rf_switch` defaults to `false`; enable it only when DIO2 drives the RF switch. `use_dcdc` defaults to `true`. `rx_boost` (default `false`) switches the receiver to boosted gain, trading a little more current for better sensitivity.

The `on_frame` trigger can be used to send received wM-Bus packets to a remote server using `socket_transmitter` component. It can also be used to process packets in any other way, such as sending them to MQTT broker or HTTP server.

Expand Down
12 changes: 6 additions & 6 deletions components/socket_transmitter/socket_transmitter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

namespace esphome {
namespace socket_transmitter {
void SocketTransmitter::send(std::string data) { return this->send((uint8_t *) data.c_str(), data.length()); }

void SocketTransmitter::send(std::vector<uint8_t> data) { return this->send(data.data(), data.size()); }
void SocketTransmitter::send(std::string_view data) {
this->send(std::span<const uint8_t>(reinterpret_cast<const uint8_t *>(data.data()), data.size()));
}

void SocketTransmitter::send(const uint8_t *data, size_t length) {
void SocketTransmitter::send(std::span<const uint8_t> data) {
ESP_LOGD(TAG, "Setting up socket transmitter");
this->socket_ = socket::socket_ip(this->protocol, 0);
int enable = 1;
Expand All @@ -20,8 +20,8 @@ void SocketTransmitter::send(const uint8_t *data, size_t length) {
return;
}

ESP_LOGD(TAG, "Sending frame [%d bytes]", length);
int n_bytes = this->socket_->write(data, length);
ESP_LOGD(TAG, "Sending frame [%zu bytes]", data.size());
int n_bytes = this->socket_->write(data.data(), data.size());
if (n_bytes < 0)
ESP_LOGE(TAG, "Failed to send message");
this->socket_->close();
Expand Down
22 changes: 10 additions & 12 deletions components/socket_transmitter/socket_transmitter.h
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#pragma once
#include <span>
#include <string>
#include <vector>

Expand All @@ -13,12 +14,11 @@ static const char *TAG = "socket_transmitter";

class SocketTransmitter : public Component {
public:
void set_host(std::string host) { this->host = host; };
void set_port(int port) { this->port = port; };
void set_protocol(int protocol) { this->protocol = protocol; };
void send(std::string data);
void send(std::vector<uint8_t> data);
void send(const uint8_t *data, size_t length);
void set_host(std::string host) { this->host = std::move(host); }
void set_port(int port) { this->port = port; }
void set_protocol(int protocol) { this->protocol = protocol; }
void send(std::span<const uint8_t> data);
void send(std::string_view data);
void dump_config() override;
float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; }

Expand All @@ -29,16 +29,14 @@ class SocketTransmitter : public Component {
std::unique_ptr<socket::Socket> socket_;
};

template<typename StrOrVector, typename... Ts> class SocketTransmitterSendAction : public Action<Ts...> {
template<typename T, typename... Ts>
class SocketTransmitterSendAction : public Action<Ts...>, public Parented<SocketTransmitter> {
public:
SocketTransmitterSendAction(SocketTransmitter *parent) : parent_(parent) {}
SocketTransmitterSendAction(SocketTransmitter *parent = nullptr) : Parented<SocketTransmitter>(parent) {}

TEMPLATABLE_VALUE(StrOrVector, data)
TEMPLATABLE_VALUE(T, data)

void play(const Ts &...x) override { this->parent_->send(this->data_.value(x...)); }

protected:
SocketTransmitter *parent_;
};
} // namespace socket_transmitter
} // namespace esphome
2 changes: 1 addition & 1 deletion components/wmbus_meter/wmbus_meter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ bool Meter::is_encrypted() const { return this->meter && this->meter->meterKeys(

void Meter::set_radio(wmbus_radio::Radio *radio) {
this->radio = radio;
radio->add_frame_handler([this](wmbus_radio::Frame *frame) { return this->handle_frame(frame); });
radio->on_frame([this](wmbus_radio::Frame *frame) { return this->handle_frame(frame); });
}

void Meter::dump_config() {
Expand Down
175 changes: 33 additions & 142 deletions components/wmbus_radio/__init__.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,159 +1,50 @@
from contextlib import suppress
from pathlib import Path

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import pins, automation
from esphome.components import spi
from esphome.cpp_generator import LambdaExpression
from esphome.const import (
CONF_ID,
CONF_RESET_PIN,
CONF_IRQ_PIN,
CONF_TRIGGER_ID,
CONF_FORMAT,
CONF_DATA,
)
from pathlib import Path
from esphome.core import CORE

CODEOWNERS = ["@kubasaw"]
from . import transceiver_sx1262, transceiver_sx1276
from .radio import SCHEMA as RADIO_SCHEMA
from .radio import RadioComponent, register_radio
from .transceiver import CONF_RADIO_TYPE

DEPENDENCIES = ["esp32", "spi"]
__all__ = ["RadioComponent"]

CODEOWNERS = ["@kubasaw"]
DEPENDENCIES = ["esp32", "spi"]
AUTO_LOAD = ["wmbus_common"]

MULTI_CONF = True

CONF_RADIO_ID = "radio_id"
CONF_ON_PACKET = "on_packet"
CONF_ON_FRAME = "on_frame"
CONF_RADIO_TYPE = "radio_type"
CONF_MARK_AS_HANDLED = "mark_as_handled"
CONF_PACKET_TRIGGER_ID = "packet_trigger_id"

radio_ns = cg.esphome_ns.namespace("wmbus_radio")
RadioComponent = radio_ns.class_("Radio", cg.Component)
RadioTransceiver = radio_ns.class_("RadioTransceiver", spi.SPIDevice, cg.Component)
Frame = radio_ns.class_("Frame")
FrameOutputFormat = Frame.enum("OutputFormat")
FramePtr = Frame.operator("ptr")
FrameTrigger = radio_ns.class_("FrameTrigger", automation.Trigger.template(FramePtr))

Packet = radio_ns.class_("Packet")
PacketPtr = Packet.operator("ptr")
PacketTrigger = radio_ns.class_("PacketTrigger", automation.Trigger.template(PacketPtr))

TRANSCEIVER_NAMES = {
r.stem.removeprefix("transceiver_").upper()
for r in Path(__file__).parent.glob("transceiver_*.cpp")
if r.is_file()
TRANSCEIVERS = {
module.__name__.split(".")[-1].removeprefix("transceiver_").upper(): module
for module in (transceiver_sx1262, transceiver_sx1276)
}

CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(RadioComponent),
cv.GenerateID(CONF_RADIO_ID): cv.declare_id(RadioTransceiver),
cv.Required(CONF_RADIO_TYPE): cv.one_of(*TRANSCEIVER_NAMES, upper=True),
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_IRQ_PIN): pins.internal_gpio_input_pin_schema,
cv.Optional(CONF_ON_FRAME): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FrameTrigger),
cv.Optional(CONF_MARK_AS_HANDLED, default=False): cv.boolean,
}
),
cv.Optional(CONF_ON_PACKET): automation.validate_automation(
{
cv.GenerateID(CONF_PACKET_TRIGGER_ID): cv.declare_id(PacketTrigger),
}
),
}
)
.extend(spi.spi_device_schema())
.extend(cv.COMPONENT_SCHEMA)
)


async def to_code(config):
cg.add(cg.LineComment("WMBus RadioTransceiver"))

config[CONF_RADIO_ID].type = radio_ns.class_(
config[CONF_RADIO_TYPE], RadioTransceiver
)
radio_var = cg.new_Pvariable(config[CONF_RADIO_ID])

reset_pin = await cg.gpio_pin_expression(config[CONF_RESET_PIN])
cg.add(radio_var.set_reset_pin(reset_pin))

irq_pin = await cg.gpio_pin_expression(config[CONF_IRQ_PIN])
cg.add(radio_var.set_irq_pin(irq_pin))

await spi.register_spi_device(radio_var, config)
await cg.register_component(radio_var, config)

cg.add(cg.LineComment("WMBus Component"))
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_radio(radio_var))

await cg.register_component(var, config)

for conf in config.get(CONF_ON_FRAME, []):
trig = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var, conf[CONF_MARK_AS_HANDLED])
await automation.build_automation(
trig,
[(FramePtr, "frame")],
conf,
)

for conf in config.get(CONF_ON_PACKET, []):
trig = cg.new_Pvariable(conf[CONF_PACKET_TRIGGER_ID], var)
await automation.build_automation(
trig,
[(PacketPtr, "packet")],
conf,
)


with suppress(ImportError):
from esphome.components.socket_transmitter import (
SOCKET_SEND_ACTION_SCHEMA,
SocketTransmitterSendAction,
)
CONFIG_SCHEMA = cv.typed_schema(
{
name: module.config_schema(name, RADIO_SCHEMA)
for name, module in TRANSCEIVERS.items()
},
key=CONF_RADIO_TYPE,
upper=True,
)

FRAME_SOCKET_SEND_SCHEMA = SOCKET_SEND_ACTION_SCHEMA.extend(
{
cv.Required(CONF_FORMAT): cv.one_of(
"hex",
"raw",
"rtlwmbus",
lower=True,
),
cv.Optional(CONF_DATA): cv.invalid(
"If you want to specify data to be sent, use generic 'socket_transmitter.send' action"
),
}
)

@automation.register_action(
"wmbus_radio.send_frame_with_socket",
SocketTransmitterSendAction,
FRAME_SOCKET_SEND_SCHEMA,
synchronous=True,
)
async def send_frame_with_socket_to_code(config, action_id, template_arg, args):
output_type = {
"hex": cg.std_string,
"raw": cg.std_vector.template(cg.uint8),
"rtlwmbus": cg.std_string,
}[config[CONF_FORMAT]]
def FILTER_SOURCE_FILES():
used = {config[CONF_RADIO_TYPE] for config in CORE.config["wmbus_radio"]}
return [
Path(module.__file__).with_suffix(suffix).name
for name, module in TRANSCEIVERS.items()
if name not in used
for suffix in (".cpp", ".h")
]

paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(
action_id, cg.TemplateArguments(output_type, *template_arg), paren
)
template_ = LambdaExpression(
f"return frame->as_{config[CONF_FORMAT]}();", args, ""
)

cg.add(var.set_data(template_))
async def to_code(config):
trx_var = await TRANSCEIVERS[config[CONF_RADIO_TYPE]].to_code(config)
radio_var = await register_radio(config)

return var
cg.add(radio_var.set_transceiver(trx_var))
4 changes: 2 additions & 2 deletions components/wmbus_radio/automation.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace wmbus_radio {
class FrameTrigger : public Trigger<Frame *> {
public:
explicit FrameTrigger(wmbus_radio::Radio *radio, bool mark_handled) {
radio->add_frame_handler([this, mark_handled](Frame *frame) {
radio->on_frame([this, mark_handled](Frame *frame) {
this->trigger(frame);
if (mark_handled)
frame->mark_as_handled();
Expand All @@ -25,4 +25,4 @@ class PacketTrigger : public Trigger<Packet *> {
};

} // namespace wmbus_radio
} // namespace esphome
} // namespace esphome
Loading
Loading