diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8b04462 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.5' + - run: gradle clean buildReleaseArtifacts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6d04dce --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,39 @@ +name: Documentation + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.5' + - run: gradle javadoc + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: build/docs/javadoc + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..65aaf8f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: ['v*'] + +jobs: + release: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - uses: gradle/actions/setup-gradle@v4 + with: + gradle-version: '8.5' + - name: Build release artifacts + run: gradle -PgithubReleaseTag=${GITHUB_REF_NAME} buildReleaseArtifacts + - uses: softprops/action-gh-release@v2 + with: + files: | + release/DataNet.txt + release/DataNet.zip + release/DataNet.pdex diff --git a/.gitignore b/.gitignore index 524f096..8a1226d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,17 @@ -# Compiled class file +.gradle/ +build/ +release/ +library.properties +.idea/ +.vscode/ +*.iml *.class - -# Log file *.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # *.jar -*.war -*.nar -*.ear *.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +*.pdex +.DS_Store hs_err_pid* replay_pid* + +!gradle/wrapper/gradle-wrapper.jar diff --git a/README.md b/README.md index 632b490..1b1e015 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,123 @@ -# datanet-processing -DataNet Processing library — realtime pub/sub for sketches, visualizations, and installations. +# DataNet for Processing + +Realtime pub/sub for networked sketches, visualizations, and installations. The library connects Processing 4 sketches to DataNet channels using the same protocol as the JavaScript, Python, and Arduino SDKs. + +## Features + +- JSON publish and subscribe with Processing `JSONObject` and `JSONArray` +- Binary messages plus DMX frame helpers +- Automatic authentication, heartbeat, reconnect, and subscription replay +- Structured gateway errors +- Authoritative channel presence +- Message and event callbacks delivered on Processing's animation thread +- No runtime dependencies beyond Processing 4 and Java 17 + +## Install + +Once the library is accepted into Processing Contributions, install it from **Sketch → Import Library… → Manage Libraries…** and search for **DataNet**. + +Until then, download `DataNet.zip` from the latest GitHub release, unzip it into your Processing sketchbook's `libraries` directory, and restart Processing. The final path should be: + +```text +/libraries/DataNet/library/DataNet.jar +``` + +## Quick start + +```java +import art.datanet.*; +import processing.data.JSONObject; + +String API_KEY = "ak_your_project_key"; +String CHANNEL = "project.your-project-id.demo"; +DataNet datanet; + +void setup() { + size(720, 480); + + datanet = new DataNet(this, DataNetConfig.builder(API_KEY) + .displayName("DataNet's Processing Sketch") + .build()); + + datanet.onError(error -> println(error.code() + ": " + error.getMessage())); + datanet.subscribe(CHANNEL, (data, meta) -> println(data)); + datanet.connect(); +} + +void mousePressed() { + JSONObject message = new JSONObject(); + message.setFloat("x", mouseX); + message.setFloat("y", mouseY); + datanet.publish(CHANNEL, message); +} +``` + +Open **File → Examples → Contributed Libraries → DataNet** for complete JSON, binary/DMX, and presence examples. + +## API overview + +```java +datanet.connect(); +datanet.disconnect(); +datanet.isConnected(); + +datanet.publish(channel, jsonValue); +datanet.subscribe(channel, (data, meta) -> { }); +datanet.unsubscribe(channel); + +datanet.publishBinary(channel, bytes); +datanet.publishBinary(channel, bytes, "binary/custom", metadata); +datanet.subscribeBinary(channel, (bytes, meta) -> { }); +datanet.publishDmx(channel, values); + +datanet.getPresence(channel, presence -> println(presence.occupancy())); +``` + +`MessageMeta` includes `channel`, `from`, and `timestamp`. `BinaryMessageMeta` also includes `contentType`, byte count, and optional protocol metadata. + +## Build and test + +Processing 4 ships with Java 17. If Java 17 is available in your shell: + +```sh +./gradlew clean test +./gradlew buildReleaseArtifacts +``` + +On macOS, you can use Processing's bundled JDK: + +```sh +JAVA_HOME=/Applications/Processing.app/Contents/app/resources/jdk ./gradlew clean buildReleaseArtifacts +``` + +Release artifacts are written to `release/DataNet.zip`, `release/DataNet.txt`, and `release/DataNet.pdex`. To install a development build directly into the local sketchbook, run: + +```sh +./gradlew deployToProcessingSketchbook +``` + +## Releasing + +1. Update `prettyVersion` and increment the integer `version` in `release.properties`. +2. Run `./gradlew clean buildReleaseArtifacts`. +3. Tag the merged commit, for example `v0.1.0`, and push the tag. +4. GitHub Actions creates the release and attaches the `.zip`, `.txt`, and `.pdex` files required by Processing Contributions. +5. For the first release, submit the stable `DataNet.txt` URL to the Processing Contributions repository. Later releases are discovered through the incremented metadata. + +## Compatibility + +- Processing 4 +- macOS, Windows, and Linux +- DataNet protocol: JSON and base64 binary WebSocket envelopes ## About DataNet is developed and supported by [Studio Jordan Shaw](https://www.jordanshaw.com), a creative technology studio building tools for realtime, networked, and physical-digital work. -- DataNet: [datanet.art](https://datanet.art) -- Studio: [jordanshaw.com](https://www.jordanshaw.com) -- Instagram: [@jshaw3](https://www.instagram.com/jshaw3) -- GitHub: [datanet-art](https://github.com/datanet-art) -- Source: [datanet-processing](https://github.com/datanet-art/datanet-processing) -- Examples: [datanet-examples](https://github.com/datanet-art/datanet-examples) +- [DataNet](https://datanet.art) +- [GitHub organization](https://github.com/datanet-art) +- [Cross-platform examples](https://github.com/datanet-art/datanet-examples) ## License -MIT +[MIT](LICENSE) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a2b33f7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,150 @@ +import java.util.Properties +import org.gradle.internal.os.OperatingSystem + +plugins { + java +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } + withSourcesJar() +} + +val libraryProperties = Properties().apply { + load(rootProject.file("release.properties").inputStream()) +} + +version = if (project.hasProperty("githubReleaseTag")) { + project.property("githubReleaseTag").toString().removePrefix("v") +} else { + libraryProperties.getProperty("prettyVersion") +} +group = "art.datanet" + +val libName = "DataNet" +val userHome = System.getProperty("user.home") +val currentOS = OperatingSystem.current() +val sketchbookLocation = when { + currentOS.isMacOsX -> if (file("$userHome/Documents/Processing/sketchbook").isDirectory) { + "$userHome/Documents/Processing/sketchbook" + } else { + "$userHome/Documents/Processing" + } + currentOS.isWindows -> { + val documents = if (file("$userHome/My Documents").isDirectory) { + "$userHome/My Documents" + } else { + "$userHome/Documents" + } + if (file("$documents/Processing/sketchbook").isDirectory) "$documents/Processing/sketchbook" else "$documents/Processing" + } + else -> "$userHome/sketchbook" +} + +repositories { + mavenCentral() + maven { url = uri("https://jogamp.org/deployment/maven/") } +} + +dependencies { + compileOnly("org.processing:core:4.3.1") + testImplementation("org.processing:core:4.3.1") + testImplementation(platform("org.junit:junit-bom:5.10.2")) + testImplementation("org.junit.jupiter:junit-jupiter") +} + +tasks.test { + useJUnitPlatform() +} + +tasks.jar { + archiveBaseName.set(libName) + archiveClassifier.set("") + archiveVersion.set("") +} + +tasks.javadoc { + options.encoding = "UTF-8" + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:none", true) +} + +val releaseRoot = layout.projectDirectory.dir("release") +val releaseDirectory = releaseRoot.dir(libName) + +val writeLibraryProperties by tasks.registering(WriteProperties::class) { + group = "processing" + destinationFile = project.file("library.properties") + property("name", libraryProperties.getProperty("name")) + property("version", libraryProperties.getProperty("version")) + property("prettyVersion", project.version) + property("authors", libraryProperties.getProperty("authors")) + property("url", libraryProperties.getProperty("url")) + property("categories", libraryProperties.getProperty("categories")) + property("sentence", libraryProperties.getProperty("sentence")) + property("paragraph", libraryProperties.getProperty("paragraph")) + property("minRevision", libraryProperties.getProperty("minRevision")) + property("maxRevision", libraryProperties.getProperty("maxRevision")) +} + +val stageRelease by tasks.registering(Sync::class) { + group = "processing" + dependsOn(tasks.build, tasks.javadoc, writeLibraryProperties) + into(releaseDirectory) + from(tasks.jar) { into("library") } + from(tasks.javadoc) { into("reference") } + from("library.properties") + from("README.md") + from("LICENSE") + from("examples") { into("examples") } + from("src/main/java") { into("src") } +} + +val packageRelease by tasks.registering(Zip::class) { + group = "processing" + dependsOn(stageRelease) + archiveFileName.set("$libName.zip") + destinationDirectory.set(releaseRoot.asFile) + from(releaseDirectory) { into(libName) } +} + +val writeContributionMetadata by tasks.registering { + group = "processing" + dependsOn(writeLibraryProperties) + inputs.file("library.properties") + outputs.file(releaseRoot.file("$libName.txt")) + doLast { + copy { + from("library.properties") + into(releaseRoot) + rename("library.properties", "$libName.txt") + } + } +} + +val duplicateZipToPdex by tasks.registering { + group = "processing" + dependsOn(packageRelease) + inputs.file(releaseRoot.file("$libName.zip")) + outputs.file(releaseRoot.file("$libName.pdex")) + doLast { + copy { + from(releaseRoot.file("$libName.zip")) + into(releaseRoot) + rename("$libName.zip", "$libName.pdex") + } + } +} + +tasks.register("buildReleaseArtifacts") { + group = "processing" + dependsOn(packageRelease, writeContributionMetadata, duplicateZipToPdex) +} + +tasks.register("deployToProcessingSketchbook") { + group = "processing" + dependsOn(stageRelease) + from(releaseDirectory) + into("$sketchbookLocation/libraries/$libName") +} diff --git a/examples/BasicPublishSubscribe/BasicPublishSubscribe.pde b/examples/BasicPublishSubscribe/BasicPublishSubscribe.pde new file mode 100644 index 0000000..17b35bb --- /dev/null +++ b/examples/BasicPublishSubscribe/BasicPublishSubscribe.pde @@ -0,0 +1,47 @@ +import art.datanet.*; +import processing.data.JSONObject; + +// Replace these two values with your DataNet project API key and channel. +String API_KEY = "ak_your_project_key"; +String CHANNEL = "project.your-project-id.demo"; + +DataNet datanet; +float remoteX; +float remoteY; +boolean online; + +void setup() { + size(720, 480); + + datanet = new DataNet(this, DataNetConfig.builder(API_KEY) + .displayName("DataNet's Processing Sketch") + .build()); + + datanet.onConnect(() -> online = true); + datanet.onDisconnect(() -> online = false); + datanet.onError(error -> println(error.code() + ": " + error.getMessage())); + + datanet.subscribe(CHANNEL, (data, meta) -> { + if (data instanceof JSONObject point) { + remoteX = point.getFloat("x", remoteX); + remoteY = point.getFloat("y", remoteY); + } + }); + + datanet.connect(); +} + +void draw() { + background(18); + fill(online ? color(92, 230, 162) : color(255, 184, 92)); + circle(remoteX, remoteY, 36); + fill(240); + text(online ? "Connected — move your mouse" : "Connecting…", 20, 30); +} + +void mouseMoved() { + JSONObject point = new JSONObject(); + point.setFloat("x", mouseX); + point.setFloat("y", mouseY); + datanet.publish(CHANNEL, point); +} diff --git a/examples/BinaryData/BinaryData.pde b/examples/BinaryData/BinaryData.pde new file mode 100644 index 0000000..4b9eaf4 --- /dev/null +++ b/examples/BinaryData/BinaryData.pde @@ -0,0 +1,26 @@ +import art.datanet.*; + +String API_KEY = "ak_your_project_key"; +String CHANNEL = "project.your-project-id.lights"; + +DataNet datanet; +int[] dmx = new int[512]; + +void setup() { + size(720, 240); + datanet = new DataNet(this, API_KEY); + datanet.onError(error -> println(error.code() + ": " + error.getMessage())); + datanet.connect(); +} + +void draw() { + background(18); + int level = constrain((int) map(mouseX, 0, width, 0, 255), 0, 255); + dmx[0] = level; + dmx[1] = 255 - level; + dmx[2] = 160; + + fill(dmx[0], dmx[1], dmx[2]); + rect(0, 0, width, height); + datanet.publishDmx(CHANNEL, dmx); +} diff --git a/examples/Presence/Presence.pde b/examples/Presence/Presence.pde new file mode 100644 index 0000000..d937342 --- /dev/null +++ b/examples/Presence/Presence.pde @@ -0,0 +1,31 @@ +import art.datanet.*; + +String API_KEY = "ak_your_project_key"; +String CHANNEL = "project.your-project-id.demo"; + +DataNet datanet; +int occupancy; +int lastCheck; + +void setup() { + size(600, 320); + textAlign(CENTER, CENTER); + datanet = new DataNet(this, API_KEY); + datanet.onError(error -> println(error.code() + ": " + error.getMessage())); + datanet.onConnect(() -> refreshPresence()); + datanet.connect(); +} + +void draw() { + background(18); + fill(240); + textSize(22); + text(occupancy + " connected device" + (occupancy == 1 ? "" : "s"), width / 2, height / 2); + + if (datanet.isConnected() && millis() - lastCheck > 3000) refreshPresence(); +} + +void refreshPresence() { + lastCheck = millis(); + datanet.getPresence(CHANNEL, presence -> occupancy = presence.occupancy()); +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1af9e09 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/release.properties b/release.properties new file mode 100644 index 0000000..037b514 --- /dev/null +++ b/release.properties @@ -0,0 +1,10 @@ +name=DataNet +version=1 +prettyVersion=0.1.0 +authors=[DataNet](https://datanet.art), [Studio Jordan Shaw](https://www.jordanshaw.com) +url=https://datanet-art.github.io/datanet-processing/ +categories=Data,I/O +sentence=Realtime pub/sub for networked sketches, visualizations, and installations. +paragraph=Connect Processing 4 sketches to browsers, phones, hardware, and other creative tools through DataNet channels. Supports JSON, binary data, reconnects, and presence on macOS, Windows, and Linux. +minRevision=0 +maxRevision=0 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e7cbe8d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} + +rootProject.name = "datanet-processing" diff --git a/src/main/java/art/datanet/BinaryMessageHandler.java b/src/main/java/art/datanet/BinaryMessageHandler.java new file mode 100644 index 0000000..09b8fee --- /dev/null +++ b/src/main/java/art/datanet/BinaryMessageHandler.java @@ -0,0 +1,7 @@ +package art.datanet; + +/** Receives raw bytes and their message metadata. */ +@FunctionalInterface +public interface BinaryMessageHandler { + void onMessage(byte[] data, BinaryMessageMeta meta); +} diff --git a/src/main/java/art/datanet/BinaryMessageMeta.java b/src/main/java/art/datanet/BinaryMessageMeta.java new file mode 100644 index 0000000..e4b3442 --- /dev/null +++ b/src/main/java/art/datanet/BinaryMessageMeta.java @@ -0,0 +1,12 @@ +package art.datanet; + +import processing.data.JSONObject; + +/** Sender, format, and timing information accompanying a binary message. */ +public record BinaryMessageMeta( + String channel, + String from, + long timestamp, + String contentType, + int bytes, + JSONObject metadata) {} diff --git a/src/main/java/art/datanet/DataNet.java b/src/main/java/art/datanet/DataNet.java new file mode 100644 index 0000000..ace2247 --- /dev/null +++ b/src/main/java/art/datanet/DataNet.java @@ -0,0 +1,622 @@ +package art.datanet; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.WebSocket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import processing.core.PApplet; +import processing.data.JSONArray; +import processing.data.JSONObject; + +/** + * Realtime DataNet client for Processing 4. + * + *

