diff --git a/WilliamsTube_MacroAtlas.png b/WilliamsTube_MacroAtlas.png index 8d7b199..38e5298 100644 Binary files a/WilliamsTube_MacroAtlas.png and b/WilliamsTube_MacroAtlas.png differ diff --git a/generate_macro_atlas.py b/generate_macro_atlas.py index c64af7b..2102f8b 100644 --- a/generate_macro_atlas.py +++ b/generate_macro_atlas.py @@ -107,7 +107,7 @@ ] TILE_LABELS_P2 = [ - ["DBG", "TWTCH", "INTRP", "-----", "DM"], + ["DBG", "TWTCH", "INTRP", "SHOCK", "DM"], ["-----", "-----", "-----", "-----", "-----"], ["-----", "-----", "-----", "-----", "-----"], ] @@ -806,6 +806,38 @@ def layout_dm_compose(buf): buf.put_status_bar() +def layout_shock(buf): + """Shock control screen: frame + labels + Execute button.""" + buf.put_frame("SHOCK") + + # Row 1 (ty=0): Shocker Selection + buf.put_text(3, 1, " < ", inverted=True) # tx=0 + buf.put_text(34, 1, " > ", inverted=True) # tx=4 + + # Row 2: Separator + buf.put_hline(2) + + # Row 3: Status + buf.put_text(4, 3, "ACTIVE MODE:") + + # Row 4 (ty=1): Intensity & Duration on same line + # tx=0: Int -, tx=2: Int +, tx=3: Dur -, tx=4: Dur + + buf.put_text(2, 4, " - ", inverted=True) # tx=0 + buf.put_text(16, 4, " + ", inverted=True) # tx=2 + buf.put_text(24, 4, " - ", inverted=True) # tx=3 + buf.put_text(34, 4, " + ", inverted=True) # tx=4 + + # Row 5 : Separator + buf.put_hline(5) + + # Row 6 (ty=2): Mode & Execute + # tx=0-1: MODE button (11 chars), tx=3-4: EXECUTE button (11 chars) + buf.put_text(2, 6, " [ MODE ] ", inverted=True) # tx=0-1 + buf.put_text(25, 6, " [ EXECUTE ] ", inverted=True) # tx=3-4 + + buf.put_status_bar() + + def layout_dm_pair(buf): """DM Pair CHOOSE mode: frame + DIAL/SCAN buttons + hint text.""" buf.put_frame("DM PAIR") @@ -909,6 +941,7 @@ def layout_dm_pair_joined(buf): 45: ("PAIR OK", layout_dm_pair_complete), 46: ("PAIR FAIL", layout_dm_pair_failed), 47: ("PAIR JOIN", layout_dm_pair_joined), + 48: ("SHOCK", layout_shock), } diff --git a/yip_os/CMakeLists.txt b/yip_os/CMakeLists.txt index bb847d2..2e4fd66 100644 --- a/yip_os/CMakeLists.txt +++ b/yip_os/CMakeLists.txt @@ -148,7 +148,11 @@ set(YIPOS_SOURCES src/screens/DMPairScreen.cpp src/screens/DMComposeScreen.cpp src/screens/DMMessageScreen.cpp + src/screens/ShockScreen.cpp src/net/DMClient.cpp + src/net/OpenShockClient.cpp + src/net/PiShockClient.cpp + src/net/ShockManager.cpp src/img/QRGen.cpp src/translate/TranslationWorker.cpp src/img/VQEncoder.cpp @@ -164,6 +168,7 @@ set(YIPOS_SOURCES src/ui/UIManager_Data.cpp src/ui/UIManager_Config.cpp src/ui/UIManager_DM.cpp + src/ui/UIManager_Shock.cpp ) # Platform-specific sources diff --git a/yip_os/src/app/PDAController.cpp b/yip_os/src/app/PDAController.cpp index 2c12d9e..922daf9 100644 --- a/yip_os/src/app/PDAController.cpp +++ b/yip_os/src/app/PDAController.cpp @@ -12,6 +12,7 @@ #include "net/TwitchClient.hpp" #include "media/MediaController.hpp" #include "platform/SystemStats.hpp" +#include "net/ShockManager.hpp" #include "core/Glyphs.hpp" #include "core/Config.hpp" #include "core/Logger.hpp" @@ -97,6 +98,10 @@ PDAController::PDAController(PDADisplay& display, NetTracker& net_tracker, Confi } } + // Initialize shock manager + shock_manager_ = std::make_unique(); + shock_manager_->InitFromConfig(config_); + // Push home screen as root auto home = std::make_unique(*this); screen_stack_.push_back(std::move(home)); @@ -819,4 +824,4 @@ void PDAController::RefreshStockCache() { stock_client_->FetchAll(window); } -} // namespace YipOS +} // namespace YipOS \ No newline at end of file diff --git a/yip_os/src/app/PDAController.hpp b/yip_os/src/app/PDAController.hpp index d1eb1dc..35b93f6 100644 --- a/yip_os/src/app/PDAController.hpp +++ b/yip_os/src/app/PDAController.hpp @@ -34,6 +34,7 @@ class StockClient; class TwitchClient; struct TwitchMessage; class TranslationWorker; +class ShockManager; class PDAController { public: @@ -141,6 +142,9 @@ class PDAController { void RefreshChatCache(); void MarkChatSeen(); + // OpenShock & PiShock integration + ShockManager* GetShockManager() { return shock_manager_.get(); } + // Hard lock (full LOCK screen from home tile) void SetLocked(bool locked); bool IsLocked() const { return locked_; } @@ -231,6 +235,7 @@ class PDAController { std::unique_ptr stock_client_; std::unique_ptr twitch_client_; const TwitchMessage* selected_twitch_ = nullptr; + std::unique_ptr shock_manager_; std::string assets_path_; std::unique_ptr dm_notify_sound_; bool prev_has_unseen_dm_ = false; @@ -292,4 +297,4 @@ class PDAController { std::array, SPVR_DEVICE_COUNT> spvr_status_{}; }; -} // namespace YipOS +} // namespace YipOS \ No newline at end of file diff --git a/yip_os/src/core/Glyphs.hpp b/yip_os/src/core/Glyphs.hpp index 3b15d09..8c0396f 100644 --- a/yip_os/src/core/Glyphs.hpp +++ b/yip_os/src/core/Glyphs.hpp @@ -32,7 +32,7 @@ constexpr TileLabel TILE_LABELS[HOME_PAGES][TILE_ROWS][TILE_COLS] = { {{"VRCX"}, {"HEART"}, {"BFI"}, {"STONK"}, {"CHAT"}}, {{"CC"}, {"AVTR"}, {"TEXT"}, {"MEDIA"}, {"LOCK"}}}, // Page 1 - {{{"DBG"}, {"TWTCH"}, {"INTRP"}, {"-----"}, {"DM"}}, + {{{"DBG"}, {"TWTCH"}, {"INTRP"}, {"SHOCK"}, {"DM"}}, {{"-----"}, {"-----"}, {"-----"}, {"-----"}, {"-----"}}, {{"-----"}, {"-----"}, {"-----"}, {"-----"}, {"-----"}}}, }; diff --git a/yip_os/src/net/IShockClient.hpp b/yip_os/src/net/IShockClient.hpp new file mode 100644 index 0000000..08c5b85 --- /dev/null +++ b/yip_os/src/net/IShockClient.hpp @@ -0,0 +1,45 @@ +/** + * IShockClient.hpp + * V1.0.0 + * + * Interface for Shock API integration to YipOS for remote control of + * OpenShock and PiShock devices. + * + * By otter_oasis + */ + +#pragma once + +#include +#include + +namespace YipOS { + +struct Shocker { + std::string id; + std::string name; + bool is_owned = false; + std::string backend; // "openshock" or "pishock" +}; + +class IShockClient { +public: + virtual ~IShockClient() = default; + + virtual void SetEnabled(bool enabled) = 0; + virtual bool HasConfig() const = 0; + virtual bool IsTokenValid() const = 0; + virtual bool IsEnabled() const = 0; + + virtual bool FetchShockers() = 0; + virtual const std::vector &GetShockers() const = 0; + + virtual bool SendControl(const std::string &shocker_id, + const std::string &type, float intensity, + int duration_ms) = 0; + + virtual int GetMinDurationMs() const = 0; + virtual int GetMaxDurationMs() const = 0; +}; + +} // namespace YipOS diff --git a/yip_os/src/net/OpenShockClient.cpp b/yip_os/src/net/OpenShockClient.cpp new file mode 100644 index 0000000..650793a --- /dev/null +++ b/yip_os/src/net/OpenShockClient.cpp @@ -0,0 +1,231 @@ +/** + * OpenShockClient.cpp + * V1.0.0 + * + * Adds OpenShock API integration to YipOS for remote control of OpenShock + * devices. + * + * By otter_oasis + */ + +#include "OpenShockClient.hpp" +#include "core/Logger.hpp" +#include +#include + +namespace YipOS { + +using json = nlohmann::json; + +static size_t WriteCallback(void *contents, size_t size, size_t nmemb, + void *userp) { + auto *str = static_cast(userp); + str->append(static_cast(contents), size * nmemb); + return size * nmemb; +} + +OpenShockClient::OpenShockClient() { + curl_global_init(CURL_GLOBAL_ALL); + curl_ = curl_easy_init(); +} + +OpenShockClient::~OpenShockClient() { + if (curl_) + curl_easy_cleanup(curl_); + curl_global_cleanup(); +} + +bool OpenShockClient::FetchShockers() { + if (!enabled_ || !HasConfig()) { + Logger::Debug("OpenShockClient: Skipping fetch (disabled or no token)"); + shockers_.clear(); + return true; + } + + Logger::Info("OpenShockClient: Fetching shockers"); + Logger::Info("OpenShockClient: Authenticating with Token"); + shockers_.clear(); + std::string response; + + // Fetch owned shockers + Logger::Info("OpenShockClient: Requesting own devices"); + if (PerformGet(std::string(API_BASE) + "/1/shockers/own", response)) { + ParseShockers(response, true); + } + + // Fetch shared shockers + response.clear(); + Logger::Info("OpenShockClient: Requesting shared devices"); + if (PerformGet(std::string(API_BASE) + "/1/shockers/shared", response)) { + ParseShockers(response, false); + } + + Logger::Debug("OpenShockClient: Fetched " + std::to_string(shockers_.size()) + + " shockers"); + return true; +} + +bool OpenShockClient::ParseShockers(const std::string &json_str, + bool is_owned) { + try { + auto j = json::parse(json_str); + if (!j.contains("data") || !j["data"].is_array()) + return false; + + for (auto &hub : j["data"]) { + std::string hub_name = + hub.contains("name") ? hub["name"].get() : "Hub"; + if (hub.contains("shockers") && hub["shockers"].is_array()) { + for (auto &item : hub["shockers"]) { + Shocker s; + s.id = item["id"].get(); + std::string shocker_name = item.contains("name") + ? item["name"].get() + : "Shocker"; + s.name = "[" + hub_name + "] " + shocker_name; + s.is_owned = is_owned; + s.backend = "openshock"; + shockers_.push_back(s); + } + } + } + return true; + } catch (const std::exception &e) { + Logger::Warning("OpenShockClient: JSON parse error: " + + std::string(e.what())); + return false; + } +} + +bool OpenShockClient::SendControl(const std::string &shocker_id, + const std::string &type, float intensity, + int duration_ms) { + if (!HasConfig()) { + return false; + } + + // Map string type to OpenShock.Common.Models.ControlType for API + // 0=Stop, 1=Shock, 2=Vibrate, 3=Sound + int type_int = 2; // Default to Vibe for safety + if (type.find("SHOCK") != std::string::npos) + type_int = 1; + else if (type.find("VIBE") != std::string::npos) + type_int = 2; + else if (type.find("SOUND") != std::string::npos) + type_int = 3; + + json payload = { + {"shocks", json::array({{{"id", shocker_id}, + {"type", type_int}, + {"intensity", static_cast(intensity)}, + {"duration", duration_ms}, + {"exclusive", true}}})}}; + + Logger::Info("OpenShock: Sending " + type + " (" + + std::to_string(static_cast(intensity)) + "%, " + + std::to_string(duration_ms) + "ms) to " + shocker_id); + + return PerformPost(std::string(API_BASE) + "/2/shockers/control", + payload.dump()); +} + +void OpenShockClient::SetToken(const std::string &token) { + if (token_ != token) { + token_ = token; + token_valid_ = false; + } +} + +bool OpenShockClient::PerformGet(const std::string &url, + std::string &response) { + if (!curl_) + return false; + + curl_easy_reset(curl_); + curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl_, CURLOPT_USERAGENT, USER_AGENT); + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, ("OpenShockToken: " + token_).c_str()); + curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl_); + curl_slist_free_all(headers); + + if (res != CURLE_OK) { + Logger::Warning("OpenShockClient: GET failed: " + + std::string(curl_easy_strerror(res))); + return false; + } + + long http_code = 0; + curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code >= 200 && http_code < 300) { + token_valid_ = true; + } else { + if (http_code == 401) + token_valid_ = false; + Logger::Warning("OpenShockClient: GET " + url + " failed (HTTP " + + std::to_string(http_code) + ")"); + if (!response.empty()) { + Logger::Warning("OpenShockClient: Response: " + response); + } + } + + return http_code == 200; +} + +bool OpenShockClient::PerformPost(const std::string &url, + const std::string &payload) { + if (!curl_) + return false; + + std::string response; + curl_easy_reset(curl_); + curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, payload.c_str()); + curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl_, CURLOPT_USERAGENT, USER_AGENT); + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = curl_slist_append(headers, ("OpenShockToken: " + token_).c_str()); + curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl_); + curl_slist_free_all(headers); + + if (res != CURLE_OK) { + Logger::Warning("OpenShockClient: POST failed: " + + std::string(curl_easy_strerror(res))); + return false; + } + + long http_code = 0; + curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code >= 200 && http_code < 300) { + token_valid_ = true; + Logger::Info("OpenShockClient: Command sent successfully (HTTP " + + std::to_string(http_code) + ")"); + } else { + if (http_code == 401) + token_valid_ = false; + Logger::Warning("OpenShockClient: POST " + url + " failed (HTTP " + + std::to_string(http_code) + ")"); + if (!response.empty()) { + Logger::Warning("OpenShockClient: Response: " + response); + } + return false; + } + + return true; +} + +} // namespace YipOS diff --git a/yip_os/src/net/OpenShockClient.hpp b/yip_os/src/net/OpenShockClient.hpp new file mode 100644 index 0000000..39fa621 --- /dev/null +++ b/yip_os/src/net/OpenShockClient.hpp @@ -0,0 +1,59 @@ +/** + * OpenShockClient.hpp + * V1.0.0 + * + * Adds OpenShock API integration to YipOS for remote control of OpenShock + * devices. + * + * By otter_oasis + */ + +#pragma once + +#include "IShockClient.hpp" +#include +#include + +typedef void CURL; + +namespace YipOS { + +class OpenShockClient : public IShockClient { +public: + OpenShockClient(); + ~OpenShockClient() override; + + void SetToken(const std::string &token); + void SetEnabled(bool enabled) override { enabled_ = enabled; } + bool IsEnabled() const override { return enabled_; } + bool HasConfig() const override { return !token_.empty(); } + bool IsTokenValid() const override { return token_valid_; } + + // Fetch list of shockers (owned and shared) + bool FetchShockers() override; + const std::vector &GetShockers() const override { return shockers_; } + + // Control shockers + // type: "Shock", "Vibrate", "Sound", "Stop" + bool SendControl(const std::string &shocker_id, const std::string &type, + float intensity, int duration_ms) override; + + int GetMinDurationMs() const override { return 100; } + int GetMaxDurationMs() const override { return 30000; } + +private: + bool ParseShockers(const std::string &json, bool is_owned); + bool PerformPost(const std::string &url, const std::string &payload); + bool PerformGet(const std::string &url, std::string &response); + + CURL *curl_ = nullptr; + std::string token_; + bool token_valid_ = false; + bool enabled_ = true; + std::vector shockers_; + + static constexpr const char *API_BASE = "https://api.openshock.app"; + static constexpr const char *USER_AGENT = "YipOS/1.0"; +}; + +} // namespace YipOS diff --git a/yip_os/src/net/PiShockClient.cpp b/yip_os/src/net/PiShockClient.cpp new file mode 100644 index 0000000..7334d7e --- /dev/null +++ b/yip_os/src/net/PiShockClient.cpp @@ -0,0 +1,383 @@ +/** + * PiShockClient.cpp + * V1.0.0 + * + * Adds PiShock API integration to YipOS for remote control of PiShock + * devices. + */ + +#include "PiShockClient.hpp" +#include "core/Logger.hpp" +#include +#include +#include + +namespace YipOS { + +using json = nlohmann::json; + +static size_t WriteCallback(void *contents, size_t size, size_t nmemb, + void *userp) { + auto *str = static_cast(userp); + str->append(static_cast(contents), size * nmemb); + return size * nmemb; +} + +PiShockClient::PiShockClient() { + curl_global_init(CURL_GLOBAL_ALL); + curl_ = curl_easy_init(); +} + +PiShockClient::~PiShockClient() { + if (curl_) + curl_easy_cleanup(curl_); + curl_global_cleanup(); +} + +void PiShockClient::SetCredentials(const std::string &username, + const std::string &apikey) { + if (username_ != username || apikey_ != apikey) { + username_ = username; + apikey_ = apikey; + token_valid_ = false; + } +} + +std::string PiShockClient::URLEncode(const std::string &value) { + if (!curl_) + return value; + + char *output = + curl_easy_escape(curl_, value.c_str(), static_cast(value.length())); + if (output) { + std::string result(output); + curl_free(output); + return result; + } + return value; +} + +bool PiShockClient::FetchShockers() { + if (!enabled_ || !HasConfig()) { + Logger::Debug("PiShockClient: Skipping fetch (disabled or no credentials)"); + shockers_.clear(); + return true; + } + + Logger::Info("PiShockClient: Fetching shockers"); + + shockers_.clear(); + std::string response; + + // 1. Resolve Username to Numeric UserId + // Following doc: + // https://auth.pishock.com/Auth/GetUserIfAPIKeyValid?apikey={apikey}&username={username} + std::string encoded_user = URLEncode(username_); + std::string encoded_key = URLEncode(apikey_); + + std::string auth_url = + "https://auth.pishock.com/Auth/GetUserIfAPIKeyValid?apikey=" + + encoded_key + "&username=" + encoded_user; + + std::string user_id = ""; + if (PerformGet(auth_url, response)) { + Logger::Debug("PiShockClient: Raw Auth Response: " + response); + + try { + auto j = json::parse(response); + + // Check for both "UserId" and "UserID" + if (j.contains("UserId") && !j["UserId"].is_null()) { + user_id = j["UserId"].is_number() + ? std::to_string(j["UserId"].get()) + : j["UserId"].get(); + } else if (j.contains("UserID") && !j["UserID"].is_null()) { + user_id = j["UserID"].is_number() + ? std::to_string(j["UserID"].get()) + : j["UserID"].get(); + } + } catch (const std::exception &e) { + Logger::Warning("PiShockClient: JSON parse error: " + + std::string(e.what())); + } + } + + // If the ID is "0" or empty, the API Key/Username combo is likely wrong + if (user_id.empty() || user_id == "0") { + Logger::Error( + "PiShockClient: Auth failed. Check logs for Raw Auth Response."); + token_valid_ = false; + return false; + } + + Logger::Info("PiShockClient: Authenticating with UserID: " + user_id); + bool any_success = false; + + // Step 2: Fetch owned shockers + response.clear(); + Logger::Info("PiShockClient: Requesting own devices"); + std::string own_url = + "https://ps.pishock.com/PiShock/GetUserDevices?UserId=" + user_id + + "&Token=" + encoded_key + "&api=true"; + if (PerformGet(own_url, response)) { + if (ParseUserDevices(response)) + any_success = true; + } + + // Step 3: Fetch share codes + response.clear(); + Logger::Info("PiShockClient: Requesting shared devices"); + std::string codes_url = + "https://ps.pishock.com/PiShock/GetShareCodesByOwner?UserId=" + user_id + + "&Token=" + encoded_key + "&api=true"; + + if (PerformGet(codes_url, response)) { + Logger::Debug("PiShockClient: Raw Share Codes Response: " + response); + + std::vector codes; + try { + auto j = json::parse(response); + + // If it's an object like {"Name": [12345]} + if (j.is_object()) { + for (auto &element : j.items()) { + if (element.value().is_array()) { + for (const auto &code : element.value()) { + if (code.is_number()) { + codes.push_back(std::to_string(code.get())); + } else if (code.is_string()) { + codes.push_back(code.get()); + } + } + } + } + } + // Fallback for standard array format + else if (j.is_array()) { + for (const auto &item : j) { + if (item.is_string()) + codes.push_back(item.get()); + else if (item.is_object() && item.contains("Code")) { + codes.push_back(item["Code"].is_string() + ? item["Code"].get() + : std::to_string(item["Code"].get())); + } + } + } + } catch (const std::exception &e) { + Logger::Warning("PiShockClient: Error parsing share codes: " + + std::string(e.what())); + } + + if (!codes.empty()) { + // Step 4: Resolve share codes to device info + std::string resolve_url = + "https://ps.pishock.com/PiShock/GetShockersByShareIds?UserId=" + + user_id + "&Token=" + encoded_key + "&api=true"; + for (const auto &code : codes) { + resolve_url += "&shareIds=" + URLEncode(code); + } + + response.clear(); + if (PerformGet(resolve_url, response)) { + Logger::Debug("PiShockClient: Shared Devices Response: " + response); + if (ParseSharedDevices(response)) + any_success = true; + } + } + } + + token_valid_ = any_success; + Logger::Debug("PiShockClient: Fetched " + std::to_string(shockers_.size()) + + " shockers"); + return any_success; +} + +bool PiShockClient::ParseUserDevices(const std::string &json_str) { + try { + auto j = json::parse(json_str); + if (!j.is_array()) + return false; + + for (const auto &hub : j) { + std::string hub_name = + hub.contains("Name") ? hub["Name"].get() : "Hub"; + if (hub.contains("Shockers") && hub["Shockers"].is_array()) { + for (const auto &item : hub["Shockers"]) { + Shocker s; + s.id = item.contains("Code") ? item["Code"].get() : ""; + s.name = "[" + hub_name + "] " + + (item.contains("Name") ? item["Name"].get() + : "Shocker"); + s.is_owned = true; + s.backend = "pishock"; + if (!s.id.empty()) + shockers_.push_back(s); + } + } + } + return true; + } catch (const std::exception &e) { + Logger::Warning("PiShockClient: JSON parse error (user devices): " + + std::string(e.what())); + return false; + } +} + +bool PiShockClient::ParseSharedDevices(const std::string &json_str) { + try { + auto j = json::parse(json_str); + + if (!j.is_object()) + return false; + + bool found_any = false; + + // Iterate through the keys + for (auto &user_entry : j.items()) { + if (user_entry.value().is_array()) { + for (const auto &item : user_entry.value()) { + Shocker s; + + if (item.contains("shareCode")) { + s.id = item["shareCode"].get(); + } else if (item.contains("shareId")) { + s.id = item["shareId"].is_number() + ? std::to_string(item["shareId"].get()) + : item["shareId"].get(); + } + + s.name = item.contains("shockerName") + ? item["shockerName"].get() + : "Shared Shocker"; + s.is_owned = false; + s.backend = "pishock"; + + if (!s.id.empty()) { + shockers_.push_back(s); + found_any = true; + } + } + } + } + return found_any; + } catch (const std::exception &e) { + Logger::Warning("PiShockClient: JSON parse error (shared devices): " + + std::string(e.what())); + return false; + } +} + +bool PiShockClient::SendControl(const std::string &shocker_id, + const std::string &type, float intensity, + int duration_ms) { + if (!HasConfig()) + return false; + + // 0=Shock, 1=Vibrate, 2=Sound + int type_int = 1; + if (type.find("SHOCK") != std::string::npos) + type_int = 0; + else if (type.find("VIBE") != std::string::npos) + type_int = 1; + else if (type.find("SOUND") != std::string::npos) + type_int = 2; + + // PiShock requires duration in seconds (1-15 range) + int duration_s = std::clamp(static_cast(duration_ms / 1000), 1, 15); + int intensity_int = std::clamp(static_cast(intensity), 1, 100); + + json payload = {{"Username", username_}, // apioperate uses Username string + {"Apikey", apikey_}, {"Code", shocker_id}, + {"Name", "YipOS"}, {"Op", type_int}, + {"Duration", duration_s}, {"Intensity", intensity_int}}; + + Logger::Info("PiShock: Sending " + type + " (" + + std::to_string(intensity_int) + "%, " + + std::to_string(duration_s) + "s) to " + shocker_id); + + return PerformPost("https://do.pishock.com/api/apioperate/", payload.dump()); +} + +bool PiShockClient::PerformGet(const std::string &url, std::string &response) { + if (!curl_) + return false; + + curl_easy_reset(curl_); + curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl_, CURLOPT_USERAGENT, USER_AGENT); + curl_easy_setopt(curl_, CURLOPT_FOLLOWLOCATION, 1L); + + CURLcode res = curl_easy_perform(curl_); + + if (res != CURLE_OK) { + Logger::Warning("PiShockClient: GET failed: " + + std::string(curl_easy_strerror(res))); + return false; + } + + long http_code = 0; + curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code < 200 || http_code >= 300) { + Logger::Warning("PiShockClient: GET " + url + " failed (HTTP " + + std::to_string(http_code) + ")"); + return false; + } + + return true; +} + +bool PiShockClient::PerformPost(const std::string &url, + const std::string &payload) { + if (!curl_) + return false; + + std::string response; + curl_easy_reset(curl_); + curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, payload.c_str()); + curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, WriteCallback); + curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl_, CURLOPT_USERAGENT, USER_AGENT); + + struct curl_slist *headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers); + + CURLcode res = curl_easy_perform(curl_); + curl_slist_free_all(headers); + + if (res != CURLE_OK) { + Logger::Warning("PiShockClient: POST failed: " + + std::string(curl_easy_strerror(res))); + return false; + } + + // Handle PiShock-specific "Not Authorized" within HTTP 200 response + if (response.find("Not Authorized") != std::string::npos) { + token_valid_ = false; + Logger::Warning("PiShockClient: Auth failed (Not Authorized)"); + return false; + } + + long http_code = 0; + curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &http_code); + + if (http_code >= 200 && http_code < 300) { + token_valid_ = true; + Logger::Info("PiShockClient: Command sent successfully (HTTP " + + std::to_string(http_code) + ")"); + return true; + } + + Logger::Warning("PiShockClient: POST failed (HTTP " + + std::to_string(http_code) + ")"); + return false; +} + +} // namespace YipOS \ No newline at end of file diff --git a/yip_os/src/net/PiShockClient.hpp b/yip_os/src/net/PiShockClient.hpp new file mode 100644 index 0000000..bd20545 --- /dev/null +++ b/yip_os/src/net/PiShockClient.hpp @@ -0,0 +1,61 @@ +/** + * PiShockClient.hpp + * V1.0.0 + * + * Adds PiShock API integration to YipOS for remote control of PiShock + * devices. + * + * By otter_oasis + */ + +#pragma once + +#include "IShockClient.hpp" +#include +#include + +typedef void CURL; + +namespace YipOS { + +class PiShockClient : public IShockClient { +public: + PiShockClient(); + ~PiShockClient() override; + + void SetCredentials(const std::string &username, const std::string &apikey); + void SetEnabled(bool enabled) override { enabled_ = enabled; } + bool IsEnabled() const override { return enabled_; } + bool HasConfig() const override { + return !username_.empty() && !apikey_.empty(); + } + bool IsTokenValid() const override { return token_valid_; } + + bool FetchShockers() override; + const std::vector &GetShockers() const override { return shockers_; } + + bool SendControl(const std::string &shocker_id, const std::string &type, + float intensity, int duration_ms) override; + + int GetMinDurationMs() const override { return 1000; } + int GetMaxDurationMs() const override { return 15000; } + +private: + bool PerformPost(const std::string &url, const std::string &payload); + bool PerformGet(const std::string &url, std::string &response); + bool ParseUserDevices(const std::string &json_str); + bool ParseSharedDevices(const std::string &json_str); + + std::string URLEncode(const std::string &value); + + CURL *curl_ = nullptr; + std::string username_; + std::string apikey_; + bool token_valid_ = false; + bool enabled_ = true; + std::vector shockers_; + + static constexpr const char *USER_AGENT = "YipOS/1.0"; +}; + +} // namespace YipOS diff --git a/yip_os/src/net/ShockManager.cpp b/yip_os/src/net/ShockManager.cpp new file mode 100644 index 0000000..1897e8e --- /dev/null +++ b/yip_os/src/net/ShockManager.cpp @@ -0,0 +1,130 @@ +/** + * ShockManager.cpp + * V1.0.0 + * + * Manages multiple shock device APIs (OpenShock, PiShock) for YipOS. + * + * By otter_oasis + */ + +#include "ShockManager.hpp" +#include "OpenShockClient.hpp" +#include "PiShockClient.hpp" +#include "core/Config.hpp" +#include "core/Logger.hpp" + +namespace YipOS { + +ShockManager::ShockManager() { + openshock_ = std::make_unique(); + pishock_ = std::make_unique(); +} + +ShockManager::~ShockManager() = default; + +void ShockManager::InitFromConfig(Config &config) { + Logger::Info("ShockManager: Initialising from config"); + + // OpenShock + std::string os_enabled = config.GetState("openshock.enabled", "0"); + openshock_->SetEnabled(os_enabled != "0"); + openshock_->SetToken(config.GetState("openshock.token", "")); + Logger::Info(std::string("ShockManager: OpenShock ") + + (os_enabled != "0" ? "enabled" : "disabled")); + + // PiShock + std::string ps_enabled = config.GetState("pishock.enabled", "0"); + pishock_->SetEnabled(ps_enabled != "0"); + pishock_->SetCredentials(config.GetState("pishock.username", ""), + config.GetState("pishock.apikey", "")); + Logger::Info(std::string("ShockManager: PiShock ") + + (ps_enabled != "0" ? "enabled" : "disabled")); + + FetchShockers(); +} + +void ShockManager::FetchShockers() { + shockers_.clear(); + Logger::Info("ShockManager: Fetching shockers from all enabled backends"); + + if (openshock_->IsEnabled()) { + openshock_->FetchShockers(); + const auto &os_list = openshock_->GetShockers(); + shockers_.insert(shockers_.end(), os_list.begin(), os_list.end()); + Logger::Info("ShockManager: OpenShock returned " + + std::to_string(os_list.size()) + " shocker(s)"); + } else { + Logger::Debug("ShockManager: OpenShock skipped (disabled)"); + } + + if (pishock_->IsEnabled()) { + pishock_->FetchShockers(); + const auto &ps_list = pishock_->GetShockers(); + shockers_.insert(shockers_.end(), ps_list.begin(), ps_list.end()); + Logger::Info("ShockManager: PiShock returned " + + std::to_string(ps_list.size()) + " shocker(s)"); + } else { + Logger::Debug("ShockManager: PiShock skipped (disabled)"); + } + + Logger::Info("ShockManager: Total shockers available: " + + std::to_string(shockers_.size())); +} + +bool ShockManager::SendControl(const std::string &shocker_id, + const std::string &backend, + const std::string &type, float intensity, + int duration_ms) { + Logger::Info("ShockManager: Routing " + type + " → backend='" + backend + + "' id='" + shocker_id + "'"); + if (backend == "openshock" && openshock_->IsEnabled()) { + bool ok = openshock_->SendControl(shocker_id, type, intensity, duration_ms); + if (!ok) + Logger::Warning("ShockManager: OpenShock command failed"); + return ok; + } else if (backend == "pishock" && pishock_->IsEnabled()) { + bool ok = pishock_->SendControl(shocker_id, type, intensity, duration_ms); + if (!ok) + Logger::Warning("ShockManager: PiShock command failed"); + return ok; + } + Logger::Warning("ShockManager: No enabled backend matched '" + backend + + "' — command dropped"); + return false; +} + +int ShockManager::GetMinDurationMs(const std::string &backend) const { + if (backend == "openshock") + return openshock_->GetMinDurationMs(); + if (backend == "pishock") + return pishock_->GetMinDurationMs(); + return 100; // default +} + +int ShockManager::GetMaxDurationMs(const std::string &backend) const { + if (backend == "openshock") + return openshock_->GetMaxDurationMs(); + if (backend == "pishock") + return pishock_->GetMaxDurationMs(); + return 15000; // default +} + +bool ShockManager::HasAnyConfig() const { + return openshock_->HasConfig() || pishock_->HasConfig(); +} + +bool ShockManager::IsHealthy() const { + // If a service IS configured but its token/auth is NOT valid, manager is + // unhealthy. + if (openshock_->IsEnabled() && openshock_->HasConfig() && + !openshock_->IsTokenValid()) { + return false; + } + if (pishock_->IsEnabled() && pishock_->HasConfig() && + !pishock_->IsTokenValid()) { + return false; + } + return true; +} + +} // namespace YipOS diff --git a/yip_os/src/net/ShockManager.hpp b/yip_os/src/net/ShockManager.hpp new file mode 100644 index 0000000..b9b88ef --- /dev/null +++ b/yip_os/src/net/ShockManager.hpp @@ -0,0 +1,51 @@ +/** + * ShockManager.hpp + * V1.0.0 + * + * Manages multiple shock device APIs (OpenShock, PiShock) for YipOS. + * + * By otter_oasis + */ + +#pragma once + +#include "IShockClient.hpp" +#include +#include +#include + +namespace YipOS { + +class Config; +class OpenShockClient; +class PiShockClient; + +class ShockManager { +public: + ShockManager(); + ~ShockManager(); + + void InitFromConfig(Config &config); + + void FetchShockers(); + const std::vector &GetShockers() const { return shockers_; } + + bool SendControl(const std::string &shocker_id, const std::string &backend, + const std::string &type, float intensity, int duration_ms); + + int GetMinDurationMs(const std::string &backend) const; + int GetMaxDurationMs(const std::string &backend) const; + + bool IsHealthy() const; + bool HasAnyConfig() const; + + OpenShockClient *GetOpenShockClient() const { return openshock_.get(); } + PiShockClient *GetPiShockClient() const { return pishock_.get(); } + +private: + std::unique_ptr openshock_; + std::unique_ptr pishock_; + std::vector shockers_; +}; + +} // namespace YipOS diff --git a/yip_os/src/screens/Screen.cpp b/yip_os/src/screens/Screen.cpp index f1265f3..2fb5f98 100644 --- a/yip_os/src/screens/Screen.cpp +++ b/yip_os/src/screens/Screen.cpp @@ -39,6 +39,7 @@ #include "DMPairScreen.hpp" #include "DMComposeScreen.hpp" #include "DMMessageScreen.hpp" +#include "ShockScreen.hpp" #include "app/PDAController.hpp" #include "app/PDADisplay.hpp" #include "core/Glyphs.hpp" @@ -181,6 +182,7 @@ std::unique_ptr CreateScreen(const std::string& name, PDAController& pda {"DM_PAIR", [](PDAController& p) -> std::unique_ptr { return std::make_unique(p); }}, {"DM_COMPOSE", [](PDAController& p) -> std::unique_ptr { return std::make_unique(p); }}, {"DM_MSG", [](PDAController& p) -> std::unique_ptr { return std::make_unique(p); }}, + {"SHOCK", [](PDAController& p) -> std::unique_ptr { return std::make_unique(p); }}, }; auto it = registry.find(name); diff --git a/yip_os/src/screens/ShockScreen.cpp b/yip_os/src/screens/ShockScreen.cpp new file mode 100644 index 0000000..11c4845 --- /dev/null +++ b/yip_os/src/screens/ShockScreen.cpp @@ -0,0 +1,244 @@ +/** + * ShockScreen.cpp + * V1.0.0 + * + * Screen for controlling multiple shock devices via the ShockManager interface. + * + * By otter_oasis + */ + +#include "ShockScreen.hpp" +#include "app/PDAController.hpp" +#include "app/PDADisplay.hpp" +#include "core/Config.hpp" +#include "core/Glyphs.hpp" +#include "core/TimeUtil.hpp" +#include "net/ShockManager.hpp" +#include +#include + +namespace YipOS { + +using namespace Glyphs; + +ShockScreen::ShockScreen(PDAController &pda) : Screen(pda) { + name = "SHOCK"; + macro_index = 48; // OPENSHOCK macro +} + +void ShockScreen::Render() { + // The OPENSHOCK title and layout is baked into the macro texture + RenderContent(); + RenderStatusBar(); +} + +void ShockScreen::RenderContent() { + RenderShockerSelection(); + RenderModeSelection(); + + // Draw feedback if flashing, otherwise restore the default label area + if (show_success_flash_) { + display_.WriteText(25, 6, " [ SENT! ] ", true); + } else { + display_.WriteText(25, 6, " [ EXECUTE ] ", true); + } + + auto *manager = pda_.GetShockManager(); + bool available = + manager && manager->HasAnyConfig() && !manager->GetShockers().empty(); + + // Intensity Value + char buf[16]; + if (available) { + std::snprintf(buf, sizeof(buf), "%3.0f%%", intensity_); + } else { + std::snprintf(buf, sizeof(buf), " - "); + } + display_.WriteText(8, 4, buf); + + // Duration Value + if (available) { + std::snprintf(buf, sizeof(buf), "%1.1fs", duration_ms_ / 1000.0f); + } else { + std::snprintf(buf, sizeof(buf), " - "); + } + display_.WriteText(28, 4, buf, false); +} + +void ShockScreen::RenderDynamic() { + if (show_success_flash_ && MonotonicNow() > flash_end_time_) { + show_success_flash_ = false; + } + + RenderContent(); + RenderClock(); + RenderCursor(); +} + +void ShockScreen::RenderShockerSelection() { + auto *manager = pda_.GetShockManager(); + if (!manager) + return; + + if (!manager->HasAnyConfig()) { + display_.WriteText(8, 1, "SETUP IN APP"); + return; + } + + const auto &items = manager->GetShockers(); + std::string label = "NO DEVICES OR BAD AUTH"; + if (!items.empty()) { + if (selected_shocker_idx_ >= static_cast(items.size())) { + selected_shocker_idx_ = 0; + } + label = items[selected_shocker_idx_].name; + } + + display_.WriteGlyph(1, 1, G_TRACKER); + + // Name centered between arrows (cols 8 to 31), padded to 24 chars to clear + // old text + std::string padded = label.substr(0, 24); + if (padded.length() < 24) + padded.append(24 - padded.length(), ' '); + display_.WriteText(8, 1, padded); +} + +void ShockScreen::RenderModeSelection() { + auto *manager = pda_.GetShockManager(); + + // Status indicator at Column 0 (to the left of ACTIVE MODE label) on Row 3 + std::string status = "- "; + if (manager && manager->HasAnyConfig()) { + status = manager->IsHealthy() ? " " : "! "; + } + display_.WriteText(2, 1, status); + + std::string val = + (!manager || !manager->HasAnyConfig() || manager->GetShockers().empty()) + ? "UNAVAILABLE" + : MODES[mode_idx_]; + + // Add hazard markers if in SHOCK mode + if (mode_idx_ == 0 && manager && manager->HasAnyConfig()) { + val = "!!" + std::string(MODES[0]) + "!!"; + } + + // Pad with spaces to clear old text (e.g. switching from !!SHOCK!! to VIBE) + if (val.length() < 11) { + val += std::string(11 - val.length(), ' '); + } + + // Positioned at Col 17 to sit directly after the new macro label position + // with a space on Row 3 + display_.WriteText(17, 3, val); +} + +bool ShockScreen::OnInput(const std::string &key) { + if (key == "TL") { + pda_.PopScreen(); + return true; + } + + if (key.size() == 2 && key[0] >= '1' && key[0] <= '5' && key[1] >= '1' && + key[1] <= '3') { + int tx = key[0] - '1'; + int ty = key[1] - '1'; + + auto *manager = pda_.GetShockManager(); + auto &config = pda_.GetConfig(); + bool changed = false; + + if (ty == 0) { // Top Row: Devices (Unchanged) + bool device_changed = false; + if (tx == 0) { // Previous + if (selected_shocker_idx_ > 0) { + selected_shocker_idx_--; + changed = true; + device_changed = true; + } + } else if (tx == 4) { // Next + if (manager && + selected_shocker_idx_ < + static_cast(manager->GetShockers().size()) - 1) { + selected_shocker_idx_++; + changed = true; + device_changed = true; + } + } + if (device_changed && manager && !manager->GetShockers().empty()) { + const auto &s = manager->GetShockers()[selected_shocker_idx_]; + int min_d = manager->GetMinDurationMs(s.backend); + int max_d = manager->GetMaxDurationMs(s.backend); + duration_ms_ = std::clamp(duration_ms_, min_d, max_d); + } + } else if (ty == 1) { // Middle Row: Intensity (Left) & Duration (Right) + float i_step = 2.5f; + int d_step = 1000; + try { + i_step = std::stof(config.GetState("shock.intensity_step", "2.5")); + } catch (...) { + } + try { + d_step = std::stoi(config.GetState("shock.duration_step", "1000")); + } catch (...) { + } + + if (tx == 0) { // Int Down + intensity_ = std::max(0.0f, intensity_ - i_step); + changed = true; + } else if (tx == 2) { // Int Up + intensity_ = std::min(100.0f, intensity_ + i_step); + changed = true; + } else if (tx == 3 || tx == 4) { // Duration Down/Up + int min_d = 100; + int max_d = 30000; + if (manager && !manager->GetShockers().empty()) { + const auto &s = manager->GetShockers()[selected_shocker_idx_]; + min_d = manager->GetMinDurationMs(s.backend); + max_d = manager->GetMaxDurationMs(s.backend); + } + if (tx == 3) { + duration_ms_ = std::max(min_d, duration_ms_ - d_step); + } else { + duration_ms_ = std::min(max_d, duration_ms_ + d_step); + } + changed = true; + } + } else if (ty == 2) { // Bottom Row: Mode Cycle (Left) & Execute (Right) + if (tx <= 1) { // Mode (tx 0-1) + mode_idx_ = (mode_idx_ + 1) % 3; + changed = true; + } else if (tx >= 3) { // EXECUTE (tx 3-4) + if (manager && !manager->GetShockers().empty()) { + const auto &s = manager->GetShockers()[selected_shocker_idx_]; + manager->SendControl(s.id, s.backend, MODES[mode_idx_], intensity_, + duration_ms_); + show_success_flash_ = true; + flash_end_time_ = MonotonicNow() + 2.0; + changed = true; + } + } + } + + if (changed) { + display_.BeginBuffered(); + RenderContent(); + } + return true; + } + + return false; +} + +void ShockScreen::Update() { + static double last_fetch = 0; + if (MonotonicNow() - last_fetch > 30.0) { + auto *manager = pda_.GetShockManager(); + if (manager) + manager->FetchShockers(); + last_fetch = MonotonicNow(); + } +} + +} // namespace YipOS diff --git a/yip_os/src/screens/ShockScreen.hpp b/yip_os/src/screens/ShockScreen.hpp new file mode 100644 index 0000000..05c3676 --- /dev/null +++ b/yip_os/src/screens/ShockScreen.hpp @@ -0,0 +1,41 @@ +/** + * ShockScreen.cpp + * V1.0.0 + * + * Screen for controlling multiple shock devices via the ShockManager interface. + * + * By otter_oasis + */ + +#pragma once + +#include "Screen.hpp" + +namespace YipOS { + +class ShockScreen : public Screen { +public: + ShockScreen(PDAController &pda); + + void Render() override; + void RenderContent() override; + void RenderDynamic() override; + bool OnInput(const std::string &key) override; + void Update() override; + +private: + void RenderModeSelection(); + void RenderShockerSelection(); + + int selected_shocker_idx_ = 0; + int mode_idx_ = 1; // 0=Shock, 1=Vibrate, 2=Sound + float intensity_ = 25.0f; + int duration_ms_ = 1000; + + bool show_success_flash_ = false; + double flash_end_time_ = 0; + + static constexpr const char *MODES[] = {"SHOCK", "VIBE ", "SOUND"}; +}; + +} // namespace YipOS diff --git a/yip_os/src/ui/UIManager.cpp b/yip_os/src/ui/UIManager.cpp index e7818a8..c1fa272 100644 --- a/yip_os/src/ui/UIManager.cpp +++ b/yip_os/src/ui/UIManager.cpp @@ -197,9 +197,9 @@ void UIManager::Render(PDAController& pda, Config& config, OSCManager& osc) { { static const char* tab_labels[] = { "Status", "OSC", "Display", "VRCX", "CC", "INTRP", "Avatar", - "Text", "IMG", "Stocks", "Twitch", "DM", "NVRAM", "Log" + "Text", "IMG", "Stocks", "Twitch", "DM", "Shock", "NVRAM", "Log" }; - static constexpr int TAB_COUNT = 14; + static constexpr int TAB_COUNT = 15; static constexpr int ROW1_COUNT = 7; ImGuiStyle& style = ImGui::GetStyle(); @@ -243,8 +243,9 @@ void UIManager::Render(PDAController& pda, Config& config, OSCManager& osc) { case 9: RenderStocksTab(pda, config); break; case 10: RenderTwitchTab(pda, config); break; case 11: RenderDMTab(pda, config); break; - case 12: RenderNVRAMTab(pda, config); break; - case 13: RenderLogTab(); break; + case 12: RenderShockTab(pda, config); break; + case 13: RenderNVRAMTab(pda, config); break; + case 14: RenderLogTab(); break; } } diff --git a/yip_os/src/ui/UIManager.hpp b/yip_os/src/ui/UIManager.hpp index a2e4fb6..f9373f5 100644 --- a/yip_os/src/ui/UIManager.hpp +++ b/yip_os/src/ui/UIManager.hpp @@ -57,6 +57,7 @@ class UIManager { void RenderTwitchTab(PDAController& pda, Config& config); void RenderIMGTab(PDAController& pda, Config& config); void RenderDMTab(PDAController& pda, Config& config); + void RenderShockTab(PDAController& pda, Config& config); void RenderNVRAMTab(PDAController& pda, Config& config); void RenderLogTab(); @@ -111,6 +112,19 @@ class UIManager { std::array dm_join_code_buf_ = {}; std::unordered_map> dm_compose_bufs_; + // Shock tab state (OpenShock & PiShock) + bool openshock_enabled_ = false; + std::array openshock_token_buf_ = {}; + bool openshock_token_initialized_ = false; + + bool pishock_enabled_ = false; + std::array pishock_username_buf_ = {}; + std::array pishock_apikey_buf_ = {}; + bool pishock_initialized_ = false; + + float shock_intensity_step_ = 2.5f; + int shock_duration_step_ = 100; + // OSC Query server (optional, for status display) OSCQueryServer* osc_query_ = nullptr; @@ -128,4 +142,4 @@ class UIManager { int initial_height_ = 480; }; -} // namespace YipOS +} // namespace YipOS \ No newline at end of file diff --git a/yip_os/src/ui/UIManager_Shock.cpp b/yip_os/src/ui/UIManager_Shock.cpp new file mode 100644 index 0000000..424622c --- /dev/null +++ b/yip_os/src/ui/UIManager_Shock.cpp @@ -0,0 +1,227 @@ +/** + * UIManager_Shock.cpp + * V1.0.0 + * + * Shock device configuration screen for YipOS. + * + * By otter_oasis + */ + +#include "UIManager.hpp" +#include "app/PDAController.hpp" +#include "core/Config.hpp" +#include "core/Logger.hpp" +#include "net/OpenShockClient.hpp" +#include "net/PiShockClient.hpp" +#include "net/ShockManager.hpp" + +#include +#include +#include + +namespace YipOS { + +void UIManager::RenderShockTab(PDAController &pda, Config &config) { + ImGui::Text("Shocker Integration"); + ImGui::TextDisabled( + "Drive your PiShock & OpenShock devices directly from the PDA."); + ImGui::Spacing(); + + ImGui::TextDisabled("Warning: Using shocking devices is at your own risk."); + ImGui::TextDisabled("Use responsibly and follow all safety guidelines."); + ImGui::TextDisabled( + "Remember: If other people can interact with your PDA, they can " + "control your shocks."); + + ImGui::Separator(); + ImGui::Spacing(); + + auto *manager = pda.GetShockManager(); + if (!manager) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.3f, 1.0f), + "ShockManager not initialized."); + return; + } + + // --- Initialize Config Buffers --- + if (!openshock_token_initialized_ || !pishock_initialized_) { + // OpenShock + std::string os_enabled = config.GetState("openshock.enabled", "0"); + openshock_enabled_ = (os_enabled != "0"); + + std::string token = config.GetState("openshock.token"); + std::snprintf(openshock_token_buf_.data(), openshock_token_buf_.size(), + "%s", token.c_str()); + + // PiShock + std::string ps_enabled = config.GetState("pishock.enabled", "0"); + pishock_enabled_ = (ps_enabled != "0"); + + std::string ps_user = config.GetState("pishock.username"); + std::snprintf(pishock_username_buf_.data(), pishock_username_buf_.size(), + "%s", ps_user.c_str()); + + std::string ps_api = config.GetState("pishock.apikey"); + std::snprintf(pishock_apikey_buf_.data(), pishock_apikey_buf_.size(), "%s", + ps_api.c_str()); + + std::string i_step = config.GetState("shock.intensity_step", "2.5"); + try { + shock_intensity_step_ = std::stof(i_step); + } catch (...) { + } + std::string d_step = config.GetState("shock.duration_step", "1000"); + try { + shock_duration_step_ = std::stoi(d_step); + } catch (...) { + } + + openshock_token_initialized_ = true; + pishock_initialized_ = true; + } + + // --- PiShock Config --- + ImGui::Text("PiShock Configuration"); + ImGui::Checkbox("Enable PiShock", &pishock_enabled_); + + if (!pishock_enabled_) { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Status: Disabled"); + } else { + auto *ps = manager->GetPiShockClient(); + if (ps && ps->HasConfig()) { + if (ps->IsTokenValid()) { + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Status: Verified"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Status: Auth Pending/Failed"); + } + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), + "Status: Unconfigured"); + } + } + + ImGui::SetNextItemWidth(150); + ImGui::InputText("Username", pishock_username_buf_.data(), + pishock_username_buf_.size()); + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##ps_token", pishock_apikey_buf_.data(), + pishock_apikey_buf_.size(), ImGuiInputTextFlags_Password); + ImGui::TextDisabled("API Key generated at https://login.pishock.com/account"); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // --- OpenShock Config --- + ImGui::Text("OpenShock Configuration"); + ImGui::Checkbox("Enable OpenShock", &openshock_enabled_); + + if (!openshock_enabled_) { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), "Status: Disabled"); + } else { + auto *os = manager->GetOpenShockClient(); + if (os && os->HasConfig()) { + if (os->IsTokenValid()) { + ImGui::TextColored(ImVec4(0.2f, 1.0f, 0.4f, 1.0f), "Status: Verified"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Status: Auth Pending/Failed"); + } + } else { + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 1.0f), + "Status: Unconfigured"); + } + } + + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##os_token", openshock_token_buf_.data(), + openshock_token_buf_.size(), ImGuiInputTextFlags_Password); + ImGui::TextDisabled( + "API Token generated at https://next.openshock.app/settings/api-tokens"); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // --- Global Wrist Screen Controls --- + ImGui::Text("Wrist Screen Controls"); + ImGui::TextDisabled( + "Step sizes used by wrist buttons for all shock devices."); + ImGui::SetNextItemWidth(150); + ImGui::InputFloat("Intensity Step (%)", &shock_intensity_step_, 0.5f, + 5.0f, "%.1f"); + ImGui::SetNextItemWidth(150); + ImGui::InputInt("Duration Step (ms)", &shock_duration_step_, 100, 1000); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // --- Actions --- + if (ImGui::Button("Apply & Save Settings")) { + // OpenShock string trim + std::string os_token(openshock_token_buf_.data()); + os_token.erase( + std::remove_if(os_token.begin(), os_token.end(), + [](unsigned char ch) { return std::isspace(ch); }), + os_token.end()); + + std::string ps_api(pishock_apikey_buf_.data()); + ps_api.erase( + std::remove_if(ps_api.begin(), ps_api.end(), + [](unsigned char ch) { return std::isspace(ch); }), + ps_api.end()); + + std::string ps_user(pishock_username_buf_.data()); + ps_user.erase( + std::remove_if(ps_user.begin(), ps_user.end(), + [](unsigned char ch) { return std::isspace(ch); }), + ps_user.end()); + + config.SetState("openshock.enabled", openshock_enabled_ ? "1" : "0"); + config.SetState("openshock.token", os_token); + config.SetState("shock.intensity_step", + std::to_string(shock_intensity_step_)); + config.SetState("shock.duration_step", + std::to_string(shock_duration_step_)); + + config.SetState("pishock.enabled", pishock_enabled_ ? "1" : "0"); + config.SetState("pishock.username", ps_user); + config.SetState("pishock.apikey", ps_api); + + manager->InitFromConfig(config); + + if (!config_path_.empty()) + config.SaveToFile(config_path_); + Logger::Info("Shock manager settings updated."); + } + + ImGui::Separator(); + ImGui::Text("Tools"); + + if (ImGui::Button("Refresh Shocker List")) { + manager->FetchShockers(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Test Vibration")) { + const auto &shockers = manager->GetShockers(); + if (!shockers.empty()) { + manager->SendControl(shockers[0].id, shockers[0].backend, "Vibrate", + 20.0f, 1000); + Logger::Info("ShockManager: Sent test vibration to " + shockers[0].name); + } else { + Logger::Warning("ShockManager: No shockers available to test."); + } + } + + // --- Footer --- + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + ImGui::TextDisabled("Module by @otter_oasis"); +} + +} // namespace YipOS