Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/field-metadata.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: field-metadata

# Guards the field-metadata generators (see tools/protoc-gen-fieldmeta and
# tools/protoc-gen-fieldmeta-swift). The Go plugin's tests aren't otherwise run
# in CI, and the Go/Swift "byte-identical" promise was previously hand-checked
# only — this workflow enforces both.
on:
pull_request:
paths:
- "meshtastic/**/*.proto"
- "tools/protoc-gen-fieldmeta/**"
- "tools/protoc-gen-fieldmeta-swift/**"
- ".github/workflows/field-metadata.yml"

permissions:
contents: read

jobs:
go-plugin-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: tools/protoc-gen-fieldmeta/go.mod

- name: Test
run: go -C tools/protoc-gen-fieldmeta test ./...

- name: Vet
run: go -C tools/protoc-gen-fieldmeta vet ./...

generator-parity:
# protoc-gen-fieldmeta (Go, target=swift) and protoc-gen-fieldmeta-swift are
# advertised as interchangeable; assert their output stays byte-identical.
runs-on: macos-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: tools/protoc-gen-fieldmeta/go.mod

- name: Setup buf
uses: bufbuild/buf-setup-action@v1
with:
version: 1.71.0 # renovate: datasource=github-releases depName=bufbuild/buf
github_token: ${{ github.token }}

- name: Verify Go/Swift generator parity
run: tools/protoc-gen-fieldmeta-swift/scripts/verify-parity.sh
12 changes: 12 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ jobs:
with:
github_token: ${{ github.token }}

# buf.gen.yaml runs the field-metadata plugin via `go run`, so Go must be
# on PATH. (`go-version-file` pins it to the plugin module's go directive.)
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: tools/protoc-gen-fieldmeta/go.mod

- name: Generate protobuf code
run: buf generate

Expand All @@ -69,7 +76,12 @@ jobs:
echo "No '*_pb.ts' files found in '$src_dir'. 'buf generate' may have produced no TypeScript files or changed their naming." >&2
exit 1
fi
if [ ! -f "$src_dir/field_metadata_registry.ts" ]; then
echo "Expected 'field_metadata_registry.ts' (field-metadata plugin output) not found in '$src_dir'. The protoc-gen-fieldmeta target may have failed; mod.ts re-exports it as 'FieldMeta'." >&2
exit 1
fi
mv "$src_dir"/*_pb.ts "$dest_dir"/
mv "$src_dir"/field_metadata_registry.ts "$dest_dir"/
- name: Show generated files
run: |
echo "=== packages/ts contents ==="
Expand Down
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@
packages/ts/lib/
packages/kmp/.gradle/
packages/kmp/build/
packages/kmp/buildSrc/.gradle/
packages/kmp/buildSrc/build/
.bin/

# Local build of the field-metadata protoc plugin
tools/protoc-gen-fieldmeta/protoc-gen-fieldmeta

# Local build of the Swift field-metadata protoc plugin
tools/protoc-gen-fieldmeta-swift/.build/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ The [Protobuf](https://developers.google.com/protocol-buffers) message definitio
- Rust package: `packages/rust`
- Kotlin Multiplatform package (Wire): `packages/kmp`

## Field metadata

Fields can carry app/UI-relevant metadata (e.g. `diy_only`) via the `(meshtastic.field_metadata)`
option — see [`meshtastic/field_metadata.proto`](meshtastic/field_metadata.proto). It is exposed to
consumers as reflection-free generated accessors: the KMP package generates them with a Wire
`SchemaHandler` ([`packages/kmp`](packages/kmp/README.md)), and other languages via the
[`tools/protoc-gen-fieldmeta`](tools/protoc-gen-fieldmeta/README.md) plugin (which has integration
recipes for firmware/Apple/Python).

## Stats

![Alt](https://repobeats.axiom.co/api/embed/47e9ee1d81d9c0fdd2b4b5b4c673adb1756f6db5.svg "Repobeats analytics image")
6 changes: 6 additions & 0 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ plugins:
- remote: buf.build/bufbuild/es:v2.1.0
out: packages/ts/lib
opt: target=ts,import_extension=.ts
# Reflection-free field-metadata registry generated from the
# (meshtastic.field_metadata) options (see tools/protoc-gen-fieldmeta).
# Invoked via `go run`, so it needs Go on PATH (GitHub-hosted runners ship it).
- local: ["go", "run", "-C", "tools/protoc-gen-fieldmeta", "."]
out: packages/ts/lib
opt: target=typescript
5 changes: 3 additions & 2 deletions meshtastic/config.proto
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ syntax = "proto3";
package meshtastic;

import "meshtastic/device_ui.proto";
import "meshtastic/field_metadata.proto";

option csharp_namespace = "Meshtastic.Protobufs";
option go_package = "github.com/meshtastic/go/generated";
Expand Down Expand Up @@ -398,12 +399,12 @@ message Config {
/*
* (Re)define GPS_RX_PIN for your board.
*/
uint32 rx_gpio = 8;
uint32 rx_gpio = 8 [(meshtastic.field_metadata) = {diy_only: true}];

