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-3 |
+ 4-5 |
+ 6-7 |
+ 8-9 |
+ 10-11 |
+
+
+ | const |
+ data checksum |
+ |
+ data checksum ^ 0xffff |
+ const |
+
+
+ | 75 56 34 12 |
+ xx xx |
+ 00 00 |
+ xx xx |
+ ff 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
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3 |
+ 4-5 |
+ 6 |
+ 7 |
+ 8-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+ page |
+ checksum |
+ unlock payload |
+
+
+ | 01 |
+ 21 |
+ 38 |
+ 00 |
+ 00 00 |
+ 84 |
+ 22 |
+ see 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
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3 |
+ 4-5 |
+ 6 |
+ 7 |
+ 8-11 |
+ 12-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+ page |
+ checksum |
+ interval ms |
+ |
+
+
+ | 01 |
+ 2b |
+ 04 |
+ 00 |
+ 00 00 |
+ d7 |
+ 00 |
+ d0 07 00 00 |
+ 00 |
+
+
+
+The response is the same as for [Init communication](#init-communication)
+(**0xaa** in byte **8**).
+
+### 0x29
+
+Send
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+
+
+ | 01 |
+ 29 |
+ 00 |
+ 00 |
+
+
+
+Response
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3 |
+ 4 |
+ 5 |
+ 6 |
+ 7 |
+ 8 |
+ 9-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+ unk |
+ |
+ checksum |
+ status |
+ |
+
+
+ | 01 |
+ 29 |
+ 01 |
+ 00 |
+ xx |
+ 04 |
+ 01 |
+ 00 |
+ 01 |
+ 00 |
+
+
+
+### 0x2a
+
+Send
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+
+
+ | 01 |
+ 2a |
+ 00 |
+ 00 |
+
+
+
+Response
+
+
+
+ | 0 |
+ 1 |
+ 2 |
+ 3 |
+ 4 |
+ 5 |
+ 6 |
+ 7 |
+ 8 |
+ 9-63 |
+
+
+ | ID |
+ cmd |
+ bytes count |
+ |
+ unk |
+ |
+ checksum |
+ status |
+ |
+
+
+ | 01 |
+ 2a |
+ 01 |
+ 00 |
+ xx |
+ 04 |
+ 00 |
+ 00 |
+ 00 |
+ 00 |
+
+
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;