Merge branch 'dev' into optolink

This commit is contained in:
j0ta29 2023-11-25 16:38:33 +01:00 committed by GitHub
commit 88d282200a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 523 additions and 29 deletions

View file

@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for needs-docs label
uses: actions/github-script@v6.4.1
uses: actions/github-script@v7.0.1
with:
script: |
const { data: labels } = await github.rest.issues.listLabelsOnIssue({

View file

@ -203,7 +203,7 @@ jobs:
needs: [deploy-manifest]
steps:
- name: Trigger Workflow
uses: actions/github-script@v6.4.1
uses: actions/github-script@v7.0.1
with:
github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }}
script: |

View file

@ -90,6 +90,7 @@ esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/* @jesserockz
esphome/components/emc2101/* @ellull
esphome/components/ens160/* @vincentscode
esphome/components/ens210/* @itn3rd77
esphome/components/esp32/* @esphome/core
esphome/components/esp32_ble/* @Rapsssito @jesserockz

View file

@ -0,0 +1 @@
CODEOWNERS = ["@vincentscode"]

View file

@ -0,0 +1,321 @@
// ENS160 sensor with I2C interface from ScioSense
//
// Datasheet: https://www.sciosense.com/wp-content/uploads/documents/SC-001224-DS-7-ENS160-Datasheet.pdf
//
// Implementation based on:
// https://github.com/sciosense/ENS160_driver
#include "ens160.h"
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
namespace esphome {
namespace ens160 {
static const char *const TAG = "ens160";
static const uint8_t ENS160_BOOTING = 10;
static const uint16_t ENS160_PART_ID = 0x0160;
static const uint8_t ENS160_REG_PART_ID = 0x00;
static const uint8_t ENS160_REG_OPMODE = 0x10;
static const uint8_t ENS160_REG_CONFIG = 0x11;
static const uint8_t ENS160_REG_COMMAND = 0x12;
static const uint8_t ENS160_REG_TEMP_IN = 0x13;
static const uint8_t ENS160_REG_DATA_STATUS = 0x20;
static const uint8_t ENS160_REG_DATA_AQI = 0x21;
static const uint8_t ENS160_REG_DATA_TVOC = 0x22;
static const uint8_t ENS160_REG_DATA_ECO2 = 0x24;
static const uint8_t ENS160_REG_GPR_READ_0 = 0x48;
static const uint8_t ENS160_REG_GPR_READ_4 = ENS160_REG_GPR_READ_0 + 4;
static const uint8_t ENS160_COMMAND_NOP = 0x00;
static const uint8_t ENS160_COMMAND_CLRGPR = 0xCC;
static const uint8_t ENS160_COMMAND_GET_APPVER = 0x0E;
static const uint8_t ENS160_OPMODE_RESET = 0xF0;
static const uint8_t ENS160_OPMODE_IDLE = 0x01;
static const uint8_t ENS160_OPMODE_STD = 0x02;
static const uint8_t ENS160_DATA_STATUS_STATAS = 0x80;
static const uint8_t ENS160_DATA_STATUS_STATER = 0x40;
static const uint8_t ENS160_DATA_STATUS_VALIDITY = 0x0C;
static const uint8_t ENS160_DATA_STATUS_NEWDAT = 0x02;
static const uint8_t ENS160_DATA_STATUS_NEWGPR = 0x01;
// helps remove reserved bits in aqi data register
static const uint8_t ENS160_DATA_AQI = 0x07;
void ENS160Component::setup() {
ESP_LOGCONFIG(TAG, "Setting up ENS160...");
// check part_id
uint16_t part_id;
if (!this->read_bytes(ENS160_REG_PART_ID, reinterpret_cast<uint8_t *>(&part_id), 2)) {
this->error_code_ = COMMUNICATION_FAILED;
this->mark_failed();
return;
}
if (part_id != ENS160_PART_ID) {
this->error_code_ = INVALID_ID;
this->mark_failed();
return;
}
// set mode to reset
if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_RESET)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
delay(ENS160_BOOTING);
// check status
uint8_t status_value;
if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) {
this->error_code_ = READ_FAILED;
this->mark_failed();
return;
}
this->validity_flag_ = static_cast<ValidityFlag>((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2);
if (this->validity_flag_ == INVALID_OUTPUT) {
this->error_code_ = VALIDITY_INVALID;
this->mark_failed();
return;
}
// set mode to idle
if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_IDLE)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
// clear command
if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_NOP)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_CLRGPR)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
// read firmware version
if (!this->write_byte(ENS160_REG_COMMAND, ENS160_COMMAND_GET_APPVER)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
uint8_t version_data[3];
if (!this->read_bytes(ENS160_REG_GPR_READ_4, version_data, 3)) {
this->error_code_ = READ_FAILED;
this->mark_failed();
return;
}
this->firmware_ver_major_ = version_data[0];
this->firmware_ver_minor_ = version_data[1];
this->firmware_ver_build_ = version_data[2];
// set mode to standard
if (!this->write_byte(ENS160_REG_OPMODE, ENS160_OPMODE_STD)) {
this->error_code_ = WRITE_FAILED;
this->mark_failed();
return;
}
// read opmode and check standard mode is achieved before finishing Setup
uint8_t op_mode;
if (!this->read_byte(ENS160_REG_OPMODE, &op_mode)) {
this->error_code_ = READ_FAILED;
this->mark_failed();
return;
}
if (op_mode != ENS160_OPMODE_STD) {
this->error_code_ = STD_OPMODE_FAILED;
this->mark_failed();
return;
}
}
void ENS160Component::update() {
uint8_t status_value, data_ready;
if (!this->read_byte(ENS160_REG_DATA_STATUS, &status_value)) {
ESP_LOGW(TAG, "Error reading status register");
this->status_set_warning();
return;
}
// verbose status logging
ESP_LOGV(TAG, "Status: ENS160 STATAS bit 0x%x",
(ENS160_DATA_STATUS_STATAS & (status_value)) == ENS160_DATA_STATUS_STATAS);
ESP_LOGV(TAG, "Status: ENS160 STATER bit 0x%x",
(ENS160_DATA_STATUS_STATER & (status_value)) == ENS160_DATA_STATUS_STATER);
ESP_LOGV(TAG, "Status: ENS160 VALIDITY FLAG 0x%02x", (ENS160_DATA_STATUS_VALIDITY & status_value) >> 2);
ESP_LOGV(TAG, "Status: ENS160 NEWDAT bit 0x%x",
(ENS160_DATA_STATUS_NEWDAT & (status_value)) == ENS160_DATA_STATUS_NEWDAT);
ESP_LOGV(TAG, "Status: ENS160 NEWGPR bit 0x%x",
(ENS160_DATA_STATUS_NEWGPR & (status_value)) == ENS160_DATA_STATUS_NEWGPR);
data_ready = ENS160_DATA_STATUS_NEWDAT & status_value;
this->validity_flag_ = static_cast<ValidityFlag>((ENS160_DATA_STATUS_VALIDITY & status_value) >> 2);
switch (validity_flag_) {
case NORMAL_OPERATION:
if (data_ready != ENS160_DATA_STATUS_NEWDAT) {
ESP_LOGD(TAG, "ENS160 readings unavailable - Normal Operation but readings not ready");
return;
}
break;
case INITIAL_STARTUP:
if (!this->initial_startup_) {
this->initial_startup_ = true;
ESP_LOGI(TAG, "ENS160 readings unavailable - 1 hour startup required after first power on");
}
return;
case WARMING_UP:
if (!this->warming_up_) {
this->warming_up_ = true;
ESP_LOGI(TAG, "ENS160 readings not available yet - Warming up requires 3 minutes");
this->send_env_data_();
}
return;
case INVALID_OUTPUT:
ESP_LOGE(TAG, "ENS160 Invalid Status - No Invalid Output");
this->status_set_warning();
return;
}
// read new data
uint16_t data_eco2;
if (!this->read_bytes(ENS160_REG_DATA_ECO2, reinterpret_cast<uint8_t *>(&data_eco2), 2)) {
ESP_LOGW(TAG, "Error reading eCO2 data register");
this->status_set_warning();
return;
}
if (this->co2_ != nullptr) {
this->co2_->publish_state(data_eco2);
}
uint16_t data_tvoc;
if (!this->read_bytes(ENS160_REG_DATA_TVOC, reinterpret_cast<uint8_t *>(&data_tvoc), 2)) {
ESP_LOGW(TAG, "Error reading TVOC data register");
this->status_set_warning();
return;
}
if (this->tvoc_ != nullptr) {
this->tvoc_->publish_state(data_tvoc);
}
uint8_t data_aqi;
if (!this->read_byte(ENS160_REG_DATA_AQI, &data_aqi)) {
ESP_LOGW(TAG, "Error reading AQI data register");
this->status_set_warning();
return;
}
if (this->aqi_ != nullptr) {
// remove reserved bits, just in case they are used in future
data_aqi = ENS160_DATA_AQI & data_aqi;
this->aqi_->publish_state(data_aqi);
}
this->status_clear_warning();
// set temperature and humidity compensation data
this->send_env_data_();
}
void ENS160Component::send_env_data_() {
if (this->temperature_ == nullptr && this->humidity_ == nullptr)
return;
float temperature = NAN;
if (this->temperature_ != nullptr)
temperature = this->temperature_->state;
if (std::isnan(temperature) || temperature < -40.0f || temperature > 85.0f) {
ESP_LOGW(TAG, "Invalid external temperature - compensation values not updated");
return;
} else {
ESP_LOGV(TAG, "External temperature compensation: %.1f°C", temperature);
}
float humidity = NAN;
if (this->humidity_ != nullptr)
humidity = this->humidity_->state;
if (std::isnan(humidity) || humidity < 0.0f || humidity > 100.0f) {
ESP_LOGW(TAG, "Invalid external humidity - compensation values not updated");
return;
} else {
ESP_LOGV(TAG, "External humidity compensation: %.1f%%", humidity);
}
uint16_t t = (uint16_t) ((temperature + 273.15f) * 64.0f);
uint16_t h = (uint16_t) (humidity * 512.0f);
uint8_t data[4];
data[0] = t & 0xff;
data[1] = (t >> 8) & 0xff;
data[2] = h & 0xff;
data[3] = (h >> 8) & 0xff;
if (!this->write_bytes(ENS160_REG_TEMP_IN, data, 4)) {
ESP_LOGE(TAG, "Error writing compensation values");
this->status_set_warning();
return;
}
}
void ENS160Component::dump_config() {
ESP_LOGCONFIG(TAG, "ENS160:");
switch (this->error_code_) {
case COMMUNICATION_FAILED:
ESP_LOGE(TAG, "Communication failed! Is the sensor connected?");
break;
case READ_FAILED:
ESP_LOGE(TAG, "Error reading from register");
break;
case WRITE_FAILED:
ESP_LOGE(TAG, "Error writing to register");
break;
case INVALID_ID:
ESP_LOGE(TAG, "Sensor reported an invalid ID. Is this a ENS160?");
break;
case VALIDITY_INVALID:
ESP_LOGE(TAG, "Invalid Device Status - No valid output");
break;
case STD_OPMODE_FAILED:
ESP_LOGE(TAG, "Device failed to achieve Standard Operating Mode");
break;
case NONE:
ESP_LOGD(TAG, "Setup successful");
break;
}
ESP_LOGI(TAG, "Firmware Version: %d.%d.%d", this->firmware_ver_major_, this->firmware_ver_minor_,
this->firmware_ver_build_);
LOG_I2C_DEVICE(this);
LOG_UPDATE_INTERVAL(this);
LOG_SENSOR(" ", "CO2 Sensor:", this->co2_);
LOG_SENSOR(" ", "TVOC Sensor:", this->tvoc_);
LOG_SENSOR(" ", "AQI Sensor:", this->aqi_);
if (this->temperature_ != nullptr && this->humidity_ != nullptr) {
LOG_SENSOR(" ", " Temperature Compensation:", this->temperature_);
LOG_SENSOR(" ", " Humidity Compensation:", this->humidity_);
} else {
ESP_LOGCONFIG(TAG, " Compensation: Not configured");
}
}
} // namespace ens160
} // namespace esphome

View file

@ -0,0 +1,60 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
namespace esphome {
namespace ens160 {
class ENS160Component : public PollingComponent, public i2c::I2CDevice, public sensor::Sensor {
public:
void set_co2(sensor::Sensor *co2) { co2_ = co2; }
void set_tvoc(sensor::Sensor *tvoc) { tvoc_ = tvoc; }
void set_aqi(sensor::Sensor *aqi) { aqi_ = aqi; }
void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; }
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
void setup() override;
void update() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::DATA; }
protected:
void send_env_data_();
enum ErrorCode {
NONE = 0,
COMMUNICATION_FAILED,
INVALID_ID,
VALIDITY_INVALID,
READ_FAILED,
WRITE_FAILED,
STD_OPMODE_FAILED,
} error_code_{NONE};
enum ValidityFlag {
NORMAL_OPERATION = 0,
WARMING_UP,
INITIAL_STARTUP,
INVALID_OUTPUT,
} validity_flag_;
bool warming_up_{false};
bool initial_startup_{false};
uint8_t firmware_ver_major_{0};
uint8_t firmware_ver_minor_{0};
uint8_t firmware_ver_build_{0};
sensor::Sensor *co2_{nullptr};
sensor::Sensor *tvoc_{nullptr};
sensor::Sensor *aqi_{nullptr};
sensor::Sensor *humidity_{nullptr};
sensor::Sensor *temperature_{nullptr};
};
} // namespace ens160
} // namespace esphome

View file

@ -0,0 +1,88 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import i2c, sensor
from esphome.const import (
CONF_ECO2,
CONF_HUMIDITY,
CONF_ID,
CONF_TEMPERATURE,
CONF_TVOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
ICON_CHEMICAL_WEAPON,
ICON_MOLECULE_CO2,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
UNIT_PARTS_PER_BILLION,
UNIT_PARTS_PER_MILLION,
)
CODEOWNERS = ["@vincentscode"]
DEPENDENCIES = ["i2c"]
ens160_ns = cg.esphome_ns.namespace("ens160")
ENS160Component = ens160_ns.class_(
"ENS160Component", cg.PollingComponent, i2c.I2CDevice, sensor.Sensor
)
CONF_AQI = "aqi"
CONF_COMPENSATION = "compensation"
UNIT_INDEX = "index"
CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(ENS160Component),
cv.Required(CONF_ECO2): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_MILLION,
icon=ICON_MOLECULE_CO2,
accuracy_decimals=0,
device_class=DEVICE_CLASS_CARBON_DIOXIDE,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Required(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Required(CONF_AQI): sensor.sensor_schema(
unit_of_measurement=UNIT_INDEX,
icon=ICON_CHEMICAL_WEAPON,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_COMPENSATION): cv.Schema(
{
cv.Required(CONF_TEMPERATURE): cv.use_id(sensor.Sensor),
cv.Required(CONF_HUMIDITY): cv.use_id(sensor.Sensor),
}
),
}
)
.extend(cv.polling_component_schema("60s"))
.extend(i2c.i2c_device_schema(0x53))
)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
sens = await sensor.new_sensor(config[CONF_ECO2])
cg.add(var.set_co2(sens))
sens = await sensor.new_sensor(config[CONF_TVOC])
cg.add(var.set_tvoc(sens))
sens = await sensor.new_sensor(config[CONF_AQI])
cg.add(var.set_aqi(sens))
if CONF_COMPENSATION in config:
compensation_config = config[CONF_COMPENSATION]
sens = await cg.get_variable(compensation_config[CONF_TEMPERATURE])
cg.add(var.set_temperature(sens))
sens = await cg.get_variable(compensation_config[CONF_HUMIDITY])
cg.add(var.set_humidity(sens))

View file

@ -93,7 +93,8 @@ bool Nextion::check_connect_() {
connect_info.push_back(response.substr(start, end - start));
}
if (connect_info.size() == 7) {
this->is_detected_ = (connect_info.size() == 7);
if (this->is_detected_) {
ESP_LOGN(TAG, "Received connect_info %zu", connect_info.size());
this->device_model_ = connect_info[2];

View file

@ -48,10 +48,12 @@ class NextionBase {
bool is_sleeping() { return this->is_sleeping_; }
bool is_setup() { return this->is_setup_; }
bool is_detected() { return this->is_detected_; }
protected:
bool is_setup_ = false;
bool is_sleeping_ = false;
bool is_detected_ = false;
};
} // namespace nextion

View file

@ -75,12 +75,13 @@ def validate_rx_pin(value):
def validate_invert_esp32(config):
if (
CORE.is_esp32
and CORE.using_arduino
and CONF_TX_PIN in config
and CONF_RX_PIN in config
and config[CONF_TX_PIN][CONF_INVERTED] != config[CONF_RX_PIN][CONF_INVERTED]
):
raise cv.Invalid(
"Different invert values for TX and RX pin are not (yet) supported for ESP32."
"Different invert values for TX and RX pin are not supported for ESP32 when using Arduino."
)
return config

View file

@ -278,10 +278,13 @@ std::string str_snake_case(const std::string &str) {
return result;
}
std::string str_sanitize(const std::string &str) {
std::string out;
std::copy_if(str.begin(), str.end(), std::back_inserter(out), [](const char &c) {
return c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
});
std::string out = str;
std::replace_if(
out.begin(), out.end(),
[](const char &c) {
return !(c == '-' || c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'));
},
'_');
return out;
}
std::string str_snprintf(const char *fmt, size_t len, ...) {

View file

@ -27,6 +27,7 @@ import tornado.process
import tornado.queues
import tornado.web
import tornado.websocket
import tornado.httputil
import yaml
from tornado.log import access_log
@ -136,7 +137,15 @@ def websocket_method(name):
@websocket_class
class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
def __init__(self, application, request, **kwargs):
"""Base class for ESPHome websocket commands."""
def __init__(
self,
application: tornado.web.Application,
request: tornado.httputil.HTTPServerRequest,
**kwargs: Any,
) -> None:
"""Initialize the websocket."""
super().__init__(application, request, **kwargs)
self._proc = None
self._queue = None
@ -145,6 +154,12 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler):
# use Popen() with a reading thread instead
self._use_popen = os.name == "nt"
def open(self, *args: str, **kwargs: str) -> None:
"""Handle new WebSocket connection."""
# Ensure messages from the subprocess are sent immediately
# to avoid a 200-500ms delay when nodelay is not set.
self.set_nodelay(True)
@authenticated
async def on_message( # pylint: disable=invalid-overridden-method
self, message: str

View file

@ -357,6 +357,9 @@ def snake_case(value):
return value.replace(" ", "_").lower()
_DISALLOWED_CHARS = re.compile(r"[^a-zA-Z0-9_]")
def sanitize(value):
"""Same behaviour as `helpers.cpp` method `str_sanitize`."""
return re.sub("[^-_0-9a-zA-Z]", r"", value)
return _DISALLOWED_CHARS.sub("_", value)

View file

@ -10,7 +10,7 @@ platformio==6.1.11 # When updating platformio, also update Dockerfile
esptool==4.6.2
click==8.1.7
esphome-dashboard==20231107.0
aioesphomeapi==18.5.5
aioesphomeapi==18.5.9
zeroconf==0.127.0
python-magic==0.4.27

View file

@ -6,12 +6,6 @@ cd "$(dirname "$0")/.."
set -x
esphome compile tests/test1.yaml
esphome compile tests/test2.yaml
esphome compile tests/test3.yaml
esphome compile tests/test3.1.yaml
esphome compile tests/test4.yaml
esphome compile tests/test5.yaml
esphome compile tests/test6.yaml
esphome compile tests/test7.yaml
esphome compile tests/test8.yaml
for f in ./tests/test*.yaml; do
esphome compile $f
done

View file

@ -225,6 +225,13 @@ sensor:
name: "ADE7953 Reactive Power B"
update_interval: 1s
- platform: ens160
eco2:
name: "ENS160 eCO2"
tvoc:
name: "ENS160 Total Volatile Organic Compounds"
aqi:
name: "ENS160 Air Quality Index"
- platform: tmp102
name: TMP102 Temperature
- platform: hm3301
@ -424,7 +431,6 @@ switch:
direction: BACKWARD
id: test_motor
custom_component:
lambda: |-
auto s = new CustomComponent();
@ -613,7 +619,6 @@ mcp23017:
mcp23008:
id: mcp23008_hub
light:
- platform: hbridge
name: Icicle Lights
@ -633,7 +638,6 @@ ttp229_bsf:
sdo_pin: D2
scl_pin: D1
display:
- platform: max7219digit
cs_pin: GPIO15
@ -645,7 +649,6 @@ display:
lambda: |-
it.printdigit("hello");
http_request:
useragent: esphome/device
timeout: 10s

View file

@ -480,7 +480,6 @@ sensor:
name: PZEMDC Power
energy:
name: PZEMDC Energy
- platform: pmsx003
uart_id: uart_9
type: PMSX003

View file

@ -41,7 +41,9 @@ uart:
rx_pin: 3
baud_rate: 9600
- id: uart_2
tx_pin: 17
tx_pin:
number: 17
inverted: true
rx_pin: 16
baud_rate: 19200

View file

@ -258,9 +258,9 @@ def test_snake_case(text, expected):
"text, expected",
(
("foo_bar", "foo_bar"),
('!"§$%&/()=?foo_bar', "foo_bar"),
('foo_!"§$%&/()=?bar', "foo_bar"),
('foo_bar!"§$%&/()=?', "foo_bar"),
('!"§$%&/()=?foo_bar', "___________foo_bar"),
('foo_!"§$%&/()=?bar', "foo____________bar"),
('foo_bar!"§$%&/()=?', "foo_bar___________"),
),
)
def test_sanitize(text, expected):