[remote_base] Add Nexus RF protocol support (#13577)

Co-authored-by: zry98 <dev@zry.io>
Co-authored-by: Citizen <kvirbickas@gmail.com>
Co-authored-by: Citizen07 <34106434+Citizen07@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
Signed-off-by: zry98 <dev@zry.io>
This commit is contained in:
Phil
2026-10-11 19:36:07 -10:00
committed by GitHub
co-authored by zry98 Citizen Citizen07 J. Nick Koston J. Nick Koston
parent 29db7b3f51
commit 2692e0f189
8 changed files with 278 additions and 1 deletions
@@ -9,6 +9,7 @@ from esphome.config_helpers import filter_source_files_from_defines
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_BATTERY_LEVEL,
CONF_BUTTON,
CONF_CARRIER_FREQUENCY,
CONF_CHANNEL,
@@ -20,7 +21,9 @@ from esphome.const import (
CONF_DELTA,
CONF_DEVICE,
CONF_FAMILY,
CONF_FORCE_UPDATE,
CONF_GROUP,
CONF_HUMIDITY,
CONF_ID,
CONF_INDEX,
CONF_INVERTED,
@@ -37,6 +40,7 @@ from esphome.const import (
CONF_SOURCE,
CONF_STATE,
CONF_SYNC,
CONF_TEMPERATURE,
CONF_TIMES,
CONF_TRIGGER_ID,
CONF_TYPE_ID,
@@ -2161,6 +2165,62 @@ register_protocol_action(
)
# Nexus
NexusData, NexusBinarySensor, NexusTrigger, _, NexusDumper = declare_protocol("Nexus")
NEXUS_SCHEMA = cv.Schema(
{
cv.Required(CONF_CHANNEL): cv.int_range(min=1, max=4),
cv.Required(CONF_ADDRESS): cv.hex_uint8_t,
}
)
NEXUS_ACTION_SCHEMA = NEXUS_SCHEMA.extend(
{
cv.Required(CONF_TEMPERATURE): cv.All(
cv.float_, cv.Range(min=-204.8, max=204.7)
),
cv.Required(CONF_HUMIDITY): cv.uint8_t,
cv.Optional(CONF_BATTERY_LEVEL, default=True): cv.boolean,
cv.Optional(CONF_FORCE_UPDATE, default=False): cv.boolean,
}
)
@register_binary_sensor("nexus", NexusBinarySensor, NEXUS_SCHEMA)
def nexus_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
NexusData,
("address", config[CONF_ADDRESS]),
("channel", config[CONF_CHANNEL]),
)
)
)
@register_trigger("nexus", NexusTrigger, NexusData)
def nexus_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("nexus", NexusDumper)
def nexus_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"nexus",
"Nexus",
NEXUS_ACTION_SCHEMA,
(CONF_CHANNEL, "channel", cg.uint8),
(CONF_ADDRESS, "address", cg.uint8),
(CONF_TEMPERATURE, "temperature", cg.float_),
(CONF_HUMIDITY, "humidity", cg.uint8),
(CONF_BATTERY_LEVEL, "battery_level", cg.bool_),
(CONF_FORCE_UPDATE, "force_update", cg.bool_),
)
# Midea
MideaData, MideaBinarySensor, MideaTrigger, MideaAction, MideaDumper = declare_protocol(
"Midea"
@@ -0,0 +1,83 @@
#include "nexus_protocol.h"
#include "esphome/core/log.h"
#include <algorithm>
#include <cinttypes>
#include <cmath>
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.nexus");
/**
* Example Nexus (Digoo) temperature & humidity sensor packet (36 data bits, each a mark and a space):
* 10000101 | 1 | 0 | 01 | 000010001010 | 1111 | 01000101
* address (sensor ID) | battery | force update | channel | temperature | - | humidity
*
* The channel is sent 0-based and shown 1-based, temperature is 12 bit signed scaled by 10,
* `-`s are constant bits.
*
* Ref: https://manual.pilight.org/protocols/433.92/weather/nexus.html
*/
static constexpr uint8_t NBITS = 36;
static constexpr uint32_t SYNC_HIGH_US = 500;
static constexpr uint32_t SYNC_LOW_US = 3900;
static constexpr uint32_t BIT_HIGH_US = 500;
static constexpr uint32_t BIT_ZERO_LOW_US = 980;
static constexpr uint32_t BIT_ONE_LOW_US = 1950;
void NexusProtocol::encode(RemoteTransmitData *dst, const NexusData &data) {
// A templated value may be out of range or NaN (an unavailable sensor); send 0 for NaN
const float temperature = std::isnan(data.temperature) ? 0.0f : std::clamp(data.temperature, -204.8f, 204.7f);
const auto t = static_cast<int16_t>(lroundf(temperature * 10.0f));
const uint64_t packet = uint64_t(data.address) << 28 | uint64_t(data.battery_level) << 27 |
uint64_t(data.force_update) << 26 | uint64_t((data.channel - 1) & 0b11) << 24 |
uint64_t(t & 0x0FFF) << 12 | 0b1111 << 8 | data.humidity;
dst->reserve(NBITS * 2 + 2);
for (int8_t i = NBITS - 1; i >= 0; i--)
dst->item(BIT_HIGH_US, (packet >> i) & 1 ? BIT_ONE_LOW_US : BIT_ZERO_LOW_US);
// The sync follows the bits, as the sensors send it: its mark ends the last space, its gap separates repeats
dst->item(SYNC_HIGH_US, SYNC_LOW_US);
}
optional<NexusData> NexusProtocol::decode(RemoteReceiveData src) {
// A buffer may start with the previous frame's sync when the receiver did not split on it
src.expect_item(SYNC_HIGH_US, SYNC_LOW_US);
if (src.size() < NBITS * 2)
return {};
uint64_t packet = 0;
for (uint8_t i = 0; i < NBITS; i++) {
packet <<= 1;
if (src.expect_item(BIT_HIGH_US, BIT_ONE_LOW_US)) {
packet |= 1;
} else if (!src.expect_item(BIT_HIGH_US, BIT_ZERO_LOW_US)) {
return {};
}
}
if (((packet >> 8) & 0xF) != 0xF)
return {};
ESP_LOGV(TAG, "Nexus packet: 0x%09" PRIX64, packet);
return NexusData{
// The 12 bit field sits at bits 12-23; shift it to the top of an int16_t and back to sign extend.
.temperature = (static_cast<int16_t>((packet >> 8) & 0xFFF0) >> 4) / 10.0f,
.address = static_cast<uint8_t>(packet >> 28),
.battery_level = static_cast<bool>((packet >> 27) & 1),
.force_update = static_cast<bool>((packet >> 26) & 1),
.channel = static_cast<uint8_t>(((packet >> 24) & 0b11) + 1),
.humidity = static_cast<uint8_t>(packet),
};
}
void NexusProtocol::dump(const NexusData &data) {
ESP_LOGI(TAG,
"Received Nexus: address=0x%02X, channel=%u, temperature=%.1f, humidity=%u, battery_level=%d, "
"force_update=%d",
data.address, data.channel, data.temperature, data.humidity, data.battery_level, data.force_update);
}
} // namespace esphome::remote_base
@@ -0,0 +1,30 @@
#pragma once
#include "remote_base.h"
namespace esphome::remote_base {
struct NexusData {
float temperature; // °C, sent as 12 bit signed tenths
uint8_t address;
bool battery_level;
bool force_update;
uint8_t channel; // sent 0-based, shown 1-based
uint8_t humidity;
bool operator==(const NexusData &rhs) const {
// The binary sensor matches the transmitter, not the reading.
return channel == rhs.channel && address == rhs.address;
}
};
class NexusProtocol : public RemoteProtocol<NexusData> {
public:
void encode(RemoteTransmitData *dst, const NexusData &data);
optional<NexusData> decode(RemoteReceiveData src);
void dump(const NexusData &data);
};
DECLARE_REMOTE_PROTOCOL(Nexus)
} // namespace esphome::remote_base
+1 -1
View File
@@ -7,7 +7,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
async def to_code_testing(config: ConfigType) -> None:
# Protocol sources are compiled only behind their define; keep the ones under test.
for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq"):
for protocol in ("hob2hood", "haier", "mirage", "aeha", "keeloq", "nexus"):
request_protocol(protocol)
manifest.to_code = to_code_testing
@@ -0,0 +1,84 @@
#include <gtest/gtest.h>
#include <cmath>
#include "esphome/components/remote_base/nexus_protocol.h"
namespace esphome::remote_base::testing {
namespace {
RawTimings encode(const NexusData &data) {
RemoteTransmitData tx;
NexusProtocol().encode(&tx, data);
return tx.get_data();
}
optional<NexusData> decode(const RawTimings &timings) {
return NexusProtocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE));
}
void expect_round_trip(float temperature, bool drop_sync) {
const NexusData sent{.temperature = temperature,
.address = 0x85,
.battery_level = true,
.force_update = false,
.channel = 2,
.humidity = 69};
RawTimings timings = encode(sent);
ASSERT_EQ(timings.size(), 2u + 36u * 2u);
if (drop_sync) {
timings.resize(timings.size() - 2);
} else {
// The receiver kept the previous frame's sync in front of this one
timings.insert(timings.begin(), timings.end() - 2, timings.end());
}
auto decoded = decode(timings);
ASSERT_TRUE(decoded.has_value()) << temperature;
if (!decoded.has_value()) {
return;
}
EXPECT_FLOAT_EQ(decoded->temperature, temperature);
EXPECT_EQ(decoded->address, 0x85);
EXPECT_TRUE(decoded->battery_level);
EXPECT_FALSE(decoded->force_update);
EXPECT_EQ(decoded->channel, 2);
EXPECT_EQ(decoded->humidity, 69);
}
} // namespace
TEST(NexusProtocolTest, RoundTripsWithLeadingSync) {
expect_round_trip(13.8f, false);
expect_round_trip(-12.3f, false);
expect_round_trip(-204.8f, false);
expect_round_trip(204.7f, false);
}
TEST(NexusProtocolTest, RoundTripsWithoutTrailingSync) {
expect_round_trip(0.0f, true);
expect_round_trip(-0.1f, true);
}
TEST(NexusProtocolTest, ClampsOutOfRangeTemperature) {
auto decoded = decode(encode(NexusData{.temperature = 300.0f, .channel = 1}));
ASSERT_TRUE(decoded.has_value());
if (!decoded.has_value()) {
return;
}
EXPECT_FLOAT_EQ(decoded->temperature, 204.7f);
decoded = decode(encode(NexusData{.temperature = NAN, .channel = 1}));
ASSERT_TRUE(decoded.has_value());
if (!decoded.has_value()) {
return;
}
EXPECT_FLOAT_EQ(decoded->temperature, 0.0f);
}
TEST(NexusProtocolTest, RejectsWrongConstantBits) {
RawTimings timings = encode(NexusData{.temperature = 20.0f, .channel = 1});
// Constant bits are data bits 24-27; each bit is a mark and a space, the sync comes last.
timings[24 * 2 + 1] = -980;
EXPECT_FALSE(decode(timings).has_value());
}
} // namespace esphome::remote_base::testing
@@ -103,6 +103,11 @@ on_nexa:
- logger.log:
format: "on_nexa: %lu %u %u %u %u"
args: ["long(x.device)", "x.group", "x.state", "x.channel", "x.level"]
on_nexus:
then:
- logger.log:
format: "on_nexus: %u %u %.1f %u %d %d"
args: ["x.channel", "x.address", "x.temperature", "x.humidity", "x.battery_level", "x.force_update"]
on_onkyori:
then:
- logger.log:
@@ -19,3 +19,8 @@ binary_sensor:
name: Hob2Hood Light On
hob2hood:
command: light_on
- platform: remote_receiver
name: Nexus Sensor
nexus:
channel: 1
address: 0x42
@@ -52,6 +52,16 @@ button:
remote_transmitter.transmit_nec:
address: 0x4242
command: 0x8484
- platform: template
name: Nexus
on_press:
remote_transmitter.transmit_nexus:
channel: 1
address: 0x42
temperature: -12.3
humidity: 69
battery_level: false
force_update: true
- platform: template
name: LG
on_press: