PCB Design Basics


So was I supposed to delete everything in Tacoma_dial_exhaust_test.INO and replace it with the new one sent
Yes. Replace everything in Tacoma_Dial_Exhaust_Test.ino with the new artwork-based firmware.
  1. Open the .ino tab.
  2. Press Ctrl+A, then Delete.
  3. Copy the complete revised .ino code from Firmware Starter.
  4. Paste it into the empty .ino tab.
  5. Add a second tab using the small dropdown/three-dot menu near the sketch tabs.
  6. Name the second tab exactly:

Text


ui_assets.h
  1. Copy the complete asset-header code from the document into that tab.
  2. Confirm the .ino begins with:

Cpp


#include <Arduino.h>
#include <M5Dial.h>
#include "ui_assets.h"
  1. Click Verify, then Upload.
Do not paste the new code below the old code—it must completely replace it.
Replace file contents
Check asset tab
Use embedded art

Firmware Starter
Complete Arduino starter firmware for M5Stack Dial v1.1, using Arduino IDE board selection M5 Dial. This revision uses the supplied full-screen embedded JPEG artwork instead of approximating the screens with vector graphics.
Preserved hardware mapping

Table


FunctionM5 Dial PORT.B pinCableRemote inputElectrical behavior
Exhaust OPENGPIO1WhiteS2500 ms active-HIGH pulse; idle LOW
Exhaust CLOSE / QUIETGPIO2YellowS1500 ms active-HIGH pulse; idle LOW
The firmware forces both output pins LOW before M5Dial.begin() or any display/UI initialization. Do not swap these pins.
State limitation: lastExhaustState records 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.
Required two-file sketch
The Arduino sketch folder must contain these two files side by side:

Text


TacomaDialController/
├── TacomaDialController.ino
└── ui_assets.h
Download the generated artwork header here:
The header contains three 240×240 baseline JPEG arrays in PROGMEM:
  • home_240_jpg and home_240_jpg_len
  • exhaust_open_240_jpg and exhaust_open_240_jpg_len
  • exhaust_quiet_240_jpg and exhaust_quiet_240_jpg_len
How to add the header in Arduino IDE
  1. Create or open the sketch TacomaDialController.ino.
  2. Click the small tab/menu control at the upper-right of the Arduino editor.
  3. Choose New Tab.
  4. Name the tab exactly ui_assets.h.
  5. Open the download link above, copy the entire generated header, and paste it into the new tab. Alternatively, download it and place it directly in the same folder as the .ino file.
  6. Keep #include "ui_assets.h" near the top of the main .ino file.
Do not rename the arrays inside the generated header.
Platform and libraries
  • Arduino IDE 2.x
  • M5Stack board package: 3.2.2 or newer
  • Tools → Board: M5Stack → M5 Dial (some versions display M5Dial without a space)
  • Library Manager: M5Dial 1.0.3 or newer, accepting its M5Unified and M5GFX dependencies
  • USB CDC On Boot: Enabled if required for serial upload/monitoring by the installed ESP32-S3 package
  • Serial Monitor: 115200 baud
The JPEG calls use the M5GFX memory-buffer overload:

Cpp


M5Dial.Display.drawJpg(jpegArray, jpegLength, 0, 0);
The generated const uint8_t[] PROGMEM arrays are directly readable by this overload on the ESP32-S3.
TacomaDialController.ino

Cpp


#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);
}
RTC behavior and setting the clock
The home screen reads the onboard RTC with:

Cpp


auto dt = M5Dial.Rtc.getDateTime();
The static sample clock embedded in the home JPEG is covered with a black rectangle. Only this small clock region is refreshed when the hour or minute changes, avoiding a full JPEG redraw in every loop.
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.
To set the RTC later, temporarily add this after 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}});
The official M5Dial RTC/NTP example can also synchronize it over Wi-Fi.
Upload steps
  1. Install/update the M5Stack board package in Arduino Board Manager.
  2. Install the M5Dial library and all prompted dependencies.
  3. Select Tools → Board → M5Stack → M5 Dial.
  4. Create the main sketch as TacomaDialController.ino.
  5. Add the second tab/file named exactly ui_assets.h and paste the complete downloaded header into it.
  6. Confirm both tabs are visible and the .ino contains #include "ui_assets.h".
  7. Connect the Dial, select its USB port, and click Verify.
  8. Click Upload, then open Serial Monitor at 115200 baud.
Test procedure
  1. Safe startup: Observe GPIO1 and GPIO2 during power-up. Both must remain LOW; neither PC817 should activate during library/display initialization.
  2. Artwork: Confirm the supplied home JPEG fills the display and the live RTC clock replaces the sample clock near the bottom.
  3. Serial OPEN: Send 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.
  4. Serial CLOSE: Send 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.
  5. Single press: Press/release the knob once and wait longer than 450 ms. Confirm no command is sent.
  6. Double press: Complete two presses/releases within about 450 ms. Confirm it toggles the last-commanded state.
  7. Encoder reservation: Rotate the knob and confirm it performs no action.
Troubleshooting
  • 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.
  • JPEG does not compile: Update M5Dial, M5Unified, and M5GFX. Confirm the three arrays and three _len constants retain their generated names.
  • JPEG is blank or corrupted: Re-copy the entire header; a missing byte, brace, or final length constant will corrupt the embedded data.
  • Clock leaves part of the sample visible: Slight JPEG compression or artwork revisions can change the clock edges. Adjust only the fillRect(42, 190, 156, 43, COLOR_BG) rectangle while keeping it inside the bottom clock region.
  • Button does not react: M5Dial.update() must remain in every loop pass. A single press intentionally does nothing.
  • Wrong valve direction: Do not swap the software mapping. Verify GPIO1/white/S2 is OPEN and GPIO2/yellow/S1 is CLOSE/QUIET.
  • Clock is incorrect: Set the RTC with the one-time line above or the official M5Stack RTC/NTP example.
API validation references
  • 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