feat: complete Cascade Indicator companion support
Add native-resolution Indicator UI, touch navigation, SD-backed pixel and emoji fonts, LoRa radio support, dual-slot WiFi Companion provisioning, and mesh clock fallback. Harden radio recovery and build defaults, normalize ESP32 setup-AP protocols, and document/test the operator workflows.
This commit is contained in:
1 parent
9b5541a44d
commit
5938cbe614
55 files changed
+4185
-175
No files matched your search
@@ -23,12 +23,17 @@ FIRMWARE_FILENAME_INFIX=""
|
||||
ESP32_FULL_BUILD=0
|
||||
SINGLE_TARGET_FULL_BUILD=0
|
||||
RADIO_SETTINGS_API_URL="https://api.meshcore.nz/api/v1/config"
|
||||
RADIO_SETTING_TITLE=""
|
||||
RADIO_FREQ_OVERRIDE=""
|
||||
RADIO_BW_OVERRIDE=""
|
||||
RADIO_SF_OVERRIDE=""
|
||||
RADIO_CR_OVERRIDE=""
|
||||
FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-}"
|
||||
USA_CASCADIA_RADIO_TITLE="USA Cascadia"
|
||||
USA_CASCADIA_FALLBACK_FREQ="910.525"
|
||||
USA_CASCADIA_FALLBACK_BW="62.5"
|
||||
USA_CASCADIA_FALLBACK_SF="7"
|
||||
USA_CASCADIA_FALLBACK_CR="5"
|
||||
RADIO_SETTING_TITLE="$USA_CASCADIA_RADIO_TITLE"
|
||||
RADIO_FREQ_OVERRIDE="$USA_CASCADIA_FALLBACK_FREQ"
|
||||
RADIO_BW_OVERRIDE="$USA_CASCADIA_FALLBACK_BW"
|
||||
RADIO_SF_OVERRIDE="$USA_CASCADIA_FALLBACK_SF"
|
||||
RADIO_CR_OVERRIDE="$USA_CASCADIA_FALLBACK_CR"
|
||||
FIRMWARE_PROFILE_OVERRIDE="${FIRMWARE_PROFILE_OVERRIDE:-cascade}"
|
||||
BATCH_BUILD_MODE=0
|
||||
OPTION3_BUILD_WORKERS="${OPTION3_BUILD_WORKERS:-2}"
|
||||
OPTION3_PIO_JOBS="${OPTION3_PIO_JOBS:-8}"
|
||||
@@ -101,8 +106,8 @@ Commands:
|
||||
|
||||
Options:
|
||||
--firmware-version <version>: Firmware version to embed.
|
||||
--radio-preset <number>: Use the numbered radio choice from the interactive menu (1 keeps target defaults).
|
||||
--profile <default|cascade>: Select the firmware settings profile.
|
||||
--radio-preset <name|number>: Override the USA Cascadia radio default. Stable names are usa-cascadia and target; legacy menu numbers remain accepted.
|
||||
--profile <default|cascade>: Override the default Cascade firmware settings profile.
|
||||
--skip-kiss|--include-kiss: Exclude (default) or include KISS modem targets in bulk builds.
|
||||
--clean|--resume: Clean output or resume existing Option 3/FULL-only artifacts.
|
||||
|
||||
@@ -115,6 +120,11 @@ FULL-everything profile for supported ESP32 Option 1 targets, debug options,
|
||||
radio settings, firmware profile, and firmware version
|
||||
$ bash build.sh
|
||||
|
||||
Builds default to the live USA/Canada preset by name and the Cascade firmware
|
||||
profile. If the preset service is offline, the radio fallback is 910.525 MHz /
|
||||
BW62.5 / SF7 / CR5. To intentionally use a target's own defaults instead:
|
||||
$ bash build.sh build-firmware RAK_4631_repeater --radio-preset target --profile default
|
||||
|
||||
Build all firmwares for device targets containing the string "RAK_4631"
|
||||
$ bash build.sh build-matching-firmwares <build-match-spec>
|
||||
|
||||
@@ -672,11 +682,23 @@ clear_firmware_profile_overrides() {
|
||||
|
||||
apply_cli_radio_preset() {
|
||||
local selection=$1
|
||||
local preset_output row
|
||||
local preset_output row title description freq bw sf cr
|
||||
local -a preset_rows=()
|
||||
|
||||
case "${selection,,}" in
|
||||
usa|usa-canada|usa-cascadia|cascadia)
|
||||
resolve_usa_cascadia_radio_default
|
||||
return 0
|
||||
;;
|
||||
default|target|target-defaults)
|
||||
clear_radio_overrides
|
||||
echo "Using target default radio settings."
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! [[ "$selection" =~ ^[0-9]+$ ]] || [ "$selection" -lt 1 ]; then
|
||||
echo "Invalid --radio-preset value: ${selection}"
|
||||
echo "Invalid --radio-preset value: ${selection} (use usa-cascadia, target, or a legacy menu number)"
|
||||
return 1
|
||||
fi
|
||||
clear_radio_overrides
|
||||
@@ -698,7 +720,7 @@ apply_cli_radio_preset() {
|
||||
row=${preset_rows[$((selection - 2))]}
|
||||
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
|
||||
set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr"
|
||||
echo "Using radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
|
||||
echo "Using legacy numbered radio setting ${selection}: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
|
||||
}
|
||||
|
||||
parse_cli_options() {
|
||||
@@ -819,6 +841,43 @@ for entry in entries:
|
||||
PY
|
||||
}
|
||||
|
||||
is_usa_cascadia_radio_title() {
|
||||
local title=${1,,}
|
||||
|
||||
[[ "$title" == usa*canada* ]]
|
||||
}
|
||||
|
||||
set_usa_cascadia_radio_fallback() {
|
||||
set_radio_overrides \
|
||||
"$USA_CASCADIA_RADIO_TITLE" \
|
||||
"$USA_CASCADIA_FALLBACK_FREQ" \
|
||||
"$USA_CASCADIA_FALLBACK_BW" \
|
||||
"$USA_CASCADIA_FALLBACK_SF" \
|
||||
"$USA_CASCADIA_FALLBACK_CR"
|
||||
}
|
||||
|
||||
resolve_usa_cascadia_radio_default() {
|
||||
local preset_output row title description freq bw sf cr
|
||||
|
||||
set_usa_cascadia_radio_fallback
|
||||
if ! preset_output=$(fetch_suggested_radio_settings) || [ -z "$preset_output" ]; then
|
||||
echo "USA Cascadia preset lookup unavailable; using offline fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})."
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r row; do
|
||||
[ -n "$row" ] || continue
|
||||
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
|
||||
if is_usa_cascadia_radio_title "$title"; then
|
||||
set_radio_overrides "$USA_CASCADIA_RADIO_TITLE" "$freq" "$bw" "$sf" "$cr"
|
||||
echo "Resolved USA Cascadia by preset name '${title}': ${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE}."
|
||||
return 0
|
||||
fi
|
||||
done <<< "$preset_output"
|
||||
|
||||
echo "USA/Canada preset not found; using offline USA Cascadia fallback (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})."
|
||||
}
|
||||
|
||||
is_valid_custom_radio_bandwidth() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
@@ -883,7 +942,15 @@ prompt_for_custom_radio_setting() {
|
||||
prompt_for_radio_build_settings() {
|
||||
local -a preset_rows=()
|
||||
local -a fetched_preset_rows=()
|
||||
local -a options=("Keep target defaults (no radio override)")
|
||||
local default_title=$RADIO_SETTING_TITLE
|
||||
local default_freq=$RADIO_FREQ_OVERRIDE
|
||||
local default_bw=$RADIO_BW_OVERRIDE
|
||||
local default_sf=$RADIO_SF_OVERRIDE
|
||||
local default_cr=$RADIO_CR_OVERRIDE
|
||||
local -a options=(
|
||||
"USA Cascadia (default): ${default_freq} MHz / SF${default_sf} / BW${default_bw} / CR${default_cr}"
|
||||
"Keep target defaults (no radio override)"
|
||||
)
|
||||
local row
|
||||
local title
|
||||
local description
|
||||
@@ -896,8 +963,6 @@ prompt_for_radio_build_settings() {
|
||||
local custom_index
|
||||
local preset_output
|
||||
|
||||
clear_radio_overrides
|
||||
|
||||
if preset_output=$(fetch_suggested_radio_settings); then
|
||||
if [ -n "$preset_output" ]; then
|
||||
mapfile -t fetched_preset_rows <<< "$preset_output"
|
||||
@@ -906,6 +971,10 @@ prompt_for_radio_build_settings() {
|
||||
if [ -z "$row" ]; then
|
||||
continue
|
||||
fi
|
||||
IFS=$'\t' read -r title description freq bw sf cr <<< "$row"
|
||||
if is_usa_cascadia_radio_title "$title"; then
|
||||
continue
|
||||
fi
|
||||
preset_rows+=("$row")
|
||||
done
|
||||
else
|
||||
@@ -934,6 +1003,13 @@ prompt_for_radio_build_settings() {
|
||||
|
||||
choice_index=$MENU_CHOICE
|
||||
if [ "$choice_index" -eq 1 ]; then
|
||||
set_radio_overrides "$default_title" "$default_freq" "$default_bw" "$default_sf" "$default_cr"
|
||||
echo "Using radio setting: ${RADIO_SETTING_TITLE} (${RADIO_FREQ_OVERRIDE}MHz / SF${RADIO_SF_OVERRIDE} / BW${RADIO_BW_OVERRIDE} / CR${RADIO_CR_OVERRIDE})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$choice_index" -eq 2 ]; then
|
||||
clear_radio_overrides
|
||||
echo "Using target default radio settings."
|
||||
return 0
|
||||
fi
|
||||
@@ -944,7 +1020,7 @@ prompt_for_radio_build_settings() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
preset_index=$((choice_index - 2))
|
||||
preset_index=$((choice_index - 3))
|
||||
if [ "$preset_index" -ge 0 ] && [ "$preset_index" -lt "${#preset_rows[@]}" ]; then
|
||||
IFS=$'\t' read -r title description freq bw sf cr <<< "${preset_rows[$preset_index]}"
|
||||
set_radio_overrides "$title" "$freq" "$bw" "$sf" "$cr"
|
||||
@@ -956,12 +1032,10 @@ prompt_for_radio_build_settings() {
|
||||
|
||||
prompt_for_firmware_profile_settings() {
|
||||
local -a options=(
|
||||
"Cascade (default): power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0"
|
||||
"Keep target defaults"
|
||||
"Cascade: power saving + RXPS on / WiFi power save=min / path.hash.mode=2 / loop.detect=minimal / cad=on / rxdelay=2 / agc.reset.interval=8 / advert.interval=0 / flood.advert.interval=83 / multi.acks=1 / companion.manual.add=1 / companion.autoadd=0"
|
||||
)
|
||||
|
||||
clear_firmware_profile_overrides
|
||||
|
||||
echo "Set firmware profile options:"
|
||||
while true; do
|
||||
print_numbered_menu "${options[@]}"
|
||||
@@ -973,12 +1047,13 @@ prompt_for_firmware_profile_settings() {
|
||||
|
||||
case "$MENU_CHOICE" in
|
||||
1)
|
||||
echo "Using target default firmware profile settings."
|
||||
set_firmware_profile_override "cascade"
|
||||
echo "Using firmware profile: Cascade"
|
||||
return 0
|
||||
;;
|
||||
2)
|
||||
set_firmware_profile_override "cascade"
|
||||
echo "Using firmware profile: Cascade"
|
||||
clear_firmware_profile_overrides
|
||||
echo "Using target default firmware profile settings."
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
@@ -4092,6 +4167,8 @@ main() {
|
||||
if ! apply_cli_radio_preset "$RADIO_PRESET_SELECTION"; then
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
resolve_usa_cascadia_radio_default
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
|
||||
+19
-6
@@ -419,6 +419,16 @@ receives their final reply.
|
||||
into a selected target; it does not change that target into another firmware
|
||||
role.
|
||||
|
||||
This fork defaults every `build.sh` target to the USA Cascadia radio preset and
|
||||
the Cascade firmware profile. The script resolves the live `USA/Canada` entry
|
||||
by name, so its changing number in the downloaded menu does not affect builds.
|
||||
If the preset service is unavailable, it falls back to
|
||||
`910.525 MHz / BW62.5 / SF7 / CR5`. Use
|
||||
`--radio-preset target --profile default` only when a build intentionally needs
|
||||
the target's original radio and profile defaults. `--radio-preset usa-cascadia`
|
||||
is the stable explicit name; legacy numbered choices remain accepted but their
|
||||
meaning can change when the service reorders or adds presets.
|
||||
|
||||
| Build profile | WiFi/MQTT behavior |
|
||||
|---|---|
|
||||
| Standard | Uses the selected target's role. Ordinary legacy-slot ESP32 repeater/room-server artifacts omit WebConfig when needed to fit. ESP32 MQTT observer and ESP-NOW bridge targets are automatically promoted to FULL; WiFi-companion targets keep their companion partition profile. |
|
||||
@@ -461,7 +471,7 @@ Fresh Cascade-profile builds default to `min`; target-default builds use
|
||||
highest power use. `min` and `max` reduce power but may add latency or reduce
|
||||
reliability on busy nodes. A saved operator setting takes precedence on an
|
||||
upgrade. ESP32 WiFi Companions expose the same values in their WebConfig WiFi
|
||||
card. Full Companion also accepts the text commands from its USB terminal and
|
||||
card and USB text terminal. Full Companion also accepts the text commands from
|
||||
TCP port 5002. The normal binary Companion protocol can read or write the
|
||||
setting over USB, BLE, or TCP port 5000 without entering terminal mode. Full
|
||||
Companion rejects `none` because its simultaneous BLE transport requires WiFi
|
||||
@@ -475,9 +485,9 @@ WiFi only, not LoRa transmit power.
|
||||
|
||||
`get wifi.status`, `get wifi.ssid`, `get wifi.powersave`, and `get wifi.cli`
|
||||
are available on MQTT observers and on FULL non-MQTT repeater/room-server
|
||||
builds with WebConfig. ESP32 WiFi Companions expose `wifi.powersave` through
|
||||
their role-specific interfaces described above; they do not expose the full
|
||||
infrastructure WiFi CLI family.
|
||||
builds with WebConfig. ESP32 WiFi Companions with WebConfig expose those
|
||||
commands, credential setters, and `start webconfig [ap]` through their USB text
|
||||
terminal; Full Companion additionally exposes that terminal on TCP port 5002.
|
||||
MQTT commands such as `get mqtt.status` and `set mqtt1.preset ...` still require
|
||||
an MQTT observer target. Unknown settings return `Error: unknown setting:
|
||||
<name>`. Older firmware that used the discontinued compact CLI can instead
|
||||
@@ -518,5 +528,8 @@ Common causes are:
|
||||
- the wrong firmware role or an older compact-CLI build without WebConfig.
|
||||
|
||||
For a WiFi companion, find its station IP in the router, connect the client to
|
||||
TCP port 5000, and use the setup AP if it cannot join the saved network. MQTT
|
||||
diagnostics apply only to a `wifi_mqtt` companion target.
|
||||
TCP port 5000, and use the open `MeshCore-Setup-XXXX` AP at
|
||||
`http://192.168.4.1/` if it cannot join the saved network. Current firmware
|
||||
normalizes both ESP32 WiFi interfaces to standard b/g/n before advertising the
|
||||
setup AP, including after an ESP-NOW image used its long-range protocol mode.
|
||||
MQTT diagnostics apply only to a `wifi_mqtt` companion target.
|
||||
@@ -126,10 +126,11 @@ does not exist on that target:
|
||||
- WebConfig and the `wifi.ssid`, `wifi.status`, `wifi.powersave`, and `wifi.cli` command
|
||||
family require an ESP32 WebConfig build. FULL standalone repeater and
|
||||
room-server builds support the corresponding WiFi setters and status
|
||||
commands. ESP32 WiFi Companion exposes `wifi.powersave` in its WebConfig
|
||||
form and binary protocol; Full Companion exposes its complete role-specific
|
||||
text terminal over USB and TCP port 5002, including WiFi credentials,
|
||||
connection status, WebConfig, CLI-tab, and power-save controls.
|
||||
commands. ESP32 WiFi Companions with WebConfig expose WiFi credentials,
|
||||
connection status, WebConfig, CLI-tab, and power-save controls from their USB
|
||||
text terminal as well as power saving through WebConfig and the binary
|
||||
protocol. Full Companion additionally exposes its complete role-specific
|
||||
text terminal on TCP port 5002.
|
||||
- MQTT commands require an MQTT observer target.
|
||||
- `discover.scopes` requires an MQTT observer with compiled neighbor support;
|
||||
it does not independently require PSRAM or the FULL parser.
|
||||
|
||||
@@ -252,11 +252,12 @@ take effect on the next connection. `get wifi.pwd` is intentionally unavailable
|
||||
so the standalone password is never returned by the CLI.
|
||||
|
||||
ESP32 WiFi Companion WebConfig exposes the same `wifi.powersave` values in its
|
||||
WiFi card. Full Companion exposes the same role-specific text terminal over
|
||||
USB and TCP port 5002, including the standalone `wifi.ssid`, `wifi.status`,
|
||||
`wifi.powersave`, `wifi.cli`, and WebConfig command families. Credential
|
||||
writes reply before restarting the WiFi station, so a TCP client should expect
|
||||
to reconnect at the new address; USB password input is masked. Binary
|
||||
WiFi card. Every ESP32 WiFi Companion with WebConfig exposes the standalone
|
||||
`wifi.ssid`, `wifi.status`, `wifi.powersave`, `wifi.cli`, and WebConfig command
|
||||
families through its USB text terminal. Full Companion exposes the same
|
||||
role-specific terminal on TCP port 5002. Credential writes reply before
|
||||
restarting the WiFi station, so a TCP client should expect to reconnect at the
|
||||
new address; USB password input is masked. Binary
|
||||
Companion clients can use command bytes
|
||||
`0x46` and `0x47` over USB, BLE, or TCP port 5000 without the terminal-start
|
||||
token. WiFi-only Companions accept all three modes; Full Companion rejects
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
|
||||
#include "NodePrefs.h"
|
||||
|
||||
#ifndef UI_USB_AUTO_OFF_MULTIPLIER
|
||||
#define UI_USB_AUTO_OFF_MULTIPLIER 5UL
|
||||
#endif
|
||||
|
||||
enum class UIEventType {
|
||||
none,
|
||||
contactMessage,
|
||||
@@ -35,11 +39,14 @@ protected:
|
||||
bool isDisplayAutoOffDue(unsigned long configured_deadline,
|
||||
unsigned long configured_timeout_millis) const {
|
||||
unsigned long deadline = configured_deadline;
|
||||
#if UI_USB_AUTO_OFF_MULTIPLIER > 1
|
||||
if (_board->isUsbHostConnected()) {
|
||||
// The configured deadline already includes the first timeout period.
|
||||
// Add four more periods for a total of 5x while attached to a computer.
|
||||
deadline += configured_timeout_millis * 4UL;
|
||||
// Add the remaining periods while attached to a computer.
|
||||
deadline += configured_timeout_millis
|
||||
* (UI_USB_AUTO_OFF_MULTIPLIER - 1UL);
|
||||
}
|
||||
#endif
|
||||
return static_cast<int32_t>(millis() - deadline) > 0;
|
||||
}
|
||||
|
||||
@@ -59,7 +66,10 @@ public:
|
||||
void enableBluetooth() { _interfaceManager->enableBluetooth(); }
|
||||
void disableBluetooth() { _interfaceManager->disableBluetooth(); }
|
||||
virtual void msgRead(int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) = 0;
|
||||
virtual void newMsg(uint8_t path_len, const char* from_name,
|
||||
const char* text, int msgcount,
|
||||
int channel_idx = -1,
|
||||
const char* channel_name = nullptr) = 0;
|
||||
virtual void notify(UIEventType t = UIEventType::none) = 0;
|
||||
virtual void loop() = 0;
|
||||
};
|
||||
@@ -847,9 +847,26 @@ bool MyMesh::filterRecvFloodPacket(mesh::Packet* packet) {
|
||||
}
|
||||
|
||||
bool MyMesh::allowPacketForward(const mesh::Packet* packet) {
|
||||
return _prefs.isRepeatEn();
|
||||
if (!_prefs.isRepeatEn()) return false;
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
_clock_sync.observeAcceptedFlood(packet);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
void MyMesh::onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
|
||||
uint32_t timestamp, const uint8_t* app_data,
|
||||
size_t app_data_len) {
|
||||
BaseChatMesh::onAdvertRecv(packet, id, timestamp, app_data, app_data_len);
|
||||
_clock_sync.observeVerifiedAdvert(packet, id, timestamp);
|
||||
}
|
||||
|
||||
void MyMesh::onGroupPacketRecv(mesh::Packet* packet) {
|
||||
_clock_sync.observeGroupPacket(packet);
|
||||
}
|
||||
#endif
|
||||
|
||||
bool MyMesh::allowFloodRetry(const mesh::Packet* packet) const {
|
||||
if (packet == NULL) return false;
|
||||
// A companion may retry its own advert once, using the core's deliberately
|
||||
@@ -982,7 +999,13 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe
|
||||
if (getChannel(channel_idx, channel_details)) {
|
||||
channel_name = channel_details.name;
|
||||
}
|
||||
if (_ui) _ui->newMsg(path_len, channel_name, text, offline_queue_len);
|
||||
char channel_label[64];
|
||||
snprintf(channel_label, sizeof(channel_label), "Ch %u %s",
|
||||
(unsigned int)channel_idx, channel_name);
|
||||
if (_ui) {
|
||||
_ui->newMsg(path_len, channel_label, text, offline_queue_len,
|
||||
channel_idx, channel_name);
|
||||
}
|
||||
#endif
|
||||
|
||||
if (pkt->isRouteFlood() && is_emergency_channel) {
|
||||
@@ -1357,6 +1380,10 @@ void MyMesh::onSendTimeout() {
|
||||
|
||||
MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMeshTables &tables, DataStore& store, AbstractUITask* ui)
|
||||
: BaseChatMesh(radio, *new ArduinoMillis(), rng, rtc, *new StaticPoolPacketManager(16), tables),
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
_clock_sync(radio, _clock_sync_millis, rtc, _clock_sync_acl, sensors,
|
||||
_prefs.airtime_factor),
|
||||
#endif
|
||||
_serial(NULL), telemetry(MAX_PACKET_PAYLOAD - 4), _store(&store), _ui(ui), _iter(0) {
|
||||
_iter_started = false;
|
||||
_cli_rescue = false;
|
||||
@@ -1606,6 +1633,12 @@ void MyMesh::begin(bool has_display, bool radio_available) {
|
||||
_store->saveChannels(this);
|
||||
}
|
||||
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
// Fixed fallback policy for this Companion build. A successful host time
|
||||
// update below suppresses mesh correction for the remainder of the boot.
|
||||
_clock_sync.begin(nullptr);
|
||||
#endif
|
||||
|
||||
configureRadioFromPrefs();
|
||||
|
||||
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
||||
@@ -1808,9 +1841,10 @@ void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) {
|
||||
_temp_radio_failures = 0;
|
||||
snprintf(reply, reply_size, "OK - normal radio restore scheduled");
|
||||
}
|
||||
#endif
|
||||
|
||||
bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
|
||||
size_t reply_size) {
|
||||
bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
|
||||
size_t reply_size) {
|
||||
if (!command || !reply || reply_size == 0) return false;
|
||||
while (*command == ' ') command++;
|
||||
|
||||
@@ -1971,6 +2005,7 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(COMPANION_RADIO_FULL)
|
||||
if (strcmp(command, "tempradio") == 0) {
|
||||
if (_temp_radio_set_at) {
|
||||
snprintf(reply, reply_size, "TempRadio pending: %.3f,%.2f,%u,%u",
|
||||
@@ -2021,10 +2056,12 @@ bool MyMesh::handleFullOtaCommand(const char* command, char* reply,
|
||||
snprintf(reply, reply_size, "%s", ota_reply);
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(COMPANION_RADIO_FULL)
|
||||
void MyMesh::serviceTempRadio() {
|
||||
const unsigned long now = _ms->getMillis();
|
||||
const bool retry_ready = !_temp_radio_retry_at
|
||||
@@ -2964,6 +3001,9 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
uint32_t curr = getRTCClock()->getCurrentTime();
|
||||
if (secs >= curr) {
|
||||
getRTCClock()->setCurrentTime(secs);
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
_clock_sync.onManualClockSet();
|
||||
#endif
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_ILLEGAL_ARG);
|
||||
@@ -4753,13 +4793,11 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
if (*command == 0) return;
|
||||
mesh::cli::normalizeCommandVerb(command);
|
||||
|
||||
#if defined(COMPANION_RADIO_FULL)
|
||||
char full_reply[160];
|
||||
if (handleFullOtaCommand(command, full_reply, sizeof(full_reply))) {
|
||||
terminalOutput().printf(" %s\r\n", full_reply);
|
||||
char local_reply[160];
|
||||
if (handleLocalControlCommand(command, local_reply, sizeof(local_reply))) {
|
||||
terminalOutput().printf(" %s\r\n", local_reply);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
mesh::cli::TerminalChannelMessage channel_message;
|
||||
const mesh::cli::TerminalChannelCommandMatch channel_match =
|
||||
@@ -4869,6 +4907,9 @@ void MyMesh::handleTerminalCommand(char* command) {
|
||||
uint32_t current = getRTCClock()->getCurrentTime();
|
||||
if (timestamp >= current) {
|
||||
getRTCClock()->setCurrentTime(timestamp);
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
_clock_sync.onManualClockSet();
|
||||
#endif
|
||||
terminalOutput().print(" OK - clock set\r\n");
|
||||
} else {
|
||||
terminalOutput().print(" ERROR: clock cannot go backwards\r\n");
|
||||
@@ -5518,6 +5559,9 @@ void MyMesh::loop() {
|
||||
serviceTempRadio();
|
||||
#endif
|
||||
BaseChatMesh::loop();
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
_clock_sync.loop();
|
||||
#endif
|
||||
#ifdef ENABLE_USB_INTERFACE
|
||||
serviceTerminalLogin();
|
||||
serviceTerminalCommand();
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
#include <helpers/TerminalDisplayFilter.h>
|
||||
#include <target.h>
|
||||
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
#include <helpers/ClientACL.h>
|
||||
#include <helpers/MeshClockSync.h>
|
||||
#endif
|
||||
|
||||
/* ---------------------------------- CONFIGURATION ------------------------------------- */
|
||||
|
||||
#ifndef LORA_FREQ
|
||||
@@ -171,11 +176,10 @@ public:
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if defined(COMPANION_RADIO_FULL)
|
||||
// Local-only control surface shared by the USB terminal and WiFi port 5002.
|
||||
// Supports bounded TempRadio windows plus the serve-only `ota ...` CLI.
|
||||
bool handleFullOtaCommand(const char* command, char* reply, size_t reply_size);
|
||||
#endif
|
||||
// Local control shared by every USB terminal. ESP32 WiFi companions expose
|
||||
// setup/status commands here; Full builds add TempRadio and OTA commands.
|
||||
bool handleLocalControlCommand(const char* command, char* reply,
|
||||
size_t reply_size);
|
||||
|
||||
int getRecentlyHeard(AdvertPath dest[], int max_num);
|
||||
|
||||
@@ -200,6 +204,12 @@ protected:
|
||||
bool filterRecvFloodPacket(mesh::Packet* packet) override;
|
||||
bool allowPacketForward(const mesh::Packet* packet) override;
|
||||
bool allowFloodRetry(const mesh::Packet* packet) const override;
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id,
|
||||
uint32_t timestamp, const uint8_t* app_data,
|
||||
size_t app_data_len) override;
|
||||
void onGroupPacketRecv(mesh::Packet* packet) override;
|
||||
#endif
|
||||
|
||||
bool sendFloodScoped(const TransportKey& scope, mesh::Packet* pkt, uint32_t delay_millis);
|
||||
bool sendFloodScoped(const ContactInfo& recipient, mesh::Packet* pkt, uint32_t delay_millis=0) override;
|
||||
@@ -362,6 +372,11 @@ private:
|
||||
|
||||
DataStore* _store;
|
||||
CompanionNodePrefs _prefs;
|
||||
#ifdef COMPANION_MESH_CLOCK_SYNC
|
||||
ArduinoMillis _clock_sync_millis;
|
||||
ClientACL _clock_sync_acl;
|
||||
mesh::MeshClockSync _clock_sync;
|
||||
#endif
|
||||
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
|
||||
MQTTPrefs _mqtt_prefs;
|
||||
MQTTBridge* _mqtt_bridge;
|
||||
|
||||
@@ -370,7 +370,7 @@ static void resetUsbMotaMode() {
|
||||
|
||||
static void leaveUsbMotaMode(bool acknowledge) {
|
||||
char reply[160] = {0};
|
||||
the_mesh.handleFullOtaCommand("ota folder off", reply, sizeof(reply));
|
||||
the_mesh.handleLocalControlCommand("ota folder off", reply, sizeof(reply));
|
||||
if (acknowledge) {
|
||||
Serial.print("\r\n");
|
||||
Serial.print(reply);
|
||||
@@ -388,7 +388,7 @@ static void enterUsbMotaMode() {
|
||||
usb_mota_disconnect_armed = isUsbTerminalDataConnected();
|
||||
|
||||
char reply[160] = {0};
|
||||
if (!the_mesh.handleFullOtaCommand("ota folder on", reply, sizeof(reply))
|
||||
if (!the_mesh.handleLocalControlCommand("ota folder on", reply, sizeof(reply))
|
||||
|| strncmp(reply, "ERR", 3) == 0) {
|
||||
Serial.print("\r\n");
|
||||
Serial.print(reply[0] ? reply : "ERR could not enter mOTA seeder mode");
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
#ifndef USB_MESSAGE_PREVIEW_MILLIS
|
||||
#define USB_MESSAGE_PREVIEW_MILLIS 15000UL
|
||||
#endif
|
||||
#ifndef UI_RADIO_REFRESH_MILLIS
|
||||
#define UI_RADIO_REFRESH_MILLIS 2000UL
|
||||
#endif
|
||||
#ifndef BLE_PAIRING_DISPLAY_MILLIS
|
||||
#define BLE_PAIRING_DISPLAY_MILLIS 120000UL
|
||||
#endif
|
||||
@@ -126,7 +129,7 @@ class HomeScreen : public UIScreen {
|
||||
AdvertPath recent[UI_RECENT_LIST_SIZE];
|
||||
|
||||
|
||||
void renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
int renderBatteryIndicator(DisplayDriver& display, uint16_t batteryMilliVolts) {
|
||||
// Convert millivolts to percentage
|
||||
#ifndef BATT_MIN_MILLIVOLTS
|
||||
#define BATT_MIN_MILLIVOLTS 3000
|
||||
@@ -136,14 +139,19 @@ class HomeScreen : public UIScreen {
|
||||
#endif
|
||||
const int minMilliVolts = BATT_MIN_MILLIVOLTS;
|
||||
const int maxMilliVolts = BATT_MAX_MILLIVOLTS;
|
||||
int batteryPercentage = ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts);
|
||||
const bool showBattery = batteryMilliVolts != 0;
|
||||
int batteryPercentage = showBattery
|
||||
? ((batteryMilliVolts - minMilliVolts) * 100) / (maxMilliVolts - minMilliVolts)
|
||||
: 0;
|
||||
if (batteryPercentage < 0) batteryPercentage = 0; // Clamp to 0%
|
||||
if (batteryPercentage > 100) batteryPercentage = 100; // Clamp to 100%
|
||||
|
||||
// battery icon
|
||||
int iconWidth = 24;
|
||||
int iconHeight = 10;
|
||||
int iconX = display.width() - iconWidth - 5; // Position the icon near the top-right corner
|
||||
int iconX = showBattery
|
||||
? display.width() - iconWidth - 5
|
||||
: display.width() - 5;
|
||||
int iconY = 0;
|
||||
display.setColor(UIColor::title_txt);
|
||||
|
||||
@@ -178,25 +186,28 @@ class HomeScreen : public UIScreen {
|
||||
#ifdef PIN_BUZZER
|
||||
if (muted) statusWidth += 9;
|
||||
#endif
|
||||
display.setCursor(iconX - statusWidth - uptimeWidth - spaceWidth, iconY);
|
||||
int statusStartX = iconX - statusWidth - uptimeWidth - spaceWidth;
|
||||
display.setCursor(statusStartX, iconY);
|
||||
display.print(uptime);
|
||||
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
if (showBattery) {
|
||||
// battery outline
|
||||
display.drawRect(iconX, iconY, iconWidth, iconHeight);
|
||||
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
// battery "cap"
|
||||
display.fillRect(iconX + iconWidth, iconY + (iconHeight / 4), 3, iconHeight / 2);
|
||||
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
// fill the battery based on the percentage
|
||||
int fillWidth = (batteryPercentage * (iconWidth - 4)) / 100;
|
||||
display.fillRect(iconX + 2, iconY + 2, fillWidth, iconHeight - 4);
|
||||
}
|
||||
|
||||
// Show external power beside the battery. Most boards have no
|
||||
// charge-complete signal, so use a high percentage band for the plug.
|
||||
if (charging) {
|
||||
static constexpr int BATT_FULL_PCT = 95;
|
||||
const uint8_t* symbol =
|
||||
batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon;
|
||||
!showBattery || batteryPercentage >= BATT_FULL_PCT ? plug_icon : charging_icon;
|
||||
display.setColor(UIColor::title_txt);
|
||||
display.drawXbm(iconX - 9, iconY + 1, symbol, 8, 8);
|
||||
}
|
||||
@@ -208,6 +219,8 @@ class HomeScreen : public UIScreen {
|
||||
display.drawXbm(iconX - (charging ? 18 : 9), iconY + 1, muted_icon, 8, 8);
|
||||
}
|
||||
#endif
|
||||
|
||||
return statusStartX;
|
||||
}
|
||||
|
||||
CayenneLPP sensors_lpp;
|
||||
@@ -255,16 +268,18 @@ public:
|
||||
display.setColor(UIColor::title_bkg);
|
||||
display.fillRect(0, 0, display.width(), 12);
|
||||
char tmp[80];
|
||||
// node name
|
||||
// status indicators
|
||||
display.setTextSize(1);
|
||||
display.setColor(UIColor::title_txt);
|
||||
int statusStartX = renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
|
||||
// node name
|
||||
char filtered_name[sizeof(_node_prefs->node_name)];
|
||||
display.translateUTF8ToBlocks(filtered_name, _node_prefs->node_name, sizeof(filtered_name));
|
||||
display.setCursor(0, 2);
|
||||
display.print(filtered_name);
|
||||
|
||||
// battery voltage
|
||||
renderBatteryIndicator(display, _task->getBattMilliVolts());
|
||||
int availableNameWidth = statusStartX - 2;
|
||||
if (availableNameWidth > 0) {
|
||||
display.drawTextEllipsized(0, 2, availableNameWidth, filtered_name);
|
||||
}
|
||||
|
||||
// curr page indicator
|
||||
if (UIColor::title_bkg == UIColor::window_bkg) {
|
||||
@@ -285,8 +300,12 @@ public:
|
||||
if (_page == HomePage::FIRST) {
|
||||
display.setColor(UIColor::primary_txt);
|
||||
display.setTextSize(2);
|
||||
sprintf(tmp, "MSG: %d", _task->getMsgCount());
|
||||
sprintf(tmp, "INBOX: %d", _task->getPreviewCount());
|
||||
display.drawTextCentered(display.width() / 2, 22, tmp);
|
||||
display.setTextSize(1);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextCentered(display.width() / 2, 43,
|
||||
"tap center: inbox");
|
||||
|
||||
#ifdef UI_SHOW_CLOCK
|
||||
display.setTextSize(3);
|
||||
@@ -371,7 +390,12 @@ public:
|
||||
sprintf(tmp, "TX: %ddBm", _node_prefs->tx_power_dbm);
|
||||
display.print(tmp);
|
||||
display.setCursor(0, 53);
|
||||
sprintf(tmp, "Noise floor: %d", radio_driver.getNoiseFloor());
|
||||
float noise_floor = radio_driver.getNoiseFloorDbm();
|
||||
if (noise_floor == 0.0f) {
|
||||
strcpy(tmp, "Noise floor: measuring");
|
||||
} else {
|
||||
snprintf(tmp, sizeof(tmp), "Noise floor: %.1f", noise_floor);
|
||||
}
|
||||
display.print(tmp);
|
||||
} else if (_page == HomePage::BLUETOOTH) {
|
||||
display.setColor(UIColor::corp_blue);
|
||||
@@ -522,7 +546,7 @@ public:
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return 5000; // next render after 5000 ms
|
||||
return _page == HomePage::RADIO ? UI_RADIO_REFRESH_MILLIS : 5000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
@@ -545,6 +569,10 @@ public:
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::FIRST) {
|
||||
_task->showMessages();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::ADVERT) {
|
||||
_task->notify(UIEventType::ack);
|
||||
if (the_mesh.advert()) {
|
||||
@@ -585,42 +613,184 @@ class MsgPreviewScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
mesh::RTCClock* _rtc;
|
||||
|
||||
static constexpr int CHANNEL_FILTER_ALL = -2;
|
||||
static constexpr int CHANNEL_FILTER_DIRECT = -1;
|
||||
|
||||
struct MsgEntry {
|
||||
uint32_t timestamp;
|
||||
int channel_idx;
|
||||
char channel_name[32];
|
||||
char origin[62];
|
||||
char msg[UI_MSG_PREVIEW_SIZE];
|
||||
};
|
||||
#define MAX_UNREAD_MSGS 32
|
||||
int num_unread;
|
||||
int view_offset;
|
||||
int channel_filter;
|
||||
int head = MAX_UNREAD_MSGS - 1; // index of latest unread message
|
||||
MsgEntry unread[MAX_UNREAD_MSGS];
|
||||
|
||||
public:
|
||||
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc) : _task(task), _rtc(rtc) { num_unread = 0; }
|
||||
bool matchesFilter(const MsgEntry& entry) const {
|
||||
return channel_filter == CHANNEL_FILTER_ALL
|
||||
|| entry.channel_idx == channel_filter;
|
||||
}
|
||||
|
||||
void addPreview(uint8_t path_len, const char* from_name, const char* msg) {
|
||||
int filteredCount() const {
|
||||
int count = 0;
|
||||
for (int age = 0; age < num_unread; ++age) {
|
||||
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
|
||||
if (matchesFilter(unread[index])) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
const MsgEntry* filteredEntry(int offset) const {
|
||||
int match = 0;
|
||||
for (int age = 0; age < num_unread; ++age) {
|
||||
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
|
||||
if (!matchesFilter(unread[index])) continue;
|
||||
if (match++ == offset) return &unread[index];
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int buildChannelFilters(int* filters, int capacity) const {
|
||||
int count = 0;
|
||||
if (count < capacity) filters[count++] = CHANNEL_FILTER_ALL;
|
||||
for (int channel_idx = 0;
|
||||
channel_idx < MAX_GROUP_CHANNELS && count < capacity;
|
||||
++channel_idx) {
|
||||
ChannelDetails details;
|
||||
if (the_mesh.getChannel(channel_idx, details)
|
||||
&& details.name[0] != 0) {
|
||||
filters[count++] = channel_idx;
|
||||
}
|
||||
}
|
||||
if (count < capacity) filters[count++] = CHANNEL_FILTER_DIRECT;
|
||||
return count;
|
||||
}
|
||||
|
||||
void cycleChannelFilter(int direction) {
|
||||
int filters[MAX_GROUP_CHANNELS + 2];
|
||||
int count = buildChannelFilters(
|
||||
filters, sizeof(filters) / sizeof(filters[0]));
|
||||
if (count == 0) return;
|
||||
|
||||
int selected = 0;
|
||||
while (selected < count && filters[selected] != channel_filter) {
|
||||
++selected;
|
||||
}
|
||||
if (selected == count) selected = 0;
|
||||
selected = (selected + direction + count) % count;
|
||||
channel_filter = filters[selected];
|
||||
view_offset = 0;
|
||||
}
|
||||
|
||||
void channelFilterLabel(char* label, size_t size) const {
|
||||
if (channel_filter == CHANNEL_FILTER_ALL) {
|
||||
StrHelper::strncpy(label, "All channels", size);
|
||||
return;
|
||||
}
|
||||
if (channel_filter == CHANNEL_FILTER_DIRECT) {
|
||||
StrHelper::strncpy(label, "Direct", size);
|
||||
return;
|
||||
}
|
||||
|
||||
ChannelDetails details;
|
||||
if (the_mesh.getChannel(channel_filter, details)
|
||||
&& details.name[0] != 0) {
|
||||
snprintf(label, size, "Ch %d %s", channel_filter, details.name);
|
||||
return;
|
||||
}
|
||||
for (int age = 0; age < num_unread; ++age) {
|
||||
int index = (head + MAX_UNREAD_MSGS - age) % MAX_UNREAD_MSGS;
|
||||
if (unread[index].channel_idx == channel_filter
|
||||
&& unread[index].channel_name[0] != 0) {
|
||||
snprintf(label, size, "Ch %d %s", channel_filter,
|
||||
unread[index].channel_name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
snprintf(label, size, "Ch %d", channel_filter);
|
||||
}
|
||||
|
||||
void renderChannelFilter(DisplayDriver& display) const {
|
||||
const int bar_height = 24;
|
||||
const int bar_y = display.height() - bar_height;
|
||||
display.setColor(UIColor::window_bkg);
|
||||
display.fillRect(0, bar_y, display.width(), bar_height);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
display.drawRect(0, bar_y, display.width(), 1);
|
||||
|
||||
char channel[48];
|
||||
channelFilterLabel(channel, sizeof(channel));
|
||||
display.setTextSize(1);
|
||||
const int text_y = bar_y + 8;
|
||||
display.setCursor(8, text_y);
|
||||
display.print("<");
|
||||
display.drawTextCentered(display.width() / 2, text_y, channel);
|
||||
display.setCursor(display.width() - display.getTextWidth(">") - 8,
|
||||
text_y);
|
||||
display.print(">");
|
||||
}
|
||||
|
||||
public:
|
||||
MsgPreviewScreen(UITask* task, mesh::RTCClock* rtc)
|
||||
: _task(task), _rtc(rtc), num_unread(0), view_offset(0),
|
||||
channel_filter(CHANNEL_FILTER_ALL) {}
|
||||
|
||||
bool hasMessages() const { return num_unread > 0; }
|
||||
int messageCount() const { return num_unread; }
|
||||
|
||||
void clear() {
|
||||
num_unread = 0;
|
||||
view_offset = 0;
|
||||
channel_filter = CHANNEL_FILTER_ALL;
|
||||
}
|
||||
|
||||
void addPreview(uint8_t path_len, const char* from_name, const char* msg,
|
||||
int channel_idx, const char* channel_name) {
|
||||
head = (head + 1) % MAX_UNREAD_MSGS;
|
||||
if (num_unread < MAX_UNREAD_MSGS) num_unread++;
|
||||
view_offset = 0;
|
||||
channel_filter = channel_idx;
|
||||
|
||||
auto p = &unread[head];
|
||||
p->timestamp = _rtc->getCurrentTime();
|
||||
p->channel_idx = channel_idx;
|
||||
StrHelper::strncpy(p->channel_name,
|
||||
channel_name == nullptr ? "" : channel_name,
|
||||
sizeof(p->channel_name));
|
||||
if (path_len == 0xFF) {
|
||||
sprintf(p->origin, "(D) %s:", from_name);
|
||||
snprintf(p->origin, sizeof(p->origin), "%s [direct]:", from_name);
|
||||
} else {
|
||||
sprintf(p->origin, "(%d) %s:", (uint32_t) path_len, from_name);
|
||||
snprintf(p->origin, sizeof(p->origin), "%s [%uh]:", from_name,
|
||||
(unsigned int)path_len);
|
||||
}
|
||||
StrHelper::strncpy(p->msg, msg, sizeof(p->msg));
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
char tmp[16];
|
||||
char tmp[24];
|
||||
int filtered_count = filteredCount();
|
||||
if (view_offset >= filtered_count) view_offset = 0;
|
||||
display.setCursor(0, 0);
|
||||
display.setTextSize(1);
|
||||
display.setColor(UIColor::corp_blue);
|
||||
sprintf(tmp, "Unread: %d", num_unread);
|
||||
snprintf(tmp, sizeof(tmp), "Message %d/%d",
|
||||
filtered_count == 0 ? 0 : view_offset + 1, filtered_count);
|
||||
display.print(tmp);
|
||||
|
||||
auto p = &unread[head];
|
||||
const MsgEntry* p = filteredEntry(view_offset);
|
||||
|
||||
if (p == nullptr) {
|
||||
display.drawRect(0, 11, display.width(), 1);
|
||||
display.setColor(UIColor::secondary_txt);
|
||||
display.drawTextCentered(display.width() / 2, 40,
|
||||
"No buffered messages");
|
||||
renderChannelFilter(display);
|
||||
return 5000;
|
||||
}
|
||||
|
||||
int secs = _rtc->getCurrentTime() - p->timestamp;
|
||||
if (secs < 60) {
|
||||
@@ -647,6 +817,8 @@ public:
|
||||
display.translateUTF8ToBlocks(filtered_msg, p->msg, sizeof(filtered_msg));
|
||||
display.printWordWrap(filtered_msg, display.width());
|
||||
|
||||
renderChannelFilter(display);
|
||||
|
||||
#if AUTO_OFF_MILLIS==0 // probably e-ink
|
||||
return 10000; // 10 s
|
||||
#else
|
||||
@@ -656,15 +828,22 @@ public:
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (c == KEY_NEXT || c == KEY_RIGHT) {
|
||||
head = (head + MAX_UNREAD_MSGS - 1) % MAX_UNREAD_MSGS;
|
||||
num_unread--;
|
||||
if (num_unread == 0) {
|
||||
_task->gotoHomeScreen();
|
||||
}
|
||||
if (view_offset + 1 < filteredCount()) ++view_offset;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_PREV || c == KEY_LEFT) {
|
||||
if (view_offset > 0) --view_offset;
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_DOWN) {
|
||||
cycleChannelFilter(1);
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_UP) {
|
||||
cycleChannelFilter(-1);
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
num_unread = 0; // clear unread queue
|
||||
_task->gotoHomeScreen();
|
||||
return true;
|
||||
}
|
||||
@@ -727,6 +906,14 @@ void UITask::showAlert(const char* text, int duration_millis) {
|
||||
_alert_expiry = millis() + duration_millis;
|
||||
}
|
||||
|
||||
void UITask::showMessages() {
|
||||
setCurrScreen(msg_preview);
|
||||
}
|
||||
|
||||
int UITask::getPreviewCount() const {
|
||||
return static_cast<const MsgPreviewScreen*>(msg_preview)->messageCount();
|
||||
}
|
||||
|
||||
void UITask::notify(UIEventType t) {
|
||||
#if defined(PIN_BUZZER)
|
||||
switch(t){
|
||||
@@ -763,14 +950,20 @@ void UITask::msgRead(int msgcount) {
|
||||
_deferred_msg_preview = false;
|
||||
const bool holding_usb_preview = curr == msg_preview && _msg_preview_until != 0
|
||||
&& static_cast<int32_t>(millis() - _msg_preview_until) < 0;
|
||||
if (!holding_usb_preview) gotoHomeScreen();
|
||||
if (!holding_usb_preview) {
|
||||
static_cast<MsgPreviewScreen*>(msg_preview)->clear();
|
||||
gotoHomeScreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx,
|
||||
const char* channel_name) {
|
||||
_msgcount = msgcount;
|
||||
|
||||
((MsgPreviewScreen *) msg_preview)->addPreview(path_len, from_name, text);
|
||||
((MsgPreviewScreen *)msg_preview)
|
||||
->addPreview(path_len, from_name, text, channel_idx, channel_name);
|
||||
if (isPairingScreenActive()) {
|
||||
// Keep the PIN visible, but retain the preview so it can be shown after
|
||||
// pairing completes or the pairing display window expires.
|
||||
@@ -781,7 +974,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i
|
||||
|
||||
// A connected app drains the offline queue almost immediately, which calls
|
||||
// msgRead(0). While attached to a computer, retain the actual message screen
|
||||
// for 15 seconds even though the app has already consumed the message.
|
||||
// for the configured preview interval even though the app has already
|
||||
// consumed the message.
|
||||
_msg_preview_until = _board->isUsbHostConnected()
|
||||
? millis() + USB_MESSAGE_PREVIEW_MILLIS
|
||||
: 0;
|
||||
@@ -955,11 +1149,17 @@ void UITask::loop() {
|
||||
if (ev == BUTTON_EVENT_CLICK) {
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
} else if (ev == BUTTON_EVENT_LONG_PRESS) {
|
||||
c = handleLongPress(KEY_ENTER);
|
||||
c = (_display != NULL && !_display->isOn())
|
||||
? checkDisplayOn(KEY_ENTER)
|
||||
: handleLongPress(KEY_ENTER);
|
||||
} else if (ev == BUTTON_EVENT_DOUBLE_CLICK) {
|
||||
c = handleDoubleClick(KEY_PREV);
|
||||
c = (_display != NULL && !_display->isOn())
|
||||
? checkDisplayOn(KEY_ENTER)
|
||||
: handleDoubleClick(KEY_PREV);
|
||||
} else if (ev == BUTTON_EVENT_TRIPLE_CLICK) {
|
||||
c = handleTripleClick(KEY_SELECT);
|
||||
c = (_display != NULL && !_display->isOn())
|
||||
? checkDisplayOn(KEY_ENTER)
|
||||
: handleTripleClick(KEY_SELECT);
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -988,6 +1188,39 @@ void UITask::loop() {
|
||||
_analogue_pin_read_millis = millis();
|
||||
}
|
||||
#endif
|
||||
#ifdef HAS_TOUCH
|
||||
if (_display != NULL
|
||||
&& (int32_t)(millis() - next_touch_check) >= 0) {
|
||||
next_touch_check = millis() + 25;
|
||||
int touch_x = -1;
|
||||
int touch_y = -1;
|
||||
const bool touched = _display->getTouch(&touch_x, &touch_y);
|
||||
const mesh::ui::TouchAction action = touch_input.update(
|
||||
touched, touch_x, touch_y, _display->width(), _display->height(),
|
||||
curr == msg_preview);
|
||||
if (c == 0) {
|
||||
switch (action) {
|
||||
case mesh::ui::TouchAction::Previous:
|
||||
c = checkDisplayOn(KEY_PREV);
|
||||
break;
|
||||
case mesh::ui::TouchAction::Next:
|
||||
c = checkDisplayOn(KEY_NEXT);
|
||||
break;
|
||||
case mesh::ui::TouchAction::Select:
|
||||
c = checkDisplayOn(KEY_ENTER);
|
||||
break;
|
||||
case mesh::ui::TouchAction::VerticalPrevious:
|
||||
c = checkDisplayOn(KEY_UP);
|
||||
break;
|
||||
case mesh::ui::TouchAction::VerticalNext:
|
||||
c = checkDisplayOn(KEY_DOWN);
|
||||
break;
|
||||
case mesh::ui::TouchAction::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if defined(BACKLIGHT_BTN)
|
||||
if (millis() > next_backlight_btn_check) {
|
||||
bool touch_state = digitalRead(PIN_BUTTON2);
|
||||
@@ -1021,10 +1254,11 @@ void UITask::loop() {
|
||||
|
||||
if (curr) curr->poll();
|
||||
|
||||
if (curr == msg_preview && _msgcount == 0 && _msg_preview_until != 0
|
||||
if (_msgcount == 0 && _msg_preview_until != 0
|
||||
&& static_cast<int32_t>(millis() - _msg_preview_until) >= 0) {
|
||||
_msg_preview_until = 0;
|
||||
gotoHomeScreen();
|
||||
static_cast<MsgPreviewScreen*>(msg_preview)->clear();
|
||||
if (curr == msg_preview) gotoHomeScreen();
|
||||
}
|
||||
|
||||
if (_display != NULL && _display->isOn()) {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include <helpers/MultiSerialInterface.h>
|
||||
#include <Arduino.h>
|
||||
#include <helpers/sensors/LPPDataHelpers.h>
|
||||
#ifdef HAS_TOUCH
|
||||
#include <helpers/ui/TouchInput.h>
|
||||
#endif
|
||||
|
||||
#ifndef LED_STATE_ON
|
||||
#define LED_STATE_ON 1
|
||||
@@ -41,6 +44,22 @@ class UITask : public AbstractUITask {
|
||||
int _msgcount;
|
||||
unsigned long ui_started_at, next_batt_chck;
|
||||
int next_backlight_btn_check = 0;
|
||||
#ifdef HAS_TOUCH
|
||||
#ifndef TOUCH_CENTER_ZONE_PERCENT
|
||||
#define TOUCH_CENTER_ZONE_PERCENT 34
|
||||
#endif
|
||||
#if defined(TOUCH_REVERSE_SWIPE) \
|
||||
&& defined(TOUCH_SEPARATE_VERTICAL_SWIPES)
|
||||
mesh::ui::TouchInput touch_input{true, true, TOUCH_CENTER_ZONE_PERCENT};
|
||||
#elif defined(TOUCH_REVERSE_SWIPE)
|
||||
mesh::ui::TouchInput touch_input{true, false, TOUCH_CENTER_ZONE_PERCENT};
|
||||
#elif defined(TOUCH_SEPARATE_VERTICAL_SWIPES)
|
||||
mesh::ui::TouchInput touch_input{false, true, TOUCH_CENTER_ZONE_PERCENT};
|
||||
#else
|
||||
mesh::ui::TouchInput touch_input{false, false, TOUCH_CENTER_ZONE_PERCENT};
|
||||
#endif
|
||||
unsigned long next_touch_check = 0;
|
||||
#endif
|
||||
#ifdef PIN_STATUS_LED
|
||||
int led_state = 0;
|
||||
int next_led_change = 0;
|
||||
@@ -83,8 +102,10 @@ public:
|
||||
void serviceWiFiToggleButton();
|
||||
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void showMessages();
|
||||
void showAlert(const char* text, int duration_millis);
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getPreviewCount() const;
|
||||
bool hasDisplay() const { return _display != NULL; }
|
||||
bool isButtonPressed() const;
|
||||
|
||||
@@ -103,7 +124,9 @@ public:
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx = -1,
|
||||
const char* channel_name = nullptr) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
@@ -156,7 +156,11 @@ void UITask::clearMsgPreview() {
|
||||
_need_refresh = true;
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx,
|
||||
const char* channel_name) {
|
||||
(void)channel_idx;
|
||||
(void)channel_name;
|
||||
_msgcount = msgcount;
|
||||
|
||||
#ifdef HAS_DRV2605
|
||||
|
||||
@@ -78,7 +78,9 @@ public:
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx = -1,
|
||||
const char* channel_name = nullptr) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
@@ -515,7 +515,11 @@ void UITask::msgRead(int msgcount) {
|
||||
}
|
||||
}
|
||||
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) {
|
||||
void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx,
|
||||
const char* channel_name) {
|
||||
(void)channel_idx;
|
||||
(void)channel_name;
|
||||
_msgcount = msgcount;
|
||||
|
||||
if (_display != NULL) {
|
||||
|
||||
@@ -106,7 +106,9 @@ public:
|
||||
|
||||
// from AbstractUITask
|
||||
void msgRead(int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text, int msgcount) override;
|
||||
void newMsg(uint8_t path_len, const char* from_name, const char* text,
|
||||
int msgcount, int channel_idx = -1,
|
||||
const char* channel_name = nullptr) override;
|
||||
void notify(UIEventType t = UIEventType::none) override;
|
||||
void loop() override;
|
||||
|
||||
|
||||
@@ -94,6 +94,9 @@ public:
|
||||
}
|
||||
|
||||
virtual int getNoiseFloor() const { return 0; }
|
||||
virtual float getNoiseFloorDbm() const {
|
||||
return static_cast<float>(getNoiseFloor());
|
||||
}
|
||||
|
||||
virtual void triggerNoiseFloorCalibrate(int threshold) { }
|
||||
|
||||
|
||||
@@ -19,14 +19,22 @@
|
||||
#ifndef FIRMWARE_BUILD_EPOCH
|
||||
#define FIRMWARE_BUILD_EPOCH 0UL
|
||||
#endif
|
||||
#ifndef MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT
|
||||
#define MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT 9
|
||||
#endif
|
||||
#ifndef MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS
|
||||
#define MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS (30ULL * 60ULL * 1000ULL)
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char PREFS_FILE[] = "/clock_sync";
|
||||
constexpr uint8_t REQUIRED_SAMPLES_MIN = 3;
|
||||
constexpr uint8_t REQUIRED_SAMPLES_MAX = 16;
|
||||
constexpr uint8_t REQUIRED_SAMPLES_DEFAULT = 9;
|
||||
constexpr uint64_t STARTUP_DELAY_MILLIS = 30ULL * 60ULL * 1000ULL;
|
||||
constexpr uint8_t REQUIRED_SAMPLES_DEFAULT =
|
||||
MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT;
|
||||
constexpr uint64_t STARTUP_DELAY_MILLIS =
|
||||
MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS;
|
||||
constexpr uint64_t RETRY_INTERVAL_MILLIS = 30ULL * 60ULL * 1000ULL;
|
||||
constexpr uint64_t RESYNC_INTERVAL_MILLIS = 7ULL * 24ULL * 60ULL * 60ULL * 1000ULL;
|
||||
constexpr uint32_t SAMPLE_MAX_AGE_MILLIS = 2UL * 60UL * 60UL * 1000UL;
|
||||
@@ -35,6 +43,12 @@ constexpr uint32_t DRIFT_MIN_SECONDS = 30UL;
|
||||
constexpr uint32_t DRIFT_MAX_SECONDS = 86400UL;
|
||||
constexpr uint16_t VALID_YEARS = 10;
|
||||
|
||||
static_assert(REQUIRED_SAMPLES_DEFAULT >= REQUIRED_SAMPLES_MIN
|
||||
&& REQUIRED_SAMPLES_DEFAULT <= REQUIRED_SAMPLES_MAX,
|
||||
"mesh clock-sync sample default is outside the supported range");
|
||||
static_assert(STARTUP_DELAY_MILLIS > 0,
|
||||
"mesh clock-sync startup delay must be positive");
|
||||
|
||||
// Public-channel AES key, zero-padded to the shared-secret buffer width used
|
||||
// by Utils::MACThenDecrypt().
|
||||
const uint8_t PUBLIC_CHANNEL_SECRET[PUB_KEY_SIZE] = {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <WiFi.h>
|
||||
#include <DNSServer.h>
|
||||
#include <Preferences.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
#include <strings.h>
|
||||
@@ -296,7 +297,15 @@ bool WiFiSetupPortal::begin(const char* ap_name, SaveCallback save_callback, voi
|
||||
|
||||
WiFi.mode(WIFI_AP_STA);
|
||||
if (!WiFi.softAPConfig(SETUP_IP, SETUP_IP, SETUP_MASK)
|
||||
|| !WiFi.softAP(impl->ap_name, nullptr)) {
|
||||
|| !WiFi.softAP(impl->ap_name, nullptr)
|
||||
|| esp_wifi_set_protocol(
|
||||
WIFI_IF_AP,
|
||||
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
|
||||
!= ESP_OK
|
||||
|| esp_wifi_set_protocol(
|
||||
WIFI_IF_STA,
|
||||
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
|
||||
!= ESP_OK) {
|
||||
WiFi.softAPdisconnect(true);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) {
|
||||
|
||||
void ESPNOWRadio::init() {
|
||||
// Set device as a Wi-Fi Station
|
||||
// ESP-NOW's LR protocol is local to this radio transport. Keeping WiFi
|
||||
// driver writes in RAM prevents a later conventional WiFi image from
|
||||
// inheriting the proprietary protocol bit through NVS.
|
||||
WiFi.persistent(false);
|
||||
WiFi.mode(WIFI_STA);
|
||||
// Long Range mode
|
||||
esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR);
|
||||
|
||||
@@ -603,7 +603,22 @@ bool WebConfigServer::startSetupMode(char reply[]) {
|
||||
#else
|
||||
ap_ok = WiFi.softAP(_ap_ssid);
|
||||
#endif
|
||||
if (ap_ok) break;
|
||||
if (ap_ok) {
|
||||
// ESP-NOW can leave the persistent AP protocol mask with the proprietary
|
||||
// LR bit set. The driver then reports a healthy SoftAP, but ordinary
|
||||
// phones and laptops cannot discover its beacon. A WiFi companion must
|
||||
// advertise using the interoperable 2.4 GHz protocol set.
|
||||
const esp_err_t ap_protocol_result = esp_wifi_set_protocol(
|
||||
WIFI_IF_AP,
|
||||
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
|
||||
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
|
||||
WIFI_IF_STA,
|
||||
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);
|
||||
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
|
||||
Serial.printf("WebConfig protocol reset failed: AP=%d STA=%d\n",
|
||||
(int)ap_protocol_result, (int)sta_protocol_result);
|
||||
ap_ok = false;
|
||||
}
|
||||
|
||||
Serial.printf(
|
||||
"WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n",
|
||||
@@ -677,6 +692,13 @@ bool WebConfigServer::startAutoMode(char reply[]) {
|
||||
}
|
||||
|
||||
WiFi.mode(WIFI_STA);
|
||||
if (esp_wifi_set_protocol(
|
||||
WIFI_IF_STA,
|
||||
WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N)
|
||||
!= ESP_OK) {
|
||||
strcpy(reply, "Err: failed to reset WiFi station protocol");
|
||||
return false;
|
||||
}
|
||||
WiFi.setAutoReconnect(true);
|
||||
_retry_saved_wifi_in_setup = false;
|
||||
_setup_reconnect_in_progress = false;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#include <stdint.h>
|
||||
|
||||
// AUTO-GENERATED by tools/mota/gen_targets.py - do not edit by hand.
|
||||
// 577 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)
|
||||
// 594 OTA-capable build targets. Maps target_id (= sha2-256:4 of the target name, LE uint32)
|
||||
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
|
||||
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
|
||||
// Size-constrained receivers can set OTA_TARGET_NAME_TABLE=0. They still match targets by ID;
|
||||
@@ -68,6 +68,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xb12e6e01, "Heltec_E290_room_server" },
|
||||
{ 0xf6f835ec, "Heltec_mesh_solar_repeater" },
|
||||
{ 0x7fab017f, "heltec_rc32_companion_radio_ble" },
|
||||
{ 0x9e23928a, "heltec_rc32_companion_radio_full" },
|
||||
{ 0xf310a4d1, "heltec_rc32_companion_radio_usb" },
|
||||
{ 0xdd5953ec, "heltec_rc32_companion_radio_wifi" },
|
||||
{ 0x97574bc2, "heltec_rc32_kiss_modem" },
|
||||
@@ -77,6 +78,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x57f69746, "heltec_rc32_sensor" },
|
||||
{ 0xbc05d6a7, "heltec_rc32_terminal_chat" },
|
||||
{ 0x5bad80f1, "heltec_rc32_without_display_companion_radio_ble" },
|
||||
{ 0xb4f727a8, "heltec_rc32_without_display_companion_radio_full" },
|
||||
{ 0x112d34c4, "heltec_rc32_without_display_companion_radio_usb" },
|
||||
{ 0xf10a26df, "heltec_rc32_without_display_companion_radio_wifi" },
|
||||
{ 0xc5409ced, "heltec_rc32_without_display_repeater" },
|
||||
@@ -120,6 +122,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xa3e8d8ce, "heltec_tracker_v1_1_room_server_observer_mqtt" },
|
||||
{ 0xb16009a0, "heltec_tracker_v2_companion_radio_ble_femoff" },
|
||||
{ 0xdc780e80, "heltec_tracker_v2_companion_radio_ble_femon" },
|
||||
{ 0x28b8007c, "heltec_tracker_v2_companion_radio_full_femon" },
|
||||
{ 0x4aa63180, "heltec_tracker_v2_companion_radio_usb_femoff" },
|
||||
{ 0x6fddcbf1, "heltec_tracker_v2_companion_radio_usb_femon" },
|
||||
{ 0xf4fb5484, "heltec_tracker_v2_companion_radio_wifi_femoff" },
|
||||
@@ -155,13 +158,16 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xceeddfaf, "Heltec_v3_room_server_observer_mqtt" },
|
||||
{ 0x21519537, "Heltec_v3_sensor" },
|
||||
{ 0x382bb181, "Heltec_v3_terminal_chat" },
|
||||
{ 0xad1bcd63, "heltec_v4_2_v4_3_companion_radio_full_femon" },
|
||||
{ 0x5a2980e6, "heltec_v4_3_companion_radio_ble_femoff" },
|
||||
{ 0x90581c83, "heltec_v4_3_companion_radio_ble_ps_femoff" },
|
||||
{ 0x90a6a854, "heltec_v4_3_companion_radio_full_femoff" },
|
||||
{ 0x356df17c, "heltec_v4_3_companion_radio_usb_femoff" },
|
||||
{ 0x99a084fc, "heltec_v4_3_companion_radio_wifi_femoff" },
|
||||
{ 0x64c9dfc9, "heltec_v4_3_companion_radio_wifi_mqtt_femoff" },
|
||||
{ 0x2a2ffc92, "heltec_v4_3_expansionkit_tft_companion_radio_ble_femoff" },
|
||||
{ 0x2c13b8e9, "heltec_v4_3_tft_companion_radio_ble_femoff" },
|
||||
{ 0x1ee7d867, "heltec_v4_3_tft_companion_radio_full_femoff" },
|
||||
{ 0xb016979a, "heltec_v4_3_tft_companion_radio_usb_femoff" },
|
||||
{ 0xb49b6b55, "heltec_v4_3_tft_companion_radio_wifi_femoff" },
|
||||
{ 0x01d8f124, "heltec_v4_companion_radio_ble" },
|
||||
@@ -181,6 +187,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xf75feb3e, "heltec_v4_kiss_modem" },
|
||||
{ 0xbc9cdc70, "heltec_v4_r8_companion_radio_ble" },
|
||||
{ 0x19e31fc6, "heltec_v4_r8_companion_radio_ble_ps" },
|
||||
{ 0xa01b330e, "heltec_v4_r8_companion_radio_full" },
|
||||
{ 0x36c78d86, "heltec_v4_r8_companion_radio_usb" },
|
||||
{ 0xdf722326, "heltec_v4_r8_companion_radio_wifi" },
|
||||
{ 0xfa901c87, "heltec_v4_r8_kiss_modem" },
|
||||
@@ -189,6 +196,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xb4cb035a, "heltec_v4_r8_sensor" },
|
||||
{ 0xbb429dde, "heltec_v4_r8_terminal_chat" },
|
||||
{ 0x883e3edc, "heltec_v4_r8_tft_companion_radio_ble" },
|
||||
{ 0x4a9d1c6c, "heltec_v4_r8_tft_companion_radio_full" },
|
||||
{ 0xa0965a6b, "heltec_v4_r8_tft_companion_radio_usb" },
|
||||
{ 0x59ea25fa, "heltec_v4_r8_tft_companion_radio_wifi" },
|
||||
{ 0x1758bdb9, "heltec_v4_r8_tft_kiss_modem" },
|
||||
@@ -204,6 +212,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x0b6847ed, "heltec_v4_sensor" },
|
||||
{ 0xab3e1ad3, "heltec_v4_terminal_chat" },
|
||||
{ 0x04a8b763, "heltec_v4_tft_companion_radio_ble_femon" },
|
||||
{ 0xc456437e, "heltec_v4_tft_companion_radio_full_femon" },
|
||||
{ 0xb3ecafca, "heltec_v4_tft_companion_radio_usb_femon" },
|
||||
{ 0xf3afcde6, "heltec_v4_tft_companion_radio_wifi_femon" },
|
||||
{ 0x87513d56, "heltec_v4_tft_repeater" },
|
||||
@@ -311,6 +320,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x07907102, "LilyGo_T_Impulse_Plus_repeater" },
|
||||
{ 0x3898ea56, "LilyGo_TBeam_1W_companion_radio_ble" },
|
||||
{ 0x2fb7e218, "LilyGo_TBeam_1W_companion_radio_ble_ps" },
|
||||
{ 0x8c61d4c8, "LilyGo_TBeam_1W_companion_radio_full" },
|
||||
{ 0x4f722e70, "LilyGo_TBeam_1W_companion_radio_usb" },
|
||||
{ 0x694eed25, "LilyGo_TBeam_1W_companion_radio_wifi" },
|
||||
{ 0x85b8b9d7, "LilyGo_TBeam_1W_kiss_modem" },
|
||||
@@ -364,6 +374,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xd45277ff, "Meshimi_companion_radio_ble_" },
|
||||
{ 0x2cad9a9d, "Meshimi_repeater_" },
|
||||
{ 0x02a4a8c1, "meshnology_w12_companion_radio_ble" },
|
||||
{ 0x4a709348, "meshnology_w12_companion_radio_full" },
|
||||
{ 0x35d960bd, "meshnology_w12_companion_radio_usb" },
|
||||
{ 0x62977023, "meshnology_w12_companion_radio_wifi" },
|
||||
{ 0x17a7ed8c, "meshnology_w12_kiss_modem" },
|
||||
@@ -376,6 +387,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x11b131e7, "Minewsemi_me25ls01_repeater_lora_ota_no_external_sensors" },
|
||||
{ 0xf8259880, "Nano_G2_Ultra_repeater" },
|
||||
{ 0x063f1f00, "nibble_screen_connect_companion_radio_ble_" },
|
||||
{ 0x70d71f92, "nibble_screen_connect_companion_radio_full_" },
|
||||
{ 0xa8a79da5, "nibble_screen_connect_companion_radio_usb_" },
|
||||
{ 0xc0d3815b, "nibble_screen_connect_companion_radio_wifi_" },
|
||||
{ 0x966ac88e, "nibble_screen_connect_kiss_modem_" },
|
||||
@@ -384,6 +396,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xc42cc90d, "nibble_screen_connect_room_server_" },
|
||||
{ 0xc24791c3, "nibble_screen_connect_terminal_chat_" },
|
||||
{ 0x15e11067, "nibble_zero_connect_companion_radio_ble_" },
|
||||
{ 0x2dbb2669, "nibble_zero_connect_companion_radio_full_" },
|
||||
{ 0x90799c32, "nibble_zero_connect_companion_radio_usb_" },
|
||||
{ 0x881a6a48, "nibble_zero_connect_companion_radio_wifi_" },
|
||||
{ 0xf26af51c, "nibble_zero_connect_repeater_" },
|
||||
@@ -408,6 +421,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x4d0b1601, "R1Neo_sensor" },
|
||||
{ 0xab2ac09e, "R1Neo_terminal_chat" },
|
||||
{ 0xa17fc4f7, "RAK_3112_companion_radio_ble" },
|
||||
{ 0xe4b2bd22, "RAK_3112_companion_radio_full" },
|
||||
{ 0x90c4eb3f, "RAK_3112_companion_radio_usb" },
|
||||
{ 0x19d9ce91, "RAK_3112_companion_radio_wifi" },
|
||||
{ 0x121cc72f, "RAK_3112_kiss_modem" },
|
||||
@@ -438,6 +452,8 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x51fa1a49, "RAK_WisMesh_Tag_sensor" },
|
||||
{ 0xdb73638b, "SenseCap_Solar_repeater" },
|
||||
{ 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" },
|
||||
{ 0x620569f4, "SenseCapIndicator-LoRa_comp_radio_usb" },
|
||||
{ 0x08483f6b, "SenseCapIndicator-LoRa_comp_radio_usb_wifi" },
|
||||
{ 0x0db48f35, "solarxiao_30S_companion_radio_ble" },
|
||||
{ 0x20532f6c, "solarxiao_30S_companion_radio_usb" },
|
||||
{ 0x293ced32, "solarxiao_30S_kiss_modem" },
|
||||
@@ -455,6 +471,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0xf2128c81, "Station_G2_repeater" },
|
||||
{ 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" },
|
||||
{ 0x8e549407, "Station_G3_ESP32_companion_radio_ble" },
|
||||
{ 0x6ca142f4, "Station_G3_ESP32_companion_radio_full" },
|
||||
{ 0x1deac038, "Station_G3_ESP32_companion_radio_usb" },
|
||||
{ 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" },
|
||||
{ 0x0ba4453d, "Station_G3_ESP32_kiss_modem" },
|
||||
@@ -584,6 +601,7 @@ inline const char* ota_target_env_name(uint32_t target_id) {
|
||||
{ 0x60f5fdc2, "Xiao_S3_sensor" },
|
||||
{ 0xe8f79d22, "Xiao_S3_WIO_companion_radio_ble" },
|
||||
{ 0xb97bbb15, "Xiao_S3_WIO_companion_radio_ble_ps" },
|
||||
{ 0xd466590c, "Xiao_S3_WIO_companion_radio_full" },
|
||||
{ 0x9f27be15, "Xiao_S3_WIO_companion_radio_serial" },
|
||||
{ 0x396fecc7, "Xiao_S3_WIO_companion_radio_usb" },
|
||||
{ 0xf94e3407, "Xiao_S3_WIO_companion_radio_wifi" },
|
||||
|
||||
@@ -62,6 +62,7 @@ void RadioLibWrapper::begin() {
|
||||
}
|
||||
|
||||
_noise_floor = 0;
|
||||
_noise_floor_centi_dbm = 0;
|
||||
_noise_floor_valid = false;
|
||||
_threshold = 0;
|
||||
_cad_enabled = false;
|
||||
@@ -71,7 +72,7 @@ void RadioLibWrapper::begin() {
|
||||
|
||||
// start average out some samples
|
||||
_num_floor_samples = 0;
|
||||
_floor_sample_sum = 0;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
_nf_calib_active = false;
|
||||
_nf_last_calib = 0;
|
||||
_nf_sample_from = 0;
|
||||
@@ -232,7 +233,7 @@ void RadioLibWrapper::recalibrateNoiseFloor() {
|
||||
_nf_refresh_requested = true;
|
||||
_nf_last_calib = 0;
|
||||
_num_floor_samples = 0;
|
||||
_floor_sample_sum = 0;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
|
||||
const unsigned long now = millis();
|
||||
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
|
||||
@@ -251,7 +252,7 @@ void RadioLibWrapper::requestNoiseFloorRefresh() {
|
||||
if (_nf_refresh_requested) return;
|
||||
_nf_refresh_requested = true;
|
||||
_num_floor_samples = 0;
|
||||
_floor_sample_sum = 0;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
_nf_calib_deadline = 0; // starts when continuous RX is actually available
|
||||
}
|
||||
|
||||
@@ -425,7 +426,7 @@ void RadioLibWrapper::noiseFloorCalibCheck(unsigned long now) {
|
||||
_nf_calib_deadline = now + NF_CALIB_TIMEOUT_MS;
|
||||
_nf_sample_from = now + NF_CALIB_SETTLE_MS;
|
||||
_num_floor_samples = 0; // start a fresh batch for this window
|
||||
_floor_sample_sum = 0;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
if (!isPacketPendingOrReceiving()) {
|
||||
requestRestartRecv(); // startReceiveMode() selects continuous RX
|
||||
}
|
||||
@@ -463,22 +464,29 @@ void RadioLibWrapper::loop() {
|
||||
_nf_calib_deadline = now + NF_CONTINUOUS_TIMEOUT_MS;
|
||||
}
|
||||
if (_nf_refresh_requested && _num_floor_samples >= NUM_NOISE_FLOOR_SAMPLES
|
||||
&& _floor_sample_sum != 0) {
|
||||
int16_t sampled_floor = _floor_sample_sum / NUM_NOISE_FLOOR_SAMPLES;
|
||||
if (sampled_floor < -120) {
|
||||
sampled_floor = -120; // clamp to lower bound of -120dBi
|
||||
&& _floor_sample_sum_centi_dbm != 0) {
|
||||
int32_t sampled_floor_centi_dbm =
|
||||
_floor_sample_sum_centi_dbm / NUM_NOISE_FLOOR_SAMPLES;
|
||||
if (sampled_floor_centi_dbm < -12000) {
|
||||
sampled_floor_centi_dbm = -12000;
|
||||
}
|
||||
if (_noise_floor_valid) {
|
||||
// Favor the fresh high-rate batch while retaining a small amount of
|
||||
// history: 25% previous floor + 75% newly sampled floor. Round the
|
||||
// negative dBm result to the nearest integer instead of toward zero.
|
||||
int32_t weighted_floor = (int32_t)_noise_floor + 3L * sampled_floor;
|
||||
_noise_floor = weighted_floor < 0 ? (weighted_floor - 2) / 4
|
||||
: (weighted_floor + 2) / 4;
|
||||
// history: 25% previous floor + 75% newly sampled floor.
|
||||
int32_t weighted_floor = _noise_floor_centi_dbm
|
||||
+ 3L * sampled_floor_centi_dbm;
|
||||
_noise_floor_centi_dbm = weighted_floor < 0
|
||||
? (weighted_floor - 2) / 4
|
||||
: (weighted_floor + 2) / 4;
|
||||
} else {
|
||||
_noise_floor = sampled_floor;
|
||||
_noise_floor_centi_dbm = sampled_floor_centi_dbm;
|
||||
}
|
||||
_floor_sample_sum = 0;
|
||||
// Preserve the existing whole-dB API and wire formats while exposing the
|
||||
// fractional 64-sample mean to local displays.
|
||||
_noise_floor = _noise_floor_centi_dbm < 0
|
||||
? (_noise_floor_centi_dbm - 50) / 100
|
||||
: (_noise_floor_centi_dbm + 50) / 100;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
_noise_floor_valid = true;
|
||||
_nf_refresh_requested = false;
|
||||
_nf_last_calib = now;
|
||||
@@ -503,7 +511,7 @@ void RadioLibWrapper::loop() {
|
||||
_nf_last_calib = now;
|
||||
_nf_calib_deadline = 0;
|
||||
_num_floor_samples = 0;
|
||||
_floor_sample_sum = 0;
|
||||
_floor_sample_sum_centi_dbm = 0;
|
||||
}
|
||||
|
||||
if (_nf_refresh_requested && state == STATE_RX
|
||||
@@ -516,13 +524,15 @@ void RadioLibWrapper::loop() {
|
||||
if (!_rx_ps_armed
|
||||
&& !(_nf_sample_from != 0 && (long)(now - _nf_sample_from) < 0)
|
||||
&& !isReceivingPacket()) {
|
||||
int rssi = getCurrentRSSI();
|
||||
if (!_noise_floor_valid || rssi < _noise_floor + SAMPLING_THRESHOLD) {
|
||||
float rssi = getCurrentRSSI();
|
||||
if (!_noise_floor_valid
|
||||
|| rssi < getNoiseFloorDbm() + SAMPLING_THRESHOLD) {
|
||||
// With no valid baseline (startup, AGC reset, or gain change), seed
|
||||
// unconditionally. Otherwise reject likely traffic above the current
|
||||
// floor plus the sampling margin.
|
||||
_num_floor_samples++;
|
||||
_floor_sample_sum += rssi;
|
||||
_floor_sample_sum_centi_dbm +=
|
||||
static_cast<int32_t>(rssi * 100.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,13 @@ protected:
|
||||
mesh::MainBoard* _board;
|
||||
uint32_t n_recv, n_sent, n_recv_errors;
|
||||
int16_t _noise_floor, _threshold;
|
||||
int32_t _noise_floor_centi_dbm;
|
||||
float _last_rssi, _last_snr;
|
||||
bool _cad_enabled;
|
||||
bool _noise_floor_valid;
|
||||
bool _nf_refresh_requested;
|
||||
uint16_t _num_floor_samples;
|
||||
int32_t _floor_sample_sum;
|
||||
int32_t _floor_sample_sum_centi_dbm;
|
||||
unsigned long last_recv_millis;
|
||||
unsigned long last_radio_interrupt_millis; // updated on any ISR event, even CRC errors
|
||||
bool _rx_ps_enabled;
|
||||
@@ -203,6 +204,9 @@ public:
|
||||
virtual int16_t performChannelScan();
|
||||
|
||||
int getNoiseFloor() const override { return _noise_floor; }
|
||||
float getNoiseFloorDbm() const override {
|
||||
return _noise_floor_centi_dbm / 100.0f;
|
||||
}
|
||||
void triggerNoiseFloorCalibrate(int threshold) override;
|
||||
void recalibrateNoiseFloor() override;
|
||||
void setCADEnabled(bool enable) override { _cad_enabled = enable; }
|
||||
|
||||
@@ -691,12 +691,17 @@ bool EnvironmentSensorManager::begin() {
|
||||
MESH_DEBUG_PRINTLN("Second I2C initialized on pins SDA: %d SCL: %d", ENV_PIN_SDA, ENV_PIN_SCL);
|
||||
#endif
|
||||
|
||||
_active_sensor_count = 0;
|
||||
// Avoid touching a shared I2C bus when no environmental drivers were built.
|
||||
if (SENSOR_TABLE_SIZE == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scan the I2C bus before touching any sensor library.
|
||||
bool detected[128] = {};
|
||||
scanI2CBus(TELEM_WIRE, detected);
|
||||
|
||||
// Walk the sensor table and initialize only detected devices.
|
||||
_active_sensor_count = 0;
|
||||
for (size_t i = 0; i < SENSOR_TABLE_SIZE && _active_sensor_count < MAX_ACTIVE_SENSORS; i++) {
|
||||
const SensorDef& def = SENSOR_TABLE[i];
|
||||
// One static driver instance per type: an alternate address is a fallback, not a second device.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
|
||||
class ColorEmojiAtlas {
|
||||
static constexpr size_t FOOTER_SIZE = 40;
|
||||
static constexpr size_t ATLAS_HEADER_SIZE = 12;
|
||||
static constexpr uint16_t FIRST_EMOJI = 0xE000;
|
||||
|
||||
const uint8_t* _pixels = nullptr;
|
||||
uint16_t _count = 0;
|
||||
uint16_t _stride = 0;
|
||||
uint8_t _width = 0;
|
||||
uint8_t _height = 0;
|
||||
uint8_t _transparent = 0;
|
||||
uint8_t _text_scale = 1;
|
||||
|
||||
static uint16_t read16(const uint8_t* p) {
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
|
||||
static uint32_t read32(const uint8_t* p) {
|
||||
return (uint32_t)p[0]
|
||||
| ((uint32_t)p[1] << 8)
|
||||
| ((uint32_t)p[2] << 16)
|
||||
| ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint32_t crc32(const uint8_t* data, size_t size) {
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
public:
|
||||
void reset() {
|
||||
_pixels = nullptr;
|
||||
_count = 0;
|
||||
_stride = 0;
|
||||
_width = 0;
|
||||
_height = 0;
|
||||
_transparent = 0;
|
||||
_text_scale = 1;
|
||||
}
|
||||
|
||||
bool begin(const uint8_t* font_data, size_t font_size) {
|
||||
reset();
|
||||
if (font_data == nullptr || font_size < FOOTER_SIZE) return false;
|
||||
|
||||
const uint8_t* footer = font_data + font_size - FOOTER_SIZE;
|
||||
static const uint8_t FOOTER_MAGIC[8] = {
|
||||
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
|
||||
};
|
||||
if (memcmp(footer, FOOTER_MAGIC, sizeof(FOOTER_MAGIC)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t atlas_offset = read32(footer + 20);
|
||||
uint32_t atlas_size = read32(footer + 24);
|
||||
uint32_t atlas_crc = read32(footer + 28);
|
||||
if (atlas_offset > font_size - FOOTER_SIZE
|
||||
|| atlas_size > font_size - FOOTER_SIZE - atlas_offset
|
||||
|| atlas_size < ATLAS_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t* atlas = font_data + atlas_offset;
|
||||
if (crc32(atlas, atlas_size) != atlas_crc
|
||||
|| memcmp(atlas, "CE01", 4) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t count = read16(atlas + 4);
|
||||
uint8_t width = atlas[6];
|
||||
uint8_t height = atlas[7];
|
||||
uint8_t transparent = atlas[8];
|
||||
uint8_t text_scale = atlas[9];
|
||||
uint16_t stride = read16(atlas + 10);
|
||||
if (count == 0 || width == 0 || height == 0
|
||||
|| width > 64 || height > 64
|
||||
|| text_scale == 0 || text_scale > 8
|
||||
|| stride != (uint16_t)width * height
|
||||
|| (size_t)count * stride != atlas_size - ATLAS_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_pixels = atlas + ATLAS_HEADER_SIZE;
|
||||
_count = count;
|
||||
_stride = stride;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_transparent = transparent;
|
||||
_text_scale = text_scale;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isReady() const { return _pixels != nullptr; }
|
||||
uint8_t width() const { return _width; }
|
||||
uint8_t height() const { return _height; }
|
||||
uint8_t transparent() const { return _transparent; }
|
||||
uint8_t textScale() const { return _text_scale; }
|
||||
uint16_t count() const { return _count; }
|
||||
|
||||
const uint8_t* glyph(uint16_t mapped_codepoint) const {
|
||||
if (!isReady() || mapped_codepoint < FIRST_EMOJI) return nullptr;
|
||||
uint16_t index = mapped_codepoint - FIRST_EMOJI;
|
||||
if (index >= _count) return nullptr;
|
||||
return _pixels + (size_t)index * _stride;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
@@ -15,6 +15,14 @@ class DisplayDriver {
|
||||
int _w, _h;
|
||||
protected:
|
||||
DisplayDriver(int w, int h) { _w = w; _h = h; }
|
||||
|
||||
static size_t trimLastUTF8Codepoint(char* str, size_t length) {
|
||||
if (length == 0) return 0;
|
||||
size_t start = length - 1;
|
||||
while (start > 0 && ((uint8_t)str[start] & 0xC0) == 0x80) --start;
|
||||
str[start] = 0;
|
||||
return start;
|
||||
}
|
||||
public:
|
||||
//enum Color { DARK=0, LIGHT, RED, GREEN, BLUE, YELLOW, ORANGE }; // on b/w screen, colors will be !=0 synonym of light
|
||||
|
||||
@@ -37,6 +45,11 @@ public:
|
||||
virtual void drawRect(int x, int y, int w, int h) = 0;
|
||||
virtual void drawXbm(int x, int y, const uint8_t* bits, int w, int h) = 0;
|
||||
virtual uint16_t getTextWidth(const char* str) = 0;
|
||||
virtual bool getTouch(int* x, int* y) {
|
||||
(void)x;
|
||||
(void)y;
|
||||
return false;
|
||||
}
|
||||
virtual void drawTextCentered(int mid_x, int y, const char* str) { // helper method (override to optimise)
|
||||
int w = getTextWidth(str);
|
||||
setCursor(mid_x - w/2, y);
|
||||
@@ -72,9 +85,16 @@ public:
|
||||
virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) {
|
||||
char temp_str[256]; // reasonable buffer size
|
||||
size_t len = strlen(str);
|
||||
if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1;
|
||||
if (len >= sizeof(temp_str)) {
|
||||
len = sizeof(temp_str) - 1;
|
||||
// If the fixed buffer cuts through a UTF-8 sequence, omit that whole
|
||||
// codepoint instead of passing malformed text to the display driver.
|
||||
while (len > 0 && ((uint8_t)str[len] & 0xC0) == 0x80) --len;
|
||||
}
|
||||
memcpy(temp_str, str, len);
|
||||
temp_str[len] = 0;
|
||||
|
||||
if (max_width <= 0) return;
|
||||
|
||||
if (getTextWidth(temp_str) <= max_width) {
|
||||
setCursor(x, y);
|
||||
@@ -95,12 +115,19 @@ public:
|
||||
}
|
||||
|
||||
int ellipsis_width = getTextWidth(ellipsis);
|
||||
int str_len = strlen(temp_str);
|
||||
size_t ellipsis_len = strlen(ellipsis);
|
||||
size_t str_len = strlen(temp_str);
|
||||
|
||||
while (str_len > 0 && getTextWidth(temp_str) > max_width - ellipsis_width) {
|
||||
temp_str[--str_len] = 0;
|
||||
while (str_len > 0
|
||||
&& (getTextWidth(temp_str) > max_width - ellipsis_width
|
||||
|| str_len + ellipsis_len >= sizeof(temp_str))) {
|
||||
str_len = trimLastUTF8Codepoint(temp_str, str_len);
|
||||
}
|
||||
if (ellipsis_width <= max_width) {
|
||||
memcpy(temp_str + str_len, ellipsis, ellipsis_len + 1);
|
||||
} else {
|
||||
temp_str[0] = 0;
|
||||
}
|
||||
strcat(temp_str, ellipsis);
|
||||
|
||||
setCursor(x, y);
|
||||
print(temp_str);
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
|
||||
class EmojiFontMap {
|
||||
static constexpr size_t MAP_HEADER_SIZE = 12;
|
||||
static constexpr size_t NODE_SIZE = 6;
|
||||
static constexpr size_t EDGE_SIZE = 3;
|
||||
static constexpr size_t FOOTER_V1_SIZE = 24;
|
||||
static constexpr size_t FOOTER_V2_SIZE = 40;
|
||||
|
||||
const uint8_t* _nodes = nullptr;
|
||||
const uint8_t* _edges = nullptr;
|
||||
uint16_t _node_count = 0;
|
||||
uint16_t _edge_count = 0;
|
||||
|
||||
static uint16_t read16(const uint8_t* p) {
|
||||
return (uint16_t)p[0] | ((uint16_t)p[1] << 8);
|
||||
}
|
||||
|
||||
static uint32_t read32(const uint8_t* p) {
|
||||
return (uint32_t)p[0]
|
||||
| ((uint32_t)p[1] << 8)
|
||||
| ((uint32_t)p[2] << 16)
|
||||
| ((uint32_t)p[3] << 24);
|
||||
}
|
||||
|
||||
static uint32_t crc32(const uint8_t* data, size_t size) {
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
const uint8_t* node(uint16_t index) const {
|
||||
return _nodes + (size_t)index * NODE_SIZE;
|
||||
}
|
||||
|
||||
const uint8_t* edge(uint16_t index) const {
|
||||
return _edges + (size_t)index * EDGE_SIZE;
|
||||
}
|
||||
|
||||
bool findChild(uint16_t node_index, uint8_t value,
|
||||
uint16_t& child) const {
|
||||
const uint8_t* current = node(node_index);
|
||||
uint16_t first = read16(current);
|
||||
uint16_t count = read16(current + 2);
|
||||
uint16_t low = 0;
|
||||
uint16_t high = count;
|
||||
while (low < high) {
|
||||
uint16_t middle = low + (high - low) / 2;
|
||||
const uint8_t* candidate = edge(first + middle);
|
||||
if (candidate[0] < value) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
if (low >= count) return false;
|
||||
const uint8_t* match = edge(first + low);
|
||||
if (match[0] != value) return false;
|
||||
child = read16(match + 1);
|
||||
return child < _node_count;
|
||||
}
|
||||
|
||||
public:
|
||||
void reset() {
|
||||
_nodes = nullptr;
|
||||
_edges = nullptr;
|
||||
_node_count = 0;
|
||||
_edge_count = 0;
|
||||
}
|
||||
|
||||
bool begin(const uint8_t* font_data, size_t font_size) {
|
||||
reset();
|
||||
if (font_data == nullptr || font_size < FOOTER_V1_SIZE) return false;
|
||||
|
||||
static const uint8_t FOOTER_V1_MAGIC[8] = {
|
||||
'M', 'C', 'E', 'M', 'A', 'P', '1', 0
|
||||
};
|
||||
static const uint8_t FOOTER_V2_MAGIC[8] = {
|
||||
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
|
||||
};
|
||||
size_t footer_size = FOOTER_V1_SIZE;
|
||||
const uint8_t* footer = font_data + font_size - footer_size;
|
||||
if (font_size >= FOOTER_V2_SIZE
|
||||
&& memcmp(font_data + font_size - FOOTER_V2_SIZE,
|
||||
FOOTER_V2_MAGIC, sizeof(FOOTER_V2_MAGIC)) == 0) {
|
||||
footer_size = FOOTER_V2_SIZE;
|
||||
footer = font_data + font_size - footer_size;
|
||||
} else if (memcmp(footer, FOOTER_V1_MAGIC,
|
||||
sizeof(FOOTER_V1_MAGIC)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t map_offset = read32(footer + 8);
|
||||
uint32_t map_size = read32(footer + 12);
|
||||
uint32_t map_crc = read32(footer + 16);
|
||||
if (map_offset > font_size - footer_size
|
||||
|| map_size > font_size - footer_size - map_offset
|
||||
|| map_size < MAP_HEADER_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uint8_t* map = font_data + map_offset;
|
||||
if (crc32(map, map_size) != map_crc
|
||||
|| memcmp(map, "EM01", 4) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t node_count = read16(map + 4);
|
||||
uint16_t edge_count = read16(map + 6);
|
||||
size_t expected_size = MAP_HEADER_SIZE
|
||||
+ (size_t)node_count * NODE_SIZE
|
||||
+ (size_t)edge_count * EDGE_SIZE;
|
||||
if (node_count == 0 || expected_size != map_size) return false;
|
||||
|
||||
const uint8_t* nodes = map + MAP_HEADER_SIZE;
|
||||
const uint8_t* edges = nodes + (size_t)node_count * NODE_SIZE;
|
||||
for (uint16_t i = 0; i < node_count; ++i) {
|
||||
const uint8_t* current = nodes + (size_t)i * NODE_SIZE;
|
||||
uint16_t first = read16(current);
|
||||
uint16_t count = read16(current + 2);
|
||||
uint16_t output = read16(current + 4);
|
||||
if (first > edge_count || count > edge_count - first) return false;
|
||||
if (output != 0 && (output < 0xE000 || output > 0xF8FF)) return false;
|
||||
int previous = -1;
|
||||
for (uint16_t j = 0; j < count; ++j) {
|
||||
const uint8_t* current_edge = edges + (size_t)(first + j) * EDGE_SIZE;
|
||||
if (current_edge[0] <= previous
|
||||
|| read16(current_edge + 1) >= node_count) {
|
||||
return false;
|
||||
}
|
||||
previous = current_edge[0];
|
||||
}
|
||||
}
|
||||
|
||||
_nodes = nodes;
|
||||
_edges = edges;
|
||||
_node_count = node_count;
|
||||
_edge_count = edge_count;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isReady() const { return _nodes != nullptr; }
|
||||
|
||||
size_t longestMatch(const uint8_t* text, size_t length,
|
||||
uint16_t& mapped_codepoint) const {
|
||||
mapped_codepoint = 0;
|
||||
if (!isReady() || text == nullptr || length == 0) return 0;
|
||||
|
||||
uint16_t node_index = 0;
|
||||
size_t best_length = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
uint16_t child;
|
||||
if (!findChild(node_index, text[i], child)) break;
|
||||
node_index = child;
|
||||
uint16_t output = read16(node(node_index) + 4);
|
||||
if (output != 0) {
|
||||
mapped_codepoint = output;
|
||||
best_length = i + 1;
|
||||
}
|
||||
}
|
||||
return best_length;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
+511
-30
@@ -1,5 +1,108 @@
|
||||
#include "LGFXDisplay.h"
|
||||
|
||||
#ifndef DISPLAY_ROTATION
|
||||
#define DISPLAY_ROTATION 1
|
||||
#endif
|
||||
#ifndef DISPLAY_BRIGHTNESS
|
||||
#define DISPLAY_BRIGHTNESS 64
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
uint32_t readBigEndian32(const uint8_t* data) {
|
||||
return ((uint32_t)data[0] << 24)
|
||||
| ((uint32_t)data[1] << 16)
|
||||
| ((uint32_t)data[2] << 8)
|
||||
| (uint32_t)data[3];
|
||||
}
|
||||
|
||||
uint32_t readLittleEndian32(const uint8_t* data) {
|
||||
return (uint32_t)data[0]
|
||||
| ((uint32_t)data[1] << 8)
|
||||
| ((uint32_t)data[2] << 16)
|
||||
| ((uint32_t)data[3] << 24);
|
||||
}
|
||||
|
||||
bool validateVlw(const uint8_t* data, size_t size) {
|
||||
static const size_t HEADER_SIZE = 24;
|
||||
static const size_t RECORD_SIZE = 28;
|
||||
static const size_t FOOTER_V1_SIZE = 24;
|
||||
static const size_t FOOTER_V2_SIZE = 40;
|
||||
static const uint8_t FOOTER_V1_MAGIC[8] = {
|
||||
'M', 'C', 'E', 'M', 'A', 'P', '1', 0
|
||||
};
|
||||
static const uint8_t FOOTER_V2_MAGIC[8] = {
|
||||
'M', 'C', 'E', 'M', 'A', 'P', '2', 0
|
||||
};
|
||||
if (data == nullptr || size < HEADER_SIZE + FOOTER_V1_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t footer_size = FOOTER_V1_SIZE;
|
||||
const uint8_t* footer = data + size - footer_size;
|
||||
if (size >= HEADER_SIZE + FOOTER_V2_SIZE
|
||||
&& memcmp(data + size - FOOTER_V2_SIZE, FOOTER_V2_MAGIC,
|
||||
sizeof(FOOTER_V2_MAGIC)) == 0) {
|
||||
footer_size = FOOTER_V2_SIZE;
|
||||
footer = data + size - footer_size;
|
||||
} else if (memcmp(footer, FOOTER_V1_MAGIC,
|
||||
sizeof(FOOTER_V1_MAGIC)) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint32_t glyph_count = readBigEndian32(data);
|
||||
uint32_t map_offset = readLittleEndian32(footer + 8);
|
||||
if (glyph_count == 0 || glyph_count > 10000
|
||||
|| map_offset < HEADER_SIZE
|
||||
|| map_offset > size - footer_size
|
||||
|| glyph_count > (map_offset - HEADER_SIZE) / RECORD_SIZE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t bitmap_offset = HEADER_SIZE + (size_t)glyph_count * RECORD_SIZE;
|
||||
uint32_t previous_codepoint = 0;
|
||||
for (uint32_t i = 0; i < glyph_count; ++i) {
|
||||
const uint8_t* record = data + HEADER_SIZE + (size_t)i * RECORD_SIZE;
|
||||
uint32_t codepoint = readBigEndian32(record);
|
||||
uint32_t height = readBigEndian32(record + 4);
|
||||
uint32_t width = readBigEndian32(record + 8);
|
||||
uint32_t advance = readBigEndian32(record + 12);
|
||||
if ((i != 0 && codepoint <= previous_codepoint)
|
||||
|| codepoint > 0xFFFF
|
||||
|| width > 64
|
||||
|| height > 64
|
||||
|| advance > 64
|
||||
|| width > SIZE_MAX / (height == 0 ? 1 : height)
|
||||
|| bitmap_offset > map_offset
|
||||
|| width * height > map_offset - bitmap_offset) {
|
||||
return false;
|
||||
}
|
||||
previous_codepoint = codepoint;
|
||||
bitmap_offset += width * height;
|
||||
}
|
||||
return bitmap_offset == map_offset;
|
||||
}
|
||||
|
||||
LGFXDisplay* activeEmojiDisplay = nullptr;
|
||||
|
||||
static const uint32_t UI_PALETTE[16] = {
|
||||
0x000000, 0xFFFFFF, 0x0000FF, 0x929292,
|
||||
0xFFAA00, 0x00FFFF, 0x0000D6, 0xE53935,
|
||||
0x43A047, 0x1E88E5, 0x8E24AA, 0xFDD835,
|
||||
0x6D4C41, 0x00ACC1, 0xF06292, 0xFF00FF,
|
||||
};
|
||||
|
||||
static constexpr uint8_t TRANSPARENT_EMOJI_PALETTE_INDEX = 15;
|
||||
static constexpr uint32_t TRANSPARENT_EMOJI_COLOR = 0xFF00FF;
|
||||
|
||||
bool isSequence(const uint8_t* text, size_t remaining,
|
||||
const uint8_t* sequence, size_t sequence_size) {
|
||||
return remaining >= sequence_size
|
||||
&& memcmp(text, sequence, sequence_size) == 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Color scheme
|
||||
ColorVal UIColor::window_bkg = 0xFFFF;
|
||||
ColorVal UIColor::title_bkg = 0x001F;
|
||||
@@ -12,31 +115,44 @@ ColorVal UIColor::popup_txt = 0x0000;
|
||||
ColorVal UIColor::corp_blue = 0x001A;
|
||||
|
||||
bool LGFXDisplay::begin() {
|
||||
turnOn();
|
||||
display->init();
|
||||
display->setRotation(1);
|
||||
display->setBrightness(64);
|
||||
if (!display->init()) return false;
|
||||
_isOn = true;
|
||||
display->setRotation(DISPLAY_ROTATION);
|
||||
display->setBrightness(DISPLAY_BRIGHTNESS);
|
||||
display->setColorDepth(8);
|
||||
display->setTextColor(TFT_WHITE);
|
||||
|
||||
buffer.setColorDepth(8);
|
||||
buffer.setPsram(true);
|
||||
buffer.createSprite(width(), height());
|
||||
buffer.setColorDepth(UI_BUFFER_COLOR_DEPTH);
|
||||
// The RGB scanout framebuffer must live in PSRAM. Keep this much smaller
|
||||
// logical UI sprite in internal DMA-capable RAM so page redraws do not
|
||||
// compete with scanout and permanently shift the panel after an underflow.
|
||||
buffer.setPsram(false);
|
||||
if (buffer.createSprite(width() * UI_COORD_SCALE,
|
||||
height() * UI_COORD_SCALE) == nullptr) {
|
||||
return false;
|
||||
}
|
||||
configurePalette();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void LGFXDisplay::turnOn() {
|
||||
// display->wakeup();
|
||||
if (!_isOn) {
|
||||
display->wakeup();
|
||||
// Keep the panel initialized while blanked so touch remains available as
|
||||
// a wake source. Restoring brightness is sufficient for RGB panels.
|
||||
display->setBrightness(DISPLAY_BRIGHTNESS);
|
||||
#ifdef HAS_TOUCH
|
||||
if (display->touch() != nullptr) display->touch()->wakeup();
|
||||
#endif
|
||||
}
|
||||
_isOn = true;
|
||||
}
|
||||
|
||||
void LGFXDisplay::turnOff() {
|
||||
if (_isOn) {
|
||||
display->sleep();
|
||||
// Do not use LGFX sleep here: a sleeping touch controller cannot provide
|
||||
// the event that wakes a touch-capable UI. Blank only the backlight.
|
||||
display->setBrightness(0);
|
||||
}
|
||||
_isOn = false;
|
||||
}
|
||||
@@ -44,68 +160,433 @@ void LGFXDisplay::turnOff() {
|
||||
void LGFXDisplay::clear() {
|
||||
// display->clearDisplay();
|
||||
buffer.clearDisplay();
|
||||
_presentedEmojiOverlayCount = 0;
|
||||
_hasLastFrame = false;
|
||||
}
|
||||
|
||||
void LGFXDisplay::startFrame(ColorVal bkg) {
|
||||
// display->startWrite();
|
||||
// display->getScanLine();
|
||||
_color = bkg;
|
||||
_emojiOverlayCount = 0;
|
||||
_hasTransparentEmojiPixels = false;
|
||||
_color = renderColor(bkg);
|
||||
buffer.setBaseColor(_color);
|
||||
buffer.fillScreen(_color);
|
||||
buffer.setTextColor(_color = UIColor::primary_txt);
|
||||
setColor(UIColor::primary_txt);
|
||||
}
|
||||
|
||||
void LGFXDisplay::setTextSize(int sz) {
|
||||
buffer.setTextSize(sz);
|
||||
int scaled = (sz * UI_COORD_SCALE + _fontNativeScale / 2)
|
||||
/ _fontNativeScale;
|
||||
if (scaled < 1) scaled = 1;
|
||||
buffer.setTextSize(scaled);
|
||||
}
|
||||
|
||||
void LGFXDisplay::setColor(ColorVal c) {
|
||||
buffer.setTextColor(_color = c);
|
||||
_color = renderColor(c);
|
||||
// Every frame starts from a freshly cleared canvas, so transparent glyph
|
||||
// backgrounds are both sufficient and unambiguous. In particular,
|
||||
// primary_txt and popup_txt are both black and cannot be distinguished by
|
||||
// their RGB565 value alone.
|
||||
buffer.setTextColor(_color);
|
||||
}
|
||||
|
||||
void LGFXDisplay::setCursor(int x, int y) {
|
||||
buffer.setCursor(x, y);
|
||||
buffer.setCursor(x * UI_COORD_SCALE, y * UI_COORD_SCALE);
|
||||
}
|
||||
|
||||
void LGFXDisplay::print(const char* str) {
|
||||
buffer.println(str);
|
||||
String mapped = mapText(str);
|
||||
buffer.println(mapped.c_str());
|
||||
// Serial.println(str);
|
||||
}
|
||||
|
||||
void LGFXDisplay::fillRect(int x, int y, int w, int h) {
|
||||
buffer.fillRect(x, y, w, h, _color);
|
||||
buffer.fillRect(x * UI_COORD_SCALE, y * UI_COORD_SCALE,
|
||||
w * UI_COORD_SCALE, h * UI_COORD_SCALE, _color);
|
||||
}
|
||||
|
||||
void LGFXDisplay::drawRect(int x, int y, int w, int h) {
|
||||
buffer.drawRect(x, y, w, h, _color);
|
||||
if (w <= 0 || h <= 0) return;
|
||||
if (UI_COORD_SCALE == 1) {
|
||||
buffer.drawRect(x, y, w, h, _color);
|
||||
return;
|
||||
}
|
||||
int left = x * UI_COORD_SCALE;
|
||||
int top = y * UI_COORD_SCALE;
|
||||
int scaled_width = w * UI_COORD_SCALE;
|
||||
int scaled_height = h * UI_COORD_SCALE;
|
||||
buffer.fillRect(left, top, scaled_width, UI_COORD_SCALE, _color);
|
||||
buffer.fillRect(left, top + scaled_height - UI_COORD_SCALE,
|
||||
scaled_width, UI_COORD_SCALE, _color);
|
||||
buffer.fillRect(left, top, UI_COORD_SCALE, scaled_height, _color);
|
||||
buffer.fillRect(left + scaled_width - UI_COORD_SCALE, top,
|
||||
UI_COORD_SCALE, scaled_height, _color);
|
||||
}
|
||||
|
||||
void LGFXDisplay::drawXbm(int x, int y, const uint8_t* bits, int w, int h) {
|
||||
buffer.drawBitmap(x, y, bits, w, h, _color);
|
||||
if (UI_COORD_SCALE == 1) {
|
||||
buffer.drawBitmap(x, y, bits, w, h, _color);
|
||||
return;
|
||||
}
|
||||
const int row_bytes = (w + 7) / 8;
|
||||
for (int row = 0; row < h; ++row) {
|
||||
int column = 0;
|
||||
while (column < w) {
|
||||
while (column < w
|
||||
&& !(bits[row * row_bytes + column / 8]
|
||||
& (0x80 >> (column & 7)))) {
|
||||
++column;
|
||||
}
|
||||
int first = column;
|
||||
while (column < w
|
||||
&& (bits[row * row_bytes + column / 8]
|
||||
& (0x80 >> (column & 7)))) {
|
||||
++column;
|
||||
}
|
||||
if (first != column) {
|
||||
buffer.fillRect((x + first) * UI_COORD_SCALE,
|
||||
(y + row) * UI_COORD_SCALE,
|
||||
(column - first) * UI_COORD_SCALE,
|
||||
UI_COORD_SCALE, _color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint16_t LGFXDisplay::getTextWidth(const char* str) {
|
||||
return buffer.textWidth(str);
|
||||
String mapped = mapText(str);
|
||||
return (buffer.textWidth(mapped.c_str()) + UI_COORD_SCALE - 1)
|
||||
/ UI_COORD_SCALE;
|
||||
}
|
||||
|
||||
void LGFXDisplay::translateUTF8ToBlocks(char* dest, const char* src,
|
||||
size_t dest_size) {
|
||||
if (!_emojiMap.isReady()) {
|
||||
DisplayDriver::translateUTF8ToBlocks(dest, src, dest_size);
|
||||
return;
|
||||
}
|
||||
if (dest_size == 0) return;
|
||||
|
||||
size_t output = 0;
|
||||
for (size_t input = 0; src[input] != 0 && output + 1 < dest_size;) {
|
||||
uint8_t first = (uint8_t)src[input];
|
||||
size_t length = 1;
|
||||
if (first >= 0xC2 && first <= 0xDF) {
|
||||
length = 2;
|
||||
} else if (first >= 0xE0 && first <= 0xEF) {
|
||||
length = 3;
|
||||
} else if (first >= 0xF0 && first <= 0xF4) {
|
||||
length = 4;
|
||||
} else if (first < 32 || first > 126) {
|
||||
++input;
|
||||
continue;
|
||||
}
|
||||
bool valid = true;
|
||||
for (size_t i = 1; i < length; ++i) {
|
||||
if (src[input + i] == 0
|
||||
|| ((uint8_t)src[input + i] & 0xC0) != 0x80) {
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!valid || output + length >= dest_size) break;
|
||||
memcpy(dest + output, src + input, length);
|
||||
output += length;
|
||||
input += length;
|
||||
}
|
||||
dest[output] = 0;
|
||||
}
|
||||
|
||||
void LGFXDisplay::endFrame() {
|
||||
uint32_t hash = frameHash();
|
||||
if (_hasLastFrame && hash == _lastFrameHash) return;
|
||||
_lastFrameHash = hash;
|
||||
_hasLastFrame = true;
|
||||
|
||||
display->startWrite();
|
||||
if (UI_ZOOM != 1) {
|
||||
buffer.pushRotateZoom(display, display->width()/2, display->height()/2 , 0, UI_ZOOM, UI_ZOOM);
|
||||
if (_hasTransparentEmojiPixels) {
|
||||
buffer.pushRotateZoom(display, display->width() / 2,
|
||||
display->height() / 2, 0, UI_ZOOM, UI_ZOOM,
|
||||
TRANSPARENT_EMOJI_COLOR);
|
||||
} else {
|
||||
buffer.pushRotateZoom(display, display->width() / 2,
|
||||
display->height() / 2, 0, UI_ZOOM, UI_ZOOM);
|
||||
}
|
||||
} else {
|
||||
buffer.pushSprite(display, 0, 0);
|
||||
if (_hasTransparentEmojiPixels) {
|
||||
buffer.pushSprite(display, 0, 0, TRANSPARENT_EMOJI_COLOR);
|
||||
} else {
|
||||
buffer.pushSprite(display, 0, 0);
|
||||
}
|
||||
}
|
||||
if (_emojiAtlas.isReady()) {
|
||||
const float source_x = (_emojiAtlas.width() - 1) * 0.5f;
|
||||
const float source_y = (_emojiAtlas.height() - 1) * 0.5f;
|
||||
for (size_t i = 0; i < _emojiOverlayCount; ++i) {
|
||||
const EmojiOverlay& overlay = _emojiOverlays[i];
|
||||
const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint);
|
||||
if (pixels == nullptr) continue;
|
||||
float size = overlay.size * UI_ZOOM;
|
||||
float zoom_x = size / _emojiAtlas.width();
|
||||
float zoom_y = size / _emojiAtlas.height();
|
||||
float x = overlay.x * UI_ZOOM + (size - 1) * 0.5f;
|
||||
float y = overlay.y * UI_ZOOM + (size - 1) * 0.5f;
|
||||
display->pushImageRotateZoom(
|
||||
x, y, source_x, source_y, 0, zoom_x, zoom_y,
|
||||
_emojiAtlas.width(), _emojiAtlas.height(),
|
||||
reinterpret_cast<const lgfx::rgb332_t*>(pixels),
|
||||
lgfx::rgb332_t(_emojiAtlas.transparent()));
|
||||
}
|
||||
}
|
||||
display->endWrite();
|
||||
|
||||
_presentedEmojiOverlayCount = _emojiOverlayCount;
|
||||
memcpy(_presentedEmojiOverlays, _emojiOverlays,
|
||||
_emojiOverlayCount * sizeof(EmojiOverlay));
|
||||
}
|
||||
|
||||
bool LGFXDisplay::getTouch(int *x, int *y) {
|
||||
lgfx::v1::touch_point_t point;
|
||||
display->getTouch(&point);
|
||||
if (UI_ZOOM != 1) {
|
||||
*x = point.x / UI_ZOOM;
|
||||
*y = point.y / UI_ZOOM;
|
||||
bool LGFXDisplay::getTouch(int* x, int* y) {
|
||||
lgfx::v1::touch_point_t point = {};
|
||||
if (display->getTouch(&point) == 0) return false;
|
||||
if (UI_ZOOM * UI_COORD_SCALE != 1) {
|
||||
*x = point.x / (UI_ZOOM * UI_COORD_SCALE);
|
||||
*y = point.y / (UI_ZOOM * UI_COORD_SCALE);
|
||||
} else {
|
||||
*x = point.x;
|
||||
*y = point.y;
|
||||
}
|
||||
return (*x >= 0) && (*y >= 0);
|
||||
}
|
||||
return *x >= 0 && *x < width() && *y >= 0 && *y < height();
|
||||
}
|
||||
|
||||
bool LGFXDisplay::installRuntimeFont(uint8_t* data, size_t size) {
|
||||
mesh::ui::EmojiFontMap map;
|
||||
mesh::ui::ColorEmojiAtlas atlas;
|
||||
if (!validateVlw(data, size)
|
||||
|| !map.begin(data, size)
|
||||
|| !buffer.loadFont(data)) {
|
||||
free(data);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_fontData != nullptr) free(_fontData);
|
||||
_fontData = data;
|
||||
_fontDataSize = size;
|
||||
_emojiMap = map;
|
||||
if (atlas.begin(data, size)) {
|
||||
_emojiAtlas = atlas;
|
||||
_fontNativeScale = atlas.textScale();
|
||||
activeEmojiDisplay = this;
|
||||
buffer.setEmojiCallback(drawEmoji);
|
||||
} else {
|
||||
_emojiAtlas.reset();
|
||||
_fontNativeScale = 1;
|
||||
buffer.setEmojiCallback(nullptr);
|
||||
if (activeEmojiDisplay == this) activeEmojiDisplay = nullptr;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t LGFXDisplay::renderColor(ColorVal color) const {
|
||||
#if UI_BUFFER_COLOR_DEPTH < 8
|
||||
if (color == UIColor::primary_txt) return 0;
|
||||
if (color == UIColor::window_bkg || color == UIColor::title_txt) return 1;
|
||||
if (color == UIColor::title_bkg) return 2;
|
||||
if (color == UIColor::secondary_txt) return 3;
|
||||
if (color == UIColor::warning_txt) return 4;
|
||||
if (color == UIColor::popup_bkg) return 5;
|
||||
if (color == UIColor::popup_txt) return 0;
|
||||
if (color == UIColor::corp_blue) return 6;
|
||||
return color & 0x0F;
|
||||
#else
|
||||
return color;
|
||||
#endif
|
||||
}
|
||||
|
||||
void LGFXDisplay::configurePalette() {
|
||||
#if UI_BUFFER_COLOR_DEPTH < 8
|
||||
buffer.createPalette(UI_PALETTE,
|
||||
sizeof(UI_PALETTE) / sizeof(UI_PALETTE[0]));
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t LGFXDisplay::frameHash() const {
|
||||
const uint8_t* data = static_cast<const uint8_t*>(buffer.getBuffer());
|
||||
size_t length = buffer.bufferLength();
|
||||
uint32_t hash = 2166136261UL;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
hash = (hash ^ data[i]) * 16777619UL;
|
||||
}
|
||||
for (size_t i = 0; i < _emojiOverlayCount; ++i) {
|
||||
const EmojiOverlay& overlay = _emojiOverlays[i];
|
||||
const uint16_t values[] = {
|
||||
overlay.codepoint,
|
||||
static_cast<uint16_t>(overlay.x),
|
||||
static_cast<uint16_t>(overlay.y),
|
||||
overlay.size,
|
||||
};
|
||||
for (uint16_t value : values) {
|
||||
hash = (hash ^ static_cast<uint8_t>(value)) * 16777619UL;
|
||||
hash = (hash ^ static_cast<uint8_t>(value >> 8)) * 16777619UL;
|
||||
}
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
bool LGFXDisplay::wasEmojiPresented(const EmojiOverlay& overlay) const {
|
||||
for (size_t i = 0; i < _presentedEmojiOverlayCount; ++i) {
|
||||
const EmojiOverlay& presented = _presentedEmojiOverlays[i];
|
||||
if (presented.codepoint == overlay.codepoint
|
||||
&& presented.x == overlay.x
|
||||
&& presented.y == overlay.y
|
||||
&& presented.size == overlay.size) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void LGFXDisplay::drawEmojiMask(int32_t x, int32_t y, int32_t size,
|
||||
const uint8_t* pixels) {
|
||||
#if UI_BUFFER_COLOR_DEPTH < 8
|
||||
if (pixels == nullptr || size <= 0) return;
|
||||
const int source_width = _emojiAtlas.width();
|
||||
const int source_height = _emojiAtlas.height();
|
||||
const uint8_t transparent = _emojiAtlas.transparent();
|
||||
for (int source_y = 0; source_y < source_height; ++source_y) {
|
||||
int top = y + source_y * size / source_height;
|
||||
int bottom = y + (source_y + 1) * size / source_height;
|
||||
for (int source_x = 0; source_x < source_width; ++source_x) {
|
||||
if (pixels[source_y * source_width + source_x] == transparent) continue;
|
||||
int left = x + source_x * size / source_width;
|
||||
int right = x + (source_x + 1) * size / source_width;
|
||||
buffer.fillRect(left, top, right - left, bottom - top,
|
||||
TRANSPARENT_EMOJI_PALETTE_INDEX);
|
||||
}
|
||||
}
|
||||
_hasTransparentEmojiPixels = true;
|
||||
#else
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)size;
|
||||
(void)pixels;
|
||||
#endif
|
||||
}
|
||||
|
||||
void LGFXDisplay::drawEmojiUnderlay(int32_t x, int32_t y, int32_t size,
|
||||
const uint8_t* pixels) {
|
||||
#if UI_BUFFER_COLOR_DEPTH < 8
|
||||
if (pixels == nullptr || size <= 0) return;
|
||||
const int source_width = _emojiAtlas.width();
|
||||
const int source_height = _emojiAtlas.height();
|
||||
const uint8_t transparent = _emojiAtlas.transparent();
|
||||
for (int source_y = 0; source_y < source_height; ++source_y) {
|
||||
int top = y + source_y * size / source_height;
|
||||
int bottom = y + (source_y + 1) * size / source_height;
|
||||
for (int source_x = 0; source_x < source_width; ++source_x) {
|
||||
uint8_t color = pixels[source_y * source_width + source_x];
|
||||
if (color == transparent) continue;
|
||||
int red = ((color >> 5) & 7) * 255 / 7;
|
||||
int green = ((color >> 2) & 7) * 255 / 7;
|
||||
int blue = (color & 3) * 255 / 3;
|
||||
int best_index = 0;
|
||||
uint32_t best_distance = UINT32_MAX;
|
||||
// The final palette entry is reserved as the transparent color used to
|
||||
// preserve an unchanged full-color emoji in the panel framebuffer.
|
||||
for (int index = 0; index < TRANSPARENT_EMOJI_PALETTE_INDEX; ++index) {
|
||||
int delta_red = red - (int)((UI_PALETTE[index] >> 16) & 0xFF);
|
||||
int delta_green = green - (int)((UI_PALETTE[index] >> 8) & 0xFF);
|
||||
int delta_blue = blue - (int)(UI_PALETTE[index] & 0xFF);
|
||||
uint32_t distance = delta_red * delta_red
|
||||
+ delta_green * delta_green + delta_blue * delta_blue;
|
||||
if (distance < best_distance) {
|
||||
best_distance = distance;
|
||||
best_index = index;
|
||||
}
|
||||
}
|
||||
int left = x + source_x * size / source_width;
|
||||
int right = x + (source_x + 1) * size / source_width;
|
||||
buffer.fillRect(left, top, right - left, bottom - top, best_index);
|
||||
}
|
||||
}
|
||||
#else
|
||||
(void)x;
|
||||
(void)y;
|
||||
(void)size;
|
||||
(void)pixels;
|
||||
#endif
|
||||
}
|
||||
|
||||
int32_t LGFXDisplay::queueEmoji(lgfx::v1::LGFXBase* gfx,
|
||||
int32_t x, int32_t y,
|
||||
uint32_t codepoint,
|
||||
int32_t font_height) {
|
||||
if (gfx != &buffer || codepoint > 0xFFFF || font_height <= 0
|
||||
|| _emojiAtlas.glyph((uint16_t)codepoint) == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
if (_emojiOverlayCount >= MAX_EMOJI_OVERLAYS) return font_height;
|
||||
|
||||
EmojiOverlay& overlay = _emojiOverlays[_emojiOverlayCount++];
|
||||
overlay.codepoint = (uint16_t)codepoint;
|
||||
overlay.x = x;
|
||||
overlay.y = y;
|
||||
overlay.size = (uint16_t)font_height;
|
||||
const uint8_t* pixels = _emojiAtlas.glyph(overlay.codepoint);
|
||||
if (wasEmojiPresented(overlay)) {
|
||||
// Do not overwrite an unchanged full-color emoji with the indexed canvas
|
||||
// during periodic message-age redraws. The following direct overlay is
|
||||
// then idempotent instead of visibly blinking once per second.
|
||||
drawEmojiMask(x, y, font_height, pixels);
|
||||
} else {
|
||||
drawEmojiUnderlay(x, y, font_height, pixels);
|
||||
}
|
||||
return font_height;
|
||||
}
|
||||
|
||||
int32_t LGFXDisplay::drawEmoji(lgfx::v1::LGFXBase* gfx,
|
||||
int32_t x, int32_t y,
|
||||
uint32_t codepoint,
|
||||
int32_t font_height) {
|
||||
if (activeEmojiDisplay == nullptr) return 0;
|
||||
return activeEmojiDisplay->queueEmoji(gfx, x, y, codepoint, font_height);
|
||||
}
|
||||
|
||||
String LGFXDisplay::mapText(const char* str) const {
|
||||
if (str == nullptr || !_emojiMap.isReady()) return String(str == nullptr ? "" : str);
|
||||
|
||||
const uint8_t* text = (const uint8_t*)str;
|
||||
size_t length = strlen(str);
|
||||
String mapped;
|
||||
mapped.reserve(length + 8);
|
||||
static const uint8_t VS15[] = {0xEF, 0xB8, 0x8E};
|
||||
static const uint8_t VS16[] = {0xEF, 0xB8, 0x8F};
|
||||
static const uint8_t ZWJ[] = {0xE2, 0x80, 0x8D};
|
||||
|
||||
for (size_t offset = 0; offset < length;) {
|
||||
uint16_t codepoint;
|
||||
size_t match = _emojiMap.longestMatch(text + offset, length - offset, codepoint);
|
||||
if (match != 0) {
|
||||
mapped += (char)(0xE0 | (codepoint >> 12));
|
||||
mapped += (char)(0x80 | ((codepoint >> 6) & 0x3F));
|
||||
mapped += (char)(0x80 | (codepoint & 0x3F));
|
||||
offset += match;
|
||||
continue;
|
||||
}
|
||||
if (text[offset] == ' ') {
|
||||
mapped += (char)0xC2;
|
||||
mapped += (char)0xA0;
|
||||
++offset;
|
||||
continue;
|
||||
}
|
||||
if (isSequence(text + offset, length - offset, VS15, sizeof(VS15))
|
||||
|| isSequence(text + offset, length - offset, VS16, sizeof(VS16))
|
||||
|| isSequence(text + offset, length - offset, ZWJ, sizeof(ZWJ))) {
|
||||
offset += 3;
|
||||
continue;
|
||||
}
|
||||
mapped += (char)text[offset++];
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
#include <helpers/ui/ColorEmojiAtlas.h>
|
||||
#include <helpers/ui/EmojiFontMap.h>
|
||||
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
@@ -8,6 +10,12 @@
|
||||
#ifndef UI_ZOOM
|
||||
#define UI_ZOOM 1
|
||||
#endif
|
||||
#ifndef UI_COORD_SCALE
|
||||
#define UI_COORD_SCALE 1
|
||||
#endif
|
||||
#ifndef UI_BUFFER_COLOR_DEPTH
|
||||
#define UI_BUFFER_COLOR_DEPTH 8
|
||||
#endif
|
||||
|
||||
class LGFXDisplay : public DisplayDriver {
|
||||
protected:
|
||||
@@ -16,10 +24,45 @@ protected:
|
||||
|
||||
bool _isOn = false;
|
||||
int _color = TFT_WHITE;
|
||||
uint8_t* _fontData = nullptr;
|
||||
size_t _fontDataSize = 0;
|
||||
mesh::ui::EmojiFontMap _emojiMap;
|
||||
mesh::ui::ColorEmojiAtlas _emojiAtlas;
|
||||
uint8_t _fontNativeScale = 1;
|
||||
|
||||
struct EmojiOverlay {
|
||||
uint16_t codepoint;
|
||||
int16_t x;
|
||||
int16_t y;
|
||||
uint16_t size;
|
||||
};
|
||||
static constexpr size_t MAX_EMOJI_OVERLAYS = 64;
|
||||
EmojiOverlay _emojiOverlays[MAX_EMOJI_OVERLAYS];
|
||||
size_t _emojiOverlayCount = 0;
|
||||
EmojiOverlay _presentedEmojiOverlays[MAX_EMOJI_OVERLAYS];
|
||||
size_t _presentedEmojiOverlayCount = 0;
|
||||
bool _hasTransparentEmojiPixels = false;
|
||||
uint32_t _lastFrameHash = 0;
|
||||
bool _hasLastFrame = false;
|
||||
|
||||
String mapText(const char* str) const;
|
||||
uint32_t renderColor(ColorVal color) const;
|
||||
void configurePalette();
|
||||
uint32_t frameHash() const;
|
||||
bool wasEmojiPresented(const EmojiOverlay& overlay) const;
|
||||
void drawEmojiMask(int32_t x, int32_t y, int32_t size,
|
||||
const uint8_t* pixels);
|
||||
void drawEmojiUnderlay(int32_t x, int32_t y, int32_t size,
|
||||
const uint8_t* pixels);
|
||||
int32_t queueEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y,
|
||||
uint32_t codepoint, int32_t font_height);
|
||||
static int32_t drawEmoji(lgfx::v1::LGFXBase* gfx, int32_t x, int32_t y,
|
||||
uint32_t codepoint, int32_t font_height);
|
||||
|
||||
public:
|
||||
LGFXDisplay(int w, int h, LGFX_Device &disp)
|
||||
: DisplayDriver(w/UI_ZOOM, h/UI_ZOOM), display(&disp) {}
|
||||
: DisplayDriver(w/(UI_ZOOM*UI_COORD_SCALE),
|
||||
h/(UI_ZOOM*UI_COORD_SCALE)), display(&disp) {}
|
||||
bool begin();
|
||||
bool isOn() override { return _isOn; }
|
||||
void turnOn() override;
|
||||
@@ -34,6 +77,9 @@ public:
|
||||
void drawRect(int x, int y, int w, int h) override;
|
||||
void drawXbm(int x, int y, const uint8_t* bits, int w, int h) override;
|
||||
uint16_t getTextWidth(const char* str) override;
|
||||
void translateUTF8ToBlocks(char* dest, const char* src,
|
||||
size_t dest_size) override;
|
||||
void endFrame() override;
|
||||
virtual bool getTouch(int *x, int *y);
|
||||
bool getTouch(int* x, int* y) override;
|
||||
bool installRuntimeFont(uint8_t* data, size_t size);
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
namespace ui {
|
||||
|
||||
enum class TouchAction : uint8_t {
|
||||
None,
|
||||
Previous,
|
||||
Next,
|
||||
Select,
|
||||
VerticalPrevious,
|
||||
VerticalNext,
|
||||
};
|
||||
|
||||
// Converts one-finger touch samples into the three actions understood by the
|
||||
// button-oriented companion UI. An action is emitted only after release so a
|
||||
// drag cannot activate the page under the user's finger.
|
||||
class TouchInput {
|
||||
bool _active = false;
|
||||
bool _reverse_swipes;
|
||||
bool _separate_vertical_swipes;
|
||||
uint8_t _center_zone_percent;
|
||||
uint8_t _touch_samples = 0;
|
||||
uint8_t _release_samples = 0;
|
||||
int _start_x = 0;
|
||||
int _start_y = 0;
|
||||
int _last_x = 0;
|
||||
int _last_y = 0;
|
||||
|
||||
static int magnitude(int value) { return value < 0 ? -value : value; }
|
||||
|
||||
TouchAction swipeAction(bool negative_direction) const {
|
||||
const bool next = negative_direction != _reverse_swipes;
|
||||
return next ? TouchAction::Next : TouchAction::Previous;
|
||||
}
|
||||
|
||||
TouchAction verticalSwipeAction(bool negative_direction) const {
|
||||
if (!_separate_vertical_swipes) return swipeAction(negative_direction);
|
||||
const bool next = negative_direction != _reverse_swipes;
|
||||
return next ? TouchAction::VerticalNext
|
||||
: TouchAction::VerticalPrevious;
|
||||
}
|
||||
|
||||
public:
|
||||
explicit TouchInput(bool reverse_swipes = false,
|
||||
bool separate_vertical_swipes = false,
|
||||
uint8_t center_zone_percent = 34)
|
||||
: _reverse_swipes(reverse_swipes),
|
||||
_separate_vertical_swipes(separate_vertical_swipes),
|
||||
_center_zone_percent(center_zone_percent > 100
|
||||
? 100
|
||||
: center_zone_percent) {}
|
||||
|
||||
TouchAction update(bool touched, int x, int y, int width, int height,
|
||||
bool bottom_selector = false) {
|
||||
if (touched) {
|
||||
_release_samples = 0;
|
||||
if (!_active) {
|
||||
_active = true;
|
||||
_start_x = x;
|
||||
_start_y = y;
|
||||
_touch_samples = 0;
|
||||
}
|
||||
if (_touch_samples != UINT8_MAX) ++_touch_samples;
|
||||
_last_x = x;
|
||||
_last_y = y;
|
||||
return TouchAction::None;
|
||||
}
|
||||
|
||||
if (!_active) return TouchAction::None;
|
||||
// A moving FT5x06-family controller can briefly report no points between
|
||||
// two valid samples. Require a stable release so that gap cannot split one
|
||||
// swipe into a swipe followed by an endpoint tap.
|
||||
if (++_release_samples < 2) return TouchAction::None;
|
||||
_active = false;
|
||||
_release_samples = 0;
|
||||
|
||||
// A contact seen only once has no measurable direction. Ignoring it is
|
||||
// safer than treating the endpoint of a fast swipe as an opposite tap.
|
||||
if (_touch_samples < 2 || width <= 0 || height <= 0) {
|
||||
return TouchAction::None;
|
||||
}
|
||||
|
||||
const int dx = _last_x - _start_x;
|
||||
const int dy = _last_y - _start_y;
|
||||
const int abs_dx = magnitude(dx);
|
||||
const int abs_dy = magnitude(dy);
|
||||
const int horizontal_threshold = width / 8 > 8 ? width / 8 : 8;
|
||||
const int vertical_threshold = height / 8 > 8 ? height / 8 : 8;
|
||||
|
||||
if (abs_dx >= abs_dy && abs_dx >= horizontal_threshold) {
|
||||
return swipeAction(dx < 0);
|
||||
}
|
||||
if (abs_dy > abs_dx && abs_dy >= vertical_threshold) {
|
||||
return verticalSwipeAction(dy < 0);
|
||||
}
|
||||
|
||||
// Message screens may reserve the otherwise empty ends of their bottom
|
||||
// status bar as forgiving arrow buttons. Keep the label in the middle
|
||||
// inert so an imprecise arrow tap cannot accidentally close the screen.
|
||||
if (bottom_selector && _separate_vertical_swipes
|
||||
&& _start_y >= (height * 3) / 4) {
|
||||
if (_start_x < width / 4) {
|
||||
return TouchAction::VerticalPrevious;
|
||||
}
|
||||
if (_start_x >= (width * 3) / 4) {
|
||||
return TouchAction::VerticalNext;
|
||||
}
|
||||
return TouchAction::None;
|
||||
}
|
||||
|
||||
// Taps use three broad zones so every companion action remains available:
|
||||
// left = previous, center = select, right = next.
|
||||
// Use the initial position for a tap. If a short swipe falls just below
|
||||
// the movement threshold, its endpoint may be in the opposite tap zone on
|
||||
// displays whose touch X axis is inverted.
|
||||
const int side_percent = (100 - _center_zone_percent) / 2;
|
||||
const int center_left = (width * side_percent) / 100;
|
||||
const int center_right = width - center_left;
|
||||
if (_start_x < center_left) return TouchAction::Previous;
|
||||
if (_start_x >= center_right) return TouchAction::Next;
|
||||
return TouchAction::Select;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_active = false;
|
||||
_touch_samples = 0;
|
||||
_release_samples = 0;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace mesh
|
||||
@@ -0,0 +1,93 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ui/ColorEmojiAtlas.h>
|
||||
|
||||
#include <stdint.h>
|
||||
#include <vector>
|
||||
|
||||
using mesh::ui::ColorEmojiAtlas;
|
||||
|
||||
namespace {
|
||||
|
||||
void append16(std::vector<uint8_t>& data, uint16_t value) {
|
||||
data.push_back((uint8_t)value);
|
||||
data.push_back((uint8_t)(value >> 8));
|
||||
}
|
||||
|
||||
void append32(std::vector<uint8_t>& data, uint32_t value) {
|
||||
data.push_back((uint8_t)value);
|
||||
data.push_back((uint8_t)(value >> 8));
|
||||
data.push_back((uint8_t)(value >> 16));
|
||||
data.push_back((uint8_t)(value >> 24));
|
||||
}
|
||||
|
||||
uint32_t crc32(const uint8_t* data, size_t size) {
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> sampleFont() {
|
||||
std::vector<uint8_t> font(24, 0x55);
|
||||
const uint32_t map_offset = font.size();
|
||||
std::vector<uint8_t> map = {
|
||||
'E', 'M', '0', '1', 1, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0,
|
||||
};
|
||||
font.insert(font.end(), map.begin(), map.end());
|
||||
|
||||
const uint32_t atlas_offset = font.size();
|
||||
std::vector<uint8_t> atlas = {
|
||||
'C', 'E', '0', '1',
|
||||
2, 0, 2, 2, 0xE3, 3, 4, 0,
|
||||
1, 2, 3, 4,
|
||||
5, 6, 7, 8,
|
||||
};
|
||||
font.insert(font.end(), atlas.begin(), atlas.end());
|
||||
|
||||
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0});
|
||||
append32(font, map_offset);
|
||||
append32(font, map.size());
|
||||
append32(font, crc32(map.data(), map.size()));
|
||||
append32(font, atlas_offset);
|
||||
append32(font, atlas.size());
|
||||
append32(font, crc32(atlas.data(), atlas.size()));
|
||||
append32(font, 0);
|
||||
append32(font, 0);
|
||||
return font;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(ColorEmojiAtlas, ReadsGlyphsAndMetrics) {
|
||||
std::vector<uint8_t> font = sampleFont();
|
||||
ColorEmojiAtlas atlas;
|
||||
ASSERT_TRUE(atlas.begin(font.data(), font.size()));
|
||||
EXPECT_EQ(2, atlas.width());
|
||||
EXPECT_EQ(2, atlas.height());
|
||||
EXPECT_EQ(3, atlas.textScale());
|
||||
EXPECT_EQ(0xE3, atlas.transparent());
|
||||
ASSERT_NE(nullptr, atlas.glyph(0xE000));
|
||||
EXPECT_EQ(1, atlas.glyph(0xE000)[0]);
|
||||
EXPECT_EQ(8, atlas.glyph(0xE001)[3]);
|
||||
EXPECT_EQ(nullptr, atlas.glyph(0xDFFF));
|
||||
EXPECT_EQ(nullptr, atlas.glyph(0xE002));
|
||||
}
|
||||
|
||||
TEST(ColorEmojiAtlas, RejectsCorruptAtlas) {
|
||||
std::vector<uint8_t> font = sampleFont();
|
||||
font[50] ^= 0x80;
|
||||
ColorEmojiAtlas atlas;
|
||||
EXPECT_FALSE(atlas.begin(font.data(), font.size()));
|
||||
EXPECT_FALSE(atlas.isReady());
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ui/DisplayDriver.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
bool isValidUTF8(const char* text) {
|
||||
for (size_t offset = 0; text[offset] != 0;) {
|
||||
uint8_t first = (uint8_t)text[offset];
|
||||
size_t length = 1;
|
||||
if (first >= 0xC2 && first <= 0xDF) {
|
||||
length = 2;
|
||||
} else if (first >= 0xE0 && first <= 0xEF) {
|
||||
length = 3;
|
||||
} else if (first >= 0xF0 && first <= 0xF4) {
|
||||
length = 4;
|
||||
} else if (first >= 0x80) {
|
||||
return false;
|
||||
}
|
||||
for (size_t i = 1; i < length; ++i) {
|
||||
if (text[offset + i] == 0
|
||||
|| ((uint8_t)text[offset + i] & 0xC0) != 0x80) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class TestDisplay : public DisplayDriver {
|
||||
public:
|
||||
std::string printed;
|
||||
|
||||
TestDisplay() : DisplayDriver(100, 100) {}
|
||||
|
||||
bool isOn() override { return true; }
|
||||
void turnOn() override {}
|
||||
void turnOff() override {}
|
||||
void clear() override {}
|
||||
void startFrame(ColorVal) override {}
|
||||
void setTextSize(int) override {}
|
||||
void setColor(ColorVal) override {}
|
||||
void setCursor(int, int) override {}
|
||||
void print(const char* str) override { printed = str; }
|
||||
void fillRect(int, int, int, int) override {}
|
||||
void drawRect(int, int, int, int) override {}
|
||||
void drawXbm(int, int, const uint8_t*, int, int) override {}
|
||||
uint16_t getTextWidth(const char* str) override {
|
||||
uint16_t width = 0;
|
||||
for (size_t i = 0; str[i] != 0; ++i) {
|
||||
if (((uint8_t)str[i] & 0xC0) != 0x80) ++width;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
void endFrame() override {}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(DisplayDriver, EllipsizesOnlyAtUTF8CodepointBoundaries) {
|
||||
TestDisplay display;
|
||||
display.drawTextEllipsized(0, 0, 5, "AB\xF0\x9F\x98\x80" "CDE");
|
||||
EXPECT_EQ("AB...", display.printed);
|
||||
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
|
||||
}
|
||||
|
||||
TEST(DisplayDriver, FixedBufferDoesNotSplitUTF8Codepoint) {
|
||||
TestDisplay display;
|
||||
std::string text(254, 'A');
|
||||
text += "\xF0\x9F\x98\x80";
|
||||
text += 'B';
|
||||
display.drawTextEllipsized(0, 0, 255, text.c_str());
|
||||
EXPECT_EQ(std::string(254, 'A'), display.printed);
|
||||
EXPECT_TRUE(isValidUTF8(display.printed.c_str()));
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ui/EmojiFontMap.h>
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <vector>
|
||||
|
||||
using mesh::ui::EmojiFontMap;
|
||||
|
||||
namespace {
|
||||
|
||||
void append16(std::vector<uint8_t>& data, uint16_t value) {
|
||||
data.push_back((uint8_t)value);
|
||||
data.push_back((uint8_t)(value >> 8));
|
||||
}
|
||||
|
||||
void append32(std::vector<uint8_t>& data, uint32_t value) {
|
||||
data.push_back((uint8_t)value);
|
||||
data.push_back((uint8_t)(value >> 8));
|
||||
data.push_back((uint8_t)(value >> 16));
|
||||
data.push_back((uint8_t)(value >> 24));
|
||||
}
|
||||
|
||||
uint32_t crc32(const uint8_t* data, size_t size) {
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> sampleFont() {
|
||||
std::vector<uint8_t> font(32, 0x55);
|
||||
const uint32_t map_offset = font.size();
|
||||
std::vector<uint8_t> map;
|
||||
map.insert(map.end(), {'E', 'M', '0', '1'});
|
||||
append16(map, 6); // nodes
|
||||
append16(map, 5); // edges
|
||||
append32(map, 0);
|
||||
|
||||
// Root: F0 -> 1
|
||||
append16(map, 0); append16(map, 1); append16(map, 0);
|
||||
// F0: 9F -> 2
|
||||
append16(map, 1); append16(map, 1); append16(map, 0);
|
||||
// F0 9F: 98 -> 3
|
||||
append16(map, 2); append16(map, 1); append16(map, 0);
|
||||
// F0 9F 98: 80 -> 4
|
||||
append16(map, 3); append16(map, 1); append16(map, 0);
|
||||
// Grinning face, also prefix of the test sequence below.
|
||||
append16(map, 4); append16(map, 1); append16(map, 0xE000);
|
||||
// Grinning face followed by '!'.
|
||||
append16(map, 5); append16(map, 0); append16(map, 0xE001);
|
||||
|
||||
map.push_back(0xF0); append16(map, 1);
|
||||
map.push_back(0x9F); append16(map, 2);
|
||||
map.push_back(0x98); append16(map, 3);
|
||||
map.push_back(0x80); append16(map, 4);
|
||||
map.push_back('!'); append16(map, 5);
|
||||
|
||||
font.insert(font.end(), map.begin(), map.end());
|
||||
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '1', 0});
|
||||
append32(font, map_offset);
|
||||
append32(font, map.size());
|
||||
append32(font, crc32(map.data(), map.size()));
|
||||
append32(font, 0);
|
||||
return font;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> sampleVersion2Font() {
|
||||
std::vector<uint8_t> font = sampleFont();
|
||||
font.resize(font.size() - 24);
|
||||
const uint32_t map_offset = 32;
|
||||
const uint32_t map_size = font.size() - map_offset;
|
||||
const uint32_t map_crc = crc32(font.data() + map_offset, map_size);
|
||||
font.insert(font.end(), {'M', 'C', 'E', 'M', 'A', 'P', '2', 0});
|
||||
append32(font, map_offset);
|
||||
append32(font, map_size);
|
||||
append32(font, map_crc);
|
||||
for (int i = 0; i < 5; ++i) append32(font, 0);
|
||||
return font;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(EmojiFontMap, UsesLongestSequenceMatch) {
|
||||
std::vector<uint8_t> font = sampleFont();
|
||||
EmojiFontMap map;
|
||||
ASSERT_TRUE(map.begin(font.data(), font.size()));
|
||||
|
||||
const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80, '!', 'x'};
|
||||
uint16_t output = 0;
|
||||
EXPECT_EQ(5U, map.longestMatch(text, sizeof(text), output));
|
||||
EXPECT_EQ(0xE001, output);
|
||||
|
||||
EXPECT_EQ(4U, map.longestMatch(text, 4, output));
|
||||
EXPECT_EQ(0xE000, output);
|
||||
}
|
||||
|
||||
TEST(EmojiFontMap, RejectsCorruptMappingData) {
|
||||
std::vector<uint8_t> font = sampleFont();
|
||||
font[40] ^= 0x80;
|
||||
EmojiFontMap map;
|
||||
EXPECT_FALSE(map.begin(font.data(), font.size()));
|
||||
EXPECT_FALSE(map.isReady());
|
||||
}
|
||||
|
||||
TEST(EmojiFontMap, RejectsMissingFooter) {
|
||||
uint8_t data[64] = {};
|
||||
EmojiFontMap map;
|
||||
EXPECT_FALSE(map.begin(data, sizeof(data)));
|
||||
}
|
||||
|
||||
TEST(EmojiFontMap, AcceptsVersion2Footer) {
|
||||
std::vector<uint8_t> font = sampleVersion2Font();
|
||||
EmojiFontMap map;
|
||||
ASSERT_TRUE(map.begin(font.data(), font.size()));
|
||||
|
||||
const uint8_t text[] = {0xF0, 0x9F, 0x98, 0x80};
|
||||
uint16_t output = 0;
|
||||
EXPECT_EQ(sizeof(text), map.longestMatch(text, sizeof(text), output));
|
||||
EXPECT_EQ(0xE000, output);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <helpers/ui/TouchInput.h>
|
||||
|
||||
using mesh::ui::TouchAction;
|
||||
using mesh::ui::TouchInput;
|
||||
|
||||
namespace {
|
||||
|
||||
TouchAction release(TouchInput& input, int width = 137, int height = 137) {
|
||||
EXPECT_EQ(input.update(false, -1, -1, width, height), TouchAction::None);
|
||||
return input.update(false, -1, -1, width, height);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST(TouchInput, EmitsOnlyAfterRelease) {
|
||||
TouchInput input;
|
||||
EXPECT_EQ(input.update(true, 70, 50, 137, 137), TouchAction::None);
|
||||
EXPECT_EQ(input.update(true, 72, 51, 137, 137), TouchAction::None);
|
||||
EXPECT_EQ(release(input), TouchAction::Select);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None);
|
||||
}
|
||||
|
||||
TEST(TouchInput, MapsTapZonesToAllActions) {
|
||||
TouchInput input;
|
||||
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
|
||||
input.update(true, 68, 60, 137, 137);
|
||||
input.update(true, 68, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Select);
|
||||
|
||||
input.update(true, 125, 60, 137, 137);
|
||||
input.update(true, 125, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
}
|
||||
|
||||
TEST(TouchInput, CanUseAWiderCenterTapZone) {
|
||||
TouchInput input(false, false, 70);
|
||||
|
||||
input.update(true, 25, 60, 137, 137);
|
||||
input.update(true, 25, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Select);
|
||||
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
|
||||
input.update(true, 127, 60, 137, 137);
|
||||
input.update(true, 127, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
}
|
||||
|
||||
TEST(TouchInput, MapsHorizontalSwipesToPageNavigation) {
|
||||
TouchInput input;
|
||||
|
||||
input.update(true, 110, 60, 137, 137);
|
||||
input.update(true, 40, 62, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
|
||||
input.update(true, 30, 60, 137, 137);
|
||||
input.update(true, 105, 58, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
}
|
||||
|
||||
TEST(TouchInput, MapsVerticalSwipesToPageNavigation) {
|
||||
TouchInput input;
|
||||
|
||||
input.update(true, 60, 110, 137, 137);
|
||||
input.update(true, 62, 35, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
|
||||
input.update(true, 60, 25, 137, 137);
|
||||
input.update(true, 58, 105, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
}
|
||||
|
||||
TEST(TouchInput, CanRouteVerticalSwipesToASeparateSelector) {
|
||||
TouchInput input(true, true);
|
||||
|
||||
input.update(true, 60, 110, 137, 137);
|
||||
input.update(true, 62, 35, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::VerticalPrevious);
|
||||
|
||||
input.update(true, 60, 25, 137, 137);
|
||||
input.update(true, 58, 105, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::VerticalNext);
|
||||
|
||||
input.update(true, 110, 60, 137, 137);
|
||||
input.update(true, 40, 62, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
}
|
||||
|
||||
TEST(TouchInput, BottomSelectorHasLargeArrowTapTargets) {
|
||||
TouchInput input(true, true, 70);
|
||||
|
||||
input.update(true, 15, 125, 137, 137, true);
|
||||
input.update(true, 15, 125, 137, 137, true);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::None);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::VerticalPrevious);
|
||||
|
||||
input.update(true, 122, 125, 137, 137, true);
|
||||
input.update(true, 122, 125, 137, 137, true);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::None);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::VerticalNext);
|
||||
|
||||
input.update(true, 68, 125, 137, 137, true);
|
||||
input.update(true, 68, 125, 137, 137, true);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::None);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137, true),
|
||||
TouchAction::None);
|
||||
}
|
||||
|
||||
TEST(TouchInput, CanReverseSwipesWithoutReversingTapZones) {
|
||||
TouchInput input(true);
|
||||
|
||||
input.update(true, 110, 60, 137, 137);
|
||||
input.update(true, 40, 62, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
|
||||
input.update(true, 30, 60, 137, 137);
|
||||
input.update(true, 105, 58, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
input.update(true, 10, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
|
||||
input.update(true, 125, 60, 137, 137);
|
||||
input.update(true, 125, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
}
|
||||
|
||||
TEST(TouchInput, KeepsGestureAcrossOneMissingTouchSample) {
|
||||
TouchInput input;
|
||||
|
||||
input.update(true, 110, 60, 137, 137);
|
||||
input.update(true, 80, 61, 137, 137);
|
||||
EXPECT_EQ(input.update(false, -1, -1, 137, 137), TouchAction::None);
|
||||
input.update(true, 40, 62, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Next);
|
||||
}
|
||||
|
||||
TEST(TouchInput, IgnoresContactWithNoDirectionSample) {
|
||||
TouchInput input;
|
||||
|
||||
input.update(true, 120, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::None);
|
||||
}
|
||||
|
||||
TEST(TouchInput, UsesStartZoneForShortAmbiguousMovement) {
|
||||
TouchInput input(true);
|
||||
|
||||
input.update(true, 40, 60, 137, 137);
|
||||
input.update(true, 52, 60, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
}
|
||||
|
||||
TEST(TouchInput, RepeatedSwipesKeepTheSameDirection) {
|
||||
TouchInput input(true);
|
||||
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
input.update(true, 110, 60, 137, 137);
|
||||
input.update(true, 72, 61, 137, 137);
|
||||
input.update(true, 35, 62, 137, 137);
|
||||
EXPECT_EQ(release(input), TouchAction::Previous);
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
Copyright 2013 Google LLC
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font Software,
|
||||
subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,35 @@
|
||||
# SenseCAP Indicator SD font
|
||||
|
||||
`generate_font.py` creates the native-resolution font used by the SenseCAP
|
||||
Indicator. Text is Noto Sans Mono Bold rendered into an 18x24 monospaced cell.
|
||||
Every text pixel is binary (`0` or `255`), so the font remains sharp and has no
|
||||
antialiasing.
|
||||
|
||||
The complete Unicode Emoji 17.0 RGI set is rendered from Noto Color Emoji into
|
||||
12x12 RGB332 cells. Transparency is binary, and each cell is enlarged 2x with
|
||||
nearest-neighbor scaling for a crisp 24x24 result on the 480x480 panel. Emoji
|
||||
sequences are mapped into the BMP private-use range by a trie appended to the
|
||||
font file because the display library's runtime font interface accepts 16-bit
|
||||
glyph identifiers. The private-use glyph is intercepted at draw time and the
|
||||
corresponding color cell is overlaid after the UI canvas is copied.
|
||||
On later UI refreshes, unchanged emoji pixels are excluded from the indexed
|
||||
canvas transfer. This preserves the already-presented color cell and avoids a
|
||||
visible fallback-to-color flash.
|
||||
|
||||
The generated `ui-font.vlw` is stored on the Indicator's SD card. The RP2040
|
||||
font service streams it to the ESP32-S3 at boot; the application falls back to
|
||||
its built-in font if the card, service, file, checksum, or mapping is invalid.
|
||||
|
||||
To reproduce the checked-in asset:
|
||||
|
||||
```bash
|
||||
python3 -m venv /tmp/meshcore-font-venv
|
||||
/tmp/meshcore-font-venv/bin/pip install -r tools/sensecap_indicator_font/requirements.txt
|
||||
/tmp/meshcore-font-venv/bin/python tools/sensecap_indicator_font/generate_font.py \
|
||||
--output variants/sensecap_indicator-espnow/sd/ui-font.vlw \
|
||||
--preview /tmp/meshcore-indicator-font-preview.png
|
||||
```
|
||||
|
||||
The source fonts are licensed under the SIL Open Font License 1.1; see
|
||||
`OFL-1.1.txt`. The Unicode data source, pinned color-font revision, and exact
|
||||
input hashes are recorded in the generated JSON manifest.
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Indicator's native-resolution text and color emoji font."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import urllib.request
|
||||
import zlib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
SOURCES = {
|
||||
"NotoColorEmoji.ttf": (
|
||||
"https://raw.githubusercontent.com/googlefonts/noto-emoji/"
|
||||
"8998f5dd683424a73e2314a8c1f1e359c19e8742/fonts/"
|
||||
"NotoColorEmoji.ttf",
|
||||
"72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b",
|
||||
),
|
||||
"NotoSansMono-wdth-wght.ttf": (
|
||||
"https://raw.githubusercontent.com/google/fonts/main/ofl/notosansmono/"
|
||||
"NotoSansMono%5Bwdth%2Cwght%5D.ttf",
|
||||
"2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081",
|
||||
),
|
||||
"emoji-test-17.0.txt": (
|
||||
"https://www.unicode.org/Public/17.0.0/emoji/emoji-test.txt",
|
||||
"1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda",
|
||||
),
|
||||
}
|
||||
|
||||
TEXT_CELL_WIDTH = 18
|
||||
EMOJI_CELL_WIDTH = 12
|
||||
CELL_HEIGHT = 24
|
||||
EMOJI_CELL_HEIGHT = 12
|
||||
BASELINE = 18
|
||||
TEXT_NATIVE_SCALE = 3
|
||||
FIRST_EMOJI_GLYPH = 0xE000
|
||||
LAST_EMOJI_GLYPH = 0xF8FF
|
||||
FOOTER_MAGIC = b"MCEMAP2\0"
|
||||
ATLAS_MAGIC = b"CE01"
|
||||
TRANSPARENT_RGB332 = 0xE3
|
||||
PIXEL_OFF = 0
|
||||
PIXEL_ON = 255
|
||||
TEXT_THRESHOLD = 96
|
||||
EMOJI_ALPHA_THRESHOLD = 48
|
||||
EMOJI_REGRESSION_MIN_ON_PIXELS = {
|
||||
"\U0001f44b": 60, # waving hand
|
||||
"\U0001f642": 70, # slightly smiling face
|
||||
"\U0001f60a": 70, # smiling face with smiling eyes
|
||||
"\U0001f44d": 60, # thumbs up
|
||||
}
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
sha = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||||
sha.update(block)
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def fetch_sources(cache_dir: Path) -> dict[str, Path]:
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
paths: dict[str, Path] = {}
|
||||
for name, (url, expected_sha) in SOURCES.items():
|
||||
path = cache_dir / name
|
||||
if not path.exists() or digest(path) != expected_sha:
|
||||
print(f"Downloading {name}")
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
contents = response.read()
|
||||
if hashlib.sha256(contents).hexdigest() != expected_sha:
|
||||
raise RuntimeError(f"source checksum mismatch: {name}")
|
||||
path.write_bytes(contents)
|
||||
paths[name] = path
|
||||
return paths
|
||||
|
||||
|
||||
def parse_emoji_test(path: Path) -> tuple[list[tuple[str, int, str]], dict[bytes, int]]:
|
||||
glyphs: list[tuple[str, int, str]] = []
|
||||
aliases: dict[bytes, int] = {}
|
||||
last_output = 0
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
data, comment = line.split("#", 1)
|
||||
codepoints, status = data.split(";", 1)
|
||||
sequence = "".join(chr(int(value, 16)) for value in codepoints.split())
|
||||
status = status.strip()
|
||||
description_fields = comment.strip().split(" ", 2)
|
||||
description = description_fields[2] if len(description_fields) == 3 else sequence
|
||||
encoded = sequence.encode("utf-8")
|
||||
if status in ("fully-qualified", "component"):
|
||||
output = FIRST_EMOJI_GLYPH + len(glyphs)
|
||||
if output > LAST_EMOJI_GLYPH:
|
||||
raise RuntimeError("emoji glyphs exceed the BMP private-use range")
|
||||
glyphs.append((sequence, output, description))
|
||||
aliases[encoded] = output
|
||||
last_output = output
|
||||
elif status in ("minimally-qualified", "unqualified"):
|
||||
if last_output == 0:
|
||||
raise RuntimeError("emoji qualification alias has no canonical glyph")
|
||||
aliases[encoded] = last_output
|
||||
|
||||
# Accept clients that omit the emoji presentation selector even when the
|
||||
# qualification file does not list that spelling separately.
|
||||
for sequence, output, _ in glyphs:
|
||||
without_vs16 = sequence.replace("\ufe0f", "").encode("utf-8")
|
||||
aliases.setdefault(without_vs16, output)
|
||||
return glyphs, aliases
|
||||
|
||||
|
||||
def text_codepoints(font_path: Path) -> list[int]:
|
||||
font = TTFont(font_path, lazy=True)
|
||||
supported: set[int] = set()
|
||||
for table in font["cmap"].tables:
|
||||
if table.isUnicode():
|
||||
supported.update(table.cmap)
|
||||
font.close()
|
||||
|
||||
ranges = (
|
||||
(0x21, 0x7E), # ASCII (space is encoded as NBSP at runtime)
|
||||
(0x00A0, 0x024F), # Latin and phonetic extensions
|
||||
(0x0370, 0x052F), # Greek and Cyrillic
|
||||
(0x2000, 0x206F), # punctuation and format marks
|
||||
(0x20A0, 0x20CF), # currency
|
||||
(0x2100, 0x214F), # letter-like symbols
|
||||
(0x2190, 0x21FF), # arrows
|
||||
(0x2500, 0x259F), # box drawing, blocks, and geometric fill
|
||||
)
|
||||
return [
|
||||
codepoint
|
||||
for first, last in ranges
|
||||
for codepoint in range(first, last + 1)
|
||||
if codepoint in supported and not 0xE000 <= codepoint <= 0xF8FF
|
||||
]
|
||||
|
||||
|
||||
def make_binary(image: Image.Image, threshold: int) -> Image.Image:
|
||||
return image.point(
|
||||
lambda value: PIXEL_ON if value >= threshold else PIXEL_OFF,
|
||||
mode="L",
|
||||
)
|
||||
|
||||
|
||||
def render_text_glyph(font: ImageFont.FreeTypeFont, codepoint: int) -> bytes:
|
||||
image = Image.new("L", (TEXT_CELL_WIDTH, CELL_HEIGHT), 0)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text(
|
||||
(TEXT_CELL_WIDTH // 2, BASELINE - 1),
|
||||
chr(codepoint),
|
||||
font=font,
|
||||
fill=255,
|
||||
anchor="ms",
|
||||
)
|
||||
return make_binary(image, TEXT_THRESHOLD).tobytes()
|
||||
|
||||
|
||||
def render_empty_emoji_fallback(sequence: str) -> Image.Image:
|
||||
image = Image.new(
|
||||
"RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0)
|
||||
)
|
||||
if sequence == "\u2796":
|
||||
ImageDraw.Draw(image).line(
|
||||
(
|
||||
1,
|
||||
EMOJI_CELL_HEIGHT // 2,
|
||||
EMOJI_CELL_WIDTH - 2,
|
||||
EMOJI_CELL_HEIGHT // 2,
|
||||
),
|
||||
fill=(50, 120, 220, 255),
|
||||
width=2,
|
||||
)
|
||||
return image
|
||||
raise RuntimeError(f"emoji rendered blank: {sequence!r}")
|
||||
|
||||
|
||||
def render_emoji_image(font: ImageFont.FreeTypeFont, sequence: str) -> Image.Image:
|
||||
bbox = font.getbbox(sequence, anchor="lt")
|
||||
if bbox is None or bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||||
return render_empty_emoji_fallback(sequence)
|
||||
source = Image.new(
|
||||
"RGBA", (bbox[2] - bbox[0], bbox[3] - bbox[1]), (0, 0, 0, 0)
|
||||
)
|
||||
ImageDraw.Draw(source).text(
|
||||
(-bbox[0], -bbox[1]),
|
||||
sequence,
|
||||
font=font,
|
||||
embedded_color=True,
|
||||
)
|
||||
cropped_bbox = source.getchannel("A").getbbox()
|
||||
if cropped_bbox is None:
|
||||
return render_empty_emoji_fallback(sequence)
|
||||
source = source.crop(cropped_bbox)
|
||||
scale = min(
|
||||
EMOJI_CELL_WIDTH / source.width,
|
||||
EMOJI_CELL_HEIGHT / source.height,
|
||||
)
|
||||
width = max(1, round(source.width * scale))
|
||||
height = max(1, round(source.height * scale))
|
||||
source = source.resize((width, height), Image.Resampling.LANCZOS)
|
||||
image = Image.new(
|
||||
"RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT), (0, 0, 0, 0)
|
||||
)
|
||||
image.paste(
|
||||
source,
|
||||
(
|
||||
(EMOJI_CELL_WIDTH - width) // 2,
|
||||
(EMOJI_CELL_HEIGHT - height) // 2,
|
||||
),
|
||||
source,
|
||||
)
|
||||
if image.getchannel("A").getbbox() is None:
|
||||
return render_empty_emoji_fallback(sequence)
|
||||
return image
|
||||
|
||||
|
||||
def pack_rgb332(image: Image.Image) -> bytes:
|
||||
packed = bytearray()
|
||||
for red, green, blue, alpha in image.getdata():
|
||||
if alpha < EMOJI_ALPHA_THRESHOLD:
|
||||
packed.append(TRANSPARENT_RGB332)
|
||||
continue
|
||||
value = (red & 0xE0) | ((green >> 3) & 0x1C) | (blue >> 6)
|
||||
if value == TRANSPARENT_RGB332:
|
||||
value ^= 1
|
||||
packed.append(value)
|
||||
return bytes(packed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrieNode:
|
||||
children: dict[int, "TrieNode"] = field(default_factory=dict)
|
||||
output: int = 0
|
||||
index: int = 0
|
||||
|
||||
|
||||
def build_map(aliases: dict[bytes, int]) -> bytes:
|
||||
root = TrieNode()
|
||||
for sequence, output in sorted(aliases.items()):
|
||||
node = root
|
||||
for value in sequence:
|
||||
node = node.children.setdefault(value, TrieNode())
|
||||
if node.output not in (0, output):
|
||||
raise RuntimeError("conflicting emoji aliases")
|
||||
node.output = output
|
||||
|
||||
nodes: list[TrieNode] = [root]
|
||||
for node in nodes:
|
||||
for _, child in sorted(node.children.items()):
|
||||
if child.index == 0 and child is not root:
|
||||
child.index = len(nodes)
|
||||
nodes.append(child)
|
||||
if len(nodes) > 0xFFFF:
|
||||
raise RuntimeError("emoji trie has too many nodes")
|
||||
|
||||
node_records = bytearray()
|
||||
edge_records = bytearray()
|
||||
edge_index = 0
|
||||
for node in nodes:
|
||||
children = sorted(node.children.items())
|
||||
node_records += struct.pack("<HHH", edge_index, len(children), node.output)
|
||||
for value, child in children:
|
||||
edge_records += struct.pack("<BH", value, child.index)
|
||||
edge_index += len(children)
|
||||
if edge_index > 0xFFFF:
|
||||
raise RuntimeError("emoji trie has too many edges")
|
||||
|
||||
return (
|
||||
b"EM01"
|
||||
+ struct.pack("<HHI", len(nodes), edge_index, 0)
|
||||
+ node_records
|
||||
+ edge_records
|
||||
)
|
||||
|
||||
|
||||
def glyph_record(codepoint: int, width: int) -> bytes:
|
||||
return struct.pack(
|
||||
">7I",
|
||||
codepoint,
|
||||
CELL_HEIGHT,
|
||||
width,
|
||||
width,
|
||||
BASELINE,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def rgb332_to_rgba(value: int) -> tuple[int, int, int, int]:
|
||||
if value == TRANSPARENT_RGB332:
|
||||
return (0, 0, 0, 0)
|
||||
red = (value >> 5) & 7
|
||||
green = (value >> 2) & 7
|
||||
blue = value & 3
|
||||
return (
|
||||
(red * 255 + 3) // 7,
|
||||
(green * 255 + 3) // 7,
|
||||
(blue * 255 + 1) // 3,
|
||||
255,
|
||||
)
|
||||
|
||||
|
||||
def make_preview(path: Path, emoji_bitmaps: list[tuple[str, bytes]]) -> None:
|
||||
scale = 6
|
||||
columns = 16
|
||||
rows = (len(emoji_bitmaps) + columns - 1) // columns
|
||||
preview = Image.new(
|
||||
"RGBA",
|
||||
(
|
||||
columns * EMOJI_CELL_WIDTH * scale,
|
||||
rows * EMOJI_CELL_HEIGHT * scale,
|
||||
),
|
||||
(28, 28, 28, 255),
|
||||
)
|
||||
for index, (_, bitmap) in enumerate(emoji_bitmaps):
|
||||
glyph = Image.new("RGBA", (EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT))
|
||||
glyph.putdata([rgb332_to_rgba(value) for value in bitmap])
|
||||
glyph = glyph.resize(
|
||||
(EMOJI_CELL_WIDTH * scale, EMOJI_CELL_HEIGHT * scale),
|
||||
Image.Resampling.NEAREST,
|
||||
)
|
||||
x = (index % columns) * EMOJI_CELL_WIDTH * scale
|
||||
y = (index // columns) * EMOJI_CELL_HEIGHT * scale
|
||||
preview.alpha_composite(glyph, (x, y))
|
||||
preview.save(path)
|
||||
|
||||
|
||||
def build_font(output: Path, cache_dir: Path, preview: Path | None) -> None:
|
||||
sources = fetch_sources(cache_dir)
|
||||
emoji_glyphs, aliases = parse_emoji_test(sources["emoji-test-17.0.txt"])
|
||||
text_points = text_codepoints(sources["NotoSansMono-wdth-wght.ttf"])
|
||||
|
||||
text_font = ImageFont.truetype(
|
||||
str(sources["NotoSansMono-wdth-wght.ttf"]), 20
|
||||
)
|
||||
text_font.set_variation_by_name("Bold")
|
||||
emoji_font = ImageFont.truetype(str(sources["NotoColorEmoji.ttf"]), 109)
|
||||
|
||||
glyphs: list[tuple[int, int, bytes]] = [
|
||||
(
|
||||
codepoint,
|
||||
TEXT_CELL_WIDTH,
|
||||
render_text_glyph(text_font, codepoint),
|
||||
)
|
||||
for codepoint in text_points
|
||||
]
|
||||
rendered_emoji: list[tuple[str, bytes]] = []
|
||||
color_emoji: list[bytes] = []
|
||||
for index, (sequence, _, _) in enumerate(emoji_glyphs, start=1):
|
||||
bitmap = pack_rgb332(render_emoji_image(emoji_font, sequence))
|
||||
minimum_pixels = EMOJI_REGRESSION_MIN_ON_PIXELS.get(sequence)
|
||||
if minimum_pixels is not None:
|
||||
on_pixels = sum(pixel != TRANSPARENT_RGB332 for pixel in bitmap)
|
||||
if on_pixels < minimum_pixels:
|
||||
raise RuntimeError(
|
||||
f"emoji lost identifying pixels: {sequence!r} "
|
||||
f"has {on_pixels}, expected at least {minimum_pixels}"
|
||||
)
|
||||
color_emoji.append(bitmap)
|
||||
if preview is not None and (index <= 128 or index % 97 == 0):
|
||||
rendered_emoji.append((sequence, bitmap))
|
||||
if index % 500 == 0:
|
||||
print(f"Rendered {index}/{len(emoji_glyphs)} emoji")
|
||||
|
||||
if any(
|
||||
pixel not in (PIXEL_OFF, PIXEL_ON)
|
||||
for _, _, bitmap in glyphs
|
||||
for pixel in bitmap
|
||||
):
|
||||
raise RuntimeError("font contains antialiased pixels")
|
||||
|
||||
glyphs.sort(key=lambda item: item[0])
|
||||
header = struct.pack(">6I", len(glyphs), 11, CELL_HEIGHT, 0, BASELINE, 1)
|
||||
records = b"".join(
|
||||
glyph_record(codepoint, width) for codepoint, width, _ in glyphs
|
||||
)
|
||||
bitmaps = b"".join(bitmap for _, _, bitmap in glyphs)
|
||||
mapping = build_map(aliases)
|
||||
map_offset = len(header) + len(records) + len(bitmaps)
|
||||
atlas_header = ATLAS_MAGIC + struct.pack(
|
||||
"<HBBBBH",
|
||||
len(color_emoji),
|
||||
EMOJI_CELL_WIDTH,
|
||||
EMOJI_CELL_HEIGHT,
|
||||
TRANSPARENT_RGB332,
|
||||
TEXT_NATIVE_SCALE,
|
||||
EMOJI_CELL_WIDTH * EMOJI_CELL_HEIGHT,
|
||||
)
|
||||
atlas = atlas_header + b"".join(color_emoji)
|
||||
atlas_offset = map_offset + len(mapping)
|
||||
footer = FOOTER_MAGIC + struct.pack(
|
||||
"<IIIIIIII",
|
||||
map_offset,
|
||||
len(mapping),
|
||||
zlib.crc32(mapping),
|
||||
atlas_offset,
|
||||
len(atlas),
|
||||
zlib.crc32(atlas),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
contents = header + records + bitmaps + mapping + atlas + footer
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_bytes(contents)
|
||||
manifest = {
|
||||
"format": "MeshCore SD UI font 2",
|
||||
"text_cell": [TEXT_CELL_WIDTH, CELL_HEIGHT],
|
||||
"emoji_cell": [EMOJI_CELL_WIDTH, EMOJI_CELL_HEIGHT],
|
||||
"pixel_font": True,
|
||||
"antialiasing": False,
|
||||
"text_pixel_values": [PIXEL_OFF, PIXEL_ON],
|
||||
"emoji_encoding": {
|
||||
"color": "RGB332",
|
||||
"transparent_key": TRANSPARENT_RGB332,
|
||||
"alpha_threshold": EMOJI_ALPHA_THRESHOLD,
|
||||
},
|
||||
"text_source": "Noto Sans Mono Bold, binary-thresholded",
|
||||
"text_native_scale": TEXT_NATIVE_SCALE,
|
||||
"unicode_emoji_version": "17.0",
|
||||
"text_glyphs": len(text_points),
|
||||
"emoji_glyphs": len(emoji_glyphs),
|
||||
"emoji_aliases": len(aliases),
|
||||
"trie_bytes": len(mapping),
|
||||
"color_atlas_bytes": len(atlas),
|
||||
"font_bytes": len(contents),
|
||||
"sha256": hashlib.sha256(contents).hexdigest(),
|
||||
"sources": {name: expected for name, (_, expected) in SOURCES.items()},
|
||||
}
|
||||
output.with_suffix(output.suffix + ".json").write_text(
|
||||
json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
if preview is not None:
|
||||
make_preview(preview, rendered_emoji)
|
||||
print(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--cache-dir",
|
||||
type=Path,
|
||||
default=Path.home() / ".cache" / "meshcore-indicator-font",
|
||||
)
|
||||
parser.add_argument("--preview", type=Path)
|
||||
args = parser.parse_args()
|
||||
build_font(args.output, args.cache_dir, args.preview)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
fonttools==4.59.1
|
||||
Pillow==10.2.0
|
||||
@@ -0,0 +1,33 @@
|
||||
# SenseCAP Indicator SD font service
|
||||
|
||||
The Indicator connects its SD card to the RP2040 rather than the ESP32-S3.
|
||||
This small RP2040 image owns the card and streams `/meshcore/ui-font.vlw` to
|
||||
the main application over the internal UART at boot. The ESP32-S3 verifies the
|
||||
whole-file CRC and falls back to its built-in font if the service, card, file,
|
||||
or emoji map is unavailable or invalid.
|
||||
|
||||
Build and flash the service through the RP2040 USB connector:
|
||||
|
||||
```bash
|
||||
pio run -d tools/sensecap_indicator_rp2040
|
||||
pio run -d tools/sensecap_indicator_rp2040 -t upload --upload-port /dev/ttyACM2
|
||||
```
|
||||
|
||||
Then upload the generated font to the SD card through that same USB serial
|
||||
port. The update is checksummed and uses temporary and backup files so an
|
||||
interrupted upload keeps the last complete font:
|
||||
|
||||
```bash
|
||||
python3 -m pip install pyserial
|
||||
python3 tools/sensecap_indicator_rp2040/upload_font.py --port /dev/ttyACM2
|
||||
```
|
||||
|
||||
The checked-in asset contains binary 18x24 Noto Sans Mono Bold text and a
|
||||
12x12 RGB332 Unicode Emoji 17.0 color atlas. Text and emoji transparency have
|
||||
no antialiasing; the ESP32-S3 renders the asset at the panel's native 480x480
|
||||
resolution. See
|
||||
`../sensecap_indicator_font/README.md` to regenerate the asset.
|
||||
|
||||
For diagnostics, send `MCFONT STATUS` over RP2040 USB serial. The response
|
||||
reports internal-UART INFO requests, GET attempts, completed streams, and
|
||||
bytes sent since the RP2040 last booted.
|
||||
@@ -0,0 +1,13 @@
|
||||
[platformio]
|
||||
default_envs = sensecap_indicator_rp2040_font
|
||||
src_dir = src
|
||||
|
||||
[env:sensecap_indicator_rp2040_font]
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git#4e22a0dcdd94eead72fdf56ea59a3d7c8aa2f379
|
||||
board = seeed_indicator_rp2040
|
||||
framework = arduino
|
||||
monitor_speed = 115200
|
||||
upload_protocol = picotool
|
||||
build_flags =
|
||||
-Wall
|
||||
-Wextra
|
||||
@@ -0,0 +1,385 @@
|
||||
#include <Arduino.h>
|
||||
#include <SD.h>
|
||||
#include <SPI.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t FONT_UART_BAUD = 1000000;
|
||||
constexpr uint32_t SD_CLOCK_HZ = 1000000;
|
||||
constexpr uint32_t RECEIVE_IDLE_TIMEOUT_MS = 10000;
|
||||
constexpr size_t MAX_FONT_BYTES = 1536 * 1024;
|
||||
|
||||
constexpr int FONT_UART_TX = 16;
|
||||
constexpr int FONT_UART_RX = 17;
|
||||
constexpr int SD_SCK = 10;
|
||||
constexpr int SD_MOSI = 11;
|
||||
constexpr int SD_MISO = 12;
|
||||
constexpr int SD_CS = 13;
|
||||
|
||||
constexpr const char* FONT_DIRECTORY = "/meshcore";
|
||||
constexpr const char* FONT_PATH = "/meshcore/ui-font.vlw";
|
||||
constexpr const char* FONT_META_PATH = "/meshcore/ui-font.meta";
|
||||
constexpr const char* TEMP_FONT_PATH = "/meshcore/ui-font.tmp";
|
||||
constexpr const char* TEMP_META_PATH = "/meshcore/ui-font-meta.tmp";
|
||||
constexpr const char* BACKUP_FONT_PATH = "/meshcore/ui-font.bak";
|
||||
constexpr const char* BACKUP_META_PATH = "/meshcore/ui-font-meta.bak";
|
||||
|
||||
bool sdReady = false;
|
||||
size_t fontSize = 0;
|
||||
uint32_t fontCrc = 0;
|
||||
uint32_t espInfoRequests = 0;
|
||||
uint32_t espGetAttempts = 0;
|
||||
uint32_t espGetCompleted = 0;
|
||||
size_t espLastBytes = 0;
|
||||
|
||||
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool readLine(File& file, char* line, size_t capacity) {
|
||||
size_t length = 0;
|
||||
while (file.available()) {
|
||||
int value = file.read();
|
||||
if (value < 0) break;
|
||||
if (value == '\n') {
|
||||
line[length] = 0;
|
||||
return true;
|
||||
}
|
||||
if (value != '\r' && length + 1 < capacity) {
|
||||
line[length++] = (char)value;
|
||||
}
|
||||
}
|
||||
line[length] = 0;
|
||||
return length != 0;
|
||||
}
|
||||
|
||||
bool parseMetadata(const char* line, size_t& size, uint32_t& crc) {
|
||||
unsigned long parsedSize = 0;
|
||||
unsigned long parsedCrc = 0;
|
||||
if (sscanf(line, "MCFONT 1 %lu %lx", &parsedSize, &parsedCrc) != 2
|
||||
|| parsedSize < 64
|
||||
|| parsedSize > MAX_FONT_BYTES) {
|
||||
return false;
|
||||
}
|
||||
size = (size_t)parsedSize;
|
||||
crc = (uint32_t)parsedCrc;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool validatePair(const char* fontPath, const char* metadataPath,
|
||||
size_t& size, uint32_t& crc) {
|
||||
File metadata = SD.open(metadataPath, FILE_READ);
|
||||
if (!metadata) return false;
|
||||
|
||||
char line[64];
|
||||
bool valid = readLine(metadata, line, sizeof(line))
|
||||
&& parseMetadata(line, size, crc);
|
||||
metadata.close();
|
||||
if (!valid) return false;
|
||||
|
||||
File font = SD.open(fontPath, FILE_READ);
|
||||
if (!font) return false;
|
||||
valid = font.size() == size;
|
||||
font.close();
|
||||
return valid;
|
||||
}
|
||||
|
||||
void removeIfPresent(const char* path) {
|
||||
if (SD.exists(path)) SD.remove(path);
|
||||
}
|
||||
|
||||
void cleanTransactionFiles() {
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
removeIfPresent(TEMP_META_PATH);
|
||||
removeIfPresent(BACKUP_FONT_PATH);
|
||||
removeIfPresent(BACKUP_META_PATH);
|
||||
}
|
||||
|
||||
bool promotePair(const char* sourceFont, const char* sourceMetadata) {
|
||||
removeIfPresent(FONT_PATH);
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (!SD.rename(sourceFont, FONT_PATH)) return false;
|
||||
if (SD.rename(sourceMetadata, FONT_META_PATH)) return true;
|
||||
removeIfPresent(FONT_PATH);
|
||||
return false;
|
||||
}
|
||||
|
||||
void recoverFontTransaction() {
|
||||
size_t size;
|
||||
uint32_t crc;
|
||||
if (validatePair(FONT_PATH, FONT_META_PATH, size, crc)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// A reset between the two final renames can leave the new font next to its
|
||||
// temporary metadata. Complete that transaction when the sizes still agree.
|
||||
if (validatePair(FONT_PATH, TEMP_META_PATH, size, crc)) {
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
if (SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (validatePair(TEMP_FONT_PATH, TEMP_META_PATH, size, crc)
|
||||
&& promotePair(TEMP_FONT_PATH, TEMP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (validatePair(BACKUP_FONT_PATH, BACKUP_META_PATH, size, crc)
|
||||
&& promotePair(BACKUP_FONT_PATH, BACKUP_META_PATH)) {
|
||||
cleanTransactionFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
removeIfPresent(FONT_PATH);
|
||||
removeIfPresent(FONT_META_PATH);
|
||||
cleanTransactionFiles();
|
||||
}
|
||||
|
||||
bool refreshFontInfo() {
|
||||
fontSize = 0;
|
||||
fontCrc = 0;
|
||||
return sdReady && validatePair(FONT_PATH, FONT_META_PATH, fontSize, fontCrc);
|
||||
}
|
||||
|
||||
void sendInfo(Print& output, bool trackEspRequest) {
|
||||
if (trackEspRequest) ++espInfoRequests;
|
||||
if (!refreshFontInfo()) {
|
||||
output.print("MCFONT 0 0 00000000\n");
|
||||
return;
|
||||
}
|
||||
output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize,
|
||||
(unsigned long)fontCrc);
|
||||
}
|
||||
|
||||
void sendFont(Print& output, bool trackEspRequest) {
|
||||
if (trackEspRequest) {
|
||||
++espGetAttempts;
|
||||
espLastBytes = 0;
|
||||
}
|
||||
if (!refreshFontInfo()) {
|
||||
output.print("MCFONT 0 0 00000000\n");
|
||||
return;
|
||||
}
|
||||
|
||||
File font = SD.open(FONT_PATH, FILE_READ);
|
||||
if (!font) {
|
||||
output.print("MCFONT 0 0 00000000\n");
|
||||
return;
|
||||
}
|
||||
|
||||
output.printf("MCFONT 1 %lu %08lx\n", (unsigned long)fontSize,
|
||||
(unsigned long)fontCrc);
|
||||
uint8_t buffer[512];
|
||||
size_t sent = 0;
|
||||
while (font.available()) {
|
||||
size_t count = font.read(buffer, sizeof(buffer));
|
||||
if (count == 0) break;
|
||||
size_t written = output.write(buffer, count);
|
||||
sent += written;
|
||||
if (written != count) break;
|
||||
}
|
||||
font.close();
|
||||
if (trackEspRequest) {
|
||||
espLastBytes = sent;
|
||||
if (sent == fontSize) ++espGetCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
bool writeMetadata(const char* path, size_t size, uint32_t crc) {
|
||||
removeIfPresent(path);
|
||||
File metadata = SD.open(path, "w");
|
||||
if (!metadata) return false;
|
||||
metadata.printf("MCFONT 1 %lu %08lx\n", (unsigned long)size,
|
||||
(unsigned long)crc);
|
||||
metadata.flush();
|
||||
metadata.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool moveCurrentToBackup() {
|
||||
removeIfPresent(BACKUP_FONT_PATH);
|
||||
removeIfPresent(BACKUP_META_PATH);
|
||||
|
||||
bool movedFont = false;
|
||||
if (SD.exists(FONT_PATH)) {
|
||||
if (!SD.rename(FONT_PATH, BACKUP_FONT_PATH)) return false;
|
||||
movedFont = true;
|
||||
}
|
||||
if (SD.exists(FONT_META_PATH)
|
||||
&& !SD.rename(FONT_META_PATH, BACKUP_META_PATH)) {
|
||||
if (movedFont) SD.rename(BACKUP_FONT_PATH, FONT_PATH);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool installTemporaryPair(size_t expectedSize, uint32_t expectedCrc) {
|
||||
if (!moveCurrentToBackup()) return false;
|
||||
if (!SD.rename(TEMP_FONT_PATH, FONT_PATH)) {
|
||||
recoverFontTransaction();
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
}
|
||||
if (!SD.rename(TEMP_META_PATH, FONT_META_PATH)) {
|
||||
recoverFontTransaction();
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
}
|
||||
cleanTransactionFiles();
|
||||
return refreshFontInfo() && fontSize == expectedSize && fontCrc == expectedCrc;
|
||||
}
|
||||
|
||||
void receiveFont(Stream& input, Print& output, size_t expectedSize,
|
||||
uint32_t expectedCrc) {
|
||||
if (!sdReady) {
|
||||
output.print("ERROR SD\n");
|
||||
return;
|
||||
}
|
||||
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
removeIfPresent(TEMP_META_PATH);
|
||||
File font = SD.open(TEMP_FONT_PATH, "w");
|
||||
if (!font) {
|
||||
output.print("ERROR OPEN\n");
|
||||
return;
|
||||
}
|
||||
|
||||
output.print("READY\n");
|
||||
uint8_t buffer[512];
|
||||
size_t received = 0;
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
uint32_t lastProgress = millis();
|
||||
bool writeFailed = false;
|
||||
while (received < expectedSize
|
||||
&& millis() - lastProgress < RECEIVE_IDLE_TIMEOUT_MS) {
|
||||
int available = input.available();
|
||||
if (available <= 0) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
size_t count = (size_t)available;
|
||||
if (count > sizeof(buffer)) count = sizeof(buffer);
|
||||
if (count > expectedSize - received) count = expectedSize - received;
|
||||
size_t actual = input.readBytes(buffer, count);
|
||||
if (actual == 0) continue;
|
||||
if (font.write(buffer, actual) != actual) {
|
||||
writeFailed = true;
|
||||
break;
|
||||
}
|
||||
crc = updateCrc32(crc, buffer, actual);
|
||||
received += actual;
|
||||
lastProgress = millis();
|
||||
}
|
||||
font.flush();
|
||||
font.close();
|
||||
|
||||
if (writeFailed || received != expectedSize || ~crc != expectedCrc) {
|
||||
removeIfPresent(TEMP_FONT_PATH);
|
||||
output.print(writeFailed ? "ERROR WRITE\n" : "ERROR CHECKSUM\n");
|
||||
return;
|
||||
}
|
||||
if (!writeMetadata(TEMP_META_PATH, expectedSize, expectedCrc)
|
||||
|| !installTemporaryPair(expectedSize, expectedCrc)) {
|
||||
recoverFontTransaction();
|
||||
output.print("ERROR INSTALL\n");
|
||||
return;
|
||||
}
|
||||
output.print("OK\n");
|
||||
}
|
||||
|
||||
struct CommandReader {
|
||||
Stream& input;
|
||||
Print& output;
|
||||
bool allowUpload;
|
||||
char line[96] = {};
|
||||
size_t length = 0;
|
||||
};
|
||||
|
||||
void processCommand(CommandReader& reader) {
|
||||
if (strcmp(reader.line, "MCFONT INFO") == 0) {
|
||||
sendInfo(reader.output, !reader.allowUpload);
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT STATUS") == 0) {
|
||||
reader.output.printf("MCFONT STATUS %lu %lu %lu %lu\n",
|
||||
(unsigned long)espInfoRequests,
|
||||
(unsigned long)espGetAttempts,
|
||||
(unsigned long)espGetCompleted,
|
||||
(unsigned long)espLastBytes);
|
||||
return;
|
||||
}
|
||||
if (strcmp(reader.line, "MCFONT GET") == 0) {
|
||||
sendFont(reader.output, !reader.allowUpload);
|
||||
return;
|
||||
}
|
||||
|
||||
unsigned long size = 0;
|
||||
unsigned long crc = 0;
|
||||
if (sscanf(reader.line, "MCFONT PUT %lu %lx", &size, &crc) == 2) {
|
||||
if (!reader.allowUpload) {
|
||||
reader.output.print("ERROR READONLY\n");
|
||||
} else if (size < 64 || size > MAX_FONT_BYTES) {
|
||||
reader.output.print("ERROR SIZE\n");
|
||||
} else {
|
||||
receiveFont(reader.input, reader.output, (size_t)size, (uint32_t)crc);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reader.output.print("ERROR COMMAND\n");
|
||||
}
|
||||
|
||||
void pollCommands(CommandReader& reader) {
|
||||
while (reader.input.available()) {
|
||||
int value = reader.input.read();
|
||||
if (value < 0) break;
|
||||
if (value == '\n') {
|
||||
reader.line[reader.length] = 0;
|
||||
processCommand(reader);
|
||||
reader.length = 0;
|
||||
} else if (value != '\r') {
|
||||
if (reader.length + 1 < sizeof(reader.line)) {
|
||||
reader.line[reader.length++] = (char)value;
|
||||
} else {
|
||||
reader.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CommandReader usbCommands = {Serial, Serial, true};
|
||||
CommandReader espCommands = {Serial1, Serial1, false};
|
||||
|
||||
} // namespace
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
Serial1.setRX(FONT_UART_RX);
|
||||
Serial1.setTX(FONT_UART_TX);
|
||||
Serial1.setFIFOSize(1024);
|
||||
Serial1.begin(FONT_UART_BAUD);
|
||||
|
||||
SPI1.setSCK(SD_SCK);
|
||||
SPI1.setTX(SD_MOSI);
|
||||
SPI1.setRX(SD_MISO);
|
||||
sdReady = SD.begin(SD_CS, SD_CLOCK_HZ, SPI1);
|
||||
if (sdReady) {
|
||||
if (!SD.exists(FONT_DIRECTORY)) SD.mkdir(FONT_DIRECTORY);
|
||||
recoverFontTransaction();
|
||||
refreshFontInfo();
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
pollCommands(espCommands);
|
||||
pollCommands(usbCommands);
|
||||
delay(1);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Upload a checked font asset to the Indicator RP2040 SD service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
|
||||
import serial
|
||||
|
||||
|
||||
MAX_FONT_BYTES = 1536 * 1024
|
||||
|
||||
|
||||
def read_line(port: serial.Serial, timeout: float) -> str:
|
||||
deadline = time.monotonic() + timeout
|
||||
line = bytearray()
|
||||
while time.monotonic() < deadline:
|
||||
value = port.read(1)
|
||||
if not value:
|
||||
continue
|
||||
if value == b"\n":
|
||||
return line.rstrip(b"\r").decode("ascii", errors="replace")
|
||||
line += value
|
||||
raise TimeoutError("font service did not respond")
|
||||
|
||||
|
||||
def upload(port_name: str, font_path: Path, timeout: float) -> None:
|
||||
data = font_path.read_bytes()
|
||||
if not 64 <= len(data) <= MAX_FONT_BYTES:
|
||||
raise ValueError(f"font size must be between 64 and {MAX_FONT_BYTES} bytes")
|
||||
crc = zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
with serial.Serial(
|
||||
port_name,
|
||||
115200,
|
||||
timeout=0.2,
|
||||
write_timeout=timeout,
|
||||
) as port:
|
||||
time.sleep(0.25)
|
||||
# Terminate any partial command left by an interrupted terminal or
|
||||
# uploader before beginning the transactional transfer.
|
||||
port.write(b"\n")
|
||||
port.flush()
|
||||
time.sleep(0.05)
|
||||
port.reset_input_buffer()
|
||||
port.write(f"MCFONT PUT {len(data)} {crc:08x}\n".encode("ascii"))
|
||||
port.flush()
|
||||
response = read_line(port, timeout)
|
||||
if response != "READY":
|
||||
raise RuntimeError(f"font service refused upload: {response}")
|
||||
|
||||
sent = 0
|
||||
while sent < len(data):
|
||||
sent += port.write(data[sent : sent + 4096])
|
||||
port.flush()
|
||||
|
||||
response = read_line(port, timeout)
|
||||
if response != "OK":
|
||||
raise RuntimeError(f"font service failed to install font: {response}")
|
||||
|
||||
port.write(b"MCFONT INFO\n")
|
||||
port.flush()
|
||||
expected = f"MCFONT 1 {len(data)} {crc:08x}"
|
||||
response = read_line(port, timeout)
|
||||
if response != expected:
|
||||
raise RuntimeError(
|
||||
f"font verification failed: expected {expected!r}, got {response!r}"
|
||||
)
|
||||
|
||||
print(f"Installed {font_path} ({len(data)} bytes, CRC32 {crc:08x})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--port", required=True, help="RP2040 USB serial port")
|
||||
parser.add_argument(
|
||||
"--font",
|
||||
type=Path,
|
||||
default=Path("variants/sensecap_indicator-espnow/sd/ui-font.vlw"),
|
||||
)
|
||||
parser.add_argument("--timeout", type=float, default=30.0)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
upload(args.port, args.font, args.timeout)
|
||||
except (OSError, ValueError, RuntimeError, TimeoutError, serial.SerialException) as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
#include "IndicatorFontClient.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <esp32-hal-psram.h>
|
||||
|
||||
namespace {
|
||||
|
||||
static const uint32_t FONT_UART_BAUD = 1000000;
|
||||
static const int FONT_UART_RX = 20;
|
||||
static const int FONT_UART_TX = 19;
|
||||
static const size_t MAX_FONT_BYTES = 1536 * 1024;
|
||||
|
||||
uint32_t updateCrc32(uint32_t crc, const uint8_t* data, size_t size) {
|
||||
for (size_t i = 0; i < size; ++i) {
|
||||
crc ^= data[i];
|
||||
for (uint8_t bit = 0; bit < 8; ++bit) {
|
||||
crc = (crc >> 1) ^ (0xEDB88320UL & (0U - (crc & 1U)));
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
bool readLine(HardwareSerial& serial, char* line, size_t capacity,
|
||||
uint32_t timeout_millis) {
|
||||
size_t length = 0;
|
||||
uint32_t started = millis();
|
||||
while (millis() - started < timeout_millis) {
|
||||
while (serial.available()) {
|
||||
int value = serial.read();
|
||||
if (value < 0) break;
|
||||
if (value == '\n') {
|
||||
line[length] = 0;
|
||||
return true;
|
||||
}
|
||||
if (value != '\r' && length + 1 < capacity) {
|
||||
line[length++] = (char)value;
|
||||
}
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
line[0] = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool parseInfo(const char* line, size_t& size, uint32_t& crc) {
|
||||
unsigned long parsed_size = 0;
|
||||
unsigned long parsed_crc = 0;
|
||||
int present = 0;
|
||||
if (sscanf(line, "MCFONT 1 %lu %lx", &parsed_size, &parsed_crc) != 2) {
|
||||
if (sscanf(line, "MCFONT %d", &present) == 1 && present == 0) return false;
|
||||
return false;
|
||||
}
|
||||
if (parsed_size < 64 || parsed_size > MAX_FONT_BYTES) return false;
|
||||
size = (size_t)parsed_size;
|
||||
crc = (uint32_t)parsed_crc;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool requestInfo(HardwareSerial& serial, size_t& size, uint32_t& crc) {
|
||||
char line[64];
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
while (serial.available()) serial.read();
|
||||
serial.print("MCFONT INFO\n");
|
||||
serial.flush();
|
||||
if (readLine(serial, line, sizeof(line), 1000)
|
||||
&& parseInfo(line, size, crc)) {
|
||||
return true;
|
||||
}
|
||||
delay(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool receiveFont(HardwareSerial& serial, uint8_t* data, size_t size,
|
||||
uint32_t expected_crc) {
|
||||
char line[64];
|
||||
serial.print("MCFONT GET\n");
|
||||
serial.flush();
|
||||
|
||||
size_t response_size;
|
||||
uint32_t response_crc;
|
||||
if (!readLine(serial, line, sizeof(line), 1500)
|
||||
|| !parseInfo(line, response_size, response_crc)
|
||||
|| response_size != size
|
||||
|| response_crc != expected_crc) {
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t received = 0;
|
||||
uint32_t crc = 0xFFFFFFFFUL;
|
||||
uint32_t last_progress = millis();
|
||||
while (received < size && millis() - last_progress < 2000) {
|
||||
int available = serial.available();
|
||||
if (available <= 0) {
|
||||
delay(1);
|
||||
continue;
|
||||
}
|
||||
size_t count = (size_t)available;
|
||||
if (count > size - received) count = size - received;
|
||||
size_t actual = serial.readBytes(data + received, count);
|
||||
if (actual == 0) continue;
|
||||
crc = updateCrc32(crc, data + received, actual);
|
||||
received += actual;
|
||||
last_progress = millis();
|
||||
}
|
||||
return received == size && ~crc == expected_crc;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
uint8_t* IndicatorFontClient::load(size_t& size) {
|
||||
size = 0;
|
||||
if (!psramFound()) return nullptr;
|
||||
|
||||
Serial2.begin(FONT_UART_BAUD, SERIAL_8N1, FONT_UART_RX, FONT_UART_TX);
|
||||
delay(50);
|
||||
|
||||
uint32_t expected_crc;
|
||||
if (!requestInfo(Serial2, size, expected_crc)) {
|
||||
Serial2.end();
|
||||
size = 0;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
uint8_t* data = (uint8_t*)ps_malloc(size);
|
||||
if (data == nullptr || !receiveFont(Serial2, data, size, expected_crc)) {
|
||||
free(data);
|
||||
data = nullptr;
|
||||
size = 0;
|
||||
}
|
||||
Serial2.end();
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
class IndicatorFontClient {
|
||||
public:
|
||||
static uint8_t* load(size_t& size);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
#include "IndicatorRadioHal.h"
|
||||
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
|
||||
IndicatorRadioHal::IndicatorRadioHal(SPIClass& spi) : ArduinoHal(spi) {}
|
||||
|
||||
bool IndicatorRadioHal::isExpanderPin(uint32_t pin) {
|
||||
return pin != RADIOLIB_NC && (pin & EXPANDER_FLAG) != 0;
|
||||
}
|
||||
|
||||
uint8_t IndicatorRadioHal::expanderIndex(uint32_t pin) {
|
||||
return static_cast<uint8_t>(pin & ~EXPANDER_FLAG);
|
||||
}
|
||||
|
||||
bool IndicatorRadioHal::readRegister(uint8_t reg, uint16_t& value) {
|
||||
uint8_t data[2];
|
||||
if (!lgfx::i2c::readRegister(I2C_PORT, EXPANDER_ADDRESS, reg, data,
|
||||
sizeof(data), I2C_FREQUENCY)
|
||||
.has_value()) {
|
||||
return false;
|
||||
}
|
||||
value = data[0];
|
||||
value |= static_cast<uint16_t>(data[1]) << 8;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IndicatorRadioHal::writeRegister(uint8_t reg, uint16_t value) {
|
||||
uint8_t data[] = {
|
||||
reg,
|
||||
static_cast<uint8_t>(value),
|
||||
static_cast<uint8_t>(value >> 8),
|
||||
};
|
||||
return lgfx::i2c::transactionWrite(I2C_PORT, EXPANDER_ADDRESS, data,
|
||||
sizeof(data), I2C_FREQUENCY)
|
||||
.has_value();
|
||||
}
|
||||
|
||||
bool IndicatorRadioHal::beginExpander() {
|
||||
if (_expanderReady) return true;
|
||||
|
||||
uint16_t inputs;
|
||||
if (!readRegister(OUTPUT_REGISTER, _output)
|
||||
|| !readRegister(CONFIG_REGISTER, _config)
|
||||
|| !readRegister(INPUT_REGISTER, inputs)) {
|
||||
// The display normally initializes this shared bus first. Reinitialize it
|
||||
// here as a fallback when display or touch probing ended early.
|
||||
if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value()
|
||||
|| !readRegister(OUTPUT_REGISTER, _output)
|
||||
|| !readRegister(CONFIG_REGISTER, _config)
|
||||
|| !readRegister(INPUT_REGISTER, inputs)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
::pinMode(EXPANDER_INTERRUPT_GPIO, INPUT_PULLUP);
|
||||
_irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
|
||||
_expanderReady = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IndicatorRadioHal::readInputs(uint16_t& value) {
|
||||
if (!beginExpander() || !readRegister(INPUT_REGISTER, value)) return false;
|
||||
processInterruptLevel(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::processInterruptLevel(uint16_t inputs) {
|
||||
bool level = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
|
||||
if (level == _irqLevel) return;
|
||||
|
||||
_irqLevel = level;
|
||||
if (_irqCallback == nullptr) return;
|
||||
if ((_irqMode == GpioInterruptRising && level)
|
||||
|| (_irqMode == GpioInterruptFalling && !level)) {
|
||||
_irqCallback();
|
||||
}
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::serviceInterrupt() {
|
||||
if (_irqCallback == nullptr || !_expanderReady
|
||||
|| ::digitalRead(EXPANDER_INTERRUPT_GPIO) != LOW) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t inputs;
|
||||
readInputs(inputs);
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::pinMode(uint32_t pin, uint32_t mode) {
|
||||
if (!isExpanderPin(pin)) {
|
||||
ArduinoHal::pinMode(pin, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t index = expanderIndex(pin);
|
||||
if (index >= 16 || !beginExpander()) return;
|
||||
uint16_t next = _config;
|
||||
if (mode == GpioModeOutput) {
|
||||
next &= ~(1U << index);
|
||||
} else {
|
||||
next |= 1U << index;
|
||||
}
|
||||
if (next != _config && writeRegister(CONFIG_REGISTER, next)) _config = next;
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::digitalWrite(uint32_t pin, uint32_t value) {
|
||||
if (!isExpanderPin(pin)) {
|
||||
ArduinoHal::digitalWrite(pin, value);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t index = expanderIndex(pin);
|
||||
if (index >= 16 || !beginExpander()) return;
|
||||
uint16_t next = value == GpioLevelHigh ? _output | (1U << index)
|
||||
: _output & ~(1U << index);
|
||||
if (next != _output && writeRegister(OUTPUT_REGISTER, next)) _output = next;
|
||||
}
|
||||
|
||||
uint32_t IndicatorRadioHal::digitalRead(uint32_t pin) {
|
||||
if (!isExpanderPin(pin)) return ArduinoHal::digitalRead(pin);
|
||||
|
||||
uint8_t index = expanderIndex(pin);
|
||||
uint16_t inputs;
|
||||
if (index >= 16 || !readInputs(inputs)) {
|
||||
// A failed BUSY read must prevent an unsafe SPI command. Other failed
|
||||
// reads are treated as inactive.
|
||||
return pin == P_LORA_BUSY ? GpioLevelHigh : GpioLevelLow;
|
||||
}
|
||||
return (inputs & (1U << index)) != 0 ? GpioLevelHigh : GpioLevelLow;
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::attachInterrupt(uint32_t interrupt_num,
|
||||
void (*callback)(void), uint32_t mode) {
|
||||
if (!isExpanderPin(interrupt_num)) {
|
||||
ArduinoHal::attachInterrupt(interrupt_num, callback, mode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanderIndex(interrupt_num) != RADIO_IRQ_INDEX
|
||||
|| !beginExpander()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_irqCallback = callback;
|
||||
_irqMode = mode;
|
||||
uint16_t inputs;
|
||||
if (readRegister(INPUT_REGISTER, inputs)) {
|
||||
_irqLevel = (inputs & (1U << RADIO_IRQ_INDEX)) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
void IndicatorRadioHal::detachInterrupt(uint32_t interrupt_num) {
|
||||
if (!isExpanderPin(interrupt_num)) {
|
||||
ArduinoHal::detachInterrupt(interrupt_num);
|
||||
return;
|
||||
}
|
||||
if (expanderIndex(interrupt_num) == RADIO_IRQ_INDEX) {
|
||||
_irqCallback = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t IndicatorRadioHal::pinToInterrupt(uint32_t pin) {
|
||||
return isExpanderPin(pin) ? pin : ArduinoHal::pinToInterrupt(pin);
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
#include <hal/Arduino/ArduinoHal.h>
|
||||
|
||||
// RadioLib HAL for the Indicator's SX1262. Four radio signals are routed
|
||||
// through a TCA9535 instead of ordinary ESP32 GPIOs. The expander's shared,
|
||||
// active-low interrupt output is polled from normal task context so I2C is
|
||||
// never touched by an ISR.
|
||||
class IndicatorRadioHal : public ArduinoHal {
|
||||
public:
|
||||
explicit IndicatorRadioHal(SPIClass& spi);
|
||||
|
||||
bool beginExpander();
|
||||
void serviceInterrupt();
|
||||
|
||||
void pinMode(uint32_t pin, uint32_t mode) override;
|
||||
void digitalWrite(uint32_t pin, uint32_t value) override;
|
||||
uint32_t digitalRead(uint32_t pin) override;
|
||||
void attachInterrupt(uint32_t interrupt_num, void (*callback)(void),
|
||||
uint32_t mode) override;
|
||||
void detachInterrupt(uint32_t interrupt_num) override;
|
||||
uint32_t pinToInterrupt(uint32_t pin) override;
|
||||
|
||||
private:
|
||||
static constexpr uint8_t EXPANDER_ADDRESS = 0x20;
|
||||
static constexpr uint8_t I2C_PORT = 0;
|
||||
static constexpr uint32_t I2C_FREQUENCY = 400000;
|
||||
static constexpr uint32_t EXPANDER_FLAG = 0x40;
|
||||
static constexpr uint8_t INPUT_REGISTER = 0x00;
|
||||
static constexpr uint8_t OUTPUT_REGISTER = 0x02;
|
||||
static constexpr uint8_t CONFIG_REGISTER = 0x06;
|
||||
static constexpr uint8_t RADIO_IRQ_INDEX = 3;
|
||||
static constexpr uint8_t EXPANDER_INTERRUPT_GPIO = 42;
|
||||
|
||||
bool _expanderReady = false;
|
||||
uint16_t _output = 0xFFFF;
|
||||
uint16_t _config = 0xFFFF;
|
||||
bool _irqLevel = false;
|
||||
void (*_irqCallback)(void) = nullptr;
|
||||
uint32_t _irqMode = RISING;
|
||||
|
||||
static bool isExpanderPin(uint32_t pin);
|
||||
static uint8_t expanderIndex(uint32_t pin);
|
||||
bool readRegister(uint8_t reg, uint16_t& value);
|
||||
bool writeRegister(uint8_t reg, uint16_t value);
|
||||
bool readInputs(uint16_t& value);
|
||||
void processInterruptLevel(uint16_t inputs);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include "IndicatorRadioHal.h"
|
||||
|
||||
class IndicatorSX1262Wrapper : public CustomSX1262Wrapper {
|
||||
public:
|
||||
IndicatorSX1262Wrapper(CustomSX1262& radio, mesh::MainBoard& board,
|
||||
IndicatorRadioHal& hal)
|
||||
: CustomSX1262Wrapper(radio, board), _hal(hal) {}
|
||||
|
||||
void loop() override {
|
||||
_hal.serviceInterrupt();
|
||||
CustomSX1262Wrapper::loop();
|
||||
}
|
||||
|
||||
private:
|
||||
IndicatorRadioHal& _hal;
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
#include <string.h>
|
||||
|
||||
// This FT5x06-compatible controller exposes valid point data at register 0x02,
|
||||
// but some units return zero for all optional identity registers. Probe the
|
||||
// point register instead of rejecting those units by vendor ID.
|
||||
class IndicatorTouch : public lgfx::Touch_FT5x06 {
|
||||
static constexpr size_t POINT_DATA_SIZE = 5;
|
||||
|
||||
bool readPointData(uint8_t* data, size_t size) {
|
||||
const uint8_t point_count_reg = 0x02;
|
||||
return lgfx::i2c::transactionWriteRead(
|
||||
_cfg.i2c_port, _cfg.i2c_addr, &point_count_reg, 1,
|
||||
data, size, _cfg.freq)
|
||||
.has_value();
|
||||
}
|
||||
|
||||
bool readStablePointData(uint8_t* data) {
|
||||
uint8_t samples[2][POINT_DATA_SIZE] = {};
|
||||
if (!readPointData(samples[0], sizeof(samples[0]))) return false;
|
||||
if ((samples[0][0] & 0x0F) == 0) {
|
||||
memcpy(data, samples[0], POINT_DATA_SIZE);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coordinates may change while the controller is being read. Match two
|
||||
// consecutive snapshots, as the standard driver does, so X/Y bytes from
|
||||
// different positions cannot reverse a gesture. If the finger keeps
|
||||
// moving through every retry, use the newest complete snapshot.
|
||||
uint8_t current = 0;
|
||||
for (uint8_t attempt = 0; attempt < 5; ++attempt) {
|
||||
const uint8_t next = current ^ 1U;
|
||||
if (!readPointData(samples[next], sizeof(samples[next]))) return false;
|
||||
if (memcmp(samples[current], samples[next], POINT_DATA_SIZE) == 0) {
|
||||
memcpy(data, samples[next], POINT_DATA_SIZE);
|
||||
return true;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
memcpy(data, samples[current], POINT_DATA_SIZE);
|
||||
return true;
|
||||
}
|
||||
|
||||
public:
|
||||
bool init() override {
|
||||
_inited = false;
|
||||
if (!lgfx::i2c::init(_cfg.i2c_port, _cfg.pin_sda, _cfg.pin_scl)
|
||||
.has_value()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t point_count = 0;
|
||||
if (!readPointData(&point_count, 1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Select normal operation and polling mode, matching the generic driver.
|
||||
if (!lgfx::i2c::writeRegister8(
|
||||
_cfg.i2c_port, _cfg.i2c_addr, 0x00, 0x00, 0, _cfg.freq)
|
||||
.has_value()
|
||||
|| !lgfx::i2c::writeRegister8(
|
||||
_cfg.i2c_port, _cfg.i2c_addr, 0xA4, 0x00, 0, _cfg.freq)
|
||||
.has_value()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
_inited = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
uint_fast8_t getTouchRaw(lgfx::touch_point_t* point,
|
||||
uint_fast8_t count) override {
|
||||
if (point == nullptr || count == 0 || (!_inited && !init())) return 0;
|
||||
|
||||
uint8_t data[POINT_DATA_SIZE] = {};
|
||||
if (!readStablePointData(data)) {
|
||||
_inited = false;
|
||||
return 0;
|
||||
}
|
||||
if ((data[0] & 0x0F) == 0) return 0;
|
||||
|
||||
point[0].x = ((data[1] & 0x0F) << 8) | data[2];
|
||||
point[0].y = ((data[3] & 0x0F) << 8) | data[4];
|
||||
point[0].size = 1;
|
||||
point[0].id = data[3] >> 4;
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
# SenseCAP Indicator controls
|
||||
|
||||
The Indicator uses its full 480x480 panel resolution while preserving the
|
||||
existing 160x160 UI coordinate system. A 4-bit internal canvas avoids PSRAM
|
||||
contention with RGB scanout; text and icons are rendered directly at 3x detail
|
||||
instead of enlarging a completed 160x160 frame. The screen blanks after five
|
||||
minutes on USB power; the hardware button or a touch wakes it without
|
||||
selecting anything.
|
||||
|
||||
## Touch navigation
|
||||
|
||||
- Swipe horizontally to move between home pages. The dots under the status bar
|
||||
show the current page.
|
||||
- The middle 70% of the panel is the Select tap target. The outer 15% on each
|
||||
side performs Previous or Next; horizontal swipes remain available anywhere.
|
||||
- A gesture is committed after a stable release. Brief missing touch samples
|
||||
are ignored so one swipe cannot become a second, opposite endpoint tap.
|
||||
|
||||
## Reading messages
|
||||
|
||||
The message preview is a non-destructive local inbox, newest first:
|
||||
|
||||
- The first home page shows the retained local `INBOX` count, which remains
|
||||
useful when a USB host has already drained the protocol's unread count. Tap
|
||||
the center to open the inbox and channel selector even when the count is 0.
|
||||
- `Message 1/N` is the newest buffered preview.
|
||||
- Each preview retains the complete 160-byte maximum chat payload. Long UTF-8
|
||||
messages wrap through the available middle of the screen instead of being
|
||||
cut at the small-display 78-byte limit.
|
||||
- When a USB host drains the radio queue, the displayed preview is retained
|
||||
for five minutes rather than disappearing immediately.
|
||||
- Swipe left for an older preview and right for a newer preview. Navigation
|
||||
stops at the oldest and newest entries instead of wrapping.
|
||||
- The bottom bar shows the active inbox filter. Its large `<` and `>` end
|
||||
buttons select the previous or next filter; swipe up or down does the same.
|
||||
Filters include `All channels`, `Direct`, and configured channels such as
|
||||
`Ch 1 #testing`. A newly received message automatically selects its source
|
||||
channel.
|
||||
- Tap the center to return home without deleting the previews.
|
||||
- On the first home page, tap the wide center target to reopen the previews,
|
||||
including when `INBOX` is zero.
|
||||
|
||||
Channel messages identify both the configured slot and name, such as
|
||||
`Ch 0 Public` or `Ch 1 #testing`. The value in brackets after it is the route,
|
||||
for example `[0h]` for a zero-hop flood or `[direct]` for a direct route.
|
||||
|
||||
The bottom selector filters received messages; it does not change the outbound
|
||||
channel because the device UI does not include a text composer. Select the
|
||||
outbound channel in the connected application, or list and address channel
|
||||
slots from the CLI with `get_channels`, `public <message>`, or `chan <slot>
|
||||
<message>`. Channel 0 is conventionally Public; always use `get_channels`
|
||||
before assuming the other slot numbers.
|
||||
|
||||
## Radio page
|
||||
|
||||
The radio page redraws once per second. Noise-floor calibration publishes a
|
||||
new 64-sample average about every two seconds, so a faster redraw would usually
|
||||
repeat the same value. The display shows the fractional sample mean to one
|
||||
decimal place; individual SX1262 instantaneous RSSI samples are still
|
||||
quantized to 0.5 dB, so the decimals are useful for comparing averages rather
|
||||
than claiming hundredth-dB measurement accuracy.
|
||||
|
||||
## USB + WiFi Companion
|
||||
|
||||
Build `SenseCapIndicator-LoRa_comp_radio_usb_wifi` to retain the USB Companion
|
||||
link and expose the same framed Companion protocol over WiFi TCP port `5000`.
|
||||
Fresh devices advertise an open `MeshCore-Setup-XXXX` access point, where the
|
||||
four-character suffix identifies the device; join it and open
|
||||
`http://192.168.4.1/` to save WiFi credentials. Saved credentials and the WiFi
|
||||
on/off preference survive application-only reflashes. Setup mode explicitly
|
||||
restores standard 2.4 GHz b/g/n operation, so a previously installed ESP-NOW
|
||||
image cannot leave the setup AP hidden behind its long-range protocol setting.
|
||||
|
||||
The combined profile uses minimum WiFi modem power saving by default. USB
|
||||
remains available for recovery and management if the configured network is
|
||||
unavailable. TCP port `5001` serves host-backed LoRa OTA files, while port
|
||||
`5002` provides the bounded OTA management console. The USB text terminal also
|
||||
accepts `get wifi.status`, `get wifi.ssid`, `get wifi.cli`, and
|
||||
`start webconfig ap`; enter it with `+++MESHCORE-TERM-START` and return to the
|
||||
framed Companion protocol with `+++MESHCORE-TERM-STOP`.
|
||||
|
||||
If a Companion client does not supply device time over USB or WiFi, this build
|
||||
starts evaluating LoRa time after two minutes. It requires three independent,
|
||||
agreeing sources from signed adverts or authenticated Public-channel messages;
|
||||
one repeated sender cannot set the clock alone. A successful Companion or
|
||||
local CLI time update always wins and disables the LoRa fallback until reboot.
|
||||
|
||||
The combined image uses two 2.5 MiB OTA application slots. Its partition map
|
||||
keeps the prior NVS and SPIFFS addresses, so installing the partition table and
|
||||
application over an existing Indicator preserves identity, channels, radio
|
||||
settings, and saved WiFi credentials.
|
||||
|
||||
## SD-backed font
|
||||
|
||||
The RP2040 streams the checked-in pixel-font asset from the SD card to the
|
||||
ESP32-S3 at boot. Text is binary 18x24 Noto Sans Mono Bold. Emoji use 12x12
|
||||
RGB332 color cells with binary transparency and are enlarged 2x with
|
||||
nearest-neighbor scaling. Periodic status updates preserve unchanged emoji in
|
||||
the panel framebuffer so the full-color cell is not replaced and redrawn once
|
||||
per second. See
|
||||
[`../../tools/sensecap_indicator_rp2040/README.md`](../../tools/sensecap_indicator_rp2040/README.md)
|
||||
for installation and diagnostics.
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ui/LGFXDisplay.h>
|
||||
#include "IndicatorFontClient.h"
|
||||
#include "IndicatorTouch.h"
|
||||
#include <driver/gpio.h>
|
||||
|
||||
#define LGFX_USE_V1
|
||||
#include <LovyanGFX.hpp>
|
||||
@@ -13,7 +16,7 @@ class LGFX : public lgfx::LGFX_Device
|
||||
lgfx::Panel_ST7701 _panel_instance;
|
||||
lgfx::Bus_RGB _bus_instance;
|
||||
lgfx::Light_PWM _light_instance;
|
||||
lgfx::Touch_FT5x06 _touch_instance;
|
||||
IndicatorTouch _touch_instance;
|
||||
|
||||
public:
|
||||
const uint16_t screenWidth = 480;
|
||||
@@ -37,7 +40,8 @@ public:
|
||||
|
||||
{
|
||||
auto cfg = _panel_instance.config_detail();
|
||||
cfg.pin_cs = 4 | IO_EXPANDER;
|
||||
// Chip-select is driven through the board's I2C expander below.
|
||||
cfg.pin_cs = GPIO_NUM_NC;
|
||||
cfg.pin_sclk = 41;
|
||||
cfg.pin_mosi = 48;
|
||||
cfg.use_psram = 1;
|
||||
@@ -105,8 +109,8 @@ public:
|
||||
cfg.y_max = 479;
|
||||
cfg.pin_int = GPIO_NUM_NC;
|
||||
cfg.pin_rst = GPIO_NUM_NC;
|
||||
cfg.bus_shared = true;
|
||||
cfg.offset_rotation = 0;
|
||||
cfg.bus_shared = false;
|
||||
cfg.offset_rotation = 2;
|
||||
|
||||
cfg.i2c_port = 0;
|
||||
cfg.i2c_addr = 0x48;
|
||||
@@ -123,6 +127,131 @@ public:
|
||||
|
||||
class SCIndicatorDisplay : public LGFXDisplay {
|
||||
LGFX disp;
|
||||
uint8_t expander_address = 0;
|
||||
uint16_t expander_output = 0;
|
||||
|
||||
static constexpr gpio_num_t BACKLIGHT_PIN = GPIO_NUM_45;
|
||||
static constexpr uint8_t I2C_PORT = 0;
|
||||
static constexpr uint32_t I2C_FREQUENCY = 400000;
|
||||
static constexpr uint8_t EXPANDER_OUTPUT_REGISTER = 0x02;
|
||||
static constexpr uint8_t EXPANDER_CONFIG_REGISTER = 0x06;
|
||||
static constexpr uint16_t PANEL_CS_MASK = 1U << 4;
|
||||
static constexpr uint16_t PANEL_RESET_MASK = 1U << 5;
|
||||
static constexpr uint16_t TOUCH_INTERRUPT_MASK = 1U << 6;
|
||||
static constexpr uint16_t TOUCH_RESET_MASK = 1U << 7;
|
||||
|
||||
static void setBacklight(bool enabled) {
|
||||
// Detach any stale LEDC routing before changing the level. Reasserting a
|
||||
// plain GPIO level makes wake reliable even if a previous image left the
|
||||
// backlight PWM channel stopped or assigned elsewhere.
|
||||
gpio_reset_pin(BACKLIGHT_PIN);
|
||||
gpio_set_direction(BACKLIGHT_PIN, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(BACKLIGHT_PIN, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
static bool readExpanderRegister(uint8_t address, uint8_t reg,
|
||||
uint16_t& value) {
|
||||
uint8_t data[2];
|
||||
if (!lgfx::i2c::readRegister(I2C_PORT, address, reg, data, sizeof(data),
|
||||
I2C_FREQUENCY)
|
||||
.has_value()) {
|
||||
return false;
|
||||
}
|
||||
value = data[0];
|
||||
value |= static_cast<uint16_t>(data[1]) << 8;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool writeExpanderRegister(uint8_t address, uint8_t reg,
|
||||
uint16_t value) {
|
||||
uint8_t data[] = {
|
||||
reg,
|
||||
static_cast<uint8_t>(value),
|
||||
static_cast<uint8_t>(value >> 8),
|
||||
};
|
||||
return lgfx::i2c::transactionWrite(I2C_PORT, address, data, sizeof(data),
|
||||
I2C_FREQUENCY)
|
||||
.has_value();
|
||||
}
|
||||
|
||||
bool prepareControllersAt(uint8_t address) {
|
||||
uint16_t config;
|
||||
if (!readExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
|
||||
expander_output)
|
||||
|| !readExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Set safe output latches before changing pin directions. Keep LCD CS
|
||||
// inactive while both controllers are held in reset.
|
||||
expander_output |= PANEL_CS_MASK;
|
||||
expander_output &= ~(PANEL_RESET_MASK | TOUCH_RESET_MASK);
|
||||
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
|
||||
expander_output)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config &= ~(PANEL_CS_MASK | PANEL_RESET_MASK | TOUCH_RESET_MASK);
|
||||
config |= TOUCH_INTERRUPT_MASK;
|
||||
if (!writeExpanderRegister(address, EXPANDER_CONFIG_REGISTER, config)) {
|
||||
return false;
|
||||
}
|
||||
delay(10);
|
||||
|
||||
expander_output |= PANEL_RESET_MASK | TOUCH_RESET_MASK;
|
||||
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
|
||||
expander_output)) {
|
||||
return false;
|
||||
}
|
||||
delay(120);
|
||||
|
||||
// Hold LCD CS active while the graphics driver emits the ST7701 init
|
||||
// sequence over the shared clock and data pins.
|
||||
expander_output &= ~PANEL_CS_MASK;
|
||||
if (!writeExpanderRegister(address, EXPANDER_OUTPUT_REGISTER,
|
||||
expander_output)) {
|
||||
return false;
|
||||
}
|
||||
expander_address = address;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool prepareControllers() {
|
||||
if (!lgfx::i2c::init(I2C_PORT, PIN_BOARD_SDA, PIN_BOARD_SCL).has_value()) {
|
||||
return false;
|
||||
}
|
||||
return prepareControllersAt(0x20) || prepareControllersAt(0x39);
|
||||
}
|
||||
|
||||
bool releasePanelChipSelect() {
|
||||
if (expander_address == 0) return false;
|
||||
expander_output |= PANEL_CS_MASK;
|
||||
return writeExpanderRegister(expander_address, EXPANDER_OUTPUT_REGISTER,
|
||||
expander_output);
|
||||
}
|
||||
|
||||
public:
|
||||
SCIndicatorDisplay() : LGFXDisplay(480, 480, disp) {}
|
||||
|
||||
bool begin() {
|
||||
if (!prepareControllers()) return false;
|
||||
const bool initialized = LGFXDisplay::begin();
|
||||
const bool released = releasePanelChipSelect();
|
||||
if (!initialized || !released) return false;
|
||||
setBacklight(true);
|
||||
size_t fontSize;
|
||||
uint8_t* fontData = IndicatorFontClient::load(fontSize);
|
||||
if (fontData != nullptr) installRuntimeFont(fontData, fontSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
void turnOn() override {
|
||||
setBacklight(true);
|
||||
_isOn = true;
|
||||
}
|
||||
|
||||
void turnOff() override {
|
||||
setBacklight(false);
|
||||
_isOn = false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
# Two 2.5 MiB OTA slots for the 8 MiB SenseCAP Indicator.
|
||||
# NVS, SPIFFS, and coredump retain Arduino default.csv's addresses so an
|
||||
# in-place partition-table migration does not discard configured node data.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs, data, nvs, 0x9000, 0x5000,
|
||||
otadata, data, ota, 0xe000, 0x2000,
|
||||
app0, app, ota_0, 0x10000, 0x280000,
|
||||
spiffs, data, spiffs, 0x290000, 0x160000,
|
||||
coredump, data, coredump,0x3f0000, 0x10000,
|
||||
app1, app, ota_1, 0x400000, 0x280000,
|
||||
|
@@ -11,17 +11,41 @@ build_flags =
|
||||
${esp32_base.build_flags}
|
||||
-D PIN_BOARD_SDA=39
|
||||
-D PIN_BOARD_SCL=40
|
||||
-D BOARD_HAS_PSRAM
|
||||
-D DISPLAY_CLASS=SCIndicatorDisplay
|
||||
-D DISPLAY_LINES=21
|
||||
-D LINE_LENGTH=53
|
||||
-D DISABLE_WIFI_OTA=1
|
||||
-D IO_EXPANDER=0x40
|
||||
-D IO_EXPANDER_IRQ=42
|
||||
-D UI_ZOOM=3.5
|
||||
-D DISPLAY_ROTATION=1
|
||||
; Render at the panel's native 480x480 resolution while preserving the
|
||||
; established 160x160 UI coordinate system. A 4-bit internal canvas avoids
|
||||
; competing with the RGB scanout framebuffer in PSRAM.
|
||||
-D UI_ZOOM=1
|
||||
-D UI_COORD_SCALE=3
|
||||
-D UI_BUFFER_COLOR_DEPTH=4
|
||||
-D AUTO_OFF_MILLIS=300000UL
|
||||
-D USB_MESSAGE_PREVIEW_MILLIS=300000UL
|
||||
; Retain every byte of a maximum-length 160-byte chat message. The generic
|
||||
; 78-byte preview is intended for small displays and left most of this panel
|
||||
; unused while silently truncating longer text.
|
||||
-D UI_MSG_PREVIEW_SIZE=161
|
||||
-D UI_RADIO_REFRESH_MILLIS=1000UL
|
||||
; Use the configured five-minute timeout literally on this USB-powered LCD.
|
||||
-D UI_USB_AUTO_OFF_MULTIPLIER=1UL
|
||||
; GPIO 38 cannot wake the ESP32-S3 from the no-wakeup hibernate path.
|
||||
; Keep this USB-powered target on and use the display timeout instead.
|
||||
-D UI_NO_HIBERNATE
|
||||
-D UI_RECENT_LIST_SIZE=9
|
||||
-D UI_SENSORS_PAGE=1
|
||||
-D PIN_USER_BTN=38
|
||||
-D HAS_TOUCH
|
||||
-D TOUCH_REVERSE_SWIPE
|
||||
-D TOUCH_SEPARATE_VERTICAL_SWIPES
|
||||
; The outer 15% remains available for Previous/Next taps; the middle 70%
|
||||
; is Select. This makes the primary tap target much easier on a 480px panel.
|
||||
-D TOUCH_CENTER_ZONE_PERCENT=70
|
||||
-I variants/sensecap_indicator-espnow
|
||||
build_src_filter = ${esp32_base.build_src_filter}
|
||||
+<../variants/sensecap_indicator-espnow/*.cpp>
|
||||
@@ -32,6 +56,35 @@ lib_deps=${esp32_base.lib_deps}
|
||||
adafruit/Adafruit BusIO @ ^1.17.2
|
||||
lovyan03/LovyanGFX @ ^1.2.7
|
||||
|
||||
; D1L and D1Pro route the onboard SX1262's control pins through the TCA9535
|
||||
; expander. Keep the original ESP-NOW target above for Indicators without the
|
||||
; LoRa hardware, and use this target for the LoRa-capable models.
|
||||
[SenseCapIndicator-LoRa]
|
||||
extends = SenseCapIndicator-ESPNow
|
||||
build_flags =
|
||||
${SenseCapIndicator-ESPNow.build_flags}
|
||||
-D SENSECAP_INDICATOR_LORA
|
||||
-D USE_SX1262
|
||||
-D RADIO_CLASS=CustomSX1262
|
||||
-D WRAPPER_CLASS=IndicatorSX1262Wrapper
|
||||
-D P_LORA_SCLK=41
|
||||
-D P_LORA_MISO=47
|
||||
-D P_LORA_MOSI=48
|
||||
-D P_LORA_NSS=64
|
||||
-D P_LORA_RESET=65
|
||||
-D P_LORA_BUSY=66
|
||||
-D P_LORA_DIO_1=67
|
||||
-D SX126X_DIO2_AS_RF_SWITCH=true
|
||||
-D SX126X_DIO3_TCXO_VOLTAGE=2.4
|
||||
-D SX126X_CURRENT_LIMIT=140
|
||||
-D SX126X_RX_BOOSTED_GAIN=1
|
||||
-D LORA_TX_POWER=22
|
||||
-D MAX_LORA_TX_POWER=22
|
||||
-D RECOVERABLE_EXTERNAL_RADIO
|
||||
build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter}
|
||||
-<helpers/esp32/ESPNOWRadio.cpp>
|
||||
lib_deps = ${SenseCapIndicator-ESPNow.lib_deps}
|
||||
|
||||
[env:SenseCapIndicator-ESPNow_comp_radio_usb]
|
||||
extends = SenseCapIndicator-ESPNow
|
||||
build_flags =
|
||||
@@ -39,6 +92,7 @@ build_flags =
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D ENABLE_USB_INTERFACE
|
||||
; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1
|
||||
; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1
|
||||
; NOTE: DO NOT ENABLE --> -D ESPNOW_DEBUG_LOGGING=1
|
||||
@@ -48,3 +102,48 @@ build_src_filter = ${SenseCapIndicator-ESPNow.build_src_filter}
|
||||
lib_deps =
|
||||
${SenseCapIndicator-ESPNow.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
[env:SenseCapIndicator-LoRa_comp_radio_usb]
|
||||
extends = SenseCapIndicator-LoRa
|
||||
build_flags =
|
||||
${SenseCapIndicator-LoRa.build_flags}
|
||||
-I examples/companion_radio/ui-new
|
||||
-D MAX_CONTACTS=350
|
||||
-D MAX_GROUP_CHANNELS=40
|
||||
-D ENABLE_USB_INTERFACE
|
||||
; NOTE: DO NOT ENABLE --> -D MESH_PACKET_LOGGING=1
|
||||
; NOTE: DO NOT ENABLE --> -D MESH_DEBUG=1
|
||||
build_src_filter = ${SenseCapIndicator-LoRa.build_src_filter}
|
||||
+<../examples/companion_radio/ui-new/*.cpp>
|
||||
+<../examples/companion_radio/*.cpp>
|
||||
lib_deps =
|
||||
${SenseCapIndicator-LoRa.lib_deps}
|
||||
densaugeo/base64 @ ~1.4.0
|
||||
|
||||
; Keep the recoverable USB Companion link while also exposing the same framed
|
||||
; protocol over WiFi TCP port 5000. Placeholder credentials deliberately start
|
||||
; WebConfig in setup-AP mode on a fresh device; saved NVS credentials win on
|
||||
; later boots.
|
||||
[env:SenseCapIndicator-LoRa_comp_radio_usb_wifi]
|
||||
extends = env:SenseCapIndicator-LoRa_comp_radio_usb
|
||||
; Two 2.5 MiB OTA slots fit the combined display/WiFi image. The custom map
|
||||
; deliberately leaves the existing NVS and SPIFFS offsets unchanged so moving
|
||||
; an already-configured USB Indicator to this build preserves its identity,
|
||||
; channels, radio settings, and WiFi credentials.
|
||||
board_build.partitions = variants/sensecap_indicator-espnow/dual_ota_2560k_preserve_spiffs.csv
|
||||
build_flags =
|
||||
${env:SenseCapIndicator-LoRa_comp_radio_usb.build_flags}
|
||||
-D WIFI_DEBUG_LOGGING=1
|
||||
-D WIFI_SSID='"myssid"'
|
||||
-D WIFI_PWD='"mypwd"'
|
||||
-D DEFAULT_WIFI_POWER_SAVE_MODE=0
|
||||
; If neither the USB nor WiFi Companion link supplies authoritative time,
|
||||
; begin evaluating independently sourced LoRa timestamps after two minutes.
|
||||
-D COMPANION_MESH_CLOCK_SYNC=1
|
||||
-D MESH_CLOCK_SYNC_STARTUP_DELAY_MILLIS=120000ULL
|
||||
-D MESH_CLOCK_SYNC_REQUIRED_SAMPLES_DEFAULT=3
|
||||
build_src_filter =
|
||||
${env:SenseCapIndicator-LoRa_comp_radio_usb.build_src_filter}
|
||||
+<helpers/esp32/*.cpp>
|
||||
lib_deps =
|
||||
${env:SenseCapIndicator-LoRa_comp_radio_usb.lib_deps}
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"format": "MeshCore SD UI font 2",
|
||||
"text_cell": [
|
||||
18,
|
||||
24
|
||||
],
|
||||
"emoji_cell": [
|
||||
12,
|
||||
12
|
||||
],
|
||||
"pixel_font": true,
|
||||
"antialiasing": false,
|
||||
"text_pixel_values": [
|
||||
0,
|
||||
255
|
||||
],
|
||||
"emoji_encoding": {
|
||||
"color": "RGB332",
|
||||
"transparent_key": 227,
|
||||
"alpha_threshold": 48
|
||||
},
|
||||
"text_source": "Noto Sans Mono Bold, binary-thresholded",
|
||||
"text_native_scale": 3,
|
||||
"unicode_emoji_version": "17.0",
|
||||
"text_glyphs": 1357,
|
||||
"emoji_glyphs": 3953,
|
||||
"emoji_aliases": 5225,
|
||||
"trie_bytes": 109080,
|
||||
"color_atlas_bytes": 569244,
|
||||
"font_bytes": 1302608,
|
||||
"sha256": "61bce9662db314054e7bcfaa26147a28ad7b500b51baac4cae1caacce90b7421",
|
||||
"sources": {
|
||||
"NotoColorEmoji.ttf": "72a635cb3d2f3524c51620cdde406b217204e8a6a06c6a096ff8ed4b5fd6e27b",
|
||||
"NotoSansMono-wdth-wght.ttf": "2cb2adb378a8f574213e23df697050b83c54c27df465a2015552740b2769a081",
|
||||
"emoji-test-17.0.txt": "1d8a944f88d7952f7ef7c5167fef3c67995bcae24543949710231b03a201acda"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,16 @@
|
||||
|
||||
ESP32Board board;
|
||||
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
static SPIClass radio_spi(FSPI);
|
||||
static IndicatorRadioHal radio_hal(radio_spi);
|
||||
RADIO_CLASS radio = new Module(&radio_hal, P_LORA_NSS, P_LORA_DIO_1,
|
||||
P_LORA_RESET, P_LORA_BUSY);
|
||||
WRAPPER_CLASS radio_driver(radio, board, radio_hal);
|
||||
static bool target_radio_available = false;
|
||||
#else
|
||||
ESPNOWRadio radio_driver;
|
||||
#endif
|
||||
|
||||
ESP32RTCClock rtc_clock;
|
||||
#if defined(ENV_INCLUDE_GPS)
|
||||
@@ -18,16 +27,28 @@ EnvironmentSensorManager sensors = EnvironmentSensorManager();
|
||||
#ifdef DISPLAY_CLASS
|
||||
DISPLAY_CLASS display;
|
||||
#ifdef PIN_USER_BTN
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
|
||||
// Touch supplies the other navigation actions, so dispatch the physical
|
||||
// button's single click immediately instead of waiting for a multi-click.
|
||||
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, false);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
bool radio_init() {
|
||||
rtc_clock.begin();
|
||||
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
if (!radio_hal.beginExpander()) {
|
||||
mesh::usbLoggingPort().println("ERROR: radio I/O expander unavailable");
|
||||
target_radio_available = false;
|
||||
return false;
|
||||
}
|
||||
target_radio_available = radio.std_init(&radio_spi);
|
||||
return target_radio_available;
|
||||
#else
|
||||
radio_driver.init();
|
||||
|
||||
return true; // success
|
||||
#endif
|
||||
}
|
||||
|
||||
// Combine the normal software source with true entropy captured before RF/ADC
|
||||
@@ -42,7 +63,20 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
||||
uint32_t radio_fallback_rng_seed() { return esp_random(); }
|
||||
#endif
|
||||
|
||||
mesh::LocalIdentity radio_new_identity() {
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
if (target_radio_available) {
|
||||
RadioNoiseListener rng(radio);
|
||||
return mesh::LocalIdentity(&rng);
|
||||
}
|
||||
ESP_RNG rng;
|
||||
return mesh::LocalIdentity(&rng);
|
||||
#else
|
||||
ESP_RNG rng;
|
||||
return mesh::LocalIdentity(&rng); // create new random identity
|
||||
#endif
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <helpers/ESP32Board.h>
|
||||
#include <helpers/esp32/ESPNOWRadio.h>
|
||||
#include <helpers/SensorManager.h>
|
||||
#include <helpers/sensors/EnvironmentSensorManager.h>
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
#define RADIOLIB_STATIC_ONLY 1
|
||||
#include <RadioLib.h>
|
||||
#include <helpers/radiolib/CustomSX1262Wrapper.h>
|
||||
#include "IndicatorRadioHal.h"
|
||||
#include "IndicatorSX1262Wrapper.h"
|
||||
#else
|
||||
#include <helpers/esp32/ESPNOWRadio.h>
|
||||
#endif
|
||||
#ifdef ENV_INCLUDE_GPS
|
||||
#include <helpers/sensors/MicroNMEALocationProvider.h>
|
||||
#endif
|
||||
@@ -13,7 +21,11 @@
|
||||
#endif
|
||||
|
||||
extern ESP32Board board;
|
||||
extern ESPNOWRadio radio_driver;
|
||||
#ifdef SENSECAP_INDICATOR_LORA
|
||||
extern WRAPPER_CLASS radio_driver;
|
||||
#else
|
||||
extern ESPNOWRadio radio_driver;
|
||||
#endif
|
||||
extern ESP32RTCClock rtc_clock;
|
||||
extern EnvironmentSensorManager sensors;
|
||||
|
||||
@@ -23,4 +35,7 @@ extern EnvironmentSensorManager sensors;
|
||||
#endif
|
||||
|
||||
bool radio_init();
|
||||
#ifdef RECOVERABLE_EXTERNAL_RADIO
|
||||
uint32_t radio_fallback_rng_seed();
|
||||
#endif
|
||||
mesh::LocalIdentity radio_new_identity();
|
||||
Reference in new issue
Block a user