ESPHome external component for Samsung 2x20 serial VFD

Component (components/samsung_vfd), example device config, README and
CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-31 17:29:09 +01:00
commit bd9389e04a
9 files changed
+796

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome import automation
from esphome.components import time as time_, uart
from esphome.const import CONF_ID, CONF_TEXT, CONF_TIME_ID
DEPENDENCIES = ["uart", "time"]
CONF_COLS = "cols"
CONF_ROWS = "rows"
CONF_LINE = "line"
CONF_FORMAT = "format"
CONF_CENTERED = "centered"
CONF_SEND_NEWLINES = "send_newlines"
samsung_vfd_ns = cg.esphome_ns.namespace("samsung_vfd")
SamsungVfd = samsung_vfd_ns.class_("SamsungVfd", cg.Component, uart.UARTDevice)
PrintAction = samsung_vfd_ns.class_("PrintAction", automation.Action)
PrintfAction = samsung_vfd_ns.class_("PrintfAction", automation.Action)
ClearAction = samsung_vfd_ns.class_("ClearAction", automation.Action)
HomeAction = samsung_vfd_ns.class_("HomeAction", automation.Action)
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(SamsungVfd),
cv.Optional(CONF_COLS, default=20): cv.int_range(min=1, max=80),
cv.Optional(CONF_ROWS, default=2): cv.int_range(min=1, max=8),
# Off by default: lines are padded to exactly `cols` characters and the
# display wraps to the next line by itself.
cv.Optional(CONF_SEND_NEWLINES, default=False): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA)
.extend(uart.UART_DEVICE_SCHEMA)
)
FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
"samsung_vfd", baud_rate=9600, require_tx=True
)
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_size(config[CONF_COLS], config[CONF_ROWS]))
cg.add(var.set_send_newlines(config[CONF_SEND_NEWLINES]))
@automation.register_action(
"samsung_vfd.print",
PrintAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(SamsungVfd),
cv.Required(CONF_TEXT): cv.templatable(cv.string),
cv.Optional(CONF_LINE): cv.templatable(cv.int_range(min=0, max=7)),
cv.Optional(CONF_CENTERED): cv.templatable(cv.boolean),
}
),
synchronous=True,
)
async def samsung_vfd_print_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
cg.add(var.set_text(await cg.templatable(config[CONF_TEXT], args, cg.std_string)))
if CONF_LINE in config:
cg.add(var.set_line(await cg.templatable(config[CONF_LINE], args, cg.int_)))
if CONF_CENTERED in config:
cg.add(var.set_centered(await cg.templatable(config[CONF_CENTERED], args, cg.bool_)))
return var
@automation.register_action(
"samsung_vfd.printf",
PrintfAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(SamsungVfd),
cv.Required(CONF_TIME_ID): cv.use_id(time_.RealTimeClock),
cv.Required(CONF_FORMAT): cv.templatable(cv.string),
cv.Optional(CONF_LINE): cv.templatable(cv.int_range(min=0, max=7)),
cv.Optional(CONF_CENTERED): cv.templatable(cv.boolean),
}
),
synchronous=True,
)
async def samsung_vfd_printf_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
cg.add(var.set_time(await cg.get_variable(config[CONF_TIME_ID])))
cg.add(var.set_format(await cg.templatable(config[CONF_FORMAT], args, cg.std_string)))
if CONF_LINE in config:
cg.add(var.set_line(await cg.templatable(config[CONF_LINE], args, cg.int_)))
if CONF_CENTERED in config:
cg.add(var.set_centered(await cg.templatable(config[CONF_CENTERED], args, cg.bool_)))
return var
@automation.register_action(
"samsung_vfd.clear",
ClearAction,
cv.Schema({cv.Required(CONF_ID): cv.use_id(SamsungVfd)}),
synchronous=True,
)
async def samsung_vfd_clear_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@automation.register_action(
"samsung_vfd.home",
HomeAction,
cv.Schema({cv.Required(CONF_ID): cv.use_id(SamsungVfd)}),
synchronous=True,
)
async def samsung_vfd_home_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
+158
View File
@@ -0,0 +1,158 @@
#include "samsung_vfd.h"
#include "esphome/core/log.h"
#include <algorithm>
namespace esphome {
namespace samsung_vfd {
// Unaccented stand-ins for U+00C0..U+00FF (À..ÿ), one per code point.
static const char LATIN1_FOLD[] = "AAAAAAACEEEEIIIIDNOOOOOxOUUUUYPs"
"aaaaaaaceeeeiiiidnooooo/ouuuuypy";
void SamsungVfd::setup() {
this->resize_lines_();
this->init_display_();
}
void SamsungVfd::dump_config() {
ESP_LOGCONFIG(TAG, "Samsung VFD:");
ESP_LOGCONFIG(TAG, " Size: %u cols x %u rows", this->cols_, this->rows_);
ESP_LOGCONFIG(TAG, " Newline between lines: %s", YESNO(this->send_newlines_));
}
void SamsungVfd::resize_lines_() {
this->lines_.assign(this->rows_, std::string(this->cols_, ' '));
}
void SamsungVfd::init_display_() {
// ESC @ (reset), then clear.
this->write_byte(CMD_ESC);
this->write_byte(CMD_INIT);
this->write_byte(CMD_CLEAR);
// Force the next flush to retransmit whatever is in the shadow buffer.
this->last_payload_.clear();
}
void SamsungVfd::write_str_(const std::string &s) {
if (!s.empty())
this->write_array(reinterpret_cast<const uint8_t *>(s.data()), s.size());
}
std::string SamsungVfd::sanitize_(const std::string &text, bool keep_newlines) {
std::string out;
out.reserve(text.size());
for (size_t i = 0; i < text.size(); i++) {
auto c = static_cast<uint8_t>(text[i]);
if (c == '\n') {
out.push_back(keep_newlines ? '\n' : ' ');
} else if (c < 0x20 || c == 0x7F) {
out.push_back(' ');
} else if (c < 0x80) {
out.push_back(static_cast<char>(c));
} else if (c >= 0xC0) {
// Start of a multi-byte UTF-8 sequence: decode 2-byte Latin-1 letters,
// replace everything else with a single '?'.
size_t len = c >= 0xF0 ? 4 : c >= 0xE0 ? 3 : 2;
char repl = '?';
if (len == 2 && i + 1 < text.size()) {
uint32_t cp = ((c & 0x1F) << 6) | (static_cast<uint8_t>(text[i + 1]) & 0x3F);
if (cp >= 0xC0 && cp <= 0xFF)
repl = LATIN1_FOLD[cp - 0xC0];
else if (cp == 0xA0)
repl = ' ';
}
out.push_back(repl);
i += len - 1;
}
// Stray continuation bytes (0x80..0xBF) are dropped.
}
return out;
}
std::string SamsungVfd::fit_(const std::string &text, bool centered) const {
std::string line = text.substr(0, this->cols_);
if (centered && line.size() < this->cols_)
line.insert(0, (this->cols_ - line.size()) / 2, ' ');
line.resize(this->cols_, ' ');
return line;
}
void SamsungVfd::print(const std::string &raw, bool centered) {
this->resize_lines_();
const std::string text = sanitize_(raw, true);
// Lay the text out over the lines: split on explicit newlines, then wrap each
// part into line-width chunks, breaking at the last space where possible.
uint8_t row = 0;
size_t i = 0;
while (row < this->rows_) {
size_t nl = text.find('\n', i);
size_t end = (nl == std::string::npos) ? text.size() : nl;
// A part always occupies at least one line, so "A\nB" puts B on line 2.
do {
size_t n = std::min<size_t>(this->cols_, end - i);
size_t next = i + n;
if (next < end && text[next] != ' ') {
size_t sp = text.rfind(' ', next);
if (sp != std::string::npos && sp > i) {
n = sp - i;
next = sp;
}
}
this->lines_[row++] = this->fit_(text.substr(i, n), centered);
i = next;
while (i < end && text[i] == ' ')
i++; // a wrapped line never starts with a space
} while (i < end && row < this->rows_);
if (nl == std::string::npos)
break;
i = nl + 1;
}
this->flush_();
}
void SamsungVfd::print_line(uint8_t line, const std::string &text, bool centered) {
if (line >= this->rows_) {
ESP_LOGW(TAG, "print_line: line %u out of range (has %u lines)", line, this->rows_);
return;
}
if (this->lines_.size() < this->rows_)
this->resize_lines_();
this->lines_[line] = this->fit_(sanitize_(text, false), centered);
this->flush_();
}
void SamsungVfd::clear() {
this->resize_lines_();
this->last_payload_.clear();
this->write_byte(CMD_CLEAR);
}
void SamsungVfd::home() { this->write_byte(CMD_HOME); }
void SamsungVfd::flush_() {
std::string payload;
for (uint8_t r = 0; r < this->rows_; r++) {
if (r != 0 && this->send_newlines_)
payload += "\r\n";
payload += this->lines_[r];
}
if (payload == this->last_payload_)
return; // nothing changed -> no re-send
// No clear: every line is padded to the full width, so going home and
// overwriting replaces all characters without the blank-screen flicker.
this->write_byte(CMD_HOME);
this->write_str_(payload);
this->last_payload_ = payload;
}
} // namespace samsung_vfd
} // namespace esphome
+127
View File
@@ -0,0 +1,127 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/core/automation.h"
#include "esphome/core/helpers.h"
#include "esphome/components/time/real_time_clock.h"
#include "esphome/components/uart/uart.h"
#include <string>
#include <vector>
namespace esphome {
namespace samsung_vfd {
static const char *const TAG = "samsung_vfd";
// Control bytes, taken from older Arduino sketches that drove this display.
// The one known to work only ever used ESC @ and 0x0C; 0x0B ("home") came
// from an earlier test sketch. That sketch also sent 0xB0, which is left out:
// it is most likely a printable glyph, not a command.
static const uint8_t CMD_ESC = 0x1B;
static const uint8_t CMD_INIT = 0x40; // ESC @ -> reset / init display
static const uint8_t CMD_CLEAR = 0x0C; // FF -> clear screen (cursor to home)
static const uint8_t CMD_HOME = 0x0B; // VT -> cursor home
class SamsungVfd : public Component, public uart::UARTDevice {
public:
void setup() override;
void dump_config() override;
// Before the default (DATA) priority, so restored entity values can be shown.
float get_setup_priority() const override { return setup_priority::HARDWARE; }
void set_size(uint8_t cols, uint8_t rows) {
this->cols_ = cols == 0 ? 20 : cols;
this->rows_ = rows == 0 ? 2 : rows;
}
void set_send_newlines(bool v) { send_newlines_ = v; }
uint8_t cols() const { return cols_; }
uint8_t rows() const { return rows_; }
/// Replace the whole screen with `text`, wrapping it over the available lines.
void print(const std::string &text, bool centered = false);
/// Replace a single line (0-based). Text is truncated/padded to the column count.
void print_line(uint8_t line, const std::string &text, bool centered = false);
void clear();
void home();
std::string line_text(uint8_t line) const {
return line < lines_.size() ? lines_[line] : std::string();
}
protected:
void init_display_();
void resize_lines_();
/// Fit `text` (already single-byte) into one line: truncate or pad, optionally centred.
std::string fit_(const std::string &text, bool centered) const;
/// Rebuild the wire payload from the shadow buffer and send it only if it changed.
void flush_();
void write_str_(const std::string &s);
/// Map UTF-8 to one byte per character: accented Latin-1 letters lose their
/// accent, control characters become spaces, anything else becomes '?'.
static std::string sanitize_(const std::string &text, bool keep_newlines);
uint8_t cols_{20};
uint8_t rows_{2};
bool send_newlines_{false};
std::vector<std::string> lines_;
std::string last_payload_;
};
// ---------- actions ----------
template<typename... Ts> class PrintAction : public Action<Ts...>, public Parented<SamsungVfd> {
public:
TEMPLATABLE_VALUE(std::string, text)
TEMPLATABLE_VALUE(int, line)
TEMPLATABLE_VALUE(bool, centered)
void play(const Ts &...x) override {
std::string text = this->text_.value(x...);
bool centered = this->centered_.has_value() && this->centered_.value(x...);
if (this->line_.has_value())
this->parent_->print_line(static_cast<uint8_t>(this->line_.value(x...)), text, centered);
else
this->parent_->print(text, centered);
}
};
/// Like print, but formats the current time with strftime (e.g. "%H:%M:%S").
template<typename... Ts> class PrintfAction : public Action<Ts...>, public Parented<SamsungVfd> {
public:
TEMPLATABLE_VALUE(std::string, format)
TEMPLATABLE_VALUE(int, line)
TEMPLATABLE_VALUE(bool, centered)
void set_time(time::RealTimeClock *rtc) { this->rtc_ = rtc; }
void play(const Ts &...x) override {
auto now = this->rtc_->now();
if (!now.is_valid())
return; // clock not synchronised yet, leave the screen as it is
std::string text = now.strftime(this->format_.value(x...));
bool centered = this->centered_.has_value() && this->centered_.value(x...);
if (this->line_.has_value())
this->parent_->print_line(static_cast<uint8_t>(this->line_.value(x...)), text, centered);
else
this->parent_->print(text, centered);
}
protected:
time::RealTimeClock *rtc_{nullptr};
};
template<typename... Ts> class ClearAction : public Action<Ts...>, public Parented<SamsungVfd> {
public:
void play(const Ts &...x) override { this->parent_->clear(); }
};
template<typename... Ts> class HomeAction : public Action<Ts...>, public Parented<SamsungVfd> {
public:
void play(const Ts &...x) override { this->parent_->home(); }
};
} // namespace samsung_vfd
} // namespace esphome