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:
commit
bd9389e04a
9 files changed
+796
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
# ESPHome build output
|
||||||
|
.esphome/
|
||||||
|
**/.esphome/
|
||||||
|
|
||||||
|
# Real credentials never go in the repo; see examples/secrets.yaml.example
|
||||||
|
secrets.yaml
|
||||||
|
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
ESPHome external component for a Samsung 2×20 serial VFD (5 V TTL, 9600 8N1,
|
||||||
|
write-only). It is consumed from git by an ESPHome server through
|
||||||
|
`external_components: source: github://...`, so the layout must stay
|
||||||
|
`components/samsung_vfd/` at the repo root.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `components/samsung_vfd/__init__.py`: config schema, codegen and the action
|
||||||
|
registrations (`print`, `printf`, `clear`, `home`).
|
||||||
|
- `components/samsung_vfd/samsung_vfd.h`: the class, the control-byte constants
|
||||||
|
and the templated action classes.
|
||||||
|
- `components/samsung_vfd/samsung_vfd.cpp`: the shadow buffer, word wrap,
|
||||||
|
UTF-8 → single-byte sanitising and `flush_()`.
|
||||||
|
- `examples/samsung-vfd.yaml`: the full reference device config. It pulls the
|
||||||
|
component from GitHub; for local work, switch to the commented
|
||||||
|
`type: local, path: ../components` source.
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
- `lines_` is the screen as it should be, each line padded to exactly `cols_`.
|
||||||
|
Every change goes through `flush_()`. It rebuilds the payload, returns early
|
||||||
|
if the payload equals `last_payload_`, and otherwise sends `0B` (home) plus
|
||||||
|
the payload. Don't clear before redrawing: that causes flicker.
|
||||||
|
- Anything that changes the display out of band (clear, init) must reset
|
||||||
|
`last_payload_` so the next flush retransmits.
|
||||||
|
- Text sent to the display is one byte per character; always pass it through
|
||||||
|
`sanitize_()`.
|
||||||
|
- Keep the protocol constants in `samsung_vfd.h`. Only `ESC @`, `0C` and `0B`
|
||||||
|
are known to work.
|
||||||
|
|
||||||
|
## Checking changes
|
||||||
|
|
||||||
|
Hardware isn't available to Claude, so validate with ESPHome itself:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install esphome
|
||||||
|
cd examples
|
||||||
|
# switch external_components to the local source first
|
||||||
|
esphome config samsung-vfd.yaml # schema check
|
||||||
|
esphome compile samsung-vfd.yaml # full C++ build
|
||||||
|
```
|
||||||
|
|
||||||
|
`secrets.yaml` is gitignored. Copy `examples/secrets.yaml.example` to
|
||||||
|
`examples/secrets.yaml` to compile.
|
||||||
|
|
||||||
|
Follow ESPHome C++ style: `this->` on members, a trailing `_` on protected
|
||||||
|
members, and the `esphome::samsung_vfd` namespace.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Samsung serial VFD — ESPHome component
|
||||||
|
|
||||||
|
An ESPHome external component for the Samsung 2-line × 20-character serial VFD
|
||||||
|
(vacuum fluorescent display) that takes 5 V TTL serial at 9600 baud, 8N1.
|
||||||
|
|
||||||
|
It keeps a copy of the screen in RAM and only sends data when something
|
||||||
|
changes. Updates overwrite the screen in place instead of clearing it first,
|
||||||
|
so the clock doesn't flicker.
|
||||||
|
|
||||||
|
```
|
||||||
|
components/samsung_vfd/ the component (Python schema + C++)
|
||||||
|
examples/samsung-vfd.yaml full example: clock + two Home Assistant text entities
|
||||||
|
examples/secrets.yaml.example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using it from your ESPHome server
|
||||||
|
|
||||||
|
Add this to your device YAML. ESPHome fetches the component from git when it
|
||||||
|
compiles.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
external_components:
|
||||||
|
- source: github://REPO_OWNER/REPO_NAME@main
|
||||||
|
components: [samsung_vfd]
|
||||||
|
refresh: 1d
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: uart_vfd
|
||||||
|
tx_pin: GPIO17
|
||||||
|
baud_rate: 9600
|
||||||
|
|
||||||
|
time: # needed by the component (used by samsung_vfd.printf)
|
||||||
|
- platform: sntp
|
||||||
|
id: sntp_time
|
||||||
|
|
||||||
|
samsung_vfd:
|
||||||
|
id: vfd
|
||||||
|
uart_id: uart_vfd
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete device, copy [examples/samsung-vfd.yaml](examples/samsung-vfd.yaml)
|
||||||
|
into your ESPHome config folder. Add the keys from
|
||||||
|
[examples/secrets.yaml.example](examples/secrets.yaml.example) to your
|
||||||
|
`secrets.yaml`.
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
The component only sends to the display. The ESP32's 3.3 V output is enough for
|
||||||
|
most 5 V TTL inputs. ESP32 GPIOs are **not** 5 V tolerant, so never connect the
|
||||||
|
display's TX back to the ESP32 without a level shifter or divider.
|
||||||
|
|
||||||
|
| Display | ESP32 |
|
||||||
|
|---------|-------|
|
||||||
|
| RX | GPIO17 (UART TX) |
|
||||||
|
| GND | GND |
|
||||||
|
| 5 V | external 5 V supply |
|
||||||
|
|
||||||
|
The display draws more current than a dev-board regulator can comfortably
|
||||||
|
supply. Power it from its own supply and connect the grounds together.
|
||||||
|
|
||||||
|
To use GPIO1 (TX0, shared with USB logging), set `tx_pin: GPIO1` and
|
||||||
|
`logger: baud_rate: 0` so log output doesn't end up on the display. If the
|
||||||
|
display stays blank at 3.3 V levels, add a 74HCT125 or similar level shifter.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
samsung_vfd:
|
||||||
|
id: vfd
|
||||||
|
uart_id: uart_vfd
|
||||||
|
cols: 20 # default 20 (1–80)
|
||||||
|
rows: 2 # default 2 (1–8)
|
||||||
|
send_newlines: false # default false; send CR LF between lines
|
||||||
|
```
|
||||||
|
|
||||||
|
The UART must be 9600 baud with a `tx_pin`. The component requires a `time:`
|
||||||
|
component in the config.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
| Action | Options |
|
||||||
|
|--------|---------|
|
||||||
|
| `samsung_vfd.print` | `text` (templatable). Optional `line:` (0-based) replaces one line. Without `line:`, the text is word-wrapped over the whole screen; `\n` forces a new line. Optional `centered: true`. |
|
||||||
|
| `samsung_vfd.printf` | Shows the current time formatted with strftime, e.g. `format: "%H:%M:%S"`. Requires `time_id:`. Takes the same `line:` and `centered:` options. Does nothing until the clock has synced. |
|
||||||
|
| `samsung_vfd.clear` | Clears the screen. |
|
||||||
|
| `samsung_vfd.home` | Moves the cursor home. |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
line: 0
|
||||||
|
text: !lambda 'return "Temp " + to_string((int) id(temp).state) + " C";'
|
||||||
|
- samsung_vfd.printf:
|
||||||
|
id: vfd
|
||||||
|
time_id: sntp_time
|
||||||
|
line: 1
|
||||||
|
centered: true
|
||||||
|
format: "%a %d %b %H:%M"
|
||||||
|
```
|
||||||
|
|
||||||
|
From a lambda you can call `id(vfd).print(...)`, `print_line(line, text, centered)`,
|
||||||
|
`clear()`, `home()` and `line_text(line)`.
|
||||||
|
|
||||||
|
Text is converted to one byte per character. Accented Latin-1 letters lose
|
||||||
|
their accent (é → e), other non-ASCII characters become `?`, and control
|
||||||
|
characters become spaces.
|
||||||
|
|
||||||
|
## What the example config does
|
||||||
|
|
||||||
|
- On boot: clears the screen, shows `Hello World!` for 3 s, then restores line 1.
|
||||||
|
- Bottom line: a live clock, `%a %d %b %H:%M:%S`, updated every second (SNTP,
|
||||||
|
Europe/Amsterdam).
|
||||||
|
- Two Home Assistant `text` entities:
|
||||||
|
- **VFD line 1** (max 20 chars) is shown on the top line and kept across
|
||||||
|
reboots.
|
||||||
|
- **VFD status** (max 40 chars) shows a full-screen, word-wrapped, centred
|
||||||
|
banner for 10 s. The clock pauses meanwhile, then line 1 and the clock come
|
||||||
|
back.
|
||||||
|
- Wi-Fi with a captive-portal fallback AP, OTA updates and an encrypted native
|
||||||
|
API.
|
||||||
|
|
||||||
|
## Protocol
|
||||||
|
|
||||||
|
| Bytes | Effect |
|
||||||
|
|-------|--------|
|
||||||
|
| `1B 40` (`ESC @`) | init / reset |
|
||||||
|
| `0C` | clear screen |
|
||||||
|
| `0B` | cursor home |
|
||||||
|
| text | printed at the cursor; wraps to the next line by itself |
|
||||||
|
|
||||||
|
Each update sends `0B` (home) followed by both lines, each padded to exactly
|
||||||
|
`cols` characters.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Line 2 lands on the wrong row, or text runs together:** try
|
||||||
|
`send_newlines: true`.
|
||||||
|
- **The screen scrolls up a line after each update:** the display scrolls as
|
||||||
|
soon as the last cell is written. Use `cols: 19` together with
|
||||||
|
`send_newlines: true`.
|
||||||
|
- **A stray character appears top-left:** the display doesn't treat `0B` as
|
||||||
|
home. Change `CMD_HOME` in
|
||||||
|
[samsung_vfd.h](components/samsung_vfd/samsung_vfd.h).
|
||||||
|
- **Garbled characters:** check that `uart:` is at 9600 baud and that the
|
||||||
|
display takes 5 V TTL levels, not RS-232 levels. A real RS-232 input needs a
|
||||||
|
MAX232 inverter.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# Example ESPHome config for a Samsung 2x20 serial VFD display.
|
||||||
|
#
|
||||||
|
# Copy this file into your ESPHome config folder (and add the keys below to
|
||||||
|
# your secrets.yaml), or use it as a reference for your own device config.
|
||||||
|
#
|
||||||
|
# Wiring (display is 5V TTL serial, 9600 8N1):
|
||||||
|
# display RX <- ESP32 GPIO17 (TX)
|
||||||
|
# display GND <-> ESP32 GND
|
||||||
|
# display 5V <- 5V supply (the display needs more current than the ESP32 regulator can give)
|
||||||
|
#
|
||||||
|
# To drive the display from GPIO1 (TX0, the USB serial pin) instead, set
|
||||||
|
# `tx_pin: GPIO1` below and `baud_rate: 0` under `logger:` so log output does
|
||||||
|
# not end up on the display.
|
||||||
|
|
||||||
|
esphome:
|
||||||
|
name: samsung-vfd
|
||||||
|
friendly_name: Samsung VFD
|
||||||
|
on_boot:
|
||||||
|
priority: -100
|
||||||
|
then:
|
||||||
|
- samsung_vfd.clear:
|
||||||
|
id: vfd
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
text: "Hello World!"
|
||||||
|
centered: true
|
||||||
|
- delay: 3s
|
||||||
|
# Bring back the last text Home Assistant set for the top line.
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
line: 0
|
||||||
|
text: !lambda "return id(vfd_line1).state;"
|
||||||
|
|
||||||
|
external_components:
|
||||||
|
- source: github://REPO_OWNER/REPO_NAME@main
|
||||||
|
components: [samsung_vfd]
|
||||||
|
refresh: 1d
|
||||||
|
# While developing the component, use the checkout instead:
|
||||||
|
# - source:
|
||||||
|
# type: local
|
||||||
|
# path: ../components
|
||||||
|
|
||||||
|
esp32:
|
||||||
|
board: esp32dev
|
||||||
|
framework:
|
||||||
|
type: arduino
|
||||||
|
|
||||||
|
logger:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
api:
|
||||||
|
encryption:
|
||||||
|
key: !secret api_key
|
||||||
|
|
||||||
|
ota:
|
||||||
|
- platform: esphome
|
||||||
|
password: !secret ota_password
|
||||||
|
|
||||||
|
wifi:
|
||||||
|
ssid: !secret wifi_ssid
|
||||||
|
password: !secret wifi_password
|
||||||
|
# Uncomment to pin a fixed address.
|
||||||
|
# manual_ip:
|
||||||
|
# static_ip: 10.1.1.60
|
||||||
|
# gateway: 10.1.1.1
|
||||||
|
# subnet: 255.255.255.0
|
||||||
|
ap:
|
||||||
|
ssid: "Samsung VFD Fallback"
|
||||||
|
password: !secret fallback_password
|
||||||
|
|
||||||
|
captive_portal:
|
||||||
|
|
||||||
|
# SNTP so samsung_vfd.printf can show the local time.
|
||||||
|
time:
|
||||||
|
- platform: sntp
|
||||||
|
id: sntp_time
|
||||||
|
timezone: Europe/Amsterdam
|
||||||
|
servers:
|
||||||
|
- 0.pool.ntp.org
|
||||||
|
- 1.pool.ntp.org
|
||||||
|
|
||||||
|
uart:
|
||||||
|
id: uart_vfd
|
||||||
|
tx_pin: GPIO17
|
||||||
|
baud_rate: 9600
|
||||||
|
# The display is output-only for us; no rx_pin needed.
|
||||||
|
|
||||||
|
samsung_vfd:
|
||||||
|
id: vfd
|
||||||
|
uart_id: uart_vfd
|
||||||
|
cols: 20
|
||||||
|
rows: 2
|
||||||
|
# Lines are padded to 20 chars and the display wraps by itself. Set to true
|
||||||
|
# only if line 2 ends up on the wrong row (sends CR LF between the lines).
|
||||||
|
send_newlines: false
|
||||||
|
|
||||||
|
# Bottom line shows the SNTP clock every second, except while a status banner is up.
|
||||||
|
interval:
|
||||||
|
- interval: 1s
|
||||||
|
then:
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
not:
|
||||||
|
script.is_running: show_status
|
||||||
|
then:
|
||||||
|
- samsung_vfd.printf:
|
||||||
|
id: vfd
|
||||||
|
time_id: sntp_time
|
||||||
|
line: 1
|
||||||
|
format: "%a %d %b %H:%M:%S"
|
||||||
|
|
||||||
|
# Full-screen centred banner for a while, then back to line 1 + clock.
|
||||||
|
script:
|
||||||
|
- id: show_status
|
||||||
|
mode: restart
|
||||||
|
parameters:
|
||||||
|
msg: string
|
||||||
|
then:
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
text: !lambda "return msg;"
|
||||||
|
centered: true
|
||||||
|
- delay: 10s
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
line: 0
|
||||||
|
text: !lambda "return id(vfd_line1).state;"
|
||||||
|
- samsung_vfd.printf:
|
||||||
|
id: vfd
|
||||||
|
time_id: sntp_time
|
||||||
|
line: 1
|
||||||
|
format: "%a %d %b %H:%M:%S"
|
||||||
|
|
||||||
|
# Two Home Assistant text entities you can type into from HA.
|
||||||
|
text:
|
||||||
|
- platform: template
|
||||||
|
id: vfd_line1
|
||||||
|
name: "VFD line 1"
|
||||||
|
icon: mdi:text-box-outline
|
||||||
|
mode: text
|
||||||
|
max_length: 20
|
||||||
|
optimistic: true
|
||||||
|
restore_value: true
|
||||||
|
initial_value: ""
|
||||||
|
on_value:
|
||||||
|
then:
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
not:
|
||||||
|
script.is_running: show_status
|
||||||
|
then:
|
||||||
|
- samsung_vfd.print:
|
||||||
|
id: vfd
|
||||||
|
line: 0
|
||||||
|
text: !lambda "return x;"
|
||||||
|
|
||||||
|
- platform: template
|
||||||
|
id: vfd_status
|
||||||
|
name: "VFD status"
|
||||||
|
icon: mdi:information-outline
|
||||||
|
mode: text
|
||||||
|
max_length: 40
|
||||||
|
optimistic: true
|
||||||
|
initial_value: ""
|
||||||
|
on_value:
|
||||||
|
then:
|
||||||
|
- if:
|
||||||
|
condition:
|
||||||
|
lambda: "return !x.empty();"
|
||||||
|
then:
|
||||||
|
- script.execute:
|
||||||
|
id: show_status
|
||||||
|
msg: !lambda "return x;"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Copy to secrets.yaml (next to your device yaml) and fill in.
|
||||||
|
wifi_ssid: "your-ssid"
|
||||||
|
wifi_password: "your-wifi-password"
|
||||||
|
ota_password: "choose-an-ota-password"
|
||||||
|
fallback_password: "choose-a-fallback-ap-password"
|
||||||
|
# Generate with: python -c "import secrets,base64;print(base64.b64encode(secrets.token_bytes(32)).decode())"
|
||||||
|
api_key: "base64-32-byte-key"
|
||||||
Reference in new issue
Block a user