From f6e3070dd8bb92636db7b30593874f078bc79940 Mon Sep 17 00:00:00 2001 From: Guillermo Ruffino Date: Sat, 25 Jul 2020 12:57:11 -0300 Subject: [PATCH] rtttl player (#1171) * rtttl player * fixes * Cleanup, add action, condition, etc. * add test * updates * fixes * Add better error messages * lint --- esphome/components/esp8266_pwm/esp8266_pwm.h | 2 +- esphome/components/ledc/ledc_output.cpp | 4 +- esphome/components/ledc/ledc_output.h | 4 +- esphome/components/output/float_output.h | 13 +- esphome/components/rtttl/__init__.py | 69 +++++++ esphome/components/rtttl/rtttl.cpp | 186 +++++++++++++++++++ esphome/components/rtttl/rtttl.h | 81 ++++++++ tests/test1.yaml | 3 + 8 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 esphome/components/rtttl/__init__.py create mode 100644 esphome/components/rtttl/rtttl.cpp create mode 100644 esphome/components/rtttl/rtttl.h diff --git a/esphome/components/esp8266_pwm/esp8266_pwm.h b/esphome/components/esp8266_pwm/esp8266_pwm.h index 51b74f48ba..661db6611f 100644 --- a/esphome/components/esp8266_pwm/esp8266_pwm.h +++ b/esphome/components/esp8266_pwm/esp8266_pwm.h @@ -13,7 +13,7 @@ class ESP8266PWM : public output::FloatOutput, public Component { void set_pin(GPIOPin *pin) { pin_ = pin; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically update frequency - void update_frequency(float frequency) { + void update_frequency(float frequency) override { this->set_frequency(frequency); this->write_state(this->last_output_); } diff --git a/esphome/components/ledc/ledc_output.cpp b/esphome/components/ledc/ledc_output.cpp index 2b1c181a62..d4e3327bb1 100644 --- a/esphome/components/ledc/ledc_output.cpp +++ b/esphome/components/ledc/ledc_output.cpp @@ -22,7 +22,7 @@ void LEDCOutput::write_state(float state) { } void LEDCOutput::setup() { - this->apply_frequency(this->frequency_); + this->update_frequency(this->frequency_); this->turn_off(); // Attach pin after setting default value ledcAttachPin(this->pin_->get_pin(), this->channel_); @@ -50,7 +50,7 @@ optional ledc_bit_depth_for_frequency(float frequency) { return {}; } -void LEDCOutput::apply_frequency(float frequency) { +void LEDCOutput::update_frequency(float frequency) { auto bit_depth_opt = ledc_bit_depth_for_frequency(frequency); if (!bit_depth_opt.has_value()) { ESP_LOGW(TAG, "Frequency %f can't be achieved with any bit depth", frequency); diff --git a/esphome/components/ledc/ledc_output.h b/esphome/components/ledc/ledc_output.h index 3f56f502b0..b3b14fe855 100644 --- a/esphome/components/ledc/ledc_output.h +++ b/esphome/components/ledc/ledc_output.h @@ -19,7 +19,7 @@ class LEDCOutput : public output::FloatOutput, public Component { void set_channel(uint8_t channel) { this->channel_ = channel; } void set_frequency(float frequency) { this->frequency_ = frequency; } /// Dynamically change frequency at runtime - void apply_frequency(float frequency); + void update_frequency(float frequency) override; /// Setup LEDC. void setup() override; @@ -45,7 +45,7 @@ template class SetFrequencyAction : public Action { void play(Ts... x) { float freq = this->frequency_.value(x...); - this->parent_->apply_frequency(freq); + this->parent_->update_frequency(freq); } protected: diff --git a/esphome/components/output/float_output.h b/esphome/components/output/float_output.h index e3f852b3f6..1b969c9225 100644 --- a/esphome/components/output/float_output.h +++ b/esphome/components/output/float_output.h @@ -46,9 +46,20 @@ class FloatOutput : public BinaryOutput { */ void set_min_power(float min_power); - /// Set the level of this float output, this is called from the front-end. + /** Set the level of this float output, this is called from the front-end. + * + * @param state The new state. + */ void set_level(float state); + /** Set the frequency of the output for PWM outputs. + * + * Implemented only by components which can set the output PWM frequency. + * + * @param frequence The new frequency. + */ + virtual void update_frequency(float frequency) {} + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/rtttl/__init__.py b/esphome/components/rtttl/__init__.py new file mode 100644 index 0000000000..df227ab20f --- /dev/null +++ b/esphome/components/rtttl/__init__.py @@ -0,0 +1,69 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome import automation +from esphome.components.output import FloatOutput +from esphome.const import CONF_ID, CONF_OUTPUT, CONF_TRIGGER_ID + +CONF_RTTTL = 'rtttl' +CONF_ON_FINISHED_PLAYBACK = 'on_finished_playback' + +rtttl_ns = cg.esphome_ns.namespace('rtttl') + +Rtttl = rtttl_ns .class_('Rtttl', cg.Component) +PlayAction = rtttl_ns.class_('PlayAction', automation.Action) +StopAction = rtttl_ns.class_('StopAction', automation.Action) +FinishedPlaybackTrigger = rtttl_ns.class_('FinishedPlaybackTrigger', + automation.Trigger.template()) +IsPlayingCondition = rtttl_ns.class_('IsPlayingCondition', automation.Condition) + +MULTI_CONF = True + +CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(CONF_ID): cv.declare_id(Rtttl), + cv.Required(CONF_OUTPUT): cv.use_id(FloatOutput), + cv.Optional(CONF_ON_FINISHED_PLAYBACK): automation.validate_automation({ + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FinishedPlaybackTrigger), + }), +}).extend(cv.COMPONENT_SCHEMA) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + + out = yield cg.get_variable(config[CONF_OUTPUT]) + cg.add(var.set_output(out)) + + for conf in config.get(CONF_ON_FINISHED_PLAYBACK, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + yield automation.build_automation(trigger, [], conf) + + +@automation.register_action('rtttl.play', PlayAction, cv.maybe_simple_value({ + cv.GenerateID(CONF_ID): cv.use_id(Rtttl), + cv.Required(CONF_RTTTL): cv.templatable(cv.string) +}, key=CONF_RTTTL)) +def rtttl_play_to_code(config, action_id, template_arg, args): + paren = yield cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, paren) + template_ = yield cg.templatable(config[CONF_RTTTL], args, cg.std_string) + cg.add(var.set_value(template_)) + yield var + + +@automation.register_action('rtttl.stop', StopAction, cv.Schema({ + cv.GenerateID(): cv.use_id(Rtttl), +})) +def rtttl_stop_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + yield cg.register_parented(var, config[CONF_ID]) + yield var + + +@automation.register_condition('rtttl.is_playing', IsPlayingCondition, cv.Schema({ + cv.GenerateID(): cv.use_id(Rtttl), +})) +def rtttl_is_playing_to_code(config, condition_id, template_arg, args): + var = cg.new_Pvariable(condition_id, template_arg) + yield cg.register_parented(var, config[CONF_ID]) + yield var diff --git a/esphome/components/rtttl/rtttl.cpp b/esphome/components/rtttl/rtttl.cpp new file mode 100644 index 0000000000..da50e1cbe9 --- /dev/null +++ b/esphome/components/rtttl/rtttl.cpp @@ -0,0 +1,186 @@ +#include "rtttl.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace rtttl { + +static const char* TAG = "rtttl"; + +static const uint32_t DOUBLE_NOTE_GAP_MS = 10; + +// These values can also be found as constants in the Tone library (Tone.h) +static const uint16_t NOTES[] = {0, 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, + 523, 554, 587, 622, 659, 698, 740, 784, 831, 880, 932, 988, 1047, + 1109, 1175, 1245, 1319, 1397, 1480, 1568, 1661, 1760, 1865, 1976, 2093, 2217, + 2349, 2489, 2637, 2794, 2960, 3136, 3322, 3520, 3729, 3951}; + +void Rtttl::dump_config() { ESP_LOGCONFIG(TAG, "Rtttl"); } + +void Rtttl::play(std::string rtttl) { + rtttl_ = std::move(rtttl); + + default_duration_ = 4; + default_octave_ = 6; + int bpm = 63; + uint8_t num; + + // Get name + position_ = rtttl_.find(':'); + + // it's somewhat documented to be up to 10 characters but let's be a bit flexible here + if (position_ == std::string::npos || position_ > 15) { + ESP_LOGE(TAG, "Missing ':' when looking for name."); + return; + } + + auto name = this->rtttl_.substr(0, position_); + ESP_LOGD(TAG, "Playing song %s", name.c_str()); + + // get default duration + position_ = this->rtttl_.find("d=", position_); + if (position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing 'd='"); + return; + } + position_ += 2; + num = this->get_integer_(); + if (num > 0) + default_duration_ = num; + + // get default octave + position_ = rtttl_.find("o=", position_); + if (position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing 'o="); + return; + } + position_ += 2; + num = get_integer_(); + if (num >= 3 && num <= 7) + default_octave_ = num; + + // get BPM + position_ = rtttl_.find("b=", position_); + if (position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing b="); + return; + } + position_ += 2; + num = get_integer_(); + if (num != 0) + bpm = num; + + position_ = rtttl_.find(':', position_); + if (position_ == std::string::npos) { + ESP_LOGE(TAG, "Missing second ':'"); + return; + } + position_++; + + // BPM usually expresses the number of quarter notes per minute + wholenote_ = 60 * 1000L * 4 / bpm; // this is the time for whole note (in milliseconds) + + output_freq_ = 0; + last_note_ = millis(); + note_duration_ = 1; +} + +void Rtttl::loop() { + if (note_duration_ == 0 || millis() - last_note_ < note_duration_) + return; + + if (!rtttl_[position_]) { + output_->set_level(0.0); + ESP_LOGD(TAG, "Playback finished"); + this->on_finished_playback_callback_.call(); + note_duration_ = 0; + return; + } + + // align to note: most rtttl's out there does not add and space after the ',' separator but just in case... + while (rtttl_[position_] == ',' || rtttl_[position_] == ' ') + position_++; + + // first, get note duration, if available + uint8_t num = this->get_integer_(); + + if (num) + note_duration_ = wholenote_ / num; + else + note_duration_ = wholenote_ / default_duration_; // we will need to check if we are a dotted note after + + uint8_t note; + + switch (rtttl_[position_]) { + case 'c': + note = 1; + break; + case 'd': + note = 3; + break; + case 'e': + note = 5; + break; + case 'f': + note = 6; + break; + case 'g': + note = 8; + break; + case 'a': + note = 10; + break; + case 'b': + note = 12; + break; + case 'p': + default: + note = 0; + } + position_++; + + // now, get optional '#' sharp + if (rtttl_[position_] == '#') { + note++; + position_++; + } + + // now, get optional '.' dotted note + if (rtttl_[position_] == '.') { + note_duration_ += note_duration_ / 2; + position_++; + } + + // now, get scale + uint8_t scale = get_integer_(); + if (scale == 0) + scale = default_octave_; + + // Now play the note + if (note) { + auto note_index = (scale - 4) * 12 + note; + if (note_index < 0 || note_index >= sizeof(NOTES)) { + ESP_LOGE(TAG, "Note out of valid range"); + return; + } + auto freq = NOTES[note_index]; + + if (freq == output_freq_) { + // Add small silence gap between same note + output_->set_level(0.0); + delay(DOUBLE_NOTE_GAP_MS); + note_duration_ -= DOUBLE_NOTE_GAP_MS; + } + output_freq_ = freq; + + ESP_LOGVV(TAG, "playing note: %d for %dms", note, note_duration_); + output_->update_frequency(freq); + output_->set_level(0.5); + } else { + ESP_LOGVV(TAG, "waiting: %dms", note_duration_); + output_->set_level(0.0); + } + + last_note_ = millis(); +} +} // namespace rtttl +} // namespace esphome diff --git a/esphome/components/rtttl/rtttl.h b/esphome/components/rtttl/rtttl.h new file mode 100644 index 0000000000..76d8241ddf --- /dev/null +++ b/esphome/components/rtttl/rtttl.h @@ -0,0 +1,81 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/output/float_output.h" + +namespace esphome { +namespace rtttl { + +extern uint32_t global_rtttl_id; + +class Rtttl : public Component { + public: + void set_output(output::FloatOutput *output) { output_ = output; } + void play(std::string rtttl); + void stop() { + note_duration_ = 0; + output_->set_level(0.0); + } + void dump_config() override; + + bool is_playing() { return note_duration_ != 0; } + void loop() override; + + void add_on_finished_playback_callback(std::function callback) { + this->on_finished_playback_callback_.add(std::move(callback)); + } + + protected: + inline uint8_t get_integer_() { + uint8_t ret = 0; + while (isdigit(rtttl_[position_])) { + ret = (ret * 10) + (rtttl_[position_++] - '0'); + } + return ret; + } + + std::string rtttl_; + size_t position_; + uint16_t wholenote_; + uint16_t default_duration_; + uint16_t default_octave_; + uint32_t last_note_; + uint16_t note_duration_; + + uint32_t output_freq_; + output::FloatOutput *output_; + + CallbackManager on_finished_playback_callback_; +}; + +template class PlayAction : public Action { + public: + PlayAction(Rtttl *rtttl) : rtttl_(rtttl) {} + TEMPLATABLE_VALUE(std::string, value) + + void play(Ts... x) override { this->rtttl_->play(this->value_.value(x...)); } + + protected: + Rtttl *rtttl_; +}; + +template class StopAction : public Action, public Parented { + public: + void play(Ts... x) override { this->parent_->stop(); } +}; + +template class IsPlayingCondition : public Condition, public Parented { + public: + bool check(Ts... x) override { return this->parent_->is_playing(); } +}; + +class FinishedPlaybackTrigger : public Trigger<> { + public: + explicit FinishedPlaybackTrigger(Rtttl *parent) { + parent->add_on_finished_playback_callback([this]() { this->trigger(); }); + } +}; + +} // namespace rtttl +} // namespace esphome diff --git a/tests/test1.yaml b/tests/test1.yaml index 84af00e90a..3738edd1de 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -1773,3 +1773,6 @@ sn74hc595: latch_pin: GPIO22 oe_pin: GPIO32 sr_count: 2 + +rtttl: + output: gpio_19