Add GPS altitude support, add channel “reply to” targeting, fix snake wall collisions, fix unread navigation incorrectly returning to home, and fix BLE continuing to advertise after being disabled

This commit is contained in:
Steve Calvário
2026-05-21 17:09:13 +01:00
parent ec2ae31b67
commit 5caf53a496
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);