Compare commits
No commits in common. 'esphome' and 'split_files' have entirely different histories.
esphome
...
split_file
45 changed files with 923 additions and 1051 deletions
@ -0,0 +1,18 @@ |
|||||
|
.pio |
||||
|
|
||||
|
# old platformio |
||||
|
.pioenvs |
||||
|
.piolibdeps |
||||
|
.clang_complete |
||||
|
.gcc-flags.json |
||||
|
|
||||
|
# virtualenv |
||||
|
bin/ |
||||
|
include/ |
||||
|
lib/ |
||||
|
local/ |
||||
|
share/ |
||||
|
|
||||
|
# custom |
||||
|
version.txt |
||||
|
build/ |
@ -0,0 +1,39 @@ |
|||||
|
stages: |
||||
|
- build |
||||
|
- deploy |
||||
|
|
||||
|
build: |
||||
|
stage: build |
||||
|
image: python:2.7 |
||||
|
before_script: |
||||
|
- pip install -U platformio |
||||
|
- platformio update |
||||
|
script: |
||||
|
# - date +%s > build/version.txt |
||||
|
- platformio ci --lib="script" --project-conf platformio.ini --board=lolin_d32_pro --build-dir="build" --keep-build-dir |
||||
|
variables: {PLATFORMIO_CI_SRC: "src"} |
||||
|
artifacts: |
||||
|
paths: |
||||
|
- build/.pio/build/lolin_d32_pro/*.bin |
||||
|
- build/version.txt |
||||
|
|
||||
|
deploy: |
||||
|
stage: deploy |
||||
|
image: alpine:latest |
||||
|
before_script: |
||||
|
- apk update && apk add openssh-client bash rsync |
||||
|
# - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' |
||||
|
- eval $(ssh-agent -s) |
||||
|
- echo "${SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - > /dev/null |
||||
|
- mkdir -p ~/.ssh |
||||
|
- chmod 700 ~/.ssh |
||||
|
- echo "${SSH_HOST_KEY}" > ~/.ssh/known_hosts |
||||
|
- chmod 644 ~/.ssh/known_hosts |
||||
|
script: |
||||
|
- scp build/.pio/build/lolin_d32_pro/firmware.bin "${SSH_USER_HOST}:${DEPLOY_PATH}" |
||||
|
- scp build/version.txt "${SSH_USER_HOST}:${DEPLOY_PATH}".version |
||||
|
dependencies: |
||||
|
- build |
||||
|
when: manual |
||||
|
only: |
||||
|
- master |
@ -0,0 +1,20 @@ |
|||||
|
Copyright (c) 2019 Hendrik Langer |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining |
||||
|
a copy of this software and associated documentation files (the |
||||
|
"Software"), to deal in the Software without restriction, including |
||||
|
without limitation the rights to use, copy, modify, merge, publish, |
||||
|
distribute, sublicense, and/or sell copies of the Software, and to |
||||
|
permit persons to whom the Software is furnished to do so, subject to |
||||
|
the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be |
||||
|
included in all copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE |
||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION |
||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION |
||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@ -1,83 +0,0 @@ |
|||||
esphome: |
|
||||
name: ${node_name} |
|
||||
platform: ESP32 |
|
||||
board: lolin_d32_pro |
|
||||
project: |
|
||||
name: "xd0.esp32weatherstation2" |
|
||||
version: "0.1.1" |
|
||||
includes: |
|
||||
- fonts/weather_icon_map.h |
|
||||
- custom/veml6075_custom_sensor.h |
|
||||
libraries: |
|
||||
- Wire |
|
||||
- SPI |
|
||||
- "https://github.com/adafruit/Adafruit_BusIO" |
|
||||
- "https://github.com/adafruit/Adafruit_VEML6075" |
|
||||
# platformio_options: |
|
||||
# lib_ldf_mode: chain+ |
|
||||
|
|
||||
# Enable logging |
|
||||
logger: |
|
||||
# level: VERY_VERBOSE |
|
||||
|
|
||||
# Enable Home Assistant API |
|
||||
api: |
|
||||
|
|
||||
ota: |
|
||||
password: !secret esphome_ota_password |
|
||||
|
|
||||
wifi: |
|
||||
networks: |
|
||||
- ssid: !secret wifi_ssid |
|
||||
password: !secret wifi_passwd |
|
||||
- ssid: !secret wifi_ssid2 |
|
||||
password: !secret wifi_passwd2 |
|
||||
power_save_mode: light |
|
||||
|
|
||||
# Enable fallback hotspot (captive portal) in case wifi connection fails |
|
||||
ap: |
|
||||
ssid: ${node_name} |
|
||||
password: !secret wifi_captive_portal_password |
|
||||
|
|
||||
captive_portal: |
|
||||
|
|
||||
time: |
|
||||
- platform: sntp |
|
||||
id: sntp_time |
|
||||
timezone: "Europe/Berlin" |
|
||||
|
|
||||
mqtt: |
|
||||
broker: home.xd0.de |
|
||||
username: !secret esphome_mqtt_user |
|
||||
password: !secret esphome_mqtt_passwd |
|
||||
discovery: false |
|
||||
discovery_retain: false |
|
||||
topic_prefix: "thomas" |
|
||||
log_topic: |
|
||||
topic: log"thomas/${node_name}/debug" |
|
||||
level: INFO |
|
||||
# bus |
|
||||
i2c: |
|
||||
sda: 21 |
|
||||
scl: 22 |
|
||||
scan: true |
|
||||
|
|
||||
spi: |
|
||||
clk_pin: 18 |
|
||||
mosi_pin: 23 |
|
||||
miso_pin: 19 |
|
||||
|
|
||||
status_led: |
|
||||
pin: |
|
||||
number: GPIO5 |
|
||||
inverted: true |
|
||||
|
|
||||
sensor: |
|
||||
# WiFi signal strength |
|
||||
- platform: wifi_signal |
|
||||
name: "${node_name} RSSI" |
|
||||
update_interval: 60s |
|
||||
# Example hall sensor |
|
||||
# - platform: esp32_hall |
|
||||
# name: "${node_name} ESP32 Hall Sensor" |
|
||||
# update_interval: 60s |
|
@ -1,27 +0,0 @@ |
|||||
sensor: |
|
||||
# Battery level |
|
||||
- platform: adc |
|
||||
pin: _VBAT |
|
||||
name: "${node_name} VBAT Voltage" |
|
||||
id: voltage |
|
||||
attenuation: 11db |
|
||||
filters: |
|
||||
- multiply: 1.71 |
|
||||
# filters: |
|
||||
# - lambda: return ( x/4096.0 ) * 2 * 3.42; |
|
||||
update_interval: 60s |
|
||||
- platform: template |
|
||||
name: "${node_name} Battery Level" |
|
||||
id: "battery_level" |
|
||||
unit_of_measurement: '%' |
|
||||
icon: "mdi:battery" |
|
||||
device_class: "battery" |
|
||||
state_class: "measurement" |
|
||||
accuracy_decimals: 1 |
|
||||
update_interval: 60s |
|
||||
lambda: |- |
|
||||
return ((id(voltage).state -3) /1.2 * 100.00); |
|
||||
|
|
||||
#deep_sleep: |
|
||||
# run_duration: 30s |
|
||||
# sleep_duration: 10min |
|
@ -1,8 +0,0 @@ |
|||||
# BH1750 Ambient Light Sensor |
|
||||
sensor: |
|
||||
- platform: bh1750 |
|
||||
name: "${node_name} Illuminance" |
|
||||
id: lux |
|
||||
address: 0x23 |
|
||||
measurement_duration: 69 |
|
||||
update_interval: 60s |
|
@ -1,45 +0,0 @@ |
|||||
# BME280 Temperature+Pressure+Humidity Sensor |
|
||||
sensor: |
|
||||
- platform: bme280 |
|
||||
temperature: |
|
||||
name: "${node_name} Temperature" |
|
||||
id: temperature |
|
||||
pressure: |
|
||||
name: "${node_name} Pressure" |
|
||||
id: pressure |
|
||||
humidity: |
|
||||
name: "${node_name} Humidity" |
|
||||
id: humidity |
|
||||
address: 0x77 |
|
||||
update_interval: 60s |
|
||||
- platform: template |
|
||||
name: "${node_name} Equivalent sea level pressure" |
|
||||
id: pressure_sealevel |
|
||||
lambda: |- |
|
||||
const float STANDARD_ALTITUDE = ${altitude}; // in meters, see note |
|
||||
return id(pressure).state / powf(1 - ((0.0065 * STANDARD_ALTITUDE) / |
|
||||
(id(temperature).state + (0.0065 * STANDARD_ALTITUDE) + 273.15)), 5.257); // in hPa |
|
||||
update_interval: 60s |
|
||||
unit_of_measurement: 'hPa' |
|
||||
- platform: template |
|
||||
name: "${node_name} Absolute Humidity" |
|
||||
lambda: |- |
|
||||
const float mw = 18.01534; // molar mass of water g/mol |
|
||||
const float r = 8.31447215; // Universal gas constant J/mol/K |
|
||||
return (6.112 * powf(2.718281828, (17.67 * id(temperature).state) / |
|
||||
(id(temperature).state + 243.5)) * id(humidity).state * mw) / |
|
||||
((273.15 + id(temperature).state) * r); // in grams/m^3 |
|
||||
accuracy_decimals: 2 |
|
||||
update_interval: 60s |
|
||||
icon: 'mdi:water' |
|
||||
unit_of_measurement: 'g/m³' |
|
||||
- platform: template |
|
||||
name: "${node_name} Dew Point" |
|
||||
lambda: |- |
|
||||
return (243.5*(log(id(humidity).state/100)+((17.67*id(temperature).state)/ |
|
||||
(243.5+id(temperature).state)))/(17.67-log(id(humidity).state/100)- |
|
||||
((17.67*id(temperature).state)/(243.5+id(temperature).state)))); |
|
||||
accuracy_decimals: 1 |
|
||||
update_interval: 60s |
|
||||
unit_of_measurement: °C |
|
||||
icon: 'mdi:thermometer-alert' |
|
@ -1,96 +0,0 @@ |
|||||
# BME680 Temperature+Pressure+Humidity+Gas Sensor |
|
||||
sensor: |
|
||||
- platform: bme680_bsec |
|
||||
temperature: |
|
||||
name: "${node_name} Temperature" |
|
||||
id: temperature |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
pressure: |
|
||||
name: "${node_name} Pressure" |
|
||||
id: pressure |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
humidity: |
|
||||
name: "${node_name} Humidity" |
|
||||
id: humidity |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
gas_resistance: |
|
||||
name: "${node_name} Gas Resistance" |
|
||||
id: voc |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
iaq: |
|
||||
name: "${node_name} IAQ" |
|
||||
id: iaq |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
iaq_accuracy: |
|
||||
name: "${node_name} Numeric IAQ Accuracy" |
|
||||
id: iaq_accuracy |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
co2_equivalent: |
|
||||
name: "${node_name} CO2 Equivalent" |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
breath_voc_equivalent: |
|
||||
name: "${node_name} Breath VOC Equivalent" |
|
||||
filters: |
|
||||
- median: |
|
||||
window_size: 12 |
|
||||
send_every: 12 |
|
||||
- platform: template |
|
||||
name: "${node_name} Equivalent sea level pressure" |
|
||||
id: pressure_sealevel |
|
||||
lambda: |- |
|
||||
const float STANDARD_ALTITUDE = ${altitude}; // in meters, see note |
|
||||
return id(pressure).state / powf(1 - ((0.0065 * STANDARD_ALTITUDE) / |
|
||||
(id(temperature).state + (0.0065 * STANDARD_ALTITUDE) + 273.15)), 5.257); // in hPa |
|
||||
update_interval: 60s |
|
||||
unit_of_measurement: 'hPa' |
|
||||
- platform: template |
|
||||
name: "${node_name} Absolute Humidity" |
|
||||
lambda: |- |
|
||||
const float mw = 18.01534; // molar mass of water g/mol |
|
||||
const float r = 8.31447215; // Universal gas constant J/mol/K |
|
||||
return (6.112 * powf(2.718281828, (17.67 * id(temperature).state) / |
|
||||
(id(temperature).state + 243.5)) * id(humidity).state * mw) / |
|
||||
((273.15 + id(temperature).state) * r); // in grams/m^3 |
|
||||
accuracy_decimals: 2 |
|
||||
update_interval: 60s |
|
||||
icon: 'mdi:water' |
|
||||
unit_of_measurement: 'g/m³' |
|
||||
- platform: template |
|
||||
name: "${node_name} Dew Point" |
|
||||
lambda: |- |
|
||||
return (243.5*(log(id(humidity).state/100)+((17.67*id(temperature).state)/ |
|
||||
(243.5+id(temperature).state)))/(17.67-log(id(humidity).state/100)- |
|
||||
((17.67*id(temperature).state)/(243.5+id(temperature).state)))); |
|
||||
accuracy_decimals: 1 |
|
||||
update_interval: 60s |
|
||||
unit_of_measurement: °C |
|
||||
icon: 'mdi:thermometer-alert' |
|
||||
|
|
||||
text_sensor: |
|
||||
- platform: bme680_bsec |
|
||||
iaq_accuracy: |
|
||||
name: "${node_name} IAQ Accuracy" |
|
||||
|
|
||||
bme680_bsec: |
|
||||
address: 0x77 |
|
@ -1,238 +0,0 @@ |
|||||
# display #250x122 pixels (250x128) |
|
||||
font: |
|
||||
- file: 'fonts/Vera.ttf' |
|
||||
id: font1 |
|
||||
size: 10 |
|
||||
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/²³µΩ€[]?' |
|
||||
- file: 'fonts/Vera.ttf' |
|
||||
id: font2 |
|
||||
size: 20 |
|
||||
# - file: 'fonts/VeraMono.ttf' |
|
||||
# id: font3 |
|
||||
# size: 10 |
|
||||
- file: 'fonts/materialdesignicons-webfont.ttf' |
|
||||
id: icon_font |
|
||||
size: 48 |
|
||||
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 |
|
||||
|
|
||||
display: |
|
||||
- platform: waveshare_epaper |
|
||||
cs_pin: 14 |
|
||||
dc_pin: 27 |
|
||||
reset_pin: 33 |
|
||||
model: 2.13in-ttgo |
|
||||
rotation: 90 |
|
||||
update_interval: 180s |
|
||||
full_update_every: 1 |
|
||||
id: my_display |
|
||||
pages: |
|
||||
- id: page1 |
|
||||
lambda: |- |
|
||||
it.print(10, 50, id(font2), "esp32weatherstation2"); |
|
||||
- id: page2 |
|
||||
lambda: |- |
|
||||
// Title |
|
||||
it.filled_rectangle(0,0,250,20, COLOR_ON); |
|
||||
it.print(5, 6, id(font1), COLOR_OFF, "${node_name}"); |
|
||||
it.strftime(150, 6, id(font1), COLOR_OFF, "%Y-%m-%d %H:%M", id(sntp_time).now()); |
|
||||
// Weather icon |
|
||||
if (id(weather_icon).has_state()) { |
|
||||
it.printf(0, 18, id(icon_font), weather_icon_map[id(weather_icon).state.c_str()].c_str()); |
|
||||
} |
|
||||
it.printf(55, 25, id(font1), "T: %.1f°C", id(temperature_outside).state); |
|
||||
it.printf(55, 35, id(font1), "H: %.1f%%", id(humidity_outside).state); |
|
||||
it.printf(55, 45, id(font1), "%.0fmm / %.0f%%", id(precipitation).state, id(precipitation_probability).state); |
|
||||
// own measurements |
|
||||
it.filled_rectangle(0, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(2, 65+4, id(font2), COLOR_OFF, "%.1f", id(temperature).state); it.printf(45, 65+4, id(font1), COLOR_OFF, "°C"); |
|
||||
it.filled_rectangle(60, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(62, 65+4, id(font2), COLOR_OFF, "%.1f", id(humidity).state); it.printf(105, 65+4, id(font1), COLOR_OFF, "%%"); |
|
||||
it.filled_rectangle(120, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(122, 65+4, id(font2), COLOR_OFF, "%.0f", id(pressure_sealevel).state); it.printf(178, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "hPa"); |
|
||||
// humidity human readable |
|
||||
if (id(humidity).state < 30) { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "low"); |
|
||||
} else if (id(humidity).state < 60) { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "comfort"); |
|
||||
} else { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "high"); |
|
||||
} |
|
||||
// graphs |
|
||||
it.graph(0, 98, id(temperature_graph)); |
|
||||
it.graph(60, 98, id(humidity_graph)); |
|
||||
it.graph(120, 98, id(pressure_graph)); |
|
||||
// voc |
|
||||
it.circle(130, 42, 20); |
|
||||
it.line(115, 42, 145, 42); |
|
||||
it.filled_rectangle(130-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(130, 26, id(font1), TextAlign::CENTER, "VOC"); |
|
||||
if (id(iaq_accuracy).state >= 2) { |
|
||||
it.printf(130, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f", id(iaq).state); |
|
||||
if (id(iaq).state < 50) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "excellent"); |
|
||||
} else if (id(iaq).state < 100) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "fine"); |
|
||||
} else if (id(iaq).state < 150) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "moderate"); |
|
||||
} else if (id(iaq).state < 200) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "poor"); |
|
||||
} else if (id(iaq).state < 300) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "very poor"); |
|
||||
} else if (id(iaq).state < 500) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "severe"); |
|
||||
} |
|
||||
} else { |
|
||||
it.printf(130, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f \u03A9", id(voc).state); |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "VOC"); |
|
||||
} |
|
||||
// climate.wz |
|
||||
it.circle(175, 42, 20); |
|
||||
it.line(160, 42, 190, 42); |
|
||||
it.filled_rectangle(175-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(175, 26, id(font1), TextAlign::CENTER, "WZ"); |
|
||||
it.printf(175, 41, id(font1), TextAlign::BASELINE_CENTER, "%.1f°C", id(temp_set_wz).state); |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "%.1f°C", id(temp_wz).state); |
|
||||
// Benzinpreis |
|
||||
it.rectangle(200, 22, 40, 40); |
|
||||
it.line(205, 42, 235, 42); |
|
||||
it.filled_rectangle(220-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(220, 26, id(font1), TextAlign::CENTER, "E5"); |
|
||||
it.printf(220, 41, id(font1), TextAlign::BASELINE_CENTER, "%.3f€", id(benzinpreis).state); |
|
||||
it.printf(220, 43, id(font1), TextAlign::TOP_CENTER, "%.3f€", id(benzinpreis2).state); |
|
||||
// other values |
|
||||
it.print(184, 75, id(font1), "UVI:"); it.printf(248, 75, id(font1), TextAlign::TOP_RIGHT, "%.2f", id(uvi_outdoor).state); |
|
||||
it.print(184, 85, id(font1), "YNH:"); it.printf(248, 85, id(font1), TextAlign::TOP_RIGHT, "%.2f", id(yunohost_load).state); |
|
||||
// Springer |
|
||||
it.print(184, 105, id(font1), "Bat:"); |
|
||||
if (id(battery_level).state >=0 && id(battery_level).state <= 100) { |
|
||||
it.printf(248, 105, id(font1), TextAlign::TOP_RIGHT, "%.0f%%", id(battery_level).state); |
|
||||
} else { |
|
||||
it.printf(248, 105, id(font1), TextAlign::TOP_RIGHT, "nc"); |
|
||||
} |
|
||||
|
|
||||
interval: |
|
||||
- interval: 30s |
|
||||
then: |
|
||||
- if: |
|
||||
condition: |
|
||||
- display.is_displaying_page: page1 |
|
||||
then: |
|
||||
- display.page.show: page2 |
|
||||
- component.update: my_display |
|
||||
|
|
||||
graph: |
|
||||
# Show bare-minimum auto-ranged graph |
|
||||
- id: temperature_graph |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
traces: |
|
||||
- sensor: temperature |
|
||||
line_type: SOLID |
|
||||
line_thickness: 2 |
|
||||
# - sensor: temperature_outside |
|
||||
# line_type: DASHED |
|
||||
# line_thickness: 1 |
|
||||
- id: humidity_graph |
|
||||
sensor: humidity |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
- id: pressure_graph |
|
||||
sensor: pressure |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
|
|
||||
# Example configuration entry |
|
||||
sensor: |
|
||||
- platform: homeassistant |
|
||||
name: "Temperature Outdoor" |
|
||||
internal: true |
|
||||
entity_id: weather.openweathermap |
|
||||
attribute: temperature |
|
||||
id: temperature_outside |
|
||||
- platform: homeassistant |
|
||||
name: "Humidity Outdoor" |
|
||||
internal: true |
|
||||
entity_id: weather.openweathermap |
|
||||
attribute: humidity |
|
||||
id: humidity_outside |
|
||||
- platform: homeassistant |
|
||||
name: "Precipitation Propability Forecast" |
|
||||
internal: true |
|
||||
entity_id: sensor.openweathermap_forecast_precipitation |
|
||||
id: precipitation |
|
||||
- platform: homeassistant |
|
||||
name: "Precipitation Probability Forecast" |
|
||||
internal: true |
|
||||
entity_id: sensor.openweathermap_forecast_precipitation_probability |
|
||||
id: precipitation_probability |
|
||||
- platform: homeassistant |
|
||||
name: "UVI Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.openweathermap_uv_index |
|
||||
id: uvi_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "WZ Temp" |
|
||||
internal: true |
|
||||
entity_id: climate.leq1333417 |
|
||||
attribute: current_temperature |
|
||||
id: temp_wz |
|
||||
- platform: homeassistant |
|
||||
name: "WZ Temp Set" |
|
||||
internal: true |
|
||||
entity_id: climate.leq1333417 |
|
||||
attribute: temperature |
|
||||
id: temp_set_wz |
|
||||
- platform: homeassistant |
|
||||
name: "Benzinpreis" |
|
||||
internal: true |
|
||||
entity_id: sensor.tankerkoenig_sb_essen_frankenstrasse_74_e5 |
|
||||
id: benzinpreis |
|
||||
- platform: homeassistant |
|
||||
name: "Benzinpreis2" |
|
||||
internal: true |
|
||||
entity_id: sensor.tankerkoenig_total_essen_e5 |
|
||||
id: benzinpreis2 |
|
||||
- platform: homeassistant |
|
||||
name: "Yunohost Load" |
|
||||
internal: true |
|
||||
entity_id: sensor.yunohost_load |
|
||||
id: yunohost_load |
|
||||
|
|
||||
text_sensor: |
|
||||
- platform: homeassistant |
|
||||
id: weather_icon |
|
||||
internal: true |
|
||||
entity_id: weather.openweathermap |
|
@ -1,294 +0,0 @@ |
|||||
# display #250x122 pixels (250x128) |
|
||||
font: |
|
||||
- file: 'fonts/Vera.ttf' |
|
||||
id: font1 |
|
||||
size: 10 |
|
||||
glyphs: '!"%()+=,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz/²³µΩ€[]?' |
|
||||
- file: 'fonts/Vera.ttf' |
|
||||
id: font2 |
|
||||
size: 20 |
|
||||
# - file: 'fonts/VeraMono.ttf' |
|
||||
# id: font3 |
|
||||
# size: 10 |
|
||||
- file: 'fonts/materialdesignicons-webfont.ttf' |
|
||||
id: icon_font |
|
||||
size: 48 |
|
||||
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 |
|
||||
|
|
||||
display: |
|
||||
- platform: waveshare_epaper |
|
||||
cs_pin: 14 |
|
||||
dc_pin: 27 |
|
||||
reset_pin: 33 |
|
||||
model: 2.13in-ttgo |
|
||||
rotation: 90 |
|
||||
update_interval: 180s |
|
||||
full_update_every: 1 |
|
||||
id: my_display |
|
||||
pages: |
|
||||
- id: page1 |
|
||||
lambda: |- |
|
||||
it.print(10, 50, id(font2), "esp32weatherstation2"); |
|
||||
- id: page2 |
|
||||
lambda: |- |
|
||||
// Title |
|
||||
it.filled_rectangle(0,0,250,20, COLOR_ON); |
|
||||
it.print(5, 6, id(font1), COLOR_OFF, "${node_name}"); |
|
||||
it.strftime(150, 6, id(font1), COLOR_OFF, "%Y-%m-%d %H:%M", id(sntp_time).now()); |
|
||||
// Weather icon |
|
||||
if (id(weather_icon).has_state()) { |
|
||||
it.printf(0, 18, id(icon_font), weather_icon_map[id(weather_icon).state.c_str()].c_str()); |
|
||||
} |
|
||||
it.printf(55, 25, id(font1), "T: %.1f°C", id(temperature_outside).state); |
|
||||
it.printf(55, 35, id(font1), "H: %.1f%%", id(humidity_outside).state); |
|
||||
it.printf(55, 45, id(font1), "%s", id(weather_icon).state.c_str()); |
|
||||
// own measurements |
|
||||
it.filled_rectangle(0, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(2, 65+4, id(font2), COLOR_OFF, "%.1f", id(temperature).state); it.printf(45, 65+4, id(font1), COLOR_OFF, "°C"); |
|
||||
it.filled_rectangle(60, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(62, 65+4, id(font2), COLOR_OFF, "%.1f", id(humidity).state); it.printf(105, 65+4, id(font1), COLOR_OFF, "%%"); |
|
||||
it.filled_rectangle(120, 65, 59, 33, COLOR_ON); |
|
||||
it.printf(122, 65+4, id(font2), COLOR_OFF, "%.0f", id(pressure_sealevel).state); it.printf(178, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "hPa"); |
|
||||
// humidity human readable |
|
||||
if (id(humidity).state < 30) { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "low"); |
|
||||
} else if (id(humidity).state < 60) { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "comfort"); |
|
||||
} else { |
|
||||
it.printf(116, 87, id(font1), COLOR_OFF, TextAlign::TOP_RIGHT, "high"); |
|
||||
} |
|
||||
// graphs |
|
||||
it.graph(0, 98, id(temperature_graph)); |
|
||||
it.graph(60, 98, id(humidity_graph)); |
|
||||
it.graph(120, 98, id(pressure_graph)); |
|
||||
// voc |
|
||||
it.circle(130, 42, 20); |
|
||||
it.line(115, 42, 145, 42); |
|
||||
it.filled_rectangle(130-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(130, 26, id(font1), TextAlign::CENTER, "WZ"); |
|
||||
if (id(iaq_accuracy_wz).state >= 2) { |
|
||||
it.printf(130, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f", id(iaq_wz).state); |
|
||||
if (id(iaq_wz).state < 50) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "excellent"); |
|
||||
} else if (id(iaq_wz).state < 100) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "fine"); |
|
||||
} else if (id(iaq_wz).state < 150) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "moderate"); |
|
||||
} else if (id(iaq_wz).state < 200) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "poor"); |
|
||||
} else if (id(iaq_wz).state < 300) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "very poor"); |
|
||||
} else if (id(iaq_wz).state < 500) { |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "severe"); |
|
||||
} |
|
||||
} else { |
|
||||
it.printf(130, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f \u03A9", id(voc_wz).state); |
|
||||
it.printf(130, 43, id(font1), TextAlign::TOP_CENTER, "VOC"); |
|
||||
} |
|
||||
// voc2 |
|
||||
it.circle(175, 42, 20); |
|
||||
it.line(160, 42, 190, 42); |
|
||||
it.filled_rectangle(175-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(175, 26, id(font1), TextAlign::CENTER, "SZ"); |
|
||||
if (id(iaq_accuracy_sz).state >= 2) { |
|
||||
it.printf(175, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f", id(iaq_sz).state); |
|
||||
if (id(iaq_sz).state < 50) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "excellent"); |
|
||||
} else if (id(iaq_sz).state < 100) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "fine"); |
|
||||
} else if (id(iaq_sz).state < 150) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "moderate"); |
|
||||
} else if (id(iaq_sz).state < 200) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "poor"); |
|
||||
} else if (id(iaq_sz).state < 300) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "very poor"); |
|
||||
} else if (id(iaq_sz).state < 500) { |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "severe"); |
|
||||
} |
|
||||
} else { |
|
||||
it.printf(175, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f \u03A9", id(voc_sz).state); |
|
||||
it.printf(175, 43, id(font1), TextAlign::TOP_CENTER, "VOC"); |
|
||||
} |
|
||||
// Particulate Matter Sensor |
|
||||
it.rectangle(200, 22, 40, 40); // it.circle(220, 42, 20); |
|
||||
it.line(205, 42, 235, 42); |
|
||||
it.filled_rectangle(220-10, 21, 20, 10, COLOR_OFF); |
|
||||
it.print(220, 26, id(font1), TextAlign::CENTER, "PM"); |
|
||||
it.printf(220, 41, id(font1), TextAlign::BASELINE_CENTER, "%.0f", id(pm2_outdoor).state); |
|
||||
it.printf(220, 43, id(font1), TextAlign::TOP_CENTER, "%.0f", id(pm10_outdoor).state); |
|
||||
// other values |
|
||||
it.print(184, 65, id(font1), "Lux:"); it.printf(248, 65, id(font1), TextAlign::TOP_RIGHT, "%.0f lx", id(lux_outdoor).state); |
|
||||
it.print(184, 75, id(font1), "UVI:"); it.printf(248, 75, id(font1), TextAlign::TOP_RIGHT, "%.0f", id(uvi_outdoor).state); |
|
||||
it.print(184, 85, id(font1), "UVA:"); it.printf(248, 85, id(font1), TextAlign::TOP_RIGHT, "%.0f", id(uva_outdoor).state); |
|
||||
it.print(184, 95, id(font1), "UVB:"); it.printf(248, 95, id(font1), TextAlign::TOP_RIGHT, "%.0f", id(uvb_outdoor).state); |
|
||||
// Springer |
|
||||
it.print(184, 105, id(font1), "Springer"); |
|
||||
if (id(battery_springer).state >=0 && id(battery_springer).state <= 100) { |
|
||||
it.printf(248, 105, id(font1), TextAlign::TOP_RIGHT, "%.0f%%", id(battery_springer).state); |
|
||||
} else { |
|
||||
it.printf(248, 105, id(font1), TextAlign::TOP_RIGHT, "nc"); |
|
||||
} |
|
||||
it.printf(184,115, id(font1), "%.1f°C, %.1f%%H, %.0fhPa", id(temperature_springer).state, id(humidity_springer).state, id(pressure_springer).state); |
|
||||
|
|
||||
interval: |
|
||||
- interval: 30s |
|
||||
then: |
|
||||
- if: |
|
||||
condition: |
|
||||
- display.is_displaying_page: page1 |
|
||||
then: |
|
||||
- display.page.show: page2 |
|
||||
- component.update: my_display |
|
||||
|
|
||||
graph: |
|
||||
# Show bare-minimum auto-ranged graph |
|
||||
- id: temperature_graph |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
traces: |
|
||||
- sensor: temperature |
|
||||
line_type: SOLID |
|
||||
line_thickness: 2 |
|
||||
# - sensor: temperature_outside |
|
||||
# line_type: DASHED |
|
||||
# line_thickness: 1 |
|
||||
- id: humidity_graph |
|
||||
sensor: humidity |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
- id: pressure_graph |
|
||||
sensor: pressure |
|
||||
duration: 1h |
|
||||
width: 59 |
|
||||
height: 31 |
|
||||
|
|
||||
# Example configuration entry |
|
||||
sensor: |
|
||||
- platform: homeassistant |
|
||||
name: "Temperature Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_temperature |
|
||||
id: temperature_outside |
|
||||
- platform: homeassistant |
|
||||
name: "Humidity Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_humidity |
|
||||
id: humidity_outside |
|
||||
- platform: homeassistant |
|
||||
name: "WZ VOC" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_wz_gas_resistance |
|
||||
id: voc_wz |
|
||||
- platform: homeassistant |
|
||||
name: "SZ VOC" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_sz_gas_resistance |
|
||||
id: voc_sz |
|
||||
- platform: homeassistant |
|
||||
name: "WZ IAQ" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_wz_iaq |
|
||||
id: iaq_wz |
|
||||
- platform: homeassistant |
|
||||
name: "SZ IAQ" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_sz_iaq |
|
||||
id: iaq_sz |
|
||||
- platform: homeassistant |
|
||||
name: "WZ IAQ Accuracy" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_wz_iaq_accuracy |
|
||||
id: iaq_accuracy_wz |
|
||||
- platform: homeassistant |
|
||||
name: "SZ IAQ Accuracy" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_sz_iaq_accuracy |
|
||||
id: iaq_accuracy_sz |
|
||||
- platform: homeassistant |
|
||||
name: "PM2.5 Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_particulate_matter_2_5um_concentration |
|
||||
id: pm2_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "PM10 Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_particulate_matter_10_0um_concentration |
|
||||
id: pm10_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "UVI Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_uvi |
|
||||
id: uvi_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "UVA Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_uva |
|
||||
id: uva_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "UVB Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_uvb |
|
||||
id: uvb_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "Illuminance Outdoor" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_outdoor_illuminance |
|
||||
id: lux_outdoor |
|
||||
- platform: homeassistant |
|
||||
name: "Temperature Springer" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_springer_temperature |
|
||||
id: temperature_springer |
|
||||
- platform: homeassistant |
|
||||
name: "Humidity Springer" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_springer_humidity |
|
||||
id: humidity_springer |
|
||||
- platform: homeassistant |
|
||||
name: "Pressure Springer" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_springer_equivalent_sea_level_pressure |
|
||||
id: pressure_springer |
|
||||
- platform: homeassistant |
|
||||
name: "Battery Springer" |
|
||||
internal: true |
|
||||
entity_id: sensor.sensor_springer_battery_level |
|
||||
id: battery_springer |
|
||||
|
|
||||
text_sensor: |
|
||||
- platform: homeassistant |
|
||||
id: weather_icon |
|
||||
internal: true |
|
||||
entity_id: weather.openweathermap |
|
@ -1,8 +0,0 @@ |
|||||
# Bluetooth Low-Energy |
|
||||
# Example configuration entry |
|
||||
esp32_ble_tracker: |
|
||||
|
|
||||
sensor: |
|
||||
- platform: ble_rssi |
|
||||
mac_address: FA:4E:84:FF:4D:16 |
|
||||
name: "${node_name} MiBand Hendrik RSSI value" |
|
@ -1,2 +0,0 @@ |
|||||
web_server: |
|
||||
port: 80 |
|
@ -1,15 +0,0 @@ |
|||||
uart: |
|
||||
rx_pin: 15 |
|
||||
tx_pin: 2 |
|
||||
baud_rate: 9600 |
|
||||
|
|
||||
# SDS 011 Particulate Matter Sensor |
|
||||
sensor: |
|
||||
- platform: sds011 |
|
||||
pm_2_5: |
|
||||
name: "${node_name} Particulate Matter <2.5µm Concentration" |
|
||||
id: pm2 |
|
||||
pm_10_0: |
|
||||
name: "${node_name} Particulate Matter <10.0µm Concentration" |
|
||||
id: pm10 |
|
||||
update_interval: 10min |
|
@ -1,4 +0,0 @@ |
|||||
<<: !include ../secrets.yaml |
|
||||
|
|
||||
# You can also use Home Assistant secrets.yaml: |
|
||||
# <<: !include ../../secrets.yaml |
|
@ -1,19 +0,0 @@ |
|||||
# VEML6075 UV sensor |
|
||||
sensor: |
|
||||
- platform: custom |
|
||||
lambda: |- |
|
||||
auto veml6075 = new VEML6075CustomSensor(); |
|
||||
App.register_component(veml6075); |
|
||||
return {veml6075->uva_sensor, veml6075->uvb_sensor, veml6075->uvi_sensor}; |
|
||||
sensors: |
|
||||
- name: "${node_name} UVA" |
|
||||
id: uva |
|
||||
unit_of_measurement: "mW/cm²" |
|
||||
accuracy_decimals: 0 |
|
||||
- name: "${node_name} UVB" |
|
||||
id: uvb |
|
||||
unit_of_measurement: "mW/cm²" |
|
||||
accuracy_decimals: 0 |
|
||||
- name: "${node_name} UVI" |
|
||||
id: uvi |
|
||||
accuracy_decimals: 0 |
|
@ -1,43 +0,0 @@ |
|||||
#include "esphome.h" |
|
||||
#include <Wire.h> |
|
||||
#include "Adafruit_VEML6075.h" |
|
||||
|
|
||||
class VEML6075CustomSensor : public PollingComponent { |
|
||||
public: |
|
||||
Adafruit_VEML6075 uv = Adafruit_VEML6075(); |
|
||||
|
|
||||
Sensor *uva_sensor = new Sensor(); |
|
||||
Sensor *uvb_sensor = new Sensor(); |
|
||||
Sensor *uvi_sensor = new Sensor(); |
|
||||
|
|
||||
VEML6075CustomSensor() : PollingComponent(15000) {} |
|
||||
|
|
||||
float get_setup_priority() const override { return esphome::setup_priority::HARDWARE; } |
|
||||
|
|
||||
void setup() override { |
|
||||
//Wire.begin(21, 22);
|
|
||||
if (!uv.begin()) { |
|
||||
ESP_LOGE("custom", "VEML6075 init failed"); |
|
||||
this->mark_failed(); |
|
||||
return; |
|
||||
} |
|
||||
uv.setIntegrationTime(VEML6075_100MS); |
|
||||
//uv.setHighDynamic(true);
|
|
||||
|
|
||||
// Set the calibration coefficients
|
|
||||
uv.setCoefficients(2.22, 1.33, // UVA_A and UVA_B coefficients
|
|
||||
2.95, 1.74, // UVB_C and UVB_D coefficients
|
|
||||
0.001461, 0.002591); // UVA and UVB responses
|
|
||||
} |
|
||||
|
|
||||
void update() override { |
|
||||
float uva = uv.readUVA(); |
|
||||
float uvb = uv.readUVB(); |
|
||||
float uvi = uv.readUVI(); |
|
||||
ESP_LOGD("custom", "The value of sensor uva is: %.0f", uva); |
|
||||
ESP_LOGD("custom", "The value of sensor uvb is: %.0f", uvb); |
|
||||
uva_sensor->publish_state(uva); |
|
||||
uvb_sensor->publish_state(uvb); |
|
||||
uvi_sensor->publish_state(uvi); |
|
||||
} |
|
||||
}; |
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,37 +0,0 @@ |
|||||
#include <map> |
|
||||
std::map<std::string, std::string> weather_icon_map |
|
||||
{ |
|
||||
{"cloudy", "\U000F0590"}, |
|
||||
{"cloudy-alert", "\U000F0F2F"}, |
|
||||
{"cloudy-arrow-right", "\U000F0E6E"}, |
|
||||
{"fog", "\U000F0591"}, |
|
||||
{"hail", "\U000F0592"}, |
|
||||
{"hazy", "\U000F0F30"}, |
|
||||
{"hurricane", "\U000F0898"}, |
|
||||
{"lightning", "\U000F0593"}, |
|
||||
{"lightning-rainy", "\U000F067E"}, |
|
||||
{"night", "\U000F0594"}, |
|
||||
{"clear-night", "\U000F0594"}, |
|
||||
{"night-partly-cloudy", "\U000F0F31"}, |
|
||||
{"partly-cloudy", "\U000F0595"}, |
|
||||
{"partlycloudy", "\U000F0595"}, |
|
||||
{"partly-lightning", "\U000F0F32"}, |
|
||||
{"partly-rainy", "\U000F0F33"}, |
|
||||
{"partly-snowy", "\U000F0F34"}, |
|
||||
{"partly-snowy-rainy", "\U000F0F35"}, |
|
||||
{"pouring", "\U000F0596"}, |
|
||||
{"rainy", "\U000F0597"}, |
|
||||
{"snowy", "\U000F0598"}, |
|
||||
{"snowy-heavy", "\U000F0F36"}, |
|
||||
{"snowy-rainy", "\U000F067F"}, |
|
||||
{"sunny", "\U000F0599"}, |
|
||||
{"sunny-alert", "\U000F0F37"}, |
|
||||
{"sunny-off", "\U000F14E4"}, |
|
||||
{"sunset", "\U000F059A"}, |
|
||||
{"sunset-down", "\U000F059B"}, |
|
||||
{"sunset-up", "\U000F059C"}, |
|
||||
{"tornado", "\U000F0F38"}, |
|
||||
{"windy", "\U000F059D"}, |
|
||||
{"windy-variant", "\U000F059E"}, |
|
||||
{"exceptional", "\U000F0599"}, |
|
||||
}; |
|
@ -1,15 +0,0 @@ |
|||||
substitutions: |
|
||||
node_name: hendrik-test |
|
||||
altitude: "111" |
|
||||
|
|
||||
packages: |
|
||||
base: !include common/base.yaml |
|
||||
battery: !include common/battery.yaml |
|
||||
display: !include common/display-hendrik.yaml |
|
||||
webserver: !include common/experimental/webserver.yaml |
|
||||
env_sensor: !include common/bme680.yaml |
|
||||
# remote_package: |
|
||||
# url: https://dev.xd0.de/hendrik/esp32-weatherstation |
|
||||
# ref: esphome |
|
||||
# files: [common/base.yaml, common/bme680.yaml] |
|
||||
# refresh: 1d |
|
@ -0,0 +1,61 @@ |
|||||
|
;PlatformIO Project Configuration File |
||||
|
; |
||||
|
; Build options: build flags, source filter |
||||
|
; Upload options: custom upload port, speed and extra flags |
||||
|
; Library options: dependencies, extra library storages |
||||
|
; Advanced options: extra scripting |
||||
|
; |
||||
|
; Please visit documentation for the other options and examples |
||||
|
; https://docs.platformio.org/page/projectconf.html |
||||
|
|
||||
|
[env] |
||||
|
platform = espressif32 |
||||
|
;platform = https://github.com/platformio/platform-espressif32.git#feature/stage |
||||
|
framework = arduino |
||||
|
|
||||
|
build_flags = |
||||
|
-DLOG_DEFAULT_LEVEL=ESP_LOG_VERBOSE |
||||
|
-DLOG_LOCAL_LEVEL=ESP_LOG_VERBOSE |
||||
|
-DCORE_DEBUG_LEVEL=ARDUHAL_LOG_LEVEL_VERBOSE |
||||
|
-DWIFI_SSID="\"${sysenv.WIFI_SSID}\"" |
||||
|
-DWIFI_PASSWD="\"${sysenv.WIFI_PASSWD}\"" |
||||
|
-DWIFI_SSID2="\"${sysenv.WIFI_SSID}\"" |
||||
|
-DWIFI_PASSWD2="\"${sysenv.WIFI_PASSWD}\"" |
||||
|
-DMQTT_BROKER_URI="\"${sysenv.MQTT_BROKER_URI}\"" |
||||
|
-DMQTT_USERNAME="\"${sysenv.MQTT_USERNAME}\"" |
||||
|
-DMQTT_PASSWORD="\"${sysenv.MQTT_PASSWORD}\"" |
||||
|
|
||||
|
lib_deps = |
||||
|
ArduinoJSON |
||||
|
|
||||
|
monitor_speed = 115200 |
||||
|
upload_speed = 115200 |
||||
|
|
||||
|
extra_scripts = |
||||
|
pre:script/autoversioning.py |
||||
|
pre:lib/script/autoversioning.py |
||||
|
|
||||
|
[env:lolin_d32_pro] |
||||
|
board = lolin_d32_pro |
||||
|
|
||||
|
build_flags = |
||||
|
${env.build_flags} |
||||
|
-DBOARD_HAS_PSRAM |
||||
|
-mfix-esp32-psram-cache-issue |
||||
|
-DARDUINO_SAMD_VARIANT_COMPLIANCE |
||||
|
|
||||
|
lib_deps = |
||||
|
${env.lib_deps} |
||||
|
GxEPD2 |
||||
|
; https://github.com/wemos/LOLIN_EPD_Library |
||||
|
Adafruit GFX Library |
||||
|
Adafruit BME280 Library |
||||
|
Adafruit Unified Sensor |
||||
|
; SDS011 sensor Library |
||||
|
; Nova Fitness Sds dust sensors library |
||||
|
https://github.com/lewapek/sds-dust-sensors-arduino-library.git |
||||
|
|
||||
|
|
||||
|
; 246f28d1a080 e paper |
||||
|
; a4cf1211c3e4 d32 |
||||
|
; 246f28d1fa5c d32_pro 2 |
@ -0,0 +1,13 @@ |
|||||
|
Import("env") |
||||
|
|
||||
|
import time |
||||
|
|
||||
|
ver = time.time() |
||||
|
|
||||
|
f = open("version.txt", "w") |
||||
|
f.write(str(ver)) |
||||
|
f.close() |
||||
|
|
||||
|
env.Append(CPPDEFINES=[ |
||||
|
("FW_VERSION", ver) |
||||
|
]) |
@ -1,10 +0,0 @@ |
|||||
|
|
||||
# esphome secrets |
|
||||
wifi_ssid: ssid1 |
|
||||
wifi_passwd: password1 |
|
||||
wifi_ssid2: ssid2 |
|
||||
wifi_passwd2: password2 |
|
||||
esphome_ota_password: "1234567890abcdef1234567890abcdef" |
|
||||
wifi_captive_portal_password: "123456789abc" |
|
||||
esphome_mqtt_user: "mqtt-user" |
|
||||
esphome_mqtt_passwd: "mqtt-password" |
|
@ -1,11 +0,0 @@ |
|||||
substitutions: |
|
||||
node_name: sensor-outdoor |
|
||||
altitude: "30" |
|
||||
|
|
||||
packages: |
|
||||
base: !include common/base.yaml |
|
||||
battery: !include common/battery.yaml |
|
||||
env_sensor: !include common/bme280.yaml |
|
||||
particulate: !include common/sds011.yaml |
|
||||
light: !include common/bh1750.yaml |
|
||||
uv: !include common/veml6075.yaml |
|
@ -1,9 +0,0 @@ |
|||||
substitutions: |
|
||||
node_name: sensor-springer |
|
||||
altitude: "30" |
|
||||
|
|
||||
packages: |
|
||||
base: !include common/base.yaml |
|
||||
battery: !include common/battery.yaml |
|
||||
env_sensor: !include common/bme280.yaml |
|
||||
|
|
@ -1,8 +0,0 @@ |
|||||
substitutions: |
|
||||
node_name: sensor-sz |
|
||||
altitude: "30" |
|
||||
|
|
||||
packages: |
|
||||
base: !include common/base.yaml |
|
||||
env_sensor: !include common/bme680.yaml |
|
||||
display: !include common/display.yaml |
|
@ -1,8 +0,0 @@ |
|||||
substitutions: |
|
||||
node_name: sensor-wz |
|
||||
altitude: "30" |
|
||||
|
|
||||
packages: |
|
||||
base: !include common/base.yaml |
|
||||
env_sensor: !include common/bme680.yaml |
|
||||
display: !include common/display.yaml |
|
@ -0,0 +1,16 @@ |
|||||
|
#include "Display.h" |
||||
|
|
||||
|
#include "esp_log.h" |
||||
|
|
||||
|
static const char *TAG = "Display"; |
||||
|
|
||||
|
|
||||
|
Display::Display() {} |
||||
|
|
||||
|
bool Display::begin(void) { |
||||
|
|
||||
|
} |
||||
|
|
||||
|
void Display::helloWorld(void) { |
||||
|
|
||||
|
} |
@ -0,0 +1,14 @@ |
|||||
|
#ifndef _DISPLAY_H |
||||
|
#define _DISPLAY_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
class Display { |
||||
|
public: |
||||
|
Display(void); |
||||
|
virtual bool begin(void); |
||||
|
virtual void helloWorld(void); |
||||
|
private: |
||||
|
}; |
||||
|
|
||||
|
#endif /* _DISPLAY_H */ |
@ -0,0 +1,57 @@ |
|||||
|
#include "Display.h" |
||||
|
#include "Display_D32_EPD.h" |
||||
|
|
||||
|
#include <GxEPD2_BW.h> |
||||
|
#include <Fonts/FreeMonoBold9pt7b.h> |
||||
|
#include "bitmaps/Bitmaps128x250.h" |
||||
|
#include <Adafruit_GFX.h> |
||||
|
|
||||
|
#include "../hardware.h" |
||||
|
|
||||
|
#include "esp_log.h" |
||||
|
static const char *TAG = "Display"; |
||||
|
|
||||
|
namespace { |
||||
|
|
||||
|
GxEPD2_BW<GxEPD2_213_B72, GxEPD2_213_B72::HEIGHT> display(GxEPD2_213_B72(/*CS=SS*/ TFT_CS, /*DC=*/ TFT_DC, /*RST=*/ TFT_RST, /*BUSY=*/ -1)); // GDEH0213B72
|
||||
|
|
||||
|
} |
||||
|
|
||||
|
Display_D32_EPD::Display_D32_EPD(void) {} |
||||
|
|
||||
|
bool Display_D32_EPD::begin(void) { |
||||
|
// initialize e-paper display
|
||||
|
SPI.begin(18, 19, 23, TFT_CS); |
||||
|
display.init(); |
||||
|
Serial.println("display init done"); Serial.flush(); |
||||
|
|
||||
|
display.clearScreen(); |
||||
|
display.refresh(); |
||||
|
|
||||
|
helloWorld(); |
||||
|
display.powerOff(); |
||||
|
} |
||||
|
|
||||
|
void Display_D32_EPD::helloWorld() |
||||
|
{ |
||||
|
const char HelloWorld[] = "Hello World!"; |
||||
|
//Serial.println("helloWorld");
|
||||
|
display.setRotation(1); |
||||
|
display.setFont(&FreeMonoBold9pt7b); |
||||
|
display.setTextColor(GxEPD_BLACK); |
||||
|
int16_t tbx, tby; uint16_t tbw, tbh; |
||||
|
display.getTextBounds(HelloWorld, 0, 0, &tbx, &tby, &tbw, &tbh); |
||||
|
// center bounding box by transposition of origin:
|
||||
|
uint16_t x = ((display.width() - tbw) / 2) - tbx; |
||||
|
uint16_t y = ((display.height() - tbh) / 2) - tby; |
||||
|
display.setFullWindow(); |
||||
|
display.firstPage(); |
||||
|
do |
||||
|
{ |
||||
|
display.fillScreen(GxEPD_WHITE); |
||||
|
display.setCursor(x, y); |
||||
|
display.print(HelloWorld); |
||||
|
} |
||||
|
while (display.nextPage()); |
||||
|
//Serial.println("helloWorld done");
|
||||
|
} |
@ -0,0 +1,16 @@ |
|||||
|
#ifndef _DISPLAY_D32_EPD_H |
||||
|
#define _DISPLAY_D32_EPD_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
#include "Display.h" |
||||
|
|
||||
|
class Display_D32_EPD : public Display { |
||||
|
public: |
||||
|
Display_D32_EPD(); |
||||
|
bool begin(void); |
||||
|
void helloWorld(void); |
||||
|
private: |
||||
|
}; |
||||
|
|
||||
|
#endif /* _DISPLAY_D32_EPD_H */ |
@ -0,0 +1,20 @@ |
|||||
|
#ifndef _HARDWARE_H |
||||
|
#define _HARDWARE_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
static constexpr uint8_t BME_SDA = 21; |
||||
|
static constexpr uint8_t BME_SCL = 22; |
||||
|
|
||||
|
/* defined in arduino-esp32/variants/d32_pro/pins_arduino.h */ |
||||
|
//static const uint8_t LED_BUILTIN = 5;
|
||||
|
//static const uint8_t _VBAT = 35; // battery voltage
|
||||
|
//#define TF_CS 4 // TF (Micro SD Card) CS pin
|
||||
|
//#define TS_CS 12 // Touch Screen CS pin
|
||||
|
//#define TFT_CS 14 // TFT CS pin
|
||||
|
//#define TFT_LED 32 // TFT backlight control pin
|
||||
|
//#define TFT_RST 33 // TFT reset pin
|
||||
|
//#define TFT_DC 27 // TFT DC pin
|
||||
|
//#define SS TF_CS
|
||||
|
|
||||
|
#endif /* _HARDWARE_H */ |
@ -0,0 +1,116 @@ |
|||||
|
/*
|
||||
|
* Blink |
||||
|
* Turns on an LED on for one second, |
||||
|
* then off for one second, repeatedly. |
||||
|
*/ |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
#include <WiFi.h> |
||||
|
#include <WiFiMulti.h> |
||||
|
|
||||
|
#include <Wire.h> |
||||
|
#include <SPI.h> |
||||
|
|
||||
|
#include "sensors/SDS011.h" |
||||
|
#include "sensors/BME280.h" |
||||
|
|
||||
|
#include "hardware.h" |
||||
|
|
||||
|
#include "network/XD0OTA.h" |
||||
|
#include "network/XD0MQTT.h" |
||||
|
|
||||
|
#include "display/Display_D32_EPD.h" |
||||
|
|
||||
|
extern "C" { |
||||
|
int rom_phy_get_vdd33(); |
||||
|
uint8_t temprature_sens_read(); |
||||
|
//uint32_t hall_sens_read();
|
||||
|
} |
||||
|
|
||||
|
static const char* TAG = "MAIN"; |
||||
|
|
||||
|
WiFiMulti wifiMulti; |
||||
|
|
||||
|
XD0MQTT mqtt; |
||||
|
|
||||
|
Display *display = new Display_D32_EPD(); |
||||
|
BME280 bme; |
||||
|
SDS011 sds; |
||||
|
|
||||
|
struct __attribute__((packed)) Measurements { |
||||
|
char timeStr[20]; |
||||
|
float temperature; |
||||
|
float humidity; |
||||
|
float pressure; |
||||
|
int voltage; |
||||
|
int32_t rssi; |
||||
|
}; |
||||
|
|
||||
|
Measurements measurements; |
||||
|
|
||||
|
void setup() |
||||
|
{ |
||||
|
Serial.begin(115200); |
||||
|
|
||||
|
// initialize LED digital pin as an output.
|
||||
|
pinMode(LED_BUILTIN, OUTPUT); |
||||
|
|
||||
|
Serial.println("setup"); |
||||
|
|
||||
|
bme.begin(); |
||||
|
sds.begin(); |
||||
|
display->begin(); |
||||
|
display->helloWorld(); |
||||
|
|
||||
|
WiFi.setHostname("esp32-weatherstation"); |
||||
|
wifiMulti.addAP(WIFI_SSID, WIFI_PASSWD); |
||||
|
wifiMulti.addAP(WIFI_SSID2, WIFI_PASSWD2); |
||||
|
|
||||
|
Serial.println("Connecting Wifi..."); |
||||
|
if(wifiMulti.run() == WL_CONNECTED) { |
||||
|
Serial.println(""); |
||||
|
Serial.println("WiFi connected"); |
||||
|
Serial.println("IP address: "); |
||||
|
Serial.println(WiFi.localIP()); |
||||
|
} |
||||
|
|
||||
|
XD0OTA ota("esp32-weatherstation"); |
||||
|
ota.update(); |
||||
|
|
||||
|
mqtt.begin(); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
void loop() |
||||
|
{ |
||||
|
Serial.println("loop"); |
||||
|
/*
|
||||
|
unsigned long endTime = bme.beginReading(); |
||||
|
if (! bme.performReading()) { |
||||
|
ESP_LOGE(TAG, "BME680: Failed to perform reading :("); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
Serial.print("Temperature = "); Serial.print(bme.temperature); Serial.println(" *C"); |
||||
|
display.print("Temperature: "); display.print(bme.temperature); display.println(" *C"); |
||||
|
|
||||
|
Serial.print("Pressure = "); Serial.print(bme.pressure / 100.0); Serial.println(" hPa"); |
||||
|
display.print("Pressure: "); display.print(bme.pressure / 100); display.println(" hPa"); |
||||
|
|
||||
|
Serial.print("Humidity = "); Serial.print(bme.humidity); Serial.println(" %"); |
||||
|
display.print("Humidity: "); display.print(bme.humidity); display.println(" %"); |
||||
|
|
||||
|
Serial.print("Gas = "); Serial.print(bme.gas_resistance / 1000.0); Serial.println(" KOhms"); |
||||
|
display.print("Gas: "); display.print(bme.gas_resistance / 1000.0); display.println(" KOhms"); |
||||
|
*/ |
||||
|
|
||||
|
sds.readPm(); |
||||
|
bme.read(); |
||||
|
|
||||
|
if(wifiMulti.run() != WL_CONNECTED) { |
||||
|
Serial.println("WiFi not connected!"); |
||||
|
delay(1000); |
||||
|
} |
||||
|
|
||||
|
delay(2000); |
||||
|
} |
@ -0,0 +1,115 @@ |
|||||
|
#include "XD0MQTT.h" |
||||
|
|
||||
|
#include <stdio.h> |
||||
|
#include <stdint.h> |
||||
|
#include <stddef.h> |
||||
|
#include <string.h> |
||||
|
#include "esp_wifi.h" |
||||
|
#include "esp_system.h" |
||||
|
#include "nvs_flash.h" |
||||
|
#include "esp_event.h" |
||||
|
#include "tcpip_adapter.h" |
||||
|
|
||||
|
#include "freertos/FreeRTOS.h" |
||||
|
#include "freertos/task.h" |
||||
|
#include "freertos/semphr.h" |
||||
|
#include "freertos/queue.h" |
||||
|
|
||||
|
#include "lwip/sockets.h" |
||||
|
#include "lwip/dns.h" |
||||
|
#include "lwip/netdb.h" |
||||
|
|
||||
|
#include "esp_log.h" |
||||
|
#include "mqtt_client.h" |
||||
|
#include "esp_tls.h" |
||||
|
|
||||
|
static const char *TAG = "MQTT"; |
||||
|
|
||||
|
XD0MQTT::XD0MQTT() {} |
||||
|
|
||||
|
static esp_err_t mqtt_event_handler_cb(esp_mqtt_event_handle_t event) |
||||
|
{ |
||||
|
esp_mqtt_client_handle_t client = event->client; |
||||
|
int msg_id; |
||||
|
//int mbedtls_err; esp_err_t err;
|
||||
|
// your_context_t *context = event->context;
|
||||
|
switch (event->event_id) { |
||||
|
case MQTT_EVENT_CONNECTED: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_CONNECTED"); |
||||
|
msg_id = esp_mqtt_client_subscribe(client, "/topic/qos0", 0); |
||||
|
ESP_LOGI(TAG, "sent subscribe successful, msg_id=%d", msg_id); |
||||
|
|
||||
|
msg_id = esp_mqtt_client_subscribe(client, "/topic/qos1", 1); |
||||
|
ESP_LOGI(TAG, "sent subscribe successful, msg_id=%d", msg_id); |
||||
|
|
||||
|
msg_id = esp_mqtt_client_unsubscribe(client, "/topic/qos1"); |
||||
|
ESP_LOGI(TAG, "sent unsubscribe successful, msg_id=%d", msg_id); |
||||
|
break; |
||||
|
case MQTT_EVENT_DISCONNECTED: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_DISCONNECTED"); |
||||
|
break; |
||||
|
|
||||
|
case MQTT_EVENT_SUBSCRIBED: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_SUBSCRIBED, msg_id=%d", event->msg_id); |
||||
|
msg_id = esp_mqtt_client_publish(client, "/topic/qos0", "data", 0, 0, 0); |
||||
|
ESP_LOGI(TAG, "sent publish successful, msg_id=%d", msg_id); |
||||
|
break; |
||||
|
case MQTT_EVENT_UNSUBSCRIBED: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_UNSUBSCRIBED, msg_id=%d", event->msg_id); |
||||
|
break; |
||||
|
case MQTT_EVENT_PUBLISHED: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_PUBLISHED, msg_id=%d", event->msg_id); |
||||
|
break; |
||||
|
case MQTT_EVENT_DATA: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_DATA"); |
||||
|
printf("TOPIC=%.*s\r\n", event->topic_len, event->topic); |
||||
|
printf("DATA=%.*s\r\n", event->data_len, event->data); |
||||
|
break; |
||||
|
case MQTT_EVENT_ERROR: |
||||
|
ESP_LOGI(TAG, "MQTT_EVENT_ERROR"); |
||||
|
//mbedtls_err = 0;
|
||||
|
//err = esp_tls_get_and_clear_last_error(event->error_handle, &mbedtls_err, NULL);
|
||||
|
//ESP_LOGI(TAG, "Last esp error code: 0x%x", err);
|
||||
|
//ESP_LOGI(TAG, "Last mbedtls failure: 0x%x", mbedtls_err);
|
||||
|
break; |
||||
|
default: |
||||
|
ESP_LOGI(TAG, "Other event id:%d", event->event_id); |
||||
|
break; |
||||
|
} |
||||
|
return ESP_OK; |
||||
|
} |
||||
|
|
||||
|
//static void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data) {
|
||||
|
static esp_err_t mqtt_event_handler(esp_mqtt_event_handle_t event) { |
||||
|
esp_mqtt_client_handle_t client = event->client; |
||||
|
mqtt_event_handler_cb(event); |
||||
|
//ESP_LOGD(TAG, "Event dispatched from event loop base=%s, event_id=%d", base, event_id);
|
||||
|
//mqtt_event_handler_cb(event_data);
|
||||
|
} |
||||
|
|
||||
|
bool XD0MQTT::begin(void) { |
||||
|
static esp_mqtt_client_config_t mqtt_cfg; |
||||
|
mqtt_cfg.uri = MQTT_BROKER_URI; |
||||
|
// mqtt_cfg.host = "home.xd0.de";
|
||||
|
// mqtt_cfg.port = 8883;
|
||||
|
mqtt_cfg.event_handle = mqtt_event_handler; |
||||
|
mqtt_cfg.cert_pem = (const char *)rootCACertificate; |
||||
|
mqtt_cfg.username = MQTT_USERNAME; |
||||
|
mqtt_cfg.password = MQTT_PASSWORD; |
||||
|
mqtt_cfg.user_context = (void*)this; |
||||
|
|
||||
|
ESP_LOGI(TAG, "[APP] Free memory: %d bytes", esp_get_free_heap_size()); |
||||
|
Serial.printf("BROKER URI: %s\n", MQTT_BROKER_URI); |
||||
|
Serial.printf("MQTT USERNAME: %s\n", MQTT_USERNAME); |
||||
|
Serial.printf("MQTT PASSWORD: %s\n", MQTT_PASSWORD); |
||||
|
client = esp_mqtt_client_init(&mqtt_cfg); |
||||
|
//esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID, mqtt_event_handler, client);
|
||||
|
esp_mqtt_client_start(client); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
bool XD0MQTT::publish(const char* topic, const char* data, int len, int qos=1, int retain=0) { |
||||
|
int msg_id = esp_mqtt_client_publish(client, topic, data, len, qos, retain); |
||||
|
ESP_LOGI(TAG, "sent publish successful, msg_id=%d", msg_id); |
||||
|
} |
@ -0,0 +1,39 @@ |
|||||
|
#ifndef _XD0MQTT_H |
||||
|
#define _XD0MQTT_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
#include "mqtt_client.h" |
||||
|
|
||||
|
class XD0MQTT { |
||||
|
public: |
||||
|
XD0MQTT(void); |
||||
|
bool begin(void); |
||||
|
bool publish(const char* topic, const char* data, int len, int qos, int retain); |
||||
|
private: |
||||
|
// openssl s_client -showcerts -connect home.xd0.de:8883 </dev/null 2>/dev/null|openssl x509 -outform PEM >mqtt_xd0.de.pem
|
||||
|
const char* rootCACertificate = \ |
||||
|
"-----BEGIN CERTIFICATE-----\n" \ |
||||
|
"MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/\n" \ |
||||
|
"MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\n" \ |
||||
|
"DkRTVCBSb290IENBIFgzMB4XDTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVow\n" \ |
||||
|
"PzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QgQ28uMRcwFQYDVQQD\n" \ |
||||
|
"Ew5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB\n" \ |
||||
|
"AN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmTrE4O\n" \ |
||||
|
"rz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEq\n" \ |
||||
|
"OLl5CjH9UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9b\n" \ |
||||
|
"xiqKqy69cK3FCxolkHRyxXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw\n" \ |
||||
|
"7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40dutolucbY38EVAjqr2m7xPi71XAicPNaD\n" \ |
||||
|
"aeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV\n" \ |
||||
|
"HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQMA0GCSqG\n" \ |
||||
|
"SIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69\n" \ |
||||
|
"ikugdB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXr\n" \ |
||||
|
"AvHRAosZy5Q6XkjEGB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZz\n" \ |
||||
|
"R8srzJmwN0jP41ZL9c8PDHIyh8bwRLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5\n" \ |
||||
|
"JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubSfZGL+T0yjWW06XyxV3bqxbYo\n" \ |
||||
|
"Ob8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ\n" \ |
||||
|
"-----END CERTIFICATE-----\n"; |
||||
|
esp_mqtt_client_handle_t client; |
||||
|
}; |
||||
|
|
||||
|
#endif /* _XD0MQTT_H */ |
@ -0,0 +1,137 @@ |
|||||
|
#include "XD0OTA.h" |
||||
|
|
||||
|
#include <WiFi.h> |
||||
|
#include <WiFiClient.h> |
||||
|
|
||||
|
#include <HTTPClient.h> |
||||
|
#include <HTTPUpdate.h> |
||||
|
|
||||
|
#include <time.h> |
||||
|
|
||||
|
#include "hardware.h" |
||||
|
|
||||
|
static const char* TAG = "OTA"; |
||||
|
|
||||
|
XD0OTA::XD0OTA(String deviceName) : deviceName{deviceName} {} |
||||
|
|
||||
|
// Set time via NTP, as required for x.509 validation
|
||||
|
void XD0OTA::setClock() { |
||||
|
configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // UTC
|
||||
|
|
||||
|
Serial.print(F("Waiting for NTP time sync: ")); |
||||
|
time_t now = time(nullptr); |
||||
|
int tries = 0; |
||||
|
while (now < 8 * 3600 * 2) { |
||||
|
yield(); |
||||
|
delay(500); |
||||
|
Serial.print(F(".")); |
||||
|
now = time(nullptr); |
||||
|
tries++; |
||||
|
if (tries>15) return; |
||||
|
} |
||||
|
|
||||
|
Serial.println(F("")); |
||||
|
struct tm timeinfo; |
||||
|
gmtime_r(&now, &timeinfo); |
||||
|
Serial.print(F("Current time: ")); |
||||
|
Serial.print(asctime(&timeinfo)); |
||||
|
} |
||||
|
|
||||
|
String XD0OTA::getMAC() { |
||||
|
uint8_t mac[6]; |
||||
|
char result[14]; |
||||
|
|
||||
|
WiFi.macAddress( mac ); |
||||
|
snprintf( result, sizeof( result ), "%02x%02x%02x%02x%02x%02x", mac[ 0 ], mac[ 1 ], mac[ 2 ], mac[ 3 ], mac[ 4 ], mac[ 5 ] ); |
||||
|
|
||||
|
return String( result ); |
||||
|
} |
||||
|
|
||||
|
String XD0OTA::getUpdateURL(String file, String extension) { |
||||
|
String updateURL = String(fwUrlBase); |
||||
|
updateURL.concat(file); |
||||
|
updateURL.concat(extension); |
||||
|
return updateURL; |
||||
|
} |
||||
|
|
||||
|
void XD0OTA::update(void) { |
||||
|
setClock(); |
||||
|
|
||||
|
// try device specific image first
|
||||
|
String fwURL = getUpdateURL(getMAC(), ".bin"); |
||||
|
int newVersion = checkForUpdates(fwURL + ".version"); |
||||
|
|
||||
|
if (newVersion < 1) { |
||||
|
ESP_LOGW(TAG, "[update] no device specific update found..\n"); |
||||
|
fwURL = getUpdateURL(deviceName, ".bin"); |
||||
|
newVersion = checkForUpdates(fwURL + ".version"); |
||||
|
// try project specific image
|
||||
|
if (newVersion < 1) { |
||||
|
ESP_LOGW(TAG, "[update] Error while looking for updates..\n"); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if( newVersion > FW_VERSION ) { |
||||
|
ESP_LOGW(TAG, "Preparing to update.\n" ); |
||||
|
|
||||
|
ESP_LOGW(TAG, "Firmware image URL: " ); |
||||
|
ESP_LOGW(TAG, "%s\n", fwURL.c_str() ); |
||||
|
|
||||
|
WiFiClientSecure client; |
||||
|
client.setCACert(rootCACertificate); |
||||
|
|
||||
|
client.setTimeout(12); // seconds
|
||||
|
|
||||
|
httpUpdate.setLedPin(LED_BUILTIN, HIGH); |
||||
|
|
||||
|
t_httpUpdate_return ret = httpUpdate.update( client, fwURL ); |
||||
|
|
||||
|
switch(ret) { |
||||
|
case HTTP_UPDATE_FAILED: |
||||
|
ESP_LOGW(TAG, "HTTP_UPDATE_FAILED Error (%d): %s\n", httpUpdate.getLastError(), httpUpdate.getLastErrorString().c_str()); |
||||
|
break; |
||||
|
case HTTP_UPDATE_NO_UPDATES: |
||||
|
ESP_LOGW(TAG, "HTTP_UPDATE_NO_UPDATES\n"); |
||||
|
break; |
||||
|
case HTTP_UPDATE_OK: |
||||
|
ESP_LOGW(TAG, "[update] Update ok.\n"); // may not called we reboot the ESP
|
||||
|
break; |
||||
|
} |
||||
|
} else { |
||||
|
ESP_LOGW(TAG, "[update] Already on latest version\n" ); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
int XD0OTA::checkForUpdates(String url) { |
||||
|
int newVersion = -1; |
||||
|
|
||||
|
ESP_LOGW(TAG, "Checking for firmware updates.\n" ); |
||||
|
ESP_LOGW(TAG, "Firmware version URL: " ); |
||||
|
ESP_LOGW(TAG, "%s\n", url.c_str() ); |
||||
|
|
||||
|
WiFiClientSecure client; |
||||
|
client.setCACert(rootCACertificate); |
||||
|
client.setTimeout(5); // seconds
|
||||
|
|
||||
|
HTTPClient httpClient; |
||||
|
|
||||
|
httpClient.begin( client, url ); |
||||
|
int httpCode = httpClient.GET(); |
||||
|
if( httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY ) { |
||||
|
String newFWVersion = httpClient.getString(); |
||||
|
|
||||
|
ESP_LOGW(TAG, "Current firmware version: " ); |
||||
|
ESP_LOGW(TAG, "%s\n", String(FW_VERSION).c_str() ); |
||||
|
ESP_LOGW(TAG, "Available firmware version: " ); |
||||
|
ESP_LOGW(TAG, "%s\n", newFWVersion.c_str() ); |
||||
|
|
||||
|
newVersion = newFWVersion.toInt(); |
||||
|
} else { |
||||
|
ESP_LOGW(TAG, "Firmware version check failed, got HTTP response code " ); |
||||
|
ESP_LOGW(TAG, "%d\n", httpCode ); |
||||
|
newVersion = -1; |
||||
|
} |
||||
|
httpClient.end(); |
||||
|
return newVersion; |
||||
|
} |
@ -0,0 +1,84 @@ |
|||||
|
#ifndef _XD0OTA_H |
||||
|
#define _XD0OTA_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
class XD0OTA { |
||||
|
public: |
||||
|
XD0OTA(String deviceName); |
||||
|
void update(void); |
||||
|
int checkForUpdates(String url); |
||||
|
private: |
||||
|
String deviceName; |
||||
|
const char* fwUrlBase = "https://fwupdate.xd0.de:444/fota/"; |
||||
|
const char* httpsFingerprint = "37 42 61 B9 E6 EE 22 36 D1 59 67 7D 55 53 6E A4 C7 AA 60 26"; |
||||
|
const char* rootCACertificate = \ |
||||
|
"-----BEGIN CERTIFICATE-----\n" \ |
||||
|
"MIID8DCCAtigAwIBAgIJAPSONy8RRejRMA0GCSqGSIb3DQEBCwUAMIGLMQswCQYD\n" \ |
||||
|
"VQQGEwJERTEeMBwGA1UECAwVTm9ydGhyaGluZS1XZXN0cGhhbGlhMQ4wDAYDVQQH\n" \ |
||||
|
"DAVFc3NlbjEPMA0GA1UECgwGeGQwLmRlMRgwFgYDVQQDDA9md3VwZGF0ZS54ZDAu\n" \ |
||||
|
"ZGUxITAfBgkqhkiG9w0BCQEWEmhlbmRyaWsrZGV2QHhkMC5kZTAgFw0xODA2MTYx\n" \ |
||||
|
"MTAzMTVaGA8yMDY4MDYxNjExMDMxNVowgYsxCzAJBgNVBAYTAkRFMR4wHAYDVQQI\n" \ |
||||
|
"DBVOb3J0aHJoaW5lLVdlc3RwaGFsaWExDjAMBgNVBAcMBUVzc2VuMQ8wDQYDVQQK\n" \ |
||||
|
"DAZ4ZDAuZGUxGDAWBgNVBAMMD2Z3dXBkYXRlLnhkMC5kZTEhMB8GCSqGSIb3DQEJ\n" \ |
||||
|
"ARYSaGVuZHJpaytkZXZAeGQwLmRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\n" \ |
||||
|
"CgKCAQEAx37S1YgaG74IhTysaaZQMFI50TDeGkxxdpNoIBX0UBeVtKI/3u3MAqBz\n" \ |
||||
|
"kKTDHFu4IQDj0PvBdlqPFGdSinFgrIvr49uAr+alNKUtkuSTT7nXI0fzqAxv1taj\n" \ |
||||
|
"0mNhigVvYikX2BUU/rNLnQclyBdPNVsOf9cv0t5+UcOHRt6oEwk5nFtG7s7k4+wu\n" \ |
||||
|
"wRdGlLy2LwLihYFon4LHAs05JW3qs0IQI4etc8E2JWjF2YwBg3+ooyzUFFIGjPSl\n" \ |
||||
|
"Lpi7WvAAR19HITbt5FJXQkFZnFxnfbQv/5f7n8vWfFmzYsEgvldwMZv+Eg6wPb2h\n" \ |
||||
|
"rgH7T6RSb55JrZE/JUY5C6pKvTJ3AwIDAQABo1MwUTAdBgNVHQ4EFgQUCnZywNj+\n" \ |
||||
|
"djz6n0sIARPx8dp+7bQwHwYDVR0jBBgwFoAUCnZywNj+djz6n0sIARPx8dp+7bQw\n" \ |
||||
|
"DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAto7IGXpNYTiPUgnA\n" \ |
||||
|
"DE6osdgSV1yVYJj75v+Y8aUGgQ93Ipl/0+PQL99wbGgjDhfxGADLtljwoEAz/fep\n" \ |
||||
|
"RqCh8swjL34XV9XjMzfhEMDCybSO6mK7ZmCKwhz9yBaK/Qjdj0YUoLhJ9Huzb70m\n" \ |
||||
|
"lGzbOhY4JJKFVaA5AcZhYHqjCmzHCVJ/H0zeuPGyKutbvSx23a24LmebfY4q2D62\n" \ |
||||
|
"U85ox3Ojek6Mc8J4V+RjORygDGAO4gZClEhAza4koAg7lCO/kSSk5PrXdlz2dqtA\n" \ |
||||
|
"D5Npv9M5363apnO1VlVR+OuO1NEJusRK1aWk9RLZsTPxzwOWwdkifXxUEJ+f8mGn\n" \ |
||||
|
"o+6SCw==\n" \ |
||||
|
"-----END CERTIFICATE-----\n"; |
||||
|
String getMAC(void); |
||||
|
String getUpdateURL(String file, String extension); |
||||
|
void setClock(void); |
||||
|
}; |
||||
|
|
||||
|
#endif /* _XD0OTA_H */ |
||||
|
|
||||
|
|
||||
|
/*
|
||||
|
|
||||
|
MAKE SURE TO RESET THE ESP8266 ONCE AFTER NORMAL FLASHING! OTHERWISE OTA WON'T WORK! |
||||
|
|
||||
|
|
||||
|
openssl req -x509 -nodes -days 18263 -newkey rsa:2048 -keyout /etc/ssl/private/xd0-fwupdate-selfsigned.key -out /etc/ssl/certs/xd0-fwupdate-selfsigned.crt |
||||
|
|
||||
|
openssl x509 -noout -fingerprint -sha1 -inform pem -in /etc/ssl/certs/xd0-fwupdate-selfsigned.crt |
||||
|
|
||||
|
|
||||
|
scp .pioenvs/nodemcuv2/firmware.bin user@webserver:/var/www/fwupdate/fota/macaddress.bin |
||||
|
and change macaddress.version |
||||
|
|
||||
|
|
||||
|
server { |
||||
|
listen 444 ssl; |
||||
|
listen [::]:444 ssl; |
||||
|
server_name fwupdate.xd0.de; |
||||
|
|
||||
|
# SSL configuration |
||||
|
ssl_certificate /etc/ssl/certs/xd0-fwupdate-selfsigned.crt; |
||||
|
ssl_certificate_key /etc/ssl/private/xd0-fwupdate-selfsigned.key; |
||||
|
ssl_buffer_size 4k; |
||||
|
|
||||
|
root /var/www/fwupdate; |
||||
|
|
||||
|
location / { |
||||
|
# First attempt to serve request as file, then |
||||
|
# as directory, then fall back to displaying a 404. |
||||
|
try_files $uri $uri/ =404; |
||||
|
} |
||||
|
|
||||
|
access_log /var/log/nginx/fwupdate_access.log; |
||||
|
error_log /var/log/nginx/fwupdate_error.log; |
||||
|
} |
||||
|
|
||||
|
*/ |
@ -0,0 +1,56 @@ |
|||||
|
#include <Arduino.h> |
||||
|
#include <Wire.h> |
||||
|
|
||||
|
#include <Adafruit_Sensor.h> |
||||
|
#include "Adafruit_BME280.h" |
||||
|
|
||||
|
#include "BME280.h" |
||||
|
|
||||
|
#include "../hardware.h" |
||||
|
|
||||
|
#include "esp_log.h" |
||||
|
static const char *TAG = "BME280"; |
||||
|
|
||||
|
|
||||
|
BME280::BME280() {} |
||||
|
|
||||
|
bool BME280::begin(void) { |
||||
|
bme = new Adafruit_BME280(); // I2C (also available: hardware SPI
|
||||
|
|
||||
|
// #define BME_SDA 21
|
||||
|
// #define BME_SCL 22
|
||||
|
Wire.begin(BME_SDA, BME_SCL); |
||||
|
if (!bme->begin()) { |
||||
|
ESP_LOGE(TAG, "Could not find a valid BME280 sensor, check wiring!"); |
||||
|
} |
||||
|
/*
|
||||
|
// Set up oversampling and filter initialization
|
||||
|
bme->setTemperatureOversampling(BME680_OS_8X); |
||||
|
bme->setHumidityOversampling(BME680_OS_2X); |
||||
|
bme->setPressureOversampling(BME680_OS_4X); |
||||
|
bme->setIIRFilterSize(BME680_FILTER_SIZE_3); |
||||
|
bme->setGasHeater(320, 150); // 320*C for 150 ms
|
||||
|
*/ |
||||
|
} |
||||
|
|
||||
|
void BME280::read() { |
||||
|
#define SEALEVELPRESSURE_HPA (1013.25) |
||||
|
Serial.print("Temperature = "); |
||||
|
Serial.print(bme->readTemperature()); |
||||
|
Serial.println(" *C"); |
||||
|
|
||||
|
Serial.print("Pressure = "); |
||||
|
|
||||
|
Serial.print(bme->readPressure() / 100.0F); |
||||
|
Serial.println(" hPa"); |
||||
|
|
||||
|
Serial.print("Approx. Altitude = "); |
||||
|
Serial.print(bme->readAltitude(SEALEVELPRESSURE_HPA)); |
||||
|
Serial.println(" m"); |
||||
|
|
||||
|
Serial.print("Humidity = "); |
||||
|
Serial.print(bme->readHumidity()); |
||||
|
Serial.println(" %"); |
||||
|
|
||||
|
Serial.println(); |
||||
|
} |
@ -0,0 +1,19 @@ |
|||||
|
#ifndef _BME280_H |
||||
|
#define _BME280_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
#include <Wire.h> |
||||
|
|
||||
|
#include <Adafruit_Sensor.h> |
||||
|
#include "Adafruit_BME280.h" |
||||
|
|
||||
|
class BME280 { |
||||
|
public: |
||||
|
BME280(void); |
||||
|
bool begin(void); |
||||
|
void read(void); |
||||
|
private: |
||||
|
Adafruit_BME280 *bme; |
||||
|
}; |
||||
|
|
||||
|
#endif /* _BME280_H */ |
@ -0,0 +1,34 @@ |
|||||
|
#include <Arduino.h> |
||||
|
|
||||
|
#define ARDUINO_SAMD_VARIANT_COMPLIANCE |
||||
|
#include "SdsDustSensor.h" |
||||
|
|
||||
|
#include "SDS011.h" |
||||
|
|
||||
|
#include "esp_log.h" |
||||
|
|
||||
|
static const char *TAG = "SDS011"; |
||||
|
|
||||
|
SDS011::SDS011() {} |
||||
|
|
||||
|
bool SDS011::begin(void) { |
||||
|
//HardwareSerial Serial2(2);
|
||||
|
sds = new SdsDustSensor(Serial2); |
||||
|
sds->begin(); |
||||
|
} |
||||
|
|
||||
|
void SDS011::readPm() { |
||||
|
PmResult pm = sds->readPm(); |
||||
|
if (pm.isOk()) { |
||||
|
Serial.print("PM2.5 = "); |
||||
|
Serial.print(pm.pm25); |
||||
|
Serial.print(", PM10 = "); |
||||
|
Serial.println(pm.pm10); |
||||
|
|
||||
|
// if you want to just print the measured values, you can use toString() method as well
|
||||
|
Serial.println(pm.toString()); |
||||
|
} else { |
||||
|
Serial.print("Could not read values from sensor, reason: "); |
||||
|
Serial.println(pm.statusToString()); |
||||
|
} |
||||
|
} |
@ -0,0 +1,18 @@ |
|||||
|
#ifndef _SDS011_H |
||||
|
#define _SDS011_H |
||||
|
|
||||
|
#include <Arduino.h> |
||||
|
|
||||
|
#define ARDUINO_SAMD_VARIANT_COMPLIANCE |
||||
|
#include "SdsDustSensor.h" |
||||
|
|
||||
|
class SDS011 { |
||||
|
public: |
||||
|
SDS011(void); |
||||
|
bool begin(void); |
||||
|
void readPm(void); |
||||
|
private: |
||||
|
SdsDustSensor *sds; |
||||
|
}; |
||||
|
|
||||
|
#endif /* _SDS011_H */ |
@ -0,0 +1,11 @@ |
|||||
|
|
||||
|
This directory is intended for PIO Unit Testing and project tests. |
||||
|
|
||||
|
Unit Testing is a software testing method by which individual units of |
||||
|
source code, sets of one or more MCU program modules together with associated |
||||
|
control data, usage procedures, and operating procedures, are tested to |
||||
|
determine whether they are fit for use. Unit testing finds problems early |
||||
|
in the development cycle. |
||||
|
|
||||
|
More information about PIO Unit Testing: |
||||
|
- https://docs.platformio.org/page/plus/unit-testing.html |
Loading…
Reference in new issue