Initial commit
This commit is contained in:
33
.editorconfig
Normal file
33
.editorconfig
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.java]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.xml]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.html]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.css]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
45
.gitattributes
vendored
Normal file
45
.gitattributes
vendored
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Auto-detect text files and normalize line endings to LF on commit
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Java source
|
||||||
|
*.java text eol=lf
|
||||||
|
*.xml text eol=lf
|
||||||
|
*.properties text eol=lf
|
||||||
|
|
||||||
|
# Web
|
||||||
|
*.html text eol=lf
|
||||||
|
*.css text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
|
||||||
|
# Python
|
||||||
|
*.py text eol=lf
|
||||||
|
|
||||||
|
# Docs
|
||||||
|
*.md text eol=lf
|
||||||
|
*.txt text eol=lf
|
||||||
|
|
||||||
|
# Shell scripts
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Windows scripts (keep CRLF)
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# Binary - do not diff or merge
|
||||||
|
*.class binary
|
||||||
|
*.jar binary
|
||||||
|
*.war binary
|
||||||
|
*.ear binary
|
||||||
|
*.db binary
|
||||||
|
*.sqlite binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
|
*.docx binary
|
||||||
|
*.xlsx binary
|
||||||
|
*.zip binary
|
||||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
*.nar
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# IDE - IntelliJ IDEA
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
|
||||||
|
# IDE - Eclipse
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# IDE - VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# IDE - NetBeans
|
||||||
|
nbproject/
|
||||||
|
nbbuild/
|
||||||
|
nbdist/
|
||||||
|
.nb-gradle/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Nexus Workshops LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
BIN
docs/Robot_Telemetry_Complete_Field_Guide.docx
Executable file
BIN
docs/Robot_Telemetry_Complete_Field_Guide.docx
Executable file
Binary file not shown.
BIN
docs/Robot_Telemetry_Complete_Field_Guide.pdf
Normal file
BIN
docs/Robot_Telemetry_Complete_Field_Guide.pdf
Normal file
Binary file not shown.
61
pom.xml
Executable file
61
pom.xml
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>com.nexusworkshops</groupId>
|
||||||
|
<artifactId>udp-telemetry</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
|
<maven.compiler.target>11</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<mainClass>com.nexusworkshops.telemetry.Main</mainClass>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- JSON serialization - no other deps needed -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
<version>2.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>com.nexusworkshops.telemetry.Main</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals><goal>shade</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<createDependencyReducedPom>false</createDependencyReducedPom>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
56
src/main/java/com/nexusworkshops/telemetry/Main.java
Executable file
56
src/main/java/com/nexusworkshops/telemetry/Main.java
Executable file
@@ -0,0 +1,56 @@
|
|||||||
|
package com.nexusworkshops.telemetry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo main -- simulates robot telemetry from your desktop.
|
||||||
|
* Run telelog.py in one terminal, this in another.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* java -jar target/udp-telemetry-1.0.0.jar
|
||||||
|
* java -jar target/udp-telemetry-1.0.0.jar 127.0.0.1 3000
|
||||||
|
*/
|
||||||
|
public class Main {
|
||||||
|
|
||||||
|
public static void main(String[] args) throws InterruptedException {
|
||||||
|
String host = args.length > 0 ? args[0] : "127.0.0.1";
|
||||||
|
int port = args.length > 1 ? Integer.parseInt(args[1]) : 3000;
|
||||||
|
int packets = 20;
|
||||||
|
|
||||||
|
System.out.println("[Main] Sending " + packets + " telemetry packets to " + host + ":" + port);
|
||||||
|
|
||||||
|
UdpTelemetry t = new UdpTelemetry(host, port);
|
||||||
|
|
||||||
|
for (int i = 0; i < packets; i++) {
|
||||||
|
double t_sec = i * 0.05;
|
||||||
|
|
||||||
|
// Simulate motor state
|
||||||
|
double leftPower = Math.sin(t_sec) * 0.8;
|
||||||
|
double rightPower = Math.sin(t_sec + 0.1) * 0.8;
|
||||||
|
|
||||||
|
// Simulate chute hood sweeping open
|
||||||
|
double hoodPos = Math.min(1.0, i / (double) packets);
|
||||||
|
|
||||||
|
// Simulate IMU
|
||||||
|
double yaw = t_sec * 15.0; // degrees
|
||||||
|
double steering = leftPower - rightPower;
|
||||||
|
|
||||||
|
t.put("left_motor", "power", leftPower)
|
||||||
|
.put("left_motor", "temp", 35.0 + i * 0.2)
|
||||||
|
.put("right_motor", "power", rightPower)
|
||||||
|
.put("right_motor", "temp", 34.5 + i * 0.15)
|
||||||
|
.put("chute_hood", "position", hoodPos)
|
||||||
|
.put("imu", "yaw", yaw)
|
||||||
|
.put("imu", "steering", steering)
|
||||||
|
.put("loop_time_ms", i * 50); // flat scalar, no explicit subject
|
||||||
|
|
||||||
|
t.send();
|
||||||
|
|
||||||
|
System.out.printf("[Main] packet %02d yaw=%.1f left=%.2f right=%.2f hood=%.2f%n",
|
||||||
|
i + 1, yaw, leftPower, rightPower, hoodPos);
|
||||||
|
|
||||||
|
Thread.sleep(50); // 50ms loop, ~20Hz
|
||||||
|
}
|
||||||
|
|
||||||
|
t.close();
|
||||||
|
System.out.println("[Main] Done.");
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/main/java/com/nexusworkshops/telemetry/UdpTelemetry.java
Executable file
154
src/main/java/com/nexusworkshops/telemetry/UdpTelemetry.java
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
package com.nexusworkshops.telemetry;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
|
||||||
|
import java.net.DatagramPacket;
|
||||||
|
import java.net.DatagramSocket;
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UdpTelemetry -- sends structured telemetry packets over UDP to telelog.py.
|
||||||
|
*
|
||||||
|
* Packets are JSON objects where top-level keys are subjects and their
|
||||||
|
* values are objects of predicate/value pairs. Flat scalars are allowed
|
||||||
|
* too and will be stored by telelog with the source IP as subject.
|
||||||
|
*
|
||||||
|
* Example packet sent:
|
||||||
|
* {
|
||||||
|
* "left_motor": {"power": 0.8, "temp": 42.1},
|
||||||
|
* "right_motor": {"power": 0.6, "temp": 38.5},
|
||||||
|
* "chute_hood": {"position": 0.45}
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* UdpTelemetry t = new UdpTelemetry("192.168.43.100", 3000);
|
||||||
|
* t.put("left_motor", "power", 0.8);
|
||||||
|
* t.put("left_motor", "temp", 42.1);
|
||||||
|
* t.put("right_motor", "power", 0.6);
|
||||||
|
* t.send(); // transmits and clears the buffer
|
||||||
|
* t.close();
|
||||||
|
*/
|
||||||
|
public class UdpTelemetry {
|
||||||
|
|
||||||
|
private final String host;
|
||||||
|
private final int port;
|
||||||
|
private final Gson gson;
|
||||||
|
private DatagramSocket socket;
|
||||||
|
|
||||||
|
// subject -> (predicate -> value)
|
||||||
|
private final Map<String, Map<String, Object>> buffer;
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public UdpTelemetry(String host, int port) {
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.gson = new Gson();
|
||||||
|
this.buffer = new LinkedHashMap<>();
|
||||||
|
try {
|
||||||
|
this.socket = new DatagramSocket();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to open UDP socket: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Building a packet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a subject/predicate/value triple to the outgoing buffer.
|
||||||
|
* Call this multiple times before send() to batch related data.
|
||||||
|
*/
|
||||||
|
public UdpTelemetry put(String subject, String predicate, Object value) {
|
||||||
|
buffer.computeIfAbsent(subject, k -> new LinkedHashMap<>())
|
||||||
|
.put(predicate, value);
|
||||||
|
return this; // fluent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: flat key/value with no explicit subject.
|
||||||
|
* telelog will assign the source IP as the subject.
|
||||||
|
*/
|
||||||
|
public UdpTelemetry put(String predicate, Object value) {
|
||||||
|
// Store under a sentinel key that becomes a top-level scalar in JSON
|
||||||
|
buffer.computeIfAbsent("", k -> new LinkedHashMap<>())
|
||||||
|
.put(predicate, value);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Sending
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the current buffer to JSON and transmit it, then clear.
|
||||||
|
* Safe to call even if buffer is empty (sends an empty object).
|
||||||
|
*/
|
||||||
|
public void send() {
|
||||||
|
JsonObject json = new JsonObject();
|
||||||
|
|
||||||
|
for (Map.Entry<String, Map<String, Object>> subjectEntry : buffer.entrySet()) {
|
||||||
|
String subject = subjectEntry.getKey();
|
||||||
|
Map<String, Object> predicates = subjectEntry.getValue();
|
||||||
|
|
||||||
|
if (subject.isEmpty()) {
|
||||||
|
// Flat scalars go directly at the top level
|
||||||
|
for (Map.Entry<String, Object> e : predicates.entrySet()) {
|
||||||
|
addValue(json, e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
JsonObject inner = new JsonObject();
|
||||||
|
for (Map.Entry<String, Object> e : predicates.entrySet()) {
|
||||||
|
addValue(inner, e.getKey(), e.getValue());
|
||||||
|
}
|
||||||
|
json.add(subject, inner);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transmit(gson.toJson(json));
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw JSON string directly, bypassing the buffer.
|
||||||
|
* Useful for sending pre-built payloads.
|
||||||
|
*/
|
||||||
|
public void sendRaw(String json) {
|
||||||
|
transmit(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Cleanup
|
||||||
|
|
||||||
|
public void close() {
|
||||||
|
if (socket != null && !socket.isClosed()) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
|
||||||
|
private void transmit(String payload) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = payload.getBytes("UTF-8");
|
||||||
|
InetAddress addr = InetAddress.getByName(host);
|
||||||
|
DatagramPacket packet = new DatagramPacket(bytes, bytes.length, addr, port);
|
||||||
|
socket.send(packet);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("[UdpTelemetry] Send failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addValue(JsonObject obj, String key, Object value) {
|
||||||
|
if (value instanceof Number) {
|
||||||
|
obj.addProperty(key, (Number) value);
|
||||||
|
} else if (value instanceof Boolean) {
|
||||||
|
obj.addProperty(key, (Boolean) value);
|
||||||
|
} else {
|
||||||
|
obj.addProperty(key, String.valueOf(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
238
telelog.py
Executable file
238
telelog.py
Executable file
@@ -0,0 +1,238 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
telelog.py -- UDP telemetry logger using a triplestore SQLite backend
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 telelog.py --init-db # create/reset schema and exit
|
||||||
|
python3 telelog.py # listen on default port, write to db
|
||||||
|
python3 telelog.py --port 3001 # specify port
|
||||||
|
python3 telelog.py --db mydata.db # specify db file
|
||||||
|
python3 telelog.py --quiet # no console output
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
DEFAULT_PORT = 3000
|
||||||
|
DEFAULT_DB = "telemetry.db"
|
||||||
|
DEFAULT_HOST = "0.0.0.0"
|
||||||
|
BUFFER_SIZE = 65535
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS packets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
tag TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_packets_tag ON packets(tag);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS triples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
packet_id INTEGER NOT NULL REFERENCES packets(id),
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
predicate TEXT NOT NULL,
|
||||||
|
object TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_triples_packet ON triples(packet_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"[telelog] Schema initialized: {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ingest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def insert_packet(conn, ts, source, tag, triples):
|
||||||
|
"""
|
||||||
|
Insert one packet and its subject-predicate-object triples.
|
||||||
|
triples is a list of (subject, predicate, object) tuples.
|
||||||
|
Returns the new packet_id.
|
||||||
|
"""
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO packets (ts, source, tag) VALUES (?, ?, ?)",
|
||||||
|
(ts, source, tag)
|
||||||
|
)
|
||||||
|
packet_id = cur.lastrowid
|
||||||
|
|
||||||
|
rows = [(packet_id, s, p, o) for s, p, o in triples]
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO triples (packet_id, subject, predicate, object) VALUES (?, ?, ?, ?)",
|
||||||
|
rows
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return packet_id
|
||||||
|
|
||||||
|
|
||||||
|
def extract_triples(obj, fallback_subject):
|
||||||
|
"""
|
||||||
|
Convert a parsed JSON payload into a list of (subject, predicate, object) tuples.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- If the top-level value is a dict of dicts, the top-level key is the subject
|
||||||
|
and its child keys are predicates:
|
||||||
|
{"left_motor": {"power": 0.8, "temp": 42}}
|
||||||
|
-> ("left_motor", "power", "0.8"), ("left_motor", "temp", "42")
|
||||||
|
|
||||||
|
- If the top-level value is a scalar or mixed, the top-level key is the
|
||||||
|
predicate and fallback_subject (source IP) is the subject:
|
||||||
|
{"heading": 1.57}
|
||||||
|
-> (fallback_subject, "heading", "1.57")
|
||||||
|
|
||||||
|
- Deeper nesting collapses predicates with dot notation:
|
||||||
|
{"left_motor": {"pid": {"p": 1.0}}}
|
||||||
|
-> ("left_motor", "pid.p", "1.0")
|
||||||
|
|
||||||
|
- Lists are stored as JSON strings.
|
||||||
|
- Non-dict top-level payload falls back to (fallback_subject, "value", raw).
|
||||||
|
"""
|
||||||
|
triples = []
|
||||||
|
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
triples.append((fallback_subject, "value", str(obj)))
|
||||||
|
return triples
|
||||||
|
|
||||||
|
for top_key, top_val in obj.items():
|
||||||
|
if isinstance(top_val, dict):
|
||||||
|
# top_key is the subject; flatten children as predicates
|
||||||
|
subject = top_key
|
||||||
|
for pred, obj_val in _flatten_predicates(top_val).items():
|
||||||
|
triples.append((subject, pred, str(obj_val)))
|
||||||
|
else:
|
||||||
|
# top_key is the predicate; use fallback subject
|
||||||
|
if isinstance(top_val, list):
|
||||||
|
triples.append((fallback_subject, top_key, json.dumps(top_val)))
|
||||||
|
else:
|
||||||
|
triples.append((fallback_subject, top_key, str(top_val)))
|
||||||
|
|
||||||
|
return triples
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_predicates(obj, prefix=""):
|
||||||
|
"""Recursively flatten a dict into dot-separated predicate keys."""
|
||||||
|
out = {}
|
||||||
|
for k, v in obj.items():
|
||||||
|
key = f"{prefix}.{k}" if prefix else k
|
||||||
|
if isinstance(v, dict):
|
||||||
|
out.update(_flatten_predicates(v, key))
|
||||||
|
elif isinstance(v, list):
|
||||||
|
out[key] = json.dumps(v)
|
||||||
|
else:
|
||||||
|
out[key] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_payload(raw_bytes, fallback_subject):
|
||||||
|
"""
|
||||||
|
Attempt to parse payload as JSON. Fall back to raw string.
|
||||||
|
Returns a list of (subject, predicate, object) tuples.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = raw_bytes.decode("utf-8").strip()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return [(fallback_subject, "_raw_hex", raw_bytes.hex())]
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
return extract_triples(data, fallback_subject)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return [(fallback_subject, "_raw", text)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main loop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def run(db_path, host, port, tag, quiet):
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind((host, port))
|
||||||
|
sock.settimeout(1.0) # allows clean shutdown on Ctrl-C
|
||||||
|
|
||||||
|
tag_display = tag if tag else "(untagged)"
|
||||||
|
print(f"[telelog] Listening on {host}:{port} db={db_path} tag={tag_display}")
|
||||||
|
print(f"[telelog] Ctrl-C to stop")
|
||||||
|
|
||||||
|
running = True
|
||||||
|
|
||||||
|
def handle_signal(sig, frame):
|
||||||
|
nonlocal running
|
||||||
|
running = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, handle_signal)
|
||||||
|
|
||||||
|
packet_count = 0
|
||||||
|
|
||||||
|
while running:
|
||||||
|
try:
|
||||||
|
data, addr = sock.recvfrom(BUFFER_SIZE)
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
|
||||||
|
ts = datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
||||||
|
source = f"{addr[0]}:{addr[1]}"
|
||||||
|
triples = parse_payload(data, source)
|
||||||
|
|
||||||
|
packet_id = insert_packet(conn, ts, source, tag, triples)
|
||||||
|
packet_count += 1
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
subjects = sorted(set(s for s, p, o in triples))
|
||||||
|
print(f"[{ts}] src={source} id={packet_id} subjects={subjects}")
|
||||||
|
|
||||||
|
sock.close()
|
||||||
|
conn.close()
|
||||||
|
print(f"\n[telelog] Stopped. {packet_count} packets written.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="UDP telemetry triplestore logger")
|
||||||
|
parser.add_argument("--db", default=DEFAULT_DB, help=f"SQLite db file (default: {DEFAULT_DB})")
|
||||||
|
parser.add_argument("--port", default=DEFAULT_PORT, type=int, help=f"UDP listen port (default: {DEFAULT_PORT})")
|
||||||
|
parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind address (default: {DEFAULT_HOST})")
|
||||||
|
parser.add_argument("--init-db", action="store_true", help="Initialize/reset schema and exit")
|
||||||
|
parser.add_argument("--tag", default=None, help="Label for this session (e.g. 'auto_match_3')")
|
||||||
|
parser.add_argument("--quiet", action="store_true", help="Suppress per-packet console output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.init_db:
|
||||||
|
init_db(args.db)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Ensure schema exists before running (idempotent)
|
||||||
|
init_db(args.db)
|
||||||
|
run(args.db, args.host, args.port, args.tag, args.quiet)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user