ESPhome replace "," with a new line "\n"

So I have a text sensor where I want to replace every comma in the string with a newline return character. Ive tried a few different things adding a value template to replace the comma with a new line, but it doesnt work.

  - platform: homeassistant
    entity_id: sensor.epshome_shopping_list
    id: alexa_shopping_list
    on_value:
      then:
         - lambda: 'id(data_updated) = true;'

Any help would be awesome? Thanks

Normally you can use the filter’s substitute function to replace one string with another. Eg. to replace comma with semicolon:

- platform: homeassistant
  ...
  filters:
    - substitute:
      - ", -> ;"

However, when using new line as replacement (- ", -> \n") the ESPHome parser doesn’t seem to recognize those escaped chars; in generated code would see SubstituteFilter({","}, {""}) instead.

So alternative is a custom lambda filter, something like this.

- platform: homeassistant
  ...
  filters:
    - lambda: |-
        std::replace(x.begin(), x.end(), ',', '\n');
        return x;

Note: I have not tested this on an actual device.

Hey there,

Thanks so much for getting back, I really appreciate it. It seems like a pretty niche thing that not may people know about.

I tried the substitute filter as well before, but as you say, does not work.

Sadly this doesn’t work either, I’m just getting unknown characters

I’m just adding the custom lambda filter to the text sensor right?

  - platform: homeassistant
    entity_id: sensor.epshome_shopping_list
    id: alexa_shopping_list
    on_value:
      then:
         - lambda: 'id(data_updated) = true;'
    filters:
      - lambda: |-
          std::replace(x.begin(), x.end(), ',', '\n');
          return x;

Thanks again for the help, I’ve been wanting to close this out for a while but have been in limbo till I can get this last piece working :slight_smile:

That is indeed how it should be configured in the yaml file. In your picture (nice display btw) I see that the comma’s are replace. Might be that this E-paper display requires a different approach to print on new lines. You’ll have to dive into its specs and see if anything is described. Without that info, it’s hard to help you further.

On the Display Component page I see that you write text using the it.print() where first 2 args are the X/Y position. Guess you’ll need to write a lambda to split up your string into individual lines and invoke it.print for each one them, each with its own Y offset.

Something like this (should remove the “filters” that you added to the other entity):

display:
  - platform: ...
    ...
    lambda: |-
       // Value to show (probably getting it as input from an entity)
       std::string str = "Milk,Milo,More ESP32s";

       // Display position and line height
       int xPos = 10, yPos = 20, lineHeight = 20;

       // Split into lines where comma is delimiter
       std::size_t curPos = 0, commaPos = 0;
       while ((commaPos = str.find(',', curPos)) != std::string::npos) {
          it.print(xPos, yPos, id(my_font), str.substr(curPos, commaPos - curPos));
          yPos += lineHeight;
          curPos = commaPos + 1;
       }

Hey :slight_smile:

Sorry ive been away for a couple of weeks.

Thanks for helping me with this :slight_smile:

So, I cant compile what you gave me. I come from an infra background, so Im mostly just guessing here LOL. When you have a sec, can you advise what ive done wrong?

Thanks mate, I really do appreciate it :slight_smile:

        // Shopping List Section
        // Value to show (probably getting it as input from an entity)
        std::string str = id(alexa_shopping_list).state; // This is the entity 

        // Display position and line height
        int xPos = 20, yPos = 440, lineHeight = 20;

        // Split into lines where comma is delimiter
        std::size_t curPos = 0, commaPos = 0;
        while ((commaPos = str.find(',', curPos)) != std::string::npos) {
          it.print(xPos, yPos, id(font_small_bold), "%s", str.substr(curPos, commaPos - curPos));
          yPos += lineHeight;
          curPos = commaPos + 1;
        }		

Gives me this error

