diff --git a/esphome/components/aip33628/__init__.py b/esphome/components/aip33628/__init__.py new file mode 100644 index 0000000..d28433a --- /dev/null +++ b/esphome/components/aip33628/__init__.py @@ -0,0 +1,82 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import pins +from esphome.components import time as time_ +from esphome.const import CONF_ID, CONF_NUMBER, PLATFORM_ESP32 + +CODEOWNERS = ["@misterblack1"] +ESP_PLATFORMS = [PLATFORM_ESP32] + +aip33628_ns = cg.esphome_ns.namespace("aip33628") +Aip33628Panel = aip33628_ns.class_("Aip33628Panel", cg.Component) + +CONF_CLK_PIN = "clk_pin" +CONF_DATA_PIN = "data_pin" +CONF_CLK2_PIN = "clk2_pin" +CONF_DATA2_PIN = "data2_pin" +CONF_TIME_ID = "time_id" +CONF_MAX_CURRENT = "max_current" +CONF_TWELVE_HOUR = "twelve_hour" +CONF_BLINK_COLON = "blink_colon" + + +def _low_bank_pin(value): + """Both buses are clocked from one write to the low GPIO output register. + + That register only reaches GPIO0 to GPIO31. A pin above that would need a + second register and a second store per edge, which is most of the reason + the frame send is fast enough to subdivide a COM slot at all. + """ + pin = value[CONF_NUMBER] + if pin >= 32: + raise cv.Invalid( + f"GPIO{pin} is above GPIO31, and the display scan drives all four " + "lines from the low output register. Pick a pin below GPIO32." + ) + return value + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(Aip33628Panel), + cv.Required(CONF_CLK_PIN): cv.All( + pins.internal_gpio_output_pin_schema, _low_bank_pin + ), + cv.Required(CONF_DATA_PIN): cv.All( + pins.internal_gpio_output_pin_schema, _low_bank_pin + ), + cv.Required(CONF_CLK2_PIN): cv.All( + pins.internal_gpio_output_pin_schema, _low_bank_pin + ), + cv.Required(CONF_DATA2_PIN): cv.All( + pins.internal_gpio_output_pin_schema, _low_bank_pin + ), + cv.Optional(CONF_TIME_ID): cv.use_id(time_.RealTimeClock), + # A fixed ceiling on IS[3:0]. It must not depend on what is on screen, + # or the whole panel changes brightness whenever the colon blinks. + cv.Optional(CONF_MAX_CURRENT, default=15): cv.int_range(min=0, max=15), + # Power on defaults only. Home Assistant owns both at run time + # through the template switches in clock.yaml. + cv.Optional(CONF_TWELVE_HOUR, default=True): cv.boolean, + cv.Optional(CONF_BLINK_COLON, default=True): cv.boolean, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + clk = await cg.gpio_pin_expression(config[CONF_CLK_PIN]) + data = await cg.gpio_pin_expression(config[CONF_DATA_PIN]) + clk2 = await cg.gpio_pin_expression(config[CONF_CLK2_PIN]) + data2 = await cg.gpio_pin_expression(config[CONF_DATA2_PIN]) + cg.add(var.set_pins(clk, data, clk2, data2)) + + if CONF_TIME_ID in config: + rtc = await cg.get_variable(config[CONF_TIME_ID]) + cg.add(var.set_time(rtc)) + + cg.add(var.set_max_current(config[CONF_MAX_CURRENT])) + cg.add(var.set_twelve_hour(config[CONF_TWELVE_HOUR])) + cg.add(var.set_blink_colon(config[CONF_BLINK_COLON])) diff --git a/esphome/components/aip33628/aip33628.cpp b/esphome/components/aip33628/aip33628.cpp new file mode 100644 index 0000000..5e46327 --- /dev/null +++ b/esphome/components/aip33628/aip33628.cpp @@ -0,0 +1,917 @@ +#include "aip33628.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include +#include +#include + +namespace esphome { +namespace aip33628 { + +static const char *const TAG = "aip33628"; + +// Each digit is one driver plus one pair of COM pairs, ten LED positions. +// Driver 1 carries the hours, driver 2 the minutes. +struct Block { + uint8_t drv; + uint8_t com_lo; + uint8_t com_hi; +}; +static const Block BLOCKS[4] = { + {0, 0x30, 0xC0}, // hour tens, annunciator AM + {0, 0x03, 0x0C}, // hour ones, annunciator colon + {1, 0x30, 0xC0}, // minute tens, annunciator date dash + {1, 0x03, 0x0C}, // minute ones, annunciator degree mark +}; + +// Segment position within a block. Side 0 is COM low, side 1 is COM high. +struct SegPos { + uint8_t side; + uint8_t led; +}; +static const SegPos SEGMAP[9] = { + {1, 5}, // A top + {1, 4}, // B top right + {1, 3}, // C bottom right + {1, 2}, // D bottom + {0, 5}, // E bottom left + {0, 4}, // F top left + {0, 3}, // G middle + {1, 1}, // annunciator + {0, 1}, // second annunciator, block 1 colon only +}; +enum { SEG_ANNUN = 7, SEG_ANNUN2 = 8 }; + +// Physical position of every LED, extracted in docs/led-layout.md. The ids +// are the ones on the board layout map. Order matches SEGMAP. +const PosGeom GEOM[4][9] = { + { // block 0, hour tens + { 1, 19, 8}, // A top + { 7, 41, 66}, // B top right + { 6, 40, 194}, // C bottom right + { 5, 19, 255}, // D bottom + { 4, 0, 188}, // E bottom left + { 2, 0, 66}, // F top left + { 3, 20, 130}, // G middle + { 8, 20, 65}, // AM mark + {0, 0, 0}, // unwired + }, + { // block 1, hour ones + { 9, 93, 0}, // A top + {15, 112, 61}, // B top right + {14, 111, 198}, // C bottom right + {13, 90, 246}, // D bottom + {12, 72, 184}, // E bottom left + {10, 73, 65}, // F top left + {11, 92, 124}, // G middle + {16, 128, 68}, // colon upper + {18, 129, 189}, // colon lower + }, + { // block 2, minute tens + {19, 163, 5}, // A top + {25, 185, 67}, // B top right + {24, 185, 195}, // C bottom right + {23, 165, 247}, // D bottom + {22, 145, 188}, // E bottom left + {20, 143, 70}, // F top left + {21, 164, 122}, // G middle + {17, 128, 128}, // date dash + {0, 0, 0}, // unwired + }, + { // block 3, minute ones + {27, 233, 11}, // A top + {33, 255, 60}, // B top right + {32, 255, 190}, // C bottom right + {31, 234, 249}, // D bottom + {30, 216, 194}, // E bottom left + {28, 216, 68}, // F top left + {29, 235, 128}, // G middle + {26, 204, 18}, // degree mark + {0, 0, 0}, // unwired + }, +}; + +// Seven segment font, bit 0 = A through bit 6 = G. The letters are the subset +// that reads unambiguously on seven segments, so a caller can put a unit or a +// short label in the rightmost position. +static uint8_t glyph(char c) { + switch (c) { + case 'A': return 0b1110111; + case 'b': return 0b1111100; + case 'C': return 0b0111001; + case 'c': return 0b1011000; + case 'd': return 0b1011110; + case 'E': return 0b1111001; + case 'F': return 0b1110001; + case 'H': return 0b1110110; + case 'h': return 0b1110100; + case 'L': return 0b0111000; + case 'n': return 0b1010100; + case 'o': return 0b1011100; + case 'P': return 0b1110011; + case 'r': return 0b1010000; + case 't': return 0b1111000; + case 'U': return 0b0111110; + case 'u': return 0b0011100; + case 'y': return 0b1101110; + case '0': return 0b0111111; + case '1': return 0b0000110; + case '2': return 0b1011011; + case '3': return 0b1001111; + case '4': return 0b1100110; + case '5': return 0b1101101; + case '6': return 0b1111101; + case '7': return 0b0000111; + case '8': return 0b1111111; + case '9': return 0b1101111; + case '-': return 0b1000000; + default: return 0; + } +} + +// Home Assistant sends gamma encoded values and the panel is linear in +// current and in duty, so both have to be linearized here. 2.8 is the same +// exponent ESPHome uses by default, which is why gamma_correct is set to 1.0 +// on the light itself. Applying it in both places would square it. +static const float PANEL_GAMMA = 2.8f; + +// Slider position to a current step. A plain gamma curve assumes the output +// can reach zero. This panel bottoms out at IS_MA[0], so a third of the +// slider ends up clamped against that floor with nothing to show for it. +// Interpolating perceived output between the floor and full instead puts all +// sixteen steps across the whole slider. +static uint8_t current_for(float brightness) { + const float p_min = powf((float) IS_MA[0] / (float) IS_MA[15], 1.0f / PANEL_GAMMA); + float p = p_min + clamp(brightness, 0.0f, 1.0f) * (1.0f - p_min); + float want = powf(p, PANEL_GAMMA) * (float) IS_MA[15]; + uint8_t best = 0; + for (uint8_t i = 1; i < 16; i++) { + if (fabsf((float) IS_MA[i] - want) < fabsf((float) IS_MA[best] - want)) + best = i; + } + return best; +} + +// Half brightness keeps the all-white hardware check brief and predictable. +static const float LAMP_BRIGHTNESS = 0.5f; + +// Color component to a duty level. Duty is linear light, so the component +// has to be linearized before it is rounded, or every pastel rounds up to a +// saturated color. Pink is the clearest case, and rounds all the way to +// white. +static uint8_t duty_level(float c) { + return (uint8_t) lroundf(powf(clamp(c, 0.0f, 1.0f), PANEL_GAMMA) * (COLOR_LEVELS - 1)); +} + +static int com_index(uint8_t cs) { + for (int i = 0; i < 4; i++) { + if (COM_SEQ[i] == cs) return i; + } + return 0; +} + +void Aip33628Panel::setup() { + for (auto *p : {clk_, data_, clk2_, data2_}) { + p->setup(); + p->digital_write(false); + } + clk_mask_ = 1u << clk_->get_pin(); + data_mask_ = 1u << data_->get_pin(); + clk2_mask_ = 1u << clk2_->get_pin(); + data2_mask_ = 1u << data2_->get_pin(); + + render_(); + + // A general purpose timer, not esp_timer. The esp_timer task dispatch path + // runs at task priority on core 0 alongside the WiFi task, which preempts + // it and stretches whichever COM slot happens to be lit. A 40us sub-frame + // does not ride that out, so this runs from the interrupt instead. + gptimer_config_t tcfg = {}; + tcfg.clk_src = GPTIMER_CLK_SRC_DEFAULT; + tcfg.direction = GPTIMER_COUNT_UP; + tcfg.resolution_hz = 1000000; // one tick per microsecond + + gptimer_alarm_config_t acfg = {}; + acfg.alarm_count = UNIT_US; // fixed, one tick per binary weight unit + acfg.reload_count = 0; + acfg.flags.auto_reload_on_alarm = true; + + gptimer_event_callbacks_t cbs = {}; + cbs.on_alarm = &Aip33628Panel::scan_tick_; + + gptimer_handle_t timer = nullptr; + if (gptimer_new_timer(&tcfg, &timer) != ESP_OK || + gptimer_register_event_callbacks(timer, &cbs, this) != ESP_OK || + gptimer_set_alarm_action(timer, &acfg) != ESP_OK || + gptimer_enable(timer) != ESP_OK || gptimer_start(timer) != ESP_OK) { + ESP_LOGE(TAG, "could not start the scan timer"); + this->mark_failed(); + } +} + +void Aip33628Panel::dump_config() { + ESP_LOGCONFIG(TAG, "AiP33628 panel:"); + LOG_PIN(" CLK: ", clk_); + LOG_PIN(" DATA: ", data_); + LOG_PIN(" CLK_1: ", clk2_); + LOG_PIN(" DATA_1: ", data2_); + ESP_LOGCONFIG(TAG, " Max current: IS 0x%X, %u.%umA per lit sink", max_current_, + IS_MA[max_current_] / 10, IS_MA[max_current_] % 10); + ESP_LOGCONFIG(TAG, " Hour format: %s", twelve_hour_ ? "12 hour" : "24 hour"); + ESP_LOGCONFIG(TAG, " Colon: %s", blink_colon_ ? "blinking" : "steady"); + ESP_LOGCONFIG(TAG, " Network: %s", online_ ? "up" : "down"); + const char *fx = effect_ == Effect::CYCLE ? "color cycle" + : effect_ == Effect::FLASH ? "flash" : "none"; + const char *sp = spread_ == Spread::DIGIT ? "per digit" + : spread_ == Spread::LED ? "per LED" : "whole panel"; + // INFO rather than LOGCONFIG on purpose. The rest of this block is wiring + // that cannot change, but the effect is live state worth being able to read + // back, and CONFIG level messages need a DEBUG logger to be visible at all. + ESP_LOGI(TAG, " Effect: %s, %s, %.1fs, axis %.0f deg, hue span %.0f deg, flash fade %.2fs", + fx, sp, effect_speed_, effect_angle_, hue_span_, flash_fade_); +} + +// Emit one 30-bit frame to each driver and latch both. Bits are LSB first: +// SS[15:0], CS[7:0], IS[3:0], then two reserved zeros. Data only changes +// while CLK is low. The latch is a DATA rising edge while CLK is held high +// after the last bit, which is the sequence the stock 8051 produces. +// +// CS and IS are common to the two drivers and only SS differs, so one pass +// down the bits clocks both buses. That halves the work outright, and going +// straight to the port registers rather than through ISRInternalGPIOPin took +// the pair from 28.0us to 6.4us. The AiP33628 accepts 30MHz and asks for +// 16ns of CLK high and low, and a store to the GPIO port costs more than +// that on its own, so the loop needs no padding. +void IRAM_ATTR Aip33628Panel::send_pair_(uint16_t ss1, uint16_t ss2, uint8_t cs, uint8_t is) { + const uint32_t wire = (uint32_t) IS_WIRE[is & 0xF] << 24; + uint32_t f1 = (uint32_t) ss1 | ((uint32_t) cs << 16) | wire; + uint32_t f2 = (uint32_t) ss2 | ((uint32_t) cs << 16) | wire; + const uint32_t clks = clk_mask_ | clk2_mask_; + const uint32_t dats = data_mask_ | data2_mask_; + + // Chip differentiation for the initial register reset + #if defined(CONFIG_IDF_TARGET_ESP32C3) + GPIO.out_w1tc.val = clks | dats; + #else + GPIO.out_w1tc = clks | dats; + #endif + + for (int i = 0; i < 30; i++) { + uint32_t set = 0; + if (f1 & 1) + set |= data_mask_; + if (f2 & 1) + set |= data2_mask_; + f1 >>= 1; + f2 >>= 1; + // Data transmission loop + #if defined(CONFIG_IDF_TARGET_ESP32C3) + GPIO.out_w1tc.val = dats & ~set; + GPIO.out_w1ts.val = set; + GPIO.out_w1ts.val = clks; + if (i < 29) + GPIO.out_w1tc.val = clks; + #else + GPIO.out_w1tc = dats & ~set; + GPIO.out_w1ts = set; + GPIO.out_w1ts = clks; + if (i < 29) + GPIO.out_w1tc = clks; + #endif + } + + // Final latch sequence + #if defined(CONFIG_IDF_TARGET_ESP32C3) + GPIO.out_w1tc.val = dats; + GPIO.out_w1ts.val = dats; + GPIO.out_w1tc.val = dats; + GPIO.out_w1tc.val = clks; + #else + GPIO.out_w1tc = dats; + GPIO.out_w1ts = dats; + GPIO.out_w1tc = dats; + GPIO.out_w1tc = clks; + #endif +} + +// Walk the schedule the renderer built. The timer runs at a fixed UNIT_US +// and this counts ticks, rather than reprogramming the alarm per step. +// Reprogramming would be fewer interrupts, but an alarm set shorter than the +// counter has already reached never matches, and a single late interrupt +// would then freeze the panel until reboot. A fixed auto-reload alarm cannot +// do that: a late interrupt costs one wobbly sub-frame and nothing more. +// +// Most ticks do nothing. A saturated color collapses to one step per COM +// pair, so fourteen of every fifteen calls are a decrement and a return. +bool IRAM_ATTR Aip33628Panel::scan_tick_(gptimer_handle_t timer, + const gptimer_alarm_event_data_t *edata, void *arg) { + auto *self = static_cast(arg); + if (self->wait_ > 0) { + self->wait_--; + return false; + } + + const ScanBuf &b = self->buf_[self->front_]; + + // The renderer can flip the buffer between two steps, and the new schedule + // may be shorter than the old one, so the index is clamped rather than + // trusted. Worst case is one odd frame while a slider is moving. + uint8_t i = self->step_; + if (i >= b.n) + i = 0; + const ScanStep &st = b.step[i]; + + self->send_pair_(st.ss[0], st.ss[1], st.cs, b.is); + + self->wait_ = (uint8_t) (st.units - 1); // this tick is the first of the step + uint8_t next = (uint8_t) (i + 1); + self->step_ = next >= b.n ? 0 : next; + return false; // no task woken, so no yield needed +} + +void Aip33628Panel::write_pos_(uint8_t block, uint8_t seg, bool on) { + on_[block][seg] = on; +} + +void Aip33628Panel::write_digit_(uint8_t block, char c) { + uint8_t bits = glyph(c); + for (int s = 0; s < 7; s++) { + on_[block][s] = (bits >> s) & 1; + } +} + +// brightness is linear and carries the transition state, so it falls to zero +// on its own during a fade to off. The color components are the normalized +// ratio and do not scale with it. ESPHome guarantees the largest of the three +// is 1, so at least one channel always survives duty_level and a color can +// never round away to nothing. +void Aip33628Panel::set_light(bool on, float r, float g, float b, float brightness) { + enabled_ = on && brightness > 0.0f; + + // Picking a new color on the master light means the whole panel, so it + // drops the digit and position tiers. Moving only the brightness slider + // leaves them alone, which matters because a transition calls this on + // every step and would otherwise wipe a gradient mid fade. + bool color_moved = fabsf(r - base_rgb_[0]) > 0.002f || fabsf(g - base_rgb_[1]) > 0.002f || + fabsf(b - base_rgb_[2]) > 0.002f; + if (color_moved && effect_ == Effect::NONE) { + for (bool &v : digit_set_) + v = false; + clear_positions_(); + } + + base_rgb_[0] = r; + base_rgb_[1] = g; + base_rgb_[2] = b; + requested_current_ = current_for(brightness); + apply_colors_(); +} + +// Quantize whatever color each block is currently supposed to be. Everything +// that changes a color goes through here, so there is one place that decides +// what a block ends up at and one place that marks the panel dirty. +void Aip33628Panel::apply_colors_() { + // The lamp test outranks every color tier, including a running effect. + if (mode_ == Mode::LAMP) { + for (auto &blk : level_) + for (auto &seg : blk) + for (uint8_t &ch : seg) + ch = COLOR_LEVELS - 1; + dirty_ = true; + return; + } + + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + const float *c = pos_set_[blk][seg] ? pos_rgb_[blk][seg] + : digit_set_[blk] ? digit_rgb_[blk] + : base_rgb_; + level_[blk][seg][CH_RED] = duty_level(c[0] * envelope_); + level_[blk][seg][CH_GREEN] = duty_level(c[1] * envelope_); + level_[blk][seg][CH_BLUE] = duty_level(c[2] * envelope_); + } + } + dirty_ = true; +} + +// A digit of -1 sets all four at once, which is what a whole display effect +// wants. Components are taken as given and not normalized: the master light +// arrives already normalized with its magnitude in the current setting, but a +// caller here is asking for one digit to look a particular way next to the +// others, and scaling that back up would throw away the difference. +void Aip33628Panel::set_digit_color(int digit, float r, float g, float b) { + if (digit < -1 || digit > 3) + return; + for (int blk = 0; blk < 4; blk++) { + if (digit != -1 && digit != blk) + continue; + digit_rgb_[blk][0] = clamp(r, 0.0f, 1.0f); + digit_rgb_[blk][1] = clamp(g, 0.0f, 1.0f); + digit_rgb_[blk][2] = clamp(b, 0.0f, 1.0f); + digit_set_[blk] = true; + // Setting a whole digit drops any per position color inside it. Without + // this a gradient would sit on top and the digit color would do nothing + // visible, which reads as the call being ignored. + for (bool &v : pos_set_[blk]) + v = false; + } + apply_colors_(); +} + +// Hand the whole panel back to the master light, per position overrides +// included. Anything else would leave a gradient stuck on with no obvious way +// to clear it. +void Aip33628Panel::clear_digit_colors() { + for (bool &v : digit_set_) + v = false; + clear_positions_(); + apply_colors_(); +} + +void Aip33628Panel::clear_positions_() { + for (auto &blk : pos_set_) + for (bool &v : blk) + v = false; +} + +// One LED, addressed by the id on the board layout map rather than by block +// and segment, so the numbering here is the same one written on the map. +void Aip33628Panel::set_position_color(int id, float r, float g, float b) { + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + if (GEOM[blk][seg].id != id) + continue; + pos_rgb_[blk][seg][0] = clamp(r, 0.0f, 1.0f); + pos_rgb_[blk][seg][1] = clamp(g, 0.0f, 1.0f); + pos_rgb_[blk][seg][2] = clamp(b, 0.0f, 1.0f); + pos_set_[blk][seg] = true; + apply_colors_(); + return; + } + } +} + +// A linear ramp across the panel between two colors. Angle is in degrees, 0 +// running left to right and 90 top to bottom, so -45 runs from the bottom +// left corner to the top right. +// +// The ramp is normalized against the LEDs themselves rather than the panel +// outline, so the two colors asked for land exactly on the outermost LEDs +// whichever way the ramp points. Normalizing against the corners instead +// leaves both ends short, because no LED sits in a corner. +void Aip33628Panel::set_gradient(float r0, float g0, float b0, float r1, float g1, float b1, + float angle_deg) { + const float a = angle_deg * 3.14159265f / 180.0f; + const float ca = cosf(a), sa = sinf(a); + + float lo = 1e9f, hi = -1e9f; + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + const PosGeom &g = GEOM[blk][seg]; + if (g.id == 0) + continue; + float t = (g.nx / 255.0f) * ca + (g.ny / 255.0f) * sa; + if (t < lo) + lo = t; + if (t > hi) + hi = t; + } + } + float span = hi - lo; + if (span < 1e-6f) + span = 1.0f; + + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + const PosGeom &g = GEOM[blk][seg]; + if (g.id == 0) + continue; // nothing wired here + float t = ((g.nx / 255.0f) * ca + (g.ny / 255.0f) * sa - lo) / span; + t = clamp(t, 0.0f, 1.0f); + pos_rgb_[blk][seg][0] = clamp(r0 + (r1 - r0) * t, 0.0f, 1.0f); + pos_rgb_[blk][seg][1] = clamp(g0 + (g1 - g0) * t, 0.0f, 1.0f); + pos_rgb_[blk][seg][2] = clamp(b0 + (b1 - b0) * t, 0.0f, 1.0f); + pos_set_[blk][seg] = true; + } + } + apply_colors_(); +} + +// A temporary mode is capped rather than trusted. Ten minutes is far longer +// than any of these are useful for, and it means a bad automation cannot park +// the panel on a stale number forever. +static uint32_t mode_lifetime(int ms) { + if (ms < 100) return 100; + if (ms > 600000) return 600000; + return (uint32_t) ms; +} + +void Aip33628Panel::show_seconds(int ms) { + mode_ = Mode::SECONDS; + mode_until_ = millis() + mode_lifetime(ms); + dirty_ = true; +} + +void Aip33628Panel::show_number(int value, const std::string &unit, int ms) { + number_ = value; + // First character only. An empty unit gives the number the whole panel. + unit_ = unit.empty() ? '\0' : unit[0]; + mode_ = Mode::NUMBER; + mode_until_ = millis() + mode_lifetime(ms); + dirty_ = true; +} + +// Every populated position, white, at a fixed brightness, for a few seconds. +// This is a hardware check, so user color and brightness settings do not +// change the result. +void Aip33628Panel::lamp_test(int ms) { + mode_ = Mode::LAMP; + mode_until_ = millis() + mode_lifetime(ms); + lamp_current_ = current_for(LAMP_BRIGHTNESS); + apply_colors_(); +} + +// Right aligned, no colon. A unit takes the rightmost position and leaves +// three for the number, so 78F and -5C both fit. Without one the number gets +// all four. Out of range values are clamped rather than wrapped, because a +// wrapped temperature is a wrong reading and a clamped one is obviously +// pinned against the end. +// +// The widest values reach the hour tens position. With a unit that only +// happens at three digits or a signed two, and never for a temperature in F. +void Aip33628Panel::draw_number_(int value, char unit) { + int pos = 3; + if (unit != '\0' && glyph(unit) != 0) { + write_digit_(3, unit); + pos = 2; + // C and F are temperatures, so light the degree mark ahead of the unit. + if (unit == 'C' || unit == 'c' || unit == 'F') + write_pos_(3, SEG_ANNUN, true); + } + + bool neg = value < 0; + if (neg) value = -value; + + int room = pos + 1 - (neg ? 1 : 0); // positions left for digits + int limit = 1; + for (int i = 0; i < room; i++) limit *= 10; + if (value > limit - 1) value = limit - 1; + + do { + write_digit_(pos--, (char) ('0' + value % 10)); + value /= 10; + } while (value > 0 && pos >= 0); + if (neg && pos >= 0) write_digit_(pos, '-'); +} + +// Full saturation hue to RGB. Effects ride the color wheel rather than the +// master light's color, because a rainbow that keeps the user's tint is not +// a rainbow. +static void hue_rgb(float h, float *out) { + h -= floorf(h); + float x = h * 6.0f; + int i = (int) x; + float f = x - (float) i; + switch (i % 6) { + case 0: out[0] = 1.0f; out[1] = f; out[2] = 0.0f; break; + case 1: out[0] = 1.0f - f; out[1] = 1.0f; out[2] = 0.0f; break; + case 2: out[0] = 0.0f; out[1] = 1.0f; out[2] = f; break; + case 3: out[0] = 0.0f; out[1] = 1.0f - f; out[2] = 1.0f; break; + case 4: out[0] = f; out[1] = 0.0f; out[2] = 1.0f; break; + default: out[0] = 1.0f; out[1] = 0.0f; out[2] = 1.0f - f; break; + } +} + +// Where every position sits along the effect axis, 0 at the trailing edge +// and 1 at the leading one. Same projection the gradient uses, normalized +// against the LEDs rather than the panel outline for the same reason. Only +// recomputed when the angle changes. +void Aip33628Panel::recompute_axis_() { + const float a = effect_angle_ * 3.14159265f / 180.0f; + const float ca = cosf(a), sa = sinf(a); + + float lo = 1e9f, hi = -1e9f; + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + if (GEOM[blk][seg].id == 0) + continue; + float t = (GEOM[blk][seg].nx / 255.0f) * ca + (GEOM[blk][seg].ny / 255.0f) * sa; + if (t < lo) lo = t; + if (t > hi) hi = t; + } + } + float span = hi - lo; + if (span < 1e-6f) + span = 1.0f; + + float blo = 1e9f, bhi = -1e9f; + for (int blk = 0; blk < 4; blk++) { + float sum = 0.0f; + int n = 0; + for (int seg = 0; seg < 9; seg++) { + if (GEOM[blk][seg].id == 0) + continue; + float t = ((GEOM[blk][seg].nx / 255.0f) * ca + (GEOM[blk][seg].ny / 255.0f) * sa - lo) / span; + axis_pos_[blk][seg] = t; + sum += t; + n++; + } + axis_blk_[blk] = n ? sum / (float) n : 0.0f; + if (axis_blk_[blk] < blo) blo = axis_blk_[blk]; + if (axis_blk_[blk] > bhi) bhi = axis_blk_[blk]; + } + + // Normalized end to end, same as the per position axis. How much of the + // wheel that covers is the hue span setting's job, not this one's. + float bspan = bhi - blo; + if (bspan < 1e-6f) + bspan = 1.0f; + for (int blk = 0; blk < 4; blk++) + axis_blk_[blk] = (axis_blk_[blk] - blo) / bspan; +} + +void Aip33628Panel::set_effect(int mode) { + Effect want = mode == 1 ? Effect::CYCLE : mode == 2 ? Effect::FLASH : Effect::NONE; + if (want == effect_) + return; + effect_ = want; + effect_t0_ = millis(); + effect_at_ = 0; + // Leaving an effect hands the panel back rather than freezing on whatever + // frame it happened to stop at. + envelope_ = 1.0f; + if (want == Effect::NONE) + clear_positions_(); + if (want == Effect::CYCLE) + recompute_axis_(); + apply_colors_(); +} + +void Aip33628Panel::set_effect_speed(float seconds) { + effect_speed_ = seconds < 0.1f ? 0.1f : (seconds > 600.0f ? 600.0f : seconds); +} + +void Aip33628Panel::set_effect_spread(int mode) { + spread_ = mode == 1 ? Spread::DIGIT : mode == 2 ? Spread::LED : Spread::PANEL; +} + +void Aip33628Panel::set_effect_angle(float deg) { + effect_angle_ = deg; + recompute_axis_(); +} + +void Aip33628Panel::set_effect_hue_span(float deg) { + hue_span_ = deg < 0.0f ? 0.0f : (deg > 360.0f ? 360.0f : deg); +} + +void Aip33628Panel::set_flash_fade(float seconds) { + flash_fade_ = seconds < 0.0f ? 0.0f : (seconds > 300.0f ? 300.0f : seconds); +} + +// Advance whichever effect is running. Called from loop() at a fixed cadence +// rather than every pass, since the scan is what the eye sees and a redraw +// faster than about 25Hz buys nothing. +void Aip33628Panel::update_effect_(uint32_t now_ms) { + float period = effect_speed_; + float phase = fmodf((float) (now_ms - effect_t0_) / 1000.0f / period, 1.0f); + + if (effect_ == Effect::FLASH) { + // A trapezoid. Rate and transition time are separate, so 1Hz with a 100ms + // ramp and 1Hz snapping hard are both reachable. The on and off halves + // stay even and the ramps eat into them rather than stretching the + // period, so changing the fade never changes the flash rate. + const float half = period * 0.5f; + const float f = flash_fade_ > half ? half : flash_fade_; + const float t = phase * period; + float e; + if (f <= 0.0f) { + e = t < half ? 1.0f : 0.0f; // square + } else if (t < f) { + e = t / f; + } else if (t < half) { + e = 1.0f; + } else if (t < half + f) { + e = 1.0f - (t - half) / f; + } else { + e = 0.0f; + } + envelope_ = e; + apply_colors_(); + return; + } + + // How much of the wheel the panel covers end to end. A full turn across + // four digits packs the whole spectrum into a hand span and reads as noise. + const float span = hue_span_ / 360.0f; + + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) { + if (GEOM[blk][seg].id == 0) + continue; + float t = spread_ == Spread::PANEL ? 0.0f + : spread_ == Spread::DIGIT ? axis_blk_[blk] + : axis_pos_[blk][seg]; + hue_rgb(phase + t * span, pos_rgb_[blk][seg]); + pos_set_[blk][seg] = true; + } + } + apply_colors_(); +} + +void Aip33628Panel::loop() { + uint32_t now_ms = millis(); + + if (effect_ != Effect::NONE && (int32_t) (now_ms - effect_at_) >= 0) { + effect_at_ = now_ms + 40; // 25Hz, smooth enough for a fade to glide + update_effect_(now_ms); + } + + // A temporary mode expires here rather than anywhere else, so there is one + // place that can put the panel back to being a clock. + if (mode_ != Mode::TIME && (int32_t) (now_ms - mode_until_) >= 0) { + bool was_lamp = mode_ == Mode::LAMP; + mode_ = Mode::TIME; + dirty_ = true; + if (was_lamp) + apply_colors_(); + } + + ESPTime now{}; + bool valid = false; + if (time_ != nullptr) { + now = time_->now(); + valid = now.is_valid(); + } + + // Whatever is on the panel, reduced to one number, so an unchanged display + // costs nothing. Time uses hour and minute together rather than the minute + // alone, because Home Assistant can push a new timezone at any point and + // every shift is a whole number of hours. A mode change sets dirty_ itself, + // so the key never has to encode which mode produced it. + int key = -1; + if (mode_ == Mode::NUMBER) { + key = number_; + } else if (valid) { + key = mode_ == Mode::SECONDS ? now.second : now.hour * 60 + now.minute; + } + + // The colon blinks once a second, so the content changes more often than + // the time does. Blinking off means a steady colon, not a dark one. + bool colon = blink_colon_ ? ((now_ms / 1000) % 2 == 0) : true; + + if (!dirty_ && key == last_key_ && colon == last_colon_) return; + last_key_ = key; + last_colon_ = colon; + dirty_ = false; + + for (auto &blk : on_) { + for (bool &v : blk) v = false; + } + + // A hardware check also works while the normal display light is off. + if (mode_ == Mode::LAMP) { + for (int blk = 0; blk < 4; blk++) { + for (int seg = 0; seg < 9; seg++) + on_[blk][seg] = GEOM[blk][seg].id != 0; + } + render_(); + return; + } + + if (!enabled_) { + render_(); + return; + } + + // A pushed number does not need the clock to be set, so it comes first. + if (mode_ == Mode::NUMBER) { + draw_number_(number_, unit_); + render_(); + return; + } + + if (!valid) { + // No time yet. Four dashes says so without pretending to know the hour. + for (int i = 0; i < 4; i++) write_digit_(i, '-'); + render_(); + return; + } + + if (mode_ == Mode::SECONDS) { + // Seconds sit where the minutes normally do, behind a colon that stays + // steady, so the panel reads as :SS rather than as a bare two digit + // number that could be anything. The upper dot still follows the network. + write_digit_(2, (char) ('0' + now.second / 10)); + write_digit_(3, (char) ('0' + now.second % 10)); + if (online_) write_pos_(1, SEG_ANNUN, true); + write_pos_(1, SEG_ANNUN2, true); + render_(); + return; + } + + int hour = now.hour; + bool pm = hour >= 12; + if (twelve_hour_) { + hour = hour % 12; + if (hour == 0) hour = 12; + } + + // Leading zero stays suppressed in 24 hour mode as well. + if (hour >= 10) { + write_digit_(0, (char) ('0' + hour / 10)); + } + write_digit_(1, (char) ('0' + hour % 10)); + write_digit_(2, (char) ('0' + now.minute / 10)); + write_digit_(3, (char) ('0' + now.minute % 10)); + + if (colon) { + // Both dots when the network is up, the lower one alone when it is not. + // Block 2 COM low LED1 is the lower dot, see docs/display-map.md. + if (online_) write_pos_(1, SEG_ANNUN, true); + write_pos_(1, SEG_ANNUN2, true); + } + if (twelve_hour_ && !pm) { + write_pos_(0, SEG_ANNUN, true); // AM indicator, lit through the morning + } + + render_(); +} + +// Collapse the panel state into a scan schedule. Each COM pair is split into +// COLOR_BITS binary weighted sub-frames, and a channel at duty level L is lit +// in sub-frame k whenever bit k of L is set. Any level from 0 to +// COLOR_LEVELS - 1 is reachable that way, with no constraint that a dimmer +// channel be a subset of a brighter one. +// +// Per digit color costs nothing here. Each driver and COM pair together +// belong to exactly one block, since the two blocks on a driver sit on +// different COM pairs, so a slot only ever holds one block's color per +// driver and the two drivers carry their own SS word anyway. Four different +// colors reach sixteen steps, which is exactly MAX_STEPS. +void Aip33628Panel::render_() { + uint16_t sub[2][4][COLOR_BITS] = {}; + + for (int blk = 0; blk < 4; blk++) { + const Block &b = BLOCKS[blk]; + for (int seg = 0; seg < 9; seg++) { + if (!on_[blk][seg]) + continue; + const SegPos &sp = SEGMAP[seg]; + int ci = com_index(sp.side ? b.com_hi : b.com_lo); + int base = 3 * sp.led - 2; // LED1 is SEG1..SEG3, LED5 is SEG13..SEG15 + for (int ch = 0; ch < 3; ch++) { + uint8_t lv = level_[blk][seg][ch]; + if (lv == 0) + continue; + uint16_t bit = (uint16_t) (1u << (base + ch)); + for (int k = 0; k < COLOR_BITS; k++) { + if (lv & (1u << k)) + sub[b.drv][ci][k] |= bit; + } + } + } + } + + // IS is set by the brightness alone and never by what is on screen. Making + // it depend on the lit sink count changes the brightness of the whole panel + // every time the colon blinks. The stock firmware held IS fixed across + // colon on and colon off, and ran 0xF with a white digit lit. + // The lamp test brings its own current, while max_current_ remains the + // thermal ceiling for every mode. + uint8_t want = mode_ == Mode::LAMP ? lamp_current_ : requested_current_; + uint8_t is = want < max_current_ ? want : max_current_; + + ScanBuf &b = buf_[front_ ^ 1]; + b.n = 0; + for (int ci = 0; ci < 4; ci++) { + for (int k = 0; k < COLOR_BITS; k++) { + uint16_t s0 = sub[0][ci][k]; + uint16_t s1 = sub[1][ci][k]; + uint8_t units = (uint8_t) (1u << k); + // Identical neighbors inside one COM pair merge, so a saturated color, + // where every sub-frame carries the same data, ends up as one step of + // the full 600us. Merging never crosses a COM boundary, because CS + // changes there and the drivers have to be re-sent regardless. + if (k > 0 && b.n > 0 && b.step[b.n - 1].ss[0] == s0 && b.step[b.n - 1].ss[1] == s1) { + b.step[b.n - 1].units = (uint8_t) (b.step[b.n - 1].units + units); + continue; + } + ScanStep &st = b.step[b.n++]; + st.units = units; + st.ss[0] = s0; + st.ss[1] = s1; + st.cs = COM_SEQ[ci]; + } + } + b.is = is; + + // Publish. The barrier keeps the writes above from being reordered past the + // flip, which is what stops the scan callback seeing a torn pattern while a + // Home Assistant slider is being dragged. + __sync_synchronize(); + front_ ^= 1; +} + +} // namespace aip33628 +} // namespace esphome diff --git a/esphome/components/aip33628/aip33628.h b/esphome/components/aip33628/aip33628.h new file mode 100644 index 0000000..bc1d18c --- /dev/null +++ b/esphome/components/aip33628/aip33628.h @@ -0,0 +1,272 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/light/light_output.h" +#include "esphome/components/time/real_time_clock.h" + +#include + +namespace esphome { +namespace aip33628 { + +// Two AiP33628 drivers behind the HU-058D panel, one per two-wire bus. +// Mapping, scan timing and the current budget are documented in +// docs/display-map.md. + +static const uint8_t DRAM_ATTR COM_SEQ[4] = {0x30, 0x0C, 0x03, 0xC0}; + +// Each COM pair holds the bus for 600us, so a full four pair cycle is 2400us +// and the panel refreshes at 416.7Hz, within a hertz of what the stock +// firmware ran. +// +// That 600us is subdivided into binary weighted sub-frames, 40, 80, 160 and +// 320us. A channel wanting duty level L is lit in the sub-frames whose weight +// bits are set in L, so four sub-frames buy sixteen levels rather than the +// five that four equal ones would. The shortest sub-frame is 40us against a +// 6.4us frame send, so there is room to spare. +static const uint8_t COLOR_BITS = 4; +static const uint8_t COLOR_LEVELS = 1 << COLOR_BITS; // 0 to 15 inclusive +static const uint32_t UNIT_US = 40; +static const uint8_t MAX_STEPS = 4 * COLOR_BITS; + +// Current step to mA, from the datasheet. Tenths of a mA. +static const uint16_t IS_MA[16] = {25, 51, 76, 101, 126, 152, 177, 202, + 227, 253, 278, 303, 328, 354, 379, 404}; + +// Current step to the nibble that goes on the wire. The frame carries IS[0] +// at bit 27 and IS[3] at bit 24, so the field is bit reversed against the +// rest of the frame. Steps 0 and 15 are palindromes, which is why getting +// this wrong looks correct at both ends of the brightness range and scrambles +// the order everywhere in between. The table is its own inverse. +static const uint8_t DRAM_ATTR IS_WIRE[16] = {0x0, 0x8, 0x4, 0xC, 0x2, 0xA, 0x6, 0xE, + 0x1, 0x9, 0x5, 0xD, 0x3, 0xB, 0x7, 0xF}; + +enum Channel : uint8_t { CH_BLUE = 0, CH_GREEN = 1, CH_RED = 2 }; + +// Where each LED physically sits, from docs/led-layout.md. id is the number +// used by the board layout map and by set_position_color, 1 to 33, and 0 +// marks a position the panel can address but nothing is wired to. nx runs 0 +// at the left edge of the first digit to 255 at the right edge of the last, +// ny 0 at the top of a digit to 255 at the bottom. +struct PosGeom { + uint8_t id; + uint8_t nx; + uint8_t ny; +}; +extern const PosGeom GEOM[4][9]; + +// Running effects. Each owns the panel while it is selected, and selecting +// NONE hands it back to whatever the colors were before. +enum class Effect : uint8_t { NONE = 0, CYCLE = 1, FLASH = 2 }; + +// How far apart an effect spreads its phase across the panel. PANEL moves +// everything together, DIGIT gives each digit its own phase, LED gives every +// position its own. The direction the spread runs is the effect angle. +enum class Spread : uint8_t { PANEL = 0, DIGIT = 1, LED = 2 }; + +// What the panel is showing. Everything except TIME is temporary and expires +// on its own, so no caller can leave the clock stuck not being a clock. +enum class Mode : uint8_t { TIME, SECONDS, NUMBER, LAMP }; + +// One step of the scan schedule: latch this pattern on both drivers, then +// hold it for this long. Adjacent sub-frames with identical data collapse +// into a single longer step, so a saturated color costs one send per COM +// pair, exactly what the two level scan cost before. +struct ScanStep { + uint8_t units; // dwell, in UNIT_US ticks, 1 to 15 + uint8_t cs; + uint16_t ss[2]; +}; + +// One complete scan pattern. render_() fills the back buffer and then flips +// front_, so the scan callback can never read a half written pattern. +struct ScanBuf { + ScanStep step[MAX_STEPS]; + uint8_t n; + uint8_t is; +}; + +class Aip33628Panel : public Component { + public: + void set_pins(InternalGPIOPin *clk, InternalGPIOPin *data, InternalGPIOPin *clk2, + InternalGPIOPin *data2) { + clk_ = clk; + data_ = data; + clk2_ = clk2; + data2_ = data2; + } + void set_time(time::RealTimeClock *rtc) { time_ = rtc; } + void set_max_current(uint8_t is) { max_current_ = is; } + // Both of these are live. Home Assistant drives them through template + // switches, so each has to force a redraw rather than wait for the next + // rollover. + void set_twelve_hour(bool v) { + twelve_hour_ = v; + dirty_ = true; + } + void set_blink_colon(bool v) { + blink_colon_ = v; + dirty_ = true; + } + // Offline drops the upper colon dot, so a glance at the panel says whether + // the time is still being kept honest. Driven from the wifi triggers in + // clock.yaml rather than by including the wifi component here. + void set_online(bool v) { + online_ = v; + dirty_ = true; + } + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::HARDWARE; } + + // Called by the light platform. Color components and brightness are 0 to 1. + void set_light(bool on, float r, float g, float b, float brightness); + + // Temporary displays, driven from the api actions in clock.yaml. Each takes + // a lifetime in milliseconds and falls back to the time when it runs out. + void show_seconds(int ms); + void show_number(int value, const std::string &unit, int ms); + // Every populated position, white, at a fixed brightness. Ignores the + // color tiers, any running effect, and the light being off. + void lamp_test(int ms); + + // Per digit color. Blocks are 0 to 3, left to right, and each carries its + // own annunciator: block 0 the AM mark, block 1 the colon, block 2 the date + // dash. Components run 0 to 1 and are used as given rather than normalized, + // so a digit can be dimmer than its neighbors as well as a different hue, + // which is what a per digit fade needs. A digit with no color of its own + // follows the master light. + void set_digit_color(int digit, float r, float g, float b); + void clear_digit_colors(); + + // One LED, by the id in docs/led-layout.md. Overrides the digit color, + // which in turn overrides the master light. + void set_position_color(int id, float r, float g, float b); + // A linear ramp between two colors across the panel. The angle is in + // degrees, 0 running left to right and 90 top to bottom, and the ramp is + // always stretched to cover the whole panel whatever the angle. + void set_gradient(float r0, float g0, float b0, float r1, float g1, float b1, + float angle_deg); + + // Effects. Everything here is live and takes hold on the next update. + void set_effect(int mode); + void set_effect_speed(float seconds); + void set_effect_spread(int mode); + void set_effect_angle(float deg); + void set_effect_hue_span(float deg); + void set_flash_fade(float seconds); + + protected: + void render_(); + void apply_colors_(); + void update_effect_(uint32_t now_ms); + void recompute_axis_(); + void clear_positions_(); + void write_digit_(uint8_t block, char c); + void write_pos_(uint8_t block, uint8_t seg, bool on); + void draw_number_(int value, char unit); + static bool scan_tick_(gptimer_handle_t timer, const gptimer_alarm_event_data_t *edata, + void *arg); + void send_pair_(uint16_t ss1, uint16_t ss2, uint8_t cs, uint8_t is); + + InternalGPIOPin *clk_{nullptr}; + InternalGPIOPin *data_{nullptr}; + InternalGPIOPin *clk2_{nullptr}; + InternalGPIOPin *data2_{nullptr}; + time::RealTimeClock *time_{nullptr}; + + // Port bit masks for the four pins. A frame goes out as direct register + // stores with both buses clocked together, which takes 6.4us against 28.0us + // for two passes through ISRInternalGPIOPin. All four pins have to live + // below GPIO32 for this, which __init__.py enforces at config time. + uint32_t clk_mask_{0}, data_mask_{0}, clk2_mask_{0}, data2_mask_{0}; + + + uint8_t max_current_{15}; + bool twelve_hour_{true}; + bool blink_colon_{true}; + bool online_{false}; + + // Which positions are lit, before color is applied. + bool on_[4][9]{}; // [block][segment], segments A..G then annunciator, annunciator 2 + bool enabled_{false}; + // [block][segment][channel], 0 to COLOR_LEVELS - 1, index by Channel. + uint8_t level_[4][9][3]{}; + uint8_t requested_current_{15}; + + // Color before it is quantized, in three tiers. A position with pos_set_ + // wins, then a block with digit_set_, then the master light's base_rgb_. + float base_rgb_[3]{1.0f, 1.0f, 1.0f}; + float digit_rgb_[4][3]{}; + bool digit_set_[4]{}; + float pos_rgb_[4][9][3]{}; + bool pos_set_[4][9]{}; + + // Scales every color on its way to a duty level. The flash effect drives + // it and nothing else touches it, so it stays at 1 the rest of the time. + float envelope_{1.0f}; + + Effect effect_{Effect::NONE}; + Spread spread_{Spread::PANEL}; + float effect_speed_{10.0f}; // seconds for one full cycle + float effect_angle_{0.0f}; + // How much of the color wheel the panel covers end to end. A full turn on + // four digits packs the whole spectrum into a hand span and reads as + // noise, so the useful settings are narrow, a slice rather than the lot. + float hue_span_{90.0f}; + // Seconds each flash transition takes. Zero snaps. + float flash_fade_{0.0f}; + uint32_t effect_t0_{0}; + uint32_t effect_at_{0}; + // Where each position sits along the effect axis, 0 to 1, recomputed only + // when the angle changes rather than every frame. + float axis_pos_[4][9]{}; + float axis_blk_[4]{}; + + ScanBuf buf_[2]{}; + volatile uint8_t front_{0}; + volatile uint8_t step_{0}; + volatile uint8_t wait_{0}; // ticks left before the next step is latched + + Mode mode_{Mode::TIME}; + uint32_t mode_until_{0}; + uint8_t lamp_current_{0}; + int number_{0}; + char unit_{'\0'}; + + int last_key_{-1}; // whatever the current mode reduces its content to + bool last_colon_{false}; + bool dirty_{true}; +}; + +class Aip33628Light : public light::LightOutput { + public: + void set_panel(Aip33628Panel *panel) { panel_ = panel; } + + light::LightTraits get_traits() override { + auto traits = light::LightTraits(); + traits.set_supported_color_modes({light::ColorMode::RGB}); + return traits; + } + + void write_state(light::LightState *state) override { + auto v = state->current_values; + // Color ratio comes from the raw components, which ESPHome has already + // normalized so the largest is 1. Magnitude comes through the helper + // rather than v.get_brightness(), because only the helper carries the + // transition state, without which a fade never moves. Both arrive linear, + // since gamma lives in the component. + float bright; + state->current_values_as_brightness(&bright); + panel_->set_light(v.is_on(), v.get_red(), v.get_green(), v.get_blue(), bright); + } + + protected: + Aip33628Panel *panel_{nullptr}; +}; + +} // namespace aip33628 +} // namespace esphome diff --git a/esphome/components/aip33628/light.py b/esphome/components/aip33628/light.py new file mode 100644 index 0000000..b8014cb --- /dev/null +++ b/esphome/components/aip33628/light.py @@ -0,0 +1,27 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import light +from esphome.const import CONF_OUTPUT_ID + +from . import Aip33628Panel, aip33628_ns + +DEPENDENCIES = ["aip33628"] + +Aip33628Light = aip33628_ns.class_("Aip33628Light", light.LightOutput) + +CONF_AIP33628_ID = "aip33628_id" + +CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend( + { + cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(Aip33628Light), + cv.GenerateID(CONF_AIP33628_ID): cv.use_id(Aip33628Panel), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_OUTPUT_ID]) + await light.register_light(var, config) + + panel = await cg.get_variable(config[CONF_AIP33628_ID]) + cg.add(var.set_panel(panel)) diff --git a/esphome/sleepingroom-clock.yaml b/esphome/sleepingroom-clock.yaml new file mode 100644 index 0000000..2319c9b --- /dev/null +++ b/esphome/sleepingroom-clock.yaml @@ -0,0 +1,450 @@ +# Board: ESP32-C3 Super Mini (Generic) +# Definition: definitions/boards/esp32-c3-supermini/manifest.yaml +# Based largely on https://github.com/misterblack1/HU-058_ESPHome by Adrian Black aka "Adrian's Digital Basement" +# AI slop in here + +substitutions: + device_description: "ESP32 Geiger counter with radiation dose rate, accumulated dose and MAX7219 seven-segment display" + name: "sleepingroom-clock" + friendly_name: "Schlafzimmeruhr" + +esphome: + comment: ${device_description} + name: ${name} + friendly_name: ${friendly_name} + name_add_mac_suffix: false + project: + name: sensor.radiation + version: "1.0" + min_version: 2022.11.0 + # Template numbers and selects restore their value by publishing it, which + # does not fire their set_action, so a restored effect would show in the UI + # and do nothing. Push the lot into the component once everything is up. + on_boot: + priority: -100 + then: + - lambda: |- + id(panel)->set_effect_speed(id(effect_speed).state); + id(panel)->set_effect_angle(id(effect_angle).state); + id(panel)->set_effect_hue_span(id(effect_hue_span).state); + id(panel)->set_flash_fade(id(fx_flash_fade).state); + std::string sp = id(effect_spread).current_option(); + id(panel)->set_effect_spread(sp == "Per Digit" ? 1 : sp == "Per LED" ? 2 : 0); + std::string ef = id(effect_mode).current_option(); + id(panel)->set_effect(ef == "Color Cycle" ? 1 : ef == "Flash" ? 2 : 0); + +esp32: + variant: esp32c3 + flash_size: 4MB + framework: + type: esp-idf + +# Enable logging +logger: + +# Enable Home Assistant API +api: + encryption: + key: !secret apikey + actions: + - action: show_seconds + variables: + duration: int + then: + - lambda: id(panel)->show_seconds(duration); + # Right aligned digits with an optional unit in the rightmost position, so + # 78F and -5C both read correctly. A unit of C or F also lights the degree + # mark. Pass an empty unit to give the number all four positions. There is + # no decimal point on the panel, so round before sending. + - action: show_number + variables: + value: int + unit: string + duration: int + then: + - lambda: id(panel)->show_number(value, unit, duration); + # Every populated LED, white, for a few seconds, then back to the time. + # It ignores color, effects, brightness and the light being off. Duration + # is milliseconds, and 3000 is the button below. + - action: lamp_test + variables: + duration: int + then: + - lambda: id(panel)->lamp_test(duration); + # Per digit color. Digit 0 is the leftmost and 3 the rightmost, and -1 + # sets all four at once. Components are 0 to 255 and are not rescaled + # against each other, so a digit can be dimmer than its neighbors as well + # as a different hue. Each digit carries its own annunciator, so the colon + # takes the color of the hour ones digit and the AM mark the hour tens. + # This is runtime state and is not remembered across a reboot. + - action: set_digit_color + variables: + digit: int + red: int + green: int + blue: int + then: + - lambda: |- + id(panel)->set_digit_color(digit, red / 255.0f, green / 255.0f, blue / 255.0f); + # Hand the whole panel back to the Display light, gradients included. + - action: clear_digit_colors + then: + - lambda: id(panel)->clear_digit_colors(); + # One LED, by the id on the board layout map. See docs/led-layout.md. + - action: set_position_color + variables: + position: int + red: int + green: int + blue: int + then: + - lambda: |- + id(panel)->set_position_color(position, red / 255.0f, green / 255.0f, + blue / 255.0f); + # A linear ramp between two colors across the whole panel. Angle is in + # degrees, 0 left to right and 90 top to bottom, so -45 runs from the + # bottom left corner to the top right. + # Everything about the running effect in one call, for automations that + # want to set it up atomically rather than nudging five entities. + # mode 0 off, 1 color cycle, 2 flash. spread 0 whole panel, 1 per digit, + # 2 per LED. speed is seconds for one full cycle, which for flash is one + # complete on and off. angle is the direction the spread runs. hue_span is + # how many degrees of the color wheel the panel covers end to end. + # flash_fade is seconds per flash transition, 0 to snap. + - action: set_effect + variables: + mode: int + spread: int + speed: float + angle: float + hue_span: float + flash_fade: float + then: + # Drives the entities rather than the component, so the Home Assistant + # UI ends up showing what the panel is actually doing. Calling the + # component directly would work and leave every slider stale. + # + # Values are clamped here because a number call outside its range is + # dropped rather than clamped, which would silently ignore the whole + # setting. + - lambda: |- + id(effect_speed).make_call().set_value(clamp(speed, 0.2f, 600.0f)).perform(); + id(effect_angle).make_call().set_value(clamp(angle, -180.0f, 180.0f)).perform(); + id(effect_hue_span).make_call().set_value(clamp(hue_span, 0.0f, 360.0f)).perform(); + id(fx_flash_fade).make_call().set_value(clamp(flash_fade, 0.0f, 30.0f)).perform(); + id(effect_spread).make_call().set_option( + spread == 1 ? "Per Digit" : spread == 2 ? "Per LED" : "Whole Panel").perform(); + id(effect_mode).make_call().set_option( + mode == 1 ? "Color Cycle" : mode == 2 ? "Flash" : "None").perform(); + - action: set_gradient + variables: + red1: int + green1: int + blue1: int + red2: int + green2: int + blue2: int + angle: float + then: + - lambda: |- + id(panel)->set_gradient(red1 / 255.0f, green1 / 255.0f, blue1 / 255.0f, + red2 / 255.0f, green2 / 255.0f, blue2 / 255.0f, angle); + +ota: + platform: esphome + encryption: + +network: + enable_ipv6: true + +wifi: + ssid: "Voltage-legacy" + password: !secret voltage_legacy_psk + #use_address: ${name}.home + power_save_mode: high + fast_connect: on + min_auth_mode: WPA2 + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: "Schlafzimmeruhr Fallback Hotspot" + password: !secret fallback_psk + +captive_portal: + +external_components: + - source: + type: local + path: components + +time: + # No timezone on this one, deliberately. Home Assistant pushes its own + # timezone to the device on every time sync, DST rules included, but only + # if this platform has no timezone of its own. Setting one here compiles + # that path out and pins the clock to whatever is written instead. + - platform: homeassistant + id: ha_time + # SNTP is the fallback for a boot with no Home Assistant. It has nowhere to + # learn a timezone from, so this one is spelled out. It is also what the + # display shows for the first few seconds of every boot, until Home + # Assistant answers. Change it if the clock moves. + #- platform: sntp + # id: sntp_time + # timezone: !secret timezone + +aip33628: + id: panel + clk_pin: GPIO21 # MCU socket pin 14 (HU-058D) or 16 (HU-058/SE), driver 1 + data_pin: GPIO20 # MCU socket pin 5, driver 1 + clk2_pin: GPIO10 # MCU socket pin 1, driver 2 + data2_pin: GPIO9 # MCU socket pin 2, driver 2 + time_id: ha_time + # Power on defaults. The switches below own both after that, and they + # restore their last state, so these only matter for a first ever boot. + twelve_hour: false + blink_colon: true + # Ceiling on IS[3:0], 0 to 15. Fixed, never varied by content. The stock + # firmware ran 0xF and drew 404mA doing it. Drop this if the drivers + # run hotter than you want. See docs/display-map.md. + max_current: 15 + +light: + - platform: aip33628 + id: display + aip33628_id: panel + name: Display + restore_mode: RESTORE_DEFAULT_ON + # Gamma is applied inside the component, because it also has to allow for + # the 2.5mA floor the drivers cannot go below. Leaving it on here as well + # would square it. + gamma_correct: 1.0 + +# The two front panel buttons. Nothing on the board pulls these nets up. The +# stock 8051 held them high with its own quasi-bidirectional port pullup, and +# that chip is out of the socket, so the ESP32 internal pullup is the only one +# and the lines never see 5V. Switch closed shorts to ground, hence inverted. +# +# No behavior is attached here on purpose. Home Assistant is the control +# surface, so these are just inputs it can bind to. Press and hold patterns +# belong in an automation, not in this file. +binary_sensor: + - platform: gpio + id: button_1 + name: Button 1 + pin: + number: GPIO8 # MCU socket pin 9, net S1, top button on the case + mode: + input: true + pullup: true + inverted: true + filters: + - delayed_on: 20ms + - delayed_off: 20ms + - platform: gpio + id: button_2 + name: Button 2 + pin: + number: GPIO5 # MCU socket pin 10, net S2, bottom button on the case + mode: + input: true + pullup: true + inverted: true + filters: + - delayed_on: 20ms + - delayed_off: 20ms + +switch: + - platform: template + id: twelve_hour_mode + name: 12 Hour Time + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + turn_on_action: + - lambda: id(panel)->set_twelve_hour(true); + turn_off_action: + - lambda: id(panel)->set_twelve_hour(false); + + # Off means a steady colon, not a dark one. + - platform: template + id: colon_blink + name: Blink Colon + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + turn_on_action: + - lambda: id(panel)->set_blink_colon(true); + turn_off_action: + - lambda: id(panel)->set_blink_colon(false); + +button: + # Three seconds of every populated LED lit white at half brightness, then + # back to the time with the settings it had before. + - platform: template + name: Lamp Test + on_press: + - lambda: id(panel)->lamp_test(3000); + + # A shortcut for the common case, and something to press by hand. + # Anything more elaborate belongs in a Home Assistant automation calling + # the api actions above. + - platform: template + name: Show Seconds + on_press: + - lambda: id(panel)->show_seconds(5000); + + # A UI handle for set_digit_color, and the quickest way to see the four + # digits driven independently. + - platform: template + name: Digit Color Demo + on_press: + - lambda: |- + id(panel)->set_digit_color(0, 1.00f, 0.00f, 0.00f); + id(panel)->set_digit_color(1, 1.00f, 0.45f, 0.00f); + id(panel)->set_digit_color(2, 0.00f, 1.00f, 0.25f); + id(panel)->set_digit_color(3, 0.10f, 0.30f, 1.00f); + + - platform: template + name: Clear Digit Colors + on_press: + - lambda: id(panel)->clear_digit_colors(); + + # Green in the bottom left corner running to yellow in the top right, which + # is the sort of thing the stock firmware did. -45 degrees is that diagonal. + - platform: template + name: Diagonal Gradient + on_press: + - lambda: |- + id(panel)->set_gradient(0.00f, 1.00f, 0.10f, + 1.00f, 0.85f, 0.00f, -45.0f); + +# A UI handle for the show_number action, which otherwise lives only under +# Developer Tools > Actions. Setting either entity redraws immediately and the +# panel falls back to the time ten seconds later on its own. +select: + # Effects run on the device, not in Home Assistant, so a fade stays smooth + # and keeps going if the network drops. Every entity here is a normal one, + # so an automation can drive them, or call the set_effect action to set the + # lot at once. + - platform: template + name: Effect + id: effect_mode + optimistic: true + restore_value: true + initial_option: "None" + options: ["None", "Color Cycle", "Flash"] + set_action: + - lambda: |- + std::string v = x; + id(panel)->set_effect(v == "Color Cycle" ? 1 : v == "Flash" ? 2 : 0); + + # Whole Panel moves everything together. Per Digit gives each digit its own + # phase, so the color walks across the four of them. Per LED gives every + # position its own, which is the smooth sweep. + - platform: template + name: Effect Spread + id: effect_spread + optimistic: true + restore_value: true + initial_option: "Whole Panel" + options: ["Whole Panel", "Per Digit", "Per LED"] + set_action: + - lambda: |- + std::string v = x; + id(panel)->set_effect_spread(v == "Per Digit" ? 1 : v == "Per LED" ? 2 : 0); + + - platform: template + name: Test Unit + id: test_unit + optimistic: true + restore_value: true + initial_option: "F" + options: ["None", "F", "C"] + set_action: + - lambda: |- + std::string u = x; + if (u == "None") u.clear(); + id(panel)->show_number((int) id(test_number).state, u, 10000); + +number: + # Seconds for one full cycle. For Flash this is one complete on and off. + - platform: template + name: Effect Speed + id: effect_speed + optimistic: true + restore_value: true + initial_value: 10 + min_value: 0.2 + max_value: 600 + step: 0.2 + unit_of_measurement: s + mode: box + set_action: + - lambda: id(panel)->set_effect_speed(x); + + # Which way the spread runs. 0 is left to right, 90 top to bottom, 180 + # right to left. -45 is the bottom left to top right diagonal and 135 is + # the other one, top right to bottom left. + - platform: template + name: Effect Angle + id: effect_angle + optimistic: true + restore_value: true + initial_value: 0 + min_value: -180 + max_value: 180 + step: 15 + unit_of_measurement: deg + mode: box + set_action: + - lambda: id(panel)->set_effect_angle(x); + + # How many degrees of the color wheel the panel covers end to end. A full + # 360 packs the whole spectrum across four digits and reads as noise, so + # the useful settings are narrow. 60 is roughly red through orange, 90 + # reaches yellow. 0 puts the whole panel on one color that still cycles. + # Only Color Cycle uses this, and only when Spread is not Whole Panel. + - platform: template + name: Effect Hue Span + id: effect_hue_span + optimistic: true + restore_value: true + initial_value: 90 + min_value: 0 + max_value: 360 + step: 5 + unit_of_measurement: deg + mode: box + set_action: + - lambda: id(panel)->set_effect_hue_span(x); + + # Seconds each flash transition takes, independent of the flash rate. 0 + # snaps on and off. 0.1 gives a quick ramp. Anything past half the Effect + # Speed is capped there, which is the point where it becomes a triangle. + - platform: template + name: Flash Fade + id: fx_flash_fade + optimistic: true + restore_value: true + initial_value: 0 + min_value: 0 + max_value: 30 + step: 0.05 + unit_of_measurement: s + mode: box + set_action: + - lambda: id(panel)->set_flash_fade(x); + + - platform: template + name: Test Number + id: test_number + optimistic: true + restore_value: true + initial_value: 72 + min_value: -999 + max_value: 9999 + step: 1 + mode: box + set_action: + - lambda: |- + std::string u = id(test_unit).current_option(); + if (u == "None") u.clear(); + id(panel)->show_number((int) x, u, 10000);