OOYCYOO MPPT PV control with esphome

Greetings yall! I used Codex and Claude Code to help me write a control scheme for these OOYCYOO MPPT controllers one would easily find on Amazon or Ebay. I have long been frustrated by them, because they say in the description that they have RS485 functionality… but good luck getting any information about their protocols besides “RJ45 only for lcd display, $29.95 extra”. So I downloaded the firmware out of the controller and used AI to reverse engineer it.

You will need 1 RS485 to TTL converter per MPPT controller. I have two 100A working in parallel, so the yaml is set up for two. Pins 4/5 are the data lines (blue/blue white pair on ethernet cable). The other 6 conductors carry 12v! Use the USB ports to power your esp32.

Update your default settings, mine is set up for 8s lifepo.

substitutions:
  device_name: ooycyoo-mppt-bridge
  friendly_name: OOYCYOO MPPT Bridge

esphome:
  name: ${device_name}
  friendly_name: ${friendly_name}

esp32:
  board: esp32dev
  framework:
    type: arduino

logger:
  baud_rate: 0

# Explicitly load the domains used by the external component.
sensor:
binary_sensor:
switch:
button:
number:

api:
ota:
  - platform: esphome

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

external_components:
  - source:
      type: local
      path: components

# Use one UART and one automatic-direction RS485 transceiver per controller.
# These GPIO assignments are examples and must match the physical ESP32 wiring.
uart:
  - id: mppt_1_uart
    tx_pin: GPIO17
    rx_pin: GPIO16
    baud_rate: 9600
    parity: NONE
    stop_bits: 1
  - id: mppt_2_uart
    tx_pin: GPIO26
    rx_pin: GPIO27
    baud_rate: 9600
    parity: NONE
    stop_bits: 1

ooycyoo_mppt:
  - id: mppt_1
    uart_id: mppt_1_uart
    # Safety default: keep false until settings and wiring have been verified.
    poll_enabled: true
    # Resend the requested settings when the controller is out of sync.
    auto_resync: true
    pv_off_voltage: 27.5
    load_off_voltage: 21.4
    load_on_voltage: 25.2
    evening_hours: 20
    interval_hours: 2
    dawn_hours: 2
    pv_voltage:
      name: "MPPT 1 PV Voltage"
    pv_current:
      name: "MPPT 1 Estimated PV Current"
    solar_power:
      name: "MPPT 1 Estimated Solar Power"
    battery_voltage:
      name: "MPPT 1 Battery Voltage"
    battery_current:
      name: "MPPT 1 Charging Current"
    battery_charging_power:
      name: "MPPT 1 Battery Charging Power"
    battery_temperature:
      name: "MPPT 1 Battery Temperature"
    battery_percent:
      name: "MPPT 1 Estimated Battery Percent"
    load_voltage:
      name: "MPPT 1 Load Voltage"
    load_current:
      name: "MPPT 1 Load Current"
    total_energy:
      name: "MPPT 1 Total Energy"
    pv_off_voltage_feedback:
      name: "MPPT 1 Controller PV Off Voltage"
    load_off_voltage_feedback:
      name: "MPPT 1 Controller Load Off Voltage"
    load_on_voltage_feedback:
      name: "MPPT 1 Controller Load On Voltage"
    evening_hours_feedback:
      name: "MPPT 1 Controller Evening Hours"
    interval_hours_feedback:
      name: "MPPT 1 Controller Interval Hours"
    dawn_hours_feedback:
      name: "MPPT 1 Controller Dawn Hours"
    load_path_fault:
      name: "MPPT 1 Load Path Fault"
    charge_path_fault:
      name: "MPPT 1 Charge Path Fault"
    settings_acknowledged:
      name: "MPPT 1 Settings Acknowledged"
    setting_update_pending:
      name: "MPPT 1 Setting Update Pending"
    setting_write_attempts:
      name: "MPPT 1 Setting Write Attempts"
    load_switch:
      name: "MPPT 1 DC Load"
    reset_button:
      name: "MPPT 1 Reset Settings"
    pv_off_number:
      name: "MPPT 1 Requested PV Off Voltage"
    load_off_number:
      name: "MPPT 1 Requested Load Off Voltage"
    load_on_number:
      name: "MPPT 1 Requested Load On Voltage"
    evening_number:
      name: "MPPT 1 Requested Evening Hours"
    interval_number:
      name: "MPPT 1 Requested Interval Hours"
    dawn_number:
      name: "MPPT 1 Requested Dawn Hours"

  - id: mppt_2
    uart_id: mppt_2_uart
    # Safety default: keep false until settings and wiring have been verified.
    poll_enabled: true
    # Resend the requested settings when the controller is out of sync.
    auto_resync: true
    pv_off_voltage: 27.6
    load_off_voltage: 24.3
    load_on_voltage: 24.8
    evening_hours: 24
    interval_hours: 0
    dawn_hours: 0
    pv_voltage:
      name: "MPPT 2 PV Voltage"
    pv_current:
      name: "MPPT 2 Estimated PV Current"
    solar_power:
      name: "MPPT 2 Estimated Solar Power"
    battery_voltage:
      name: "MPPT 2 Battery Voltage"
    battery_current:
      name: "MPPT 2 Charging Current"
    battery_charging_power:
      name: "MPPT 2 Battery Charging Power"
    battery_temperature:
      name: "MPPT 2 Battery Temperature"
    battery_percent:
      name: "MPPT 2 Estimated Battery Percent"
    load_voltage:
      name: "MPPT 2 Load Voltage"
    load_current:
      name: "MPPT 2 Load Current"
    total_energy:
      name: "MPPT 2 Total Energy"
    pv_off_voltage_feedback:
      name: "MPPT 2 Controller PV Off Voltage"
    load_off_voltage_feedback:
      name: "MPPT 2 Controller Load Off Voltage"
    load_on_voltage_feedback:
      name: "MPPT 2 Controller Load On Voltage"
    evening_hours_feedback:
      name: "MPPT 2 Controller Evening Hours"
    interval_hours_feedback:
      name: "MPPT 2 Controller Interval Hours"
    dawn_hours_feedback:
      name: "MPPT 2 Controller Dawn Hours"
    load_path_fault:
      name: "MPPT 2 Load Path Fault"
    charge_path_fault:
      name: "MPPT 2 Charge Path Fault"
    settings_acknowledged:
      name: "MPPT 2 Settings Acknowledged"
    setting_update_pending:
      name: "MPPT 2 Setting Update Pending"
    setting_write_attempts:
      name: "MPPT 2 Setting Write Attempts"
    load_switch:
      name: "MPPT 2 DC Load"
    reset_button:
      name: "MPPT 2 Reset Settings"
    pv_off_number:
      name: "MPPT 2 Requested PV Off Voltage"
    load_off_number:
      name: "MPPT 2 Requested Load Off Voltage"
    load_on_number:
      name: "MPPT 2 Requested Load On Voltage"
    evening_number:
      name: "MPPT 2 Requested Evening Hours"
    interval_number:
      name: "MPPT 2 Requested Interval Hours"
    dawn_number:
      name: "MPPT 2 Requested Dawn Hours"

