Skip to content
Merged
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
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ graph BT
traffic[kinavis-traffic] --> kinavis
colregs[kinavis-colregs] --> kinavis
alerts[kinavis-alerts] --> traffic
signalk[kinavis-signalk] --> traffic
signalk --> colregs
```

| Crate | What it does |
Expand All @@ -94,6 +96,7 @@ graph BT
| [`kinavis-traffic`](https://docs.rs/kinavis-traffic) | target tracking from radar and AIS, CPA and TCPA, the avoiding manoeuvre |
| [`kinavis-colregs`](https://docs.rs/kinavis-colregs) | the steering and sailing rules of the COLREGs as data |
| [`kinavis-alerts`](https://docs.rs/kinavis-alerts) | bridge alert management: alarms, warnings and cautions, with acknowledgement |
| [`kinavis-signalk`](https://docs.rs/kinavis-signalk) | Signal K deltas in, CPA, TCPA and the COLREGs ruling out; the core of the [Signal K plugin](https://github.com/KINAVIS/signalk-kinavis) (`std`) |

An adapter depends on the kernel alone and never pulls in the algorithms;
take only the crates you need.
Expand Down
6 changes: 6 additions & 0 deletions ci/allowed-deps.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ allow = ["kinavis-kernel", "serde"]
# Unpublished demonstrations. Every KINAVIS crate they use is a
# dev-dependency, so the shipped graph of this package is empty.
allow = []

[kinavis-signalk]
# An adapter that sits above the rules: Signal K deltas become own ship and
# targets, and the assessment and the ruling go back as deltas. JSON needs
# `serde_json`; nothing below depends on this crate.
allow = ["kinavis-kernel", "kinavis", "kinavis-traffic", "kinavis-colregs", "serde", "serde_json"]
28 changes: 28 additions & 0 deletions crates/kinavis-signalk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "kinavis-signalk"
description = "Signal K deltas in and out of the KINAVIS navigation crates: the traffic picture from a Signal K server, and CPA, TCPA and the COLREGs ruling for every target back as deltas and notifications."
documentation = "https://docs.rs/kinavis-signalk"
readme = "README.md"
keywords = ["signalk", "ais", "colregs", "cpa", "marine"]
categories = ["science", "parser-implementations"]
# Satellite crate, versioned independently of the kernel and `kinavis`.
version = "0.1.0"
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
repository.workspace = true
homepage.workspace = true
license.workspace = true

[dependencies]
# Signal K is JSON over a server: this adapter needs the standard library, as
# the crates it feeds do not.
kinavis-kernel = { workspace = true, features = ["std"] }
kinavis = { workspace = true, features = ["std"] }
kinavis-traffic = { version = "0.1.0", path = "../kinavis-traffic" }
kinavis-colregs = { version = "0.1.0", path = "../kinavis-colregs" }
serde = { workspace = true }
serde_json = { workspace = true }

[lints]
workspace = true
1 change: 1 addition & 0 deletions crates/kinavis-signalk/LICENSE-APACHE
1 change: 1 addition & 0 deletions crates/kinavis-signalk/LICENSE-MIT
44 changes: 44 additions & 0 deletions crates/kinavis-signalk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# kinavis-signalk

Signal K deltas in and out of the [KINAVIS](https://crates.io/crates/kinavis)
navigation crates.

A [Signal K](https://signalk.org) server merges a vessel's NMEA 0183,
NMEA 2000 and AIS into one JSON model and streams changes as deltas. This
crate reads them into a picture of own vessel and every target, and assesses
it: CPA and TCPA of each target, and for a dangerous or developing approach
the COLREGs ruling — the encounter, who gives way, and what each may do. The
results go back as `navigation.closestApproach` deltas in each target's
context and as notifications on own vessel:

```text
notifications.navigation.closestApproach.urn:mrn:imo:mmsi:244000001
state: alarm
message: ANNA: CPA 0.00 NM in 15:00; crossing, target on starboard, give way (Rule 15): alter to starboard, or slow down
```

- Units converted at the boundary: Signal K's radians and metres per second
in, KINAVIS types inside.
- COLREGs category from `navigation.state`, else the AIS ship type; a vessel
at anchor, moored or aground gets a CPA but no ruling.
- An alarm for a dangerous CPA within the TCPA limit, a warning for one within
the warning horizon; a notification is sent when a target's level changes,
and cleared when it is no longer dangerous, stale or gone.
- No ruling while own vessel is at rest: the steering rules are for vessels
under way.

An aid to the watch, not a substitute for it: a proper lookout (COLREGs
Rule 5) comes first, the ruling takes every target to be what AIS says it is,
and Rules 9 and 10 are not applied.

Requires the standard library. The Signal K server plugin built on it is
[`signalk-kinavis`](https://github.com/KINAVIS/signalk-kinavis).

The navigation algorithms and the list of the other KINAVIS crates are in
[`kinavis`](https://crates.io/crates/kinavis).

## License

Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or [MIT license](LICENSE-MIT) at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
87 changes: 87 additions & 0 deletions crates/kinavis-signalk/src/delta.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//! The Signal K delta: a context, and updates of paths within it.
//!
//! Only the fields this crate reads or writes are modelled; anything else in
//! a delta is ignored on reading.

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One delta message.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Delta {
/// Vessel the updates are about, `vessels.<id>`; absent means own vessel.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
/// Updates, each from one source at one time.
#[serde(default)]
pub updates: Vec<Update>,
}

/// Values from one source at one time.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Update {
/// RFC 3339 time of the values; the server fills it in on emission.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
/// Where the values came from: for a device, `type` with `sentence`
/// (NMEA 0183) or `pgn` (NMEA 2000). The server fills it in.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<Value>,
/// Path and value pairs.
#[serde(default)]
pub values: Vec<PathValue>,
}

/// A value at a path.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PathValue {
/// Dotted path, `navigation.position`; empty for the vessel's root
/// properties (`name`, `mmsi`).
pub path: String,
/// JSON value; `null` clears it.
pub value: Value,
}

impl Delta {
/// Delta of one value at one path in a context.
#[must_use]
pub fn single(context: Option<String>, path: &str, value: Value) -> Self {
Self {
context,
updates: vec![Update {
timestamp: None,
source: None,
values: vec![PathValue {
path: path.to_owned(),
value,
}],
}],
}
}
}

/// NMEA 2000 parameter groups that carry other vessels: the AIS messages.
const AIS_PGNS: [u64; 11] = [
129_038, 129_039, 129_040, 129_041, 129_793, 129_794, 129_798, 129_801, 129_802, 129_809,
129_810,
];

impl Update {
/// Whether the values come from own vessel's own sensors: an NMEA 0183
/// sentence other than AIS `VDM`, or an NMEA 2000 parameter group other
/// than AIS. Signal K puts such values in own vessel's context; only AIS
/// describes other vessels. `false` when the source does not say.
#[must_use]
pub fn is_own_sensor(&self) -> bool {
let Some(source) = &self.source else {
return false;
};
if let Some(sentence) = source.get("sentence").and_then(Value::as_str) {
return sentence != "VDM";
}
source
.get("pgn")
.and_then(Value::as_u64)
.is_some_and(|pgn| !AIS_PGNS.contains(&pgn))
}
}
60 changes: 60 additions & 0 deletions crates/kinavis-signalk/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Signal K deltas in and out of the KINAVIS navigation crates.
//!
//! A [Signal K](https://signalk.org) server merges a vessel's NMEA 0183,
//! NMEA 2000 and AIS into one JSON model and streams changes as deltas. This
//! crate reads those deltas into a [`Picture`] of own vessel and every
//! target, and a [`Watch`] assesses the picture: CPA and TCPA of each target
//! (`kinavis-traffic`), and for a dangerous or developing approach the
//! COLREGs ruling (`kinavis-colregs`): the encounter, who gives way and what
//! each may do. The results go back to the server as
//! `navigation.closestApproach` deltas in each target's context and as
//! notifications on own vessel.
//!
//! Signal K units are SI: radians, metres, metres per second. The crate
//! converts at the boundary; everything inside is KINAVIS types.
//!
//! ```rust
//! use kinavis_signalk::{parse_timestamp, Emission, Level, Watch, WatchConfig};
//!
//! let mut watch = Watch::new(WatchConfig::default());
//! // Own vessel steering north at 6 knots (3.087 m/s)...
//! watch.ingest(r#"{"context":"vessels.self","updates":[{"timestamp":"2026-09-25T12:00:00Z","values":[
//! {"path":"navigation.position","value":{"latitude":53.0,"longitude":5.0}},
//! {"path":"navigation.courseOverGroundTrue","value":0.0},
//! {"path":"navigation.speedOverGround","value":3.087}]}]}"#)?;
//! // ...and a motor vessel a mile and a half north and east, steering west at 6 knots.
//! watch.ingest(r#"{"context":"vessels.urn:mrn:imo:mmsi:244000001","updates":[{"timestamp":"2026-09-25T12:00:00Z","values":[
//! {"path":"","value":{"name":"ANNA"}},
//! {"path":"navigation.position","value":{"latitude":53.025,"longitude":5.04155}},
//! {"path":"navigation.courseOverGroundTrue","value":4.712389},
//! {"path":"navigation.speedOverGround","value":3.087},
//! {"path":"navigation.state","value":"motoring"}]}]}"#)?;
//!
//! let now = parse_timestamp("2026-09-25T12:00:01Z").unwrap();
//! let (reports, emissions) = watch.assess(now);
//! assert_eq!(reports[0].level, Level::Alarm);
//! let Some(Emission::Notification { path, value }) = emissions.last() else { panic!() };
//! assert_eq!(path, "notifications.navigation.closestApproach.urn:mrn:imo:mmsi:244000001");
//! assert_eq!(value["state"], "alarm");
//! assert!(value["message"].as_str().unwrap().contains("give way (Rule 15)"));
//! # Ok::<(), serde_json::Error>(())
//! ```
//!
//! Requires the standard library: JSON, and a picture as large as the server's.

mod delta;
mod picture;
mod time;
mod watch;

/// Runs the `README.md` example as a doctest.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeExamples;

pub use delta::{Delta, PathValue, Update};
/// The clock [`Watch::assess`] takes.
pub use kinavis_kernel::time::{Instant, Utc};
pub use picture::{Category, Picture, VesselState, SELF_CONTEXT};
pub use time::parse_timestamp;
pub use watch::{message, Emission, Level, TargetReport, Watch, WatchConfig, NOTIFICATION_PATH};
Loading
Loading