commit e80990c521c7de496ea662abac20889a72332218 Author: Avii Date: Tue Jul 1 18:20:48 2025 +0200 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..7757c08 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,30 @@ +[env:m5paper] +platform = espressif32 +; platform = https://github.com/platformio/platform-espressif32.git#feature/arduino-upstream +board = m5stack-fire +framework = arduino +upload_speed = 2000000 +; platform_packages = +; framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32.git +monitor_speed = 115200 +board_build.partitions = default_16MB.csv +build_flags = + -std=gnu++17 + -Ofast + -DBOARD_HAS_PSRAM + -mfix-esp32-psram-cache-issue + ; -DCORE_DEBUG_LEVEL=4 +build_unflags = + -std=gnu++11 +lib_deps = + https://github.com/m5stack/M5EPD + https://github.com/arduino-libraries/NTPClient + https://github.com/tobozo/YAMLDuino + LovyanGFX + bblanchon/ArduinoJson + knolleary/PubSubClient +; upload_protocol = espota +; upload_port = 192.168.10.104 +; extra_scripts = +; pre:extra_script.py +; upload_flags = --host_port=55910 \ No newline at end of file diff --git a/settings.yml.example b/settings.yml.example new file mode 100644 index 0000000..670aeb0 --- /dev/null +++ b/settings.yml.example @@ -0,0 +1,12 @@ +wifi: + ssid: + password: + +mqtt: + ident: + hostname: + username: + password: + +totp: + hmac: # MUST be 20 characters diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..22fcc55 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,400 @@ +#undef ARDUINO_M5STACK_FIRE +#define ARDUINO_M5STACK_Paper + +#define YAML_DISABLE_CJSON + +#include +#include +#include +#include +#include +#include +#include + +// https://github.com/Netthaw/TOTP-MCU +#include "totp.h" + +#define LGFX_M5PAPER +#define LGFX_USE_V1 +#define LGFX_AUTODETECT + +#include + +static LGFX gfx; +static WiFiUDP ntpUDP; +static NTPClient timeClient(ntpUDP); + +constexpr float FONT_SIZE_NORMAL = 4.0; +constexpr float FONT_SIZE_LARGE = 8.0; + +static YAMLNode *SETTINGS = nullptr; + +static String oldinput = ""; +static String input = ""; + +static int last_index = -1; + +static bool is_disarmed = false; + +static const int WIFI_CONNECT_RETRY_MAX = 30; +static const float_t BUTTON_WIDTH = 115; +static const float_t BUTTON_HEIGHT = 115; + +void drawLockState(String state) +{ + state.replace("_", " "); + + gfx.setTextSize(FONT_SIZE_NORMAL); + + gfx.setColor(TFT_WHITE); + gfx.fillRect(405, 155, gfx.fontWidth() * 10, gfx.fontHeight()); + gfx.setColor(TFT_BLACK); + + gfx.setCursor(405, 155); + state[0] = toupper(state[0]); + gfx.printf("%s", state); +} + +void mqtt_callback(char *topic, byte *payload, unsigned int length) +{ + String state; + for (int i = 0; i < length; i++) + { + state += (const char)payload[i]; + } + + is_disarmed = state == "disarmed"; + + Serial.printf("%d: %s - %s\n", length, topic, state); + drawLockState(state); +} + +WiFiClient wifiClient; +PubSubClient *client = nullptr; + +void cls() +{ + gfx.setEpdMode(epd_mode_t::epd_quality); + gfx.fillScreen(TFT_WHITE); + gfx.waitDisplay(); + gfx.fillScreen(TFT_WHITE); + gfx.setEpdMode(epd_mode_t::epd_fast); + gfx.setColor(TFT_BLACK); + gfx.waitDisplay(); +} + +void drawButton(int index, String label) +{ + float_t x = (125 * (index % 3)) + 15; + float_t y = (125 * (index / 3)) + 20; + + gfx.setCursor(x + (115 / 2) - (gfx.fontWidth() / 2), y + (115 / 2) - (gfx.fontHeight() / 2)); + gfx.print(label); + + gfx.setColor(TFT_BLACK); + gfx.drawFastHLine(x, y, BUTTON_WIDTH); + gfx.drawFastHLine(x, y + BUTTON_HEIGHT, BUTTON_WIDTH); + gfx.drawFastVLine(x, y, BUTTON_HEIGHT); + gfx.drawFastVLine(x + BUTTON_WIDTH, y, BUTTON_HEIGHT); +} + +void drawButtons() +{ + gfx.setTextSize(FONT_SIZE_NORMAL); + for (int i = 0; i <= 11; i++) + { + String label = String(i + 1); + if (i == 9) + { + label = String("<"); + } + if (i == 10) + { + label = String("0"); + } + if (i == 11) + { + label = String(">"); + } + + drawButton(i, label); + } +} + +void drawInputField() +{ + int x = 405; + int y = 20; + + gfx.setColor(TFT_BLACK); + gfx.drawFastHLine(x, y, 520); + gfx.drawFastHLine(x, y + 115, 520); + gfx.drawFastVLine(x, y, 115); + gfx.drawFastVLine(x + 520, y, 115); +} + +void drawInput() +{ + int x = 405; + int y = 20; + + gfx.setColor(TFT_WHITE); + gfx.fillRect(x + 1, y + 1, 519, 114); + gfx.setColor(TFT_BLACK); + + gfx.setEpdMode(epd_mode_t::epd_text); + gfx.setColor(TFT_BLACK); + gfx.setTextSize(FONT_SIZE_LARGE); + gfx.setCursor(x + (520 / 2) - ((gfx.fontWidth() * input.length()) / 2), y + (115 / 2) - (gfx.fontHeight() / 2)); + String display = ""; + for (int i = 0; i < input.length(); i++) + { + display += "*"; + } + gfx.print(display); + gfx.setEpdMode(epd_mode_t::epd_fast); +} + +void setupTime() +{ + timeClient.begin(); +} + +void send(String state) +{ + if (SETTINGS == nullptr) + { + return; + } + + if (client->connect(SETTINGS->gettext("mqtt:ident"), SETTINGS->gettext("mqtt:username"), SETTINGS->gettext("mqtt:password"))) + { + client->unsubscribe(SETTINGS->gettext("mqtt:state_topic")); + client->subscribe(SETTINGS->gettext("mqtt:state_topic")); + client->publish(SETTINGS->gettext("mqtt:command_topic"), state.c_str()); + } +} + +void lock() +{ + if (is_disarmed) + { + send("ARM_AWAY"); + } +} + +void unlock() +{ + send("DISARM"); +} + +bool isConnecting = false; +void resetWifi() +{ + if (!SETTINGS) + { + return; + } + + if (isConnecting) + { + return; + } + isConnecting = true; + drawLockState("connecting"); + + WiFi.begin(SETTINGS->gettext("wifi:ssid"), SETTINGS->gettext("wifi:password")); + + if (WiFi.isConnected()) + { + WiFi.disconnect(); + } + + Serial.print("Connecting to Wi-Fi network"); + for (int cnt_retry = 0; cnt_retry < WIFI_CONNECT_RETRY_MAX && !WiFi.isConnected(); + cnt_retry++) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + if (WiFi.isConnected()) + { + Serial.print("Local IP: "); + Serial.println(WiFi.localIP()); + isConnecting = false; + } + else + { + drawLockState("failed to connect"); + isConnecting = false; + return; + } + + IPAddress ip; + if (!ip.fromString(SETTINGS->gettext("mqtt:hostname"))) + { + drawLockState("failed to parse mqtt ip"); + isConnecting = false; + return; + } + + client = new PubSubClient(ip, 1883, mqtt_callback, wifiClient); + + if (client->connect(SETTINGS->gettext("mqtt:ident"), SETTINGS->gettext("mqtt:username"), SETTINGS->gettext("mqtt:password"))) + { + client->unsubscribe(SETTINGS->gettext("mqtt:state_topic")); + client->subscribe(SETTINGS->gettext("mqtt:state_topic")); + } +} + +bool initSD() +{ + if (!SD.exists("/settings.yml")) + { + Serial.println("settings.yml not found"); + drawLockState("settings.yml not found"); + return false; + } + + File settingsFile = SD.open("/settings.yml"); + YAMLNode node = YAMLNode::loadStream(settingsFile); + settingsFile.close(); + SETTINGS = new YAMLNode(node); + + return true; +} + +void initTOTP() +{ + uint8_t *base32_key = new uint8_t[20]; + const char *hmac = ((String)SETTINGS->gettext("totp:hmac")).c_str(); + + for (int i = 0; i < 20; i++) + { + base32_key[i] = (uint8_t)hmac[i]; + } + + TOTP(base32_key, 20, 30); +} + +void setup() +{ + M5.begin(); + + gfx.init(); + gfx.setEpdMode(epd_mode_t::epd_fast); + gfx.setRotation(1); + cls(); + + drawButtons(); + drawInputField(); + drawInput(); + + if (!initSD()) + return; + + resetWifi(); + sleep(1); + setupTime(); + initTOTP(); + + lock(); +} + +int index(int x, int y) +{ + for (int index = 0; index <= 11; index++) + { + float_t cx = (125 * (index % 3)) + 15; + float_t cy = (125 * (index / 3)) + 20; + + if (x > cx && x < cx + BUTTON_WIDTH && y > cy && y < cy + BUTTON_HEIGHT) + { + return index; + } + } + + return -1; +} + +void submit(String code) +{ + uint32_t newCode = getCodeFromTimestamp(timeClient.getEpochTime()); + uint32_t input = code.toInt(); + + if (newCode == input) + { + unlock(); + return; + } + + lock(); +} + +void checkForInput() +{ + if (M5.TP.available()) + { + M5.TP.update(); + if (M5.TP.isFingerUp()) + { + last_index = -1; + return; + } + + tp_finger_t fingerItem = M5.TP.readFinger(0); + + int i = index(fingerItem.x, fingerItem.y); + if (last_index != i) + { + last_index = i; + if (i == 9) + { + input = input.substring(0, input.length() - 1); + } + else if (i == 10) + { + input += String(0); + } + else if (i == 11) + { + // submit + submit(input); + input = ""; + } + else + { + input += String(i + 1); + } + } + } +} + +unsigned long lastBtnPressed = millis(); + +void checkForButton() +{ + M5.BtnP.read(); + if (M5.BtnP.isPressed() && millis() - lastBtnPressed > 1000) + { + lastBtnPressed = millis(); // try and debouce, not really working i guess + resetWifi(); + } +} + +void loop() +{ + checkForButton(); + timeClient.update(); + if (client != nullptr) + { + client->loop(); + } + checkForInput(); + if (oldinput != input) + { + oldinput = input; + drawInput(); + } +} diff --git a/src/sha1.cpp b/src/sha1.cpp new file mode 100644 index 0000000..5cc8631 --- /dev/null +++ b/src/sha1.cpp @@ -0,0 +1,202 @@ +#include +#include "sha1.h" + +#define SHA1_K0 0x5a827999 +#define SHA1_K20 0x6ed9eba1 +#define SHA1_K40 0x8f1bbcdc +#define SHA1_K60 0xca62c1d6 + +union _buffer +{ + uint8_t b[BLOCK_LENGTH]; + uint32_t w[BLOCK_LENGTH / 4]; +} buffer; + +union _state +{ + uint8_t b[HASH_LENGTH]; + uint32_t w[HASH_LENGTH / 4]; +} state; + +uint8_t bufferOffset; +uint32_t byteCount; +uint8_t keyBuffer[BLOCK_LENGTH]; +uint8_t innerHash[HASH_LENGTH]; + +uint8_t sha1InitState[] = { + 0x01, 0x23, 0x45, 0x67, // H0 + 0x89, 0xab, 0xcd, 0xef, // H1 + 0xfe, 0xdc, 0xba, 0x98, // H2 + 0x76, 0x54, 0x32, 0x10, // H3 + 0xf0, 0xe1, 0xd2, 0xc3 // H4 +}; + +void init(void) +{ + memcpy(state.b, sha1InitState, HASH_LENGTH); + byteCount = 0; + bufferOffset = 0; +} + +uint32_t rol32(uint32_t number, uint8_t bits) +{ + return ((number << bits) | (uint32_t)(number >> (32 - bits))); +} + +void hashBlock() +{ + uint8_t i; + uint32_t a, b, c, d, e, t; + + a = state.w[0]; + b = state.w[1]; + c = state.w[2]; + d = state.w[3]; + e = state.w[4]; + for (i = 0; i < 80; i++) + { + if (i >= 16) + { + t = buffer.w[(i + 13) & 15] ^ buffer.w[(i + 8) & 15] ^ buffer.w[(i + 2) & 15] ^ buffer.w[i & 15]; + buffer.w[i & 15] = rol32(t, 1); + } + if (i < 20) + { + t = (d ^ (b & (c ^ d))) + SHA1_K0; + } + else if (i < 40) + { + t = (b ^ c ^ d) + SHA1_K20; + } + else if (i < 60) + { + t = ((b & c) | (d & (b | c))) + SHA1_K40; + } + else + { + t = (b ^ c ^ d) + SHA1_K60; + } + t += rol32(a, 5) + e + buffer.w[i & 15]; + e = d; + d = c; + c = rol32(b, 30); + b = a; + a = t; + } + state.w[0] += a; + state.w[1] += b; + state.w[2] += c; + state.w[3] += d; + state.w[4] += e; +} + +void addUncounted(uint8_t data) +{ + buffer.b[bufferOffset ^ 3] = data; + bufferOffset++; + if (bufferOffset == BLOCK_LENGTH) + { + hashBlock(); + bufferOffset = 0; + } +} + +void write(uint8_t data) +{ + ++byteCount; + addUncounted(data); + + return; +} + +void writeArray(uint8_t *buffer, uint8_t size) +{ + while (size--) + { + write(*buffer++); + } +} + +void pad() +{ + // Implement SHA-1 padding (fips180-2 ��5.1.1) + + // Pad with 0x80 followed by 0x00 until the end of the block + addUncounted(0x80); + while (bufferOffset != 56) + addUncounted(0x00); + + // Append length in the last 8 bytes + addUncounted(0); // We're only using 32 bit lengths + addUncounted(0); // But SHA-1 supports 64 bit lengths + addUncounted(0); // So zero pad the top bits + addUncounted(byteCount >> 29); // Shifting to multiply by 8 + addUncounted(byteCount >> 21); // as SHA-1 supports bitstreams as well as + addUncounted(byteCount >> 13); // byte. + addUncounted(byteCount >> 5); + addUncounted(byteCount << 3); +} + +uint8_t *result(void) +{ + // Pad to complete the last block + pad(); + + // Swap byte order back + uint8_t i; + for (i = 0; i < 5; i++) + { + uint32_t a, b; + a = state.w[i]; + b = a << 24; + b |= (a << 8) & 0x00ff0000; + b |= (a >> 8) & 0x0000ff00; + b |= a >> 24; + state.w[i] = b; + } + + // Return pointer to hash (20 characters) + return state.b; +} + +#define HMAC_IPAD 0x36 +#define HMAC_OPAD 0x5c + +void initHmac(const uint8_t *key, uint8_t keyLength) +{ + uint8_t i; + memset(keyBuffer, 0, BLOCK_LENGTH); + if (keyLength > BLOCK_LENGTH) + { + // Hash long keys + init(); + for (; keyLength--;) + write(*key++); + memcpy(keyBuffer, result(), HASH_LENGTH); + } + else + { + // Block length keys are used as is + memcpy(keyBuffer, key, keyLength); + } + // Start inner hash + init(); + for (i = 0; i < BLOCK_LENGTH; i++) + { + write(keyBuffer[i] ^ HMAC_IPAD); + } +} + +uint8_t *resultHmac(void) +{ + uint8_t i; + // Complete inner hash + memcpy(innerHash, result(), HASH_LENGTH); + // Calculate outer hash + init(); + for (i = 0; i < BLOCK_LENGTH; i++) + write(keyBuffer[i] ^ HMAC_OPAD); + for (i = 0; i < HASH_LENGTH; i++) + write(innerHash[i]); + return result(); +} diff --git a/src/sha1.h b/src/sha1.h new file mode 100644 index 0000000..ccdf9da --- /dev/null +++ b/src/sha1.h @@ -0,0 +1,16 @@ +#ifndef __SHA1_H__ +#define __SHA1_H__ + +#include + +#define HASH_LENGTH 20 +#define BLOCK_LENGTH 64 + +void init(void); +void initHmac(const uint8_t *secret, uint8_t secretLength); +uint8_t *result(void); +uint8_t *resultHmac(void); +void write(uint8_t); +void writeArray(uint8_t *buffer, uint8_t size); + +#endif \ No newline at end of file diff --git a/src/totp.cpp b/src/totp.cpp new file mode 100644 index 0000000..817c694 --- /dev/null +++ b/src/totp.cpp @@ -0,0 +1,76 @@ +#include "totp.h" +#include "sha1.h" + +uint8_t *_hmacKey; +uint8_t _keyLength; +uint8_t _timeZoneOffset = 0; +uint32_t _timeStep; + +// Init the library with the private key, its length and the timeStep duration +void TOTP(uint8_t *hmacKey, uint8_t keyLength, uint32_t timeStep) +{ + _hmacKey = hmacKey; + _keyLength = keyLength; + _timeStep = timeStep; +} + +void setTimezone(uint8_t timezone) +{ + _timeZoneOffset = timezone; +} + +uint32_t TimeStruct2Timestamp(struct tm time) +{ + // time.tm_mon -= 1; + // time.tm_year -= 1900; + return mktime(&(time)) - (_timeZoneOffset * 3600) - 2208988800; +} + +// Generate a code, using the timestamp provided +uint32_t getCodeFromTimestamp(uint32_t timeStamp) +{ + uint32_t steps = timeStamp / _timeStep; + return getCodeFromSteps(steps); +} + +// Generate a code, using the timestamp provided +uint32_t getCodeFromTimeStruct(struct tm time) +{ + return getCodeFromTimestamp(TimeStruct2Timestamp(time)); +} + +// Generate a code, using the number of steps provided +uint32_t getCodeFromSteps(uint32_t steps) +{ + // STEP 0, map the number of steps in a 8-bytes array (counter value) + uint8_t _byteArray[8]; + _byteArray[0] = 0x00; + _byteArray[1] = 0x00; + _byteArray[2] = 0x00; + _byteArray[3] = 0x00; + _byteArray[4] = (uint8_t)((steps >> 24) & 0xFF); + _byteArray[5] = (uint8_t)((steps >> 16) & 0xFF); + _byteArray[6] = (uint8_t)((steps >> 8) & 0XFF); + _byteArray[7] = (uint8_t)((steps & 0XFF)); + + // STEP 1, get the HMAC-SHA1 hash from counter and key + initHmac(_hmacKey, _keyLength); + writeArray(_byteArray, 8); + uint8_t *_hash = resultHmac(); + + // STEP 2, apply dynamic truncation to obtain a 4-bytes string + uint32_t _truncatedHash = 0; + uint8_t _offset = _hash[20 - 1] & 0xF; + uint8_t j; + for (j = 0; j < 4; ++j) + { + _truncatedHash <<= 8; + _truncatedHash |= _hash[_offset + j]; + } + + // STEP 3, compute the OTP value + _truncatedHash &= 0x7FFFFFFF; // Disabled + _truncatedHash %= 1000000; + + return _truncatedHash; +} diff --git a/src/totp.h b/src/totp.h new file mode 100644 index 0000000..4044920 --- /dev/null +++ b/src/totp.h @@ -0,0 +1,13 @@ +#ifndef __TOTP_H__ +#define __TOTP_H__ + +#include +#include "time.h" + +void TOTP(uint8_t *hmacKey, uint8_t keyLength, uint32_t timeStep); +void setTimezone(uint8_t timezone); +uint32_t getCodeFromTimestamp(uint32_t timeStamp); +uint32_t getCodeFromTimeStruct(struct tm time); +uint32_t getCodeFromSteps(uint32_t steps); + +#endif \ No newline at end of file diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner 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 PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html