Nice job. How was that done?

I soldered in the serial pins closest to the microcontroller in the remote control, extracted the firmware via serial-usb adapter and uploaded it to chatgpt codex. Within a few tests and revisions, I was able to control the remote control via usb programmer. After a listening session between the controller and remote, testing a few different methods to save changes, I had a working setup.

Compliments.
Were you already familiar with that MCU?

Thank you! That was my first exposure to an STM8 (clone) project at that kind of level besides some arduino work I did on a canbus BMS a while back. I had also turned a Heltec 8s balancer from Ali into an esphome device too before this, so I could read the cell voltages in HAOS. Same instance, another STM8 clone. Despite being very amateur at all of these communication protocols Ive been bathed in them for trying to piece together information from strange devices with limited documentation.

My copy-paste shortcut some of the files off :expressionless_face:

PROTOCOL_NOTES.md

OOYCYOO remote-display protocol notes

Transport

  • STM8S003F3 remote display UART: 9600 8N1.
  • UART connects to a 7LB184 half-duplex RS485 transceiver.
  • Display polls at approximately 0.84 s.
  • Frame: 0x55, 30-byte payload, one checksum byte.
  • Checksum: sum of the 30 payload bytes modulo 256. The 0x55 header is not
    included.
  • This is not Modbus and has no CRC.

Startup exchange

The physical display sends one initialization frame before normal polling:

55 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

The controller replies, and the display then starts its recurring setting/poll
frame. The ESPHome component reproduces this one-time startup frame when
polling is first enabled.

Response payload map

Offsets exclude the 0x55 header.

Bytes Encoding Meaning
R[0..1] big-endian, 0.1 V Controller-reported PV-off threshold
R[3..4] little-endian, 0.1 V Controller-reported load-off threshold
R[5] byte, 0.1 V Controller-reported load-on threshold (low byte)
R[6] unknown Not part of load-on in replies: MPPT 1 answered FC EF to the display’s FC 00 (25.2 V) in every 2026-09-10 capture and EF never followed a setting change; later seen as A0. The display’s own polls send load-on little-endian in R[5..6].
R[7] hours Controller-reported evening setting
R[8] hours Controller-reported interval setting
R[9] hours Controller-reported dawn setting
R[10..11] big-endian, 0.1 V PV voltage
R[12..13] big-endian, 0.1 A Battery charging current
R[14..15] big-endian, 0.1 V Battery voltage
R[16..17] big-endian, 0.1 C Battery temperature
R[18] byte 0 load on, 1 load off
R[20..21] big-endian, 0.1 A Load current
R[22].0 bit Battery-to-load path X/fault icon
R[23].0 bit PV-to-battery path X/fault icon
R[24..25] unknown No repeatable visible effect; leave zero
R[28..29] big-endian Total energy; LCD displays raw / 10 kWh

The display is the bus master and sends desired settings in each poll. Response
bytes R[0..9] report the controller’s current stored settings. They may remain
at the old values during the nine pre-commit polls; this is expected and is not
a refusal.

Confirmed write and acknowledgement sequence

The 2026-09-10 physical-display capture includes a PV-off change from 27.5 V
(01 13) to 27.8 V (01 16):

  1. The outgoing display setting changes to 01 16.
  2. Controller replies remain 01 13 throughout the ordinary pre-commit polls.
  3. The display sends the changed setting with R[10] = 1 on the tenth poll.
  4. The controller’s immediately following response changes to 01 16.

A captured load-on change shows the same pattern. The response after the commit
poll is therefore a real acknowledgement, and software must compare response
settings to requested settings rather than echoing its transmit buffer.

Load timers (Evening, Interval, Dawn)

From the controller manual (ooycyoo-mppt.pdf, page 6), all in hours:

  • Evening R[7]: 24 = load always on; 0 = street-lamp mode, on at
    dusk and off at dawn; 1-23 = on at dusk for that many hours.
  • Interval R[8]: after the evening period, hours off before the dawn
    period (0 = none).
  • Dawn R[9]: hours the load comes back on before dawn (0 = none).

With Evening 24 the other two have no effect. There is no rule that the
three add up to 24
: in the 2026-09-10 capture the display committed
20/2/2, then 24/2/2 (28 h), and the controller acknowledged both in its
next reply; while editing, the display passed through 20/24/0 and wraps
0 down to 24. Timers are written and acknowledged exactly like the
voltages (nine polls, then the R[10] = 1 commit).

Load voltage = battery voltage when R[18] = 0, otherwise 0 V.
PV current = battery_voltage * battery_charging_current / PV_voltage
             when PV voltage >= battery voltage + 1.0 V; otherwise 0 A.
Battery percent is estimated locally from battery voltage and the
controller-reported load-off/PV-off thresholds.

PV voltage and battery charging current are direct controller telemetry. PV
current and solar power are estimates because the captured response does not
contain a separate PV-current field.

Commands/events

Short Load press: R[13] = 1 for one outgoing frame, then 0.

Long Load hold/reset:
  reset settings to 13.8 V / 10.7 V / 12.6 V / 24 h / 0 h / 0 h
  send R[11] = 1 for one frame
  send R[11] = 2 for approximately three frames
  return R[11] = 0

The physical remote uses PA1 and PA2 as activity LEDs:

PA1 toggles while battery current is >= 0.6 A.
PA2 toggles while load current is >= 0.6 A.
__init__.py

import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import binary_sensor, button, number, sensor, switch, uart
from esphome.const import CONF_ID, CONF_UART_ID

CODEOWNERS =
DEPENDENCIES = [“uart”]
MULTI_CONF = True

ooycyoo_ns = cg.esphome_ns.namespace(“ooycyoo_mppt”)
OoycyooMPPT = ooycyoo_ns.class_(“OoycyooMPPT”, cg.PollingComponent, uart.UARTDevice)
OoycyooLoadSwitch = ooycyoo_ns.class_(“OoycyooLoadSwitch”, switch.Switch)
OoycyooResetButton = ooycyoo_ns.class_(“OoycyooResetButton”, button.Button)
OoycyooSettingNumber = ooycyoo_ns.class_(“OoycyooSettingNumber”, number.Number)

CONF_POLL_ENABLED = “poll_enabled”
CONF_PV_OFF = “pv_off_voltage”
CONF_LOAD_OFF = “load_off_voltage”
CONF_LOAD_ON = “load_on_voltage”
CONF_EVENING = “evening_hours”
CONF_INTERVAL = “interval_hours”
CONF_DAWN = “dawn_hours”
CONF_LOAD_SWITCH = “load_switch”
CONF_RESET_BUTTON = “reset_button”
CONF_AUTO_RESYNC = “auto_resync”
CONF_WRITE_ATTEMPTS = “setting_write_attempts”

NUMBERS = {
“pv_off_number”: (0, “V”, 0.0, 99.9, 0.1),
“load_off_number”: (1, “V”, 0.0, 99.9, 0.1),
“load_on_number”: (2, “V”, 0.0, 99.9, 0.1),
“evening_number”: (3, “h”, 0, 24, 1),
“interval_number”: (4, “h”, 0, 24, 1),
“dawn_number”: (5, “h”, 0, 24, 1),
}

SENSORS = {
“pv_voltage”: (“set_pv_voltage_sensor”, “V”),
“pv_current”: (“set_pv_current_sensor”, “A”),
“solar_power”: (“set_solar_power_sensor”, “W”),
“battery_voltage”: (“set_battery_voltage_sensor”, “V”),
“battery_current”: (“set_battery_current_sensor”, “A”),
“battery_charging_power”: (“set_battery_charging_power_sensor”, “W”),
“battery_temperature”: (“set_battery_temperature_sensor”, “°C”),
“battery_percent”: (“set_battery_percent_sensor”, “%”),
“load_voltage”: (“set_load_voltage_sensor”, “V”),
“load_current”: (“set_load_current_sensor”, “A”),
“total_energy”: (“set_total_energy_sensor”, “kWh”),
}

SETTING_FEEDBACK_SENSORS = {
“pv_off_voltage_feedback”: (0, “V”),
“load_off_voltage_feedback”: (1, “V”),
“load_on_voltage_feedback”: (2, “V”),
“evening_hours_feedback”: (3, “h”),
“interval_hours_feedback”: (4, “h”),
“dawn_hours_feedback”: (5, “h”),
}

BINARY_SENSORS = {
“load_path_fault”: “set_load_path_fault_sensor”,
“charge_path_fault”: “set_charge_path_fault_sensor”,
“settings_acknowledged”: “set_settings_acknowledged_sensor”,
“setting_update_pending”: “set_setting_update_pending_sensor”,
}

CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(OoycyooMPPT),
cv.Required(CONF_UART_ID): cv.use_id(uart.UARTComponent),
cv.Optional(CONF_POLL_ENABLED, default=False): cv.boolean,
cv.Optional(CONF_AUTO_RESYNC, default=True): cv.boolean,
cv.Optional(CONF_WRITE_ATTEMPTS): sensor.sensor_schema(),
cv.Required(CONF_PV_OFF): cv.float_range(min=0.0, max=99.9),
cv.Required(CONF_LOAD_OFF): cv.float_range(min=0.0, max=99.9),
cv.Required(CONF_LOAD_ON): cv.float_range(min=0.0, max=99.9),
cv.Required(CONF_EVENING): cv.int_range(min=0, max=24),
cv.Required(CONF_INTERVAL): cv.int_range(min=0, max=24),
cv.Required(CONF_DAWN): cv.int_range(min=0, max=24),
cv.Optional(CONF_LOAD_SWITCH): switch.switch_schema(OoycyooLoadSwitch),
cv.Optional(CONF_RESET_BUTTON): button.button_schema(OoycyooResetButton),
**{
cv.Optional(key): number.number_schema(
OoycyooSettingNumber, unit_of_measurement=unit
)
for key, (_, unit, _, , ) in NUMBERS.items()
},
**{
cv.Optional(key): sensor.sensor_schema(unit_of_measurement=unit)
for key, (
, unit) in SENSORS.items()
},
**{
cv.Optional(key): sensor.sensor_schema(unit_of_measurement=unit)
for key, (
, unit) in SETTING_FEEDBACK_SENSORS.items()
},
**{
cv.Optional(key): binary_sensor.binary_sensor_schema()
for key in BINARY_SENSORS
},
}
).extend(cv.polling_component_schema(“840ms”))

async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
cg.add(var.set_poll_enabled(config[CONF_POLL_ENABLED]))
cg.add(var.set_auto_resync(config[CONF_AUTO_RESYNC]))
# Saved requested settings are keyed by the controller block’s id.
cg.add(var.set_store_key(f"ooycyoo_mppt_{config[CONF_ID].id}"))
if CONF_WRITE_ATTEMPTS in config:
cg.add(
var.set_write_attempts_sensor(
await sensor.new_sensor(config[CONF_WRITE_ATTEMPTS])
)
)
cg.add(
var.set_settings(
config[CONF_PV_OFF],
config[CONF_LOAD_OFF],
config[CONF_LOAD_ON],
config[CONF_EVENING],
config[CONF_INTERVAL],
config[CONF_DAWN],
)
)

for key, (setter, _) in SENSORS.items():
    if key in config:
        cg.add(getattr(var, setter)(await sensor.new_sensor(config[key])))
for key, (setting, _) in SETTING_FEEDBACK_SENSORS.items():
    if key in config:
        cg.add(
            var.set_setting_feedback_sensor(
                setting, await sensor.new_sensor(config[key])
            )
        )
for key, setter in BINARY_SENSORS.items():
    if key in config:
        cg.add(
            getattr(var, setter)(
                await binary_sensor.new_binary_sensor(config[key])
            )
        )
if CONF_LOAD_SWITCH in config:
    load_switch = cg.new_Pvariable(config[CONF_LOAD_SWITCH][CONF_ID], var)
    await switch.register_switch(load_switch, config[CONF_LOAD_SWITCH])
    cg.add(var.set_load_switch(load_switch))
if CONF_RESET_BUTTON in config:
    reset_button = cg.new_Pvariable(config[CONF_RESET_BUTTON][CONF_ID], var)
    await button.register_button(reset_button, config[CONF_RESET_BUTTON])
for key, (setting, _, minimum, maximum, step) in NUMBERS.items():
    if key in config:
        entity = cg.new_Pvariable(config[key][CONF_ID], var, setting)
        await number.register_number(
            entity,
            config[key],
            min_value=minimum,
            max_value=maximum,
            step=step,
        )
        cg.add(var.set_setting_number(setting, entity))

and continued due to character limitations
ooycyoo_mppt.h

#pragma once

#include <algorithm>
#include <cmath>

#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/button/button.h"
#include "esphome/components/number/number.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/switch/switch.h"
#include "esphome/components/uart/uart.h"
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"

namespace esphome {
namespace ooycyoo_mppt {

static const char *const TAG = "ooycyoo_mppt";
static const char *const COMPONENT_VERSION = "0.2.1";

class OoycyooMPPT;

class OoycyooLoadSwitch : public switch_::Switch {
 public:
  explicit OoycyooLoadSwitch(OoycyooMPPT *parent) : parent_(parent) {}

 protected:
  void write_state(bool state) override;
  OoycyooMPPT *parent_;
};

class OoycyooResetButton : public button::Button {
 public:
  explicit OoycyooResetButton(OoycyooMPPT *parent) : parent_(parent) {}

 protected:
  void press_action() override;
  OoycyooMPPT *parent_;
};

enum OoycyooSetting : uint8_t {
  SETTING_PV_OFF = 0,
  SETTING_LOAD_OFF = 1,
  SETTING_LOAD_ON = 2,
  SETTING_EVENING = 3,
  SETTING_INTERVAL = 4,
  SETTING_DAWN = 5,
};

class OoycyooSettingNumber : public number::Number {
 public:
  OoycyooSettingNumber(OoycyooMPPT *parent, uint8_t setting) : parent_(parent), setting_(setting) {}

 protected:
  void control(float value) override;
  OoycyooMPPT *parent_;
  uint8_t setting_;
};

class OoycyooMPPT : public PollingComponent, public uart::UARTDevice {
 public:
  void setup() override {
    ESP_LOGCONFIG(TAG, "OOYCYOO MPPT protocol component %s ready", COMPONENT_VERSION);
    // Requested settings survive a reboot. Without a saved copy (first boot on
    // 0.2.1) the controller's settings are adopted once three replies agree,
    // so the YAML defaults are never pushed to a controller unasked.
    this->pref_ = global_preferences->make_preference<SavedSettings>(fnv1_hash(this->store_key_), true);
    SavedSettings saved{};
    if (this->pref_.load(&saved) && saved.magic == SAVED_MAGIC) {
      for (uint8_t i = 0; i < SAVED_BYTES; i++)
        this->tx_[i] = saved.bytes[i];
      this->settings_known_ = true;
      ESP_LOGI(TAG, "Restored requested settings saved before the reboot");
    }
    this->publish_requested_settings_();
    if (this->setting_update_pending_ != nullptr)
      this->setting_update_pending_->publish_state(false);
    this->publish_write_attempts_();
  }

