Skip to content

Commit 007d7b2

Browse files
jamesarichclaude
andcommitted
feat: add field metadata option + cross-language registry generators
Introduces a structured (meshtastic.field_metadata) custom field option and build-time generators that expose it as reflection-free static accessors in every consumer language: KMP/Wire (Wire SchemaHandler in packages/kmp/buildSrc) and C/Python/TypeScript/Rust/Swift (the tools/protoc-gen-fieldmeta plugin). The TS target is wired into buf.gen.yaml and re-exported from mod.ts as FieldMeta. Generalizes the #905 experiment: one extensible FieldMetadata message carried in a single FieldOptions extension, so adding an attribute is a schema-only change (scalar-only, enforced at generation time) with no new extension number and no generator changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent da60cee commit 007d7b2

18 files changed

Lines changed: 1809 additions & 2 deletions

File tree

.github/workflows/publish.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ jobs:
5353
with:
5454
github_token: ${{ github.token }}
5555

56+
# buf.gen.yaml runs the field-metadata plugin via `go run`, so Go must be
57+
# on PATH. (`go-version-file` pins it to the plugin module's go directive.)
58+
- name: Setup Go
59+
uses: actions/setup-go@v5
60+
with:
61+
go-version-file: tools/protoc-gen-fieldmeta/go.mod
62+
5663
- name: Generate protobuf code
5764
run: buf generate
5865

@@ -69,7 +76,12 @@ jobs:
6976
echo "No '*_pb.ts' files found in '$src_dir'. 'buf generate' may have produced no TypeScript files or changed their naming." >&2
7077
exit 1
7178
fi
79+
if [ ! -f "$src_dir/field_metadata_registry.ts" ]; then
80+
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
81+
exit 1
82+
fi
7283
mv "$src_dir"/*_pb.ts "$dest_dir"/
84+
mv "$src_dir"/field_metadata_registry.ts "$dest_dir"/
7385
- name: Show generated files
7486
run: |
7587
echo "=== packages/ts contents ==="

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@
44
packages/ts/lib/
55
packages/kmp/.gradle/
66
packages/kmp/build/
7+
packages/kmp/buildSrc/.gradle/
8+
packages/kmp/buildSrc/build/
79
.bin/
10+
11+
# Local build of the field-metadata protoc plugin
12+
tools/protoc-gen-fieldmeta/protoc-gen-fieldmeta

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ The [Protobuf](https://developers.google.com/protocol-buffers) message definitio
1717
- Rust package: `packages/rust`
1818
- Kotlin Multiplatform package (Wire): `packages/kmp`
1919

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

2231
![Alt](https://repobeats.axiom.co/api/embed/47e9ee1d81d9c0fdd2b4b5b4c673adb1756f6db5.svg "Repobeats analytics image")

buf.gen.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@ plugins:
33
- remote: buf.build/bufbuild/es:v2.1.0
44
out: packages/ts/lib
55
opt: target=ts,import_extension=.ts
6+
# Reflection-free field-metadata registry generated from the
7+
# (meshtastic.field_metadata) options (see tools/protoc-gen-fieldmeta).
8+
# Invoked via `go run`, so it needs Go on PATH (GitHub-hosted runners ship it).
9+
- local: ["go", "run", "-C", "tools/protoc-gen-fieldmeta", "."]
10+
out: packages/ts/lib
11+
opt: target=typescript

meshtastic/config.proto

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ syntax = "proto3";
33
package meshtastic;
44

55
import "meshtastic/device_ui.proto";
6+
import "meshtastic/field_metadata.proto";
67

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

403404
/*
404405
* (Re)define GPS_TX_PIN for your board.
405406
*/
406-
uint32 tx_gpio = 9;
407+
uint32 tx_gpio = 9 [(meshtastic.field_metadata) = {diy_only: true}];
407408

408409
/*
409410
* The minimum distance in meters traveled (since the last send) before we can send a position to the mesh if position_broadcast_smart_enabled

meshtastic/field_metadata.proto

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
syntax = "proto2";
2+
3+
package meshtastic;
4+
5+
import "google/protobuf/descriptor.proto";
6+
7+
option csharp_namespace = "Meshtastic.Protobufs";
8+
option go_package = "github.com/meshtastic/go/generated";
9+
option java_outer_classname = "FieldMetadataProtos";
10+
option java_package = "org.meshtastic.proto";
11+
option swift_prefix = "";
12+
13+
/*
14+
* Structured, app/UI-relevant metadata describing a protobuf field.
15+
*
16+
* Carried in a single FieldOptions extension (`field_metadata` below), so adding
17+
* an attribute is a SCHEMA-ONLY change: add a new field to this message (use the
18+
* next field number) and every generated registry — KMP/Wire, C, Python,
19+
* TypeScript, Rust, Swift — picks it up automatically. No code generator or
20+
* build change is needed, and no additional FieldOptions extension number is
21+
* consumed.
22+
*
23+
* Constraint: attributes must be SCALAR (bool / int / float / string). A
24+
* message, enum, bytes, repeated, or map attribute is rejected at generation
25+
* time (the generators would otherwise emit meaningless, non-deterministic
26+
* values). See tools/protoc-gen-fieldmeta.
27+
*
28+
* To tag a field, set the option on it, e.g.
29+
* uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
30+
*
31+
* Downstream apps use this to drive UI decisions (e.g. hiding DIY-only settings
32+
* on pre-assembled boards) directly from the protobuf schema.
33+
*/
34+
message FieldMetadata {
35+
/*
36+
* Field is only relevant to DIY hardware builds. Apps may hide it when
37+
* connected to a pre-assembled / commercial board.
38+
*/
39+
optional bool diy_only = 1;
40+
41+
/*
42+
* Field is only relevant in advanced / administrative contexts and may be
43+
* hidden from the default UI.
44+
*/
45+
optional bool admin_only = 2;
46+
47+
/*
48+
* Inclusive lower bound for the field value, for UI validation/clamping.
49+
*/
50+
optional double min_value = 3;
51+
52+
/*
53+
* Inclusive upper bound for the field value, for UI validation/clamping.
54+
*/
55+
optional double max_value = 4;
56+
57+
/*
58+
* Human-facing unit label for the value (e.g. "m", "s", "dBm").
59+
*/
60+
optional string unit = 5;
61+
}
62+
63+
extend google.protobuf.FieldOptions {
64+
/*
65+
* Attach FieldMetadata to a field, e.g.
66+
* uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
67+
*
68+
* Private-use extension number range is 50000-99999.
69+
*/
70+
optional FieldMetadata field_metadata = 51001;
71+
}

