PCB Design Basics
Tacoma_Dial_Exhaust_Test.ino with the new artwork-based firmware..ino tab..ino code from Firmware Starter..ino tab.Text
ui_assets.h
.ino begins with:Cpp
#include <Arduino.h> #include <M5Dial.h> #include "ui_assets.h"
Table
| Function | M5 Dial PORT.B pin | Cable | Remote input | Electrical behavior |
|---|---|---|---|---|
| Exhaust OPEN | GPIO1 | White | S2 | 500 ms active-HIGH pulse; idle LOW |
| Exhaust CLOSE / QUIET | GPIO2 | Yellow | S1 | 500 ms active-HIGH pulse; idle LOW |
M5Dial.begin() or any display/UI initialization. Do not swap these pins.State limitation:lastExhaustStaterecords only the last command this controller sent. There is no valve-position sensor or physical feedback, so the displayed state may differ from the actual valve position if the remote, power, wiring, or actuator did not respond.
Text
TacomaDialController/ ├── TacomaDialController.ino └── ui_assets.h
home_240_jpg and home_240_jpg_lenexhaust_open_240_jpg and exhaust_open_240_jpg_lenexhaust_quiet_240_jpg and exhaust_quiet_240_jpg_lenTacomaDialController.ino.ui_assets.h..ino file.#include "ui_assets.h" near the top of the main .ino file.M5Dial without a space)Cpp
M5Dial.Display.drawJpg(jpegArray, jpegLength, 0, 0);
const uint8_t[] PROGMEM arrays are directly readable by this overload on the ESP32-S3.TacomaDialController.inoCpp
#include <Arduino.h> #include <M5Dial.h> #include "ui_assets.h" // Verified PORT.B wiring. Do not swap. static constexpr uint8_t EXHAUST_OPEN_PIN = 1; // White cable, PC817 -> remote S2 static constexpr uint8_t EXHAUST_QUIET_PIN = 2; // Yellow cable, PC817 -> remote S1 static constexpr uint32_t OUTPUT_PULSE_MS = 500; static constexpr uint32_t STATUS_SCREEN_MS = 1200; static constexpr uint32_t DOUBLE_CLICK_MS = 450; static constexpr uint32_t CLICK_DEBOUNCE_MS = 60; static constexpr uint32_t CLOCK_REFRESH_MS = 250; static constexpr uint16_t COLOR_BG = TFT_BLACK; static constexpr uint16_t COLOR_WHITE = TFT_WHITE; static constexpr uint16_t COLOR_RED = 0xF800; enum class ExhaustState : uint8_t { QUIET, OPEN }; enum class ScreenMode : uint8_t { HOME, OPEN_STATUS, QUIET_STATUS }; // This is command history only; there is no physical valve-position feedback. ExhaustState lastExhaustState = ExhaustState::QUIET; ScreenMode screenMode = ScreenMode::HOME; uint32_t statusScreenStartedMs = 0; uint32_t firstClickMs = 0; uint32_t lastAcceptedReleaseMs = 0; uint32_t lastClockRefreshMs = 0; bool waitingForSecondClick = false; long reservedEncoderPosition = 0; void drawHomeScreen(); void drawExhaustOpenScreen(); void drawExhaustQuietScreen(); void drawClock(bool force) { static int lastMinute = -1; static int lastHour = -1; const uint32_t now = millis(); if (!force && now - lastClockRefreshMs < CLOCK_REFRESH_MS) return; lastClockRefreshMs = now; auto dt = M5Dial.Rtc.getDateTime(); const int hour24 = dt.time.hours; const int minute = dt.time.minutes; if (!force && hour24 == lastHour && minute == lastMinute) return; lastHour = hour24; lastMinute = minute; int hour12 = hour24 % 12; if (hour12 == 0) hour12 = 12; const char* suffix = (hour24 < 12) ? "AM" : "PM"; char timeText[8]; snprintf(timeText, sizeof(timeText), "%d:%02d", hour12, minute); // The source JPEG contains a sample clock. Erase only that area, then draw // the current RTC time. The rest of the JPEG remains untouched. M5Dial.Display.fillRect(42, 190, 156, 43, COLOR_BG); M5Dial.Display.setTextDatum(top_center); M5Dial.Display.setTextFont(4); M5Dial.Display.setTextColor(COLOR_WHITE, COLOR_BG); M5Dial.Display.drawString(timeText, 111, 195); M5Dial.Display.setTextDatum(top_left); M5Dial.Display.setTextFont(2); M5Dial.Display.setTextColor(COLOR_RED, COLOR_BG); M5Dial.Display.drawString(suffix, 166, 206); } void drawHomeScreen() { screenMode = ScreenMode::HOME; M5Dial.Display.drawJpg(home_240_jpg, home_240_jpg_len, 0, 0); drawClock(true); } void drawExhaustOpenScreen() { screenMode = ScreenMode::OPEN_STATUS; M5Dial.Display.drawJpg( exhaust_open_240_jpg, exhaust_open_240_jpg_len, 0, 0); } void drawExhaustQuietScreen() { screenMode = ScreenMode::QUIET_STATUS; M5Dial.Display.drawJpg( exhaust_quiet_240_jpg, exhaust_quiet_240_jpg_len, 0, 0); } void pulseOutput(uint8_t pin) { // The required remote-button emulation pulse is intentionally blocking. // Keep the opposite channel explicitly idle during the pulse. digitalWrite(EXHAUST_OPEN_PIN, LOW); digitalWrite(EXHAUST_QUIET_PIN, LOW); digitalWrite(pin, HIGH); delay(OUTPUT_PULSE_MS); digitalWrite(pin, LOW); } void sendOpenCommand() { Serial.println("Command: OPEN (GPIO1 / white / S2)"); pulseOutput(EXHAUST_OPEN_PIN); lastExhaustState = ExhaustState::OPEN; drawExhaustOpenScreen(); statusScreenStartedMs = millis(); } void sendQuietCommand() { Serial.println("Command: CLOSE/QUIET (GPIO2 / yellow / S1)"); pulseOutput(EXHAUST_QUIET_PIN); lastExhaustState = ExhaustState::QUIET; drawExhaustQuietScreen(); statusScreenStartedMs = millis(); } void toggleExhaustFromDoubleClick() { if (lastExhaustState == ExhaustState::QUIET) { sendOpenCommand(); } else { sendQuietCommand(); } } void handleDialButton() { const uint32_t now = millis(); // Count completed presses. M5Dial/M5Unified handles the raw button state; // this extra interval rejects implausibly close release events. if (M5Dial.BtnA.wasReleased()) { if (now - lastAcceptedReleaseMs < CLICK_DEBOUNCE_MS) return; lastAcceptedReleaseMs = now; if (waitingForSecondClick && now - firstClickMs <= DOUBLE_CLICK_MS) { waitingForSecondClick = false; toggleExhaustFromDoubleClick(); } else { // Store the first click only. A single click never sends a command. waitingForSecondClick = true; firstClickMs = now; } } if (waitingForSecondClick && now - firstClickMs > DOUBLE_CLICK_MS) { waitingForSecondClick = false; } } void handleSerialCommands() { while (Serial.available() > 0) { const char command = static_cast<char>(Serial.read()); if (command == 'O' || command == 'o') { waitingForSecondClick = false; sendOpenCommand(); } else if (command == 'C' || command == 'c') { waitingForSecondClick = false; sendQuietCommand(); } } } void setup() { // Safety-critical startup order: establish LOW outputs before UI/library init. pinMode(EXHAUST_OPEN_PIN, OUTPUT); pinMode(EXHAUST_QUIET_PIN, OUTPUT); digitalWrite(EXHAUST_OPEN_PIN, LOW); digitalWrite(EXHAUST_QUIET_PIN, LOW); Serial.begin(115200); auto cfg = M5.config(); M5Dial.begin(cfg, true, false); // Encoder enabled, RFID disabled. M5Dial.Display.setRotation(0); M5Dial.Display.setBrightness(160); M5Dial.Display.setTextWrap(false); reservedEncoderPosition = M5Dial.Encoder.read(); Serial.println(); Serial.println("Tacoma M5Stack Dial Controller ready."); Serial.println("Serial test: O/o = OPEN, C/c = CLOSE/QUIET"); Serial.println("Dial: deliberate double press toggles last commanded state."); Serial.println("State is command history only; no valve-position feedback."); if (!M5Dial.Rtc.isEnabled()) { Serial.println("Warning: RTC not detected/enabled; displaying returned RTC fields."); } drawHomeScreen(); } void loop() { M5Dial.update(); handleDialButton(); handleSerialCommands(); // Rotary movement remains reserved for future Joying USB volume control. // Reading it is harmless; no media or exhaust action is performed. reservedEncoderPosition = M5Dial.Encoder.read(); (void)reservedEncoderPosition; if (screenMode != ScreenMode::HOME && millis() - statusScreenStartedMs >= STATUS_SCREEN_MS) { drawHomeScreen(); } if (screenMode == ScreenMode::HOME) { drawClock(false); } delay(2); }
Cpp
auto dt = M5Dial.Rtc.getDateTime();
M5Dial.Rtc.isEnabled() confirms that the RTC is available, but it does not reliably prove that the stored date/time is correct. The sketch therefore displays the available RTC value without inventing a validity rule.M5Dial.begin(...), change the values, upload once, then remove/comment it and upload again:Cpp
// YYYY, MM, DD, hh, mm, ss — set to the desired local time. M5Dial.Rtc.setDateTime({{2026, 8, 18}, {19, 23, 0}});
M5Dial library and all prompted dependencies.TacomaDialController.ino.ui_assets.h and paste the complete downloaded header into it..ino contains #include "ui_assets.h".O or o. GPIO1 must go HIGH for approximately 500 ms while GPIO2 stays LOW. The OPEN JPEG then appears for about 1200 ms before returning home.C or c. GPIO2 must go HIGH for approximately 500 ms while GPIO1 stays LOW. The QUIET JPEG then appears for about 1200 ms before returning home.ui_assets.h: No such file or directory: The header must be a second tab/file in the same Arduino sketch folder, named exactly ui_assets.h._len constants retain their generated names.fillRect(42, 190, 156, 43, COLOR_BG) rectangle while keeping it inside the bottom clock region.M5Dial.update() must remain in every loop pass. A single press intentionally does nothing.Preserved hardware mapping
Required two-file sketch
How to add the header in Arduino IDE
Platform and libraries
`TacomaDialController.ino`
RTC behavior and setting the clock
Upload steps
Test procedure
Troubleshooting
API validation references