commit 9f29b8de9c3220219b816de7272d33dd87b4f3f8 Author: Eric Ratliff Date: Fri Mar 13 08:24:03 2026 -0500 Initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c5a5ee6 --- /dev/null +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b2d013 --- /dev/null +++ b/.gitattributes @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..129246e --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..feb888b --- /dev/null +++ b/LICENSE @@ -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. diff --git a/docs/Robot_Telemetry_Complete_Field_Guide.docx b/docs/Robot_Telemetry_Complete_Field_Guide.docx new file mode 100755 index 0000000..46b3cdb Binary files /dev/null and b/docs/Robot_Telemetry_Complete_Field_Guide.docx differ diff --git a/docs/Robot_Telemetry_Complete_Field_Guide.pdf b/docs/Robot_Telemetry_Complete_Field_Guide.pdf new file mode 100644 index 0000000..12cd46f Binary files /dev/null and b/docs/Robot_Telemetry_Complete_Field_Guide.pdf differ diff --git a/pom.xml b/pom.xml new file mode 100755 index 0000000..6397ee8 --- /dev/null +++ b/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + com.nexusworkshops + udp-telemetry + 1.0.0 + jar + + + 11 + 11 + UTF-8 + com.nexusworkshops.telemetry.Main + + + + + + com.google.code.gson + gson + 2.10.1 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.nexusworkshops.telemetry.Main + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + false + + + + + + + + diff --git a/src/main/java/com/nexusworkshops/telemetry/Main.java b/src/main/java/com/nexusworkshops/telemetry/Main.java new file mode 100755 index 0000000..0c91fb4 --- /dev/null +++ b/src/main/java/com/nexusworkshops/telemetry/Main.java @@ -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."); + } +} diff --git a/src/main/java/com/nexusworkshops/telemetry/UdpTelemetry.java b/src/main/java/com/nexusworkshops/telemetry/UdpTelemetry.java new file mode 100755 index 0000000..562dab2 --- /dev/null +++ b/src/main/java/com/nexusworkshops/telemetry/UdpTelemetry.java @@ -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> 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> subjectEntry : buffer.entrySet()) { + String subject = subjectEntry.getKey(); + Map predicates = subjectEntry.getValue(); + + if (subject.isEmpty()) { + // Flat scalars go directly at the top level + for (Map.Entry e : predicates.entrySet()) { + addValue(json, e.getKey(), e.getValue()); + } + } else { + JsonObject inner = new JsonObject(); + for (Map.Entry 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)); + } + } +} diff --git a/telelog.py b/telelog.py new file mode 100755 index 0000000..de1139e --- /dev/null +++ b/telelog.py @@ -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()