From 99a1850a21d5cba423b6b57eaf97cc3c1179dab1 Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Sun, 23 Aug 2026 19:27:12 +0900 Subject: [PATCH] Add console CAN controller node --- Auto_Seat.ino | 551 ++++++++++++++++++++++++++++++++++ Console_Node/Console_Node.ino | 514 +++++++++++++++++++++++++++++++ README.md | 400 ++++++++++++++++++------ 3 files changed, 1366 insertions(+), 99 deletions(-) create mode 100644 Console_Node/Console_Node.ino diff --git a/Auto_Seat.ino b/Auto_Seat.ino index 405bf97..1d01c22 100644 --- a/Auto_Seat.ino +++ b/Auto_Seat.ino @@ -3,6 +3,8 @@ // Automatic seat controller with EEPROM settings and optional serial tools. // #include +#include +#include #define seatRelayD8 8 // HIGH = OFF, LOW = ON (up relay) #define seatRelayD9 9 // HIGH = OFF, LOW = ON (down relay) @@ -45,11 +47,59 @@ const unsigned long AUTO_CALIBRATION_DOWN_MS = 6000UL; const unsigned long DEBUG_INTERVAL_MS = 1000UL; const long MANUAL_STEP_MS = 500; +// Chaegeon-CAN heartbeat. MCP2515 module uses an 8MHz crystal. +const byte CAN_CS_PIN = 10; +const unsigned long CAN_ID_MAIN_HEARTBEAT = 0x100UL; +const unsigned long CAN_ID_CONSOLE_COMMAND = 0x101UL; +const unsigned long CAN_ID_CONSOLE_STATUS = 0x200UL; +const unsigned long CAN_HEARTBEAT_INTERVAL_MS = 250UL; + +// Center-console node protocol. Nano stores memory and sends pulse commands. +const unsigned long CONSOLE_CAN_TIMEOUT_MS = 2000UL; +const unsigned long CONSOLE_COMMAND_HOLD_MS = 2500UL; +const unsigned long CONSOLE_MANUAL_LEARN_SETTLE_MS = 700UL; +const unsigned long CONSOLE_STARTUP_DELAY_MS = 3000UL; +const unsigned long CONSOLE_MEMORY_REFRESH_INTERVAL_MS = 1170000UL; // 19m 30s +const unsigned long CONSOLE_STEERING_RESTART_INTERVAL_MS = 1170000UL; +const unsigned int CONSOLE_SHORT_PRESS_MS = 300; +const unsigned int CONSOLE_SHORT_PRESS_GAP_MS = 450; +const unsigned int CONSOLE_CLIMATE_LONG_OFF_MS = 2100; +const unsigned int CONSOLE_CLIMATE_LONG_OFF_GAP_MS = 700; +const unsigned int CONSOLE_STEERING_RESTART_GAP_MS = 1200; + +const byte CONSOLE_CHANNEL_COUNT = 7; +const byte CONSOLE_CLIMATE_COUNT = 4; +const byte CONSOLE_TOGGLE_COUNT = 3; +const byte CONSOLE_PULSE_QUEUE_SIZE = 24; + +const byte CH_DRIVER_VENT = 0; +const byte CH_DRIVER_HEAT = 1; +const byte CH_PASSENGER_VENT = 2; +const byte CH_PASSENGER_HEAT = 3; +const byte CH_STEERING_HEAT = 4; +const byte CH_FRONT_SENSOR = 5; +const byte CH_VESS = 6; + +const byte TG_STEERING_HEAT = 0; +const byte TG_FRONT_SENSOR = 1; +const byte TG_VESS = 2; + +const byte CONSOLE_OPPOSITE_CLIMATE[CONSOLE_CLIMATE_COUNT] = { + CH_DRIVER_HEAT, + CH_DRIVER_VENT, + CH_PASSENGER_HEAT, + CH_PASSENGER_VENT +}; + // EEPROM map. const int EEPROM_MAGIC_ADDR = 0; const byte EEPROM_MAGIC_VALUE = 0x64; const int EEPROM_USE_COUNT_ADDR = 1; const int EEPROM_SEAT_POSITION_ADDR = 2; // uint16_t, low byte first +const int EEPROM_CONSOLE_MAGIC_ADDR = 16; +const byte EEPROM_CONSOLE_MAGIC_VALUE = 0xC2; +const int EEPROM_CONSOLE_CLIMATE_TARGET_ADDR = 17; // 4 bytes, driver vent/heat/passenger vent/heat +const int EEPROM_CONSOLE_TOGGLE_TARGET_ADDR = 21; // 3 bytes, steering/front sensor/VESS const byte CALIBRATION_IDLE = 0; const byte CALIBRATION_UP = 1; @@ -92,6 +142,38 @@ bool serialIntroPrinted = false; bool debugEnabled = false; bool manualStepStatusPending = false; +MCP_CAN chaegeonCan(CAN_CS_PIN); +bool chaegeonCanReady = false; +byte chaegeonCanSeq = 0; +unsigned long lastCanHeartbeatTime = 0; + +bool consoleAlive = false; +bool consoleBusy = false; +bool consoleStartupApplyDone = false; +bool previousConsoleIgnitionOn = false; +bool consoleIgnitionApplyPending = false; +bool consoleCommandInFlight = false; +byte consoleCommandSeq = 0; +byte consoleCommandInFlightSeq = 0; +byte consoleLastAcceptedSeq = 0; +byte consoleActualClimatePack = 0; +byte consoleActualToggleBits = 0; +byte consoleButtonBits = 0; +byte consolePreviousButtonBits = 0; +byte consoleClimateTarget[CONSOLE_CLIMATE_COUNT] = {0, 0, 0, 0}; +bool consoleToggleTarget[CONSOLE_TOGGLE_COUNT] = {false, false, false}; +bool consoleManualLearnPending[CONSOLE_CHANNEL_COUNT] = {false}; +unsigned long consoleManualLearnTime[CONSOLE_CHANNEL_COUNT] = {0}; +unsigned long lastConsoleStatusTime = 0; +unsigned long lastConsoleCommandTime = 0; +unsigned long lastConsoleClimateRefreshTime = 0; +unsigned long lastConsoleSteeringRestartTime = 0; +byte consolePulseQueueChannel[CONSOLE_PULSE_QUEUE_SIZE] = {0}; +unsigned int consolePulseQueueOnMs[CONSOLE_PULSE_QUEUE_SIZE] = {0}; +unsigned int consolePulseQueueGapMs[CONSOLE_PULSE_QUEUE_SIZE] = {0}; +byte consolePulseQueueHead = 0; +byte consolePulseQueueTail = 0; + void setup() { Serial.begin(115200); Serial.setTimeout(25); @@ -103,6 +185,7 @@ void setup() { applySeatAction(0); seat(0); + setupChaegeonCan(); loadSettings(); measurement(); @@ -137,6 +220,7 @@ void loop() { registerIgnitionOffUse(currentTime); } prevIgnitionState = ignitionState; + updateChaegeonCan(currentTime); if (autoCalibrationState != CALIBRATION_IDLE) { updateAutoCalibration(currentTime); @@ -538,6 +622,8 @@ void loadSettings() { storedSeatPositionMs = SEAT_HARD_LIMIT_UP_MS; writeUint16(EEPROM_SEAT_POSITION_ADDR, storedSeatPositionMs); } + + loadConsoleSettings(); } uint16_t readUint16(int addr) { @@ -604,6 +690,7 @@ void printStatus(const __FlashStringHelper *tag) { printNamedBool(F("forceCalibration"), forceCalibration); printNamedInt(F("autoCalibrationState"), autoCalibrationState); printAutoCalibrationDelayStatus(); + printConsoleStatusFields(); } void printDebugLine() { @@ -631,6 +718,7 @@ void printDebugLine() { printNamedBool(F("forceCalibration"), forceCalibration); printNamedInt(F("autoCalibrationState"), autoCalibrationState); printAutoCalibrationDelayStatus(); + printConsoleStatusFields(); } void printUptime() { @@ -715,6 +803,469 @@ void printAutoCalibrationDelayStatus() { Serial.println(); } +void printConsoleStatusFields() { + printNamedBool(F("consoleCanReady"), chaegeonCanReady); + printNamedBool(F("consoleAlive"), consoleAlive); + printNamedBool(F("consoleBusy"), consoleBusy); + printNamedInt(F("consolePulseQueueLength"), consolePulseQueueLength()); + printNamedInt(F("consoleDriverVentTarget"), consoleClimateTarget[CH_DRIVER_VENT]); + printNamedInt(F("consoleDriverHeatTarget"), consoleClimateTarget[CH_DRIVER_HEAT]); + printNamedInt(F("consolePassengerVentTarget"), consoleClimateTarget[CH_PASSENGER_VENT]); + printNamedInt(F("consolePassengerHeatTarget"), consoleClimateTarget[CH_PASSENGER_HEAT]); + printNamedBool(F("consoleSteeringHeatTarget"), consoleToggleTarget[TG_STEERING_HEAT]); + printNamedBool(F("consoleFrontSensorTarget"), consoleToggleTarget[TG_FRONT_SENSOR]); + printNamedBool(F("consoleVessTarget"), consoleToggleTarget[TG_VESS]); + printNamedInt(F("consoleDriverVentActual"), getConsoleClimateActual(CH_DRIVER_VENT)); + printNamedInt(F("consoleDriverHeatActual"), getConsoleClimateActual(CH_DRIVER_HEAT)); + printNamedInt(F("consolePassengerVentActual"), getConsoleClimateActual(CH_PASSENGER_VENT)); + printNamedInt(F("consolePassengerHeatActual"), getConsoleClimateActual(CH_PASSENGER_HEAT)); + printNamedBool(F("consoleSteeringHeatActual"), getConsoleToggleActual(TG_STEERING_HEAT)); + printNamedBool(F("consoleFrontSensorActual"), getConsoleToggleActual(TG_FRONT_SENSOR)); + printNamedBool(F("consoleVessActual"), getConsoleToggleActual(TG_VESS)); +} + +void updateChaegeonCan(unsigned long currentTime) { + updateChaegeonCanReceive(currentTime); + updateConsoleAlive(currentTime); + updateChaegeonCanHeartbeat(currentTime); + updateConsoleManualLearn(currentTime); + updateConsoleAutomation(currentTime); + updateConsoleCommandSender(currentTime); +} + +void updateChaegeonCanReceive(unsigned long currentTime) { + if (!chaegeonCanReady) { + return; + } + + while (chaegeonCan.checkReceive() == CAN_MSGAVAIL) { + unsigned long rxId = 0; + byte len = 0; + byte payload[8] = {0}; + if (chaegeonCan.readMsgBuf(&rxId, &len, payload) != CAN_OK) { + return; + } + + if (rxId == CAN_ID_CONSOLE_STATUS && len >= 6 && payload[0] == 1) { + consoleBusy = (payload[1] & 0x02) != 0; + consoleActualClimatePack = payload[2]; + consoleActualToggleBits = payload[3]; + consoleLastAcceptedSeq = payload[5]; + lastConsoleStatusTime = currentTime; + consoleAlive = true; + + if (consoleCommandInFlight && + consoleLastAcceptedSeq == consoleCommandInFlightSeq) { + consoleCommandInFlight = false; + } + + handleConsoleButtonBits(payload[4], currentTime); + } + } +} + +void updateConsoleAlive(unsigned long currentTime) { + bool aliveNow = chaegeonCanReady && + lastConsoleStatusTime != 0 && + currentTime - lastConsoleStatusTime <= CONSOLE_CAN_TIMEOUT_MS; + + if (!aliveNow && consoleAlive) { + consoleStartupApplyDone = false; + consoleCommandInFlight = false; + clearConsolePulseQueue(); + } + + consoleAlive = aliveNow; +} + +void handleConsoleButtonBits(byte newButtonBits, unsigned long currentTime) { + consolePreviousButtonBits = consoleButtonBits; + consoleButtonBits = newButtonBits; + + for (byte channel = 0; channel < CONSOLE_CHANNEL_COUNT; channel++) { + bool wasPressed = (consolePreviousButtonBits & (1 << channel)) != 0; + bool isPressed = (consoleButtonBits & (1 << channel)) != 0; + if (wasPressed && !isPressed) { + consoleManualLearnPending[channel] = true; + consoleManualLearnTime[channel] = currentTime + CONSOLE_MANUAL_LEARN_SETTLE_MS; + } + } +} + +void updateConsoleManualLearn(unsigned long currentTime) { + for (byte channel = 0; channel < CONSOLE_CHANNEL_COUNT; channel++) { + if (!consoleManualLearnPending[channel]) { + continue; + } + + if ((long)(currentTime - consoleManualLearnTime[channel]) < 0) { + continue; + } + + consoleManualLearnPending[channel] = false; + learnConsoleManualChannel(channel); + } +} + +void learnConsoleManualChannel(byte channel) { + if (channel <= CH_PASSENGER_HEAT) { + setConsoleClimateTarget(channel, getConsoleClimateActual(channel)); + return; + } + + byte toggleIndex = channel - CH_STEERING_HEAT; + if (toggleIndex < CONSOLE_TOGGLE_COUNT) { + setConsoleToggleTarget(toggleIndex, getConsoleToggleActual(toggleIndex)); + } +} + +void updateConsoleAutomation(unsigned long currentTime) { + bool ignitionOn = ignitionState == HIGH; + if (ignitionOn && !previousConsoleIgnitionOn) { + consoleIgnitionApplyPending = true; + } + previousConsoleIgnitionOn = ignitionOn; + + if (!consoleAlive || !consoleReadyForNewSequence()) { + return; + } + + if (!consoleStartupApplyDone && currentTime >= CONSOLE_STARTUP_DELAY_MS) { + applyConsoleClimateTargets(true); + if (ignitionOn) { + applyConsoleIgnitionTargets(); + consoleIgnitionApplyPending = false; + } + consoleStartupApplyDone = true; + lastConsoleClimateRefreshTime = currentTime; + lastConsoleSteeringRestartTime = currentTime; + return; + } + + if (ignitionOn && consoleIgnitionApplyPending) { + applyConsoleIgnitionTargets(); + consoleIgnitionApplyPending = false; + lastConsoleSteeringRestartTime = currentTime; + previousConsoleIgnitionOn = ignitionOn; + return; + } + + if (currentTime - lastConsoleClimateRefreshTime >= CONSOLE_MEMORY_REFRESH_INTERVAL_MS) { + applyConsoleClimateTargets(true); + if (ignitionOn) { + applyConsoleIgnitionTargets(); + consoleIgnitionApplyPending = false; + } + lastConsoleClimateRefreshTime = currentTime; + return; + } + + if (ignitionOn && + consoleToggleTarget[TG_STEERING_HEAT] && + currentTime - lastConsoleSteeringRestartTime >= CONSOLE_STEERING_RESTART_INTERVAL_MS) { + restartConsoleSteeringHeat(); + lastConsoleSteeringRestartTime = currentTime; + } +} + +bool consoleReadyForNewSequence() { + return !consoleBusy && + !consoleCommandInFlight && + consolePulseQueueHead == consolePulseQueueTail; +} + +void applyConsoleClimateTargets(bool force) { + for (byte climate = 0; climate < CONSOLE_CLIMATE_COUNT; climate++) { + ensureConsoleClimateTarget(climate, force); + } +} + +void applyConsoleIgnitionTargets() { + for (byte toggle = 0; toggle < CONSOLE_TOGGLE_COUNT; toggle++) { + ensureConsoleToggleTarget(toggle); + } +} + +void ensureConsoleClimateTarget(byte climate, bool force) { + byte target = consoleClimateTarget[climate]; + byte actual = getConsoleClimateActual(climate); + + if (target == 0) { + if (actual != 0) { + enqueueConsolePulse(climate, CONSOLE_CLIMATE_LONG_OFF_MS, CONSOLE_CLIMATE_LONG_OFF_GAP_MS); + } + return; + } + + if (!force && actual == target) { + return; + } + + enqueueConsoleClimateSet(climate, target); +} + +void ensureConsoleToggleTarget(byte toggle) { + if (getConsoleToggleActual(toggle) == consoleToggleTarget[toggle]) { + return; + } + + enqueueConsolePulse(toggle + CH_STEERING_HEAT, + CONSOLE_SHORT_PRESS_MS, + CONSOLE_SHORT_PRESS_GAP_MS); +} + +void restartConsoleSteeringHeat() { + if (getConsoleToggleActual(TG_STEERING_HEAT)) { + enqueueConsolePulse(CH_STEERING_HEAT, + CONSOLE_SHORT_PRESS_MS, + CONSOLE_STEERING_RESTART_GAP_MS); + enqueueConsolePulse(CH_STEERING_HEAT, + CONSOLE_SHORT_PRESS_MS, + CONSOLE_SHORT_PRESS_GAP_MS); + } else { + enqueueConsolePulse(CH_STEERING_HEAT, + CONSOLE_SHORT_PRESS_MS, + CONSOLE_SHORT_PRESS_GAP_MS); + } +} + +void enqueueConsoleClimateSet(byte climate, byte targetLevel) { + enqueueConsolePulse(climate, CONSOLE_CLIMATE_LONG_OFF_MS, CONSOLE_CLIMATE_LONG_OFF_GAP_MS); + + byte shortPresses = getConsoleShortPressCount(targetLevel); + for (byte i = 0; i < shortPresses; i++) { + enqueueConsolePulse(climate, CONSOLE_SHORT_PRESS_MS, CONSOLE_SHORT_PRESS_GAP_MS); + } +} + +byte getConsoleShortPressCount(byte level) { + if (level == 3) { + return 1; + } + if (level == 2) { + return 2; + } + if (level == 1) { + return 3; + } + return 0; +} + +byte getConsoleClimateActual(byte climate) { + return (consoleActualClimatePack >> (climate * 2)) & 0x03; +} + +bool getConsoleToggleActual(byte toggle) { + return (consoleActualToggleBits & (1 << toggle)) != 0; +} + +void setConsoleClimateTarget(byte climate, byte level) { + if (level > 3) { + level = 3; + } + + bool changed = false; + if (consoleClimateTarget[climate] != level) { + consoleClimateTarget[climate] = level; + changed = true; + } + + if (level > 0) { + byte opposite = CONSOLE_OPPOSITE_CLIMATE[climate]; + if (consoleClimateTarget[opposite] != 0) { + consoleClimateTarget[opposite] = 0; + changed = true; + } + } + + if (changed) { + saveConsoleSettings(); + } +} + +void setConsoleToggleTarget(byte toggle, bool enabled) { + if (consoleToggleTarget[toggle] == enabled) { + return; + } + + consoleToggleTarget[toggle] = enabled; + saveConsoleSettings(); +} + +void updateConsoleCommandSender(unsigned long currentTime) { + if (!chaegeonCanReady || !consoleAlive || consoleBusy) { + return; + } + + if (consoleCommandInFlight) { + if (currentTime - lastConsoleCommandTime >= CONSOLE_COMMAND_HOLD_MS) { + consoleCommandInFlight = false; + } + return; + } + + if (consolePulseQueueHead == consolePulseQueueTail) { + return; + } + + byte channel = consolePulseQueueChannel[consolePulseQueueHead]; + unsigned int onMs = consolePulseQueueOnMs[consolePulseQueueHead]; + unsigned int gapMs = consolePulseQueueGapMs[consolePulseQueueHead]; + + byte seq = nextConsoleCommandSeq(); + byte payload[8] = { + 1, + 1, + channel, + (byte)(onMs & 0xFF), + (byte)(onMs >> 8), + (byte)(gapMs & 0xFF), + (byte)(gapMs >> 8), + seq + }; + + if (chaegeonCan.sendMsgBuf(CAN_ID_CONSOLE_COMMAND, 0, 8, payload) == CAN_OK) { + consolePulseQueueHead = nextConsoleQueueIndex(consolePulseQueueHead); + consoleCommandInFlight = true; + consoleCommandInFlightSeq = seq; + lastConsoleCommandTime = currentTime; + } +} + +byte nextConsoleCommandSeq() { + consoleCommandSeq++; + if (consoleCommandSeq == 0) { + consoleCommandSeq = 1; + } + return consoleCommandSeq; +} + +void enqueueConsolePulse(byte channel, unsigned int onMs, unsigned int gapMs) { + byte nextTail = nextConsoleQueueIndex(consolePulseQueueTail); + if (nextTail == consolePulseQueueHead || channel >= CONSOLE_CHANNEL_COUNT) { + return; + } + + consolePulseQueueChannel[consolePulseQueueTail] = channel; + consolePulseQueueOnMs[consolePulseQueueTail] = onMs; + consolePulseQueueGapMs[consolePulseQueueTail] = gapMs; + consolePulseQueueTail = nextTail; +} + +void clearConsolePulseQueue() { + consolePulseQueueHead = consolePulseQueueTail; +} + +byte consolePulseQueueLength() { + if (consolePulseQueueTail >= consolePulseQueueHead) { + return consolePulseQueueTail - consolePulseQueueHead; + } + return CONSOLE_PULSE_QUEUE_SIZE - consolePulseQueueHead + consolePulseQueueTail; +} + +byte nextConsoleQueueIndex(byte index) { + index++; + if (index >= CONSOLE_PULSE_QUEUE_SIZE) { + index = 0; + } + return index; +} + +void loadConsoleSettings() { + if (EEPROM.read(EEPROM_CONSOLE_MAGIC_ADDR) != EEPROM_CONSOLE_MAGIC_VALUE) { + EEPROM.update(EEPROM_CONSOLE_MAGIC_ADDR, EEPROM_CONSOLE_MAGIC_VALUE); + for (byte i = 0; i < CONSOLE_CLIMATE_COUNT; i++) { + EEPROM.update(EEPROM_CONSOLE_CLIMATE_TARGET_ADDR + i, 0); + } + for (byte i = 0; i < CONSOLE_TOGGLE_COUNT; i++) { + EEPROM.update(EEPROM_CONSOLE_TOGGLE_TARGET_ADDR + i, 0); + } + } + + for (byte i = 0; i < CONSOLE_CLIMATE_COUNT; i++) { + byte value = EEPROM.read(EEPROM_CONSOLE_CLIMATE_TARGET_ADDR + i); + consoleClimateTarget[i] = value <= 3 ? value : 0; + } + for (byte i = 0; i < CONSOLE_TOGGLE_COUNT; i++) { + consoleToggleTarget[i] = EEPROM.read(EEPROM_CONSOLE_TOGGLE_TARGET_ADDR + i) != 0; + } + + if (consoleClimateTarget[CH_DRIVER_VENT] > 0 && + consoleClimateTarget[CH_DRIVER_HEAT] > 0) { + consoleClimateTarget[CH_DRIVER_HEAT] = 0; + } + if (consoleClimateTarget[CH_PASSENGER_VENT] > 0 && + consoleClimateTarget[CH_PASSENGER_HEAT] > 0) { + consoleClimateTarget[CH_PASSENGER_HEAT] = 0; + } + saveConsoleSettings(); +} + +void saveConsoleSettings() { + for (byte i = 0; i < CONSOLE_CLIMATE_COUNT; i++) { + EEPROM.update(EEPROM_CONSOLE_CLIMATE_TARGET_ADDR + i, consoleClimateTarget[i]); + } + for (byte i = 0; i < CONSOLE_TOGGLE_COUNT; i++) { + EEPROM.update(EEPROM_CONSOLE_TOGGLE_TARGET_ADDR + i, consoleToggleTarget[i] ? 1 : 0); + } +} + +void setupChaegeonCan() { + if (chaegeonCan.begin(MCP_ANY, CAN_125KBPS, MCP_8MHZ) == CAN_OK) { + chaegeonCan.setMode(MCP_NORMAL); + chaegeonCanReady = true; + } +} + +void updateChaegeonCanHeartbeat(unsigned long currentTime) { + if (!chaegeonCanReady) { + return; + } + + if (currentTime - lastCanHeartbeatTime < CAN_HEARTBEAT_INTERVAL_MS) { + return; + } + lastCanHeartbeatTime = currentTime; + + byte flags = 0; + if (ignitionState == HIGH) { + flags |= 0x01; + } + if (parkingState == HIGH) { + flags |= 0x02; + } + if (getRawDriveMode() == HIGH) { + flags |= 0x04; + } + if (confirmedDriveMode == HIGH) { + flags |= 0x08; + } + if (isStabilizing) { + flags |= 0x10; + } + if (currentSeatAction != 0 || requestedSeatAction != 0) { + flags |= 0x20; + } + if (autoCalibrationPending || autoCalibrationState != CALIBRATION_IDLE) { + flags |= 0x40; + } + + uint16_t raw = (uint16_t)parkingRawValue; + uint16_t uptimeSeconds = (uint16_t)(currentTime / 1000UL); + byte payload[8] = { + 1, + flags, + chaegeonCanSeq++, + useCount, + (byte)(raw & 0xFF), + (byte)(raw >> 8), + (byte)(uptimeSeconds & 0xFF), + (byte)(uptimeSeconds >> 8) + }; + + chaegeonCan.sendMsgBuf(CAN_ID_MAIN_HEARTBEAT, 0, 8, payload); +} + void setRelay(int relayPin, boolean state) { digitalWrite(relayPin, state ? LOW : HIGH); } diff --git a/Console_Node/Console_Node.ino b/Console_Node/Console_Node.ino new file mode 100644 index 0000000..f3baacf --- /dev/null +++ b/Console_Node/Console_Node.ino @@ -0,0 +1,514 @@ +// +// Console_Node +// Chaegeon-CAN center-console I/O node for Arduino Mega. +// +#include +#include + +// ============================================================ +// CAN +// ============================================================ +const byte CAN_CS_PIN = 53; +const byte CAN_INT_PIN = 2; +const unsigned long CAN_ID_MAIN_HEARTBEAT = 0x100UL; +const unsigned long CAN_ID_CONSOLE_COMMAND = 0x101UL; +const unsigned long CAN_ID_CONSOLE_STATUS = 0x200UL; +const unsigned long MAIN_HEARTBEAT_TIMEOUT_MS = 2000UL; +const unsigned long STATUS_INTERVAL_MS = 100UL; + +// ============================================================ +// I/O +// ============================================================ +const byte CHANNEL_COUNT = 7; +const byte CH_DRIVER_VENT = 0; +const byte CH_DRIVER_HEAT = 1; +const byte CH_PASSENGER_VENT = 2; +const byte CH_PASSENGER_HEAT = 3; +const byte CH_STEERING_HEAT = 4; +const byte CH_FRONT_SENSOR = 5; +const byte CH_VESS = 6; + +// YYNMOS-8 style low-side MOSFET board. Change these two if the board is inverted. +const byte OUTPUT_ACTIVE_LEVEL = HIGH; +const byte OUTPUT_INACTIVE_LEVEL = LOW; +const byte OUTPUT_PINS[CHANNEL_COUNT] = { + 22, 23, 24, 25, 26, 27, 28 +}; + +// Button press detect inputs. PC817 output modules are commonly active-low. +const bool BUTTON_USE_INTERNAL_PULLUP = true; +const byte BUTTON_INPUT_PINS[CHANNEL_COUNT] = { + 30, 31, 32, 33, 34, 35, 36 +}; +const bool BUTTON_ACTIVE_LOW[CHANNEL_COUNT] = { + true, true, true, true, true, true, true +}; +const unsigned long BUTTON_DEBOUNCE_MS = 40UL; +const unsigned long OWN_PRESS_MASK_MS = 1200UL; + +// STATE_LED analog inputs. +// 0..2 driver vent high/mid/low +// 3..5 driver heat high/mid/low +// 6..8 passenger vent high/mid/low +// 9..11 passenger heat high/mid/low +// 12 steering heat +// 13 front sensor +// 14 VESS +const byte LED_COUNT = 15; +const byte LED_PINS[LED_COUNT] = { + A0, A1, A2, A3, A4, A5, A6, A7, + A8, A9, A10, A11, A12, A13, A14 +}; + +// 5V ADC reference: 1.8V ~= 368, 3.3V ~= 675. +// Tune each channel after raw measurements. For inverted PC817 outputs, set false. +const int LED_ON_THRESHOLD[LED_COUNT] = { + 300, 300, 300, 300, 300, + 300, 300, 300, 300, 300, + 300, 300, 300, 300, 300 +}; +const bool LED_ON_WHEN_ABOVE[LED_COUNT] = { + true, true, true, true, true, + true, true, true, true, true, + true, true, true, true, true +}; + +const byte CLIMATE_COUNT = 4; +const byte TOGGLE_COUNT = 3; +const byte CLIMATE_LED_BASE[CLIMATE_COUNT] = { + 0, 3, 6, 9 +}; +const byte TOGGLE_LED_INDEX[TOGGLE_COUNT] = { + 12, 13, 14 +}; + +// ============================================================ +// Pulse execution +// ============================================================ +const byte PULSE_QUEUE_SIZE = 16; +byte pulseQueueChannel[PULSE_QUEUE_SIZE] = {0}; +unsigned int pulseQueueOnMs[PULSE_QUEUE_SIZE] = {0}; +unsigned int pulseQueueGapMs[PULSE_QUEUE_SIZE] = {0}; +byte pulseQueueHead = 0; +byte pulseQueueTail = 0; + +bool pulseOnPhase = false; +bool pulseGapPhase = false; +byte activePulseChannel = CHANNEL_COUNT; +unsigned int activePulseOnMs = 0; +unsigned int activePulseGapMs = 0; +unsigned long pulsePhaseStartTime = 0; +unsigned long buttonMaskUntil[CHANNEL_COUNT] = {0}; + +// ============================================================ +// State +// ============================================================ +MCP_CAN consoleCan(CAN_CS_PIN); + +bool canReady = false; +bool mainAlive = false; +bool mainIgnitionOn = false; +bool mainParkingOn = false; +byte mainHeartbeatSeq = 0; +byte lastAcceptedCommandSeq = 0; +bool commandSeqSeen = false; +bool pulseQueueOverflow = false; +unsigned long lastMainHeartbeatTime = 0; +unsigned long lastStatusTime = 0; + +bool lastButtonRaw[CHANNEL_COUNT] = {false}; +bool buttonStable[CHANNEL_COUNT] = {false}; +unsigned long buttonChangeTime[CHANNEL_COUNT] = {0}; + +void setup() { + Serial.begin(115200); + setupPins(); + setupCan(); + Serial.println(F("Console_Node I/O ready. Send h for help.")); +} + +void loop() { + unsigned long currentTime = millis(); + + updateCanReceive(currentTime); + updateMainAlive(currentTime); + updateButtonInputs(currentTime); + updatePulseOutput(currentTime); + updateStatusSend(currentTime); + handleSerial(); +} + +void setupPins() { + for (byte channel = 0; channel < CHANNEL_COUNT; channel++) { + pinMode(OUTPUT_PINS[channel], OUTPUT); + digitalWrite(OUTPUT_PINS[channel], OUTPUT_INACTIVE_LEVEL); + } + + for (byte channel = 0; channel < CHANNEL_COUNT; channel++) { + pinMode(BUTTON_INPUT_PINS[channel], BUTTON_USE_INTERNAL_PULLUP ? INPUT_PULLUP : INPUT); + bool pressed = readButtonPressedRaw(channel); + lastButtonRaw[channel] = pressed; + buttonStable[channel] = pressed; + buttonChangeTime[channel] = millis(); + } + + for (byte led = 0; led < LED_COUNT; led++) { + pinMode(LED_PINS[led], INPUT); + } +} + +void setupCan() { + pinMode(CAN_INT_PIN, INPUT); + if (consoleCan.begin(MCP_ANY, CAN_125KBPS, MCP_8MHZ) == CAN_OK) { + consoleCan.setMode(MCP_NORMAL); + canReady = true; + } +} + +void updateCanReceive(unsigned long currentTime) { + if (!canReady) { + return; + } + + while (consoleCan.checkReceive() == CAN_MSGAVAIL) { + unsigned long rxId = 0; + byte len = 0; + byte payload[8] = {0}; + if (consoleCan.readMsgBuf(&rxId, &len, payload) != CAN_OK) { + return; + } + + if (rxId == CAN_ID_MAIN_HEARTBEAT && len >= 3 && payload[0] == 1) { + byte flags = payload[1]; + mainIgnitionOn = (flags & 0x01) != 0; + mainParkingOn = (flags & 0x02) != 0; + mainHeartbeatSeq = payload[2]; + mainAlive = true; + lastMainHeartbeatTime = currentTime; + } else if (rxId == CAN_ID_CONSOLE_COMMAND && len == 8 && payload[0] == 1) { + processCommand(payload, currentTime); + } + } +} + +void processCommand(byte payload[8], unsigned long currentTime) { + byte command = payload[1]; + byte seq = payload[7]; + + if (commandSeqSeen && seq == lastAcceptedCommandSeq) { + return; + } + + if (command == 1) { + byte channel = payload[2]; + unsigned int onMs = (unsigned int)payload[3] | ((unsigned int)payload[4] << 8); + unsigned int gapMs = (unsigned int)payload[5] | ((unsigned int)payload[6] << 8); + if (enqueuePulse(channel, onMs, gapMs)) { + lastAcceptedCommandSeq = seq; + commandSeqSeen = true; + } + } else if (command == 2) { + clearPulseQueue(); + allOutputsOff(); + lastAcceptedCommandSeq = seq; + commandSeqSeen = true; + } + + (void)currentTime; +} + +void updateMainAlive(unsigned long currentTime) { + bool aliveNow = lastMainHeartbeatTime != 0 && + currentTime - lastMainHeartbeatTime <= MAIN_HEARTBEAT_TIMEOUT_MS; + if (!aliveNow && mainAlive) { + clearPulseQueue(); + allOutputsOff(); + } + + mainAlive = aliveNow; +} + +void updateStatusSend(unsigned long currentTime) { + if (!canReady || currentTime - lastStatusTime < STATUS_INTERVAL_MS) { + return; + } + lastStatusTime = currentTime; + + byte flags = 0; + if (mainAlive) { + flags |= 0x01; + } + if (isPulseBusy()) { + flags |= 0x02; + } + if (pulseQueueOverflow) { + flags |= 0x04; + } + if (mainIgnitionOn) { + flags |= 0x08; + } + if (mainParkingOn) { + flags |= 0x10; + } + + byte payload[8] = { + 1, + flags, + packClimateLevels(), + packToggleStates(), + packButtonStates(), + lastAcceptedCommandSeq, + queueLength(), + mainHeartbeatSeq + }; + + consoleCan.sendMsgBuf(CAN_ID_CONSOLE_STATUS, 0, 8, payload); +} + +byte packClimateLevels() { + byte packed = 0; + for (byte climate = 0; climate < CLIMATE_COUNT; climate++) { + packed |= (readClimateLevel(climate) & 0x03) << (climate * 2); + } + return packed; +} + +byte packToggleStates() { + byte packed = 0; + for (byte toggle = 0; toggle < TOGGLE_COUNT; toggle++) { + if (readLedOn(TOGGLE_LED_INDEX[toggle])) { + packed |= (1 << toggle); + } + } + return packed; +} + +byte packButtonStates() { + byte packed = 0; + unsigned long currentTime = millis(); + for (byte channel = 0; channel < CHANNEL_COUNT; channel++) { + if (isButtonMasked(channel, currentTime)) { + continue; + } + if (buttonStable[channel]) { + packed |= (1 << channel); + } + } + return packed; +} + +void updateButtonInputs(unsigned long currentTime) { + for (byte channel = 0; channel < CHANNEL_COUNT; channel++) { + bool rawPressed = readButtonPressedRaw(channel); + if (rawPressed != lastButtonRaw[channel]) { + lastButtonRaw[channel] = rawPressed; + buttonChangeTime[channel] = currentTime; + } + + if (currentTime - buttonChangeTime[channel] >= BUTTON_DEBOUNCE_MS) { + buttonStable[channel] = rawPressed; + } + } +} + +bool readButtonPressedRaw(byte channel) { + bool high = digitalRead(BUTTON_INPUT_PINS[channel]) == HIGH; + if (BUTTON_ACTIVE_LOW[channel]) { + return !high; + } + return high; +} + +bool isButtonMasked(byte channel, unsigned long currentTime) { + return (long)(buttonMaskUntil[channel] - currentTime) > 0; +} + +byte readClimateLevel(byte climate) { + byte base = CLIMATE_LED_BASE[climate]; + if (readLedOn(base)) { + return 3; + } + if (readLedOn(base + 1)) { + return 2; + } + if (readLedOn(base + 2)) { + return 1; + } + return 0; +} + +bool readLedOn(byte ledIndex) { + int raw = analogRead(LED_PINS[ledIndex]); + bool above = raw >= LED_ON_THRESHOLD[ledIndex]; + return LED_ON_WHEN_ABOVE[ledIndex] ? above : !above; +} + +int readLedRaw(byte ledIndex) { + return analogRead(LED_PINS[ledIndex]); +} + +bool enqueuePulse(byte channel, unsigned int onMs, unsigned int gapMs) { + byte nextTail = nextQueueIndex(pulseQueueTail); + if (channel >= CHANNEL_COUNT || nextTail == pulseQueueHead) { + pulseQueueOverflow = true; + return false; + } + + pulseQueueChannel[pulseQueueTail] = channel; + pulseQueueOnMs[pulseQueueTail] = onMs; + pulseQueueGapMs[pulseQueueTail] = gapMs; + pulseQueueTail = nextTail; + + unsigned long maskUntil = millis() + onMs + gapMs + OWN_PRESS_MASK_MS; + if ((long)(maskUntil - buttonMaskUntil[channel]) > 0) { + buttonMaskUntil[channel] = maskUntil; + } + + return true; +} + +void updatePulseOutput(unsigned long currentTime) { + if (!pulseOnPhase && !pulseGapPhase) { + startNextPulse(currentTime); + return; + } + + if (pulseOnPhase && currentTime - pulsePhaseStartTime >= activePulseOnMs) { + setOutput(activePulseChannel, false); + pulseOnPhase = false; + pulseGapPhase = true; + pulsePhaseStartTime = currentTime; + return; + } + + if (pulseGapPhase && currentTime - pulsePhaseStartTime >= activePulseGapMs) { + pulseGapPhase = false; + activePulseChannel = CHANNEL_COUNT; + startNextPulse(currentTime); + } +} + +void startNextPulse(unsigned long currentTime) { + if (pulseQueueHead == pulseQueueTail) { + return; + } + + activePulseChannel = pulseQueueChannel[pulseQueueHead]; + activePulseOnMs = pulseQueueOnMs[pulseQueueHead]; + activePulseGapMs = pulseQueueGapMs[pulseQueueHead]; + pulseQueueHead = nextQueueIndex(pulseQueueHead); + + setOutput(activePulseChannel, true); + pulseOnPhase = true; + pulsePhaseStartTime = currentTime; +} + +void setOutput(byte channel, bool active) { + if (channel >= CHANNEL_COUNT) { + return; + } + digitalWrite(OUTPUT_PINS[channel], active ? OUTPUT_ACTIVE_LEVEL : OUTPUT_INACTIVE_LEVEL); +} + +void allOutputsOff() { + for (byte channel = 0; channel < CHANNEL_COUNT; channel++) { + setOutput(channel, false); + } + pulseOnPhase = false; + pulseGapPhase = false; + activePulseChannel = CHANNEL_COUNT; +} + +void clearPulseQueue() { + pulseQueueHead = pulseQueueTail; +} + +bool isPulseBusy() { + return pulseOnPhase || pulseGapPhase || pulseQueueHead != pulseQueueTail; +} + +byte queueLength() { + if (pulseQueueTail >= pulseQueueHead) { + return pulseQueueTail - pulseQueueHead; + } + return PULSE_QUEUE_SIZE - pulseQueueHead + pulseQueueTail; +} + +byte nextQueueIndex(byte index) { + index++; + if (index >= PULSE_QUEUE_SIZE) { + index = 0; + } + return index; +} + +void handleSerial() { + while (Serial.available()) { + char command = Serial.read(); + if (command == '\r' || command == '\n') { + continue; + } + + if (command == 'h') { + printHelp(); + } else if (command == 's') { + printStatus(); + } else if (command == 'x') { + clearPulseQueue(); + allOutputsOff(); + Serial.println(F("ACK: outputs off")); + } else { + Serial.println(F("ERR: h/s/x only")); + } + } +} + +void printHelp() { + Serial.println(); + Serial.println(F("+------------------------------------------------------+")); + Serial.println(F("| Console_Node I/O |")); + Serial.println(F("+------------------------------------------------------+")); + Serial.println(F("| h = help |")); + Serial.println(F("| s = status and raw LED values |")); + Serial.println(F("| x = stop outputs and clear pulse queue |")); + Serial.println(F("| Memory and automation live in Auto_Seat Nano. |")); + Serial.println(F("+------------------------------------------------------+")); +} + +void printStatus() { + Serial.println(); + Serial.println(F("[CONSOLE_IO_STATUS]")); + printNamedBool(F("canReady"), canReady); + printNamedBool(F("mainAlive"), mainAlive); + printNamedBool(F("mainIgnitionOn"), mainIgnitionOn); + printNamedBool(F("mainParkingOn"), mainParkingOn); + printNamedBool(F("pulseBusy"), isPulseBusy()); + printNamedBool(F("pulseQueueOverflow"), pulseQueueOverflow); + printNamedInt(F("queueLength"), queueLength()); + printNamedInt(F("lastAcceptedCommandSeq"), lastAcceptedCommandSeq); + printNamedInt(F("driverVentLevel"), readClimateLevel(CH_DRIVER_VENT)); + printNamedInt(F("driverHeatLevel"), readClimateLevel(CH_DRIVER_HEAT)); + printNamedInt(F("passengerVentLevel"), readClimateLevel(CH_PASSENGER_VENT)); + printNamedInt(F("passengerHeatLevel"), readClimateLevel(CH_PASSENGER_HEAT)); + printNamedBool(F("steeringHeatOn"), readLedOn(12)); + printNamedBool(F("frontSensorOn"), readLedOn(13)); + printNamedBool(F("vessOn"), readLedOn(14)); + for (byte led = 0; led < LED_COUNT; led++) { + Serial.print(F("ledRaw")); + Serial.print(led); + Serial.print(F("=")); + Serial.println(readLedRaw(led)); + } +} + +void printNamedBool(const __FlashStringHelper *name, bool value) { + Serial.print(name); + Serial.print(F("=")); + Serial.println(value ? F("true") : F("false")); +} + +void printNamedInt(const __FlashStringHelper *name, long value) { + Serial.print(name); + Serial.print(F("=")); + Serial.println(value); +} diff --git a/README.md b/README.md index 9cf9587..de115bd 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,117 @@ -# 차량 승하차 편의를 위한 자동 시트 조절기 +# Auto Seat -Arduino Nano와 2채널 릴레이를 이용해 순정 이지억세스가 없는 차량에 자동 시트 이동 기능을 붙이는 개인 프로젝트입니다. +차량 운전석 자동 시트 위치 제어와 센터콘솔 메모리 기능을 관리하는 Arduino 프로젝트입니다. -시동 상태와 P단 신호를 읽어 주행 상태가 되면 시트를 저장된 주행 위치까지 올리고, P단 또는 하차 상태가 되면 시트를 아래 위치까지 내립니다. 실제 위치 센서는 없으며, 이동 시간을 기준으로 현재 위치를 추정합니다. +운전석 아래의 Arduino Nano가 메인 컨트롤러입니다. 기존 시트 상승/하강 제어, EEPROM 저장값, 시리얼 콘솔, IG1/P단 판단을 담당합니다. 센터콘솔 쪽 Arduino Mega는 단순 I/O 노드로 동작하며, CAN 명령을 받아 MOSFET 출력으로 버튼을 누르고 버튼/STATE_LED 상태를 Nano에 보고합니다. -## 하드웨어 +## 목적 + +- P단이 아닌 주행 상태가 확인되면 시트를 EEPROM에 저장된 주행 위치까지 상승시킵니다. +- P단 또는 비주행 상태가 확인되면 시트를 하단 `0ms` 위치까지 하강시킵니다. +- 실제 위치 센서 없이 릴레이 구동 시간으로 현재 위치를 추정합니다. +- 운전석/조수석 열선과 통풍, 핸들 열선, 전방센서, VESS 보조 기능을 센터콘솔 노드로 제어합니다. +- 모든 메모리와 판단은 Nano가 담당하고, Mega는 신호 감지와 출력 수행만 담당합니다. + +## 저장소 구성 + +```text +Auto_Seat.ino +Console_Node/Console_Node.ino +upload.ps1 +report.html +README.md +``` + +- `Auto_Seat.ino`: 운전석 아래 Nano용 메인 스케치입니다. +- `Console_Node/Console_Node.ino`: 센터콘솔 Mega용 I/O 노드 스케치입니다. +- `upload.ps1`: Nano 업로드용 PowerShell 스크립트입니다. +- `report.html`: 현재 동작 시뮬레이션과 운영 보고서입니다. + +## 메인 Nano 하드웨어 - MCU: Arduino Nano ATmega328P -- 릴레이: 2채널 Active-Low 릴레이 모듈 -- 상승 릴레이: `D8`, `LOW = ON` -- 하강 릴레이: `D9`, `LOW = ON` +- 상승 릴레이: `D8`, active-low, `LOW = ON` +- 하강 릴레이: `D9`, active-low, `LOW = ON` - P단 입력: `A0`, analog raw `0..1023` - IG1 입력: `D7`, `HIGH = IG1 ON`, `LOW = IG1 OFF` +- CAN 모듈: MCP2515 + TJA1050, 8MHz crystal +- CAN CS: `D10` +- CAN SPI: `D11 MOSI`, `D12 MISO`, `D13 SCK` -## 기본 동작 +Nano의 기존 시트 제어 핀과 MCP2515 SPI 핀은 충돌하지 않습니다. -- `IG1 HIGH`이고 P단 신호가 꺼져 있으면 80ms 확인 뒤 주행 모드로 봅니다. -- 주행 모드에서는 EEPROM에 저장된 주행 위치까지 시트를 올립니다. -- P단 또는 비주행 모드가 250ms 동안 유지되면 시트를 `0ms` 위치까지 내립니다. -- 하강 속도는 상승보다 약 `1.1배` 빠른 것으로 계산합니다. -- 추정 위치가 `0ms`에 도달하면 하단 끝단 보정을 위해 하강 릴레이를 300ms 더 유지합니다. -- IG1이 켜진 직후에는 신호 튐을 피하기 위해 1000ms 동안 릴레이 출력을 끕니다. +## 센터콘솔 Mega 하드웨어 -## 위치 저장 +- MCU: Arduino Mega +- CAN 모듈: MCP2515 + TJA1050, 8MHz crystal +- CAN CS: `D53` +- CAN INT: `D2` +- CAN SPI: `D50 MISO`, `D51 MOSI`, `D52 SCK` +- MOSFET 출력 7채널: `D22..D28` +- 버튼 눌림 감지 7채널: `D30..D36` +- STATE_LED analog 입력 15채널: `A0..A14` -- 시트 주행 위치는 EEPROM에 저장합니다. -- 허용 범위는 `0..6000ms`입니다. -- EEPROM magic 값이 없거나 저장값이 유효하지 않으면 기본 주행 위치를 `6000ms`로 둡니다. -- serial 명령 `p`로 현재 추정 위치를 EEPROM에 저장합니다. -- 보정 명령 `c`는 현재 위치를 다시 맞출 뿐, 저장된 주행 위치를 바꾸지 않습니다. +MOSFET 출력은 YYNMOS-8 같은 low-side 보드를 기준으로 합니다. 현재 설정은 `HIGH = ON`, `LOW = OFF`입니다. 보드가 반전형이면 `OUTPUT_ACTIVE_LEVEL`, `OUTPUT_INACTIVE_LEVEL`만 바꾸면 됩니다. + +PC817 출력 모듈은 보통 active-low입니다. 버튼 감지 쪽은 기본값이 `INPUT_PULLUP`, `BUTTON_ACTIVE_LOW = true`입니다. 실제 모듈 출력이 반대면 배열 값만 바꿉니다. + +## 센터콘솔 채널 + +```text +0 = 운전석 통풍 +1 = 운전석 열선 +2 = 조수석 통풍 +3 = 조수석 열선 +4 = 핸들 열선 +5 = 전방센서 +6 = VESS +``` + +열선/통풍 4채널은 단계형입니다. + +```text +0 -> 3 -> 2 -> 1 -> 0 +``` + +일반 클릭은 `300ms`입니다. 열선/통풍 OFF 기준화는 해당 버튼을 `2100ms` 길게 눌러 처리합니다. + +## STATE_LED 입력 + +Mega는 STATE_LED 15개를 analogRead로 읽고, 단계와 ON/OFF 상태만 Nano에 보고합니다. + +```text +A0..A2 운전석 통풍 High/Mid/Low +A3..A5 운전석 열선 High/Mid/Low +A6..A8 조수석 통풍 High/Mid/Low +A9..A11 조수석 열선 High/Mid/Low +A12 핸들 열선 +A13 전방센서 +A14 VESS +``` + +기본 임계값은 전 채널 `300`입니다. 5V ADC 기준으로 1.8V는 약 `368`, 3.3V는 약 `675`입니다. 실제 배선 후 Mega 시리얼의 `s` 명령으로 `ledRaw0..ledRaw14` 값을 확인하고 `LED_ON_THRESHOLD`를 조정합니다. + +PC817 출력처럼 LED ON일 때 analog 값이 낮아지는 채널은 `LED_ON_WHEN_ABOVE`를 `false`로 바꿉니다. + +## 시트 동작 로직 + +- `IG1 HIGH`이고 P단 신호가 꺼져 있으면 주행 후보 상태로 봅니다. +- 주행 후보가 `80ms` 유지되면 주행 모드로 확정합니다. +- P단 또는 비주행 후보가 `250ms` 유지되면 비주행 모드로 확정합니다. +- 시동 직후 `1000ms` 동안은 IG1/P 신호 흔들림을 무시하고 릴레이를 정지합니다. +- 주행 모드에서는 EEPROM에 저장된 시트 위치로 상승합니다. +- 비주행 모드에서는 `0ms` 위치로 하강합니다. +- 하강 속도는 상승보다 `1.1배` 빠른 것으로 계산합니다. +- `0ms` 도달 시 하단 밀착을 위해 하강 릴레이를 추가 `300ms` 유지합니다. +- 릴레이 방향 전환 사이에는 `120ms` dead time을 둡니다. + +시트 위치는 실제 센서값이 아니라 시간 기반 추정값입니다. 전원 차단 후 다시 켜지면 기본적으로 하단 `0ms`에서 시작한다고 봅니다. ## USB Serial Reset 보호 -Arduino Nano는 USB serial 연결 때 DTR 때문에 리셋될 수 있습니다. +Arduino Nano는 USB 시리얼 연결 시 DTR 때문에 리셋될 수 있습니다. -보드가 부팅되는 순간 이미 `IG1 HIGH`이고 P단이 아니라면, 차가 주행 위치에 있다고 보고 현재 위치와 목표 위치를 EEPROM 저장 위치로 맞춥니다. +보드가 부팅된 순간 `IG1 HIGH`이고 P단이 아니면, 이미 주행 중 USB 재연결이 발생한 것으로 보고 현재 위치와 목표 위치를 EEPROM 저장 주행 위치로 맞춥니다. ```text currentPositionMs = savedPositionMs @@ -42,18 +119,154 @@ targetPositionMs = savedPositionMs relay = OFF ``` -이렇게 해서 주행 중 USB 재연결이나 serial monitor 열림 때문에 시트가 불필요하게 위로 움직이는 상황을 막습니다. 이후 기어를 P단으로 옮기면 저장된 위치 추정값에서 `0ms`까지 내려갑니다. +이렇게 해서 주행 중 시리얼 연결만으로 시트가 불필요하게 상승하는 상황을 막습니다. 이후 사용자가 P단으로 이동하면 저장 위치 기준으로 `0ms`까지 하강합니다. -## Serial Console +## 시트 EEPROM -Serial은 기본적으로 조용한 상태입니다. +```text +0 = magic +1 = 10회 보정 useCount +2..3 = 시트 주행 위치 uint16_t +``` + +시트 위치 허용 범위는 `0..6000ms`입니다. EEPROM magic이 없거나 저장값이 유효하지 않으면 기본 주행 위치를 `6000ms`로 설정합니다. + +## 센터콘솔 EEPROM + +센터콘솔 목표값은 Mega가 아니라 Nano EEPROM에 저장합니다. + +```text +16 = console magic +17 = 운전석 통풍 목표 단계 0..3 +18 = 운전석 열선 목표 단계 0..3 +19 = 조수석 통풍 목표 단계 0..3 +20 = 조수석 열선 목표 단계 0..3 +21 = 핸들 열선 목표 0/1 +22 = 전방센서 목표 0/1 +23 = VESS 목표 0/1 +``` + +운전석 통풍이 켜지면 운전석 열선 메모리는 `0`으로 지웁니다. 운전석 열선이 켜지면 운전석 통풍 메모리는 `0`으로 지웁니다. 조수석도 같은 규칙을 적용합니다. + +## 센터콘솔 자동 적용 + +- Mega가 CAN 상태를 보내고 Nano가 alive로 인식하면 센터콘솔 기능을 사용할 수 있습니다. +- 부팅 후 `3000ms`가 지나고 Mega가 준비되어 있으면 저장된 열선/통풍 목표를 적용합니다. +- 핸들 열선, 전방센서, VESS는 IG1이 켜진 뒤에만 목표값을 적용합니다. +- 열선/통풍 목표가 `1..3`이면 약 `19분 30초`마다 저장 목표를 다시 적용합니다. +- 핸들 열선 목표가 ON이고 IG1이 ON이면 약 `19분 30초`마다 OFF 클릭 후 다시 ON 클릭을 수행합니다. +- Mega가 바쁘거나 CAN 응답이 없으면 Nano는 새 명령을 보내지 않습니다. + +사용자가 센터콘솔 버튼을 직접 누르면 Mega가 눌림/릴리즈를 Nano에 보고합니다. Nano는 릴리즈 후 `700ms` 뒤 실제 LED 상태를 읽어 EEPROM 목표값을 갱신합니다. + +## CAN 프로토콜 + +속도는 `125kbps`, MCP2515 crystal은 `8MHz`로 설정합니다. + +```text +0x100 Nano -> Mega heartbeat +0x101 Nano -> Mega command +0x200 Mega -> Nano status +``` + +### 0x100 Heartbeat + +```text +byte0 = protocol version, 1 +byte1 = flags +byte2 = sequence +byte3 = useCount +byte4 = parking raw low +byte5 = parking raw high +byte6 = uptime seconds low +byte7 = uptime seconds high +``` + +flags: + +```text +bit0 = IG1 ON +bit1 = P ON +bit2 = raw drive mode +bit3 = confirmed drive mode +bit4 = ignition stabilizing +bit5 = seat moving +bit6 = auto calibration pending/running +``` + +### 0x101 Command + +현재 명령은 pulse 실행입니다. + +```text +byte0 = protocol version, 1 +byte1 = command, 1 pulse +byte2 = channel 0..6 +byte3 = onMs low +byte4 = onMs high +byte5 = gapMs low +byte6 = gapMs high +byte7 = command sequence +``` + +Mega는 같은 sequence를 중복 수신하면 무시합니다. 명령을 수락하면 status 프레임의 `lastAcceptedCommandSeq`로 돌려줍니다. + +### 0x200 Status + +```text +byte0 = protocol version, 1 +byte1 = flags +byte2 = packed climate levels +byte3 = toggle bits +byte4 = button bits +byte5 = lastAcceptedCommandSeq +byte6 = Mega pulse queue length +byte7 = last main heartbeat sequence +``` + +flags: + +```text +bit0 = main alive +bit1 = Mega pulse busy +bit2 = Mega pulse queue overflow +bit3 = main IG1 ON +bit4 = main P ON +``` + +packed climate levels: + +```text +bits 0..1 = 운전석 통풍 0..3 +bits 2..3 = 운전석 열선 0..3 +bits 4..5 = 조수석 통풍 0..3 +bits 6..7 = 조수석 열선 0..3 +``` + +toggle bits: + +```text +bit0 = 핸들 열선 +bit1 = 전방센서 +bit2 = VESS +``` + +button bits: + +```text +bit0..bit6 = 센터콘솔 버튼 눌림 상태 +``` + +Mega가 MOSFET으로 직접 누른 버튼은 일정 시간 마스킹하여 사용자 직접 조작으로 오인하지 않습니다. + +## Nano Serial Console + +Nano 시리얼은 기본적으로 조용합니다. 1. `115200` baud로 연결합니다. 2. 빈 Enter를 한 번 보내면 help가 출력되고 콘솔이 열립니다. 3. 이후에는 한 글자 명령만 받습니다. -명령: - ```text s 현재 상태 출력 u 수동 상승 +500ms @@ -62,80 +275,43 @@ p 현재 추정 위치를 EEPROM에 저장 g 1초 간격 debug 출력 시작 x debug 모드만 종료 a 기어 상태 기반 자동 목표로 복귀 -c 보정 실행: 7초 상승, 6초 하강, 현재 위치 0으로 설정 +c 보정 실행: 상승 7초, 하강 6초, 현재 위치 0 설정 r 10회 자동 보정 카운터 초기화 h help 출력 ``` -다음 관리 명령은 안전을 위해 P단에서만 실행됩니다. +`a`, `c`, `r`은 안전을 위해 P단에서만 실행됩니다. + +상태 출력에는 센터콘솔 상태도 함께 포함됩니다. ```text -a c r +consoleCanReady=true +consoleAlive=true +consoleBusy=false +consolePulseQueueLength=0 +consoleDriverVentTarget=0 +consoleDriverVentActual=0 +consoleSteeringHeatTarget=false +consoleSteeringHeatActual=false ``` -P단이 아니면 다음처럼 거부합니다. +## Mega Serial Console + +Mega 시리얼은 I/O 노드 디버그용입니다. ```text -ERR: check gear state. Command requires P. +h help 출력 +s 상태와 ledRaw0..ledRaw14 출력 +x 모든 출력 OFF, pulse queue 비움 ``` -debug 모드 중에는 `x`만 받으며 다른 입력은 무시합니다. - -## 상태 출력 - -상태 출력은 한 줄에 한 항목씩 표시하고, 부팅 후 경과 시간을 포함합니다. - -```text -[STATUS] -uptime=0h 2m 13s 457ms -parkingRawValue=1023 -ignition=true -parking=true -rawDriveMode=false -confirmedDriveMode=false -``` - -자동 보정 카운터가 10회에 도달한 상태라면 IG1 OFF 유지 시간을 함께 보여줍니다. - -```text -autoCalibrationOffTimer=elapsed 0h 1m 20s 123ms remaining 0h 3m 39s 877ms -``` - -IG1이 아직 켜져 있으면 다음처럼 표시합니다. - -```text -autoCalibrationOffTimer=waiting_for_ig_off -``` - -debug 모드는 1초마다 읽기 쉬운 블록을 출력합니다. - -```text -[DEBUG] -uptime=0h 0m 5s 123ms -parkingRawValue=1023 -ignition=true -parking=true -rawDriveMode=false -confirmedDriveMode=false -currentPositionMs=0 -targetPositionMs=0 -savedPositionMs=6000 -hardLimitPositionMs=6000 -requestedRelayAction=0 -actualRelayAction=0 -manualMode=false -stabilizing=false -useCount=0/10 -autoCalibrationPending=false -forceCalibration=false -autoCalibrationState=0 -``` +Mega에는 EEPROM 메모리나 자동 판단 로직이 없습니다. Mega가 리셋되어도 Nano가 다음 상태 보고를 받은 뒤 필요한 명령을 다시 보냅니다. ## 자동 보정 -- `IG1 HIGH -> LOW` 전환이 발생할 때마다 EEPROM 사용 카운터를 1 올립니다. +- `IG1 HIGH -> LOW` 전환이 발생할 때마다 EEPROM useCount를 1 올립니다. - 10회가 되면 자동 보정을 예약합니다. -- IG1이 꺼진 상태로 5분 유지되면 자동 보정이 실행됩니다. +- IG1 OFF 상태가 5분 유지되면 자동 보정을 실행합니다. ```text up 7000ms @@ -145,32 +321,58 @@ targetPositionMs = 0 useCount = 0 ``` -예약 상태에서 IG1이 켜지면 보정은 취소됩니다. 수동 `c` 보정은 P단에서만 가능하고, 시작되면 끝까지 수행합니다. - -## 안전 장치 - -- 시트가 움직이는 중이거나 릴레이 전환 대기 중이면 관리 명령을 거부합니다. -- 내부 위치값은 항상 `0..6000ms`로 제한합니다. -- 릴레이 방향 전환 사이에는 120ms dead time을 둡니다. -- 수동 이동 중 기어 상태가 바뀌면 수동 step 완료 처리를 취소합니다. -- 실수로 EEPROM을 초기화하지 않도록 factory reset 명령은 제거했습니다. +예약 상태에서 IG1이 다시 켜지면 보정은 대기 상태로 남고, 다음 IG1 OFF 후 5분 조건을 다시 봅니다. 수동 `c` 보정은 P단에서만 가능하고, 시작되면 끝까지 수행합니다. ## 빌드와 업로드 -컴파일: +Nano 컴파일: ```powershell & "C:\Program Files\Arduino CLI\arduino-cli.exe" compile --fqbn arduino:avr:nano:cpu=atmega328 --warnings all . ``` -업로드: +Nano 업로드: ```powershell .\upload.ps1 -Port COM3 ``` -## 참고 +Mega 컴파일: -P단 감지 임계값은 현재 코드 기준으로 ON `950`, OFF `940` hysteresis를 사용합니다. 차량별 전압 분배와 신호 특성이 다를 수 있으므로 실제 `analogRead` 값을 확인한 뒤 임계값을 조정해야 합니다. +```powershell +& "C:\Program Files\Arduino CLI\arduino-cli.exe" compile --fqbn arduino:avr:mega .\Console_Node --warnings all +``` -이 프로젝트는 물리 위치 센서 없이 시간으로 위치를 추정합니다. IG1 OFF/P 상태에서 전원이 끊기면 다음 부팅은 하단 위치 추정값 `0ms`에서 시작합니다. 반대로 이미 주행 상태에서 보드가 리셋되면 EEPROM에 저장된 주행 위치에서 시작해 USB 재연결로 인한 불필요한 상승 동작을 줄입니다. +Mega 업로드 예시: + +```powershell +& "C:\Program Files\Arduino CLI\arduino-cli.exe" upload --fqbn arduino:avr:mega --port COM4 .\Console_Node +``` + +## 운영 메모 + +- 커밋 전 `git status --short --branch`로 의도하지 않은 파일이 섞였는지 확인합니다. +- MCP2515 모듈의 crystal은 현재 `8MHz` 기준입니다. 16MHz 모듈이면 `MCP_8MHZ`를 바꿔야 합니다. +- CAN 종단저항은 노드가 2개일 때 양 끝 120Ω 구성을 기준으로 합니다. +- 센터콘솔 CAN 상태가 2초 이상 끊기면 Mega는 출력 OFF와 큐 초기화를 수행합니다. +- Nano가 Mega 상태를 2초 이상 받지 못하면 콘솔 명령 큐를 비우고 자동 적용을 다시 대기합니다. +- STATE_LED 임계값은 차량 실측 raw 값을 기준으로 조정해야 합니다. +- 시트 위치는 센서 기반이 아니라 시간 기반 추정이므로 전원 차단, 수동 조작, 릴레이 지연에 따라 실제 위치와 차이가 날 수 있습니다. + +## 현재 기준값 + +```text +SEAT_HARD_LIMIT_UP_MS = 6000 +DOWN_SPEED_NUM / DOWN_SPEED_DEN = 11 / 10 +IGNITION_STABILIZE_MS = 1000 +DRIVE_CONFIRM_MS = 80 +PARK_CONFIRM_MS = 250 +RELAY_DEADTIME_MS = 120 +DOWN_EDGE_MARGIN_MS = 300 +AUTO_CALIBRATION_DELAY_MS = 300000 +AUTO_CALIBRATION_UP_MS = 7000 +AUTO_CALIBRATION_DOWN_MS = 6000 +CAN_HEARTBEAT_INTERVAL_MS = 250 +CONSOLE_MEMORY_REFRESH_INTERVAL_MS = 1170000 +CONSOLE_STEERING_RESTART_INTERVAL_MS = 1170000 +```