/*
* (Re)define GPS_TX_PIN for your board.
*/
uint32 tx_gpio = 9;
uint32 tx_gpio = 9 [(meshtastic.field_metadata) = {diy_only: true}];

/*
* The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if position_broadcast_smart_enabled
Expand Down
88 changes: 88 additions & 0 deletions meshtastic/field_metadata.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
syntax = "proto2";

package meshtastic;

import "google/protobuf/descriptor.proto";

option csharp_namespace = "Meshtastic.Protobufs";
option go_package = "github.com/meshtastic/go/generated";
option java_outer_classname = "FieldMetadataProtos";
option java_package = "org.meshtastic.proto";
option swift_prefix = "";

/*
* Structured, app/UI-relevant metadata describing a protobuf field.
*
* Carried in a single FieldOptions extension (`field_metadata` below), so adding
* an attribute is a SCHEMA-ONLY change: add a new field to this message (use the
* next field number) and every generated registry — KMP/Wire, C, Python,
* TypeScript, Rust, Swift — picks it up automatically. No code generator or
* build change is needed, and no additional FieldOptions extension number is
* consumed.
*
* Constraint: attributes must be SCALAR (bool / int / float / string). A
* message, enum, bytes, repeated, or map attribute is rejected at generation
* time (the generators would otherwise emit meaningless, non-deterministic
* values). See tools/protoc-gen-fieldmeta.
*
* To tag a field, set the option on it, e.g.
* uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
*
* One attribute, `deprecated`, is special: it MIRRORS the field's standard
* `[deprecated = true]` option rather than being set inside this annotation.
* The generators populate it from that standard option so apps can read
* deprecation at runtime (protobuf runtimes strip options, so it is otherwise
* invisible). This is the only attribute that costs a generator change.
*
* Downstream apps use this to drive UI decisions (e.g. hiding DIY-only settings
* on pre-assembled boards) directly from the protobuf schema.
*/
message FieldMetadata {
/*
* Field is only relevant to DIY hardware builds. Apps may hide it when
* connected to a pre-assembled / commercial board.
*/
optional bool diy_only = 1;

/*
* Field is only relevant in advanced / administrative contexts and may be
* hidden from the default UI.
*/
optional bool admin_only = 2;

/*
* Inclusive lower bound for the field value, for UI validation/clamping.
*/
optional double min_value = 3;

/*
* Inclusive upper bound for the field value, for UI validation/clamping.
*/
optional double max_value = 4;

/*
* Human-facing unit label for the value (e.g. "m", "s", "dBm").
*/
optional string unit = 5;

/*
* Field is deprecated. MIRRORS the field's standard `[deprecated = true]`
* option — the generators populate this automatically from that option so
* every consumer can read it at runtime (protobuf runtimes strip options, so
* the standard `deprecated` bit is otherwise invisible to apps). Setting it
* by hand in a (meshtastic.field_metadata) annotation is a generation-time
* ERROR; mark the field `[deprecated = true]` as usual and it flows through
* here.
*/
optional bool deprecated = 6;
}