  void update() override {
    if (this->poll_enabled_)
      this->send_poll_();
  }

  void loop() override {
    uint8_t value;
    while (this->available() && this->read_byte(&value)) {
      if (this->rx_index_ == 0 && value != 0x55)
        continue;
      this->rx_[this->rx_index_++] = value;
      if (this->rx_index_ != FRAME_SIZE)
        continue;
      this->handle_frame_();
      this->rx_index_ = 0;
    }
  }

  void set_poll_enabled(bool enabled) { this->poll_enabled_ = enabled; }
  void set_auto_resync(bool enabled) { this->auto_resync_ = enabled; }
  void set_store_key(const std::string &key) { this->store_key_ = key; }
  void set_write_attempts_sensor(sensor::Sensor *s) { this->write_attempts_sensor_ = s; }

  void set_settings(float pv_off, float load_off, float load_on, uint8_t evening, uint8_t interval, uint8_t dawn) {
    this->put_be_(0, this->to_tenths_(pv_off));
    this->put_le_(3, this->to_tenths_(load_off));
    this->put_le_(5, this->to_tenths_(load_on));
    this->tx_[7] = evening;
    this->tx_[8] = interval;
    this->tx_[9] = dawn;
  }

  void request_load(bool desired_on) {
    if (desired_on == this->load_on_)
      return;
    this->toggle_pending_ = true;
  }

  void request_reset() {
    this->set_settings(13.8f, 10.7f, 12.6f, 24, 0, 0);
    // Reset uses its own R[11] sequence, so cancel any partly completed
    // ordinary R[10] setting commit before starting it.
    this->settings_write_pending_ = false;
    this->settings_polls_sent_ = 0;
    this->reset_phase_ = 1;
    this->reset_twos_remaining_ = 0;
    this->settings_ack_pending_ = true;
    // The controller's post-reset settings are adopted once the reset
    // sequence ends; the 12 V values above are never pushed by a resync.
    this->settings_known_ = false;
    this->adopt_matches_ = 0;
    this->write_attempts_ = 0;
    this->awaiting_confirmation_ = false;
    this->publish_requested_settings_();
    this->publish_setting_status_(false, true);
  }

  void set_setting_value(uint8_t setting, float value) {
    switch (setting) {
      case SETTING_PV_OFF: this->put_be_(0, this->to_tenths_(value)); break;
      case SETTING_LOAD_OFF: this->put_le_(3, this->to_tenths_(value)); break;
      case SETTING_LOAD_ON: this->put_le_(5, this->to_tenths_(value)); break;
      case SETTING_EVENING: this->tx_[7] = static_cast<uint8_t>(std::round(value)); break;
      case SETTING_INTERVAL: this->tx_[8] = static_cast<uint8_t>(std::round(value)); break;
      case SETTING_DAWN: this->tx_[9] = static_cast<uint8_t>(std::round(value)); break;
      default:
        ESP_LOGW(TAG, "Ignored unknown setting index %u", setting);
        return;
    }
    // The physical display sends nine ordinary setting polls followed by one
    // R[10]=1 commit poll. The controller acknowledges in the next reply.
    // A new request starts a fresh attempt count and cancels any wait.
    this->settings_known_ = true;
    this->write_attempts_ = 0;
    this->next_retry_ms_ = millis();
    this->save_settings_();
    this->start_write_();
    this->publish_requested_settings_();
    this->publish_setting_status_(false, true);
  }

  void set_pv_voltage_sensor(sensor::Sensor *s) { this->pv_voltage_ = s; }
  void set_pv_current_sensor(sensor::Sensor *s) { this->pv_current_ = s; }
  void set_solar_power_sensor(sensor::Sensor *s) { this->solar_power_ = s; }
  void set_battery_voltage_sensor(sensor::Sensor *s) { this->battery_voltage_ = s; }
  void set_battery_current_sensor(sensor::Sensor *s) { this->battery_current_ = s; }
  void set_battery_charging_power_sensor(sensor::Sensor *s) { this->battery_charging_power_ = s; }
  void set_battery_temperature_sensor(sensor::Sensor *s) { this->battery_temperature_ = s; }
  void set_battery_percent_sensor(sensor::Sensor *s) { this->battery_percent_ = s; }
  void set_load_voltage_sensor(sensor::Sensor *s) { this->load_voltage_ = s; }
  void set_load_current_sensor(sensor::Sensor *s) { this->load_current_ = s; }
  void set_total_energy_sensor(sensor::Sensor *s) { this->total_energy_ = s; }
  void set_load_path_fault_sensor(binary_sensor::BinarySensor *s) { this->load_path_fault_ = s; }
  void set_charge_path_fault_sensor(binary_sensor::BinarySensor *s) { this->charge_path_fault_ = s; }
  void set_settings_acknowledged_sensor(binary_sensor::BinarySensor *s) { this->settings_acknowledged_ = s; }
  void set_setting_update_pending_sensor(binary_sensor::BinarySensor *s) { this->setting_update_pending_ = s; }
  void set_load_switch(OoycyooLoadSwitch *s) { this->load_switch_ = s; }
  void set_setting_feedback_sensor(uint8_t setting, sensor::Sensor *s) {
    if (setting < SETTING_COUNT)
      this->setting_feedback_sensors_[setting] = s;
  }
  void set_setting_number(uint8_t setting, OoycyooSettingNumber *s) {
    if (setting >= SETTING_COUNT)
      return;
    this->setting_numbers_[setting] = s;
    this->publish_requested_settings_();
  }

