Skip to content

Instantly share code, notes, and snippets.

@khenderick
Created December 24, 2022 09:03
Show Gist options
  • Select an option

  • Save khenderick/92322c3c08dffde1a469ac490a54de29 to your computer and use it in GitHub Desktop.

Select an option

Save khenderick/92322c3c08dffde1a469ac490a54de29 to your computer and use it in GitHub Desktop.
Inplate 6 plus - Esphome - HomeAssistant - Version 1
int last_press_millis = 0;
const int large_button_width = 200;
const int columns_3[3] = {139, 379, 618};
const int columns_3_borders[3][2] = {{39, 239}, {279, 479}, {518, 718}};
void drawTime(DisplayBuffer& it) {
it.strftime(
it.get_width() - 35, it.get_height() - 5,
&id(font_small), COLOR_OFF, TextAlign::BOTTOM_RIGHT,
"%H:%M", id(hass_time).now()
);
it.print(
it.get_width() - 5, it.get_height() - 8,
&id(font_small_icons), COLOR_OFF, TextAlign::BOTTOM_RIGHT,
"󰚰"
);
}
void drawScreentime(DisplayBuffer& it, int x, int y, const char* name, esphome::homeassistant::HomeassistantBinarySensor& active, esphome::homeassistant::HomeassistantSensor& remaining) {
if (active.has_state() && active.state) {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰊗");
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰺷");
}
it.print(x, y + 120, &id(font_large), COLOR_OFF, TextAlign::TOP_CENTER, name);
if (remaining.has_state()) {
auto total_remaining = max(0.0f, remaining.state);
int hours = total_remaining < 60 ? 0 : floor(total_remaining / 60);
int minutes = total_remaining - (hours * 60);
it.print(x - 20, y + 176, &id(font_small_icons), COLOR_OFF, TextAlign::TOP_RIGHT, "󰅑");
it.printf(x - 15, y + 170, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "%02d:%02d", hours, minutes);
} else {
it.print(x - 12, y + 176, &id(font_small_icons), COLOR_OFF, TextAlign::TOP_RIGHT, "󰅑");
it.print(x - 7, y + 170, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "--:--");
}
}
void drawCharging(DisplayBuffer& it) {
auto x = columns_3[2];
auto y = 40;
if (id(hass_charger).has_state()) {
if (id(hass_charger).state) {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰂅"); // ON
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰢟"); // OFF
}
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󱃍"); // Error
}
}
void drawSpotify(DisplayBuffer& it) {
auto x = 100;
auto y = 750;
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰓇"); // Spotify
it.print(x + 95, y + 16, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "Title:");
if (id(hass_spotify_title).has_state()) {
auto text = id(hass_spotify_title).state;
if (text.length() > 35) {
text = text.substr(0, 32) + "...";
}
it.print(x + 160, y + 16, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, text.c_str());
} else {
it.print(x + 160, y + 16, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "-");
}
it.print(x + 95, y + 56, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "Artist:");
if (id(hass_spotify_artist).has_state()) {
auto text = id(hass_spotify_artist).state;
if (text.length() > 35) {
text = text.substr(0, 32) + "...";
}
it.print(x + 175, y + 56, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, text.c_str());
} else {
it.print(x + 175, y + 56, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "-");
}
it.print(x + 95, y + 96, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "Playlist:");
if (id(hass_spotify_playlist).has_state()) {
auto text = id(hass_spotify_playlist).state;
if (text.length() > 35) {
text = text.substr(0, 32) + "...";
}
it.print(x + 190, y + 96, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, text.c_str());
} else {
it.print(x + 190, y + 96, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "-");
}
if (id(hass_spotify).has_state()) {
auto state = id(hass_spotify).state;
if (state == "playing") {
it.print(x + 420, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰒫"); // Skip backwards
it.print(x + 500, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰏤"); // Pause
it.print(x + 580, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰒬"); // Skip forwards
} else if (state == "paused") {
it.print(x + 420, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰼥"); // Skip backwards outline
it.print(x + 500, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰐊"); // Play
it.print(x + 580, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰼦"); // Skip forwards outline
} else {
it.print(x + 420, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰼥"); // Skip backwards outline
it.print(x + 500, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰼛"); // Play outline
it.print(x + 580, y + 148, &id(font_medium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰼦"); // Skip forwards outline
}
}
it.print(x - 33, y + 162, &id(font_smallmedium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰝞"); // Vol-
it.filled_rectangle(x + 10, y + 185, 270, 2, COLOR_OFF);
it.print(x + 330, y + 162, &id(font_smallmedium_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰝝"); // Vol+
if (id(hass_spotify_volume).has_state()) {
auto volume = id(hass_spotify_volume).state;
auto position = volume * 270.0;
it.filled_circle(x + 10 + position, y + 185, 10, COLOR_OFF);
}
}
void drawHeating(DisplayBuffer& it, int x, int y, const char* name, esphome::homeassistant::HomeassistantTextSensor& state, esphome::homeassistant::HomeassistantSensor& setpoint, esphome::homeassistant::HomeassistantSensor& air, esphome::homeassistant::HomeassistantSensor& floor) {
if (state.has_state() && state.state != "off") {
if (state.state == "heating") {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰐸"); // Radiator
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰫗"); // Radiator disabled
}
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰫘"); // Radiator off
}
if (setpoint.has_state() && air.has_state() && floor.has_state()) {
it.printf(x + 100, y + 5, &id(font_large), COLOR_OFF, TextAlign::TOP_LEFT, "%.1f °C", air.state);
it.printf(x + 100, y + 55, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "%.1f / %.1f", floor.state, setpoint.state);
} else {
it.print(x + 100, y + 5, &id(font_large), COLOR_OFF, TextAlign::TOP_LEFT, "--.- °C");
it.print(x + 100, y + 55, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "--.- / --.-");
}
it.print(x + 100, y + 100, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, name);
}
void drawCooling(DisplayBuffer& it, int x, int y, const char* name, esphome::homeassistant::HomeassistantTextSensor& state, esphome::homeassistant::HomeassistantSensor& setpoint, esphome::homeassistant::HomeassistantSensor& temperature) {
if (state.has_state()) {
if (state.state == "cool") {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰜗"); // Snowflake
} else if (state.state == "heat") {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󱩅"); // Heat wave
} else if (state.state == "fan_only") {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰈐"); // Fan
} else if (state.state == "dry") {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󱡕"); // Water opacity
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󱓣"); // Snowflake off
}
} else {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󱓣"); // Snowflake off
}
if (setpoint.has_state() && temperature.has_state()) {
it.printf(x + 100, y + 5, &id(font_large), COLOR_OFF, TextAlign::TOP_LEFT, "%.1f °C", temperature.state);
it.printf(x + 100, y + 55, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "%.1f °C", setpoint.state);
} else {
it.print(x + 100, y + 5, &id(font_large), COLOR_OFF, TextAlign::TOP_LEFT, "-.-- °C");
it.print(x + 100, y + 55, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, "-.-- °C");
}
it.print(x + 100, y + 100, &id(font_small), COLOR_OFF, TextAlign::TOP_LEFT, name);
}
void drawCandle(DisplayBuffer& it, int x, int y, esphome::homeassistant::HomeassistantBinarySensor& sensor) {
if (sensor.has_state()) {
it.print(x, y, &id(font_large_icons), COLOR_OFF, TextAlign::TOP_CENTER, "󰗢"); // Candle
if (!sensor.state) {
it.filled_rectangle(x - 20, y, 45, 70, COLOR_ON);
}
}
}
void drawDisplay() {
ESP_LOGD("custom", "Drawing...");
DisplayBuffer& it = id(inkplate_display);
it.fill(COLOR_ON);
drawTime(it);
drawScreentime(it, columns_3[0], 0, "Child 1", id(hass_child_1_screen_time_active), id(hass_child_1_weekly_screen_time_remaining));
drawScreentime(it, columns_3[1], 0, "Child 2", id(hass_child_2_screen_time_active), id(hass_child_2_weekly_screen_time_remaining));
drawCharging(it);
if (id(hass_season).has_state()) {
auto season = id(hass_season).state;
if (season == "autumn" || season == "winter") {
drawHeating(it, 110, 250, "Downstairs", id(hass_heating_downstairs_state), id(hass_heating_downstairs_setpoint), id(hass_temperature_living), id(hass_temperature_living_floor));
drawHeating(it, 480, 250, "Upstairs", id(hass_heating_upstairs_state), id(hass_heating_upstairs_setpoint), id(hass_temperature_mbr), id(hass_temperature_mbr_floor));
} else {
drawCooling(it, 110, 250, "Downstairs", id(hass_cooling_downstairs_state), id(hass_cooling_downstairs_setpoint), id(hass_temperature_living));
drawCooling(it, 480, 250, "Upstairs", id(hass_cooling_upstairs_state), id(hass_cooling_upstairs_setpoint), id(hass_temperature_mbr));
}
}
drawCandle(it, columns_3[0], 500, id(hass_candles));
drawSpotify(it);
ESP_LOGD("custom", "Drawing... Done");
}
void callHomeAssistant(const char* service, const char* entityId) {
ESP_LOGD("custom", "Calling %s with entity_id=%s...", service, entityId);
HomeassistantServiceMap map;
map.key = "entity_id";
map.value = entityId;
HomeassistantServiceResponse response;
response.service = service;
response.data.push_back(map);
id(hass_api).send_homeassistant_service_call(response);
ESP_LOGD("custom", "Calling %s with entity_id=%s... Done", service, entityId);
}
void callHomeAssistant(const char* service, const char* entityId, const char* extraKey, char* extraValue) {
ESP_LOGD("custom", "Calling %s with entity_id=%s, %s=%s... Done", service, entityId, extraKey, extraValue);
HomeassistantServiceResponse response;
response.service = service;
HomeassistantServiceMap entityMap;
entityMap.key = "entity_id";
entityMap.value = entityId;
response.data.push_back(entityMap);
HomeassistantServiceMap extraMap;
extraMap.key = extraKey;
extraMap.value = extraValue;
response.data.push_back(extraMap);
id(hass_api).send_homeassistant_service_call(response);
ESP_LOGD("custom", "Calling %s with entity_id=%s, %s=%s... Done", service, entityId, extraKey, extraValue);
}
void processTouch(uint16_t& x, uint16_t& y) {
ESP_LOGD("custom", "Touch x=%d, y=%d", x, y);
auto current_millis = millis();
auto difference = current_millis - last_press_millis;
last_press_millis = current_millis;
if (0 < difference && difference < 250) {
ESP_LOGD("custom", "Touch discarded. Difference=%d", difference);
return;
}
if (y < 200) {
// First row
if (columns_3_borders[0][0] <= x && x <= columns_3_borders[0][1]) {
callHomeAssistant("input_boolean.toggle", "input_boolean.child_1_screen_time_active");
} else if (columns_3_borders[1][0] <= x && x <= columns_3_borders[1][1]) {
callHomeAssistant("input_boolean.toggle", "input_boolean.child_2_screen_time_active");
} else if (columns_3_borders[2][0] <= x && x <= columns_3_borders[2][1]) {
callHomeAssistant("switch.toggle", "switch.wallplug_charger");
}
} else if (500 <= y && y <= 700) {
// Third row
if (columns_3_borders[0][0] <= x && x <= columns_3_borders[0][1]) {
callHomeAssistant("input_boolean.toggle", "input_boolean.candles");
}
} else if (900 <= y && y <= 960) {
// Spotify
if (id(hass_spotify).has_state()) {
// Actions
auto action = String("");
if (470 <= x && x <= 550) {
action = "media_player.media_previous_track";
} else if (570 <= x && x <= 630) {
action = "media_player.media_play_pause";
} else if (650 <= x && x <=890) {
action = "media_player.media_next_track";
}
auto state = id(hass_spotify).state;
if (action != "" && (state == "playing" || (state == "paused" && action == "media_player.media_play_pause"))) {
callHomeAssistant(action.c_str(), "media_player.spotify_family_henderick");
}
}
// Volume
if (id(hass_spotify_volume).has_state()) {
auto volume = id(hass_spotify_volume).state;
float newVolume = -1.0;
if (50 <= x && x <= 120) {
newVolume = max(0.0, (round(volume * 100) / 100) - 0.05);
} else if (390 <= x && x <= 450) {
newVolume = min(1.0, (round(volume * 100) / 100) + 0.05);
}
if (newVolume >= 0) {
char buffer[5];
snprintf(buffer, sizeof(buffer) , "%.2f", newVolume);
callHomeAssistant("media_player.volume_set", "media_player.spotify_family_henderick", "volume_level", buffer);
}
}
}
}
substitutions:
static_ip: "10.4.x.x"
device_name: "epaper"
device_verbose_name: "E-paper"
packages:
wifi: !include includes/common/wifi_storage.yaml
status: !include includes/common/status.yaml
board: !include includes/boards/inkplate_6_plus.yaml
api:
id: hass_api
esphome:
includes:
- libraries/epaper.h
on_boot:
priority: 0
then:
- script.execute: draw
script:
- id: draw
mode: restart
then:
- delay: 3s
- lambda: id(inkplate_display).update();
- id: direct_draw
mode: restart
then:
- lambda: id(inkplate_display).update();
font:
- file: "fonts/opensans.ttf"
id: font_small
size: 25
- file: "fonts/opensans.ttf"
id: font_large
size: 40
- file: "fonts/materialdesignicons.ttf"
id: font_small_icons
size: 25
glyphs: [
"󰚰", # mdi:update
"󰅕", # mdi:clock-start
"󰅑", # mdi:clock-end
"󰈼", # mdi:flag-checkered
]
- file: "fonts/materialdesignicons.ttf"
id: font_smallmedium_icons
size: 50
glyphs: [
"󰝝", # mdi:volume-plus
"󰝞", # mdi:volume-minus
]
- file: "fonts/materialdesignicons.ttf"
id: font_medium_icons
size: 80
glyphs: [
"󰐊", # mdi:play
"󰼛", # mdi:play-outline
"󰏤", # mdi:pause
"󰒬", # mdi:skip-forward
"󰼦", # mdi:skip-forward-outline
"󰒫", # mdi:skip-backward
"󰼥", # mdi:skip-backward-outline
"󰝝", # mdi:volume-plus
"󰝞", # mdi:volume-minus
]
- file: "fonts/materialdesignicons.ttf"
id: font_large_icons
size: 150
glyphs: [
"󰊗", # mdi:gamepad-variant
"󰺷", # mdi:gamepad-variant-outline
"󰢟", # mdi:battery-charging-outline
"󰂅", # mdi:battery-charging-100
"󱃍", # mdi:battery-alert-variant-outline
"󰓇", # mdi:spotify
"󰐸", # mdi:radiator
"󰫗", # mdi:radiator-disabled
"󰫘", # mdi:radiator-off
"󰜗", # mdi:snowflake
"󱓣", # mdi:snowflake-off
"󰈐", # mdi:fan
"󱩅", # mdi:heat-wave
"󱡕", # mdi:water-opacity
"󰗢", # mdi:candle
]
time:
- platform: homeassistant
id: hass_time
timezone: Europe/Brussels
on_time:
- seconds: 0
minutes: /10
then:
- script.execute: draw
binary_sensor:
- platform: homeassistant
entity_id: switch.wallplug_charger
id: hass_charger
on_state:
- script.execute: direct_draw
- platform: homeassistant
entity_id: input_boolean.child_1_screen_time_active
id: hass_child_1_screen_time_active
on_state:
- script.execute: direct_draw
- platform: homeassistant
entity_id: input_boolean.child_2_screen_time_active
id: hass_child_2_screen_time_active
on_state:
- script.execute: direct_draw
- platform: homeassistant
entity_id: input_boolean.candles
id: hass_candles
on_state:
- script.execute: direct_draw
sensor:
- platform: homeassistant
entity_id: sensor.child_1_weekly_screen_time_remaining
id: hass_child_1_weekly_screen_time_remaining
- platform: homeassistant
entity_id: sensor.child_2_weekly_screen_time_remaining
id: hass_child_2_weekly_screen_time_remaining
- platform: homeassistant
entity_id: media_player.spotify_family_henderick
attribute: volume_level
id: hass_spotify_volume
on_value:
- script.execute: direct_draw
- platform: homeassistant
entity_id: climate.downstairs
attribute: temperature
id: hass_heating_downstairs_setpoint
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.upstairs
attribute: temperature
id: hass_heating_upstairs_setpoint
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.panasonic_downstairs
attribute: temperature
id: hass_cooling_downstairs_setpoint
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.panasonic_upstairs
attribute: temperature
id: hass_cooling_upstairs_setpoint
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: sensor.aqara_weather_living_temperature
id: hass_temperature_living
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: sensor.aqara_weather_mbr_temperature
id: hass_temperature_mbr
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: sensor.solar_display_ir_object_temperature
id: hass_temperature_living_floor
filters:
- delta: 0.1
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: sensor.master_bedroom_ir_object_temperature
id: hass_temperature_mbr_floor
filters:
- delta: 0.1
on_value:
- script.execute: draw
text_sensor:
- platform: homeassistant
entity_id: media_player.spotify_family_henderick
id: hass_spotify
on_value:
- script.execute: direct_draw
- platform: homeassistant
entity_id: media_player.spotify_family_henderick
attribute: media_playlist
id: hass_spotify_playlist
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: media_player.spotify_family_henderick
attribute: media_title
id: hass_spotify_title
on_value:
- script.execute: direct_draw
- platform: homeassistant
entity_id: media_player.spotify_family_henderick
attribute: media_artist
id: hass_spotify_artist
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.downstairs
attribute: hvac_action
id: hass_heating_downstairs_state
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.upstairs
id: hass_heating_upstairs_state
attribute: hvac_action
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.panasonic_downstairs
id: hass_cooling_downstairs_state
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: climate.panasonic_upstairs
id: hass_cooling_upstairs_state
on_value:
- script.execute: draw
- platform: homeassistant
entity_id: sensor.season
id: hass_season
on_value:
- script.execute: draw
esp32:
board: esp-wrover-kit
i2c:
mcp23017:
- id: mcp23017_hub
address: 0x20
display:
- platform: inkplate6
model: inkplate_6_plus
id: inkplate_display
greyscale: false
partial_updating: true
update_interval: never
rotation: 270
full_update_every: 6
ckv_pin: 32
sph_pin: 33
gmod_pin:
mcp23xxx: mcp23017_hub
number: 1
gpio0_enable_pin:
mcp23xxx: mcp23017_hub
number: 8
oe_pin:
mcp23xxx: mcp23017_hub
number: 0
spv_pin:
mcp23xxx: mcp23017_hub
number: 2
powerup_pin:
mcp23xxx: mcp23017_hub
number: 4
wakeup_pin:
mcp23xxx: mcp23017_hub
number: 3
vcom_pin:
mcp23xxx: mcp23017_hub
number: 5
lambda: drawDisplay();
power_supply:
- id: backlight_power
keep_on_time: 0.2s
enable_time: 0s
pin:
mcp23xxx: mcp23017_hub
number: 11
output:
- platform: mcp47a1
id: backlight_brightness_output
power_supply: backlight_power
light:
- platform: monochromatic
output: backlight_brightness_output
id: backlight
default_transition_length: 0.2s
name: "${device_verbose_name} backlight"
switch:
- platform: gpio
name: "${device_verbose_name} touchscreen enabled"
restore_mode: ALWAYS_ON
entity_category: config
pin:
mcp23xxx: mcp23017_hub
number: 12
inverted: true
- platform: gpio
id: battery_read_mosfet
pin:
mcp23xxx: mcp23017_hub
number: 9
inverted: true
- platform: template
name: "${device_verbose_name} greyscale mode"
entity_category: config
lambda: return id(inkplate_display).get_greyscale();
turn_on_action:
- lambda: id(inkplate_display).set_greyscale(true);
turn_off_action:
- lambda: id(inkplate_display).set_greyscale(false);
- platform: template
name: "${device_verbose_name} partial updating"
entity_category: config
lambda: return id(inkplate_display).get_partial_updating();
turn_on_action:
- lambda: id(inkplate_display).set_partial_updating(true);
turn_off_action:
- lambda: id(inkplate_display).set_partial_updating(false);
touchscreen:
- platform: ektf2232
interrupt_pin: GPIO36
rts_pin:
mcp23xxx: mcp23017_hub
number: 10
on_touch:
- lambda: processTouch(touch.x, touch.y);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment