Initial commit

This commit is contained in:
Eric Ratliff
2026-03-13 08:24:03 -05:00
commit 9f29b8de9c
10 changed files with 671 additions and 0 deletions

33
.editorconfig Normal file
View 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
View 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
View 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
View 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.

Binary file not shown.

Binary file not shown.

61
pom.xml Executable file
View 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>

View 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.");
}
}

View 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
View 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()