diff --git a/.github/workflows/ci-dotnet.yaml b/.github/workflows/ci-dotnet.yaml index d80dcf5a..3e56599c 100644 --- a/.github/workflows/ci-dotnet.yaml +++ b/.github/workflows/ci-dotnet.yaml @@ -1,35 +1,74 @@ -name: dotnet CI build +# Keep this filename: its existing run counter supplies all package versions. +name: Protocol packages -on: push +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: - donet-ci-build: + version: runs-on: ubuntu-latest + outputs: + package-version: ${{ steps.version.outputs.version }} steps: - - name: Checkout source - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + - name: Test shared version rules + run: python3 -B -m unittest discover -s scripts -p 'test_*.py' + - name: Calculate the shared package version + id: version + run: | + VERSION=$(python3 scripts/package-version.py) + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Setup .NET Core @ Latest - uses: actions/setup-dotnet@v4 + nuget: + needs: version + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v4 with: - dotnet-version: | - 9.0.x + dotnet-version: 9.0.x source-url: https://nuget.pkg.github.com/BluEye-Robotics/index.json env: - NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} - - - name: Sanitize branch name - id: branches + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build the NuGet package + env: + PACKAGE_VERSION: ${{ needs.version.outputs.package-version }} + run: dotnet pack Blueye.Protocol.Protobuf.csproj -p:Version="$PACKAGE_VERSION" -c Release -o out + - name: Publish to GitHub Packages + if: github.event_name != 'pull_request' && github.repository == 'BluEye-Robotics/ProtocolDefinitions' + env: + NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - BRANCH="${GITHUB_REF_NAME}" - SANITIZED=$(echo "$BRANCH" | sed 's/[^a-zA-Z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' | tr '[:upper:]' '[:lower:]' | cut -c1-63) - echo "sanitized-branch-name=$SANITIZED" >> "$GITHUB_OUTPUT" + dotnet nuget push ./out/*.nupkg --skip-duplicate --no-symbols \ + --source https://nuget.pkg.github.com/BluEye-Robotics/index.json --api-key "$NUGET_AUTH_TOKEN" - - name: Build project and generate NuGet package - run: | - dotnet pack Blueye.Protocol.Protobuf.csproj --version-suffix "${{ steps.branches.outputs.sanitized-branch-name }}.$GITHUB_RUN_NUMBER" -c Release -o out + kotlin: + needs: version + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-kotlin.yaml + with: + package-version: ${{ needs.version.outputs.package-version }} - - name: Push generated package to GitHub registry - env: - NUGET_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} - run: dotnet nuget push ./out/*.nupkg --skip-duplicate --no-symbols --api-key $NUGET_AUTH_TOKEN \ No newline at end of file + swift: + needs: [version, nuget, kotlin] + if: >- + github.event_name != 'pull_request' && + github.repository == 'BluEye-Robotics/ProtocolDefinitions' && + github.ref == 'refs/heads/master' && + vars.SWIFT_PROTOCOL_REPOSITORY != '' + uses: ./.github/workflows/ci-swift.yaml + with: + package-version: ${{ needs.version.outputs.package-version }} + secrets: + CI_PAT: ${{ secrets.CI_PAT }} diff --git a/.github/workflows/ci-kotlin.yaml b/.github/workflows/ci-kotlin.yaml new file mode 100644 index 00000000..acdb8fbc --- /dev/null +++ b/.github/workflows/ci-kotlin.yaml @@ -0,0 +1,55 @@ +name: Kotlin package + +on: + workflow_call: + inputs: + package-version: + required: true + type: string + +permissions: + contents: read + +defaults: + run: + working-directory: kotlin + +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 + - name: Build, test, and publish to a local test repository + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + run: ./gradlew --no-daemon build publishProtocolPublicationToSmokeTestRepository -PpackageVersion="$PACKAGE_VERSION" + - name: Verify package from a separate consuming project + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + run: ./gradlew --no-daemon -p smoke-test run -PpackageVersion="$PACKAGE_VERSION" + + publish: + if: github.event_name != 'pull_request' && github.repository == 'BluEye-Robotics/ProtocolDefinitions' + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + - name: Publish to GitHub Packages + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PACKAGE_VERSION: ${{ inputs.package-version }} + run: ./gradlew --no-daemon publishProtocolPublicationToGitHubPackagesRepository -PpackageVersion="$PACKAGE_VERSION" diff --git a/.github/workflows/ci-swift.yaml b/.github/workflows/ci-swift.yaml new file mode 100644 index 00000000..0ca53c1a --- /dev/null +++ b/.github/workflows/ci-swift.yaml @@ -0,0 +1,29 @@ +name: Release blueye.protocol.swift + +on: + workflow_call: + inputs: + package-version: + required: true + type: string + secrets: + CI_PAT: + required: true + +permissions: + contents: read + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Dispatch the protocol revision and shared version + env: + GH_TOKEN: ${{ secrets.CI_PAT }} + SWIFT_PROTOCOL_REPOSITORY: ${{ vars.SWIFT_PROTOCOL_REPOSITORY }} + PACKAGE_VERSION: ${{ inputs.package-version }} + run: | + gh api "repos/$SWIFT_PROTOCOL_REPOSITORY/dispatches" \ + -f event_type=protocol-update \ + -f "client_payload[sha]=$GITHUB_SHA" \ + -f "client_payload[version]=$PACKAGE_VERSION" diff --git a/.gitignore b/.gitignore index afc52765..19d07f1c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ node_modules # Rider directories .idea + +# Gradle build state +.gradle +.kotlin diff --git a/Blueye.Protocol.Protobuf.csproj b/Blueye.Protocol.Protobuf.csproj index 64cb0c37..a386a5bc 100644 --- a/Blueye.Protocol.Protobuf.csproj +++ b/Blueye.Protocol.Protobuf.csproj @@ -5,7 +5,6 @@ true 13.0 Blueye.Protocol.Protobuf - 6.3.0 Blueye Robotics AS True Library with dotnet representation of the ProtocolDefinition protobuf files. @@ -25,7 +24,7 @@ - + diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..17e961a3 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)version.txt').Trim()).0 + + diff --git a/README.md b/README.md index 3a3da4f6..662f90ea 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,49 @@ Drones running a Blunux version \< 3.0 will need to use the legacy protocol. [bl Version 3 of the Blueye communication protocol is based on [Protocol Buffers](https://developers.google.com/protocol-buffers). -The protobuf definitions are compiled to language specific libraries, and are available as a [NuGet package](https://github.com/BluEye-Robotics/ProtocolDefinitions/packages/1239508), [Python package](https://pypi.org/project/blueye.protocol/) and a [npm package](https://www.npmjs.com/package/@blueyerobotics/protocol-definitions); a Rust crate is available as a git dependency (see below). +The protobuf definitions are compiled to language specific libraries, and are available as a [NuGet package](https://github.com/BluEye-Robotics/ProtocolDefinitions/packages/1239508), [Python package](https://pypi.org/project/blueye.protocol/) and a [npm package](https://www.npmjs.com/package/@blueyerobotics/protocol-definitions). Swift sources are maintained in [blueye.protocol.swift](https://github.com/BluEye-Robotics/blueye.protocol.swift). This repository also builds a [Kotlin/JVM Maven package](kotlin/README.md) and a Rust crate (see below). Automatically generated documentation for the protocol format can be found [here](https://blueyebuildserver.blob.core.windows.net/protocoldefinitions/docs/protocol.html). +### Swift and Kotlin + +The Swift package exposes `BlueyeProtocol` through Swift Package Manager from +the separate [blueye.protocol.swift repository](https://github.com/BluEye-Robotics/blueye.protocol.swift). +Like Python, it pins this repository as a submodule and commits generated +sources in its own repository. Swift tags use the same version as NuGet and Maven. + +The Kotlin/JVM library publishes to GitHub Packages as +`com.blueyerobotics:protocol-definitions`, with the full Protobuf runtime and +generated Java classes plus Kotlin DSLs. See [Kotlin setup and publishing](kotlin/README.md). + +All three packages use `protobuf_definitions/` as their source of truth. No +generated Java/Kotlin sources are committed here. Package versions are separate +from the wire protocol version described above. + +`version.txt` holds the Blueye App major/minor version, currently `6.5`. The +**Protocol packages** workflow (`ci-dotnet.yaml`) computes one version for every +package using its existing run counter: + +- Master builds: stable `6.5.` releases. +- Other branches and pull requests: `6.5.-branch-`. +- Local builds: `6.5.0-local` for Maven and `6.5.0` for .NET unless overridden. + +Merging to master automatically creates the next stable package version. Update +`version.txt` only when the Blueye App major/minor version changes. NuGet and Maven +publish on upstream branch pushes; Swift receives master releases after both +publish successfully. Its receiver generates and validates the pinned schema, +then commits and tags it with the same version. Delayed older releases are +generated and tagged separately without moving the Swift default branch backward. +Python keeps its separate repository and release versioning. Do not rename or +recreate the coordinating workflow without preserving a monotonically increasing +build counter. + +Once the Swift receiver is installed, set the Actions variable +`SWIFT_PROTOCOL_REPOSITORY` to `BluEye-Robotics/blueye.protocol.swift`. The existing +`CI_PAT` secret must have permission to dispatch events to that repository. +`ci-swift.yaml` receives the shared version and sends it with the exact protocol +SHA. Dispatch is skipped until the variable is configured. + ### Rust The `rust/` directory contains the `blueye-protocol` crate. Code is generated at build time from `protobuf_definitions/*.proto` using [prost](https://crates.io/crates/prost) and [protox](https://crates.io/crates/protox) — no `protoc` installation required. diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 00000000..8b869aa6 --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,99 @@ +# Kotlin/JVM package + +Maven coordinate: `com.blueyerobotics:protocol-definitions`. + +The library generates Java message classes and Kotlin builder DSLs from the +shared schemas. It uses the **full** `protobuf-java` and `protobuf-kotlin` runtimes, +including descriptors, reflection, and `Any`. It targets Java 8 bytecode, uses +Kotlin 2.2.21, and builds with JDK 17 and the included Gradle 8.14.3 wrapper. +It is a JVM/Android library, not a Kotlin Multiplatform library, and contains no +network transport implementation. + +## Consume + +Add the repository to `dependencyResolutionManagement.repositories` in your +`settings.gradle.kts` (or your project's `repositories` block): + +```kotlin +maven { + url = uri("https://maven.pkg.github.com/blueye-robotics/ProtocolDefinitions") + credentials { + username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") + password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") + } + content { includeGroup("com.blueyerobotics") } +} +mavenCentral() +``` + +Store `gpr.user` and `gpr.key` in your user-level `~/.gradle/gradle.properties`, +not in source control. Use a GitHub personal access token (classic) with +`read:packages` and access to the repository. In Actions, a workflow's +`GITHUB_TOKEN` can be used where it has the required package access. + +After the first release is published, use its full version from the package workflow +(the run number below is an example): + +```kotlin +dependencies { + implementation("com.blueyerobotics:protocol-definitions:6.5.815") +} +``` + +Both Protobuf runtimes are transitive API dependencies. Consumers do not need a +Protobuf Gradle plugin or `protoc` installation. + +```kotlin +import blueye.protocol.Attitude +import blueye.protocol.attitude +import com.google.protobuf.Any as ProtoAny + +val original = attitude { yaw = 90f } +val decoded = Attitude.parseFrom(original.toByteArray()) +val packed = ProtoAny.pack(original) +val unpacked = packed.unpack(Attitude::class.java) +val descriptor = Attitude.getDescriptor() +``` + +Java classes live directly in `blueye.protocol` because the schemas enable +`java_multiple_files`. This does not change protobuf wire names or field numbers. +For Protobuf JSON, add `com.google.protobuf:protobuf-java-util` at the same runtime +version (currently `4.36.2`); tests exercise JSON with an `Any` type registry. + +## Develop and test + +From `kotlin/`: + +```sh +./gradlew build +./gradlew publishProtocolPublicationToSmokeTestRepository -PpackageVersion=6.5.0-local +./gradlew -p smoke-test run -PpackageVersion=6.5.0-local +``` + +The Gradle build downloads the matching `protoc` artifact and runtimes using the +version in `../protobuf.version`. Java and Kotlin sources are generated only in +the build directory. The separate smoke-test project resolves the actual local +Maven publication to verify the JAR, Kotlin DSL, and transitive dependencies. +The publication includes a sources JAR, POM, and Gradle module metadata. + +## Publishing + +`ci-kotlin.yaml` is called by the **Protocol packages** workflow in +`ci-dotnet.yaml`. The caller supplies the same version to NuGet, Maven, and Swift; +this reusable workflow does not calculate its own build number. Builds and tests +run on pushes and pull requests. Upstream branch pushes publish to GitHub Packages +using `GITHUB_TOKEN` with `packages: write`; pull requests and forks do not publish. + +The shared `version.txt` contains the Blueye App major/minor version, currently +`6.5`. Master publishes stable `6.5.` versions automatically. Other +branches publish `6.5.-branch-`. Local builds default +to `6.5.0-local`. The run counter belongs to `ci-dotnet.yaml`, so merges to master +do not require a version-file edit. NuGet and Maven publish first, then the same +version and source SHA are dispatched to Swift for generation and tagging. + +Published versions are immutable. A workflow rerun keeps its original version; +prefer rerunning failed jobs so successful publications are not repeated. + +To publish manually after testing, set credentials as above and run +`./gradlew publishProtocolPublicationToGitHubPackagesRepository -PpackageVersion=`. +The generic `publish` task also writes to the local smoke-test repository. diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts new file mode 100644 index 00000000..a2a4e1fa --- /dev/null +++ b/kotlin/build.gradle.kts @@ -0,0 +1,110 @@ +import com.google.protobuf.gradle.* +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + `java-library` + `maven-publish` + kotlin("jvm") version "2.2.21" + id("com.google.protobuf") version "0.9.5" +} + +group = "com.blueyerobotics" +val baseVersion = file("../version.txt").readText().trim() +version = providers.gradleProperty("packageVersion").getOrElse("$baseVersion.0-local") + +val protocVersion = file("../protobuf.version").readText().trim() +val protobufVersion = "4.$protocVersion" + +repositories { + mavenCentral() +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(17)) + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + withSourcesJar() +} + +kotlin { + compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8) +} + +tasks.withType().configureEach { + options.release.set(8) +} + +dependencies { + // Public APIs expose the full Java message classes and Kotlin DSL types. + api("com.google.protobuf:protobuf-java:$protobufVersion") + api("com.google.protobuf:protobuf-kotlin:$protobufVersion") + testImplementation(kotlin("test-junit5")) + testImplementation("com.google.protobuf:protobuf-java-util:$protobufVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.11.4") +} + +sourceSets { + main { + proto { + setSrcDirs(listOf("../protobuf_definitions")) + } + } +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$protobufVersion" + } + generateProtoTasks { + all().configureEach { + builtins { + // The Java builtin is enabled by default. Kotlin builds on that output. + id("kotlin") + } + } + } +} + +tasks.test { + useJUnitPlatform() +} + +publishing { + publications { + register("protocol") { + from(components["java"]) + artifactId = "protocol-definitions" + pom { + name.set("Blueye Protocol Definitions") + description.set("Protocol Buffer messages and Kotlin DSLs for Blueye underwater drones.") + url.set("https://github.com/BluEye-Robotics/ProtocolDefinitions") + licenses { + license { + name.set("GNU Lesser General Public License v3.0 only") + url.set("https://www.gnu.org/licenses/lgpl-3.0.html") + } + } + scm { + url.set("https://github.com/BluEye-Robotics/ProtocolDefinitions") + connection.set("scm:git:https://github.com/BluEye-Robotics/ProtocolDefinitions.git") + } + } + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/blueye-robotics/ProtocolDefinitions") + credentials { + username = providers.gradleProperty("gpr.user").orNull ?: System.getenv("GITHUB_ACTOR") + password = providers.gradleProperty("gpr.key").orNull ?: System.getenv("GITHUB_TOKEN") + } + } + // Exercise the published artifact without credentials or remote writes. + maven { + name = "SmokeTest" + url = uri(layout.buildDirectory.dir("smoke-repository")) + } + } +} diff --git a/kotlin/gradle.properties b/kotlin/gradle.properties new file mode 100644 index 00000000..265ded2f --- /dev/null +++ b/kotlin/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +kotlin.code.style=official diff --git a/kotlin/gradle/wrapper/gradle-wrapper.jar b/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kotlin/gradle/wrapper/gradle-wrapper.properties b/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..78cb6e16 --- /dev/null +++ b/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin/gradlew b/kotlin/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/kotlin/gradlew @@ -0,0 +1,251 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || 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="\\\"\\\"" + + +# 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, 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" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# 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/kotlin/gradlew.bat b/kotlin/gradlew.bat new file mode 100644 index 00000000..db3a6ac2 --- /dev/null +++ b/kotlin/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +: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/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts new file mode 100644 index 00000000..d2500958 --- /dev/null +++ b/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "protocol-definitions" diff --git a/kotlin/smoke-test/build.gradle.kts b/kotlin/smoke-test/build.gradle.kts new file mode 100644 index 00000000..2d2bd675 --- /dev/null +++ b/kotlin/smoke-test/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + application + kotlin("jvm") version "2.2.21" +} + +repositories { + maven { + url = uri("../build/smoke-repository") + content { includeGroup("com.blueyerobotics") } + } + mavenCentral() +} + +dependencies { + // Resolve the publication, including its transitive runtime dependencies. + implementation("com.blueyerobotics:protocol-definitions:${providers.gradleProperty("packageVersion").get()}") +} + +kotlin { + jvmToolchain(17) +} + +application { + mainClass.set("ConsumerKt") +} diff --git a/kotlin/smoke-test/settings.gradle.kts b/kotlin/smoke-test/settings.gradle.kts new file mode 100644 index 00000000..07ada910 --- /dev/null +++ b/kotlin/smoke-test/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "protocol-consumer-smoke-test" diff --git a/kotlin/smoke-test/src/main/kotlin/Consumer.kt b/kotlin/smoke-test/src/main/kotlin/Consumer.kt new file mode 100644 index 00000000..7585b4f9 --- /dev/null +++ b/kotlin/smoke-test/src/main/kotlin/Consumer.kt @@ -0,0 +1,11 @@ +import blueye.protocol.Attitude +import blueye.protocol.attitude +import com.google.protobuf.Any as ProtoAny + +fun main() { + val original = attitude { yaw = 90f } + check(Attitude.parseFrom(original.toByteArray()) == original) + check(ProtoAny.pack(original).unpack(Attitude::class.java) == original) + check(Attitude.getDescriptor().fullName == "blueye.protocol.Attitude") + println("Published Kotlin package: DSL, full runtime, and transitive dependencies OK") +} diff --git a/kotlin/src/test/kotlin/blueye/protocol/ProtocolTest.kt b/kotlin/src/test/kotlin/blueye/protocol/ProtocolTest.kt new file mode 100644 index 00000000..ae798889 --- /dev/null +++ b/kotlin/src/test/kotlin/blueye/protocol/ProtocolTest.kt @@ -0,0 +1,90 @@ +package blueye.protocol + +import com.google.protobuf.Any as ProtoAny +import com.google.protobuf.DynamicMessage +import com.google.protobuf.InvalidProtocolBufferException +import com.google.protobuf.TypeRegistry +import com.google.protobuf.timestamp +import com.google.protobuf.util.JsonFormat +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ProtocolTest { + @Test + fun attitudeMatchesWireFixture() { + val original = attitude { + roll = 1f + pitch = 2f + yaw = 3f + } + // The same fixed32 wire fixture is checked by the Swift client tests. + val expected = byteArrayOf( + 0x0d, 0x00, 0x00, 0x80.toByte(), 0x3f, + 0x15, 0x00, 0x00, 0x00, 0x40, + 0x1d, 0x00, 0x00, 0x40, 0x40, + ) + assertContentEquals(expected, original.toByteArray()) + assertEquals(original, Attitude.parseFrom(expected)) + } + + @Test + fun anyAndTimestampRoundTrip() { + val original = attitude { yaw = 90f } + val record = binlogRecord { + payload = ProtoAny.pack(original) + unixTimestamp = timestamp { + seconds = 1_700_000_000 + nanos = 123_456_789 + } + } + val decoded = BinlogRecord.parseFrom(record.toByteArray()) + assertEquals(record, decoded) + assertEquals("type.googleapis.com/blueye.protocol.Attitude", decoded.payload.typeUrl) + assertEquals(original, decoded.payload.unpack(Attitude::class.java)) + } + + @Test + fun fullRuntimeSupportsReflectionAndAnyJson() { + val original = attitude { roll = 1.5f } + val descriptor = Attitude.getDescriptor() + assertEquals("blueye.protocol.Attitude", descriptor.fullName) + val dynamic = DynamicMessage.parseFrom(descriptor, original.toByteArray()) + assertEquals(1.5f, dynamic.getField(descriptor.findFieldByName("roll"))) + + val registry = TypeRegistry.newBuilder().add(descriptor).build() + val packed = ProtoAny.pack(original) + val json = JsonFormat.printer().usingTypeRegistry(registry).print(packed) + val decoded = ProtoAny.newBuilder() + JsonFormat.parser().usingTypeRegistry(registry).merge(json, decoded) + assertEquals(packed, decoded.build()) + } + + @Test + fun missionOneofRoundTrip() { + val original = mission { + id = 42 + name = "Inspection" + instructions += instruction { + id = 1 + depthSetPointCommand = depthSetPointCommand { + depthSetPoint = depthSetPoint { depth = 10f } + } + } + } + val decoded = Mission.parseFrom(original.toByteArray()) + assertEquals(original, decoded) + assertTrue(decoded.getInstructions(0).hasDepthSetPointCommand()) + } + + @Test + fun unknownFieldsSurviveRoundTrip() { + val bytes = byteArrayOf(0xa0.toByte(), 0x06, 0x07) // Field 100, varint 7. + assertContentEquals(bytes, Attitude.parseFrom(bytes).toByteArray()) + assertFailsWith { + Attitude.parseFrom(byteArrayOf(0x0d)) + } + } +} diff --git a/protobuf.version b/protobuf.version new file mode 100644 index 00000000..b82d77ec --- /dev/null +++ b/protobuf.version @@ -0,0 +1 @@ +36.2 diff --git a/protobuf_definitions/aquatroll.proto b/protobuf_definitions/aquatroll.proto index a01e11f9..12bf7d3c 100644 --- a/protobuf_definitions/aquatroll.proto +++ b/protobuf_definitions/aquatroll.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package blueye.protocol; import "google/protobuf/timestamp.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // Type IDs enum Type { diff --git a/protobuf_definitions/control.proto b/protobuf_definitions/control.proto index 09528486..065ff262 100644 --- a/protobuf_definitions/control.proto +++ b/protobuf_definitions/control.proto @@ -7,6 +7,7 @@ package blueye.protocol; import "aquatroll.proto"; import "message_formats.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // Issue a command to move the drone in the surge, sway, heave, or yaw direction. message MotionInputCtrl { diff --git a/protobuf_definitions/message_formats.proto b/protobuf_definitions/message_formats.proto index 05c06df6..f0a5cc82 100644 --- a/protobuf_definitions/message_formats.proto +++ b/protobuf_definitions/message_formats.proto @@ -8,6 +8,7 @@ import "google/protobuf/any.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // Wrapper message for each entry in the drone telemetry logfile. // diff --git a/protobuf_definitions/mission_planning.proto b/protobuf_definitions/mission_planning.proto index ba5a940e..2a6dc8a1 100644 --- a/protobuf_definitions/mission_planning.proto +++ b/protobuf_definitions/mission_planning.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package blueye.protocol; import "message_formats.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // A list of waypoints describes a mission that the auto pilot can execute. message Mission { diff --git a/protobuf_definitions/req_rep.proto b/protobuf_definitions/req_rep.proto index 796735eb..8e563151 100644 --- a/protobuf_definitions/req_rep.proto +++ b/protobuf_definitions/req_rep.proto @@ -9,6 +9,7 @@ import "message_formats.proto"; import "mission_planning.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // Request to set video overlay parameters. message SetOverlayParametersReq { diff --git a/protobuf_definitions/telemetry.proto b/protobuf_definitions/telemetry.proto index f693a6db..73fee511 100644 --- a/protobuf_definitions/telemetry.proto +++ b/protobuf_definitions/telemetry.proto @@ -8,6 +8,7 @@ import "aquatroll.proto"; import "message_formats.proto"; import "mission_planning.proto"; option csharp_namespace = "Blueye.Protocol.Protobuf"; +option java_multiple_files = true; // Receive the current attitude of the drone. message AttitudeTel { diff --git a/scripts/package-version.py b/scripts/package-version.py new file mode 100644 index 00000000..dc8b597b --- /dev/null +++ b/scripts/package-version.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Calculate one version for NuGet, Maven, and the downstream Swift release.""" + +import os +from pathlib import Path +import re + + +def package_version(base: str, env: dict[str, str]) -> str: + if not re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", base): + raise ValueError("version.txt must contain a major.minor version, for example 6.5") + + run = env.get("GITHUB_RUN_NUMBER") + if not run: + return f"{base}.0-local" + if not re.fullmatch(r"[1-9][0-9]*", run): + raise ValueError("GITHUB_RUN_NUMBER must be a positive integer") + + version = f"{base}.{run}" + if env.get("GITHUB_REF") == "refs/heads/master" and not env.get("GITHUB_HEAD_REF"): + return version + + ref = env.get("GITHUB_HEAD_REF") or env.get("GITHUB_REF_NAME", "dev") + branch = re.sub(r"[^a-z0-9]+", "-", ref.lower()).strip("-")[:63].rstrip("-") or "dev" + # A fixed prefix also makes numeric branch names valid SemVer prereleases. + return f"{version}-branch-{branch}" + + +if __name__ == "__main__": + version_file = Path(__file__).resolve().parents[1] / "version.txt" + try: + print(package_version(version_file.read_text().strip(), dict(os.environ))) + except ValueError as error: + raise SystemExit(str(error)) diff --git a/scripts/test_package_version.py b/scripts/test_package_version.py new file mode 100644 index 00000000..4b8bded4 --- /dev/null +++ b/scripts/test_package_version.py @@ -0,0 +1,49 @@ +import importlib.util +from pathlib import Path +import unittest + + +spec = importlib.util.spec_from_file_location("package_version", Path(__file__).with_name("package-version.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +package_version = module.package_version + + +class PackageVersionTests(unittest.TestCase): + def test_master_uses_the_existing_workflow_run_counter(self): + env = {"GITHUB_REF": "refs/heads/master", "GITHUB_RUN_NUMBER": "815"} + self.assertEqual(package_version("6.5", env), "6.5.815") + self.assertEqual(package_version("6.6", env), "6.6.815") + + def test_each_master_run_gets_a_new_stable_version(self): + for run in (815, 816): + env = {"GITHUB_REF": "refs/heads/master", "GITHUB_RUN_NUMBER": str(run)} + self.assertEqual(package_version("6.5", env), f"6.5.{run}") + + def test_pull_request_from_a_fork_master_is_a_prerelease(self): + env = {"GITHUB_REF": "refs/pull/12/merge", "GITHUB_HEAD_REF": "master", "GITHUB_RUN_NUMBER": "817"} + self.assertEqual(package_version("6.5", env), "6.5.817-branch-master") + + def test_numeric_and_punctuation_branch_names_are_valid_prereleases(self): + for branch, expected in [("001", "001"), ("Feature/New Sensor", "feature-new-sensor"), ("///", "dev")]: + env = {"GITHUB_REF_NAME": branch, "GITHUB_RUN_NUMBER": "818"} + self.assertEqual(package_version("6.5", env), f"6.5.818-branch-{expected}") + + def test_reruns_keep_the_same_version(self): + env = {"GITHUB_REF": "refs/heads/master", "GITHUB_RUN_NUMBER": "815", "GITHUB_RUN_ATTEMPT": "2"} + self.assertEqual(package_version("6.5", env), "6.5.815") + + def test_local_builds_cannot_be_mistaken_for_releases(self): + self.assertEqual(package_version("6.5", {}), "6.5.0-local") + + def test_invalid_base_and_counter_are_rejected(self): + for base in ("6.5.0", "v6.5", "06.5", "6.5-rc", "6"): + with self.subTest(base=base), self.assertRaises(ValueError): + package_version(base, {}) + for run in ("0", "0815", "-1", "815.2"): + with self.subTest(run=run), self.assertRaises(ValueError): + package_version("6.5", {"GITHUB_RUN_NUMBER": run}) + + +if __name__ == "__main__": + unittest.main() diff --git a/version.txt b/version.txt new file mode 100644 index 00000000..7822c1ab --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +6.5