Skip to content

Commit 2dda36c

Browse files
author
Hovhannes Tumanyan
committed
Add ESPHome 2026.1 support and agent instructions
1 parent 928d360 commit 2dda36c

17 files changed

Lines changed: 1306 additions & 260 deletions

.github/copilot-instructions.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copilot Instructions (Navien ESPHome / RS-485)
2+
3+
Follow the repository playbook in `AGENTS.md` (source of truth).
4+
5+
Key rules:
6+
- Spaces only (2 spaces). No tabs.
7+
- Avoid STL and dynamic allocation: no `std::*`, no `malloc/free`, no `new/delete`. Prefer static/stack C-style buffers/structs.
8+
- Protocol changes require updates in `doc/` + at least one example frame and expected decoded output.
9+
- Do not provide speculative/unsafe hardware wiring advice; avoid unknown/high-voltage pins.
10+
11+
How to validate changes:
12+
- Build/compile with ESPHome per README.
13+
- For protocol/entity changes: provide DEBUG logs and a trace sample if available.

.vscode/launch.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": []
7+
}

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"chat.tools.terminal.autoApprove": {
3+
"esphome": true
4+
}
5+
}

AGENTS.md

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# AGENTS.md — Navien ESPHome Integration
2+
3+
## What this repository is
4+
This repository provides:
5+
- An ESPHome external component for local control and telemetry of Navien water heaters via RS-485
6+
- Reverse-engineered protocol documentation, traces, and message decoding notes
7+
- Optional hardware reference materials for DIY controller builds
8+
9+
Primary goal: reliable, local Home Assistant integration without proprietary cloud dependencies.
10+
11+
The root README is the source of truth for end-user setup and supported configurations.
12+
13+
## Scope & boundaries
14+
- Prefer **backwards-compatible** changes; avoid breaking existing YAML keys, entity names, or IDs.
15+
- Protocol changes must be documented under `doc/` with examples.
16+
- Do **not** add cloud-based features or proprietary APIs.
17+
- **Safety first**: never recommend wiring to unknown or high-voltage pins; explicitly warn when a pin is known to be ~14V or otherwise unsafe.
18+
19+
## Repository layout
20+
- `esphome/` — ESPHome external component and example YAMLs
21+
- `doc/` — protocol reverse-engineering notes and field documentation
22+
- `trace/rs485/` — RS-485 captures used for validation
23+
- `hardware/` — PCB, connector, and harness reference designs
24+
- `src/` — implementation details (protocol parsing, entities)
25+
26+
## Supported workflows
27+
28+
### Build / compile
29+
Follow the workflow in the root README:
30+
- Install ESPHome (`pip install esphome`)
31+
- Copy secrets (`cp secrets.yaml.sample secrets.yaml`)
32+
- Compile: `esphome compile <config>.yml`
33+
- Flash/run: `esphome run <config>.yml`
34+
35+
ESPHome versions can introduce breaking changes; follow README version guidance.
36+
37+
### Runtime validation
38+
When modifying protocol parsing or entity behavior:
39+
- Enable DEBUG logging and capture at least one full request/response cycle
40+
- Verify:
41+
- Stable RS-485 connection
42+
- Sensors update correctly
43+
- Controls (power, hot button, target temp) still work on known-good models
44+
45+
### Protocol decode changes (required checklist)
46+
If protocol parsing changes:
47+
1. Update documentation in `doc/` (byte offsets, scaling, meaning)
48+
2. Include at least one example frame (raw bytes + decoded values)
49+
3. Document model-specific behavior (e.g., NPE vs NCB)
50+
4. Preserve unknown bytes or log them; do not silently drop data
51+
52+
## C++ conventions & formatting
53+
54+
### Indentation / whitespace (required)
55+
- **Spaces only. No tabs.**
56+
- Use **2 spaces** for indentation.
57+
- Strip trailing whitespace.
58+
- End all files with a newline.
59+
60+
### Style
61+
- Favor readability over cleverness.
62+
- Keep functions short and single-purpose.
63+
- Use `const` where applicable; pass large objects by `const&`.
64+
- Prefer `enum class` for new enums.
65+
- Avoid macros unless required by ESPHome.
66+
67+
### Memory & dependencies (embedded best practices)
68+
- **Avoid the C++ standard library** (`std::vector`, `std::map`, `std::string`, streams, etc.).
69+
- **Do not use dynamic allocation**:
70+
- No `malloc` / `free`
71+
- No `new` / `delete`
72+
- Prefer **statically allocated or stack-allocated C data structures**:
73+
- Fixed-size arrays and ring buffers
74+
- POD `struct`s with explicit sizes
75+
- Preallocated buffers with strict bounds checks
76+
- If dynamic allocation is absolutely unavoidable:
77+
- Justify it clearly in the PR
78+
- Ensure it never occurs in `loop()` or hot paths
79+
- Document ownership and lifetime explicitly
80+
81+
### Safety & performance
82+
- Never block in hot paths (`loop()`, callbacks).
83+
- Avoid long delays or busy waits.
84+
- Minimize allocations and heavy string operations.
85+
- Treat all inbound RS-485 data as untrusted:
86+
- Validate lengths before indexing
87+
- Verify checksums/CRCs if available
88+
89+
### Logging
90+
- Use ESPHome logging macros (`ESP_LOGD`, `ESP_LOGI`, `ESP_LOGW`).
91+
- Log unknown or unsupported frames at DEBUG with:
92+
- Message/opcode (if known)
93+
- Raw bytes (trimmed if large)
94+
- Avoid log spam in steady state; throttle repeated warnings.
95+
96+
### Protocol parsing conventions
97+
- Centralize parsing logic; do not duplicate byte-offset logic.
98+
- Keep conversion math close to decode logic and comment units.
99+
- Isolate model-specific branching and document it.
100+
101+
## Adding a new exposed metric (recommended flow)
102+
1. Identify the field in traces (`trace/rs485/`)
103+
2. Document it in `doc/` (offsets, scaling, examples)
104+
3. Implement decode + conversion
105+
4. Expose via ESPHome entity
106+
5. Update README or example YAML if user-visible
107+
108+
## Communication & contributions
109+
- When handling issues/PRs:
110+
- Ask for model info and PCB/front-panel photos when relevant
111+
- Request RS-485 traces and ESPHome logs
112+
- Avoid speculative electrical advice; call out unverified assumptions
113+
114+
## What not to do
115+
- Do not recommend bypassing safety systems or modifying heater internals.
116+
- Do not introduce breaking renames without strong justification and migration notes.
117+
- Do not add “magic” auto-detection that can misconfigure pins or voltage assumptions.

