mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-10 07:14:49 +02:00
Compare commits
5 Commits
v0.5.6-rc5
..
v0.5.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 7160d72aae | |||
| f4aee2aba4 | |||
| a789a9552a | |||
| 9fd1401737 | |||
| 9ae837582b |
@@ -25,7 +25,12 @@ permissions:
|
||||
|
||||
jobs:
|
||||
flutter:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./app
|
||||
@@ -44,6 +49,12 @@ jobs:
|
||||
run: dart format --set-exit-if-changed .
|
||||
- name: Analyze Dart code
|
||||
run: flutter analyze
|
||||
- name: Build Android debug APK
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: flutter build apk --debug
|
||||
- name: Build iOS debug IPA
|
||||
if: matrix.os == 'macos-latest'
|
||||
run: flutter build ipa --debug --no-codesign
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright © 2025-26 l5yth & contributors -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="256"
|
||||
android:viewportHeight="256">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M 126.00 231.38 C116.24,230.24 108.69,228.55 97.57,225.02 C89.71,222.53 81.61,220.73 74.92,220.01 C56.54,218.03 47.54,215.19 35.90,207.72 C25.90,201.31 16.09,188.73 11.28,176.17 C9.05,170.34 8.73,167.93 8.69,157.00 C8.66,146.56 9.01,143.52 10.83,138.58 C13.71,130.75 16.90,125.44 22.99,118.35 C25.75,115.13 30.06,108.55 32.57,103.72 C39.65,90.08 52.73,77.41 61.66,75.54 C63.80,75.09 65.00,74.16 65.38,72.61 C66.30,68.96 73.33,61.70 79.81,57.70 C83.11,55.66 86.29,54.00 86.90,54.00 C87.50,54.00 91.26,50.90 95.25,47.11 C107.07,35.89 118.95,31.10 131.78,32.42 C139.49,33.21 147.99,37.24 153.00,42.48 C154.93,44.50 160.84,48.38 166.15,51.12 C175.72,56.04 185.00,64.04 185.00,67.35 C185.00,68.84 187.05,70.00 189.69,70.00 C190.52,70.00 193.21,71.35 195.66,73.00 C198.10,74.65 200.30,76.00 200.55,76.00 C201.87,76.00 209.57,85.37 213.05,91.22 C215.39,95.15 221.15,102.01 226.90,107.72 C237.44,118.20 241.06,123.94 244.87,136.29 C252.34,160.53 244.67,187.19 225.29,204.27 C215.26,213.12 206.19,217.02 189.50,219.66 C184.00,220.54 174.32,222.95 168.00,225.04 C152.19,230.25 136.54,232.62 126.00,231.38 ZM 149.23 220.54 C152.93,219.75 160.90,217.52 166.93,215.57 C172.97,213.61 182.16,211.33 187.36,210.50 C192.56,209.66 198.70,208.31 200.99,207.50 C219.04,201.13 232.11,186.69 236.54,168.21 C239.68,155.13 237.23,137.60 230.79,127.15 C229.56,125.14 224.01,118.78 218.45,113.00 C212.70,107.01 206.60,99.49 204.27,95.50 C200.27,88.66 197.03,85.00 192.03,81.65 C188.54,79.32 187.14,79.59 186.36,82.75 C184.35,90.89 178.61,95.54 166.19,99.09 C162.70,100.09 157.74,102.15 155.17,103.68 C142.50,111.19 133.13,113.70 123.94,112.03 C120.95,111.49 114.12,108.78 108.77,106.02 C100.44,101.72 97.99,100.92 91.77,100.48 C81.44,99.74 78.08,99.08 74.05,97.01 C69.74,94.80 66.49,91.44 65.08,87.73 C64.04,84.97 64.02,84.96 60.27,86.48 C54.59,88.78 45.89,98.56 40.58,108.65 C38.01,113.52 33.29,120.79 30.09,124.80 C22.80,133.95 19.57,140.55 17.99,149.58 C14.84,167.46 24.86,188.52 41.92,199.87 C51.75,206.40 57.70,208.37 74.36,210.57 C80.89,211.43 90.12,213.30 94.86,214.73 C115.79,221.03 120.20,221.90 131.50,221.93 C137.55,221.95 145.53,221.32 149.23,220.54 ZM 136.76 102.49 C140.36,101.65 145.79,99.27 149.54,96.90 C153.18,94.60 159.03,92.02 162.79,91.05 C170.97,88.95 173.22,87.80 175.92,84.37 C178.38,81.24 178.67,75.20 176.55,71.10 C174.28,66.71 168.40,62.13 160.26,58.42 C155.62,56.31 150.53,52.93 146.85,49.52 C133.35,37.02 118.65,38.50 101.56,54.08 C97.39,57.87 91.17,62.38 87.74,64.10 C77.86,69.03 73.00,74.56 73.00,80.87 C73.00,88.43 77.15,90.86 91.91,91.95 C102.41,92.73 103.22,92.96 111.55,97.50 C124.46,104.54 126.44,104.93 136.76,102.49 ZM 88.35 189.48 C87.37,186.91 91.59,125.01 92.84,123.76 C94.47,122.13 105.29,121.55 107.91,122.95 C109.31,123.70 113.25,130.52 118.80,141.81 L 127.51 159.50 L 136.00 142.15 C140.68,132.61 145.09,124.17 145.82,123.40 C146.71,122.46 149.41,122.00 154.00,122.00 C161.67,122.00 163.99,123.34 164.02,127.82 C164.03,129.29 164.70,140.18 165.50,152.00 C167.35,179.25 167.41,189.65 165.75,190.21 C165.06,190.44 161.57,190.38 158.00,190.07 L 151.50 189.50 L 150.36 170.00 C149.73,159.27 149.14,150.27 149.06,150.00 C148.98,149.73 145.22,156.70 140.71,165.50 L 132.50 181.50 L 127.50 181.50 L 122.50 181.50 L 114.25 165.08 C107.87,152.38 105.97,149.32 105.88,151.58 C105.82,153.19 105.81,154.81 105.86,155.18 C105.91,155.55 105.48,163.42 104.90,172.67 C104.23,183.39 103.43,189.74 102.68,190.22 C102.03,190.63 98.67,190.98 95.22,190.98 C90.51,191.00 88.79,190.62 88.35,189.48 ZM 61.61 182.42 C60.64,179.90 60.89,178.25 62.48,176.66 C65.63,173.51 69.97,177.37 68.50,182.00 C67.71,184.48 62.52,184.80 61.61,182.42 ZM 216.53 167.38 C215.64,166.50 215.07,165.29 215.26,164.71 C215.85,162.94 218.62,161.64 220.36,162.31 C222.46,163.11 222.50,167.60 220.42,168.39 C218.30,169.21 218.37,169.22 216.53,167.38 ZM 36.04 153.55 C33.39,150.36 37.30,145.34 40.42,147.93 C42.30,149.49 42.47,152.13 40.80,153.80 C39.20,155.40 37.51,155.31 36.04,153.55 ZM 201.16 148.12 C199.41,147.01 200.04,142.99 202.19,141.64 C203.48,140.83 204.37,141.02 205.94,142.45 C208.41,144.68 208.48,145.38 206.43,147.43 C204.67,149.18 203.15,149.38 201.16,148.12 ZM 67.34 137.43 C66.47,135.18 68.24,132.50 70.61,132.50 C73.61,132.50 73.27,138.39 70.25,138.82 C68.83,139.02 67.75,138.50 67.34,137.43 ZM 56.54 116.66 C55.17,115.65 54.88,114.62 55.39,112.59 C55.94,110.38 56.56,109.93 58.68,110.19 C62.53,110.65 63.88,113.37 61.55,115.94 C59.36,118.37 58.96,118.42 56.54,116.66 ZM 186.67 113.33 C185.21,111.88 186.09,108.02 188.12,106.93 C189.87,106.00 190.60,106.15 192.23,107.78 C195.00,110.56 193.82,114.00 190.10,114.00 C188.58,114.00 187.03,113.70 186.67,113.33 Z" />
|
||||
</vector>
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Copyright © 2025-26 l5yth & contributors
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
platform :ios, "14.0"
|
||||
|
||||
ENV["COCOAPODS_DISABLE_STATS"] = "true"
|
||||
|
||||
project "Runner", {
|
||||
"Debug" => :debug,
|
||||
"Profile" => :release,
|
||||
"Release" => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join("..", "Flutter", "Generated.xcconfig"), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Run flutter pub get and try again."
|
||||
end
|
||||
|
||||
require File.expand_path(File.join("packages", "flutter_tools", "bin", "podhelper"), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target "Runner" do
|
||||
use_frameworks!
|
||||
use_modular_headers!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = "14.0"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -346,7 +346,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -472,7 +472,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -523,7 +523,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
+109
-23
@@ -45,6 +45,7 @@ const String _notificationChannelId = 'mesh.messages';
|
||||
const String _notificationChannelName = 'Mesh messages';
|
||||
const String _notificationChannelDescription =
|
||||
'Alerts when new PotatoMesh messages arrive';
|
||||
const String _notificationIconName = 'ic_mesh_notification';
|
||||
const String _backgroundTaskName = 'mesh_message_poll';
|
||||
const String _backgroundTaskId = 'mesh.message.poll';
|
||||
const Duration _backgroundFetchInterval = Duration(minutes: 15);
|
||||
@@ -61,9 +62,32 @@ abstract class NotificationClient {
|
||||
required MeshMessage message,
|
||||
required String domain,
|
||||
String? senderShortName,
|
||||
String? senderLongName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Display names used when rendering a notification title and subtitle.
|
||||
class NotificationSender {
|
||||
const NotificationSender({
|
||||
required this.shortName,
|
||||
this.longName,
|
||||
});
|
||||
|
||||
/// Short name fallback for the sender when a long name is unavailable.
|
||||
final String shortName;
|
||||
|
||||
/// Full descriptive name for the sender when provided by the mesh.
|
||||
final String? longName;
|
||||
|
||||
/// Preferred display name that favors the long form when available.
|
||||
String get preferredName {
|
||||
if (longName != null && longName!.trim().isNotEmpty) {
|
||||
return longName!.trim();
|
||||
}
|
||||
return shortName.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op notification client used in tests and web builds.
|
||||
class NoopNotificationClient implements NotificationClient {
|
||||
const NoopNotificationClient();
|
||||
@@ -76,6 +100,7 @@ class NoopNotificationClient implements NotificationClient {
|
||||
required MeshMessage message,
|
||||
required String domain,
|
||||
String? senderShortName,
|
||||
String? senderLongName,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
@@ -102,14 +127,9 @@ class LocalNotificationClient implements NotificationClient {
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosInit = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
final settings =
|
||||
const InitializationSettings(android: androidInit, iOS: iosInit);
|
||||
final androidInit = buildAndroidInitializationSettings();
|
||||
final iosInit = buildDarwinInitializationSettings();
|
||||
final settings = InitializationSettings(android: androidInit, iOS: iosInit);
|
||||
await _plugin.initialize(settings);
|
||||
|
||||
final android = _plugin.resolvePlatformSpecificImplementation<
|
||||
@@ -137,11 +157,14 @@ class LocalNotificationClient implements NotificationClient {
|
||||
priority: Priority.high,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
category: AndroidNotificationCategory.message,
|
||||
icon: _notificationIconName,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
categoryIdentifier: 'chat',
|
||||
);
|
||||
return const NotificationDetails(
|
||||
android: androidDetails,
|
||||
@@ -149,22 +172,64 @@ class LocalNotificationClient implements NotificationClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds the default Android initialization settings.
|
||||
@visibleForTesting
|
||||
AndroidInitializationSettings buildAndroidInitializationSettings() {
|
||||
return const AndroidInitializationSettings(_notificationIconName);
|
||||
}
|
||||
|
||||
/// Builds the default Darwin (iOS) initialization settings.
|
||||
@visibleForTesting
|
||||
DarwinInitializationSettings buildDarwinInitializationSettings() {
|
||||
return const DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Exposes notification details for test assertions.
|
||||
@visibleForTesting
|
||||
NotificationDetails notificationDetailsForTest() {
|
||||
return _notificationDetails();
|
||||
}
|
||||
|
||||
/// Picks the preferred sender name prioritising the long form when present.
|
||||
String _preferredSenderName({
|
||||
required MeshMessage message,
|
||||
String? senderLongName,
|
||||
String? senderShortName,
|
||||
}) {
|
||||
final trimmedLong = senderLongName?.trim();
|
||||
if (trimmedLong != null && trimmedLong.isNotEmpty) {
|
||||
return trimmedLong;
|
||||
}
|
||||
final trimmedShort = senderShortName?.trim();
|
||||
if (trimmedShort != null && trimmedShort.isNotEmpty) {
|
||||
return trimmedShort;
|
||||
}
|
||||
return message.fromShort;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> showNewMessage({
|
||||
required MeshMessage message,
|
||||
required String domain,
|
||||
String? senderShortName,
|
||||
String? senderLongName,
|
||||
}) async {
|
||||
await initialize();
|
||||
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return;
|
||||
debugPrint('D/Notifications: showing message ${message.id} on $domain');
|
||||
final displaySender = senderShortName?.trim().isNotEmpty == true
|
||||
? senderShortName!.trim()
|
||||
: message.fromShort;
|
||||
final displaySender = _preferredSenderName(
|
||||
senderLongName: senderLongName,
|
||||
senderShortName: senderShortName,
|
||||
message: message,
|
||||
);
|
||||
final channel = message.channelName?.trim().isNotEmpty == true
|
||||
? message.channelName!.trim()
|
||||
: domain;
|
||||
final title = 'New message from $displaySender';
|
||||
final title = displaySender;
|
||||
final body = message.text.trim().isNotEmpty
|
||||
? message.text.trim()
|
||||
: 'New message on $channel';
|
||||
@@ -337,17 +402,17 @@ class BackgroundSyncManager {
|
||||
'D/BackgroundSync: task=$task domain=$domain fetched=${messages.length} unseen=${unseen.length}');
|
||||
|
||||
for (final message in unseen) {
|
||||
final sender = NodeShortNameCache.fallbackShortName(
|
||||
message.lookupNodeId.isNotEmpty
|
||||
? message.lookupNodeId
|
||||
: message.fromId,
|
||||
final sender = repository.resolveNotificationSender(
|
||||
domain: domain,
|
||||
message: message,
|
||||
);
|
||||
debugPrint(
|
||||
'D/BackgroundSync: notifying message=${message.id} sender=$sender');
|
||||
'D/BackgroundSync: notifying message=${message.id} sender=${sender.preferredName}');
|
||||
await notification.showNewMessage(
|
||||
message: message,
|
||||
domain: domain,
|
||||
senderShortName: sender,
|
||||
senderShortName: sender.shortName,
|
||||
senderLongName: sender.longName,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -1619,6 +1684,27 @@ class MeshRepository implements MeshNodeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the best-effort sender names for notification payloads.
|
||||
NotificationSender resolveNotificationSender({
|
||||
required String domain,
|
||||
required MeshMessage message,
|
||||
}) {
|
||||
final node = findNode(domain, message.lookupNodeId);
|
||||
final trimmedLong = node?.longName.trim();
|
||||
final fallbackId =
|
||||
message.lookupNodeId.isNotEmpty ? message.lookupNodeId : message.fromId;
|
||||
final shortFromNode = node?.displayShortName.trim();
|
||||
final shortName = (shortFromNode != null && shortFromNode.isNotEmpty)
|
||||
? shortFromNode
|
||||
: NodeShortNameCache.fallbackShortName(fallbackId);
|
||||
|
||||
return NotificationSender(
|
||||
shortName: shortName,
|
||||
longName:
|
||||
trimmedLong != null && trimmedLong.isNotEmpty ? trimmedLong : null,
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesNodeId(String existing, String candidate) {
|
||||
final cleanExisting = _normalizeNodeId(existing);
|
||||
final cleanCandidate = _normalizeNodeId(candidate);
|
||||
@@ -1865,15 +1951,15 @@ class _MessagesScreenState extends State<MessagesScreen>
|
||||
}
|
||||
await _notificationReady;
|
||||
for (final message in unseen) {
|
||||
final sender = NodeShortNameCache.fallbackShortName(
|
||||
message.lookupNodeId.isNotEmpty
|
||||
? message.lookupNodeId
|
||||
: message.fromId,
|
||||
final sender = repo.resolveNotificationSender(
|
||||
domain: widget.domain,
|
||||
message: message,
|
||||
);
|
||||
await _notificationClient.showNewMessage(
|
||||
message: message,
|
||||
domain: widget.domain,
|
||||
senderShortName: sender,
|
||||
senderShortName: sender.shortName,
|
||||
senderLongName: sender.longName,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -56,6 +56,10 @@ class _FakeNotificationClient extends NotificationClient {
|
||||
_FakeNotificationClient();
|
||||
|
||||
int calls = 0;
|
||||
MeshMessage? lastMessage;
|
||||
String? lastDomain;
|
||||
String? lastShortName;
|
||||
String? lastLongName;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
@@ -65,8 +69,13 @@ class _FakeNotificationClient extends NotificationClient {
|
||||
required MeshMessage message,
|
||||
required String domain,
|
||||
String? senderShortName,
|
||||
String? senderLongName,
|
||||
}) async {
|
||||
calls += 1;
|
||||
lastMessage = message;
|
||||
lastDomain = domain;
|
||||
lastShortName = senderShortName;
|
||||
lastLongName = senderLongName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,11 +84,15 @@ class _FakeRepository extends MeshRepository {
|
||||
required this.domain,
|
||||
required this.messages,
|
||||
required this.unseen,
|
||||
}) : super();
|
||||
NotificationSender? sender,
|
||||
}) : sender = sender ??
|
||||
const NotificationSender(shortName: 'MOCK', longName: 'Mocky'),
|
||||
super();
|
||||
|
||||
final String domain;
|
||||
final List<MeshMessage> messages;
|
||||
final List<MeshMessage> unseen;
|
||||
final NotificationSender sender;
|
||||
int loadMessagesCalls = 0;
|
||||
|
||||
@override
|
||||
@@ -101,6 +114,14 @@ class _FakeRepository extends MeshRepository {
|
||||
}) async {
|
||||
return unseen;
|
||||
}
|
||||
|
||||
@override
|
||||
NotificationSender resolveNotificationSender({
|
||||
required String domain,
|
||||
required MeshMessage message,
|
||||
}) {
|
||||
return sender;
|
||||
}
|
||||
}
|
||||
|
||||
MeshMessage _buildMessage(int id, String text) {
|
||||
@@ -133,6 +154,10 @@ void main() {
|
||||
domain: 'potatomesh.net',
|
||||
messages: [_buildMessage(1, 'hello')],
|
||||
unseen: [_buildMessage(2, 'new')],
|
||||
sender: const NotificationSender(
|
||||
shortName: 'TEST',
|
||||
longName: 'Test Sender',
|
||||
),
|
||||
);
|
||||
final notifier = _FakeNotificationClient();
|
||||
|
||||
@@ -160,6 +185,9 @@ void main() {
|
||||
expect(handled, isTrue);
|
||||
expect(fakeRepo.loadMessagesCalls, 1);
|
||||
expect(notifier.calls, 1);
|
||||
expect(notifier.lastDomain, 'potatomesh.net');
|
||||
expect(notifier.lastShortName, 'TEST');
|
||||
expect(notifier.lastLongName, 'Test Sender');
|
||||
});
|
||||
|
||||
test('returns true and no-ops when dependencies are missing', () async {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:potato_mesh_reader/main.dart';
|
||||
|
||||
void main() {
|
||||
test('uses drawable name for Android init and notifications', () {
|
||||
final client = LocalNotificationClient();
|
||||
|
||||
final androidInit = client.buildAndroidInitializationSettings();
|
||||
expect(androidInit.defaultIcon, 'ic_mesh_notification');
|
||||
|
||||
final details = client.notificationDetailsForTest();
|
||||
final androidDetails = details.android as AndroidNotificationDetails;
|
||||
expect(androidDetails.icon, 'ic_mesh_notification');
|
||||
expect(androidDetails.category, AndroidNotificationCategory.message);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright © 2025-26 l5yth & contributors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:potato_mesh_reader/main.dart';
|
||||
|
||||
class _StubRepository extends MeshRepository {
|
||||
_StubRepository(this.node) : super();
|
||||
|
||||
final MeshNode? node;
|
||||
|
||||
@override
|
||||
MeshNode? findNode(String domain, String nodeId) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
MeshMessage _buildMessage({
|
||||
required int id,
|
||||
required String nodeId,
|
||||
String text = 'hello',
|
||||
}) {
|
||||
final rx = DateTime.utc(2024, 1, 1, 12, id);
|
||||
return MeshMessage(
|
||||
id: id,
|
||||
rxTime: rx,
|
||||
rxIso: rx.toIso8601String(),
|
||||
fromId: nodeId,
|
||||
nodeId: nodeId,
|
||||
toId: '^',
|
||||
channel: 1,
|
||||
channelName: 'Main',
|
||||
portnum: 'TEXT',
|
||||
text: text,
|
||||
rssi: -50,
|
||||
snr: 1.0,
|
||||
hopLimit: 1,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
NodeShortNameCache.instance.clear();
|
||||
});
|
||||
|
||||
test('prefers node long name when resolving sender', () {
|
||||
const node = MeshNode(
|
||||
nodeId: '!NODE1',
|
||||
shortName: 'N1',
|
||||
longName: 'Verbose Node',
|
||||
);
|
||||
final repo = _StubRepository(node);
|
||||
final sender = repo.resolveNotificationSender(
|
||||
domain: 'potatomesh.net',
|
||||
message: _buildMessage(id: 1, nodeId: '!NODE1'),
|
||||
);
|
||||
|
||||
expect(sender.longName, 'Verbose Node');
|
||||
expect(sender.shortName, 'N1');
|
||||
expect(sender.preferredName, 'Verbose Node');
|
||||
});
|
||||
|
||||
test('falls back to short identifier when metadata is missing', () {
|
||||
final repo = _StubRepository(null);
|
||||
final sender = repo.resolveNotificationSender(
|
||||
domain: 'potatomesh.net',
|
||||
message: _buildMessage(id: 2, nodeId: '!NODE2'),
|
||||
);
|
||||
|
||||
expect(sender.longName, isNull);
|
||||
expect(sender.shortName, 'ODE2');
|
||||
expect(sender.preferredName, 'ODE2');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:ns1="http://sozi.baierouge.fr"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
id="svg7158"
|
||||
viewBox="0 0 453.54 453.54"
|
||||
version="1.1"
|
||||
>
|
||||
<title
|
||||
id="title7193"
|
||||
>Satellite Icon</title
|
||||
>
|
||||
<g
|
||||
id="layer1"
|
||||
transform="translate(-174.99 -97.13)"
|
||||
>
|
||||
<path
|
||||
id="path7145"
|
||||
style="fill:#000000"
|
||||
d="m287.44 286.58c8.8583-0.0756 18.161 1.2414 27.843 4.3505 23.639 7.5916 49.437 23.77 72.544 46.876 22.316 22.316 38.195 47.044 46.114 70.042 3.5848 10.411 5.3257 20.665 5.3294 30.127 34.464-34.381 74.719-74.501 74.719-74.501 8.8046-8.8046 10.662-25.016 4.0244-45.788-6.6374-20.772-21.622-44.897-43.287-66.562-21.664-21.665-45.789-36.649-66.561-43.287-20.772-6.6373-36.984-4.7803-45.788 4.0241a7.3368 7.3368 0 0 1 -0.32655 0.32655c0.00015-0.00038-0.15572-0.0378-0.32656 0.10658a7.3368 7.3368 0 0 1 -0.10733 0.10809c-0.0755 0.0755-0.23434 0.23735-0.3258 0.32655a7.3368 7.3368 0 0 1 -0.10734 0.10734c-0.0227 0.0227-0.0755 0.0907-0.10734 0.10734-0.27741 0.27062-0.46261 0.46942-0.87004 0.87005-1.3246 1.3035-3.2358 3.2497-5.7641 5.7642-5.0603 5.0256-12.407 12.42-21.317 21.317-11.973 11.959-29.332 29.327-45.679 45.68zm-40.639-122.78-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm233 156.07-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-115.4-38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm38.467 38.467-33.338-33.338 33.338-33.338 33.338 33.338zm-26.89-249.86c14.671-18.593 23.601-32.737 23.601-32.737 6.0975-9.8498 8.7897-20.878 6.5255-33.498-2.2639-12.621-9.7854-27.187-25.667-43.069-15.989-15.989-29.977-23.759-42.199-26.103-12.222-2.3439-23.154 0.29026-34.586 6.9608a8.7573 8.7573 0 0 1 -0.10733 0.10734c-0.90581 0.51174-0.23131 0.0975-0.4354 0.21694-0.20486 0.11566-0.55106 0.2948-0.97959 0.54275-0.85462 0.49889-2.117 1.2198-3.8063 2.284-3.3786 2.1287-8.3084 5.4327-14.574 10.006-3.793 2.7685-8.1932 6.2168-12.943 10.115 20.824 8.7749 42.483 23.558 62.211 43.287 19.646 19.646 34.196 41.165 42.96 61.885zm-243.08 138.23c15.825 15.825 27.262 20.215 35.673 20.012 8.2898-0.19956 16.009-4.9974 24.362-13.269 0.18141-0.20258 0.17082-0.17235 0.32654-0.32579 0.67351-0.65991 1.1912-1.0953 1.5228-1.3049 0.81396-0.51552 1.1331-0.63722 1.4148-0.75877 0.5639-0.24415 0.87103-0.43313 1.196-0.54349 0.65234-0.22073 1.3406-0.40517 2.1753-0.65311 1.67-0.49361 3.9356-1.1132 6.6343-1.8491 5.3972-1.4715 12.536-3.2413 19.686-5.1116 12.5-3.2709 22.067-5.7697 25.015-6.5254 5.2003-6.2337 6.9486-16.641 2.8278-30.888-4.3795-15.141-14.954-33.009-30.779-48.834s-33.693-26.4-48.834-30.779c-14.246-4.121-24.654-2.3723-30.888 2.8278-0.39307 1.4287-0.92984 3.0175-1.5226 5.2205-1.4259 5.3024-3.3261 12.432-5.2202 19.577-3.793 14.289-7.613 28.713-7.613 28.713a9.2981 9.2981 0 0 1 -2.3919 4.1331c-8.4974 8.4974-13.392 16.277-13.595 24.689-0.20259 8.4119 4.1869 19.848 20.012 35.673z"
|
||||
/>
|
||||
</g
|
||||
>
|
||||
<metadata
|
||||
>
|
||||
<rdf:RDF
|
||||
>
|
||||
<cc:Work
|
||||
>
|
||||
<dc:format
|
||||
>image/svg+xml</dc:format
|
||||
>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage"
|
||||
/>
|
||||
<cc:license
|
||||
rdf:resource="http://creativecommons.org/licenses/publicdomain/"
|
||||
/>
|
||||
<dc:publisher
|
||||
>
|
||||
<cc:Agent
|
||||
rdf:about="http://openclipart.org/"
|
||||
>
|
||||
<dc:title
|
||||
>Openclipart</dc:title
|
||||
>
|
||||
</cc:Agent
|
||||
>
|
||||
</dc:publisher
|
||||
>
|
||||
</cc:Work
|
||||
>
|
||||
<cc:License
|
||||
rdf:about="http://creativecommons.org/licenses/publicdomain/"
|
||||
>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Reproduction"
|
||||
/>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#Distribution"
|
||||
/>
|
||||
<cc:permits
|
||||
rdf:resource="http://creativecommons.org/ns#DerivativeWorks"
|
||||
/>
|
||||
</cc:License
|
||||
>
|
||||
</rdf:RDF
|
||||
>
|
||||
</metadata
|
||||
>
|
||||
</svg
|
||||
>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -113,6 +113,36 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshNodeInformation surfaces sats_in_view from the latest position packet', async () => {
|
||||
const calls = [];
|
||||
const responses = new Map([
|
||||
['/api/nodes/!sat?limit=7', createResponse(200, {
|
||||
node_id: '!sat',
|
||||
short_name: 'SAT',
|
||||
})],
|
||||
['/api/telemetry/!sat?limit=1000', createResponse(404, { error: 'not found' })],
|
||||
['/api/positions/!sat?limit=7', createResponse(200, [
|
||||
{ node_id: '!sat', position_time: 200, rx_time: 200, latitude: 1.1, longitude: 2.1, sats_in_view: 8 },
|
||||
{ node_id: '!sat', position_time: 100, rx_time: 100, latitude: 1, longitude: 2, sats_in_view: 3 },
|
||||
])],
|
||||
['/api/neighbors/!sat?limit=1000', createResponse(404, { error: 'not found' })],
|
||||
]);
|
||||
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return responses.get(url) ?? createResponse(404, { error: 'not found' });
|
||||
};
|
||||
|
||||
const node = await refreshNodeInformation({ nodeId: '!sat' }, { fetchImpl });
|
||||
|
||||
assert.equal(node.satsInView, 8);
|
||||
assert.equal(node.sats_in_view, 8);
|
||||
assert.ok(node.position);
|
||||
assert.equal(node.position.sats_in_view, 8);
|
||||
assert.equal(node.rawSources.position.sats_in_view, 8);
|
||||
assert.equal(calls.length, 4);
|
||||
});
|
||||
|
||||
test('refreshNodeInformation normalizes telemetry aliases for downstream consumers', async () => {
|
||||
const responses = new Map([
|
||||
['/api/nodes/!chan?limit=7', createResponse(404, { error: 'not found' })],
|
||||
|
||||
@@ -354,12 +354,14 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
voltage: 4.1,
|
||||
channel_utilization: 40,
|
||||
air_util_tx: 22,
|
||||
current: 0.75,
|
||||
},
|
||||
environment_metrics: {
|
||||
temperature: 19.5,
|
||||
relative_humidity: 55,
|
||||
barometric_pressure: 995,
|
||||
gas_resistance: 1500,
|
||||
iaq: 83,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -369,12 +371,14 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
voltage: 4.05,
|
||||
channelUtilization: 35,
|
||||
airUtilTx: 20,
|
||||
current: 0.65,
|
||||
},
|
||||
environmentMetrics: {
|
||||
temperature: 18.4,
|
||||
relativeHumidity: 52,
|
||||
barometricPressure: 1000,
|
||||
gasResistance: 2000,
|
||||
iaq: 88,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -387,12 +391,15 @@ test('renderTelemetryCharts renders condensed scatter charts when telemetry exis
|
||||
assert.equal(html.includes('node-detail__charts'), true);
|
||||
assert.equal(html.includes('Power metrics'), true);
|
||||
assert.equal(html.includes('Environmental telemetry'), true);
|
||||
assert.equal(html.includes('Battery (0-100%)'), true);
|
||||
assert.equal(html.includes('Voltage (0-6V)'), true);
|
||||
assert.equal(html.includes('Battery (%)'), true);
|
||||
assert.equal(html.includes('Voltage (V)'), true);
|
||||
assert.equal(html.includes('Current (A)'), true);
|
||||
assert.equal(html.includes('Channel utilization (%)'), true);
|
||||
assert.equal(html.includes('Air util TX (%)'), true);
|
||||
assert.equal(html.includes('Utilization (%)'), true);
|
||||
assert.equal(html.includes('Gas resistance (\u03a9)'), true);
|
||||
assert.equal(html.includes('Air quality'), true);
|
||||
assert.equal(html.includes('IAQ index'), true);
|
||||
assert.equal(html.includes('Temperature (\u00b0C)'), true);
|
||||
assert.equal(html.includes(expectedDate), true);
|
||||
assert.equal(html.includes('node-detail__chart-point'), true);
|
||||
@@ -454,6 +461,7 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
|
||||
battery_level: 75,
|
||||
voltage: 4.08,
|
||||
channel_utilization: 30,
|
||||
current: 0.42,
|
||||
temperature: 20,
|
||||
relative_humidity: 45,
|
||||
barometric_pressure: 990,
|
||||
@@ -469,6 +477,7 @@ test('renderNodeDetailHtml embeds telemetry charts when snapshots are present',
|
||||
});
|
||||
assert.equal(html.includes('node-detail__charts'), true);
|
||||
assert.equal(html.includes('Power metrics'), true);
|
||||
assert.equal(html.includes('Air quality'), true);
|
||||
});
|
||||
|
||||
test('fetchNodeDetailHtml renders the node layout for overlays', async () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { renderSatsInViewBadge, resolveSatsInView, __testUtils } from '../short-info-satellites.js';
|
||||
|
||||
const { toPositiveInteger } = __testUtils;
|
||||
|
||||
test('resolveSatsInView inspects aliases and nested payloads', () => {
|
||||
assert.equal(resolveSatsInView({ sats_in_view: '3.6' }), 4);
|
||||
assert.equal(resolveSatsInView({ position: { satsInView: 5 } }), 5);
|
||||
assert.equal(resolveSatsInView({ rawSources: { position: { sats_in_view: 9 } } }), 9);
|
||||
assert.equal(resolveSatsInView({ satsInView: 0 }), null);
|
||||
assert.equal(resolveSatsInView(null), null);
|
||||
});
|
||||
|
||||
test('renderSatsInViewBadge returns markup only for positive counts', () => {
|
||||
const html = renderSatsInViewBadge({ satsInView: 6 });
|
||||
assert.match(html, /short-info-sats/);
|
||||
assert.ok(html.includes('satellite-icon.svg'));
|
||||
assert.match(html, />6</);
|
||||
|
||||
assert.equal(renderSatsInViewBadge({ satsInView: 0 }), '');
|
||||
assert.equal(renderSatsInViewBadge({ position: { sats_in_view: -1 } }), '');
|
||||
});
|
||||
|
||||
test('toPositiveInteger normalizes numeric values defensively', () => {
|
||||
assert.equal(toPositiveInteger('7.2'), 7);
|
||||
assert.equal(toPositiveInteger(''), null);
|
||||
assert.equal(toPositiveInteger(-3), null);
|
||||
assert.equal(toPositiveInteger(NaN), null);
|
||||
});
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
fmtTemperature,
|
||||
fmtTx,
|
||||
} from './short-info-telemetry.js';
|
||||
import { renderSatsInViewBadge } from './short-info-satellites.js';
|
||||
import { createMessageNodeHydrator } from './message-node-hydrator.js';
|
||||
import {
|
||||
extractChatMessageMetadata,
|
||||
@@ -2002,6 +2003,7 @@ export function initializeApp(config) {
|
||||
['latitude', source.latitude],
|
||||
['longitude', source.longitude],
|
||||
['altitude', source.altitude],
|
||||
['satsInView', source.satsInView ?? source.sats_in_view],
|
||||
['positionTime', source.positionTime ?? source.position_time],
|
||||
];
|
||||
for (const [key, value] of numericPairs) {
|
||||
@@ -2116,6 +2118,10 @@ export function initializeApp(config) {
|
||||
if (nodeIdValue !== '—') {
|
||||
shortParts.push(`<span class="mono">${escapeHtml(nodeIdValue)}</span>`);
|
||||
}
|
||||
const satelliteLine = renderSatsInViewBadge(overlayInfo);
|
||||
if (satelliteLine) {
|
||||
shortParts.push(satelliteLine);
|
||||
}
|
||||
if (shortParts.length) {
|
||||
lines.push(shortParts.join(' '));
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ function mergeNodeFields(target, record) {
|
||||
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
|
||||
mergeModemMetadata(target, record);
|
||||
assignNumber(target, 'snr', extractNumber(record, ['snr']));
|
||||
assignNumber(target, 'satsInView', extractNumber(record, ['sats_in_view', 'satsInView']));
|
||||
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
|
||||
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
|
||||
assignNumber(target, 'uptime', extractNumber(record, ['uptime', 'uptime_seconds', 'uptimeSeconds']));
|
||||
@@ -278,6 +279,8 @@ function mergePosition(target, position) {
|
||||
assignString(target, 'lastSeenIso', extractString(position, ['rx_iso', 'rxIso']), { preferExisting: true });
|
||||
}
|
||||
}
|
||||
|
||||
assignNumber(target, 'satsInView', extractNumber(position, ['sats_in_view', 'satsInView']), { preferExisting: true });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from './chat-format.js';
|
||||
import {
|
||||
fmtAlt,
|
||||
fmtCurrent,
|
||||
fmtHumidity,
|
||||
fmtPressure,
|
||||
fmtTemperature,
|
||||
@@ -53,7 +54,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
{
|
||||
id: 'battery',
|
||||
position: 'left',
|
||||
label: 'Battery (0-100%)',
|
||||
label: 'Battery (%)',
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: 4,
|
||||
@@ -62,12 +63,21 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
{
|
||||
id: 'voltage',
|
||||
position: 'right',
|
||||
label: 'Voltage (0-6V)',
|
||||
label: 'Voltage (V)',
|
||||
min: 0,
|
||||
max: 6,
|
||||
ticks: 3,
|
||||
color: '#9ebcda',
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
position: 'rightSecondary',
|
||||
label: 'Current (A)',
|
||||
min: 0,
|
||||
max: 3,
|
||||
ticks: 3,
|
||||
color: '#3182bd',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
@@ -75,7 +85,7 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
axis: 'battery',
|
||||
color: '#8856a7',
|
||||
label: 'Battery level',
|
||||
legend: 'Battery (0-100%)',
|
||||
legend: 'Battery (%)',
|
||||
fields: ['battery', 'battery_level', 'batteryLevel'],
|
||||
valueFormatter: value => `${value.toFixed(1)}%`,
|
||||
},
|
||||
@@ -84,10 +94,19 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
axis: 'voltage',
|
||||
color: '#9ebcda',
|
||||
label: 'Voltage',
|
||||
legend: 'Voltage (0-6V)',
|
||||
legend: 'Voltage (V)',
|
||||
fields: ['voltage', 'voltageReading'],
|
||||
valueFormatter: value => `${value.toFixed(2)} V`,
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
axis: 'current',
|
||||
color: '#3182bd',
|
||||
label: 'Current',
|
||||
legend: 'Current (A)',
|
||||
fields: ['current'],
|
||||
valueFormatter: value => fmtCurrent(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -148,25 +167,6 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
color: '#91bfdb',
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
id: 'pressure',
|
||||
position: 'right',
|
||||
label: 'Pressure (hPa)',
|
||||
min: 800,
|
||||
max: 1_100,
|
||||
ticks: 4,
|
||||
color: '#c51b8a',
|
||||
},
|
||||
{
|
||||
id: 'gas',
|
||||
position: 'rightSecondary',
|
||||
label: 'Gas resistance (\u03a9)',
|
||||
min: 10,
|
||||
max: 100_000,
|
||||
ticks: 5,
|
||||
color: '#fa9fb5',
|
||||
scale: 'log',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
@@ -187,6 +187,42 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
fields: ['humidity', 'relative_humidity', 'relativeHumidity'],
|
||||
valueFormatter: value => `${value.toFixed(1)}%`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'airQuality',
|
||||
title: 'Air quality',
|
||||
axes: [
|
||||
{
|
||||
id: 'pressure',
|
||||
position: 'left',
|
||||
label: 'Pressure (hPa)',
|
||||
min: 800,
|
||||
max: 1_100,
|
||||
ticks: 4,
|
||||
color: '#c51b8a',
|
||||
},
|
||||
{
|
||||
id: 'gas',
|
||||
position: 'right',
|
||||
label: 'Gas resistance (\u03a9)',
|
||||
min: 10,
|
||||
max: 100_000,
|
||||
ticks: 5,
|
||||
color: '#fa9fb5',
|
||||
scale: 'log',
|
||||
},
|
||||
{
|
||||
id: 'iaq',
|
||||
position: 'rightSecondary',
|
||||
label: 'IAQ index',
|
||||
min: 0,
|
||||
max: 500,
|
||||
ticks: 5,
|
||||
color: '#636363',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
id: 'pressure',
|
||||
axis: 'pressure',
|
||||
@@ -205,6 +241,15 @@ const TELEMETRY_CHART_SPECS = Object.freeze([
|
||||
fields: ['gas_resistance', 'gasResistance'],
|
||||
valueFormatter: value => formatGasResistance(value),
|
||||
},
|
||||
{
|
||||
id: 'iaq',
|
||||
axis: 'iaq',
|
||||
color: '#636363',
|
||||
label: 'IAQ',
|
||||
legend: 'IAQ index',
|
||||
fields: ['iaq'],
|
||||
valueFormatter: value => value.toFixed(0),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -74,6 +74,7 @@ const FIELD_ALIASES = Object.freeze([
|
||||
{ keys: ['relative_humidity', 'relativeHumidity', 'humidity'], normalise: normalizeNumber },
|
||||
{ keys: ['barometric_pressure', 'barometricPressure', 'pressure'], normalise: normalizeNumber },
|
||||
{ keys: ['gas_resistance', 'gasResistance'], normalise: normalizeNumber },
|
||||
{ keys: ['sats_in_view', 'satsInView'], normalise: normalizeNumber },
|
||||
{ keys: ['snr'], normalise: normalizeNumber },
|
||||
{ keys: ['last_heard', 'lastHeard'], normalise: normalizeNumber },
|
||||
{ keys: ['last_seen_iso', 'lastSeenIso'], normalise: normalizeString },
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright © 2025-26 l5yth & contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Coerce a candidate value into a positive integer satellite count.
|
||||
*
|
||||
* @param {*} value Raw candidate value.
|
||||
* @returns {number|null} Rounded positive integer or ``null``.
|
||||
*/
|
||||
function toPositiveInteger(value) {
|
||||
if (value == null || value === '') return null;
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
return Math.round(num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the satellite count from a node-like payload.
|
||||
*
|
||||
* @param {*} info Node payload potentially containing satellite metadata.
|
||||
* @returns {number|null} Satellite count when present and positive.
|
||||
*/
|
||||
export function resolveSatsInView(info) {
|
||||
if (!info || typeof info !== 'object') {
|
||||
return toPositiveInteger(info);
|
||||
}
|
||||
const candidates = [
|
||||
info.satsInView,
|
||||
info.sats_in_view,
|
||||
info.position?.satsInView,
|
||||
info.position?.sats_in_view,
|
||||
info.rawSources?.position?.satsInView,
|
||||
info.rawSources?.position?.sats_in_view,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const count = toPositiveInteger(candidate);
|
||||
if (count != null) {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ICON_PATH = '/assets/img/satellite-icon.svg';
|
||||
|
||||
/**
|
||||
* Render a short-info overlay row describing visible satellites.
|
||||
*
|
||||
* @param {*} info Node payload providing satellite metadata.
|
||||
* @returns {string} HTML snippet or an empty string when unavailable.
|
||||
*/
|
||||
export function renderSatsInViewBadge(info) {
|
||||
const count = resolveSatsInView(info);
|
||||
if (count == null) {
|
||||
return '';
|
||||
}
|
||||
return [
|
||||
'<span class="short-info-sats" aria-label="Satellites in view">',
|
||||
`<span class="short-info-sats__icon" aria-hidden="true">` +
|
||||
`<img src="${ICON_PATH}" alt="" width="14" height="14" loading="lazy" decoding="async" class="short-info-sats__glyph">` +
|
||||
`</span>`,
|
||||
`<span class="short-info-sats__count">${count}</span>`,
|
||||
'</span>',
|
||||
].join('');
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
toPositiveInteger,
|
||||
};
|
||||
@@ -783,6 +783,39 @@ body.view-map .map-panel--full #map {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.short-info-sats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.short-info-sats__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.short-info-sats__glyph {
|
||||
display: block;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
body.dark .short-info-sats__glyph {
|
||||
filter: invert(1) grayscale(1);
|
||||
}
|
||||
|
||||
.short-info-sats__count {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: 0.1px;
|
||||
}
|
||||
|
||||
.node-detail-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user