Merge pull request #23 from Calvario/joysticktest

Add GPS altitude support, add channel “reply to” targeting, fix snake…
This commit is contained in:
liquidraver
2026-05-21 21:42:37 +02:00
committed by GitHub
11 changed files with 216 additions and 97 deletions
+16
View File
@@ -147,6 +147,9 @@ static bool adv_stop_for_interval_change;
* (SerialBLEInterface.cpp:343). */
static bool adv_running;
/* Administrative BLE state */
static bool ble_enabled = true;
/* Runtime BLE passkey */
static uint32_t ble_passkey = CONFIG_ZEPHCORE_BLE_PASSKEY;
@@ -781,6 +784,10 @@ static void adv_slow_work_fn(struct k_work *work)
static void start_adv(void)
{
if (!ble_enabled) {
return;
}
uint16_t interval = fast_adv_active ? BT_ADV_FAST_INTERVAL : BT_ADV_INTERVAL;
struct bt_le_adv_param adv_param = {
@@ -908,6 +915,7 @@ size_t zephcore_ble_send(const uint8_t *data, uint16_t len)
void zephcore_ble_set_enabled(bool enable)
{
ble_enabled = enable;
if (!enable) {
/* Disconnect current connection if any */
if (current_conn) {
@@ -917,6 +925,10 @@ void zephcore_ble_set_enabled(bool enable)
/* Stop advertising */
bt_le_adv_stop();
adv_running = false;
/* No future fast->slow transition needed */
k_work_cancel_delayable(&adv_slow_work);
LOG_INF("BLE disabled");
} else {
/* Re-enable advertising — start fast window */
@@ -926,6 +938,10 @@ void zephcore_ble_set_enabled(bool enable)
LOG_INF("BLE enabled");
}
}
bool zephcore_ble_is_enabled(void)
{
return ble_enabled;
}
bool zephcore_ble_is_active(void)
{
+3
View File
@@ -43,6 +43,9 @@ size_t zephcore_ble_send(const uint8_t *data, uint16_t len);
/** Enable/disable BLE. Disabling disconnects and stops advertising. */
void zephcore_ble_set_enabled(bool enable);
/** True if BLE is enabled */
bool zephcore_ble_is_enabled(void);
/** True if BLE is the active transport and ready to send. */
bool zephcore_ble_is_active(void);
@@ -111,6 +111,8 @@ class GPSSettingsScreen : public UIScreen {
float _speed_kmh, _heading_deg;
bool _heading_valid;
uint32_t _heading_hold_until;
bool _show_alt;
uint32_t _alt_cycle_ms;
struct k_timer _sample_timer;
static void sampleTimerCb(struct k_timer *t);
void sampleGPS();
@@ -235,6 +237,13 @@ class T9InputScreen : public UIScreen {
public:
T9InputScreen(JoystickUITask *task);
void clearInput() { memset(_input, 0, sizeof(_input)); _cursor = 0; }
void setInitialInput(const char *text) {
if (!text || !text[0]) return;
strncpy(_input, text, sizeof(_input) - 1);
_input[sizeof(_input) - 1] = '\0';
_cursor = (int)strlen(_input);
_last_key = -1; _letter_index = 0; _last_press_time = 0;
}
const char *getInput() const { return _input; }
int render(JoystickDisplay &display) override;
bool handleInput(char c) override;
@@ -313,17 +322,24 @@ public:
/* ===== SnakeScreen ===== */
class SnakeScreen : public UIScreen {
JoystickUITask *_task;
static const int GRID_W = 21;
static const int GRID_H = 5;
int8_t _snake_x[GRID_W * GRID_H];
int8_t _snake_y[GRID_W * GRID_H];
/* runtime grid */
int _grid_w;
int _grid_h;
int _max_len;
/* snake storage (safe upper bound) */
static constexpr int MAX_SNAKE_LEN = 1024;
int8_t _snake_x[MAX_SNAKE_LEN];
int8_t _snake_y[MAX_SNAKE_LEN];
int _snake_len;
int8_t _food_x, _food_y;
uint32_t _next_move;
int _score;
bool _grid_ready;
struct k_timer _tick_timer;
volatile bool _tick_due; /* set in timer ISR, consumed in render */
static void tickTimerCb(struct k_timer *t);
void updateGrid(JoystickDisplay &display);
void placeFood();
void reset();
void advanceGame();
@@ -197,13 +197,6 @@ extern "C" void ui_notify_packet_sent(void)
}
}
extern "C" void ui_set_msg_count(uint16_t count)
{
if (s_task) {
s_task->msgRead((int)count);
}
}
extern "C" void ui_set_ble_status(bool connected, const char *name)
{
(void)name;
@@ -232,21 +225,6 @@ extern "C" void ui_notify_radio_stats(uint32_t pkt_recv, uint32_t pkt_sent, uint
}
}
/* ===== Pull-model no-ops =====
* The joystick UI reads all hardware state on demand at render time, so these
* push-model hooks from the old button UI have no work to do. */
extern "C" void ui_set_gps_data(bool, uint8_t, int32_t, int32_t, int32_t) {}
extern "C" void ui_set_clock(uint32_t) {}
extern "C" void ui_add_recent(const char *, int16_t, uint32_t) {}
extern "C" void ui_set_node_name(const char *) {}
extern "C" void ui_clear_recent(void) {}
extern "C" void ui_set_sensor_data(int16_t, uint32_t, uint16_t, uint16_t) {}
extern "C" void ui_set_gps_available(bool) {}
extern "C" void ui_set_gps_enabled(bool) {}
extern "C" void ui_set_gps_state(uint8_t, uint32_t, uint32_t) {}
extern "C" void ui_set_buzzer_quiet(bool) {}
extern "C" void ui_set_offgrid_mode(bool) {}
extern "C" void ui_set_battery(uint16_t mv, uint8_t /*pct*/)
{
if (s_task) {
@@ -267,3 +245,20 @@ extern "C" void ui_refresh_display(void)
s_task->forceRefresh();
}
}
/* ===== Pull-model no-ops =====
* The joystick UI reads all hardware state on demand at render time, so these
* push-model hooks from the old button UI have no work to do. */
extern "C" void ui_set_gps_data(bool, uint8_t, int32_t, int32_t, int32_t) {}
extern "C" void ui_set_clock(uint32_t) {}
extern "C" void ui_add_recent(const char *, int16_t, uint32_t) {}
extern "C" void ui_set_node_name(const char *) {}
extern "C" void ui_clear_recent(void) {}
extern "C" void ui_set_sensor_data(int16_t, uint32_t, uint16_t, uint16_t) {}
extern "C" void ui_set_gps_available(bool) {}
extern "C" void ui_set_gps_enabled(bool) {}
extern "C" void ui_set_gps_state(uint8_t, uint32_t, uint32_t) {}
extern "C" void ui_set_buzzer_quiet(bool) {}
extern "C" void ui_set_offgrid_mode(bool) {}
extern "C" void ui_set_msg_count(uint16_t count) {}
@@ -300,6 +300,16 @@ void JoystickUITask::gotoRadioStatsScreen() { setCurrScreen(_radio_stats); }
void JoystickUITask::gotoRepeatersScreen() { setCurrScreen(_repeaters); }
void JoystickUITask::gotoChannelsScreen() { setCurrScreen(_channels); }
void JoystickUITask::gotoT9InputScreen() { setCurrScreen(_t9_input); }
void JoystickUITask::gotoT9InputScreenWithPrefix(const char *prefix)
{
if (!_t9_input) return;
auto *t9 = static_cast<T9InputScreen *>(_t9_input);
t9->clearInput();
t9->setInitialInput(prefix);
setCurrScreen(_t9_input);
}
void JoystickUITask::gotoRenameNodeScreen() { setCurrScreen(_rename_node); }
void JoystickUITask::gotoBLECodeScreen() { setCurrScreen(_ble_code); }
void JoystickUITask::gotoStatsScreen() { setCurrScreen(_stats); }
@@ -670,7 +680,7 @@ void JoystickUITask::toggleWakeOnMsg()
/* ===== Notifications from mesh ===== */
void JoystickUITask::newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount)
{
(void)msgcount; /* tracked by CompanionMesh; we mirror via msgRead() side-effect only */
(void)msgcount; /* tracked by CompanionMesh */
if (_unread) {
/* When BLE phone is connected it pulls offline queue and marks read;
* keep the message in our local history but don't count as unread. */
@@ -723,16 +733,6 @@ void JoystickUITask::newChannelMsg(const char *channel_name, const char *text,
#endif
}
void JoystickUITask::msgRead(int msgcount)
{
/* Called from CompanionMesh when the BLE-offline-queue drains to 0
* (phone synced); auto-navigate home if the user is sitting on the
* Unread list looking at what just got cleared. */
if (msgcount == 0 && _curr == _unread) {
gotoHomeScreen();
}
}
void JoystickUITask::notify()
{
_next_refresh = 0;
@@ -51,7 +51,6 @@ public:
/* Called from CompanionMesh callbacks (mesh thread context) */
void newMsg(uint8_t path_len, const char *from_name, const char *text, int msgcount);
void newChannelMsg(const char *channel_name, const char *text, uint32_t ts, uint8_t path_len);
void msgRead(int msgcount);
void notify(); /* BLE connect/disconnect, ACK, etc. */
/* Alert overlay */
@@ -70,6 +69,7 @@ public:
void gotoRepeatersScreen();
void gotoChannelsScreen();
void gotoT9InputScreen();
void gotoT9InputScreenWithPrefix(const char *prefix);
void gotoRenameNodeScreen();
void gotoBLECodeScreen();
void gotoStatsScreen();
@@ -307,22 +307,22 @@ bool UnreadScreen::handleInput(char key)
return true;
}
if (key == KEY_ENTER_LONG) {
/* Reply to channel message if applicable */
if (entry && strchr(entry->msg, '#') != nullptr) {
const char *hash_pos = strchr(entry->msg, '#');
char channel_name[32] = {};
const char *space_after = hash_pos ? strchr(hash_pos, ' ') : nullptr;
if (space_after) {
size_t len = space_after - hash_pos;
if (len < sizeof(channel_name)) {
memcpy(channel_name, hash_pos, len);
channel_name[len] = '\0';
}
}
if (channel_name[0]) {
_task->setComposeChannel(-1, channel_name);
if (!entry) return true;
/* Determine if this is a channel message by checking origin for '#' */
char origin_sender[32] = {};
uint8_t path_len_origin = OUT_PATH_UNKNOWN;
parseMessageOriginAndPath(entry->origin, origin_sender, sizeof(origin_sender),
&path_len_origin);
if (origin_sender[0] == '#') {
/* Channel message: channel name follows '#' in origin_sender */
const char *ch_name = origin_sender + 1;
_task->setComposeChannel(-1, ch_name);
char at_buf[38];
if (buildChannelReplyPrefix(entry->msg, path_len_origin,
at_buf, sizeof(at_buf))) {
_task->gotoT9InputScreenWithPrefix(at_buf);
} else {
_task->gotoT9InputScreen();
return true;
}
}
return true;
@@ -479,8 +479,9 @@ bool ChannelsScreen::handleInput(char key)
if (_msg_details) {
const char *msg = "";
uint32_t ts = 0;
uint8_t path_len = OUT_PATH_UNKNOWN;
int max_scroll = 0;
if (_task->getChannelPreviewFor(_msg_channel, _msg_scroll, msg, ts)) {
if (_task->getChannelPreviewFor(_msg_channel, _msg_scroll, msg, ts, &path_len)) {
max_scroll = getMessageDetailMaxScrollSanitized(
_task->getDisplay(), msg, 20, 5, 5);
}
@@ -492,7 +493,13 @@ bool ChannelsScreen::handleInput(char key)
}
if (key == KEY_ENTER_LONG) {
_task->setComposeChannel(_msg_channel_idx, _msg_channel);
_task->gotoT9InputScreen();
char at_buf[38];
if (buildChannelReplyPrefix(msg, path_len,
at_buf, sizeof(at_buf))) {
_task->gotoT9InputScreenWithPrefix(at_buf);
} else {
_task->gotoT9InputScreen();
}
return true;
}
return false;
@@ -531,6 +531,29 @@ static inline void renderT9Keypad(JoystickDisplay &display, const char * const *
}
}
/* ===== buildChannelReplyPrefix ===== */
/* Builds "@[SenderName] " from a "SenderName: text" channel message.
* Returns false for outbound messages (OUT_PATH_SENT) or if no ": " separator
* is found. out_buf must be at least (name_len + 5) bytes; 38 is always safe. */
static inline bool buildChannelReplyPrefix(
const char *msg, uint8_t path_len,
char *out_buf, size_t out_size
){
if (path_len == OUT_PATH_SENT) return false;
const char *sep = strstr(msg, ": ");
if (!sep || sep == msg) return false;
size_t nlen = (size_t)(sep - msg);
if (nlen > 32) nlen = 32;
if (out_size < nlen + 5) return false; /* '@' '[' name ']' ' ' '\0' */
out_buf[0] = '@';
out_buf[1] = '[';
memcpy(out_buf + 2, msg, nlen);
out_buf[2 + nlen] = ']';
out_buf[3 + nlen] = ' ';
out_buf[4 + nlen] = '\0';
return true;
}
/* Helper: leave compose input and return to appropriate screen */
static inline void leaveComposeInput(class JoystickUITask *task);
@@ -621,7 +621,8 @@ GPSSettingsScreen::GPSSettingsScreen(JoystickUITask *task, mesh::RTCClock *rtc)
: _task(task), _rtc(rtc), _selected(0),
_have_prev_fix(false), _prev_lat_e6(0), _prev_lon_e6(0),
_last_sample_ms(0), _speed_kmh(0.0f), _heading_deg(0.0f),
_heading_valid(false), _heading_hold_until(0)
_heading_valid(false), _heading_hold_until(0),
_show_alt(false), _alt_cycle_ms(0)
{
k_timer_init(&_sample_timer, sampleTimerCb, NULL);
k_timer_user_data_set(&_sample_timer, this);
@@ -660,12 +661,20 @@ void GPSSettingsScreen::onDisplayOn()
void GPSSettingsScreen::sampleGPS()
{
/* Cycle between position and altitude view every 4 seconds */
uint32_t now = k_uptime_get_32();
if (_alt_cycle_ms == 0) {
_alt_cycle_ms = now + GPS_HEADING_HOLD_MS;
} else if (now >= _alt_cycle_ms) {
_show_alt = !_show_alt;
_alt_cycle_ms = now + GPS_HEADING_HOLD_MS;
}
if (!_task->isGPSAvailable() || !_task->getGPSState()) return;
struct gps_position pos;
gps_get_position(&pos);
if (!pos.valid) return;
uint32_t now = k_uptime_get_32();
if (now - _last_sample_ms < 1000) return;
int32_t lat = (int32_t)(pos.latitude_ndeg / 1000LL);
@@ -737,11 +746,16 @@ int GPSSettingsScreen::render(JoystickDisplay &display)
snprintf(satellites_line, sizeof(satellites_line), "Sat: %d (%s)", sat, fix_str);
if (has_fix) {
double lat = (double)(pos.latitude_ndeg / 1000000LL) / 1000.0;
double lon = (double)(pos.longitude_ndeg / 1000000LL) / 1000.0;
snprintf(lat_lon_line, sizeof(lat_lon_line), "%.4f%c %.4f%c",
fabs(lat), lat >= 0 ? 'N' : 'S',
fabs(lon), lon >= 0 ? 'E' : 'W');
if (_show_alt) {
float alt_m = (float)pos.altitude_mm / 1000.0f;
snprintf(lat_lon_line, sizeof(lat_lon_line), "Alt: %.1f m", (double)alt_m);
} else {
double lat = (double)(pos.latitude_ndeg / 1000000LL) / 1000.0;
double lon = (double)(pos.longitude_ndeg / 1000000LL) / 1000.0;
snprintf(lat_lon_line, sizeof(lat_lon_line), "%.4f%c %.4f%c",
fabs(lat), lat >= 0 ? 'N' : 'S',
fabs(lon), lon >= 0 ? 'E' : 'W');
}
if (_heading_valid) {
snprintf(speed_line, sizeof(speed_line), "%.1f km/h | %s %.0f",
+78 -33
View File
@@ -456,9 +456,11 @@ static int8_t s_ndir_y = 0;
static int s_state = STATE_READY;
SnakeScreen::SnakeScreen(JoystickUITask *task)
: _task(task), _snake_len(0),
_food_x(0), _food_y(0), _score(0),
_tick_due(false)
: _task(task), _grid_w(0), _grid_h(0),
_max_len(0), _snake_len(0),
_food_x(0), _food_y(0),
_next_move(0), _score(0),
_grid_ready(false), _tick_due(false)
{
memset(_snake_x, 0, sizeof(_snake_x));
memset(_snake_y, 0, sizeof(_snake_y));
@@ -505,58 +507,93 @@ void SnakeScreen::onDisplayOn()
if (s_state == STATE_PLAYING) startTicking();
}
void SnakeScreen::updateGrid(JoystickDisplay &display)
{
_grid_w = display.width() / SNAKE_CELL;
_grid_h = (display.height() - SNAKE_HEADER) / SNAKE_CELL;
if (_grid_w < 5) _grid_w = 5;
if (_grid_h < 3) _grid_h = 3;
_max_len = _grid_w * _grid_h;
if (_max_len > MAX_SNAKE_LEN)
_max_len = MAX_SNAKE_LEN;
_grid_ready = true;
}
void SnakeScreen::reset()
{
int cx = GRID_W / 2, cy = GRID_H / 2;
_snake_len = 3;
_snake_x[0] = cx; _snake_y[0] = cy;
_snake_x[1] = cx - 1; _snake_y[1] = cy;
_snake_x[2] = cx - 2; _snake_y[2] = cy;
s_dir_x = 1; s_dir_y = 0;
s_ndir_x = 1; s_ndir_y = 0;
_score = 0;
s_state = STATE_READY;
_tick_due = false;
placeFood();
_next_move = 0;
if (_grid_ready) {
int cx = _grid_w / 2;
int cy = _grid_h / 2;
_snake_x[0] = cx; _snake_y[0] = cy;
_snake_x[1] = cx - 1; _snake_y[1] = cy;
_snake_x[2] = cx - 2; _snake_y[2] = cy;
placeFood();
}
}
void SnakeScreen::placeFood()
{
for (int tries = 0; tries < 200; tries++) {
int8_t x = (int8_t)(sys_rand32_get() % GRID_W);
int8_t y = (int8_t)(sys_rand32_get() % GRID_H);
int8_t x = sys_rand32_get() % _grid_w;
int8_t y = sys_rand32_get() % _grid_h;
bool hit = false;
for (int i = 0; i < _snake_len; i++) {
if (_snake_x[i] == x && _snake_y[i] == y) { hit = true; break; }
if (_snake_x[i] == x && _snake_y[i] == y) {
hit = true;
break;
}
}
if (!hit) {
_food_x = x; _food_y = y;
return;
}
if (!hit) { _food_x = x; _food_y = y; return; }
}
_food_x = 0; _food_y = 0;
}
static bool headHitsBody(const int8_t *sx, const int8_t *sy, int len)
{
for (int i = 1; i < len; i++) {
if (sx[0] == sx[i] && sy[0] == sy[i]) return true;
}
return false;
}
void SnakeScreen::advanceGame()
{
if (s_state != STATE_PLAYING) return;
if (!_grid_ready) return;
uint32_t now = k_uptime_get_32();
if (now < _next_move) return;
_next_move = now + SNAKE_TICK_MS;
s_dir_x = s_ndir_x; s_dir_y = s_ndir_y;
int8_t nx = _snake_x[0] + s_dir_x;
int8_t ny = _snake_y[0] + s_dir_y;
int nx = _snake_x[0] + s_dir_x;
int ny = _snake_y[0] + s_dir_y;
if (nx < 0 || ny < 0 || nx >= GRID_W || ny >= GRID_H) {
/* WALL CHECK */
if (nx < 0 || ny < 0 || nx >= _grid_w || ny >= _grid_h) {
s_state = STATE_OVER;
k_timer_stop(&_tick_timer);
return;
}
bool eat = (nx == _food_x && ny == _food_y);
if (eat && _snake_len < SNAKE_MAX_LEN) _snake_len++;
int body_to_check = eat ? _snake_len : (_snake_len - 1);
for (int i = 1; i < body_to_check; i++) {
if (_snake_x[i] == nx && _snake_y[i] == ny) {
s_state = STATE_OVER;
k_timer_stop(&_tick_timer);
return;
}
}
if (eat && _snake_len < _max_len) _snake_len++;
for (int i = _snake_len - 1; i > 0; i--) {
_snake_x[i] = _snake_x[i - 1];
@@ -564,16 +601,17 @@ void SnakeScreen::advanceGame()
}
_snake_x[0] = nx; _snake_y[0] = ny;
if (headHitsBody(_snake_x, _snake_y, _snake_len)) {
s_state = STATE_OVER;
k_timer_stop(&_tick_timer);
return;
if (eat) {
_score++;
placeFood();
}
if (eat) { _score++; placeFood(); }
}
int SnakeScreen::render(JoystickDisplay &display)
{
// Use the correct size based on the display
updateGrid(display);
/* Advance game on tick fire (set by k_timer ISR). */
if (_tick_due) {
_tick_due = false;
@@ -614,10 +652,17 @@ int SnakeScreen::render(JoystickDisplay &display)
bool SnakeScreen::handleInput(char c)
{
if (c == KEY_UP && s_dir_y == 0) { s_ndir_x = 0; s_ndir_y = -1; return true; }
if (c == KEY_DOWN && s_dir_y == 0) { s_ndir_x = 0; s_ndir_y = 1; return true; }
if (c == KEY_LEFT && s_dir_x == 0) { s_ndir_x = -1; s_ndir_y = 0; return true; }
if (c == KEY_RIGHT && s_dir_x == 0) { s_ndir_x = 1; s_ndir_y = 0; return true; }
/* One turn per tick: if a direction change is already queued for the
* upcoming tick, ignore further direction input. Also block the 180°
* reverse (would move head straight into body[1]).
* Once the tick fires, s_dir == s_ndir again and new input is accepted. */
bool turn_queued = (s_ndir_x != s_dir_x || s_ndir_y != s_dir_y);
if (!turn_queued) {
if (c == KEY_UP && s_dir_y != 1) { s_ndir_x = 0; s_ndir_y = -1; return true; }
if (c == KEY_DOWN && s_dir_y != -1) { s_ndir_x = 0; s_ndir_y = 1; return true; }
if (c == KEY_LEFT && s_dir_x != 1) { s_ndir_x = -1; s_ndir_y = 0; return true; }
if (c == KEY_RIGHT && s_dir_x != -1) { s_ndir_x = 1; s_ndir_y = 0; return true; }
}
if (c == KEY_ENTER) {
if (s_state == STATE_READY || s_state == STATE_OVER) {
reset(); s_state = STATE_PLAYING;
+2 -2
View File
@@ -334,7 +334,7 @@ static void mesh_event_loop(void)
* transiently (HCI timeout, controller pacing) the device
* would silently stop advertising and be undiscoverable
* until next reboot. Cheap to nudge it back here. */
if (!zephcore_ble_is_connected() && !zephcore_ble_is_advertising()) {
if (zephcore_ble_is_enabled && !zephcore_ble_is_connected() && !zephcore_ble_is_advertising()) {
LOG_WRN("BLE adv watchdog: not advertising, re-enabling");
zephcore_ble_set_enabled(true);
}
@@ -675,7 +675,7 @@ int main(void)
ui_set_battery(zephyr_board.getBattMilliVolts(), 0);
ui_set_gps_available(gps_is_available());
ui_set_gps_enabled(companion_mesh.prefs.gps_enabled != 0);
ui_set_ble_enabled(true); /* BLE starts advertising at boot */
ui_set_ble_enabled(companion_mesh.prefs.ble_disabled != 1); /* BLE starts advertising at boot */
/* Restore offgrid mode (client repeat) state from persisted prefs */
ui_set_offgrid_mode(companion_mesh.prefs.client_repeat != 0);