Documentation
¶
Overview ¶
Package clientplugin is the contract between the Pacenote client — the telemetry app a driver runs beside the simulator — and the plugins compiled into it.
The client is one executable that a team's server builds: the operator picks the plugins, the server generates a main that imports them all and compiles it. Go has no run-time plugins on Windows, so a client plugin is a Go module whose package registers itself when imported:
func init() { clientplugin.RegisterSource(&iracing{}) }
and carries a client-plugin.json at its root saying what it is. This module holds what both sides agree on and nothing else: the two things a plugin can be, what the app lends a plugin, the sample a simulator reader produces, the events the app raises, the registry, and the manifest.
Two kinds of plugin ¶
A Source reads one simulator and produces a Sample at the simulator's own rate. One source runs at a time: the one whose simulator is running.
A Companion is the client half of a server plugin. It subscribes to the app's events and talks to its own server plugin's routes under /plugin/<name>/, with the driver's token, through the Host the app lends it. It cannot reach anything else. It is started when the server lists its plugin and stopped when the plugin disappears.
Versions ¶
InterfaceVersion is what a plugin was compiled against and what the app checks at start. It moves only when a type or method here changes shape.
Dependencies ¶
The standard library and the wire protocol, so that a plugin author depends on nothing they did not already need.
Index ¶
- Constants
- Variables
- func RegisterCompanion(c Companion)
- func RegisterSource(s Source)
- type Actor
- type Companion
- type Corner
- type CornerApproaching
- type CornerPassed
- type Event
- type EventKind
- type Host
- type Kind
- type LapCompleted
- type LapLastCorner
- type Manifest
- type Muted
- type Player
- type Registry
- type Sample
- type Sampled
- type ServerChanged
- type Source
- type Stint
- type StintFinished
- type StintStarted
- type Tyres
- type Weather
Constants ¶
const InterfaceVersion = 1
InterfaceVersion is the shape of this contract. A plugin's manifest says which version it was written against; the app refuses a plugin built against another at start, with a sentence, rather than misreading it later.
It moves when a type or method a plugin implements or calls changes shape. Adding an event kind or an optional field does not move it: a plugin that does not know a kind never asks for it, and a zero field means "not measured".
const ManifestName = "client-plugin.json"
ManifestName is the file at the root of a plugin's module that says what it is. The server reads it, from the module as the proxy serves it, to draw the build page; the app never does, because by the time the app runs the plugin is compiled in and speaks for itself.
const MaxManifestBytes = 64 << 10
MaxManifestBytes is the largest manifest that is read.
Variables ¶
var Default = NewRegistry()
Default is the registry plugins register into and the app reads.
var ErrNoManifest = errors.New("no " + ManifestName + ": not a client plugin")
ErrNoManifest reports that a directory holds no client-plugin.json: it is not a client plugin.
Functions ¶
func RegisterCompanion ¶
func RegisterCompanion(c Companion)
RegisterCompanion adds a companion to Default. Call it from init().
func RegisterSource ¶
func RegisterSource(s Source)
RegisterSource adds a source to Default. Call it from init().
Types ¶
type Actor ¶
Actor is what a companion implements when its page has controls. A page is plain HTML; a form or an input in it with a data-action attribute is wired by the window to this: submitting the form, or changing the input, calls Act with the action's name and the values. The companion answers by redrawing its page. That is how a plugin owns its own settings — a volume slider on the voice companion's page — without the client knowing what a volume is.
<form data-action="volume"><input type="range" name="level" value="80"></form> <select data-action="voice" name="mode">…</select>
type Companion ¶
type Companion interface {
// Name is the server plugin this is the client half of, exactly as GET /me
// lists it: "engineer", "visual-telemetry".
Name() string
// Wants is the events to deliver. A kind not listed is never delivered;
// a kind this contract version does not know is ignored.
Wants() []EventKind
// Start is called with the Host the companion keeps for its lifetime.
// Returning an error leaves the companion stopped, and the app shows why.
Start(ctx context.Context, h Host) error
// Notify is one event. It should return quickly; anything that waits on
// the network belongs in a goroutine the companion runs under the ctx
// it was started with, so that Stop can end it. An error is logged by the
// app with the companion's name and changes nothing else: the next event
// is still delivered.
Notify(ctx context.Context, e Event) error
// Stop is called once after the last Notify. Everything the companion
// started must have ended when it returns.
Stop() error
}
Companion is the client half of a server plugin. It exists so that a server plugin can have a part running beside the simulator — measuring, posting, playing what comes back — without the client knowing what any particular plugin does.
The app starts a companion when GET /me lists a running server plugin with the companion's Companion.Name, and stops it when the plugin disappears from that list or the app closes. Between those, the app delivers the events the companion asked for, one at a time and in order, from a goroutine that belongs to the companion: a slow Notify delays this companion's next event and nobody else's.
type Corner ¶
type Corner struct {
Turn int `json:"turn"`
ApexPct int `json:"apex_pct"`
// ApexKmh is the speed at the apex; MinKmh the lowest in the corner, which
// is the apex speed unless the driver slowed past the apex; ExitKmh the
// speed where the throttle was fully back on.
ApexKmh int `json:"apex_kmh,omitempty"`
MinKmh int `json:"min_kmh,omitempty"`
ExitKmh int `json:"exit_kmh,omitempty"`
// BrakeAtPct is where braking began, in thousandths of the lap.
BrakeAtPct int `json:"brake_at_pct,omitempty"`
// PeakBrakePct is the hardest the pedal was pressed for this corner;
// TurnInBrakePct the pressure at turn-in; BrakeAtApex the pressure still
// on at the apex.
PeakBrakePct int `json:"peak_brake_pct,omitempty"`
TurnInBrakePct int `json:"turn_in_brake_pct,omitempty"`
BrakeAtApex int `json:"brake_at_apex,omitempty"`
// ThrottleLag is the distance from the apex to the throttle coming back, in
// thousandths of the lap.
ThrottleLag int `json:"throttle_lag,omitempty"`
// GearAtApex is the gear at the apex.
GearAtApex int `json:"gear_at_apex,omitempty"`
// Pattern is the protocol's name for the shape of the mistake, when the
// detector named one.
Pattern wire.CornerPattern `json:"pattern,omitempty"`
}
Corner is one corner of a lap as the client measured it, with nothing compared against anything. Turn is from 1 in the order driven, counting the corners the detector found. Positions are in thousandths of the lap, speeds in whole km/h, pressures in percent. A zero is "not measured".
type CornerApproaching ¶
type CornerApproaching struct {
At time.Time
Stint Stint
Lap int
// Turn and ApexPct name the corner, as the last lap's measurement numbered it.
Turn int
ApexPct int
// MetresToApex is the distance to it, or zero when the track length is unknown.
MetresToApex int
}
CornerApproaching is KindCornerApproaching.
type CornerPassed ¶
CornerPassed is KindCornerPassed.
type Event ¶
type Event interface {
// Kind names the event.
Kind() EventKind
// When is when it happened, by the simulator's clock where there is one.
When() time.Time
}
Event is what the app delivers to a companion. Every kind has its own type below; a companion switches on the type, not the kind:
switch e := e.(type) {
case *clientplugin.LapLastCorner: …
}
type EventKind ¶
type EventKind string
EventKind names an event the app raises. A companion lists the kinds it wants; the app delivers those and no others.
const ( // KindStintStarted: the driver is on track in a car for the first time // this sitting. Carries a [StintStarted]. KindStintStarted EventKind = "stint.started" // KindSampled: one reading of the simulator, at its rate. Carries a // [Sampled]. Ask for it only to do something on every sample. KindSampled EventKind = "sampled" // KindCornerPassed: the detector has closed a corner and measured it. // Carries a [CornerPassed]. KindCornerPassed EventKind = "corner.passed" // KindLapLastCorner: the last corner of the lap is behind the car and every // corner of the lap is measured; the straight to the line remains. Carries // a [LapLastCorner]. This is when engineer wants the lap. KindLapLastCorner EventKind = "lap.last-corner" // KindLapCompleted: the line. Carries a [LapCompleted] with the trace. KindLapCompleted EventKind = "lap.completed" // KindCornerApproaching: a corner's apex is a set distance ahead. Carries a // [CornerApproaching]. This is when a line for the corner is played. KindCornerApproaching EventKind = "corner.approaching" // KindStintFinished: the sitting is over. Carries a [StintFinished]. KindStintFinished EventKind = "stint.finished" // KindServerChanged: GET /me was read again. Carries a [ServerChanged]. // Companions are started and stopped by the app on this; one that wants // it too can read the driver's entitlements from it. KindServerChanged EventKind = "server.changed" )
The v1 events, in the order a stint produces them.
type Host ¶
type Host interface {
// Do sends a request to the companion's own server plugin. path is relative
// to the plugin's mount — "/laps", "/cues?stint=…" — and the app supplies
// the server, the mount and the driver's token. A path that would leave the
// mount is an error before anything is sent. The caller closes the body.
Do(ctx context.Context, method, path string, body io.Reader, header http.Header) (*http.Response, error)
// Play plays audio through the running companion that is a [Player]:
// the voice companion. contentType is audio/mpeg or audio/wav. The latest
// call wins: a clip still playing when the next arrives is cut, because a
// line for the corner behind the car is worth nothing. Without a Player
// running, nothing is heard and the app logs the loss.
Play(ctx context.Context, audio []byte, contentType string) error
// Say speaks words that came without audio, through the same Player.
Say(ctx context.Context, text string) error
// Audible reports that something handed to Play would actually be heard:
// a Player is running and its driver has sound turned on. It is false with
// no Player at all, and false while one is muted.
//
// It is for deciding what to ask for, not what to send. Audio is bought by
// the character at somebody's vendor, and a companion whose server half
// would buy some should say first that nobody is listening.
Audible() bool
// Page shows HTML in the app's window, under a tab with the companion's
// title. The last call replaces the page. Scripts are not run.
Page(html string)
// Status is one short line beside the companion's name in the app's
// window: "3 lines ready", "waiting for the coach". The last call replaces
// it; an empty string clears it.
Status(text string)
// Log is the app's log with the companion's name attached. The driver's
// token never appears in it, whatever a companion logs.
Log() *slog.Logger
// Driver is who is signed in, as GET /me said: slug and display name.
Driver() (slug, name string)
// Server is the address the app is paired to, "https://pacenote.example".
// It is for showing a person where something of theirs lives on the
// server; requests go through Do, which needs no address.
Server() string
// Setting is a value this companion kept with SetSetting, or "" when it
// kept none. Settings are the companion's own, by key, and survive a
// restart: the app keeps them beside its own configuration.
Setting(key string) string
// SetSetting keeps a value. An empty value forgets the key.
SetSetting(key, value string) error
}
Host is what the app lends a companion, and all of it. A companion that needs something not here needs a change to this contract, not a way round it.
type LapCompleted ¶
type LapCompleted struct {
At time.Time
Stint Stint
Lap int
// LapMs is the lap time. LapKind is the protocol's: clean, or not.
LapMs int
LapKind wire.Kind
// StartedAt is when the lap began.
StartedAt time.Time
// Trace is the lap at the source's full rate, in wire scaling, from the
// line to the line. A companion posting it resamples to its own limit.
Trace []wire.TracePoint
// SectorMs is each sector's time, in the order of Stint.Sectors, or nil.
SectorMs []int
Corners []Corner
}
LapCompleted is KindLapCompleted.
type LapLastCorner ¶
type LapLastCorner struct {
At time.Time
Stint Stint
Lap int
// ElapsedMs is the lap time so far.
ElapsedMs int
Corners []Corner
}
LapLastCorner is KindLapLastCorner: every corner of the lap, measured, with the line still ahead.
type Manifest ¶
type Manifest struct {
// Name is the plugin's name: for a source, the simulator ("iracing"); for
// a companion, the server plugin it belongs to ("engineer"). It is what
// Source.Name or Companion.Name returns.
Name string `json:"name"`
// Kind is source or companion.
Kind Kind `json:"kind"`
// Title is what a person sees on the build page.
Title string `json:"title"`
// Description is a sentence for the build page. Optional.
Description string `json:"description,omitempty"`
// InterfaceVersion is the [InterfaceVersion] this plugin was written against.
InterfaceVersion int `json:"interface_version"`
// ServerPlugin is the server plugin a companion belongs to. Required for a
// companion, where it equals Name; not allowed on a source.
ServerPlugin string `json:"server_plugin,omitempty"`
}
Manifest is client-plugin.json.
{"name": "engineer", "kind": "companion", "title": "Race engineer",
"description": "Posts each lap to engineer and plays its lines.",
"interface_version": 1, "server_plugin": "engineer"}
func LoadManifest ¶
LoadManifest reads and validates the manifest in dir.
func LoadManifestFS ¶
LoadManifestFS reads and validates the manifest in dir of fsys — a module the server unpacked, a test fixture.
type Muted ¶
type Muted interface {
// Audible is false while the driver has the sound turned off, or down to
// the words alone.
Audible() bool
}
Muted is a Player that knows whether anything would be heard. A Player that does not implement it is taken to be audible whenever it is running, which is what a player with no volume of its own is.
type Player ¶
type Player interface {
// Play plays audio through the driver's speakers at the driver's volume.
// contentType is audio/mpeg or audio/wav. The latest call wins.
Play(ctx context.Context, audio []byte, contentType string) error
// Say speaks words that came without audio, however it can.
Say(ctx context.Context, text string) error
}
Player is what a companion implements when it is the one that makes sound: the voice companion. The app routes every companion's Host.Play and Host.Say to the running companion that implements it. Without one, nothing is heard and the log says so: sound is a plugin, like everything else that talks to the world.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry holds what was compiled in. Plugins register into Default from their init(); the app reads it once at start. A Registry of one's own is for tests.
func (*Registry) AddCompanion ¶
AddCompanion adds a companion. Two companions for one server plugin is refused the same way.
func (*Registry) AddSource ¶
AddSource adds a source. Two sources with one name is a build that cannot be right — two plugins both claiming "iracing" — so it panics at start, where the operator building the client sees it, rather than picking one.
func (*Registry) Companions ¶
Companions is every registered companion, by name.
type Sample ¶
type Sample struct {
// At is when the simulator produced this reading.
At time.Time
// Session is the kind of session the simulator is in.
Session wire.SessionType
// SessionName is the simulator's own name for it ("PRACTICE", "RACE").
SessionName string
// Track is the circuit's display name; TrackID the simulator's stable
// identifier for it ("spa gp"). TrackLengthM is metres round it, or zero.
Track string
TrackID string
TrackLengthM int
// Car is the car's display name, CarClass its class ("gt3").
Car string
CarClass string
// Sectors are the sector starts as fractions of the lap, ascending, the
// first 0. Empty when the simulator publishes none.
Sectors []float64
// SetupOpen reports that the car's setup can be changed in this session.
SetupOpen bool
// OnTrack is false in the garage or spectating; OnPitRoad true on the pit lane.
OnTrack bool
OnPitRoad bool
// Lap is the simulator's lap counter. LapDistPct is the position round the
// lap as a fraction, 0…1.
Lap int
LapDistPct float64
// SpeedKmh is the speed. Throttle and Brake are pedal positions, 0…1.
SpeedKmh float64
Throttle float64
Brake float64
// Gear: -1 reverse, 0 neutral, 1 and up. RPM as the simulator gives it.
Gear int
RPM float64
// SteerDeg is the steering angle in degrees, left negative.
SteerDeg float64
// LatG and LongG are accelerations in g.
LatG float64
LongG float64
// Lat and Lon are the car's position in degrees, both zero when the
// simulator publishes none.
Lat float64
Lon float64
// LastLapMs and BestLapMs are the simulator's own lap times, or zero.
LastLapMs int
BestLapMs int
// Incidents is the simulator's incident count, where it has one.
Incidents int
// Position is the car's place in the session, from 1, or zero.
Position int
// FuelL is fuel on board in litres; FuelPerHourL the burn rate.
FuelL float64
FuelPerHourL float64
// Weather and Tyres are what the simulator publishes of each.
Weather Weather
Tyres Tyres
// Flag is the session flag. LapsTotal is the race length in laps, or zero.
Flag wire.Flag
LapsTotal int
// Field is every car in the session, the driver's own included. Empty
// when the simulator publishes none, or on the samples between the ones a
// source chooses to fill it on.
Field []wire.FieldCar
}
Sample is one reading of the simulator, in the units a person uses: km/h, degrees, litres, fractions of a lap. A Source fills what its simulator publishes and leaves the rest zero; the app and the companions never see the simulator's own units or layout.
Everything that describes the session rather than the instant — track, car, sectors, session type — is repeated on every sample. It costs nothing, and it means a source has no second thing to publish and the app has no state to keep in step with it.
func (Sample) LivePoint ¶
LivePoint is this sample as POST /live carries it: the trace point and the three things that only make sense live.
func (Sample) TracePoint ¶
func (s Sample) TracePoint(offsetMs int) wire.TracePoint
TracePoint is this sample as the wire carries it: whole km/h, pedals in percent, position in thousandths of the lap, accelerations in hundredths of a g, coordinates in hundred-thousandths of a degree. offsetMs is the time since the lap began.
type Sampled ¶
type Sampled struct {
Stint Stint
Sample Sample
// Lap is the lap counter the app keeps, which is the one every other event
// uses; the sample's own is the simulator's.
Lap int
// OffsetMs is the time since this lap began.
OffsetMs int
}
Sampled is KindSampled.
type ServerChanged ¶
ServerChanged is KindServerChanged.
type Source ¶
type Source interface {
// Name is the simulator, lower-case and stable: "iracing", "assetto-corsa".
// It is what the stint's Sim field says and what a recording is filed under.
Name() string
// Running reports whether the simulator is up and publishing. It is called
// often, from one goroutine, and must be cheap: a check that a shared-memory
// name exists, not an attach.
Running() bool
// Open attaches to the simulator. It is called once before the first Read
// and only after Running said yes.
Open(ctx context.Context) error
// Read blocks until the simulator publishes the next reading, or ctx ends,
// and returns it. An error ends the session: the app closes the source and
// goes back to polling. A simulator that has gone away is an error, not a
// zero sample.
Read(ctx context.Context) (Sample, error)
// Close detaches. It is called once after the last Read, whatever Read
// returned.
Close() error
}
Source reads one simulator. The app polls Source.Running on every registered source while nothing is being read, opens the first that says yes, reads it until Source.Read fails, closes it, and polls again.
A source is one simulator and one way of reading it. It converts what the simulator publishes into a Sample and does nothing else: no lap detection, no filtering, no rate limiting. The app does those, the same way for every simulator.
type Stint ¶
type Stint struct {
// ID is the stint's id, the one the server knows it by.
ID string
// Sim, Track, TrackID, Car, CarClass and Session are as the source reported them.
Sim string
Track string
TrackID string
Car string
CarClass string
Session wire.SessionType
// TrackLengthM is metres round the circuit, or zero when unknown.
TrackLengthM int
// Sectors are the sector starts as fractions of the lap.
Sectors []float64
// SetupOpen reports that the car's setup can be changed in this session.
SetupOpen bool
// StartedAt is when the stint began.
StartedAt time.Time
}
Stint describes the sitting an event belongs to. It is repeated on every event that concerns a stint, so a companion never has to remember it.
type StintFinished ¶
type StintFinished struct {
At time.Time
Stint Stint
// Laps is how many laps were completed.
Laps int
}
StintFinished is KindStintFinished.
type StintStarted ¶
StintStarted is KindStintStarted.
type Tyres ¶
type Tyres struct {
LF, RF, LR, RR float64
}
Tyres is the temperature of each tyre in °C: left front, right front, left rear, right rear.
type Weather ¶
type Weather struct {
// Skies: 0 clear, 1 partly cloudy, 2 mostly cloudy, 3 overcast.
Skies int
// Wetness: 0 dry, rising with the water on the track, in the simulator's steps.
Wetness int
// WindKmh, Humidity (percent), TrackTempC, AirTempC.
WindKmh float64
Humidity float64
TrackTempC float64
AirTempC float64
}
Weather is what the simulator says about the sky and the surface.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package clientplugintest is what a plugin author tests against: a Host that records what a companion did, a way to deliver events the way the app does, and a way to drain a source.
|
Package clientplugintest is what a plugin author tests against: a Host that records what a companion did, a way to deliver events the way the app does, and a way to drain a source. |
|
examples
|
|
|
companion-echo
Package echo is a companion to start one from.
|
Package echo is a companion to start one from. |
|
source-demo
Package demo is a source to start a simulator reader from.
|
Package demo is a source to start a simulator reader from. |