diff --git a/images/TxLight_16x16.png b/images/TxLight_16x16.png new file mode 100644 index 0000000..c35c6be Binary files /dev/null and b/images/TxLight_16x16.png differ diff --git a/images/TxShock_16x16.png b/images/TxShock_16x16.png new file mode 100644 index 0000000..4484e3a Binary files /dev/null and b/images/TxShock_16x16.png differ diff --git a/images/TxSound_16x16.png b/images/TxSound_16x16.png new file mode 100644 index 0000000..72b5d20 Binary files /dev/null and b/images/TxSound_16x16.png differ diff --git a/images/TxVibrate_16x16.png b/images/TxVibrate_16x16.png new file mode 100644 index 0000000..861105f Binary files /dev/null and b/images/TxVibrate_16x16.png differ diff --git a/openshock.c b/openshock.c index 4379291..161f64f 100644 --- a/openshock.c +++ b/openshock.c @@ -1,38 +1,52 @@ #include #include #include +#include +#include "openshock_app_icons.h" #include +#include +#include +#include #include #include "protocols.h" #include "transmit.h" #include "receiver.h" #include "storage.h" +#include "settings.h" +#include +#include #include #include -// --- Types --- +#define TX_ICON_DRAW_ROTATION IconRotation270 +#define TX_SOUND_ICON_ROTATION IconRotation180 typedef enum { ScreenList, ScreenTransmit, ScreenEdit, ScreenReceive, + ScreenSettings, } Screen; typedef enum { + EditName, EditModel, EditID, EditChannel, + EditSync, EditFieldCount, } EditField; typedef struct { char name[OPENSHOCK_NAME_MAX_LEN]; + char saved_path[OPENSHOCK_PATH_MAX_LEN]; ShockerModel model; uint16_t shocker_id; uint8_t channel; + uint8_t sync_group; ShockerCommand command; uint8_t intensity; } ShockerState; @@ -40,37 +54,65 @@ typedef struct { typedef struct { Screen screen; - // List SavedShocker saved[OPENSHOCK_MAX_SAVED]; size_t saved_count; - int list_sel; // 0..saved_count-1 = shockers, saved_count = "Add New", saved_count+1 = "Receive" + int list_sel; - // Current shocker being used/edited ShockerState current; - bool current_is_saved; // true if loaded from a saved entry + bool current_is_saved; + int edit_saved_idx; - // Transmit bool transmitting; - uint8_t saved_intensity; // non-light intensity, preserved when in Light mode - uint8_t saved_light; // light state (0=ON, 100=OFF), preserved when not in Light mode + bool transmit_bar_focus; + uint8_t transmit_sync_cycle; + uint8_t saved_intensity; + uint8_t saved_light; - // Edit EditField edit_field; - // Receive bool rx_found; DecodedShocker rx_result; - // Flash uint32_t flash_tick; const char* flash_msg; + char flash_msg_buf[40]; + bool flash_draw_vertical; + bool transmit_vertical_ui; +} AppState; + +typedef struct OpenshockCtx OpenshockCtx; + +typedef struct { + OpenshockCtx* app; +} OpenshockMainModel; + +typedef enum { + OpenshockViewMain = 0, + OpenshockViewTextInput = 1, +} OpenshockViewId; + +struct OpenshockCtx { + AppState state; + ViewDispatcher* view_dispatcher; + View* main_view; + TextInput* text_input; + char text_input_buffer[OPENSHOCK_NAME_MAX_LEN]; + OpenshockViewId shown_view; FuriMutex* mutex; OpenshockTx* tx; OpenshockRx* rx; -} AppState; - -// --- Helpers --- + OpenshockTx* tx_active; +}; + +static bool handle_list(OpenshockCtx* app, InputEvent* e); +static bool handle_transmit(OpenshockCtx* app, InputEvent* e); +static bool handle_transmit_horizontal(OpenshockCtx* app, InputEvent* e); +static void draw_transmit_horizontal(Canvas* canvas, AppState* s); +static bool handle_settings(OpenshockCtx* app, InputEvent* e); +static void draw_settings(Canvas* canvas, AppState* s); +static bool handle_edit(OpenshockCtx* app, InputEvent* e); +static bool handle_receive(OpenshockCtx* app, InputEvent* e); static uint8_t max_channel(ShockerModel model) { switch(model) { @@ -92,6 +134,7 @@ static uint8_t max_channel(ShockerModel model) { static void ensure_valid(ShockerState* s) { uint8_t ch_max = max_channel(s->model); if(s->channel > ch_max) s->channel = ch_max; + if(s->sync_group > 9) s->sync_group = 9; if(!openshock_command_supported(s->model, s->command)) { for(int i = 0; i < ShockerCmdCount; i++) { if(openshock_command_supported(s->model, (ShockerCommand)i)) { @@ -102,8 +145,115 @@ static void ensure_valid(ShockerState* s) { } } +#define OPENSHOCK_INTER_PACKET_GAP_HIGH_US 50 +#define OPENSHOCK_INTER_PACKET_GAP_LOW_US 10000 + +typedef struct { + ShockerModel model; + uint16_t id; + uint8_t ch; +} TxSeg; + +static void stem_trim(char* s) { + size_t n = strlen(s); + while(n > 0 && isspace((unsigned char)s[n - 1])) { + s[--n] = '\0'; + } + char* p = s; + while(*p && isspace((unsigned char)*p)) p++; + if(p != s) memmove(s, p, strlen(p) + 1); +} + +static size_t tx_group_member_indices(AppState* s, size_t* out_idx, size_t max_idx) { + ShockerState* c = &s->current; + if(c->sync_group == 0) return 0; + size_t n = 0; + for(size_t i = 0; i < s->saved_count && n < max_idx; i++) { + if(s->saved[i].sync_group == c->sync_group) { + out_idx[n++] = i; + } + } + return n; +} + +static size_t tx_collect_segments(AppState* s, TxSeg* seg, size_t max_seg) { + ShockerState* c = &s->current; + + if(c->sync_group == 0 || !s->current_is_saved || c->saved_path[0] == '\0') { + seg[0] = (TxSeg){c->model, c->shocker_id, c->channel}; + return 1; + } + + size_t group_idx[OPENSHOCK_MAX_SAVED]; + size_t gn = tx_group_member_indices(s, group_idx, OPENSHOCK_MAX_SAVED); + if(gn == 0) { + seg[0] = (TxSeg){c->model, c->shocker_id, c->channel}; + return 1; + } + + if(s->transmit_sync_cycle > gn) { + s->transmit_sync_cycle = 0; + } + + if(s->transmit_sync_cycle == 0) { + size_t n = 0; + seg[n++] = (TxSeg){c->model, c->shocker_id, c->channel}; + for(size_t gi = 0; gi < gn && n < max_seg; gi++) { + SavedShocker* sh = &s->saved[group_idx[gi]]; + if(strcmp(sh->filename, c->saved_path) == 0) continue; + seg[n++] = (TxSeg){sh->model, sh->shocker_id, sh->channel}; + } + return n; + } + + size_t slot = (size_t)(s->transmit_sync_cycle - 1); + if(slot >= gn) slot = 0; + SavedShocker* sh = &s->saved[group_idx[slot]]; + seg[0] = (TxSeg){sh->model, sh->shocker_id, sh->channel}; + return 1; +} + +static bool tx_fill_pulses(AppState* s, OokPulse* pulses, size_t* out_count) { + ShockerState* c = &s->current; + TxSeg seg[OPENSHOCK_MAX_SAVED + 1]; + size_t ns = tx_collect_segments(s, seg, sizeof(seg) / sizeof(seg[0])); + + size_t offset = 0; + for(size_t si = 0; si < ns; si++) { + OokPulse chunk[OPENSHOCK_MAX_PULSES]; + size_t n = openshock_encode( + seg[si].model, + seg[si].id, + c->command, + c->intensity, + seg[si].ch, + chunk, + OPENSHOCK_MAX_PULSES); + if(n == 0) return false; + if(offset + n > OPENSHOCK_MAX_PULSES) return false; + memcpy(pulses + offset, chunk, n * sizeof(OokPulse)); + offset += n; + if(si + 1 < ns) { + if(offset + 1 > OPENSHOCK_MAX_PULSES) return false; + pulses[offset].high_us = OPENSHOCK_INTER_PACKET_GAP_HIGH_US; + pulses[offset].low_us = OPENSHOCK_INTER_PACKET_GAP_LOW_US; + offset++; + } + } + *out_count = offset; + return true; +} + static int list_total(AppState* s) { - return (int)s->saved_count + 2; // shockers + "Add New" + "Receive" + return (int)s->saved_count + 2; +} + +static bool list_row_is_collar(AppState* s, int row) { + return row >= 2 && row < 2 + (int)s->saved_count; +} + +static int list_collar_index(int row) { + return row - 2; } static void reload_list(AppState* s) { @@ -115,41 +265,675 @@ static void reload_list(AppState* s) { static void show_flash(AppState* s, const char* msg) { s->flash_msg = msg; s->flash_tick = furi_get_tick() + 1000; + s->flash_draw_vertical = false; } -static bool flash_active(AppState* s) { +static void show_flash_tx(AppState* s, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(s->flash_msg_buf, sizeof(s->flash_msg_buf), fmt, ap); + va_end(ap); + s->flash_msg = s->flash_msg_buf; + s->flash_tick = furi_get_tick() + 1200; + s->flash_draw_vertical = true; +} + +static void show_flash_tx_horizontal(AppState* s, const char* fmt, ...) { + va_list ap; + va_start(ap, fmt); + vsnprintf(s->flash_msg_buf, sizeof(s->flash_msg_buf), fmt, ap); + va_end(ap); + s->flash_msg = s->flash_msg_buf; + s->flash_tick = furi_get_tick() + 1200; + s->flash_draw_vertical = false; +} + +static bool flash_active(const AppState* s) { return s->flash_tick && furi_get_tick() < s->flash_tick; } -static void cycle_cmd_fwd(ShockerState* s) { - for(int i = 1; i < ShockerCmdCount; i++) { - ShockerCommand n = (s->command + i) % ShockerCmdCount; - if(openshock_command_supported(s->model, n)) { - s->command = n; +static bool transmit_sync_popup_active(const AppState* s) { + if(!flash_active(s) || s->flash_msg == NULL) return false; + return strncmp(s->flash_msg, "TX ", 3) == 0; +} + +static void openshock_sync_edit_idx_to_saved_path(AppState* s) { + s->edit_saved_idx = -1; + if(s->current.saved_path[0] == '\0') return; + for(size_t i = 0; i < s->saved_count; i++) { + if(strcmp(s->saved[i].filename, s->current.saved_path) == 0) { + s->edit_saved_idx = (int)i; return; } } } -static void cycle_cmd_back(ShockerState* s) { +static void openshock_after_shocker_write_ok(OpenshockCtx* app) { + AppState* s = &app->state; + snprintf( + s->current.saved_path, + sizeof(s->current.saved_path), + "%s/%s.shk", + OPENSHOCK_SAVE_DIR, + s->current.name); + reload_list(s); + openshock_sync_edit_idx_to_saved_path(s); + show_flash(s, "Saved!"); +} + +static bool openshock_persist_new_from_current(OpenshockCtx* app) { + AppState* s = &app->state; + stem_trim(s->current.name); + if(s->current.name[0] == '\0') { + show_flash(s, "Need name"); + return false; + } + if(openshock_stem_exists(s->current.name)) { + show_flash(s, "Name taken"); + return false; + } + if(!openshock_shocker_write( + s->current.name, + s->current.model, + s->current.shocker_id, + s->current.channel, + s->current.sync_group, + NULL)) { + show_flash(s, "Save failed"); + return false; + } + s->current_is_saved = true; + openshock_after_shocker_write_ok(app); + return true; +} + +static void openshock_save_current_shocker(OpenshockCtx* app) { + AppState* s = &app->state; + if(s->edit_saved_idx < 0) { + (void)openshock_persist_new_from_current(app); + return; + } + stem_trim(s->current.name); + if(s->current.name[0] == '\0') { + show_flash(s, "Need name"); + return; + } + const char* oldp = s->saved[s->edit_saved_idx].filename; + if(openshock_shocker_write( + s->current.name, + s->current.model, + s->current.shocker_id, + s->current.channel, + s->current.sync_group, + oldp)) { + openshock_after_shocker_write_ok(app); + } else { + show_flash(s, "Save failed"); + } +} + +static bool openshock_save_name_keyboard(OpenshockCtx* app) { + AppState* s = &app->state; + if(s->edit_saved_idx < 0) { + return openshock_persist_new_from_current(app); + } + + stem_trim(s->current.name); + if(s->current.name[0] == '\0') { + show_flash(s, "Need name"); + return false; + } + + if(s->edit_saved_idx >= (int)s->saved_count) { + show_flash(s, "Save failed"); + return false; + } + + SavedShocker disk = s->saved[s->edit_saved_idx]; + + char new_path[OPENSHOCK_PATH_MAX_LEN]; + snprintf(new_path, sizeof(new_path), "%s/%s.shk", OPENSHOCK_SAVE_DIR, s->current.name); + if(strcmp(disk.filename, new_path) != 0 && openshock_stem_exists(s->current.name)) { + show_flash(s, "Name taken"); + return false; + } + + if(openshock_shocker_write( + s->current.name, + disk.model, + disk.shocker_id, + disk.channel, + disk.sync_group, + disk.filename)) { + snprintf( + s->current.saved_path, + sizeof(s->current.saved_path), + "%s/%s.shk", + OPENSHOCK_SAVE_DIR, + s->current.name); + s->current.model = disk.model; + s->current.shocker_id = disk.shocker_id; + s->current.channel = disk.channel; + s->current.sync_group = disk.sync_group; + ensure_valid(&s->current); + openshock_after_shocker_write_ok(app); + return true; + } + show_flash(s, "Save failed"); + return false; +} + +static bool transmit_cmd_to_rc(ShockerCommand cmd, bool has_light, int* row, int* col) { + switch(cmd) { + case ShockerCmdVibrate: + *row = 0; + *col = 0; + return true; + case ShockerCmdLight: + if(!has_light) return false; + *row = 0; + *col = 1; + return true; + case ShockerCmdShock: + *row = 1; + *col = 0; + return true; + case ShockerCmdSound: + *row = 1; + *col = 1; + return true; + default: + return false; + } +} + +static bool transmit_rc_to_cmd(int row, int col, bool has_light, ShockerCommand* out) { + if(row < 0 || row > 1 || col < 0 || col > 1) return false; + if(row == 0 && col == 0) { + *out = ShockerCmdVibrate; + return true; + } + if(row == 0 && col == 1) { + if(!has_light) return false; + *out = ShockerCmdLight; + return true; + } + if(row == 1 && col == 0) { + *out = ShockerCmdShock; + return true; + } + if(row == 1 && col == 1) { + *out = ShockerCmdSound; + return true; + } + return false; +} + +static bool transmit_grid_try_move( + const ShockerState* c, + int dr, + int dc, + ShockerCommand* out_next) { + bool hl = openshock_command_supported(c->model, ShockerCmdLight); + int r, col; + if(!transmit_cmd_to_rc(c->command, hl, &r, &col)) return false; + int nr = r + dr; + int nc = col + dc; + ShockerCommand cand; + if(transmit_rc_to_cmd(nr, nc, hl, &cand)) { + if(openshock_command_supported(c->model, cand)) { + *out_next = cand; + return true; + } + return false; + } + if(!hl && dr == 0 && dc == 1 && r == 0 && col == 0) { + *out_next = ShockerCmdSound; + return openshock_command_supported(c->model, ShockerCmdSound); + } + if(!hl && r == 1 && col == 1 && dr == -1 && dc == 0) { + *out_next = ShockerCmdVibrate; + return openshock_command_supported(c->model, ShockerCmdVibrate); + } + return false; +} + +static void transmit_apply_command(AppState* s, ShockerCommand new_cmd) { + ShockerState* c = &s->current; + if(new_cmd == c->command) return; + bool was_light = (c->command == ShockerCmdLight); + bool is_light = (new_cmd == ShockerCmdLight); + c->command = new_cmd; + if(!was_light && is_light) { + s->saved_intensity = c->intensity; + c->intensity = s->saved_light; + } else if(was_light && !is_light) { + s->saved_light = c->intensity; + c->intensity = s->saved_intensity; + } +} + +static ShockerCommand cycle_cmd_fwd_cmd(const ShockerState* c) { + for(int i = 1; i < ShockerCmdCount; i++) { + ShockerCommand n = (ShockerCommand)(((int)c->command + i) % ShockerCmdCount); + if(openshock_command_supported(c->model, n)) return n; + } + return c->command; +} + +static ShockerCommand cycle_cmd_back_cmd(const ShockerState* c) { for(int i = ShockerCmdCount - 1; i >= 1; i--) { - ShockerCommand n = (s->command + i) % ShockerCmdCount; - if(openshock_command_supported(s->model, n)) { - s->command = n; - return; + ShockerCommand n = (ShockerCommand)(((int)c->command + i) % ShockerCmdCount); + if(openshock_command_supported(c->model, n)) return n; + } + return c->command; +} + +static void str_trunc_fit(char* dst, size_t dst_sz, const char* src, size_t max_chars) { + size_t len = strlen(src); + if(len <= max_chars) { + snprintf(dst, dst_sz, "%s", src); + return; + } + if(max_chars <= 3) { + snprintf(dst, dst_sz, "%.*s", (int)dst_sz - 1, src); + return; + } + snprintf(dst, dst_sz, "%.*s..", (int)(max_chars - 2), src); +} + +static void str_trunc_fit_hard(char* dst, size_t dst_sz, const char* src, size_t max_chars) { + size_t len = strlen(src); + if(len <= max_chars) { + snprintf(dst, dst_sz, "%s", src); + return; + } + if(max_chars == 0) { + if(dst_sz > 0) dst[0] = '\0'; + return; + } + snprintf(dst, dst_sz, "%.*s", (int)max_chars, src); +} + +static void draw_tx_icon_cell( + Canvas* canvas, + int cell_x, + int cell_y, + int cell_w, + int cell_h, + const Icon* icon, + IconRotation rotation, + bool selected, + int icon_dy) { + if(selected) { + const int inset_x = 2; + canvas_draw_rframe( + canvas, + cell_x + inset_x, + cell_y, + (size_t)(cell_w - 2 * inset_x), + (size_t)cell_h, + 2); + } + uint16_t iw = icon_get_width(icon); + uint16_t ih = icon_get_height(icon); + uint16_t cw = iw; + uint16_t ch = ih; + if(rotation == IconRotation90 || rotation == IconRotation270) { + uint16_t t = cw; + cw = ch; + ch = t; + } + int ix = cell_x + (cell_w - (int)cw) / 2; + int iy = cell_y + (cell_h - (int)ch) / 2 + icon_dy; + if(iy + (int)ch > cell_y + cell_h) iy = cell_y + cell_h - (int)ch; + canvas_draw_icon_ex(canvas, ix, iy, icon, rotation); +} + +static void draw_tx_icon_for_command( + Canvas* canvas, + int cell_x, + int cell_y, + int cell_w, + int cell_h, + ShockerCommand cell_cmd, + bool selected) { + const Icon* icon; + switch(cell_cmd) { + case ShockerCmdShock: + icon = &I_TxShock_16x16; + break; + case ShockerCmdVibrate: + icon = &I_TxVibrate_16x16; + break; + case ShockerCmdSound: + icon = &I_TxSound_16x16; + break; + case ShockerCmdLight: + icon = &I_TxLight_16x16; + break; + default: + return; + } + IconRotation rot = + (cell_cmd == ShockerCmdSound) ? TX_SOUND_ICON_ROTATION : TX_ICON_DRAW_ROTATION; + int icon_dy = (cell_cmd == ShockerCmdShock) ? 1 : 0; + draw_tx_icon_cell(canvas, cell_x, cell_y, cell_w, cell_h, icon, rot, selected, icon_dy); +} + +static void transmit_draw_collar_title(Canvas* canvas, const ShockerState* c, size_t max_fit) { + char narrow[OPENSHOCK_NAME_MAX_LEN + 16]; + const char* base = c->name[0] ? c->name : openshock_model_name(c->model); + str_trunc_fit_hard(narrow, sizeof(narrow), base, max_fit); + canvas_set_font(canvas, FontSecondary); + canvas_set_font_direction(canvas, CanvasDirectionBottomToTop); + canvas_draw_str_aligned(canvas, 10, 58, AlignLeft, AlignCenter, narrow); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); +} + +static void transmit_draw_horizontal_header(Canvas* canvas, const ShockerState* c) { + char name_fit[OPENSHOCK_NAME_MAX_LEN]; + const char* nm = c->name[0] ? c->name : openshock_model_name(c->model); + str_trunc_fit_hard(name_fit, sizeof(name_fit), nm, 20); + canvas_set_font(canvas, FontSecondary); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); + canvas_set_color(canvas, ColorBlack); + canvas_draw_str(canvas, 0, 8, name_fit); +} + +static void transmit_draw_vertical_in_bar( + Canvas* canvas, + int bar_x, + int bar_y, + int bar_w, + int bar_h, + const char* text, + bool is_light_label) { + if(text == NULL || text[0] == '\0') return; + canvas_set_font(canvas, FontKeyboard); + canvas_set_font_direction(canvas, CanvasDirectionBottomToTop); + canvas_set_color(canvas, ColorXOR); + int cy = bar_y + bar_h / 2 + 4; + int cx = bar_x + bar_w / 2 + 7; + if(is_light_label) { + cx += 1; + cy += 1; + } + if(!is_light_label) { + cx += 4; + size_t nt = strlen(text); + if(nt == 2) { + cx -= 3; + cy -= 1; + } else if(nt >= 4) { + cx += 2; + cy += 2; } } + if(is_light_label) { + if(strcmp(text, "OFF") == 0) { + cx += 3; + } else if(strcmp(text, "ON") == 0) { + cy -= 2; + } + } + canvas_draw_str_aligned(canvas, cx, cy, AlignCenter, AlignCenter, text); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); + canvas_set_color(canvas, ColorBlack); + canvas_set_font(canvas, FontSecondary); } -// --- Draw --- +static void draw_transmit_sync_strip( + Canvas* canvas, + AppState* app_s, + int bar_x, + int bar_y, + int bar_w, + int bar_h) { + ShockerState* c = &app_s->current; + if(c->sync_group == 0 || !app_s->current_is_saved) return; + + size_t group_idx[OPENSHOCK_MAX_SAVED]; + size_t gn = tx_group_member_indices(app_s, group_idx, OPENSHOCK_MAX_SAVED); + if(gn == 0) return; + + char tag[20]; + if(app_s->transmit_sync_cycle == 0) { + snprintf(tag, sizeof(tag), "Group %u", c->sync_group); + } else { + snprintf(tag, sizeof(tag), "Collar %u", (unsigned)app_s->transmit_sync_cycle); + } -static void draw_flash(Canvas* canvas, AppState* s) { - if(flash_active(s)) { + const int gap_after_bar = 3; + const int label_col_w = 16; + const int gap_before_dots = 1; + const int label_nudge_x = 7; + const int dots_nudge_left = 6; + const size_t r = 2; + const int dot_edge_gap = 4; + const int step = (int)(2 * r) + dot_edge_gap; + + int label_left = bar_x + bar_w + gap_after_bar + label_nudge_x; + int tag_y = bar_y + bar_h - 10; + if(app_s->transmit_sync_cycle == 0) { + tag_y += 2; + } else { + tag_y += 1; + } + canvas_set_font(canvas, FontSecondary); + canvas_set_font_direction(canvas, CanvasDirectionBottomToTop); + canvas_set_color(canvas, ColorBlack); + canvas_draw_str_aligned(canvas, label_left, tag_y, AlignLeft, AlignBottom, tag); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); + + int dots_cx = bar_x + bar_w + gap_after_bar + label_col_w + gap_before_dots + (int)r - + dots_nudge_left; + + size_t span_h = (gn - 1) * (size_t)step + 2 * r; + int y_top_center = bar_y + (int)((bar_h - (int)span_h) / 2) + (int)r; + + for(size_t i = 0; i < gn; i++) { + int cy = y_top_center + (int)(i * step); + if(cy + (int)r >= bar_y + bar_h) break; + bool on = (app_s->transmit_sync_cycle == 0) || + (app_s->transmit_sync_cycle == (uint8_t)(gn - i)); + if(on) { + canvas_draw_disc(canvas, dots_cx, cy, r); + } else { + canvas_draw_circle(canvas, dots_cx, cy, r); + } + } +} + +/* Group / collar: horizontal dot row (left) + tag (horizontal transmit). */ +static void draw_transmit_sync_strip_horizontal(Canvas* canvas, AppState* app_s) { + ShockerState* c = &app_s->current; + if(c->sync_group == 0 || !app_s->current_is_saved) return; + + size_t group_idx[OPENSHOCK_MAX_SAVED]; + size_t gn = tx_group_member_indices(app_s, group_idx, OPENSHOCK_MAX_SAVED); + if(gn == 0) return; + + char tag[22]; + if(app_s->transmit_sync_cycle == 0) { + snprintf(tag, sizeof(tag), "Group %u", c->sync_group); + } else { + snprintf(tag, sizeof(tag), "Collar %u", (unsigned)app_s->transmit_sync_cycle); + } + + canvas_set_font(canvas, FontSecondary); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); + canvas_set_color(canvas, ColorBlack); + + const int tag_x = 50; + const int tag_y = 8; + canvas_draw_str(canvas, tag_x, tag_y, tag); + + const size_t r = 2; + const int step = (int)(2 * (int)r) + 4; + int dots_span = (int)((gn > 0 ? gn - 1 : 0) * (size_t)step) + (int)(2 * (int)r); + /* Fixed anchor (wider "Collar" prefix) so dots do not shift when tag toggles Group/Collar. */ + const int prefix_w = 6 * 6; + int anchor_x = tag_x + prefix_w / 2 - 4; + int x_first = anchor_x - dots_span / 2 + 2; + if(x_first < (int)r + 1) x_first = (int)r + 1; + + const int dot_y = 13; + + for(size_t i = 0; i < gn; i++) { + int cx = x_first + (int)(i * step); + bool on = (app_s->transmit_sync_cycle == 0) || + (app_s->transmit_sync_cycle == (uint8_t)(i + 1)); + if(on) { + canvas_draw_disc(canvas, cx, dot_y, r); + } else { + canvas_draw_circle(canvas, cx, dot_y, r); + } + } +} + +static void draw_transmit_mode_grid_and_bar(Canvas* canvas, AppState* app_s, int gy0) { + ShockerState* c = &app_s->current; + const bool bar_focus = app_s->transmit_bar_focus; + const bool has_light = openshock_command_supported(c->model, ShockerCmdLight); + + const int gap_x = 3; + const int gap_y = 2; + const int cell_w = 34; + const int cell_h = 19; + const int gx0 = 11; + const int gx1 = gx0 + cell_w + gap_x; + + const bool cg = !bar_focus; + + draw_tx_icon_for_command(canvas, gx0, gy0, cell_w, cell_h, ShockerCmdVibrate, cg && c->command == ShockerCmdVibrate); + + if(has_light) { + draw_tx_icon_for_command(canvas, gx1, gy0, cell_w, cell_h, ShockerCmdLight, cg && c->command == ShockerCmdLight); + } + + const int gy1 = gy0 + cell_h + gap_y; + draw_tx_icon_for_command(canvas, gx0, gy1, cell_w, cell_h, ShockerCmdShock, cg && c->command == ShockerCmdShock); + + draw_tx_icon_for_command(canvas, gx1, gy1, cell_w, cell_h, ShockerCmdSound, cg && c->command == ShockerCmdSound); + + const int bar_pad = 3; + const int bar_y = gy0 - bar_pad; + const int bar_x = gx1 + cell_w + 3; + const int bar_w = 15; + const int bar_h = (gy1 + cell_h + bar_pad) - gy0; + + if(bar_focus) { + elements_bold_rounded_frame(canvas, bar_x - 1, bar_y - 1, (size_t)(bar_w + 2), (size_t)(bar_h + 2)); + } + canvas_draw_frame(canvas, bar_x, bar_y, bar_w, bar_h); + if(c->command == ShockerCmdLight) { + int inner = bar_h - 2; + int fill = ((c->intensity == 0) ? inner : 0); + if(fill > 0) canvas_draw_box(canvas, bar_x + 1, bar_y + bar_h - 1 - fill, bar_w - 2, fill); + } else { + int inner = bar_h - 2; + int fill = ((int)c->intensity * inner) / 100; + if(fill > 0) canvas_draw_box(canvas, bar_x + 1, bar_y + bar_h - 1 - fill, bar_w - 2, fill); + } + + char lvl[8]; + if(c->command == ShockerCmdLight) { + snprintf(lvl, sizeof(lvl), "%s", (c->intensity == 0) ? "ON" : "OFF"); + } else { + snprintf(lvl, sizeof(lvl), "%u%%", (unsigned)c->intensity); + } + transmit_draw_vertical_in_bar( + canvas, + bar_x, + bar_y, + bar_w, + bar_h, + lvl, + c->command == ShockerCmdLight); + + draw_transmit_sync_strip(canvas, app_s, bar_x, bar_y, bar_w, bar_h); +} + +/* Classic horizontal transmit UI (ported from OpenShock/FlipperZero), + header + group strip. */ +static void draw_transmit_horizontal(Canvas* canvas, AppState* s) { + ShockerState* c = &s->current; + + transmit_draw_horizontal_header(canvas, c); + + draw_transmit_sync_strip_horizontal(canvas, s); + + if(s->transmitting && c->command != ShockerCmdLight) { + canvas_set_font(canvas, FontPrimary); + canvas_draw_str_aligned(canvas, 64, 30, AlignCenter, AlignCenter, "Transmitting..."); + canvas_set_font(canvas, FontSecondary); + char info[32]; + snprintf( + info, sizeof(info), "%s @ %u%%", openshock_command_name(c->command), c->intensity); + canvas_draw_str_aligned(canvas, 64, 42, AlignCenter, AlignCenter, info); + return; + } + + if(!flash_active(s)) { canvas_set_font(canvas, FontPrimary); + canvas_draw_str_aligned( + canvas, 64, 26, AlignCenter, AlignCenter, openshock_command_name(c->command)); + } + + if(c->command == ShockerCmdLight) { + if(!flash_active(s)) { + canvas_set_font(canvas, FontSecondary); + const char* light_state = (c->intensity == 0) ? "ON" : "OFF"; + canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignCenter, light_state); + } + } else if(!flash_active(s)) { + canvas_set_font(canvas, FontSecondary); + char int_str[16]; + snprintf(int_str, sizeof(int_str), "%u%%", c->intensity); + canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignCenter, int_str); + + const int bar_x = 14; + const int bar_y = 44; + const int bar_w = 100; + const int bar_h = 6; + canvas_draw_frame(canvas, bar_x, bar_y, bar_w, bar_h); + int fill = ((int)c->intensity * (bar_w - 2)) / 100; + if(fill > 0) canvas_draw_box(canvas, bar_x + 1, bar_y + 1, fill, bar_h - 2); + } + + canvas_set_font(canvas, FontSecondary); + elements_button_left(canvas, "Mode"); + elements_button_center(canvas, "TX"); + elements_button_right(canvas, "Mode"); +} + +static void draw_flash(Canvas* canvas, AppState* s) { + if(!flash_active(s)) return; + canvas_set_font(canvas, FontPrimary); + if(s->flash_draw_vertical) { + canvas_set_font_direction(canvas, CanvasDirectionBottomToTop); + canvas_draw_str_aligned(canvas, 55, 55, AlignLeft, AlignCenter, s->flash_msg); + canvas_set_font_direction(canvas, CanvasDirectionLeftToRight); + } else { canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignCenter, s->flash_msg); } } +static void draw_settings(Canvas* canvas, AppState* s) { + canvas_set_font(canvas, FontSecondary); + canvas_draw_str(canvas, 0, 8, "Settings"); + char line[48]; + snprintf( + line, + sizeof(line), + "> Transmit UI: %s", + s->transmit_vertical_ui ? "Vertical" : "Horizontal"); + canvas_draw_str(canvas, 0, 22, line); + elements_button_left(canvas, "Horiz"); + elements_button_right(canvas, "Vert"); + elements_button_center(canvas, "Done"); +} + static void draw_list(Canvas* canvas, AppState* s) { canvas_set_font(canvas, FontPrimary); canvas_draw_str(canvas, 0, 11, "OpenShock"); @@ -166,8 +950,11 @@ static void draw_list(Canvas* canvas, AppState* s) { const int rh = 10; const int cy = 20; - int scroll = 0; - if(s->list_sel >= max_visible) scroll = s->list_sel - max_visible + 1; + int scroll = s->list_sel - max_visible + 1; + if(scroll < 0) scroll = 0; + int max_scroll = total - max_visible; + if(max_scroll < 0) max_scroll = 0; + if(scroll > max_scroll) scroll = max_scroll; for(int vi = 0; vi < max_visible; vi++) { int i = scroll + vi; @@ -175,132 +962,152 @@ static void draw_list(Canvas* canvas, AppState* s) { int y = cy + vi * rh; char line[48]; - if(i < (int)s->saved_count) { + if(i == 0) { + snprintf(line, sizeof(line), "%s + Add New", (i == s->list_sel) ? ">" : " "); + } else if(i == 1) { + snprintf(line, sizeof(line), "%s ~ Receive", (i == s->list_sel) ? ">" : " "); + } else { + char nm[44]; + str_trunc_fit(nm, sizeof(nm), s->saved[list_collar_index(i)].name, 22); snprintf( line, sizeof(line), - "%s %s #%u", + "%s %s", (i == s->list_sel) ? ">" : " ", - openshock_model_name(s->saved[i].model), - s->saved[i].shocker_id); - } else if(i == (int)s->saved_count) { - snprintf(line, sizeof(line), "%s + Add New", (i == s->list_sel) ? ">" : " "); - } else { - snprintf(line, sizeof(line), "%s ~ Receive", (i == s->list_sel) ? ">" : " "); + nm); } canvas_draw_str(canvas, 0, y, line); } - if(scroll > 0) canvas_draw_str(canvas, 122, cy, "^"); - if(scroll + max_visible < total) - canvas_draw_str(canvas, 122, cy + (max_visible - 1) * rh, "v"); + elements_scrollbar_pos( + canvas, + 128, + cy - 4, + (size_t)(max_visible * rh), + (size_t)s->list_sel, + (size_t)total); - if(s->list_sel < (int)s->saved_count) { + if(list_row_is_collar(s, s->list_sel)) { elements_button_left(canvas, "Del"); elements_button_center(canvas, "Use"); elements_button_right(canvas, "Edit"); } else { + elements_button_left(canvas, "Set"); elements_button_center(canvas, "Select"); } } static void draw_transmit(Canvas* canvas, AppState* s) { + if(!s->transmit_vertical_ui) { + draw_transmit_horizontal(canvas, s); + draw_flash(canvas, s); + return; + } + ShockerState* c = &s->current; - canvas_set_font(canvas, FontSecondary); - // Header: model + ID - char header[40]; - snprintf( - header, - sizeof(header), - "%s #%u CH:%u", - openshock_model_name(c->model), - c->shocker_id, - c->channel); - canvas_draw_str(canvas, 0, 8, header); + const int transmit_grid_y0 = 17; - if(s->transmitting && c->command != ShockerCmdLight) { + transmit_draw_collar_title(canvas, c, 14); + + if(s->transmitting) { canvas_set_font(canvas, FontPrimary); - canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignCenter, "Transmitting..."); + canvas_draw_str_aligned(canvas, 64, 30, AlignCenter, AlignCenter, "Transmitting..."); canvas_set_font(canvas, FontSecondary); char info[32]; snprintf( info, sizeof(info), "%s @ %u%%", openshock_command_name(c->command), c->intensity); - canvas_draw_str_aligned(canvas, 64, 44, AlignCenter, AlignCenter, info); + canvas_draw_str_aligned(canvas, 64, 42, AlignCenter, AlignCenter, info); + draw_flash(canvas, s); return; } - // Command mode (center, large) - canvas_set_font(canvas, FontPrimary); - canvas_draw_str_aligned( - canvas, 64, 26, AlignCenter, AlignCenter, openshock_command_name(c->command)); - - if(c->command == ShockerCmdLight) { - // Light: binary ON/OFF (0% = ON, 100% = OFF) - canvas_set_font(canvas, FontPrimary); - const char* light_state = (c->intensity == 0) ? "ON" : "OFF"; - canvas_draw_str_aligned(canvas, 64, 42, AlignCenter, AlignCenter, light_state); - } else { - // Intensity bar - canvas_set_font(canvas, FontSecondary); - char int_str[16]; - snprintf(int_str, sizeof(int_str), "%u%%", c->intensity); - canvas_draw_str_aligned(canvas, 64, 38, AlignCenter, AlignCenter, int_str); - - const int bar_x = 14; - const int bar_y = 42; - const int bar_w = 100; - const int bar_h = 6; - canvas_draw_frame(canvas, bar_x, bar_y, bar_w, bar_h); - int fill = (c->intensity * (bar_w - 2)) / 100; - if(fill > 0) canvas_draw_box(canvas, bar_x + 1, bar_y + 1, fill, bar_h - 2); - } - - // Hints - elements_button_left(canvas, "Mode"); - elements_button_center(canvas, "TX"); - elements_button_right(canvas, "Mode"); + draw_transmit_mode_grid_and_bar(canvas, s, transmit_grid_y0); + draw_flash(canvas, s); } static void draw_edit(Canvas* canvas, AppState* s) { - canvas_set_font(canvas, FontPrimary); - canvas_draw_str(canvas, 0, 11, "Edit Shocker"); + canvas_set_font(canvas, FontSecondary); + canvas_draw_str(canvas, 0, 8, "Edit"); if(flash_active(s)) { draw_flash(canvas, s); return; } - canvas_set_font(canvas, FontSecondary); - char buf[32]; - const int rh = 12; - const int cy = 24; - const int vx = 56; - - const char* labels[] = {"Model:", "ID:", "Ch:"}; - for(int i = 0; i < EditFieldCount; i++) { - int y = cy + i * rh; - const char* prefix = (i == (int)s->edit_field) ? ">" : " "; - snprintf(buf, sizeof(buf), "%s%s", prefix, labels[i]); + const int max_visible = 4; + const int rh = 8; + const int cy = 18; + const int vx = 44; + + int scroll = (int)s->edit_field - max_visible + 1; + if(scroll < 0) scroll = 0; + int max_scroll = EditFieldCount - max_visible; + if(max_scroll < 0) max_scroll = 0; + if(scroll > max_scroll) scroll = max_scroll; + + char buf[40]; + char val[OPENSHOCK_NAME_MAX_LEN + 8]; + + const char* labels[] = {"Name:", "Model:", "ID:", "Ch:", "Sync:"}; + + for(int vi = 0; vi < max_visible; vi++) { + int row = scroll + vi; + if(row >= EditFieldCount) break; + + EditField fi = (EditField)row; + int y = cy + vi * rh; + const char* prefix = (fi == s->edit_field) ? ">" : " "; + snprintf(buf, sizeof(buf), "%s%s", prefix, labels[row]); canvas_draw_str(canvas, 0, y, buf); - switch(i) { + switch(fi) { + case EditName: + str_trunc_fit(val, sizeof(val), s->current.name, 18); + canvas_draw_str(canvas, vx, y, val); + break; case EditModel: - canvas_draw_str(canvas, vx, y, openshock_model_name(s->current.model)); + str_trunc_fit(val, sizeof(val), openshock_model_name(s->current.model), 18); + canvas_draw_str(canvas, vx, y, val); break; case EditID: - snprintf(buf, sizeof(buf), "%u", s->current.shocker_id); - canvas_draw_str(canvas, vx, y, buf); + snprintf(val, sizeof(val), "%u", s->current.shocker_id); + canvas_draw_str(canvas, vx, y, val); break; case EditChannel: - snprintf(buf, sizeof(buf), "%u", s->current.channel); - canvas_draw_str(canvas, vx, y, buf); + snprintf(val, sizeof(val), "%u", s->current.channel); + canvas_draw_str(canvas, vx, y, val); + break; + case EditSync: + if(s->current.sync_group == 0) { + canvas_draw_str(canvas, vx, y, "off"); + } else { + snprintf(val, sizeof(val), "Group %u", s->current.sync_group); + canvas_draw_str(canvas, vx, y, val); + } + break; + default: break; } } - - elements_button_left(canvas, "Save"); - elements_button_right(canvas, "Change"); + + elements_scrollbar_pos( + canvas, + 128, + cy - 4, + (size_t)(max_visible * rh), + (size_t)s->edit_field, + (size_t)EditFieldCount); + + if(s->edit_field == EditName) { + elements_button_center(canvas, "Set"); + elements_button_left(canvas, "-"); + elements_button_right(canvas, "+"); + } else { + elements_button_center(canvas, "Save"); + elements_button_left(canvas, "-"); + elements_button_right(canvas, "+"); + } } static void draw_receive(Canvas* canvas, AppState* s) { @@ -339,9 +1146,62 @@ static void draw_receive(Canvas* canvas, AppState* s) { } } -static void draw_callback(Canvas* canvas, void* ctx) { - AppState* s = ctx; - furi_mutex_acquire(s->mutex, FuriWaitForever); +static void openshock_name_input_cb(void* ctx) { + OpenshockCtx* app = ctx; + stem_trim(app->text_input_buffer); + + char prev[OPENSHOCK_NAME_MAX_LEN]; + snprintf(prev, sizeof(prev), "%s", app->state.current.name); + + snprintf( + app->state.current.name, + sizeof(app->state.current.name), + "%s", + app->text_input_buffer); + stem_trim(app->state.current.name); + if(app->state.current.name[0] == '\0') { + snprintf(app->state.current.name, sizeof(app->state.current.name), "Collar"); + } + + if(!openshock_save_name_keyboard(app)) { + snprintf(app->state.current.name, sizeof(app->state.current.name), "%s", prev); + } + + view_dispatcher_switch_to_view(app->view_dispatcher, OpenshockViewMain); + app->shown_view = OpenshockViewMain; +} + +static void openshock_open_name_input(OpenshockCtx* app) { + strncpy(app->text_input_buffer, app->state.current.name, OPENSHOCK_NAME_MAX_LEN - 1); + app->text_input_buffer[OPENSHOCK_NAME_MAX_LEN - 1] = '\0'; + text_input_reset(app->text_input); + text_input_set_header_text(app->text_input, "Collar name"); + text_input_set_result_callback( + app->text_input, + openshock_name_input_cb, + app, + app->text_input_buffer, + OPENSHOCK_NAME_MAX_LEN, + false); + view_dispatcher_switch_to_view(app->view_dispatcher, OpenshockViewTextInput); + app->shown_view = OpenshockViewTextInput; +} + +static void main_draw(Canvas* canvas, void* model) { + OpenshockMainModel* m = model; + OpenshockCtx* app = m->app; + AppState* s = &app->state; + + furi_mutex_acquire(app->mutex, FuriWaitForever); + + if(s->screen == ScreenReceive) { + DecodedShocker d; + if(openshock_rx_get_result(app->rx, &d)) { + s->rx_result = d; + s->rx_found = true; + } + } + canvas_clear(canvas); switch(s->screen) { @@ -357,20 +1217,68 @@ static void draw_callback(Canvas* canvas, void* ctx) { case ScreenReceive: draw_receive(canvas, s); break; + case ScreenSettings: + draw_settings(canvas, s); + break; + } + + furi_mutex_release(app->mutex); +} + +static bool main_view_input(InputEvent* event, void* context) { + OpenshockCtx* app = context; + + furi_mutex_acquire(app->mutex, FuriWaitForever); + + bool consumed = false; + switch(app->state.screen) { + case ScreenList: + consumed = handle_list(app, event); + break; + case ScreenTransmit: + consumed = handle_transmit(app, event); + break; + case ScreenEdit: + consumed = handle_edit(app, event); + break; + case ScreenReceive: + consumed = handle_receive(app, event); + break; + case ScreenSettings: + consumed = handle_settings(app, event); + break; + default: + break; } - furi_mutex_release(s->mutex); + furi_mutex_release(app->mutex); + return consumed; } -static void input_callback(InputEvent* event, void* ctx) { - FuriMessageQueue* queue = ctx; - furi_message_queue_put(queue, event, FuriWaitForever); +static bool openshock_navigation_cb(void* context) { + OpenshockCtx* app = context; + if(app->shown_view == OpenshockViewTextInput) { + view_dispatcher_switch_to_view(app->view_dispatcher, OpenshockViewMain); + app->shown_view = OpenshockViewMain; + return true; + } + return false; } -// --- Input --- +static bool handle_list(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeShort) { + view_dispatcher_stop(app->view_dispatcher); + return true; + } + return false; + } -// Returns true if app should exit -static bool handle_list(AppState* s, InputEvent* e) { if(e->type != InputTypePress && e->type != InputTypeRepeat) return false; int total = list_total(s); @@ -382,58 +1290,111 @@ static bool handle_list(AppState* s, InputEvent* e) { s->list_sel = (s->list_sel < total - 1) ? (s->list_sel + 1) : 0; break; case InputKeyOk: - if(s->list_sel < (int)s->saved_count) { - // Load saved shocker into current and go to transmit - SavedShocker* sel = &s->saved[s->list_sel]; + if(s->list_sel == 0) { + memset(&s->current, 0, sizeof(s->current)); + s->current.command = ShockerCmdShock; + s->current.saved_path[0] = '\0'; + s->current.sync_group = 0; + if(!openshock_shocker_unique_stem( + s->current.model, + s->current.shocker_id, + s->current.name, + sizeof(s->current.name))) { + snprintf(s->current.name, sizeof(s->current.name), "Collar"); + } + s->current_is_saved = false; + s->edit_saved_idx = -1; + s->edit_field = EditName; + s->screen = ScreenEdit; + } else if(s->list_sel == 1) { + s->rx_found = false; + s->screen = ScreenReceive; + openshock_rx_start(app->rx); + } else if(list_row_is_collar(s, s->list_sel)) { + SavedShocker* sel = &s->saved[list_collar_index(s->list_sel)]; snprintf(s->current.name, sizeof(s->current.name), "%s", sel->name); + snprintf(s->current.saved_path, sizeof(s->current.saved_path), "%s", sel->filename); s->current.model = sel->model; s->current.shocker_id = sel->shocker_id; s->current.channel = sel->channel; - s->current.command = ShockerCmdVibrate; + s->current.sync_group = sel->sync_group; + s->current.command = ShockerCmdShock; s->current.intensity = 0; s->saved_intensity = 0; - s->saved_light = 100; // OFF by default + s->saved_light = 100; s->current_is_saved = true; ensure_valid(&s->current); + s->transmit_bar_focus = false; + s->transmit_sync_cycle = 0; s->screen = ScreenTransmit; - } else if(s->list_sel == (int)s->saved_count) { - // Add new → go to edit with defaults - memset(&s->current, 0, sizeof(s->current)); - s->current.command = ShockerCmdVibrate; - s->current_is_saved = false; - s->edit_field = EditModel; - s->screen = ScreenEdit; - } else { - // Receive - s->rx_found = false; - s->screen = ScreenReceive; - openshock_rx_start(s->rx); } break; case InputKeyRight: - // Edit selected shocker - if(s->list_sel < (int)s->saved_count) { - SavedShocker* sel = &s->saved[s->list_sel]; + if(list_row_is_collar(s, s->list_sel)) { + SavedShocker* sel = &s->saved[list_collar_index(s->list_sel)]; snprintf(s->current.name, sizeof(s->current.name), "%s", sel->name); + snprintf(s->current.saved_path, sizeof(s->current.saved_path), "%s", sel->filename); s->current.model = sel->model; s->current.shocker_id = sel->shocker_id; s->current.channel = sel->channel; - s->current.command = ShockerCmdVibrate; + s->current.sync_group = sel->sync_group; + s->current.command = ShockerCmdShock; s->current.intensity = 0; s->current_is_saved = true; - s->edit_field = EditModel; + s->edit_saved_idx = list_collar_index(s->list_sel); + s->edit_field = EditName; s->screen = ScreenEdit; } break; case InputKeyLeft: - // Delete selected shocker - if(s->list_sel < (int)s->saved_count) { - openshock_shocker_delete(s->saved[s->list_sel].filename); + if(s->list_sel == 0 || s->list_sel == 1) { + s->screen = ScreenSettings; + break; + } + if(list_row_is_collar(s, s->list_sel)) { + openshock_shocker_delete(s->saved[list_collar_index(s->list_sel)].filename); reload_list(s); show_flash(s, "Deleted"); } break; - case InputKeyBack: + default: + break; + } + return false; +} + +static bool handle_settings(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeShort) { + s->screen = ScreenList; + return true; + } + if(e->type == InputTypeLong) { + view_dispatcher_stop(app->view_dispatcher); + return true; + } + return false; + } + + if(e->type != InputTypePress && e->type != InputTypeRepeat) return false; + + switch(e->key) { + case InputKeyOk: + if(e->type != InputTypePress) return false; + s->screen = ScreenList; + return true; + case InputKeyLeft: + s->transmit_vertical_ui = false; + openshock_settings_save(false); + return true; + case InputKeyRight: + s->transmit_vertical_ui = true; + openshock_settings_save(true); return true; default: break; @@ -441,130 +1402,302 @@ static bool handle_list(AppState* s, InputEvent* e) { return false; } -static void handle_transmit(AppState* s, InputEvent* e, OpenshockTx* tx, OpenshockTx** active) { +static bool handle_transmit_horizontal(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + OpenshockTx* tx = app->tx; + OpenshockTx** active = &app->tx_active; ShockerState* c = &s->current; - // OK press: start TX + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeShort) { + if(*active) { + openshock_tx_stop(tx); + *active = NULL; + s->transmitting = false; + } + if(c->sync_group != 0 && s->current_is_saved) { + size_t group_idx[OPENSHOCK_MAX_SAVED]; + size_t gn = tx_group_member_indices(s, group_idx, OPENSHOCK_MAX_SAVED); + if(gn > 0) { + s->transmit_sync_cycle = + (uint8_t)((s->transmit_sync_cycle + 1) % (uint8_t)(gn + 1)); + if(s->transmit_sync_cycle == 0) { + show_flash_tx_horizontal(s, "TX Group %u", c->sync_group); + } else { + show_flash_tx_horizontal( + s, "TX Collar %u", (unsigned)s->transmit_sync_cycle); + } + return true; + } + } + s->transmit_bar_focus = false; + s->transmit_sync_cycle = 0; + s->screen = ScreenList; + return true; + } + if(e->type == InputTypeLong) { + if(*active) { + openshock_tx_stop(tx); + *active = NULL; + s->transmitting = false; + } + s->transmit_bar_focus = false; + s->transmit_sync_cycle = 0; + s->screen = ScreenList; + return true; + } + return false; + } + if(e->key == InputKeyOk && e->type == InputTypePress) { + if(transmit_sync_popup_active(s)) return true; s->transmitting = true; OokPulse pulses[OPENSHOCK_MAX_PULSES]; - size_t count = openshock_encode( - c->model, - c->shocker_id, - c->command, - c->intensity, - c->channel, - pulses, - OPENSHOCK_MAX_PULSES); - if(count > 0) { + size_t count = 0; + if(tx_fill_pulses(s, pulses, &count) && count > 0) { openshock_tx_start(tx, pulses, count); *active = tx; } - return; + return true; } - // OK release: stop TX if(e->key == InputKeyOk && e->type == InputTypeRelease && *active) { openshock_tx_stop(tx); *active = NULL; s->transmitting = false; - return; + return true; } - // Back: return to list - if(e->key == InputKeyBack && (e->type == InputTypePress || e->type == InputTypeShort)) { - if(*active) { + if(e->type != InputTypePress && e->type != InputTypeRepeat) return false; + + if(*active) { + switch(e->key) { + case InputKeyUp: + case InputKeyDown: + case InputKeyLeft: + case InputKeyRight: openshock_tx_stop(tx); *active = NULL; s->transmitting = false; + break; + default: + break; } - s->screen = ScreenList; - return; } - if(e->type != InputTypePress && e->type != InputTypeRepeat) return; - switch(e->key) { case InputKeyUp: if(c->command == ShockerCmdLight) { - c->intensity = 0; // ON - if(*active) openshock_tx_stop(tx); - OokPulse pulses_on[OPENSHOCK_MAX_PULSES]; - size_t cnt_on = openshock_encode( - c->model, - c->shocker_id, - c->command, - c->intensity, - c->channel, - pulses_on, - OPENSHOCK_MAX_PULSES); - if(cnt_on > 0) { - openshock_tx_start(tx, pulses_on, cnt_on); - *active = tx; - s->transmitting = true; - } + if(transmit_sync_popup_active(s)) return true; + c->intensity = 0; } else if(c->intensity < 100) { uint8_t step = (e->type == InputTypeRepeat) ? 5 : 1; uint16_t n = (uint16_t)c->intensity + step; c->intensity = (n <= 100) ? (uint8_t)n : 100; } - break; + return true; case InputKeyDown: if(c->command == ShockerCmdLight) { - c->intensity = 100; // OFF - if(*active) openshock_tx_stop(tx); - OokPulse pulses_off[OPENSHOCK_MAX_PULSES]; - size_t cnt_off = openshock_encode( - c->model, - c->shocker_id, - c->command, - c->intensity, - c->channel, - pulses_off, - OPENSHOCK_MAX_PULSES); - if(cnt_off > 0) { - openshock_tx_start(tx, pulses_off, cnt_off); - *active = tx; - s->transmitting = true; - } + if(transmit_sync_popup_active(s)) return true; + c->intensity = 100; } else if(c->intensity > 0) { uint8_t step = (e->type == InputTypeRepeat) ? 5 : 1; c->intensity = (c->intensity >= step) ? (c->intensity - step) : 0; } - break; + return true; case InputKeyLeft: case InputKeyRight: - // Stop any active TX when switching modes if(*active) { openshock_tx_stop(tx); *active = NULL; s->transmitting = false; } { - bool was_light = (c->command == ShockerCmdLight); - if(e->key == InputKeyLeft) - cycle_cmd_back(c); - else - cycle_cmd_fwd(c); - bool is_light = (c->command == ShockerCmdLight); - if(!was_light && is_light) { - // Entering light: save intensity, restore light state - s->saved_intensity = c->intensity; - c->intensity = s->saved_light; - } else if(was_light && !is_light) { - // Leaving light: save light state, restore intensity - s->saved_light = c->intensity; - c->intensity = s->saved_intensity; - } + ShockerCommand n = (e->key == InputKeyLeft) ? cycle_cmd_back_cmd(c) : cycle_cmd_fwd_cmd(c); + transmit_apply_command(s, n); } + return true; + default: break; + } + return false; +} + +static bool handle_transmit(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + if(!s->transmit_vertical_ui) { + return handle_transmit_horizontal(app, e); + } + OpenshockTx* tx = app->tx; + OpenshockTx** active = &app->tx_active; + ShockerState* c = &s->current; + + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeLong) { + if(*active) { + openshock_tx_stop(tx); + *active = NULL; + s->transmitting = false; + } + s->transmit_bar_focus = false; + s->transmit_sync_cycle = 0; + s->screen = ScreenList; + return true; + } + if(e->type == InputTypeShort) { + if(c->sync_group == 0 || !s->current_is_saved) { + return true; + } + size_t group_idx[OPENSHOCK_MAX_SAVED]; + size_t gn = tx_group_member_indices(s, group_idx, OPENSHOCK_MAX_SAVED); + if(gn == 0) return true; + s->transmit_sync_cycle = + (uint8_t)((s->transmit_sync_cycle + 1) % (uint8_t)(gn + 1)); + if(s->transmit_sync_cycle == 0) { + show_flash_tx(s, "TX Group %u", c->sync_group); + } else { + show_flash_tx(s, "TX Collar %u", (unsigned)s->transmit_sync_cycle); + } + return true; + } + return true; + } + + if(e->key == InputKeyOk && e->type == InputTypePress) { + if(transmit_sync_popup_active(s)) return true; + s->transmitting = true; + OokPulse pulses[OPENSHOCK_MAX_PULSES]; + size_t count = 0; + if(tx_fill_pulses(s, pulses, &count) && count > 0) { + openshock_tx_start(tx, pulses, count); + *active = tx; + } + return true; + } + + if(e->key == InputKeyOk && e->type == InputTypeRelease && *active) { + openshock_tx_stop(tx); + *active = NULL; + s->transmitting = false; + return true; + } + + if(e->type != InputTypePress && e->type != InputTypeRepeat) return false; + + if(*active) { + switch(e->key) { + case InputKeyUp: + case InputKeyDown: + case InputKeyLeft: + case InputKeyRight: + openshock_tx_stop(tx); + *active = NULL; + s->transmitting = false; + break; + default: + break; + } + } + + switch(e->key) { + case InputKeyUp: { + if(s->transmit_bar_focus) { + if(c->command == ShockerCmdLight) { + c->intensity = 0; + } else if(c->intensity < 100) { + uint8_t step = (e->type == InputTypeRepeat) ? 5 : 1; + uint16_t ni = (uint16_t)c->intensity + step; + c->intensity = (ni <= 100) ? (uint8_t)ni : 100; + } + return true; + } + ShockerCommand n; + if(transmit_grid_try_move(c, -1, 0, &n)) { + transmit_apply_command(s, n); + return true; + } + bool hl = openshock_command_supported(c->model, ShockerCmdLight); + if(c->command == ShockerCmdVibrate && openshock_command_supported(c->model, ShockerCmdShock)) { + transmit_apply_command(s, ShockerCmdShock); + } else if(hl && c->command == ShockerCmdLight && + openshock_command_supported(c->model, ShockerCmdSound)) { + transmit_apply_command(s, ShockerCmdSound); + } + return true; + } + case InputKeyDown: { + if(s->transmit_bar_focus) { + if(c->command == ShockerCmdLight) { + c->intensity = 100; + } else if(c->intensity > 0) { + uint8_t step = (e->type == InputTypeRepeat) ? 5 : 1; + c->intensity = (c->intensity >= step) ? (c->intensity - step) : 0; + } + return true; + } + ShockerCommand n; + if(transmit_grid_try_move(c, 1, 0, &n)) { + transmit_apply_command(s, n); + return true; + } + bool hl = openshock_command_supported(c->model, ShockerCmdLight); + if(c->command == ShockerCmdShock && openshock_command_supported(c->model, ShockerCmdVibrate)) { + transmit_apply_command(s, ShockerCmdVibrate); + } else if(hl && c->command == ShockerCmdSound && + openshock_command_supported(c->model, ShockerCmdLight)) { + transmit_apply_command(s, ShockerCmdLight); + } + return true; + } + case InputKeyLeft: { + if(s->transmit_bar_focus) { + s->transmit_bar_focus = false; + return true; + } + ShockerCommand n; + if(transmit_grid_try_move(c, 0, -1, &n)) transmit_apply_command(s, n); + return true; + } + case InputKeyRight: { + if(s->transmit_bar_focus) { + return true; + } + if(c->command == ShockerCmdLight || c->command == ShockerCmdSound) { + s->transmit_bar_focus = true; + return true; + } + ShockerCommand n; + if(transmit_grid_try_move(c, 0, 1, &n)) transmit_apply_command(s, n); + return true; + } default: break; } + return false; } -static void handle_edit(AppState* s, InputEvent* e) { - if(e->type != InputTypePress && e->type != InputTypeRepeat) return; +static bool handle_edit(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeShort) { + reload_list(s); + s->screen = ScreenList; + return true; + } + return false; + } + + if(e->type != InputTypePress && e->type != InputTypeRepeat) return false; switch(e->key) { case InputKeyUp: @@ -573,10 +1706,39 @@ static void handle_edit(AppState* s, InputEvent* e) { case InputKeyDown: if(s->edit_field < EditFieldCount - 1) s->edit_field++; break; + case InputKeyLeft: + switch(s->edit_field) { + case EditName: + return true; + case EditModel: + s->current.model = + (ShockerModel)((s->current.model + ShockerModelCount - 1) % ShockerModelCount); + ensure_valid(&s->current); + break; + case EditID: { + uint16_t step = (e->type == InputTypeRepeat) ? 100 : 1; + uint32_t v = (uint32_t)s->current.shocker_id + 65536u - (uint32_t)step; + s->current.shocker_id = (uint16_t)(v % 65536u); + break; + } + case EditChannel: { + uint8_t mx = max_channel(s->current.model); + s->current.channel = (s->current.channel == 0) ? mx : (uint8_t)(s->current.channel - 1); + break; + } + case EditSync: + s->current.sync_group = (s->current.sync_group + 9) % 10; + break; + default: + break; + } + return true; case InputKeyRight: switch(s->edit_field) { + case EditName: + return true; case EditModel: - s->current.model = (s->current.model + 1) % ShockerModelCount; + s->current.model = (ShockerModel)((s->current.model + 1) % ShockerModelCount); ensure_valid(&s->current); break; case EditID: { @@ -590,154 +1752,153 @@ static void handle_edit(AppState* s, InputEvent* e) { s->current.channel = (s->current.channel >= mx) ? 0 : (s->current.channel + 1); break; } + case EditSync: + s->current.sync_group = (s->current.sync_group + 1) % 10; + break; default: break; } - break; - case InputKeyLeft: - // Save - if(e->type == InputTypePress) { - char name[32]; - snprintf( - name, - sizeof(name), - "%s_%u", - openshock_model_name(s->current.model), - s->current.shocker_id); - openshock_shocker_save( - name, s->current.model, s->current.shocker_id, s->current.channel); - show_flash(s, "Saved!"); - reload_list(s); + return true; + case InputKeyOk: + if(e->type != InputTypePress) return false; + if(s->edit_field == EditName) { + openshock_open_name_input(app); + return true; } - break; - case InputKeyBack: - reload_list(s); - s->screen = ScreenList; - break; + openshock_save_current_shocker(app); + return true; default: break; } + return false; } -static void handle_receive(AppState* s, InputEvent* e) { - if(e->type != InputTypePress && e->type != InputTypeShort) return; +static bool handle_receive(OpenshockCtx* app, InputEvent* e) { + AppState* s = &app->state; + + if(e->key == InputKeyBack) { + if(e->type == InputTypePress) { + return true; + } + if(e->type == InputTypeShort) { + openshock_rx_stop(app->rx); + reload_list(s); + s->screen = ScreenList; + return true; + } + return false; + } + + if(e->type != InputTypePress) return false; switch(e->key) { case InputKeyOk: if(s->rx_found) { - // Load into current and go to transmit s->current.model = s->rx_result.model; s->current.shocker_id = s->rx_result.shocker_id; s->current.channel = s->rx_result.channel; s->current.command = s->rx_result.command; s->current.intensity = s->rx_result.intensity; + s->current.saved_path[0] = '\0'; + s->current.sync_group = 0; s->current_is_saved = false; s->saved_intensity = 0; s->saved_light = 100; ensure_valid(&s->current); - openshock_rx_stop(s->rx); + snprintf( + s->current.name, + sizeof(s->current.name), + "RX %s #%u", + openshock_model_name(s->rx_result.model), + s->rx_result.shocker_id); + openshock_rx_stop(app->rx); + s->transmit_bar_focus = false; + s->transmit_sync_cycle = 0; s->screen = ScreenTransmit; } - break; + return true; case InputKeyLeft: if(s->rx_found) { - char name[32]; - snprintf( - name, - sizeof(name), - "%s_%u", - openshock_model_name(s->rx_result.model), - s->rx_result.shocker_id); - openshock_shocker_save( - name, s->rx_result.model, s->rx_result.shocker_id, s->rx_result.channel); - show_flash(s, "Saved!"); - reload_list(s); + char stem[OPENSHOCK_NAME_MAX_LEN]; + if(openshock_shocker_unique_stem( + s->rx_result.model, + s->rx_result.shocker_id, + stem, + sizeof(stem))) { + if(openshock_shocker_write( + stem, + s->rx_result.model, + s->rx_result.shocker_id, + s->rx_result.channel, + 0, + NULL)) { + show_flash(s, "Saved!"); + reload_list(s); + } else { + show_flash(s, "Save failed"); + } + } else { + show_flash(s, "No free name"); + } } - break; - case InputKeyBack: - openshock_rx_stop(s->rx); - reload_list(s); - s->screen = ScreenList; - break; + return true; default: break; } + return false; } -// --- Main --- - int32_t openshock_app(void* p) { UNUSED(p); - AppState state; - memset(&state, 0, sizeof(state)); - state.screen = ScreenList; - state.current.command = ShockerCmdVibrate; - state.mutex = furi_mutex_alloc(FuriMutexTypeNormal); - state.tx = openshock_tx_alloc(); - state.rx = openshock_rx_alloc(); - reload_list(&state); + OpenshockCtx ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.state.screen = ScreenList; + ctx.state.current.command = ShockerCmdShock; + ctx.mutex = furi_mutex_alloc(FuriMutexTypeNormal); + ctx.tx = openshock_tx_alloc(); + ctx.rx = openshock_rx_alloc(); + reload_list(&ctx.state); + openshock_settings_load(&ctx.state.transmit_vertical_ui); - FuriMessageQueue* event_queue = furi_message_queue_alloc(8, sizeof(InputEvent)); + Gui* gui = furi_record_open(RECORD_GUI); - ViewPort* view_port = view_port_alloc(); - view_port_draw_callback_set(view_port, draw_callback, &state); - view_port_input_callback_set(view_port, input_callback, event_queue); + ctx.view_dispatcher = view_dispatcher_alloc(); + view_dispatcher_set_event_callback_context(ctx.view_dispatcher, &ctx); + view_dispatcher_set_navigation_event_callback(ctx.view_dispatcher, openshock_navigation_cb); - Gui* gui = furi_record_open(RECORD_GUI); - gui_add_view_port(gui, view_port, GuiLayerFullscreen); - - InputEvent event; - OpenshockTx* tx_active = NULL; - bool running = true; - - while(running) { - // Poll RX results - if(state.screen == ScreenReceive) { - DecodedShocker decoded; - if(openshock_rx_get_result(state.rx, &decoded)) { - furi_mutex_acquire(state.mutex, FuriWaitForever); - state.rx_result = decoded; - state.rx_found = true; - furi_mutex_release(state.mutex); - view_port_update(view_port); - } - } + ctx.main_view = view_alloc(); + view_allocate_model(ctx.main_view, ViewModelTypeLockFree, sizeof(OpenshockMainModel)); + OpenshockMainModel* mm = view_get_model(ctx.main_view); + mm->app = &ctx; + view_set_context(ctx.main_view, &ctx); + view_set_draw_callback(ctx.main_view, main_draw); + view_set_input_callback(ctx.main_view, main_view_input); - FuriStatus status = furi_message_queue_get(event_queue, &event, 50); - if(status != FuriStatusOk) { - view_port_update(view_port); - continue; - } + ctx.text_input = text_input_alloc(); - furi_mutex_acquire(state.mutex, FuriWaitForever); + view_dispatcher_add_view(ctx.view_dispatcher, OpenshockViewMain, ctx.main_view); + view_dispatcher_add_view( + ctx.view_dispatcher, OpenshockViewTextInput, text_input_get_view(ctx.text_input)); - switch(state.screen) { - case ScreenList: - running = !handle_list(&state, &event); - break; - case ScreenTransmit: - handle_transmit(&state, &event, state.tx, &tx_active); - break; - case ScreenEdit: - handle_edit(&state, &event); - break; - case ScreenReceive: - handle_receive(&state, &event); - break; - } + view_dispatcher_attach_to_gui(ctx.view_dispatcher, gui, ViewDispatcherTypeFullscreen); - furi_mutex_release(state.mutex); - view_port_update(view_port); - } + ctx.shown_view = OpenshockViewMain; + view_dispatcher_switch_to_view(ctx.view_dispatcher, OpenshockViewMain); + + view_dispatcher_run(ctx.view_dispatcher); + + if(ctx.tx_active) openshock_tx_stop(ctx.tx); + openshock_tx_free(ctx.tx); + openshock_rx_free(ctx.rx); + + view_dispatcher_remove_view(ctx.view_dispatcher, OpenshockViewTextInput); + view_dispatcher_remove_view(ctx.view_dispatcher, OpenshockViewMain); + text_input_free(ctx.text_input); + view_free(ctx.main_view); + view_dispatcher_free(ctx.view_dispatcher); - if(tx_active) openshock_tx_stop(state.tx); - openshock_tx_free(state.tx); - openshock_rx_free(state.rx); - gui_remove_view_port(gui, view_port); - view_port_free(view_port); - furi_message_queue_free(event_queue); - furi_mutex_free(state.mutex); + furi_mutex_free(ctx.mutex); furi_record_close(RECORD_GUI); return 0; diff --git a/protocols.h b/protocols.h index a55a297..97adb22 100644 --- a/protocols.h +++ b/protocols.h @@ -27,8 +27,8 @@ typedef struct { uint16_t low_us; } OokPulse; -// Maximum pulse buffer size across all protocols (CaiXianlin is largest at 44) -#define OPENSHOCK_MAX_PULSES 48 +// Maximum pulse buffer size (dual TX concatenates two packets + gap; CaiXianlin is 44 each) +#define OPENSHOCK_MAX_PULSES 160 // Decoded shocker command (result of RX decoding). typedef struct { diff --git a/settings.c b/settings.c new file mode 100644 index 0000000..6d1bb46 --- /dev/null +++ b/settings.c @@ -0,0 +1,56 @@ +#include "settings.h" + +#include +#include +#include + +#include + +#define SETTINGS_PATH APP_DATA_PATH("openshock_settings.cfg") +#define SETTINGS_TYPE "OpenShock Settings" +#define SETTINGS_VER 1u + +void openshock_settings_load(bool* transmit_vertical_ui_out) { + *transmit_vertical_ui_out = false; + + Storage* storage = furi_record_open(RECORD_STORAGE); + FlipperFormat* ff = flipper_format_file_alloc(storage); + do { + if(!flipper_format_file_open_existing(ff, SETTINGS_PATH)) break; + + FuriString* hdr = furi_string_alloc(); + uint32_t ver = 0; + if(!flipper_format_read_header(ff, hdr, &ver)) { + furi_string_free(hdr); + break; + } + if(strcmp(furi_string_get_cstr(hdr), SETTINGS_TYPE) != 0 || ver != SETTINGS_VER) { + furi_string_free(hdr); + break; + } + furi_string_free(hdr); + + uint32_t v = 0; + if(flipper_format_read_uint32(ff, "TransmitVertical", &v, 1)) { + *transmit_vertical_ui_out = (v != 0); + } + } while(false); + flipper_format_file_close(ff); + flipper_format_free(ff); + furi_record_close(RECORD_STORAGE); +} + +void openshock_settings_save(bool transmit_vertical_ui) { + Storage* storage = furi_record_open(RECORD_STORAGE); + + FlipperFormat* ff = flipper_format_file_alloc(storage); + do { + if(!flipper_format_file_open_always(ff, SETTINGS_PATH)) break; + if(!flipper_format_write_header_cstr(ff, SETTINGS_TYPE, SETTINGS_VER)) break; + uint32_t v = transmit_vertical_ui ? 1u : 0u; + if(!flipper_format_write_uint32(ff, "TransmitVertical", &v, 1)) break; + } while(false); + flipper_format_file_close(ff); + flipper_format_free(ff); + furi_record_close(RECORD_STORAGE); +} diff --git a/settings.h b/settings.h new file mode 100644 index 0000000..e26206f --- /dev/null +++ b/settings.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +void openshock_settings_load(bool* transmit_vertical_ui_out); +void openshock_settings_save(bool transmit_vertical_ui); diff --git a/storage.c b/storage.c index 02796cc..4c844a0 100644 --- a/storage.c +++ b/storage.c @@ -1,7 +1,5 @@ #include "storage.h" -#include -#include #include #include @@ -10,12 +8,53 @@ #define FILE_TYPE "OpenShock Shocker" #define FILE_VERSION 1 -bool openshock_shocker_save(const char* name, ShockerModel model, uint16_t id, uint8_t channel) { +bool openshock_shocker_unique_stem(ShockerModel model, uint16_t id, char* stem_out, size_t stem_sz) { + Storage* storage = furi_record_open(RECORD_STORAGE); + storage_simply_mkdir(storage, OPENSHOCK_SAVE_DIR); + + const char* model_name = openshock_model_name(model); + bool ok = false; + + for(unsigned suffix = 0; suffix < 1000; suffix++) { + char candidate[OPENSHOCK_NAME_MAX_LEN]; + if(suffix == 0) { + snprintf(candidate, sizeof(candidate), "%s_%u", model_name, id); + } else { + snprintf(candidate, sizeof(candidate), "%s_%u_%u", model_name, id, suffix); + } + + char path[OPENSHOCK_PATH_MAX_LEN]; + snprintf(path, sizeof(path), "%s/%s.shk", OPENSHOCK_SAVE_DIR, candidate); + + FileInfo info; + FS_Error err = storage_common_stat(storage, path, &info); + if(err != FSE_OK) { + snprintf(stem_out, stem_sz, "%s", candidate); + ok = true; + break; + } + } + + furi_record_close(RECORD_STORAGE); + return ok; +} + +bool openshock_shocker_write( + const char* stem, + ShockerModel model, + uint16_t id, + uint8_t channel, + uint8_t sync_group, + const char* replace_path) { + if(!stem || stem[0] == '\0') return false; + + if(sync_group > 9) sync_group = 9; + Storage* storage = furi_record_open(RECORD_STORAGE); storage_simply_mkdir(storage, OPENSHOCK_SAVE_DIR); char path[OPENSHOCK_PATH_MAX_LEN]; - snprintf(path, sizeof(path), "%s/%s.shk", OPENSHOCK_SAVE_DIR, name); + snprintf(path, sizeof(path), "%s/%s.shk", OPENSHOCK_SAVE_DIR, stem); FlipperFormat* ff = flipper_format_file_alloc(storage); bool ok = false; @@ -23,7 +62,7 @@ bool openshock_shocker_save(const char* name, ShockerModel model, uint16_t id, u do { if(!flipper_format_file_open_always(ff, path)) break; if(!flipper_format_write_header_cstr(ff, FILE_TYPE, FILE_VERSION)) break; - if(!flipper_format_write_string_cstr(ff, "Name", name)) break; + if(!flipper_format_write_string_cstr(ff, "Name", stem)) break; if(!flipper_format_write_string_cstr(ff, "Model", openshock_model_name(model))) break; uint32_t val; @@ -31,12 +70,19 @@ bool openshock_shocker_save(const char* name, ShockerModel model, uint16_t id, u if(!flipper_format_write_uint32(ff, "ID", &val, 1)) break; val = channel; if(!flipper_format_write_uint32(ff, "Channel", &val, 1)) break; + val = sync_group; + if(!flipper_format_write_uint32(ff, "Sync", &val, 1)) break; ok = true; } while(false); flipper_format_file_close(ff); flipper_format_free(ff); + + if(ok && replace_path && strcmp(replace_path, path) != 0) { + storage_common_remove(storage, replace_path); + } + furi_record_close(RECORD_STORAGE); return ok; } @@ -53,7 +99,7 @@ static bool parse_model(const char* str, ShockerModel* out) { static bool load_one(Storage* storage, const char* path, SavedShocker* out) { FlipperFormat* ff = flipper_format_file_alloc(storage); - bool ok = false; + bool load_ok = false; do { if(!flipper_format_file_open_existing(ff, path)) break; @@ -92,13 +138,33 @@ static bool load_one(Storage* storage, const char* path, SavedShocker* out) { if(!flipper_format_read_uint32(ff, "Channel", &val, 1)) break; out->channel = (uint8_t)val; + out->sync_group = 0; + if(flipper_format_read_uint32(ff, "Sync", &val, 1)) { + if(val <= 9) out->sync_group = (uint8_t)val; + } + snprintf(out->filename, sizeof(out->filename), "%s", path); - ok = true; + load_ok = true; } while(false); flipper_format_file_close(ff); flipper_format_free(ff); - return ok; + return load_ok; +} + +static void saved_shocker_sort_by_name(SavedShocker* list, size_t count) { + for(size_t i = 1; i < count; i++) { + SavedShocker key = list[i]; + size_t j = i; + while(j > 0) { + int r = strcmp(list[j - 1].name, key.name); + if(r == 0) r = strcmp(list[j - 1].filename, key.filename); + if(r <= 0) break; + list[j] = list[j - 1]; + j--; + } + list[j] = key; + } } size_t openshock_shocker_list(SavedShocker* list, size_t max_count) { @@ -118,7 +184,6 @@ size_t openshock_shocker_list(SavedShocker* list, size_t max_count) { while(count < max_count && storage_dir_read(dir, &info, name, sizeof(name))) { if(info.flags & FSF_DIRECTORY) continue; - // Only .shk files size_t len = strlen(name); if(len < 5 || strcmp(name + len - 4, ".shk") != 0) continue; @@ -132,6 +197,10 @@ size_t openshock_shocker_list(SavedShocker* list, size_t max_count) { storage_dir_close(dir); } + if(count > 1) { + saved_shocker_sort_by_name(list, count); + } + storage_file_free(dir); furi_record_close(RECORD_STORAGE); return count; @@ -143,3 +212,14 @@ bool openshock_shocker_delete(const char* path) { furi_record_close(RECORD_STORAGE); return err == FSE_OK; } + +bool openshock_stem_exists(const char* stem) { + if(!stem || !stem[0]) return false; + char path[OPENSHOCK_PATH_MAX_LEN]; + snprintf(path, sizeof(path), "%s/%s.shk", OPENSHOCK_SAVE_DIR, stem); + Storage* storage = furi_record_open(RECORD_STORAGE); + FileInfo info; + FS_Error err = storage_common_stat(storage, path, &info); + furi_record_close(RECORD_STORAGE); + return err == FSE_OK; +} diff --git a/storage.h b/storage.h index 05e93ca..455af53 100644 --- a/storage.h +++ b/storage.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include "protocols.h" #include @@ -9,18 +11,27 @@ #define OPENSHOCK_PATH_MAX_LEN 128 typedef struct { - char filename[OPENSHOCK_PATH_MAX_LEN]; // Full path + char filename[OPENSHOCK_PATH_MAX_LEN]; char name[OPENSHOCK_NAME_MAX_LEN]; ShockerModel model; uint16_t shocker_id; uint8_t channel; + uint8_t sync_group; } SavedShocker; -// Save a shocker config. Name is used for the filename and stored in the file. -bool openshock_shocker_save(const char* name, ShockerModel model, uint16_t id, uint8_t channel); +// replace_path: remove after successful write when renaming to a different file. +bool openshock_shocker_write( + const char* stem, + ShockerModel model, + uint16_t id, + uint8_t channel, + uint8_t sync_group, + const char* replace_path); + +bool openshock_shocker_unique_stem(ShockerModel model, uint16_t id, char* stem_out, size_t stem_sz); -// Load the list of saved shockers. Returns number loaded. size_t openshock_shocker_list(SavedShocker* list, size_t max_count); -// Delete a saved shocker by its full path. bool openshock_shocker_delete(const char* path); + +bool openshock_stem_exists(const char* stem);