Network work runs in the background. Message, connection, error, and convenience presence + * callbacks run during Processing's {@code pre()} phase, immediately before {@code draw()}.

+ */ +public final class DataNet implements AutoCloseable { + public static final String VERSION = "0.1.0"; + public static final String CONTENT_TYPE_BINARY = "application/octet-stream"; + public static final String CONTENT_TYPE_DMX = "binary/dmx"; + public static final String CONTENT_TYPE_ARTNET = "binary/artnet"; + + private final PApplet parent; + private final DataNetConfig config; + private final HttpClient httpClient; + private final ScheduledExecutorService scheduler; + private final ConcurrentLinkedQueue callbackQueue = new ConcurrentLinkedQueue<>(); + private final Map> messageHandlers = new ConcurrentHashMap<>(); + private final Map> binaryHandlers = new ConcurrentHashMap<>(); + private final Object connectionLock = new Object(); + + private volatile WebSocket webSocket; + private volatile String jwt; + private volatile boolean connected; + private volatile boolean connecting; + private volatile boolean explicitlyDisconnected = true; + private volatile boolean fatalError; + private volatile int reconnectAttempts; + private volatile long activeGeneration; + private volatile long handledDisconnectGeneration = -1; + private volatile CompletableFuture connectFuture; + private volatile ScheduledFuture heartbeatTask; + private volatile ScheduledFuture reconnectTask; + private volatile Runnable connectHandler; + private volatile Runnable disconnectHandler; + private volatile ErrorHandler errorHandler; + + /** Create a client with default endpoints and identity settings. */ + public DataNet(PApplet parent, String apiKey) { + this(parent, DataNetConfig.builder(apiKey).build()); + } + + /** Create a client with custom identity, endpoint, or reconnect settings. */ + public DataNet(PApplet parent, DataNetConfig config) { + this.parent = parent; + this.config = Objects.requireNonNull(config, "config"); + ThreadFactory threads = runnable -> { + Thread thread = new Thread(runnable, "datanet-processing"); + thread.setDaemon(true); + return thread; + }; + this.scheduler = Executors.newSingleThreadScheduledExecutor(threads); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(config.requestTimeout) + .executor(scheduler) + .build(); + if (parent != null) { + parent.registerMethod("pre", this); + parent.registerMethod("dispose", this); + } + } + + /** Connect asynchronously. Calling this again while connected is harmless. */ + public CompletableFuture connect() { + synchronized (connectionLock) { + if (connected) return CompletableFuture.completedFuture(this); + if (connecting && connectFuture != null) return connectFuture; + explicitlyDisconnected = false; + fatalError = false; + reconnectAttempts = 0; + connecting = true; + connectFuture = new CompletableFuture<>(); + authenticateAndOpen(); + return connectFuture; + } + } + + /** Disconnect and disable automatic reconnect until {@link #connect()} is called again. */ + public void disconnect() { + WebSocket socket; + boolean notify; + synchronized (connectionLock) { + explicitlyDisconnected = true; + connecting = false; + activeGeneration++; + socket = webSocket; + webSocket = null; + notify = connected; + connected = false; + cancel(reconnectTask); + reconnectTask = null; + stopHeartbeat(); + if (connectFuture != null && !connectFuture.isDone()) { + connectFuture.completeExceptionally(new DataNetError("disconnected", "DataNet: connection cancelled")); + } + } + if (socket != null) socket.sendClose(WebSocket.NORMAL_CLOSURE, "client disconnect"); + if (notify) dispatch(this::notifyDisconnected); + } + + public boolean isConnected() { + return connected; + } + + /** Run queued callbacks on Processing's animation thread. Called automatically by Processing. */ + public void pre() { + Runnable callback; + while ((callback = callbackQueue.poll()) != null) { + try { + callback.run(); + } catch (RuntimeException exception) { + System.err.println("DataNet callback failed: " + exception.getMessage()); + exception.printStackTrace(); + } + } + } + + /** Release network resources. Called automatically when the sketch exits. */ + public void dispose() { + disconnect(); + scheduler.shutdownNow(); + if (parent != null) { + parent.unregisterMethod("pre", this); + parent.unregisterMethod("dispose", this); + } + } + + @Override + public void close() { + dispose(); + } + + public DataNet onConnect(Runnable handler) { + this.connectHandler = handler; + return this; + } + + public DataNet onDisconnect(Runnable handler) { + this.disconnectHandler = handler; + return this; + } + + public DataNet onError(ErrorHandler handler) { + this.errorHandler = handler; + return this; + } + + /** Subscribe to JSON-compatible messages on a channel. */ + public DataNet subscribe(String channel, MessageHandler handler) { + channel = Protocol.requireChannel(channel); + Objects.requireNonNull(handler, "handler"); + boolean first = !hasHandlers(channel); + messageHandlers.computeIfAbsent(channel, ignored -> new CopyOnWriteArraySet<>()).add(handler); + if (first) send(Protocol.subscribe(channel)); + return this; + } + + /** Remove one JSON handler. The network subscription remains while another handler is present. */ + public DataNet unsubscribe(String channel, MessageHandler handler) { + channel = Protocol.requireChannel(channel); + Set handlers = messageHandlers.get(channel); + if (handlers != null) { + handlers.remove(handler); + if (handlers.isEmpty()) messageHandlers.remove(channel, handlers); + } + unsubscribeIfUnused(channel); + return this; + } + + /** Remove every JSON handler for a channel. */ + public DataNet unsubscribe(String channel) { + channel = Protocol.requireChannel(channel); + messageHandlers.remove(channel); + unsubscribeIfUnused(channel); + return this; + } + + /** Subscribe to binary messages on a channel. */ + public DataNet subscribeBinary(String channel, BinaryMessageHandler handler) { + channel = Protocol.requireChannel(channel); + Objects.requireNonNull(handler, "handler"); + boolean first = !hasHandlers(channel); + binaryHandlers.computeIfAbsent(channel, ignored -> new CopyOnWriteArraySet<>()).add(handler); + if (first) send(Protocol.subscribe(channel)); + return this; + } + + public DataNet unsubscribeBinary(String channel, BinaryMessageHandler handler) { + channel = Protocol.requireChannel(channel); + Set handlers = binaryHandlers.get(channel); + if (handlers != null) { + handlers.remove(handler); + if (handlers.isEmpty()) binaryHandlers.remove(channel, handlers); + } + unsubscribeIfUnused(channel); + return this; + } + + public DataNet unsubscribeBinary(String channel) { + channel = Protocol.requireChannel(channel); + binaryHandlers.remove(channel); + unsubscribeIfUnused(channel); + return this; + } + + /** Publish any Processing JSON object, array, string, number, boolean, or null value. */ + public DataNet publish(String channel, Object data) { + send(Protocol.publish(Protocol.requireChannel(channel), data)); + return this; + } + + /** Publish raw bytes with the default application/octet-stream content type. */ + public DataNet publishBinary(String channel, byte[] data) { + return publishBinary(channel, data, CONTENT_TYPE_BINARY, null); + } + + /** Publish raw bytes with format and optional protocol metadata. */ + public DataNet publishBinary(String channel, byte[] data, String contentType, JSONObject metadata) { + Objects.requireNonNull(data, "data"); + if (contentType == null || contentType.isBlank()) contentType = CONTENT_TYPE_BINARY; + send(Protocol.publishBinary(Protocol.requireChannel(channel), data, contentType, metadata)); + return this; + } + + /** Build and publish a zero-padded DMX frame of 1 to 512 channels. */ + public DataNet publishDmx(String channel, int[] values) { + Objects.requireNonNull(values, "values"); + if (values.length > 512) throw new IllegalArgumentException("DMX data cannot exceed 512 channels"); + byte[] frame = new byte[512]; + for (int index = 0; index < values.length; index++) { + frame[index] = (byte) Math.max(0, Math.min(255, values[index])); + } + return publishBinary(channel, frame, CONTENT_TYPE_DMX, null); + } + + /** Query authoritative channel occupancy. The returned future completes off the animation thread. */ + public CompletableFuture getPresence(String channel) { + channel = Protocol.requireChannel(channel); + String token = jwt; + if (token == null) { + return CompletableFuture.failedFuture( + new DataNetError("not_connected", "DataNet: connect before requesting presence")); + } + String projectId = projectIdFromJwt(token); + String query = "channel=" + encode(channel) + + (projectId == null ? "" : "&projectId=" + encode(projectId)); + URI endpoint = config.apiUrl.resolve("/presence?" + query); + HttpRequest request = HttpRequest.newBuilder(endpoint) + .timeout(Duration.ofSeconds(8)) + .header("Authorization", "Bearer " + token) + .GET() + .build(); + String requestedChannel = channel; + return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + .handle((response, failure) -> { + if (failure != null) { + throw new DataNetError("presence_failed", "DataNet: presence request failed", unwrap(failure)); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String detail = response.body(); + try { + JSONObject body = JSONObject.parse(response.body()); + detail = body.getString("error", detail); + } catch (RuntimeException ignored) { + // Preserve the response body when it is not JSON. + } + String code = response.statusCode() == 403 ? "presence_forbidden" : "presence_failed"; + throw new DataNetError(code, + "DataNet: presence request failed (" + response.statusCode() + " " + detail + ")", + requestedChannel, null, null, response.statusCode(), null, null); + } + JSONObject body = JSONObject.parse(response.body()); + int occupancy = body.getInt("occupancy", body.getInt("count", 0)); + List members = new ArrayList<>(); + if (body.hasKey("members") && !body.isNull("members")) { + JSONArray list = body.getJSONArray("members"); + for (int index = 0; index < list.size(); index++) { + Object member = list.get(index); + if (member instanceof String value) members.add(value); + } + } + return new PresenceResult(occupancy, members); + }); + } + + /** Query presence and invoke handlers on Processing's animation thread. */ + public DataNet getPresence(String channel, PresenceHandler handler) { + Objects.requireNonNull(handler, "handler"); + getPresence(channel).whenComplete((presence, failure) -> { + if (failure != null) dispatch(() -> notifyError(asDataNetError(failure, "presence_failed"))); + else dispatch(() -> handler.onPresence(presence)); + }); + return this; + } + + private void authenticateAndOpen() { + JSONObject body = new JSONObject().setString("apiKey", config.apiKey); + if (config.deviceId != null) body.setString("deviceId", config.deviceId); + if (config.clientId != null) body.setString("clientId", config.clientId); + if (config.displayName != null) body.setString("deviceName", config.displayName); + HttpRequest request = HttpRequest.newBuilder(config.apiUrl.resolve("/auth/token")) + .timeout(config.requestTimeout) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)) + .build(); + httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) + .whenComplete((response, failure) -> { + if (failure != null) { + failConnect(new DataNetError("auth_failed", "DataNet: authentication failed", unwrap(failure))); + return; + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + failConnect(new DataNetError("auth_failed", + "DataNet: authentication failed (HTTP " + response.statusCode() + ")", + null, null, null, response.statusCode(), null, null)); + return; + } + try { + String token = JSONObject.parse(response.body()).getString("token"); + if (token == null || token.isBlank()) throw new IllegalArgumentException("missing token"); + jwt = token; + openSocket(token); + } catch (RuntimeException exception) { + failConnect(new DataNetError("auth_failed", "DataNet: authentication returned an invalid token", exception)); + } + }); + } + + private void openSocket(String token) { + final long generation; + synchronized (connectionLock) { + if (explicitlyDisconnected || fatalError) return; + generation = ++activeGeneration; + handledDisconnectGeneration = -1; + } + SocketListener listener = new SocketListener(generation); + httpClient.newWebSocketBuilder() + .connectTimeout(config.requestTimeout) + .subprotocols("bearer", token) + .buildAsync(config.wsUrl, listener) + .whenComplete((socket, failure) -> { + if (failure != null) handleSocketEnded(generation, + new DataNetError("connection_failed", "DataNet: WebSocket connection failed", unwrap(failure))); + }); + } + + private void handleText(long generation, String text) { + if (generation != activeGeneration) return; + final JSONObject message; + try { + message = JSONObject.parse(text); + } catch (RuntimeException exception) { + dispatch(() -> notifyError(new DataNetError("invalid_message", "DataNet: invalid JSON message", exception))); + return; + } + String type = message.getString("type", ""); + String operation = message.getString("op", ""); + if ("connected".equals(type)) { + connected = true; + connecting = false; + reconnectAttempts = 0; + startHeartbeat(); + replaySubscriptions(); + CompletableFuture pending = connectFuture; + if (pending != null && !pending.isDone()) pending.complete(this); + dispatch(this::notifyConnected); + return; + } + if ("error".equals(type)) { + DataNetError error = Protocol.gatewayError(message); + dispatch(() -> notifyError(error)); + if ("device_limit_reached".equals(error.code())) { + fatalError = true; + CompletableFuture pending = connectFuture; + if (pending != null && !pending.isDone()) pending.completeExceptionally(error); + WebSocket socket = webSocket; + if (socket != null) socket.abort(); + handleSocketEnded(generation, null); + } + return; + } + if (!"message".equals(type) && !"pub".equals(operation)) return; + String channel = message.getString("ch", ""); + String from = message.getString("from", ""); + long timestamp = message.getLong("ts", System.currentTimeMillis()); + if (message.getBoolean("bin", false)) { + try { + byte[] bytes = Protocol.decodeBinary(message.getString("b64", "")); + JSONObject metadata = message.hasKey("meta") && !message.isNull("meta") + ? message.getJSONObject("meta") : null; + BinaryMessageMeta meta = new BinaryMessageMeta( + channel, + from, + timestamp, + message.getString("ct", CONTENT_TYPE_BINARY), + message.getInt("bytes", bytes.length), + metadata); + Set handlers = binaryHandlers.get(channel); + if (handlers != null) { + for (BinaryMessageHandler handler : handlers) { + dispatch(() -> handler.onMessage(bytes.clone(), meta)); + } + } + } catch (RuntimeException exception) { + dispatch(() -> notifyError(new DataNetError("invalid_binary", "DataNet: invalid binary message", exception))); + } + return; + } + Object data = message.hasKey("d") ? message.get("d") : null; + MessageMeta meta = new MessageMeta(channel, from, timestamp); + Set handlers = messageHandlers.get(channel); + if (handlers != null) { + for (MessageHandler handler : handlers) dispatch(() -> handler.onMessage(data, meta)); + } + } + + private void handleSocketEnded(long generation, DataNetError error) { + boolean wasConnected; + synchronized (connectionLock) { + if (generation != activeGeneration || handledDisconnectGeneration == generation) return; + handledDisconnectGeneration = generation; + wasConnected = connected; + connected = false; + connecting = false; + webSocket = null; + stopHeartbeat(); + } + if (error != null) dispatch(() -> notifyError(error)); + if (wasConnected) dispatch(this::notifyDisconnected); + if (!explicitlyDisconnected && !fatalError) scheduleReconnect(); + } + + private void scheduleReconnect() { + synchronized (connectionLock) { + if (reconnectAttempts >= config.maxReconnectAttempts) { + DataNetError error = new DataNetError("reconnect_failed", "DataNet: maximum reconnect attempts reached"); + if (connectFuture != null && !connectFuture.isDone()) connectFuture.completeExceptionally(error); + dispatch(() -> notifyError(error)); + return; + } + reconnectAttempts++; + long delay = Math.min(30_000L, 1_000L << Math.min(reconnectAttempts - 1, 5)); + connecting = true; + reconnectTask = scheduler.schedule(this::authenticateAndOpen, delay, TimeUnit.MILLISECONDS); + } + } + + private void failConnect(DataNetError error) { + connecting = false; + CompletableFuture pending = connectFuture; + if (pending != null && !pending.isDone()) pending.completeExceptionally(error); + dispatch(() -> notifyError(error)); + } + + private void replaySubscriptions() { + Set channels = ConcurrentHashMap.newKeySet(); + channels.addAll(messageHandlers.keySet()); + channels.addAll(binaryHandlers.keySet()); + for (String channel : channels) send(Protocol.subscribe(channel)); + } + + private boolean hasHandlers(String channel) { + Set json = messageHandlers.get(channel); + Set binary = binaryHandlers.get(channel); + return (json != null && !json.isEmpty()) || (binary != null && !binary.isEmpty()); + } + + private void unsubscribeIfUnused(String channel) { + if (!hasHandlers(channel)) send(Protocol.unsubscribe(channel)); + } + + private void send(String text) { + WebSocket socket = webSocket; + if (socket != null && connected) socket.sendText(text, true); + } + + private void startHeartbeat() { + stopHeartbeat(); + heartbeatTask = scheduler.scheduleAtFixedRate( + () -> send(Protocol.heartbeat()), 30, 30, TimeUnit.SECONDS); + } + + private void stopHeartbeat() { + cancel(heartbeatTask); + heartbeatTask = null; + } + + private void dispatch(Runnable callback) { + if (parent == null) callback.run(); + else callbackQueue.add(callback); + } + + private void notifyConnected() { + Runnable handler = connectHandler; + if (handler != null) handler.run(); + } + + private void notifyDisconnected() { + Runnable handler = disconnectHandler; + if (handler != null) handler.run(); + } + + private void notifyError(DataNetError error) { + ErrorHandler handler = errorHandler; + if (handler != null) handler.onError(error); + else System.err.println(error.getMessage()); + } + + private static void cancel(ScheduledFuture task) { + if (task != null) task.cancel(false); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static String projectIdFromJwt(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length < 2) return null; + byte[] decoded = Base64.getUrlDecoder().decode(parts[1]); + return JSONObject.parse(new String(decoded, StandardCharsets.UTF_8)).getString("pid", null); + } catch (RuntimeException ignored) { + return null; + } + } + + private static Throwable unwrap(Throwable failure) { + return failure.getCause() == null ? failure : failure.getCause(); + } + + private static DataNetError asDataNetError(Throwable failure, String fallbackCode) { + Throwable cause = unwrap(failure); + if (cause instanceof DataNetError error) return error; + return new DataNetError(fallbackCode, "DataNet: " + cause.getMessage(), cause); + } + + private final class SocketListener implements WebSocket.Listener { + private final long generation; + private final StringBuilder textBuffer = new StringBuilder(); + + private SocketListener(long generation) { + this.generation = generation; + } + + @Override + public void onOpen(WebSocket socket) { + if (generation != activeGeneration || explicitlyDisconnected) { + socket.abort(); + return; + } + webSocket = socket; + socket.request(1); + } + + @Override + public CompletionStage onText(WebSocket socket, CharSequence data, boolean last) { + synchronized (textBuffer) { + textBuffer.append(data); + if (last) { + String complete = textBuffer.toString(); + textBuffer.setLength(0); + handleText(generation, complete); + } + } + socket.request(1); + return null; + } + + @Override + public CompletionStage onBinary(WebSocket socket, ByteBuffer data, boolean last) { + socket.request(1); + return null; + } + + @Override + public CompletionStage onClose(WebSocket socket, int statusCode, String reason) { + handleSocketEnded(generation, + explicitlyDisconnected ? null : new DataNetError("connection_closed", + "DataNet: connection closed (" + statusCode + (reason.isBlank() ? "" : " " + reason) + ")")); + return null; + } + + @Override + public void onError(WebSocket socket, Throwable failure) { + handleSocketEnded(generation, + new DataNetError("connection_error", "DataNet: WebSocket error", failure)); + } + } +} diff --git a/src/main/java/art/datanet/DataNetConfig.java b/src/main/java/art/datanet/DataNetConfig.java new file mode 100644 index 0000000..b0b5d92 --- /dev/null +++ b/src/main/java/art/datanet/DataNetConfig.java @@ -0,0 +1,106 @@ +package art.datanet; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; + +/** Connection settings for a {@link DataNet} client. */ +public final class DataNetConfig { + static final URI DEFAULT_API_URL = URI.create("https://api.datanet.art"); + static final URI DEFAULT_WS_URL = URI.create("wss://ws.datanet.art/ws"); + + final String apiKey; + final String deviceId; + final String clientId; + final String displayName; + final URI apiUrl; + final URI wsUrl; + final int maxReconnectAttempts; + final Duration requestTimeout; + + private DataNetConfig(Builder builder) { + this.apiKey = requireText(builder.apiKey, "apiKey"); + this.deviceId = trimToNull(builder.deviceId); + this.clientId = trimToNull(builder.clientId); + this.displayName = trimToNull(builder.displayName); + this.apiUrl = Objects.requireNonNull(builder.apiUrl, "apiUrl"); + this.wsUrl = Objects.requireNonNull(builder.wsUrl, "wsUrl"); + if (builder.maxReconnectAttempts < 0) { + throw new IllegalArgumentException("maxReconnectAttempts must be at least 0"); + } + this.maxReconnectAttempts = builder.maxReconnectAttempts; + this.requestTimeout = Objects.requireNonNull(builder.requestTimeout, "requestTimeout"); + } + + /** Start configuring a client with a DataNet project API key. */ + public static Builder builder(String apiKey) { + return new Builder(apiKey); + } + + /** Fluent builder for optional device identity and endpoint settings. */ + public static final class Builder { + private final String apiKey; + private String deviceId; + private String clientId = "processing"; + private String displayName = "DataNet's Processing Sketch"; + private URI apiUrl = DEFAULT_API_URL; + private URI wsUrl = DEFAULT_WS_URL; + private int maxReconnectAttempts = 5; + private Duration requestTimeout = Duration.ofSeconds(10); + + private Builder(String apiKey) { + this.apiKey = apiKey; + } + + public Builder deviceId(String value) { + this.deviceId = value; + return this; + } + + public Builder clientId(String value) { + this.clientId = value; + return this; + } + + /** Set the human-readable device name shown in DataNet dashboards. */ + public Builder displayName(String value) { + this.displayName = value; + return this; + } + + public Builder apiUrl(String value) { + this.apiUrl = URI.create(requireText(value, "apiUrl")); + return this; + } + + public Builder webSocketUrl(String value) { + this.wsUrl = URI.create(requireText(value, "webSocketUrl")); + return this; + } + + public Builder maxReconnectAttempts(int value) { + this.maxReconnectAttempts = value; + return this; + } + + public Builder requestTimeout(Duration value) { + this.requestTimeout = value; + return this; + } + + public DataNetConfig build() { + return new DataNetConfig(this); + } + } + + private static String requireText(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } + + private static String trimToNull(String value) { + return value == null || value.trim().isEmpty() ? null : value.trim(); + } +} diff --git a/src/main/java/art/datanet/DataNetError.java b/src/main/java/art/datanet/DataNetError.java new file mode 100644 index 0000000..889a547 --- /dev/null +++ b/src/main/java/art/datanet/DataNetError.java @@ -0,0 +1,38 @@ +package art.datanet; + +/** A structured error returned by DataNet or raised by the client. */ +public final class DataNetError extends RuntimeException { + private static final long serialVersionUID = 1L; + private final String code; + private final String channel; + private final Long retryMs; + private final String scope; + private final Integer status; + private final Integer limit; + + DataNetError(String code, String message) { + this(code, message, null, null, null, null, null, null); + } + + DataNetError(String code, String message, Throwable cause) { + this(code, message, null, null, null, null, null, cause); + } + + DataNetError(String code, String message, String channel, Long retryMs, + String scope, Integer status, Integer limit, Throwable cause) { + super(message, cause); + this.code = code; + this.channel = channel; + this.retryMs = retryMs; + this.scope = scope; + this.status = status; + this.limit = limit; + } + + public String code() { return code; } + public String channel() { return channel; } + public Long retryMs() { return retryMs; } + public String scope() { return scope; } + public Integer status() { return status; } + public Integer limit() { return limit; } +} diff --git a/src/main/java/art/datanet/ErrorHandler.java b/src/main/java/art/datanet/ErrorHandler.java new file mode 100644 index 0000000..77492d0 --- /dev/null +++ b/src/main/java/art/datanet/ErrorHandler.java @@ -0,0 +1,7 @@ +package art.datanet; + +/** Receives structured connection, protocol, and API errors. */ +@FunctionalInterface +public interface ErrorHandler { + void onError(DataNetError error); +} diff --git a/src/main/java/art/datanet/MessageHandler.java b/src/main/java/art/datanet/MessageHandler.java new file mode 100644 index 0000000..84cb6d4 --- /dev/null +++ b/src/main/java/art/datanet/MessageHandler.java @@ -0,0 +1,7 @@ +package art.datanet; + +/** Receives a JSON-compatible value and its message metadata. */ +@FunctionalInterface +public interface MessageHandler { + void onMessage(Object data, MessageMeta meta); +} diff --git a/src/main/java/art/datanet/MessageMeta.java b/src/main/java/art/datanet/MessageMeta.java new file mode 100644 index 0000000..356819e --- /dev/null +++ b/src/main/java/art/datanet/MessageMeta.java @@ -0,0 +1,4 @@ +package art.datanet; + +/** Sender and timing information accompanying a JSON message. */ +public record MessageMeta(String channel, String from, long timestamp) {} diff --git a/src/main/java/art/datanet/PresenceHandler.java b/src/main/java/art/datanet/PresenceHandler.java new file mode 100644 index 0000000..a2cadb2 --- /dev/null +++ b/src/main/java/art/datanet/PresenceHandler.java @@ -0,0 +1,7 @@ +package art.datanet; + +/** Receives a presence lookup result on the Processing animation thread. */ +@FunctionalInterface +public interface PresenceHandler { + void onPresence(PresenceResult presence); +} diff --git a/src/main/java/art/datanet/PresenceResult.java b/src/main/java/art/datanet/PresenceResult.java new file mode 100644 index 0000000..ddb684a --- /dev/null +++ b/src/main/java/art/datanet/PresenceResult.java @@ -0,0 +1,10 @@ +package art.datanet; + +import java.util.List; + +/** Current authoritative occupancy for one DataNet channel. */ +public record PresenceResult(int occupancy, List members) { + public PresenceResult { + members = List.copyOf(members); + } +} diff --git a/src/main/java/art/datanet/Protocol.java b/src/main/java/art/datanet/Protocol.java new file mode 100644 index 0000000..a145c67 --- /dev/null +++ b/src/main/java/art/datanet/Protocol.java @@ -0,0 +1,75 @@ +package art.datanet; + +import java.util.Base64; +import processing.data.JSONObject; + +final class Protocol { + private Protocol() {} + + static String subscribe(String channel) { + return new JSONObject().setString("op", "sub").setString("ch", channel).toString(); + } + + static String unsubscribe(String channel) { + return new JSONObject().setString("op", "unsub").setString("ch", channel).toString(); + } + + static String publish(String channel, Object data) { + return new JSONObject().setString("op", "pub").setString("ch", channel).put("d", data).toString(); + } + + static String publishBinary(String channel, byte[] data, String contentType, JSONObject metadata) { + JSONObject envelope = new JSONObject() + .setString("op", "pub") + .setString("ch", channel) + .setBoolean("bin", true) + .setString("b64", Base64.getEncoder().encodeToString(data)) + .setString("ct", contentType); + if (metadata != null) envelope.setJSONObject("meta", metadata); + return envelope.toString(); + } + + static String heartbeat() { + return new JSONObject().setString("op", "hb").toString(); + } + + static byte[] decodeBinary(String encoded) { + return Base64.getDecoder().decode(encoded); + } + + static DataNetError gatewayError(JSONObject message) { + String code = message.getString("code", message.getString("error", "gateway_error")); + String channel = message.getString("channel", message.getString("ch", null)); + Long retryMs = numberAsLong(message, "retry_ms"); + String scope = message.getString("scope", null); + Integer limit = numberAsInteger(message, "limit"); + return new DataNetError( + code, + "DataNet: " + message.getString("error", code) + (channel == null ? "" : " (" + channel + ")"), + channel, + retryMs, + scope, + null, + limit, + null); + } + + static String requireChannel(String channel) { + if (channel == null || channel.trim().isEmpty()) { + throw new IllegalArgumentException("channel must not be blank"); + } + return channel.trim(); + } + + private static Long numberAsLong(JSONObject object, String key) { + if (!object.hasKey(key) || object.isNull(key)) return null; + Object value = object.get(key); + return value instanceof Number number ? number.longValue() : null; + } + + private static Integer numberAsInteger(JSONObject object, String key) { + if (!object.hasKey(key) || object.isNull(key)) return null; + Object value = object.get(key); + return value instanceof Number number ? number.intValue() : null; + } +} diff --git a/src/main/java/art/datanet/package-info.java b/src/main/java/art/datanet/package-info.java new file mode 100644 index 0000000..0968ae9 --- /dev/null +++ b/src/main/java/art/datanet/package-info.java @@ -0,0 +1,2 @@ +/** Realtime DataNet pub/sub client types for Processing 4 sketches. */ +package art.datanet; diff --git a/src/test/java/art/datanet/DataNetConfigTest.java b/src/test/java/art/datanet/DataNetConfigTest.java new file mode 100644 index 0000000..99f55d0 --- /dev/null +++ b/src/test/java/art/datanet/DataNetConfigTest.java @@ -0,0 +1,35 @@ +package art.datanet; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +final class DataNetConfigTest { + @Test + void suppliesProcessingFriendlyDefaults() { + DataNetConfig config = DataNetConfig.builder("ak_test").build(); + + assertEquals("processing", config.clientId); + assertEquals("DataNet's Processing Sketch", config.displayName); + assertEquals(5, config.maxReconnectAttempts); + } + + @Test + void supportsCustomDeviceIdentity() { + DataNetConfig config = DataNetConfig.builder("ak_test") + .deviceId("gallery-screen-1") + .displayName("DataNet's Gallery Screen") + .build(); + + assertEquals("gallery-screen-1", config.deviceId); + assertEquals("DataNet's Gallery Screen", config.displayName); + } + + @Test + void rejectsMissingApiKeyAndNegativeReconnects() { + assertThrows(IllegalArgumentException.class, () -> DataNetConfig.builder(" ").build()); + assertThrows(IllegalArgumentException.class, + () -> DataNetConfig.builder("ak_test").maxReconnectAttempts(-1).build()); + } +} diff --git a/src/test/java/art/datanet/ProtocolTest.java b/src/test/java/art/datanet/ProtocolTest.java new file mode 100644 index 0000000..6c9724f --- /dev/null +++ b/src/test/java/art/datanet/ProtocolTest.java @@ -0,0 +1,67 @@ +package art.datanet; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import processing.data.JSONObject; + +final class ProtocolTest { + @Test + void buildsJsonPublishEnvelope() { + JSONObject data = new JSONObject().setInt("value", 42); + JSONObject envelope = JSONObject.parse(Protocol.publish("project.demo.sensor", data)); + + assertEquals("pub", envelope.getString("op")); + assertEquals("project.demo.sensor", envelope.getString("ch")); + assertEquals(42, envelope.getJSONObject("d").getInt("value")); + } + + @Test + void roundTripsBinaryEnvelopeAndMetadata() { + byte[] payload = new byte[] {0, 1, (byte) 255}; + JSONObject metadata = new JSONObject().setInt("universe", 1); + JSONObject envelope = JSONObject.parse( + Protocol.publishBinary("project.demo.lights", payload, "binary/dmx", metadata)); + + assertTrue(envelope.getBoolean("bin")); + assertEquals("binary/dmx", envelope.getString("ct")); + assertEquals(1, envelope.getJSONObject("meta").getInt("universe")); + assertArrayEquals(payload, Protocol.decodeBinary(envelope.getString("b64"))); + } + + @Test + void omitsMetadataWhenItWasNotProvided() { + JSONObject envelope = JSONObject.parse( + Protocol.publishBinary("project.demo.bytes", new byte[] {1}, "application/octet-stream", null)); + + assertFalse(envelope.hasKey("meta")); + } + + @Test + void convertsGatewayErrorsToStructuredErrors() { + JSONObject envelope = new JSONObject() + .setString("type", "error") + .setString("error", "rate_limited") + .setString("channel", "project.demo.sensor") + .setLong("retry_ms", 2500) + .setString("scope", "connection") + .setInt("limit", 20); + + DataNetError error = Protocol.gatewayError(envelope); + + assertEquals("rate_limited", error.code()); + assertEquals("project.demo.sensor", error.channel()); + assertEquals(2500L, error.retryMs()); + assertEquals("connection", error.scope()); + assertEquals(20, error.limit()); + } + + @Test + void rejectsBlankChannels() { + assertThrows(IllegalArgumentException.class, () -> Protocol.requireChannel(" ")); + } +}