packages/kmp/README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ This package publishes Kotlin Multiplatform models generated from the protobuf s
1111

1212
> 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.
1313
14+
## Field metadata
15+
16+
Fields in the schema can carry app/UI-relevant metadata via the `(meshtastic.field_metadata)`
17+
field option (see `meshtastic/field_metadata.proto`). For example, GPIO pins that only matter on
18+
DIY hardware are annotated:
19+
20+
```proto
21+
uint32 rx_gpio = 8 [(meshtastic.field_metadata) = { diy_only: true }];
22+
```
23+
24+
At build time a custom Wire `SchemaHandler` (in `buildSrc`) generates a reflection-free
25+
`FieldMetadataRegistry` into the published artifact — plain Kotlin, queryable on **every** target
26+
(JVM, Android, JS, Wasm, Native) with no reflection and no runtime cost.
27+
28+
Prefer the **typed accessors**, generated as extension properties on each message's companion
29+
object — they hang directly off the real message type, so they're autocomplete-friendly with no
30+
magic strings or field tags (the IDE auto-imports the extension on use):
31+
32+
```kotlin
33+
import org.meshtastic.proto.rx_gpio // companion extension on Config.PositionConfig
34+
35+
// e.g. hide a DIY-only setting unless the connected device is a DIY build
36+
if (!Config.PositionConfig.rx_gpio.diy_only || deviceIsDiy) {
37+
/* show the GPIO setting */
38+
}
39+
```
40+
41+
Each typed accessor is a per-field extension on the message companion, so it carries its own
42+
import (the IDE auto-imports on use). To **enumerate** what's annotated on a message — e.g. a
43+
settings screen iterating a message's fields — use the dynamic lookups keyed by proto message
44+
name: `forType` returns every annotated field (keyed by tag) and `get` looks up one field:
45+
46+
```kotlin
47+
// all annotated fields on a message: tag -> FieldMetadata
48+
for ((tag, meta) in FieldMetadataRegistry.forType("meshtastic.Config.PositionConfig")) { /**/ }
49+
50+
// a single field
51+
val isDiyOnly = FieldMetadataRegistry.get("meshtastic.Config.PositionConfig", tag = 8)?.diy_only == true
52+
```
53+
54+
Adding a new **scalar** attribute to the `FieldMetadata` message (e.g. `admin_only`, `unit`) is a
55+
schema-only change — the generator serializes whatever sub-fields are set, so no build code needs
56+
to change.
57+
1458
## Versioning
1559

1660
The SDK version is derived automatically from the latest git tag — no manual version file to maintain.

packages/kmp/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ wire {
111111
// Reduces allocations on high-frequency decode paths (mesh packets).
112112
makeImmutableCopies = false
113113
}
114+
115+
// Generate a reflection-free `FieldMetadataRegistry` from the
116+
// `(meshtastic.field_metadata)` field options. The custom SchemaHandler lives in
117+
// `buildSrc`; the Wire plugin auto-registers this output dir into `commonMain` (with the
118+
// generate-task dependency), so downstream consumers query field metadata on every KMP
119+
// target with no reflection and no runtime cost. A distinct `out` dir avoids clobbering
120+
// the `kotlin {}` target's generated sources.
121+
custom {
122+
schemaHandlerFactoryClass = "org.meshtastic.proto.build.FieldMetadataRegistryHandlerFactory"
123+
out = "build/generated/source/wire-field-metadata"
124+
}
114125
}
115126

116127
// Ensure protos are synced before Wire generates code
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
plugins {
2+
`kotlin-dsl`
3+
}
4+
5+
repositories {
6+
google()
7+
mavenCentral()
8+
}
9+
10+
dependencies {
11+
// Build-time only: lets the custom Wire SchemaHandler walk the parsed schema and read
12+
// the (meshtastic.field_metadata) field options. NOT shipped in any published artifact.
13+
implementation("com.squareup.wire:wire-schema:6.4.0")
14+
}

0 commit comments

Comments
 (0)