 protected:
  static constexpr uint8_t PAYLOAD_SIZE = 30;
  static constexpr uint8_t FRAME_SIZE = 32;
  static constexpr uint8_t SETTING_COUNT = 6;
  uint8_t tx_[PAYLOAD_SIZE]{};
  uint8_t rx_[FRAME_SIZE]{};
  uint8_t rx_index_{0};
  bool poll_enabled_{false};
  bool initialization_pending_{true};
  bool load_on_{false};
  bool toggle_pending_{false};
  bool settings_write_pending_{false};
  bool settings_ack_pending_{false};
  uint8_t settings_polls_sent_{0};
  // Resync: out-of-sync replies seen while idle, replies since a commit,
  // commit attempts for the current request and when the next may start.
  static constexpr uint8_t SAVED_BYTES = 10;
  static constexpr uint8_t SAVED_MAGIC = 0xA5;
  static constexpr uint8_t STEADY_MISMATCH_REPLIES = 3;
  static constexpr uint8_t CONFIRMATION_REPLIES = 3;
  struct SavedSettings {
    uint8_t magic;
    uint8_t bytes[SAVED_BYTES];
  };
  ESPPreferenceObject pref_;
  std::string store_key_{"ooycyoo_mppt"};
  bool settings_known_{false};
  uint8_t adopt_matches_{0};
  uint8_t adopt_candidate_[SAVED_BYTES + 1]{};
  bool auto_resync_{true};
  bool awaiting_confirmation_{false};
  uint8_t mismatch_replies_{0};
  uint8_t replies_since_commit_{0};
  uint16_t write_attempts_{0};
  uint32_t next_retry_ms_{0};
  sensor::Sensor *write_attempts_sensor_{nullptr};
  uint8_t reset_phase_{0};
  uint8_t reset_twos_remaining_{0};

  sensor::Sensor *pv_voltage_{nullptr};
  sensor::Sensor *pv_current_{nullptr};
  sensor::Sensor *solar_power_{nullptr};
  sensor::Sensor *battery_voltage_{nullptr};
  sensor::Sensor *battery_current_{nullptr};
  sensor::Sensor *battery_charging_power_{nullptr};
  sensor::Sensor *battery_temperature_{nullptr};
  sensor::Sensor *battery_percent_{nullptr};
  sensor::Sensor *load_voltage_{nullptr};
  sensor::Sensor *load_current_{nullptr};
  sensor::Sensor *total_energy_{nullptr};
  sensor::Sensor *setting_feedback_sensors_[SETTING_COUNT]{};
  binary_sensor::BinarySensor *load_path_fault_{nullptr};
  binary_sensor::BinarySensor *charge_path_fault_{nullptr};
  binary_sensor::BinarySensor *settings_acknowledged_{nullptr};
  binary_sensor::BinarySensor *setting_update_pending_{nullptr};
  OoycyooLoadSwitch *load_switch_{nullptr};
  OoycyooSettingNumber *setting_numbers_[SETTING_COUNT]{};

  uint16_t to_tenths_(float value) const { return static_cast<uint16_t>(std::round(value * 10.0f)); }
  uint16_t be_(uint8_t offset) const { return (uint16_t(this->rx_[offset + 1]) << 8) | this->rx_[offset + 2]; }
  uint16_t le_(uint8_t offset) const { return uint16_t(this->rx_[offset + 1]) | (uint16_t(this->rx_[offset + 2]) << 8); }
  uint16_t tx_be_(uint8_t offset) const { return (uint16_t(this->tx_[offset]) << 8) | this->tx_[offset + 1]; }
  uint16_t tx_le_(uint8_t offset) const { return uint16_t(this->tx_[offset]) | (uint16_t(this->tx_[offset + 1]) << 8); }
  void put_be_(uint8_t offset, uint16_t value) { this->tx_[offset] = value >> 8; this->tx_[offset + 1] = value & 0xFF; }
  void put_le_(uint8_t offset, uint16_t value) { this->tx_[offset] = value & 0xFF; this->tx_[offset + 1] = value >> 8; }

  void send_poll_() {
    if (this->initialization_pending_) {
      uint8_t packet[FRAME_SIZE]{};
      packet[0] = 0x55;
      this->write_array(packet, FRAME_SIZE);
      this->flush();
      this->initialization_pending_ = false;
      ESP_LOGD(TAG, "Sent startup initialization poll");
      return;
    }

    const bool commit_settings = this->settings_write_pending_ && this->settings_polls_sent_ >= 9;
    this->tx_[10] = commit_settings ? 1 : 0;
    this->tx_[11] = this->reset_phase_;
    this->tx_[13] = this->toggle_pending_ ? 1 : 0;
    uint8_t packet[FRAME_SIZE];
    packet[0] = 0x55;
    uint8_t checksum = 0;
    for (uint8_t i = 0; i < PAYLOAD_SIZE; i++) {
      packet[i + 1] = this->tx_[i];
      checksum += this->tx_[i];
    }
    packet[31] = checksum;
    ESP_LOGD(TAG, "TX poll: PV-off=%02X%02X load-off=%02X%02X load-on=%02X%02X timers=%u/%u/%u commit=%02X flags=%02X/%02X checksum=%02X",
             this->tx_[0], this->tx_[1], this->tx_[3], this->tx_[4], this->tx_[5], this->tx_[6],
             this->tx_[7], this->tx_[8], this->tx_[9], this->tx_[10], this->tx_[11], this->tx_[13], checksum);
    this->write_array(packet, FRAME_SIZE);
    this->flush();

    if (this->toggle_pending_)
      this->toggle_pending_ = false;
    if (this->settings_write_pending_) {
      if (commit_settings) {
        this->settings_write_pending_ = false;
        this->settings_polls_sent_ = 0;
        this->awaiting_confirmation_ = true;
        this->replies_since_commit_ = 0;
      } else {
        this->settings_polls_sent_++;
      }
    }
    if (this->reset_phase_ == 1) {
      this->reset_phase_ = 2;
      this->reset_twos_remaining_ = 3;
    } else if (this->reset_phase_ == 2 && --this->reset_twos_remaining_ == 0) {
      this->reset_phase_ = 0;
    }
  }

