diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53dc580 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Gitignore settings for ESPHome +# This is an example and may include too much for your use-case. +# You can modify this file to suit your needs. +/.esphome/ +/secrets.yaml +/secrets.*.yaml +!/secrets.example.yaml +/.pio +/.vscode +__pycache__ +/_ignored +esp32_test.code-workspace +case/d1_mini_esp32.FCStd1 diff --git a/case/d1_mini_esp32-Body.stl b/case/d1_mini_esp32-Body.stl new file mode 100644 index 0000000..64adf67 Binary files /dev/null and b/case/d1_mini_esp32-Body.stl differ diff --git a/case/d1_mini_esp32.FCStd b/case/d1_mini_esp32.FCStd new file mode 100644 index 0000000..0473ad4 Binary files /dev/null and b/case/d1_mini_esp32.FCStd differ diff --git a/components/wordclock/__init__.py b/components/wordclock/__init__.py new file mode 100644 index 0000000..449526b --- /dev/null +++ b/components/wordclock/__init__.py @@ -0,0 +1,153 @@ +import logging + +from esphome import core +from esphome.components import display, font, time, light, color +import esphome.config_validation as cv +import esphome.codegen as cg +import esphome.cpp_generator as cpp + +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_RAW_DATA_ID, + CONF_TYPE, + CONF_TIME_ID, + CONF_SEGMENTS, + CONF_ADDRESSABLE_LIGHT_ID, + CONF_HOUR, + CONF_HOURS, + CONF_MINUTE, + CONF_MINUTES, + CONF_BRIGHTNESS, + CONF_UPDATE_INTERVAL, +) +from esphome.core import CORE, HexInt + +_LOGGER = logging.getLogger(__name__) + +CONF_DISPLAY_ID = "display_id" + +CONF_LINE_START_X = "x1" +CONF_LINE_START_Y = "y1" + +CONF_LINE_END_X = "x2" +CONF_LINE_END_Y = "y2" + +CONF_LINE = "line" + +CONF_COLOR_ON = "color_on" +CONF_COLOR_OFF = "color_off" + +DATA_VECTOR_SEGMENTS_HOUR = "data_vector_segments_hour" +DATA_VECTOR_SEGMENTS_MINUTE = "data_vector_segments_minute" +DATA_VECTOR_SEGMENTS = "data_vector_segments_ptr" + +CONF_WORDCLOCK_STATIC_SEGMENTS = "static_segments" +CONF_HOUR_OFFSET = "hour_offset" +CONF_FADE_LENGTH = "fade_length" + +DEPENDENCIES = ["display", "time"] +MULTI_CONF = False + +int8 = cg.global_ns.namespace("int8_t") +wordclock_ns = cg.esphome_ns.namespace("wordclock") +SegmentCoords = wordclock_ns.struct("SegmentCoords") +Wordclock = wordclock_ns.class_( + "Wordclock", cg.PollingComponent +) + +WORDCLOCK_SEGMENT_SCHEMA = { + cv.Required(CONF_NAME): cv.string_strict, + cv.Required(CONF_LINE): { + cv.Required(CONF_LINE_START_X): cv.int_, + cv.Required(CONF_LINE_START_Y): cv.int_, + cv.Required(CONF_LINE_END_X): cv.int_, + cv.Optional(CONF_LINE_END_Y): cv.int_, + }, +} + +WORDCLOCK_HOUR_SCHEMA = { + cv.Required(CONF_HOUR): cv.uint16_t, + cv.Required(CONF_SEGMENTS): cv.ensure_list(cv.string_strict), +} + +WORDCLOCK_MINUTE_SCHEMA = { + cv.Required(CONF_MINUTE): cv.uint16_t, + cv.Required(CONF_HOUR_OFFSET): cv.int_range(-1,1), + cv.Required(CONF_SEGMENTS): cv.ensure_list(cv.string_strict), +} + + +WORDCLOCK_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(Wordclock), + cv.Required(CONF_DISPLAY_ID): cv.use_id( + display.DisplayBuffer + ), + cv.Required(CONF_TIME_ID): cv.use_id( + time.RealTimeClock + ), + cv.Required(CONF_ADDRESSABLE_LIGHT_ID): cv.use_id( + light.AddressableLightState + ), + cv.Optional(CONF_BRIGHTNESS, default=1.0): cv.percentage, + cv.Optional(CONF_COLOR_ON): cv.use_id(color.ColorStruct), + cv.Optional(CONF_COLOR_OFF): cv.use_id(color.ColorStruct), + cv.Optional(CONF_WORDCLOCK_STATIC_SEGMENTS): cv.ensure_list(cv.string_strict), + cv.Required(CONF_SEGMENTS): cv.ensure_list(WORDCLOCK_SEGMENT_SCHEMA), + cv.Required(CONF_MINUTES): cv.ensure_list(WORDCLOCK_MINUTE_SCHEMA), + cv.Required(CONF_HOURS): cv.ensure_list(WORDCLOCK_HOUR_SCHEMA), + cv.Optional( + CONF_FADE_LENGTH, default="700ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_UPDATE_INTERVAL, default="16ms" + ): cv.positive_time_period_milliseconds, + cv.GenerateID(DATA_VECTOR_SEGMENTS): cv.declare_id(cg.std_vector.template(cg.uint16)), + } +) + +CONFIG_SCHEMA = WORDCLOCK_SCHEMA + +async def to_code(config): + wrapped_display = await cg.get_variable(config[CONF_DISPLAY_ID]) + wrapped_time = await cg.get_variable(config[CONF_TIME_ID]) + wrapped_light_state = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID]) + var = cg.new_Pvariable(config[CONF_ID], wrapped_time, wrapped_display, wrapped_light_state) + + if CONF_COLOR_ON in config: + wrapped_color_on = await cg.get_variable(config[CONF_COLOR_ON]) + cg.add(var.set_on_color(wrapped_color_on)) + if CONF_COLOR_OFF in config: + wrapped_color_off = await cg.get_variable(config[CONF_COLOR_OFF]) + cg.add(var.set_off_color(wrapped_color_off)) + + cg.add(var.setup_transitions(config[CONF_FADE_LENGTH])) + cg.add(var.set_brightness(int(config[CONF_BRIGHTNESS] * 255))) + + SEGMENT_MAP = dict() + for idx, segment in enumerate(config[CONF_SEGMENTS]): + SEGMENT_MAP[segment[CONF_NAME]] = idx + line = segment[CONF_LINE] + + x1 = line[CONF_LINE_START_X] + y1 = line[CONF_LINE_START_Y] + x2 = line[CONF_LINE_END_X] + y2 = line.get(CONF_LINE_END_Y, y1) + + exp = cg.StructInitializer(SegmentCoords, ("x1", x1), ("y1", y1), ("x2", x2), ("y2", y2),) + cg.add(var.add_segment(exp)) + + if CONF_WORDCLOCK_STATIC_SEGMENTS in config: + for segment_name in config[CONF_WORDCLOCK_STATIC_SEGMENTS]: + cg.add(var.add_static(SEGMENT_MAP[segment_name])) + + for idx, hour in enumerate(config[CONF_HOURS]): + segment_ids = [SEGMENT_MAP[a] for a in hour[CONF_SEGMENTS]] + cg.add(var.add_hour(hour[CONF_HOUR], cg.std_vector.template(cg.uint16).new(segment_ids))) + + for idx, minute in enumerate(config[CONF_MINUTES]): + segment_ids = [SEGMENT_MAP[a] for a in minute[CONF_SEGMENTS]] + cg.add(var.add_minute(minute[CONF_MINUTE], minute[CONF_HOUR_OFFSET], cg.std_vector.template(cg.uint16).new(segment_ids))) + + await cg.register_component(var, config) diff --git a/components/wordclock/wordclock.cpp b/components/wordclock/wordclock.cpp new file mode 100644 index 0000000..57b932c --- /dev/null +++ b/components/wordclock/wordclock.cpp @@ -0,0 +1,184 @@ + +#include "wordclock.h" + +namespace esphome { +namespace wordclock { + +void Wordclock::setup() { + this->valid_time = this->time->now().is_valid(); + if (!this->valid_time) { + this->start_idle_animation(); + } +} + +void Wordclock::update() { + esphome::time::ESPTime time = this->time->now(); + + if (!time.is_valid()) { + if (this->valid_time) { + ESP_LOGD("wordclock.cpp", "time is not valid [%02i:%02i:%02i]", time.hour, time.minute, time.second); + this->start_idle_animation(); + this->valid_time = false; + return; + } + } + else { + if (!this->valid_time) { + ESP_LOGD("wordclock.cpp", "time is valid [%02i:%02i:%02i]", time.hour, time.minute, time.second); + this->end_idle_animation(); + this->valid_time = true; + return; + } + bool dirty = false; + + int8_t minute = this->find_minute(time.minute); + int8_t hour = this->find_hour((time.hour + this->minutes->at(minute).hour_offset) % 24); + + if (hour != this->current_hour) { + this->current_hour = hour; + dirty = true; + } + if (minute != this->current_minute) { + this->current_minute = minute; + dirty = true; + } + + if (dirty) { + this->previous_segments->clear(); + + for (uint16_t segment_idx : *this->current_segments) { + this->previous_segments->push_back(segment_idx); + } + + this->current_segments->clear(); + + for (uint16_t segment_idx : *this->static_segments) { + this->current_segments->push_back(segment_idx); + } + + for (uint16_t segment_idx : *(this->minutes->at(minute).segments)) { + this->current_segments->push_back(segment_idx); + } + + for (uint16_t segment_idx : *(this->hours->at(hour).segments)) { + this->current_segments->push_back(segment_idx); + } + + std::sort(this->current_segments->begin(), this->current_segments->end()); + + this->find_difference(this->previous_segments, this->current_segments); + + this->on_transformer->reset(); + this->off_transformer->reset(); + } + + this->current_position = 0; + uint8_t transition_progress = this->on_transformer->apply().value_or(255); + Color removed_color = this->on_color.gradient(this->off_color, transition_progress); + + for (uint16_t segment_idx : *this->removed_segments) { + this->disable_segment_effect(segment_idx, this->off_color, transition_progress); + } + for (uint16_t segment_idx : *this->added_segments) { + this->enable_segment_effect(segment_idx, this->off_color, transition_progress); + } + for (uint16_t segment_idx : *this->staying_segments) { + this->apply_segment_effect(segment_idx); + } + } +} + +void Wordclock::find_difference(std::vector *a_vec, std::vector *b_vec) { + this->added_segments->clear(); + this->removed_segments->clear(); + this->staying_segments->clear(); + + auto a = a_vec->begin(); + auto b = b_vec->begin(); + + while (!(a == a_vec->end() && b == b_vec->end())) { + if (a == a_vec->end()) { this->added_segments-> push_back(*b); b++; } + else if (b == b_vec->end()) { this->removed_segments->push_back(*a); a++; } + else if (*a > *b) { this->added_segments-> push_back(*b); b++; } + else if (*a < *b) { this->removed_segments->push_back(*a); a++; } + else if (*a == *b) { this->staying_segments->push_back(*a); a++; b++; } + } +} + +Color Wordclock::get_next_color_base_(uint32_t position, uint32_t speed, uint16_t width, const Color ¤t_color) { + esphome::light::ESPHSVColor hsv; + hsv.value = 255; + hsv.saturation = 240; + hsv.hue = ((millis() * speed + (position * (0xFFFF / width))) % 0xFFFF) >> 8; + return hsv.to_rgb(); +} + +Color Wordclock::get_next_color(uint32_t position, const Color ¤t_color) { + if (this->effect_active) { + uint32_t speed_ = 3; + uint16_t width_ = 100; // Original width_ = 50 + return get_next_color_base_(position, speed_, width_, current_color); + } + else { + return Color(this->on_color); + } +} + +int8_t Wordclock::find_hour(uint8_t target_value) { + std::vector *elements = this->hours; + for (int i = 0; i < elements->size(); i++) { + if (elements->at(i).hour == target_value) { + return i; + } else if (elements->at(i).hour > target_value) { + return i - 1; + } + } + return elements->size() - 1; +} + +int8_t Wordclock::find_minute(uint8_t target_value) { + std::vector *elements = this->minutes; + for (int i = 0; i < elements->size(); i++) { + if (elements->at(i).minute == target_value) { + return i; + } else if (elements->at(i).minute > target_value) { + return i - 1; + } + } + return elements->size() - 1; +} + +void Wordclock::setup_transitions(uint32_t milliseconds) { + this->on_transformer->setup(0, 255, milliseconds); + this->off_transformer->setup(255, 0, milliseconds); +} + +Wordclock::Wordclock( + esphome::time::RealTimeClock *time, + esphome::addressable_light::AddressableLightDisplay *display, + esphome::light::AddressableLightState *light_state) { + + this->time = time; + this->display = display; + this->light_state = light_state; + + this->light_state->set_default_transition_length(1); + + this->minutes = new std::vector(); + this->hours = new std::vector(); + + this->segments = new std::vector(); + this->static_segments = new std::vector(); + + this->previous_segments = new std::vector(); + this->current_segments = new std::vector(); + + this->added_segments = new std::vector(); + this->removed_segments = new std::vector(); + this->staying_segments = new std::vector(); + + this->on_transformer = new BrightnessTransitionTransformer(); + this->off_transformer = new BrightnessTransitionTransformer(); +} +} // namespace wordclock +} // namespace esphome diff --git a/components/wordclock/wordclock.h b/components/wordclock/wordclock.h new file mode 100644 index 0000000..0530e4d --- /dev/null +++ b/components/wordclock/wordclock.h @@ -0,0 +1,223 @@ +#pragma once + +#include "esphome.h" +// #include "esphome/components/sensor/sensor.h" + +namespace esphome { +namespace wordclock { + +struct Minute { + uint8_t minute; + uint8_t hour_offset; + std::vector *segments; +}; + +struct Hour { + uint8_t hour; + std::vector *segments; +}; + +struct SegmentCoords { + uint16_t x1; + uint16_t y1; + uint16_t x2; + uint16_t y2; +}; + +class BrightnessTransitionTransformer { +public: + virtual ~BrightnessTransitionTransformer() = default; + + void setup(const uint8_t start_values, const uint8_t target_values, uint32_t length) { + this->start_time_ = millis(); + this->length_ = length; + this->start_values_ = start_values; + this->target_values_ = target_values; + this->start(); + } + virtual bool is_finished() { return this->get_progress_() >= 1.0f; } + virtual void start() {} + // virtual optional apply() = 0; + virtual optional apply() { + float v = BrightnessTransitionTransformer::smoothed_progress(this->get_progress_()); + return esphome::lerp(v, this->start_values_, this->target_values_); + } + virtual void stop() {} + void reset() { + this->start_time_ = millis(); + } + const uint8_t &get_start_values() const { return this->start_values_; } + const uint8_t &get_target_values() const { return this->target_values_; } + +protected: + // The progress of this transition, on a scale of 0 to 1. + float get_progress_() { + uint32_t now = esphome::millis(); + if (now < this->start_time_) + return 0.0f; + if (now >= this->start_time_ + this->length_) + return 1.0f; + return clamp((now - this->start_time_) / float(this->length_), 0.0f, 1.0f); + } + + static float smoothed_progress(float x) { return x * x * x * (x * (x * 6.0f - 15.0f) + 10.0f); } + + uint32_t start_time_; + uint32_t length_; + uint8_t start_values_; + uint8_t target_values_; +}; + +class Wordclock : public esphome::PollingComponent { +public: + Wordclock( + esphome::time::RealTimeClock *time, + esphome::addressable_light::AddressableLightDisplay *display, + esphome::light::AddressableLightState *light_state + ); + + void setup(); + void update(); + void set_brightness(uint8_t brightness) { + this->brightness = brightness; + } + bool get_effect_active() { return this->effect_active; } + void set_effect_active(bool x) { this->effect_active = x; } + + bool get_effect_width() { return this->effect_width; } + void set_effect_width(bool x) { this->effect_width = x; } + + bool get_effect_speed() { return this->effect_speed; } + void set_effect_speed(bool x) { this->effect_speed = x; } + + void set_on_color(Color on_color) { + this->on_color = on_color; + } + void set_off_color(Color off_color) { + this->off_color = off_color; + } + void setup_transitions(uint32_t milliseconds); + + void add_segment(SegmentCoords segment) { + this->segments->push_back(segment); + } + + void add_static(uint16_t segment_id) { + this->static_segments->push_back(segment_id); + } + + void add_hour(uint8_t hour, std::vector *segments_ptr) { + this->hours->push_back(Hour{ + .hour = hour, + .segments = segments_ptr, + }); + } + + void add_minute(uint8_t minute, uint8_t hour_offset, std::vector *segments_ptr) { + this->minutes->push_back(Minute{ + .minute = minute, + .hour_offset = hour_offset, + .segments = segments_ptr, + }); + } + + Color on_color {Color(0xFFFFFF)}; + Color off_color {Color(0x000000)}; + uint8_t brightness{255}; + +protected: + // ESPHome Components + esphome::time::RealTimeClock *time; + esphome::light::AddressableLightState *light_state; + esphome::addressable_light::AddressableLightDisplay *display; + + // Time related state + bool valid_time{false}; + int8_t current_hour{-1}; + int8_t current_minute{-1}; + + // Segments Configuration + std::vector *segments; + std::vector *static_segments; + std::vector *minutes; + std::vector *hours; + + BrightnessTransitionTransformer *off_transformer; + BrightnessTransitionTransformer *on_transformer; + + std::vector *added_segments; + std::vector *removed_segments; + std::vector *staying_segments; + + std::vector *current_segments; + std::vector *previous_segments; + + uint32_t current_position{0}; + bool effect_active{true}; + uint32_t effect_speed{12}; + uint16_t effect_width{50}; + Color get_next_color_base_(uint32_t position, uint32_t speed, uint16_t width, const Color ¤t_color); + Color get_next_color(uint32_t position, const Color ¤t_color); + + + // Utils + int8_t find_hour(uint8_t target_value); + int8_t find_minute(uint8_t target_value); + void find_difference(std::vector *a_vec, std::vector *b_vec); + + void segment_effect_base(uint16_t segment_id, bool to_effect, Color base_color, uint8_t transition_progress) { + SegmentCoords s = this->segments->at(segment_id); + int x1 = s.x1; int y1 = s.y1; int x2 = s.x2; int y2 = s.y2; + Color color_to_draw; + Color effect_color; + + const int32_t dx = abs(x2 - x1), sx = x1 < x2 ? 1 : -1; + const int32_t dy = -abs(y2 - y1), sy = y1 < y2 ? 1 : -1; + int32_t err = dx + dy; + while (true) { + effect_color = this->get_next_color(x1 + y1, Color(0)) * this->brightness; + if (to_effect) { + color_to_draw = base_color.gradient(effect_color, transition_progress); + } else { + color_to_draw = effect_color.gradient(base_color, transition_progress); + } + this->display->draw_pixel_at(x1, y1, color_to_draw); + if (x1 == x2 && y1 == y2) break; + int32_t e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x1 += sx; + } + if (e2 <= dx) { + err += dx; + y1 += sy; + } + } + } + + void enable_segment_effect(uint16_t segment_id, Color off_color, uint8_t transition_progress) { + segment_effect_base(segment_id, true, off_color, transition_progress); + } + void disable_segment_effect(uint16_t segment_id, Color off_color, uint8_t transition_progress) { + segment_effect_base(segment_id, false, off_color, transition_progress); + } + void apply_segment_effect(uint16_t segment_id) { + segment_effect_base(segment_id, true, Color(0), 255); + } + void start_idle_animation() { + this->display->fill(this->off_color); + this->display->set_enabled(false); + this->light_state->turn_on().set_effect("random_twinkle").perform(); + } + + void end_idle_animation() { + this->light_state->turn_off().perform(); + this->display->set_enabled(true); + this->display->fill(this->off_color); + this->previous_segments->clear(); + this->current_segments->clear(); + } +}; +} // namespace wordclock +} // namespace esphome + diff --git a/devlog.md b/devlog.md new file mode 100644 index 0000000..2a687de --- /dev/null +++ b/devlog.md @@ -0,0 +1,9 @@ +# Wordclock Development Log + + +## Feature "make speed and width of rainbow effect user-configurable" [2023-06-10 17:13] + +[17:16] Step - add attributes, getters, and setters for effect-speed and -width. + - [x] add attr/funcs to header file. + - [x] add inputs to yaml. + - \ No newline at end of file diff --git a/display_test.yaml b/display_test.yaml new file mode 100644 index 0000000..9a0f27f --- /dev/null +++ b/display_test.yaml @@ -0,0 +1,98 @@ +esphome: + name: "display-thing" + +esp8266: + board: d1_mini + framework: + version: recommended + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "${devicename}" + password: !secret ap_password + manual_ip: + static_ip: !secret manualip_static_ip + gateway: !secret manualip_gateway + subnet: !secret manualip_subnet + dns1: 1.1.1.1 + dns2: 1.0.0.1 + +api: + +ota: + password: "${devicename}" + +logger: + # esp8266_store_log_strings_in_flash: false +# web_server: +# port: 80 + +light: + - name: NeoPixel Strip 1 + id: neopixel_strip_1 + platform: neopixelbus + type: GRB + variant: WS2812 + pin: GPIO3 + num_leds: 199 + restore_mode: ALWAYS_ON + default_transition_length: 0s + color_correct: [100%, 100%, 100%] + method: + type: esp8266_dma + +display: + - platform: addressable_light + id: led_matrix_display + addressable_light_id: neopixel_strip_1 + width: 18 + height: 11 + rotation: 270° + update_interval: 16ms + pixel_mapper: |- + int mapping_even[] = {0, 2, 4, 6,8,10,11,13,15,17, 16,16,16,16, 16,16,16,16}; + int mapping_odd[] = {17,15,13,11,9, 7, 6, 4, 2, 0, 16,16,16,16, 16,16,16,16}; + if (x > 9) return -1; + if (y % 2 == 0) { + return (y * 18 + mapping_even[x]); // mapping_even[y]; + } else { + return (y * 18 + mapping_odd[x]); // mapping_odd[y]; + //return (y * 18 + 17 - x); // mapping_odd[y]; + } + //if (x % 2 == 0) { + // return (x * 8) + y; + //} + //return (x * 8) + (7 - y); + lambda: |- + Color red = Color(0xFF0000); + Color green = Color(0x00FF00); + Color blue = Color(0x0000FF); + Color turquoise = Color(0x00FFFF); + it.draw_pixel_at(0,0, green); + it.draw_pixel_at(10,0, red); + it.draw_pixel_at(0,9, turquoise); + it.draw_pixel_at(10,9, blue); + //it.line(0,0, 11,0, red); + //it.line(0,1, 11,1, green); + //it.line(0,2, 11,2, blue); + //it.line(0,3, 11,3, red); + //it.line(0,4, 11,4, green); + //it.line(0,5, 11,5, blue); + //it.line(0,6, 11,6, red); + //it.line(0,7, 11,7, green); + //it.line(0,8, 11,8, blue); + //it.line(0,9, 11,9, red); + //it.line(0,10, 9,10, green); + //it.line(0,11, 9,11, blue); + // it.line(5,14, 10,14, green); + //it.rectangle(0, 0, 17, 10, red); + //it.rectangle(1, 1, 16, 9, green); + //it.rectangle(2, 2, 15, 8, blue); + //it.rectangle(3, 3, 14, 7, red); + +time: + - platform: sntp + id: current_time + timezone: !secret timezone diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..48ccbe4 --- /dev/null +++ b/flake.lock @@ -0,0 +1,64 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1683777345, + "narHash": "sha256-V2p/A4RpEGqEZussOnHYMU6XglxBJGCODdzoyvcwig8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "635a306fc8ede2e34cb3dd0d6d0a5d49362150ed", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems", + "utils": "utils" + } + }, + "systems": { + "locked": { + "lastModified": 1680978846, + "narHash": "sha256-Gtqg8b/v49BFDpDetjclCYXm8mAnTrUzR0JnE2nv5aw=", + "owner": "nix-systems", + "repo": "x86_64-linux", + "rev": "2ecfcac5e15790ba6ce360ceccddb15ad16d08a8", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "x86_64-linux", + "type": "github" + } + }, + "utils": { + "inputs": { + "systems": [ + "systems" + ] + }, + "locked": { + "lastModified": 1681202837, + "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "cfacdce06f30d2b68473a46042957675eebb3401", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..208d1ce --- /dev/null +++ b/flake.nix @@ -0,0 +1,67 @@ +{ + description = "EspHome Word Clock"; + inputs = { + systems.url = "github:nix-systems/x86_64-linux"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + # nixpkgs.url = "github:NixOS/nixpkgs/22.11"; + utils.url = "github:numtide/flake-utils"; + utils.inputs.systems.follows = "systems"; + }; + outputs = { self, nixpkgs, utils, ... }: utils.lib.eachDefaultSystem ( + system: + let pkgs = import nixpkgs { inherit system; }; + in { + + # This block here is used when running `nix develop` + devShells.default = pkgs.mkShell rec { + # Update the name to something that suites your project. + name = "esphome-wordclock"; + + # build environment dependencies + packages = with pkgs; [ + esphome + ]; + + # Setting up the environment variables you need during development. + + # Todo figure out why I can't use clang on Asahi but can on Darwin + # Use "clang++" for most systems but OSX Asahi requires g++ for some reason or a runtime error occurs + shellHook = let + # This is for an icon that is used below for the command line input below + icon = "f121"; + in '' + export PS1="$(echo -e '\u${icon}') {\[$(tput sgr0)\]\[\033[38;5;228m\]\w\[$(tput sgr0)\]\[\033[38;5;15m\]} (${name}) \\$ \[$(tput sgr0)\]" + # export COMPILER="clang++" + #export COMPILER="g++" + echo "test"; + ''; + }; + # This is used when running `nix build` + # packages.default = pkgs.llvmPackages_14.stdenv.mkDerivation rec { + packages.default = pkgs.esphome.stdenv.mkDerivation rec { + name = "esphome-wordclock"; + version = "2.0.0"; + src = self; + + # buildInputs = [ pkgs.esphome ]; + + # buildPhase = "COMPILER='clang++' make"; + + # installPhase = '' + # mkdir -p $out/bin; + # install -t $out/bin worm + # ''; + + # meta = with inputs.utils.lib; { + # homepage = "https://github.com/icecreammatt/ssu-cs315-worm"; + # description = '' + # Terminal CLI Worm Game + # ''; + # }; + }; + # packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello; + + # packages.x86_64-linux.default = self.packages.x86_64-linux.hello; + + }); +} diff --git a/image_test.yaml b/image_test.yaml new file mode 100644 index 0000000..f3662f4 --- /dev/null +++ b/image_test.yaml @@ -0,0 +1,154 @@ +esphome: + name: "display-thing" + +esp8266: + board: d1_mini + framework: + version: recommended + +external_components: + - source: + type: local + path: components + components: [ wordclock, addressable_light ] + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "${devicename}" + password: !secret ap_password + manual_ip: + static_ip: !secret manualip_static_ip + gateway: !secret manualip_gateway + subnet: !secret manualip_subnet + dns1: 1.1.1.1 + dns2: 1.0.0.1 + +api: + +# ota: +# password: "${devicename}" + +logger: + # esp8266_store_log_strings_in_flash: false +web_server: + port: 80 + +light: + - name: NeoPixel Strip 1 + id: neopixel_strip_1 + platform: neopixelbus + type: GRB + variant: WS2812 + pin: GPIO3 + num_leds: 199 + restore_mode: ALWAYS_ON + default_transition_length: 0s + color_correct: [100%, 100%, 100%] + method: + type: esp8266_dma + +display: + - platform: addressable_light + id: led_matrix_display + addressable_light_id: neopixel_strip_1 + width: 18 + height: 11 + rotation: 270° + update_interval: 16ms + auto_clear_enabled: false + pixel_mapper: |- + int mapping_even[] = {0, 2, 4, 6,8,10,11,13,15,17, 16,16,16,16, 16,16,16,16}; + int mapping_odd[] = {17,15,13,11,9, 7, 6, 4, 2, 0, 16,16,16,16, 16,16,16,16}; + if (x > 9) return -1; + if (y % 2 == 0) { + return (y * 18 + mapping_even[x]); // mapping_even[y]; + } else { + return (y * 18 + mapping_odd[x]); // mapping_odd[y]; + } + # lambda: |- + # it.fill(Color(0)); + # it.image(0, 0, id(foo)); + # //it.line(0,0,5,5, Color(0x00FF00)); + +# image: +# - id: foo +# file: ./test.png +# type: RGB24 +# resize: 18x11 + + +time: + - platform: sntp + id: current_time + timezone: !secret timezone + + +wordclock: + time_id: current_time + display_id: led_matrix_display + static_segments: ["IT", "IS"] + segments: + - {name: "IT", line: {x1: 0, x2: 1, y1: 0}} + - {name: "IS", line: {x1: 3, x2: 4, y1: 0}} + - {name: "AM", line: {x1: 7, x2: 8, y1: 0}} + - {name: "PM", line: {x1: 9, x2: 10, y1: 0}} + - {name: "QUARTER", line: {x1: 2, x2: 8, y1: 1}} + - {name: "TWENTY", line: {x1: 0, x2: 5, y1: 2}} + - {name: "FIVE_MINUTES", line: {x1: 6, x2: 9, y1: 2}} + - {name: "HALF", line: {x1: 0, x2: 3, y1: 3}} + - {name: "TEN_MINUTES", line: {x1: 5, x2: 7, y1: 3}} + - {name: "TO", line: {x1: 9, x2: 10, y1: 3}} + - {name: "PAST", line: {x1: 0, x2: 3, y1: 4}} + - {name: "NINE", line: {x1: 7, x2: 10, y1: 4}} + - {name: "ONE", line: {x1: 0, x2: 2, y1: 5}} + - {name: "SIX", line: {x1: 3, x2: 5, y1: 5}} + - {name: "THREE", line: {x1: 6, x2: 10, y1: 5}} + - {name: "FOUR", line: {x1: 0, x2: 3, y1: 6}} + - {name: "FIVE", line: {x1: 4, x2: 7, y1: 6}} + - {name: "TWO", line: {x1: 8, x2: 10, y1: 6}} + - {name: "EIGHT", line: {x1: 0, x2: 4, y1: 7}} + - {name: "ELEVEN", line: {x1: 5, x2: 10, y1: 7}} + - {name: "SEVEN", line: {x1: 0, x2: 4, y1: 8}} + - {name: "TWELVE", line: {x1: 5, x2: 10, y1: 8}} + - {name: "TEN", line: {x1: 0, x2: 2, y1: 9}} + - {name: "OCLOCK", line: {x1: 5, x2: 10, y1: 9}} + minutes: + - {minute: 0, segments: ["OCLOCK"]} + - {minute: 5, segments: ["FIVE_MINUTES", "PAST"]} + - {minute: 10, segments: ["TEN_MINUTES", "PAST"]} + - {minute: 15, segments: ["QUARTER", "PAST"]} + - {minute: 20, segments: ["TWENTY", "PAST"]} + - {minute: 25, segments: ["TWENTY", "FIVE_MINUTES", "PAST"]} + - {minute: 30, segments: ["HALF", "PAST"]} + - {minute: 35, segments: ["TWENTY", "FIVE_MINUTES", "TO"]} + - {minute: 40, segments: ["TWENTY", "TO"]} + - {minute: 45, segments: ["QUARTER", "TO"]} + - {minute: 50, segments: ["TEN_MINUTES", "TO"]} + - {minute: 55, segments: ["FIVE_MINUTES", "TO"]} + hours: + - {hour: 0, segments: ["TWELVE", "AM"]} + - {hour: 1, segments: ["ONE", "AM"]} + - {hour: 2, segments: ["TWO", "AM"]} + - {hour: 3, segments: ["THREE", "AM"]} + - {hour: 4, segments: ["FOUR", "AM"]} + - {hour: 5, segments: ["FIVE", "AM"]} + - {hour: 6, segments: ["SIX", "AM"]} + - {hour: 7, segments: ["SEVEN", "AM"]} + - {hour: 8, segments: ["EIGHT", "AM"]} + - {hour: 9, segments: ["NINE", "AM"]} + - {hour: 10, segments: ["TEN", "AM"]} + - {hour: 11, segments: ["ELEVEN", "AM"]} + - {hour: 12, segments: ["TWELVE", "PM"]} + - {hour: 13, segments: ["ONE", "PM"]} + - {hour: 14, segments: ["TWO", "PM"]} + - {hour: 15, segments: ["THREE", "PM"]} + - {hour: 16, segments: ["FOUR", "PM"]} + - {hour: 17, segments: ["FIVE", "PM"]} + - {hour: 18, segments: ["SIX", "PM"]} + - {hour: 19, segments: ["SEVEN", "PM"]} + - {hour: 20, segments: ["EIGHT", "PM"]} + - {hour: 21, segments: ["NINE", "PM"]} + - {hour: 22, segments: ["TEN", "PM"]} + - {hour: 23, segments: ["ELEVEN", "PM"]} \ No newline at end of file diff --git a/secrets.example.yaml b/secrets.example.yaml new file mode 100644 index 0000000..a6373f1 --- /dev/null +++ b/secrets.example.yaml @@ -0,0 +1,7 @@ +wifi_password: secret +wifi_ssid: example +manualip_static_ip: 192.168.1.177 +manualip_gateway: 192.168.1.1 +manualip_subnet: 255.255.255.0 +timezone: Europe/Zurich +ap_password: "password for internal fallback accesspoint" diff --git a/susannes_wordclock.yaml b/susannes_wordclock.yaml new file mode 100644 index 0000000..4680d96 --- /dev/null +++ b/susannes_wordclock.yaml @@ -0,0 +1,186 @@ +esphome: + name: "wordclock" + +esp8266: + board: d1_mini + framework: + version: recommended +# esp32: +# board: ttgo-t7-v13-mini32 +# framework: +# type: arduino + + +external_components: + - source: + type: local + path: components + components: [ wordclock ] + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "WordClock Configuration" + password: !secret ap_password + manual_ip: + static_ip: !secret manualip_static_ip + gateway: !secret manualip_gateway + subnet: !secret manualip_subnet + dns1: 1.1.1.1 + dns2: 1.0.0.1 + +captive_portal: + +api: + +ota: + password: "wordclock" + +logger: + +select: + - platform: template + name: "Template select" + restore_value: true + set_action: + - lambda: |- + return id(wordclock_1)->set_effect_active(false); + optimistic: true + options: + - effect + - color + initial_option: effect + +web_server: + port: 80 + version: 1 + +time: + - platform: sntp + id: current_time + timezone: !secret timezone + +light: + - name: NeoPixel Strip 1 + id: neopixel_strip_1 + platform: neopixelbus + type: GRB + variant: WS2812 + pin: GPIO3 + # pin: GPIO22 + num_leds: 198 + restore_mode: ALWAYS_OFF + method: + type: esp8266_dma + # type: esp32_i2s + + effects: + - addressable_random_twinkle: + name: "random_twinkle" + + +display: + - platform: addressable_light + id: led_matrix_display + addressable_light_id: neopixel_strip_1 + width: 18 + height: 11 + rotation: 270° + update_interval: 16ms + auto_clear_enabled: false + pixel_mapper: |- + int mapping_even[] = {0, 2, 4, 6,8,10,11,13,15,17, 16,16,16,16, 16,16,16,16}; + int mapping_odd[] = {17,15,13,11,9, 7, 6, 4, 2, 0, 16,16,16,16, 16,16,16,16}; + if (x > 9) return -1; + if (y % 2 == 0) { + return (y * 18 + mapping_even[x]); + } else { + return (y * 18 + mapping_odd[x]); + } + +color: + - id: col_on + red: 90% + green: 50% + blue: 0% + - id: col_off + red: 0% + green: 0% + blue: 0% + +wordclock: + id: wordclock_1 + time_id: current_time + display_id: led_matrix_display + addressable_light_id: neopixel_strip_1 + brightness: 100% + color_on: col_on + color_off: col_off + update_interval: 16ms + static_segments: ["IT", "IS"] + segments: + - {name: "IT", line: {x1: 0, x2: 1, y1: 0}} + - {name: "IS", line: {x1: 3, x2: 4, y1: 0}} + - {name: "AM", line: {x1: 7, x2: 8, y1: 0}} + - {name: "PM", line: {x1: 9, x2: 10, y1: 0}} + - {name: "QUARTER", line: {x1: 2, x2: 8, y1: 1}} + - {name: "TWENTY", line: {x1: 0, x2: 5, y1: 2}} + - {name: "FIVE_MINUTES", line: {x1: 6, x2: 9, y1: 2}} + - {name: "HALF", line: {x1: 0, x2: 3, y1: 3}} + - {name: "TEN_MINUTES", line: {x1: 5, x2: 7, y1: 3}} + - {name: "TO", line: {x1: 9, x2: 10, y1: 3}} + - {name: "PAST", line: {x1: 0, x2: 3, y1: 4}} + - {name: "NINE", line: {x1: 7, x2: 10, y1: 4}} + - {name: "ONE", line: {x1: 0, x2: 2, y1: 5}} + - {name: "SIX", line: {x1: 3, x2: 5, y1: 5}} + - {name: "THREE", line: {x1: 6, x2: 10, y1: 5}} + - {name: "FOUR", line: {x1: 0, x2: 3, y1: 6}} + - {name: "FIVE", line: {x1: 4, x2: 7, y1: 6}} + - {name: "TWO", line: {x1: 8, x2: 10, y1: 6}} + - {name: "EIGHT", line: {x1: 0, x2: 4, y1: 7}} + - {name: "ELEVEN", line: {x1: 5, x2: 10, y1: 7}} + - {name: "SEVEN", line: {x1: 0, x2: 4, y1: 8}} + - {name: "TWELVE", line: {x1: 5, x2: 10, y1: 8}} + - {name: "TEN", line: {x1: 0, x2: 2, y1: 9}} + - {name: "OCLOCK", line: {x1: 5, x2: 10, y1: 9}} + minutes: + - {minute: 0, hour_offset: 0, segments: ["OCLOCK"]} + - {minute: 5, hour_offset: 0, segments: ["FIVE_MINUTES", "PAST"]} + - {minute: 10, hour_offset: 0, segments: ["TEN_MINUTES", "PAST"]} + - {minute: 15, hour_offset: 0, segments: ["QUARTER", "PAST"]} + - {minute: 20, hour_offset: 0, segments: ["TWENTY", "PAST"]} + - {minute: 25, hour_offset: 0, segments: ["TWENTY", "FIVE_MINUTES", "PAST"]} + - {minute: 30, hour_offset: 0, segments: ["HALF", "PAST"]} + - {minute: 35, hour_offset: 1, segments: ["TWENTY", "FIVE_MINUTES", "TO"]} + - {minute: 40, hour_offset: 1, segments: ["TWENTY", "TO"]} + - {minute: 45, hour_offset: 1, segments: ["QUARTER", "TO"]} + - {minute: 50, hour_offset: 1, segments: ["TEN_MINUTES", "TO"]} + - {minute: 55, hour_offset: 1, segments: ["FIVE_MINUTES", "TO"]} + hours: + - {hour: 0, segments: "TWELVE"} + - {hour: 1, segments: "ONE"} + - {hour: 2, segments: "TWO"} + - {hour: 3, segments: "THREE"} + - {hour: 4, segments: "FOUR"} + - {hour: 5, segments: "FIVE"} + - {hour: 6, segments: "SIX"} + - {hour: 7, segments: "SEVEN"} + - {hour: 8, segments: "EIGHT"} + - {hour: 9, segments: "NINE"} + - {hour: 10, segments: "TEN"} + - {hour: 11, segments: "ELEVEN"} + - {hour: 12, segments: "TWELVE"} + - {hour: 13, segments: "ONE"} + - {hour: 14, segments: "TWO"} + - {hour: 15, segments: "THREE"} + - {hour: 16, segments: "FOUR"} + - {hour: 17, segments: "FIVE"} + - {hour: 18, segments: "SIX"} + - {hour: 19, segments: "SEVEN"} + - {hour: 20, segments: "EIGHT"} + - {hour: 21, segments: "NINE"} + - {hour: 22, segments: "TEN"} + - {hour: 23, segments: "ELEVEN"} + + \ No newline at end of file diff --git a/susannes_wordclock_esp32.yaml b/susannes_wordclock_esp32.yaml new file mode 100644 index 0000000..38987ce --- /dev/null +++ b/susannes_wordclock_esp32.yaml @@ -0,0 +1,327 @@ +esphome: + name: "wordclock" + +esp32: + # board: ttgo-t7-v13-mini32 + board: wemos_d1_mini32 + framework: + type: arduino + +external_components: + - source: + type: local + path: components + components: [ wordclock ] + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "WordClock Configuration" + password: !secret ap_password + manual_ip: + static_ip: !secret manualip_static_ip + gateway: !secret manualip_gateway + subnet: !secret manualip_subnet + dns1: 1.1.1.1 + dns2: 1.0.0.1 + +captive_portal: + +# api: + +ota: + password: "wordclock" + +logger: + +switch: + - platform: template + name: "5 Rainbow ON/OFF" + # lambda: |- + # return id(wordclock_1)->get_effect_active(); + restore_state: true + optimistic: true + restore_mode: RESTORE_DEFAULT_ON + turn_on_action: + - lambda: |- + id(wordclock_1)->set_effect_active(true); + turn_off_action: + - lambda: |- + id(wordclock_1)->set_effect_active(false); + +# on_...: +# - globals.set: +# id: my_global_var +# value: '10' + + +# output: +# - platform: template +# id: dummy_out +# type: float +# write_action: +# - lambda: |- +# return id(wordclock_1)->set_effect_active(true); + # Example configuration entry +globals: + - id: my_global_int + type: int + restore_value: no + initial_value: '0' + # Example for global string variable + - id: my_global_string + type: std::string + restore_value: no # Strings cannot be saved/restored + initial_value: '"Global value is"' + +number: + - platform: template + name: "1 Red" + optimistic: true + restore_value: true + initial_value: 255 + # lambda: |- + # return id(wordclock_1)->on_color.red; + on_value: + - lambda: |- + id(wordclock_1)->on_color.red = x; + set_action: + - lambda: |- + id(wordclock_1)->on_color.red = x; + min_value: 0 + max_value: 255 + step: 1 + - platform: template + name: "2 Green" + optimistic: true + restore_value: true + initial_value: 255 + # lambda: |- + # return id(wordclock_1)->on_color.green; + on_value: + - lambda: |- + id(wordclock_1)->on_color.green = x; + set_action: + - lambda: |- + id(wordclock_1)->on_color.green = x; + min_value: 0 + max_value: 255 + step: 1 + - platform: template + name: "3 Blue" + optimistic: true + restore_value: true + initial_value: 255 + # lambda: |- + # return id(wordclock_1)->on_color.blue; + on_value: + - lambda: |- + id(wordclock_1)->on_color.blue = x; + set_action: + - lambda: |- + id(wordclock_1)->on_color.blue = x; + min_value: 0 + max_value: 255 + step: 1 + - platform: template + name: "4 Brightness" + optimistic: true + restore_value: true + initial_value: 255 + # lambda: |- + # return id(wordclock_1)->brightness; + on_value: + - lambda: |- + id(wordclock_1)->brightness = x; + set_action: + - lambda: |- + id(wordclock_1)->brightness = x; + min_value: 0 + max_value: 255 + step: 1 + - platform: template + name: "6 Rainbow Speed" + optimistic: true + restore_value: true + initial_value: 12 + on_value: + - lambda: |- + id(wordclock_1)->set_effect_speed(x); + set_action: + - lambda: |- + id(wordclock_1)->set_effect_speed(x); + min_value: 1 + max_value: 4096 + step: 1 + - platform: template + name: "7 Rainbow Width" + optimistic: true + restore_value: true + initial_value: 50 + on_value: + - lambda: |- + id(wordclock_1)->set_effect_width(x); + set_action: + - lambda: |- + id(wordclock_1)->set_effect_width(x); + min_value: 1 + max_value: 65535 + step: 1 + +# select: +# - platform: template +# name: "Rainbow ON/OFF" +# restore_value: true +# set_action: +# - lambda: |- +# return id(wordclock_1)->set_color_value(x); +# optimistic: true +# options: +# - effect +# - color +# initial_option: effect +# - platform: template +# name: "Template select" +# restore_value: true +# set_action: +# - lambda: |- +# return id(wordclock_1)->set_color_value(x); +# optimistic: true +# options: +# - effect +# - color +# initial_option: effect + + +web_server: + port: 80 + version: 2 + local: true + +time: + - platform: sntp + id: current_time + timezone: !secret timezone + + +light: + - name: NeoPixel Strip 1 + id: neopixel_strip_1 + platform: neopixelbus + type: GRB + variant: WS2812 + pin: GPIO22 + num_leds: 198 + restore_mode: ALWAYS_OFF + internal: true + method: + type: esp32_i2s + effects: + - addressable_random_twinkle: + name: "random_twinkle" + +display: + - platform: addressable_light + id: led_matrix_display + addressable_light_id: neopixel_strip_1 + width: 18 + height: 11 + rotation: 270° + update_interval: 16ms + auto_clear_enabled: false + pixel_mapper: |- + int mapping_even[] = {0, 2, 4, 6,8,10,11,13,15,17, 16,16,16,16, 16,16,16,16}; + int mapping_odd[] = {17,15,13,11,9, 7, 6, 4, 2, 0, 16,16,16,16, 16,16,16,16}; + if (x > 9) return -1; + if (y % 2 == 0) { + return (y * 18 + mapping_even[x]); + } else { + return (y * 18 + mapping_odd[x]); + } + +color: + - id: col_on + red: 90% + green: 50% + blue: 0% + - id: col_off + red: 0% + green: 0% + blue: 0% + +wordclock: + id: wordclock_1 + time_id: current_time + display_id: led_matrix_display + addressable_light_id: neopixel_strip_1 + brightness: 100% + color_on: col_on + color_off: col_off + update_interval: 16ms + static_segments: ["IT", "IS"] + segments: + - {name: "IT", line: {x1: 0, x2: 1, y1: 0}} + - {name: "IS", line: {x1: 3, x2: 4, y1: 0}} + - {name: "AM", line: {x1: 7, x2: 8, y1: 0}} + - {name: "PM", line: {x1: 9, x2: 10, y1: 0}} + - {name: "QUARTER", line: {x1: 2, x2: 8, y1: 1}} + - {name: "TWENTY", line: {x1: 0, x2: 5, y1: 2}} + - {name: "FIVE_MINUTES", line: {x1: 6, x2: 9, y1: 2}} + - {name: "HALF", line: {x1: 0, x2: 3, y1: 3}} + - {name: "TEN_MINUTES", line: {x1: 5, x2: 7, y1: 3}} + - {name: "TO", line: {x1: 9, x2: 10, y1: 3}} + - {name: "PAST", line: {x1: 0, x2: 3, y1: 4}} + - {name: "NINE", line: {x1: 7, x2: 10, y1: 4}} + - {name: "ONE", line: {x1: 0, x2: 2, y1: 5}} + - {name: "SIX", line: {x1: 3, x2: 5, y1: 5}} + - {name: "THREE", line: {x1: 6, x2: 10, y1: 5}} + - {name: "FOUR", line: {x1: 0, x2: 3, y1: 6}} + - {name: "FIVE", line: {x1: 4, x2: 7, y1: 6}} + - {name: "TWO", line: {x1: 8, x2: 10, y1: 6}} + - {name: "EIGHT", line: {x1: 0, x2: 4, y1: 7}} + - {name: "ELEVEN", line: {x1: 5, x2: 10, y1: 7}} + - {name: "SEVEN", line: {x1: 0, x2: 4, y1: 8}} + - {name: "TWELVE", line: {x1: 5, x2: 10, y1: 8}} + - {name: "TEN", line: {x1: 0, x2: 2, y1: 9}} + - {name: "OCLOCK", line: {x1: 5, x2: 10, y1: 9}} + minutes: + - {minute: 0, hour_offset: 0, segments: ["OCLOCK"]} + - {minute: 5, hour_offset: 0, segments: ["FIVE_MINUTES", "PAST"]} + - {minute: 10, hour_offset: 0, segments: ["TEN_MINUTES", "PAST"]} + - {minute: 15, hour_offset: 0, segments: ["QUARTER", "PAST"]} + - {minute: 20, hour_offset: 0, segments: ["TWENTY", "PAST"]} + - {minute: 25, hour_offset: 0, segments: ["TWENTY", "FIVE_MINUTES", "PAST"]} + - {minute: 30, hour_offset: 0, segments: ["HALF", "PAST"]} + - {minute: 35, hour_offset: 1, segments: ["TWENTY", "FIVE_MINUTES", "TO"]} + - {minute: 40, hour_offset: 1, segments: ["TWENTY", "TO"]} + - {minute: 45, hour_offset: 1, segments: ["QUARTER", "TO"]} + - {minute: 50, hour_offset: 1, segments: ["TEN_MINUTES", "TO"]} + - {minute: 55, hour_offset: 1, segments: ["FIVE_MINUTES", "TO"]} + hours: + - {hour: 0, segments: "TWELVE"} + - {hour: 1, segments: "ONE"} + - {hour: 2, segments: "TWO"} + - {hour: 3, segments: "THREE"} + - {hour: 4, segments: "FOUR"} + - {hour: 5, segments: "FIVE"} + - {hour: 6, segments: "SIX"} + - {hour: 7, segments: "SEVEN"} + - {hour: 8, segments: "EIGHT"} + - {hour: 9, segments: "NINE"} + - {hour: 10, segments: "TEN"} + - {hour: 11, segments: "ELEVEN"} + - {hour: 12, segments: "TWELVE"} + - {hour: 13, segments: "ONE"} + - {hour: 14, segments: "TWO"} + - {hour: 15, segments: "THREE"} + - {hour: 16, segments: "FOUR"} + - {hour: 17, segments: "FIVE"} + - {hour: 18, segments: "SIX"} + - {hour: 19, segments: "SEVEN"} + - {hour: 20, segments: "EIGHT"} + - {hour: 21, segments: "NINE"} + - {hour: 22, segments: "TEN"} + - {hour: 23, segments: "ELEVEN"} + + \ No newline at end of file diff --git a/test.png b/test.png new file mode 100644 index 0000000..a6acecd Binary files /dev/null and b/test.png differ diff --git a/wordclock.h b/wordclock.h new file mode 100644 index 0000000..95feeb5 --- /dev/null +++ b/wordclock.h @@ -0,0 +1,228 @@ +#include "esphome.h" +#include + +// By now only loosely based on https://github.com/leinich/ha-wordclock-esphome + +// esphome dependencies: +// needs: esphome time --> id: current_time +// needs: esphome fastled --> id: fastledlight + +///// Random Stuff ///// + +#ifndef WORDCLOCK_NUM_LEDS +#define WORDCLOCK_NUM_LEDS 198 +#endif + +#ifndef WORDCLOCK_DATA_PIN +#define WORDCLOCK_DATA_PIN 22 +#endif + +/* NOTE: +This section is about mapping LED indices to +positions in the grid. + +On EVEN rows, the LED strip is running along the +matrix indices, but the physical positions of the +LEDs on the strip did not align with the letter +cutouts. + +On ODD rows, the string is running backwards, because +it is snaking its way back and forth. */ +int mapping_even[] = {0, 2, 4, 6,8,10,11,13,15,17}; +int mapping_odd[] = {17,15,13,11,9, 7, 6, 4, 2, 0}; +int map_coords_to_strip(int row, int column) { + if (column % 2) { + return (10 - column) * 18 + mapping_odd[row]; + } else { + return (10 - column) * 18 + mapping_even [row]; + } +} + +///// Word Table ///// +int WORD_IT_IS[5][2] = {{4,0}, {0,0}, {0,1}, {0,3}, {0,4}}; + +int WORD_QUARTER[8][2] = {{7,0}, {1,2}, {1,3}, {1,4}, {1,5}, {1,6}, {1,7}, {1,8}}; +int WORD_TWENTY[7][2] = {{6,0}, {2,0}, {2,1}, {2,2}, {2,3}, {2,4}, {2,5}}; +int WORD_FIVE_MINUTES[5][2] = {{4,0}, {2,6}, {2,7}, {2,8}, {2,9}}; +int WORD_HALF[5][2] = {{4,0}, {3,0}, {3,1}, {3,2}, {3,3}}; +int WORD_TEN_MINUTES[4][2] = {{3,0}, {3,5}, {3,6}, {3,7}}; + +int WORD_TO[3][2] = {{2,0}, {3,9}, {3,10}}; +int WORD_PAST[5][2] = {{4,0}, {4,0}, {4,1}, {4,2}, {4,3}}; + +int WORD_NINE[5][2] = {{4,0}, {4,7}, {4,8}, {4,9}, {4,10}}; +int WORD_ONE[4][2] = {{3,0}, {5,0}, {5,1}, {5,2}}; +int WORD_SIX[4][2] = {{3,0}, {5,3}, {5,4}, {5,5}}; +int WORD_THREE[6][2] = {{5,0}, {5,6}, {5,7}, {5,8}, {5,9}, {5,10}}; +int WORD_FOUR[5][2] = {{4,0}, {6,0}, {6,1}, {6,2}, {6,3}}; +int WORD_FIVE[5][2] = {{4,0}, {6,4}, {6,5}, {6,6}, {6,7}}; +int WORD_TWO[4][2] = {{3,0}, {6,8}, {6,9}, {6,10}}; +int WORD_EIGHT[6][2] = {{5,0}, {7,0}, {7,1}, {7,2}, {7,3}, {7,4}}; +int WORD_ELEVEN[7][2] = {{6,0}, {7,5}, {7,6}, {7,7}, {7,8}, {7,9}, {7,10}}; +int WORD_SEVEN[6][2] = {{5,0}, {8,0}, {8,1}, {8,2}, {8,3}, {8,4}}; +int WORD_TWELFE[7][2] = {{6,0}, {8,5}, {8,6}, {8,7}, {8,8}, {8,9}, {8,10}}; +int WORD_TEN[4][2] = {{3,0}, {9,0}, {9,1}, {9,2}}; + +int WORD_OCLOCK[7][2] = {{6,0}, {9,5}, {9,6}, {9,7}, {9,8}, {9,9}, {9,10}}; + +// TODO: It's probably unnecessary to have the "API" in CustomAPIDevice, since this +// component doesn't actually register any services anymore. +class Wordclock : public Component, public CustomAPIDevice { + public: + CRGB leds[WORDCLOCK_NUM_LEDS]; + int hour = -1; + int minute = -1; + int second = -1; + int red = 124; + int green = 124; + int blue = 124; + int brightness = 50; + + void setup() override { + FastLED.addLeds(leds, WORDCLOCK_NUM_LEDS); + FastLED.setBrightness(brightness); + clear_all_leds(); + FastLED.show(); + // TODO: Set up some kind of initialization sequence. But it should be based on an effect or similarly supporting the + // cooperative multithreading. delay() calls are uncalled for. + } + + void clear_all_leds() { + for(int i = 0; i < WORDCLOCK_NUM_LEDS; i++) { + leds[i].setRGB(0, 0, 0); + } + } + + void display_word(const int word[][2], const CRGB& c) { + for (int i=1; i < word[0][0] + 1; i++) { + leds[map_coords_to_strip(word[i][0], word[i][1])].setRGB(c.r, c.g, c.b); + } + } + + void display_minutes(int minutes, const CRGB& color) { + int five_minute_chunk = minutes / 5; + + switch (five_minute_chunk) + { + case 0: // sharp + display_word(WORD_OCLOCK, color); ESP_LOGD("minute", "oclock "); break; + case 1: // five past + display_word(WORD_FIVE_MINUTES, color); ESP_LOGD("minute", "five past "); break; + case 2: // ten past + display_word(WORD_TEN_MINUTES, color); ESP_LOGD("minute", "ten past "); break; + case 3: // quarter past + display_word(WORD_QUARTER, color); ESP_LOGD("minute", "quarter past "); break; + case 4: // twenty past + display_word(WORD_TWENTY, color); ESP_LOGD("minute", "twenty past "); break; + case 5: // twenty five past + display_word(WORD_TWENTY, color); display_word(WORD_FIVE_MINUTES, color); + ESP_LOGD("minute", "twenty five past "); break; + case 6: // half past + display_word(WORD_HALF, color); ESP_LOGD("minute", "half past "); break; + case 7: // twenty five to + display_word(WORD_TWENTY, color); display_word(WORD_FIVE_MINUTES, color); + ESP_LOGD("minute", "twenty five to "); break; + case 8: // twenty to + display_word(WORD_TWENTY, color); ESP_LOGD("minute", "twenty to "); break; + case 9: // quarter to + display_word(WORD_QUARTER, color); ESP_LOGD("minute", "quarter to "); break; + case 10: // ten to + display_word(WORD_TEN_MINUTES, color); ESP_LOGD("minute", "ten to "); break; + case 11: // five to + display_word(WORD_FIVE_MINUTES, color); ESP_LOGD("minute", "five to "); break; + default: + break; + } + if (five_minute_chunk > 6) { + display_word(WORD_TO, color); + } else if (five_minute_chunk > 0) { + display_word(WORD_PAST, color); + } + } + + void display_hour(int hour, int minutes, const CRGB& color) { + int five_minute_chunk = minutes / 5; + if (five_minute_chunk > 6) { + hour += 1; + } + + switch (hour % 12) + { + case 0: // twelve + display_word(WORD_TWELFE, color); ESP_LOGD("hour", "twelve "); break; + case 1: // one + display_word(WORD_ONE, color); ESP_LOGD("hour", "one "); break; + case 2: // two + display_word(WORD_TWO, color); ESP_LOGD("hour", "two "); break; + case 3: // three + display_word(WORD_THREE, color); ESP_LOGD("hour", "three "); break; + case 4: // four + display_word(WORD_FOUR, color); ESP_LOGD("hour", "four "); break; + case 5: // five + display_word(WORD_FIVE, color); ESP_LOGD("hour", "five "); break; + case 6: // six + display_word(WORD_SIX, color); ESP_LOGD("hour", "six "); break; + case 7: // seven + display_word(WORD_SEVEN, color); ESP_LOGD("hour", "seven "); break; + case 8: // eight + display_word(WORD_EIGHT, color); ESP_LOGD("hour", "eight "); break; + case 9: // nine + display_word(WORD_NINE, color); ESP_LOGD("hour", "nine "); break; + case 10: // ten + display_word(WORD_TEN, color); ESP_LOGD("hour", "ten "); break; + case 11: // eleven + display_word(WORD_ELEVEN, color); ESP_LOGD("hour", "eleven "); break; + default: + break; + } + } + + void display_time(int hour, int minutes, const CRGB& color) { + display_word(WORD_IT_IS, color); + display_hour(hour, minutes, color); + display_minutes(minutes, color); + } + + void loop() override { + auto time = id(current_time).now(); + // https://www.esphome.io/api/classesphome_1_1light_1_1_light_color_values.html LightColorValues Class + auto fastledlight2 = id(fastledlight).current_values; + //convert float 0.0 till 1.0 into int 0 till 255 + red = (int) (fastledlight2.get_red() * 125); + green = (int) (fastledlight2.get_green() * 125); + blue = (int) (fastledlight2.get_blue() * 125); + brightness = 0; + //check if light is on and set brightness + if (fastledlight2.get_state() > 0 ) { + brightness = (int) (fastledlight2.get_brightness()*125); + } else { + // ESP_LOGD("loop", "fastledlight state off - b: %i rgb %i %i %i", brightness, red, green, blue); delay(100); + } + + FastLED.setBrightness(brightness); + FastLED.show(); + //check if valid time. Blink red,green,blue until valid time is present + if (time.is_valid() == false) { + ESP_LOGD("loop", "time is not valid"); + // do something to show that the clock isn't dead. Maybe I can instantiate and effect and use that for this. + } + else { + if (time.second != second) { + second = time.second; + ESP_LOGD("loop", "time is now [%02i:%02i:%02i]", time.hour, time.minute, time.second); + } + if(time.hour != hour || time.minute != minute) { + hour = time.hour; + minute = time.minute; + if (hour >= 0 && time.is_valid() == true){ + + clear_all_leds(); + display_time(time.hour, time.minute, CRGB(red, green, blue)); + FastLED.show(); + + ESP_LOGE("loop", "Update Time: %i:%i Brightness: %i RGB: %i-%i-%i", time.hour, time.minute, brightness, red, green, blue); + } + } + } + } +}; diff --git a/wordclock.yaml b/wordclock.yaml new file mode 100644 index 0000000..d3250e1 --- /dev/null +++ b/wordclock.yaml @@ -0,0 +1,74 @@ +esphome: + name: "${devicename}" + platformio_options: + build_flags: + - -DWORDCLOCK_DATA_PIN=22 + - -DWORDCLOCK_NUM_LEDS=198 + includes: + - wordclock.h + +external_components: + - source: + type: local + path: components + components: [ wordcl ] + +esp8266: + board: d1_mini + framework: + version: recommended + +# esp32: +# board: ttgo-t7-v13-mini32 +# framework: +# type: arduino + +substitutions: + devicename: wordclock + friendly_name: "Wordclock" + light_friendly_name: "Wordclock Light" + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + ap: + ssid: "${devicename}" + password: !secret ap_password + manual_ip: + static_ip: !secret manualip_static_ip + gateway: !secret manualip_gateway + subnet: !secret manualip_subnet + dns1: 1.1.1.1 + dns2: 1.0.0.1 + +api: + +ota: + password: "${devicename}" + +logger: + # esp8266_store_log_strings_in_flash: false +web_server: + port: 80 + +light: + - platform: fastled_clockless + id: fastledlight + chipset: WS2812 + pin: 23 + num_leds: 198 + rgb_order: GRB + name: ${light_friendly_name} + restore_mode: ALWAYS_ON + +time: + - platform: sntp + id: current_time + timezone: !secret timezone + +custom_component: + - lambda: |- + auto wordclock = new Wordclock(); + return {wordclock}; + components: + - id: wordclock diff --git a/wordclock8266.yaml b/wordclock8266.yaml new file mode 100644 index 0000000..5986c77 --- /dev/null +++ b/wordclock8266.yaml @@ -0,0 +1,89 @@ +esphome: + name: "wordclock" + +esp8266: + board: d1_mini + framework: + version: recommended + +external_components: + - source: + type: local + path: components + components: [ wordcl ] + +# substitutions: +# devicename: wordclock +# friendly_name: "Wordclock" +# light_friendly_name: "Wordclock Light" + +# wifi: +# ssid: !secret wifi_ssid +# password: !secret wifi_password +# ap: +# ssid: "${devicename}" +# password: !secret ap_password +# manual_ip: +# static_ip: !secret manualip_static_ip +# gateway: !secret manualip_gateway +# subnet: !secret manualip_subnet +# dns1: 1.1.1.1 +# dns2: 1.0.0.1 + +# api: + +# ota: +# password: "${devicename}" + +# logger: +# # esp8266_store_log_strings_in_flash: false +# web_server: +# port: 80 + +wordclock: + +light: + - name: neopixel + id: neopixel_1 + platform: neopixelbus + type: GRB + variant: WS2812 + pin: GPIO3 + num_leds: 30 + default_transition_length: 0.2s + # restore_mode: RESTORE_DEFAULT_ON + method: + type: esp8266_dma + on_turn_on: + then: + - light.turn_on: + id: neopixel_1 + brightness: 35% + effect: rainbow + effects: + # - random: + # - pulse: + # - strobe: + # - flicker: + - addressable_rainbow: + name: rainbow + # - addressable_color_wipe: + # - addressable_scan: + # - addressable_twinkle: + # - addressable_random_twinkle: + # - addressable_fireworks: + # - addressable_flicker: + # - wled: + + + + +# time: +# - platform: sntp +# id: current_time +# timezone: !secret timezone + +# - light.turn_on: +# id: light_1 +# brightness: 100% +# effect: addressable_rainbow \ No newline at end of file diff --git a/wordclock_new.yaml b/wordclock_new.yaml new file mode 100644 index 0000000..cdaf34e --- /dev/null +++ b/wordclock_new.yaml @@ -0,0 +1,29 @@ +esphome: + name: wordclock-livingroom + +esp32: + board: ttgo-t7-v13-mini32 + framework: + type: arduino + +# Enable logging +logger: + +# Enable Home Assistant API +api: + password: "" + +ota: + password: "" + +wifi: + ssid: "wifithing" + password: "lostandfound1" + + # Enable fallback hotspot (captive portal) in case wifi connection fails + ap: + ssid: "Wordclock-Livingroom" + password: "wGfGBPnJcbzE" + +captive_portal: + \ No newline at end of file