xteink-github-sync
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#include "GitHubSync.h"
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <Preferences.h>
|
||||||
|
#include <SD.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
#define GH_PREFS_NS "github_sync"
|
||||||
|
#define GH_KEY_USER "username"
|
||||||
|
#define GH_KEY_PAT "pat"
|
||||||
|
#define GH_KEY_REPO "repo"
|
||||||
|
#define GH_KEY_BRANCH "branch"
|
||||||
|
#define GH_SHA_DIR "/.crosspoint/github_sha/"
|
||||||
|
#define GH_BOOKS_DIR "/"
|
||||||
|
#define GH_SLEEP_BMP "sleep.bmp"
|
||||||
|
#define GH_SLEEP_PATH "/sleep.bmp"
|
||||||
|
#define GH_API_BASE "https://api.github.com"
|
||||||
|
|
||||||
|
bool GitHubSync::loadConfig(GitHubSyncConfig &cfg) {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(GH_PREFS_NS, true);
|
||||||
|
cfg.username = prefs.getString(GH_KEY_USER, "").c_str();
|
||||||
|
cfg.pat = prefs.getString(GH_KEY_PAT, "").c_str();
|
||||||
|
cfg.repo = prefs.getString(GH_KEY_REPO, "xteink").c_str();
|
||||||
|
cfg.branch = prefs.getString(GH_KEY_BRANCH, "main").c_str();
|
||||||
|
prefs.end();
|
||||||
|
return !cfg.username.empty() && !cfg.pat.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSync::saveConfig(const GitHubSyncConfig &cfg) {
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(GH_PREFS_NS, false);
|
||||||
|
prefs.putString(GH_KEY_USER, cfg.username.c_str());
|
||||||
|
prefs.putString(GH_KEY_PAT, cfg.pat.c_str());
|
||||||
|
prefs.putString(GH_KEY_REPO, cfg.repo.c_str());
|
||||||
|
prefs.putString(GH_KEY_BRANCH, cfg.branch.c_str());
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GitHubSync::isConfigured() {
|
||||||
|
GitHubSyncConfig cfg;
|
||||||
|
return loadConfig(cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* GitHubSync::resultMessage(GitHubSyncResult r) {
|
||||||
|
switch (r) {
|
||||||
|
case GitHubSyncResult::OK: return "GitHub sync complete";
|
||||||
|
case GitHubSyncResult::NOT_CONFIGURED: return "GitHub sync: not configured";
|
||||||
|
case GitHubSyncResult::NO_WIFI: return "GitHub sync: no WiFi";
|
||||||
|
case GitHubSyncResult::AUTH_ERROR: return "GitHub sync: auth failed (check PAT)";
|
||||||
|
case GitHubSyncResult::REPO_NOT_FOUND: return "GitHub sync: repo not found";
|
||||||
|
case GitHubSyncResult::API_ERROR: return "GitHub sync: API error";
|
||||||
|
case GitHubSyncResult::SD_ERROR: return "GitHub sync: SD card error";
|
||||||
|
case GitHubSyncResult::PARSE_ERROR: return "GitHub sync: bad API response";
|
||||||
|
default: return "GitHub sync: unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string GitHubSync::shaFilePath(const std::string &filename) {
|
||||||
|
std::string safe = filename;
|
||||||
|
for (char &c : safe) if (c == '/') c = '_';
|
||||||
|
return std::string(GH_SHA_DIR) + safe + ".sha";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string GitHubSync::loadLocalSha(const std::string &filename) {
|
||||||
|
std::string path = shaFilePath(filename);
|
||||||
|
File f = SD.open(path.c_str(), FILE_READ);
|
||||||
|
if (!f) return "";
|
||||||
|
String sha = f.readString();
|
||||||
|
f.close();
|
||||||
|
sha.trim();
|
||||||
|
return sha.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSync::saveLocalSha(const std::string &filename, const std::string &sha) {
|
||||||
|
SD.mkdir(GH_SHA_DIR);
|
||||||
|
std::string path = shaFilePath(filename);
|
||||||
|
File f = SD.open(path.c_str(), FILE_WRITE);
|
||||||
|
if (!f) return;
|
||||||
|
f.print(sha.c_str());
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GitHubSync::fetchFileList(const GitHubSyncConfig &cfg, std::string &outJson, GitHubSyncResult &err) {
|
||||||
|
std::string url = std::string(GH_API_BASE) + "/repos/" + cfg.username + "/" +
|
||||||
|
cfg.repo + "/contents/?ref=" + cfg.branch;
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(url.c_str());
|
||||||
|
http.addHeader("Authorization", ("token " + cfg.pat).c_str());
|
||||||
|
http.addHeader("Accept", "application/vnd.github.v3+json");
|
||||||
|
http.addHeader("User-Agent", "CrossPoint-X4");
|
||||||
|
|
||||||
|
int code = http.GET();
|
||||||
|
if (code == 401 || code == 403) { http.end(); err = GitHubSyncResult::AUTH_ERROR; return false; }
|
||||||
|
if (code == 404) { http.end(); err = GitHubSyncResult::REPO_NOT_FOUND; return false; }
|
||||||
|
if (code != 200) { http.end(); err = GitHubSyncResult::API_ERROR; return false; }
|
||||||
|
|
||||||
|
outJson = http.getString().c_str();
|
||||||
|
http.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool GitHubSync::downloadFile(const GitHubSyncConfig &cfg, const std::string &path, const std::string &sha, GitHubSyncResult &err) {
|
||||||
|
std::string url = std::string(GH_API_BASE) + "/repos/" + cfg.username + "/" +
|
||||||
|
cfg.repo + "/contents/" + path + "?ref=" + cfg.branch;
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
http.begin(url.c_str());
|
||||||
|
http.addHeader("Authorization", ("token " + cfg.pat).c_str());
|
||||||
|
http.addHeader("Accept", "application/vnd.github.v3.raw");
|
||||||
|
http.addHeader("User-Agent", "CrossPoint-X4");
|
||||||
|
|
||||||
|
int code = http.GET();
|
||||||
|
if (code == 401 || code == 403) { http.end(); err = GitHubSyncResult::AUTH_ERROR; return false; }
|
||||||
|
if (code != 200) { http.end(); err = GitHubSyncResult::API_ERROR; return false; }
|
||||||
|
|
||||||
|
std::string destPath = (path == GH_SLEEP_BMP) ? GH_SLEEP_PATH : std::string(GH_BOOKS_DIR) + path;
|
||||||
|
|
||||||
|
File f = SD.open(destPath.c_str(), FILE_WRITE);
|
||||||
|
if (!f) { http.end(); err = GitHubSyncResult::SD_ERROR; return false; }
|
||||||
|
|
||||||
|
WiFiClient *stream = http.getStreamPtr();
|
||||||
|
uint8_t buf[512];
|
||||||
|
int total = http.getSize();
|
||||||
|
int remaining = total;
|
||||||
|
|
||||||
|
while (http.connected() && (remaining > 0 || total == -1)) {
|
||||||
|
size_t available = stream->available();
|
||||||
|
if (available) {
|
||||||
|
size_t read = stream->readBytes(buf, min(available, sizeof(buf)));
|
||||||
|
f.write(buf, read);
|
||||||
|
if (remaining > 0) remaining -= (int)read;
|
||||||
|
}
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
http.end();
|
||||||
|
saveLocalSha(path, sha);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
GitHubSyncResult GitHubSync::sync() {
|
||||||
|
GitHubSyncConfig cfg;
|
||||||
|
if (!loadConfig(cfg)) return GitHubSyncResult::NOT_CONFIGURED;
|
||||||
|
if (WiFi.status() != WL_CONNECTED) return GitHubSyncResult::NO_WIFI;
|
||||||
|
|
||||||
|
std::string jsonStr;
|
||||||
|
GitHubSyncResult err = GitHubSyncResult::OK;
|
||||||
|
if (!fetchFileList(cfg, jsonStr, err)) return err;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
DeserializationError jsonErr = deserializeJson(doc, jsonStr);
|
||||||
|
if (jsonErr) return GitHubSyncResult::PARSE_ERROR;
|
||||||
|
|
||||||
|
JsonArray files = doc.as<JsonArray>();
|
||||||
|
for (JsonObject file : files) {
|
||||||
|
std::string type = file["type"].as<const char*>();
|
||||||
|
std::string name = file["name"].as<const char*>();
|
||||||
|
std::string sha = file["sha"].as<const char*>();
|
||||||
|
|
||||||
|
if (type != "file") continue;
|
||||||
|
|
||||||
|
bool isEpub = name.size() > 5 && (name.substr(name.size()-5) == ".epub" || name.substr(name.size()-5) == ".EPUB");
|
||||||
|
bool isSleep = (name == GH_SLEEP_BMP);
|
||||||
|
|
||||||
|
if (!isEpub && !isSleep) continue;
|
||||||
|
|
||||||
|
std::string localSha = loadLocalSha(name);
|
||||||
|
if (localSha == sha) continue;
|
||||||
|
|
||||||
|
if (!downloadFile(cfg, name, sha, err)) return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
return GitHubSyncResult::OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
struct GitHubSyncConfig {
|
||||||
|
std::string username;
|
||||||
|
std::string pat;
|
||||||
|
std::string repo;
|
||||||
|
std::string branch;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class GitHubSyncResult {
|
||||||
|
OK,
|
||||||
|
NOT_CONFIGURED,
|
||||||
|
NO_WIFI,
|
||||||
|
AUTH_ERROR,
|
||||||
|
REPO_NOT_FOUND,
|
||||||
|
API_ERROR,
|
||||||
|
SD_ERROR,
|
||||||
|
PARSE_ERROR
|
||||||
|
};
|
||||||
|
|
||||||
|
class GitHubSync {
|
||||||
|
public:
|
||||||
|
static bool loadConfig(GitHubSyncConfig &cfg);
|
||||||
|
static void saveConfig(const GitHubSyncConfig &cfg);
|
||||||
|
static bool isConfigured();
|
||||||
|
static GitHubSyncResult sync();
|
||||||
|
static const char* resultMessage(GitHubSyncResult r);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool fetchFileList(const GitHubSyncConfig &cfg, std::string &outJson, GitHubSyncResult &err);
|
||||||
|
static bool downloadFile(const GitHubSyncConfig &cfg, const std::string &path, const std::string &sha, GitHubSyncResult &err);
|
||||||
|
static std::string loadLocalSha(const std::string &filename);
|
||||||
|
static void saveLocalSha(const std::string &filename, const std::string &sha);
|
||||||
|
static std::string shaFilePath(const std::string &filename);
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
#include "GitHubSyncSettingsActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <I18n.h>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int MENU_ITEMS = 5;
|
||||||
|
const char* menuNames[MENU_ITEMS] = {
|
||||||
|
"Username",
|
||||||
|
"Token (PAT)",
|
||||||
|
"Repo",
|
||||||
|
"Branch",
|
||||||
|
"Save"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSyncSettingsActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
selectedIndex = 0;
|
||||||
|
requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSyncSettingsActivity::onExit() {
|
||||||
|
Activity::onExit();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string GitHubSyncSettingsActivity::getMasked(const std::string &s) const {
|
||||||
|
return s.empty() ? "" : "••••••••";
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSyncSettingsActivity::handleSelection() {
|
||||||
|
GitHubSyncConfig cfg;
|
||||||
|
GitHubSync::loadConfig(cfg);
|
||||||
|
|
||||||
|
if (selectedIndex == 0) {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "GitHub Username",
|
||||||
|
cfg.username, 64, false),
|
||||||
|
[](const ActivityResult &result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
GitHubSyncConfig c;
|
||||||
|
GitHubSync::loadConfig(c);
|
||||||
|
c.username = std::get<KeyboardResult>(result.data).text;
|
||||||
|
GitHubSync::saveConfig(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (selectedIndex == 1) {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "Personal Access Token",
|
||||||
|
"", 128, false),
|
||||||
|
[](const ActivityResult &result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
const auto &text = std::get<KeyboardResult>(result.data).text;
|
||||||
|
if (!text.empty()) {
|
||||||
|
GitHubSyncConfig c;
|
||||||
|
GitHubSync::loadConfig(c);
|
||||||
|
c.pat = text;
|
||||||
|
GitHubSync::saveConfig(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (selectedIndex == 2) {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "Repo Name",
|
||||||
|
cfg.repo, 64, false),
|
||||||
|
[](const ActivityResult &result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
GitHubSyncConfig c;
|
||||||
|
GitHubSync::loadConfig(c);
|
||||||
|
c.repo = std::get<KeyboardResult>(result.data).text;
|
||||||
|
GitHubSync::saveConfig(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (selectedIndex == 3) {
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, "Branch",
|
||||||
|
cfg.branch, 32, false),
|
||||||
|
[](const ActivityResult &result) {
|
||||||
|
if (!result.isCancelled) {
|
||||||
|
GitHubSyncConfig c;
|
||||||
|
GitHubSync::loadConfig(c);
|
||||||
|
c.branch = std::get<KeyboardResult>(result.data).text;
|
||||||
|
GitHubSync::saveConfig(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (selectedIndex == 4) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSyncSettingsActivity::loop() {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
handleSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator.onNext([this] {
|
||||||
|
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPrevious([this] {
|
||||||
|
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
|
||||||
|
requestUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void GitHubSyncSettingsActivity::render(RenderLock &&) {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const auto &metrics = UITheme::getInstance().getMetrics();
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "GitHub Sync");
|
||||||
|
|
||||||
|
GitHubSyncConfig cfg;
|
||||||
|
GitHubSync::loadConfig(cfg);
|
||||||
|
|
||||||
|
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
|
||||||
|
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||||
|
|
||||||
|
GUI.drawList(
|
||||||
|
renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS,
|
||||||
|
selectedIndex,
|
||||||
|
[](int index) { return std::string(menuNames[index]); },
|
||||||
|
nullptr, nullptr,
|
||||||
|
[this, &cfg](int index) -> std::string {
|
||||||
|
if (index == 0) return cfg.username.empty() ? "(not set)" : cfg.username;
|
||||||
|
if (index == 1) return cfg.pat.empty() ? "(not set)" : getMasked(cfg.pat);
|
||||||
|
if (index == 2) return cfg.repo.empty() ? "xteink" : cfg.repo;
|
||||||
|
if (index == 3) return cfg.branch.empty() ? "main" : cfg.branch;
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
true);
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "activities/Activity.h"
|
||||||
|
#include "GitHubSync.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class GitHubSyncSettingsActivity final : public Activity {
|
||||||
|
public:
|
||||||
|
explicit GitHubSyncSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||||
|
: Activity("GitHubSync", renderer, mappedInput) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
void render(RenderLock&&) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
int selectedIndex = 0;
|
||||||
|
|
||||||
|
static constexpr int MENU_ITEMS = 5;
|
||||||
|
|
||||||
|
void handleSelection();
|
||||||
|
std::string getMasked(const std::string& s) const;
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# GitHub Sync — Manual Integration (Advanced)
|
||||||
|
|
||||||
|
If you prefer not to use the auto-patcher (`patch.py`), follow these steps to integrate GitHub Sync into a CrossPoint/xteink firmware tree manually.
|
||||||
|
|
||||||
|
## Files to copy into your CrossPoint repo
|
||||||
|
|
||||||
|
Copy the files from this repository into your CrossPoint firmware repo at the following destinations:
|
||||||
|
|
||||||
|
| Source (this repo) | Destination (CrossPoint repo) |
|
||||||
|
|---|---|
|
||||||
|
| `GitHubSync.h` | `include/GitHubSync.h` |
|
||||||
|
| `GitHubSync.cpp` | `src/github_sync/GitHubSync.cpp` |
|
||||||
|
| `GitHubSyncSettingsActivity.h` | `include/GitHubSyncSettingsActivity.h` |
|
||||||
|
| `GitHubSyncSettingsActivity.cpp` | `src/activities/settings/GitHubSyncSettingsActivity.cpp` |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Ensure the destination folders exist (`src/github_sync/`, `src/activities/settings/`).
|
||||||
|
- Your CrossPoint repo may use slightly different folder names; the important part is that your build system compiles the `.cpp` files and that includes resolve from `include/`.
|
||||||
|
|
||||||
|
## 1) `platformio.ini` dependency
|
||||||
|
|
||||||
|
Make sure ArduinoJson v7 is available in `lib_deps`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
bblanchon/ArduinoJson @ ^7
|
||||||
|
```
|
||||||
|
|
||||||
|
If your project already depends on ArduinoJson, do not duplicate it.
|
||||||
|
|
||||||
|
## 2) Call sync after WiFi connects (boot/startup path)
|
||||||
|
|
||||||
|
Find your boot path after WiFi is connected (often near where OTA update checks happen) and add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "GitHubSync.h"
|
||||||
|
|
||||||
|
// After WiFi is up:
|
||||||
|
if (GitHubSync::isConfigured()) {
|
||||||
|
GitHubSyncResult result = GitHubSync::sync();
|
||||||
|
if (result != GitHubSyncResult::OK) {
|
||||||
|
// Replace this with your project's preferred UI/logging mechanism.
|
||||||
|
// (Some CrossPoint forks use LOG_ERR; others use Serial; some show a toast.)
|
||||||
|
LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(result));
|
||||||
|
// Or: Serial.printf("[SYNC] %s\n", GitHubSync::resultMessage(result));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
- The return type is **`GitHubSyncResult`** (not `SyncResult`).
|
||||||
|
- If your firmware does not define `LOG_ERR`, use whatever logging you already use (or `Serial.printf`).
|
||||||
|
|
||||||
|
## 3) Add a Settings entry ("GitHub Sync")
|
||||||
|
|
||||||
|
In your settings menu implementation (commonly something like `SettingsActivity.cpp`):
|
||||||
|
|
||||||
|
1. Include the activity:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
#include "GitHubSyncSettingsActivity.h"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add a new item in the settings list that navigates to the activity.
|
||||||
|
|
||||||
|
The exact code depends on your UI framework, but the action should instantiate/push:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add a translation string (if your project uses translations):
|
||||||
|
- Add a string ID similar to `STR_GITHUB_SYNC`
|
||||||
|
- Map it to the label `GitHub Sync` in your translation files
|
||||||
|
|
||||||
|
## 4) (Optional) Web settings editor schema
|
||||||
|
|
||||||
|
If your CrossPoint fork has a web settings editor that can read/write NVS keys, add these keys:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ns": "github_sync", "key": "username", "label": "GitHub Username", "type": "text" },
|
||||||
|
{ "ns": "github_sync", "key": "pat", "label": "GitHub PAT", "type": "password" },
|
||||||
|
{ "ns": "github_sync", "key": "repo", "label": "Repo name", "type": "text" },
|
||||||
|
{ "ns": "github_sync", "key": "branch", "label": "Branch", "type": "text" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Namespace/keys used by firmware:
|
||||||
|
- Namespace: `github_sync`
|
||||||
|
- Keys: `username`, `pat`, `repo`, `branch`
|
||||||
|
|
||||||
|
## 5) GitHub repo setup (for content)
|
||||||
|
|
||||||
|
1. Create a GitHub repo (private recommended)
|
||||||
|
2. Add `.epub` files to the **repo root**
|
||||||
|
3. Optionally add `sleep.bmp` to the repo root (152x152 grayscale BMP)
|
||||||
|
4. Create a PAT with permission to read repository contents
|
||||||
|
5. On device: Settings → GitHub Sync:
|
||||||
|
- username
|
||||||
|
- PAT
|
||||||
|
- repo
|
||||||
|
- branch
|
||||||
|
|
||||||
|
## Behavior summary
|
||||||
|
|
||||||
|
- On boot (after WiFi connects), the device calls the GitHub Contents API for the repo root.
|
||||||
|
- For each `*.epub` and `sleep.bmp` found:
|
||||||
|
- Compare GitHub blob SHA to cached SHA stored in `/.crosspoint/github_sha/` on the SD card
|
||||||
|
- Download only when SHA differs or is missing locally
|
||||||
|
- Download destinations:
|
||||||
|
- `sleep.bmp` → `/sleep.bmp`
|
||||||
|
- `*.epub` → `/<filename>.epub` (SD card root)
|
||||||
|
- No automatic deletions are performed.
|
||||||
|
|
||||||
|
## RAM note
|
||||||
|
|
||||||
|
The GitHub Contents API directory listing is small (one JSON object per file). Downloads are streamed directly to SD using a small buffer.
|
||||||
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# xteink-github-sync
|
||||||
|
|
||||||
|
Sync `.epub` files (and an optional `sleep.bmp`) from a GitHub repository to your CrossPoint/xteink device at boot.
|
||||||
|
|
||||||
|
This repo contains:
|
||||||
|
1. The device-side C++ implementation (`GitHubSync.*`, `GitHubSyncSettingsActivity.*`)
|
||||||
|
2. A Python patcher (`patch.py`) that injects the feature into your CrossPoint Reader codebase and uploads the firmware (with a prompt to connect via USB first).
|
||||||
|
|
||||||
|
## What it syncs
|
||||||
|
|
||||||
|
- All files in the root of your GitHub repo ending in `.epub`
|
||||||
|
- `sleep.bmp` in the repo root (optional)
|
||||||
|
|
||||||
|
The device compares GitHub blob SHAs against cached SHAs stored on the SD card and downloads only what changed.
|
||||||
|
|
||||||
|
## GitHub repo setup
|
||||||
|
|
||||||
|
1. Create a **private** GitHub repo (example: `xteink`)
|
||||||
|
2. Add `.epub` files to the **root** of the repo
|
||||||
|
3. Optionally add `sleep.bmp` (152x152 grayscale BMP)
|
||||||
|
4. Create a Personal Access Token (PAT):
|
||||||
|
- Token type: classic or a fine-grained token with equivalent access
|
||||||
|
- Scopes/permissions: `contents: read-only` (or equivalent for reading repository contents)
|
||||||
|
5. In the device UI (or during initial setup), configure:
|
||||||
|
- GitHub username
|
||||||
|
- PAT
|
||||||
|
- Repo name
|
||||||
|
- Branch (default: `main`)
|
||||||
|
|
||||||
|
## How the sync works (device behavior)
|
||||||
|
|
||||||
|
- After WiFi connects, the firmware calls the GitHub Contents API
|
||||||
|
- For each candidate file in the repo root (`*.epub` and `sleep.bmp`), it:
|
||||||
|
- Fetches the GitHub blob SHA
|
||||||
|
- Compares it to the cached SHA stored at `/.crosspoint/github_sha/` on the SD card
|
||||||
|
- Downloads the raw file if the SHA differs or is missing locally
|
||||||
|
- Download destinations on the SD card:
|
||||||
|
- `/sleep.bmp` for the sleep image
|
||||||
|
- `/<filename>.epub` for book files
|
||||||
|
- Files are **not** deleted automatically
|
||||||
|
|
||||||
|
## Requirements (local machine)
|
||||||
|
|
||||||
|
- `git`
|
||||||
|
- `platformio` (PlatformIO CLI, provides `pio`)
|
||||||
|
- `esptool.py` (used by PlatformIO for uploading)
|
||||||
|
- `esp-idf-nvs-partition-gen` (used to build an NVS partition for flashing credentials)
|
||||||
|
- Python packages the script may prompt to install (press `Enter` when asked):
|
||||||
|
- `platformio`
|
||||||
|
- `esptool`
|
||||||
|
- `esp-idf-nvs-partition-gen`
|
||||||
|
- `certifi` (recommended on macOS to avoid `CERTIFICATE_VERIFY_FAILED`)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Run the patcher:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 patch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional arguments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 patch.py /path/to/destination [usb_upload_port]
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
1. Clones/updates `crosspoint-reader` into `destination/crosspoint-reader`
|
||||||
|
2. Copies the C++ files into the correct CrossPoint locations
|
||||||
|
3. Injects the startup sync call into the CrossPoint boot path
|
||||||
|
4. Adds a “GitHub Sync” entry to the settings menu
|
||||||
|
5. Optionally writes NVS credentials (username/PAT/repo/branch) into the device
|
||||||
|
6. Prompts you to connect the device via USB before uploading
|
||||||
|
7. Runs `pio run --target upload`
|
||||||
|
|
||||||
|
## Prompts and validation
|
||||||
|
|
||||||
|
During setup, `patch.py` will prompt for:
|
||||||
|
|
||||||
|
- GitHub username
|
||||||
|
- Validates reachability via `GET https://api.github.com/users/{username}`
|
||||||
|
- PAT
|
||||||
|
- Validates via `GET https://api.github.com/user`
|
||||||
|
- Repo name
|
||||||
|
- Validates via `GET https://api.github.com/repos/{owner}/{repo}` using your PAT
|
||||||
|
|
||||||
|
Before uploading firmware, it prompts you to:
|
||||||
|
- Connect the xteink device via **USB data** (not charge-only)
|
||||||
|
- Press `Enter` once connected
|
||||||
|
- It then shows likely serial ports and PlatformIO’s device list to help you choose the correct upload port.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `CERTIFICATE_VERIFY_FAILED` talking to GitHub
|
||||||
|
|
||||||
|
On macOS, Python can miss the system CA bundle. Recommended fix:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install certifi
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses `certifi` automatically when available.
|
||||||
|
|
||||||
|
If you *must* bypass SSL verification (insecure), you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITHUB_SYNC_SSL_NO_VERIFY=1
|
||||||
|
python3 patch.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upload picks the wrong serial device (e.g. Bluetooth)
|
||||||
|
|
||||||
|
Make sure you use a **data** USB cable and that the device is connected.
|
||||||
|
|
||||||
|
Use the prompt (or pass the upload port explicitly) so PlatformIO/esptool doesn’t auto-detect a non-ESP32 serial device.
|
||||||
|
|
||||||
|
If you still have trouble, try:
|
||||||
|
- Unplugging Bluetooth devices temporarily
|
||||||
|
- Passing `usb_upload_port` explicitly to `patch.py`
|
||||||
|
|
||||||
|
## Integration details
|
||||||
|
|
||||||
|
See `INTEGRATION.md` for the file injection locations and the CrossPoint-side integration checklist.
|
||||||
|
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import getpass
|
||||||
|
import csv
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import json
|
||||||
|
|
||||||
|
PATCH_DIR = Path(__file__).parent
|
||||||
|
CROSSPOINT_URL = "https://github.com/crosspoint-reader/crosspoint-reader.git"
|
||||||
|
NVS_NAMESPACE = "github_sync"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
GREEN = "\033[92m"
|
||||||
|
RED = "\033[91m"
|
||||||
|
YELLOW = "\033[93m"
|
||||||
|
CYAN = "\033[96m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
def ok(msg): print(f"{GREEN} ✓ {msg}{RESET}")
|
||||||
|
def err(msg): print(f"{RED} ✗ {msg}{RESET}"); sys.exit(1)
|
||||||
|
def warn(msg): print(f"{YELLOW} ! {msg}{RESET}")
|
||||||
|
def info(msg): print(f" {msg}")
|
||||||
|
|
||||||
|
def ensure_python_module(module_name: str, pip_package: str | None = None, *, description: str = "") -> None:
|
||||||
|
"""If import fails, prompt user (Press Enter) then pip install into this Python."""
|
||||||
|
pip_package = pip_package or module_name
|
||||||
|
try:
|
||||||
|
__import__(module_name)
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
desc = f" — {description}" if description else ""
|
||||||
|
warn(f"Python module '{module_name}' is not installed{desc}.")
|
||||||
|
input(f" Press Enter to install {pip_package}... ")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", pip_package],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err(f"Failed to install {pip_package}:\n{result.stderr.strip()}")
|
||||||
|
ok(f"{pip_package} installed")
|
||||||
|
try:
|
||||||
|
__import__(module_name)
|
||||||
|
except ImportError:
|
||||||
|
err(f"'{module_name}' still not importable after install — try restarting the terminal or use the same Python as: {sys.executable}")
|
||||||
|
|
||||||
|
_github_https_deps_ready = False
|
||||||
|
|
||||||
|
def ensure_github_https_dependencies() -> None:
|
||||||
|
"""certifi fixes macOS CERTIFICATE_VERIFY_FAILED for api.github.com unless user opted out."""
|
||||||
|
global _github_https_deps_ready
|
||||||
|
if _github_https_deps_ready:
|
||||||
|
return
|
||||||
|
if os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower() in ("1", "true", "yes", "on"):
|
||||||
|
_github_https_deps_ready = True
|
||||||
|
return
|
||||||
|
cf = os.environ.get("SSL_CERT_FILE", "").strip()
|
||||||
|
if cf and os.path.isfile(cf):
|
||||||
|
_github_https_deps_ready = True
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import certifi # noqa: F401
|
||||||
|
_github_https_deps_ready = True
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
ensure_python_module(
|
||||||
|
"certifi",
|
||||||
|
description="recommended for GitHub HTTPS (fixes macOS SSL certificate errors)",
|
||||||
|
)
|
||||||
|
_github_https_deps_ready = True
|
||||||
|
|
||||||
|
def get_github_ssl_context():
|
||||||
|
"""
|
||||||
|
macOS Python from python.org often lacks system CA bundle → CERTIFICATE_VERIFY_FAILED.
|
||||||
|
Fixes: pip install certifi, set SSL_CERT_FILE, run Install Certificates.command,
|
||||||
|
or (last resort) GITHUB_SYNC_SSL_NO_VERIFY=1.
|
||||||
|
"""
|
||||||
|
flag = os.environ.get("GITHUB_SYNC_SSL_NO_VERIFY", "").strip().lower()
|
||||||
|
if flag in ("1", "true", "yes", "on"):
|
||||||
|
warn("SSL verification is OFF (GITHUB_SYNC_SSL_NO_VERIFY). Only use if you trust this network.")
|
||||||
|
return ssl._create_unverified_context()
|
||||||
|
cert_file = os.environ.get("SSL_CERT_FILE", "").strip()
|
||||||
|
if cert_file and os.path.isfile(cert_file):
|
||||||
|
return ssl.create_default_context(cafile=cert_file)
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
return ssl.create_default_context(cafile=certifi.where())
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return ssl.create_default_context()
|
||||||
|
|
||||||
|
def ssl_troubleshoot_hint(err_txt: str | None) -> str:
|
||||||
|
if not err_txt:
|
||||||
|
return ""
|
||||||
|
if "CERTIFICATE_VERIFY_FAILED" in err_txt or "SSL" in err_txt:
|
||||||
|
return (
|
||||||
|
" Try: pip install certifi (then re-run), or run macOS "
|
||||||
|
"'Install Certificates.command' for your Python, or set SSL_CERT_FILE to a CA bundle. "
|
||||||
|
"Last resort: GITHUB_SYNC_SSL_NO_VERIFY=1 (insecure)."
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def github_api_get(path: str, token: str | None = None, timeout: int = 10):
|
||||||
|
url = f"https://api.github.com{path}"
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": "xteink-github-sync-patcher",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
ctx = get_github_ssl_context()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||||
|
body = resp.read().decode("utf-8", errors="replace")
|
||||||
|
data = json.loads(body) if body else {}
|
||||||
|
return resp.status, data, None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
data = json.loads(body) if body else {}
|
||||||
|
except Exception:
|
||||||
|
data = {"message": body.strip()} if body else {}
|
||||||
|
return e.code, data, None
|
||||||
|
except Exception as e:
|
||||||
|
return None, None, str(e)
|
||||||
|
|
||||||
|
def validate_github_username(username: str) -> tuple[bool, str]:
|
||||||
|
status, data, err_txt = github_api_get(f"/users/{username}")
|
||||||
|
if err_txt:
|
||||||
|
return False, f"Could not reach GitHub API: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
|
||||||
|
if status == 200:
|
||||||
|
return True, "GitHub username is reachable."
|
||||||
|
if status == 404:
|
||||||
|
return False, "Username not found on GitHub."
|
||||||
|
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
|
||||||
|
return False, f"GitHub API error ({status}): {msg}"
|
||||||
|
|
||||||
|
def validate_pat(token: str) -> tuple[bool, str, str | None]:
|
||||||
|
status, data, err_txt = github_api_get("/user", token=token)
|
||||||
|
if err_txt:
|
||||||
|
return False, f"Could not validate PAT: {err_txt}{ssl_troubleshoot_hint(err_txt)}", None
|
||||||
|
if status == 200 and isinstance(data, dict):
|
||||||
|
login = data.get("login")
|
||||||
|
return True, f"PAT is valid (authenticated as {login}).", login
|
||||||
|
if status in (401, 403):
|
||||||
|
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
|
||||||
|
return False, f"PAT rejected: {msg}", None
|
||||||
|
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
|
||||||
|
return False, f"PAT validation failed ({status}): {msg}", None
|
||||||
|
|
||||||
|
def validate_repo_access(owner: str, repo: str, token: str) -> tuple[bool, str]:
|
||||||
|
status, data, err_txt = github_api_get(f"/repos/{owner}/{repo}", token=token)
|
||||||
|
if err_txt:
|
||||||
|
return False, f"Could not validate repo access: {err_txt}{ssl_troubleshoot_hint(err_txt)}"
|
||||||
|
if status == 200:
|
||||||
|
return True, "Repo is reachable with this PAT."
|
||||||
|
if status == 404:
|
||||||
|
return False, "Repo not found, or PAT cannot access it."
|
||||||
|
if status in (401, 403):
|
||||||
|
msg = data.get("message", "Unauthorized") if isinstance(data, dict) else "Unauthorized"
|
||||||
|
return False, f"Access denied: {msg}"
|
||||||
|
msg = data.get("message", "Unknown API error") if isinstance(data, dict) else "Unknown API error"
|
||||||
|
return False, f"Repo validation failed ({status}): {msg}"
|
||||||
|
|
||||||
|
def prompt_install(pip_package: str):
|
||||||
|
warn(f"{pip_package} is not installed or not available to this Python.")
|
||||||
|
input(f" Press Enter to install {pip_package}... ")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", pip_package],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err(f"Failed to install {pip_package}:\n{result.stderr.strip()}")
|
||||||
|
ok(f"{pip_package} installed")
|
||||||
|
|
||||||
|
def check_tool(cmd: list[str], pip_package: str, fatal: bool = True) -> bool:
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
if fatal:
|
||||||
|
prompt_install(pip_package)
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
err(f"{cmd[0]} still not found after install — check your PATH")
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_git():
|
||||||
|
try:
|
||||||
|
subprocess.run(["git", "--version"], check=True, capture_output=True)
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
err("git is not installed. Install from https://git-scm.com and re-run.")
|
||||||
|
|
||||||
|
def check_pio():
|
||||||
|
check_tool(["pio", "--version"], "platformio")
|
||||||
|
|
||||||
|
def check_esptool():
|
||||||
|
return check_tool(["esptool.py", "version"], "esptool", fatal=False)
|
||||||
|
|
||||||
|
def check_nvs_gen() -> bool:
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True
|
||||||
|
check_tool(["python3", "-m", "esp_idf_nvs_partition_gen", "--help"], "esp-idf-nvs-partition-gen", fatal=False)
|
||||||
|
result2 = subprocess.run(
|
||||||
|
["python3", "-m", "esp_idf_nvs_partition_gen", "--help"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return result2.returncode == 0
|
||||||
|
|
||||||
|
def clone_or_update(dest: Path) -> Path:
|
||||||
|
repo = dest / "crosspoint-reader"
|
||||||
|
if repo.exists() and (repo / ".git").exists():
|
||||||
|
info(f"Repo already exists at {repo}, pulling latest...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "-C", str(repo), "pull", "--recurse-submodules"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err(f"git pull failed:\n{result.stderr}")
|
||||||
|
ok("Repo updated to latest")
|
||||||
|
else:
|
||||||
|
info(f"Cloning CrossPoint into {repo}...")
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "clone", "--recurse-submodules", CROSSPOINT_URL, str(repo)],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err(f"git clone failed:\n{result.stderr}")
|
||||||
|
ok("Repo cloned successfully")
|
||||||
|
return repo
|
||||||
|
|
||||||
|
def prompt_github_config() -> dict:
|
||||||
|
print(f"\n{BOLD}GitHub Sync Configuration{RESET}")
|
||||||
|
print(f"{CYAN} Press Enter to skip and configure on-device later.{RESET}\n")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
username = input(" GitHub username: ").strip()
|
||||||
|
if not username:
|
||||||
|
return {}
|
||||||
|
ensure_github_https_dependencies()
|
||||||
|
ok_user, msg = validate_github_username(username)
|
||||||
|
if ok_user:
|
||||||
|
ok(msg)
|
||||||
|
break
|
||||||
|
warn(msg)
|
||||||
|
retry = input(" Try another username? [Y/n]: ").strip().lower()
|
||||||
|
if retry == "n":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print(f"\n{CYAN} To generate a Personal Access Token (PAT):{RESET}")
|
||||||
|
print(f"{CYAN} 1. Go to github.com -> Settings -> Developer settings{RESET}")
|
||||||
|
print(f"{CYAN} 2. Personal access tokens -> Fine-grained tokens{RESET}")
|
||||||
|
print(f"{CYAN} 3. Click 'Generate new token'{RESET}")
|
||||||
|
print(f"{CYAN} 4. Token name: xteink{RESET}")
|
||||||
|
print(f"{CYAN} 5. Expiration: No expiration{RESET}")
|
||||||
|
print(f"{CYAN} 6. Repository access: Only selected repositories -> select 'xteink'{RESET}")
|
||||||
|
print(f"{CYAN} 7. Permissions -> Add permissions -> Contents: Read-only{RESET}")
|
||||||
|
print(f"{CYAN} 8. Click 'Generate token' then copy it - GitHub only shows it once\n{RESET}")
|
||||||
|
|
||||||
|
authenticated_login = None
|
||||||
|
while True:
|
||||||
|
pat = getpass.getpass(" Personal Access Token (PAT): ").strip()
|
||||||
|
if not pat:
|
||||||
|
return {}
|
||||||
|
ok_pat, msg, authenticated_login = validate_pat(pat)
|
||||||
|
if ok_pat:
|
||||||
|
ok(msg)
|
||||||
|
break
|
||||||
|
warn(msg)
|
||||||
|
retry = input(" Try entering PAT again? [Y/n]: ").strip().lower()
|
||||||
|
if retry == "n":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
repo = input(" Repo name [xteink]: ").strip() or "xteink"
|
||||||
|
owner_for_repo = authenticated_login or username
|
||||||
|
ok_repo, msg = validate_repo_access(owner_for_repo, repo, pat)
|
||||||
|
if ok_repo:
|
||||||
|
ok(msg)
|
||||||
|
if authenticated_login and authenticated_login != username:
|
||||||
|
warn(f"Username '{username}' differs from PAT owner '{authenticated_login}'. Repo was validated under '{owner_for_repo}'.")
|
||||||
|
break
|
||||||
|
warn(msg)
|
||||||
|
retry = input(" Try another repo name? [Y/n]: ").strip().lower()
|
||||||
|
if retry == "n":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
branch = input(" Branch [main]: ").strip() or "main"
|
||||||
|
|
||||||
|
return {"username": username, "pat": pat, "repo": repo, "branch": branch}
|
||||||
|
|
||||||
|
def write_nvs_partition(cfg: dict, repo: Path) -> Path:
|
||||||
|
if not check_nvs_gen():
|
||||||
|
prompt_install("esp-idf-nvs-partition-gen")
|
||||||
|
|
||||||
|
nvs_csv = Path(tempfile.mkdtemp()) / "github_sync_nvs.csv"
|
||||||
|
rows = [
|
||||||
|
["key", "type", "encoding", "value"],
|
||||||
|
[NVS_NAMESPACE, "namespace", "", ""],
|
||||||
|
["username", "data", "string", cfg["username"]],
|
||||||
|
["pat", "data", "string", cfg["pat"]],
|
||||||
|
["repo", "data", "string", cfg["repo"]],
|
||||||
|
["branch", "data", "string", cfg["branch"]],
|
||||||
|
]
|
||||||
|
with open(nvs_csv, "w", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
nvs_bin = nvs_csv.with_suffix(".bin")
|
||||||
|
result = subprocess.run(
|
||||||
|
["python3", "-m", "esp_idf_nvs_partition_gen", "generate",
|
||||||
|
str(nvs_csv), str(nvs_bin), "0x3000"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
warn(f"nvs_partition_gen failed — enter credentials on-device via Settings -> GitHub Sync.\n{result.stderr.strip()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
ok(f"NVS partition written to {nvs_bin}")
|
||||||
|
return nvs_bin
|
||||||
|
|
||||||
|
def flash_nvs(nvs_bin: Path, port: str | None):
|
||||||
|
if not check_esptool():
|
||||||
|
prompt_install("esptool")
|
||||||
|
info("Flashing NVS credentials partition...")
|
||||||
|
cmd = ["esptool.py", "--chip", "esp32c3", "write_flash", "0x9000", str(nvs_bin)]
|
||||||
|
if port:
|
||||||
|
cmd += ["--port", port]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
warn("esptool.py flash failed — you may need to enter credentials on-device.")
|
||||||
|
warn(result.stderr.strip())
|
||||||
|
else:
|
||||||
|
ok("Credentials flashed to device NVS")
|
||||||
|
|
||||||
|
def copy_new_files(repo: Path):
|
||||||
|
copies = [
|
||||||
|
(PATCH_DIR / "GitHubSync.h",
|
||||||
|
repo / "include" / "GitHubSync.h"),
|
||||||
|
(PATCH_DIR / "GitHubSyncSettingsActivity.h",
|
||||||
|
repo / "include" / "GitHubSyncSettingsActivity.h"),
|
||||||
|
(PATCH_DIR / "GitHubSync.cpp",
|
||||||
|
repo / "src" / "github_sync" / "GitHubSync.cpp"),
|
||||||
|
(PATCH_DIR / "GitHubSyncSettingsActivity.cpp",
|
||||||
|
repo / "src" / "activities" / "settings" / "GitHubSyncSettingsActivity.cpp"),
|
||||||
|
]
|
||||||
|
for src, dst in copies:
|
||||||
|
if not src.exists():
|
||||||
|
err(f"Patch file missing: {src}")
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
ok(f"Copied {dst.relative_to(repo)}")
|
||||||
|
|
||||||
|
def inject_into_file(path: Path, marker: str, injection: str, after: bool = True, once: bool = True):
|
||||||
|
if not path.exists():
|
||||||
|
warn(f"File not found, skipping: {path}")
|
||||||
|
return False
|
||||||
|
text = path.read_text()
|
||||||
|
if injection.strip() in text:
|
||||||
|
ok(f"Already patched: {path.name}")
|
||||||
|
return True
|
||||||
|
if marker not in text:
|
||||||
|
warn(f"Marker not found in {path.name}: {repr(marker)}")
|
||||||
|
warn(f" → Add manually per INTEGRATION.md")
|
||||||
|
return False
|
||||||
|
if after:
|
||||||
|
new_text = text.replace(marker, marker + "\n" + injection, 1 if once else -1)
|
||||||
|
else:
|
||||||
|
new_text = text.replace(marker, injection + "\n" + marker, 1 if once else -1)
|
||||||
|
path.write_text(new_text)
|
||||||
|
ok(f"Patched {path.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def patch_main(repo: Path):
|
||||||
|
candidates = [repo / "src" / "main.cpp", repo / "src" / "Main.cpp"]
|
||||||
|
main = next((p for p in candidates if p.exists()), None)
|
||||||
|
if not main:
|
||||||
|
warn("Could not find main.cpp — add GitHub sync call manually per INTEGRATION.md")
|
||||||
|
return
|
||||||
|
|
||||||
|
inject_into_file(main,
|
||||||
|
marker='#include "CrossPointSettings.h"',
|
||||||
|
injection='#include "GitHubSync.h"',
|
||||||
|
after=False)
|
||||||
|
|
||||||
|
sync_injection = (
|
||||||
|
'\n if (GitHubSync::isConfigured()) {\n'
|
||||||
|
' GitHubSyncResult result = GitHubSync::sync();\n'
|
||||||
|
' if (result != GitHubSyncResult::OK) {\n'
|
||||||
|
' LOG_ERR("SYNC", "%s", GitHubSync::resultMessage(result));\n'
|
||||||
|
' }\n'
|
||||||
|
' }\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
inject_into_file(main,
|
||||||
|
marker="activityManager.goToBoot();",
|
||||||
|
injection=sync_injection,
|
||||||
|
after=True)
|
||||||
|
|
||||||
|
def patch_settings_menu(repo: Path):
|
||||||
|
# 1. Patch SettingsActivity.h — add GitHubSync to SettingAction enum
|
||||||
|
settings_h_candidates = list(repo.rglob("SettingsActivity.h"))
|
||||||
|
if not settings_h_candidates:
|
||||||
|
warn("Could not find SettingsActivity.h — add GitHubSync to SettingAction enum manually")
|
||||||
|
else:
|
||||||
|
inject_into_file(settings_h_candidates[0],
|
||||||
|
marker=" CheckForUpdates,",
|
||||||
|
injection=" GitHubSync,",
|
||||||
|
after=True)
|
||||||
|
|
||||||
|
# 2. Patch SettingsActivity.cpp — add include, menu entry, and switch case
|
||||||
|
candidates = list(repo.rglob("SettingsActivity.cpp"))
|
||||||
|
if not candidates:
|
||||||
|
warn("Could not find SettingsActivity.cpp — add menu entry manually per INTEGRATION.md")
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = candidates[0]
|
||||||
|
|
||||||
|
inject_into_file(settings,
|
||||||
|
marker='#include "SettingsActivity.h"',
|
||||||
|
injection='#include "GitHubSyncSettingsActivity.h"',
|
||||||
|
after=False)
|
||||||
|
|
||||||
|
inject_into_file(settings,
|
||||||
|
marker="SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates));",
|
||||||
|
injection=" systemSettings.push_back(SettingInfo::Action(StrId::STR_GITHUB_SYNC, SettingAction::GitHubSync));",
|
||||||
|
after=True)
|
||||||
|
|
||||||
|
inject_into_file(settings,
|
||||||
|
marker="case SettingAction::CheckForUpdates:",
|
||||||
|
injection=" case SettingAction::GitHubSync:\n startActivityForResult(std::make_unique<GitHubSyncSettingsActivity>(renderer, mappedInput), resultHandler);\n break;",
|
||||||
|
after=True)
|
||||||
|
|
||||||
|
# 3. Patch all YAML translation files — add STR_GITHUB_SYNC entry
|
||||||
|
yaml_files = list((repo / "lib" / "I18n" / "translations").glob("*.yaml"))
|
||||||
|
if not yaml_files:
|
||||||
|
warn("Could not find translation YAML files — add STR_GITHUB_SYNC manually to lib/I18n/translations/*.yaml")
|
||||||
|
else:
|
||||||
|
patched = 0
|
||||||
|
for yf in yaml_files:
|
||||||
|
content = yf.read_text()
|
||||||
|
if 'STR_GITHUB_SYNC' in content:
|
||||||
|
ok(f"Already patched: {yf.name}")
|
||||||
|
patched += 1
|
||||||
|
continue
|
||||||
|
lines = content.splitlines(keepends=True)
|
||||||
|
new_lines = []
|
||||||
|
for line in lines:
|
||||||
|
new_lines.append(line)
|
||||||
|
if line.startswith("STR_CHECK_UPDATES:"):
|
||||||
|
new_lines.append('STR_GITHUB_SYNC: "GitHub Sync"\n')
|
||||||
|
if len(new_lines) > len(lines):
|
||||||
|
yf.write_text("".join(new_lines))
|
||||||
|
ok(f"Patched {yf.name}")
|
||||||
|
patched += 1
|
||||||
|
else:
|
||||||
|
warn(f"STR_CHECK_UPDATES not found in {yf.name} — add STR_GITHUB_SYNC manually")
|
||||||
|
ok(f"Added STR_GITHUB_SYNC to {patched} translation files")
|
||||||
|
|
||||||
|
def patch_platformio(repo: Path):
|
||||||
|
ini = repo / "platformio.ini"
|
||||||
|
if not ini.exists():
|
||||||
|
warn("platformio.ini not found")
|
||||||
|
return
|
||||||
|
if "ArduinoJson" in ini.read_text():
|
||||||
|
ok("ArduinoJson already in platformio.ini")
|
||||||
|
return
|
||||||
|
inject_into_file(ini,
|
||||||
|
marker="lib_deps",
|
||||||
|
injection="\tbblanchon/ArduinoJson @ ^7",
|
||||||
|
after=True)
|
||||||
|
|
||||||
|
def list_likely_serial_ports() -> list[str]:
|
||||||
|
patterns = [
|
||||||
|
"/dev/cu.usb*",
|
||||||
|
"/dev/tty.usb*",
|
||||||
|
"/dev/cu.wchusb*",
|
||||||
|
"/dev/tty.wchusb*",
|
||||||
|
"/dev/cu.SLAB*",
|
||||||
|
"/dev/tty.SLAB*",
|
||||||
|
"/dev/ttyACM*",
|
||||||
|
"/dev/ttyUSB*",
|
||||||
|
]
|
||||||
|
ports: list[str] = []
|
||||||
|
for pattern in patterns:
|
||||||
|
ports.extend(glob.glob(pattern))
|
||||||
|
# Keep stable order and remove duplicates.
|
||||||
|
return sorted(set(ports))
|
||||||
|
|
||||||
|
def prompt_for_upload_port(existing_port: str | None) -> str | None:
|
||||||
|
print(f"\n{BOLD}Ready to upload firmware{RESET}")
|
||||||
|
print(f"{CYAN} Connect your xteink device via USB now, then press Enter.{RESET}")
|
||||||
|
input(" Press Enter when connected... ")
|
||||||
|
|
||||||
|
likely_ports = list_likely_serial_ports()
|
||||||
|
if likely_ports:
|
||||||
|
print("\n Detected likely USB serial ports:")
|
||||||
|
for p in likely_ports:
|
||||||
|
print(f" - {p}")
|
||||||
|
else:
|
||||||
|
warn("No obvious USB serial ports detected. You can still enter one manually.")
|
||||||
|
|
||||||
|
# Show PlatformIO's own device list as extra context.
|
||||||
|
device_list = subprocess.run(["pio", "device", "list"], capture_output=True, text=True)
|
||||||
|
if device_list.returncode == 0 and device_list.stdout.strip():
|
||||||
|
print("\n PlatformIO device list:")
|
||||||
|
print(device_list.stdout.rstrip())
|
||||||
|
|
||||||
|
if existing_port:
|
||||||
|
info(f"Current upload port argument: {existing_port}")
|
||||||
|
entered = input(" Upload port (Enter to keep current): ").strip()
|
||||||
|
return entered or existing_port
|
||||||
|
|
||||||
|
entered = input(" Upload port (recommended, e.g. /dev/cu.usbmodemXXXX; Enter for auto-detect): ").strip()
|
||||||
|
if entered:
|
||||||
|
return entered
|
||||||
|
|
||||||
|
warn("Proceeding with auto-detect. This may choose a non-USB port (like Bluetooth).")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"\n{BOLD}CrossPoint GitHub Sync Patcher{RESET}\n")
|
||||||
|
|
||||||
|
check_git()
|
||||||
|
check_pio()
|
||||||
|
check_esptool()
|
||||||
|
|
||||||
|
dest = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
|
||||||
|
port = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
print(f"{BOLD}1. Fetching CrossPoint repo ({CROSSPOINT_URL}){RESET}")
|
||||||
|
repo = clone_or_update(dest)
|
||||||
|
info(f"Repo: {repo}\n")
|
||||||
|
|
||||||
|
print(f"{BOLD}2. Copying new files{RESET}")
|
||||||
|
copy_new_files(repo)
|
||||||
|
|
||||||
|
print(f"\n{BOLD}3. Patching platformio.ini{RESET}")
|
||||||
|
patch_platformio(repo)
|
||||||
|
|
||||||
|
print(f"\n{BOLD}4. Patching main.cpp{RESET}")
|
||||||
|
patch_main(repo)
|
||||||
|
|
||||||
|
print(f"\n{BOLD}5. Patching settings menu{RESET}")
|
||||||
|
patch_settings_menu(repo)
|
||||||
|
|
||||||
|
print(f"\n{BOLD}6. GitHub credentials{RESET}")
|
||||||
|
cfg = prompt_github_config()
|
||||||
|
nvs_bin = None
|
||||||
|
if cfg:
|
||||||
|
nvs_bin = write_nvs_partition(cfg, repo)
|
||||||
|
|
||||||
|
port = prompt_for_upload_port(port)
|
||||||
|
|
||||||
|
print(f"\n{BOLD}7. Building and flashing firmware{RESET}")
|
||||||
|
info("Running: pio run --target upload")
|
||||||
|
result = subprocess.run(
|
||||||
|
["pio", "run", "--target", "upload"] + (["--upload-port", port] if port else []),
|
||||||
|
cwd=repo
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
err("pio build/flash failed — check output above")
|
||||||
|
ok("Firmware flashed")
|
||||||
|
|
||||||
|
if nvs_bin:
|
||||||
|
print(f"\n{BOLD}8. Flashing credentials{RESET}")
|
||||||
|
flash_nvs(nvs_bin, port)
|
||||||
|
|
||||||
|
print(f"\n{GREEN}{BOLD}All done.{RESET}")
|
||||||
|
if not cfg:
|
||||||
|
print("No credentials entered — configure on-device via Settings → GitHub Sync.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user