/config/esphome/to-do.yaml: In lambda function:
/config/esphome/to-do.yaml:344:92: error: no matching function for call to 'esphome::display::DisplayBuffer::print(int&, int&, esphome::display::Font*&, const char [3], std::__cxx11::basic_string<char>)'
           it.print(xPos, yPos, id(font_small_bold), "%s", str.substr(curPos, commaPos - curPos));
                                                                                            ^
In file included from src/esphome.h:20,
                 from src/main.cpp:3:
src/esphome/components/display/display_buffer.h:190:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::Color, esphome::display::TextAlign, const char*)'
   void print(int x, int y, Font *font, Color color, TextAlign align, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:190:8: note:   candidate expects 6 arguments, 5 provided
src/esphome/components/display/display_buffer.h:200:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::Color, const char*)'
   void print(int x, int y, Font *font, Color color, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:200:8: note:   no known conversion for argument 4 from 'const char [3]' to 'esphome::Color'
src/esphome/components/display/display_buffer.h:210:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::display::TextAlign, const char*)'
   void print(int x, int y, Font *font, TextAlign align, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:210:8: note:   no known conversion for argument 4 from 'const char [3]' to 'esphome::display::TextAlign'
src/esphome/components/display/display_buffer.h:219:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, const char*)'
   void print(int x, int y, Font *font, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:219:8: note:   candidate expects 4 arguments, 5 provided
*** [/data/to-do/.pioenvs/to-do/src/main.cpp.o] Error 1

Just to add,

I have this code which does work, it does a line break every 15 lines. I tried to reverse engineer it, but I just don’t know the programing LOL

        std::string s = id(alexa_to_do_list).state;

          int limit = 15;

          int space = 0;

          int i = 0;

          int line = 0;

          int y= 190;

          y= 190; // start Y

          while(i<s.length()){ //loop through string, counting all the spaces, and replacing the last one with ~ [marked by space variable] if the count exceeds limit of 35

            if(s.substr(i,1) == " "){space = i; }

            if(line>limit-1){s=s.substr(0,space)+"~"+s.substr(space+1);line = 0;}

              i++;line++;}

          size_t pos = s.find("~"); //find the first line break

          int linecount = 1; //need number of lines to store the break positions in an array

          int breakpositions[10]; //store breakpositions [the '~']

          breakpositions[0] = -1; // start at -1 cause we need to truncate the replaced characters and without this will cut off 1st character of message

          while ( pos != std::string::npos) //loop through  replacing the ~ with CR - though this doesnt matter here it will never be displayed, but need to change them to keep the loop from repeating at the start

          {

            s.replace(pos,1, "\n");

            breakpositions[linecount] = pos; //store the position of the break in an array

            pos = s.find("~"); // move forward

            linecount++; // we have a new line, count it

          }

          breakpositions[linecount] = s.length(); //set the last entry in array to the length of string for calculation below

          std::string singleline; //this will be the line we print

          i = 0;

          while (i < linecount ) {  // count through the lines

            singleline = s.substr(breakpositions[i]+1,(breakpositions[i+1]-breakpositions[i]-1)); //extract each line of text from the string - strip off the CRLF and the space.

            it.printf(10, y, id(font_small_bold), "%s", singleline.c_str()); //print it!

            y=y+30; // increment y to print properly on display

          i++;

       }

So, I just got old mate GTP to have a look at the above and it gave me this.

        std::string l = id(alexa_shopping_list).state;

          int b = 0;

          int c = 440; // start Y

          while (i < l.length()) {

            if (l.substr(b, 1) == ",") {

              l.replace(b, 1, "\n");

              c += 20; // increment Y by 20 pixels for each new line

            }

            b++;

        }

       it.printf(20, c, id(font_small_bold), l.c_str()); 

Have to wait till I get home to see if it worked :slight_smile:

Let you know.

Guess it’s the %s which you added to the it.print command, therefore compiler can’t find the method that matches the arguments.

Yeah, GPT is telling me that there is missing functions so I was trying a few things lol

        // Shopping List Section
        std::string str = id(alexa_shopping_list).state; 

          // Display position and line height
          int xPos = 20, yPos = 440, lineHeight = 20;

          // Split into lines where comma is delimiter
          std::size_t curPos = 0, commaPos = 0;
          while ((commaPos = str.find(',', curPos)) != std::string::npos) {
              it.print(xPos, yPos, id(font_small_bold), str.substr(curPos, commaPos - curPos));
              yPos += lineHeight;
              curPos = commaPos + 1;
          }   

So going back to your code, only adding the entity and the font, gives me this error; I also don’t understand why we are defining line height when that’s defined in the font?

/config/esphome/to-do.yaml: In lambda function:
/config/esphome/to-do.yaml:341:90: error: no matching function for call to 'esphome::display::DisplayBuffer::print(int&, int&, esphome::display::Font*&, std::__cxx11::basic_string<char>)'
               it.print(xPos, yPos, id(font_small_bold), str.substr(curPos, commaPos - curPos));
                                                                                          ^
In file included from src/esphome.h:20,
                 from src/main.cpp:3:
src/esphome/components/display/display_buffer.h:190:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::Color, esphome::display::TextAlign, const char*)'
   void print(int x, int y, Font *font, Color color, TextAlign align, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:190:8: note:   candidate expects 6 arguments, 4 provided
src/esphome/components/display/display_buffer.h:200:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::Color, const char*)'
   void print(int x, int y, Font *font, Color color, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:200:8: note:   candidate expects 5 arguments, 4 provided
src/esphome/components/display/display_buffer.h:210:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, esphome::display::TextAlign, const char*)'
   void print(int x, int y, Font *font, TextAlign align, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:210:8: note:   candidate expects 5 arguments, 4 provided
src/esphome/components/display/display_buffer.h:219:8: note: candidate: 'void esphome::display::DisplayBuffer::print(int, int, esphome::display::Font*, const char*)'
   void print(int x, int y, Font *font, const char *text);
        ^~~~~
src/esphome/components/display/display_buffer.h:219:8: note:   no known conversion for argument 4 from 'std::__cxx11::basic_string<char>' to 'const char*'
*** [/data/to-do/.pioenvs/to-do/src/main.cpp.o] Error 1

I know you have spent a bit of time with this now lol, If you have a chance to look, id appreciate it, but if you’re busy all good :slight_smile:

Thank you :slight_smile:

Ah, there is no print overload that accepts a std::string. Change that line to the following, then it should compile:

it.print(xPos, yPos, id(font_small_bold), str.substr(curPos, commaPos - curPos).c_str());

Don’t have a display so can’t test it over here.

WOW!!! That kinda works!!! The formatting seems to go a bit weird and the first entry does not print, which is strange lol.

BUT closer. So, so close now HAHA

Thank you so much, this project has been dragging on for months lol

So the formatting, Ill fiddle round with “replace” to remove the ’ and get it lined up, but if you have any ideas about why the first entry wouldn’t print, id really appreciate it?

Do you mean that there should be an item before “stuff 4”? Are you sure it’s part of the Alexa string? Cause purely based on what is printed, the first item (stuff 4) doesn’t have a space prefix which, to me means, the first string. Why? Cause comma separated lists are usually concatenated by ", ". The comma is removed in the code (curPos = commaPos + 1) but the space isn’t. Therefore, only the consecutive values have a space as prefix.

Can post your yaml + a sample Alexa shopping list value so we can check if any error in your config or in the logic.

:frowning: so it appears that in 2023.4.2 the todoist integration is broken again. I’ve done a restore, back to 2023.3.6 but now I cant get access to the whole config folder (It appears it only did a partial backup before the last update). Ill have to restore back to the broken todoist version to get everything working. But hopefully this is enough info FML lol

  - platform: template
    sensors:
      esphome_to_do:
        friendly_name: "ESPHome Alexa To-Do"
        value_template: '{{states.calendar.alexa_to_do_list.attributes.all_tasks | replace(''['','''') | replace('']'','''') | replace("''","")}}'

  - platform: template
    sensors:
      epshome_shopping_list:
        friendly_name: "ESPHome Alexa Shopping List"
        value_template: '{{states.calendar.alexa_shopping_list.attributes.all_tasks | replace(''['','''') | replace('']'','''') | replace("''","") }}'

So I have a value template for each that removes brackets , and the ’ and outputs that to a new entity that the ESPhome.yaml uses, looks like

As you can see we have “stuff 1” though “stuff 4” but when looking at the actual display the first entry is missing, the order is also reversed, but that does not matter (you can see this for both the to do list and the shopping list).

And lastly here is the code I have in the esphome.yaml. I’ve just sanitized it a little, but everything important is there.

esphome:
  name: to-do
  on_boot:
      priority: 200.0
      then:
        - component.update: eink_display  

esp32:
  board: esp32dev
  framework:
    type: arduino

# Enable logging
logger:



# Global variables for detecting if the display needs to be refreshed. (Thanks @paviro!)
globals:
  - id: data_updated
    type: bool
    restore_value: no
    initial_value: 'false'
  - id: initial_data_received
    type: bool
    restore_value: no
    initial_value: 'false'

# Include custom fonts
font:
  - file: fonts/materialdesignicons-webfont.ttf
    id: icon_font
    size: 50
    glyphs:
      - "\U000F0590" # weather-cloudy
      - "\U000F0F2F" # weather-cloudy-alert
      - "\U000F0E6E" # weather-cloudy-arrow-right
      - "\U000F0591" # weather-fog
      - "\U000F0592" # weather-hail
      - "\U000F0F30" # weather-hazy
      - "\U000F0898" # weather-hurricane
      - "\U000F0593" # weather-lightning
      - "\U000F067E" # weather-lightning-rainy
      - "\U000F0594" # weather-night
      - "\U000F0F31" # weather-night-partly-cloudy
      - "\U000F0595" # weather-partly-cloudy
      - "\U000F0F32" # weather-partly-lightning
      - "\U000F0F33" # weather-partly-rainy
      - "\U000F0F34" # weather-partly-snowy
      - "\U000F0F35" # weather-partly-snowy-rainy
      - "\U000F0596" # weather-pouring
      - "\U000F0597" # weather-rainy
      - "\U000F0598" # weather-snowy
      - "\U000F0F36" # weather-snowy-heavy
      - "\U000F067F" # weather-snowy-rainy
      - "\U000F0599" # weather-sunny
      - "\U000F0F37" # weather-sunny-alert
      - "\U000F14E4" # weather-sunny-off
      - "\U000F059A" # weather-sunset
      - "\U000F059B" # weather-sunset-down
      - "\U000F059C" # weather-sunset-up
      - "\U000F0F38" # weather-tornado
      - "\U000F059D" # weather-windy
      - "\U000F059E" # weather-windy-variant
      - "\U000F050F" # mdi-thermometer   
      - "\U000F18D7" # sun-thermometer-outline    
      - "\U000F0F55" # home-thermometer-outline                  


  - file: 'fonts/GothamRnd-Bold.ttf'
    id: font_title
    size: 40
    glyphs: 
      ['&', '@', '!', ',', '.', '"', '%', '(', ')', '+', '-', '_', ':', '°', '0',
       '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E',
       'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
       'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ' ', 'a', 'b', 'c', 'd', 'e', 'f',
       'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
       'u', 'v', 'w', 'x', 'y', 'z', '/','[', ']',"'"]

  - file: 'fonts/GothamRnd-Bold.ttf'
    id: font_small_bold
    size: 25
    glyphs: 
      ['&', '@', '!', ',', '.', '"', '%', '(', ')', '+', '-', '_', ':', '°', '0',
       '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E',
       'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S',
       'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ' ', 'a', 'b', 'c', 'd', 'e', 'f',
       'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
       'u', 'v', 'w', 'x', 'y', 'z', '/','[', ']',"'"]


# Check whether the display needs to be refreshed every minute,
# based on whether new data is received or motion is detected. (Thanks @paviro!)
time:
  - platform: homeassistant
    id: homeassistant_time
    on_time:
      - seconds: 0
        minutes: /1
        then:
          - if:
              condition:
                lambda: 'return id(data_updated) == true;'
              then:
                - lambda: 'id(initial_data_received) = true;'
                - if:
                    condition:
                      binary_sensor.is_on: motion_detected
                    then:
                      - logger.log: "Sensor data updated and activity in home detected: Refreshing display..."
                - component.update: eink_display
                - lambda: 'id(data_updated) = false;'
              else:
                      - logger.log: "Sensor data updated but no activity in home - skipping display refresh."
  - platform: sntp
    id: ntp
    timezone: Australia/Sydney
    servers:
      - 0.pool.ntp.org
      - 1.pool.ntp.org
      - 2.pool.ntp.org



# Check if motion is detected in the bathroom.
binary_sensor:
  - platform: homeassistant
    entity_id: binary_sensor.bathroom_motion_sensor
    id: motion_detected       

# Call calender sensors from HA.
text_sensor:
  - platform: homeassistant
    entity_id: sensor.esphome_to_do
    id: alexa_to_do_list
    on_value:
      then:
         - lambda: 'id(data_updated) = true;'
    
  - platform: homeassistant
    entity_id: sensor.epshome_shopping_list
    id: alexa_shopping_list
    on_value:
      then:
         - lambda: 'id(data_updated) = true;'
        
  - platform: homeassistant
    name: "Today Weather"
    entity_id: weather.home
    id: weather_icon
    internal: true      

sensor:
  - platform: homeassistant
    entity_id: sensor.outside_temperature_sensor
    id: weather_temperature
    on_value:
      then:
        - lambda: 'id(data_updated) = true;'         

  - platform: homeassistant
    entity_id: sensor.bedroom_temperature
    id: bedroom_temp     
    on_value:
      then:
        - lambda: 'id(data_updated) = true;'         

# Define colors
# This design is white on black so this is necessary.
color:
  - id: color_black
    red: 0%
    green: 0%
    blue: 0%
    white: 50%
  - id: color_white
    red: 0%
    green: 0%
    blue: 0%
    white: 0%
  - id: color_red
    red: 0%
    green: 0%
    blue: 0%
    white: 0%    

# Pins for Waveshare ePaper ESP Board
spi:
  clk_pin: GPIO13
  mosi_pin: GPIO14

# Now render everything on the ePaper screen.
display:
  - platform: waveshare_epaper
    id: eink_display
    cs_pin: GPIO15
    dc_pin: GPIO27
    busy_pin: GPIO25
    reset_pin: GPIO26
    reset_duration: 2ms
    model: 7.50in-bV2
    update_interval: 1h
    rotation: 90°
    lambda: |-

      // Fill background.
      // it.fill(color_bg);

      // Show loading screen before data is received.
      if (id(initial_data_received) == false) {
        it.printf(215, 190, id(font_title), color_black, TextAlign::TOP_CENTER, "Just Wait a Second");
        it.printf(200, 410, id(font_title), color_black, TextAlign::TOP_CENTER, "It's Still Loading");
      } else {

        int time = id(ntp).now().hour * 100 + id(ntp).now().minute;

        if (id(weather_icon).has_state()) {
            std::map<std::string, std::string> weather_state { 
                { "sunny", "\U000F0599" },             // mdi:weather-sunny
                { "clear-night", "\U000F0594" },           // mdi:weather-night
                { "cloudy", "\U000F0590"},                // mdi:weather-cloudy
                { "rainy", "\U000F0597" },                  // mdi:weather-pouring
                { "windy", "\U000F059D" },                  // mdi:weather-windy-variant
                { "fog", "\U000F0591" },                   // mdi:weather-fog
                { "partlycloudy", "\U000F0595" },     // mdi:weather-partly-cloudy
            };
          if (time < 1900) {
              it.printf(20, 90, id(icon_font), TextAlign::BASELINE_LEFT, weather_state[id(weather_icon).state.c_str()].c_str());
          } else {
              it.printf(20, 90, id(icon_font), TextAlign::BASELINE_LEFT, weather_state[id(weather_icon).state.c_str()].c_str());
          }
        }

        // Outside Temp
        it.printf(80, 90, id(icon_font), TextAlign::BASELINE_LEFT, "\U000F18D7"); 
        it.printf(135, 85, id(font_title), color_black, TextAlign::BASELINE_LEFT, "%2.0f°", id(weather_temperature).state);        

        // Inside Temp
        it.printf(240, 90, id(icon_font), TextAlign::BASELINE_LEFT, "\U000F0F55"); 
        it.printf(295, 85, id(font_title), color_black, TextAlign::BASELINE_LEFT, "%2.0f°", id(bedroom_temp).state);        

        // To Do List
        it.printf(20, 150, id(font_title), color_black, TextAlign::BASELINE_LEFT, "To Do List");
        it.line(240, 135, 480, 135);

        std::string str1 = id(alexa_to_do_list).state; 

          // Display position and line height
          int xPos1 = 20, yPos1 = 190, lineHeight1 = 20;

          // Split into lines where comma is delimiter
          std::size_t curPos1 = 0, commaPos1 = 0;
          while ((commaPos1 = str1.find(',', curPos1)) != std::string::npos) {
              it.print(xPos1, yPos1, id(font_small_bold), str1.substr(curPos1, commaPos1 - curPos1).c_str());
              yPos1 += lineHeight1;
              curPos1 = commaPos1 + 1;
          }  

        // Shopping List
        it.printf(20, 408, id(font_title), color_black, TextAlign::BASELINE_LEFT, "Shopping List");
        it.line(320, 395, 480, 395);  

        std::string str = id(alexa_shopping_list).state; 

          // Display position and line height
          int xPos = 20, yPos = 430, lineHeight = 20;

          // Split into lines where comma is delimiter
          std::size_t curPos = 0, commaPos = 0;
          while ((commaPos = str.find(',', curPos)) != std::string::npos) {
              it.print(xPos, yPos, id(font_small_bold), str.substr(curPos, commaPos - curPos).c_str());
              yPos += lineHeight;
              curPos = commaPos + 1;
          }   
       
        // FOOTER
        //Divider draw a line from [x=0,y=0] to [x=50,y=50]
        it.line(0, 665, 480, 665);
        // Show date and time of last update          
        it.strftime(230, 700, id(font_small_bold), TextAlign::BASELINE_CENTER, "Updated: %Y-%m-%d  %H:%M", id(ntp).now());
      };

captive_portal:

Ah, the last item of the list is not shown in the display. Replace the current code to split with the following. That should fix the issue. It also replaces comma+space so you’ll get rid of the leading space in the items.

// Split into lines where comma+space is delimiter
std::string delimiter1 = ", ";
std::size_t curPos1 = 0, commaPos1 = 0;
while (commaPos1 != std::string::npos) {
    commaPos1 = str1.find(delimiter1, curPos1);
    it.print(xPos1, yPos1, id(font_small_bold), str1.substr(curPos1, commaPos1 - curPos1).c_str());
    yPos1 += lineHeight1;
    curPos1 = commaPos1 + delimiter1.size();
}

Regarding the reversed list, the printed order seems to be the same as the order in sensor.epshome_shopping_list. You can reverse the order with std::list::reverse but then you’ll need to include <list> header which is not really an easy task in ESPHome. Let me know if you’re interested.

2 Likes

Hey mate,

So been waiting for the todoist integration to be fixed. Still broken, but at least those guys helped me with a workaround. And after adding your code… it works perfectly!

Thank you so much for all your mucking around with this, I appreciate it a lot. This has been months in the making and now I can finally finish it lol

You’re a legend!