  // Reply byte R[6] is not part of load-on: MPPT 1 answered FC EF to the
  // original display's FC 00 (25.2 V) in every 2026-09-10 capture, and EF never
  // followed a setting change. Load-on is compared on R[5] alone.
  bool settings_match_() const {
    return this->be_(0) == this->tx_be_(0) &&
           this->le_(3) == this->tx_le_(3) &&
           this->rx_[6] == this->tx_[5] &&
           this->rx_[8] == this->tx_[7] &&
           this->rx_[9] == this->tx_[8] &&
           this->rx_[10] == this->tx_[9];
  }

  void publish_setting_status_(bool acknowledged, bool pending) {
    if (this->settings_acknowledged_ != nullptr)
      this->settings_acknowledged_->publish_state(acknowledged);
    if (this->setting_update_pending_ != nullptr)
      this->setting_update_pending_->publish_state(pending);
  }

  void publish_controller_settings_() {
    const float values[SETTING_COUNT] = {
        this->be_(0) / 10.0f,
        this->le_(3) / 10.0f,
        this->reported_load_on_tenths_() / 10.0f,
        static_cast<float>(this->rx_[8]),
        static_cast<float>(this->rx_[9]),
        static_cast<float>(this->rx_[10]),
    };
    for (uint8_t i = 0; i < SETTING_COUNT; i++)
      if (this->setting_feedback_sensors_[i] != nullptr)
        this->setting_feedback_sensors_[i]->publish_state(values[i]);
  }

  void handle_frame_() {
    uint8_t checksum = 0;
    for (uint8_t i = 1; i <= PAYLOAD_SIZE; i++)
      checksum += this->rx_[i];
    if (checksum != this->rx_[31]) {
      ESP_LOGW(TAG, "Discarded frame with invalid checksum");
      return;
    }

    ESP_LOGD(TAG, "RX reply: settings=%02X%02X/%02X%02X/%02X%02X/%u/%u/%u checksum=%02X",
             this->rx_[1], this->rx_[2], this->rx_[4], this->rx_[5], this->rx_[6], this->rx_[7],
             this->rx_[8], this->rx_[9], this->rx_[10], this->rx_[31]);

    const float pv_v = this->be_(10) / 10.0f;
    const float battery_a = this->be_(12) / 10.0f;
    const float battery_v = this->be_(14) / 10.0f;
    const float temperature_c = this->be_(16) / 10.0f;
    const float load_a = this->be_(20) / 10.0f;
    const float total_kwh = this->be_(28) / 10.0f;
    this->load_on_ = this->rx_[19] == 0;

    float pv_a = 0.0f;
    if (pv_v >= battery_v + 1.0f && pv_v > 0.0f)
      pv_a = (battery_v * battery_a) / pv_v;
    const float solar_w = pv_v * pv_a;
    const float battery_charging_w = battery_v * battery_a;

    const float controller_load_off = this->le_(3) / 10.0f;
    const float controller_pv_off = this->be_(0) / 10.0f;
    float percent = 0.0f;
    if (controller_pv_off > controller_load_off)
      percent = std::max(0.0f, std::min(100.0f, 100.0f * (battery_v - controller_load_off) /
                                                       (controller_pv_off - controller_load_off)));

    if (this->pv_voltage_ != nullptr) this->pv_voltage_->publish_state(pv_v);
    if (this->pv_current_ != nullptr) this->pv_current_->publish_state(pv_a);
    if (this->solar_power_ != nullptr) this->solar_power_->publish_state(solar_w);
    if (this->battery_voltage_ != nullptr) this->battery_voltage_->publish_state(battery_v);
    if (this->battery_current_ != nullptr) this->battery_current_->publish_state(battery_a);
    if (this->battery_charging_power_ != nullptr) this->battery_charging_power_->publish_state(battery_charging_w);
    if (this->battery_temperature_ != nullptr) this->battery_temperature_->publish_state(temperature_c);
    if (this->battery_percent_ != nullptr) this->battery_percent_->publish_state(percent);
    if (this->load_voltage_ != nullptr) this->load_voltage_->publish_state(this->load_on_ ? battery_v : 0.0f);
    if (this->load_current_ != nullptr) this->load_current_->publish_state(load_a);
    if (this->total_energy_ != nullptr) this->total_energy_->publish_state(total_kwh);
    if (this->load_path_fault_ != nullptr) this->load_path_fault_->publish_state((this->rx_[23] & 0x01) != 0);
    if (this->charge_path_fault_ != nullptr) this->charge_path_fault_->publish_state((this->rx_[24] & 0x01) != 0);
    if (this->load_switch_ != nullptr) this->load_switch_->publish_state(this->load_on_);

    if (!this->settings_known_ && this->reset_phase_ == 0)
      this->consider_adopting_();
    this->publish_controller_settings_();
    ESP_LOGV(TAG, "Reply byte R[6]=%02X (not part of load-on)", this->rx_[7]);
    const bool acknowledged = this->settings_match_();
    this->update_sync_(acknowledged);
    this->publish_setting_status_(acknowledged, this->settings_ack_pending_);
  }

