Skip to content

Commit 1a7c156

Browse files
authored
Merge pull request #138 from FreeTAKTeam/codex/eam-readiness-rust
[codex] Move EAM readiness calculations to Rust
2 parents 449efa9 + 3e7165c commit 1a7c156

43 files changed

Lines changed: 2685 additions & 993 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/mobile/android/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ dependencies {
120120
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
121121
implementation project(':capacitor-android')
122122
testImplementation "junit:junit:$junitVersion"
123+
testImplementation "org.json:json:20240303"
123124
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
124125
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
125126
implementation project(':capacitor-cordova-android-plugins')
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
package network.reticulum.emergency;
2+
3+
import org.json.JSONArray;
4+
import org.json.JSONException;
5+
import org.json.JSONObject;
6+
7+
import java.util.Locale;
8+
9+
final class RemWatchStatusPayload {
10+
private RemWatchStatusPayload() {
11+
}
12+
13+
static String build(
14+
String statusJson,
15+
String operationalSummaryJson,
16+
String eamReadinessJson,
17+
String eventsJson,
18+
String telemetryPositionsJson,
19+
long nowMs
20+
) throws JSONException {
21+
final JSONObject status = parseObject(statusJson);
22+
final JSONObject summary = parseObject(operationalSummaryJson);
23+
final JSONObject readiness = parseObject(eamReadinessJson);
24+
final JSONArray events = itemsArray(parseObject(eventsJson));
25+
final JSONObject telemetry = parseObject(telemetryPositionsJson);
26+
27+
final boolean running = status.optBoolean("running", summary.optBoolean("running", false));
28+
final String runtimeError = firstNonBlank(
29+
status.optString("lastError", ""),
30+
status.optString("last_error", "")
31+
);
32+
final String operatorName = firstNonBlank(
33+
status.optString("name", ""),
34+
status.optString("operatorName", ""),
35+
status.optString("displayName", ""),
36+
"REM"
37+
);
38+
final String connectionState = !runtimeError.isEmpty()
39+
? "ERROR"
40+
: running
41+
? "CONNECTED"
42+
: "OFFLINE";
43+
final String operatorStatus = "ERROR".equals(connectionState)
44+
? "ERROR"
45+
: running ? "ACTIVE" : "STOPPED";
46+
final String operatorEam = findOperatorReadinessBand(readiness, operatorName);
47+
final String teamStatus = teamStatus(readiness, operatorEam);
48+
final JSONObject latestEvent = latestEvent(events);
49+
final int activeEvents = Math.max(summary.optInt("eventCount", events.length()), events.length());
50+
final long lastSyncMs = positiveLong(summary, "updatedAtMs", summary.optLong("updated_at_ms", nowMs));
51+
final long ageSeconds = Math.max(0L, (nowMs - (lastSyncMs > 0L ? lastSyncMs : nowMs)) / 1_000L);
52+
final String highestPriority = highestPriority(connectionState, teamStatus, latestEvent);
53+
54+
final JSONObject payload = new JSONObject();
55+
payload.put("type", "rem.watch.status");
56+
payload.put("version", 1);
57+
payload.put("connection_state", connectionState);
58+
payload.put("operator_name", operatorName);
59+
payload.put("operator_status", operatorStatus);
60+
payload.put("operator_eam", operatorEam);
61+
payload.put("team", firstNonBlank(status.optString("team", ""), "REM"));
62+
payload.put("team_status", teamStatus);
63+
payload.put("last_sync_epoch_ms", lastSyncMs > 0L ? lastSyncMs : nowMs);
64+
payload.put("last_sync_age_seconds", ageSeconds);
65+
payload.put("active_events", activeEvents);
66+
payload.put("highest_priority", highestPriority);
67+
payload.put("alert_state", "ERROR".equals(connectionState) ? "ERROR" : "EMERGENCY".equals(highestPriority) ? "ALERT" : "NORMAL");
68+
69+
if (latestEvent != null) {
70+
payload.put("latest_event", latestEvent);
71+
}
72+
73+
final JSONObject position = latestPosition(telemetry);
74+
if (position != null) {
75+
payload.put("position", position);
76+
}
77+
78+
return payload.toString();
79+
}
80+
81+
private static JSONObject parseObject(String raw) {
82+
if (raw == null || raw.trim().isEmpty()) {
83+
return new JSONObject();
84+
}
85+
try {
86+
return new JSONObject(raw);
87+
} catch (JSONException ex) {
88+
return new JSONObject();
89+
}
90+
}
91+
92+
private static JSONArray itemsArray(JSONObject object) {
93+
final JSONArray items = object.optJSONArray("items");
94+
return items == null ? new JSONArray() : items;
95+
}
96+
97+
private static String firstNonBlank(String... values) {
98+
for (String value : values) {
99+
if (value != null && !value.trim().isEmpty()) {
100+
return value.trim();
101+
}
102+
}
103+
return "";
104+
}
105+
106+
private static long positiveLong(JSONObject object, String key, long fallback) {
107+
final long value = object.optLong(key, fallback);
108+
return value > 0L ? value : fallback;
109+
}
110+
111+
private static String findOperatorReadinessBand(JSONObject readiness, String operatorName) {
112+
final JSONArray messages = readiness.optJSONArray("messages");
113+
if (messages == null) {
114+
return "UNKNOWN";
115+
}
116+
for (int index = 0; index < messages.length(); index += 1) {
117+
final JSONObject message = messages.optJSONObject(index);
118+
if (message == null) {
119+
continue;
120+
}
121+
final String callsign = message.optString("callsign", "").trim();
122+
if (!callsign.equalsIgnoreCase(operatorName)) {
123+
continue;
124+
}
125+
return normalizeBand(message.optString("overallBand", message.optString("overall_band", "UNKNOWN")));
126+
}
127+
return "UNKNOWN";
128+
}
129+
130+
private static String teamStatus(JSONObject readiness, String operatorEam) {
131+
String selected = "UNKNOWN";
132+
int selectedRank = severityRank(operatorEam);
133+
if (selectedRank > 0) {
134+
selected = normalizeBand(operatorEam);
135+
}
136+
137+
final JSONArray metrics = readiness.optJSONArray("statusMetrics");
138+
if (metrics == null) {
139+
return selected;
140+
}
141+
for (int index = 0; index < metrics.length(); index += 1) {
142+
final JSONObject metric = metrics.optJSONObject(index);
143+
if (metric == null) {
144+
continue;
145+
}
146+
final String band = normalizeBand(metric.optString("band", ""));
147+
final int rank = severityRank(band);
148+
if (rank > selectedRank) {
149+
selected = band;
150+
selectedRank = rank;
151+
}
152+
}
153+
return selected;
154+
}
155+
156+
private static JSONObject latestEvent(JSONArray events) throws JSONException {
157+
for (int index = 0; index < events.length(); index += 1) {
158+
final JSONObject record = events.optJSONObject(index);
159+
if (record == null || record.optLong("deletedAtMs", record.optLong("deleted_at_ms", 0L)) > 0L) {
160+
continue;
161+
}
162+
163+
final JSONObject args = record.optJSONObject("args") == null
164+
? new JSONObject()
165+
: record.optJSONObject("args");
166+
final JSONObject source = record.optJSONObject("source") == null
167+
? new JSONObject()
168+
: record.optJSONObject("source");
169+
final JSONArray keywords = args.optJSONArray("keywords");
170+
final String category = keywords != null && keywords.length() > 0
171+
? String.valueOf(keywords.opt(0)).toUpperCase(Locale.US)
172+
: normalizeCategory(record.optString("command_type", record.optString("type", "EVENT")));
173+
final String title = firstNonBlank(
174+
args.optString("content", ""),
175+
record.optString("title", ""),
176+
"Event"
177+
);
178+
final String eventSource = firstNonBlank(
179+
source.optString("display_name", ""),
180+
args.optString("callsign", ""),
181+
source.optString("rns_identity", ""),
182+
"REM"
183+
);
184+
final String time = firstNonBlank(
185+
args.optString("server_time", ""),
186+
args.optString("serverTime", ""),
187+
record.optString("timestamp", ""),
188+
args.optString("client_time", "")
189+
);
190+
191+
final JSONObject latest = new JSONObject();
192+
latest.put("severity", eventSeverity(category, title));
193+
latest.put("category", category);
194+
latest.put("title", title);
195+
latest.put("source", eventSource);
196+
latest.put("time", time);
197+
return latest;
198+
}
199+
return null;
200+
}
201+
202+
private static JSONObject latestPosition(JSONObject telemetry) throws JSONException {
203+
final JSONArray items = itemsArray(telemetry);
204+
if (items.length() == 0) {
205+
return null;
206+
}
207+
final JSONObject item = items.optJSONObject(0);
208+
if (item == null) {
209+
return null;
210+
}
211+
final JSONObject position = new JSONObject();
212+
if (item.has("lat")) {
213+
position.put("lat", item.optDouble("lat"));
214+
}
215+
if (item.has("lon")) {
216+
position.put("lon", item.optDouble("lon"));
217+
}
218+
final String mgrs = firstNonBlank(item.optString("mgrs", ""), item.optString("grid", ""));
219+
if (!mgrs.isEmpty()) {
220+
position.put("mgrs", mgrs);
221+
}
222+
return position.length() == 0 ? null : position;
223+
}
224+
225+
private static String highestPriority(String connectionState, String teamStatus, JSONObject latestEvent) {
226+
if ("ERROR".equals(connectionState)) {
227+
return "ERROR";
228+
}
229+
if (latestEvent != null) {
230+
final String severity = latestEvent.optString("severity", "HIGH").toUpperCase(Locale.US);
231+
if ("EMERGENCY".equals(severity)) {
232+
return "EMERGENCY";
233+
}
234+
return "HIGH";
235+
}
236+
final int rank = severityRank(teamStatus);
237+
if (rank >= 3) {
238+
return "EMERGENCY";
239+
}
240+
if (rank == 2) {
241+
return "HIGH";
242+
}
243+
return "NORMAL";
244+
}
245+
246+
private static String eventSeverity(String category, String title) {
247+
final String combined = (category + " " + title).toUpperCase(Locale.US);
248+
if (combined.contains("SOS") || combined.contains("EMERGENCY") || combined.contains("MAYDAY")) {
249+
return "EMERGENCY";
250+
}
251+
return "HIGH";
252+
}
253+
254+
private static String normalizeCategory(String value) {
255+
final String trimmed = firstNonBlank(value, "EVENT");
256+
final int separator = trimmed.indexOf('.');
257+
return (separator >= 0 ? trimmed.substring(separator + 1) : trimmed).toUpperCase(Locale.US);
258+
}
259+
260+
private static String normalizeBand(String value) {
261+
final String normalized = firstNonBlank(value, "UNKNOWN").toLowerCase(Locale.US);
262+
if ("red".equals(normalized)) {
263+
return "Red";
264+
}
265+
if ("yellow".equals(normalized)) {
266+
return "Yellow";
267+
}
268+
if ("orange".equals(normalized)) {
269+
return "Orange";
270+
}
271+
if ("green".equals(normalized)) {
272+
return "Green";
273+
}
274+
return "UNKNOWN";
275+
}
276+
277+
private static int severityRank(String value) {
278+
final String normalized = String.valueOf(value).toLowerCase(Locale.US);
279+
if ("red".equals(normalized) || "emergency".equals(normalized) || "error".equals(normalized)) {
280+
return 3;
281+
}
282+
if ("orange".equals(normalized) || "yellow".equals(normalized) || "high".equals(normalized) || "urgent".equals(normalized)) {
283+
return 2;
284+
}
285+
if ("green".equals(normalized) || "normal".equals(normalized)) {
286+
return 1;
287+
}
288+
return 0;
289+
}
290+
}

0 commit comments

Comments
 (0)