From 0258932b0c21f28385c2ab3acecbf4eaaa00b399 Mon Sep 17 00:00:00 2001 From: vlapa Date: Sat, 13 Jun 2026 18:38:24 +0300 Subject: First --- .gitignore | 2 + description | Bin 0 -> 78 bytes include/tablo_ws2812.h | 314 ++++++++++++++++++++++++++ platformio.ini | 29 +++ src/main.cpp | 588 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 933 insertions(+) create mode 100644 .gitignore create mode 100644 description create mode 100644 include/tablo_ws2812.h create mode 100644 platformio.ini create mode 100644 src/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b9f3806 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.pio +.vscode diff --git a/description b/description new file mode 100644 index 0000000..48e71f2 Binary files /dev/null and b/description differ diff --git a/include/tablo_ws2812.h b/include/tablo_ws2812.h new file mode 100644 index 0000000..e74d17f --- /dev/null +++ b/include/tablo_ws2812.h @@ -0,0 +1,314 @@ +/*========================================================= + Tablo ws2812 + = vlapa = v.509 + 2021.02.01 - 2023.11.14 +=========================================================*/ +#pragma once + +#define PIXEL_PIN D2 +#define PIXEL_COUNT 210 +#define RAZR_PIXEL 42 +// #define BRIGHT_DAY 5 +// #define BRIGHT_NIGHT 1 + +#include +Adafruit_NeoPixel strip(PIXEL_COUNT, PIXEL_PIN, NEO_GRB + NEO_KHZ800); + +const uint8_t digit = 5; // кол-во разрядов табло + +//******************************************************************** +// плата +void visibleWork(String visData, uint32_t color, uint8_t bright) + +{ // данные, цвет + strip.setBrightness(bright); + + for (uint8_t razr = 0; razr < digit; ++razr) + { + uint8_t x; + if (visData.charAt(razr) == 'A') + { + x = 10; + } + else if (visData.charAt(razr) == 'B') + { + x = 11; + } + else if (visData.charAt(razr) == 'C') + { + x = 12; + } + else if (visData.charAt(razr) == 'D') + { + x = 13; + } + else if (visData.charAt(razr) == 'E') + { + x = 14; + } + else if (visData.charAt(razr) == 'p') + { + x = 15; + } + else if (visData.charAt(razr) == 'h') + { + x = 16; + } + else + { + x = visData.substring(razr, razr + 1).toInt(); + } + + switch (x) + { + case 0: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if (i >= 0 && i < 36) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 1: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if (i > 11 && i < 24) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 2: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i > 5 && i < 18) || (i > 23 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 3: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i > 5 && i < 30) || (i > 36 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 4: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i >= 0 && i < 6) || (i > 11 && i < 24) || (i > 35 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 5: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i >= 0 && i < 12) || (i > 17 && i < 30) || (i > 35 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 6: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i >= 0 && i < 12) || (i > 17 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 7: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if (i > 5 && i < 24) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 8: + for (int i = 0; i < RAZR_PIXEL; ++i) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + break; + case 9: + for (int i = 0; i < RAZR_PIXEL; i++) + { + if ((i >= 0 && i < 30) || (i > 35 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 10: // градус ( A ) + for (int i = 0; i < RAZR_PIXEL; i++) + { + if ((i >= 0 && i < 18) || (i > 35 && i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 11: // минус ( B ) + for (int i = 0; i < RAZR_PIXEL; i++) + { + if ((i > 35) && (i < 42)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 12: // двоеточие ( C ) + for (int i = 0; i < RAZR_PIXEL; i++) + { + if ((i == 36) || (i == 37) || (i == 40) || (i == 41)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 13: // null (пусто) ( D ) + for (int i = 0; i < RAZR_PIXEL; ++i) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + break; + case 14: // точка ( E ) + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i == 26) || (i == 27)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 15: // давление ( p ) + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i < 18) || (i > 29)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + case 16: // влажность ( h ) + for (int i = 0; i < RAZR_PIXEL; ++i) + { + if ((i < 6) || (i > 17 && i < 24) || (i > 29)) + { + strip.setPixelColor(i + razr * RAZR_PIXEL, color); + } + else + { + strip.setPixelColor(i + razr * RAZR_PIXEL, strip.Color(0, 0, 0)); + } + } + break; + } + } + strip.show(); +} + +//******************************************************************** +// Эффекты табло: +// void visible_effect() +// { +// uint8_t a, b, c; +// for (uint8_t k = 0; k < 3; ++k) +// { +// (k == 0) ? a = 255 : b = c = 0; +// (k == 1) ? b = 255 : a = c = 0; +// (k == 2) ? c = 255 : b = a = 0; + +// for (uint8_t i = 0; i < PIXEL_COUNT; ++i) +// { +// // uint32_t col = 255; +// for (uint8_t i = 0; i < PIXEL_COUNT; ++i) +// { +// // uint32_t col = random(200, 65535); +// strip.setPixelColor(i, strip.Color(a, b, c)); +// strip.show(); +// delay(10); +// } +// } +// } + +// // for (uint8_t i = 0; i < PIXEL_COUNT; ++i) +// // { +// // uint32_t col = random(200, 65535); +// // strip.setPixelColor(i, col); +// // strip.show(); +// // delay(10); +// // } +// delay(1000); + +// for (uint8_t i = 0; i < PIXEL_COUNT; ++i) +// { +// strip.setPixelColor(i, 0); +// strip.show(); +// } +// } \ No newline at end of file diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..2e2c947 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,29 @@ +; 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:d1_mini] +; platform = espressif8266 +; board = d1_mini + +[env:esp32-c3-devkitm-1] +platform = espressif32 +board = esp32-c3-devkitm-1 + +framework = arduino +upload_speed = 460800 ;460800;921600 +monitor_speed = 115200 + +lib_deps = + adafruit/Adafruit NeoPixel + arduino-libraries/NTPClient + knolleary/PubSubClient + + + \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9227cb7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,588 @@ +//******************************************************************** +//* +//* Tablo na WS2812b + mqtt + bme280 (2) +//* 2021.02.01 - 2025.03.01 +//* +//* A - градус +//* В - минус +//* С - двоеточие +//* D - пусто +//* E - точка +//* F - p +//* G - h +//******************************************************************** +#include +#include +#include +#include +#include +#include + +#include "tablo_ws2812.h" +// #include + +#include +#include +#include +#include +const char *host = "esp32"; + +const uint8_t TIME_DAY = 8; +const uint8_t TIME_NIGHT = 18; +const uint8_t BRIGHT_DAY = 5; +const uint8_t BRIGHT_NIGHT = 1; + +// const char *ssid = "link"; +const char *ssid = "MikroTik-2"; +const char *pass = "dkfgf#*12091997"; +const char *ntp_server = "ntp3.vniiftri.ru"; +const char *mqtt_server = "89.110.92.137"; +const uint16_t mqtt_port = 1883; +const char *mqtt_user = "mqtt"; +const char *mqtt_pass = "qwe1243"; + +const uint32_t utcOffsetInSeconds = 10800; +const uint32_t utcPeriodMseconds = 86400000; // 86400000-р/сут; 604800000-р/нед + +WiFiUDP ntpUDP; +NTPClient timeClient(ntpUDP, ntp_server, utcOffsetInSeconds, utcPeriodMseconds); + +// const char *mqtt_client = "Tablo_Home_345"; +const char *mqtt_client = "Tablo_Villa-002"; +// const char *mqtt_client2 = "Home_bme280_villa"; +const char *mqtt_client2 = "Villa_bme280_base"; + +const char *inTopic = "/t;p;h;v;;"; +// const char *inTopic_room = "/t;p;h;"; +// const char *inTopic_balkon = "/t;"; + +String temp, pres, hum, vcc; + +// String outTemp_room = "00.00A"; +// String outTemp_balkon = "00.00A"; +// String outPres_room = "000"; +// String outHum_room = "00"; +// String outHum_balkon = "00"; +// String outVolt_room = "00"; +// String outVolt_balkon = "00"; + +WiFiClient espClient; +PubSubClient client(espClient); + +uint32_t timeUpdateH = 02; // время обновления NTP +uint32_t timeUpdateM = 05; // время обновления NTP +String z = ""; // строка данных для отображения + +uint32_t colorTime = 16766720; // цвет часы (желтый) +uint32_t colorTemp = 16711935; // цвет температура (красный) +uint32_t colorTemp2 = 65280; // цвет внешняя температура (зеленый) +uint32_t colorTempOut = 9127187; // цвет температура2 (коричневый) +uint32_t colorPress = 9830400; // цвет давление (розовый) +uint32_t colorWiFi = 16728935; // цвет WiFi (томато) +uint32_t colorHum = 52945; // цвет влажность (бирюзовый) +uint32_t colorHumOut = 255; // цвет влажность2 (синий// Время отображения каждого показания: +// 0-время, 1-темп, 2-давл, 3-влажн, 4-темп2, 5-влажн2 +uint16_t visData[] = {5, 2, 2, 2, 2, 2, 2}; +uint8_t count_data = 4; // сколько еще отображать показаний +uint16_t strOld = 0; +uint8_t count = 0; + +uint32_t timeReadOld = 0; +uint32_t color = 0; + +bool flagSec = true; +bool flagTemp = false; +bool flagTemp2 = false; +bool flagTempOut = false; +bool flagPress = false; +bool flagHum = false; +bool flagHumOut = false; +bool flagVis = true; + +uint32_t timeOld = 0; +uint8_t brightVis = BRIGHT_NIGHT; + +WebServer server(80); +ESP8266HTTPUpdateServer httpUpdater; + +//----------------------------------- +// Login page +const char *loginIndex = + "
" + "" + "" + "" + "
" + "
" + "" + "" + "" + "" + "
" + "
" + "" + "" + "" + "
" + "
" + "" + "" + "" + "" + "
" + "
ESP32 Login Page
" + "
" + "
Username:
Password:
" + "
" + ""; + +// Server Index Page +const char *serverIndex = + "" + "
" + "" + "" + "
" + "
progress: 0%
" + ""; + +//----------------------------------- +inline bool mqtt_subscribe(PubSubClient &client, const String &topic) +{ + Serial.print("Subscribing to: "); + Serial.println(topic); + return client.subscribe(topic.c_str()); +} + +//----------------------------------- +inline bool mqtt_publish(PubSubClient &client, const String &topic, const String &value) +{ + Serial.print("Publishing topic "); + Serial.print(topic); + Serial.print(" = "); + Serial.println(value); + return client.publish(topic.c_str(), value.c_str()); +} + +//----------------------------------- +void reconnect() +{ + // visibleWork("DDDDD", colorWiFi, BRIGHT_DAY); + // strip.show(); + uint8_t countMqtt = 20; + while (!client.connect(mqtt_client, mqtt_user, mqtt_pass)) + { + strip.show(); + if (countMqtt--) + { + visibleWork("0B0B0", colorWiFi, BRIGHT_DAY); + delay(250); + visibleWork("B0B0B", colorWiFi, BRIGHT_DAY); + delay(250); + } + else + { + ESP.restart(); + } + } + + String topic('/'); + topic += mqtt_client2; + topic += inTopic; + mqtt_subscribe(client, topic); + + // topic = '/'; + // topic += mqtt_client3; + // topic += inTopic_balkon; + // mqtt_subscribe(client, topic); + + Serial.println("MQTT - Ok!\n"); +} + +//******************************************************************** +// подключение к WiFi +void setupWiFi() +{ + digitalWrite(LED_BUILTIN, LOW); + Serial.println("\n\nSetup WiFi: "); + visibleWork("ABABA", colorWiFi, BRIGHT_DAY); + strip.show(); + + WiFi.begin(ssid, pass); + uint8_t countWiFi = 20; + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print('.'); + if (!countWiFi--) + ESP.restart(); + } + digitalWrite(LED_BUILTIN, HIGH); + + //----------------------------------- + // индикация IP + String l = WiFi.localIP().toString(); + l = l.substring(l.lastIndexOf('.') + 1, l.length()); + + (l.toInt() < 100) ? l = "DDE" + l : l = "DE" + l; + visibleWork(l, colorWiFi, BRIGHT_DAY); + + strip.show(); + Serial.print("\nWiFi connected !\n"); + Serial.println(WiFi.localIP()); + delay(2000); + + //----------------------------------- + // индикация силы сигнала + int16_t RSSI_MAX = -50; + int16_t RSSI_MIN = -100; + int16_t dBm = WiFi.RSSI(); + Serial.print("RSSI dBm = "); + Serial.println(dBm); + l = "DDE"; + (dBm <= RSSI_MIN) ? l += 0 : (dBm >= RSSI_MAX) ? l += 100 + : l += 2 * (dBm + 100); + Serial.print("RSSI % = "); + Serial.println(l); + Serial.println("\n"); + + visibleWork(l, colorWiFi, BRIGHT_DAY); + strip.show(); + delay(2000); + + Serial.println(); + + z = "BBBBB"; + visibleWork(z, colorTemp, BRIGHT_DAY); + strip.show(); + + while (!timeClient.update()) + { + delay(500); + Serial.print('.'); + } + flagVis = true; + count = 0; +} + +//******************************************************************** +// MQTT +void mqtt_callback(char *topic, byte *payload, unsigned int length) +{ + //------------------------ + // Просто печатаем то, что пришло: + // Serial.print(topic); + // Serial.print(" \t"); + // for (uint8_t i = 0; i < length; ++i) + // { + // Serial.print((char)payload[i]); + // } + // Serial.println("---"); + //------------------------ + // + if (!strncmp(topic + 1, mqtt_client2, strlen(mqtt_client2))) + { + char *topicBody = topic + strlen(mqtt_client2) + 1; + if (!strncmp(topicBody, inTopic, strlen(inTopic))) + { + String data_room = ""; + Serial.print(topicBody); + Serial.print(" - "); + for (uint8_t i = 0; i < length; i++) + data_room += (char)payload[i]; + Serial.println(data_room); + temp = data_room.substring(0, data_room.indexOf(";")); + data_room = data_room.substring(data_room.indexOf(";") + 1, data_room.length()); + pres = data_room.substring(0, data_room.indexOf(";")); + data_room = data_room.substring(data_room.indexOf(";") + 1, data_room.length()); + hum = data_room.substring(0, data_room.indexOf(";")); + } + } + // else if (!strncmp(topic + 1, mqtt_client3, strlen(mqtt_client3))) + // { + // char *topicBody = topic + strlen(mqtt_client3) + 1; + // if (!strncmp(topicBody, inTopic_balkon, strlen(inTopic_balkon))) + // { + // String data_balkon = ""; + // Serial.print(topicBody); + // Serial.print(" - "); + // for (uint8_t i = 0; i < length; i++) + // data_balkon += (char)payload[i]; + // Serial.println(data_balkon); + // temp_out = data_balkon.substring(0, data_balkon.indexOf(";")); + // } + // } + + //------------------------ +} + +//******************************************************************** +// пересчет millis() +void TimeMillis() +{ + color = colorTime; + + if (timeClient.getHours() < 10) + z += "D"; + + z += timeClient.getHours(); // часы + (flagSec) ? z += "C" : z += "D"; + + if (timeClient.getMinutes() < 10) + z += "0"; + + z += timeClient.getMinutes(); // минуты + + if ((timeClient.getHours() >= TIME_DAY) && (timeClient.getHours() < TIME_NIGHT)) + { + brightVis = BRIGHT_DAY; + } + else + { + brightVis = BRIGHT_NIGHT; + } +} + +//******************************************************************** +// BME280 +void Temp() +{ + if (temp.charAt(0) == '-') + { + if (temp.indexOf('.') == 2) + { + z = "B" + temp.substring(1, temp.indexOf('.')); + z += 'E' + temp.substring(3, 4); + } + else + { + z = "DB" + temp.substring(1, temp.indexOf('.')); + } + } + else + { + if (temp.indexOf('.') == 2) + { + z = temp.substring(0, temp.indexOf('.')); + z += 'E' + temp.substring(3, 4); + } + else + { + z = 'D' + temp.substring(0, temp.indexOf('.')); + z += 'E' + temp.substring(2, 3); + } + } + temp.substring(0, 1); + + z += 'A'; + color = colorTemp; + flagTemp = true; +} + +void Press() +{ + z += "DD" + pres.substring(0, 3); + color = colorPress; + flagPress = true; +} + +void Hum() +{ + (hum.length() > 2) ? z = "DD" : z = "DDD"; + z += hum.substring(0, 2); + color = colorHum; + flagHum = true; +} + +//******************************************************************** +// запись значений датчиков в память +// void dataWrite(uint8_t n, String str) +// { +// data[n].num = str.substring(0, 1).toInt(); +// if (str.indexOf('%') > 0) +// data[n].hum = str.substring(str.indexOf('%') + 1, str.indexOf('%') + 3).toInt(); +// if (str.indexOf('*') > 0) +// data[n].temp = str.substring(str.indexOf('*') + 1, str.indexOf('*' + 5)).toFloat(); +// if (str.indexOf('$') > 0) +// data[n].press = str.substring(str.indexOf('$') + 1, str.indexOf('$') + 4).toInt(); +// if (str.indexOf('^') > 0) +// data[n].voltage = str.substring(str.indexOf('^') + 1, str.indexOf('^') + 4).toInt(); +// } +//******************************************************************** +// void parserBuf(String tmp) +// { +// if (tmp.substring(0, 2).toInt() == sensorNum_1) +// { +// dataWrite(0, tmp); +// } +// // else if (tmp.substring(0, 2).toInt() == sensorNum_2) +// // { +// // dataWrite(1, tmp); +// // } +// } +//******************************************************************** +// визуальные эффекты +// void show_1() +// { +// strip.clear(); +// for (uint8_t i = 0; i < PIXEL_COUNT / 2; ++i) +// { +// strip.setPixelColor(i, strip.Color(random(1, 255), random(1, 255), random(1, 255))); +// strip.setPixelColor(PIXEL_COUNT - i - 1, strip.Color(random(1, 255), random(1, 255), random(1, 255))); +// strip.show(); +// delay(5); +// } +// delay(500); +// for (uint8_t i = 0; i < PIXEL_COUNT / 2; ++i) +// { +// strip.setPixelColor(i, 0, 0, 0); +// strip.setPixelColor(PIXEL_COUNT - i - 1, 0, 0, 0); +// strip.show(); +// delay(5); +// } +// delay(500); +// z = "DDDDD"; +// } + +//******************************************************************** +void setup() +{ + Serial.begin(115200); + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, HIGH); + + strip.begin(); + z = "AAAAA"; + strip.show(); + + client.setServer(mqtt_server, mqtt_port); + client.setCallback(mqtt_callback); + + setupWiFi(); + + timeClient.begin(); + server.begin(); + httpUpdater.setup(&server); + + // visible_effect(); + + timeOld = millis(); +} + +//******************************************************************** +void loop() +{ + // visible_effect(); + + if (WiFi.status() != WL_CONNECTED) + { + setupWiFi(); + } + + if (!client.connected()) + { + reconnect(); + } + + if (flagVis) + { + z = ""; + switch (count) + { + case 0: + { + TimeMillis(); + flagTemp = false; + flagPress = false; + flagHum = false; + flagTempOut = false; + flagHumOut = false; + break; + } + case 1: + { + if (flagTemp == false) + Temp(); + break; + } + case 2: + { + if (flagPress == false) + Press(); + break; + } + case 3: + { + if (flagHum == false) + Hum(); + break; + } + } + flagVis = false; + visibleWork(z, color, brightVis); + // timeReadOld = millis(); + } + + // Отображение секундного тире + if (millis() - timeReadOld >= 500 && (!count)) // || count == 1)) + { + flagSec = !flagSec; + timeReadOld = millis(); + + flagVis = true; + } + + // Счетчик отображаемых данных + if (millis() - timeOld >= (visData[count] * 1000)) + { + (count >= count_data) ? count = 0 : count++; + flagVis = true; + timeOld = millis(); + } + + server.handleClient(); + client.loop(); + delay(1); +} + +//******************************************************************** \ No newline at end of file -- cgit v1.2.3