  uint16_t reported_load_on_tenths_() const {
    // Low byte from the controller; the high byte is not reported, so the
    // requested one is assumed.
    return uint16_t(this->rx_[6]) | (uint16_t(this->tx_[6]) << 8);
  }

  // Adopt only after the same settings arrive in consecutive replies.
  void consider_adopting_() {
    bool same = this->adopt_matches_ > 0;
    for (uint8_t i = 1; i <= SAVED_BYTES && same; i++)
      same = i == 7 || this->adopt_candidate_[i] == this->rx_[i];  // skip R[6]
    for (uint8_t i = 1; i <= SAVED_BYTES; i++)
      this->adopt_candidate_[i] = this->rx_[i];
    this->adopt_matches_ = same ? this->adopt_matches_ + 1 : 1;
    if (this->adopt_matches_ >= STEADY_MISMATCH_REPLIES)
      this->adopt_controller_settings_();
  }

  void adopt_controller_settings_() {
    this->tx_[0] = this->rx_[1];
    this->tx_[1] = this->rx_[2];
    this->tx_[3] = this->rx_[4];
    this->tx_[4] = this->rx_[5];
    this->tx_[5] = this->rx_[6];
    this->tx_[7] = this->rx_[8];
    this->tx_[8] = this->rx_[9];
    this->tx_[9] = this->rx_[10];
    this->settings_known_ = true;
    this->save_settings_();
    this->publish_requested_settings_();
    ESP_LOGI(TAG, "No saved settings: adopted the controller's current settings as requested");
  }

  void save_settings_() {
    SavedSettings saved{};
    saved.magic = SAVED_MAGIC;
    for (uint8_t i = 0; i < SAVED_BYTES; i++)
      saved.bytes[i] = this->tx_[i];
    this->pref_.save(&saved);
    // Write now: a reboot inside the usual flash delay would restore an
    // older request and resync the controller back to it.
    global_preferences->sync();
  }

  void start_write_() {
    this->settings_write_pending_ = true;
    this->settings_ack_pending_ = true;
    this->settings_polls_sent_ = 0;
    this->awaiting_confirmation_ = false;
    this->mismatch_replies_ = 0;
    this->write_attempts_++;
    this->publish_write_attempts_();
  }

  static uint32_t retry_delay_ms_(uint16_t attempts) {
    // Each attempt is a settings commit on the controller, so back off.
    if (attempts <= 1) return 30000;
    if (attempts == 2) return 120000;
    if (attempts == 3) return 600000;
    return 3600000;
  }

  void update_sync_(bool acknowledged) {
    if (acknowledged) {
      this->mismatch_replies_ = 0;
      this->awaiting_confirmation_ = false;
      if (this->settings_ack_pending_ && !this->settings_write_pending_) {
        this->settings_ack_pending_ = false;
        ESP_LOGI(TAG, "Controller acknowledged all requested settings");
      }
      if (this->write_attempts_ != 0 && !this->settings_write_pending_) {
        this->write_attempts_ = 0;
        this->publish_write_attempts_();
      }
      return;
    }
    if (this->settings_write_pending_ || this->reset_phase_ != 0 || !this->settings_known_)
      return;
    if (this->awaiting_confirmation_) {
      if (++this->replies_since_commit_ < CONFIRMATION_REPLIES)
        return;
      this->awaiting_confirmation_ = false;
      this->next_retry_ms_ = millis() + retry_delay_ms_(this->write_attempts_);
      ESP_LOGW(TAG, "Controller did not confirm the settings (attempt %u); retrying in %u s",
               this->write_attempts_, retry_delay_ms_(this->write_attempts_) / 1000);
      return;
    }
    this->settings_ack_pending_ = true;
    if (!this->auto_resync_ || ++this->mismatch_replies_ < STEADY_MISMATCH_REPLIES)
      return;
    if (static_cast<int32_t>(millis() - this->next_retry_ms_) < 0)
      return;
    ESP_LOGW(TAG, "Controller settings differ from the requested ones; resending (attempt %u)",
             this->write_attempts_ + 1);
    this->start_write_();
  }

  void publish_write_attempts_() {
    if (this->write_attempts_sensor_ != nullptr)
      this->write_attempts_sensor_->publish_state(this->write_attempts_);
  }

  void publish_requested_settings_() {
    const float values[SETTING_COUNT] = {
        this->tx_be_(0) / 10.0f,
        this->tx_le_(3) / 10.0f,
        this->tx_le_(5) / 10.0f,
        static_cast<float>(this->tx_[7]),
        static_cast<float>(this->tx_[8]),
        static_cast<float>(this->tx_[9]),
    };
    for (uint8_t i = 0; i < SETTING_COUNT; i++)
      if (this->setting_numbers_[i] != nullptr)
        this->setting_numbers_[i]->publish_state(values[i]);
  }
};

inline void OoycyooLoadSwitch::write_state(bool state) {
  this->parent_->request_load(state);
  this->publish_state(state);
}

inline void OoycyooResetButton::press_action() { this->parent_->request_reset(); }

inline void OoycyooSettingNumber::control(float value) {
  this->parent_->set_setting_value(this->setting_, value);
  this->publish_state(value);
}

}  // namespace ooycyoo_mppt
}  // namespace esphome