esphome/components/navien/navien.cpp

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,60 +4,82 @@
44
#include "esphome.h"
55
#include "esphome/core/log.h"
66
#include "navien.h"
7-
#include "navien_link_esp.h"
87

98
namespace esphome {
109
namespace navien {
1110

1211
static const char *TAG = "navien.sensor";
1312

14-
// NavienBase implementation
15-
void NavienBase::set_link(NavienLinkEsp *link, uint8_t src) {
16-
this->link_ = link;
17-
this->src_ = src;
18-
if (link != nullptr) {
19-
link->add_visitor(this, src);
20-
}
13+
namespace {
14+
class NavienEspUartAdapter : public NavienUartI {
15+
public:
16+
void set_uart(esphome::uart::UARTComponent *uart) { uart_ = uart; }
17+
int available() override { return uart_ ? uart_->available() : 0; }
18+
uint8_t peek_byte(uint8_t *byte) override {
19+
if (!uart_) return 0;
20+
return uart_->peek_byte(byte) ? 1 : 0;
2121
}
22-
23-
void NavienBase::send_turn_on_cmd() {
24-
if (this->link_ != nullptr) {
25-
this->link_->send_turn_on_cmd();
26-
}
22+
uint8_t read_byte(uint8_t *byte) override {
23+
if (!uart_) return 0;
24+
return uart_->read_byte(byte) ? 1 : 0;
2725
}
28-
29-
void NavienBase::send_turn_off_cmd() {
30-
if (this->link_ != nullptr) {
31-
this->link_->send_turn_off_cmd();
32-
}
26+
bool read_array(uint8_t *data, uint8_t len) override {
27+
if (!uart_) return false;
28+
return uart_->read_array(data, len);
3329
}
34-
35-
void NavienBase::send_hot_button_cmd() {
36-
if (this->link_ != nullptr) {
37-
this->link_->send_hot_button_cmd();
38-
}
30+
void write_array(const uint8_t *data, uint8_t len) override {
31+
if (!uart_) return;
32+
uart_->write_array(data, len);
3933
}
4034

41-
void NavienBase::send_set_temp_cmd(float temp) {
42-
if (this->link_ != nullptr) {
43-
this->link_->send_set_temp_cmd(temp);
44-
}
45-
}
35+
private:
36+
esphome::uart::UARTComponent *uart_ = nullptr;
37+
};
4638

47-
void NavienBase::send_scheduled_recirculation_on_cmd() {
48-
if (this->link_ != nullptr) {
49-
this->link_->send_scheduled_recirculation_on_cmd();
50-
}
51-
}
39+
NavienEspUartAdapter global_uart_adapter;
40+
} // namespace
5241

53-
void NavienBase::send_scheduled_recirculation_off_cmd() {
54-
if (this->link_ != nullptr) {
55-
this->link_->send_scheduled_recirculation_off_cmd();
56-
}
42+
NavienBase::NavienBase() : navien_link_(nullptr), uart_(nullptr), src_(0), is_rt(false) {}
43+
44+
void NavienBase::setup() {
45+
if (!uart_) {
46+
ESP_LOGE(TAG, "UART interface not set");
47+
return;
5748
}
49+
global_uart_adapter.set_uart(uart_);
50+
navien_link_ = NavienLink::get_instance(&global_uart_adapter);
51+
if (navien_link_ != nullptr) {
52+
navien_link_->add_visitor(this, src_);
53+
} else {
54+
ESP_LOGE(TAG, "Failed to acquire NavienLink singleton");
55+
}
56+
}
57+
58+
// NavienBase implementation
59+
// NavienLinkEsp removed. set_link now uses NavienLink.
60+
61+
void NavienBase::send_turn_on_cmd() {
62+
if (navien_link_) navien_link_->send_turn_on_cmd();
63+
}
64+
void NavienBase::send_turn_off_cmd() {
65+
if (navien_link_) navien_link_->send_turn_off_cmd();
66+
}
67+
void NavienBase::send_hot_button_cmd() {
68+
if (navien_link_) navien_link_->send_hot_button_cmd();
69+
}
70+
void NavienBase::send_set_temp_cmd(float temp) {
71+
if (navien_link_) navien_link_->send_set_temp_cmd(temp);
72+
}
73+
void NavienBase::send_scheduled_recirculation_on_cmd() {
74+
if (navien_link_) navien_link_->send_scheduled_recirculation_on_cmd();
75+
}
76+
void NavienBase::send_scheduled_recirculation_off_cmd() {
77+
if (navien_link_) navien_link_->send_scheduled_recirculation_off_cmd();
78+
}
5879

5980
// Navien implementation
6081
void Navien::setup() {
82+
NavienBase::setup();
6183
this->state.power = POWER_OFF;
6284
}
6385

@@ -324,6 +346,11 @@ namespace navien {
324346
void Navien::update() {
325347
ESP_LOGV(TAG, "Conn Status: received: %d, updated: %d", this->received_cnt, this->updated_cnt);
326348

349+
// Call receive on navien_link_ to process UART data
350+
if (navien_link_) {
351+
navien_link_->receive();
352+
}
353+
327354
// here we track how many packets were received
328355
// since the last update
329356
// if Navien is connected and we receive packets, the
@@ -344,7 +371,7 @@ namespace navien {
344371
this->conn_status_sensor->publish_state(this->is_connected);
345372

346373
if (this->other_navilink_installed_sensor != nullptr)
347-
this->other_navilink_installed_sensor->publish_state(this->link_->is_other_navilink_installed());
374+
this->other_navilink_installed_sensor->publish_state(this->navien_link_->is_other_navilink_installed());
348375

349376
update_water_sensors();
350377
update_gas_sensors();

esphome/components/navien/navien.h

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
#include "esphome/components/switch/switch.h"
1818
//#endif
1919
#include "esphome/components/uart/uart.h"
20+
#include "navien_link.h"
21+
#include "navien_proto.h"
2022
#include "esphome/components/climate/climate.h"
2123
#include "esphome/components/text_sensor/text_sensor.h"
2224

23-
#include "navien_link_esp.h"
25+
// #include "navien_link_esp.h" // No longer needed
2426

2527
#ifndef USE_SWITCH
2628
namespace switch_ {
@@ -143,23 +145,16 @@ namespace navien {
143145

144146

145147
// Forward declaration
146-
class NavienLinkEsp;
147148

148149
class NavienBase : public NavienLinkVisitorI {
149150
public:
150-
NavienBase() : link_(nullptr), src_(0), is_rt(false) {}
151+
NavienBase();
151152

152-
/**
153-
* Set the link to the NavienLinkEsp singleton instance.
154-
* This also registers this instance as a visitor to receive callbacks.
155-
* @param link - pointer to the NavienLinkEsp singleton
156-
* @param src - source index (0-15) used to identify this visitor
157-
*/
158-
void set_link(NavienLinkEsp *link, uint8_t src = 0);
153+
virtual void setup();
154+
155+
void set_uart(esphome::uart::UARTComponent* uart) { uart_ = uart; }
156+
void set_src(uint8_t src) { src_ = src; }
159157

160-
/**
161-
* Send commands to Navien unit (forwarded to link)
162-
*/
163158
void send_turn_on_cmd();
164159
void send_turn_off_cmd();
165160
void send_hot_button_cmd();
@@ -261,7 +256,8 @@ namespace navien {
261256
switch_::Switch *allow_recirc_switch = nullptr;
262257
climate::Climate *climate = nullptr;
263258

264-
NavienLinkEsp *link_;
259+
NavienLink *navien_link_;
260+
esphome::uart::UARTComponent* uart_;
265261
uint8_t src_;
266262
bool is_rt;
267263
};
@@ -272,6 +268,7 @@ namespace navien {
272268
updated_cnt = 0;
273269
received_cnt = 0;
274270
is_connected = false;
271+
navien_link_ = nullptr;
275272
}
276273

277274
virtual float get_setup_priority() const { return setup_priority::HARDWARE; }

0 commit comments

Comments
 (0)