extend google.protobuf.FieldOptions {
/*
* Attach FieldMetadata to a field, e.g.
* uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
*
* Private-use extension number range is 50000-99999.
*/
optional FieldMetadata field_metadata = 51001;
}
44 changes: 44 additions & 0 deletions packages/kmp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@ This package publishes Kotlin Multiplatform models generated from the protobuf s

> This package publishes Kotlin/KMP artifacts to Maven repositories. Native Swift-only distribution (for example an XCFramework/SPM package) is a separate delivery path and is not part of this module.

## Field metadata

Fields in the schema can carry app/UI-relevant metadata via the `(meshtastic.field_metadata)`
field option (see `meshtastic/field_metadata.proto`). For example, GPIO pins that only matter on
DIY hardware are annotated:

```proto
uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
```

At build time a custom Wire `SchemaHandler` (in `buildSrc`) generates a reflection-free
`FieldMetadataRegistry` into the published artifact — plain Kotlin, queryable on **every** target
(JVM, Android, JS, Wasm, Native) with no reflection and no runtime cost.

Prefer the **typed accessors**, generated as extension properties on each message's companion
object — they hang directly off the real message type, so they're autocomplete-friendly with no
magic strings or field tags (the IDE auto-imports the extension on use):

```kotlin
import org.meshtastic.proto.rx_gpio // companion extension on Config.PositionConfig

// e.g. hide a DIY-only setting unless the connected device is a DIY build
if (!Config.PositionConfig.rx_gpio.diy_only || deviceIsDiy) {
/* show the GPIO setting */
}
```

Each typed accessor is a per-field extension on the message companion, so it carries its own
import (the IDE auto-imports on use). To **enumerate** what's annotated on a message — e.g. a
settings screen iterating a message's fields — use the dynamic lookups keyed by proto message
name: `forType` returns every annotated field (keyed by tag) and `get` looks up one field:

```kotlin
// all annotated fields on a message: tag -> FieldMetadata
for ((tag, meta) in FieldMetadataRegistry.forType("meshtastic.Config.PositionConfig")) { /* … */ }

// a single field
val isDiyOnly = FieldMetadataRegistry.get("meshtastic.Config.PositionConfig", tag = 8)?.diy_only == true
```

Adding a new **scalar** attribute to the `FieldMetadata` message (e.g. `admin_only`, `unit`) is a
schema-only change — the generator serializes whatever sub-fields are set, so no build code needs
to change.

## Versioning

The SDK version is derived automatically from the latest git tag — no manual version file to maintain.
Expand Down
11 changes: 11 additions & 0 deletions packages/kmp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ wire {
// Reduces allocations on high-frequency decode paths (mesh packets).
makeImmutableCopies = false
}

// Generate a reflection-free `FieldMetadataRegistry` from the
// `(meshtastic.field_metadata)` field options. The custom SchemaHandler lives in
// `buildSrc`; the Wire plugin auto-registers this output dir into `commonMain` (with the
// generate-task dependency), so downstream consumers query field metadata on every KMP
// target with no reflection and no runtime cost. A distinct `out` dir avoids clobbering
// the `kotlin {}` target's generated sources.
custom {
schemaHandlerFactoryClass = "org.meshtastic.proto.build.FieldMetadataRegistryHandlerFactory"
out = "build/generated/source/wire-field-metadata"
}
}

// Ensure protos are synced before Wire generates code
Expand Down
14 changes: 14 additions & 0 deletions packages/kmp/buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
plugins {
`kotlin-dsl`
}

repositories {
google()
mavenCentral()
}

dependencies {
// Build-time only: lets the custom Wire SchemaHandler walk the parsed schema and read
// the (meshtastic.field_metadata) field options. NOT shipped in any published artifact.
implementation("com.squareup.wire:wire-schema:6.4.0")
}
Loading
Loading