Skip to content
Open
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
57 changes: 57 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: docs

# Build the CAN-FIX specification (Sphinx) and publish the HTML to GitHub Pages.
# Docs-only: this reads src/CAN-FIX.ods + the .rst sources to build the site and
# never modifies the schema. Mirrors src/buildall.sh (minus the PDF).

on:
push:
branches: [master]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# One Pages deployment at a time.
concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Sphinx + spreadsheet readers
run: pip install sphinx pyexcel pyexcel-ods
- name: Generate parameter data from CAN-FIX.ods
working-directory: src
run: |
python canfix_json.py
python canfix_xml.py
python parameters.py
- name: Build HTML
working-directory: src
run: sphinx-build -b html . _build/html
- name: Field-list cleanup + root redirect to the TOC
run: |
find src/_build/html -name '*.html' -exec sed -i 's/::/:/g' {} +
printf '<!doctype html><meta http-equiv="refresh" content="0; url=toc.html">\n' > src/_build/html/index.html
- uses: actions/upload-pages-artifact@v3
with:
path: src/_build/html

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# CAN-FIX Protocol Specification

**Status:** Open Source Specification — Creative Commons Licensed
**Format:** Sphinx documentation source (builds to PDF and HTML)
**Scope:** Experimental Amateur-Built (E-AB) Aircraft Avionics

---

## What This Is

This repository contains the **authoritative specification** for the CAN-FIX communication protocol — the CAN bus implementation of the Flight Information eXchange (FIX) protocol family. CAN-FIX defines how avionics nodes on an aircraft CAN network identify themselves, publish flight data parameters, and consume data from other nodes.

The specification is licensed under Creative Commons, meaning anyone can read, implement, modify, and distribute compliant devices without licensing fees or vendor lock-in.

## Read the Specification Online

