diff --git a/docs/protocolV2.md b/docs/protocolV2.md index b67e27b..fb14ff4 100644 --- a/docs/protocolV2.md +++ b/docs/protocolV2.md @@ -89,6 +89,37 @@ Configuration total size is 1024 bytes. Structure: 12 bytes header + 1012 bytes data. +Header: + + + + + + + + + + + + + + + + + + + + + + + +
0-34-56-78-910-11
constdata checksumdata checksum ^ 0xffffconst
75 56 34 12xx xx00 00xx xxff ff
+ +**data checksum** is the sum of the 1012 data bytes. + +The official app keeps both header checksum fields consistent with the data on +every write; update them after modifying the data. + ## Back buttons ### mini 25 @@ -178,6 +209,14 @@ Unless stated otherwise, checksum is the sum of all bytes after it. **bytes count** is the number of bytes set, after the header. +> NOTE: +> +> All packets go over the control pipe. The official app sends them as +> SET_REPORT(Output); on Linux use HID **feature** reports +> (`hid_send_feature_report`) — interface 0 also has an interrupt OUT +> endpoint, so output reports are routed down the interrupt pipe there and +> the command handler silently ignores them. + ## Init communication Send @@ -3636,6 +3675,56 @@ Response Successful initialization returns **0xaa** in byte **8**. +The payload-less init above is enough for writes that only reach controller +RAM (lost on controller reset). For a write that is going to be flushed to +flash, the official app always sends this init with a fixed 56 bytes unlock +payload instead: + +Send + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234-5678-63
IDcmdbytes countpagechecksumunlock payload
0121380000 008422see below
+ +Unlock payload (fixed): + +``` +a2 a3 a0 a1 a6 a7 a4 a5 ba bb b8 b9 be bf bc bd +b2 b3 b0 b1 b6 b7 b4 b5 8a 8b 88 89 8e 8f 8c 8d +82 83 80 81 86 87 84 85 9a 9b 98 99 9e 9f 9c 9d +92 93 90 91 96 97 94 95 +``` + +The response is the same as for the payload-less init. + ## Write config to memory You must send all 1024 bytes, writes don't have a response. @@ -6624,6 +6713,9 @@ Send ## Prepare for flush +The official app sends this init with the same fixed 56 bytes unlock payload +described in [Prepare for write](#prepare-for-write). + Send @@ -6694,9 +6786,26 @@ Successful initialization returns **0xaa** in byte **8**. ## Flush config to controller -> [!WARNING]: +Flush persists the current configuration to controller flash, so it survives a +controller reset (reboot / suspend) without toggling the controller mode +switch. + +> [!IMPORTANT] +> +> The flush command is acknowledged even when it has no effect. To actually +> persist, the write and the flush must run between two +> [flash session brackets](#flash-session-bracket): +> +> ``` +> bracket : the full sequence below +> write : prepare for write (with unlock payload) → write config → checksum validation → end +> flush : prepare for flush → flush → end +> bracket : again — the flash burn happens in this window +> ``` > -> Not working for some reason, help is needed! +> Verified on Win Mini 2025 (firmware 1.22): with the brackets the config +> survives suspend/resume and a full power cycle; without them the device +> reverts to its previous flash config on the next reset. Send @@ -6763,3 +6872,195 @@ Send
00
+ +## Flash session bracket + +The official app wraps every write + flush between two of these sequences; +the flash burn happens in the window after the flush (see +[Flush config to controller](#flush-config-to-controller)). + +``` +init (0x21, with unlock payload) → keep-alive (0x2b) → 0x29 → +N × keep-alive (2 s apart) → 0x2a → end (0x22) +``` + +`0x29` responds **0x01** in byte **8**, `0x2a` responds **0x00**. The exact +semantics are unknown; from the ordering, `0x29` appears to close/commit a +flash session and `0x2a` to (re)arm config writes. The official app keeps a +bracket open with keep-alives for as long as it runs; N = 2–3 keep-alives per +bracket is verified to be sufficient. + +### Keep-alive + +Inside a flash session the official app sends this every 2000 ms; the payload +is the interval itself (u32, 0x07d0 = 2000). + +Send + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
01234-5678-1112-63
IDcmdbytes countpagechecksuminterval ms
012b040000 00d700d0 07 00 0000
+ +The response is the same as for [Init communication](#init-communication) +(**0xaa** in byte **8**). + +### 0x29 + +Send + + + + + + + + + + + + + + + + + + + + +
0123-63
IDcmdbytes count
01290000
+ +Response + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
0123456789-63
IDcmdbytes countunkchecksumstatus
01290100xx0401000100
+ +### 0x2a + +Send + + + + + + + + + + + + + + + + + + + + +
0123-63
IDcmdbytes count
012a0000
+ +Response + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
0123456789-63
IDcmdbytes countunkchecksumstatus
012a0100xx0400000000
diff --git a/src/controller/Controller.h b/src/controller/Controller.h index cac2f20..d693f5d 100644 --- a/src/controller/Controller.h +++ b/src/controller/Controller.h @@ -82,6 +82,15 @@ namespace OWC { */ [[nodiscard]] virtual bool writeConfig() const = 0; + /*! + * @brief persist the current in-RAM config to device flash + * @details commits changes so they survive a controller reset / reboot + * without physically toggling the mode switch. Default no-op for + * controllers that persist on write. + * @return true on success + */ + [[nodiscard]] virtual bool flushConfig() const { return true; } + /*! * @brief reset the whole controller configuration memory * @details writes a known working configuration buffer diff --git a/src/controller/ControllerV2.cpp b/src/controller/ControllerV2.cpp index 7a29e80..4026779 100644 --- a/src/controller/ControllerV2.cpp +++ b/src/controller/ControllerV2.cpp @@ -17,6 +17,8 @@ */ #include #include +#include +#include #include "ControllerV2.h" #include "../Utils.h" @@ -52,11 +54,93 @@ namespace OWC { } bool ControllerV2::initWriteCommunication() const { - prepareSendBuffer(CMD::Init1); + // GPD's official app opens every write/flush transaction with 0x21 carrying + // a fixed 56-byte "unlock" payload (not a bare init). The firmware only + // persists writes / honours the 0x25 flush when this token is present; + // a bare 0x21 leaves changes in RAM only (which is why flushing did nothing). + static constexpr uint8_t unlock[56] = { + 0xa2,0xa3,0xa0,0xa1,0xa6,0xa7,0xa4,0xa5, 0xba,0xbb,0xb8,0xb9,0xbe,0xbf,0xbc,0xbd, + 0xb2,0xb3,0xb0,0xb1,0xb6,0xb7,0xb4,0xb5, 0x8a,0x8b,0x88,0x89,0x8e,0x8f,0x8c,0x8d, + 0x82,0x83,0x80,0x81,0x86,0x87,0x84,0x85, 0x9a,0x9b,0x98,0x99,0x9e,0x9f,0x9c,0x9d, + 0x92,0x93,0x90,0x91,0x96,0x97,0x94,0x95 + }; + uint16_t *sendBufU16 = reinterpret_cast(sendBuf); + + prepareSendBuffer(CMD::Init1, sizeof(unlock)); + sendBufU16[2] = 0; // page index + sendBufU16[3] = getBytesSum(unlock, sizeof(unlock)); // checksum (== 0x2284) + std::memcpy(sendBuf + 8, unlock, sizeof(unlock)); + + return sendReadRequest() && respBuf[8] == 0xaa; + } + + bool ControllerV2::sendKeepAlive() const { + // 0x2b carrying u32 2000 — the keep-alive the official app sends every + // 2000ms while a config session is open (payload = the interval itself) + static constexpr uint8_t interval[4] = {0xd0, 0x07, 0x00, 0x00}; + uint16_t *sendBufU16 = reinterpret_cast(sendBuf); + + prepareSendBuffer(CMD::Init2, sizeof(interval)); + sendBufU16[2] = 0; // page index + sendBufU16[3] = getBytesSum(interval, sizeof(interval)); // checksum + std::memcpy(sendBuf + 8, interval, sizeof(interval)); return sendReadRequest() && respBuf[8] == 0xaa; } + bool ControllerV2::flashBracket(const int keepAlives) const { + // The official app brackets every write/flush with a + // magic-0x21, 0x2b, 0x29, N x 0x2b (2s apart), 0x2a, 0x22 + // session (captured from WinControls on Windows). Without it the 0x25 + // flush is acknowledged but nothing is persisted; with it the config + // survives a controller reset. 0x29 appears to close/commit the flash + // session, 0x2a to (re)arm config writes. + using namespace std::chrono_literals; + + if (!initWriteCommunication()) { + if (logFn) + writeLog(L"failed to init communication"); + + return false; + } + + if (!sendKeepAlive()) + return false; + + prepareSendBuffer(CMD::FlashLock); + if (!sendReadRequest() || isCmdRejected()) { + if (logFn) + writeLog(L"flash lock (0x29) rejected"); + + return false; + } + + for (int i=0; i(sendBuf); + uint16_t *configBufU16 = reinterpret_cast(configBuf); + const uint16_t dataSum = getBytesSum(configData, configBufLen - configHeaderSz); + + // keep the header self-checksum consistent with the (possibly modified) + // config data: u16@4 = sum of data bytes, u16@8 = its complement. + // The official app maintains these on every write. + configBufU16[2] = dataSum; + configBufU16[4] = 0xffff - dataSum; if (!initWriteCommunication()) { if (logFn) @@ -211,9 +303,6 @@ namespace OWC { return false; } - if (logFn) - writeLog(bufferToString(configBuf, configBufLen)); - for (int i=0; i write all pages -> flush (0x25) -> bracket (burn window) + // + // Self-contained: re-sends the pages itself, so it can be called after + // writeConfig() (or standalone) and always commits what configBuf holds. + if (!flashBracket(2)) + return false; + + if (!writePages()) + return false; + if (!initWriteCommunication()) { if (logFn) writeLog(L"failed to init communication"); @@ -242,21 +356,29 @@ namespace OWC { return false; } + // 01 25 04 00 00 00 04 00 00 04 — same arg shape as the checksum query prepareSendBuffer(CMD::Flush, 4); - // required args sendBuf[6] = 4; // checksum sendBuf[9] = 4; // unk if (!sendReadRequest() || isCmdRejected()) { if (logFn) - writeLog(L"failed to flush config buffer"); + writeLog(L"failed to flush config to flash"); return false; } prepareSendBuffer(CMD::EndCmd); - return sendReadRequest() && !isCmdRejected(); + if (!sendReadRequest() || isCmdRejected()) { + if (logFn) + writeLog(L"failed end cmd after flush"); + + return false; + } + + // post-flush bracket: the flash burn happens in this window + return flashBracket(3); } bool ControllerV2::resetConfig() const { diff --git a/src/controller/ControllerV2.h b/src/controller/ControllerV2.h index 9ef81ee..6f1af5a 100644 --- a/src/controller/ControllerV2.h +++ b/src/controller/ControllerV2.h @@ -36,6 +36,8 @@ namespace OWC { Write = 0x43, Checksum = 0x27, Flush = 0x25, + FlashLock = 0x29, // close/commit a flash session (official app sends after flush; device replies 0x01) + FlashUnlock = 0x2a, // (re)open a config session (official app sends right before a write; device replies 0x00) EndCmd = 0x22 }; @@ -50,6 +52,9 @@ namespace OWC { [[nodiscard]] bool initReadCommunication() const; [[nodiscard]] bool initWriteCommunication() const; + [[nodiscard]] bool sendKeepAlive() const; + [[nodiscard]] bool flashBracket(int keepAlives) const; + [[nodiscard]] bool writePages() const; void prepareSendBuffer(CMD cmd, int bytesCount = 0) const; [[nodiscard]] bool isCmdRejected() const; [[nodiscard]] bool isValidRespPacket() const; @@ -121,16 +126,10 @@ namespace OWC { */ [[nodiscard]] int getBackButtonHoldTime(int num, int slot) const; - /*! - * @brief flush config buffer to controller to make it permanent - * @details NOT WORKING, help needed - * @return true on success - */ - [[nodiscard]] bool flushConfig() const; - [[nodiscard]] bool readVersion() override; [[nodiscard]] bool readConfig() override; [[nodiscard]] bool writeConfig() const override; + [[nodiscard]] bool flushConfig() const override; [[nodiscard]] bool resetConfig() const override; [[nodiscard]] bool setButton(Button btn, const std::string &key) const override; [[nodiscard]] std::string getButton(Button btn) const override;