- **Full specification (web):** <https://billmallard.github.io/canfix-spec/> — the complete document, rebuilt automatically from this repository on every push.
- **Wiki (quick reference):** <https://github.com/billmallard/canfix-spec/wiki> — a friendly companion with a [parameter quick-reference](https://github.com/billmallard/canfix-spec/wiki/Parameter-Reference) table, a [frame-format](https://github.com/billmallard/canfix-spec/wiki/Frame-Format) cheat-sheet, and an [implementer's quick-start](https://github.com/billmallard/canfix-spec/wiki/Implementing-a-Node).
- **PDF:** build it locally with `make latexpdf` (see [Building the Documentation](#building-the-documentation)).

## What CAN-FIX Defines

- **Parameter namespace:** Every flight data value (airspeed, altitude, heading, GPS position, engine temperatures, control surface positions, etc.) has a named identifier. Parameters are organized by type and support multiple simultaneous sources (e.g., two independent EGT sensors for two engines).
- **Frame structure:** How 11-bit CAN identifiers are allocated across parameter types, node IDs, and index bytes for multi-instance data.
- **Node types and device IDs:** Standard device type identifiers so nodes can announce their role on the network.
- **Multi-source design:** The protocol explicitly accommodates redundant nodes publishing the same parameter type using separate identifier ranges, avoiding CAN arbitration conflicts.
- **Data types:** float, int, bool, string with defined units and ranges per parameter.

## Parameter Coverage

The specification covers the full avionics parameter set for a modern aircraft:

| Domain | Example Parameters |
|---|---|
| Navigation | LAT, LONG, ALT, IAS, TAS, GS, heading, track |
| AHRS | Pitch, roll, yaw rate, accelerations |
| Engine (×2) | RPM, MAP, EGT (×N cylinders), CHT, oil temp/pressure, fuel flow |
| Control surfaces | Pitch, roll, yaw, flap, trim positions |
| Electrical | Bus voltage, current, alternator status |
| Fuel | Quantity (×N tanks), flow, pressure |
| COM/NAV radios | Active/standby frequencies |
| Systems | Gear position, door status, general annunciators |

## Repository Contents

| Path | Description |
|---|---|
| `src/CAN-FIX.ods` | **Master parameter spreadsheet** — the source of truth for all parameter definitions |
| `src/canfix.json` | Machine-readable parameter definitions (*generated* from `CAN-FIX.ods`) |
| `src/canfix.xml` | XML form of parameter definitions (*generated* from `CAN-FIX.ods`) |
| `src/parameters.rst` | Human-readable parameter list (*generated* from `CAN-FIX.ods`) |
| `src/framedef.rst` | CAN frame format specification |
| `src/datatypes.rst` | Data type definitions |
| `src/physical.rst` | Physical layer requirements (wiring, termination, bit rate) |
| `src/requirements.rst` | Protocol requirements |
| `src/practices.rst` | Recommended implementation practices |
| `tools/gen_param_reference.py` | Generates the wiki parameter quick-reference from `canfix.json` |
| `tools/sync_wiki.sh` | Mirrors `wiki/` to the GitHub wiki clone for publishing |

> **Editing parameters:** change `src/CAN-FIX.ods` (the master), then regenerate
> the derived files with the commands below. Do not hand-edit `canfix.json`,
> `canfix.xml`, or `parameters.rst` — they are overwritten on every build.

## Building the Documentation

Requires Python with Sphinx and supporting packages:

```bash
sudo pip install pyexcel pyexcel-ods sphinx
sudo apt install texlive-latex-base texlive-fonts-recommended texlive-fonts-extra texlive-latex-extra texlive-xetex latexmk
cd src
python canfix_json.py # regenerate canfix.json from CAN-FIX.ods
python canfix_xml.py # regenerate canfix.xml from CAN-FIX.ods
python parameters.py # regenerate parameter_list from CAN-FIX.ods
make latexpdf # builds PDF -> src/_build/latex
make html # builds HTML -> src/_build/html
```

The web specification at <https://billmallard.github.io/canfix-spec/> is built
and published automatically by GitHub Actions (`.github/workflows/docs.yml`) on
every push to `master`, so the online copy always matches the repository.

## Important Disclaimer

> This specification is developed for Experimental Amateur-Built aircraft use only.
> It is not FAA-approved avionics software or a certified communication standard.
> All implementations are the builder's responsibility.
252 changes: 252 additions & 0 deletions tests/test_canfix_spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
"""
Structural validation tests for canfix.json.

Run from the repo root:
python -m pytest tests/ -v
"""
import json
import os
import pytest

SPEC_PATH = os.path.join(os.path.dirname(__file__), "..", "src", "canfix.json")

# Base types per the CAN-FIX spec; compound types (e.g. INT[2],BYTE) are split on ','
# and array suffixes (e.g. CHAR[2]) are stripped before lookup.
VALID_BASE_TYPES = {"BYTE", "WORD", "SHORT", "USHORT", "FLOAT", "DOUBLE", "CHAR", "BOOL",
"INT", "UINT", "LONG", "ULONG", "DINT", ""}


@pytest.fixture(scope="module")
def spec():
with open(SPEC_PATH, encoding="utf-8") as f:
return json.load(f)


@pytest.fixture(scope="module")
def parameters(spec):
return spec["parameters"]


@pytest.fixture(scope="module")
def groups(spec):
return spec["groups"]


@pytest.fixture(scope="module")
def status(spec):
return spec["status"]


# ── Top-level structure ────────────────────────────────────────────────────────

class TestTopLevelStructure:
def test_has_parameters_key(self, spec):
assert "parameters" in spec

def test_has_groups_key(self, spec):
assert "groups" in spec

def test_has_status_key(self, spec):
assert "status" in spec

def test_parameter_count(self, parameters):
assert len(parameters) == 230, (
f"Expected 230 parameters, got {len(parameters)}"
)

def test_group_count(self, groups):
assert len(groups) == 17

def test_status_count(self, status):
assert len(status) == 10


# ── Parameter required fields ─────────────────────────────────────────────────

class TestParameterRequiredFields:
def test_all_have_id(self, parameters):
missing = [p.get("name", "?") for p in parameters if "id" not in p]
assert not missing, f"Parameters missing 'id': {missing}"

def test_all_have_name(self, parameters):
missing = [p.get("id", "?") for p in parameters if "name" not in p or not p["name"]]
assert not missing, f"Parameters missing 'name': {missing}"

def test_all_have_count(self, parameters):
missing = [p.get("id", "?") for p in parameters if "count" not in p]
assert not missing, f"Parameters missing 'count': {missing}"

def test_all_have_type(self, parameters):
missing = [p.get("id", "?") for p in parameters if "type" not in p]
assert not missing, f"Parameters missing 'type': {missing}"

def test_all_have_index_key(self, parameters):
missing = [p.get("id", "?") for p in parameters if "index" not in p]
assert not missing, f"Parameters missing 'index' key (may be null): {missing}"

def test_all_have_metadata_key(self, parameters):
missing = [p.get("id", "?") for p in parameters if "metadata" not in p]
assert not missing, f"Parameters missing 'metadata' key: {missing}"


# ── Parameter ID constraints ──────────────────────────────────────────────────

class TestParameterIds:
def test_ids_are_integers(self, parameters):
non_int = [p["name"] for p in parameters if not isinstance(p["id"], int)]
assert not non_int, f"Non-integer IDs: {non_int}"

def test_ids_are_unique(self, parameters):
ids = [p["id"] for p in parameters]
seen = set()
dupes = []
for i in ids:
if i in seen:
dupes.append(i)
seen.add(i)
assert not dupes, f"Duplicate parameter IDs: {dupes}"

def test_ids_in_parameter_range(self, parameters):
# Parameters live in IDs 256–1759 per the CAN-FIX spec
out_of_range = [p["id"] for p in parameters if not (256 <= p["id"] <= 1759)]
assert not out_of_range, f"Parameter IDs outside 256–1759: {out_of_range}"

def test_ids_sorted_ascending(self, parameters):
ids = [p["id"] for p in parameters]
assert ids == sorted(ids), "Parameter list is not sorted by ID"


# ── Parameter type constraints ────────────────────────────────────────────────

class TestParameterTypes:
def test_types_are_strings(self, parameters):
non_str = [p["id"] for p in parameters if not isinstance(p["type"], str)]
assert not non_str, f"Non-string types: {non_str}"

def test_types_are_valid(self, parameters):
import re
def base_types(type_str):
# Split compound types on ',' then strip array notation e.g. CHAR[2] → CHAR
parts = [t.strip() for t in type_str.split(",")]
return [re.sub(r"\[\d+\]$", "", p) for p in parts]

invalid = []
for p in parameters:
for bt in base_types(p["type"]):
if bt not in VALID_BASE_TYPES:
invalid.append((p["id"], p["type"], bt))
assert not invalid, f"Unrecognized base types: {invalid}"

def test_count_is_positive_integer(self, parameters):
bad = [p["id"] for p in parameters
if not isinstance(p["count"], int) or p["count"] < 1]
assert not bad, f"Parameters with invalid count: {bad}"


# ── Min/max constraints ───────────────────────────────────────────────────────

class TestMinMax:
@staticmethod
def _parse_number(s):
# Some values use comma-formatted numbers e.g. "60,000" → 60000
return float(str(s).replace(",", ""))

def test_min_max_parseable_as_float(self, parameters):
bad = []
for p in parameters:
for key in ("min", "max"):
if key in p and p[key] is not None:
try:
self._parse_number(p[key])
except (TypeError, ValueError):
bad.append((p["id"], key, p[key]))
assert not bad, f"Non-numeric min/max values: {bad}"

def test_min_less_than_max_where_present(self, parameters):
violations = []
for p in parameters:
if "min" in p and "max" in p and p["min"] is not None and p["max"] is not None:
try:
mn = self._parse_number(p["min"])
mx = self._parse_number(p["max"])
if mn >= mx:
violations.append((p["id"], p["name"], mn, mx))
except (TypeError, ValueError):
violations.append((p["id"], p["name"], p["min"], p["max"]))
assert not violations, f"min >= max: {violations}"

def test_multiplier_parseable_as_float(self, parameters):
bad = []
for p in parameters:
if "multiplier" in p and p["multiplier"] is not None:
try:
float(p["multiplier"])
except (TypeError, ValueError):
bad.append((p["id"], p["multiplier"]))
assert not bad, f"Non-numeric multipliers: {bad}"


# ── Group constraints ─────────────────────────────────────────────────────────

class TestGroups:
def test_groups_have_required_fields(self, groups):
for g in groups:
assert "name" in g, f"Group missing 'name': {g}"
assert "startid" in g, f"Group missing 'startid': {g}"
assert "endid" in g, f"Group missing 'endid': {g}"

def test_group_startid_lte_endid(self, groups):
violations = [
(g["name"], g["startid"], g["endid"])
for g in groups
if g["startid"] > g["endid"]
]
assert not violations, f"Groups with startid > endid: {violations}"

def test_group_ids_cover_0_to_2047(self, groups):
covered = set()
for g in groups:
for i in range(g["startid"], g["endid"] + 1):
covered.add(i)
assert 0 in covered
assert 2047 in covered

def test_every_parameter_id_falls_in_a_group(self, parameters, groups):
def in_any_group(pid):
return any(g["startid"] <= pid <= g["endid"] for g in groups)

orphans = [p["id"] for p in parameters if not in_any_group(p["id"])]
assert not orphans, f"Parameter IDs not covered by any group: {orphans}"


# ── Status entries ────────────────────────────────────────────────────────────

class TestStatus:
def test_status_have_required_fields(self, status):
for s in status:
assert "type" in s, f"Status entry missing 'type': {s}"
assert "description" in s, f"Status entry missing 'description': {s}"
assert "datatype" in s, f"Status entry missing 'datatype': {s}"

def test_status_descriptions_are_nonempty(self, status):
empty = [s.get("type") for s in status if not s.get("description")]
assert not empty, f"Status entries with empty description: {empty}"


# ── Spot-check well-known parameters ─────────────────────────────────────────

class TestKnownParameters:
def _get(self, parameters, pid):
return next((p for p in parameters if p["id"] == pid), None)

def test_flap_control_switches_id_256(self, parameters):
p = self._get(parameters, 256)
assert p is not None
assert p["name"] == "Flap Control Switches"
assert p["type"] == "BYTE"

def test_trim_switches_id_258(self, parameters):
p = self._get(parameters, 258)
assert p is not None
assert p["name"] == "Trim Switches"
assert p["type"] == "WORD"
Loading