From d0d1e11b8686217859fbc3125e06e6a6a408bb87 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 21:38:55 -0400 Subject: [PATCH 01/13] Establish repository foundation --- .editorconfig | 15 + .gitattributes | 10 + .github/CODEOWNERS | 1 + .github/CONTRIBUTING.md | 3 + .github/ISSUE_TEMPLATE/bug_report.yml | 42 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 36 +++ .github/ISSUE_TEMPLATE/hardware_issue.yml | 46 +++ .github/SECURITY.md | 3 + .github/pull_request_template.md | 23 ++ .github/workflows/docs.yml | 26 ++ .github/workflows/firmware.yml | 29 ++ .github/workflows/repository-checks.yml | 27 ++ .github/workflows/software.yml | 39 +++ .gitignore | 37 +++ .pre-commit-config.yaml | 14 + CHANGELOG.md | 14 + CITATION.cff | 11 + CODE_OF_CONDUCT.md | 31 ++ CONTRIBUTING.md | 30 ++ LICENSE | 21 ++ README.md | 125 ++++++++ ROADMAP.md | 46 +++ SECURITY.md | 23 ++ assets/README.md | 5 + assets/branding/README.md | 3 + assets/diagrams/README.md | 4 + assets/screenshots/README.md | 5 + data/README.md | 9 + data/examples/README.md | 5 + data/examples/simulated/dipole-like-scan.json | 82 +++++ data/schemas/scan-v1.schema.json | 220 ++++++++++++++ docs/architecture/data-flow.md | 25 ++ docs/architecture/design-decisions.md | 16 + docs/architecture/overview.md | 44 +++ docs/architecture/system-boundaries.md | 25 ++ docs/development/repository-layout.md | 13 + docs/development/roadmap.md | 5 + docs/development/setup.md | 19 ++ docs/development/testing.md | 10 + docs/experiments/calibration.md | 8 + docs/experiments/measurement-procedure.md | 12 + docs/experiments/repeatability.md | 8 + docs/experiments/uncertainty.md | 9 + docs/firmware/configuration.md | 14 + docs/firmware/overview.md | 9 + docs/firmware/protocol.md | 28 ++ docs/glossary.md | 15 + docs/hardware/bill-of-materials.md | 9 + docs/hardware/motion-system.md | 10 + docs/hardware/overview.md | 8 + docs/hardware/power-system.md | 9 + docs/hardware/rf-measurement.md | 11 + docs/hardware/wiring.md | 9 + docs/index.md | 18 ++ docs/software/data-pipeline.md | 14 + docs/software/file-formats.md | 26 ++ docs/software/overview.md | 9 + docs/software/visualization.md | 9 + firmware/README.md | 9 + firmware/controller/README.md | 14 + firmware/controller/include/protocol.hpp | 41 +++ firmware/controller/platformio.ini | 19 ++ firmware/controller/src/main.cpp | 34 +++ firmware/controller/src/protocol.cpp | 128 ++++++++ firmware/controller/test/README.md | 5 + firmware/shared/README.md | 5 + hardware/README.md | 11 + hardware/bom/README.md | 5 + hardware/electronics/README.md | 6 + hardware/electronics/pcb/README.md | 5 + hardware/electronics/schematics/README.md | 5 + hardware/mechanical/README.md | 18 ++ hardware/mechanical/cad/README.md | 4 + hardware/mechanical/drawings/README.md | 4 + hardware/mechanical/exports/README.md | 4 + hardware/wiring/README.md | 6 + scripts/README.md | 8 + scripts/bootstrap.sh | 13 + scripts/check_repository.py | 105 +++++++ software/README.md | 14 + software/pyproject.toml | 49 +++ software/src/radiance3d/__init__.py | 16 + software/src/radiance3d/__main__.py | 5 + software/src/radiance3d/cli.py | 39 +++ software/src/radiance3d/models.py | 280 ++++++++++++++++++ software/src/radiance3d/py.typed | 1 + software/src/radiance3d/validation.py | 31 ++ software/tests/test_cli.py | 13 + software/tests/test_models.py | 38 +++ tools/README.md | 5 + tools/conversion/README.md | 5 + tools/validation/README.md | 5 + 93 files changed, 2332 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/hardware_issue.yml create mode 100644 .github/SECURITY.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/firmware.yml create mode 100644 .github/workflows/repository-checks.yml create mode 100644 .github/workflows/software.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CITATION.cff create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 assets/README.md create mode 100644 assets/branding/README.md create mode 100644 assets/diagrams/README.md create mode 100644 assets/screenshots/README.md create mode 100644 data/README.md create mode 100644 data/examples/README.md create mode 100644 data/examples/simulated/dipole-like-scan.json create mode 100644 data/schemas/scan-v1.schema.json create mode 100644 docs/architecture/data-flow.md create mode 100644 docs/architecture/design-decisions.md create mode 100644 docs/architecture/overview.md create mode 100644 docs/architecture/system-boundaries.md create mode 100644 docs/development/repository-layout.md create mode 100644 docs/development/roadmap.md create mode 100644 docs/development/setup.md create mode 100644 docs/development/testing.md create mode 100644 docs/experiments/calibration.md create mode 100644 docs/experiments/measurement-procedure.md create mode 100644 docs/experiments/repeatability.md create mode 100644 docs/experiments/uncertainty.md create mode 100644 docs/firmware/configuration.md create mode 100644 docs/firmware/overview.md create mode 100644 docs/firmware/protocol.md create mode 100644 docs/glossary.md create mode 100644 docs/hardware/bill-of-materials.md create mode 100644 docs/hardware/motion-system.md create mode 100644 docs/hardware/overview.md create mode 100644 docs/hardware/power-system.md create mode 100644 docs/hardware/rf-measurement.md create mode 100644 docs/hardware/wiring.md create mode 100644 docs/index.md create mode 100644 docs/software/data-pipeline.md create mode 100644 docs/software/file-formats.md create mode 100644 docs/software/overview.md create mode 100644 docs/software/visualization.md create mode 100644 firmware/README.md create mode 100644 firmware/controller/README.md create mode 100644 firmware/controller/include/protocol.hpp create mode 100644 firmware/controller/platformio.ini create mode 100644 firmware/controller/src/main.cpp create mode 100644 firmware/controller/src/protocol.cpp create mode 100644 firmware/controller/test/README.md create mode 100644 firmware/shared/README.md create mode 100644 hardware/README.md create mode 100644 hardware/bom/README.md create mode 100644 hardware/electronics/README.md create mode 100644 hardware/electronics/pcb/README.md create mode 100644 hardware/electronics/schematics/README.md create mode 100644 hardware/mechanical/README.md create mode 100644 hardware/mechanical/cad/README.md create mode 100644 hardware/mechanical/drawings/README.md create mode 100644 hardware/mechanical/exports/README.md create mode 100644 hardware/wiring/README.md create mode 100644 scripts/README.md create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/check_repository.py create mode 100644 software/README.md create mode 100644 software/pyproject.toml create mode 100644 software/src/radiance3d/__init__.py create mode 100644 software/src/radiance3d/__main__.py create mode 100644 software/src/radiance3d/cli.py create mode 100644 software/src/radiance3d/models.py create mode 100644 software/src/radiance3d/py.typed create mode 100644 software/src/radiance3d/validation.py create mode 100644 software/tests/test_cli.py create mode 100644 software/tests/test_models.py create mode 100644 tools/README.md create mode 100644 tools/conversion/README.md create mode 100644 tools/validation/README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64d3bf0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.py] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..66baf65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.pdf binary +*.stl binary +*.step binary +*.stp binary +*.FCStd binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..551270b --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @bostromdev diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..aeba85d --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +The canonical contribution guide is [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9c5534d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Software bug +description: Report reproducible unexpected software behavior. +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: "Do not include secrets or sensitive RF information." + - type: input + id: revision + attributes: + label: Version or commit + placeholder: "Commit SHA, tag, or branch" + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: OS, Python version, installation method, and relevant devices. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: "1. …" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior and logs + render: shell + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..7feca9e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security and safety-sensitive reports + url: https://github.com/bostromdev/Radiance3D/security + about: Report vulnerabilities privately; do not publish hazardous details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..1a3b518 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,36 @@ +name: Feature proposal +description: Propose a scoped capability or design change. +title: "[Proposal]: " +labels: ["enhancement", "needs-design"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What engineering or user need is not met? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed approach + description: Include interfaces, units, and expected limitations. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Architecture + - Firmware + - Software + - Hardware + - Data format + - Documentation + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/hardware_issue.yml b/.github/ISSUE_TEMPLATE/hardware_issue.yml new file mode 100644 index 0000000..13d8cf0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/hardware_issue.yml @@ -0,0 +1,46 @@ +name: Hardware issue +description: Report a reproducible motion, electronics, power, or RF integration issue. +title: "[Hardware]: " +labels: ["hardware", "needs-triage"] +body: + - type: textarea + id: configuration + attributes: + label: Hardware configuration + description: Controller board, firmware version, driver, motors, and power supply. + validations: + required: true + - type: textarea + id: wiring + attributes: + label: Wiring description + description: Connections, cable lengths, grounding, and relevant photos or diagrams. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + placeholder: "1. Begin with power disconnected…" + validations: + required: true + - type: textarea + id: behavior + attributes: + label: Expected and actual behavior + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs and measurements + render: shell + - type: checkboxes + id: safety + attributes: + label: Safety confirmation + options: + - label: Power was disconnected before wiring changes. + required: true + - label: The report contains no unverified accuracy claim. + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..478e66e --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security + +The canonical security policy is [SECURITY.md](../SECURITY.md). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..14667c7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Summary + + + +## Project status + +- [ ] Implemented behavior is separated from planned or experimental work. +- [ ] Measured, simulated, imported, and processed data are labeled correctly. +- [ ] No unvalidated accuracy or compatibility claim is introduced. + +## Validation + + + +## Hardware and safety impact + + + +## Checklist + +- [ ] The change is focused and documented. +- [ ] Tests or a clear validation method are included. +- [ ] Relevant architecture, protocol, schema, or changelog text is updated. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..1e5d8de --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,26 @@ +name: Documentation + +on: + pull_request: + paths: + - "**/*.md" + - ".github/workflows/docs.yml" + push: + branches: [main] + paths: + - "**/*.md" + - ".github/workflows/docs.yml" + +permissions: + contents: read + +jobs: + links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check Markdown links + uses: lycheeverse/lychee-action@v2 + with: + args: --no-progress --exclude-path CHANGELOG.md "./**/*.md" + fail: true diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml new file mode 100644 index 0000000..ecc028b --- /dev/null +++ b/.github/workflows/firmware.yml @@ -0,0 +1,29 @@ +name: Firmware + +on: + pull_request: + paths: + - "firmware/**" + - ".github/workflows/firmware.yml" + push: + branches: [main] + paths: + - "firmware/**" + - ".github/workflows/firmware.yml" + +permissions: + contents: read + +jobs: + native-simulator: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install PlatformIO + run: python -m pip install "platformio>=6.1,<7" + - name: Build native simulator + working-directory: firmware/controller + run: pio run -e native diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml new file mode 100644 index 0000000..c3eb0e2 --- /dev/null +++ b/.github/workflows/repository-checks.yml @@ -0,0 +1,27 @@ +name: Repository checks + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + structure-and-schema: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Check repository structure and internal links + run: python scripts/check_repository.py + - name: Install JSON Schema validator + run: python -m pip install "check-jsonschema>=0.31,<1" + - name: Validate scan example against schema + run: >- + check-jsonschema + --schemafile data/schemas/scan-v1.schema.json + data/examples/simulated/dipole-like-scan.json diff --git a/.github/workflows/software.yml b/.github/workflows/software.yml new file mode 100644 index 0000000..741fd42 --- /dev/null +++ b/.github/workflows/software.yml @@ -0,0 +1,39 @@ +name: Software + +on: + pull_request: + paths: + - "software/**" + - "data/**" + - ".github/workflows/software.yml" + push: + branches: [main] + paths: + - "software/**" + - "data/**" + - ".github/workflows/software.yml" + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: software/pyproject.toml + - name: Install package and development tools + run: python -m pip install -e "./software[dev]" + - name: Lint + working-directory: software + run: ruff check . + - name: Type check + working-directory: software + run: mypy + - name: Test + working-directory: software + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57c532d --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +.venv/ +venv/ +dist/ +build/ + +# PlatformIO +.pio/ + +# Temporary and generated data +data/raw/ +data/tmp/ +*.tmp +*.log + +# Mechanical CAD temporary and generated exports +*.autosave +*.bak +*.FCStd1 +hardware/mechanical/exports/* +!hardware/mechanical/exports/README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ce3cd33 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-json + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c26f115 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable project changes will be documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project intends +to use semantic versioning once releases begin. + +## [Unreleased] + +### Added + +- Initial repository architecture, documentation, schema, software package, + firmware simulator foundation, and quality checks. + +[Unreleased]: https://github.com/bostromdev/Radiance3D diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..526f1f2 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,11 @@ +cff-version: 1.2.0 +message: "If you use Radiance3D, cite the repository and the exact commit or release." +title: "Radiance3D" +type: software +authors: + - family-names: "bostromdev" +repository-code: "https://github.com/bostromdev/Radiance3D" +license: MIT +abstract: >- + An open-source hardware and software platform in early development for + automated 3D antenna radiation-pattern measurement and visualization. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c9354e1 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,31 @@ +# Code of Conduct + +## Our commitment + +We are committed to a welcoming, harassment-free community for everyone, +regardless of experience, identity, background, or ability. Technical disagreement +is welcome; disrespect toward people is not. + +## Expected behavior + +- Be specific, constructive, and patient. +- Separate evidence from assumptions and label unverified engineering claims. +- Respect safety, privacy, licenses, and responsible disclosure. +- Accept correction and focus on what improves the project. + +## Unacceptable behavior + +Harassment, discrimination, threats, sexualized attention, deliberate intimidation, +doxxing, sustained disruption, or publishing another person's private information +without permission are not acceptable. + +## Enforcement + +Report conduct concerns privately to the repository owner through GitHub. Reports +will be reviewed in good faith, kept as confidential as practical, and handled +proportionally. Maintainers may edit, reject, or remove contributions and may +temporarily or permanently restrict participation. + +## Scope + +This policy applies in project spaces and when representing the project elsewhere. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6ca813 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to Radiance3D + +Radiance3D is in architecture and prototyping. Contributions should improve a +documented interface, testable behavior, or an evidence-backed design decision. + +## Before starting + +1. Search existing issues and discussions. +2. Open an issue before large hardware, protocol, schema, or dependency changes. +3. Distinguish measured, simulated, imported, and processed data. +4. Do not claim hardware compatibility or accuracy without reproducible evidence. + +## Development workflow + +1. Branch from `main` using a descriptive name. +2. Keep commits focused and avoid generated artifacts. +3. Run `python scripts/check_repository.py`. +4. For software changes, run Ruff, mypy, and pytest from `software/`. +5. For firmware changes, run the native simulator build with PlatformIO. +6. Update relevant documentation and the changelog when behavior changes. + +Pull requests should explain the problem, the chosen design, validation performed, +and any remaining uncertainty. Hardware changes should include units, constraints, +safety considerations, and the revision of any referenced component. + +## Engineering records + +Record consequential decisions in `docs/architecture/design-decisions.md`. +Experimental results should include raw provenance, configuration, timestamps, +and limitations. Never replace raw data with processed output. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..54571ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bostromdev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c6c686 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Radiance3D + +> **Visualize the invisible.** + +Open-source hardware and software for automated 3D antenna radiation-pattern +measurement and visualization. + +> [!IMPORTANT] +> **Early development — architecture and prototyping phase.** Radiance3D is not +> yet validated as laboratory-grade measurement equipment. Measurement accuracy +> has not been established, and the project is not a substitute for certified RF +> test equipment. + +## Overview + +Radiance3D is a planned, affordable platform for coordinating antenna positioning, +RF measurements, dataset processing, and radiation-pattern visualization. The +repository begins with documented boundaries, a versioned scan format, validation +tools, and simulator-friendly firmware interfaces so physical claims can be added +only after evidence exists. + +## Why the project exists + +Full 3D antenna characterization is often inaccessible outside specialized labs. +Radiance3D aims to make repeatable experimentation easier to study and reproduce +without presenting unvalidated measurements as calibrated results. + +## Planned capabilities + +- Motorized azimuth and elevation positioning +- Synchronized position and receiver samples +- Versioned JSON and CSV-compatible data exchange +- Calibration and repeatability workflows +- Polar plots and interactive 3D visualization +- Beamwidth, front-to-back ratio, side-lobe, and dataset comparison tools + +These are roadmap targets, not completed features. + +## System concept + +```mermaid +flowchart LR + source["RF source"] --> aut["Antenna under test (AUT)"] + aut --> receiver["Measurement receiver"] + receiver --> acquisition["Host acquisition software"] + controller["ESP32 motion controller"] --> motion["Azimuth / elevation mechanism"] + motion --> aut + acquisition <-->|"commands, position, synchronization"| controller + acquisition --> processing["Calibration and processing"] + processing --> visualization["2D and 3D visualization"] +``` + +Motion control and RF acquisition are separate interfaces. The host application is +intended to coordinate them and store each angle/value pair as one sample. + +## Repository structure + +| Path | Purpose | +| --- | --- | +| `firmware/` | ESP32 motion-control protocol and simulator-friendly foundation | +| `software/` | Python models, scan validation, and inspection CLI | +| `hardware/` | Provisional electronics and mechanical architecture | +| `data/` | Versioned schemas and clearly labeled simulated examples | +| `docs/` | Architecture, formats, experiments, and development guidance | +| `tools/`, `scripts/` | Conversion, validation, and repository checks | +| `assets/` | Reserved, documented locations for future project media | + +See the [repository layout](docs/development/repository-layout.md) for details. + +## Current project status + +Stage 0 establishes terminology, architecture, schemas, contribution standards, +and automated checks. No physical scanner, receiver integration, measurement +accuracy, calibrated antenna gain, or production-ready workflow is claimed. +Example datasets may be simulated and are labeled in their metadata. + +## Getting started + +The initial useful workflow validates or summarizes scan files: + +```bash +cd software +python3.11 -m venv .venv +source .venv/bin/activate +python -m pip install -e ".[dev]" +radiance3d validate ../data/examples/simulated/dipole-like-scan.json +radiance3d inspect ../data/examples/simulated/dipole-like-scan.json +``` + +For the full development setup, see [docs/development/setup.md](docs/development/setup.md). + +## Documentation + +Start at the [documentation index](docs/index.md), then review the +[architecture overview](docs/architecture/overview.md), [scan file +format](docs/software/file-formats.md), and [roadmap](ROADMAP.md). + +## Contributing + +Contributions are welcome at this early stage, especially design review, schema +feedback, and simulator tests. Read [CONTRIBUTING.md](CONTRIBUTING.md) before +opening a change. + +## Roadmap + +Development is staged from repository foundation through validation and a public +hardware release. See [ROADMAP.md](ROADMAP.md) for entry and exit criteria. + +## Safety + +RF transmissions must comply with all applicable laws, licensing requirements, +power limits, and local regulations. Motion systems can pinch, entangle, or move +unexpectedly; prototypes need accessible power isolation, conservative limits, +and supervision. See [SECURITY.md](SECURITY.md) for vulnerability reporting. + +## License + +Software and documentation in this repository are licensed under the +[MIT License](LICENSE). Future hardware design files may use a separate +open-hardware license when verified design files are actually published. + +## Citation + +Citation metadata is available in [CITATION.cff](CITATION.cff). Until a versioned +release exists, cite the repository URL and the commit used. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c621889 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,46 @@ +# Radiance3D Roadmap + +Stages are sequential engineering targets, not release promises. A stage is complete +only when its outputs are documented and reproducible. + +## Stage 0 — Repository foundation + +Architecture, terminology, schemas, contribution standards, and CI. Exit when the +simulated example validates and software and firmware simulator checks pass. + +## Stage 1 — Motion-control prototype + +Single-axis movement, repeatable positioning, command protocol, and simulator mode. +Exit requires documented test conditions and repeatability results. + +## Stage 2 — Two-axis scanner + +Azimuth and elevation, homing, safety limits, and synchronized stepping. Exit +requires safe fault behavior and repeatable zeroing. + +## Stage 3 — RF acquisition + +Receiver integration, timestamped measurements, raw capture, and reference +measurements. Exit requires receiver-specific limitations and provenance. + +## Stage 4 — Data processing + +Normalization, interpolation, coordinate conversion, and repeatability analysis. +Exit requires tests against transparent reference cases. + +## Stage 5 — Visualization + +Polar plots, spherical data representation, interactive 3D visualization, and +dataset comparison. Exit requires faithful handling of missing and irregular data. + +## Stage 6 — Calibration and validation + +Known reference antenna, repeatability testing, uncertainty documentation, and +comparison with trusted equipment. This is the first stage that may support +evidence-bounded accuracy statements. + +## Stage 7 — Public hardware release + +Tested BOM, assembly instructions, verified wiring, reproducible sample datasets, +and release documentation. Hardware licensing will be selected when verified design +files are published. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..24fec1a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported versions + +Radiance3D has no released or production-supported version. Security fixes are +applied to the current default branch when appropriate. + +## Reporting a vulnerability + +Do not open a public issue for a vulnerability that could endanger users, expose +systems, or enable unsafe motion or RF operation. Use GitHub's private vulnerability +reporting for this repository. Include affected revision, reproduction details, +impact, and a safe way to validate a fix. + +Maintainers will acknowledge a report when available, coordinate disclosure, and +credit reporters who want attribution. No response-time guarantee is offered during +this volunteer, pre-release phase. + +## Safety is broader than security + +Unexpected motor movement, missing limits, overheating, and unlawful RF transmission +can cause harm even without a software exploit. Treat safety-related failures as +high priority and disconnect power before changing wiring. diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..b833747 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,5 @@ +# Project assets + +Asset directories are intentional future locations, not evidence of existing project +media. Add only source-traceable, licensed assets that match the repository's actual +state. diff --git a/assets/branding/README.md b/assets/branding/README.md new file mode 100644 index 0000000..edb3e48 --- /dev/null +++ b/assets/branding/README.md @@ -0,0 +1,3 @@ +# Branding + +No official logo exists. Future logo source and documented exports belong here. diff --git a/assets/diagrams/README.md b/assets/diagrams/README.md new file mode 100644 index 0000000..1b62975 --- /dev/null +++ b/assets/diagrams/README.md @@ -0,0 +1,4 @@ +# Diagrams + +Source-controlled architecture diagrams should remain Mermaid in documentation where +practical. Reviewed source and exports for diagrams requiring other tools belong here. diff --git a/assets/screenshots/README.md b/assets/screenshots/README.md new file mode 100644 index 0000000..ed5a511 --- /dev/null +++ b/assets/screenshots/README.md @@ -0,0 +1,5 @@ +# Screenshots and visualizations + +No product screenshots or physical-hardware photographs exist. Future screenshots, +hardware photos, and example visualizations must state the revision and whether the +data shown is measured or simulated. diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..6c4da8d --- /dev/null +++ b/data/README.md @@ -0,0 +1,9 @@ +# Data + +- `schemas/` contains versioned machine-readable formats. +- `examples/` contains small, intentionally tracked examples. +- `raw/` is ignored because captures can be large and may contain sensitive setup + details; preserve raw experiment data in an appropriate external archive. + +Stored datasets must retain provenance and warnings. Never relabel simulated or +processed output as a physical measurement. diff --git a/data/examples/README.md b/data/examples/README.md new file mode 100644 index 0000000..5fcf6ba --- /dev/null +++ b/data/examples/README.md @@ -0,0 +1,5 @@ +# Example datasets + +Every example must state whether it is measured, simulated, imported, or processed +through `provenance.data_kind`. The `simulated/` examples exercise tools and formats; +they are not evidence about scanner performance or antenna behavior. diff --git a/data/examples/simulated/dipole-like-scan.json b/data/examples/simulated/dipole-like-scan.json new file mode 100644 index 0000000..3d475e4 --- /dev/null +++ b/data/examples/simulated/dipole-like-scan.json @@ -0,0 +1,82 @@ +{ + "schema_version": "1.0.0", + "scan_id": "simulated-dipole-like-azimuth-001", + "scan_name": "Simulated dipole-like azimuth demonstration", + "timestamp": "2026-07-30T18:00:00Z", + "software_version": "0.1.0.dev0", + "firmware_version": "simulator-0.1.0", + "hardware_configuration": { + "name": "Software-only demonstration", + "notes": "No physical scanner or receiver participated." + }, + "antenna_under_test": { + "name": "Idealized dipole-like model", + "manufacturer": null, + "model": null, + "notes": "Mathematical demonstration only; not a model of a tested antenna." + }, + "rf_source": { + "name": "Simulated constant source", + "notes": "No RF energy was transmitted." + }, + "receiver": { + "name": "Deterministic pattern generator", + "notes": "Values are generated and are not receiver readings." + }, + "frequency_hz": 2450000000, + "transmit_power": null, + "calibration_reference": null, + "environmental_notes": "Not applicable to simulated data.", + "warnings": [ + "SIMULATED DATA — not captured from physical hardware.", + "Values must not be used to infer measurement accuracy or antenna gain." + ], + "provenance": { + "data_kind": "simulated", + "created_by": "Radiance3D repository example", + "method": "Hand-authored symmetric values for schema and visualization testing.", + "notes": "No random inputs and no physical observations." + }, + "samples": [ + { + "sample_timestamp": "2026-07-30T18:00:00.000Z", + "azimuth_angle_deg": 0, + "elevation_angle_deg": 0, + "measured_value": 0, + "measurement_unit": "dB_relative", + "quality_flags": ["simulated"] + }, + { + "sample_timestamp": "2026-07-30T18:00:00.100Z", + "azimuth_angle_deg": 45, + "elevation_angle_deg": 0, + "measured_value": -3.01, + "measurement_unit": "dB_relative", + "quality_flags": ["simulated"] + }, + { + "sample_timestamp": "2026-07-30T18:00:00.200Z", + "azimuth_angle_deg": 90, + "elevation_angle_deg": 0, + "measured_value": -40, + "measurement_unit": "dB_relative", + "quality_flags": ["simulated", "floor-clamped"] + }, + { + "sample_timestamp": "2026-07-30T18:00:00.300Z", + "azimuth_angle_deg": 135, + "elevation_angle_deg": 0, + "measured_value": -3.01, + "measurement_unit": "dB_relative", + "quality_flags": ["simulated"] + }, + { + "sample_timestamp": "2026-07-30T18:00:00.400Z", + "azimuth_angle_deg": 180, + "elevation_angle_deg": 0, + "measured_value": 0, + "measurement_unit": "dB_relative", + "quality_flags": ["simulated"] + } + ] +} diff --git a/data/schemas/scan-v1.schema.json b/data/schemas/scan-v1.schema.json new file mode 100644 index 0000000..2e84b25 --- /dev/null +++ b/data/schemas/scan-v1.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/bostromdev/Radiance3D/blob/main/data/schemas/scan-v1.schema.json", + "title": "Radiance3D scan dataset", + "description": "Version 1 schema for a stored scan and its provenance.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "scan_id", + "scan_name", + "timestamp", + "software_version", + "firmware_version", + "hardware_configuration", + "antenna_under_test", + "rf_source", + "receiver", + "frequency_hz", + "transmit_power", + "calibration_reference", + "environmental_notes", + "warnings", + "provenance", + "samples" + ], + "properties": { + "schema_version": { + "const": "1.0.0" + }, + "scan_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "scan_name": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "software_version": { + "type": "string", + "minLength": 1 + }, + "firmware_version": { + "type": ["string", "null"], + "description": "Null only when no physical or simulated motion firmware participated." + }, + "hardware_configuration": { + "$ref": "#/$defs/namedMetadata" + }, + "antenna_under_test": { + "$ref": "#/$defs/namedMetadata" + }, + "rf_source": { + "$ref": "#/$defs/namedMetadata" + }, + "receiver": { + "$ref": "#/$defs/namedMetadata" + }, + "frequency_hz": { + "type": "number", + "exclusiveMinimum": 0 + }, + "transmit_power": { + "oneOf": [ + { + "$ref": "#/$defs/valueWithUnit" + }, + { + "type": "null" + } + ] + }, + "calibration_reference": { + "oneOf": [ + { + "$ref": "#/$defs/namedMetadata" + }, + { + "type": "null" + } + ] + }, + "environmental_notes": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "provenance": { + "type": "object", + "additionalProperties": true, + "required": ["data_kind", "created_by"], + "properties": { + "data_kind": { + "enum": ["measured", "simulated", "imported", "processed"] + }, + "created_by": { + "type": "string", + "minLength": 1 + }, + "source_dataset_ids": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "method": { + "type": "string" + }, + "notes": { + "type": "string" + } + } + }, + "samples": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/sample" + } + } + }, + "$defs": { + "namedMetadata": { + "type": "object", + "additionalProperties": true, + "required": ["name"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "manufacturer": { + "type": ["string", "null"] + }, + "model": { + "type": ["string", "null"] + }, + "serial_number": { + "type": ["string", "null"] + }, + "revision": { + "type": ["string", "null"] + }, + "notes": { + "type": "string" + } + } + }, + "valueWithUnit": { + "type": "object", + "additionalProperties": false, + "required": ["value", "unit"], + "properties": { + "value": { + "type": "number" + }, + "unit": { + "type": "string", + "minLength": 1 + } + } + }, + "sample": { + "type": "object", + "additionalProperties": false, + "required": [ + "sample_timestamp", + "azimuth_angle_deg", + "elevation_angle_deg", + "measured_value", + "measurement_unit" + ], + "properties": { + "sample_timestamp": { + "type": "string", + "format": "date-time" + }, + "azimuth_angle_deg": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "elevation_angle_deg": { + "type": "number", + "minimum": -180, + "maximum": 180 + }, + "measured_value": { + "type": "number" + }, + "measurement_unit": { + "type": "string", + "minLength": 1, + "description": "Receiver-native or processed unit; not restricted to RSSI." + }, + "quality_flags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + } + } +} diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md new file mode 100644 index 0000000..c24fda8 --- /dev/null +++ b/docs/architecture/data-flow.md @@ -0,0 +1,25 @@ +# Data flow + +One planned measurement sample moves through the system as follows: + +```mermaid +sequenceDiagram + participant H as Host coordinator + participant M as Motion controller + participant R as Receiver adapter + participant D as Dataset writer + + H->>M: SCAN_STEP azimuth elevation rate + M-->>H: READY with reported position + H->>R: Request measurement + R-->>H: value, unit, timestamp, device status + H->>D: Append sample + angle + flags + D-->>H: Persisted or explicit error +``` + +The controller's `READY` response is a synchronization boundary in the simulator, not +proof that a physical axis has settled. A physical implementation may need encoder +feedback, dwell time, vibration criteria, and receiver settling. The host must record +reported rather than merely commanded angles when that information is available. + +Write failures must stop or pause a scan rather than allow unrecorded measurements. diff --git a/docs/architecture/design-decisions.md b/docs/architecture/design-decisions.md new file mode 100644 index 0000000..bbc85d7 --- /dev/null +++ b/docs/architecture/design-decisions.md @@ -0,0 +1,16 @@ +# Design decisions + +Consequential decisions are recorded here until separate architecture decision +records become worthwhile. + +| ID | Status | Decision | Reason | +| --- | --- | --- | --- | +| D-001 | Accepted | Use a monorepo. | Protocol, schema, firmware, and host changes can be reviewed together. | +| D-002 | Accepted | Keep motion and RF acquisition separate. | Receiver choice must not be embedded in motor firmware. | +| D-003 | Accepted | Use versioned JSON as the initial canonical format. | It is readable, extensible, and schema-validatable at current data volumes. | +| D-004 | Accepted | Use Python 3.11+ with no runtime dependency initially. | Typed models and useful validation do not yet require a framework. | +| D-005 | Accepted | Provide a native firmware simulator first. | Protocol behavior can be tested without claiming connected hardware. | +| D-006 | Provisional | Expose a line-oriented serial protocol. | It is easy to inspect during prototyping; framing may change after noise testing. | + +New entries should state context, alternatives, consequences, and evidence. Changing +an accepted data or protocol contract requires a versioning and migration plan. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..15ad3cd --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,44 @@ +# Architecture overview + +Radiance3D separates motion, measurement, coordination, analysis, visualization, and +storage so a receiver or motion mechanism can change without redefining the entire +system. + +```mermaid +flowchart TB + host["Host application / scan coordinator"] + motion["Motion controller"] + device["Measurement device adapter"] + analysis["Analysis library"] + view["Visualization layer"] + data[("Versioned dataset")] + mechanism["Pan-tilt mechanism"] + receiver["RF receiver or detector"] + + host -->|"versioned motion commands"| motion + motion --> mechanism + host -->|"receiver-specific interface"| device + device --> receiver + host -->|"raw synchronized samples"| data + data --> analysis + analysis -->|"derived data with provenance"| data + data --> view +``` + +## Component responsibilities + +- **Motion controller:** Owns axis state, homing, limits, stop behavior, position + reports, and motion faults. It does not interpret RF values. +- **Measurement device:** Produces a timestamped value with an explicit unit and + device metadata. It does not control motion. +- **Host application:** Coordinates requested angles and measurement timing, records + warnings, and persists raw samples. +- **Analysis library:** Performs explicit, testable transformations without mutating + raw data. +- **Visualization layer:** Presents stored data and must expose missing, interpolated, + clipped, or otherwise qualified samples. +- **Dataset format:** Preserves configuration, provenance, units, timestamps, and + data kind independently of any specific receiver. + +Only the schema, dependency-free scan validation, and motion-protocol simulator exist +at this stage. diff --git a/docs/architecture/system-boundaries.md b/docs/architecture/system-boundaries.md new file mode 100644 index 0000000..37b9d96 --- /dev/null +++ b/docs/architecture/system-boundaries.md @@ -0,0 +1,25 @@ +# System boundaries + +## Motion boundary + +The host sends positions in degrees and rates in degrees per second through the +versioned serial protocol. The controller reports its believed state and faults. A +future physical implementation must enforce machine-specific soft limits and +independent hardware safety; protocol range checks are not safety controls. + +## Measurement boundary + +A receiver adapter will return a numeric value, unit, sample timestamp, and device +metadata. RSSI is one possible value, not a required representation. Receiver setup, +settling, overload, and uncertainty remain device-specific. + +## Dataset boundary + +The host writes immutable raw scan records. Processing produces a new dataset with +`provenance.data_kind` set to `processed` and references to source dataset IDs. + +## Presentation boundary + +Analysis computes values; visualization displays them. A renderer must not silently +normalize, interpolate, or discard samples. Any such transformation belongs in a +documented processing step. diff --git a/docs/development/repository-layout.md b/docs/development/repository-layout.md new file mode 100644 index 0000000..6d22bf7 --- /dev/null +++ b/docs/development/repository-layout.md @@ -0,0 +1,13 @@ +# Repository layout + +The monorepo keeps contracts close to their consumers: + +- `data/schemas/` is normative for stored datasets. +- `software/` consumes the schema and implements host-side domain behavior. +- `firmware/` owns the controller protocol implementation. +- `docs/` explains cross-component contracts and provisional designs. +- `hardware/` will hold editable design sources only when they exist. +- `scripts/` checks the repository itself; `tools/` is for dataset utilities. +- `assets/` contains only documented, intentional project media. + +Generated builds, raw captures, temporary CAD files, and exports are ignored. diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md new file mode 100644 index 0000000..fb0ca56 --- /dev/null +++ b/docs/development/roadmap.md @@ -0,0 +1,5 @@ +# Development roadmap + +The canonical staged roadmap is [ROADMAP.md](../../ROADMAP.md). Issues and pull +requests should name their stage and avoid bypassing safety, data provenance, or +validation prerequisites. diff --git a/docs/development/setup.md b/docs/development/setup.md new file mode 100644 index 0000000..91edf10 --- /dev/null +++ b/docs/development/setup.md @@ -0,0 +1,19 @@ +# Development setup + +Prerequisites are Git, Python 3.11+, and PlatformIO for firmware work. + +```bash +cd software +python3.11 -m venv .venv +source .venv/bin/activate +python -m pip install -e ".[dev]" +pytest +ruff check . +mypy + +cd ../firmware/controller +pio run -e native +``` + +Run `python scripts/check_repository.py` from the repository root. No secrets or paid +services are required. diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 0000000..efa37cd --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,10 @@ +# Testing + +- **Repository:** required paths, valid JSON, simulated provenance, and internal links. +- **Software:** model invariants, CLI results, formatting, lint, and strict type checks. +- **Firmware:** native simulator compilation and protocol tests as semantics stabilize. +- **Schemas:** metaschema check plus example validation. +- **Hardware:** future test records must identify exact revisions, setup, instruments, + raw data, and safety controls. + +A passing simulator or schema test is not evidence of physical measurement accuracy. diff --git a/docs/experiments/calibration.md b/docs/experiments/calibration.md new file mode 100644 index 0000000..0b79ecd --- /dev/null +++ b/docs/experiments/calibration.md @@ -0,0 +1,8 @@ +# Calibration + +Calibration means a documented correction based on a known process or reference. It +must identify the reference, traceability if any, date, setup, algorithm, frequency, +applicable range, and uncertainty. Normalizing a plot maximum to zero is processing, +not calibrated gain. + +Radiance3D currently has no calibration procedure or calibrated output. diff --git a/docs/experiments/measurement-procedure.md b/docs/experiments/measurement-procedure.md new file mode 100644 index 0000000..d868677 --- /dev/null +++ b/docs/experiments/measurement-procedure.md @@ -0,0 +1,12 @@ +# Measurement procedure + +No physical procedure is validated. A future scan record should document: + +1. legal and safety review; +2. equipment revisions, warm-up, and configuration; +3. AUT orientation and coordinate reference; +4. geometry, surroundings, feedline routing, and frequency; +5. zeroing, limits, angular sequence, dwell, and receiver settings; +6. reference measurements and environmental observations; +7. raw capture with timestamps, units, warnings, and provenance; and +8. shutdown plus a check for invalid or missing samples. diff --git a/docs/experiments/repeatability.md b/docs/experiments/repeatability.md new file mode 100644 index 0000000..cd82b04 --- /dev/null +++ b/docs/experiments/repeatability.md @@ -0,0 +1,8 @@ +# Repeatability + +Repeatability testing should run nominally identical scans without selecting only +favorable trials. Record zeroing variation, commanded and observed angles, timing, +receiver drift, cable placement, temperature, and interventions. + +Report distributions or worst cases with sample counts and raw dataset references. +No repeatability result exists yet. diff --git a/docs/experiments/uncertainty.md b/docs/experiments/uncertainty.md new file mode 100644 index 0000000..c983b1d --- /dev/null +++ b/docs/experiments/uncertainty.md @@ -0,0 +1,9 @@ +# Uncertainty + +Potential contributors include angular zero and backlash, structural deflection, +settling, receiver noise and nonlinearity, source drift, geometry, reflections, +feedline radiation, polarization alignment, interpolation, and calibration reference +quality. + +An uncertainty budget must state assumptions and combine evidence from characterized +contributors. No uncertainty bound or laboratory-grade accuracy is claimed. diff --git a/docs/firmware/configuration.md b/docs/firmware/configuration.md new file mode 100644 index 0000000..c703a89 --- /dev/null +++ b/docs/firmware/configuration.md @@ -0,0 +1,14 @@ +# Firmware configuration + +Configuration must eventually separate: + +- controller and board definition; +- azimuth and elevation driver type and pins; +- steps, gearing, and microsteps per degree; +- direction, speed, acceleration, and machine limits; +- homing direction, switch polarity, and timeouts; +- communication rate and protocol version; and +- simulator versus physical mode. + +No default pinout or motor calibration is supplied because none is verified. Firmware +build constants are not substitutes for wiring records or mechanical limit testing. diff --git a/docs/firmware/overview.md b/docs/firmware/overview.md new file mode 100644 index 0000000..55b7afd --- /dev/null +++ b/docs/firmware/overview.md @@ -0,0 +1,9 @@ +# Firmware overview + +The planned ESP32 controller owns two motion axes and exposes a host protocol. Axis +drivers should be replaceable behind interfaces for move, home/zero, stop, position, +limit state, and fault state. + +The current implementation is an in-memory simulator. It does not access GPIO, +drivers, encoders, or limit switches. `esp32dev` is a provisional compilation target, +not a statement of supported hardware. diff --git a/docs/firmware/protocol.md b/docs/firmware/protocol.md new file mode 100644 index 0000000..34a02b8 --- /dev/null +++ b/docs/firmware/protocol.md @@ -0,0 +1,28 @@ +# Motion protocol version 1 + +Version 1 is UTF-8/ASCII text, one command or response per line, at a provisionally +documented 115200 baud. Whitespace separates fields. Angles are decimal degrees, +rates are degrees per second, and future time fields will use integer milliseconds. + +## Commands + +| Command | Arguments | Success response | Purpose | +| --- | --- | --- | --- | +| `IDENTIFY` | none | `OK IDENTIFY DEVICE=… PROTOCOL=1 MODE=…` | Identify device and protocol. | +| `STATUS` | none | `OK STATUS …` | Report positions, homing, stop, and fault state. | +| `POSITION` | none | `OK STATUS …` | Report current position state. | +| `HOME` | `AZ`, `EL`, or `BOTH` | `OK HOME AXIS=…` | Establish an axis zero. | +| `MOVE` | `AZ_DEG EL_DEG DEG_PER_S` | `OK MOVE …` | Move to an absolute position. | +| `SCAN_STEP` | `AZ_DEG EL_DEG DEG_PER_S` | `OK SCAN_STEP … READY=1` | Move and signal a measurement boundary. | +| `STOP` | none | `OK STOP` | Latch the controller in a stopped state. | +| `CLEAR_FAULT` | none | `OK CLEAR_FAULT` | Clear the simulator fault/stop latch. | + +Errors use `ERR CODE detail`. Defined simulator codes are `INVALID_COMMAND`, +`INVALID_ARGUMENT`, `NOT_HOMED`, `LIMIT_REACHED`, and `STOPPED`. + +## Important limitations + +`READY=1` means the simulator updated its state. It does not establish physical +settling. A physical implementation must distinguish commanded and observed position, +report active limits, define command IDs or acknowledgements if needed, and preserve +stop behavior across communication loss where safety analysis requires it. diff --git a/docs/glossary.md b/docs/glossary.md new file mode 100644 index 0000000..c11a6bd --- /dev/null +++ b/docs/glossary.md @@ -0,0 +1,15 @@ +# Glossary + +- **AUT:** Antenna under test. +- **Azimuth:** Horizontal rotation angle, expressed in degrees. +- **Elevation:** Vertical rotation angle, expressed in degrees. +- **Sample:** One synchronized angle and RF measurement. +- **Scan:** A collection of samples made under one configuration. +- **Dataset:** Stored scan data plus metadata, warnings, and provenance. +- **Calibration:** Correction based on a known process or reference. +- **Simulated data:** Generated data not captured by physical hardware. +- **Measured data:** Values captured from a physical receiver during an experiment. +- **Imported data:** Data translated from an external format without changing its + underlying meaning. +- **Processed data:** Derived data whose provenance identifies its source datasets and + method. diff --git a/docs/hardware/bill-of-materials.md b/docs/hardware/bill-of-materials.md new file mode 100644 index 0000000..160f769 --- /dev/null +++ b/docs/hardware/bill-of-materials.md @@ -0,0 +1,9 @@ +# Bill of materials + +There is no tested BOM. Candidate categories include an ESP32-class controller, +two stepper drivers, two appropriately sized motors, position/limit devices, power +conversion and protection, structure, bearings, fasteners, cabling, and RF equipment. + +A publishable BOM must contain tested part numbers, revision, quantity, function, +acceptable substitutions, source date, and validation status. Price and availability +alone are not engineering validation. diff --git a/docs/hardware/motion-system.md b/docs/hardware/motion-system.md new file mode 100644 index 0000000..92286b9 --- /dev/null +++ b/docs/hardware/motion-system.md @@ -0,0 +1,10 @@ +# Motion system + +The conceptual arrangement uses a base azimuth axis and a supported elevation axis. +Stepper drivers such as TMC2209 and NEMA 17-class motors are candidates only. +Selection depends on inertia, gear ratio, holding torque, current, thermal behavior, +backlash, resolution, and RF emissions. + +Each axis needs a repeatable zero strategy, conservative soft limits, and preferably +independent limit inputs. Emergency power isolation must not depend solely on working +firmware or a host connection. diff --git a/docs/hardware/overview.md b/docs/hardware/overview.md new file mode 100644 index 0000000..7690911 --- /dev/null +++ b/docs/hardware/overview.md @@ -0,0 +1,8 @@ +# Hardware overview + +The provisional system consists of a pan-tilt mechanism, an ESP32-class controller, +two stepper drivers, motors such as NEMA 17 units where load testing supports them, +power conversion, limits, and an independently connected RF measurement device. + +Component families are examples, not tested recommendations. Electrical, mechanical, +thermal, and RF validation must precede a supported configuration. diff --git a/docs/hardware/power-system.md b/docs/hardware/power-system.md new file mode 100644 index 0000000..a3c96b6 --- /dev/null +++ b/docs/hardware/power-system.md @@ -0,0 +1,9 @@ +# Power system + +The provisional controller power design must separate motor-current paths from logic +and measurement paths, use appropriately rated conversion and protection, and document +grounding. Driver current limits must be set from verified motor and thermal data. + +Future designs need fusing, polarity protection, wire and connector ratings, accessible +power isolation, and conducted/radiated noise testing. No supply voltage or current +rating is specified yet. diff --git a/docs/hardware/rf-measurement.md b/docs/hardware/rf-measurement.md new file mode 100644 index 0000000..defdbf0 --- /dev/null +++ b/docs/hardware/rf-measurement.md @@ -0,0 +1,11 @@ +# RF measurement architecture + +The receiver or detector remains stationary where practical and connects to the host +through a device adapter. The RF source and AUT arrangement must be chosen for the +measurement method and local legal requirements. + +Feedline movement, common-mode current, receiver overload, reflections, nearby +conductive structure, motor wiring, and controller emissions can distort patterns. +Cable routing and non-conductive structural materials should reduce disturbance where +testing shows benefit. No receiver, dynamic range, accuracy, or supported frequency +range is currently validated. diff --git a/docs/hardware/wiring.md b/docs/hardware/wiring.md new file mode 100644 index 0000000..b23f87f --- /dev/null +++ b/docs/hardware/wiring.md @@ -0,0 +1,9 @@ +# Wiring + +Verified wiring diagrams do not exist. A future diagram must identify connector pins, +signal reference, voltage domain, wire gauge, shielding, grounding point, limit switch +behavior, and emergency isolation. + +Route motor and power wiring away from receiver inputs and RF feedlines. Provide +strain relief throughout the full motion envelope. Disconnect power before changing +wiring and verify continuity and polarity before energizing. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5ef80a9 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,18 @@ +# Radiance3D documentation + +Radiance3D is in architecture and prototyping. Documentation describes intended +interfaces unless a page explicitly marks behavior as implemented and tested. + +## Start here + +- [Architecture overview](architecture/overview.md) +- [System boundaries](architecture/system-boundaries.md) +- [Data flow](architecture/data-flow.md) +- [Scan file format](software/file-formats.md) +- [Motion protocol](firmware/protocol.md) +- [Development setup](development/setup.md) +- [Roadmap](development/roadmap.md) +- [Glossary](glossary.md) + +Hardware choices in this documentation are provisional. Experiment documents define +the evidence required before measurement or accuracy claims can be made. diff --git a/docs/software/data-pipeline.md b/docs/software/data-pipeline.md new file mode 100644 index 0000000..fa563eb --- /dev/null +++ b/docs/software/data-pipeline.md @@ -0,0 +1,14 @@ +# Data pipeline + +The planned pipeline preserves rather than overwrites raw observations: + +1. Acquire receiver-native values with units and timestamps. +2. Pair them with reported angles and quality flags. +3. Store a `measured` raw dataset with full configuration. +4. Apply calibration or normalization as a named transformation. +5. Store a new `processed` dataset referencing its source IDs. +6. Interpolate or convert coordinates only in explicit later stages. +7. Render the selected dataset and expose processing status. + +Algorithms must handle missing, irregular, and repeated angles. They must not assume +all receiver values are logarithmic, RSSI, power, or directly comparable. diff --git a/docs/software/file-formats.md b/docs/software/file-formats.md new file mode 100644 index 0000000..ce3812a --- /dev/null +++ b/docs/software/file-formats.md @@ -0,0 +1,26 @@ +# Scan dataset format + +The canonical version 1 format is JSON validated by +[`data/schemas/scan-v1.schema.json`](../../data/schemas/scan-v1.schema.json). The +[simulated example](../../data/examples/simulated/dipole-like-scan.json) is clearly +labeled and contains no physical measurement. + +## Required record groups + +- Identity: schema version, scan ID/name, scan timestamp, software, and firmware. +- Configuration: hardware, AUT, RF source, receiver, frequency, and transmit power. +- Experiment context: calibration reference, environmental notes, and warnings. +- Provenance: `measured`, `simulated`, `imported`, or `processed`, plus creator/method. +- Samples: timestamp, azimuth degrees, elevation degrees, value, unit, and flags. + +`transmit_power` and `calibration_reference` are explicitly nullable so unknown is +not confused with zero or an empty object. Metadata objects allow additional fields +to accommodate device-specific information. Sample fields are strict so a typo +cannot silently create a second representation. + +## Versioning + +`schema_version` uses semantic versioning. Additive optional metadata is a minor +change; removing fields or changing meaning requires a new major schema file. +Readers must reject unsupported major versions. A CSV export may be added later, but +it must retain or accompany all dataset-level metadata. diff --git a/docs/software/overview.md b/docs/software/overview.md new file mode 100644 index 0000000..e9d9194 --- /dev/null +++ b/docs/software/overview.md @@ -0,0 +1,9 @@ +# Software overview + +The initial `radiance3d` package provides typed domain models, core version 1 +validation, and scan inspection. Acquisition, analysis, calibration, and visualization +interfaces are architectural boundaries only; there is no GUI or hardware control. + +The package uses a `src` layout and Python 3.11+. The JSON Schema is the normative +interchange contract; Python validation gives fast, dependency-free feedback for core +invariants. diff --git a/docs/software/visualization.md b/docs/software/visualization.md new file mode 100644 index 0000000..f316108 --- /dev/null +++ b/docs/software/visualization.md @@ -0,0 +1,9 @@ +# Visualization + +Planned outputs include polar cuts, spherical representations, interactive 3D views, +and dataset comparisons. No visualization is implemented. + +A visualization must show units, frequency, data kind, normalization or calibration +state, and warnings. Interpolated regions must be distinguishable from samples. +Color scales and radial transformations must not imply absolute gain when the source +data is relative. diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..f0ad970 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,9 @@ +# Firmware + +`controller/` defines a versioned, line-oriented motion protocol and a buildable +simulator target. It establishes interfaces for azimuth, elevation, homing, limits, +position reports, scan-step synchronization, stop, and faults. + +No motor driver, pinout, controller board, or physical safety behavior is verified. +Hardware-specific implementations must preserve the protocol contract and add +independent safety controls before energizing motors. diff --git a/firmware/controller/README.md b/firmware/controller/README.md new file mode 100644 index 0000000..a9aae62 --- /dev/null +++ b/firmware/controller/README.md @@ -0,0 +1,14 @@ +# Motion controller foundation + +The controller currently implements an in-memory simulator for protocol and host +integration work. It compiles as a native command-line program and as a provisional +ESP32 Arduino target. Neither build drives pins. + +```bash +pio run -e native +``` + +The native process reads one command per line from standard input and writes one +response per line. See [the protocol specification](../../docs/firmware/protocol.md). +Board selection, electrical limits, motor drivers, pins, and emergency-stop behavior +remain provisional. diff --git a/firmware/controller/include/protocol.hpp b/firmware/controller/include/protocol.hpp new file mode 100644 index 0000000..40d2012 --- /dev/null +++ b/firmware/controller/include/protocol.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace radiance3d { + +enum class FaultCode { + none, + invalid_command, + invalid_argument, + not_homed, + limit_reached, + stopped, +}; + +struct AxisState { + double position_deg{0.0}; + bool homed{false}; + bool limit_active{false}; +}; + +struct ControllerState { + AxisState azimuth{}; + AxisState elevation{}; + FaultCode fault{FaultCode::none}; + bool stopped{false}; +}; + +class ProtocolEngine { + public: + std::string handle(const std::string& line); + const ControllerState& state() const; + + private: + ControllerState state_{}; + + std::string status() const; + std::string fault(FaultCode code, const std::string& detail); +}; + +} // namespace radiance3d diff --git a/firmware/controller/platformio.ini b/firmware/controller/platformio.ini new file mode 100644 index 0000000..a67918b --- /dev/null +++ b/firmware/controller/platformio.ini @@ -0,0 +1,19 @@ +[platformio] +default_envs = native + +[env] +build_flags = + -D RADIANCE3D_PROTOCOL_VERSION=1 +build_src_filter = +<*.cpp> + +[env:native] +platform = native +test_framework = unity + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +monitor_speed = 115200 + +; esp32dev is a provisional compile target, not a supported board or pinout. diff --git a/firmware/controller/src/main.cpp b/firmware/controller/src/main.cpp new file mode 100644 index 0000000..cf59276 --- /dev/null +++ b/firmware/controller/src/main.cpp @@ -0,0 +1,34 @@ +#include "protocol.hpp" + +#ifdef ARDUINO +#include + +radiance3d::ProtocolEngine engine; +String incoming; + +void setup() { Serial.begin(115200); } + +void loop() { + while (Serial.available() > 0) { + const char character = static_cast(Serial.read()); + if (character == '\n') { + Serial.println(engine.handle(incoming.c_str()).c_str()); + incoming = ""; + } else if (character != '\r') { + incoming += character; + } + } +} +#else +#include +#include + +int main() { + radiance3d::ProtocolEngine engine; + std::string line; + while (std::getline(std::cin, line)) { + std::cout << engine.handle(line) << '\n'; + } + return 0; +} +#endif diff --git a/firmware/controller/src/protocol.cpp b/firmware/controller/src/protocol.cpp new file mode 100644 index 0000000..93c009a --- /dev/null +++ b/firmware/controller/src/protocol.cpp @@ -0,0 +1,128 @@ +#include "protocol.hpp" + +#include +#include +#include + +#ifndef RADIANCE3D_PROTOCOL_VERSION +#define RADIANCE3D_PROTOCOL_VERSION 1 +#endif + +namespace radiance3d { +namespace { + +const char* fault_name(const FaultCode code) { + switch (code) { + case FaultCode::none: + return "NONE"; + case FaultCode::invalid_command: + return "INVALID_COMMAND"; + case FaultCode::invalid_argument: + return "INVALID_ARGUMENT"; + case FaultCode::not_homed: + return "NOT_HOMED"; + case FaultCode::limit_reached: + return "LIMIT_REACHED"; + case FaultCode::stopped: + return "STOPPED"; + } + return "UNKNOWN"; +} + +bool read_double(std::istringstream& input, double& value) { + input >> value; + return !input.fail(); +} + +} // namespace + +const ControllerState& ProtocolEngine::state() const { return state_; } + +std::string ProtocolEngine::fault(const FaultCode code, const std::string& detail) { + state_.fault = code; + return "ERR " + std::string(fault_name(code)) + " " + detail; +} + +std::string ProtocolEngine::status() const { + std::ostringstream output; + output << std::fixed << std::setprecision(3) << "OK STATUS" + << " AZ_DEG=" << state_.azimuth.position_deg + << " EL_DEG=" << state_.elevation.position_deg + << " AZ_HOMED=" << (state_.azimuth.homed ? 1 : 0) + << " EL_HOMED=" << (state_.elevation.homed ? 1 : 0) + << " STOPPED=" << (state_.stopped ? 1 : 0) + << " FAULT=" << fault_name(state_.fault); + return output.str(); +} + +std::string ProtocolEngine::handle(const std::string& line) { + std::istringstream input(line); + std::string command; + input >> command; + + if (command == "IDENTIFY") { + return "OK IDENTIFY DEVICE=Radiance3D-SIM PROTOCOL=" + + std::to_string(RADIANCE3D_PROTOCOL_VERSION) + " MODE=SIMULATOR"; + } + if (command == "STATUS" || command == "POSITION") { + return status(); + } + if (command == "CLEAR_FAULT") { + state_.fault = FaultCode::none; + state_.stopped = false; + return "OK CLEAR_FAULT"; + } + if (command == "STOP") { + state_.stopped = true; + state_.fault = FaultCode::stopped; + return "OK STOP"; + } + if (state_.stopped) { + return fault(FaultCode::stopped, "send CLEAR_FAULT before motion"); + } + if (command == "HOME") { + std::string axis; + input >> axis; + if (axis == "AZ" || axis == "BOTH") { + state_.azimuth = AxisState{0.0, true, false}; + } + if (axis == "EL" || axis == "BOTH") { + state_.elevation = AxisState{0.0, true, false}; + } + if (axis != "AZ" && axis != "EL" && axis != "BOTH") { + return fault(FaultCode::invalid_argument, "HOME expects AZ, EL, or BOTH"); + } + state_.fault = FaultCode::none; + return "OK HOME AXIS=" + axis; + } + if (command == "MOVE" || command == "SCAN_STEP") { + double azimuth = 0.0; + double elevation = 0.0; + double speed = 0.0; + if (!read_double(input, azimuth) || !read_double(input, elevation) || + !read_double(input, speed) || speed <= 0.0) { + return fault(FaultCode::invalid_argument, command + " expects AZ_DEG EL_DEG DEG_PER_S"); + } + if (!state_.azimuth.homed || !state_.elevation.homed) { + return fault(FaultCode::not_homed, "both axes must be homed"); + } + if (azimuth < -360.0 || azimuth > 360.0 || elevation < -180.0 || elevation > 180.0) { + return fault(FaultCode::limit_reached, "requested position exceeds protocol bounds"); + } + state_.azimuth.position_deg = azimuth; + state_.elevation.position_deg = elevation; + state_.fault = FaultCode::none; + std::ostringstream output; + output << std::fixed << std::setprecision(3) << "OK " << command + << " AZ_DEG=" << azimuth << " EL_DEG=" << elevation + << " DEG_PER_S=" << speed; + if (command == "SCAN_STEP") { + output << " READY=1"; + } + return output.str(); + } + + return fault(FaultCode::invalid_command, "unknown command"); +} + +} // namespace radiance3d diff --git a/firmware/controller/test/README.md b/firmware/controller/test/README.md new file mode 100644 index 0000000..d7c234b --- /dev/null +++ b/firmware/controller/test/README.md @@ -0,0 +1,5 @@ +# Firmware tests + +Protocol behavior is currently exercised through the native simulator. Add PlatformIO +unit tests here as commands and fault semantics stabilize; physical hardware tests +must state board revision, wiring, load, supply, and safety controls. diff --git a/firmware/shared/README.md b/firmware/shared/README.md new file mode 100644 index 0000000..c4b873d --- /dev/null +++ b/firmware/shared/README.md @@ -0,0 +1,5 @@ +# Shared firmware components + +This location is reserved for reusable, tested components shared by future firmware +targets. Code belongs here only after a second consumer exists; the protocol currently +lives with the controller to avoid a premature abstraction. diff --git a/hardware/README.md b/hardware/README.md new file mode 100644 index 0000000..7ce4584 --- /dev/null +++ b/hardware/README.md @@ -0,0 +1,11 @@ +# Hardware + +Hardware content is architectural and provisional. There are no verified schematics, +PCBs, wiring diagrams, CAD models, or released printable parts. + +- `electronics/` describes where reviewed electrical design files will live. +- `mechanical/` records mechanical requirements and future design locations. +- `wiring/` defines documentation expectations before energizing a prototype. +- `bom/` defines evidence required before publishing a tested bill of materials. + +Future verified hardware files may receive a separate open-hardware license. diff --git a/hardware/bom/README.md b/hardware/bom/README.md new file mode 100644 index 0000000..9673618 --- /dev/null +++ b/hardware/bom/README.md @@ -0,0 +1,5 @@ +# Bill of materials + +No tested BOM exists. A future BOM must distinguish required and optional parts, +identify exact manufacturer part numbers or functional requirements, record tested +revisions, and avoid implying compatibility for untested substitutions. diff --git a/hardware/electronics/README.md b/hardware/electronics/README.md new file mode 100644 index 0000000..300fb79 --- /dev/null +++ b/hardware/electronics/README.md @@ -0,0 +1,6 @@ +# Electronics design files + +Verified source schematics belong in `schematics/` and reviewed PCB source files in +`pcb/`. Do not publish fabricated-looking placeholders. Every future design must +identify its tool version, revision, supply limits, grounding assumptions, protection, +connectors, and validation status. diff --git a/hardware/electronics/pcb/README.md b/hardware/electronics/pcb/README.md new file mode 100644 index 0000000..ccca68c --- /dev/null +++ b/hardware/electronics/pcb/README.md @@ -0,0 +1,5 @@ +# PCB + +No PCB is designed or validated. Future PCB source must match a reviewed schematic +revision and include fabrication constraints, stack-up assumptions, and bring-up +results. diff --git a/hardware/electronics/schematics/README.md b/hardware/electronics/schematics/README.md new file mode 100644 index 0000000..49aeac0 --- /dev/null +++ b/hardware/electronics/schematics/README.md @@ -0,0 +1,5 @@ +# Schematics + +No verified schematic exists. This directory will contain editable source schematics, +review exports, revision history, and electrical validation notes when a controller +design has been reviewed. diff --git a/hardware/mechanical/README.md b/hardware/mechanical/README.md new file mode 100644 index 0000000..4f5fdbe --- /dev/null +++ b/hardware/mechanical/README.md @@ -0,0 +1,18 @@ +# Mechanical architecture + +The planned mechanism has a base pan axis for azimuth and a supported tilt axis for +elevation. The AUT mount should provide a documented reference plane, repeatable zero, +strain relief, and interchangeable non-conductive fixtures where practical. + +Design priorities are: + +- low and characterized backlash; +- sufficient stiffness without placing unnecessary conductive mass near the AUT; +- controlled cable routing through the motion envelope; +- accessible hard stops, limits, and power isolation; +- separation of printable fixtures from bearings, shafts, and other parts better + made from non-printed materials; and +- provisions for future alignment and calibration fixtures. + +All choices are provisional until loads, required angular resolution, RF disturbance, +and repeatability are tested. diff --git a/hardware/mechanical/cad/README.md b/hardware/mechanical/cad/README.md new file mode 100644 index 0000000..4671031 --- /dev/null +++ b/hardware/mechanical/cad/README.md @@ -0,0 +1,4 @@ +# CAD sources + +No verified CAD model exists. Editable, revisioned source assemblies will be stored +here when dimensions and interfaces have been reviewed. diff --git a/hardware/mechanical/drawings/README.md b/hardware/mechanical/drawings/README.md new file mode 100644 index 0000000..94fad5c --- /dev/null +++ b/hardware/mechanical/drawings/README.md @@ -0,0 +1,4 @@ +# Mechanical drawings + +No released drawings exist. Future drawings must identify units, tolerances, material, +revision, and the matching source model. diff --git a/hardware/mechanical/exports/README.md b/hardware/mechanical/exports/README.md new file mode 100644 index 0000000..7b98f05 --- /dev/null +++ b/hardware/mechanical/exports/README.md @@ -0,0 +1,4 @@ +# Generated mechanical exports + +Generated STEP, STL, and drawing exports are ignored by default. Release artifacts +should be attached to a versioned release and traceable to a reviewed CAD revision. diff --git a/hardware/wiring/README.md b/hardware/wiring/README.md new file mode 100644 index 0000000..c750d92 --- /dev/null +++ b/hardware/wiring/README.md @@ -0,0 +1,6 @@ +# Wiring records + +No verified wiring configuration exists. Before a prototype is energized, document +controller and driver revisions, wire gauges, connectors, fusing, supply ratings, +grounding, shielding, limit circuits, emergency power isolation, and photographs or +diagrams of the actual build. diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..46cd155 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,8 @@ +# Scripts + +- `bootstrap.sh` creates `software/.venv`, installs development dependencies, and + runs repository checks. +- `check_repository.py` validates required paths, JSON parsing, the simulated example, + internal documentation links, and unintended empty files. + +Scripts are designed to run from any working directory and require no project secrets. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..7975f64 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +repository_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +environment_dir="$repository_dir/software/.venv" + +python3 -c 'import sys; assert sys.version_info >= (3, 11), "Python 3.11+ is required"' +python3 -m venv "$environment_dir" +"$environment_dir/bin/python" -m pip install --upgrade pip +"$environment_dir/bin/python" -m pip install -e "$repository_dir/software[dev]" +"$environment_dir/bin/python" "$repository_dir/scripts/check_repository.py" + +printf '%s\n' "Development environment ready at software/.venv" diff --git a/scripts/check_repository.py b/scripts/check_repository.py new file mode 100755 index 0000000..a039255 --- /dev/null +++ b/scripts/check_repository.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Fast, dependency-free checks for the Radiance3D repository contract.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOFTWARE_SRC = ROOT / "software" / "src" +sys.path.insert(0, str(SOFTWARE_SRC)) + +from radiance3d.validation import ScanValidationError, load_scan + +REQUIRED_PATHS = ( + "README.md", + "ROADMAP.md", + "LICENSE", + "data/schemas/scan-v1.schema.json", + "data/examples/simulated/dipole-like-scan.json", + "software/pyproject.toml", + "firmware/controller/platformio.ini", + "docs/architecture/overview.md", + "docs/firmware/protocol.md", + "docs/software/file-formats.md", +) +LINK_PATTERN = re.compile(r"(? list[str]: + return [ + f"missing required path: {path}" + for path in REQUIRED_PATHS + if not (ROOT / path).is_file() + ] + + +def check_json() -> list[str]: + errors: list[str] = [] + for path in ROOT.rglob("*.json"): + try: + json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") + + example = ROOT / "data" / "examples" / "simulated" / "dipole-like-scan.json" + try: + scan = load_scan(example) + except ScanValidationError as exc: + errors.append(str(exc)) + else: + if scan.data_kind != "simulated": + errors.append(f"{example.relative_to(ROOT)}: example must remain simulated") + if not any("SIMULATED" in warning.upper() for warning in scan.warnings): + errors.append(f"{example.relative_to(ROOT)}: simulated warning is required") + return errors + + +def check_internal_links() -> list[str]: + errors: list[str] = [] + for markdown in ROOT.rglob("*.md"): + text = markdown.read_text(encoding="utf-8") + for target in LINK_PATTERN.findall(text): + target = target.strip().strip("<>") + if target.startswith(("http://", "https://", "mailto:", "#")): + continue + path_text = target.split("#", 1)[0] + if not path_text: + continue + resolved = (markdown.parent / path_text).resolve() + if not resolved.exists(): + errors.append( + f"{markdown.relative_to(ROOT)}: broken internal link target {target!r}" + ) + return errors + + +def check_empty_files() -> list[str]: + allowed = {ROOT / "software" / "src" / "radiance3d" / "py.typed"} + errors: list[str] = [] + for path in ROOT.rglob("*"): + if path.is_file() and path.stat().st_size == 0 and path not in allowed: + errors.append(f"{path.relative_to(ROOT)}: empty file") + return errors + + +def main() -> int: + errors = [ + *check_required_paths(), + *check_json(), + *check_internal_links(), + *check_empty_files(), + ] + if errors: + for error in errors: + print(f"ERROR: {error}") + return 1 + print("Repository checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/software/README.md b/software/README.md new file mode 100644 index 0000000..cd9c718 --- /dev/null +++ b/software/README.md @@ -0,0 +1,14 @@ +# Radiance3D software + +This Python 3.11+ package provides typed scan models plus two intentionally small +commands: + +```bash +radiance3d validate path/to/scan.json +radiance3d inspect path/to/scan.json +``` + +It does not acquire RF data, control physical hardware, or visualize patterns yet. +The validation code enforces the version 1 record shape and domain invariants without +adding a runtime dependency. The JSON Schema remains the normative interchange +specification and is validated separately in CI. diff --git a/software/pyproject.toml b/software/pyproject.toml new file mode 100644 index 0000000..e5167c2 --- /dev/null +++ b/software/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["hatchling>=1.27"] +build-backend = "hatchling.build" + +[project] +name = "radiance3d" +version = "0.1.0.dev0" +description = "Data models and validation tools for Radiance3D scan datasets." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "bostromdev" }] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Typing :: Typed", +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "mypy>=1.15,<2", + "pytest>=8.3,<9", + "ruff>=0.9,<1", +] + +[project.scripts] +radiance3d = "radiance3d.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/radiance3d"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tests"] diff --git a/software/src/radiance3d/__init__.py b/software/src/radiance3d/__init__.py new file mode 100644 index 0000000..39ddc77 --- /dev/null +++ b/software/src/radiance3d/__init__.py @@ -0,0 +1,16 @@ +"""Typed models and validation for Radiance3D datasets.""" + +from radiance3d.models import Angle, HardwareMetadata, RFMeasurement, Sample, Scan +from radiance3d.validation import ScanValidationError, load_scan + +__all__ = [ + "Angle", + "HardwareMetadata", + "RFMeasurement", + "Sample", + "Scan", + "ScanValidationError", + "load_scan", +] + +__version__ = "0.1.0.dev0" diff --git a/software/src/radiance3d/__main__.py b/software/src/radiance3d/__main__.py new file mode 100644 index 0000000..06dc914 --- /dev/null +++ b/software/src/radiance3d/__main__.py @@ -0,0 +1,5 @@ +"""Run the Radiance3D CLI with ``python -m radiance3d``.""" + +from radiance3d.cli import main + +raise SystemExit(main()) diff --git a/software/src/radiance3d/cli.py b/software/src/radiance3d/cli.py new file mode 100644 index 0000000..a5b06e4 --- /dev/null +++ b/software/src/radiance3d/cli.py @@ -0,0 +1,39 @@ +"""Command-line interface for honest, local scan-file operations.""" + +from __future__ import annotations + +import argparse +from collections.abc import Sequence + +from radiance3d.validation import ScanValidationError, load_scan + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="radiance3d") + commands = parser.add_subparsers(dest="command", required=True) + for name in ("validate", "inspect"): + command = commands.add_parser(name) + command.add_argument("path", help="Path to a Radiance3D JSON scan") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + try: + scan = load_scan(args.path) + except ScanValidationError as exc: + print(f"error: {exc}") + return 1 + + if args.command == "validate": + print(f"valid Radiance3D {scan.schema_version} scan: {scan.scan_id}") + else: + units = sorted({sample.measurement.unit for sample in scan.samples}) + print(f"name: {scan.name}") + print(f"id: {scan.scan_id}") + print(f"kind: {scan.data_kind}") + print(f"frequency_hz: {scan.frequency_hz:g}") + print(f"samples: {len(scan.samples)}") + print(f"measurement_units: {', '.join(units)}") + print(f"warnings: {len(scan.warnings)}") + return 0 diff --git a/software/src/radiance3d/models.py b/software/src/radiance3d/models.py new file mode 100644 index 0000000..41684e6 --- /dev/null +++ b/software/src/radiance3d/models.py @@ -0,0 +1,280 @@ +"""Dependency-free domain models for version 1 scan datasets.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime +from math import isfinite +from typing import Any, Literal, cast + +DataKind = Literal["measured", "simulated", "imported", "processed"] +TOP_LEVEL_FIELDS = { + "schema_version", + "scan_id", + "scan_name", + "timestamp", + "software_version", + "firmware_version", + "hardware_configuration", + "antenna_under_test", + "rf_source", + "receiver", + "frequency_hz", + "transmit_power", + "calibration_reference", + "environmental_notes", + "warnings", + "provenance", + "samples", +} +SAMPLE_FIELDS = { + "sample_timestamp", + "azimuth_angle_deg", + "elevation_angle_deg", + "measured_value", + "measurement_unit", + "quality_flags", +} + + +def _text(value: object, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _string(value: object, field: str) -> str: + if not isinstance(value, str): + raise ValueError(f"{field} must be a string") + return value + + +def _optional_text(value: object, field: str) -> str | None: + if value is None: + return None + return _text(value, field) + + +def _number(value: object, field: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{field} must be a number") + result = float(value) + if not isfinite(result): + raise ValueError(f"{field} must be finite") + return result + + +def _mapping(value: object, field: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{field} must be an object") + return value + + +def _timestamp(value: object, field: str) -> datetime: + text = _text(value, field) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field} must be an ISO 8601 timestamp") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field} must include a timezone") + return parsed + + +def _string_list(value: object, field: str) -> tuple[str, ...]: + if not isinstance(value, list) or not all( + isinstance(item, str) and item.strip() for item in value + ): + raise ValueError(f"{field} must be a list of non-empty strings") + if len(value) != len(set(value)): + raise ValueError(f"{field} must not contain duplicates") + return tuple(value) + + +@dataclass(frozen=True) +class Angle: + """An angle in degrees with an explicit axis.""" + + axis: Literal["azimuth", "elevation"] + degrees: float + + def __post_init__(self) -> None: + if not isfinite(self.degrees): + raise ValueError("angle must be finite") + lower, upper = (-360.0, 360.0) if self.axis == "azimuth" else (-180.0, 180.0) + if not lower <= self.degrees <= upper: + raise ValueError(f"{self.axis} angle must be between {lower:g} and {upper:g} degrees") + + +@dataclass(frozen=True) +class RFMeasurement: + """A receiver value and its unit; the value is not assumed to be RSSI.""" + + value: float + unit: str + + def __post_init__(self) -> None: + if not isfinite(self.value): + raise ValueError("measurement must be finite") + if not self.unit.strip(): + raise ValueError("measurement unit must not be empty") + + @classmethod + def from_mapping(cls, value: object, field: str) -> RFMeasurement: + item = _mapping(value, field) + unexpected = set(item) - {"value", "unit"} + if unexpected: + raise ValueError(f"{field} contains unsupported fields: {sorted(unexpected)}") + return cls( + value=_number(item.get("value"), f"{field}.value"), + unit=_text(item.get("unit"), f"{field}.unit"), + ) + + +@dataclass(frozen=True) +class HardwareMetadata: + """Named, extensible metadata for hardware or a simulator.""" + + name: str + details: Mapping[str, Any] + + @classmethod + def from_mapping(cls, value: object, field: str) -> HardwareMetadata: + item = _mapping(value, field) + return cls(name=_text(item.get("name"), f"{field}.name"), details=dict(item)) + + +@dataclass(frozen=True) +class Sample: + timestamp: datetime + azimuth: Angle + elevation: Angle + measurement: RFMeasurement + quality_flags: tuple[str, ...] = () + + @classmethod + def from_mapping(cls, value: object, index: int) -> Sample: + item = _mapping(value, f"samples[{index}]") + unexpected = set(item) - SAMPLE_FIELDS + if unexpected: + raise ValueError(f"samples[{index}] contains unsupported fields: {sorted(unexpected)}") + return cls( + timestamp=_timestamp( + item.get("sample_timestamp"), + f"samples[{index}].sample_timestamp", + ), + azimuth=Angle( + "azimuth", + _number(item.get("azimuth_angle_deg"), f"samples[{index}].azimuth_angle_deg"), + ), + elevation=Angle( + "elevation", + _number(item.get("elevation_angle_deg"), f"samples[{index}].elevation_angle_deg"), + ), + measurement=RFMeasurement( + _number(item.get("measured_value"), f"samples[{index}].measured_value"), + _text(item.get("measurement_unit"), f"samples[{index}].measurement_unit"), + ), + quality_flags=_string_list( + item.get("quality_flags", []), + f"samples[{index}].quality_flags", + ), + ) + + +@dataclass(frozen=True) +class Scan: + schema_version: str + scan_id: str + name: str + timestamp: datetime + software_version: str + firmware_version: str | None + frequency_hz: float + hardware: HardwareMetadata + antenna_under_test: HardwareMetadata + rf_source: HardwareMetadata + receiver: HardwareMetadata + transmit_power: RFMeasurement | None + calibration_reference: HardwareMetadata | None + environmental_notes: str + data_kind: DataKind + samples: tuple[Sample, ...] + warnings: tuple[str, ...] + + @classmethod + def from_mapping(cls, value: object) -> Scan: + data = _mapping(value, "scan") + missing = TOP_LEVEL_FIELDS - set(data) + if missing: + raise ValueError(f"missing required fields: {sorted(missing)}") + unexpected = set(data) - TOP_LEVEL_FIELDS + if unexpected: + raise ValueError(f"unsupported top-level fields: {sorted(unexpected)}") + + version = _text(data.get("schema_version"), "schema_version") + if version != "1.0.0": + raise ValueError(f"unsupported schema_version: {version}") + + provenance = _mapping(data.get("provenance"), "provenance") + data_kind_value = provenance.get("data_kind") + allowed = {"measured", "simulated", "imported", "processed"} + if not isinstance(data_kind_value, str) or data_kind_value not in allowed: + raise ValueError( + "provenance.data_kind must be measured, simulated, imported, or processed" + ) + data_kind = cast(DataKind, data_kind_value) + _text(provenance.get("created_by"), "provenance.created_by") + if "source_dataset_ids" in provenance: + _string_list(provenance["source_dataset_ids"], "provenance.source_dataset_ids") + for field in ("method", "notes"): + if field in provenance: + _string(provenance[field], f"provenance.{field}") + + samples_value = data.get("samples") + if not isinstance(samples_value, list) or not samples_value: + raise ValueError("samples must be a non-empty list") + + frequency_hz = _number(data.get("frequency_hz"), "frequency_hz") + if frequency_hz <= 0: + raise ValueError("frequency_hz must be greater than zero") + + transmit_power_value = data.get("transmit_power") + transmit_power = ( + None + if transmit_power_value is None + else RFMeasurement.from_mapping(transmit_power_value, "transmit_power") + ) + calibration_value = data.get("calibration_reference") + calibration_reference = ( + None + if calibration_value is None + else HardwareMetadata.from_mapping(calibration_value, "calibration_reference") + ) + + return cls( + schema_version=version, + scan_id=_text(data.get("scan_id"), "scan_id"), + name=_text(data.get("scan_name"), "scan_name"), + timestamp=_timestamp(data.get("timestamp"), "timestamp"), + software_version=_text(data.get("software_version"), "software_version"), + firmware_version=_optional_text(data.get("firmware_version"), "firmware_version"), + frequency_hz=frequency_hz, + hardware=HardwareMetadata.from_mapping( + data.get("hardware_configuration"), "hardware_configuration" + ), + antenna_under_test=HardwareMetadata.from_mapping( + data.get("antenna_under_test"), "antenna_under_test" + ), + rf_source=HardwareMetadata.from_mapping(data.get("rf_source"), "rf_source"), + receiver=HardwareMetadata.from_mapping(data.get("receiver"), "receiver"), + transmit_power=transmit_power, + calibration_reference=calibration_reference, + environmental_notes=_string(data.get("environmental_notes"), "environmental_notes"), + data_kind=data_kind, + samples=tuple( + Sample.from_mapping(item, index) for index, item in enumerate(samples_value) + ), + warnings=_string_list(data.get("warnings"), "warnings"), + ) diff --git a/software/src/radiance3d/py.typed b/software/src/radiance3d/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/software/src/radiance3d/py.typed @@ -0,0 +1 @@ + diff --git a/software/src/radiance3d/validation.py b/software/src/radiance3d/validation.py new file mode 100644 index 0000000..34557af --- /dev/null +++ b/software/src/radiance3d/validation.py @@ -0,0 +1,31 @@ +"""Loading and error handling for Radiance3D scan data.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from radiance3d.models import Scan + + +class ScanValidationError(ValueError): + """A user-facing scan parsing or validation error.""" + + +def load_scan(path: str | Path) -> Scan: + """Load and validate the core invariants of a version 1 scan.""" + + source = Path(path) + try: + raw = json.loads(source.read_text(encoding="utf-8")) + except OSError as exc: + raise ScanValidationError(f"cannot read {source}: {exc}") from exc + except json.JSONDecodeError as exc: + raise ScanValidationError( + f"{source}:{exc.lineno}:{exc.colno}: invalid JSON: {exc.msg}" + ) from exc + + try: + return Scan.from_mapping(raw) + except ValueError as exc: + raise ScanValidationError(f"{source}: {exc}") from exc diff --git a/software/tests/test_cli.py b/software/tests/test_cli.py new file mode 100644 index 0000000..6c4aadf --- /dev/null +++ b/software/tests/test_cli.py @@ -0,0 +1,13 @@ +from pathlib import Path + +from radiance3d.cli import main + +EXAMPLE = Path(__file__).parents[2] / "data" / "examples" / "simulated" / "dipole-like-scan.json" + + +def test_validate_command(capsys: object) -> None: + assert main(["validate", str(EXAMPLE)]) == 0 + + +def test_inspect_command(capsys: object) -> None: + assert main(["inspect", str(EXAMPLE)]) == 0 diff --git a/software/tests/test_models.py b/software/tests/test_models.py new file mode 100644 index 0000000..0965f24 --- /dev/null +++ b/software/tests/test_models.py @@ -0,0 +1,38 @@ +import json +from pathlib import Path + +import pytest + +from radiance3d.models import Angle +from radiance3d.validation import ScanValidationError, load_scan + +EXAMPLE = Path(__file__).parents[2] / "data" / "examples" / "simulated" / "dipole-like-scan.json" + + +def test_simulated_example_loads() -> None: + scan = load_scan(EXAMPLE) + assert scan.data_kind == "simulated" + assert len(scan.samples) == 5 + assert scan.samples[0].measurement.unit == "dB_relative" + + +def test_angle_rejects_out_of_range_value() -> None: + with pytest.raises(ValueError, match="azimuth angle"): + Angle("azimuth", 361.0) + + +def test_invalid_json_has_context(tmp_path: Path) -> None: + invalid = tmp_path / "invalid.json" + invalid.write_text("{", encoding="utf-8") + with pytest.raises(ScanValidationError, match="invalid JSON"): + load_scan(invalid) + + +def test_invalid_firmware_metadata_is_rejected(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["firmware_version"] = 7 + invalid = tmp_path / "invalid-firmware.json" + invalid.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ScanValidationError, match="firmware_version"): + load_scan(invalid) diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..689eeff --- /dev/null +++ b/tools/README.md @@ -0,0 +1,5 @@ +# Development and data tools + +Reusable dataset validators belong in `validation/`; explicit format translators +belong in `conversion/`. Tools must preserve provenance, units, and warnings and must +never overwrite raw input by default. diff --git a/tools/conversion/README.md b/tools/conversion/README.md new file mode 100644 index 0000000..01fa5d2 --- /dev/null +++ b/tools/conversion/README.md @@ -0,0 +1,5 @@ +# Conversion tools + +No conversion tool is implemented. Future converters must document source semantics, +map units explicitly, set `provenance.data_kind` to `imported` or `processed`, and +retain source identifiers. diff --git a/tools/validation/README.md b/tools/validation/README.md new file mode 100644 index 0000000..73d9cf7 --- /dev/null +++ b/tools/validation/README.md @@ -0,0 +1,5 @@ +# Validation tools + +The initial validator is the `radiance3d validate` command in the Python package. +Cross-language or hardware-log validators may be added here when they have a real +consumer. From 28ea8430edb0de755d7ee6d31a08a226b5145fcb Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 22:25:11 -0400 Subject: [PATCH 02/13] feat(architecture): establish Version 1 engineering baseline --- .github/workflows/firmware.yml | 3 + CHANGELOG.md | 17 ++ README.md | 13 +- ROADMAP.md | 21 +- data/README.md | 4 +- data/examples/simulated/dipole-like-scan.json | 36 ++- data/schemas/scan-v1.schema.json | 153 +++++++++++- docs/architecture/data-flow.md | 13 +- docs/architecture/design-decisions.md | 4 + docs/architecture/overview.md | 3 +- docs/architecture/system-boundaries.md | 13 +- docs/architecture/version-1.md | 163 +++++++++++++ docs/experiments/measurement-procedure.md | 26 +- docs/firmware/configuration.md | 50 +++- docs/firmware/overview.md | 16 +- docs/firmware/protocol.md | 23 +- docs/glossary.md | 11 +- docs/hardware/bill-of-materials.md | 9 +- docs/hardware/motion-system.md | 64 ++++- docs/hardware/overview.md | 14 +- docs/hardware/power-system.md | 49 +++- docs/hardware/rf-measurement.md | 24 +- docs/hardware/wiring.md | 33 ++- docs/index.md | 1 + docs/software/data-pipeline.md | 25 +- docs/software/file-formats.md | 27 ++- firmware/controller/README.md | 12 +- .../controller/include/motion_controller.hpp | 122 ++++++++++ firmware/controller/include/protocol.hpp | 30 +-- firmware/controller/platformio.ini | 4 + firmware/controller/src/main.cpp | 2 +- firmware/controller/src/motion_controller.cpp | 179 ++++++++++++++ firmware/controller/src/protocol.cpp | 121 ++++++--- firmware/controller/test/README.md | 13 +- .../controller/test/test_motion/test_main.cpp | 71 ++++++ software/README.md | 14 +- software/src/radiance3d/__init__.py | 26 +- software/src/radiance3d/interfaces.py | 114 +++++++++ software/src/radiance3d/models.py | 181 +++++++++++++- software/src/radiance3d/scanning.py | 229 ++++++++++++++++++ software/tests/test_models.py | 82 +++++++ software/tests/test_scanning.py | 168 +++++++++++++ 42 files changed, 1991 insertions(+), 192 deletions(-) create mode 100644 docs/architecture/version-1.md create mode 100644 firmware/controller/include/motion_controller.hpp create mode 100644 firmware/controller/src/motion_controller.cpp create mode 100644 firmware/controller/test/test_motion/test_main.cpp create mode 100644 software/src/radiance3d/interfaces.py create mode 100644 software/src/radiance3d/scanning.py create mode 100644 software/tests/test_scanning.py diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index ecc028b..fa1f75c 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -27,3 +27,6 @@ jobs: - name: Build native simulator working-directory: firmware/controller run: pio run -e native + - name: Test motion and protocol behavior + working-directory: firmware/controller + run: pio test -e native diff --git a/CHANGELOG.md b/CHANGELOG.md index c26f115..033c324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,22 @@ to use semantic versioning once releases begin. - Initial repository architecture, documentation, schema, software package, firmware simulator foundation, and quality checks. +- Complete Version 1 engineering baseline for ESP32, dual NEMA 17 axes, driver-neutral + TMC2209 target architecture, power, wiring, RF geometry, and scope boundaries. +- Host `MotionController` and `MeasurementAdapter` protocols, bounds-safe serpentine + raster planning, and move-settle-measure coordination that preserves raw/rejected + readings. +- Firmware motion-controller abstraction with configuration-derived command + quantization, configured limits, homing, position confidence, enable, stop, and + emergency-stop simulator behavior. +- Native firmware behavior tests and software planner/coordinator tests. + +### Changed + +- Scan schema 1.1 adds protocol/hardware revisions, commanded step sizes, units, + calibration/operator context, per-reading source/validity/warnings, and sequence + numbers while retaining schema 1.0 reads. +- Stage 1 roadmap now represents the complete Version 1 architecture; physical + two-axis hardware remains Stage 2. [Unreleased]: https://github.com/bostromdev/Radiance3D diff --git a/README.md b/README.md index 5c6c686..38ddfc7 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,12 @@ See the [repository layout](docs/development/repository-layout.md) for details. ## Current project status -Stage 0 establishes terminology, architecture, schemas, contribution standards, -and automated checks. No physical scanner, receiver integration, measurement -accuracy, calibrated antenna gain, or production-ready workflow is claimed. -Example datasets may be simulated and are labeled in their metadata. +Stage 1 now defines the complete Version 1 two-axis hardware boundary, configurable +motion/position-confidence model, receiver-neutral host interfaces, raster scan +coordination, and schema 1.1 provenance contract. The controller and adapters remain +simulator/interface-only: no physical scanner, receiver integration, measurement +accuracy, calibrated antenna gain, or production-ready workflow is claimed. Example +datasets may be simulated and are labeled in their metadata. ## Getting started @@ -93,7 +95,8 @@ For the full development setup, see [docs/development/setup.md](docs/development Start at the [documentation index](docs/index.md), then review the [architecture overview](docs/architecture/overview.md), [scan file -format](docs/software/file-formats.md), and [roadmap](ROADMAP.md). +format](docs/software/file-formats.md), [Version 1 engineering +baseline](docs/architecture/version-1.md), and [roadmap](ROADMAP.md). ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index c621889..d2cffad 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,20 +8,25 @@ only when its outputs are documented and reproducible. Architecture, terminology, schemas, contribution standards, and CI. Exit when the simulated example validates and software and firmware simulator checks pass. -## Stage 1 — Motion-control prototype +## Stage 1 — Version 1 engineering architecture -Single-axis movement, repeatable positioning, command protocol, and simulator mode. -Exit requires documented test conditions and repeatability results. +Two-axis configurable motion interfaces, commanded-position confidence, homing and +safety semantics, receiver-neutral host boundaries, raster planning, synchronized +sampling, complete raw-data provenance, and simulator tests. Exit requires all +interfaces and provisional hardware assumptions to be documented without physical +performance claims. -## Stage 2 — Two-axis scanner +## Stage 2 — Physical two-axis scanner -Azimuth and elevation, homing, safety limits, and synchronized stepping. Exit -requires safe fault behavior and repeatable zeroing. +ESP32 GPIO, replaceable driver implementation, two NEMA 17 axes, switches, emergency +stop, acceleration, timeouts, and synchronized stepping. Exit requires documented +electrical safety checks, fault behavior, cable limits, and repeatable zeroing. ## Stage 3 — RF acquisition -Receiver integration, timestamped measurements, raw capture, and reference -measurements. Exit requires receiver-specific limitations and provenance. +At least one host-side receiver adapter, timestamped native measurements, raw capture, +and reference measurements. Exit requires receiver-specific limitations, retry/timeout +tests, and complete provenance. ## Stage 4 — Data processing diff --git a/data/README.md b/data/README.md index 6c4da8d..2710608 100644 --- a/data/README.md +++ b/data/README.md @@ -5,5 +5,7 @@ - `raw/` is ignored because captures can be large and may contain sensitive setup details; preserve raw experiment data in an appropriate external archive. -Stored datasets must retain provenance and warnings. Never relabel simulated or +Stored datasets must retain provenance and warnings. Raw files are immutable: +normalization, interpolation, visualization preparation, and export produce derived +artifacts that reference their source dataset IDs. Never relabel simulated or processed output as a physical measurement. diff --git a/data/examples/simulated/dipole-like-scan.json b/data/examples/simulated/dipole-like-scan.json index 3d475e4..ca8cdc5 100644 --- a/data/examples/simulated/dipole-like-scan.json +++ b/data/examples/simulated/dipole-like-scan.json @@ -1,10 +1,12 @@ { - "schema_version": "1.0.0", + "schema_version": "1.1.0", "scan_id": "simulated-dipole-like-azimuth-001", "scan_name": "Simulated dipole-like azimuth demonstration", "timestamp": "2026-07-30T18:00:00Z", "software_version": "0.1.0.dev0", "firmware_version": "simulator-0.1.0", + "protocol_version": "1", + "hardware_revision": "simulator", "hardware_configuration": { "name": "Software-only demonstration", "notes": "No physical scanner or receiver participated." @@ -24,6 +26,13 @@ "notes": "Values are generated and are not receiver readings." }, "frequency_hz": 2450000000, + "step_size_deg": { + "azimuth": 45, + "elevation": 1 + }, + "measurement_units": ["dB_relative"], + "calibration_status": "not-applicable", + "operator_notes": "Software-only schema demonstration.", "transmit_power": null, "calibration_reference": null, "environmental_notes": "Not applicable to simulated data.", @@ -39,43 +48,68 @@ }, "samples": [ { + "sequence_number": 0, "sample_timestamp": "2026-07-30T18:00:00.000Z", "azimuth_angle_deg": 0, "elevation_angle_deg": 0, "measured_value": 0, "measurement_unit": "dB_relative", + "measurement_source": "deterministic-pattern-generator", + "position_kind": "commanded", + "validity": "valid", + "warnings": [], "quality_flags": ["simulated"] }, { + "sequence_number": 1, "sample_timestamp": "2026-07-30T18:00:00.100Z", "azimuth_angle_deg": 45, "elevation_angle_deg": 0, "measured_value": -3.01, "measurement_unit": "dB_relative", + "measurement_source": "deterministic-pattern-generator", + "position_kind": "commanded", + "validity": "valid", + "warnings": [], "quality_flags": ["simulated"] }, { + "sequence_number": 2, "sample_timestamp": "2026-07-30T18:00:00.200Z", "azimuth_angle_deg": 90, "elevation_angle_deg": 0, "measured_value": -40, "measurement_unit": "dB_relative", + "measurement_source": "deterministic-pattern-generator", + "position_kind": "commanded", + "validity": "valid", + "warnings": ["Value was clamped to the demonstration floor."], "quality_flags": ["simulated", "floor-clamped"] }, { + "sequence_number": 3, "sample_timestamp": "2026-07-30T18:00:00.300Z", "azimuth_angle_deg": 135, "elevation_angle_deg": 0, "measured_value": -3.01, "measurement_unit": "dB_relative", + "measurement_source": "deterministic-pattern-generator", + "position_kind": "commanded", + "validity": "valid", + "warnings": [], "quality_flags": ["simulated"] }, { + "sequence_number": 4, "sample_timestamp": "2026-07-30T18:00:00.400Z", "azimuth_angle_deg": 180, "elevation_angle_deg": 0, "measured_value": 0, "measurement_unit": "dB_relative", + "measurement_source": "deterministic-pattern-generator", + "position_kind": "commanded", + "validity": "valid", + "warnings": [], "quality_flags": ["simulated"] } ] diff --git a/data/schemas/scan-v1.schema.json b/data/schemas/scan-v1.schema.json index 2e84b25..df3c1b5 100644 --- a/data/schemas/scan-v1.schema.json +++ b/data/schemas/scan-v1.schema.json @@ -5,6 +5,87 @@ "description": "Version 1 schema for a stored scan and its provenance.", "type": "object", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "schema_version": { + "const": "1.1.0" + } + } + }, + "then": { + "required": [ + "protocol_version", + "hardware_revision", + "step_size_deg", + "measurement_units", + "calibration_status", + "operator_notes" + ], + "properties": { + "samples": { + "items": { + "required": [ + "sequence_number", + "measurement_source", + "position_kind", + "validity", + "warnings" + ] + } + } + } + } + }, + { + "if": { + "properties": { + "calibration_status": { + "const": "calibrated" + } + }, + "required": ["calibration_status"] + }, + "then": { + "properties": { + "calibration_reference": { + "$ref": "#/$defs/namedMetadata" + } + } + } + }, + { + "if": { + "properties": { + "provenance": { + "properties": { + "data_kind": { + "const": "processed" + } + }, + "required": ["data_kind"] + } + }, + "required": ["provenance"] + }, + "then": { + "properties": { + "provenance": { + "required": ["source_dataset_ids", "method"], + "properties": { + "source_dataset_ids": { + "minItems": 1 + }, + "method": { + "minLength": 1 + } + } + } + } + } + } + ], "required": [ "schema_version", "scan_id", @@ -26,7 +107,8 @@ ], "properties": { "schema_version": { - "const": "1.0.0" + "enum": ["1.0.0", "1.1.0"], + "description": "1.0.0 remains readable; new datasets must use 1.1.0." }, "scan_id": { "type": "string", @@ -50,6 +132,15 @@ "type": ["string", "null"], "description": "Null only when no physical or simulated motion firmware participated." }, + "protocol_version": { + "type": "string", + "minLength": 1 + }, + "hardware_revision": { + "type": "string", + "minLength": 1, + "description": "Use an explicit value such as 'simulator' or 'prototype-unassigned'; do not omit uncertainty." + }, "hardware_configuration": { "$ref": "#/$defs/namedMetadata" }, @@ -66,6 +157,43 @@ "type": "number", "exclusiveMinimum": 0 }, + "step_size_deg": { + "type": "object", + "additionalProperties": false, + "required": ["azimuth", "elevation"], + "properties": { + "azimuth": { + "type": "number", + "exclusiveMinimum": 0 + }, + "elevation": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "description": "Commanded scan increment, not a claim of mechanical resolution or accuracy." + }, + "measurement_units": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "calibration_status": { + "enum": [ + "uncalibrated", + "reference-measured", + "calibrated", + "not-applicable", + "unknown" + ] + }, + "operator_notes": { + "type": "string" + }, "transmit_power": { "oneOf": [ { @@ -206,6 +334,29 @@ "minLength": 1, "description": "Receiver-native or processed unit; not restricted to RSSI." }, + "sequence_number": { + "type": "integer", + "minimum": 0 + }, + "measurement_source": { + "type": "string", + "minLength": 1 + }, + "position_kind": { + "enum": ["commanded", "observed"], + "description": "Version 1 is commanded/open-loop; observed is reserved for future feedback." + }, + "validity": { + "enum": ["valid", "invalid", "timeout"] + }, + "warnings": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "quality_flags": { "type": "array", "items": { diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index c24fda8..c98cf7f 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -10,16 +10,19 @@ sequenceDiagram participant D as Dataset writer H->>M: SCAN_STEP azimuth elevation rate - M-->>H: READY with reported position - H->>R: Request measurement - R-->>H: value, unit, timestamp, device status - H->>D: Append sample + angle + flags + M-->>H: READY with trusted commanded position + H->>H: Apply configured settling delay + loop Samples per position + retries + H->>R: Request measurement with timeout + R-->>H: native value, unit, timestamp, source, validity, warnings + H->>D: Append raw accepted or rejected reading + end D-->>H: Persisted or explicit error ``` The controller's `READY` response is a synchronization boundary in the simulator, not proof that a physical axis has settled. A physical implementation may need encoder feedback, dwell time, vibration criteria, and receiver settling. The host must record -reported rather than merely commanded angles when that information is available. +the controller's position kind; Version 1 reports commanded/open-loop position. Write failures must stop or pause a scan rather than allow unrecorded measurements. diff --git a/docs/architecture/design-decisions.md b/docs/architecture/design-decisions.md index bbc85d7..445cb65 100644 --- a/docs/architecture/design-decisions.md +++ b/docs/architecture/design-decisions.md @@ -11,6 +11,10 @@ records become worthwhile. | D-004 | Accepted | Use Python 3.11+ with no runtime dependency initially. | Typed models and useful validation do not yet require a framework. | | D-005 | Accepted | Provide a native firmware simulator first. | Protocol behavior can be tested without claiming connected hardware. | | D-006 | Provisional | Expose a line-oriented serial protocol. | It is easy to inspect during prototyping; framing may change after noise testing. | +| D-007 | Accepted | Rotate the AUT while the receiver/reference remains stationary. | Reduced receiver cable movement improves repeatability and cable strain while retaining a future slip-ring boundary. | +| D-008 | Accepted | Use degrees and the documented forward/right/up coordinate convention at public boundaries. | It keeps firmware configuration and scan files inspectable; radians remain internal to math utilities. | +| D-009 | Accepted | Treat Version 1 position as open-loop commanded position with explicit confidence. | Step counting is not independent verification; fault, reset, disable, stop, or suspected missed steps must force re-homing. | +| D-010 | Accepted | Preserve schema 1.0.0 reads while requiring schema 1.1.0 metadata for new scans. | Existing examples and external prototypes can migrate without weakening the new provenance contract. | New entries should state context, alternatives, consequences, and evidence. Changing an accepted data or protocol contract requires a versioning and migration plan. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 15ad3cd..3e94209 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -41,4 +41,5 @@ flowchart TB data kind independently of any specific receiver. Only the schema, dependency-free scan validation, and motion-protocol simulator exist -at this stage. +at this stage. The [Version 1 engineering baseline](version-1.md) defines the complete +hardware, motion, synchronization, and data architecture that these interfaces serve. diff --git a/docs/architecture/system-boundaries.md b/docs/architecture/system-boundaries.md index 37b9d96..0855cbd 100644 --- a/docs/architecture/system-boundaries.md +++ b/docs/architecture/system-boundaries.md @@ -7,11 +7,18 @@ versioned serial protocol. The controller reports its believed state and faults. future physical implementation must enforce machine-specific soft limits and independent hardware safety; protocol range checks are not safety controls. +The host-facing `MotionController` protocol is transport-neutral. The firmware-facing +`MotionController` interface owns homing, limits, position confidence, enable, stop, +and emergency-stop behavior; a TMC2209 implementation must stay behind that boundary. +Reported Version 1 position is open-loop commanded position, never independently +verified physical position. + ## Measurement boundary -A receiver adapter will return a numeric value, unit, sample timestamp, and device -metadata. RSSI is one possible value, not a required representation. Receiver setup, -settling, overload, and uncertainty remain device-specific. +A receiver adapter returns a numeric value, native unit, sample timestamp, source +identifier, validity state, and optional warnings. RSSI is one possible value, not a +required representation. Receiver setup, settling, overload, and uncertainty remain +device-specific. The motion controller never calls this interface. ## Dataset boundary diff --git a/docs/architecture/version-1.md b/docs/architecture/version-1.md new file mode 100644 index 0000000..fa1c2f6 --- /dev/null +++ b/docs/architecture/version-1.md @@ -0,0 +1,163 @@ +# Version 1 engineering baseline + +This page is the current Version 1 architecture. Values called **provisional** must +remain configurable and must not be presented as verified hardware performance. +Version 1 is an affordable, repeatable experimental platform, not laboratory or +certified antenna-test equipment. + +## System partition + +```mermaid +flowchart LR + host["Host scan coordinator"] + motion_api["MotionController interface"] + esp["ESP32 motion controller"] + axes["Azimuth and elevation axes"] + adapter["MeasurementAdapter interface"] + receiver["Stationary RF receiver / detector"] + raw[("Immutable raw dataset")] + derived[("Derived datasets")] + + host --> motion_api --> esp --> axes + host --> adapter --> receiver + host --> raw + raw -->|"normalize / interpolate / analyze"| derived +``` + +The ESP32 owns motion planning, open-loop step counting, homing, configured limits, +scan-step execution, and host communication. It does not acquire, interpret, analyze, +or visualize RF data. The host coordinates motion and an interchangeable measurement +adapter. No Version 1 interface assumes RX5808, SDR, spectrum-analyzer, power-detector, +NanoVNA-derived, or custom-detector semantics. + +USB serial is the Version 1 transport. Wi-Fi and Bluetooth are out of scope, but +transport details stay outside the public motion API so a future transport can carry +the same versioned protocol. + +## Coordinate convention + +All public configuration, protocol, scan planning, and stored positions use degrees: + +- Azimuth `0°` is forward, `90°` right, `180°` rear, and `270°` left. +- Elevation `0°` is the horizon, `+90°` straight up, and `-90°` straight down. + +Radians may appear only inside a mathematical utility. An imported dataset with a +different convention must be transformed explicitly and recorded as derived data. +Initial conceptual travel is azimuth `0°` through `360°` and elevation `-90°` through +`+90°`; actual configured limits govern every move. Azimuth is not continuous or +unlimited in Version 1. + +## Version 1 hardware assumptions + +- One ESP32 development board using 3.3 V logic. +- Two NEMA 17 bipolar steppers, provisionally assumed to be 1.8° full-step unless + configuration says otherwise. +- One replaceable stepper-driver implementation per axis. TMC2209 with UART + configuration, ESP32-controlled STEP/DIR, and firmware-controlled enable is the + Version 1 target, but no public motion behavior depends on it. +- One configurable home switch per axis and one emergency-stop input. +- One 12 V DC input, with motor drivers on 12 V and an appropriate regulated buck + converter feeding the ESP32 by its board-supported input method. + +Exact motor current, holding torque, winding resistance, pulley ratio, gear ratio, +belt pitch, supply-current rating, board pinout, and printed dimensions are not yet +selected. They must not be hardcoded or inferred from these assumptions. + +## Motion configuration and position confidence + +Each axis configuration contains motor full steps per revolution, microsteps, gear +ratio, direction inversion, home offset, minimum and maximum angle, maximum speed, +acceleration, and homing behavior. The commanded increment is calculated as: + +```text +steps per output revolution = motor full steps × microsteps × gear ratio +commanded step angle = 360° / steps per output revolution +``` + +These calculations describe command quantization only: + +- **Commanded angular resolution** is the smallest representable request. +- **Motor step resolution** follows the full-step and microstep configuration. +- **Mechanical resolution** includes transmission geometry, stiffness, and backlash. +- **Repeatability** is the observed spread when returning to a position. +- **Absolute accuracy** is error relative to a traceable angular reference. + +Microstepping alone does not demonstrate 0.1° mechanical resolution or accuracy. +Version 1 targets 0.1° commanded resolution, while repeatability is prioritized over +speed and requires measurement before any performance claim. Velocity and acceleration +are configurable; backlash compensation is a future transformation at the motion +planning boundary. + +Position is open-loop commanded position. It becomes trusted only after successful +homing. Reset, motion fault, emergency stop, driver disable, motion timeout, or +suspected missed steps invalidates confidence and requires re-homing. The public host +API can later report encoder-backed observations without changing scan coordination. + +## Homing and safety behavior + +Version 1 configuration supports normally-open or normally-closed switch logic, +debounce duration, homing direction and speed, back-off distance, and a second slow +approach. Normally-closed wiring is recommended because a broken wire can be detected, +but logic remains configurable. + +Every move is checked against software travel limits and a motion timeout. Emergency +stop is a dedicated input and invalidates position; clearing a fault never restores +position confidence. A home switch establishes a repeatable reference, not continuous +absolute position after missed steps. Firmware controls driver enable, but a supervised +prototype also needs accessible power isolation that does not depend on firmware. + +## Mechanical and RF geometry + +The AUT rotates while the RF source or measurement reference and receiver remain +stationary. This reduces receiver/feedline movement, cable strain, and position-linked +RF variation; it also keeps a future slip-ring option at the rotating fixture boundary. +It does not eliminate cable effects. Restricted azimuth travel, row reversal, planned +cable unwinding, and eventually a validated slip ring may be needed. + +Record fixed separation distance, antenna height, polarization, line of sight, +surroundings, support material, RF-source stability, coax routing, and whether +far-field distance was sufficient for the experiment. Keep conductive structure and +nearby reflections low where practical. Do not label results as true gain without an +appropriate documented reference calibration. + +## Raster scan synchronization + +Version 1 uses an elevation-major raster. Alternate azimuth rows may reverse direction +to reduce cable travel. The planner includes both configured endpoints and refuses any +point outside machine limits. + +At every point the host: + +1. moves to the commanded azimuth/elevation; +2. waits for a trusted motion-complete response; +3. waits the configured settling delay; +4. requests one or more native RF readings; +5. validates each reading and applies the configured retry policy; +6. stores every accepted and rejected raw reading with sequence and source; and +7. computes an optional mean or median only as an additional result or derived dataset. + +Settling time, samples per position, averaging method, rejected-reading treatment, +measurement timeout, and retry count are scan configuration. A simulator `READY` +acknowledgement is a synchronization boundary, not proof of physical settling. + +## Dataset and processing contract + +Each new scan uses schema `1.1.0` and records a unique ID, zoned timestamp, software, +firmware, protocol and hardware revisions, AUT and device metadata, frequency, +commanded step sizes, native units, calibration status, operator/environmental notes, +warnings, provenance, and samples. Each sample records azimuth, elevation, native +value/unit, timestamp, measurement source, commanded/observed position kind, validity, +and a contiguous sequence number. + +Raw data is immutable. Normalization, calibration, interpolation, coordinate +conversion, visualization preparation, and export produce derived artifacts that +reference source dataset IDs and name their method. Arbitrary receiver output must +not be converted to dBm without a documented calibration model. + +## Explicit Version 1 exclusions + +Version 1 does not require a custom PCB, GUI or Android application, wireless or cloud +operation, automatic gain calibration, continuous azimuth rotation, encoder feedback, +real-time 3D rendering, simultaneous multi-frequency scanning, or physical support for +any RF device. Interfaces and provenance support those later developments without +claiming them now. diff --git a/docs/experiments/measurement-procedure.md b/docs/experiments/measurement-procedure.md index d868677..9c604a7 100644 --- a/docs/experiments/measurement-procedure.md +++ b/docs/experiments/measurement-procedure.md @@ -1,12 +1,22 @@ # Measurement procedure -No physical procedure is validated. A future scan record should document: +No physical procedure is validated. A Version 1 experiment record should document: 1. legal and safety review; -2. equipment revisions, warm-up, and configuration; -3. AUT orientation and coordinate reference; -4. geometry, surroundings, feedline routing, and frequency; -5. zeroing, limits, angular sequence, dwell, and receiver settings; -6. reference measurements and environmental observations; -7. raw capture with timestamps, units, warnings, and provenance; and -8. shutdown plus a check for invalid or missing samples. +2. exact equipment, firmware, software, protocol, and hardware revisions; +3. motor current, warm-up, airflow, power, motion, and receiver configuration; +4. AUT identity and forward/right/up coordinate orientation; +5. fixed separation, repeatable height, polarization, line of sight, nearby + reflections, non-conductive supports, far-field assessment, and feedline routing; +6. RF source frequency/power stability and legal operating conditions; +7. homing result, configured limits, angular sequence, acceleration, velocity, + settling time, samples/position, averaging, timeout, retry, and receiver settings; +8. reference measurements, calibration status, operator/environmental notes; +9. raw accepted and rejected capture with position kind, sequence, timestamp, source, + native unit, validity, warnings, and provenance; and +10. shutdown plus checks for invalid/missing samples, cable winding, driver + temperature, suspected missed steps, and any loss of position confidence. + +Do not claim true gain unless an appropriate reference method and uncertainty budget +support it. A calibrated receiver alone does not calibrate chamber geometry, +reflections, feedline radiation, polarization, or angular position. diff --git a/docs/firmware/configuration.md b/docs/firmware/configuration.md index c703a89..4d0955b 100644 --- a/docs/firmware/configuration.md +++ b/docs/firmware/configuration.md @@ -1,14 +1,40 @@ # Firmware configuration -Configuration must eventually separate: - -- controller and board definition; -- azimuth and elevation driver type and pins; -- steps, gearing, and microsteps per degree; -- direction, speed, acceleration, and machine limits; -- homing direction, switch polarity, and timeouts; -- communication rate and protocol version; and -- simulator versus physical mode. - -No default pinout or motor calibration is supplied because none is verified. Firmware -build constants are not substitutes for wiring records or mechanical limit testing. +Version 1 separates board/pin configuration from driver-neutral motion behavior. The +implemented simulator uses typed controller configuration; a physical target must +populate the same fields from a versioned configuration record. + +## Axis fields + +Each azimuth and elevation axis defines: + +- `motor_full_steps_per_revolution` (provisionally 200 for a typical 1.8° motor); +- `microsteps`; +- configurable `motor_rms_current_ma` (`0` means deliberately unset in the simulator; + a physical driver must refuse operation until a safe value is selected); +- `gear_ratio`; +- calculated `steps_per_output_revolution`; +- direction inversion; +- home offset in degrees; +- minimum and maximum angle in degrees; +- maximum speed in degrees per second; +- acceleration in degrees per second squared; and +- switch normally-closed state, debounce milliseconds, homing direction, homing + speed, back-off degrees, and second slow-approach speed. + +The conversion is `full steps × microsteps × gear ratio`. It describes commands, not +measured mechanics. Backlash compensation may later use this configuration boundary, +but Version 1 does not implement it. + +## Controller fields + +Controller configuration contains motion timeout, emergency-stop active polarity, +protocol version, USB serial rate, board definition, physical pin mapping, and +simulator/physical mode. A TMC2209-specific physical configuration will additionally +record UART address/connection, sense-resistor/module revision, and safely selected RMS +motor current. Current remains unset until the exact motors and thermal design exist. + +No default pinout or physical motor calibration is supplied because none is verified. +ESP32 signals are 3.3 V and no attached module is assumed 5 V tolerant. Firmware build +constants are not substitutes for a wiring record, thermal test, or mechanical-limit +test. diff --git a/docs/firmware/overview.md b/docs/firmware/overview.md index 55b7afd..a4f14c6 100644 --- a/docs/firmware/overview.md +++ b/docs/firmware/overview.md @@ -1,9 +1,13 @@ # Firmware overview -The planned ESP32 controller owns two motion axes and exposes a host protocol. Axis -drivers should be replaceable behind interfaces for move, home/zero, stop, position, -limit state, and fault state. +The planned ESP32 controller owns two motion axes and exposes a host protocol. The +implemented firmware `MotionController` interface covers configured absolute moves, +homing, stop, emergency stop, driver enable, open-loop commanded position, confidence, +limits, and faults. `SimulatedMotionController` is the first implementation. TMC2209 +or another physical driver must stay behind the same interface. -The current implementation is an in-memory simulator. It does not access GPIO, -drivers, encoders, or limit switches. `esp32dev` is a provisional compilation target, -not a statement of supported hardware. +The simulator calculates command quantization from axis configuration, applies +configured azimuth/elevation limits, requires homing, and invalidates confidence after +stop, emergency stop, or disable. It does not access GPIO, drive motors, read switches, +apply real acceleration, or detect missed steps. `esp32dev` is a provisional +compilation target, not a statement of supported hardware or pinout. diff --git a/docs/firmware/protocol.md b/docs/firmware/protocol.md index 34a02b8..52877c5 100644 --- a/docs/firmware/protocol.md +++ b/docs/firmware/protocol.md @@ -15,14 +15,25 @@ rates are degrees per second, and future time fields will use integer millisecon | `MOVE` | `AZ_DEG EL_DEG DEG_PER_S` | `OK MOVE …` | Move to an absolute position. | | `SCAN_STEP` | `AZ_DEG EL_DEG DEG_PER_S` | `OK SCAN_STEP … READY=1` | Move and signal a measurement boundary. | | `STOP` | none | `OK STOP` | Latch the controller in a stopped state. | -| `CLEAR_FAULT` | none | `OK CLEAR_FAULT` | Clear the simulator fault/stop latch. | +| `E_STOP` | none | `OK E_STOP` | Simulate activation of the dedicated emergency-stop input. | +| `ENABLE` | `0` or `1` | `OK ENABLE VALUE=…` | Disable or enable both driver outputs. | +| `CLEAR_FAULT` | none | `OK CLEAR_FAULT` | Clear a releasable fault/stop latch; never restore position confidence. | Errors use `ERR CODE detail`. Defined simulator codes are `INVALID_COMMAND`, -`INVALID_ARGUMENT`, `NOT_HOMED`, `LIMIT_REACHED`, and `STOPPED`. +`INVALID_ARGUMENT`, `INVALID_CONFIGURATION`, `NOT_HOMED`, `POSITION_UNTRUSTED`, +`LIMIT_REACHED`, `MOTION_TIMEOUT`, `DRIVER_DISABLED`, `EMERGENCY_STOP`, and +`STOPPED`. + +`STATUS` labels `AZ_DEG` and `EL_DEG` with `POSITION_KIND=COMMANDED` and reports +per-axis homed/trusted state, driver enable, stop, emergency stop, and fault. Startup +is untrusted. Reset, stop, emergency stop, driver disable, timeout, or suspected missed +steps requires a new home operation; `CLEAR_FAULT` alone is insufficient. ## Important limitations -`READY=1` means the simulator updated its state. It does not establish physical -settling. A physical implementation must distinguish commanded and observed position, -report active limits, define command IDs or acknowledgements if needed, and preserve -stop behavior across communication loss where safety analysis requires it. +`READY=1 POSITION_KIND=COMMANDED` means the simulator completed its immediate state +update. It does not establish physical settling or verify position. The host still +applies its configured settling delay. A physical implementation must read/debounce +home inputs, apply acceleration and timeout, report active limits, define command IDs +or acknowledgements if needed, and preserve safe stop behavior across communication +loss. Emergency-stop release is a physical input condition, not a software clear. diff --git a/docs/glossary.md b/docs/glossary.md index c11a6bd..f245077 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,8 +1,8 @@ # Glossary - **AUT:** Antenna under test. -- **Azimuth:** Horizontal rotation angle, expressed in degrees. -- **Elevation:** Vertical rotation angle, expressed in degrees. +- **Azimuth:** Horizontal angle in degrees: 0° forward, 90° right, 180° rear, 270° left. +- **Elevation:** Vertical angle in degrees: 0° horizon, +90° up, -90° down. - **Sample:** One synchronized angle and RF measurement. - **Scan:** A collection of samples made under one configuration. - **Dataset:** Stored scan data plus metadata, warnings, and provenance. @@ -13,3 +13,10 @@ underlying meaning. - **Processed data:** Derived data whose provenance identifies its source datasets and method. +- **Commanded position:** Position calculated from open-loop step commands; not an + independent physical observation. +- **Position confidence:** Controller state indicating whether commanded position is + usable after a successful home and has not been invalidated by reset, fault, stop, + emergency stop, disable, timeout, or suspected missed steps. +- **Commanded angular resolution:** Smallest configurable motion increment; not the + same as mechanical resolution, repeatability, or absolute accuracy. diff --git a/docs/hardware/bill-of-materials.md b/docs/hardware/bill-of-materials.md index 160f769..e20cf7b 100644 --- a/docs/hardware/bill-of-materials.md +++ b/docs/hardware/bill-of-materials.md @@ -1,8 +1,11 @@ # Bill of materials -There is no tested BOM. Candidate categories include an ESP32-class controller, -two stepper drivers, two appropriately sized motors, position/limit devices, power -conversion and protection, structure, bearings, fasteners, cabling, and RF equipment. +There is no tested BOM. Version 1 categories include one ESP32 development board, two +TMC2209 target modules, two NEMA 17 bipolar motors, two home switches, one emergency +stop, one 12 V input supply, buck conversion, overcurrent protection, bulk/local +decoupling, driver cooling, structure, bearings, fasteners, strain-relieved cabling, +and independently connected RF equipment. Exact part numbers and supply-current +rating await motor, current, thermal, and mechanical testing. A publishable BOM must contain tested part numbers, revision, quantity, function, acceptable substitutions, source date, and validation status. Price and availability diff --git a/docs/hardware/motion-system.md b/docs/hardware/motion-system.md index 92286b9..0fc6401 100644 --- a/docs/hardware/motion-system.md +++ b/docs/hardware/motion-system.md @@ -1,10 +1,58 @@ # Motion system -The conceptual arrangement uses a base azimuth axis and a supported elevation axis. -Stepper drivers such as TMC2209 and NEMA 17-class motors are candidates only. -Selection depends on inertia, gear ratio, holding torque, current, thermal behavior, -backlash, resolution, and RF emissions. - -Each axis needs a repeatable zero strategy, conservative soft limits, and preferably -independent limit inputs. Emergency power isolation must not depend solely on working -firmware or a host connection. +Version 1 uses a base azimuth (pan) axis and a supported elevation (tilt) axis. The +AUT rotates; the measurement receiver/reference remains stationary. This arrangement +reduces receiver cable movement, cable strain, and position-dependent RF changes. +Cable routing still limits travel unless a future slip ring is tested and documented. + +## Motors and drivers + +Each axis uses one NEMA 17 bipolar stepper, provisionally assumed to be 1.8° full-step +only when configuration does not override it. Exact current rating, holding torque, +winding resistance, and required gearbox are unresolved. + +TMC2209 is the Version 1 target driver. Prefer UART configuration so RMS motor current, +microstepping, and diagnostic state are reproducible. STEP, DIR, and enable are driven +by the ESP32. Current is never hardcoded before motor selection; it must be configured +from motor ratings, the module's sense-resistor implementation, load testing, and +thermal limits. Drivers need heatsinking/airflow appropriate to measured dissipation. + +The firmware `MotionController` interface contains no TMC2209 type. A later physical +implementation may use that device or another stepper driver without changing the +serial or host scan API. Generic axis configuration reserves motor RMS current with +an unset simulator value rather than inventing a motor-specific default. + +## Configuration-derived movement + +Each axis records: + +- motor full steps per revolution and microsteps; +- gear ratio and calculated steps per output revolution; +- direction inversion and home offset; +- minimum and maximum angle; +- maximum speed and acceleration; and +- home-switch polarity, debounce, direction, speed, back-off, and slow approach. + +Gear or pulley dimensions are not assumed. The initial conceptual limits are azimuth +`0°` to `360°` and elevation `-90°` to `+90°`; real cable and structure clearances may +require smaller limits. The planner must reject every out-of-range point. It may +reverse alternate raster rows to reduce cable winding. Continuous/unlimited azimuth +rotation is not supported. + +## Resolution and confidence + +The project targets 0.1° commanded resolution and prioritizes repeatability over +speed. Motor/microstep quantization, mechanical resolution, repeatability, and absolute +accuracy are different quantities. Belt compliance, shaft play, backlash, frame +deflection, motor torque margin, and microstep nonlinearity must be measured. No +0.1° physical-accuracy claim follows from selecting a microstep value. + +Version 1 position is open-loop step counting. A successful two-stage home establishes +trust. Reset, fault, emergency stop, driver disable, timeout, or suspected missed +steps invalidates it, and motion used for scanning requires re-homing. A home switch +does not observe position throughout a move. Future encoders can supply observed +position behind the same public API. + +Normally-closed switches are recommended for broken-wire fault detection, while +polarity remains configurable. Emergency power isolation must not depend solely on +working firmware or a host connection. diff --git a/docs/hardware/overview.md b/docs/hardware/overview.md index 7690911..75533c1 100644 --- a/docs/hardware/overview.md +++ b/docs/hardware/overview.md @@ -1,8 +1,12 @@ # Hardware overview -The provisional system consists of a pan-tilt mechanism, an ESP32-class controller, -two stepper drivers, motors such as NEMA 17 units where load testing supports them, -power conversion, limits, and an independently connected RF measurement device. +The Version 1 baseline consists of an ESP32 development board, two TMC2209 target +drivers behind a replaceable motion interface, two NEMA 17 bipolar steppers, one home +switch per axis, an emergency-stop input, a pan-tilt AUT fixture, one protected 12 V +input, and regulated ESP32 power. The RF measurement device is independently connected +to the host and remains stationary with the RF source/reference. -Component families are examples, not tested recommendations. Electrical, mechanical, -thermal, and RF validation must precede a supported configuration. +The architecture is fixed; exact board, modules, motors, currents, mechanics, pinout, +power rating, and RF device remain provisional. Component families are not tested +recommendations. Electrical, mechanical, thermal, and RF validation must precede a +supported configuration. See the [Version 1 baseline](../architecture/version-1.md). diff --git a/docs/hardware/power-system.md b/docs/hardware/power-system.md index a3c96b6..12861ac 100644 --- a/docs/hardware/power-system.md +++ b/docs/hardware/power-system.md @@ -1,9 +1,46 @@ # Power system -The provisional controller power design must separate motor-current paths from logic -and measurement paths, use appropriately rated conversion and protection, and document -grounding. Driver current limits must be set from verified motor and thermal data. +Version 1 uses one 12 V DC input with this distribution: -Future designs need fusing, polarity protection, wire and connector ratings, accessible -power isolation, and conducted/radiated noise testing. No supply voltage or current -rating is specified yet. +```text +12 V power supply +├── fuse / overcurrent protection +├── TMC2209 azimuth motor supply +├── TMC2209 elevation motor supply +└── regulated buck converter + └── ESP32 board-supported supply input +``` + +Motor drivers remain on 12 V. Raw 12 V must never reach an ESP32 power or logic pin. +The buck converter must provide the voltage, current, ripple, and transient behavior +appropriate to the selected development board's documented input method. Do not +assume every board should be fed through a nominal 5 V pin. + +The 12 V source, both drivers, buck converter, and ESP32 signal reference require a +common ground. Motor-current return paths should not share long, high-impedance runs +with logic or measurement returns. Place suitable bulk capacitance close to each +driver's motor-supply input and local decoupling close to logic electronics, following +the selected module and IC recommendations. + +## Protection and commissioning + +- Select fuse/overcurrent protection below the safe rating of the wiring, connectors, + and weakest protected component. +- Add accessible power isolation for unexpected motion. +- Treat reverse-polarity protection as a recommended improvement before a public + hardware revision. +- Verify connector polarity and continuity before energizing. +- Configure TMC2209 RMS motor current from the exact motor and module data; do not + copy a nominal value from an unrelated build. +- Provide heatsinking and airflow based on driver temperature under the actual load. +- Separate motor/power wiring from STEP/DIR, switch inputs, receiver wiring, and RF + feedlines where practical. + +USB can energize an ESP32 while the external rail is present. The selected board and +buck topology must be reviewed for USB/external-power backfeeding before both are +connected. A jumper, ideal-diode/power-mux arrangement, or use of the board's protected +input may be required; there is no universal safe 5 V-pin rule. + +The final power-supply current rating remains provisional until both motor ratings, +driver RMS-current settings, acceleration/load profile, ESP32 board, and future logic +loads are known. Include startup and stall margin without exceeding component ratings. diff --git a/docs/hardware/rf-measurement.md b/docs/hardware/rf-measurement.md index defdbf0..4a3e9a8 100644 --- a/docs/hardware/rf-measurement.md +++ b/docs/hardware/rf-measurement.md @@ -1,11 +1,25 @@ # RF measurement architecture -The receiver or detector remains stationary where practical and connects to the host -through a device adapter. The RF source and AUT arrangement must be chosen for the -measurement method and local legal requirements. +The antenna under test rotates while the RF source or measurement reference and +receiver remain stationary. The receiver connects to the host through a +device-neutral `MeasurementAdapter`; it is logically and electrically separate from +the ESP32 motion controller. Version 1 defines this interface but implements no +RX5808, SDR, spectrum-analyzer, power-detector, NanoVNA-derived, or custom-detector +support. + +Each adapter returns a numeric native value, unit, timezone-aware timestamp, stable +source identifier, validity state, and optional warnings. Conceptual units include +dBm, relative dB, volts, ADC counts, RSSI units, and arbitrary units. Arbitrary +receiver output must not be relabeled dBm without a documented calibration model. + +The RF source and AUT arrangement must be chosen for the measurement method and local +legal requirements. Record fixed separation, repeatable antenna height, polarization, +clear line of sight, support material, source-power stability, consistent coax routing, +and whether sufficient far-field distance was practical. Feedline movement, common-mode current, receiver overload, reflections, nearby conductive structure, motor wiring, and controller emissions can distort patterns. Cable routing and non-conductive structural materials should reduce disturbance where -testing shows benefit. No receiver, dynamic range, accuracy, or supported frequency -range is currently validated. +testing shows benefit. Reduce nearby reflective surfaces and preserve environment +notes. No receiver, dynamic range, accuracy, supported frequency range, or true-gain +measurement is currently validated. diff --git a/docs/hardware/wiring.md b/docs/hardware/wiring.md index b23f87f..dd36641 100644 --- a/docs/hardware/wiring.md +++ b/docs/hardware/wiring.md @@ -1,9 +1,30 @@ # Wiring -Verified wiring diagrams do not exist. A future diagram must identify connector pins, -signal reference, voltage domain, wire gauge, shielding, grounding point, limit switch -behavior, and emergency isolation. +Verified pin assignments do not exist. The Version 1 wiring record must identify the +exact ESP32 board and every connector pin, signal reference, voltage domain, wire +rating, shielding, grounding point, switch behavior, and emergency isolation before a +physical build is called supported. -Route motor and power wiring away from receiver inputs and RF feedlines. Provide -strain relief throughout the full motion envelope. Disconnect power before changing -wiring and verify continuity and polarity before energizing. +## Required electrical boundaries + +- ESP32 logic is 3.3 V. Do not assume a driver module, switch module, display, or + future sensor is 5 V tolerant. +- Each driver receives 12 V motor power, common ground, 3.3 V-compatible STEP/DIR, + firmware-controlled enable, and a reviewed UART connection. +- Each axis has one independently configured home input. Normally-closed wiring is + recommended, with pull-up/pull-down choice and debounce documented. +- Emergency stop has a dedicated input and accessible power-isolation behavior. A + firmware input alone is not the only protection against unexpected motion. +- Bulk motor-supply capacitance and local logic decoupling must appear on the wiring + record with values and voltage ratings selected from component guidance. + +Route motor and power wiring away from STEP/DIR, switch inputs, receiver leads, and RF +feedlines. Cross sensitive and noisy runs at right angles where separation is limited. +Provide strain relief and verify clearance through the entire configured azimuth and +elevation envelope. Consistent coax routing is part of the RF experiment, not an +afterthought. + +Disconnect external and USB power before changing wiring. Because USB and a buck +converter may energize the board simultaneously, explicitly document the chosen +backfeed prevention or supported dual-power behavior. Verify continuity, polarity, +grounding, and absence of shorts before energizing motor power. diff --git a/docs/index.md b/docs/index.md index 5ef80a9..296ab2f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ interfaces unless a page explicitly marks behavior as implemented and tested. ## Start here - [Architecture overview](architecture/overview.md) +- [Version 1 engineering baseline](architecture/version-1.md) - [System boundaries](architecture/system-boundaries.md) - [Data flow](architecture/data-flow.md) - [Scan file format](software/file-formats.md) diff --git a/docs/software/data-pipeline.md b/docs/software/data-pipeline.md index fa563eb..5ee35b7 100644 --- a/docs/software/data-pipeline.md +++ b/docs/software/data-pipeline.md @@ -2,13 +2,24 @@ The planned pipeline preserves rather than overwrites raw observations: -1. Acquire receiver-native values with units and timestamps. -2. Pair them with reported angles and quality flags. -3. Store a `measured` raw dataset with full configuration. -4. Apply calibration or normalization as a named transformation. -5. Store a new `processed` dataset referencing its source IDs. -6. Interpolate or convert coordinates only in explicit later stages. -7. Render the selected dataset and expose processing status. +1. Move to a bounds-checked raster point and wait for trusted motion completion. +2. Apply the configured settling time. +3. Acquire receiver-native values with units, timestamps, source, validity, warnings, + timeout, and retry policy. +4. Pair every accepted or rejected raw reading with commanded-position kind, + sequence number, and quality flags. +5. Store a `measured` raw dataset with full configuration before processing. +6. If requested, average only as an additional result or named derived dataset; raw + individual readings remain available. +7. Apply calibration or normalization as a named transformation. +8. Store a new `processed` dataset referencing its source IDs. +9. Interpolate, convert coordinates, prepare visualization, or export only in explicit + later stages. Algorithms must handle missing, irregular, and repeated angles. They must not assume all receiver values are logarithmic, RSSI, power, or directly comparable. + +The dependency-free host package now provides `MotionController` and +`MeasurementAdapter` protocols, a limits-aware raster planner, and a scan coordinator +for move/settle/measure behavior. No serial transport or physical receiver adapter is +implemented yet. diff --git a/docs/software/file-formats.md b/docs/software/file-formats.md index ce3812a..144757f 100644 --- a/docs/software/file-formats.md +++ b/docs/software/file-formats.md @@ -5,22 +5,35 @@ The canonical version 1 format is JSON validated by [simulated example](../../data/examples/simulated/dipole-like-scan.json) is clearly labeled and contains no physical measurement. +Schema `1.0.0` remains readable for migration. New datasets use `1.1.0`, which adds +the complete Version 1 synchronization and provenance fields. + ## Required record groups -- Identity: schema version, scan ID/name, scan timestamp, software, and firmware. +- Identity: schema version, scan ID/name, scan timestamp, software, firmware, + protocol version, and hardware revision. - Configuration: hardware, AUT, RF source, receiver, frequency, and transmit power. -- Experiment context: calibration reference, environmental notes, and warnings. +- Scan configuration: commanded azimuth/elevation step sizes and native measurement + units. +- Experiment context: calibration status/reference, operator notes, environmental + notes, and warnings. - Provenance: `measured`, `simulated`, `imported`, or `processed`, plus creator/method. -- Samples: timestamp, azimuth degrees, elevation degrees, value, unit, and flags. +- Samples: contiguous sequence number, timestamp, azimuth/elevation degrees, native + value/unit, measurement source, commanded/observed position kind, validity, warnings, + and quality flags. `transmit_power` and `calibration_reference` are explicitly nullable so unknown is not confused with zero or an empty object. Metadata objects allow additional fields to accommodate device-specific information. Sample fields are strict so a typo cannot silently create a second representation. +`step_size_deg` is the commanded raster increment, not demonstrated physical +resolution or accuracy. `measurement_units` must exactly match units found in samples. +Calibration status is explicit and does not imply true gain. + ## Versioning -`schema_version` uses semantic versioning. Additive optional metadata is a minor -change; removing fields or changing meaning requires a new major schema file. -Readers must reject unsupported major versions. A CSV export may be added later, but -it must retain or accompany all dataset-level metadata. +`schema_version` uses semantic versioning. Schema 1.1 makes new fields mandatory only +for 1.1 records, while readers retain 1.0 support. Removing fields or changing meaning +requires a new major schema file. Readers reject unsupported versions. A CSV export +may be added later, but it must retain or accompany all dataset-level metadata. diff --git a/firmware/controller/README.md b/firmware/controller/README.md index a9aae62..529d600 100644 --- a/firmware/controller/README.md +++ b/firmware/controller/README.md @@ -1,14 +1,16 @@ # Motion controller foundation -The controller currently implements an in-memory simulator for protocol and host -integration work. It compiles as a native command-line program and as a provisional -ESP32 Arduino target. Neither build drives pins. +The controller currently implements an in-memory `MotionController` for protocol and +host integration work. It compiles as a native command-line program and as a +provisional ESP32 Arduino target. Neither build drives pins or implements a TMC2209. ```bash pio run -e native +pio test -e native ``` The native process reads one command per line from standard input and writes one response per line. See [the protocol specification](../../docs/firmware/protocol.md). -Board selection, electrical limits, motor drivers, pins, and emergency-stop behavior -remain provisional. +The simulator enforces configuration-derived limits and position-confidence rules. +Board selection, electrical limits, motor drivers, pins, physical homing, and physical +emergency-stop behavior remain provisional. diff --git a/firmware/controller/include/motion_controller.hpp b/firmware/controller/include/motion_controller.hpp new file mode 100644 index 0000000..e912156 --- /dev/null +++ b/firmware/controller/include/motion_controller.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include + +namespace radiance3d { + +enum class AxisSelection { azimuth, elevation, both }; + +enum class FaultCode { + none, + invalid_command, + invalid_argument, + invalid_configuration, + not_homed, + position_untrusted, + limit_reached, + motion_timeout, + driver_disabled, + emergency_stop, + stopped, +}; + +struct HomingConfig { + bool switch_normally_closed{true}; + bool direction_negative{true}; + std::uint32_t debounce_ms{10}; + double speed_deg_per_s{5.0}; + double backoff_deg{2.0}; + double slow_approach_deg_per_s{1.0}; +}; + +struct AxisConfig { + std::uint16_t motor_full_steps_per_revolution{200}; + std::uint16_t microsteps{16}; + std::uint16_t motor_rms_current_ma{0}; + double gear_ratio{1.0}; + bool direction_inverted{false}; + double home_offset_deg{0.0}; + double minimum_angle_deg{0.0}; + double maximum_angle_deg{360.0}; + double maximum_speed_deg_per_s{20.0}; + double acceleration_deg_per_s2{40.0}; + HomingConfig homing{}; + + double steps_per_output_revolution() const; + double commanded_step_angle_deg() const; + bool valid() const; +}; + +struct ControllerConfig { + AxisConfig azimuth{}; + AxisConfig elevation{}; + std::uint32_t motion_timeout_ms{120000}; + bool emergency_stop_active_low{true}; + + bool valid() const; +}; + +struct AxisState { + double commanded_position_deg{0.0}; + bool homed{false}; + bool position_trusted{false}; + bool enabled{true}; + bool home_switch_active{false}; +}; + +struct ControllerState { + AxisState azimuth{}; + AxisState elevation{}; + FaultCode fault{FaultCode::none}; + bool stopped{false}; + bool emergency_stop_active{false}; +}; + +struct MotionResult { + bool ok{false}; + FaultCode fault{FaultCode::none}; +}; + +class MotionController { + public: + virtual ~MotionController() = default; + + virtual const ControllerConfig& config() const = 0; + virtual const ControllerState& state() const = 0; + virtual MotionResult home(AxisSelection axis) = 0; + virtual MotionResult move_absolute(double azimuth_deg, double elevation_deg, + double speed_deg_per_s) = 0; + virtual MotionResult stop() = 0; + virtual MotionResult emergency_stop() = 0; + virtual MotionResult clear_fault() = 0; + virtual MotionResult set_enabled(bool enabled) = 0; + virtual void report_fault(FaultCode code) = 0; +}; + +class SimulatedMotionController final : public MotionController { + public: + explicit SimulatedMotionController(ControllerConfig config = {}); + + const ControllerConfig& config() const override; + const ControllerState& state() const override; + MotionResult home(AxisSelection axis) override; + MotionResult move_absolute(double azimuth_deg, double elevation_deg, + double speed_deg_per_s) override; + MotionResult stop() override; + MotionResult emergency_stop() override; + MotionResult clear_fault() override; + MotionResult set_enabled(bool enabled) override; + void report_fault(FaultCode code) override; + + private: + ControllerConfig config_{}; + ControllerState state_{}; + + MotionResult fail(FaultCode code); + MotionResult succeed(); + static void invalidate(AxisState& axis); +}; + +ControllerConfig provisional_simulator_config(); + +} // namespace radiance3d diff --git a/firmware/controller/include/protocol.hpp b/firmware/controller/include/protocol.hpp index 40d2012..6fe9c09 100644 --- a/firmware/controller/include/protocol.hpp +++ b/firmware/controller/include/protocol.hpp @@ -1,38 +1,22 @@ #pragma once +#include "motion_controller.hpp" + #include namespace radiance3d { -enum class FaultCode { - none, - invalid_command, - invalid_argument, - not_homed, - limit_reached, - stopped, -}; - -struct AxisState { - double position_deg{0.0}; - bool homed{false}; - bool limit_active{false}; -}; - -struct ControllerState { - AxisState azimuth{}; - AxisState elevation{}; - FaultCode fault{FaultCode::none}; - bool stopped{false}; -}; - class ProtocolEngine { public: + ProtocolEngine(); + explicit ProtocolEngine(MotionController& controller); + std::string handle(const std::string& line); const ControllerState& state() const; private: - ControllerState state_{}; + SimulatedMotionController default_controller_; + MotionController* controller_; std::string status() const; std::string fault(FaultCode code, const std::string& detail); diff --git a/firmware/controller/platformio.ini b/firmware/controller/platformio.ini index a67918b..38521dc 100644 --- a/firmware/controller/platformio.ini +++ b/firmware/controller/platformio.ini @@ -9,6 +9,10 @@ build_src_filter = +<*.cpp> [env:native] platform = native test_framework = unity +test_build_src = yes +build_flags = + ${env.build_flags} + -std=c++14 [env:esp32dev] platform = espressif32 diff --git a/firmware/controller/src/main.cpp b/firmware/controller/src/main.cpp index cf59276..cd9b1e6 100644 --- a/firmware/controller/src/main.cpp +++ b/firmware/controller/src/main.cpp @@ -19,7 +19,7 @@ void loop() { } } } -#else +#elif !defined(UNIT_TEST) #include #include diff --git a/firmware/controller/src/motion_controller.cpp b/firmware/controller/src/motion_controller.cpp new file mode 100644 index 0000000..2082e09 --- /dev/null +++ b/firmware/controller/src/motion_controller.cpp @@ -0,0 +1,179 @@ +#include "motion_controller.hpp" + +#include +#include + +namespace radiance3d { +namespace { + +bool finite_positive(const double value) { return std::isfinite(value) && value > 0.0; } + +bool angle_in_range(const double value, const AxisConfig& config) { + return std::isfinite(value) && value >= config.minimum_angle_deg && + value <= config.maximum_angle_deg; +} + +} // namespace + +double AxisConfig::steps_per_output_revolution() const { + return static_cast(motor_full_steps_per_revolution) * + static_cast(microsteps) * gear_ratio; +} + +double AxisConfig::commanded_step_angle_deg() const { + const double steps = steps_per_output_revolution(); + return steps > 0.0 ? 360.0 / steps : 0.0; +} + +bool AxisConfig::valid() const { + return motor_full_steps_per_revolution > 0 && microsteps > 0 && + finite_positive(gear_ratio) && std::isfinite(home_offset_deg) && + std::isfinite(minimum_angle_deg) && std::isfinite(maximum_angle_deg) && + minimum_angle_deg < maximum_angle_deg && + home_offset_deg >= minimum_angle_deg && home_offset_deg <= maximum_angle_deg && + finite_positive(maximum_speed_deg_per_s) && + finite_positive(acceleration_deg_per_s2) && homing.debounce_ms > 0 && + finite_positive(homing.speed_deg_per_s) && finite_positive(homing.backoff_deg) && + finite_positive(homing.slow_approach_deg_per_s); +} + +bool ControllerConfig::valid() const { + return azimuth.valid() && elevation.valid() && motion_timeout_ms > 0; +} + +ControllerConfig provisional_simulator_config() { + ControllerConfig config; + config.azimuth.minimum_angle_deg = 0.0; + config.azimuth.maximum_angle_deg = 360.0; + config.azimuth.home_offset_deg = 0.0; + config.elevation.minimum_angle_deg = -90.0; + config.elevation.maximum_angle_deg = 90.0; + config.elevation.home_offset_deg = 0.0; + return config; +} + +SimulatedMotionController::SimulatedMotionController(ControllerConfig config) + : config_(std::move(config)) { + if (!config_.valid()) { + state_.fault = FaultCode::invalid_configuration; + } +} + +const ControllerConfig& SimulatedMotionController::config() const { return config_; } + +const ControllerState& SimulatedMotionController::state() const { return state_; } + +MotionResult SimulatedMotionController::fail(const FaultCode code) { + state_.fault = code; + return MotionResult{false, code}; +} + +MotionResult SimulatedMotionController::succeed() { + state_.fault = FaultCode::none; + return MotionResult{true, FaultCode::none}; +} + +void SimulatedMotionController::invalidate(AxisState& axis) { + axis.position_trusted = false; + axis.homed = false; +} + +MotionResult SimulatedMotionController::home(const AxisSelection axis) { + if (!config_.valid()) { + return fail(FaultCode::invalid_configuration); + } + if (state_.emergency_stop_active) { + return fail(FaultCode::emergency_stop); + } + if (state_.stopped) { + return fail(FaultCode::stopped); + } + if (!state_.azimuth.enabled || !state_.elevation.enabled) { + return fail(FaultCode::driver_disabled); + } + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.commanded_position_deg = config_.azimuth.home_offset_deg; + state_.azimuth.homed = true; + state_.azimuth.position_trusted = true; + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.commanded_position_deg = config_.elevation.home_offset_deg; + state_.elevation.homed = true; + state_.elevation.position_trusted = true; + } + return succeed(); +} + +MotionResult SimulatedMotionController::move_absolute(const double azimuth_deg, + const double elevation_deg, + const double speed_deg_per_s) { + if (!config_.valid()) { + return fail(FaultCode::invalid_configuration); + } + if (state_.emergency_stop_active) { + return fail(FaultCode::emergency_stop); + } + if (state_.stopped) { + return fail(FaultCode::stopped); + } + if (!state_.azimuth.enabled || !state_.elevation.enabled) { + return fail(FaultCode::driver_disabled); + } + if (!state_.azimuth.homed || !state_.elevation.homed) { + return fail(FaultCode::not_homed); + } + if (!state_.azimuth.position_trusted || !state_.elevation.position_trusted) { + return fail(FaultCode::position_untrusted); + } + if (!finite_positive(speed_deg_per_s) || + speed_deg_per_s > config_.azimuth.maximum_speed_deg_per_s || + speed_deg_per_s > config_.elevation.maximum_speed_deg_per_s) { + return fail(FaultCode::invalid_argument); + } + if (!angle_in_range(azimuth_deg, config_.azimuth) || + !angle_in_range(elevation_deg, config_.elevation)) { + return fail(FaultCode::limit_reached); + } + + state_.azimuth.commanded_position_deg = azimuth_deg; + state_.elevation.commanded_position_deg = elevation_deg; + return succeed(); +} + +MotionResult SimulatedMotionController::stop() { + state_.stopped = true; + invalidate(state_.azimuth); + invalidate(state_.elevation); + return fail(FaultCode::stopped); +} + +MotionResult SimulatedMotionController::emergency_stop() { + state_.emergency_stop_active = true; + state_.stopped = true; + invalidate(state_.azimuth); + invalidate(state_.elevation); + return fail(FaultCode::emergency_stop); +} + +MotionResult SimulatedMotionController::clear_fault() { + if (state_.emergency_stop_active) { + return fail(FaultCode::emergency_stop); + } + state_.stopped = false; + return succeed(); +} + +MotionResult SimulatedMotionController::set_enabled(const bool enabled) { + state_.azimuth.enabled = enabled; + state_.elevation.enabled = enabled; + if (!enabled) { + invalidate(state_.azimuth); + invalidate(state_.elevation); + return fail(FaultCode::driver_disabled); + } + return succeed(); +} + +void SimulatedMotionController::report_fault(const FaultCode code) { state_.fault = code; } + +} // namespace radiance3d diff --git a/firmware/controller/src/protocol.cpp b/firmware/controller/src/protocol.cpp index 93c009a..f7eaaff 100644 --- a/firmware/controller/src/protocol.cpp +++ b/firmware/controller/src/protocol.cpp @@ -1,5 +1,6 @@ #include "protocol.hpp" +#include #include #include #include @@ -19,10 +20,20 @@ const char* fault_name(const FaultCode code) { return "INVALID_COMMAND"; case FaultCode::invalid_argument: return "INVALID_ARGUMENT"; + case FaultCode::invalid_configuration: + return "INVALID_CONFIGURATION"; case FaultCode::not_homed: return "NOT_HOMED"; + case FaultCode::position_untrusted: + return "POSITION_UNTRUSTED"; case FaultCode::limit_reached: return "LIMIT_REACHED"; + case FaultCode::motion_timeout: + return "MOTION_TIMEOUT"; + case FaultCode::driver_disabled: + return "DRIVER_DISABLED"; + case FaultCode::emergency_stop: + return "EMERGENCY_STOP"; case FaultCode::stopped: return "STOPPED"; } @@ -31,27 +42,46 @@ const char* fault_name(const FaultCode code) { bool read_double(std::istringstream& input, double& value) { input >> value; - return !input.fail(); + return !input.fail() && std::isfinite(value); +} + +bool no_extra_arguments(std::istringstream& input) { + std::string extra; + input >> extra; + return extra.empty(); } } // namespace -const ControllerState& ProtocolEngine::state() const { return state_; } +ProtocolEngine::ProtocolEngine() + : default_controller_(provisional_simulator_config()), controller_(&default_controller_) {} + +ProtocolEngine::ProtocolEngine(MotionController& controller) + : default_controller_(provisional_simulator_config()), controller_(&controller) {} + +const ControllerState& ProtocolEngine::state() const { return controller_->state(); } std::string ProtocolEngine::fault(const FaultCode code, const std::string& detail) { - state_.fault = code; + controller_->report_fault(code); return "ERR " + std::string(fault_name(code)) + " " + detail; } std::string ProtocolEngine::status() const { + const ControllerState& controller_state = state(); std::ostringstream output; output << std::fixed << std::setprecision(3) << "OK STATUS" - << " AZ_DEG=" << state_.azimuth.position_deg - << " EL_DEG=" << state_.elevation.position_deg - << " AZ_HOMED=" << (state_.azimuth.homed ? 1 : 0) - << " EL_HOMED=" << (state_.elevation.homed ? 1 : 0) - << " STOPPED=" << (state_.stopped ? 1 : 0) - << " FAULT=" << fault_name(state_.fault); + << " AZ_DEG=" << controller_state.azimuth.commanded_position_deg + << " EL_DEG=" << controller_state.elevation.commanded_position_deg + << " POSITION_KIND=COMMANDED" + << " AZ_HOMED=" << (controller_state.azimuth.homed ? 1 : 0) + << " EL_HOMED=" << (controller_state.elevation.homed ? 1 : 0) + << " AZ_TRUSTED=" << (controller_state.azimuth.position_trusted ? 1 : 0) + << " EL_TRUSTED=" << (controller_state.elevation.position_trusted ? 1 : 0) + << " DRIVERS_ENABLED=" + << ((controller_state.azimuth.enabled && controller_state.elevation.enabled) ? 1 : 0) + << " STOPPED=" << (controller_state.stopped ? 1 : 0) + << " ESTOP=" << (controller_state.emergency_stop_active ? 1 : 0) + << " FAULT=" << fault_name(controller_state.fault); return output.str(); } @@ -61,38 +91,68 @@ std::string ProtocolEngine::handle(const std::string& line) { input >> command; if (command == "IDENTIFY") { + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, "IDENTIFY expects no arguments"); + } return "OK IDENTIFY DEVICE=Radiance3D-SIM PROTOCOL=" + std::to_string(RADIANCE3D_PROTOCOL_VERSION) + " MODE=SIMULATOR"; } if (command == "STATUS" || command == "POSITION") { + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, command + " expects no arguments"); + } return status(); } if (command == "CLEAR_FAULT") { - state_.fault = FaultCode::none; - state_.stopped = false; + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, "CLEAR_FAULT expects no arguments"); + } + const MotionResult result = controller_->clear_fault(); + if (!result.ok) { + return fault(result.fault, "emergency-stop input must be released first"); + } return "OK CLEAR_FAULT"; } if (command == "STOP") { - state_.stopped = true; - state_.fault = FaultCode::stopped; + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, "STOP expects no arguments"); + } + controller_->stop(); return "OK STOP"; } - if (state_.stopped) { - return fault(FaultCode::stopped, "send CLEAR_FAULT before motion"); + if (command == "E_STOP") { + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, "E_STOP expects no arguments"); + } + controller_->emergency_stop(); + return "OK E_STOP"; + } + if (command == "ENABLE") { + int enabled = -1; + input >> enabled; + if (input.fail() || (enabled != 0 && enabled != 1) || !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, "ENABLE expects 0 or 1"); + } + const MotionResult result = controller_->set_enabled(enabled == 1); + if (!result.ok && enabled == 1) { + return fault(result.fault, "driver state change failed"); + } + return "OK ENABLE VALUE=" + std::to_string(enabled); } if (command == "HOME") { std::string axis; input >> axis; - if (axis == "AZ" || axis == "BOTH") { - state_.azimuth = AxisState{0.0, true, false}; - } - if (axis == "EL" || axis == "BOTH") { - state_.elevation = AxisState{0.0, true, false}; - } - if (axis != "AZ" && axis != "EL" && axis != "BOTH") { + if (!no_extra_arguments(input) || (axis != "AZ" && axis != "EL" && axis != "BOTH")) { return fault(FaultCode::invalid_argument, "HOME expects AZ, EL, or BOTH"); } - state_.fault = FaultCode::none; + const AxisSelection selection = + axis == "AZ" ? AxisSelection::azimuth + : axis == "EL" ? AxisSelection::elevation + : AxisSelection::both; + const MotionResult result = controller_->home(selection); + if (!result.ok) { + return fault(result.fault, "homing rejected by motion controller"); + } return "OK HOME AXIS=" + axis; } if (command == "MOVE" || command == "SCAN_STEP") { @@ -100,24 +160,19 @@ std::string ProtocolEngine::handle(const std::string& line) { double elevation = 0.0; double speed = 0.0; if (!read_double(input, azimuth) || !read_double(input, elevation) || - !read_double(input, speed) || speed <= 0.0) { + !read_double(input, speed) || speed <= 0.0 || !no_extra_arguments(input)) { return fault(FaultCode::invalid_argument, command + " expects AZ_DEG EL_DEG DEG_PER_S"); } - if (!state_.azimuth.homed || !state_.elevation.homed) { - return fault(FaultCode::not_homed, "both axes must be homed"); - } - if (azimuth < -360.0 || azimuth > 360.0 || elevation < -180.0 || elevation > 180.0) { - return fault(FaultCode::limit_reached, "requested position exceeds protocol bounds"); + const MotionResult result = controller_->move_absolute(azimuth, elevation, speed); + if (!result.ok) { + return fault(result.fault, "motion rejected by configured controller limits or state"); } - state_.azimuth.position_deg = azimuth; - state_.elevation.position_deg = elevation; - state_.fault = FaultCode::none; std::ostringstream output; output << std::fixed << std::setprecision(3) << "OK " << command << " AZ_DEG=" << azimuth << " EL_DEG=" << elevation << " DEG_PER_S=" << speed; if (command == "SCAN_STEP") { - output << " READY=1"; + output << " READY=1 POSITION_KIND=COMMANDED"; } return output.str(); } diff --git a/firmware/controller/test/README.md b/firmware/controller/test/README.md index d7c234b..d9d8837 100644 --- a/firmware/controller/test/README.md +++ b/firmware/controller/test/README.md @@ -1,5 +1,12 @@ # Firmware tests -Protocol behavior is currently exercised through the native simulator. Add PlatformIO -unit tests here as commands and fault semantics stabilize; physical hardware tests -must state board revision, wiring, load, supply, and safety controls. +PlatformIO native tests exercise configuration-derived angular conversion, configured +travel limits, homing, position confidence, driver-disable behavior, and the protocol +synchronization boundary. Run them with: + +```bash +pio test -e native +``` + +These tests exercise the simulator only. Physical hardware tests must state board +revision, wiring, load, supply, and safety controls. diff --git a/firmware/controller/test/test_motion/test_main.cpp b/firmware/controller/test/test_motion/test_main.cpp new file mode 100644 index 0000000..9debc98 --- /dev/null +++ b/firmware/controller/test/test_motion/test_main.cpp @@ -0,0 +1,71 @@ +#include + +#include + +#include "motion_controller.hpp" +#include "protocol.hpp" + +using radiance3d::AxisConfig; +using radiance3d::ProtocolEngine; + +void setUp() {} +void tearDown() {} + +void test_angular_conversion_is_derived_from_configuration() { + AxisConfig config; + config.motor_full_steps_per_revolution = 200; + config.microsteps = 16; + config.gear_ratio = 3.0; + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 9600.0f, + static_cast(config.steps_per_output_revolution())); + TEST_ASSERT_FLOAT_WITHIN(0.0001f, 0.0375f, + static_cast(config.commanded_step_angle_deg())); +} + +void test_motion_requires_homing_and_uses_configured_limits() { + ProtocolEngine engine; + + TEST_ASSERT_TRUE(engine.handle("MOVE 0 0 5").find("ERR NOT_HOMED") == 0); + TEST_ASSERT_EQUAL_STRING("OK HOME AXIS=BOTH", engine.handle("HOME BOTH").c_str()); + TEST_ASSERT_TRUE(engine.handle("SCAN_STEP 359 -90 5").find("OK SCAN_STEP") == 0); + TEST_ASSERT_TRUE(engine.handle("MOVE 0 91 5").find("ERR LIMIT_REACHED") == 0); +} + +void test_stop_invalidates_position_and_requires_rehoming() { + ProtocolEngine engine; + engine.handle("HOME BOTH"); + engine.handle("MOVE 1 1 5"); + + TEST_ASSERT_EQUAL_STRING("OK STOP", engine.handle("STOP").c_str()); + TEST_ASSERT_EQUAL_STRING("OK CLEAR_FAULT", engine.handle("CLEAR_FAULT").c_str()); + TEST_ASSERT_TRUE(engine.handle("MOVE 2 2 5").find("ERR NOT_HOMED") == 0); +} + +void test_driver_disable_invalidates_position_confidence() { + ProtocolEngine engine; + engine.handle("HOME BOTH"); + + TEST_ASSERT_EQUAL_STRING("OK ENABLE VALUE=0", engine.handle("ENABLE 0").c_str()); + TEST_ASSERT_EQUAL_STRING("OK ENABLE VALUE=1", engine.handle("ENABLE 1").c_str()); + TEST_ASSERT_TRUE(engine.handle("MOVE 2 2 5").find("ERR NOT_HOMED") == 0); +} + +void test_status_labels_position_as_commanded_and_untrusted_at_startup() { + ProtocolEngine engine; + const std::string status = engine.handle("STATUS"); + + TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("POSITION_KIND=COMMANDED")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("AZ_TRUSTED=0")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("EL_TRUSTED=0")); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_angular_conversion_is_derived_from_configuration); + RUN_TEST(test_motion_requires_homing_and_uses_configured_limits); + RUN_TEST(test_stop_invalidates_position_and_requires_rehoming); + RUN_TEST(test_driver_disable_invalidates_position_confidence); + RUN_TEST(test_status_labels_position_as_commanded_and_untrusted_at_startup); + return UNITY_END(); +} diff --git a/software/README.md b/software/README.md index cd9c718..7da6c6e 100644 --- a/software/README.md +++ b/software/README.md @@ -1,14 +1,16 @@ # Radiance3D software -This Python 3.11+ package provides typed scan models plus two intentionally small -commands: +This Python 3.11+ package provides typed scan models, receiver/motion protocols, a +bounds-safe raster planner, a move-settle-measure coordinator, and two intentionally +small commands: ```bash radiance3d validate path/to/scan.json radiance3d inspect path/to/scan.json ``` -It does not acquire RF data, control physical hardware, or visualize patterns yet. -The validation code enforces the version 1 record shape and domain invariants without -adding a runtime dependency. The JSON Schema remains the normative interchange -specification and is validated separately in CI. +It does not provide a serial transport, RF-device implementation, physical hardware +control, data writer, or visualization yet. The coordinator accepts interchangeable +adapters and preserves raw/rejected readings when computing an aggregate. Validation +enforces schema 1.0 migration reads and the complete 1.1 record invariants without a +runtime dependency. JSON Schema remains the normative interchange specification. diff --git a/software/src/radiance3d/__init__.py b/software/src/radiance3d/__init__.py index 39ddc77..85a1403 100644 --- a/software/src/radiance3d/__init__.py +++ b/software/src/radiance3d/__init__.py @@ -1,16 +1,38 @@ -"""Typed models and validation for Radiance3D datasets.""" +"""Typed models, device boundaries, scan planning, and dataset validation.""" -from radiance3d.models import Angle, HardwareMetadata, RFMeasurement, Sample, Scan +from radiance3d.interfaces import ( + MeasurementAdapter, + MeasurementReading, + MeasurementValidity, + MotionController, + PositionConfidence, + PositionKind, + PositionReport, +) +from radiance3d.models import Angle, AngularStep, HardwareMetadata, RFMeasurement, Sample, Scan +from radiance3d.scanning import AxisScan, RasterScanConfig, ScanCoordinator, raster_points from radiance3d.validation import ScanValidationError, load_scan __all__ = [ "Angle", + "AngularStep", + "AxisScan", "HardwareMetadata", + "MeasurementAdapter", + "MeasurementReading", + "MeasurementValidity", + "MotionController", + "PositionConfidence", + "PositionKind", + "PositionReport", "RFMeasurement", + "RasterScanConfig", "Sample", "Scan", + "ScanCoordinator", "ScanValidationError", "load_scan", + "raster_points", ] __version__ = "0.1.0.dev0" diff --git a/software/src/radiance3d/interfaces.py b/software/src/radiance3d/interfaces.py new file mode 100644 index 0000000..acb1d83 --- /dev/null +++ b/software/src/radiance3d/interfaces.py @@ -0,0 +1,114 @@ +"""Stable host-side boundaries for motion and RF measurement devices.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from math import isfinite +from typing import Protocol, runtime_checkable + + +class PositionConfidence(str, Enum): # noqa: UP042 + """Whether the controller's open-loop position is safe to use.""" + + TRUSTED = "trusted" + UNTRUSTED = "untrusted" + + +class PositionKind(str, Enum): # noqa: UP042 + """Origin of a reported position.""" + + COMMANDED = "commanded" + OBSERVED = "observed" + + +class MeasurementValidity(str, Enum): # noqa: UP042 + """Receiver-adapter assessment of one native reading.""" + + VALID = "valid" + INVALID = "invalid" + TIMEOUT = "timeout" + + +@dataclass(frozen=True) +class PositionReport: + """Controller position with explicit origin and confidence.""" + + azimuth_deg: float + elevation_deg: float + timestamp: datetime + kind: PositionKind + confidence: PositionConfidence + motion_complete: bool + warnings: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isfinite(self.azimuth_deg) or not isfinite(self.elevation_deg): + raise ValueError("reported angles must be finite") + if self.timestamp.tzinfo is None: + raise ValueError("position timestamp must include a timezone") + if self.confidence is PositionConfidence.UNTRUSTED and self.motion_complete: + raise ValueError("an untrusted position cannot be a measurement boundary") + + +@dataclass(frozen=True) +class MeasurementReading: + """One unmodified reading returned by a measurement adapter.""" + + value: float + unit: str + timestamp: datetime + source_id: str + validity: MeasurementValidity + warnings: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isfinite(self.value): + raise ValueError("measurement value must be finite") + if not self.unit.strip(): + raise ValueError("measurement unit must not be empty") + if not self.source_id.strip(): + raise ValueError("measurement source identifier must not be empty") + if self.timestamp.tzinfo is None: + raise ValueError("measurement timestamp must include a timezone") + if len(self.warnings) != len(set(self.warnings)): + raise ValueError("measurement warnings must not contain duplicates") + + +@runtime_checkable +class MotionController(Protocol): + """Public host API for interchangeable motion-controller implementations.""" + + @property + def protocol_version(self) -> str: + """Return the negotiated motion protocol version.""" + + def home(self) -> PositionReport: + """Home both axes and return the resulting commanded-position report.""" + + def move_to( + self, + azimuth_deg: float, + elevation_deg: float, + speed_deg_per_s: float | None = None, + ) -> PositionReport: + """Move within configured limits and wait for motion completion.""" + + def position(self) -> PositionReport: + """Return the controller's current open-loop position report.""" + + def emergency_stop(self) -> None: + """Stop motion and invalidate position confidence.""" + + +@runtime_checkable +class MeasurementAdapter(Protocol): + """Receiver-neutral interface; implementations preserve native units.""" + + @property + def source_id(self) -> str: + """Return a stable identifier recorded with every reading.""" + + def measure(self, timeout_s: float) -> MeasurementReading: + """Return one reading or an explicit invalid/timeout reading.""" diff --git a/software/src/radiance3d/models.py b/software/src/radiance3d/models.py index 41684e6..7ecb7ca 100644 --- a/software/src/radiance3d/models.py +++ b/software/src/radiance3d/models.py @@ -9,7 +9,14 @@ from typing import Any, Literal, cast DataKind = Literal["measured", "simulated", "imported", "processed"] -TOP_LEVEL_FIELDS = { +CalibrationStatus = Literal[ + "uncalibrated", + "reference-measured", + "calibrated", + "not-applicable", + "unknown", +] +BASE_TOP_LEVEL_FIELDS = { "schema_version", "scan_id", "scan_name", @@ -28,7 +35,15 @@ "provenance", "samples", } -SAMPLE_FIELDS = { +V1_1_TOP_LEVEL_FIELDS = { + "protocol_version", + "hardware_revision", + "step_size_deg", + "measurement_units", + "calibration_status", + "operator_notes", +} +BASE_SAMPLE_FIELDS = { "sample_timestamp", "azimuth_angle_deg", "elevation_angle_deg", @@ -36,6 +51,13 @@ "measurement_unit", "quality_flags", } +V1_1_SAMPLE_FIELDS = { + "sequence_number", + "measurement_source", + "position_kind", + "validity", + "warnings", +} def _text(value: object, field: str) -> str: @@ -145,6 +167,31 @@ def from_mapping(cls, value: object, field: str) -> HardwareMetadata: return cls(name=_text(item.get("name"), f"{field}.name"), details=dict(item)) +@dataclass(frozen=True) +class AngularStep: + """Commanded raster step sizes in degrees, independent of physical accuracy.""" + + azimuth_deg: float + elevation_deg: float + + def __post_init__(self) -> None: + if not isfinite(self.azimuth_deg) or not isfinite(self.elevation_deg): + raise ValueError("step_size_deg values must be finite") + if self.azimuth_deg <= 0 or self.elevation_deg <= 0: + raise ValueError("step_size_deg values must be greater than zero") + + @classmethod + def from_mapping(cls, value: object) -> AngularStep: + item = _mapping(value, "step_size_deg") + unexpected = set(item) - {"azimuth", "elevation"} + if unexpected: + raise ValueError(f"step_size_deg contains unsupported fields: {sorted(unexpected)}") + return cls( + _number(item.get("azimuth"), "step_size_deg.azimuth"), + _number(item.get("elevation"), "step_size_deg.elevation"), + ) + + @dataclass(frozen=True) class Sample: timestamp: datetime @@ -152,13 +199,51 @@ class Sample: elevation: Angle measurement: RFMeasurement quality_flags: tuple[str, ...] = () + sequence_number: int | None = None + measurement_source: str | None = None + position_kind: Literal["commanded", "observed"] | None = None + validity: Literal["valid", "invalid", "timeout"] | None = None + warnings: tuple[str, ...] = () @classmethod - def from_mapping(cls, value: object, index: int) -> Sample: + def from_mapping(cls, value: object, index: int, *, schema_version: str) -> Sample: item = _mapping(value, f"samples[{index}]") - unexpected = set(item) - SAMPLE_FIELDS + allowed_fields = ( + BASE_SAMPLE_FIELDS | V1_1_SAMPLE_FIELDS + if schema_version == "1.1.0" + else BASE_SAMPLE_FIELDS + ) + unexpected = set(item) - allowed_fields if unexpected: raise ValueError(f"samples[{index}] contains unsupported fields: {sorted(unexpected)}") + if schema_version == "1.1.0": + missing = V1_1_SAMPLE_FIELDS - set(item) + if missing: + raise ValueError(f"samples[{index}] missing required fields: {sorted(missing)}") + sequence_value = item.get("sequence_number") + if isinstance(sequence_value, bool) or not isinstance(sequence_value, int): + raise ValueError(f"samples[{index}].sequence_number must be an integer") + if sequence_value < 0: + raise ValueError(f"samples[{index}].sequence_number must not be negative") + source = _text( + item.get("measurement_source"), + f"samples[{index}].measurement_source", + ) + position_kind_value = item.get("position_kind") + if position_kind_value not in {"commanded", "observed"}: + raise ValueError(f"samples[{index}].position_kind must be commanded or observed") + position_kind = cast(Literal["commanded", "observed"], position_kind_value) + validity_value = item.get("validity") + if validity_value not in {"valid", "invalid", "timeout"}: + raise ValueError(f"samples[{index}].validity must be valid, invalid, or timeout") + validity = cast(Literal["valid", "invalid", "timeout"], validity_value) + warnings = _string_list(item.get("warnings"), f"samples[{index}].warnings") + else: + sequence_value = None + source = None + position_kind = None + validity = None + warnings = () return cls( timestamp=_timestamp( item.get("sample_timestamp"), @@ -180,6 +265,11 @@ def from_mapping(cls, value: object, index: int) -> Sample: item.get("quality_flags", []), f"samples[{index}].quality_flags", ), + sequence_number=sequence_value, + measurement_source=source, + position_kind=position_kind, + validity=validity, + warnings=warnings, ) @@ -191,7 +281,13 @@ class Scan: timestamp: datetime software_version: str firmware_version: str | None + protocol_version: str | None + hardware_revision: str | None frequency_hz: float + step_size: AngularStep | None + measurement_units: tuple[str, ...] + calibration_status: CalibrationStatus | None + operator_notes: str hardware: HardwareMetadata antenna_under_test: HardwareMetadata rf_source: HardwareMetadata @@ -206,17 +302,22 @@ class Scan: @classmethod def from_mapping(cls, value: object) -> Scan: data = _mapping(value, "scan") - missing = TOP_LEVEL_FIELDS - set(data) + version = _text(data.get("schema_version"), "schema_version") + if version not in {"1.0.0", "1.1.0"}: + raise ValueError(f"unsupported schema_version: {version}") + + required_fields = ( + BASE_TOP_LEVEL_FIELDS | V1_1_TOP_LEVEL_FIELDS + if version == "1.1.0" + else BASE_TOP_LEVEL_FIELDS + ) + missing = required_fields - set(data) if missing: raise ValueError(f"missing required fields: {sorted(missing)}") - unexpected = set(data) - TOP_LEVEL_FIELDS + unexpected = set(data) - required_fields if unexpected: raise ValueError(f"unsupported top-level fields: {sorted(unexpected)}") - version = _text(data.get("schema_version"), "schema_version") - if version != "1.0.0": - raise ValueError(f"unsupported schema_version: {version}") - provenance = _mapping(data.get("provenance"), "provenance") data_kind_value = provenance.get("data_kind") allowed = {"measured", "simulated", "imported", "processed"} @@ -231,6 +332,14 @@ def from_mapping(cls, value: object) -> Scan: for field in ("method", "notes"): if field in provenance: _string(provenance[field], f"provenance.{field}") + if data_kind == "processed": + source_ids = _string_list( + provenance.get("source_dataset_ids"), + "provenance.source_dataset_ids", + ) + if not source_ids: + raise ValueError("processed datasets require source_dataset_ids") + _text(provenance.get("method"), "provenance.method") samples_value = data.get("samples") if not isinstance(samples_value, list) or not samples_value: @@ -240,6 +349,34 @@ def from_mapping(cls, value: object) -> Scan: if frequency_hz <= 0: raise ValueError("frequency_hz must be greater than zero") + if version == "1.1.0": + protocol_version = _text(data.get("protocol_version"), "protocol_version") + hardware_revision = _text(data.get("hardware_revision"), "hardware_revision") + step_size = AngularStep.from_mapping(data.get("step_size_deg")) + measurement_units = _string_list( + data.get("measurement_units"), + "measurement_units", + ) + calibration_value_raw = data.get("calibration_status") + allowed_calibration = { + "uncalibrated", + "reference-measured", + "calibrated", + "not-applicable", + "unknown", + } + if calibration_value_raw not in allowed_calibration: + raise ValueError("calibration_status contains an unsupported value") + calibration_status = cast(CalibrationStatus, calibration_value_raw) + operator_notes = _string(data.get("operator_notes"), "operator_notes") + else: + protocol_version = None + hardware_revision = None + step_size = None + measurement_units = () + calibration_status = None + operator_notes = "" + transmit_power_value = data.get("transmit_power") transmit_power = ( None @@ -252,6 +389,20 @@ def from_mapping(cls, value: object) -> Scan: if calibration_value is None else HardwareMetadata.from_mapping(calibration_value, "calibration_reference") ) + if calibration_status == "calibrated" and calibration_reference is None: + raise ValueError("calibrated datasets require calibration_reference metadata") + + samples = tuple( + Sample.from_mapping(item, index, schema_version=version) + for index, item in enumerate(samples_value) + ) + if version == "1.1.0": + sequence_numbers = tuple(sample.sequence_number for sample in samples) + if sequence_numbers != tuple(range(len(samples))): + raise ValueError("sample sequence_number values must be contiguous from zero") + sample_units = {sample.measurement.unit for sample in samples} + if sample_units != set(measurement_units): + raise ValueError("measurement_units must exactly match sample measurement units") return cls( schema_version=version, @@ -260,7 +411,13 @@ def from_mapping(cls, value: object) -> Scan: timestamp=_timestamp(data.get("timestamp"), "timestamp"), software_version=_text(data.get("software_version"), "software_version"), firmware_version=_optional_text(data.get("firmware_version"), "firmware_version"), + protocol_version=protocol_version, + hardware_revision=hardware_revision, frequency_hz=frequency_hz, + step_size=step_size, + measurement_units=measurement_units, + calibration_status=calibration_status, + operator_notes=operator_notes, hardware=HardwareMetadata.from_mapping( data.get("hardware_configuration"), "hardware_configuration" ), @@ -273,8 +430,6 @@ def from_mapping(cls, value: object) -> Scan: calibration_reference=calibration_reference, environmental_notes=_string(data.get("environmental_notes"), "environmental_notes"), data_kind=data_kind, - samples=tuple( - Sample.from_mapping(item, index) for index, item in enumerate(samples_value) - ), + samples=samples, warnings=_string_list(data.get("warnings"), "warnings"), ) diff --git a/software/src/radiance3d/scanning.py b/software/src/radiance3d/scanning.py new file mode 100644 index 0000000..df1b8aa --- /dev/null +++ b/software/src/radiance3d/scanning.py @@ -0,0 +1,229 @@ +"""Bounds-safe raster planning and receiver-neutral scan coordination.""" + +from __future__ import annotations + +import statistics +import time +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from math import isfinite +from typing import Literal + +from radiance3d.interfaces import ( + MeasurementAdapter, + MeasurementReading, + MeasurementValidity, + MotionController, + PositionConfidence, + PositionReport, +) + +AveragingMethod = Literal["none", "mean", "median"] + + +def _require_finite(value: float, field: str) -> None: + if not isfinite(value): + raise ValueError(f"{field} must be finite") + + +def _axis_values(start: float, stop: float, step: float) -> tuple[float, ...]: + """Build an inclusive sequence without accumulating binary floating-point error.""" + + if step <= 0: + raise ValueError("step size must be greater than zero") + if start > stop: + raise ValueError("axis start must not exceed axis stop") + count = int((stop - start) // step) + values = [start + index * step for index in range(count + 1)] + tolerance = max(1e-9, step * 1e-9) + if stop - values[-1] > tolerance: + values.append(stop) + else: + values[-1] = stop + return tuple(round(value, 12) for value in values) + + +@dataclass(frozen=True) +class AxisScan: + """Inclusive scan range for one axis, in degrees.""" + + start_deg: float + stop_deg: float + step_deg: float + minimum_deg: float + maximum_deg: float + + def __post_init__(self) -> None: + for field in ( + "start_deg", + "stop_deg", + "step_deg", + "minimum_deg", + "maximum_deg", + ): + _require_finite(getattr(self, field), field) + if self.minimum_deg > self.maximum_deg: + raise ValueError("axis minimum must not exceed maximum") + if self.start_deg < self.minimum_deg or self.stop_deg > self.maximum_deg: + raise ValueError("scan range exceeds configured axis limits") + _axis_values(self.start_deg, self.stop_deg, self.step_deg) + + @property + def values(self) -> tuple[float, ...]: + return _axis_values(self.start_deg, self.stop_deg, self.step_deg) + + +@dataclass(frozen=True) +class RasterScanConfig: + """Version 1 raster and synchronization behavior.""" + + azimuth: AxisScan + elevation: AxisScan + settle_time_s: float = 0.25 + samples_per_position: int = 1 + averaging_method: AveragingMethod = "none" + measurement_timeout_s: float = 2.0 + retry_count: int = 0 + reverse_alternate_rows: bool = True + speed_deg_per_s: float | None = None + + def __post_init__(self) -> None: + for value, field in ( + (self.settle_time_s, "settle_time_s"), + (self.measurement_timeout_s, "measurement_timeout_s"), + ): + _require_finite(value, field) + if value < 0: + raise ValueError(f"{field} must not be negative") + if self.samples_per_position < 1: + raise ValueError("samples_per_position must be at least one") + if self.retry_count < 0: + raise ValueError("retry_count must not be negative") + if self.averaging_method not in {"none", "mean", "median"}: + raise ValueError("averaging_method must be none, mean, or median") + if self.averaging_method == "none" and self.samples_per_position != 1: + raise ValueError("multiple samples require mean or median averaging") + if self.measurement_timeout_s == 0: + raise ValueError("measurement_timeout_s must be greater than zero") + if self.speed_deg_per_s is not None: + _require_finite(self.speed_deg_per_s, "speed_deg_per_s") + if self.speed_deg_per_s <= 0: + raise ValueError("speed_deg_per_s must be greater than zero") + + +@dataclass(frozen=True) +class ScanPoint: + sequence_number: int + azimuth_deg: float + elevation_deg: float + + +@dataclass(frozen=True) +class PositionCapture: + """Raw readings plus an optional aggregate that never replaces them.""" + + point: ScanPoint + position: PositionReport + raw_readings: tuple[MeasurementReading, ...] + rejected_readings: tuple[MeasurementReading, ...] + aggregate_value: float | None + aggregate_unit: str | None + warnings: tuple[str, ...] = () + + +def raster_points(config: RasterScanConfig) -> Iterator[ScanPoint]: + """Yield elevation-major raster points, reversing alternate azimuth rows.""" + + azimuth_values = config.azimuth.values + sequence_number = 0 + for row, elevation_deg in enumerate(config.elevation.values): + row_values = ( + tuple(reversed(azimuth_values)) + if config.reverse_alternate_rows and row % 2 + else azimuth_values + ) + for azimuth_deg in row_values: + yield ScanPoint(sequence_number, azimuth_deg, elevation_deg) + sequence_number += 1 + + +class ScanCoordinator: + """Execute Version 1 move-settle-measure synchronization.""" + + def __init__( + self, + motion: MotionController, + measurement: MeasurementAdapter, + *, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._motion = motion + self._measurement = measurement + self._sleep = sleep + + def capture_point( + self, + point: ScanPoint, + config: RasterScanConfig, + ) -> PositionCapture: + position = self._motion.move_to( + point.azimuth_deg, + point.elevation_deg, + config.speed_deg_per_s, + ) + if position.confidence is not PositionConfidence.TRUSTED or not position.motion_complete: + raise RuntimeError("motion controller did not establish a trusted measurement boundary") + + self._sleep(config.settle_time_s) + accepted: list[MeasurementReading] = [] + rejected: list[MeasurementReading] = [] + attempts_remaining = config.samples_per_position + config.retry_count + while len(accepted) < config.samples_per_position and attempts_remaining: + reading = self._measurement.measure(config.measurement_timeout_s) + attempts_remaining -= 1 + if reading.source_id != self._measurement.source_id: + raise RuntimeError("measurement adapter returned an inconsistent source identifier") + if reading.validity is MeasurementValidity.VALID: + accepted.append(reading) + else: + rejected.append(reading) + + warnings = tuple( + dict.fromkeys( + warning for reading in (*accepted, *rejected) for warning in reading.warnings + ) + ) + if len(accepted) < config.samples_per_position: + return PositionCapture( + point, + position, + tuple(accepted), + tuple(rejected), + None, + None, + (*warnings, "insufficient valid measurements"), + ) + + units = {reading.unit for reading in accepted} + if len(units) != 1: + raise RuntimeError("measurement adapter changed units within one scan position") + aggregate = _aggregate( + tuple(reading.value for reading in accepted), config.averaging_method + ) + return PositionCapture( + point, + position, + tuple(accepted), + tuple(rejected), + aggregate, + accepted[0].unit, + warnings, + ) + + +def _aggregate(values: tuple[float, ...], method: AveragingMethod) -> float: + if method == "none": + return values[0] + if method == "mean": + return statistics.fmean(values) + return statistics.median(values) diff --git a/software/tests/test_models.py b/software/tests/test_models.py index 0965f24..bdf18fd 100644 --- a/software/tests/test_models.py +++ b/software/tests/test_models.py @@ -11,9 +11,19 @@ def test_simulated_example_loads() -> None: scan = load_scan(EXAMPLE) + assert scan.schema_version == "1.1.0" assert scan.data_kind == "simulated" assert len(scan.samples) == 5 assert scan.samples[0].measurement.unit == "dB_relative" + assert scan.samples[0].measurement_source == "deterministic-pattern-generator" + assert scan.samples[0].position_kind == "commanded" + assert scan.samples[0].sequence_number == 0 + assert scan.protocol_version == "1" + assert scan.hardware_revision == "simulator" + assert scan.step_size is not None + assert scan.step_size.azimuth_deg == 45 + assert scan.measurement_units == ("dB_relative",) + assert scan.calibration_status == "not-applicable" def test_angle_rejects_out_of_range_value() -> None: @@ -36,3 +46,75 @@ def test_invalid_firmware_metadata_is_rejected(tmp_path: Path) -> None: with pytest.raises(ScanValidationError, match="firmware_version"): load_scan(invalid) + + +def test_legacy_1_0_scan_remains_readable(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["schema_version"] = "1.0.0" + for field in ( + "protocol_version", + "hardware_revision", + "step_size_deg", + "measurement_units", + "calibration_status", + "operator_notes", + ): + del payload[field] + for sample in payload["samples"]: + for field in ( + "sequence_number", + "measurement_source", + "position_kind", + "validity", + "warnings", + ): + del sample[field] + legacy = tmp_path / "legacy.json" + legacy.write_text(json.dumps(payload), encoding="utf-8") + + scan = load_scan(legacy) + + assert scan.schema_version == "1.0.0" + assert scan.protocol_version is None + assert scan.samples[0].sequence_number is None + + +def test_new_scan_requires_contiguous_sequence_numbers(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["samples"][2]["sequence_number"] = 7 + invalid = tmp_path / "invalid-sequence.json" + invalid.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ScanValidationError, match="contiguous from zero"): + load_scan(invalid) + + +def test_declared_measurement_units_must_match_samples(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["measurement_units"] = ["dBm"] + invalid = tmp_path / "invalid-units.json" + invalid.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ScanValidationError, match="must exactly match"): + load_scan(invalid) + + +def test_calibrated_scan_requires_reference_metadata(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["calibration_status"] = "calibrated" + invalid = tmp_path / "invalid-calibration.json" + invalid.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ScanValidationError, match="require calibration_reference"): + load_scan(invalid) + + +def test_processed_scan_requires_source_ids_and_method(tmp_path: Path) -> None: + payload = json.loads(EXAMPLE.read_text(encoding="utf-8")) + payload["provenance"]["data_kind"] = "processed" + del payload["provenance"]["method"] + invalid = tmp_path / "invalid-provenance.json" + invalid.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ScanValidationError, match="source_dataset_ids"): + load_scan(invalid) diff --git a/software/tests/test_scanning.py b/software/tests/test_scanning.py new file mode 100644 index 0000000..1748e4c --- /dev/null +++ b/software/tests/test_scanning.py @@ -0,0 +1,168 @@ +from collections.abc import Iterator +from datetime import UTC, datetime + +import pytest + +from radiance3d.interfaces import ( + MeasurementReading, + MeasurementValidity, + PositionConfidence, + PositionKind, + PositionReport, +) +from radiance3d.scanning import ( + AxisScan, + RasterScanConfig, + ScanCoordinator, + ScanPoint, + raster_points, +) + +NOW = datetime(2026, 7, 30, tzinfo=UTC) + + +class FakeMotion: + protocol_version = "1" + + def __init__(self, *, trusted: bool = True) -> None: + self._trusted = trusted + self.moves: list[tuple[float, float, float | None]] = [] + + def home(self) -> PositionReport: + return self._report(0.0, 0.0) + + def move_to( + self, + azimuth_deg: float, + elevation_deg: float, + speed_deg_per_s: float | None = None, + ) -> PositionReport: + self.moves.append((azimuth_deg, elevation_deg, speed_deg_per_s)) + return self._report(azimuth_deg, elevation_deg) + + def position(self) -> PositionReport: + return self._report(0.0, 0.0) + + def emergency_stop(self) -> None: + self._trusted = False + + def _report(self, azimuth_deg: float, elevation_deg: float) -> PositionReport: + confidence = PositionConfidence.TRUSTED if self._trusted else PositionConfidence.UNTRUSTED + return PositionReport( + azimuth_deg, + elevation_deg, + NOW, + PositionKind.COMMANDED, + confidence, + motion_complete=self._trusted, + ) + + +class FakeMeasurement: + source_id = "fake-receiver" + + def __init__(self, readings: Iterator[MeasurementReading]) -> None: + self._readings = readings + self.timeouts: list[float] = [] + + def measure(self, timeout_s: float) -> MeasurementReading: + self.timeouts.append(timeout_s) + return next(self._readings) + + +def reading( + value: float, + validity: MeasurementValidity = MeasurementValidity.VALID, +) -> MeasurementReading: + return MeasurementReading(value, "ADC_counts", NOW, "fake-receiver", validity) + + +def config(**changes: object) -> RasterScanConfig: + defaults: dict[str, object] = { + "azimuth": AxisScan(0.0, 2.0, 1.0, 0.0, 359.0), + "elevation": AxisScan(-1.0, 1.0, 1.0, -90.0, 90.0), + "settle_time_s": 0.5, + } + defaults.update(changes) + return RasterScanConfig(**defaults) # type: ignore[arg-type] + + +def test_raster_reverses_alternate_rows_and_uses_inclusive_ranges() -> None: + points = list(raster_points(config())) + + assert [(point.azimuth_deg, point.elevation_deg) for point in points] == [ + (0.0, -1.0), + (1.0, -1.0), + (2.0, -1.0), + (2.0, 0.0), + (1.0, 0.0), + (0.0, 0.0), + (0.0, 1.0), + (1.0, 1.0), + (2.0, 1.0), + ] + assert [point.sequence_number for point in points] == list(range(9)) + + +def test_axis_scan_rejects_requested_travel_outside_machine_limits() -> None: + with pytest.raises(ValueError, match="exceeds configured axis limits"): + AxisScan(-1.0, 359.0, 1.0, 0.0, 359.0) + + +def test_scan_configuration_rejects_unknown_averaging_and_zero_timeout() -> None: + with pytest.raises(ValueError, match="averaging_method"): + config(averaging_method="mode") + with pytest.raises(ValueError, match="measurement_timeout_s"): + config(measurement_timeout_s=0.0) + + +def test_capture_preserves_raw_and_rejected_readings_before_averaging() -> None: + motion = FakeMotion() + receiver = FakeMeasurement( + iter( + [ + reading(999.0, MeasurementValidity.INVALID), + reading(10.0), + reading(14.0), + ] + ) + ) + sleeps: list[float] = [] + scan_config = config( + samples_per_position=2, + averaging_method="mean", + retry_count=1, + measurement_timeout_s=1.25, + speed_deg_per_s=3.0, + ) + + capture = ScanCoordinator(motion, receiver, sleep=sleeps.append).capture_point( + ScanPoint(4, 2.0, 0.0), + scan_config, + ) + + assert capture.aggregate_value == 12.0 + assert [item.value for item in capture.raw_readings] == [10.0, 14.0] + assert [item.value for item in capture.rejected_readings] == [999.0] + assert sleeps == [0.5] + assert receiver.timeouts == [1.25, 1.25, 1.25] + assert motion.moves == [(2.0, 0.0, 3.0)] + + +def test_capture_refuses_untrusted_position() -> None: + coordinator = ScanCoordinator(FakeMotion(trusted=False), FakeMeasurement(iter([reading(1.0)]))) + + with pytest.raises(RuntimeError, match="trusted measurement boundary"): + coordinator.capture_point(ScanPoint(0, 0.0, 0.0), config()) + + +def test_position_report_does_not_allow_untrusted_ready_state() -> None: + with pytest.raises(ValueError, match="cannot be a measurement boundary"): + PositionReport( + 0.0, + 0.0, + NOW, + PositionKind.COMMANDED, + PositionConfidence.UNTRUSTED, + motion_complete=True, + ) From 7221b0e907a7fe0f368bb16d8c44ea425b54a260 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 22:30:15 -0400 Subject: [PATCH 03/13] feat(firmware): add TMC2209 driver abstraction --- .../controller/include/hardware_platform.hpp | 29 ++ .../controller/include/stepper_driver.hpp | 75 ++++ .../controller/include/tmc2209_driver.hpp | 71 ++++ firmware/controller/src/tmc2209_driver.cpp | 389 ++++++++++++++++++ .../controller/test/test_driver/test_main.cpp | 184 +++++++++ 5 files changed, 748 insertions(+) create mode 100644 firmware/controller/include/hardware_platform.hpp create mode 100644 firmware/controller/include/stepper_driver.hpp create mode 100644 firmware/controller/include/tmc2209_driver.hpp create mode 100644 firmware/controller/src/tmc2209_driver.cpp create mode 100644 firmware/controller/test/test_driver/test_main.cpp diff --git a/firmware/controller/include/hardware_platform.hpp b/firmware/controller/include/hardware_platform.hpp new file mode 100644 index 0000000..e573433 --- /dev/null +++ b/firmware/controller/include/hardware_platform.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace radiance3d { + +enum class PinMode { input, input_pullup, output }; + +class HardwarePlatform { + public: + virtual ~HardwarePlatform() = default; + + virtual bool configure_pin(int pin, PinMode mode) = 0; + virtual void write_pin(int pin, bool high) = 0; + virtual bool read_pin(int pin) const = 0; + virtual std::uint64_t monotonic_micros() const = 0; + + virtual bool begin_uart(std::uint8_t channel, int tx_pin, int rx_pin, + std::uint32_t baud) = 0; + virtual void flush_uart_input(std::uint8_t channel) = 0; + virtual bool write_uart(std::uint8_t channel, const std::uint8_t* data, + std::size_t length) = 0; + virtual std::size_t read_uart(std::uint8_t channel, std::uint8_t* data, + std::size_t maximum_length, + std::uint32_t timeout_ms) = 0; +}; + +} // namespace radiance3d diff --git a/firmware/controller/include/stepper_driver.hpp b/firmware/controller/include/stepper_driver.hpp new file mode 100644 index 0000000..6d4aa35 --- /dev/null +++ b/firmware/controller/include/stepper_driver.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include + +namespace radiance3d { + +enum class ChopperMode { stealthchop, spreadcycle }; + +enum class DriverFault { + none, + invalid_configuration, + communication_failure, + reset_detected, + undervoltage, + overtemperature_warning, + overtemperature_shutdown, + short_to_ground, + short_to_supply, + open_load, +}; + +struct DriverCapabilities { + bool uart_diagnostics{false}; + bool configurable_current{false}; + bool configurable_microsteps{false}; + bool interpolation{false}; + bool stealthchop{false}; + bool spreadcycle{false}; +}; + +struct DriverStatus { + bool connected{false}; + bool enabled{false}; + bool standstill{true}; + bool stealthchop_active{false}; + bool reset_detected{false}; + bool undervoltage{false}; + bool overtemperature_warning{false}; + bool overtemperature_shutdown{false}; + bool short_to_ground_a{false}; + bool short_to_ground_b{false}; + bool short_to_supply_a{false}; + bool short_to_supply_b{false}; + bool open_load_a{false}; + bool open_load_b{false}; + std::uint8_t current_scale{0}; + DriverFault fault{DriverFault::none}; + + bool critical_fault() const { + return !connected || undervoltage || overtemperature_shutdown || + short_to_ground_a || short_to_ground_b || short_to_supply_a || + short_to_supply_b; + } +}; + +class StepperDriver { + public: + virtual ~StepperDriver() = default; + + virtual bool initialize() = 0; + virtual DriverCapabilities capabilities() const = 0; + virtual bool enable() = 0; + virtual void disable() = 0; + virtual bool set_direction(bool positive) = 0; + virtual void set_step(bool high) = 0; + virtual bool set_current_milliamps(std::uint16_t rms_current_ma, + std::uint8_t hold_percent) = 0; + virtual bool set_microsteps(std::uint16_t microsteps) = 0; + virtual bool set_interpolation(bool enabled) = 0; + virtual bool set_chopper_mode(ChopperMode mode) = 0; + virtual DriverStatus read_status() = 0; + virtual bool is_connected() const = 0; +}; + +} // namespace radiance3d diff --git a/firmware/controller/include/tmc2209_driver.hpp b/firmware/controller/include/tmc2209_driver.hpp new file mode 100644 index 0000000..536ec6d --- /dev/null +++ b/firmware/controller/include/tmc2209_driver.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "hardware_platform.hpp" +#include "stepper_driver.hpp" + +#include +#include + +namespace radiance3d { + +struct Tmc2209Config { + std::uint8_t uart_channel{1}; + std::uint8_t address{0}; + int uart_tx_pin{-1}; + int uart_rx_pin{-1}; + int step_pin{-1}; + int direction_pin{-1}; + int enable_pin{-1}; + bool enable_active_low{true}; + bool direction_inverted{false}; + std::uint16_t sense_resistor_milliohms{110}; + std::uint16_t maximum_rms_current_ma{800}; + std::uint32_t uart_baud{115200}; + std::uint32_t uart_timeout_ms{20}; +}; + +class Tmc2209Driver final : public StepperDriver { + public: + Tmc2209Driver(HardwarePlatform& platform, Tmc2209Config config); + + bool initialize() override; + DriverCapabilities capabilities() const override; + bool enable() override; + void disable() override; + bool set_direction(bool positive) override; + void set_step(bool high) override; + bool set_current_milliamps(std::uint16_t rms_current_ma, + std::uint8_t hold_percent) override; + bool set_microsteps(std::uint16_t microsteps) override; + bool set_interpolation(bool enabled) override; + bool set_chopper_mode(ChopperMode mode) override; + DriverStatus read_status() override; + bool is_connected() const override; + + const Tmc2209Config& config() const; + std::uint16_t configured_current_ma() const; + std::uint16_t configured_microsteps() const; + static std::uint8_t calculate_crc(const std::uint8_t* bytes, + std::size_t length_without_crc); + + private: + HardwarePlatform& platform_; + Tmc2209Config config_; + bool connected_{false}; + bool enabled_{false}; + bool interpolation_{true}; + ChopperMode chopper_mode_{ChopperMode::stealthchop}; + std::uint16_t configured_current_ma_{0}; + std::uint16_t configured_microsteps_{16}; + std::uint32_t gconf_{0}; + std::uint32_t chopconf_{0x10000053UL}; + + bool valid_config() const; + bool write_register(std::uint8_t address, std::uint32_t value); + bool read_register(std::uint8_t address, std::uint32_t& value); + bool verify_write_counter(std::uint8_t before); + static bool microstep_code(std::uint16_t microsteps, std::uint8_t& code); + static DriverFault primary_fault(const DriverStatus& status); +}; + +} // namespace radiance3d diff --git a/firmware/controller/src/tmc2209_driver.cpp b/firmware/controller/src/tmc2209_driver.cpp new file mode 100644 index 0000000..3271dff --- /dev/null +++ b/firmware/controller/src/tmc2209_driver.cpp @@ -0,0 +1,389 @@ +#include "tmc2209_driver.hpp" + +#include +#include + +namespace radiance3d { +namespace { + +constexpr std::uint8_t kSync = 0x05; +constexpr std::uint8_t kMasterAddress = 0xFF; +constexpr std::uint8_t kWriteBit = 0x80; +constexpr std::uint8_t kRegisterGconf = 0x00; +constexpr std::uint8_t kRegisterGstat = 0x01; +constexpr std::uint8_t kRegisterIfcnt = 0x02; +constexpr std::uint8_t kRegisterSlaveconf = 0x03; +constexpr std::uint8_t kRegisterIholdIrun = 0x10; +constexpr std::uint8_t kRegisterTpowerdown = 0x11; +constexpr std::uint8_t kRegisterChopconf = 0x6C; +constexpr std::uint8_t kRegisterDrvStatus = 0x6F; +constexpr std::uint8_t kRegisterPwmconf = 0x70; + +constexpr std::uint32_t kGconfSpreadcycle = 1UL << 2; +constexpr std::uint32_t kGconfPdnDisable = 1UL << 6; +constexpr std::uint32_t kGconfMstepRegisterSelect = 1UL << 7; +constexpr std::uint32_t kGconfMultistepFilter = 1UL << 8; +constexpr std::uint32_t kChopconfVsense = 1UL << 17; +constexpr std::uint32_t kChopconfMresMask = 0xFUL << 24; +constexpr std::uint32_t kChopconfInterpolation = 1UL << 28; +constexpr std::uint32_t kPwmAutoscale = 1UL << 18; +constexpr std::uint32_t kPwmAutograd = 1UL << 19; + +std::uint8_t clamp_scale(const long value) { + return static_cast(std::max(0L, std::min(31L, value))); +} + +} // namespace + +Tmc2209Driver::Tmc2209Driver(HardwarePlatform& platform, Tmc2209Config config) + : platform_(platform), config_(config) {} + +const Tmc2209Config& Tmc2209Driver::config() const { return config_; } + +std::uint16_t Tmc2209Driver::configured_current_ma() const { + return configured_current_ma_; +} + +std::uint16_t Tmc2209Driver::configured_microsteps() const { + return configured_microsteps_; +} + +bool Tmc2209Driver::valid_config() const { + return config_.address <= 3 && config_.uart_channel > 0 && + config_.uart_tx_pin >= 0 && config_.uart_rx_pin >= 0 && + config_.step_pin >= 0 && config_.direction_pin >= 0 && + config_.enable_pin >= 0 && config_.sense_resistor_milliohms > 0 && + config_.maximum_rms_current_ma > 0 && config_.uart_baud > 0 && + config_.uart_timeout_ms > 0; +} + +DriverCapabilities Tmc2209Driver::capabilities() const { + return DriverCapabilities{true, true, true, true, true, true}; +} + +std::uint8_t Tmc2209Driver::calculate_crc(const std::uint8_t* bytes, + const std::size_t length_without_crc) { + std::uint8_t crc = 0; + for (std::size_t index = 0; index < length_without_crc; ++index) { + std::uint8_t current = bytes[index]; + for (std::uint8_t bit = 0; bit < 8; ++bit) { + if (((crc >> 7) ^ (current & 0x01U)) != 0U) { + crc = static_cast((crc << 1) ^ 0x07U); + } else { + crc = static_cast(crc << 1); + } + current = static_cast(current >> 1); + } + } + return crc; +} + +bool Tmc2209Driver::write_register(const std::uint8_t address, + const std::uint32_t value) { + std::uint8_t datagram[8] = { + kSync, + config_.address, + static_cast(address | kWriteBit), + static_cast((value >> 24) & 0xFFU), + static_cast((value >> 16) & 0xFFU), + static_cast((value >> 8) & 0xFFU), + static_cast(value & 0xFFU), + 0, + }; + datagram[7] = calculate_crc(datagram, 7); + return platform_.write_uart(config_.uart_channel, datagram, sizeof(datagram)); +} + +bool Tmc2209Driver::read_register(const std::uint8_t address, + std::uint32_t& value) { + std::uint8_t request[4] = {kSync, config_.address, address, 0}; + request[3] = calculate_crc(request, 3); + platform_.flush_uart_input(config_.uart_channel); + if (!platform_.write_uart(config_.uart_channel, request, sizeof(request))) { + return false; + } + + std::uint8_t response[16] = {}; + const std::size_t received = + platform_.read_uart(config_.uart_channel, response, sizeof(response), + config_.uart_timeout_ms); + if (received < 8) { + return false; + } + for (std::size_t offset = 0; offset + 8 <= received; ++offset) { + const std::uint8_t* frame = response + offset; + if (frame[0] != kSync || frame[1] != kMasterAddress || + frame[2] != address || calculate_crc(frame, 7) != frame[7]) { + continue; + } + value = (static_cast(frame[3]) << 24) | + (static_cast(frame[4]) << 16) | + (static_cast(frame[5]) << 8) | + static_cast(frame[6]); + return true; + } + return false; +} + +bool Tmc2209Driver::verify_write_counter(const std::uint8_t before) { + std::uint32_t after = 0; + return read_register(kRegisterIfcnt, after) && + static_cast(after) == + static_cast(before + 1U); +} + +bool Tmc2209Driver::initialize() { + connected_ = false; + enabled_ = false; + if (!valid_config() || + !platform_.configure_pin(config_.step_pin, PinMode::output) || + !platform_.configure_pin(config_.direction_pin, PinMode::output) || + !platform_.configure_pin(config_.enable_pin, PinMode::output)) { + return false; + } + platform_.write_pin(config_.step_pin, false); + platform_.write_pin(config_.direction_pin, config_.direction_inverted); + disable(); + if (!platform_.begin_uart(config_.uart_channel, config_.uart_tx_pin, + config_.uart_rx_pin, config_.uart_baud)) { + return false; + } + + std::uint32_t ifcnt = 0; + if (!read_register(kRegisterIfcnt, ifcnt)) { + return false; + } + gconf_ = kGconfPdnDisable | kGconfMstepRegisterSelect | + kGconfMultistepFilter; + if (!write_register(kRegisterGconf, gconf_) || + !verify_write_counter(static_cast(ifcnt))) { + return false; + } + if (!write_register(kRegisterSlaveconf, 2UL << 8) || + !write_register(kRegisterTpowerdown, 10) || + !write_register(kRegisterPwmconf, + 0xC10D0024UL | kPwmAutoscale | kPwmAutograd)) { + return false; + } + connected_ = true; + return set_microsteps(configured_microsteps_) && + set_interpolation(interpolation_) && + set_chopper_mode(chopper_mode_); +} + +bool Tmc2209Driver::enable() { + if (!connected_) { + return false; + } + platform_.write_pin(config_.enable_pin, !config_.enable_active_low); + enabled_ = true; + return true; +} + +void Tmc2209Driver::disable() { + platform_.write_pin(config_.enable_pin, config_.enable_active_low); + platform_.write_pin(config_.step_pin, false); + enabled_ = false; +} + +bool Tmc2209Driver::set_direction(const bool positive) { + if (!connected_) { + return false; + } + platform_.write_pin(config_.direction_pin, + positive != config_.direction_inverted); + return true; +} + +void Tmc2209Driver::set_step(const bool high) { + platform_.write_pin(config_.step_pin, high); +} + +bool Tmc2209Driver::set_current_milliamps( + const std::uint16_t rms_current_ma, const std::uint8_t hold_percent) { + if (!connected_ || rms_current_ma == 0 || + rms_current_ma > config_.maximum_rms_current_ma || + hold_percent > 100) { + return false; + } + + const double resistance_ohms = + static_cast(config_.sense_resistor_milliohms) / 1000.0 + 0.02; + const double current_amps = static_cast(rms_current_ma) / 1000.0; + double voltage = 0.325; + long run_scale = std::lround(32.0 * 1.41421356237 * current_amps * + resistance_ohms / voltage - + 1.0); + if (run_scale < 16) { + voltage = 0.180; + run_scale = std::lround(32.0 * 1.41421356237 * current_amps * + resistance_ohms / voltage - + 1.0); + chopconf_ |= kChopconfVsense; + } else { + chopconf_ &= ~kChopconfVsense; + } + if (run_scale < 0 || run_scale > 31) { + return false; + } + const std::uint8_t run = clamp_scale(run_scale); + const long hold_scale = + std::lround((static_cast(run + 1U) * hold_percent / 100.0) - 1.0); + const std::uint8_t hold = clamp_scale(hold_scale); + const std::uint32_t ihold_irun = + static_cast(hold) | + (static_cast(run) << 8) | (6UL << 16); + if (!write_register(kRegisterChopconf, chopconf_) || + !write_register(kRegisterIholdIrun, ihold_irun)) { + connected_ = false; + disable(); + return false; + } + configured_current_ma_ = rms_current_ma; + return true; +} + +bool Tmc2209Driver::microstep_code(const std::uint16_t microsteps, + std::uint8_t& code) { + switch (microsteps) { + case 256: + code = 0; + return true; + case 128: + code = 1; + return true; + case 64: + code = 2; + return true; + case 32: + code = 3; + return true; + case 16: + code = 4; + return true; + case 8: + code = 5; + return true; + case 4: + code = 6; + return true; + case 2: + code = 7; + return true; + case 1: + code = 8; + return true; + default: + return false; + } +} + +bool Tmc2209Driver::set_microsteps(const std::uint16_t microsteps) { + std::uint8_t code = 0; + if (!connected_ || !microstep_code(microsteps, code)) { + return false; + } + chopconf_ = (chopconf_ & ~kChopconfMresMask) | + (static_cast(code) << 24); + if (!write_register(kRegisterChopconf, chopconf_)) { + connected_ = false; + disable(); + return false; + } + configured_microsteps_ = microsteps; + return true; +} + +bool Tmc2209Driver::set_interpolation(const bool enabled) { + if (!connected_) { + return false; + } + interpolation_ = enabled; + if (enabled) { + chopconf_ |= kChopconfInterpolation; + } else { + chopconf_ &= ~kChopconfInterpolation; + } + return write_register(kRegisterChopconf, chopconf_); +} + +bool Tmc2209Driver::set_chopper_mode(const ChopperMode mode) { + if (!connected_) { + return false; + } + chopper_mode_ = mode; + if (mode == ChopperMode::spreadcycle) { + gconf_ |= kGconfSpreadcycle; + } else { + gconf_ &= ~kGconfSpreadcycle; + } + return write_register(kRegisterGconf, gconf_); +} + +DriverFault Tmc2209Driver::primary_fault(const DriverStatus& status) { + if (!status.connected) { + return DriverFault::communication_failure; + } + if (status.overtemperature_shutdown) { + return DriverFault::overtemperature_shutdown; + } + if (status.short_to_ground_a || status.short_to_ground_b) { + return DriverFault::short_to_ground; + } + if (status.short_to_supply_a || status.short_to_supply_b) { + return DriverFault::short_to_supply; + } + if (status.undervoltage) { + return DriverFault::undervoltage; + } + if (status.overtemperature_warning) { + return DriverFault::overtemperature_warning; + } + if (status.reset_detected) { + return DriverFault::reset_detected; + } + if (status.open_load_a || status.open_load_b) { + return DriverFault::open_load; + } + return DriverFault::none; +} + +DriverStatus Tmc2209Driver::read_status() { + DriverStatus status; + status.connected = connected_; + status.enabled = enabled_; + std::uint32_t gstat = 0; + std::uint32_t driver = 0; + if (!connected_ || !read_register(kRegisterGstat, gstat) || + !read_register(kRegisterDrvStatus, driver)) { + connected_ = false; + disable(); + status.connected = false; + status.enabled = false; + status.fault = DriverFault::communication_failure; + return status; + } + status.reset_detected = (gstat & (1UL << 0)) != 0; + status.undervoltage = (gstat & (1UL << 2)) != 0; + status.standstill = (driver & (1UL << 31)) != 0; + status.stealthchop_active = (driver & (1UL << 30)) != 0; + status.current_scale = static_cast((driver >> 16) & 0x1FU); + status.overtemperature_warning = (driver & (1UL << 0)) != 0; + status.overtemperature_shutdown = (driver & (1UL << 1)) != 0; + status.short_to_ground_a = (driver & (1UL << 2)) != 0; + status.short_to_ground_b = (driver & (1UL << 3)) != 0; + status.short_to_supply_a = (driver & (1UL << 4)) != 0; + status.short_to_supply_b = (driver & (1UL << 5)) != 0; + status.open_load_a = (driver & (1UL << 6)) != 0; + status.open_load_b = (driver & (1UL << 7)) != 0; + status.fault = primary_fault(status); + if (status.critical_fault()) { + disable(); + status.enabled = false; + } + return status; +} + +bool Tmc2209Driver::is_connected() const { return connected_; } + +} // namespace radiance3d diff --git a/firmware/controller/test/test_driver/test_main.cpp b/firmware/controller/test/test_driver/test_main.cpp new file mode 100644 index 0000000..aa0fc74 --- /dev/null +++ b/firmware/controller/test/test_driver/test_main.cpp @@ -0,0 +1,184 @@ +#include + +#include +#include +#include + +#include "hardware_platform.hpp" +#include "tmc2209_driver.hpp" + +namespace { + +class FakePlatform final : public radiance3d::HardwarePlatform { + public: + bool uart_present{true}; + bool uart_started{false}; + bool pin_values[64]{}; + std::array registers{}; + + bool configure_pin(int pin, radiance3d::PinMode) override { + return pin >= 0 && pin < 64; + } + + void write_pin(int pin, bool high) override { + if (pin >= 0 && pin < 64) { + pin_values[pin] = high; + } + } + + bool read_pin(int pin) const override { + return pin >= 0 && pin < 64 && pin_values[pin]; + } + + std::uint64_t monotonic_micros() const override { return 0; } + + bool begin_uart(std::uint8_t, int, int, std::uint32_t) override { + uart_started = true; + return true; + } + + void flush_uart_input(std::uint8_t) override {} + + bool write_uart(std::uint8_t, const std::uint8_t* data, + std::size_t length) override { + if (!uart_present) { + return false; + } + if (length == 8 && data[0] == 0x05 && data[1] <= 3 && + radiance3d::Tmc2209Driver::calculate_crc(data, 7) == data[7]) { + const std::uint8_t address = static_cast(data[2] & 0x7FU); + registers[address] = (static_cast(data[3]) << 24) | + (static_cast(data[4]) << 16) | + (static_cast(data[5]) << 8) | + static_cast(data[6]); + registers[0x02] = static_cast(registers[0x02] + 1U); + pending_register_ = address; + return true; + } + if (length == 4 && data[0] == 0x05 && data[1] <= 3 && + radiance3d::Tmc2209Driver::calculate_crc(data, 3) == data[3]) { + pending_register_ = data[2]; + return true; + } + return false; + } + + std::size_t read_uart(std::uint8_t, std::uint8_t* data, + std::size_t maximum_length, std::uint32_t) override { + if (!uart_present || maximum_length < 8) { + return 0; + } + const std::uint32_t value = registers[pending_register_]; + data[0] = 0x05; + data[1] = 0xFF; + data[2] = pending_register_; + data[3] = static_cast((value >> 24) & 0xFFU); + data[4] = static_cast((value >> 16) & 0xFFU); + data[5] = static_cast((value >> 8) & 0xFFU); + data[6] = static_cast(value & 0xFFU); + data[7] = radiance3d::Tmc2209Driver::calculate_crc(data, 7); + return 8; + } + + private: + std::uint8_t pending_register_{0}; +}; + +radiance3d::Tmc2209Config config() { + radiance3d::Tmc2209Config value; + value.uart_channel = 1; + value.address = 0; + value.uart_tx_pin = 17; + value.uart_rx_pin = 16; + value.step_pin = 25; + value.direction_pin = 26; + value.enable_pin = 27; + value.sense_resistor_milliohms = 110; + value.maximum_rms_current_ma = 800; + return value; +} + +} // namespace + +void setUp() {} +void tearDown() {} + +void test_successful_initialization_starts_disabled_and_probes_uart() { + FakePlatform platform; + radiance3d::Tmc2209Driver driver(platform, config()); + + TEST_ASSERT_TRUE(driver.initialize()); + TEST_ASSERT_TRUE(driver.is_connected()); + TEST_ASSERT_TRUE(platform.uart_started); + TEST_ASSERT_TRUE(platform.pin_values[27]); +} + +void test_failed_uart_probe_keeps_driver_disabled() { + FakePlatform platform; + platform.uart_present = false; + radiance3d::Tmc2209Driver driver(platform, config()); + + TEST_ASSERT_FALSE(driver.initialize()); + TEST_ASSERT_FALSE(driver.is_connected()); + TEST_ASSERT_TRUE(platform.pin_values[27]); +} + +void test_invalid_driver_address_is_rejected() { + FakePlatform platform; + auto invalid = config(); + invalid.address = 4; + radiance3d::Tmc2209Driver driver(platform, invalid); + + TEST_ASSERT_FALSE(driver.initialize()); + TEST_ASSERT_FALSE(driver.enable()); +} + +void test_current_is_configurable_and_safe_ceiling_is_enforced() { + FakePlatform platform; + radiance3d::Tmc2209Driver driver(platform, config()); + TEST_ASSERT_TRUE(driver.initialize()); + + TEST_ASSERT_TRUE(driver.set_current_milliamps(400, 30)); + TEST_ASSERT_EQUAL_UINT16(400, driver.configured_current_ma()); + TEST_ASSERT_FALSE(driver.set_current_milliamps(801, 30)); + TEST_ASSERT_FALSE(driver.set_current_milliamps(400, 101)); +} + +void test_supported_microsteps_are_written_and_unsupported_values_rejected() { + FakePlatform platform; + radiance3d::Tmc2209Driver driver(platform, config()); + TEST_ASSERT_TRUE(driver.initialize()); + + TEST_ASSERT_TRUE(driver.set_microsteps(32)); + TEST_ASSERT_EQUAL_UINT16(32, driver.configured_microsteps()); + TEST_ASSERT_FALSE(driver.set_microsteps(3)); +} + +void test_diagnostics_map_faults_and_critical_fault_disables_output() { + FakePlatform platform; + radiance3d::Tmc2209Driver driver(platform, config()); + TEST_ASSERT_TRUE(driver.initialize()); + TEST_ASSERT_TRUE(driver.enable()); + platform.registers[0x01] = 1U << 1; + platform.registers[0x6F] = (1UL << 1) | (1UL << 2); + + const radiance3d::DriverStatus status = driver.read_status(); + + TEST_ASSERT_TRUE(status.overtemperature_shutdown); + TEST_ASSERT_TRUE(status.short_to_ground_a); + TEST_ASSERT_TRUE(status.critical_fault()); + TEST_ASSERT_EQUAL(radiance3d::DriverFault::overtemperature_shutdown, + status.fault); + TEST_ASSERT_TRUE(platform.pin_values[27]); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_successful_initialization_starts_disabled_and_probes_uart); + RUN_TEST(test_failed_uart_probe_keeps_driver_disabled); + RUN_TEST(test_invalid_driver_address_is_rejected); + RUN_TEST(test_current_is_configurable_and_safe_ceiling_is_enforced); + RUN_TEST(test_supported_microsteps_are_written_and_unsupported_values_rejected); + RUN_TEST(test_diagnostics_map_faults_and_critical_fault_disables_output); + return UNITY_END(); +} From e8da447fef0ec02b7532590ce8b4cefb828cb7a9 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 22:41:51 -0400 Subject: [PATCH 04/13] feat(firmware): implement axis motion homing and safety --- firmware/config/provisional-esp32dev-v1.json | 72 +++ .../controller/include/axis_controller.hpp | 92 +++ .../controller/include/hardware_config.hpp | 37 ++ .../controller/include/motion_controller.hpp | 99 +++- .../include/physical_motion_controller.hpp | 72 +++ .../controller/include/stepper_driver.hpp | 3 +- firmware/controller/src/axis_controller.cpp | 561 ++++++++++++++++++ firmware/controller/src/hardware_config.cpp | 156 +++++ firmware/controller/src/motion_controller.cpp | 178 +++++- .../src/physical_motion_controller.cpp | 393 ++++++++++++ firmware/controller/src/protocol.cpp | 14 + .../controller/test/test_axis/test_main.cpp | 318 ++++++++++ .../test/test_physical/test_main.cpp | 245 ++++++++ 13 files changed, 2217 insertions(+), 23 deletions(-) create mode 100644 firmware/config/provisional-esp32dev-v1.json create mode 100644 firmware/controller/include/axis_controller.hpp create mode 100644 firmware/controller/include/hardware_config.hpp create mode 100644 firmware/controller/include/physical_motion_controller.hpp create mode 100644 firmware/controller/src/axis_controller.cpp create mode 100644 firmware/controller/src/hardware_config.cpp create mode 100644 firmware/controller/src/physical_motion_controller.cpp create mode 100644 firmware/controller/test/test_axis/test_main.cpp create mode 100644 firmware/controller/test/test_physical/test_main.cpp diff --git a/firmware/config/provisional-esp32dev-v1.json b/firmware/config/provisional-esp32dev-v1.json new file mode 100644 index 0000000..706b456 --- /dev/null +++ b/firmware/config/provisional-esp32dev-v1.json @@ -0,0 +1,72 @@ +{ + "status": "provisional-development-only", + "controller": { + "board": "esp32dev", + "protocol_version": 1, + "usb_serial_baud": 115200, + "emergency_stop_pin": 13, + "emergency_stop_active_low": true + }, + "power": { + "motor_supply_voltage": 12.0, + "esp32_supply": "regulated board-supported input from buck converter" + }, + "axes": { + "azimuth": { + "driver": "tmc2209", + "uart_channel": 1, + "uart_address": 0, + "uart_tx_pin": 22, + "uart_rx_pin": 21, + "step_pin": 25, + "direction_pin": 26, + "enable_pin": 27, + "home_switch_pin": 32, + "home_switch_normally_closed": true, + "motor_full_steps_per_revolution": 200, + "microsteps": 16, + "gear_ratio": 1.0, + "direction_inverted": false, + "minimum_angle_deg": 0.0, + "maximum_angle_deg": 360.0, + "home_offset_deg": 0.0, + "rms_current_ma": 400, + "maximum_rms_current_ma": 800, + "hold_current_percent": 30, + "max_speed_deg_s": 10.0, + "acceleration_deg_s2": 20.0, + "home_speed_deg_s": 5.0, + "slow_home_speed_deg_s": 1.0, + "homing_backoff_deg": 3.0, + "motion_timeout_ms": 60000 + }, + "elevation": { + "driver": "tmc2209", + "uart_channel": 2, + "uart_address": 0, + "uart_tx_pin": 17, + "uart_rx_pin": 16, + "step_pin": 18, + "direction_pin": 19, + "enable_pin": 23, + "home_switch_pin": 33, + "home_switch_normally_closed": true, + "motor_full_steps_per_revolution": 200, + "microsteps": 16, + "gear_ratio": 1.0, + "direction_inverted": false, + "minimum_angle_deg": -90.0, + "maximum_angle_deg": 90.0, + "home_offset_deg": 0.0, + "rms_current_ma": 400, + "maximum_rms_current_ma": 800, + "hold_current_percent": 30, + "max_speed_deg_s": 8.0, + "acceleration_deg_s2": 15.0, + "home_speed_deg_s": 4.0, + "slow_home_speed_deg_s": 1.0, + "homing_backoff_deg": 3.0, + "motion_timeout_ms": 60000 + } + } +} diff --git a/firmware/controller/include/axis_controller.hpp b/firmware/controller/include/axis_controller.hpp new file mode 100644 index 0000000..5f037ee --- /dev/null +++ b/firmware/controller/include/axis_controller.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include "hardware_platform.hpp" +#include "motion_controller.hpp" +#include "stepper_driver.hpp" + +#include + +namespace radiance3d { + +struct PhysicalAxisConfig { + const char* name{"axis"}; + AxisConfig motion{}; + int home_switch_pin{-1}; +}; + +class AxisController { + public: + AxisController(HardwarePlatform& platform, StepperDriver& driver, + PhysicalAxisConfig config); + + bool initialize(); + void service(); + MotionResult start_homing(std::uint32_t command_id = 0); + MotionResult move_absolute_degrees(double target_deg, double speed_deg_per_s, + std::uint32_t command_id = 0); + MotionResult move_relative_degrees(double delta_deg, double speed_deg_per_s, + std::uint32_t command_id = 0); + MotionResult bench_move_steps(std::int64_t signed_steps, + std::uint32_t command_id = 0); + MotionResult stop(bool invalidate_position = true); + MotionResult emergency_stop(); + MotionResult set_enabled(bool enabled); + MotionResult clear_fault(); + MotionResult set_current(std::uint16_t rms_current_ma); + MotionResult set_microsteps(std::uint16_t microsteps); + void mark_position_untrusted(TrustLossReason reason); + + const PhysicalAxisConfig& config() const; + const AxisState& state() const; + AxisState& mutable_state(); + DriverCapabilities driver_capabilities() const; + DriverStatus driver_status() const; + + bool degrees_to_steps(double degrees, std::int64_t& steps) const; + double steps_to_degrees(std::int64_t steps) const; + bool motor_full_steps_to_output_steps(std::int64_t motor_full_steps, + std::int64_t& output_steps) const; + + private: + enum class MotionPurpose { none, normal, bench, homing_fast, homing_backoff, homing_slow }; + + HardwarePlatform& platform_; + StepperDriver& driver_; + PhysicalAxisConfig config_; + AxisState state_{}; + MotionPurpose motion_purpose_{MotionPurpose::none}; + std::uint64_t motion_started_us_{0}; + std::uint64_t homing_started_us_{0}; + std::uint64_t next_edge_us_{0}; + std::uint64_t last_status_check_us_{0}; + bool step_high_{false}; + int step_direction_{0}; + double current_speed_steps_s_{0.0}; + double requested_speed_steps_s_{0.0}; + bool raw_home_candidate_{false}; + bool debounced_home_active_{false}; + bool home_input_initialized_{false}; + std::uint64_t home_candidate_changed_us_{0}; + std::uint32_t active_command_id_{0}; + + MotionResult fail(FaultCode fault, TrustLossReason reason, + bool disable_driver); + MotionResult succeed(); + MotionResult start_step_move(std::int64_t target_steps, + double speed_deg_per_s, + MotionPurpose purpose, + std::uint32_t command_id, + bool require_trust); + void service_step_generator(std::uint64_t now_us); + void service_homing(std::uint64_t now_us); + void service_driver_status(std::uint64_t now_us); + void finish_motion(); + void stop_pulse_generation(); + void update_home_switch(std::uint64_t now_us); + bool home_switch_active_raw() const; + std::uint64_t step_interval_us(std::int64_t remaining_steps); + std::int64_t minimum_steps() const; + std::int64_t maximum_steps() const; +}; + +} // namespace radiance3d diff --git a/firmware/controller/include/hardware_config.hpp b/firmware/controller/include/hardware_config.hpp new file mode 100644 index 0000000..df4e7da --- /dev/null +++ b/firmware/controller/include/hardware_config.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "axis_controller.hpp" +#include "motion_controller.hpp" +#include "tmc2209_driver.hpp" + +#include + +namespace radiance3d { + +struct PhysicalAxisDefinition { + PhysicalAxisConfig axis{}; + Tmc2209Config driver{}; +}; + +struct PhysicalControllerConfig { + const char* board_name{"unassigned"}; + std::uint32_t protocol_version{1}; + PhysicalAxisDefinition azimuth{}; + PhysicalAxisDefinition elevation{}; + int emergency_stop_pin{-1}; + bool emergency_stop_active_low{true}; + std::uint32_t emergency_stop_debounce_ms{10}; +}; + +struct GpioValidationResult { + bool valid{false}; + int duplicate_pin{-1}; + int invalid_output_pin{-1}; + std::uint64_t bootstrapping_pin_mask{0}; +}; + +GpioValidationResult validate_esp32_gpio( + const PhysicalControllerConfig& config); +PhysicalControllerConfig provisional_esp32_dev_config(); + +} // namespace radiance3d diff --git a/firmware/controller/include/motion_controller.hpp b/firmware/controller/include/motion_controller.hpp index e912156..15bd766 100644 --- a/firmware/controller/include/motion_controller.hpp +++ b/firmware/controller/include/motion_controller.hpp @@ -1,5 +1,7 @@ #pragma once +#include "stepper_driver.hpp" + #include namespace radiance3d { @@ -17,9 +19,45 @@ enum class FaultCode { motion_timeout, driver_disabled, emergency_stop, + driver_communication, + driver_critical, + homing_stuck_switch, + homing_switch_never_triggered, + homing_switch_failed_release, + homing_timeout, + unexpected_home_switch, + stopped, +}; + +enum class TrustLossReason { + startup, + none, + reset, + power_loss, + emergency_stop, + driver_fault, + driver_disabled_during_motion, + motion_timeout, + suspected_missed_step, + manual_movement, + configuration_changed, + homing_failed, + watchdog_reset_during_motion, stopped, }; +enum class HomingPhase { + idle, + validate_switch, + fast_approach, + backoff, + confirm_release, + slow_approach, + apply_offset, + complete, + failed, +}; + struct HomingConfig { bool switch_normally_closed{true}; bool direction_negative{true}; @@ -27,12 +65,14 @@ struct HomingConfig { double speed_deg_per_s{5.0}; double backoff_deg{2.0}; double slow_approach_deg_per_s{1.0}; + std::uint32_t timeout_ms{60000}; }; struct AxisConfig { std::uint16_t motor_full_steps_per_revolution{200}; std::uint16_t microsteps{16}; std::uint16_t motor_rms_current_ma{0}; + std::uint8_t hold_current_percent{30}; double gear_ratio{1.0}; bool direction_inverted{false}; double home_offset_deg{0.0}; @@ -40,6 +80,9 @@ struct AxisConfig { double maximum_angle_deg{360.0}; double maximum_speed_deg_per_s{20.0}; double acceleration_deg_per_s2{40.0}; + std::uint32_t settling_time_ms{250}; + std::uint32_t motion_timeout_ms{60000}; + std::int64_t maximum_bench_test_steps{3200}; HomingConfig homing{}; double steps_per_output_revolution() const; @@ -58,10 +101,18 @@ struct ControllerConfig { struct AxisState { double commanded_position_deg{0.0}; + std::int64_t internal_step_position{0}; + std::int64_t target_step_position{0}; bool homed{false}; bool position_trusted{false}; - bool enabled{true}; + TrustLossReason trust_loss_reason{TrustLossReason::startup}; + bool moving{false}; + bool enabled{false}; bool home_switch_active{false}; + FaultCode fault{FaultCode::none}; + HomingPhase homing_phase{HomingPhase::idle}; + DriverStatus last_driver_status{}; + std::uint32_t last_completed_command{0}; }; struct ControllerState { @@ -81,15 +132,33 @@ class MotionController { public: virtual ~MotionController() = default; + virtual bool initialize() = 0; + virtual void service() = 0; virtual const ControllerConfig& config() const = 0; virtual const ControllerState& state() const = 0; - virtual MotionResult home(AxisSelection axis) = 0; + virtual MotionResult home(AxisSelection axis, + std::uint32_t command_id = 0) = 0; virtual MotionResult move_absolute(double azimuth_deg, double elevation_deg, - double speed_deg_per_s) = 0; + double speed_deg_per_s, + std::uint32_t command_id = 0) = 0; virtual MotionResult stop() = 0; virtual MotionResult emergency_stop() = 0; virtual MotionResult clear_fault() = 0; virtual MotionResult set_enabled(bool enabled) = 0; + virtual MotionResult move_relative(AxisSelection axis, double delta_deg, + double speed_deg_per_s, + std::uint32_t command_id = 0) = 0; + virtual MotionResult bench_move_steps(AxisSelection axis, + std::int64_t signed_steps, + std::uint32_t command_id = 0) = 0; + virtual MotionResult stop_axis(AxisSelection axis) = 0; + virtual MotionResult set_axis_enabled(AxisSelection axis, bool enabled) = 0; + virtual MotionResult set_axis_current(AxisSelection axis, + std::uint16_t rms_current_ma) = 0; + virtual MotionResult set_axis_microsteps(AxisSelection axis, + std::uint16_t microsteps) = 0; + virtual DriverCapabilities driver_capabilities(AxisSelection axis) const = 0; + virtual DriverStatus driver_status(AxisSelection axis) const = 0; virtual void report_fault(FaultCode code) = 0; }; @@ -97,15 +166,33 @@ class SimulatedMotionController final : public MotionController { public: explicit SimulatedMotionController(ControllerConfig config = {}); + bool initialize() override; + void service() override; const ControllerConfig& config() const override; const ControllerState& state() const override; - MotionResult home(AxisSelection axis) override; + MotionResult home(AxisSelection axis, + std::uint32_t command_id = 0) override; MotionResult move_absolute(double azimuth_deg, double elevation_deg, - double speed_deg_per_s) override; + double speed_deg_per_s, + std::uint32_t command_id = 0) override; MotionResult stop() override; MotionResult emergency_stop() override; MotionResult clear_fault() override; MotionResult set_enabled(bool enabled) override; + MotionResult move_relative(AxisSelection axis, double delta_deg, + double speed_deg_per_s, + std::uint32_t command_id = 0) override; + MotionResult bench_move_steps(AxisSelection axis, + std::int64_t signed_steps, + std::uint32_t command_id = 0) override; + MotionResult stop_axis(AxisSelection axis) override; + MotionResult set_axis_enabled(AxisSelection axis, bool enabled) override; + MotionResult set_axis_current(AxisSelection axis, + std::uint16_t rms_current_ma) override; + MotionResult set_axis_microsteps(AxisSelection axis, + std::uint16_t microsteps) override; + DriverCapabilities driver_capabilities(AxisSelection axis) const override; + DriverStatus driver_status(AxisSelection axis) const override; void report_fault(FaultCode code) override; private: @@ -114,7 +201,7 @@ class SimulatedMotionController final : public MotionController { MotionResult fail(FaultCode code); MotionResult succeed(); - static void invalidate(AxisState& axis); + static void invalidate(AxisState& axis, TrustLossReason reason); }; ControllerConfig provisional_simulator_config(); diff --git a/firmware/controller/include/physical_motion_controller.hpp b/firmware/controller/include/physical_motion_controller.hpp new file mode 100644 index 0000000..4bb057d --- /dev/null +++ b/firmware/controller/include/physical_motion_controller.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "axis_controller.hpp" +#include "hardware_config.hpp" +#include "hardware_platform.hpp" +#include "motion_controller.hpp" + +#include + +namespace radiance3d { + +class PhysicalMotionController final : public MotionController { + public: + PhysicalMotionController(HardwarePlatform& platform, AxisController& azimuth, + AxisController& elevation, + PhysicalControllerConfig physical_config); + + bool initialize() override; + void service() override; + const ControllerConfig& config() const override; + const ControllerState& state() const override; + MotionResult home(AxisSelection axis, + std::uint32_t command_id = 0) override; + MotionResult move_absolute(double azimuth_deg, double elevation_deg, + double speed_deg_per_s, + std::uint32_t command_id = 0) override; + MotionResult stop() override; + MotionResult emergency_stop() override; + MotionResult clear_fault() override; + MotionResult set_enabled(bool enabled) override; + MotionResult move_relative(AxisSelection axis, double delta_deg, + double speed_deg_per_s, + std::uint32_t command_id = 0) override; + MotionResult bench_move_steps(AxisSelection axis, + std::int64_t signed_steps, + std::uint32_t command_id = 0) override; + MotionResult stop_axis(AxisSelection axis) override; + MotionResult set_axis_enabled(AxisSelection axis, bool enabled) override; + MotionResult set_axis_current(AxisSelection axis, + std::uint16_t rms_current_ma) override; + MotionResult set_axis_microsteps(AxisSelection axis, + std::uint16_t microsteps) override; + DriverCapabilities driver_capabilities(AxisSelection axis) const override; + DriverStatus driver_status(AxisSelection axis) const override; + void report_fault(FaultCode code) override; + + const PhysicalControllerConfig& physical_config() const; + + private: + HardwarePlatform& platform_; + AxisController& azimuth_; + AxisController& elevation_; + PhysicalControllerConfig physical_config_; + ControllerConfig config_{}; + ControllerState state_{}; + bool initialized_{false}; + bool coordinated_move_active_{false}; + bool emergency_latched_{false}; + bool emergency_candidate_{false}; + bool emergency_stable_{false}; + bool emergency_input_initialized_{false}; + std::uint64_t emergency_changed_us_{0}; + + AxisController* selected_axis(AxisSelection axis); + const AxisController* selected_axis(AxisSelection axis) const; + void synchronize_state(); + void update_emergency_input(std::uint64_t now_us); + bool emergency_input_active_raw() const; + MotionResult reject(FaultCode fault); +}; + +} // namespace radiance3d diff --git a/firmware/controller/include/stepper_driver.hpp b/firmware/controller/include/stepper_driver.hpp index 6d4aa35..b56fc80 100644 --- a/firmware/controller/include/stepper_driver.hpp +++ b/firmware/controller/include/stepper_driver.hpp @@ -47,7 +47,8 @@ struct DriverStatus { DriverFault fault{DriverFault::none}; bool critical_fault() const { - return !connected || undervoltage || overtemperature_shutdown || + return !connected || reset_detected || undervoltage || + overtemperature_shutdown || short_to_ground_a || short_to_ground_b || short_to_supply_a || short_to_supply_b; } diff --git a/firmware/controller/src/axis_controller.cpp b/firmware/controller/src/axis_controller.cpp new file mode 100644 index 0000000..09eac79 --- /dev/null +++ b/firmware/controller/src/axis_controller.cpp @@ -0,0 +1,561 @@ +#include "axis_controller.hpp" + +#include +#include +#include +#include + +namespace radiance3d { +namespace { + +constexpr std::uint64_t kStepPulseWidthUs = 2; +constexpr std::uint64_t kDirectionSetupUs = 2; +constexpr std::uint64_t kMinimumStepLowUs = 2; +constexpr std::uint64_t kDriverStatusIntervalUs = 100000; + +bool elapsed(const std::uint64_t now, const std::uint64_t started, + const std::uint64_t duration) { + return now - started >= duration; +} + +} // namespace + +AxisController::AxisController(HardwarePlatform& platform, StepperDriver& driver, + PhysicalAxisConfig config) + : platform_(platform), driver_(driver), config_(config) {} + +const PhysicalAxisConfig& AxisController::config() const { return config_; } + +const AxisState& AxisController::state() const { return state_; } + +AxisState& AxisController::mutable_state() { return state_; } + +DriverCapabilities AxisController::driver_capabilities() const { + return driver_.capabilities(); +} + +DriverStatus AxisController::driver_status() const { + return state_.last_driver_status; +} + +bool AxisController::degrees_to_steps(const double degrees, + std::int64_t& steps) const { + if (!std::isfinite(degrees) || !config_.motion.valid()) { + return false; + } + const long double scaled = + static_cast(degrees) * + static_cast(config_.motion.steps_per_output_revolution()) / + 360.0L; + if (scaled > + static_cast(std::numeric_limits::max()) || + scaled < + static_cast(std::numeric_limits::min())) { + return false; + } + steps = static_cast(std::llround(scaled)); + return true; +} + +double AxisController::steps_to_degrees(const std::int64_t steps) const { + return static_cast(steps) * 360.0 / + config_.motion.steps_per_output_revolution(); +} + +bool AxisController::motor_full_steps_to_output_steps( + const std::int64_t motor_full_steps, std::int64_t& output_steps) const { + const long double scaled = + static_cast(motor_full_steps) * + static_cast(config_.motion.microsteps) * + static_cast(config_.motion.gear_ratio); + if (scaled > + static_cast(std::numeric_limits::max()) || + scaled < + static_cast(std::numeric_limits::min())) { + return false; + } + output_steps = static_cast(std::llround(scaled)); + return true; +} + +std::int64_t AxisController::minimum_steps() const { + std::int64_t value = 0; + degrees_to_steps(config_.motion.minimum_angle_deg, value); + return value; +} + +std::int64_t AxisController::maximum_steps() const { + std::int64_t value = 0; + degrees_to_steps(config_.motion.maximum_angle_deg, value); + return value; +} + +bool AxisController::initialize() { + state_ = AxisState{}; + state_.enabled = false; + state_.trust_loss_reason = TrustLossReason::startup; + if (config_.name == nullptr || config_.name[0] == '\0' || + config_.home_switch_pin < 0 || !config_.motion.valid() || + config_.motion.motor_rms_current_ma == 0 || + !platform_.configure_pin(config_.home_switch_pin, PinMode::input_pullup) || + !driver_.initialize()) { + state_.fault = driver_.is_connected() + ? FaultCode::invalid_configuration + : FaultCode::driver_communication; + return false; + } + if (!driver_.set_current_milliamps( + config_.motion.motor_rms_current_ma, + config_.motion.hold_current_percent) || + !driver_.set_microsteps(config_.motion.microsteps) || + !driver_.set_interpolation(true) || + !driver_.set_chopper_mode(ChopperMode::stealthchop)) { + driver_.disable(); + state_.fault = FaultCode::invalid_configuration; + return false; + } + driver_.disable(); + update_home_switch(platform_.monotonic_micros()); + state_.last_driver_status = driver_.read_status(); + if (state_.last_driver_status.critical_fault()) { + state_.fault = FaultCode::driver_critical; + return false; + } + return true; +} + +MotionResult AxisController::fail(const FaultCode fault, + const TrustLossReason reason, + const bool disable_driver) { + stop_pulse_generation(); + state_.fault = fault; + state_.homing_phase = HomingPhase::failed; + mark_position_untrusted(reason); + if (disable_driver) { + driver_.disable(); + state_.enabled = false; + } + return MotionResult{false, fault}; +} + +MotionResult AxisController::succeed() { + state_.fault = FaultCode::none; + return MotionResult{true, FaultCode::none}; +} + +void AxisController::mark_position_untrusted(const TrustLossReason reason) { + state_.position_trusted = false; + state_.homed = false; + state_.trust_loss_reason = reason; +} + +bool AxisController::home_switch_active_raw() const { + const bool level_high = platform_.read_pin(config_.home_switch_pin); + return config_.motion.homing.switch_normally_closed ? level_high + : !level_high; +} + +void AxisController::update_home_switch(const std::uint64_t now_us) { + const bool raw = home_switch_active_raw(); + if (!home_input_initialized_) { + raw_home_candidate_ = raw; + debounced_home_active_ = raw; + home_candidate_changed_us_ = now_us; + home_input_initialized_ = true; + state_.home_switch_active = raw; + return; + } + if (raw != raw_home_candidate_) { + raw_home_candidate_ = raw; + home_candidate_changed_us_ = now_us; + } + const std::uint64_t debounce_us = + static_cast(config_.motion.homing.debounce_ms) * 1000ULL; + if (raw_home_candidate_ != debounced_home_active_ && + elapsed(now_us, home_candidate_changed_us_, debounce_us)) { + debounced_home_active_ = raw_home_candidate_; + } + state_.home_switch_active = debounced_home_active_; +} + +MotionResult AxisController::start_step_move( + const std::int64_t target_steps, const double speed_deg_per_s, + const MotionPurpose purpose, const std::uint32_t command_id, + const bool require_trust) { + if (state_.moving || !std::isfinite(speed_deg_per_s) || + speed_deg_per_s <= 0.0 || + speed_deg_per_s > config_.motion.maximum_speed_deg_per_s) { + return MotionResult{false, FaultCode::invalid_argument}; + } + if (require_trust && !state_.position_trusted) { + return MotionResult{false, FaultCode::not_homed}; + } + if (purpose == MotionPurpose::normal && + (target_steps < minimum_steps() || target_steps > maximum_steps())) { + return MotionResult{false, FaultCode::limit_reached}; + } + if (!driver_.is_connected() || state_.last_driver_status.critical_fault()) { + return fail(FaultCode::driver_critical, TrustLossReason::driver_fault, + true); + } + if (!state_.enabled) { + if (!driver_.enable()) { + return fail(FaultCode::driver_communication, + TrustLossReason::driver_fault, true); + } + state_.enabled = true; + } + if (target_steps == state_.internal_step_position) { + state_.target_step_position = target_steps; + state_.last_completed_command = command_id; + return succeed(); + } + + step_direction_ = + target_steps > state_.internal_step_position ? 1 : -1; + if (!driver_.set_direction(step_direction_ > 0)) { + return fail(FaultCode::driver_communication, + TrustLossReason::driver_fault, true); + } + state_.target_step_position = target_steps; + state_.moving = true; + motion_purpose_ = purpose; + motion_started_us_ = platform_.monotonic_micros(); + active_command_id_ = command_id; + requested_speed_steps_s_ = + speed_deg_per_s * config_.motion.steps_per_output_revolution() / 360.0; + const double acceleration_steps_s2 = + config_.motion.acceleration_deg_per_s2 * + config_.motion.steps_per_output_revolution() / 360.0; + current_speed_steps_s_ = + std::min(requested_speed_steps_s_, + std::max(1.0, std::sqrt(2.0 * acceleration_steps_s2))); + next_edge_us_ = motion_started_us_ + kDirectionSetupUs; + step_high_ = false; + return succeed(); +} + +MotionResult AxisController::move_absolute_degrees( + const double target_deg, const double speed_deg_per_s, + const std::uint32_t command_id) { + std::int64_t target_steps = 0; + if (!degrees_to_steps(target_deg, target_steps)) { + return MotionResult{false, FaultCode::invalid_argument}; + } + return start_step_move(target_steps, speed_deg_per_s, + MotionPurpose::normal, command_id, true); +} + +MotionResult AxisController::move_relative_degrees( + const double delta_deg, const double speed_deg_per_s, + const std::uint32_t command_id) { + if (!state_.position_trusted) { + return MotionResult{false, FaultCode::not_homed}; + } + std::int64_t delta_steps = 0; + if (!degrees_to_steps(delta_deg, delta_steps) || + (delta_steps > 0 && + state_.internal_step_position > + std::numeric_limits::max() - delta_steps) || + (delta_steps < 0 && + state_.internal_step_position < + std::numeric_limits::min() - delta_steps)) { + return MotionResult{false, FaultCode::invalid_argument}; + } + return start_step_move(state_.internal_step_position + delta_steps, + speed_deg_per_s, MotionPurpose::normal, command_id, + true); +} + +MotionResult AxisController::bench_move_steps( + const std::int64_t signed_steps, const std::uint32_t command_id) { + if (signed_steps == 0 || + signed_steps > config_.motion.maximum_bench_test_steps || + signed_steps < -config_.motion.maximum_bench_test_steps) { + return MotionResult{false, FaultCode::invalid_argument}; + } + if ((signed_steps > 0 && + state_.internal_step_position > + std::numeric_limits::max() - signed_steps) || + (signed_steps < 0 && + state_.internal_step_position < + std::numeric_limits::min() - signed_steps)) { + return MotionResult{false, FaultCode::invalid_argument}; + } + const double conservative_speed = + std::min(config_.motion.maximum_speed_deg_per_s, + config_.motion.homing.speed_deg_per_s); + return start_step_move(state_.internal_step_position + signed_steps, + conservative_speed, MotionPurpose::bench, command_id, + false); +} + +MotionResult AxisController::start_homing(const std::uint32_t command_id) { + if (state_.moving) { + return MotionResult{false, FaultCode::invalid_argument}; + } + update_home_switch(platform_.monotonic_micros()); + mark_position_untrusted(TrustLossReason::homing_failed); + state_.homing_phase = HomingPhase::validate_switch; + if (state_.home_switch_active) { + return fail(FaultCode::homing_stuck_switch, + TrustLossReason::homing_failed, true); + } + homing_started_us_ = platform_.monotonic_micros(); + state_.homing_phase = HomingPhase::fast_approach; + const std::int64_t travel = + std::llabs(maximum_steps() - minimum_steps()) + + std::llabs(maximum_steps() - minimum_steps()) / 10 + 1; + const std::int64_t target = + state_.internal_step_position + + (config_.motion.homing.direction_negative ? -travel : travel); + return start_step_move(target, config_.motion.homing.speed_deg_per_s, + MotionPurpose::homing_fast, command_id, false); +} + +std::uint64_t AxisController::step_interval_us( + const std::int64_t remaining_steps) { + const double acceleration_steps_s2 = + config_.motion.acceleration_deg_per_s2 * + config_.motion.steps_per_output_revolution() / 360.0; + const double acceleration_limited = + std::sqrt(current_speed_steps_s_ * current_speed_steps_s_ + + 2.0 * acceleration_steps_s2); + const double braking_limited = + std::sqrt(2.0 * acceleration_steps_s2 * + std::max(remaining_steps, 1)); + current_speed_steps_s_ = + std::max(1.0, std::min({requested_speed_steps_s_, + acceleration_limited, braking_limited})); + const std::uint64_t interval = + static_cast(std::llround(1000000.0 / + current_speed_steps_s_)); + return std::max(interval, kStepPulseWidthUs + kMinimumStepLowUs); +} + +void AxisController::stop_pulse_generation() { + if (step_high_) { + driver_.set_step(false); + } + step_high_ = false; + state_.moving = false; + state_.target_step_position = state_.internal_step_position; + motion_purpose_ = MotionPurpose::none; +} + +void AxisController::finish_motion() { + stop_pulse_generation(); + state_.commanded_position_deg = + steps_to_degrees(state_.internal_step_position); + state_.last_completed_command = active_command_id_; +} + +void AxisController::service_step_generator(const std::uint64_t now_us) { + if (!state_.moving || now_us < next_edge_us_) { + return; + } + if (!step_high_) { + driver_.set_step(true); + step_high_ = true; + next_edge_us_ = now_us + kStepPulseWidthUs; + return; + } + + driver_.set_step(false); + step_high_ = false; + state_.internal_step_position += step_direction_; + state_.commanded_position_deg = + steps_to_degrees(state_.internal_step_position); + const std::int64_t remaining = + std::llabs(state_.target_step_position - + state_.internal_step_position); + if (remaining == 0) { + finish_motion(); + return; + } + const std::uint64_t interval = step_interval_us(remaining); + next_edge_us_ = + now_us + std::max(kMinimumStepLowUs, interval - kStepPulseWidthUs); +} + +void AxisController::service_homing(const std::uint64_t now_us) { + if (state_.homing_phase == HomingPhase::idle || + state_.homing_phase == HomingPhase::complete || + state_.homing_phase == HomingPhase::failed) { + return; + } + const std::uint64_t homing_timeout_us = + static_cast(config_.motion.homing.timeout_ms) * 1000ULL; + if (elapsed(now_us, homing_started_us_, homing_timeout_us)) { + const FaultCode fault = + state_.homing_phase == HomingPhase::fast_approach + ? FaultCode::homing_switch_never_triggered + : FaultCode::homing_timeout; + fail(fault, TrustLossReason::homing_failed, true); + return; + } + + if (state_.homing_phase == HomingPhase::fast_approach && + state_.home_switch_active) { + stop_pulse_generation(); + state_.homing_phase = HomingPhase::backoff; + std::int64_t backoff_steps = 0; + degrees_to_steps(config_.motion.homing.backoff_deg, backoff_steps); + const std::int64_t target = + state_.internal_step_position + + (config_.motion.homing.direction_negative ? backoff_steps + : -backoff_steps); + start_step_move(target, config_.motion.homing.speed_deg_per_s, + MotionPurpose::homing_backoff, active_command_id_, false); + return; + } + + if (state_.homing_phase == HomingPhase::backoff && !state_.moving) { + state_.homing_phase = HomingPhase::confirm_release; + if (state_.home_switch_active) { + fail(FaultCode::homing_switch_failed_release, + TrustLossReason::homing_failed, true); + return; + } + state_.homing_phase = HomingPhase::slow_approach; + std::int64_t backoff_steps = 0; + degrees_to_steps(config_.motion.homing.backoff_deg, backoff_steps); + const std::int64_t target = + state_.internal_step_position + + (config_.motion.homing.direction_negative + ? -std::max(backoff_steps * 2, 1) + : std::max(backoff_steps * 2, 1)); + start_step_move(target, config_.motion.homing.slow_approach_deg_per_s, + MotionPurpose::homing_slow, active_command_id_, false); + return; + } + + if (state_.homing_phase == HomingPhase::slow_approach && + state_.home_switch_active) { + stop_pulse_generation(); + state_.homing_phase = HomingPhase::apply_offset; + std::int64_t offset_steps = 0; + if (!degrees_to_steps(config_.motion.home_offset_deg, offset_steps)) { + fail(FaultCode::invalid_configuration, + TrustLossReason::homing_failed, true); + return; + } + state_.internal_step_position = offset_steps; + state_.target_step_position = offset_steps; + state_.commanded_position_deg = config_.motion.home_offset_deg; + state_.position_trusted = true; + state_.homed = true; + state_.trust_loss_reason = TrustLossReason::none; + state_.fault = FaultCode::none; + state_.homing_phase = HomingPhase::complete; + state_.last_completed_command = active_command_id_; + } +} + +void AxisController::service_driver_status(const std::uint64_t now_us) { + if (!elapsed(now_us, last_status_check_us_, kDriverStatusIntervalUs)) { + return; + } + last_status_check_us_ = now_us; + state_.last_driver_status = driver_.read_status(); + if (state_.last_driver_status.critical_fault()) { + fail(state_.last_driver_status.connected + ? FaultCode::driver_critical + : FaultCode::driver_communication, + TrustLossReason::driver_fault, true); + } +} + +void AxisController::service() { + const std::uint64_t now_us = platform_.monotonic_micros(); + update_home_switch(now_us); + if (state_.moving && + elapsed(now_us, motion_started_us_, + static_cast( + config_.motion.motion_timeout_ms) * + 1000ULL)) { + fail(FaultCode::motion_timeout, TrustLossReason::motion_timeout, true); + return; + } + if (state_.moving && motion_purpose_ == MotionPurpose::normal && + state_.home_switch_active) { + fail(FaultCode::unexpected_home_switch, + TrustLossReason::suspected_missed_step, true); + return; + } + service_step_generator(now_us); + service_homing(now_us); + service_driver_status(now_us); +} + +MotionResult AxisController::stop(const bool invalidate_position) { + const bool was_moving = state_.moving; + stop_pulse_generation(); + if (invalidate_position && was_moving) { + mark_position_untrusted(TrustLossReason::stopped); + } + state_.fault = FaultCode::stopped; + return MotionResult{true, FaultCode::stopped}; +} + +MotionResult AxisController::emergency_stop() { + stop_pulse_generation(); + driver_.disable(); + state_.enabled = false; + return fail(FaultCode::emergency_stop, + TrustLossReason::emergency_stop, true); +} + +MotionResult AxisController::set_enabled(const bool enabled) { + if (!enabled) { + stop_pulse_generation(); + driver_.disable(); + state_.enabled = false; + mark_position_untrusted( + TrustLossReason::driver_disabled_during_motion); + state_.fault = FaultCode::driver_disabled; + return MotionResult{true, FaultCode::driver_disabled}; + } + if (state_.last_driver_status.critical_fault() || !driver_.enable()) { + return fail(FaultCode::driver_critical, + TrustLossReason::driver_fault, true); + } + state_.enabled = true; + return succeed(); +} + +MotionResult AxisController::clear_fault() { + if (state_.last_driver_status.critical_fault()) { + return MotionResult{false, FaultCode::driver_critical}; + } + state_.fault = FaultCode::none; + if (state_.homing_phase == HomingPhase::failed) { + state_.homing_phase = HomingPhase::idle; + } + return succeed(); +} + +MotionResult AxisController::set_current( + const std::uint16_t rms_current_ma) { + if (state_.moving || + !driver_.set_current_milliamps( + rms_current_ma, config_.motion.hold_current_percent)) { + return MotionResult{false, FaultCode::invalid_argument}; + } + config_.motion.motor_rms_current_ma = rms_current_ma; + return succeed(); +} + +MotionResult AxisController::set_microsteps( + const std::uint16_t microsteps) { + if (state_.moving || !driver_.set_microsteps(microsteps)) { + return MotionResult{false, FaultCode::invalid_argument}; + } + if (microsteps != config_.motion.microsteps) { + config_.motion.microsteps = microsteps; + mark_position_untrusted(TrustLossReason::configuration_changed); + } + return succeed(); +} + +} // namespace radiance3d diff --git a/firmware/controller/src/hardware_config.cpp b/firmware/controller/src/hardware_config.cpp new file mode 100644 index 0000000..c5705ec --- /dev/null +++ b/firmware/controller/src/hardware_config.cpp @@ -0,0 +1,156 @@ +#include "hardware_config.hpp" + +#include +#include + +namespace radiance3d { +namespace { + +bool valid_gpio(const int pin) { return pin >= 0 && pin <= 39; } + +bool input_only_gpio(const int pin) { return pin >= 34 && pin <= 39; } + +bool bootstrapping_gpio(const int pin) { + return pin == 0 || pin == 2 || pin == 5 || pin == 12 || pin == 15; +} + +AxisConfig azimuth_motion() { + AxisConfig config; + config.motor_full_steps_per_revolution = 200; + config.microsteps = 16; + config.motor_rms_current_ma = 400; + config.hold_current_percent = 30; + config.gear_ratio = 1.0; + config.direction_inverted = false; + config.home_offset_deg = 0.0; + config.minimum_angle_deg = 0.0; + config.maximum_angle_deg = 360.0; + config.maximum_speed_deg_per_s = 10.0; + config.acceleration_deg_per_s2 = 20.0; + config.settling_time_ms = 250; + config.motion_timeout_ms = 60000; + config.maximum_bench_test_steps = 3200; + config.homing.switch_normally_closed = true; + config.homing.direction_negative = true; + config.homing.debounce_ms = 10; + config.homing.speed_deg_per_s = 5.0; + config.homing.slow_approach_deg_per_s = 1.0; + config.homing.backoff_deg = 3.0; + config.homing.timeout_ms = 60000; + return config; +} + +AxisConfig elevation_motion() { + AxisConfig config = azimuth_motion(); + config.minimum_angle_deg = -90.0; + config.maximum_angle_deg = 90.0; + config.maximum_speed_deg_per_s = 8.0; + config.acceleration_deg_per_s2 = 15.0; + config.homing.speed_deg_per_s = 4.0; + return config; +} + +} // namespace + +PhysicalControllerConfig provisional_esp32_dev_config() { + PhysicalControllerConfig config; + config.board_name = "esp32dev-provisional"; + config.protocol_version = 1; + + config.azimuth.axis.name = "azimuth"; + config.azimuth.axis.motion = azimuth_motion(); + config.azimuth.axis.home_switch_pin = 32; + config.azimuth.driver.uart_channel = 1; + config.azimuth.driver.address = 0; + config.azimuth.driver.uart_tx_pin = 22; + config.azimuth.driver.uart_rx_pin = 21; + config.azimuth.driver.step_pin = 25; + config.azimuth.driver.direction_pin = 26; + config.azimuth.driver.enable_pin = 27; + config.azimuth.driver.direction_inverted = + config.azimuth.axis.motion.direction_inverted; + config.azimuth.driver.maximum_rms_current_ma = 800; + + config.elevation.axis.name = "elevation"; + config.elevation.axis.motion = elevation_motion(); + config.elevation.axis.home_switch_pin = 33; + config.elevation.driver.uart_channel = 2; + config.elevation.driver.address = 0; + config.elevation.driver.uart_tx_pin = 17; + config.elevation.driver.uart_rx_pin = 16; + config.elevation.driver.step_pin = 18; + config.elevation.driver.direction_pin = 19; + config.elevation.driver.enable_pin = 23; + config.elevation.driver.direction_inverted = + config.elevation.axis.motion.direction_inverted; + config.elevation.driver.maximum_rms_current_ma = 800; + + config.emergency_stop_pin = 13; + config.emergency_stop_active_low = true; + config.emergency_stop_debounce_ms = 10; + return config; +} + +GpioValidationResult validate_esp32_gpio( + const PhysicalControllerConfig& config) { + GpioValidationResult result; + const std::array pins = { + config.azimuth.driver.step_pin, + config.azimuth.driver.direction_pin, + config.azimuth.driver.enable_pin, + config.azimuth.driver.uart_tx_pin, + config.azimuth.driver.uart_rx_pin, + config.azimuth.axis.home_switch_pin, + config.elevation.driver.step_pin, + config.elevation.driver.direction_pin, + config.elevation.driver.enable_pin, + config.elevation.driver.uart_tx_pin, + config.elevation.driver.uart_rx_pin, + config.elevation.axis.home_switch_pin, + config.emergency_stop_pin, + }; + for (std::size_t index = 0; index < pins.size(); ++index) { + if (index == pins.size() - 1 && pins[index] < 0) { + continue; + } + if (!valid_gpio(pins[index])) { + result.invalid_output_pin = pins[index]; + return result; + } + if (bootstrapping_gpio(pins[index])) { + result.bootstrapping_pin_mask |= 1ULL << pins[index]; + } + for (std::size_t other = index + 1; other < pins.size(); ++other) { + if (other == pins.size() - 1 && pins[other] < 0) { + continue; + } + if (pins[index] == pins[other]) { + result.duplicate_pin = pins[index]; + return result; + } + } + } + const std::array output_pins = { + config.azimuth.driver.step_pin, + config.azimuth.driver.direction_pin, + config.azimuth.driver.enable_pin, + config.azimuth.driver.uart_tx_pin, + config.elevation.driver.step_pin, + config.elevation.driver.direction_pin, + config.elevation.driver.enable_pin, + config.elevation.driver.uart_tx_pin, + }; + for (const int pin : output_pins) { + if (input_only_gpio(pin)) { + result.invalid_output_pin = pin; + return result; + } + } + result.valid = config.board_name != nullptr && config.board_name[0] != '\0' && + config.emergency_stop_debounce_ms > 0 && + config.azimuth.axis.motion.valid() && + config.elevation.axis.motion.valid(); + return result; +} + +} // namespace radiance3d diff --git a/firmware/controller/src/motion_controller.cpp b/firmware/controller/src/motion_controller.cpp index 2082e09..dd4a449 100644 --- a/firmware/controller/src/motion_controller.cpp +++ b/firmware/controller/src/motion_controller.cpp @@ -1,6 +1,7 @@ #include "motion_controller.hpp" #include +#include #include namespace radiance3d { @@ -31,10 +32,13 @@ bool AxisConfig::valid() const { std::isfinite(minimum_angle_deg) && std::isfinite(maximum_angle_deg) && minimum_angle_deg < maximum_angle_deg && home_offset_deg >= minimum_angle_deg && home_offset_deg <= maximum_angle_deg && + hold_current_percent <= 100 && settling_time_ms > 0 && + motion_timeout_ms > 0 && maximum_bench_test_steps > 0 && finite_positive(maximum_speed_deg_per_s) && finite_positive(acceleration_deg_per_s2) && homing.debounce_ms > 0 && finite_positive(homing.speed_deg_per_s) && finite_positive(homing.backoff_deg) && - finite_positive(homing.slow_approach_deg_per_s); + finite_positive(homing.slow_approach_deg_per_s) && + homing.timeout_ms > 0; } bool ControllerConfig::valid() const { @@ -59,6 +63,10 @@ SimulatedMotionController::SimulatedMotionController(ControllerConfig config) } } +bool SimulatedMotionController::initialize() { return config_.valid(); } + +void SimulatedMotionController::service() {} + const ControllerConfig& SimulatedMotionController::config() const { return config_; } const ControllerState& SimulatedMotionController::state() const { return state_; } @@ -73,12 +81,15 @@ MotionResult SimulatedMotionController::succeed() { return MotionResult{true, FaultCode::none}; } -void SimulatedMotionController::invalidate(AxisState& axis) { +void SimulatedMotionController::invalidate(AxisState& axis, + const TrustLossReason reason) { axis.position_trusted = false; axis.homed = false; + axis.trust_loss_reason = reason; } -MotionResult SimulatedMotionController::home(const AxisSelection axis) { +MotionResult SimulatedMotionController::home(const AxisSelection axis, + const std::uint32_t command_id) { if (!config_.valid()) { return fail(FaultCode::invalid_configuration); } @@ -88,25 +99,33 @@ MotionResult SimulatedMotionController::home(const AxisSelection axis) { if (state_.stopped) { return fail(FaultCode::stopped); } - if (!state_.azimuth.enabled || !state_.elevation.enabled) { - return fail(FaultCode::driver_disabled); - } if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.enabled = true; state_.azimuth.commanded_position_deg = config_.azimuth.home_offset_deg; + state_.azimuth.internal_step_position = 0; + state_.azimuth.target_step_position = 0; state_.azimuth.homed = true; state_.azimuth.position_trusted = true; + state_.azimuth.trust_loss_reason = TrustLossReason::none; + state_.azimuth.last_completed_command = command_id; } if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.enabled = true; state_.elevation.commanded_position_deg = config_.elevation.home_offset_deg; + state_.elevation.internal_step_position = 0; + state_.elevation.target_step_position = 0; state_.elevation.homed = true; state_.elevation.position_trusted = true; + state_.elevation.trust_loss_reason = TrustLossReason::none; + state_.elevation.last_completed_command = command_id; } return succeed(); } MotionResult SimulatedMotionController::move_absolute(const double azimuth_deg, const double elevation_deg, - const double speed_deg_per_s) { + const double speed_deg_per_s, + const std::uint32_t command_id) { if (!config_.valid()) { return fail(FaultCode::invalid_configuration); } @@ -116,15 +135,15 @@ MotionResult SimulatedMotionController::move_absolute(const double azimuth_deg, if (state_.stopped) { return fail(FaultCode::stopped); } - if (!state_.azimuth.enabled || !state_.elevation.enabled) { - return fail(FaultCode::driver_disabled); - } if (!state_.azimuth.homed || !state_.elevation.homed) { return fail(FaultCode::not_homed); } if (!state_.azimuth.position_trusted || !state_.elevation.position_trusted) { return fail(FaultCode::position_untrusted); } + if (!state_.azimuth.enabled || !state_.elevation.enabled) { + return fail(FaultCode::driver_disabled); + } if (!finite_positive(speed_deg_per_s) || speed_deg_per_s > config_.azimuth.maximum_speed_deg_per_s || speed_deg_per_s > config_.elevation.maximum_speed_deg_per_s) { @@ -137,21 +156,25 @@ MotionResult SimulatedMotionController::move_absolute(const double azimuth_deg, state_.azimuth.commanded_position_deg = azimuth_deg; state_.elevation.commanded_position_deg = elevation_deg; + state_.azimuth.last_completed_command = command_id; + state_.elevation.last_completed_command = command_id; return succeed(); } MotionResult SimulatedMotionController::stop() { state_.stopped = true; - invalidate(state_.azimuth); - invalidate(state_.elevation); + invalidate(state_.azimuth, TrustLossReason::stopped); + invalidate(state_.elevation, TrustLossReason::stopped); return fail(FaultCode::stopped); } MotionResult SimulatedMotionController::emergency_stop() { state_.emergency_stop_active = true; state_.stopped = true; - invalidate(state_.azimuth); - invalidate(state_.elevation); + state_.azimuth.enabled = false; + state_.elevation.enabled = false; + invalidate(state_.azimuth, TrustLossReason::emergency_stop); + invalidate(state_.elevation, TrustLossReason::emergency_stop); return fail(FaultCode::emergency_stop); } @@ -167,13 +190,136 @@ MotionResult SimulatedMotionController::set_enabled(const bool enabled) { state_.azimuth.enabled = enabled; state_.elevation.enabled = enabled; if (!enabled) { - invalidate(state_.azimuth); - invalidate(state_.elevation); + invalidate(state_.azimuth, TrustLossReason::driver_disabled_during_motion); + invalidate(state_.elevation, TrustLossReason::driver_disabled_during_motion); return fail(FaultCode::driver_disabled); } return succeed(); } +MotionResult SimulatedMotionController::move_relative( + const AxisSelection axis, const double delta_deg, + const double speed_deg_per_s, const std::uint32_t command_id) { + const double azimuth = + state_.azimuth.commanded_position_deg + + ((axis == AxisSelection::azimuth || axis == AxisSelection::both) + ? delta_deg + : 0.0); + const double elevation = + state_.elevation.commanded_position_deg + + ((axis == AxisSelection::elevation || axis == AxisSelection::both) + ? delta_deg + : 0.0); + const MotionResult result = + move_absolute(azimuth, elevation, speed_deg_per_s, command_id); + if (result.ok) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.last_completed_command = command_id; + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.last_completed_command = command_id; + } + } + return result; +} + +MotionResult SimulatedMotionController::bench_move_steps( + const AxisSelection axis, const std::int64_t signed_steps, + const std::uint32_t command_id) { + if (axis == AxisSelection::both || signed_steps == 0) { + return fail(FaultCode::invalid_argument); + } + AxisState& selected = + axis == AxisSelection::azimuth ? state_.azimuth : state_.elevation; + const AxisConfig& selected_config = + axis == AxisSelection::azimuth ? config_.azimuth : config_.elevation; + if (std::llabs(signed_steps) > selected_config.maximum_bench_test_steps) { + return fail(FaultCode::limit_reached); + } + selected.enabled = true; + selected.internal_step_position += signed_steps; + selected.target_step_position = selected.internal_step_position; + selected.commanded_position_deg = + static_cast(selected.internal_step_position) * 360.0 / + selected_config.steps_per_output_revolution(); + selected.last_completed_command = command_id; + return succeed(); +} + +MotionResult SimulatedMotionController::stop_axis( + const AxisSelection axis) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + invalidate(state_.azimuth, TrustLossReason::stopped); + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + invalidate(state_.elevation, TrustLossReason::stopped); + } + return succeed(); +} + +MotionResult SimulatedMotionController::set_axis_enabled( + const AxisSelection axis, const bool enabled) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.enabled = enabled; + if (!enabled) { + invalidate(state_.azimuth, + TrustLossReason::driver_disabled_during_motion); + } + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.enabled = enabled; + if (!enabled) { + invalidate(state_.elevation, + TrustLossReason::driver_disabled_during_motion); + } + } + return succeed(); +} + +MotionResult SimulatedMotionController::set_axis_current( + const AxisSelection axis, const std::uint16_t rms_current_ma) { + if (axis == AxisSelection::both || rms_current_ma == 0) { + return fail(FaultCode::invalid_argument); + } + AxisConfig& selected = + axis == AxisSelection::azimuth ? config_.azimuth : config_.elevation; + selected.motor_rms_current_ma = rms_current_ma; + return succeed(); +} + +MotionResult SimulatedMotionController::set_axis_microsteps( + const AxisSelection axis, const std::uint16_t microsteps) { + const bool supported = + microsteps == 1 || microsteps == 2 || microsteps == 4 || + microsteps == 8 || microsteps == 16 || microsteps == 32 || + microsteps == 64 || microsteps == 128 || microsteps == 256; + if (axis == AxisSelection::both || !supported) { + return fail(FaultCode::invalid_argument); + } + AxisConfig& selected = + axis == AxisSelection::azimuth ? config_.azimuth : config_.elevation; + AxisState& selected_state = + axis == AxisSelection::azimuth ? state_.azimuth : state_.elevation; + if (selected.microsteps != microsteps) { + selected.microsteps = microsteps; + invalidate(selected_state, TrustLossReason::configuration_changed); + } + return succeed(); +} + +DriverCapabilities SimulatedMotionController::driver_capabilities( + AxisSelection) const { + return DriverCapabilities{true, true, true, true, true, true}; +} + +DriverStatus SimulatedMotionController::driver_status( + const AxisSelection axis) const { + if (axis == AxisSelection::elevation) { + return state_.elevation.last_driver_status; + } + return state_.azimuth.last_driver_status; +} + void SimulatedMotionController::report_fault(const FaultCode code) { state_.fault = code; } } // namespace radiance3d diff --git a/firmware/controller/src/physical_motion_controller.cpp b/firmware/controller/src/physical_motion_controller.cpp new file mode 100644 index 0000000..794fed6 --- /dev/null +++ b/firmware/controller/src/physical_motion_controller.cpp @@ -0,0 +1,393 @@ +#include "physical_motion_controller.hpp" + +#include + +namespace radiance3d { +namespace { + +bool elapsed(const std::uint64_t now, const std::uint64_t started, + const std::uint64_t duration) { + return now - started >= duration; +} + +} // namespace + +PhysicalMotionController::PhysicalMotionController( + HardwarePlatform& platform, AxisController& azimuth, + AxisController& elevation, PhysicalControllerConfig physical_config) + : platform_(platform), + azimuth_(azimuth), + elevation_(elevation), + physical_config_(std::move(physical_config)) { + config_.azimuth = physical_config_.azimuth.axis.motion; + config_.elevation = physical_config_.elevation.axis.motion; + config_.motion_timeout_ms = + config_.azimuth.motion_timeout_ms > config_.elevation.motion_timeout_ms + ? config_.azimuth.motion_timeout_ms + : config_.elevation.motion_timeout_ms; + config_.emergency_stop_active_low = + physical_config_.emergency_stop_active_low; +} + +const PhysicalControllerConfig& +PhysicalMotionController::physical_config() const { + return physical_config_; +} + +const ControllerConfig& PhysicalMotionController::config() const { + return config_; +} + +const ControllerState& PhysicalMotionController::state() const { + return state_; +} + +AxisController* PhysicalMotionController::selected_axis( + const AxisSelection axis) { + if (axis == AxisSelection::azimuth) { + return &azimuth_; + } + if (axis == AxisSelection::elevation) { + return &elevation_; + } + return nullptr; +} + +const AxisController* PhysicalMotionController::selected_axis( + const AxisSelection axis) const { + if (axis == AxisSelection::azimuth) { + return &azimuth_; + } + if (axis == AxisSelection::elevation) { + return &elevation_; + } + return nullptr; +} + +bool PhysicalMotionController::emergency_input_active_raw() const { + if (physical_config_.emergency_stop_pin < 0) { + return false; + } + const bool level_high = + platform_.read_pin(physical_config_.emergency_stop_pin); + return physical_config_.emergency_stop_active_low ? !level_high + : level_high; +} + +void PhysicalMotionController::update_emergency_input( + const std::uint64_t now_us) { + const bool raw = emergency_input_active_raw(); + if (!emergency_input_initialized_) { + emergency_candidate_ = raw; + emergency_stable_ = raw; + emergency_changed_us_ = now_us; + emergency_input_initialized_ = true; + return; + } + if (raw != emergency_candidate_) { + emergency_candidate_ = raw; + emergency_changed_us_ = now_us; + } + const std::uint64_t debounce_us = + static_cast( + physical_config_.emergency_stop_debounce_ms) * + 1000ULL; + if (emergency_candidate_ != emergency_stable_ && + elapsed(now_us, emergency_changed_us_, debounce_us)) { + emergency_stable_ = emergency_candidate_; + } +} + +void PhysicalMotionController::synchronize_state() { + state_.azimuth = azimuth_.state(); + state_.elevation = elevation_.state(); + state_.emergency_stop_active = emergency_latched_; + state_.stopped = emergency_latched_ || + (!state_.azimuth.moving && !state_.elevation.moving && + state_.fault == FaultCode::stopped); +} + +bool PhysicalMotionController::initialize() { + state_ = ControllerState{}; + const GpioValidationResult gpio = + validate_esp32_gpio(physical_config_); + if (!gpio.valid) { + state_.fault = FaultCode::invalid_configuration; + return false; + } + if (physical_config_.emergency_stop_pin >= 0 && + !platform_.configure_pin(physical_config_.emergency_stop_pin, + PinMode::input_pullup)) { + state_.fault = FaultCode::invalid_configuration; + return false; + } + update_emergency_input(platform_.monotonic_micros()); + const bool azimuth_ok = azimuth_.initialize(); + const bool elevation_ok = elevation_.initialize(); + synchronize_state(); + initialized_ = azimuth_ok && elevation_ok; + if (!initialized_) { + state_.fault = FaultCode::driver_communication; + } + if (emergency_stable_) { + emergency_stop(); + return false; + } + return initialized_; +} + +MotionResult PhysicalMotionController::reject(const FaultCode fault) { + state_.fault = fault; + return MotionResult{false, fault}; +} + +void PhysicalMotionController::service() { + const std::uint64_t now_us = platform_.monotonic_micros(); + update_emergency_input(now_us); + if (emergency_stable_ && !emergency_latched_) { + emergency_stop(); + return; + } + azimuth_.service(); + elevation_.service(); + + if (coordinated_move_active_) { + const bool azimuth_fault = + azimuth_.state().fault == FaultCode::driver_critical || + azimuth_.state().fault == FaultCode::driver_communication || + azimuth_.state().fault == FaultCode::motion_timeout || + azimuth_.state().fault == FaultCode::unexpected_home_switch; + const bool elevation_fault = + elevation_.state().fault == FaultCode::driver_critical || + elevation_.state().fault == FaultCode::driver_communication || + elevation_.state().fault == FaultCode::motion_timeout || + elevation_.state().fault == FaultCode::unexpected_home_switch; + if (azimuth_fault || elevation_fault) { + azimuth_.stop(true); + elevation_.stop(true); + state_.fault = FaultCode::driver_critical; + coordinated_move_active_ = false; + } else if (!azimuth_.state().moving && !elevation_.state().moving) { + coordinated_move_active_ = false; + state_.fault = FaultCode::none; + } + } + synchronize_state(); +} + +MotionResult PhysicalMotionController::home( + const AxisSelection axis, const std::uint32_t command_id) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } + if (axis == AxisSelection::both) { + const MotionResult azimuth_result = + azimuth_.start_homing(command_id); + if (!azimuth_result.ok) { + return reject(azimuth_result.fault); + } + const MotionResult elevation_result = + elevation_.start_homing(command_id); + if (!elevation_result.ok) { + azimuth_.stop(true); + return reject(elevation_result.fault); + } + coordinated_move_active_ = true; + synchronize_state(); + return MotionResult{true, FaultCode::none}; + } + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); + } + const MotionResult result = selected->start_homing(command_id); + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::move_absolute( + const double azimuth_deg, const double elevation_deg, + const double speed_deg_per_s, const std::uint32_t command_id) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } + const MotionResult azimuth_result = azimuth_.move_absolute_degrees( + azimuth_deg, speed_deg_per_s, command_id); + if (!azimuth_result.ok) { + return reject(azimuth_result.fault); + } + const MotionResult elevation_result = elevation_.move_absolute_degrees( + elevation_deg, speed_deg_per_s, command_id); + if (!elevation_result.ok) { + azimuth_.stop(true); + return reject(elevation_result.fault); + } + coordinated_move_active_ = + azimuth_.state().moving || elevation_.state().moving; + synchronize_state(); + return MotionResult{true, FaultCode::none}; +} + +MotionResult PhysicalMotionController::move_relative( + const AxisSelection axis, const double delta_deg, + const double speed_deg_per_s, const std::uint32_t command_id) { + if (axis == AxisSelection::both) { + const MotionResult azimuth_result = azimuth_.move_relative_degrees( + delta_deg, speed_deg_per_s, command_id); + if (!azimuth_result.ok) { + return reject(azimuth_result.fault); + } + const MotionResult elevation_result = elevation_.move_relative_degrees( + delta_deg, speed_deg_per_s, command_id); + if (!elevation_result.ok) { + azimuth_.stop(true); + return reject(elevation_result.fault); + } + coordinated_move_active_ = true; + synchronize_state(); + return MotionResult{true, FaultCode::none}; + } + AxisController* selected = selected_axis(axis); + if (selected == nullptr || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_argument); + } + const MotionResult result = selected->move_relative_degrees( + delta_deg, speed_deg_per_s, command_id); + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::bench_move_steps( + const AxisSelection axis, const std::int64_t signed_steps, + const std::uint32_t command_id) { + AxisController* selected = selected_axis(axis); + if (selected == nullptr || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_argument); + } + const MotionResult result = + selected->bench_move_steps(signed_steps, command_id); + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::stop_axis( + const AxisSelection axis) { + if (axis == AxisSelection::both) { + return stop(); + } + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); + } + const MotionResult result = selected->stop(true); + coordinated_move_active_ = false; + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::stop() { + azimuth_.stop(true); + elevation_.stop(true); + coordinated_move_active_ = false; + state_.fault = FaultCode::stopped; + synchronize_state(); + return MotionResult{true, FaultCode::stopped}; +} + +MotionResult PhysicalMotionController::emergency_stop() { + emergency_latched_ = true; + coordinated_move_active_ = false; + azimuth_.emergency_stop(); + elevation_.emergency_stop(); + state_.fault = FaultCode::emergency_stop; + synchronize_state(); + return MotionResult{true, FaultCode::emergency_stop}; +} + +MotionResult PhysicalMotionController::clear_fault() { + update_emergency_input(platform_.monotonic_micros()); + if (emergency_stable_) { + return reject(FaultCode::emergency_stop); + } + emergency_latched_ = false; + const MotionResult azimuth_result = azimuth_.clear_fault(); + const MotionResult elevation_result = elevation_.clear_fault(); + if (!azimuth_result.ok || !elevation_result.ok) { + return reject(!azimuth_result.ok ? azimuth_result.fault + : elevation_result.fault); + } + state_.fault = FaultCode::none; + synchronize_state(); + return MotionResult{true, FaultCode::none}; +} + +MotionResult PhysicalMotionController::set_enabled(const bool enabled) { + const MotionResult azimuth_result = azimuth_.set_enabled(enabled); + const MotionResult elevation_result = elevation_.set_enabled(enabled); + if (!azimuth_result.ok || !elevation_result.ok) { + return reject(!azimuth_result.ok ? azimuth_result.fault + : elevation_result.fault); + } + synchronize_state(); + return MotionResult{true, enabled ? FaultCode::none + : FaultCode::driver_disabled}; +} + +MotionResult PhysicalMotionController::set_axis_enabled( + const AxisSelection axis, const bool enabled) { + if (axis == AxisSelection::both) { + return set_enabled(enabled); + } + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); + } + const MotionResult result = selected->set_enabled(enabled); + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::set_axis_current( + const AxisSelection axis, const std::uint16_t rms_current_ma) { + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); + } + const MotionResult result = selected->set_current(rms_current_ma); + synchronize_state(); + return result; +} + +MotionResult PhysicalMotionController::set_axis_microsteps( + const AxisSelection axis, const std::uint16_t microsteps) { + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); + } + const MotionResult result = selected->set_microsteps(microsteps); + synchronize_state(); + return result; +} + +DriverCapabilities PhysicalMotionController::driver_capabilities( + const AxisSelection axis) const { + const AxisController* selected = selected_axis(axis); + return selected == nullptr ? DriverCapabilities{} + : selected->driver_capabilities(); +} + +DriverStatus PhysicalMotionController::driver_status( + const AxisSelection axis) const { + const AxisController* selected = selected_axis(axis); + return selected == nullptr ? DriverStatus{} : selected->driver_status(); +} + +void PhysicalMotionController::report_fault(const FaultCode code) { + state_.fault = code; +} + +} // namespace radiance3d diff --git a/firmware/controller/src/protocol.cpp b/firmware/controller/src/protocol.cpp index f7eaaff..6f552e3 100644 --- a/firmware/controller/src/protocol.cpp +++ b/firmware/controller/src/protocol.cpp @@ -34,6 +34,20 @@ const char* fault_name(const FaultCode code) { return "DRIVER_DISABLED"; case FaultCode::emergency_stop: return "EMERGENCY_STOP"; + case FaultCode::driver_communication: + return "DRIVER_COMMUNICATION"; + case FaultCode::driver_critical: + return "DRIVER_CRITICAL"; + case FaultCode::homing_stuck_switch: + return "HOMING_STUCK_SWITCH"; + case FaultCode::homing_switch_never_triggered: + return "HOMING_SWITCH_NEVER_TRIGGERED"; + case FaultCode::homing_switch_failed_release: + return "HOMING_SWITCH_FAILED_RELEASE"; + case FaultCode::homing_timeout: + return "HOMING_TIMEOUT"; + case FaultCode::unexpected_home_switch: + return "UNEXPECTED_HOME_SWITCH"; case FaultCode::stopped: return "STOPPED"; } diff --git a/firmware/controller/test/test_axis/test_main.cpp b/firmware/controller/test/test_axis/test_main.cpp new file mode 100644 index 0000000..7c3d4a3 --- /dev/null +++ b/firmware/controller/test/test_axis/test_main.cpp @@ -0,0 +1,318 @@ +#include + +#include +#include +#include + +#include "axis_controller.hpp" + +namespace { + +class FakePlatform final : public radiance3d::HardwarePlatform { + public: + std::uint64_t now_us{0}; + bool pins[64]{}; + + bool configure_pin(int pin, radiance3d::PinMode mode) override { + if (pin < 0 || pin >= 64) { + return false; + } + if (mode == radiance3d::PinMode::input_pullup) { + pins[pin] = true; + } + return true; + } + void write_pin(int pin, bool high) override { pins[pin] = high; } + bool read_pin(int pin) const override { return pins[pin]; } + std::uint64_t monotonic_micros() const override { return now_us; } + bool begin_uart(std::uint8_t, int, int, std::uint32_t) override { + return true; + } + void flush_uart_input(std::uint8_t) override {} + bool write_uart(std::uint8_t, const std::uint8_t*, std::size_t) override { + return true; + } + std::size_t read_uart(std::uint8_t, std::uint8_t*, std::size_t, + std::uint32_t) override { + return 0; + } + + void advance(std::uint64_t amount_us) { now_us += amount_us; } +}; + +class FakeDriver final : public radiance3d::StepperDriver { + public: + bool connected{true}; + bool enabled{false}; + bool step_high{false}; + std::uint32_t rising_steps{0}; + radiance3d::DriverStatus status{}; + + FakeDriver() { + status.connected = true; + status.standstill = true; + } + + bool initialize() override { return connected; } + radiance3d::DriverCapabilities capabilities() const override { + return {true, true, true, true, true, true}; + } + bool enable() override { + enabled = connected; + return enabled; + } + void disable() override { + enabled = false; + step_high = false; + } + bool set_direction(bool direction) override { + positive_ = direction; + return connected; + } + void set_step(bool high) override { + if (high && !step_high) { + ++rising_steps; + } + step_high = high; + } + bool set_current_milliamps(std::uint16_t value, + std::uint8_t hold) override { + return connected && value > 0 && value <= 800 && hold <= 100; + } + bool set_microsteps(std::uint16_t value) override { + return connected && + (value == 1 || value == 2 || value == 4 || value == 8 || + value == 16 || value == 32 || value == 64 || value == 128 || + value == 256); + } + bool set_interpolation(bool) override { return connected; } + bool set_chopper_mode(radiance3d::ChopperMode) override { + return connected; + } + radiance3d::DriverStatus read_status() override { + status.connected = connected; + status.enabled = enabled; + if (!connected) { + status.fault = radiance3d::DriverFault::communication_failure; + } + return status; + } + bool is_connected() const override { return connected; } + + private: + bool positive_{true}; +}; + +radiance3d::PhysicalAxisConfig axis_config() { + radiance3d::PhysicalAxisConfig config; + config.name = "elevation"; + config.home_switch_pin = 32; + config.motion.motor_full_steps_per_revolution = 200; + config.motion.microsteps = 1; + config.motion.motor_rms_current_ma = 400; + config.motion.gear_ratio = 1.0; + config.motion.minimum_angle_deg = -90.0; + config.motion.maximum_angle_deg = 90.0; + config.motion.home_offset_deg = 0.0; + config.motion.maximum_speed_deg_per_s = 100.0; + config.motion.acceleration_deg_per_s2 = 200.0; + config.motion.motion_timeout_ms = 10000; + config.motion.maximum_bench_test_steps = 20; + config.motion.homing.switch_normally_closed = false; + config.motion.homing.direction_negative = true; + config.motion.homing.debounce_ms = 1; + config.motion.homing.speed_deg_per_s = 50.0; + config.motion.homing.slow_approach_deg_per_s = 10.0; + config.motion.homing.backoff_deg = 3.6; + config.motion.homing.timeout_ms = 5000; + return config; +} + +void service_until_stopped(radiance3d::AxisController& axis, + FakePlatform& platform, + std::uint32_t maximum_iterations = 1000) { + for (std::uint32_t index = 0; + index < maximum_iterations && axis.state().moving; ++index) { + platform.advance(100000); + axis.service(); + } +} + +void settle_switch(radiance3d::AxisController& axis, + FakePlatform& platform, bool level_high) { + platform.pins[32] = level_high; + axis.service(); + platform.advance(2000); + axis.service(); +} + +} // namespace + +void setUp() {} +void tearDown() {} + +void test_conversions_use_integer_steps_and_half_away_from_zero_rounding() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + + std::int64_t steps = 0; + TEST_ASSERT_TRUE(axis.degrees_to_steps(90.0, steps)); + TEST_ASSERT_EQUAL_INT64(50, steps); + TEST_ASSERT_TRUE(axis.degrees_to_steps(-90.0, steps)); + TEST_ASSERT_EQUAL_INT64(-50, steps); + TEST_ASSERT_TRUE(axis.degrees_to_steps(0.9, steps)); + TEST_ASSERT_EQUAL_INT64(1, steps); + TEST_ASSERT_TRUE(axis.degrees_to_steps(-0.9, steps)); + TEST_ASSERT_EQUAL_INT64(-1, steps); + TEST_ASSERT_FLOAT_WITHIN(0.001f, -90.0f, + static_cast(axis.steps_to_degrees(-50))); + TEST_ASSERT_TRUE(axis.motor_full_steps_to_output_steps(10, steps)); + TEST_ASSERT_EQUAL_INT64(10, steps); +} + +void test_absolute_and_relative_motion_require_homing() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + + TEST_ASSERT_EQUAL(radiance3d::FaultCode::not_homed, + axis.move_absolute_degrees(10.0, 10.0).fault); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::not_homed, + axis.move_relative_degrees(5.0, 10.0).fault); +} + +void test_valid_motion_finishes_on_integer_target_and_limits_are_strict() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + axis.mutable_state().position_trusted = true; + axis.mutable_state().homed = true; + + TEST_ASSERT_TRUE(axis.move_absolute_degrees(18.0, 20.0, 7).ok); + service_until_stopped(axis, platform); + TEST_ASSERT_FALSE(axis.state().moving); + TEST_ASSERT_EQUAL_INT64(10, axis.state().internal_step_position); + TEST_ASSERT_EQUAL_UINT32(7, axis.state().last_completed_command); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::limit_reached, + axis.move_absolute_degrees(91.0, 20.0).fault); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::limit_reached, + axis.move_absolute_degrees(-91.0, 20.0).fault); +} + +void test_stop_and_timeout_disable_motion_and_lose_trust() { + FakePlatform platform; + FakeDriver driver; + auto config = axis_config(); + config.motion.motion_timeout_ms = 1; + radiance3d::AxisController axis(platform, driver, config); + TEST_ASSERT_TRUE(axis.initialize()); + axis.mutable_state().position_trusted = true; + axis.mutable_state().homed = true; + TEST_ASSERT_TRUE(axis.move_absolute_degrees(50.0, 10.0).ok); + + platform.advance(2000); + axis.service(); + + TEST_ASSERT_EQUAL(radiance3d::FaultCode::motion_timeout, + axis.state().fault); + TEST_ASSERT_FALSE(axis.state().position_trusted); + TEST_ASSERT_FALSE(axis.state().enabled); +} + +void test_stuck_active_home_switch_fails_without_motion() { + FakePlatform platform; + platform.pins[32] = false; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + settle_switch(axis, platform, false); + + const radiance3d::MotionResult result = axis.start_homing(10); + + TEST_ASSERT_FALSE(result.ok); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::homing_stuck_switch, + result.fault); + TEST_ASSERT_FALSE(axis.state().enabled); + TEST_ASSERT_FALSE(axis.state().position_trusted); +} + +void test_homing_times_out_when_switch_never_activates() { + FakePlatform platform; + FakeDriver driver; + auto config = axis_config(); + config.motion.homing.timeout_ms = 2; + radiance3d::AxisController axis(platform, driver, config); + TEST_ASSERT_TRUE(axis.initialize()); + TEST_ASSERT_TRUE(axis.start_homing(11).ok); + + platform.advance(3000); + axis.service(); + + TEST_ASSERT_EQUAL(radiance3d::FaultCode::homing_switch_never_triggered, + axis.state().fault); + TEST_ASSERT_FALSE(axis.state().position_trusted); +} + +void test_successful_two_pass_homing_applies_offset_and_trusts_position() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + TEST_ASSERT_TRUE(axis.start_homing(12).ok); + + for (int index = 0; index < 6; ++index) { + platform.advance(100000); + axis.service(); + } + settle_switch(axis, platform, false); + TEST_ASSERT_EQUAL(radiance3d::HomingPhase::backoff, + axis.state().homing_phase); + + settle_switch(axis, platform, true); + service_until_stopped(axis, platform); + axis.service(); + TEST_ASSERT_EQUAL(radiance3d::HomingPhase::slow_approach, + axis.state().homing_phase); + + settle_switch(axis, platform, false); + + TEST_ASSERT_EQUAL(radiance3d::HomingPhase::complete, + axis.state().homing_phase); + TEST_ASSERT_TRUE(axis.state().position_trusted); + TEST_ASSERT_TRUE(axis.state().homed); + TEST_ASSERT_EQUAL_INT64(0, axis.state().internal_step_position); + TEST_ASSERT_EQUAL_UINT32(12, axis.state().last_completed_command); +} + +void test_emergency_stop_during_homing_disables_and_loses_trust() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + TEST_ASSERT_TRUE(axis.start_homing(13).ok); + + axis.emergency_stop(); + + TEST_ASSERT_FALSE(axis.state().moving); + TEST_ASSERT_FALSE(axis.state().enabled); + TEST_ASSERT_FALSE(axis.state().position_trusted); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::emergency_stop, + axis.state().fault); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_conversions_use_integer_steps_and_half_away_from_zero_rounding); + RUN_TEST(test_absolute_and_relative_motion_require_homing); + RUN_TEST(test_valid_motion_finishes_on_integer_target_and_limits_are_strict); + RUN_TEST(test_stop_and_timeout_disable_motion_and_lose_trust); + RUN_TEST(test_stuck_active_home_switch_fails_without_motion); + RUN_TEST(test_homing_times_out_when_switch_never_activates); + RUN_TEST(test_successful_two_pass_homing_applies_offset_and_trusts_position); + RUN_TEST(test_emergency_stop_during_homing_disables_and_loses_trust); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_physical/test_main.cpp b/firmware/controller/test/test_physical/test_main.cpp new file mode 100644 index 0000000..840a70f --- /dev/null +++ b/firmware/controller/test/test_physical/test_main.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include + +#include "axis_controller.hpp" +#include "hardware_config.hpp" +#include "physical_motion_controller.hpp" + +namespace { + +class FakePlatform final : public radiance3d::HardwarePlatform { + public: + std::uint64_t now_us{0}; + bool pins[64]{}; + + bool configure_pin(int pin, radiance3d::PinMode mode) override { + if (pin < 0 || pin >= 64) { + return false; + } + if (mode == radiance3d::PinMode::input_pullup) { + pins[pin] = true; + } + return true; + } + void write_pin(int pin, bool high) override { pins[pin] = high; } + bool read_pin(int pin) const override { return pins[pin]; } + std::uint64_t monotonic_micros() const override { return now_us; } + bool begin_uart(std::uint8_t, int, int, std::uint32_t) override { + return true; + } + void flush_uart_input(std::uint8_t) override {} + bool write_uart(std::uint8_t, const std::uint8_t*, std::size_t) override { + return true; + } + std::size_t read_uart(std::uint8_t, std::uint8_t*, std::size_t, + std::uint32_t) override { + return 0; + } + void advance(std::uint64_t amount_us) { now_us += amount_us; } +}; + +class FakeDriver final : public radiance3d::StepperDriver { + public: + bool connected{true}; + bool enabled{false}; + radiance3d::DriverStatus status{}; + + FakeDriver() { status.connected = true; } + bool initialize() override { return connected; } + radiance3d::DriverCapabilities capabilities() const override { + return {true, true, true, true, true, true}; + } + bool enable() override { + enabled = connected; + return enabled; + } + void disable() override { enabled = false; } + bool set_direction(bool) override { return connected; } + void set_step(bool) override {} + bool set_current_milliamps(std::uint16_t value, + std::uint8_t hold) override { + return connected && value > 0 && value <= 800 && hold <= 100; + } + bool set_microsteps(std::uint16_t value) override { + return connected && value > 0; + } + bool set_interpolation(bool) override { return connected; } + bool set_chopper_mode(radiance3d::ChopperMode) override { + return connected; + } + radiance3d::DriverStatus read_status() override { + status.connected = connected; + status.enabled = enabled; + if (!connected) { + status.fault = radiance3d::DriverFault::communication_failure; + } + return status; + } + bool is_connected() const override { return connected; } +}; + +struct Fixture { + FakePlatform platform; + FakeDriver azimuth_driver; + FakeDriver elevation_driver; + radiance3d::PhysicalControllerConfig config; + radiance3d::AxisController azimuth; + radiance3d::AxisController elevation; + radiance3d::PhysicalMotionController controller; + + Fixture() + : config(radiance3d::provisional_esp32_dev_config()), + azimuth(platform, azimuth_driver, configure_axis(config.azimuth.axis)), + elevation(platform, elevation_driver, + configure_axis(config.elevation.axis)), + controller(platform, azimuth, elevation, configure_controller(config)) {} + + static radiance3d::PhysicalAxisConfig configure_axis( + radiance3d::PhysicalAxisConfig axis) { + axis.motion.homing.switch_normally_closed = false; + axis.motion.microsteps = 1; + axis.motion.maximum_speed_deg_per_s = 100.0; + axis.motion.acceleration_deg_per_s2 = 200.0; + axis.motion.motion_timeout_ms = 10000; + return axis; + } + + static radiance3d::PhysicalControllerConfig configure_controller( + radiance3d::PhysicalControllerConfig value) { + value.azimuth.axis = configure_axis(value.azimuth.axis); + value.elevation.axis = configure_axis(value.elevation.axis); + value.emergency_stop_debounce_ms = 1; + return value; + } + + void trust_positions() { + azimuth.mutable_state().homed = true; + azimuth.mutable_state().position_trusted = true; + azimuth.mutable_state().trust_loss_reason = + radiance3d::TrustLossReason::none; + elevation.mutable_state().homed = true; + elevation.mutable_state().position_trusted = true; + elevation.mutable_state().trust_loss_reason = + radiance3d::TrustLossReason::none; + } + + void service_until_complete(std::uint32_t maximum = 2000) { + for (std::uint32_t index = 0; + index < maximum && + (azimuth.state().moving || elevation.state().moving); + ++index) { + platform.advance(100000); + controller.service(); + } + } +}; + +} // namespace + +void setUp() {} +void tearDown() {} + +void test_provisional_gpio_is_valid_and_validation_rejects_conflicts() { + auto config = radiance3d::provisional_esp32_dev_config(); + radiance3d::GpioValidationResult result = + radiance3d::validate_esp32_gpio(config); + TEST_ASSERT_TRUE(result.valid); + TEST_ASSERT_EQUAL_INT64(0, result.bootstrapping_pin_mask); + + config.elevation.driver.step_pin = config.azimuth.driver.step_pin; + result = radiance3d::validate_esp32_gpio(config); + TEST_ASSERT_FALSE(result.valid); + TEST_ASSERT_EQUAL_INT(config.azimuth.driver.step_pin, + result.duplicate_pin); + + config = radiance3d::provisional_esp32_dev_config(); + config.azimuth.driver.step_pin = 34; + result = radiance3d::validate_esp32_gpio(config); + TEST_ASSERT_FALSE(result.valid); + TEST_ASSERT_EQUAL_INT(34, result.invalid_output_pin); +} + +void test_safe_startup_initializes_both_axes_disabled_and_untrusted() { + Fixture fixture; + + TEST_ASSERT_TRUE(fixture.controller.initialize()); + TEST_ASSERT_FALSE(fixture.azimuth_driver.enabled); + TEST_ASSERT_FALSE(fixture.elevation_driver.enabled); + TEST_ASSERT_FALSE(fixture.controller.state().azimuth.position_trusted); + TEST_ASSERT_FALSE(fixture.controller.state().elevation.position_trusted); +} + +void test_coordinated_move_completes_only_after_both_axes_finish() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.trust_positions(); + + TEST_ASSERT_TRUE( + fixture.controller.move_absolute(18.0, 9.0, 20.0, 42).ok); + TEST_ASSERT_TRUE(fixture.azimuth.state().moving); + TEST_ASSERT_TRUE(fixture.elevation.state().moving); + fixture.service_until_complete(); + + TEST_ASSERT_FALSE(fixture.azimuth.state().moving); + TEST_ASSERT_FALSE(fixture.elevation.state().moving); + TEST_ASSERT_EQUAL_UINT32(42, + fixture.azimuth.state().last_completed_command); + TEST_ASSERT_EQUAL_UINT32(42, + fixture.elevation.state().last_completed_command); +} + +void test_one_axis_critical_fault_stops_coordinated_move_and_loses_trust() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.trust_positions(); + TEST_ASSERT_TRUE( + fixture.controller.move_absolute(90.0, 45.0, 10.0, 43).ok); + fixture.elevation_driver.status.overtemperature_shutdown = true; + fixture.elevation_driver.status.fault = + radiance3d::DriverFault::overtemperature_shutdown; + + fixture.platform.advance(101000); + fixture.controller.service(); + + TEST_ASSERT_FALSE(fixture.azimuth.state().moving); + TEST_ASSERT_FALSE(fixture.elevation.state().moving); + TEST_ASSERT_FALSE(fixture.azimuth.state().position_trusted); + TEST_ASSERT_FALSE(fixture.elevation.state().position_trusted); +} + +void test_emergency_stop_latches_both_axes_and_requires_released_input() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.trust_positions(); + TEST_ASSERT_TRUE( + fixture.controller.move_absolute(90.0, 45.0, 10.0, 44).ok); + + fixture.platform.pins[fixture.config.emergency_stop_pin] = false; + fixture.controller.service(); + fixture.platform.advance(2000); + fixture.controller.service(); + + TEST_ASSERT_TRUE(fixture.controller.state().emergency_stop_active); + TEST_ASSERT_FALSE(fixture.azimuth_driver.enabled); + TEST_ASSERT_FALSE(fixture.elevation_driver.enabled); + TEST_ASSERT_FALSE(fixture.controller.clear_fault().ok); + + fixture.platform.pins[fixture.config.emergency_stop_pin] = true; + fixture.controller.service(); + fixture.platform.advance(2000); + fixture.controller.service(); + TEST_ASSERT_TRUE(fixture.controller.clear_fault().ok); + TEST_ASSERT_FALSE(fixture.controller.state().emergency_stop_active); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_provisional_gpio_is_valid_and_validation_rejects_conflicts); + RUN_TEST(test_safe_startup_initializes_both_axes_disabled_and_untrusted); + RUN_TEST(test_coordinated_move_completes_only_after_both_axes_finish); + RUN_TEST(test_one_axis_critical_fault_stops_coordinated_move_and_loses_trust); + RUN_TEST(test_emergency_stop_latches_both_axes_and_requires_released_input); + return UNITY_END(); +} From 9eafe7810cbb2af6389ea3e58ac7b126fe9d9764 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 23:06:30 -0400 Subject: [PATCH 05/13] feat(firmware): integrate ESP32 motion protocol --- .../controller/include/esp32_platform.hpp | 32 ++ .../controller/include/motion_controller.hpp | 14 +- firmware/controller/include/protocol.hpp | 12 +- .../controller/include/stepper_driver.hpp | 25 +- firmware/controller/src/esp32_platform.cpp | 103 ++++ firmware/controller/src/main.cpp | 78 ++- firmware/controller/src/motion_controller.cpp | 83 ++- firmware/controller/src/protocol.cpp | 528 ++++++++++++++++-- firmware/controller/src/tmc2209_driver.cpp | 3 +- 9 files changed, 819 insertions(+), 59 deletions(-) create mode 100644 firmware/controller/include/esp32_platform.hpp create mode 100644 firmware/controller/src/esp32_platform.cpp diff --git a/firmware/controller/include/esp32_platform.hpp b/firmware/controller/include/esp32_platform.hpp new file mode 100644 index 0000000..52225de --- /dev/null +++ b/firmware/controller/include/esp32_platform.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "hardware_platform.hpp" + +#ifdef ARDUINO + +#include + +namespace radiance3d { + +class ArduinoEsp32Platform final : public HardwarePlatform { + public: + bool configure_pin(int pin, PinMode mode) override; + void write_pin(int pin, bool high) override; + bool read_pin(int pin) const override; + std::uint64_t monotonic_micros() const override; + bool begin_uart(std::uint8_t channel, int tx_pin, int rx_pin, + std::uint32_t baud) override; + void flush_uart_input(std::uint8_t channel) override; + bool write_uart(std::uint8_t channel, const std::uint8_t* data, + std::size_t length) override; + std::size_t read_uart(std::uint8_t channel, std::uint8_t* data, + std::size_t maximum_length, + std::uint32_t timeout_ms) override; + + private: + HardwareSerial* uart(std::uint8_t channel) const; +}; + +} // namespace radiance3d + +#endif diff --git a/firmware/controller/include/motion_controller.hpp b/firmware/controller/include/motion_controller.hpp index 15bd766..210f058 100644 --- a/firmware/controller/include/motion_controller.hpp +++ b/firmware/controller/include/motion_controller.hpp @@ -124,8 +124,12 @@ struct ControllerState { }; struct MotionResult { - bool ok{false}; - FaultCode fault{FaultCode::none}; + bool ok; + FaultCode fault; + + constexpr MotionResult(bool ok_value = false, + FaultCode fault_value = FaultCode::none) + : ok(ok_value), fault(fault_value) {} }; class MotionController { @@ -195,9 +199,15 @@ class SimulatedMotionController final : public MotionController { DriverStatus driver_status(AxisSelection axis) const override; void report_fault(FaultCode code) override; + void simulate_driver_status(AxisSelection axis, DriverStatus status); + void simulate_homing_failure(AxisSelection axis, FaultCode fault); + void simulate_reset(AxisSelection axis); + private: ControllerConfig config_{}; ControllerState state_{}; + FaultCode azimuth_homing_failure_{FaultCode::none}; + FaultCode elevation_homing_failure_{FaultCode::none}; MotionResult fail(FaultCode code); MotionResult succeed(); diff --git a/firmware/controller/include/protocol.hpp b/firmware/controller/include/protocol.hpp index 6fe9c09..484c5d9 100644 --- a/firmware/controller/include/protocol.hpp +++ b/firmware/controller/include/protocol.hpp @@ -12,13 +12,23 @@ class ProtocolEngine { explicit ProtocolEngine(MotionController& controller); std::string handle(const std::string& line); + std::string service(); const ControllerState& state() const; private: SimulatedMotionController default_controller_; MotionController* controller_; - + std::uint32_t last_command_id_{0}; + FaultCode previous_fault_{FaultCode::none}; + bool previous_estop_{false}; + bool previous_azimuth_moving_{false}; + bool previous_elevation_moving_{false}; + + std::string handle_command(const std::string& line, + std::uint32_t command_id); std::string status() const; + std::string axis_status(AxisSelection axis) const; + std::string diagnostics(AxisSelection axis) const; std::string fault(FaultCode code, const std::string& detail); }; diff --git a/firmware/controller/include/stepper_driver.hpp b/firmware/controller/include/stepper_driver.hpp index b56fc80..b4df22b 100644 --- a/firmware/controller/include/stepper_driver.hpp +++ b/firmware/controller/include/stepper_driver.hpp @@ -20,12 +20,25 @@ enum class DriverFault { }; struct DriverCapabilities { - bool uart_diagnostics{false}; - bool configurable_current{false}; - bool configurable_microsteps{false}; - bool interpolation{false}; - bool stealthchop{false}; - bool spreadcycle{false}; + bool uart_diagnostics; + bool configurable_current; + bool configurable_microsteps; + bool interpolation; + bool stealthchop; + bool spreadcycle; + + constexpr DriverCapabilities(bool uart_diagnostics_value = false, + bool configurable_current_value = false, + bool configurable_microsteps_value = false, + bool interpolation_value = false, + bool stealthchop_value = false, + bool spreadcycle_value = false) + : uart_diagnostics(uart_diagnostics_value), + configurable_current(configurable_current_value), + configurable_microsteps(configurable_microsteps_value), + interpolation(interpolation_value), + stealthchop(stealthchop_value), + spreadcycle(spreadcycle_value) {} }; struct DriverStatus { diff --git a/firmware/controller/src/esp32_platform.cpp b/firmware/controller/src/esp32_platform.cpp new file mode 100644 index 0000000..5fc12e6 --- /dev/null +++ b/firmware/controller/src/esp32_platform.cpp @@ -0,0 +1,103 @@ +#include "esp32_platform.hpp" + +#ifdef ARDUINO + +#include + +namespace radiance3d { + +bool ArduinoEsp32Platform::configure_pin(const int pin, const PinMode mode) { + if (pin < 0) { + return false; + } + switch (mode) { + case PinMode::input: + pinMode(pin, INPUT); + break; + case PinMode::input_pullup: + pinMode(pin, INPUT_PULLUP); + break; + case PinMode::output: + pinMode(pin, OUTPUT); + break; + } + return true; +} + +void ArduinoEsp32Platform::write_pin(const int pin, const bool high) { + digitalWrite(pin, high ? HIGH : LOW); +} + +bool ArduinoEsp32Platform::read_pin(const int pin) const { + return digitalRead(pin) == HIGH; +} + +std::uint64_t ArduinoEsp32Platform::monotonic_micros() const { + return static_cast(esp_timer_get_time()); +} + +HardwareSerial* ArduinoEsp32Platform::uart(const std::uint8_t channel) const { + if (channel == 1) { + return &Serial1; + } + if (channel == 2) { + return &Serial2; + } + return nullptr; +} + +bool ArduinoEsp32Platform::begin_uart(const std::uint8_t channel, + const int tx_pin, const int rx_pin, + const std::uint32_t baud) { + HardwareSerial* serial = uart(channel); + if (serial == nullptr) { + return false; + } + serial->begin(baud, SERIAL_8N1, rx_pin, tx_pin); + return true; +} + +void ArduinoEsp32Platform::flush_uart_input(const std::uint8_t channel) { + HardwareSerial* serial = uart(channel); + if (serial == nullptr) { + return; + } + while (serial->available() > 0) { + serial->read(); + } +} + +bool ArduinoEsp32Platform::write_uart(const std::uint8_t channel, + const std::uint8_t* data, + const std::size_t length) { + HardwareSerial* serial = uart(channel); + return serial != nullptr && serial->write(data, length) == length; +} + +std::size_t ArduinoEsp32Platform::read_uart( + const std::uint8_t channel, std::uint8_t* data, + const std::size_t maximum_length, const std::uint32_t timeout_ms) { + HardwareSerial* serial = uart(channel); + if (serial == nullptr) { + return 0; + } + const std::uint64_t started = monotonic_micros(); + const std::uint64_t timeout_us = + static_cast(timeout_ms) * 1000ULL; + std::size_t received = 0; + while (received < maximum_length && + monotonic_micros() - started < timeout_us) { + while (serial->available() > 0 && received < maximum_length) { + data[received++] = static_cast(serial->read()); + } + if (received >= 8) { + break; + } + yield(); + } + return received; +} + +} // namespace radiance3d + +#endif diff --git a/firmware/controller/src/main.cpp b/firmware/controller/src/main.cpp index cd9b1e6..87ab864 100644 --- a/firmware/controller/src/main.cpp +++ b/firmware/controller/src/main.cpp @@ -1,25 +1,92 @@ #include "protocol.hpp" #ifdef ARDUINO + +#include "axis_controller.hpp" +#include "esp32_platform.hpp" +#include "hardware_config.hpp" +#include "physical_motion_controller.hpp" +#include "tmc2209_driver.hpp" + #include -radiance3d::ProtocolEngine engine; +namespace { + +radiance3d::ArduinoEsp32Platform platform; +radiance3d::PhysicalControllerConfig physical_config = + radiance3d::provisional_esp32_dev_config(); +radiance3d::Tmc2209Driver azimuth_driver( + platform, physical_config.azimuth.driver); +radiance3d::Tmc2209Driver elevation_driver( + platform, physical_config.elevation.driver); +radiance3d::AxisController azimuth_axis( + platform, azimuth_driver, physical_config.azimuth.axis); +radiance3d::AxisController elevation_axis( + platform, elevation_driver, physical_config.elevation.axis); +radiance3d::PhysicalMotionController controller( + platform, azimuth_axis, elevation_axis, physical_config); +radiance3d::ProtocolEngine engine(controller); String incoming; +std::uint32_t last_host_activity_ms = 0; +bool host_seen = false; +bool host_watchdog_tripped = false; +constexpr std::uint32_t kHostWatchdogMs = 2000; -void setup() { Serial.begin(115200); } +} // namespace + +void setup() { + Serial.begin(115200); + const radiance3d::GpioValidationResult gpio = + radiance3d::validate_esp32_gpio(physical_config); + if (gpio.bootstrapping_pin_mask != 0) { + Serial.print("EVENT WARNING CODE=ESP32_BOOTSTRAP_GPIO MASK="); + Serial.println(static_cast(gpio.bootstrapping_pin_mask)); + } + const bool initialized = controller.initialize(); + Serial.print("EVENT STARTUP READY="); + Serial.print(initialized ? 1 : 0); + Serial.print(" DRIVERS_ENABLED=0 BOARD="); + Serial.println(physical_config.board_name); +} void loop() { + if (host_seen && !host_watchdog_tripped && + millis() - last_host_activity_ms >= kHostWatchdogMs && + (controller.state().azimuth.enabled || + controller.state().elevation.enabled)) { + controller.stop(); + controller.set_enabled(false); + host_watchdog_tripped = true; + Serial.println( + "EVENT FAULT CODE=DRIVER_DISABLED DETAIL=HOST_HEARTBEAT_TIMEOUT"); + } + + const std::string event = engine.service(); + if (!event.empty()) { + Serial.println(event.c_str()); + } + while (Serial.available() > 0) { const char character = static_cast(Serial.read()); if (character == '\n') { + last_host_activity_ms = millis(); + host_seen = true; + host_watchdog_tripped = false; Serial.println(engine.handle(incoming.c_str()).c_str()); incoming = ""; } else if (character != '\r') { - incoming += character; + if (incoming.length() < 255) { + incoming += character; + } else { + incoming = ""; + Serial.println("ERR INVALID_ARGUMENT input line exceeds 255 bytes"); + } } } } + #elif !defined(UNIT_TEST) + #include #include @@ -28,7 +95,12 @@ int main() { std::string line; while (std::getline(std::cin, line)) { std::cout << engine.handle(line) << '\n'; + const std::string event = engine.service(); + if (!event.empty()) { + std::cout << event << '\n'; + } } return 0; } + #endif diff --git a/firmware/controller/src/motion_controller.cpp b/firmware/controller/src/motion_controller.cpp index dd4a449..51581af 100644 --- a/firmware/controller/src/motion_controller.cpp +++ b/firmware/controller/src/motion_controller.cpp @@ -50,22 +50,46 @@ ControllerConfig provisional_simulator_config() { config.azimuth.minimum_angle_deg = 0.0; config.azimuth.maximum_angle_deg = 360.0; config.azimuth.home_offset_deg = 0.0; + config.azimuth.motor_rms_current_ma = 400; config.elevation.minimum_angle_deg = -90.0; config.elevation.maximum_angle_deg = 90.0; config.elevation.home_offset_deg = 0.0; + config.elevation.motor_rms_current_ma = 400; return config; } SimulatedMotionController::SimulatedMotionController(ControllerConfig config) : config_(std::move(config)) { + state_.azimuth.last_driver_status.connected = true; + state_.elevation.last_driver_status.connected = true; if (!config_.valid()) { state_.fault = FaultCode::invalid_configuration; } } -bool SimulatedMotionController::initialize() { return config_.valid(); } +bool SimulatedMotionController::initialize() { + return config_.valid() && state_.azimuth.last_driver_status.connected && + state_.elevation.last_driver_status.connected; +} -void SimulatedMotionController::service() {} +void SimulatedMotionController::service() { + if (state_.azimuth.last_driver_status.critical_fault()) { + state_.azimuth.enabled = false; + state_.azimuth.fault = + state_.azimuth.last_driver_status.connected + ? FaultCode::driver_critical + : FaultCode::driver_communication; + invalidate(state_.azimuth, TrustLossReason::driver_fault); + } + if (state_.elevation.last_driver_status.critical_fault()) { + state_.elevation.enabled = false; + state_.elevation.fault = + state_.elevation.last_driver_status.connected + ? FaultCode::driver_critical + : FaultCode::driver_communication; + invalidate(state_.elevation, TrustLossReason::driver_fault); + } +} const ControllerConfig& SimulatedMotionController::config() const { return config_; } @@ -99,8 +123,23 @@ MotionResult SimulatedMotionController::home(const AxisSelection axis, if (state_.stopped) { return fail(FaultCode::stopped); } + if ((axis == AxisSelection::azimuth || axis == AxisSelection::both) && + azimuth_homing_failure_ != FaultCode::none) { + state_.azimuth.fault = azimuth_homing_failure_; + state_.azimuth.enabled = false; + invalidate(state_.azimuth, TrustLossReason::homing_failed); + return fail(azimuth_homing_failure_); + } + if ((axis == AxisSelection::elevation || axis == AxisSelection::both) && + elevation_homing_failure_ != FaultCode::none) { + state_.elevation.fault = elevation_homing_failure_; + state_.elevation.enabled = false; + invalidate(state_.elevation, TrustLossReason::homing_failed); + return fail(elevation_homing_failure_); + } if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { state_.azimuth.enabled = true; + state_.azimuth.last_driver_status.enabled = true; state_.azimuth.commanded_position_deg = config_.azimuth.home_offset_deg; state_.azimuth.internal_step_position = 0; state_.azimuth.target_step_position = 0; @@ -111,6 +150,7 @@ MotionResult SimulatedMotionController::home(const AxisSelection axis, } if (axis == AxisSelection::elevation || axis == AxisSelection::both) { state_.elevation.enabled = true; + state_.elevation.last_driver_status.enabled = true; state_.elevation.commanded_position_deg = config_.elevation.home_offset_deg; state_.elevation.internal_step_position = 0; state_.elevation.target_step_position = 0; @@ -189,6 +229,8 @@ MotionResult SimulatedMotionController::clear_fault() { MotionResult SimulatedMotionController::set_enabled(const bool enabled) { state_.azimuth.enabled = enabled; state_.elevation.enabled = enabled; + state_.azimuth.last_driver_status.enabled = enabled; + state_.elevation.last_driver_status.enabled = enabled; if (!enabled) { invalidate(state_.azimuth, TrustLossReason::driver_disabled_during_motion); invalidate(state_.elevation, TrustLossReason::driver_disabled_during_motion); @@ -261,6 +303,7 @@ MotionResult SimulatedMotionController::set_axis_enabled( const AxisSelection axis, const bool enabled) { if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { state_.azimuth.enabled = enabled; + state_.azimuth.last_driver_status.enabled = enabled; if (!enabled) { invalidate(state_.azimuth, TrustLossReason::driver_disabled_during_motion); @@ -268,6 +311,7 @@ MotionResult SimulatedMotionController::set_axis_enabled( } if (axis == AxisSelection::elevation || axis == AxisSelection::both) { state_.elevation.enabled = enabled; + state_.elevation.last_driver_status.enabled = enabled; if (!enabled) { invalidate(state_.elevation, TrustLossReason::driver_disabled_during_motion); @@ -320,6 +364,41 @@ DriverStatus SimulatedMotionController::driver_status( return state_.azimuth.last_driver_status; } +void SimulatedMotionController::simulate_driver_status( + const AxisSelection axis, const DriverStatus status) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.last_driver_status = status; + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.last_driver_status = status; + } +} + +void SimulatedMotionController::simulate_homing_failure( + const AxisSelection axis, const FaultCode fault) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + azimuth_homing_failure_ = fault; + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + elevation_homing_failure_ = fault; + } +} + +void SimulatedMotionController::simulate_reset(const AxisSelection axis) { + if (axis == AxisSelection::azimuth || axis == AxisSelection::both) { + state_.azimuth.enabled = false; + state_.azimuth.fault = FaultCode::driver_communication; + invalidate(state_.azimuth, TrustLossReason::watchdog_reset_during_motion); + } + if (axis == AxisSelection::elevation || axis == AxisSelection::both) { + state_.elevation.enabled = false; + state_.elevation.fault = FaultCode::driver_communication; + invalidate(state_.elevation, + TrustLossReason::watchdog_reset_during_motion); + } + state_.fault = FaultCode::driver_communication; +} + void SimulatedMotionController::report_fault(const FaultCode code) { state_.fault = code; } } // namespace radiance3d diff --git a/firmware/controller/src/protocol.cpp b/firmware/controller/src/protocol.cpp index 6f552e3..3b2b595 100644 --- a/firmware/controller/src/protocol.cpp +++ b/firmware/controller/src/protocol.cpp @@ -1,7 +1,10 @@ #include "protocol.hpp" +#include #include +#include #include +#include #include #include @@ -54,6 +57,90 @@ const char* fault_name(const FaultCode code) { return "UNKNOWN"; } +const char* trust_reason_name(const TrustLossReason reason) { + switch (reason) { + case TrustLossReason::startup: + return "STARTUP"; + case TrustLossReason::none: + return "NONE"; + case TrustLossReason::reset: + return "RESET"; + case TrustLossReason::power_loss: + return "POWER_LOSS"; + case TrustLossReason::emergency_stop: + return "EMERGENCY_STOP"; + case TrustLossReason::driver_fault: + return "DRIVER_FAULT"; + case TrustLossReason::driver_disabled_during_motion: + return "DRIVER_DISABLED"; + case TrustLossReason::motion_timeout: + return "MOTION_TIMEOUT"; + case TrustLossReason::suspected_missed_step: + return "SUSPECTED_MISSED_STEP"; + case TrustLossReason::manual_movement: + return "MANUAL_MOVEMENT"; + case TrustLossReason::configuration_changed: + return "CONFIGURATION_CHANGED"; + case TrustLossReason::homing_failed: + return "HOMING_FAILED"; + case TrustLossReason::watchdog_reset_during_motion: + return "WATCHDOG_RESET"; + case TrustLossReason::stopped: + return "STOPPED"; + } + return "UNKNOWN"; +} + +const char* homing_phase_name(const HomingPhase phase) { + switch (phase) { + case HomingPhase::idle: + return "IDLE"; + case HomingPhase::validate_switch: + return "VALIDATE_SWITCH"; + case HomingPhase::fast_approach: + return "FAST_APPROACH"; + case HomingPhase::backoff: + return "BACKOFF"; + case HomingPhase::confirm_release: + return "CONFIRM_RELEASE"; + case HomingPhase::slow_approach: + return "SLOW_APPROACH"; + case HomingPhase::apply_offset: + return "APPLY_OFFSET"; + case HomingPhase::complete: + return "COMPLETE"; + case HomingPhase::failed: + return "FAILED"; + } + return "UNKNOWN"; +} + +const char* driver_fault_name(const DriverFault fault) { + switch (fault) { + case DriverFault::none: + return "NONE"; + case DriverFault::invalid_configuration: + return "INVALID_CONFIGURATION"; + case DriverFault::communication_failure: + return "COMMUNICATION_FAILURE"; + case DriverFault::reset_detected: + return "RESET_DETECTED"; + case DriverFault::undervoltage: + return "UNDERVOLTAGE"; + case DriverFault::overtemperature_warning: + return "OVERTEMPERATURE_WARNING"; + case DriverFault::overtemperature_shutdown: + return "OVERTEMPERATURE_SHUTDOWN"; + case DriverFault::short_to_ground: + return "SHORT_TO_GROUND"; + case DriverFault::short_to_supply: + return "SHORT_TO_SUPPLY"; + case DriverFault::open_load: + return "OPEN_LOAD"; + } + return "UNKNOWN"; +} + bool read_double(std::istringstream& input, double& value) { input >> value; return !input.fail() && std::isfinite(value); @@ -65,78 +152,219 @@ bool no_extra_arguments(std::istringstream& input) { return extra.empty(); } +bool parse_axis(const std::string& text, AxisSelection& axis, + const bool allow_both = true) { + if (text == "AZ") { + axis = AxisSelection::azimuth; + return true; + } + if (text == "EL") { + axis = AxisSelection::elevation; + return true; + } + if (allow_both && text == "BOTH") { + axis = AxisSelection::both; + return true; + } + return false; +} + +std::string with_command_id(const std::string& response, + const std::uint32_t command_id) { + if (command_id == 0) { + return response; + } + const std::string id = " ID=" + std::to_string(command_id); + if (response.rfind("OK", 0) == 0) { + return "OK" + id + response.substr(2); + } + if (response.rfind("ERR", 0) == 0) { + return "ERR" + id + response.substr(3); + } + return response + id; +} + } // namespace ProtocolEngine::ProtocolEngine() - : default_controller_(provisional_simulator_config()), controller_(&default_controller_) {} + : default_controller_(provisional_simulator_config()), + controller_(&default_controller_) {} ProtocolEngine::ProtocolEngine(MotionController& controller) - : default_controller_(provisional_simulator_config()), controller_(&controller) {} + : default_controller_(provisional_simulator_config()), + controller_(&controller) {} -const ControllerState& ProtocolEngine::state() const { return controller_->state(); } +const ControllerState& ProtocolEngine::state() const { + return controller_->state(); +} -std::string ProtocolEngine::fault(const FaultCode code, const std::string& detail) { +std::string ProtocolEngine::fault(const FaultCode code, + const std::string& detail) { controller_->report_fault(code); return "ERR " + std::string(fault_name(code)) + " " + detail; } +std::string ProtocolEngine::axis_status(const AxisSelection axis) const { + const AxisState& selected = + axis == AxisSelection::elevation ? state().elevation : state().azimuth; + const char* axis_name = + axis == AxisSelection::elevation ? "EL" : "AZ"; + std::ostringstream output; + output << std::fixed << std::setprecision(3) << "AXIS=" << axis_name + << " DEG=" << selected.commanded_position_deg + << " STEPS=" << selected.internal_step_position + << " TARGET_STEPS=" << selected.target_step_position + << " POSITION_KIND=COMMANDED" + << " TRUSTED=" << (selected.position_trusted ? 1 : 0) + << " TRUST_LOSS=" << trust_reason_name(selected.trust_loss_reason) + << " HOMED=" << (selected.homed ? 1 : 0) + << " MOVING=" << (selected.moving ? 1 : 0) + << " ENABLED=" << (selected.enabled ? 1 : 0) + << " HOME_ACTIVE=" << (selected.home_switch_active ? 1 : 0) + << " HOMING=" << homing_phase_name(selected.homing_phase) + << " FAULT=" << fault_name(selected.fault) + << " LAST_COMMAND=" << selected.last_completed_command; + return output.str(); +} + std::string ProtocolEngine::status() const { - const ControllerState& controller_state = state(); std::ostringstream output; output << std::fixed << std::setprecision(3) << "OK STATUS" - << " AZ_DEG=" << controller_state.azimuth.commanded_position_deg - << " EL_DEG=" << controller_state.elevation.commanded_position_deg + << " AZ_DEG=" << state().azimuth.commanded_position_deg + << " EL_DEG=" << state().elevation.commanded_position_deg << " POSITION_KIND=COMMANDED" - << " AZ_HOMED=" << (controller_state.azimuth.homed ? 1 : 0) - << " EL_HOMED=" << (controller_state.elevation.homed ? 1 : 0) - << " AZ_TRUSTED=" << (controller_state.azimuth.position_trusted ? 1 : 0) - << " EL_TRUSTED=" << (controller_state.elevation.position_trusted ? 1 : 0) + << " AZ_HOMED=" << (state().azimuth.homed ? 1 : 0) + << " EL_HOMED=" << (state().elevation.homed ? 1 : 0) + << " AZ_TRUSTED=" + << (state().azimuth.position_trusted ? 1 : 0) + << " EL_TRUSTED=" + << (state().elevation.position_trusted ? 1 : 0) << " DRIVERS_ENABLED=" - << ((controller_state.azimuth.enabled && controller_state.elevation.enabled) ? 1 : 0) - << " STOPPED=" << (controller_state.stopped ? 1 : 0) - << " ESTOP=" << (controller_state.emergency_stop_active ? 1 : 0) - << " FAULT=" << fault_name(controller_state.fault); + << ((state().azimuth.enabled && state().elevation.enabled) ? 1 : 0) + << " " << axis_status(AxisSelection::azimuth) << " " + << axis_status(AxisSelection::elevation) + << " STOPPED=" << (state().stopped ? 1 : 0) + << " ESTOP=" << (state().emergency_stop_active ? 1 : 0) + << " FAULT=" << fault_name(state().fault); + return output.str(); +} + +std::string ProtocolEngine::diagnostics(const AxisSelection axis) const { + const DriverStatus driver = controller_->driver_status(axis); + const DriverCapabilities capabilities = + controller_->driver_capabilities(axis); + std::ostringstream output; + output << "OK MOTOR_DIAGNOSTICS AXIS=" + << (axis == AxisSelection::azimuth ? "AZ" : "EL") + << " CONNECTED=" << (driver.connected ? 1 : 0) + << " ENABLED=" << (driver.enabled ? 1 : 0) + << " FAULT=" << driver_fault_name(driver.fault) + << " OTPW=" << (driver.overtemperature_warning ? 1 : 0) + << " OT=" << (driver.overtemperature_shutdown ? 1 : 0) + << " UV=" << (driver.undervoltage ? 1 : 0) + << " RESET=" << (driver.reset_detected ? 1 : 0) + << " S2GA=" << (driver.short_to_ground_a ? 1 : 0) + << " S2GB=" << (driver.short_to_ground_b ? 1 : 0) + << " S2VSA=" << (driver.short_to_supply_a ? 1 : 0) + << " S2VSB=" << (driver.short_to_supply_b ? 1 : 0) + << " OLA=" << (driver.open_load_a ? 1 : 0) + << " OLB=" << (driver.open_load_b ? 1 : 0) + << " CURRENT_SCALE=" << static_cast(driver.current_scale) + << " CAP_UART=" << (capabilities.uart_diagnostics ? 1 : 0) + << " CAP_CURRENT=" << (capabilities.configurable_current ? 1 : 0) + << " CAP_MICROSTEPS=" + << (capabilities.configurable_microsteps ? 1 : 0); return output.str(); } std::string ProtocolEngine::handle(const std::string& line) { std::istringstream input(line); + std::string first; + input >> first; + if (first != "CMD") { + return handle_command(line, 0); + } + + std::uint64_t parsed_id = 0; + input >> parsed_id; + std::string command; + std::getline(input, command); + const std::size_t first_non_space = command.find_first_not_of(" \t"); + if (input.fail() || parsed_id == 0 || + parsed_id > std::numeric_limits::max() || + first_non_space == std::string::npos) { + return "ERR INVALID_ARGUMENT CMD expects positive-id and command"; + } + const std::uint32_t command_id = static_cast(parsed_id); + if (command_id == last_command_id_) { + return "ERR ID=" + std::to_string(command_id) + + " DUPLICATE_COMMAND command id was already consumed"; + } + if (command_id < last_command_id_) { + return "ERR ID=" + std::to_string(command_id) + + " STALE_COMMAND command id is older than last consumed id"; + } + last_command_id_ = command_id; + return with_command_id( + handle_command(command.substr(first_non_space), command_id), + command_id); +} + +std::string ProtocolEngine::handle_command(const std::string& line, + const std::uint32_t command_id) { + std::istringstream input(line); std::string command; input >> command; if (command == "IDENTIFY") { if (!no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, "IDENTIFY expects no arguments"); + return fault(FaultCode::invalid_argument, + "IDENTIFY expects no arguments"); } - return "OK IDENTIFY DEVICE=Radiance3D-SIM PROTOCOL=" + - std::to_string(RADIANCE3D_PROTOCOL_VERSION) + " MODE=SIMULATOR"; + return "OK IDENTIFY DEVICE=Radiance3D CONTROLLER=motion " + "PROTOCOL=" + + std::to_string(RADIANCE3D_PROTOCOL_VERSION) + + " MODE=" + (controller_ == &default_controller_ ? "SIMULATOR" + : "PHYSICAL"); } if (command == "STATUS" || command == "POSITION") { if (!no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, command + " expects no arguments"); + return fault(FaultCode::invalid_argument, + command + " expects no arguments"); } return status(); } - if (command == "CLEAR_FAULT") { + if (command == "HEARTBEAT") { if (!no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, "CLEAR_FAULT expects no arguments"); + return fault(FaultCode::invalid_argument, + "HEARTBEAT expects no arguments"); + } + return "OK HEARTBEAT"; + } + if (command == "CLEAR_FAULT" || command == "RESET_ESTOP") { + if (!no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + command + " expects no arguments"); } const MotionResult result = controller_->clear_fault(); if (!result.ok) { - return fault(result.fault, "emergency-stop input must be released first"); + return fault(result.fault, + "physical emergency-stop input or driver fault remains active"); } - return "OK CLEAR_FAULT"; + return "OK " + command; } if (command == "STOP") { if (!no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, "STOP expects no arguments"); + return fault(FaultCode::invalid_argument, + "STOP expects no arguments"); } controller_->stop(); return "OK STOP"; } if (command == "E_STOP") { if (!no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, "E_STOP expects no arguments"); + return fault(FaultCode::invalid_argument, + "E_STOP expects no arguments"); } controller_->emergency_stop(); return "OK E_STOP"; @@ -144,8 +372,10 @@ std::string ProtocolEngine::handle(const std::string& line) { if (command == "ENABLE") { int enabled = -1; input >> enabled; - if (input.fail() || (enabled != 0 && enabled != 1) || !no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, "ENABLE expects 0 or 1"); + if (input.fail() || (enabled != 0 && enabled != 1) || + !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "ENABLE expects 0 or 1"); } const MotionResult result = controller_->set_enabled(enabled == 1); if (!result.ok && enabled == 1) { @@ -154,44 +384,254 @@ std::string ProtocolEngine::handle(const std::string& line) { return "OK ENABLE VALUE=" + std::to_string(enabled); } if (command == "HOME") { - std::string axis; - input >> axis; - if (!no_extra_arguments(input) || (axis != "AZ" && axis != "EL" && axis != "BOTH")) { - return fault(FaultCode::invalid_argument, "HOME expects AZ, EL, or BOTH"); - } - const AxisSelection selection = - axis == "AZ" ? AxisSelection::azimuth - : axis == "EL" ? AxisSelection::elevation - : AxisSelection::both; - const MotionResult result = controller_->home(selection); + std::string axis_text; + AxisSelection axis = AxisSelection::both; + input >> axis_text; + if (!parse_axis(axis_text, axis) || !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "HOME expects AZ, EL, or BOTH"); + } + const MotionResult result = controller_->home(axis, command_id); if (!result.ok) { - return fault(result.fault, "homing rejected by motion controller"); + return fault(result.fault, + "homing rejected by motion controller"); } - return "OK HOME AXIS=" + axis; + return "OK HOME AXIS=" + axis_text + + (state().azimuth.moving || state().elevation.moving + ? " ACCEPTED=1 READY=0" + : ""); } if (command == "MOVE" || command == "SCAN_STEP") { double azimuth = 0.0; double elevation = 0.0; double speed = 0.0; if (!read_double(input, azimuth) || !read_double(input, elevation) || - !read_double(input, speed) || speed <= 0.0 || !no_extra_arguments(input)) { - return fault(FaultCode::invalid_argument, command + " expects AZ_DEG EL_DEG DEG_PER_S"); + !read_double(input, speed) || speed <= 0.0 || + !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + command + " expects AZ_DEG EL_DEG DEG_PER_S"); } - const MotionResult result = controller_->move_absolute(azimuth, elevation, speed); + const MotionResult result = controller_->move_absolute( + azimuth, elevation, speed, command_id); if (!result.ok) { - return fault(result.fault, "motion rejected by configured controller limits or state"); + return fault( + result.fault, + "motion rejected by configured controller limits or state"); } + const bool moving = state().azimuth.moving || state().elevation.moving; std::ostringstream output; output << std::fixed << std::setprecision(3) << "OK " << command << " AZ_DEG=" << azimuth << " EL_DEG=" << elevation << " DEG_PER_S=" << speed; if (command == "SCAN_STEP") { - output << " READY=1 POSITION_KIND=COMMANDED"; + output << " READY=" << (moving ? 0 : 1) + << " POSITION_KIND=COMMANDED"; + } else if (moving) { + output << " ACCEPTED=1"; } return output.str(); } + if (command == "MOVE_REL") { + std::string axis_text; + AxisSelection axis = AxisSelection::both; + double delta = 0.0; + double speed = 0.0; + input >> axis_text; + if (!parse_axis(axis_text, axis) || !read_double(input, delta) || + !read_double(input, speed) || speed <= 0.0 || + !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "MOVE_REL expects AXIS DELTA_DEG DEG_PER_S"); + } + const MotionResult result = + controller_->move_relative(axis, delta, speed, command_id); + if (!result.ok) { + return fault(result.fault, "relative motion rejected"); + } + return "OK MOVE_REL AXIS=" + axis_text + " ACCEPTED=1"; + } + if (command == "MOTOR") { + std::string operation; + input >> operation; + if (operation == "IDENTIFY" && no_extra_arguments(input)) { + const DriverCapabilities azimuth = + controller_->driver_capabilities(AxisSelection::azimuth); + const DriverCapabilities elevation = + controller_->driver_capabilities(AxisSelection::elevation); + return "OK MOTOR_IDENTIFY AZ_PRESENT=" + + std::to_string( + controller_->driver_status(AxisSelection::azimuth).connected) + + " EL_PRESENT=" + + std::to_string(controller_ + ->driver_status(AxisSelection::elevation) + .connected) + + " UART=" + + std::to_string(azimuth.uart_diagnostics && + elevation.uart_diagnostics); + } + + std::string axis_text; + AxisSelection axis = AxisSelection::both; + input >> axis_text; + if (!parse_axis(axis_text, axis, false)) { + return fault(FaultCode::invalid_argument, + "MOTOR operation expects AZ or EL"); + } + if (operation == "STATUS" && no_extra_arguments(input)) { + return "OK MOTOR_STATUS " + axis_status(axis); + } + if (operation == "CONFIG" && no_extra_arguments(input)) { + const AxisConfig& config = + axis == AxisSelection::azimuth ? controller_->config().azimuth + : controller_->config().elevation; + std::ostringstream output; + output << std::fixed << std::setprecision(3) + << "OK MOTOR_CONFIG AXIS=" << axis_text + << " FULL_STEPS=" << config.motor_full_steps_per_revolution + << " MICROSTEPS=" << config.microsteps + << " GEAR_RATIO=" << config.gear_ratio + << " MIN_DEG=" << config.minimum_angle_deg + << " MAX_DEG=" << config.maximum_angle_deg + << " MAX_SPEED=" << config.maximum_speed_deg_per_s + << " ACCEL=" << config.acceleration_deg_per_s2 + << " RMS_MA=" << config.motor_rms_current_ma + << " HOLD_PERCENT=" + << static_cast(config.hold_current_percent) + << " HOME_OFFSET=" << config.home_offset_deg; + return output.str(); + } + if (operation == "DIAGNOSTICS" && no_extra_arguments(input)) { + return diagnostics(axis); + } + if ((operation == "ENABLE" || operation == "DISABLE") && + no_extra_arguments(input)) { + const bool enabled = operation == "ENABLE"; + const MotionResult result = + controller_->set_axis_enabled(axis, enabled); + if (!result.ok) { + return fault(result.fault, "axis enable change rejected"); + } + return "OK MOTOR_" + operation + " AXIS=" + axis_text; + } + if (operation == "STOP" && no_extra_arguments(input)) { + const MotionResult result = controller_->stop_axis(axis); + return result.ok ? "OK MOTOR_STOP AXIS=" + axis_text + : fault(result.fault, "axis stop rejected"); + } + if (operation == "STEP") { + long long signed_steps = 0; + input >> signed_steps; + if (input.fail() || !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "MOTOR STEP expects AXIS SIGNED_STEPS"); + } + const MotionResult result = controller_->bench_move_steps( + axis, static_cast(signed_steps), command_id); + return result.ok + ? "OK MOTOR_STEP AXIS=" + axis_text + + " ACCEPTED=1 POSITION_TRUSTED=0" + : fault(result.fault, "bench step rejected"); + } + if (operation == "MOVE_DEGREES") { + double degrees = 0.0; + if (!read_double(input, degrees) || !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "MOTOR MOVE_DEGREES expects AXIS SIGNED_DEGREES"); + } + const AxisConfig& config = + axis == AxisSelection::azimuth ? controller_->config().azimuth + : controller_->config().elevation; + const long double scaled = + static_cast(degrees) * + config.steps_per_output_revolution() / 360.0L; + if (scaled > + static_cast( + std::numeric_limits::max()) || + scaled < + static_cast( + std::numeric_limits::min())) { + return fault(FaultCode::invalid_argument, + "bench angle overflows step range"); + } + const MotionResult result = controller_->bench_move_steps( + axis, static_cast(std::llround(scaled)), + command_id); + return result.ok + ? "OK MOTOR_MOVE_DEGREES AXIS=" + axis_text + + " ACCEPTED=1 POSITION_TRUSTED=0" + : fault(result.fault, "bench angle rejected"); + } + if (operation == "SET_CURRENT") { + unsigned long current = 0; + input >> current; + if (input.fail() || + current > std::numeric_limits::max() || + !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "MOTOR SET_CURRENT expects AXIS RMS_MA"); + } + const MotionResult result = controller_->set_axis_current( + axis, static_cast(current)); + return result.ok + ? "OK MOTOR_SET_CURRENT AXIS=" + axis_text + + " RMS_MA=" + std::to_string(current) + : fault(result.fault, "current rejected"); + } + if (operation == "SET_MICROSTEPS") { + unsigned long microsteps = 0; + input >> microsteps; + if (input.fail() || + microsteps > std::numeric_limits::max() || + !no_extra_arguments(input)) { + return fault(FaultCode::invalid_argument, + "MOTOR SET_MICROSTEPS expects AXIS VALUE"); + } + const MotionResult result = controller_->set_axis_microsteps( + axis, static_cast(microsteps)); + return result.ok + ? "OK MOTOR_SET_MICROSTEPS AXIS=" + axis_text + + " VALUE=" + std::to_string(microsteps) + + " POSITION_TRUSTED=0" + : fault(result.fault, "microsteps rejected"); + } + return fault(FaultCode::invalid_command, + "unknown or malformed MOTOR operation"); + } return fault(FaultCode::invalid_command, "unknown command"); } +std::string ProtocolEngine::service() { + controller_->service(); + const ControllerState& current = state(); + std::string event; + if (current.emergency_stop_active != previous_estop_) { + event = "EVENT ESTOP ACTIVE=" + + std::to_string(current.emergency_stop_active ? 1 : 0); + } else if (current.fault != previous_fault_) { + event = "EVENT FAULT CODE=" + + std::string(fault_name(current.fault)); + } else { + const bool completed = + (previous_azimuth_moving_ || previous_elevation_moving_) && + !current.azimuth.moving && !current.elevation.moving; + if (completed && current.fault == FaultCode::none) { + const std::uint32_t completed_id = + std::max(current.azimuth.last_completed_command, + current.elevation.last_completed_command); + event = "EVENT MOTION_COMPLETE ID=" + + std::to_string(completed_id) + + " AZ_DONE=" + + std::to_string(current.azimuth.moving ? 0 : 1) + + " EL_DONE=" + + std::to_string(current.elevation.moving ? 0 : 1); + } + } + previous_estop_ = current.emergency_stop_active; + previous_fault_ = current.fault; + previous_azimuth_moving_ = current.azimuth.moving; + previous_elevation_moving_ = current.elevation.moving; + return event; +} + } // namespace radiance3d diff --git a/firmware/controller/src/tmc2209_driver.cpp b/firmware/controller/src/tmc2209_driver.cpp index 3271dff..cab8380 100644 --- a/firmware/controller/src/tmc2209_driver.cpp +++ b/firmware/controller/src/tmc2209_driver.cpp @@ -162,7 +162,8 @@ bool Tmc2209Driver::initialize() { if (!write_register(kRegisterSlaveconf, 2UL << 8) || !write_register(kRegisterTpowerdown, 10) || !write_register(kRegisterPwmconf, - 0xC10D0024UL | kPwmAutoscale | kPwmAutograd)) { + 0xC10D0024UL | kPwmAutoscale | kPwmAutograd) || + !write_register(kRegisterGstat, 0x07UL)) { return false; } connected_ = true; From 1b57fae0d9ea66a7bb7aa93ae162583081869803 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 23:06:40 -0400 Subject: [PATCH 06/13] feat(host): add physical motion-control commands --- software/pyproject.toml | 3 + software/src/radiance3d/__init__.py | 28 +++ software/src/radiance3d/motion_client.py | 281 +++++++++++++++++++++++ software/src/radiance3d/transport.py | 223 ++++++++++++++++++ 4 files changed, 535 insertions(+) create mode 100644 software/src/radiance3d/motion_client.py create mode 100644 software/src/radiance3d/transport.py diff --git a/software/pyproject.toml b/software/pyproject.toml index e5167c2..dffd576 100644 --- a/software/pyproject.toml +++ b/software/pyproject.toml @@ -20,6 +20,9 @@ classifiers = [ dependencies = [] [project.optional-dependencies] +serial = [ + "pyserial>=3.5,<4", +] dev = [ "mypy>=1.15,<2", "pytest>=8.3,<9", diff --git a/software/src/radiance3d/__init__.py b/software/src/radiance3d/__init__.py index 85a1403..78e7e6f 100644 --- a/software/src/radiance3d/__init__.py +++ b/software/src/radiance3d/__init__.py @@ -10,13 +10,35 @@ PositionReport, ) from radiance3d.models import Angle, AngularStep, HardwareMetadata, RFMeasurement, Sample, Scan +from radiance3d.motion_client import ( + AxisConfiguration, + AxisState, + ControllerCommandError, + DriverCapabilities, + DriverDiagnostics, + PhysicalMotionController, +) from radiance3d.scanning import AxisScan, RasterScanConfig, ScanCoordinator, raster_points +from radiance3d.transport import ( + DeviceIdentityError, + ProtocolTransport, + ResponseCorrelationError, + SerialTransport, + TransportError, + TransportTimeout, +) from radiance3d.validation import ScanValidationError, load_scan __all__ = [ "Angle", "AngularStep", + "AxisConfiguration", "AxisScan", + "AxisState", + "ControllerCommandError", + "DeviceIdentityError", + "DriverCapabilities", + "DriverDiagnostics", "HardwareMetadata", "MeasurementAdapter", "MeasurementReading", @@ -25,12 +47,18 @@ "PositionConfidence", "PositionKind", "PositionReport", + "PhysicalMotionController", + "ProtocolTransport", "RFMeasurement", "RasterScanConfig", "Sample", "Scan", "ScanCoordinator", "ScanValidationError", + "SerialTransport", + "ResponseCorrelationError", + "TransportError", + "TransportTimeout", "load_scan", "raster_points", ] diff --git a/software/src/radiance3d/motion_client.py b/software/src/radiance3d/motion_client.py new file mode 100644 index 0000000..6a71cc8 --- /dev/null +++ b/software/src/radiance3d/motion_client.py @@ -0,0 +1,281 @@ +"""Public motion-controller client over an interchangeable protocol transport.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from math import isfinite +from time import monotonic + +from radiance3d.interfaces import PositionConfidence, PositionKind, PositionReport +from radiance3d.transport import ( + ProtocolTransport, + ResponseCorrelationError, + TransportError, + TransportTimeout, + parse_fields, +) + + +class ControllerCommandError(TransportError): + """Structured fault returned by the motion controller.""" + + def __init__(self, response: str) -> None: + super().__init__(response) + self.response = response + parts = response.split() + self.code = next((part for part in parts[1:] if not part.startswith("ID=")), "UNKNOWN") + + +@dataclass(frozen=True) +class AxisConfiguration: + axis: str + full_steps_per_revolution: int + microsteps: int + gear_ratio: float + minimum_angle_deg: float + maximum_angle_deg: float + maximum_speed_deg_per_s: float + acceleration_deg_per_s2: float + rms_current_ma: int + hold_current_percent: int + home_offset_deg: float + + +@dataclass(frozen=True) +class AxisState: + axis: str + commanded_position_deg: float + internal_steps: int + target_steps: int + trusted: bool + trust_loss_reason: str + homed: bool + moving: bool + enabled: bool + home_switch_active: bool + homing_phase: str + fault: str + last_completed_command: int + + +@dataclass(frozen=True) +class DriverCapabilities: + azimuth_present: bool + elevation_present: bool + uart_diagnostics: bool + + +@dataclass(frozen=True) +class DriverDiagnostics: + axis: str + connected: bool + enabled: bool + fault: str + overtemperature_warning: bool + overtemperature_shutdown: bool + undervoltage: bool + reset_detected: bool + open_load_a: bool + open_load_b: bool + current_scale: int + + +class PhysicalMotionController: + """Synchronous host API for either a physical or simulated protocol transport.""" + + def __init__( + self, + transport: ProtocolTransport, + *, + motion_timeout_s: float = 120.0, + event_poll_s: float = 0.25, + ) -> None: + if motion_timeout_s <= 0.0 or event_poll_s <= 0.0: + raise ValueError("motion timeout and event poll interval must be positive") + self._transport = transport + self._motion_timeout_s = motion_timeout_s + self._event_poll_s = event_poll_s + self._protocol_version = "1" + + @property + def protocol_version(self) -> str: + return self._protocol_version + + def connect(self) -> None: + self._transport.connect() + + def disconnect(self) -> None: + self._transport.disconnect() + + def reconnect(self) -> None: + self._transport.reconnect() + + def _request(self, command: str) -> str: + response = self._transport.request(command) + if response.startswith("ERR "): + raise ControllerCommandError(response) + if not response.startswith("OK "): + raise TransportError(f"malformed controller response: {response}") + return response + + def capabilities(self) -> DriverCapabilities: + fields = parse_fields(self._request("MOTOR IDENTIFY")) + return DriverCapabilities( + fields.get("AZ_PRESENT") == "1", + fields.get("EL_PRESENT") == "1", + fields.get("UART") == "1", + ) + + def axis_configuration(self, axis: str) -> AxisConfiguration: + axis_name = _axis(axis, allow_both=False) + fields = parse_fields(self._request(f"MOTOR CONFIG {axis_name}")) + return AxisConfiguration( + axis_name, + int(fields["FULL_STEPS"]), + int(fields["MICROSTEPS"]), + float(fields["GEAR_RATIO"]), + float(fields["MIN_DEG"]), + float(fields["MAX_DEG"]), + float(fields["MAX_SPEED"]), + float(fields["ACCEL"]), + int(fields["RMS_MA"]), + int(fields["HOLD_PERCENT"]), + float(fields["HOME_OFFSET"]), + ) + + def axis_state(self, axis: str) -> AxisState: + axis_name = _axis(axis, allow_both=False) + fields = parse_fields(self._request(f"MOTOR STATUS {axis_name}")) + return AxisState( + axis_name, + float(fields["DEG"]), + int(fields["STEPS"]), + int(fields["TARGET_STEPS"]), + fields["TRUSTED"] == "1", + fields["TRUST_LOSS"], + fields["HOMED"] == "1", + fields["MOVING"] == "1", + fields["ENABLED"] == "1", + fields["HOME_ACTIVE"] == "1", + fields["HOMING"], + fields["FAULT"], + int(fields["LAST_COMMAND"]), + ) + + def diagnostics(self, axis: str) -> DriverDiagnostics: + axis_name = _axis(axis, allow_both=False) + fields = parse_fields(self._request(f"MOTOR DIAGNOSTICS {axis_name}")) + return DriverDiagnostics( + axis_name, + fields["CONNECTED"] == "1", + fields["ENABLED"] == "1", + fields["FAULT"], + fields["OTPW"] == "1", + fields["OT"] == "1", + fields["UV"] == "1", + fields["RESET"] == "1", + fields["OLA"] == "1", + fields["OLB"] == "1", + int(fields["CURRENT_SCALE"]), + ) + + def home_axis(self, axis: str = "BOTH") -> PositionReport: + response = self._request(f"HOME {_axis(axis)}") + self._wait_for_motion(response) + return self.position() + + def home(self) -> PositionReport: + return self.home_axis("BOTH") + + def move_relative(self, axis: str, delta_deg: float, speed_deg_per_s: float) -> PositionReport: + _finite_positive(speed_deg_per_s, "speed_deg_per_s") + if not isfinite(delta_deg): + raise ValueError("delta_deg must be finite") + response = self._request(f"MOVE_REL {_axis(axis)} {delta_deg:.9g} {speed_deg_per_s:.9g}") + self._wait_for_motion(response) + return self.position() + + def move_to( + self, + azimuth_deg: float, + elevation_deg: float, + speed_deg_per_s: float | None = None, + ) -> PositionReport: + speed = 10.0 if speed_deg_per_s is None else speed_deg_per_s + _finite_positive(speed, "speed_deg_per_s") + if not isfinite(azimuth_deg) or not isfinite(elevation_deg): + raise ValueError("target angles must be finite") + response = self._request(f"MOVE {azimuth_deg:.9g} {elevation_deg:.9g} {speed:.9g}") + self._wait_for_motion(response) + return self.position() + + def stop(self) -> None: + self._request("STOP") + + def stop_axis(self, axis: str) -> None: + self._request(f"MOTOR STOP {_axis(axis, allow_both=False)}") + + def emergency_stop(self) -> None: + self._request("E_STOP") + + def reset_fault(self) -> None: + self._request("CLEAR_FAULT") + + def position(self) -> PositionReport: + response = self._request("STATUS") + fields = parse_fields(response) + trusted = fields["AZ_TRUSTED"] == "1" and fields["EL_TRUSTED"] == "1" + moving = "MOVING=1" in response + warnings = () if trusted else ("controller position is untrusted",) + return PositionReport( + float(fields["AZ_DEG"]), + float(fields["EL_DEG"]), + datetime.now(UTC), + PositionKind.COMMANDED, + PositionConfidence.TRUSTED if trusted else PositionConfidence.UNTRUSTED, + motion_complete=trusted and not moving, + warnings=warnings, + ) + + def _wait_for_motion(self, response: str) -> None: + if "ACCEPTED=1" not in response and "READY=0" not in response: + return + command_id = parse_fields(response).get("ID") + if command_id is None: + raise ResponseCorrelationError("accepted motion response has no command ID") + deadline = monotonic() + self._motion_timeout_s + while monotonic() < deadline: + event = self._transport.read_event(min(self._event_poll_s, deadline - monotonic())) + if event is None: + self._request("HEARTBEAT") + continue + fields = parse_fields(event) + if event.startswith("EVENT FAULT "): + raise ControllerCommandError("ERR " + fields.get("CODE", "UNKNOWN")) + if event.startswith("EVENT ESTOP ") and fields.get("ACTIVE") == "1": + raise ControllerCommandError("ERR EMERGENCY_STOP") + if event.startswith("EVENT MOTION_COMPLETE "): + event_id = fields.get("ID") + if event_id == command_id: + return + if event_id is not None and int(event_id) < int(command_id): + continue + raise ResponseCorrelationError( + f"motion event ID {event_id or 'none'} did not match {command_id}" + ) + self.stop() + raise TransportTimeout(f"motion command {command_id} timed out") + + +def _axis(axis: str, *, allow_both: bool = True) -> str: + normalized = axis.upper() + allowed = {"AZ", "EL", "BOTH"} if allow_both else {"AZ", "EL"} + if normalized not in allowed: + raise ValueError(f"axis must be one of {', '.join(sorted(allowed))}") + return normalized + + +def _finite_positive(value: float, name: str) -> None: + if not isfinite(value) or value <= 0.0: + raise ValueError(f"{name} must be finite and positive") diff --git a/software/src/radiance3d/transport.py b/software/src/radiance3d/transport.py new file mode 100644 index 0000000..2018622 --- /dev/null +++ b/software/src/radiance3d/transport.py @@ -0,0 +1,223 @@ +"""Transport-independent request/response framing and optional serial I/O.""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +from importlib import import_module +from time import monotonic +from typing import Any, Protocol, cast, runtime_checkable + + +class TransportError(RuntimeError): + """Base error for controller transport failures.""" + + +class TransportTimeout(TransportError): + """Raised when a complete controller response is not received in time.""" + + +class DeviceIdentityError(TransportError): + """Raised when the connected serial device is not a compatible controller.""" + + +class ResponseCorrelationError(TransportError): + """Raised when a response does not match the outstanding command.""" + + +@runtime_checkable +class ProtocolTransport(Protocol): + """Minimal command/event boundary implemented by serial and simulators.""" + + @property + def connected(self) -> bool: + """Whether the underlying device is open and validated.""" + + def connect(self) -> None: + """Open and validate the device.""" + + def disconnect(self) -> None: + """Close the device and release resources.""" + + def reconnect(self) -> None: + """Close and reopen the configured device.""" + + def request(self, command: str, timeout_s: float | None = None) -> str: + """Send one correlated command and return its response.""" + + def read_event(self, timeout_s: float) -> str | None: + """Return one asynchronous event, or None at the timeout.""" + + +SerialFactory = Callable[..., Any] + + +class SerialTransport: + """Line-oriented USB serial adapter for the Radiance3D protocol.""" + + def __init__( + self, + port: str, + *, + baudrate: int = 115_200, + read_timeout_s: float = 0.25, + connection_timeout_s: float = 2.0, + expected_protocol: str = "1", + serial_factory: SerialFactory | None = None, + ) -> None: + if not port.strip(): + raise ValueError("a serial device path must be supplied") + if baudrate <= 0 or read_timeout_s <= 0.0 or connection_timeout_s <= 0.0: + raise ValueError("serial baud rate and timeouts must be positive") + self._port = port + self._baudrate = baudrate + self._read_timeout_s = read_timeout_s + self._connection_timeout_s = connection_timeout_s + self._expected_protocol = expected_protocol + self._serial_factory = serial_factory + self._serial: Any | None = None + self._command_id = 0 + self._events: deque[str] = deque() + + @property + def connected(self) -> bool: + return bool(self._serial is not None and getattr(self._serial, "is_open", True)) + + def _factory(self) -> SerialFactory: + if self._serial_factory is not None: + return self._serial_factory + try: + serial_module = import_module("serial") + except ImportError as error: + raise TransportError( + "serial support requires the 'radiance3d[serial]' extra" + ) from error + return cast(SerialFactory, serial_module.Serial) + + def connect(self) -> None: + if self.connected: + return + try: + self._serial = self._factory()( + port=self._port, + baudrate=self._baudrate, + timeout=self._read_timeout_s, + write_timeout=self._connection_timeout_s, + ) + reset = getattr(self._serial, "reset_input_buffer", None) + if reset is not None: + reset() + identity = self._raw_request("IDENTIFY", self._connection_timeout_s) + except Exception as error: + self.disconnect() + if isinstance(error, TransportError): + raise + raise TransportError(f"could not connect to {self._port}: {error}") from error + fields = parse_fields(identity) + if ( + not identity.startswith("OK IDENTIFY ") + or fields.get("DEVICE") != "Radiance3D" + or fields.get("PROTOCOL") != self._expected_protocol + ): + self.disconnect() + raise DeviceIdentityError( + "serial device did not identify as the expected Radiance3D protocol" + ) + + def disconnect(self) -> None: + serial_port, self._serial = self._serial, None + self._events.clear() + if serial_port is not None: + try: + serial_port.close() + except Exception as error: + raise TransportError(f"could not close serial device: {error}") from error + + def reconnect(self) -> None: + self.disconnect() + self.connect() + + def _write_line(self, line: str) -> None: + if not self.connected: + raise TransportError("serial device is not connected") + assert self._serial is not None + try: + self._serial.write((line + "\n").encode("ascii")) + flush = getattr(self._serial, "flush", None) + if flush is not None: + flush() + except Exception as error: + raise TransportError(f"serial write failed: {error}") from error + + def _read_line(self, deadline: float) -> str: + assert self._serial is not None + while monotonic() < deadline: + try: + raw = cast(bytes, self._serial.readline()) + except Exception as error: + raise TransportError(f"serial read failed: {error}") from error + if not raw: + continue + try: + line = raw.decode("ascii").strip() + except UnicodeDecodeError as error: + raise TransportError("controller returned non-ASCII data") from error + if line: + return line + raise TransportTimeout("controller response timed out") + + def _raw_request(self, command: str, timeout_s: float) -> str: + self._write_line(command) + deadline = monotonic() + timeout_s + while True: + line = self._read_line(deadline) + if line.startswith("EVENT "): + self._events.append(line) + continue + return line + + def request(self, command: str, timeout_s: float | None = None) -> str: + if not command.strip() or "\n" in command or "\r" in command: + raise ValueError("command must be one non-empty line") + if not self.connected: + self.connect() + self._command_id += 1 + command_id = self._command_id + response = self._raw_request( + f"CMD {command_id} {command}", timeout_s or self._connection_timeout_s + ) + response_id = parse_fields(response).get("ID") + if response_id != str(command_id): + raise ResponseCorrelationError( + f"expected response ID {command_id}, received {response_id or 'none'}" + ) + return response + + def read_event(self, timeout_s: float) -> str | None: + if timeout_s < 0.0: + raise ValueError("event timeout must not be negative") + if self._events: + return self._events.popleft() + if not self.connected: + raise TransportError("serial device is not connected") + deadline = monotonic() + timeout_s + while monotonic() < deadline: + try: + line = self._read_line(deadline) + except TransportTimeout: + return None + if line.startswith("EVENT "): + return line + raise ResponseCorrelationError("received an unsolicited command response") + return None + + +def parse_fields(line: str) -> dict[str, str]: + """Parse the protocol's whitespace-separated KEY=VALUE fields.""" + + fields: dict[str, str] = {} + for token in line.split(): + if "=" in token: + key, value = token.split("=", 1) + fields[key] = value + return fields From 39b52307459689634217fe30451309cd34529d7b Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 23:06:47 -0400 Subject: [PATCH 07/13] test(motion): cover physical driver host and safety behavior --- .github/workflows/firmware.yml | 3 + .../controller/test/test_axis/test_main.cpp | 22 +++ .../controller/test/test_driver/test_main.cpp | 16 ++- .../controller/test/test_motion/test_main.cpp | 113 +++++++++++++++ .../test/test_physical/test_main.cpp | 44 ++++++ software/tests/test_motion_client.py | 135 ++++++++++++++++++ software/tests/test_transport.py | 103 +++++++++++++ 7 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 software/tests/test_motion_client.py create mode 100644 software/tests/test_transport.py diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index fa1f75c..512be62 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -30,3 +30,6 @@ jobs: - name: Test motion and protocol behavior working-directory: firmware/controller run: pio test -e native + - name: Compile provisional ESP32 target + working-directory: firmware/controller + run: pio run -e esp32dev diff --git a/firmware/controller/test/test_axis/test_main.cpp b/firmware/controller/test/test_axis/test_main.cpp index 7c3d4a3..ea98eba 100644 --- a/firmware/controller/test/test_axis/test_main.cpp +++ b/firmware/controller/test/test_axis/test_main.cpp @@ -288,6 +288,27 @@ void test_successful_two_pass_homing_applies_offset_and_trusts_position() { TEST_ASSERT_EQUAL_UINT32(12, axis.state().last_completed_command); } +void test_homing_fails_when_switch_does_not_release_after_backoff() { + FakePlatform platform; + FakeDriver driver; + radiance3d::AxisController axis(platform, driver, axis_config()); + TEST_ASSERT_TRUE(axis.initialize()); + TEST_ASSERT_TRUE(axis.start_homing(14).ok); + + for (int index = 0; index < 6; ++index) { + platform.advance(100000); + axis.service(); + } + settle_switch(axis, platform, false); + service_until_stopped(axis, platform); + axis.service(); + + TEST_ASSERT_EQUAL(radiance3d::FaultCode::homing_switch_failed_release, + axis.state().fault); + TEST_ASSERT_FALSE(axis.state().position_trusted); + TEST_ASSERT_FALSE(axis.state().enabled); +} + void test_emergency_stop_during_homing_disables_and_loses_trust() { FakePlatform platform; FakeDriver driver; @@ -313,6 +334,7 @@ int main(int, char**) { RUN_TEST(test_stuck_active_home_switch_fails_without_motion); RUN_TEST(test_homing_times_out_when_switch_never_activates); RUN_TEST(test_successful_two_pass_homing_applies_offset_and_trusts_position); + RUN_TEST(test_homing_fails_when_switch_does_not_release_after_backoff); RUN_TEST(test_emergency_stop_during_homing_disables_and_loses_trust); return UNITY_END(); } diff --git a/firmware/controller/test/test_driver/test_main.cpp b/firmware/controller/test/test_driver/test_main.cpp index aa0fc74..a80e1f4 100644 --- a/firmware/controller/test/test_driver/test_main.cpp +++ b/firmware/controller/test/test_driver/test_main.cpp @@ -47,10 +47,16 @@ class FakePlatform final : public radiance3d::HardwarePlatform { if (length == 8 && data[0] == 0x05 && data[1] <= 3 && radiance3d::Tmc2209Driver::calculate_crc(data, 7) == data[7]) { const std::uint8_t address = static_cast(data[2] & 0x7FU); - registers[address] = (static_cast(data[3]) << 24) | - (static_cast(data[4]) << 16) | - (static_cast(data[5]) << 8) | - static_cast(data[6]); + const std::uint32_t value = + (static_cast(data[3]) << 24) | + (static_cast(data[4]) << 16) | + (static_cast(data[5]) << 8) | + static_cast(data[6]); + if (address == 0x01) { + registers[address] &= ~value; + } else { + registers[address] = value; + } registers[0x02] = static_cast(registers[0x02] + 1U); pending_register_ = address; return true; @@ -105,12 +111,14 @@ void tearDown() {} void test_successful_initialization_starts_disabled_and_probes_uart() { FakePlatform platform; + platform.registers[0x01] = 1U; radiance3d::Tmc2209Driver driver(platform, config()); TEST_ASSERT_TRUE(driver.initialize()); TEST_ASSERT_TRUE(driver.is_connected()); TEST_ASSERT_TRUE(platform.uart_started); TEST_ASSERT_TRUE(platform.pin_values[27]); + TEST_ASSERT_EQUAL_UINT32(0, platform.registers[0x01]); } void test_failed_uart_probe_keeps_driver_disabled() { diff --git a/firmware/controller/test/test_motion/test_main.cpp b/firmware/controller/test/test_motion/test_main.cpp index 9debc98..3992924 100644 --- a/firmware/controller/test/test_motion/test_main.cpp +++ b/firmware/controller/test/test_motion/test_main.cpp @@ -7,6 +7,7 @@ using radiance3d::AxisConfig; using radiance3d::ProtocolEngine; +using radiance3d::SimulatedMotionController; void setUp() {} void tearDown() {} @@ -60,6 +61,111 @@ void test_status_labels_position_as_commanded_and_untrusted_at_startup() { TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("EL_TRUSTED=0")); } +void test_correlated_commands_reject_duplicate_and_stale_ids() { + ProtocolEngine engine; + + TEST_ASSERT_TRUE(engine.handle("CMD 2 STATUS").find("OK ID=2 STATUS") == 0); + TEST_ASSERT_TRUE( + engine.handle("CMD 2 STATUS").find("ERR ID=2 DUPLICATE_COMMAND") == 0); + TEST_ASSERT_TRUE( + engine.handle("CMD 1 STATUS").find("ERR ID=1 STALE_COMMAND") == 0); +} + +void test_heartbeat_is_correlated_and_rejects_arguments() { + ProtocolEngine engine; + + TEST_ASSERT_EQUAL_STRING("OK ID=1 HEARTBEAT", + engine.handle("CMD 1 HEARTBEAT").c_str()); + TEST_ASSERT_TRUE(engine.handle("CMD 2 HEARTBEAT extra") + .find("ERR ID=2 INVALID_ARGUMENT") == 0); +} + +void test_bench_commands_are_relative_and_explicitly_untrusted() { + ProtocolEngine engine; + + const std::string response = engine.handle("CMD 1 MOTOR STEP AZ 10"); + + TEST_ASSERT_TRUE(response.find("OK ID=1 MOTOR_STEP AXIS=AZ") == 0); + TEST_ASSERT_NOT_EQUAL(std::string::npos, + response.find("POSITION_TRUSTED=0")); + TEST_ASSERT_TRUE( + engine.handle("CMD 2 MOTOR SET_CURRENT AZ 0") + .find("ERR ID=2 INVALID_ARGUMENT") == 0); + TEST_ASSERT_TRUE( + engine.handle("CMD 3 MOTOR SET_MICROSTEPS EL 3") + .find("ERR ID=3 INVALID_ARGUMENT") == 0); +} + +void test_malformed_motor_command_and_unsupported_axis_are_rejected() { + ProtocolEngine engine; + + TEST_ASSERT_TRUE(engine.handle("MOTOR STEP Z 10") + .find("ERR INVALID_ARGUMENT") == 0); + TEST_ASSERT_TRUE(engine.handle("MOTOR STEP AZ") + .find("ERR INVALID_ARGUMENT") == 0); +} + +void test_protocol_exposes_transport_neutral_axis_configuration() { + ProtocolEngine engine; + const std::string response = engine.handle("CMD 1 MOTOR CONFIG AZ"); + + TEST_ASSERT_TRUE(response.find("OK ID=1 MOTOR_CONFIG AXIS=AZ") == 0); + TEST_ASSERT_NOT_EQUAL(std::string::npos, response.find("FULL_STEPS=200")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, response.find("MICROSTEPS=16")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, response.find("RMS_MA=400")); +} + +void test_simulator_models_missing_driver_and_critical_thermal_fault() { + SimulatedMotionController controller( + radiance3d::provisional_simulator_config()); + TEST_ASSERT_TRUE(controller.initialize()); + controller.home(radiance3d::AxisSelection::both); + radiance3d::DriverStatus missing; + missing.connected = false; + missing.fault = radiance3d::DriverFault::communication_failure; + controller.simulate_driver_status(radiance3d::AxisSelection::azimuth, + missing); + controller.service(); + + TEST_ASSERT_FALSE(controller.state().azimuth.position_trusted); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::driver_communication, + controller.state().azimuth.fault); + + radiance3d::DriverStatus thermal; + thermal.connected = true; + thermal.overtemperature_shutdown = true; + thermal.fault = radiance3d::DriverFault::overtemperature_shutdown; + controller.simulate_driver_status(radiance3d::AxisSelection::elevation, + thermal); + controller.service(); + TEST_ASSERT_FALSE(controller.state().elevation.enabled); + TEST_ASSERT_EQUAL(radiance3d::FaultCode::driver_critical, + controller.state().elevation.fault); +} + +void test_simulator_models_homing_failure_and_reset_trust_loss() { + SimulatedMotionController controller( + radiance3d::provisional_simulator_config()); + controller.simulate_homing_failure( + radiance3d::AxisSelection::azimuth, + radiance3d::FaultCode::homing_switch_never_triggered); + + const radiance3d::MotionResult home = + controller.home(radiance3d::AxisSelection::azimuth); + TEST_ASSERT_FALSE(home.ok); + TEST_ASSERT_EQUAL( + radiance3d::FaultCode::homing_switch_never_triggered, home.fault); + + controller.simulate_homing_failure(radiance3d::AxisSelection::azimuth, + radiance3d::FaultCode::none); + controller.clear_fault(); + controller.home(radiance3d::AxisSelection::both); + controller.simulate_reset(radiance3d::AxisSelection::both); + TEST_ASSERT_FALSE(controller.state().azimuth.position_trusted); + TEST_ASSERT_EQUAL(radiance3d::TrustLossReason::watchdog_reset_during_motion, + controller.state().azimuth.trust_loss_reason); +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_angular_conversion_is_derived_from_configuration); @@ -67,5 +173,12 @@ int main(int, char**) { RUN_TEST(test_stop_invalidates_position_and_requires_rehoming); RUN_TEST(test_driver_disable_invalidates_position_confidence); RUN_TEST(test_status_labels_position_as_commanded_and_untrusted_at_startup); + RUN_TEST(test_correlated_commands_reject_duplicate_and_stale_ids); + RUN_TEST(test_heartbeat_is_correlated_and_rejects_arguments); + RUN_TEST(test_bench_commands_are_relative_and_explicitly_untrusted); + RUN_TEST(test_malformed_motor_command_and_unsupported_axis_are_rejected); + RUN_TEST(test_protocol_exposes_transport_neutral_axis_configuration); + RUN_TEST(test_simulator_models_missing_driver_and_critical_thermal_fault); + RUN_TEST(test_simulator_models_homing_failure_and_reset_trust_loss); return UNITY_END(); } diff --git a/firmware/controller/test/test_physical/test_main.cpp b/firmware/controller/test/test_physical/test_main.cpp index 840a70f..c1f8655 100644 --- a/firmware/controller/test/test_physical/test_main.cpp +++ b/firmware/controller/test/test_physical/test_main.cpp @@ -6,6 +6,7 @@ #include "axis_controller.hpp" #include "hardware_config.hpp" #include "physical_motion_controller.hpp" +#include "protocol.hpp" namespace { @@ -159,6 +160,12 @@ void test_provisional_gpio_is_valid_and_validation_rejects_conflicts() { result = radiance3d::validate_esp32_gpio(config); TEST_ASSERT_FALSE(result.valid); TEST_ASSERT_EQUAL_INT(34, result.invalid_output_pin); + + config = radiance3d::provisional_esp32_dev_config(); + config.azimuth.driver.step_pin = 0; + result = radiance3d::validate_esp32_gpio(config); + TEST_ASSERT_TRUE(result.valid); + TEST_ASSERT_NOT_EQUAL_INT64(0, result.bootstrapping_pin_mask); } void test_safe_startup_initializes_both_axes_disabled_and_untrusted() { @@ -234,6 +241,41 @@ void test_emergency_stop_latches_both_axes_and_requires_released_input() { TEST_ASSERT_FALSE(fixture.controller.state().emergency_stop_active); } +void test_stop_all_stops_both_axes_and_invalidates_active_move() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.trust_positions(); + TEST_ASSERT_TRUE( + fixture.controller.move_absolute(90.0, 45.0, 10.0, 45).ok); + + TEST_ASSERT_TRUE(fixture.controller.stop().ok); + + TEST_ASSERT_FALSE(fixture.azimuth.state().moving); + TEST_ASSERT_FALSE(fixture.elevation.state().moving); + TEST_ASSERT_FALSE(fixture.azimuth.state().position_trusted); + TEST_ASSERT_FALSE(fixture.elevation.state().position_trusted); +} + +void test_protocol_emits_completion_only_after_both_axes_stop() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.trust_positions(); + radiance3d::ProtocolEngine engine(fixture.controller); + + const std::string accepted = + engine.handle("CMD 50 SCAN_STEP 18 9 20"); + TEST_ASSERT_NOT_EQUAL(std::string::npos, accepted.find("READY=0")); + std::string event; + for (std::uint32_t index = 0; index < 2000 && event.empty(); ++index) { + fixture.platform.advance(100000); + event = engine.service(); + } + + TEST_ASSERT_TRUE(event.find("EVENT MOTION_COMPLETE ID=50") == 0); + TEST_ASSERT_NOT_EQUAL(std::string::npos, event.find("AZ_DONE=1")); + TEST_ASSERT_NOT_EQUAL(std::string::npos, event.find("EL_DONE=1")); +} + int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_provisional_gpio_is_valid_and_validation_rejects_conflicts); @@ -241,5 +283,7 @@ int main(int, char**) { RUN_TEST(test_coordinated_move_completes_only_after_both_axes_finish); RUN_TEST(test_one_axis_critical_fault_stops_coordinated_move_and_loses_trust); RUN_TEST(test_emergency_stop_latches_both_axes_and_requires_released_input); + RUN_TEST(test_stop_all_stops_both_axes_and_invalidates_active_move); + RUN_TEST(test_protocol_emits_completion_only_after_both_axes_stop); return UNITY_END(); } diff --git a/software/tests/test_motion_client.py b/software/tests/test_motion_client.py new file mode 100644 index 0000000..4fcf154 --- /dev/null +++ b/software/tests/test_motion_client.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from collections import deque + +import pytest + +from radiance3d.interfaces import PositionConfidence +from radiance3d.motion_client import ControllerCommandError, PhysicalMotionController +from radiance3d.transport import ResponseCorrelationError, TransportTimeout + + +class FakeTransport: + def __init__(self) -> None: + self._connected = False + self.command_id = 0 + self.events: deque[str | None] = deque() + self.commands: list[str] = [] + self.responses: dict[str, str] = {} + + @property + def connected(self) -> bool: + return self._connected + + def connect(self) -> None: + self._connected = True + + def disconnect(self) -> None: + self._connected = False + + def reconnect(self) -> None: + self.disconnect() + self.connect() + + def request(self, command: str, timeout_s: float | None = None) -> str: + del timeout_s + self.command_id += 1 + self.commands.append(command) + response = self.responses.get(command) + if response is not None: + return response.replace("{id}", str(self.command_id)) + return f"OK ID={self.command_id} ACCEPTED=1" + + def read_event(self, timeout_s: float) -> str | None: + del timeout_s + return self.events.popleft() if self.events else None + + +STATUS = ( + "OK ID={id} STATUS AZ_DEG=12.500 EL_DEG=-4.000 POSITION_KIND=COMMANDED " + "AZ_HOMED=1 EL_HOMED=1 AZ_TRUSTED=1 EL_TRUSTED=1 " + "AXIS=AZ MOVING=0 AXIS=EL MOVING=0 FAULT=NONE" +) + + +def controller() -> tuple[PhysicalMotionController, FakeTransport]: + transport = FakeTransport() + transport.responses["STATUS"] = STATUS + return PhysicalMotionController(transport, motion_timeout_s=0.01, event_poll_s=0.001), transport + + +def test_host_reads_capabilities_configuration_state_and_diagnostics() -> None: + motion, transport = controller() + transport.responses["MOTOR IDENTIFY"] = ( + "OK ID={id} MOTOR_IDENTIFY AZ_PRESENT=1 EL_PRESENT=0 UART=1" + ) + transport.responses["MOTOR CONFIG AZ"] = ( + "OK ID={id} MOTOR_CONFIG AXIS=AZ FULL_STEPS=200 MICROSTEPS=16 " + "GEAR_RATIO=2.500 MIN_DEG=0.000 MAX_DEG=359.000 MAX_SPEED=10.000 " + "ACCEL=20.000 RMS_MA=400 HOLD_PERCENT=30 HOME_OFFSET=1.500" + ) + transport.responses["MOTOR STATUS AZ"] = ( + "OK ID={id} MOTOR_STATUS AXIS=AZ DEG=12.500 STEPS=222 TARGET_STEPS=222 " + "POSITION_KIND=COMMANDED TRUSTED=1 TRUST_LOSS=NONE HOMED=1 MOVING=0 " + "ENABLED=1 HOME_ACTIVE=0 HOMING=COMPLETE FAULT=NONE LAST_COMMAND=7" + ) + transport.responses["MOTOR DIAGNOSTICS AZ"] = ( + "OK ID={id} MOTOR_DIAGNOSTICS AXIS=AZ CONNECTED=1 ENABLED=1 FAULT=NONE " + "OTPW=1 OT=0 UV=0 RESET=0 OLA=0 OLB=1 CURRENT_SCALE=12" + ) + + assert motion.capabilities().azimuth_present + assert not motion.capabilities().elevation_present + assert motion.axis_configuration("az").gear_ratio == 2.5 + assert motion.axis_state("AZ").internal_steps == 222 + diagnostics = motion.diagnostics("az") + assert diagnostics.overtemperature_warning + assert diagnostics.open_load_b + + +def test_move_waits_for_matching_completion_and_returns_trusted_position() -> None: + motion, transport = controller() + transport.events.extend( + [ + "EVENT MOTION_COMPLETE ID=0 AZ_DONE=1 EL_DONE=1", + "EVENT MOTION_COMPLETE ID=1 AZ_DONE=1 EL_DONE=1", + ] + ) + + report = motion.move_to(12.5, -4.0, 5.0) + + assert report.confidence is PositionConfidence.TRUSTED + assert report.motion_complete + assert transport.commands[:2] == ["MOVE 12.5 -4 5", "STATUS"] + + +def test_already_complete_motion_does_not_wait_for_an_event() -> None: + motion, transport = controller() + transport.responses["HOME BOTH"] = "OK ID={id} HOME AXIS=BOTH" + + report = motion.home() + + assert report.motion_complete + assert transport.commands == ["HOME BOTH", "STATUS"] + + +def test_future_completion_and_driver_fault_are_not_silently_accepted() -> None: + motion, transport = controller() + transport.events.append("EVENT MOTION_COMPLETE ID=2 AZ_DONE=1 EL_DONE=1") + with pytest.raises(ResponseCorrelationError): + motion.move_relative("AZ", 1.0, 2.0) + + motion, transport = controller() + transport.events.append("EVENT FAULT CODE=DRIVER_COMMUNICATION") + with pytest.raises(ControllerCommandError) as error: + motion.home() + assert error.value.code == "DRIVER_COMMUNICATION" + + +def test_motion_timeout_sends_stop_and_reports_timeout() -> None: + motion, transport = controller() + + with pytest.raises(TransportTimeout, match="motion command 1"): + motion.move_relative("EL", -1.0, 2.0) + + assert transport.commands[-1] == "STOP" diff --git a/software/tests/test_transport.py b/software/tests/test_transport.py new file mode 100644 index 0000000..f99f3e7 --- /dev/null +++ b/software/tests/test_transport.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from collections import deque +from typing import Any + +import pytest + +from radiance3d.transport import ( + DeviceIdentityError, + ResponseCorrelationError, + SerialTransport, + TransportTimeout, +) + + +class FakeSerial: + def __init__(self, responses: list[bytes], **_: Any) -> None: + self.responses = deque(responses) + self.writes: list[bytes] = [] + self.is_open = True + self.reset_count = 0 + + def reset_input_buffer(self) -> None: + self.reset_count += 1 + + def write(self, value: bytes) -> int: + self.writes.append(value) + return len(value) + + def flush(self) -> None: + pass + + def readline(self) -> bytes: + return self.responses.popleft() if self.responses else b"" + + def close(self) -> None: + self.is_open = False + + +def factory_for(*instances: FakeSerial) -> Any: + remaining = deque(instances) + + def factory(**_: Any) -> FakeSerial: + return remaining.popleft() + + return factory + + +def test_serial_validates_identity_correlates_commands_and_disconnects() -> None: + serial = FakeSerial( + [ + b"OK IDENTIFY DEVICE=Radiance3D CONTROLLER=motion PROTOCOL=1 MODE=PHYSICAL\n", + b"EVENT FAULT CODE=NONE\n", + b"OK ID=1 MOTOR_IDENTIFY AZ_PRESENT=1 EL_PRESENT=1 UART=1\n", + ] + ) + transport = SerialTransport("/dev/test-controller", serial_factory=factory_for(serial)) + + transport.connect() + response = transport.request("MOTOR IDENTIFY") + + assert "ID=1" in response + assert transport.read_event(0.0) == "EVENT FAULT CODE=NONE" + assert serial.writes == [b"IDENTIFY\n", b"CMD 1 MOTOR IDENTIFY\n"] + transport.disconnect() + assert not transport.connected + assert not serial.is_open + + +def test_serial_rejects_wrong_identity_and_stale_response() -> None: + wrong_device = FakeSerial([b"OK IDENTIFY DEVICE=Other PROTOCOL=1\n"]) + transport = SerialTransport("/dev/wrong", serial_factory=factory_for(wrong_device)) + with pytest.raises(DeviceIdentityError): + transport.connect() + + stale = FakeSerial( + [ + b"OK IDENTIFY DEVICE=Radiance3D PROTOCOL=1\n", + b"OK ID=0 STATUS\n", + ] + ) + transport = SerialTransport("/dev/stale", serial_factory=factory_for(stale)) + with pytest.raises(ResponseCorrelationError, match="expected response ID 1"): + transport.request("STATUS") + + +def test_serial_timeout_and_explicit_reconnect() -> None: + timed_out = FakeSerial([b"OK IDENTIFY DEVICE=Radiance3D PROTOCOL=1\n"]) + replacement = FakeSerial([b"OK IDENTIFY DEVICE=Radiance3D PROTOCOL=1\n"]) + transport = SerialTransport( + "/dev/reconnect", + read_timeout_s=0.001, + connection_timeout_s=0.001, + serial_factory=factory_for(timed_out, replacement), + ) + + transport.connect() + with pytest.raises(TransportTimeout): + transport.request("STATUS", timeout_s=0.001) + transport.reconnect() + + assert not timed_out.is_open + assert replacement.is_open From a3bc8f1b19142e6f550d9dfb56e1c0986ad719b8 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 23:06:55 -0400 Subject: [PATCH 08/13] docs(hardware): document TMC2209 commissioning --- CHANGELOG.md | 11 +- README.md | 14 +- ROADMAP.md | 5 + docs/development/setup.md | 8 +- docs/development/testing.md | 7 +- docs/firmware/configuration.md | 32 ++-- docs/firmware/overview.md | 31 ++-- docs/firmware/protocol.md | 108 +++++++----- docs/hardware/motion-system.md | 20 +-- docs/hardware/tmc2209-commissioning.md | 165 +++++++++++++++++++ docs/hardware/wiring.md | 4 +- docs/index.md | 1 + firmware/config/provisional-esp32dev-v1.json | 8 + software/README.md | 17 +- 14 files changed, 339 insertions(+), 92 deletions(-) create mode 100644 docs/hardware/tmc2209-commissioning.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 033c324..2c6aee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,20 @@ to use semantic versioning once releases begin. quantization, configured limits, homing, position confidence, enable, stop, and emergency-stop simulator behavior. - Native firmware behavior tests and software planner/coordinator tests. +- Physical ESP32 motion layer with TMC2209 UART diagnostics and current control, + non-blocking dual-axis STEP/DIR motion, two-pass switch homing, and latched + emergency-stop behavior. +- Correlated motor protocol commands, configuration/state inspection, completion and + fault events, heartbeat timeout handling, and an optional pyserial host transport. +- Stage 2 simulator fault injection, native/host tests, provisional GPIO/current + configuration, and a safety-focused commissioning guide. ### Changed - Scan schema 1.1 adds protocol/hardware revisions, commanded step sizes, units, calibration/operator context, per-reading source/validity/warnings, and sequence numbers while retaining schema 1.0 reads. -- Stage 1 roadmap now represents the complete Version 1 architecture; physical - two-axis hardware remains Stage 2. +- Stage 2 is implemented and compile/unit-test validated; physical commissioning and + thermal/mechanical validation remain pending. [Unreleased]: https://github.com/bostromdev/Radiance3D diff --git a/README.md b/README.md index 38ddfc7..3b754b3 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,12 @@ See the [repository layout](docs/development/repository-layout.md) for details. ## Current project status -Stage 1 now defines the complete Version 1 two-axis hardware boundary, configurable -motion/position-confidence model, receiver-neutral host interfaces, raster scan -coordination, and schema 1.1 provenance contract. The controller and adapters remain -simulator/interface-only: no physical scanner, receiver integration, measurement -accuracy, calibrated antenna gain, or production-ready workflow is claimed. Example -datasets may be simulated and are labeled in their metadata. +Stage 2 now implements the first physical motion-control layer: a compilable ESP32 +target, two TMC2209 driver instances, non-blocking dual-axis stepping, two-pass homing, +latched emergency stop, diagnostics, command correlation, a serial host adapter, and +simulator parity. It is compiled and unit tested but has not yet been exercised on +connected hardware. No physical scanner motion, receiver integration, measurement +accuracy, calibrated antenna gain, or production-ready workflow is claimed. ## Getting started @@ -97,6 +97,8 @@ Start at the [documentation index](docs/index.md), then review the [architecture overview](docs/architecture/overview.md), [scan file format](docs/software/file-formats.md), [Version 1 engineering baseline](docs/architecture/version-1.md), and [roadmap](ROADMAP.md). +For physical preparation, use the +[TMC2209 commissioning guide](docs/hardware/tmc2209-commissioning.md). ## Contributing diff --git a/ROADMAP.md b/ROADMAP.md index d2cffad..1ff5b0b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -22,6 +22,11 @@ ESP32 GPIO, replaceable driver implementation, two NEMA 17 axes, switches, emerg stop, acceleration, timeouts, and synchronized stepping. Exit requires documented electrical safety checks, fault behavior, cable limits, and repeatable zeroing. +**Implementation status:** firmware, host protocol, simulator parity, tests, and +commissioning documentation are complete on the Stage 2 feature branch. Physical +bring-up, thermal characterization, exact-board pin confirmation, repeatable homing, +and cable-envelope evidence remain required before the stage exit is claimed. + ## Stage 3 — RF acquisition At least one host-side receiver adapter, timestamped native measurements, raw capture, diff --git a/docs/development/setup.md b/docs/development/setup.md index 91edf10..86b989a 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -6,14 +6,18 @@ Prerequisites are Git, Python 3.11+, and PlatformIO for firmware work. cd software python3.11 -m venv .venv source .venv/bin/activate -python -m pip install -e ".[dev]" +python -m pip install -e ".[dev,serial]" pytest ruff check . +ruff format --check . mypy cd ../firmware/controller pio run -e native +pio test -e native +pio run -e esp32dev ``` Run `python scripts/check_repository.py` from the repository root. No secrets or paid -services are required. +services are required. The serial extra is optional for simulator-only work and does +not assume a fixed device path. diff --git a/docs/development/testing.md b/docs/development/testing.md index efa37cd..3ae78a8 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -2,9 +2,12 @@ - **Repository:** required paths, valid JSON, simulated provenance, and internal links. - **Software:** model invariants, CLI results, formatting, lint, and strict type checks. -- **Firmware:** native simulator compilation and protocol tests as semantics stabilize. +- **Firmware:** native simulator build/tests plus compilation of the provisional + ESP32 target. Native tests cover UART framing, current limits, diagnostics, integer + conversion, motion, homing, safety, dual-axis completion, and protocol contracts. - **Schemas:** metaschema check plus example validation. - **Hardware:** future test records must identify exact revisions, setup, instruments, raw data, and safety controls. -A passing simulator or schema test is not evidence of physical measurement accuracy. +A passing native test or ESP32 compilation is not evidence that the selected board, +carrier, motor, switch, power supply, thermal design, or mechanics work physically. diff --git a/docs/firmware/configuration.md b/docs/firmware/configuration.md index 4d0955b..2838c5e 100644 --- a/docs/firmware/configuration.md +++ b/docs/firmware/configuration.md @@ -1,8 +1,10 @@ # Firmware configuration -Version 1 separates board/pin configuration from driver-neutral motion behavior. The -implemented simulator uses typed controller configuration; a physical target must -populate the same fields from a versioned configuration record. +Version 1 separates board/pin configuration from driver-neutral motion behavior. +The physical target and simulator use the same typed motion fields. The clearly +provisional development record is +[`firmware/config/provisional-esp32dev-v1.json`](../../firmware/config/provisional-esp32dev-v1.json); +the compiled defaults live together in `hardware_config.cpp`. ## Axis fields @@ -10,15 +12,15 @@ Each azimuth and elevation axis defines: - `motor_full_steps_per_revolution` (provisionally 200 for a typical 1.8° motor); - `microsteps`; -- configurable `motor_rms_current_ma` (`0` means deliberately unset in the simulator; - a physical driver must refuse operation until a safe value is selected); +- configurable `motor_rms_current_ma`; +- configurable maximum RMS-current ceiling and hold-current percentage; - `gear_ratio`; - calculated `steps_per_output_revolution`; - direction inversion; - home offset in degrees; - minimum and maximum angle in degrees; - maximum speed in degrees per second; -- acceleration in degrees per second squared; and +- acceleration, settling time, motion timeout, and bench-test step ceiling; and - switch normally-closed state, debounce milliseconds, homing direction, homing speed, back-off degrees, and second slow-approach speed. @@ -28,13 +30,13 @@ but Version 1 does not implement it. ## Controller fields -Controller configuration contains motion timeout, emergency-stop active polarity, -protocol version, USB serial rate, board definition, physical pin mapping, and -simulator/physical mode. A TMC2209-specific physical configuration will additionally -record UART address/connection, sense-resistor/module revision, and safely selected RMS -motor current. Current remains unset until the exact motors and thermal design exist. +Controller configuration includes emergency-stop pin/polarity/debounce, protocol +version, serial rate, board name, and all GPIO assignments. Startup rejects duplicate +pins and input-only STEP/DIR/ENABLE/TX assignments and reports ESP32 bootstrapping-pin +use as a warning. -No default pinout or physical motor calibration is supplied because none is verified. -ESP32 signals are 3.3 V and no attached module is assumed 5 V tolerant. Firmware build -constants are not substitutes for a wiring record, thermal test, or mechanical-limit -test. +The example uses 400 mA RMS with an 800 mA software ceiling only as conservative +development placeholders. Before energizing a motor, replace them using the selected +motor rating, the carrier's actual sense resistor and schematic, cooling, load, and +measured temperatures. ESP32 signals are 3.3 V and no attached module is assumed 5 V +tolerant. The example is not a verified pinout or motor calibration. diff --git a/docs/firmware/overview.md b/docs/firmware/overview.md index a4f14c6..9b86411 100644 --- a/docs/firmware/overview.md +++ b/docs/firmware/overview.md @@ -1,13 +1,22 @@ # Firmware overview -The planned ESP32 controller owns two motion axes and exposes a host protocol. The -implemented firmware `MotionController` interface covers configured absolute moves, -homing, stop, emergency stop, driver enable, open-loop commanded position, confidence, -limits, and faults. `SimulatedMotionController` is the first implementation. TMC2209 -or another physical driver must stay behind the same interface. - -The simulator calculates command quantization from axis configuration, applies -configured azimuth/elevation limits, requires homing, and invalidates confidence after -stop, emergency stop, or disable. It does not access GPIO, drive motors, read switches, -apply real acceleration, or detect missed steps. `esp32dev` is a provisional -compilation target, not a statement of supported hardware or pinout. +The ESP32 firmware now has physical and simulated implementations behind the same +`MotionController` and `StepperDriver` boundaries. The physical path consists of an +Arduino ESP32 platform adapter, two TMC2209 UART/STEP/DIR drivers, one reusable +non-blocking axis controller per motor, a dual-axis coordinator, and protocol engine. + +The controller uses integer microsteps as authoritative position. It services STEP +edges, acceleration, switch debounce, homing, driver diagnostics, emergency stop, and +serial input without long delay loops. A coordinated command completes only after +both axes stop. Critical faults stop both axes when a coordinated move is active. + +The TMC2209 implementation is a small, datasheet-based register driver rather than a +third-party motion library. This keeps timer ownership, stop behavior, dual-axis +servicing, and native tests explicit. It supports addressed UART checks, IFCNT write +verification, RMS current and hold-current configuration, microsteps, interpolation, +stealthChop/spreadCycle selection, and diagnostic mapping. + +The simulator models the public state/fault contract, not electrical waveforms. +The `esp32dev` environment compiles the physical implementation, but its board and +pin map remain provisional until the exact board and carrier modules are inspected. +See the [commissioning guide](../hardware/tmc2209-commissioning.md). diff --git a/docs/firmware/protocol.md b/docs/firmware/protocol.md index 52877c5..f82edf8 100644 --- a/docs/firmware/protocol.md +++ b/docs/firmware/protocol.md @@ -1,39 +1,73 @@ # Motion protocol version 1 -Version 1 is UTF-8/ASCII text, one command or response per line, at a provisionally -documented 115200 baud. Whitespace separates fields. Angles are decimal degrees, -rates are degrees per second, and future time fields will use integer milliseconds. - -## Commands - -| Command | Arguments | Success response | Purpose | -| --- | --- | --- | --- | -| `IDENTIFY` | none | `OK IDENTIFY DEVICE=… PROTOCOL=1 MODE=…` | Identify device and protocol. | -| `STATUS` | none | `OK STATUS …` | Report positions, homing, stop, and fault state. | -| `POSITION` | none | `OK STATUS …` | Report current position state. | -| `HOME` | `AZ`, `EL`, or `BOTH` | `OK HOME AXIS=…` | Establish an axis zero. | -| `MOVE` | `AZ_DEG EL_DEG DEG_PER_S` | `OK MOVE …` | Move to an absolute position. | -| `SCAN_STEP` | `AZ_DEG EL_DEG DEG_PER_S` | `OK SCAN_STEP … READY=1` | Move and signal a measurement boundary. | -| `STOP` | none | `OK STOP` | Latch the controller in a stopped state. | -| `E_STOP` | none | `OK E_STOP` | Simulate activation of the dedicated emergency-stop input. | -| `ENABLE` | `0` or `1` | `OK ENABLE VALUE=…` | Disable or enable both driver outputs. | -| `CLEAR_FAULT` | none | `OK CLEAR_FAULT` | Clear a releasable fault/stop latch; never restore position confidence. | - -Errors use `ERR CODE detail`. Defined simulator codes are `INVALID_COMMAND`, -`INVALID_ARGUMENT`, `INVALID_CONFIGURATION`, `NOT_HOMED`, `POSITION_UNTRUSTED`, -`LIMIT_REACHED`, `MOTION_TIMEOUT`, `DRIVER_DISABLED`, `EMERGENCY_STOP`, and -`STOPPED`. - -`STATUS` labels `AZ_DEG` and `EL_DEG` with `POSITION_KIND=COMMANDED` and reports -per-axis homed/trusted state, driver enable, stop, emergency stop, and fault. Startup -is untrusted. Reset, stop, emergency stop, driver disable, timeout, or suspected missed -steps requires a new home operation; `CLEAR_FAULT` alone is insufficient. - -## Important limitations - -`READY=1 POSITION_KIND=COMMANDED` means the simulator completed its immediate state -update. It does not establish physical settling or verify position. The host still -applies its configured settling delay. A physical implementation must read/debounce -home inputs, apply acceleration and timeout, report active limits, define command IDs -or acknowledgements if needed, and preserve safe stop behavior across communication -loss. Emergency-stop release is a physical input condition, not a software clear. +Version 1 is line-oriented ASCII at 115200 baud. Angles are decimal degrees, angular +rates are degrees per second, current is RMS milliamps, and integer positions are +microsteps at the configured motor/gear ratio. + +## Correlation and events + +Physical host traffic uses `CMD `. Every direct response +contains the same `ID=`. IDs must increase; reused and older IDs return +`DUPLICATE_COMMAND` and `STALE_COMMAND`. Uncorrelated commands remain supported for +interactive use and the native simulator. + +Accepted physical motion can return before it completes. `ACCEPTED=1` or `READY=0` +means motion is in progress. The measurement boundary is: + +```text +EVENT MOTION_COMPLETE ID= AZ_DONE=1 EL_DONE=1 +``` + +The controller also emits `EVENT FAULT CODE=` and +`EVENT ESTOP ACTIVE=<0|1>`. A host must not measure until the completion ID matches +the accepted scan/move command and both axes are done. It then applies the configured +settling delay. + +## General commands + +| Command | Arguments | Purpose | +| --- | --- | --- | +| `IDENTIFY` | none | Report device, protocol, physical/simulator mode. | +| `HEARTBEAT` | none | Keep the physical controller's two-second host watchdog alive. | +| `STATUS` / `POSITION` | none | Report commanded angles, integer steps, targets, homing, trust/reason, switches, enabled/moving state, last command, e-stop, and faults. | +| `HOME` | `AZ`, `EL`, or `BOTH` | Start the two-pass homing state machine. | +| `MOVE` | `AZ_DEG EL_DEG DEG_PER_S` | Start a homed absolute move. | +| `MOVE_REL` | `AXIS DELTA_DEG DEG_PER_S` | Start a homed relative move. | +| `SCAN_STEP` | `AZ_DEG EL_DEG DEG_PER_S` | Start a coordinated scan move; readiness comes from the completion event. | +| `STOP` | none | Stop both axes; an interrupted open-loop position becomes untrusted. | +| `E_STOP` | none | Latch software emergency stop and disable both drivers. | +| `ENABLE` | `0` or `1` | Change both driver-enable outputs. | +| `CLEAR_FAULT` / `RESET_ESTOP` | none | Clear releasable faults; reset is rejected while the physical e-stop input remains active. | + +## Motor commands + +| Command | Arguments | Purpose | +| --- | --- | --- | +| `MOTOR IDENTIFY` | none | Report driver presence and optional UART capability. | +| `MOTOR CONFIG` | `AZ` or `EL` | Inspect transport-neutral axis motion configuration. | +| `MOTOR STATUS` | `AZ` or `EL` | Inspect axis position, trust, homing, and fault state. | +| `MOTOR DIAGNOSTICS` | `AZ` or `EL` | Read optional driver connection/current-scale/thermal/electrical diagnostics. | +| `MOTOR ENABLE` / `DISABLE` | `AZ` or `EL` | Control one axis. | +| `MOTOR STEP` | `AXIS SIGNED_STEPS` | Bounded, unhomed bench move; position remains explicitly untrusted. | +| `MOTOR MOVE_DEGREES` | `AXIS SIGNED_DEGREES` | Bounded relative bench move converted once to integer steps. | +| `MOTOR SET_CURRENT` | `AXIS RMS_MA` | Set current within the configured ceiling while stopped. | +| `MOTOR SET_MICROSTEPS` | `AXIS VALUE` | Set a supported power-of-two microstep value; invalidates position trust when changed. | +| `MOTOR STOP` | `AZ` or `EL` | Immediately stop one axis. | + +## Fault and trust contract + +Errors use `ERR ID= CODE detail`. Faults include invalid command/configuration, +not homed, position untrusted, limits, motion timeout, disabled driver, emergency +stop, driver communication/critical faults, the exact homing failure classes, +unexpected home-switch activation, and controlled stop. + +`AZ_DEG` and `EL_DEG` are always labeled `POSITION_KIND=COMMANDED`. They are not +encoder measurements. Reset, emergency stop, critical driver failure, timeout, +interrupted motion, driver disable, configuration changes affecting scale, and failed +homing make position untrusted. Clearing a fault does not restore trust; successful +homing does. + +On the physical ESP32 target, two seconds without a command/heartbeat while a driver +is enabled stops motion, disables both drivers, and emits a host-timeout fault event. +The Python motion client sends heartbeats while waiting. This watchdog does not make +USB serial or software an emergency-rated control path. diff --git a/docs/hardware/motion-system.md b/docs/hardware/motion-system.md index 0fc6401..6f3a2b1 100644 --- a/docs/hardware/motion-system.md +++ b/docs/hardware/motion-system.md @@ -11,16 +11,16 @@ Each axis uses one NEMA 17 bipolar stepper, provisionally assumed to be 1.8° fu only when configuration does not override it. Exact current rating, holding torque, winding resistance, and required gearbox are unresolved. -TMC2209 is the Version 1 target driver. Prefer UART configuration so RMS motor current, -microstepping, and diagnostic state are reproducible. STEP, DIR, and enable are driven -by the ESP32. Current is never hardcoded before motor selection; it must be configured -from motor ratings, the module's sense-resistor implementation, load testing, and -thermal limits. Drivers need heatsinking/airflow appropriate to measured dissipation. - -The firmware `MotionController` interface contains no TMC2209 type. A later physical -implementation may use that device or another stepper driver without changing the -serial or host scan API. Generic axis configuration reserves motor RMS current with -an unset simulator value rather than inventing a motor-specific default. +TMC2209 is the implemented Version 1 driver. UART configuration makes RMS motor +current, microstepping, mode, and diagnostic state reproducible; STEP, DIR, and enable +are driven by the ESP32. Current remains configuration-driven and must be selected +from motor ratings, the carrier's sense-resistor implementation, load testing, and +thermal limits. Drivers need cooling appropriate to measured dissipation. + +The public firmware `MotionController` interface contains no TMC2209 type. The device +is behind `StepperDriver`, so a future driver can preserve the serial and host scan +API. The 400 mA development value is explicitly provisional, not a motor-specific +rating. See [TMC2209 commissioning](tmc2209-commissioning.md). ## Configuration-derived movement diff --git a/docs/hardware/tmc2209-commissioning.md b/docs/hardware/tmc2209-commissioning.md new file mode 100644 index 0000000..1376d04 --- /dev/null +++ b/docs/hardware/tmc2209-commissioning.md @@ -0,0 +1,165 @@ +# TMC2209 and NEMA 17 commissioning + +> [!CAUTION] +> This is a provisional development configuration, not verified final wiring. +> Never connect or disconnect a stepper motor while its driver is powered. Provide a +> physical motor-power disconnect; software emergency stop is not a substitute. + +## Implemented architecture + +One ESP32 services two independent TMC2209 drivers. Each uses a separate ESP32 +hardware UART plus STEP, DIR, and active-low enable. Separate UART channels avoid +shared-bus/address ambiguity during first bring-up; both provisional addresses are +zero because they are on different channels. Confirm each carrier's PDN_UART and +address-strap implementation against its schematic before wiring. + +Startup sets STEP low and both enable pins inactive, validates pins/configuration, +starts the two UARTs, probes each driver, reads diagnostics, writes conservative +current/microstep/mode settings, reads switches/e-stop, and leaves both axes disabled +and untrusted. A missing driver is a structured fault and cannot be enabled. + +The internal driver follows the manufacturer datagram/CRC and register definitions; +successful writes are checked through IFCNT. STEP timing is a conservative 2 µs high, +2 µs minimum low, with 2 µs after DIR changes. These values exceed the TMC2209's +published STEP/DIR minimum timing. See the +[Analog Devices TMC2209 datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/TMC2209_datasheet_rev1.09.pdf). + +No large motion library was introduced. The small local implementation keeps pulse +timing, dual-axis scheduling, stop behavior, UART diagnostics, and native simulation +under direct test. + +## Provisional GPIO and signal table + +These assignments target PlatformIO's generic classic `esp32dev`. They avoid the +classic ESP32 bootstrapping and input-only pins, but the exact user's board and carrier +pin labels are unknown. Change the unified configuration before connecting a board +whose schematic differs. + +| ESP32/TMC signal | Azimuth | Elevation | Level | Purpose | Status | +| --- | ---: | ---: | --- | --- | --- | +| STEP → TMC2209 STEP | GPIO25 | GPIO18 | 3.3 V logic | Step edge | Provisional | +| DIR → TMC2209 DIR | GPIO26 | GPIO19 | 3.3 V logic | Direction | Provisional | +| ENABLE → TMC2209 EN/ENN | GPIO27 | GPIO23 | 3.3 V logic, active low | Output enable | Provisional; confirm carrier polarity | +| UART TX → PDN_UART | GPIO22 / UART1 | GPIO17 / UART2 | 3.3 V logic | Configuration/write | Provisional; carrier interface required | +| UART RX ← PDN_UART | GPIO21 / UART1 | GPIO16 / UART2 | 3.3 V logic | Diagnostics/read | Provisional; confirm one-wire circuit | +| Driver address straps | address 0 | address 0 | Carrier-defined | UART slave address | Separate UARTs; confirm MS1/MS2 straps | +| Home switch | GPIO32 | GPIO33 | 3.3 V input/pull-up | Homing and unexpected-limit detection | Provisional, normally closed | +| Emergency-stop sense | GPIO13 | shared | 3.3 V input/pull-up | Active-low latched input | Optional/provisional | +| Logic ground → GND | common | common | 0 V reference | STEP/DIR/UART reference | Required | +| Motor-power ground → GND | common | common | 12 V return | VM return tied to common ground | Required; manage high-current return path | +| VM | 12 V supply | 12 V supply | 12 V power | Motor driver supply only | Required; never connect to ESP32 | + +The actual carrier may expose `PDN_UART`, `UART`, `CFG`, `MS1/MS2`, `SPREAD`, or +different labels and may require a resistor for bidirectional single-wire UART. +Resolve those details from the exact carrier schematic. Do not infer them from this +generic table. + +## Power, motor wiring, and current + +Distribute 12 V separately to both VM inputs and a buck converter. Feed the ESP32 only +through the input method documented for the exact board. All logic references share +ground, but keep high motor-current returns short and away from switch/RF returns. +Place appropriately rated bulk capacitance close to each driver's VM/GND and local +logic decoupling near the electronics. Review the board's USB/external-power circuit +before connecting USB and the buck at the same time; do not assume a 5 V header is +backfeed-safe. + +Identify the motor's two coil pairs with its datasheet or an unpowered continuity +test, then connect each pair to one driver phase. A 12 V supply does not mean the +driver continuously applies 12 V across a winding: the TMC2209 chops the supply to +regulate winding current. Incorrect RMS current can still overheat or under-drive the +motor/driver. + +The checked-in 400 mA RMS and 800 mA ceiling are placeholders, not safe-current +claims. Before power-up, derive the setting from: + +- the exact motor's rated phase current; +- the exact carrier's sense-resistor value and circuit; +- the driver's current formula and UART configuration; +- available heatsinking/airflow and mechanical load; and +- measured motor and carrier temperature during progressively longer tests. + +Reduced hold current is configured as 30% while stationary. Overtemperature warning +is reported; shutdown, shorts, undervoltage/reset, or communication loss disable the +affected axis. Open-load flags are diagnostic hints and can be unreliable at +standstill or low current; do not use them as the only continuity test. + +## Homing and position trust + +Each axis accepts normally closed or normally open polarity. The default is normally +closed with pull-up. Homing validates an inactive switch, approaches quickly, stops +on a debounced activation, backs off, confirms release, approaches slowly, stops, +applies the configured offset, and only then marks position trusted. + +Failures are distinct: switch active at start, never activated, failed to release, +overall timeout, unexpected activation during normal motion, or e-stop interruption. +Failure stops and disables the axis, leaves position untrusted, and requires operator +inspection and fault reset before retry. Open-loop microsteps are commanded position, +not verified physical accuracy; backlash, compliance, stalls, and manual movement are +not measured. + +## First power-up checklist + +1. Disconnect both motors and remove 12 V motor power. +2. Confirm the exact ESP32 and both carrier part numbers, schematics, pin labels, + sense resistors, enable polarity, UART circuit, address straps, and voltage levels. +3. Inspect and edit the provisional GPIO/current/configuration record. +4. Verify the buck output with a multimeter before connecting the ESP32. +5. Verify the intended ESP32 supply pin voltage and USB backfeed protection. +6. Verify common ground and continuity from ESP32 to both driver logic grounds. +7. Verify no short between VM and ground and check all supply polarities. +8. Fit appropriately rated VM bulk capacitors and local logic decoupling. +9. Power only the ESP32/driver logic; confirm startup reports drivers disabled. +10. Run `CMD 1 MOTOR IDENTIFY` and confirm both drivers present over UART. +11. Run diagnostics for each axis; resolve communication, reset, undervoltage, short, + or thermal faults before continuing. +12. Remove every power source, identify one motor's coil pairs, and connect only the + azimuth motor. +13. Set an RMS current justified by that motor and carrier; do not assume 400 mA is + correct. +14. Raise/support the mechanism so this axis can move freely, clear people/cables, + and make the physical 12 V disconnect reachable. +15. Apply 12 V and enable only azimuth. +16. Issue exactly `CMD 10 MOTOR STEP AZ 20`; be ready to cut motor power. +17. Verify movement direction and that the reported position is explicitly untrusted. +18. Stop, disable, remove power, and inspect connector, motor, carrier, capacitor, + buck, and wiring temperature/condition. +19. Manually actuate the unpowered home switch and verify electrical polarity/status. +20. Re-power and perform low-speed azimuth homing while ready to disconnect power. +21. Repeat steps 12–20 for elevation only. +22. Route and strain-relieve all cables through the full envelope with power removed. +23. Home both axes independently, then issue a small coordinated target within limits. +24. Confirm completion occurs after the last axis stops and only then check settling. +25. Expand distance, speed, acceleration, current, and test duration incrementally, + recording temperatures, faults, repeatability, load, and exact revisions. + +## Fault interpretation + +| Fault/status | Required response | +| --- | --- | +| Driver communication / absent | Keep axis disabled; verify UART topology, channel, address straps, common ground, and logic level. | +| Reset detected / undervoltage | Stop; inspect VM and logic supply sequencing, wiring, bulk capacitance, and transients. Rehome after correction. | +| Overtemperature warning | Stop the test soon; reduce current/load or improve cooling and measure temperature. | +| Overtemperature shutdown | Driver is disabled; remove motor power, allow cooling, diagnose before reset, and rehome. | +| Short to ground/supply | Remove power immediately and inspect motor cable, connector, phase pairing, and carrier. | +| Open load | With power removed, inspect continuity/connector; remember the flag may be ambiguous at standstill/low current. | +| Homing stuck / never / release / timeout | Remove motor power if travel is unsafe; inspect switch polarity, mechanics, wiring, direction, debounce, speed, and travel. | +| Unexpected home switch | Stop and rehome only after checking limits, direction, switch noise, and mechanical position. | +| Host heartbeat timeout | Drivers have been stopped/disabled; inspect USB/host failure and rehome because position is untrusted. | +| Emergency stop | Use the physical disconnect as necessary; clear the hazard, release the input, explicitly reset, and rehome. | + +## Known limitations and required confirmations + +- Exact ESP32 board and TMC2209 carrier revisions/pinouts are unconfirmed. +- Carrier UART one-wire circuitry, address straps, sense resistance, enable polarity, + and onboard potentiometer interaction are unconfirmed. +- Motor rated current, phase wiring, torque, inductance, thermal limits, and load are + unconfirmed. +- No encoder, stall verification, closed-loop positioning, backlash compensation, or + continuous-rotation/slip-ring support exists. +- The 2 µs software STEP timing compiles for ESP32 but has not been measured on a + scope or logic analyzer under serial/dual-axis load. +- GPIO electrical behavior, e-stop circuit, switch noise, USB backfeed, power + transient behavior, thermal performance, homing repeatability, cable clearance, and + coordinated physical motion all remain unvalidated. +- ESP32 compilation, native simulation, and unit tests are not physical validation. diff --git a/docs/hardware/wiring.md b/docs/hardware/wiring.md index dd36641..78ccf93 100644 --- a/docs/hardware/wiring.md +++ b/docs/hardware/wiring.md @@ -1,6 +1,8 @@ # Wiring -Verified pin assignments do not exist. The Version 1 wiring record must identify the +Verified final pin assignments do not exist. A provisional ESP32 development map is +listed in the [TMC2209 commissioning guide](tmc2209-commissioning.md). The Version 1 +wiring record must identify the exact ESP32 board and every connector pin, signal reference, voltage domain, wire rating, shielding, grounding point, switch behavior, and emergency isolation before a physical build is called supported. diff --git a/docs/index.md b/docs/index.md index 296ab2f..47f1fbb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ interfaces unless a page explicitly marks behavior as implemented and tested. - [Data flow](architecture/data-flow.md) - [Scan file format](software/file-formats.md) - [Motion protocol](firmware/protocol.md) +- [TMC2209 commissioning](hardware/tmc2209-commissioning.md) - [Development setup](development/setup.md) - [Roadmap](development/roadmap.md) - [Glossary](glossary.md) diff --git a/firmware/config/provisional-esp32dev-v1.json b/firmware/config/provisional-esp32dev-v1.json index 706b456..62957f7 100644 --- a/firmware/config/provisional-esp32dev-v1.json +++ b/firmware/config/provisional-esp32dev-v1.json @@ -23,6 +23,8 @@ "enable_pin": 27, "home_switch_pin": 32, "home_switch_normally_closed": true, + "homing_direction_negative": true, + "home_switch_debounce_ms": 10, "motor_full_steps_per_revolution": 200, "microsteps": 16, "gear_ratio": 1.0, @@ -38,6 +40,8 @@ "home_speed_deg_s": 5.0, "slow_home_speed_deg_s": 1.0, "homing_backoff_deg": 3.0, + "settling_time_ms": 250, + "maximum_bench_test_steps": 3200, "motion_timeout_ms": 60000 }, "elevation": { @@ -51,6 +55,8 @@ "enable_pin": 23, "home_switch_pin": 33, "home_switch_normally_closed": true, + "homing_direction_negative": true, + "home_switch_debounce_ms": 10, "motor_full_steps_per_revolution": 200, "microsteps": 16, "gear_ratio": 1.0, @@ -66,6 +72,8 @@ "home_speed_deg_s": 4.0, "slow_home_speed_deg_s": 1.0, "homing_backoff_deg": 3.0, + "settling_time_ms": 250, + "maximum_bench_test_steps": 3200, "motion_timeout_ms": 60000 } } diff --git a/software/README.md b/software/README.md index 7da6c6e..2df2ca5 100644 --- a/software/README.md +++ b/software/README.md @@ -1,16 +1,21 @@ # Radiance3D software This Python 3.11+ package provides typed scan models, receiver/motion protocols, a -bounds-safe raster planner, a move-settle-measure coordinator, and two intentionally -small commands: +bounds-safe raster planner, a move-settle-measure coordinator, a transport-independent +physical motion client, and two intentionally small commands: ```bash radiance3d validate path/to/scan.json radiance3d inspect path/to/scan.json ``` -It does not provide a serial transport, RF-device implementation, physical hardware -control, data writer, or visualization yet. The coordinator accepts interchangeable -adapters and preserves raw/rejected readings when computing an aggregate. Validation +The optional `radiance3d[serial]` extra adds pyserial. `SerialTransport` requires an +explicit device path, validates device identity and protocol version, correlates +commands, queues asynchronous events, rejects mismatched responses, and supports +clean disconnect/reconnect. `PhysicalMotionController` works over that adapter or any +simulator/future transport implementing `ProtocolTransport`. + +RF-device integration, a data writer, and visualization are not implemented yet. The +coordinator preserves raw/rejected readings when computing an aggregate. Validation enforces schema 1.0 migration reads and the complete 1.1 record invariants without a -runtime dependency. JSON Schema remains the normative interchange specification. +runtime dependency. From a954db8032b2a924b9689f10ae0718c2c04bcccb Mon Sep 17 00:00:00 2001 From: bostromdev Date: Thu, 30 Jul 2026 23:29:24 -0400 Subject: [PATCH 09/13] Update Version 1 hardware baseline docs and config --- README.md | 10 +- ROADMAP.md | 4 +- docs/architecture/version-1.md | 31 +-- docs/firmware/configuration.md | 12 +- docs/firmware/overview.md | 9 +- docs/firmware/protocol.md | 4 +- docs/hardware/bill-of-materials.md | 13 +- docs/hardware/motion-system.md | 75 ++++--- docs/hardware/overview.md | 32 ++- docs/hardware/power-system.md | 83 +++++--- docs/hardware/tmc2209-commissioning.md | 212 +++++++++---------- docs/hardware/wiring.md | 22 +- docs/index.md | 5 +- firmware/config/provisional-esp32dev-v1.json | 61 +++++- firmware/controller/README.md | 10 +- firmware/controller/platformio.ini | 3 +- firmware/controller/src/hardware_config.cpp | 10 +- 17 files changed, 341 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index 3b754b3..6a56c65 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ repository begins with documented boundaries, a versioned scan format, validatio tools, and simulator-friendly firmware interfaces so physical claims can be added only after evidence exists. +The current Version 1 baseline is an ESP32 development board with ESP-WROOM-32, +USB serial, two BIGTREETECH TMC2209 V1.3 drivers, two YEJMKJ/LYLANMO NEMA 17 +bipolar motors, a standalone 12 V battery power system, and 5.0 V logic power from +an LM2596 buck converter. The exact board revision, carrier pinout, UART wiring, +R10 setting, sense resistor value, and mechanical validation remain pending. + ## Why the project exists Full 3D antenna characterization is often inaccessible outside specialized labs. @@ -74,7 +80,9 @@ target, two TMC2209 driver instances, non-blocking dual-axis stepping, two-pass latched emergency stop, diagnostics, command correlation, a serial host adapter, and simulator parity. It is compiled and unit tested but has not yet been exercised on connected hardware. No physical scanner motion, receiver integration, measurement -accuracy, calibrated antenna gain, or production-ready workflow is claimed. +accuracy, calibrated antenna gain, or production-ready workflow is claimed. The +hardware baseline is now documented as the confirmed Version 1 family and the pending +validation items are explicit. ## Getting started diff --git a/ROADMAP.md b/ROADMAP.md index 1ff5b0b..943b9e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,7 +25,9 @@ electrical safety checks, fault behavior, cable limits, and repeatable zeroing. **Implementation status:** firmware, host protocol, simulator parity, tests, and commissioning documentation are complete on the Stage 2 feature branch. Physical bring-up, thermal characterization, exact-board pin confirmation, repeatable homing, -and cable-envelope evidence remain required before the stage exit is claimed. +and cable-envelope evidence remain required before the stage exit is claimed. The +hardware baseline now reflects the confirmed Version 1 components and the remaining +validation work is documented explicitly. ## Stage 3 — RF acquisition diff --git a/docs/architecture/version-1.md b/docs/architecture/version-1.md index fa1c2f6..e819103 100644 --- a/docs/architecture/version-1.md +++ b/docs/architecture/version-1.md @@ -47,21 +47,26 @@ Initial conceptual travel is azimuth `0°` through `360°` and elevation `-90°` `+90°`; actual configured limits govern every move. Azimuth is not continuous or unlimited in Version 1. -## Version 1 hardware assumptions - -- One ESP32 development board using 3.3 V logic. -- Two NEMA 17 bipolar steppers, provisionally assumed to be 1.8° full-step unless - configuration says otherwise. -- One replaceable stepper-driver implementation per axis. TMC2209 with UART - configuration, ESP32-controlled STEP/DIR, and firmware-controlled enable is the - Version 1 target, but no public motion behavior depends on it. +## Version 1 hardware baseline + +- One ESP32 development board with an ESP-WROOM-32 module using 3.3 V GPIO logic. +- USB serial is the Version 1 transport. Wi-Fi, Ethernet, and Bluetooth remain future + transport options but are intentionally unused in Version 1. +- Two YEJMKJ/LYLANMO NEMA 17 bipolar 4-wire steppers with 1.8° full-step geometry, + 200 full steps/revolution, 1.0 A rated phase current, 3.5 Ω phase resistance, + approximately 0.13 N·m holding torque, and approximately 42 × 42 × 21 mm size. +- Two BIGTREETECH TMC2209 V1.3 stepper-driver instances with UART configuration, + ESP32-controlled STEP/DIR, and firmware-controlled enable. The driver profile must + be validated against each carrier's actual R10, UART wiring, sense resistor, board + revision, and pinout. - One configurable home switch per axis and one emergency-stop input. -- One 12 V DC input, with motor drivers on 12 V and an appropriate regulated buck - converter feeding the ESP32 by its board-supported input method. +- One standalone 12 V automotive battery as the motor supply, with a 3–5 A inline + fuse, master disconnect, TMC2209 VM power, and a regulated 5.0 V buck converter + for the ESP32 only. -Exact motor current, holding torque, winding resistance, pulley ratio, gear ratio, -belt pitch, supply-current rating, board pinout, and printed dimensions are not yet -selected. They must not be hardcoded or inferred from these assumptions. +The initial motor current is 650 mA RMS, with a software ceiling of 1000 mA RMS. +These values are the current baseline for commissioning and are not a substitute for +measured torque, thermal, or long-duration testing. ## Motion configuration and position confidence diff --git a/docs/firmware/configuration.md b/docs/firmware/configuration.md index 2838c5e..8fdd330 100644 --- a/docs/firmware/configuration.md +++ b/docs/firmware/configuration.md @@ -1,16 +1,16 @@ # Firmware configuration Version 1 separates board/pin configuration from driver-neutral motion behavior. -The physical target and simulator use the same typed motion fields. The clearly -provisional development record is +The physical target and simulator use the same typed motion fields. The Version 1 +hardware profile is stored in [`firmware/config/provisional-esp32dev-v1.json`](../../firmware/config/provisional-esp32dev-v1.json); -the compiled defaults live together in `hardware_config.cpp`. +the compiled defaults live in `hardware_config.cpp`. ## Axis fields Each azimuth and elevation axis defines: -- `motor_full_steps_per_revolution` (provisionally 200 for a typical 1.8° motor); +- `motor_full_steps_per_revolution` (200 for the selected 1.8° motor); - `microsteps`; - configurable `motor_rms_current_ma`; - configurable maximum RMS-current ceiling and hold-current percentage; @@ -35,8 +35,8 @@ version, serial rate, board name, and all GPIO assignments. Startup rejects dupl pins and input-only STEP/DIR/ENABLE/TX assignments and reports ESP32 bootstrapping-pin use as a warning. -The example uses 400 mA RMS with an 800 mA software ceiling only as conservative -development placeholders. Before energizing a motor, replace them using the selected +The example uses 650 mA RMS as the initial commissioning current with a 1000 mA +software ceiling. Before energizing a motor, verify this value against the selected motor rating, the carrier's actual sense resistor and schematic, cooling, load, and measured temperatures. ESP32 signals are 3.3 V and no attached module is assumed 5 V tolerant. The example is not a verified pinout or motor calibration. diff --git a/docs/firmware/overview.md b/docs/firmware/overview.md index 9b86411..fe4a6a0 100644 --- a/docs/firmware/overview.md +++ b/docs/firmware/overview.md @@ -9,6 +9,9 @@ The controller uses integer microsteps as authoritative position. It services ST edges, acceleration, switch debounce, homing, driver diagnostics, emergency stop, and serial input without long delay loops. A coordinated command completes only after both axes stop. Critical faults stop both axes when a coordinated move is active. +The current profile reflects the Version 1 hardware baseline, but the exact GPIO map, +carrier wiring, and initial current remain pending validation against the physical +hardware. The TMC2209 implementation is a small, datasheet-based register driver rather than a third-party motion library. This keeps timer ownership, stop behavior, dual-axis @@ -17,6 +20,6 @@ verification, RMS current and hold-current configuration, microsteps, interpolat stealthChop/spreadCycle selection, and diagnostic mapping. The simulator models the public state/fault contract, not electrical waveforms. -The `esp32dev` environment compiles the physical implementation, but its board and -pin map remain provisional until the exact board and carrier modules are inspected. -See the [commissioning guide](../hardware/tmc2209-commissioning.md). +The `esp32dev` environment compiles the physical implementation, but the board and +carrier pin map remain pending validation until the exact board and modules are +inspected. See the [commissioning guide](../hardware/tmc2209-commissioning.md). diff --git a/docs/firmware/protocol.md b/docs/firmware/protocol.md index f82edf8..20e3881 100644 --- a/docs/firmware/protocol.md +++ b/docs/firmware/protocol.md @@ -2,7 +2,9 @@ Version 1 is line-oriented ASCII at 115200 baud. Angles are decimal degrees, angular rates are degrees per second, current is RMS milliamps, and integer positions are -microsteps at the configured motor/gear ratio. +microsteps at the configured motor/gear ratio. The protocol remains transport-neutral; +Version 1 uses USB serial, while future transports may add Wi-Fi, Ethernet, or +Bluetooth without changing the motion API. ## Correlation and events diff --git a/docs/hardware/bill-of-materials.md b/docs/hardware/bill-of-materials.md index e20cf7b..ea7a12b 100644 --- a/docs/hardware/bill-of-materials.md +++ b/docs/hardware/bill-of-materials.md @@ -1,11 +1,12 @@ # Bill of materials -There is no tested BOM. Version 1 categories include one ESP32 development board, two -TMC2209 target modules, two NEMA 17 bipolar motors, two home switches, one emergency -stop, one 12 V input supply, buck conversion, overcurrent protection, bulk/local -decoupling, driver cooling, structure, bearings, fasteners, strain-relieved cabling, -and independently connected RF equipment. Exact part numbers and supply-current -rating await motor, current, thermal, and mechanical testing. +There is no tested BOM. Version 1 categories include one ESP32 development board with +ESP-WROOM-32, two BIGTREETECH TMC2209 V1.3 modules, two YEJMKJ/LYLANMO NEMA 17 +bipolar motors, two home switches, one emergency stop, one standalone 12 V battery +power source, buck conversion, overcurrent protection, bulk/local decoupling, driver +cooling, structure, bearings, fasteners, strain-relieved cabling, and independently +connected RF equipment. Exact part numbers and supply-current rating await motor, +current, thermal, and mechanical testing. A publishable BOM must contain tested part numbers, revision, quantity, function, acceptable substitutions, source date, and validation status. Price and availability diff --git a/docs/hardware/motion-system.md b/docs/hardware/motion-system.md index 6f3a2b1..d6facea 100644 --- a/docs/hardware/motion-system.md +++ b/docs/hardware/motion-system.md @@ -1,26 +1,27 @@ # Motion system -Version 1 uses a base azimuth (pan) axis and a supported elevation (tilt) axis. The -AUT rotates; the measurement receiver/reference remains stationary. This arrangement -reduces receiver cable movement, cable strain, and position-dependent RF changes. -Cable routing still limits travel unless a future slip ring is tested and documented. +Version 1 uses a base azimuth axis and a supported elevation axis. The AUT rotates while +the measurement receiver/reference remains stationary. This arrangement reduces receiver +cable movement and position-dependent RF variation, but cable routing and structure +clearances still limit travel unless a future slip ring is tested and documented. ## Motors and drivers -Each axis uses one NEMA 17 bipolar stepper, provisionally assumed to be 1.8° full-step -only when configuration does not override it. Exact current rating, holding torque, -winding resistance, and required gearbox are unresolved. +Each axis uses one YEJMKJ/LYLANMO NEMA 17 bipolar 4-wire stepper. The selected motors +are 1.8° full-step, 200 full steps/revolution, rated at 1.0 A phase current, 3.5 Ω +phase resistance, and about 0.13 N·m holding torque. These values come from the +selected hardware and are not generic placeholders. -TMC2209 is the implemented Version 1 driver. UART configuration makes RMS motor -current, microstepping, mode, and diagnostic state reproducible; STEP, DIR, and enable -are driven by the ESP32. Current remains configuration-driven and must be selected -from motor ratings, the carrier's sense-resistor implementation, load testing, and -thermal limits. Drivers need cooling appropriate to measured dissipation. +Version 1 uses BIGTREETECH TMC2209 V1.3 drivers. They are UART controlled, use the +STEP/DIR interface, expose driver diagnostics, and allow the firmware to set current +through UART. The enable pin remains configurable, current is stored in RMS milliamps, +and current must never exceed the selected motor rating. The driver profile must be +validated against the actual carrier revision, R10 setting, sense resistor, UART +wiring, and pinout. See [TMC2209 commissioning](tmc2209-commissioning.md). -The public firmware `MotionController` interface contains no TMC2209 type. The device -is behind `StepperDriver`, so a future driver can preserve the serial and host scan -API. The 400 mA development value is explicitly provisional, not a motor-specific -rating. See [TMC2209 commissioning](tmc2209-commissioning.md). +The firmware motion interface remains driver-neutral, so a future driver can preserve +its serial and host scan API. The current profile is intentionally conservative and +must be re-verified under load, temperature, and mechanical requirements. ## Configuration-derived movement @@ -33,26 +34,22 @@ Each axis records: - maximum speed and acceleration; and - home-switch polarity, debounce, direction, speed, back-off, and slow approach. -Gear or pulley dimensions are not assumed. The initial conceptual limits are azimuth -`0°` to `360°` and elevation `-90°` to `+90°`; real cable and structure clearances may -require smaller limits. The planner must reject every out-of-range point. It may -reverse alternate raster rows to reduce cable winding. Continuous/unlimited azimuth -rotation is not supported. - -## Resolution and confidence - -The project targets 0.1° commanded resolution and prioritizes repeatability over -speed. Motor/microstep quantization, mechanical resolution, repeatability, and absolute -accuracy are different quantities. Belt compliance, shaft play, backlash, frame -deflection, motor torque margin, and microstep nonlinearity must be measured. No -0.1° physical-accuracy claim follows from selecting a microstep value. - -Version 1 position is open-loop step counting. A successful two-stage home establishes -trust. Reset, fault, emergency stop, driver disable, timeout, or suspected missed -steps invalidates it, and motion used for scanning requires re-homing. A home switch -does not observe position throughout a move. Future encoders can supply observed -position behind the same public API. - -Normally-closed switches are recommended for broken-wire fault detection, while -polarity remains configurable. Emergency power isolation must not depend solely on -working firmware or a host connection. +The initial configuration is 16 microsteps and direct drive (1:1). That gives: + +200 full steps/rev × 16 microsteps = 3200 microsteps/rev + +and therefore a commanded microstep resolution of 0.1125° per microstep. This is a +commanded motion resolution and is not a guarantee of physical angular accuracy. +Backlash, frame rigidity, shaft coupling, microstep linearity, and motor repeatability +must be measured before any physical accuracy claim is made. + +## Position confidence + +Version 1 position is open-loop step counting. A successful homing sequence establishes +trust. Reset, faults, emergency stop, driver disable, timeout, or suspected missed +steps invalidate trust and require re-homing. + +Home switches remain configurable as normally-open or normally-closed, with debounce, +fast approach, backoff, slow approach, home offset, position trust, and timeout +options still available in configuration. A home switch is a reference event, not a +continuous position sensor. diff --git a/docs/hardware/overview.md b/docs/hardware/overview.md index 75533c1..ce41298 100644 --- a/docs/hardware/overview.md +++ b/docs/hardware/overview.md @@ -1,12 +1,24 @@ # Hardware overview -The Version 1 baseline consists of an ESP32 development board, two TMC2209 target -drivers behind a replaceable motion interface, two NEMA 17 bipolar steppers, one home -switch per axis, an emergency-stop input, a pan-tilt AUT fixture, one protected 12 V -input, and regulated ESP32 power. The RF measurement device is independently connected -to the host and remains stationary with the RF source/reference. - -The architecture is fixed; exact board, modules, motors, currents, mechanics, pinout, -power rating, and RF device remain provisional. Component families are not tested -recommendations. Electrical, mechanical, thermal, and RF validation must precede a -supported configuration. See the [Version 1 baseline](../architecture/version-1.md). +Version 1 uses an ESP32 development board with an ESP-WROOM-32 module, USB-C, 3.3 V +GPIO logic, and USB serial for host communication. Wi-Fi and Bluetooth are available +on the module but are intentionally unused in Version 1. The motion system uses two +BIGTREETECH TMC2209 V1.3 drivers, each driven over UART with STEP/DIR and firmware- +controlled enable. Two YEJMKJ/LYLANMO NEMA 17 bipolar 4-wire motors form the initial +azimuth and elevation axes. + +The current baseline is: + +- Controller: ESP32 development board, ESP-WROOM-32, USB serial, 3.3 V logic +- Drivers: BIGTREETECH TMC2209 V1.3, UART controlled, STEP/DIR interface, driver + diagnostics enabled, current configured in RMS milliamps +- Motors: NEMA 17 bipolar, 1.8° full-step, 200 full steps/revolution, 1.0 A rated + phase current, 3.5 Ω phase resistance, approximately 0.13 N·m holding torque, + approximately 42 × 42 × 21 mm +- Motion: 16 microsteps, direct drive, 3200 microsteps/revolution, + 0.1125° commanded microstep resolution +- Power: 12 V standalone battery, inline fuse, master disconnect, TMC2209 VM power, + and a regulated 5.0 V buck converter for the ESP32 only + +The exact ESP32 board revision, carrier pinout, UART wiring, sense resistor value, +and physical mechanics remain pending validation. See the [Version 1 baseline](../architecture/version-1.md). diff --git a/docs/hardware/power-system.md b/docs/hardware/power-system.md index 12861ac..c07494b 100644 --- a/docs/hardware/power-system.md +++ b/docs/hardware/power-system.md @@ -1,46 +1,61 @@ # Power system -Version 1 uses one 12 V DC input with this distribution: +Version 1 uses a standalone 12 V automotive battery as the motor supply. It is not +connected to a running vehicle. The architecture is: ```text -12 V power supply -├── fuse / overcurrent protection -├── TMC2209 azimuth motor supply -├── TMC2209 elevation motor supply -└── regulated buck converter - └── ESP32 board-supported supply input +12 V battery +├── 3–5 A inline fuse +├── master disconnect switch +├── power distribution +│ ├── TMC2209 VM +│ ├── TMC2209 VM +│ └── LM2596 buck converter +│ └── 5.0 V logic rail +│ └── ESP32 ``` -Motor drivers remain on 12 V. Raw 12 V must never reach an ESP32 power or logic pin. -The buck converter must provide the voltage, current, ripple, and transient behavior -appropriate to the selected development board's documented input method. Do not -assume every board should be fed through a nominal 5 V pin. +Motors receive 12 V directly from the battery rail. The ESP32 receives a regulated +5.0 V rail from the buck converter. The buck converter powers only logic electronics; +it is not intended to power the stepper motors. This architecture is used because the +motors need a robust 12 V supply and the ESP32 needs a clean, regulated logic supply +that is isolated from the motor-current return path. -The 12 V source, both drivers, buck converter, and ESP32 signal reference require a -common ground. Motor-current return paths should not share long, high-impedance runs -with logic or measurement returns. Place suitable bulk capacitance close to each -driver's motor-supply input and local decoupling close to logic electronics, following -the selected module and IC recommendations. +The 12 V source, both drivers, the buck converter, and the ESP32 logic reference must +share a common ground. Motor-current return paths should not share high-impedance +runs with logic or measurement returns. Place a bulk capacitor near each TMC2209 VM +input and local decoupling near the ESP32 and logic electronics. + +## Buck converter guidance + +The confirmed modules are the SELOKY LM2596 and the LYLANMO LM2596S. Both are +adjustable DC-DC buck converters. Set the output with a multimeter before connecting +an ESP32. The output must be set to exactly 5.0 V, and the onboard display should not +be trusted as the only verification. Verify polarity and confirm that the buck output +and the ESP32 logic reference share a common ground. + +## Capacitors and transients + +Use one bulk capacitor near each TMC2209 VM input. An initial recommendation is +100–220 µF with a minimum 25 V rating. Add 0.1 µF ceramic decoupling where practical. +Stepper motors regenerate current when they are switched or decelerated. Supply +transients can therefore appear at the VM rail even when the command is static, so the +bulk capacitance and layout matter for noise and driver stability. ## Protection and commissioning -- Select fuse/overcurrent protection below the safe rating of the wiring, connectors, - and weakest protected component. -- Add accessible power isolation for unexpected motion. -- Treat reverse-polarity protection as a recommended improvement before a public - hardware revision. +- Install an inline fuse sized for the wiring and the weakest protected component. +- Use a master disconnect switch that is visible and accessible. +- Use insulated terminals and avoid loose alligator clips. - Verify connector polarity and continuity before energizing. -- Configure TMC2209 RMS motor current from the exact motor and module data; do not +- Disconnect power before changing wiring. +- Never connect or disconnect motors while the drivers are powered. +- Configure TMC2209 RMS motor current from the exact motor and carrier data; do not copy a nominal value from an unrelated build. -- Provide heatsinking and airflow based on driver temperature under the actual load. -- Separate motor/power wiring from STEP/DIR, switch inputs, receiver wiring, and RF - feedlines where practical. - -USB can energize an ESP32 while the external rail is present. The selected board and -buck topology must be reviewed for USB/external-power backfeeding before both are -connected. A jumper, ideal-diode/power-mux arrangement, or use of the board's protected -input may be required; there is no universal safe 5 V-pin rule. - -The final power-supply current rating remains provisional until both motor ratings, -driver RMS-current settings, acceleration/load profile, ESP32 board, and future logic -loads are known. Include startup and stall margin without exceeding component ratings. +- Separate motor/power wiring from STEP/DIR, switch inputs, and RF wiring where + practical. + +USB can energize an ESP32 while the external rail is present. Review the selected +board and buck topology for USB/external-power backfeeding before connecting both. +The Version 1 design assumes a protected 5.0 V input path and does not depend on the +buck converter to power the motors. diff --git a/docs/hardware/tmc2209-commissioning.md b/docs/hardware/tmc2209-commissioning.md index 1376d04..322c307 100644 --- a/docs/hardware/tmc2209-commissioning.md +++ b/docs/hardware/tmc2209-commissioning.md @@ -1,58 +1,61 @@ # TMC2209 and NEMA 17 commissioning > [!CAUTION] -> This is a provisional development configuration, not verified final wiring. -> Never connect or disconnect a stepper motor while its driver is powered. Provide a -> physical motor-power disconnect; software emergency stop is not a substitute. - -## Implemented architecture - -One ESP32 services two independent TMC2209 drivers. Each uses a separate ESP32 -hardware UART plus STEP, DIR, and active-low enable. Separate UART channels avoid -shared-bus/address ambiguity during first bring-up; both provisional addresses are -zero because they are on different channels. Confirm each carrier's PDN_UART and -address-strap implementation against its schematic before wiring. - -Startup sets STEP low and both enable pins inactive, validates pins/configuration, -starts the two UARTs, probes each driver, reads diagnostics, writes conservative -current/microstep/mode settings, reads switches/e-stop, and leaves both axes disabled -and untrusted. A missing driver is a structured fault and cannot be enabled. - -The internal driver follows the manufacturer datagram/CRC and register definitions; -successful writes are checked through IFCNT. STEP timing is a conservative 2 µs high, -2 µs minimum low, with 2 µs after DIR changes. These values exceed the TMC2209's -published STEP/DIR minimum timing. See the +> This document records the Version 1 hardware baseline and the pending validation +> steps. It does not claim physical testing. Never connect or disconnect a stepper +> motor while its driver is powered. Provide a physical motor-power disconnect; a +> software emergency stop is not a substitute. + +## Implemented hardware baseline + +Version 1 uses an ESP32 development board with an ESP-WROOM-32 module, USB-C, 3.3 V +logic, and USB serial for host communication. The motion system uses two +BIGTREETECH TMC2209 V1.3 drivers. The motors are YEJMKJ/LYLANMO NEMA 17 bipolar +4-wire units with 1.8° full-step geometry and a 1.0 A rated phase current. The exact +ESP32 board revision, TMC2209 carrier revision, UART wiring, R10 setting, sense +resistor, and pinout remain pending validation. + +Startup sets STEP low, keeps the enable pins inactive, validates pins/configuration, +starts the UARTs, probes each driver, reads diagnostics, writes current/microstep +settings, reads switches and the e-stop input, and leaves both axes disabled and +untrusted. A missing driver is a structured fault and cannot be enabled. + +The firmware follows the TMC2209 datagram/CRC/register definition and verifies +register writes with IFCNT. STEP timing remains conservative and must be measured on +hardware before final claims are made. See the [Analog Devices TMC2209 datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/TMC2209_datasheet_rev1.09.pdf). -No large motion library was introduced. The small local implementation keeps pulse -timing, dual-axis scheduling, stop behavior, UART diagnostics, and native simulation -under direct test. +## Version 1 hardware profile -## Provisional GPIO and signal table +The current profile is intended for the selected hardware family and uses the following +baseline values: -These assignments target PlatformIO's generic classic `esp32dev`. They avoid the -classic ESP32 bootstrapping and input-only pins, but the exact user's board and carrier -pin labels are unknown. Change the unified configuration before connecting a board -whose schematic differs. +- Controller: ESP32 development board, ESP-WROOM-32, USB serial, 3.3 V logic +- Drivers: BIGTREETECH TMC2209 V1.3, UART controlled, STEP/DIR interface, + diagnostic reporting enabled, current configured in RMS milliamps +- Motors: NEMA 17 bipolar, 4-wire, 1.8° full-step, 200 full steps/revolution, + 1.0 A rated phase current, 3.5 Ω phase resistance, approximately 0.13 N·m holding + torque + +The GPIO assignments below are a working baseline for the current firmware but remain +provisional until the exact board revision and carrier label map are confirmed. | ESP32/TMC signal | Azimuth | Elevation | Level | Purpose | Status | | --- | ---: | ---: | --- | --- | --- | -| STEP → TMC2209 STEP | GPIO25 | GPIO18 | 3.3 V logic | Step edge | Provisional | -| DIR → TMC2209 DIR | GPIO26 | GPIO19 | 3.3 V logic | Direction | Provisional | -| ENABLE → TMC2209 EN/ENN | GPIO27 | GPIO23 | 3.3 V logic, active low | Output enable | Provisional; confirm carrier polarity | -| UART TX → PDN_UART | GPIO22 / UART1 | GPIO17 / UART2 | 3.3 V logic | Configuration/write | Provisional; carrier interface required | -| UART RX ← PDN_UART | GPIO21 / UART1 | GPIO16 / UART2 | 3.3 V logic | Diagnostics/read | Provisional; confirm one-wire circuit | -| Driver address straps | address 0 | address 0 | Carrier-defined | UART slave address | Separate UARTs; confirm MS1/MS2 straps | -| Home switch | GPIO32 | GPIO33 | 3.3 V input/pull-up | Homing and unexpected-limit detection | Provisional, normally closed | -| Emergency-stop sense | GPIO13 | shared | 3.3 V input/pull-up | Active-low latched input | Optional/provisional | +| STEP → TMC2209 STEP | GPIO25 | GPIO18 | 3.3 V logic | Step edge | Baseline, verify against board | +| DIR → TMC2209 DIR | GPIO26 | GPIO19 | 3.3 V logic | Direction | Baseline, verify against board | +| ENABLE → TMC2209 EN/ENN | GPIO27 | GPIO23 | 3.3 V logic, active low | Output enable | Confirm carrier polarity | +| UART TX → PDN_UART | GPIO22 / UART1 | GPIO17 / UART2 | 3.3 V logic | Configuration/write | Confirm carrier interface | +| UART RX ← PDN_UART | GPIO21 / UART1 | GPIO16 / UART2 | 3.3 V logic | Diagnostics/read | Confirm one-wire circuit | +| Home switch | GPIO32 | GPIO33 | 3.3 V input/pull-up | Homing and limit detection | Configurable | +| Emergency-stop sense | GPIO13 | shared | 3.3 V input/pull-up | Active-low input | Pending validation | | Logic ground → GND | common | common | 0 V reference | STEP/DIR/UART reference | Required | -| Motor-power ground → GND | common | common | 12 V return | VM return tied to common ground | Required; manage high-current return path | +| Motor-power ground → GND | common | common | 12 V return | VM return tied to common ground | Required | | VM | 12 V supply | 12 V supply | 12 V power | Motor driver supply only | Required; never connect to ESP32 | The actual carrier may expose `PDN_UART`, `UART`, `CFG`, `MS1/MS2`, `SPREAD`, or -different labels and may require a resistor for bidirectional single-wire UART. -Resolve those details from the exact carrier schematic. Do not infer them from this -generic table. +other labels and may require a resistor for bidirectional UART. Resolve those details +from the exact carrier schematic before wiring the final build. ## Power, motor wiring, and current @@ -61,88 +64,70 @@ through the input method documented for the exact board. All logic references sh ground, but keep high motor-current returns short and away from switch/RF returns. Place appropriately rated bulk capacitance close to each driver's VM/GND and local logic decoupling near the electronics. Review the board's USB/external-power circuit -before connecting USB and the buck at the same time; do not assume a 5 V header is -backfeed-safe. +before connecting USB and the buck at the same time. -Identify the motor's two coil pairs with its datasheet or an unpowered continuity -test, then connect each pair to one driver phase. A 12 V supply does not mean the -driver continuously applies 12 V across a winding: the TMC2209 chops the supply to -regulate winding current. Incorrect RMS current can still overheat or under-drive the -motor/driver. +Identify the motor's two coil pairs and connect each pair to one driver phase. A 12 V +supply does not mean the driver continuously applies 12 V across a winding; the +TMC2209 chops the supply to regulate winding current. Incorrect RMS current can still +overheat or under-drive the motor/driver. -The checked-in 400 mA RMS and 800 mA ceiling are placeholders, not safe-current -claims. Before power-up, derive the setting from: +The initial commissioning current is 650 mA RMS. The motor ceiling is 1000 mA RMS. +Suggested commissioning progression is: -- the exact motor's rated phase current; -- the exact carrier's sense-resistor value and circuit; -- the driver's current formula and UART configuration; -- available heatsinking/airflow and mechanical load; and -- measured motor and carrier temperature during progressively longer tests. +500–650 mA → 650–800 mA → approximately 900 mA only if necessary → never exceed +rated phase current -Reduced hold current is configured as 30% while stationary. Overtemperature warning -is reported; shutdown, shorts, undervoltage/reset, or communication loss disable the -affected axis. Open-load flags are diagnostic hints and can be unreliable at -standstill or low current; do not use them as the only continuity test. +Final current must be verified using actual torque requirements, motor temperature, +driver temperature, and long-duration testing. Reduced hold current is configured as +30% while stationary. ## Homing and position trust -Each axis accepts normally closed or normally open polarity. The default is normally -closed with pull-up. Homing validates an inactive switch, approaches quickly, stops -on a debounced activation, backs off, confirms release, approaches slowly, stops, -applies the configured offset, and only then marks position trusted. +Each axis accepts normally-closed or normally-open switch logic. The default is +normally closed with pull-up, but the polarity remains configurable. Homing validates +an inactive switch, approaches quickly, stops on a debounced activation, backs off, +confirms release, approaches slowly, stops, applies the configured offset, and only +then marks position trusted. -Failures are distinct: switch active at start, never activated, failed to release, -overall timeout, unexpected activation during normal motion, or e-stop interruption. -Failure stops and disables the axis, leaves position untrusted, and requires operator -inspection and fault reset before retry. Open-loop microsteps are commanded position, -not verified physical accuracy; backlash, compliance, stalls, and manual movement are -not measured. +The firmware distinguishes commanded position, estimated position, trusted position, +and physical position. Commanded position is the open-loop step count. Estimated +position is the internal model. Trusted position is the state after a successful home. +Physical position is the real-world location to be measured externally. Trust is lost on +reset, fault, emergency stop, driver disable, timeout, motion interruption, or any +suspicion of missed steps. ## First power-up checklist -1. Disconnect both motors and remove 12 V motor power. -2. Confirm the exact ESP32 and both carrier part numbers, schematics, pin labels, - sense resistors, enable polarity, UART circuit, address straps, and voltage levels. -3. Inspect and edit the provisional GPIO/current/configuration record. -4. Verify the buck output with a multimeter before connecting the ESP32. -5. Verify the intended ESP32 supply pin voltage and USB backfeed protection. -6. Verify common ground and continuity from ESP32 to both driver logic grounds. -7. Verify no short between VM and ground and check all supply polarities. -8. Fit appropriately rated VM bulk capacitors and local logic decoupling. -9. Power only the ESP32/driver logic; confirm startup reports drivers disabled. -10. Run `CMD 1 MOTOR IDENTIFY` and confirm both drivers present over UART. -11. Run diagnostics for each axis; resolve communication, reset, undervoltage, short, - or thermal faults before continuing. -12. Remove every power source, identify one motor's coil pairs, and connect only the - azimuth motor. -13. Set an RMS current justified by that motor and carrier; do not assume 400 mA is - correct. -14. Raise/support the mechanism so this axis can move freely, clear people/cables, - and make the physical 12 V disconnect reachable. -15. Apply 12 V and enable only azimuth. -16. Issue exactly `CMD 10 MOTOR STEP AZ 20`; be ready to cut motor power. -17. Verify movement direction and that the reported position is explicitly untrusted. -18. Stop, disable, remove power, and inspect connector, motor, carrier, capacitor, - buck, and wiring temperature/condition. -19. Manually actuate the unpowered home switch and verify electrical polarity/status. -20. Re-power and perform low-speed azimuth homing while ready to disconnect power. -21. Repeat steps 12–20 for elevation only. -22. Route and strain-relieve all cables through the full envelope with power removed. -23. Home both axes independently, then issue a small coordinated target within limits. -24. Confirm completion occurs after the last axis stops and only then check settling. -25. Expand distance, speed, acceleration, current, and test duration incrementally, - recording temperatures, faults, repeatability, load, and exact revisions. +1. Verify the buck converter output with a multimeter and set it to exactly 5.0 V before connecting the ESP32. +2. Verify polarity and common ground between the 12 V rail, the TMC2209 logic reference, and the buck output. +3. Verify the inline fuse is installed and the master disconnect is accessible. +4. Verify the drivers are disabled before power is applied. +5. Verify UART communication and confirm the driver responds over the configured channel. +6. Connect one motor only. +7. Configure 650 mA RMS current. +8. Test a 20-step movement and verify the direction. +9. Verify temperatures on the motor, driver, and buck converter. +10. Test the home switch. +11. Home the first axis. +12. Repeat for the second axis. +13. Test coordinated motion. + +Warnings: + +- A car battery can supply very large current and can short dangerously. +- USB and external power can backfeed into the logic rail if the board topology is not reviewed. +- Hot-plugging motors can cause transients and driver faults. +- Incorrect current settings can overheat the motor and driver. ## Fault interpretation | Fault/status | Required response | | --- | --- | -| Driver communication / absent | Keep axis disabled; verify UART topology, channel, address straps, common ground, and logic level. | -| Reset detected / undervoltage | Stop; inspect VM and logic supply sequencing, wiring, bulk capacitance, and transients. Rehome after correction. | +| Driver communication / absent | Keep axis disabled; verify UART topology, channel, common ground, and logic level. | +| Reset detected / undervoltage | Stop; inspect VM and logic supply sequencing, wiring, bulk capacitance, and transients. | | Overtemperature warning | Stop the test soon; reduce current/load or improve cooling and measure temperature. | | Overtemperature shutdown | Driver is disabled; remove motor power, allow cooling, diagnose before reset, and rehome. | | Short to ground/supply | Remove power immediately and inspect motor cable, connector, phase pairing, and carrier. | -| Open load | With power removed, inspect continuity/connector; remember the flag may be ambiguous at standstill/low current. | | Homing stuck / never / release / timeout | Remove motor power if travel is unsafe; inspect switch polarity, mechanics, wiring, direction, debounce, speed, and travel. | | Unexpected home switch | Stop and rehome only after checking limits, direction, switch noise, and mechanical position. | | Host heartbeat timeout | Drivers have been stopped/disabled; inspect USB/host failure and rehome because position is untrusted. | @@ -150,16 +135,11 @@ not measured. ## Known limitations and required confirmations -- Exact ESP32 board and TMC2209 carrier revisions/pinouts are unconfirmed. +- Exact ESP32 board and TMC2209 carrier revisions/pinouts are pending confirmation. - Carrier UART one-wire circuitry, address straps, sense resistance, enable polarity, - and onboard potentiometer interaction are unconfirmed. -- Motor rated current, phase wiring, torque, inductance, thermal limits, and load are - unconfirmed. -- No encoder, stall verification, closed-loop positioning, backlash compensation, or - continuous-rotation/slip-ring support exists. -- The 2 µs software STEP timing compiles for ESP32 but has not been measured on a - scope or logic analyzer under serial/dual-axis load. -- GPIO electrical behavior, e-stop circuit, switch noise, USB backfeed, power - transient behavior, thermal performance, homing repeatability, cable clearance, and - coordinated physical motion all remain unvalidated. -- ESP32 compilation, native simulation, and unit tests are not physical validation. + and onboard potentiometer interaction are pending confirmation. +- Motor rated current, thermal limits, and load behavior remain to be validated. +- No encoder, stall verification, backlash compensation, or continuous-rotation support + exists in Version 1. +- USB backfeed behavior, power transients, thermal performance, homing repeatability, + cable clearance, and coordinated physical motion remain unvalidated. diff --git a/docs/hardware/wiring.md b/docs/hardware/wiring.md index 78ccf93..d3a722b 100644 --- a/docs/hardware/wiring.md +++ b/docs/hardware/wiring.md @@ -1,11 +1,13 @@ # Wiring -Verified final pin assignments do not exist. A provisional ESP32 development map is -listed in the [TMC2209 commissioning guide](tmc2209-commissioning.md). The Version 1 -wiring record must identify the -exact ESP32 board and every connector pin, signal reference, voltage domain, wire -rating, shielding, grounding point, switch behavior, and emergency isolation before a -physical build is called supported. +The Version 1 wiring baseline uses an ESP32 development board, two BIGTREETECH TMC2209 +V1.3 carriers, 12 V motor power, a regulated 5.0 V logic rail, and configurable home +switches. Final pin assignments remain to be confirmed against the exact ESP32 board +revision and carrier labels. The current mapping is documented in the +[TMC2209 commissioning guide](tmc2209-commissioning.md). The Version 1 wiring record +must identify the exact board and every connector pin, signal reference, voltage +domain, wire rating, shielding, grounding point, switch behavior, and emergency +isolation before a physical build is called supported. ## Required electrical boundaries @@ -26,6 +28,14 @@ Provide strain relief and verify clearance through the entire configured azimuth elevation envelope. Consistent coax routing is part of the RF experiment, not an afterthought. +Recommended wire usage: + +- 18 AWG: battery, main power, and power distribution +- 22 AWG: driver power, motor extensions, and buck output +- 26 AWG: STEP, DIR, UART, ENABLE, DIAG, and switch signals + +Separate signal wiring from motor wiring and keep high-current loops short. + Disconnect external and USB power before changing wiring. Because USB and a buck converter may energize the board simultaneously, explicitly document the chosen backfeed prevention or supported dual-power behavior. Verify continuity, polarity, diff --git a/docs/index.md b/docs/index.md index 47f1fbb..febbe07 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,5 +16,6 @@ interfaces unless a page explicitly marks behavior as implemented and tested. - [Roadmap](development/roadmap.md) - [Glossary](glossary.md) -Hardware choices in this documentation are provisional. Experiment documents define -the evidence required before measurement or accuracy claims can be made. +Hardware choices in this documentation are now recorded as a Version 1 engineering +baseline with explicit pending validation notes. Experiment documents define the +evidence required before measurement or accuracy claims can be made. diff --git a/firmware/config/provisional-esp32dev-v1.json b/firmware/config/provisional-esp32dev-v1.json index 62957f7..64e98f0 100644 --- a/firmware/config/provisional-esp32dev-v1.json +++ b/firmware/config/provisional-esp32dev-v1.json @@ -1,19 +1,63 @@ { - "status": "provisional-development-only", + "status": "version-1-hardware-baseline-pending-validation", "controller": { - "board": "esp32dev", + "board": "ESP32 development board", + "module": "ESP-WROOM-32", + "logic_voltage_v": 3.3, + "transport": "USB serial", + "transport_note": "Wi-Fi and Bluetooth are available but intentionally unused in Version 1", + "gpio_mapping_status": "provisional-until-board-revision-confirmed", "protocol_version": 1, "usb_serial_baud": 115200, "emergency_stop_pin": 13, "emergency_stop_active_low": true }, "power": { - "motor_supply_voltage": 12.0, - "esp32_supply": "regulated board-supported input from buck converter" + "motor_supply_voltage_v": 12.0, + "motor_supply_source": "standalone 12 V automotive battery, not connected to a running vehicle", + "motor_supply_fuse_a": 3.0, + "motor_supply_distribution": "12 V battery -> inline fuse -> master disconnect -> TMC2209 VM rails and LM2596 buck converter", + "esp32_supply_voltage_v": 5.0, + "esp32_supply_source": "LM2596 buck converter for logic electronics only", + "motors_powered_through_buck": false, + "buck_converters": [ + "SELOKY LM2596", + "LYLANMO LM2596S" + ] + }, + "drivers": { + "driver_family": "BIGTREETECH TMC2209 V1.3", + "driver_interface": "UART controlled STEP/DIR", + "driver_diagnostics_enabled": true, + "enable_active_low_configurable": true, + "current_configured_in_rms_ma": true, + "ifcnt_write_verification_required": true, + "verify": [ + "R10 configuration", + "UART wiring", + "sense resistor value", + "board revision", + "carrier pinout" + ] + }, + "motors": { + "manufacturer": "YEJMKJ / LYLANMO", + "type": "NEMA 17 bipolar 4-wire 1.8 degree full-step", + "full_steps_per_revolution": 200, + "rated_phase_current_a": 1.0, + "phase_resistance_ohm": 3.5, + "holding_torque_nm": 0.13, + "dimensions_mm": { + "width": 42, + "height": 42, + "depth": 21 + }, + "notes": "Selected hardware values from the Version 1 build, not generic placeholders" }, "axes": { "azimuth": { "driver": "tmc2209", + "driver_profile": "BIGTREETECH TMC2209 V1.3", "uart_channel": 1, "uart_address": 0, "uart_tx_pin": 22, @@ -32,8 +76,8 @@ "minimum_angle_deg": 0.0, "maximum_angle_deg": 360.0, "home_offset_deg": 0.0, - "rms_current_ma": 400, - "maximum_rms_current_ma": 800, + "commissioning_current_ma": 650, + "maximum_rms_current_ma": 1000, "hold_current_percent": 30, "max_speed_deg_s": 10.0, "acceleration_deg_s2": 20.0, @@ -46,6 +90,7 @@ }, "elevation": { "driver": "tmc2209", + "driver_profile": "BIGTREETECH TMC2209 V1.3", "uart_channel": 2, "uart_address": 0, "uart_tx_pin": 17, @@ -64,8 +109,8 @@ "minimum_angle_deg": -90.0, "maximum_angle_deg": 90.0, "home_offset_deg": 0.0, - "rms_current_ma": 400, - "maximum_rms_current_ma": 800, + "commissioning_current_ma": 650, + "maximum_rms_current_ma": 1000, "hold_current_percent": 30, "max_speed_deg_s": 8.0, "acceleration_deg_s2": 15.0, diff --git a/firmware/controller/README.md b/firmware/controller/README.md index 529d600..aa513f0 100644 --- a/firmware/controller/README.md +++ b/firmware/controller/README.md @@ -1,8 +1,9 @@ # Motion controller foundation The controller currently implements an in-memory `MotionController` for protocol and -host integration work. It compiles as a native command-line program and as a -provisional ESP32 Arduino target. Neither build drives pins or implements a TMC2209. +host integration work. It compiles as a native command-line program and as an ESP32 +Arduino target for the Version 1 hardware baseline. The physical wiring, carrier +revision, and bring-up details remain pending validation. ```bash pio run -e native @@ -12,5 +13,6 @@ pio test -e native The native process reads one command per line from standard input and writes one response per line. See [the protocol specification](../../docs/firmware/protocol.md). The simulator enforces configuration-derived limits and position-confidence rules. -Board selection, electrical limits, motor drivers, pins, physical homing, and physical -emergency-stop behavior remain provisional. +Board selection, electrical limits, motor-driver carrier details, pins, physical +homing, and physical emergency-stop behavior remain pending validation against the +actual hardware. diff --git a/firmware/controller/platformio.ini b/firmware/controller/platformio.ini index 38521dc..31fadc6 100644 --- a/firmware/controller/platformio.ini +++ b/firmware/controller/platformio.ini @@ -20,4 +20,5 @@ board = esp32dev framework = arduino monitor_speed = 115200 -; esp32dev is a provisional compile target, not a supported board or pinout. +; The esp32dev target is the Version 1 firmware baseline; the exact board revision +; and GPIO mapping remain pending validation against the installed hardware. diff --git a/firmware/controller/src/hardware_config.cpp b/firmware/controller/src/hardware_config.cpp index c5705ec..a5e0470 100644 --- a/firmware/controller/src/hardware_config.cpp +++ b/firmware/controller/src/hardware_config.cpp @@ -18,7 +18,7 @@ AxisConfig azimuth_motion() { AxisConfig config; config.motor_full_steps_per_revolution = 200; config.microsteps = 16; - config.motor_rms_current_ma = 400; + config.motor_rms_current_ma = 650; config.hold_current_percent = 30; config.gear_ratio = 1.0; config.direction_inverted = false; @@ -54,7 +54,7 @@ AxisConfig elevation_motion() { PhysicalControllerConfig provisional_esp32_dev_config() { PhysicalControllerConfig config; - config.board_name = "esp32dev-provisional"; + config.board_name = "esp32dev-v1-baseline"; config.protocol_version = 1; config.azimuth.axis.name = "azimuth"; @@ -62,6 +62,8 @@ PhysicalControllerConfig provisional_esp32_dev_config() { config.azimuth.axis.home_switch_pin = 32; config.azimuth.driver.uart_channel = 1; config.azimuth.driver.address = 0; + // GPIO assignments remain provisional until the exact ESP32 development-board + // revision and carrier pinout are confirmed. config.azimuth.driver.uart_tx_pin = 22; config.azimuth.driver.uart_rx_pin = 21; config.azimuth.driver.step_pin = 25; @@ -69,7 +71,7 @@ PhysicalControllerConfig provisional_esp32_dev_config() { config.azimuth.driver.enable_pin = 27; config.azimuth.driver.direction_inverted = config.azimuth.axis.motion.direction_inverted; - config.azimuth.driver.maximum_rms_current_ma = 800; + config.azimuth.driver.maximum_rms_current_ma = 1000; config.elevation.axis.name = "elevation"; config.elevation.axis.motion = elevation_motion(); @@ -83,7 +85,7 @@ PhysicalControllerConfig provisional_esp32_dev_config() { config.elevation.driver.enable_pin = 23; config.elevation.driver.direction_inverted = config.elevation.axis.motion.direction_inverted; - config.elevation.driver.maximum_rms_current_ma = 800; + config.elevation.driver.maximum_rms_current_ma = 1000; config.emergency_stop_pin = 13; config.emergency_stop_active_low = true; From 655e495a4eadffce09d59ecc07429dacb79f2669 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Fri, 31 Jul 2026 00:15:00 -0400 Subject: [PATCH 10/13] docs(firmware): record ESP-IDF migration inventory --- docs/firmware/esp-idf-migration-inventory.md | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/firmware/esp-idf-migration-inventory.md diff --git a/docs/firmware/esp-idf-migration-inventory.md b/docs/firmware/esp-idf-migration-inventory.md new file mode 100644 index 0000000..8c98678 --- /dev/null +++ b/docs/firmware/esp-idf-migration-inventory.md @@ -0,0 +1,78 @@ +# ESP-IDF migration inventory + +**Status:** implementation inventory recorded before the native ESP-IDF +migration. The physical hardware remains unvalidated. + +## Baseline + +- Source branch: `codex/tmc2209-motion-control` at `a954db8`. +- Migration branch: `codex/esp-idf-migration`. +- Existing public protocol: line-oriented ASCII protocol version 1 at 115200 + baud, including `CMD` correlation, `EVENT` messages, heartbeat behavior, + and simulator mode. +- Existing physical target: PlatformIO `esp32dev` using Arduino-ESP32. + +## Arduino dependency inventory and replacement map + +| Existing dependency | Location | Native ESP-IDF replacement | +| --- | --- | --- | +| `Arduino.h` and `#ifdef ARDUINO` | `src/main.cpp`, `include/esp32_platform.hpp` | Remove; use a physical `app_main()` and a separate host simulator executable. | +| `setup()` / `loop()` | `src/main.cpp` | `extern "C" void app_main(void)` plus bounded FreeRTOS protocol, motion, safety, and diagnostics tasks. | +| `pinMode`, `digitalWrite`, `digitalRead` | `src/esp32_platform.cpp` | Centralized `gpio_config`, `gpio_set_level`, and `gpio_get_level` in `IdfHardwarePlatform`. | +| `Serial` for the host protocol | `src/main.cpp` | UART0 ESP-IDF driver, owned only by the protocol task. | +| `HardwareSerial`, `Serial1`, `Serial2`, `SERIAL_8N1` | `src/esp32_platform.cpp` | `uart_param_config`, `uart_set_pin`, `uart_driver_install`, `uart_write_bytes`, and `uart_read_bytes`. | +| `millis()` heartbeat clock | `src/main.cpp` | `esp_timer_get_time()` in the safety task. | +| `yield()` UART polling | `src/esp32_platform.cpp` | bounded blocking UART reads in a FreeRTOS task. | +| loop-serviced STEP edges | `src/axis_controller.cpp` | two one-shot GPTimer edge schedulers; ISR work is restricted to GPIO edge emission and notification. | +| PlatformIO Arduino target and CI | `platformio.ini`, `.github/workflows/firmware.yml` | ESP-IDF CMake project, `sdkconfig.defaults`, Kconfig, CTest portable tests, and pinned ESP-IDF CI. | + +There are no third-party Arduino libraries, Arduino interrupt APIs, Arduino +watchdog APIs, EEPROM/Preferences use, or Arduino timer libraries. The +TMC2209 driver is already custom C++ register/CRC code and stays +driver-neutral behind `StepperDriver`. + +## Migration-sensitive behavior + +- The current core is single-threaded. Native tasks must use queues and a + single motion-state owner; they must not concurrently mutate + `PhysicalMotionController`, `AxisController`, or `Tmc2209Driver`. +- The current step service combines pulse edges with homing debounce and + occasional blocking TMC reads. Diagnostics and UART reads cannot execute + in a timer callback. +- UART0 is the USB host protocol and UART1/UART2 are assigned to the two + TMC2209 devices. ESP-IDF logs must be suppressed or redirected so they + never corrupt the host protocol stream. +- TMC PDN_UART uses a single-wire electrical model. The IDF adapter must + tolerate write echo, bound reads, validate CRC, and retain IFCNT write + verification. +- GPIO home and emergency inputs must perform only notification in ISR + context. Debounce and latching remain task work. + +## Configuration findings + +The profile at `firmware/config/provisional-esp32dev-v1.json` and compiled +defaults in `src/hardware_config.cpp` duplicate the same provisional hardware +configuration. The native build will generate one validated configuration +header from the JSON profile and test it for consistency. The request says +the elevation hold current is 40%, while the source profile and current +firmware say 30%. The migration preserves the existing documented 30% +baseline until that physical-hardware decision is separately validated. + +The current public `GEAR_RATIO` is a decimal value and the profile is `1.0`. +No unvalidated gear-ratio change is part of this migration; portable +conversion code remains host-testable. + +## Timing decision + +The documented maximum pulse rates are below 90 steps/second per axis. Two +independent one-shot GPTimer schedulers are the simplest native mechanism for +the required two-microsecond pulse and direction setup timing while retaining +immediate shutdown. This is an architecture decision, not a claim of +measured timing accuracy; logic-analyzer validation remains required. + +## Validation constraints at inventory time + +No usable `idf.py` or PlatformIO executable is installed locally. The native +project will pin ESP-IDF v5.5.4 in documentation and CI. Portable CMake/CTest +and Python checks can run locally; ESP-IDF compile, target component tests, and +hardware tests require a provisioned ESP-IDF environment and target hardware. From 8b3b19b0e9c599011360de28a459ef6e71bc0446 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Fri, 31 Jul 2026 01:23:36 -0400 Subject: [PATCH 11/13] build(firmware): establish native ESP-IDF target --- .gitignore | 9 +- firmware/config/provisional-esp32dev-v1.json | 20 +- firmware/controller/CMakeLists.txt | 49 ++ .../components/platform_idf/CMakeLists.txt | 5 + .../include/idf_hardware_platform.hpp} | 15 +- .../include/idf_step_pulse_scheduler.hpp | 61 ++ .../src/idf_hardware_platform.cpp | 143 +++++ .../src/idf_step_pulse_scheduler.cpp | 204 ++++++ .../components/radiance3d_core/CMakeLists.txt | 4 + .../radiance3d_physical/CMakeLists.txt | 9 + firmware/controller/dependencies.lock | 10 + .../controller/include/axis_controller.hpp | 10 +- .../controller/include/hardware_config.hpp | 1 + .../controller/include/hardware_platform.hpp | 14 +- .../controller/include/motion_controller.hpp | 15 +- .../include/physical_motion_controller.hpp | 4 + firmware/controller/include/protocol.hpp | 6 +- .../include/step_pulse_scheduler.hpp | 24 + .../controller/include/tmc2209_driver.hpp | 3 + firmware/controller/main/CMakeLists.txt | 6 + firmware/controller/main/Kconfig.projbuild | 40 ++ firmware/controller/main/app_main.cpp | 59 ++ .../controller/main/controller_runtime.cpp | 581 ++++++++++++++++++ .../controller/main/controller_runtime.hpp | 10 + firmware/controller/main/idf_component.yml | 2 + firmware/controller/partitions.csv | 4 + firmware/controller/platformio.ini | 24 - firmware/controller/sdkconfig.defaults | 30 + firmware/controller/src/axis_controller.cpp | 109 +++- firmware/controller/src/esp32_platform.cpp | 103 ---- firmware/controller/src/hardware_config.cpp | 140 +++-- firmware/controller/src/main.cpp | 106 ---- firmware/controller/src/motion_controller.cpp | 48 +- .../src/physical_motion_controller.cpp | 88 ++- firmware/controller/src/protocol.cpp | 25 +- firmware/controller/src/tmc2209_driver.cpp | 91 +-- scripts/generate_hardware_profile_header.py | 359 +++++++++++ 37 files changed, 2044 insertions(+), 387 deletions(-) create mode 100644 firmware/controller/CMakeLists.txt create mode 100644 firmware/controller/components/platform_idf/CMakeLists.txt rename firmware/controller/{include/esp32_platform.hpp => components/platform_idf/include/idf_hardware_platform.hpp} (63%) create mode 100644 firmware/controller/components/platform_idf/include/idf_step_pulse_scheduler.hpp create mode 100644 firmware/controller/components/platform_idf/src/idf_hardware_platform.cpp create mode 100644 firmware/controller/components/platform_idf/src/idf_step_pulse_scheduler.cpp create mode 100644 firmware/controller/components/radiance3d_core/CMakeLists.txt create mode 100644 firmware/controller/components/radiance3d_physical/CMakeLists.txt create mode 100644 firmware/controller/dependencies.lock create mode 100644 firmware/controller/include/step_pulse_scheduler.hpp create mode 100644 firmware/controller/main/CMakeLists.txt create mode 100644 firmware/controller/main/Kconfig.projbuild create mode 100644 firmware/controller/main/app_main.cpp create mode 100644 firmware/controller/main/controller_runtime.cpp create mode 100644 firmware/controller/main/controller_runtime.hpp create mode 100644 firmware/controller/main/idf_component.yml create mode 100644 firmware/controller/partitions.csv delete mode 100644 firmware/controller/platformio.ini create mode 100644 firmware/controller/sdkconfig.defaults delete mode 100644 firmware/controller/src/esp32_platform.cpp delete mode 100644 firmware/controller/src/main.cpp create mode 100644 scripts/generate_hardware_profile_header.py diff --git a/.gitignore b/.gitignore index 57c532d..3a326ef 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,15 @@ venv/ dist/ build/ -# PlatformIO +# Native ESP-IDF and host CMake build artifacts .pio/ +firmware/controller/build/ +firmware/controller/build-host/ +firmware/controller/sdkconfig +firmware/controller/sdkconfig.old +CMakeFiles/ +CMakeCache.txt +Testing/ # Temporary and generated data data/raw/ diff --git a/firmware/config/provisional-esp32dev-v1.json b/firmware/config/provisional-esp32dev-v1.json index 64e98f0..492b888 100644 --- a/firmware/config/provisional-esp32dev-v1.json +++ b/firmware/config/provisional-esp32dev-v1.json @@ -10,7 +10,10 @@ "protocol_version": 1, "usb_serial_baud": 115200, "emergency_stop_pin": 13, - "emergency_stop_active_low": true + "emergency_stop_active_low": true, + "emergency_stop_pullup": true, + "emergency_stop_pulldown": false, + "emergency_stop_debounce_ms": 10 }, "power": { "motor_supply_voltage_v": 12.0, @@ -30,6 +33,12 @@ "driver_interface": "UART controlled STEP/DIR", "driver_diagnostics_enabled": true, "enable_active_low_configurable": true, + "enable_active_low": true, + "uart_baud": 115200, + "uart_timeout_ms": 20, + "sense_resistor_milliohms": 110, + "single_wire_pdn_uart": true, + "write_echo_expected": true, "current_configured_in_rms_ma": true, "ifcnt_write_verification_required": true, "verify": [ @@ -38,7 +47,8 @@ "sense resistor value", "board revision", "carrier pinout" - ] + ], + "electrical_profile_status": "provisional; confirm R10 and PDN_UART topology before energizing motors" }, "motors": { "manufacturer": "YEJMKJ / LYLANMO", @@ -67,6 +77,8 @@ "enable_pin": 27, "home_switch_pin": 32, "home_switch_normally_closed": true, + "home_switch_pullup": true, + "home_switch_pulldown": false, "homing_direction_negative": true, "home_switch_debounce_ms": 10, "motor_full_steps_per_revolution": 200, @@ -100,6 +112,8 @@ "enable_pin": 23, "home_switch_pin": 33, "home_switch_normally_closed": true, + "home_switch_pullup": true, + "home_switch_pulldown": false, "homing_direction_negative": true, "home_switch_debounce_ms": 10, "motor_full_steps_per_revolution": 200, @@ -111,7 +125,7 @@ "home_offset_deg": 0.0, "commissioning_current_ma": 650, "maximum_rms_current_ma": 1000, - "hold_current_percent": 30, + "hold_current_percent": 40, "max_speed_deg_s": 8.0, "acceleration_deg_s2": 15.0, "home_speed_deg_s": 4.0, diff --git a/firmware/controller/CMakeLists.txt b/firmware/controller/CMakeLists.txt new file mode 100644 index 0000000..866688f --- /dev/null +++ b/firmware/controller/CMakeLists.txt @@ -0,0 +1,49 @@ +cmake_minimum_required(VERSION 3.16) + +# ESP-IDF 5.5 uses generated response files during compiler detection. Keep +# CMake's normal toolchain-file variable when its cache entry is created so +# external ESP-IDF subprojects (including the bootloader) inherit it reliably. +if(POLICY CMP0126) + # project.cmake declares its own 3.16 baseline, so setting the policy in + # this scope alone is not enough. The default is inherited by ESP-IDF's + # toolchain and bootloader project scopes as they are created. + set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) + cmake_policy(SET CMP0126 NEW) +endif() + +# This is intentionally the source of truth for the physical ESP32 target. +# Host builds live in host/CMakeLists.txt and do not require ESP-IDF. +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +set(RADIANCE3D_PROFILE + "${CMAKE_CURRENT_LIST_DIR}/../config/provisional-esp32dev-v1.json") +set(RADIANCE3D_GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/generated") +file(MAKE_DIRECTORY "${RADIANCE3D_GENERATED_INCLUDE_DIR}") +set(RADIANCE3D_GENERATED_PROFILE_HEADER + "${RADIANCE3D_GENERATED_INCLUDE_DIR}/hardware_profile_generated.hpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${RADIANCE3D_PROFILE}" + "${CMAKE_CURRENT_LIST_DIR}/../../scripts/generate_hardware_profile_header.py") + +execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${CMAKE_CURRENT_LIST_DIR}/../../scripts/generate_hardware_profile_header.py" + --profile "${RADIANCE3D_PROFILE}" + --output "${RADIANCE3D_GENERATED_PROFILE_HEADER}" + RESULT_VARIABLE RADIANCE3D_PROFILE_RESULT + OUTPUT_VARIABLE RADIANCE3D_PROFILE_OUTPUT + ERROR_VARIABLE RADIANCE3D_PROFILE_ERROR +) +if(NOT RADIANCE3D_PROFILE_RESULT EQUAL 0) + message(FATAL_ERROR "Could not generate hardware profile header: ${RADIANCE3D_PROFILE_ERROR}") +endif() + +set(EXTRA_COMPONENT_DIRS "${CMAKE_CURRENT_LIST_DIR}/components") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +# The bootloader is configured as an external ESP-IDF subproject. Forward the +# policy default so it uses the same response-file handling on newer CMake. +if(POLICY CMP0126) + idf_build_set_property( + EXTRA_CMAKE_ARGS "-DCMAKE_POLICY_DEFAULT_CMP0126=NEW" APPEND) +endif() +project(radiance3d_controller) diff --git a/firmware/controller/components/platform_idf/CMakeLists.txt b/firmware/controller/components/platform_idf/CMakeLists.txt new file mode 100644 index 0000000..a34fb92 --- /dev/null +++ b/firmware/controller/components/platform_idf/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRCS "src/idf_hardware_platform.cpp" "src/idf_step_pulse_scheduler.cpp" + INCLUDE_DIRS "include" "../../include" + REQUIRES driver esp_timer freertos +) diff --git a/firmware/controller/include/esp32_platform.hpp b/firmware/controller/components/platform_idf/include/idf_hardware_platform.hpp similarity index 63% rename from firmware/controller/include/esp32_platform.hpp rename to firmware/controller/components/platform_idf/include/idf_hardware_platform.hpp index 52225de..8f8370b 100644 --- a/firmware/controller/include/esp32_platform.hpp +++ b/firmware/controller/components/platform_idf/include/idf_hardware_platform.hpp @@ -2,13 +2,14 @@ #include "hardware_platform.hpp" -#ifdef ARDUINO - -#include +#include namespace radiance3d { -class ArduinoEsp32Platform final : public HardwarePlatform { +// ESP-IDF implementation of the driver-neutral physical I/O boundary. It is +// deliberately limited to GPIO, TMC UARTs, and monotonic time; UART0 protocol +// ownership belongs to the protocol task, not this adapter. +class IdfHardwarePlatform final : public HardwarePlatform { public: bool configure_pin(int pin, PinMode mode) override; void write_pin(int pin, bool high) override; @@ -16,6 +17,7 @@ class ArduinoEsp32Platform final : public HardwarePlatform { std::uint64_t monotonic_micros() const override; bool begin_uart(std::uint8_t channel, int tx_pin, int rx_pin, std::uint32_t baud) override; + bool configure_uart_half_duplex(std::uint8_t channel, bool enabled) override; void flush_uart_input(std::uint8_t channel) override; bool write_uart(std::uint8_t channel, const std::uint8_t* data, std::size_t length) override; @@ -24,9 +26,8 @@ class ArduinoEsp32Platform final : public HardwarePlatform { std::uint32_t timeout_ms) override; private: - HardwareSerial* uart(std::uint8_t channel) const; + static int uart_port(std::uint8_t channel); + bool uart_installed_[3]{}; }; } // namespace radiance3d - -#endif diff --git a/firmware/controller/components/platform_idf/include/idf_step_pulse_scheduler.hpp b/firmware/controller/components/platform_idf/include/idf_step_pulse_scheduler.hpp new file mode 100644 index 0000000..9f0b29e --- /dev/null +++ b/firmware/controller/components/platform_idf/include/idf_step_pulse_scheduler.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "step_pulse_scheduler.hpp" + +#include "driver/gptimer.h" +#include "driver/gpio.h" +#include "esp_attr.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include + +namespace radiance3d { + +// GPTimer emits a single STEP high/low pulse. AxisController remains the sole +// owner of position and acceleration state and consumes completed pulses in the +// motion task. The ISR never performs UART, allocation, logging, or motion +// planning. +class IdfStepPulseScheduler final : public StepPulseScheduler { + public: + explicit IdfStepPulseScheduler(int step_pin); + ~IdfStepPulseScheduler() override; + + IdfStepPulseScheduler(const IdfStepPulseScheduler&) = delete; + IdfStepPulseScheduler& operator=(const IdfStepPulseScheduler&) = delete; + + void set_motion_task(TaskHandle_t task); + bool initialize() override; + bool schedule_pulse(std::uint32_t delay_before_rising_us) override; + void stop() override; + std::uint32_t consume_completed_pulses() override; + bool consume_scheduler_fault() override; + + // Called only by the emergency-input ISR. It pulls STEP low and disarms the + // scheduler; the safety/motion task performs driver disable and fault logic. + void IRAM_ATTR emergency_stop_from_isr(); + + private: + static bool IRAM_ATTR on_alarm(gptimer_handle_t timer, + const gptimer_alarm_event_data_t* event_data, + void* user_context); + bool IRAM_ATTR handle_alarm(const gptimer_alarm_event_data_t* event_data); + bool IRAM_ATTR set_alarm(std::uint64_t count); + void IRAM_ATTR disarm_alarm(); + void IRAM_ATTR mark_scheduler_fault(); + + int step_pin_; + gptimer_handle_t timer_{nullptr}; + TaskHandle_t motion_task_{nullptr}; + portMUX_TYPE lock_ = portMUX_INITIALIZER_UNLOCKED; + // The runtime owns this scheduler as static storage, so this member resides + // in DRAM when the cache-safe callback passes it to GPTimer. + gptimer_alarm_config_t alarm_config_{}; + bool initialized_{false}; + bool active_{false}; + bool step_high_{false}; + bool scheduler_fault_{false}; + std::uint32_t completed_pulses_{0}; +}; + +} // namespace radiance3d diff --git a/firmware/controller/components/platform_idf/src/idf_hardware_platform.cpp b/firmware/controller/components/platform_idf/src/idf_hardware_platform.cpp new file mode 100644 index 0000000..f359de8 --- /dev/null +++ b/firmware/controller/components/platform_idf/src/idf_hardware_platform.cpp @@ -0,0 +1,143 @@ +#include "idf_hardware_platform.hpp" + +#include "driver/gpio.h" +#include "driver/uart.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" + +#include + +namespace radiance3d { + +int IdfHardwarePlatform::uart_port(const std::uint8_t channel) { + if (channel == 1) { + return UART_NUM_1; + } + if (channel == 2) { + return UART_NUM_2; + } + return -1; +} + +bool IdfHardwarePlatform::configure_pin(const int pin, const PinMode mode) { + if (pin < 0 || pin >= GPIO_NUM_MAX) { + return false; + } + gpio_config_t config = {}; + config.pin_bit_mask = 1ULL << static_cast(pin); + config.intr_type = GPIO_INTR_DISABLE; + if (mode == PinMode::output) { + config.mode = GPIO_MODE_OUTPUT; + config.pull_up_en = GPIO_PULLUP_DISABLE; + config.pull_down_en = GPIO_PULLDOWN_DISABLE; + } else { + config.mode = GPIO_MODE_INPUT; + config.pull_up_en = mode == PinMode::input_pullup ? GPIO_PULLUP_ENABLE + : GPIO_PULLUP_DISABLE; + config.pull_down_en = mode == PinMode::input_pulldown + ? GPIO_PULLDOWN_ENABLE + : GPIO_PULLDOWN_DISABLE; + } + return gpio_config(&config) == ESP_OK; +} + +void IdfHardwarePlatform::write_pin(const int pin, const bool high) { + if (pin >= 0 && pin < GPIO_NUM_MAX) { + gpio_set_level(static_cast(pin), high ? 1 : 0); + } +} + +bool IdfHardwarePlatform::read_pin(const int pin) const { + return pin >= 0 && pin < GPIO_NUM_MAX && + gpio_get_level(static_cast(pin)) != 0; +} + +std::uint64_t IdfHardwarePlatform::monotonic_micros() const { + return static_cast(esp_timer_get_time()); +} + +bool IdfHardwarePlatform::begin_uart(const std::uint8_t channel, + const int tx_pin, const int rx_pin, + const std::uint32_t baud) { + const int port = uart_port(channel); + if (port < 0 || tx_pin < 0 || rx_pin < 0 || baud == 0) { + return false; + } + uart_config_t config = {}; + config.baud_rate = static_cast(baud); + config.data_bits = UART_DATA_8_BITS; + config.parity = UART_PARITY_DISABLE; + config.stop_bits = UART_STOP_BITS_1; + config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + config.source_clk = UART_SCLK_DEFAULT; + if (uart_param_config(static_cast(port), &config) != ESP_OK || + uart_set_pin(static_cast(port), tx_pin, rx_pin, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE) != ESP_OK) { + return false; + } + if (!uart_installed_[channel]) { + if (uart_driver_install(static_cast(port), 256, 256, 0, + nullptr, 0) != ESP_OK) { + return false; + } + uart_installed_[channel] = true; + } + return true; +} + +bool IdfHardwarePlatform::configure_uart_half_duplex( + const std::uint8_t channel, const bool enabled) { + const int port = uart_port(channel); + if (port < 0 || !uart_installed_[channel]) { + return false; + } + // A TMC2209 PDN_UART bus is electrically single-wire, but it is not an + // RS-485 bus: RS485 half-duplex mode drives RTS and does not join TX/RX. + // The carrier wiring joins the ESP TX (through its required resistor) and + // RX at PDN_UART. Keep the peripheral in normal UART mode and let the + // portable driver filter the expected write echo. + static_cast(enabled); + return uart_set_mode(static_cast(port), UART_MODE_UART) == ESP_OK; +} + +void IdfHardwarePlatform::flush_uart_input(const std::uint8_t channel) { + const int port = uart_port(channel); + if (port >= 0 && uart_installed_[channel]) { + uart_flush_input(static_cast(port)); + } +} + +bool IdfHardwarePlatform::write_uart(const std::uint8_t channel, + const std::uint8_t* const data, + const std::size_t length) { + const int port = uart_port(channel); + if (port < 0 || !uart_installed_[channel] || data == nullptr || length == 0 || + length > static_cast(std::numeric_limits::max())) { + return false; + } + const int written = uart_write_bytes(static_cast(port), data, + static_cast(length)); + if (written != static_cast(length)) { + return false; + } + return uart_wait_tx_done(static_cast(port), pdMS_TO_TICKS(20)) == + ESP_OK; +} + +std::size_t IdfHardwarePlatform::read_uart(const std::uint8_t channel, + std::uint8_t* const data, + const std::size_t maximum_length, + const std::uint32_t timeout_ms) { + const int port = uart_port(channel); + if (port < 0 || !uart_installed_[channel] || data == nullptr || + maximum_length == 0 || + maximum_length > static_cast(std::numeric_limits::max())) { + return 0; + } + const int received = uart_read_bytes( + static_cast(port), data, maximum_length, + pdMS_TO_TICKS(timeout_ms)); + return received > 0 ? static_cast(received) : 0; +} + +} // namespace radiance3d diff --git a/firmware/controller/components/platform_idf/src/idf_step_pulse_scheduler.cpp b/firmware/controller/components/platform_idf/src/idf_step_pulse_scheduler.cpp new file mode 100644 index 0000000..cb5d0fa --- /dev/null +++ b/firmware/controller/components/platform_idf/src/idf_step_pulse_scheduler.cpp @@ -0,0 +1,204 @@ +#include "idf_step_pulse_scheduler.hpp" + +#include "esp_err.h" + +#include +#include + +namespace radiance3d { +namespace { + +// ESP-IDF documents GPTimer alarms below 5 us as unsuitable for reliable +// control. TMC2209 accepts a pulse wider than its 1 us minimum, so use a +// conservative 5 us high time and minimum lead/setup interval. +constexpr std::uint64_t kPulseWidthUs = 5; +constexpr std::uint64_t kMinimumAlarmLeadUs = 5; + +} // namespace + +IdfStepPulseScheduler::IdfStepPulseScheduler(const int step_pin) + : step_pin_(step_pin) {} + +IdfStepPulseScheduler::~IdfStepPulseScheduler() { + if (timer_ != nullptr) { + gptimer_stop(timer_); + gptimer_disable(timer_); + gptimer_del_timer(timer_); + } +} + +void IdfStepPulseScheduler::set_motion_task(TaskHandle_t task) { + portENTER_CRITICAL(&lock_); + motion_task_ = task; + portEXIT_CRITICAL(&lock_); +} + +bool IdfStepPulseScheduler::initialize() { + if (initialized_) { + return true; + } + if (step_pin_ < 0 || step_pin_ >= GPIO_NUM_MAX) { + return false; + } + gpio_set_level(static_cast(step_pin_), 0); + gptimer_config_t config = {}; + config.clk_src = GPTIMER_CLK_SRC_DEFAULT; + config.direction = GPTIMER_COUNT_UP; + config.resolution_hz = 1000000; + if (gptimer_new_timer(&config, &timer_) != ESP_OK) { + return false; + } + gptimer_event_callbacks_t callbacks = {}; + callbacks.on_alarm = &IdfStepPulseScheduler::on_alarm; + if (gptimer_register_event_callbacks(timer_, &callbacks, this) != ESP_OK || + gptimer_enable(timer_) != ESP_OK || gptimer_start(timer_) != ESP_OK) { + gptimer_del_timer(timer_); + timer_ = nullptr; + return false; + } + initialized_ = true; + return true; +} + +bool IdfStepPulseScheduler::set_alarm(const std::uint64_t count) { + // Callers hold lock_. ESP-IDF requires an alarm configuration used from a + // cache-safe callback to live in internal memory, not on the ISR stack. + alarm_config_.alarm_count = count; + alarm_config_.reload_count = 0; + alarm_config_.flags.auto_reload_on_alarm = false; + return gptimer_set_alarm_action(timer_, &alarm_config_) == ESP_OK; +} + +void IdfStepPulseScheduler::disarm_alarm() { + gptimer_set_alarm_action(timer_, nullptr); +} + +void IdfStepPulseScheduler::mark_scheduler_fault() { + gpio_set_level(static_cast(step_pin_), 0); + step_high_ = false; + active_ = false; + scheduler_fault_ = true; + disarm_alarm(); +} + +bool IdfStepPulseScheduler::schedule_pulse( + const std::uint32_t delay_before_rising_us) { + if (!initialized_ || timer_ == nullptr || delay_before_rising_us == 0) { + return false; + } + + portENTER_CRITICAL(&lock_); + if (active_) { + portEXIT_CRITICAL(&lock_); + return false; + } + + std::uint64_t now = 0; + const std::uint64_t delay = std::max( + delay_before_rising_us, kMinimumAlarmLeadUs); + if (gptimer_get_raw_count(timer_, &now) != ESP_OK || + now > std::numeric_limits::max() - delay) { + mark_scheduler_fault(); + portEXIT_CRITICAL(&lock_); + return false; + } + + gpio_set_level(static_cast(step_pin_), 0); + step_high_ = false; + active_ = true; + if (!set_alarm(now + delay)) { + mark_scheduler_fault(); + portEXIT_CRITICAL(&lock_); + return false; + } + portEXIT_CRITICAL(&lock_); + return true; +} + +void IdfStepPulseScheduler::stop() { + if (!initialized_ || timer_ == nullptr) { + return; + } + portENTER_CRITICAL(&lock_); + active_ = false; + step_high_ = false; + gpio_set_level(static_cast(step_pin_), 0); + disarm_alarm(); + portEXIT_CRITICAL(&lock_); +} + +std::uint32_t IdfStepPulseScheduler::consume_completed_pulses() { + portENTER_CRITICAL(&lock_); + const std::uint32_t completed = completed_pulses_; + completed_pulses_ = 0; + portEXIT_CRITICAL(&lock_); + return completed; +} + +bool IdfStepPulseScheduler::consume_scheduler_fault() { + portENTER_CRITICAL(&lock_); + const bool fault = scheduler_fault_; + scheduler_fault_ = false; + portEXIT_CRITICAL(&lock_); + return fault; +} + +void IdfStepPulseScheduler::emergency_stop_from_isr() { + if (!initialized_ || timer_ == nullptr) { + return; + } + portENTER_CRITICAL_ISR(&lock_); + active_ = false; + step_high_ = false; + gpio_set_level(static_cast(step_pin_), 0); + disarm_alarm(); + portEXIT_CRITICAL_ISR(&lock_); +} + +bool IdfStepPulseScheduler::on_alarm( + gptimer_handle_t, const gptimer_alarm_event_data_t* const event_data, + void* const user_context) { + return static_cast(user_context)->handle_alarm( + event_data); +} + +bool IdfStepPulseScheduler::handle_alarm( + const gptimer_alarm_event_data_t* const event_data) { + BaseType_t higher_priority_woken = pdFALSE; + TaskHandle_t task_to_wake = nullptr; + + portENTER_CRITICAL_ISR(&lock_); + if (!active_) { + portEXIT_CRITICAL_ISR(&lock_); + return false; + } + + if (!step_high_) { + gpio_set_level(static_cast(step_pin_), 1); + step_high_ = true; + std::uint64_t now = event_data->count_value; + if (gptimer_get_raw_count(timer_, &now) != ESP_OK || + now > std::numeric_limits::max() - + kMinimumAlarmLeadUs || + !set_alarm(std::max(event_data->count_value + kPulseWidthUs, + now + kMinimumAlarmLeadUs))) { + mark_scheduler_fault(); + task_to_wake = motion_task_; + } + } else { + gpio_set_level(static_cast(step_pin_), 0); + step_high_ = false; + active_ = false; + disarm_alarm(); + ++completed_pulses_; + task_to_wake = motion_task_; + } + portEXIT_CRITICAL_ISR(&lock_); + + if (task_to_wake != nullptr) { + vTaskNotifyGiveFromISR(task_to_wake, &higher_priority_woken); + } + return higher_priority_woken == pdTRUE; +} + +} // namespace radiance3d diff --git a/firmware/controller/components/radiance3d_core/CMakeLists.txt b/firmware/controller/components/radiance3d_core/CMakeLists.txt new file mode 100644 index 0000000..d563ca8 --- /dev/null +++ b/firmware/controller/components/radiance3d_core/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "../../src/motion_controller.cpp" "../../src/protocol.cpp" + INCLUDE_DIRS "../../include" "${RADIANCE3D_GENERATED_INCLUDE_DIR}" +) diff --git a/firmware/controller/components/radiance3d_physical/CMakeLists.txt b/firmware/controller/components/radiance3d_physical/CMakeLists.txt new file mode 100644 index 0000000..ec08c6e --- /dev/null +++ b/firmware/controller/components/radiance3d_physical/CMakeLists.txt @@ -0,0 +1,9 @@ +idf_component_register( + SRCS + "../../src/axis_controller.cpp" + "../../src/hardware_config.cpp" + "../../src/physical_motion_controller.cpp" + "../../src/tmc2209_driver.cpp" + INCLUDE_DIRS "../../include" "${RADIANCE3D_GENERATED_INCLUDE_DIR}" + REQUIRES radiance3d_core platform_idf +) diff --git a/firmware/controller/dependencies.lock b/firmware/controller/dependencies.lock new file mode 100644 index 0000000..e9517ef --- /dev/null +++ b/firmware/controller/dependencies.lock @@ -0,0 +1,10 @@ +dependencies: + idf: + source: + type: idf + version: 5.5.4 +direct_dependencies: +- idf +manifest_hash: c6435d380e1560973706570bb39a90b2facb47c1662605c92574c1954cea2568 +target: esp32 +version: 2.0.0 diff --git a/firmware/controller/include/axis_controller.hpp b/firmware/controller/include/axis_controller.hpp index 5f037ee..e15b1a8 100644 --- a/firmware/controller/include/axis_controller.hpp +++ b/firmware/controller/include/axis_controller.hpp @@ -2,6 +2,7 @@ #include "hardware_platform.hpp" #include "motion_controller.hpp" +#include "step_pulse_scheduler.hpp" #include "stepper_driver.hpp" #include @@ -12,15 +13,21 @@ struct PhysicalAxisConfig { const char* name{"axis"}; AxisConfig motion{}; int home_switch_pin{-1}; + PinMode home_switch_input_mode{PinMode::input_pullup}; }; class AxisController { public: AxisController(HardwarePlatform& platform, StepperDriver& driver, - PhysicalAxisConfig config); + PhysicalAxisConfig config, + StepPulseScheduler* pulse_scheduler = nullptr); bool initialize(); void service(); + // Runs bounded, potentially blocking driver diagnostics only while this + // axis is idle. The physical runtime invokes it from its diagnostics tick + // so UART timeouts can never delay scheduling the next STEP pulse. + void service_diagnostics(); MotionResult start_homing(std::uint32_t command_id = 0); MotionResult move_absolute_degrees(double target_deg, double speed_deg_per_s, std::uint32_t command_id = 0); @@ -53,6 +60,7 @@ class AxisController { HardwarePlatform& platform_; StepperDriver& driver_; PhysicalAxisConfig config_; + StepPulseScheduler* pulse_scheduler_{nullptr}; AxisState state_{}; MotionPurpose motion_purpose_{MotionPurpose::none}; std::uint64_t motion_started_us_{0}; diff --git a/firmware/controller/include/hardware_config.hpp b/firmware/controller/include/hardware_config.hpp index df4e7da..b886502 100644 --- a/firmware/controller/include/hardware_config.hpp +++ b/firmware/controller/include/hardware_config.hpp @@ -20,6 +20,7 @@ struct PhysicalControllerConfig { PhysicalAxisDefinition elevation{}; int emergency_stop_pin{-1}; bool emergency_stop_active_low{true}; + PinMode emergency_stop_input_mode{PinMode::input_pullup}; std::uint32_t emergency_stop_debounce_ms{10}; }; diff --git a/firmware/controller/include/hardware_platform.hpp b/firmware/controller/include/hardware_platform.hpp index e573433..278b820 100644 --- a/firmware/controller/include/hardware_platform.hpp +++ b/firmware/controller/include/hardware_platform.hpp @@ -5,7 +5,10 @@ namespace radiance3d { -enum class PinMode { input, input_pullup, output }; +// Keep input bias explicit in the portable configuration. The ESP-IDF +// adapter maps these modes to gpio_config(); host fakes can model the same +// default levels without depending on ESP-IDF headers. +enum class PinMode { input, input_pullup, input_pulldown, output }; class HardwarePlatform { public: @@ -18,6 +21,15 @@ class HardwarePlatform { virtual bool begin_uart(std::uint8_t channel, int tx_pin, int rx_pin, std::uint32_t baud) = 0; + // TMC2209 PDN_UART may be wired as a single-wire bus. The portable driver + // requests this explicitly; platforms without a distinct mode can retain + // their normal UART implementation and still filter write echo on receive. + virtual bool configure_uart_half_duplex(std::uint8_t channel, + bool enabled) { + static_cast(channel); + static_cast(enabled); + return true; + } virtual void flush_uart_input(std::uint8_t channel) = 0; virtual bool write_uart(std::uint8_t channel, const std::uint8_t* data, std::size_t length) = 0; diff --git a/firmware/controller/include/motion_controller.hpp b/firmware/controller/include/motion_controller.hpp index 210f058..61eacb6 100644 --- a/firmware/controller/include/motion_controller.hpp +++ b/firmware/controller/include/motion_controller.hpp @@ -68,12 +68,23 @@ struct HomingConfig { std::uint32_t timeout_ms{60000}; }; +// The protocol continues to expose GEAR_RATIO as a decimal number. Internally +// the motion core stores the build profile as an exact ratio so conversion math +// does not accumulate floating-point configuration error. +struct RationalGearRatio { + std::int32_t numerator{1}; + std::int32_t denominator{1}; + + bool valid() const; + double as_double() const; +}; + struct AxisConfig { std::uint16_t motor_full_steps_per_revolution{200}; std::uint16_t microsteps{16}; std::uint16_t motor_rms_current_ma{0}; std::uint8_t hold_current_percent{30}; - double gear_ratio{1.0}; + RationalGearRatio gear_ratio{}; bool direction_inverted{false}; double home_offset_deg{0.0}; double minimum_angle_deg{0.0}; @@ -87,6 +98,8 @@ struct AxisConfig { double steps_per_output_revolution() const; double commanded_step_angle_deg() const; + bool output_steps_per_motor_full_step(std::int64_t& steps) const; + bool steps_per_output_revolution_exact(std::int64_t& steps) const; bool valid() const; }; diff --git a/firmware/controller/include/physical_motion_controller.hpp b/firmware/controller/include/physical_motion_controller.hpp index 4bb057d..aaa0500 100644 --- a/firmware/controller/include/physical_motion_controller.hpp +++ b/firmware/controller/include/physical_motion_controller.hpp @@ -17,6 +17,9 @@ class PhysicalMotionController final : public MotionController { bool initialize() override; void service() override; + // Called by the diagnostics task through the motion owner. It is a no-op + // while either axis is moving so UART timeouts cannot create pulse gaps. + void service_diagnostics(); const ControllerConfig& config() const override; const ControllerState& state() const override; MotionResult home(AxisSelection axis, @@ -64,6 +67,7 @@ class PhysicalMotionController final : public MotionController { AxisController* selected_axis(AxisSelection axis); const AxisController* selected_axis(AxisSelection axis) const; void synchronize_state(); + FaultCode active_axis_fault() const; void update_emergency_input(std::uint64_t now_us); bool emergency_input_active_raw() const; MotionResult reject(FaultCode fault); diff --git a/firmware/controller/include/protocol.hpp b/firmware/controller/include/protocol.hpp index 484c5d9..bdf0347 100644 --- a/firmware/controller/include/protocol.hpp +++ b/firmware/controller/include/protocol.hpp @@ -2,6 +2,7 @@ #include "motion_controller.hpp" +#include #include namespace radiance3d { @@ -9,15 +10,18 @@ namespace radiance3d { class ProtocolEngine { public: ProtocolEngine(); - explicit ProtocolEngine(MotionController& controller); + explicit ProtocolEngine(MotionController& controller, + std::uint32_t protocol_version = 1); std::string handle(const std::string& line); std::string service(); + std::string host_heartbeat_timeout(); const ControllerState& state() const; private: SimulatedMotionController default_controller_; MotionController* controller_; + std::uint32_t protocol_version_{1}; std::uint32_t last_command_id_{0}; FaultCode previous_fault_{FaultCode::none}; bool previous_estop_{false}; diff --git a/firmware/controller/include/step_pulse_scheduler.hpp b/firmware/controller/include/step_pulse_scheduler.hpp new file mode 100644 index 0000000..1b53a53 --- /dev/null +++ b/firmware/controller/include/step_pulse_scheduler.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace radiance3d { + +// The portable axis state machine owns position, acceleration, and motion +// decisions. A platform scheduler owns only the time-critical high/low GPIO +// edges for one STEP pin. Keeping that boundary narrow lets host tests retain +// the cooperative fallback while physical ESP-IDF uses GPTimer. +class StepPulseScheduler { + public: + virtual ~StepPulseScheduler() = default; + + virtual bool initialize() = 0; + virtual bool schedule_pulse(std::uint32_t delay_before_rising_us) = 0; + virtual void stop() = 0; + virtual std::uint32_t consume_completed_pulses() = 0; + // A platform callback may be unable to arm its next edge. The motion + // owner consumes this sticky flag and turns it into a normal safe fault. + virtual bool consume_scheduler_fault() { return false; } +}; + +} // namespace radiance3d diff --git a/firmware/controller/include/tmc2209_driver.hpp b/firmware/controller/include/tmc2209_driver.hpp index 536ec6d..787db03 100644 --- a/firmware/controller/include/tmc2209_driver.hpp +++ b/firmware/controller/include/tmc2209_driver.hpp @@ -22,6 +22,8 @@ struct Tmc2209Config { std::uint16_t maximum_rms_current_ma{800}; std::uint32_t uart_baud{115200}; std::uint32_t uart_timeout_ms{20}; + bool uart_single_wire{true}; + bool write_echo_expected{true}; }; class Tmc2209Driver final : public StepperDriver { @@ -62,6 +64,7 @@ class Tmc2209Driver final : public StepperDriver { bool valid_config() const; bool write_register(std::uint8_t address, std::uint32_t value); + bool write_register_verified(std::uint8_t address, std::uint32_t value); bool read_register(std::uint8_t address, std::uint32_t& value); bool verify_write_counter(std::uint8_t before); static bool microstep_code(std::uint16_t microsteps, std::uint8_t& code); diff --git a/firmware/controller/main/CMakeLists.txt b/firmware/controller/main/CMakeLists.txt new file mode 100644 index 0000000..618bd4b --- /dev/null +++ b/firmware/controller/main/CMakeLists.txt @@ -0,0 +1,6 @@ +idf_component_register( + SRCS "app_main.cpp" "controller_runtime.cpp" + INCLUDE_DIRS "." "../include" "${RADIANCE3D_GENERATED_INCLUDE_DIR}" + REQUIRES radiance3d_core radiance3d_physical platform_idf + PRIV_REQUIRES nvs_flash esp_system esp_timer driver freertos log +) diff --git a/firmware/controller/main/Kconfig.projbuild b/firmware/controller/main/Kconfig.projbuild new file mode 100644 index 0000000..f2bda12 --- /dev/null +++ b/firmware/controller/main/Kconfig.projbuild @@ -0,0 +1,40 @@ +menu "Radiance3D motion controller" + +config RADIANCE3D_HOST_HEARTBEAT_TIMEOUT_MS + int "Host heartbeat timeout (ms)" + range 250 60000 + default 2000 + help + Timeout enforced by the safety task after host activity has been seen. + +config RADIANCE3D_TASK_WDT_TIMEOUT_MS + int "Application task-watchdog timeout (ms)" + range 1000 30000 + default 5000 + +config RADIANCE3D_PROTOCOL_TASK_STACK + int "Protocol task stack size" + range 2048 16384 + default 4096 + +config RADIANCE3D_MOTION_TASK_STACK + int "Motion task stack size" + range 3072 16384 + default 6144 + +config RADIANCE3D_SAFETY_TASK_STACK + int "Safety task stack size" + range 2048 8192 + default 3072 + +config RADIANCE3D_DIAGNOSTICS_TASK_STACK + int "Diagnostics task stack size" + range 2048 8192 + default 3072 + +config RADIANCE3D_DIAGNOSTICS_INTERVAL_MS + int "Diagnostics task interval (ms)" + range 100 10000 + default 1000 + +endmenu diff --git a/firmware/controller/main/app_main.cpp b/firmware/controller/main/app_main.cpp new file mode 100644 index 0000000..4a19f97 --- /dev/null +++ b/firmware/controller/main/app_main.cpp @@ -0,0 +1,59 @@ +#include "controller_runtime.hpp" + +#include "esp_err.h" +#include "esp_log.h" +#include "esp_system.h" +#include "nvs_flash.h" + +namespace { + +const char* reset_reason_name(const esp_reset_reason_t reason) { + switch (reason) { + case ESP_RST_POWERON: + return "POWER_ON"; + case ESP_RST_EXT: + return "EXTERNAL"; + case ESP_RST_SW: + return "SOFTWARE"; + case ESP_RST_PANIC: + return "PANIC"; + case ESP_RST_INT_WDT: + return "INT_WATCHDOG"; + case ESP_RST_TASK_WDT: + return "TASK_WATCHDOG"; + case ESP_RST_WDT: + return "OTHER_WATCHDOG"; + case ESP_RST_DEEPSLEEP: + return "DEEP_SLEEP"; + case ESP_RST_BROWNOUT: + return "BROWNOUT"; + case ESP_RST_SDIO: + return "SDIO"; + default: + return "UNKNOWN"; + } +} + +void initialize_nvs() { + const esp_err_t result = nvs_flash_init(); + if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW("CONFIG", "reinitializing NVS after %s", esp_err_to_name(result)); + ESP_ERROR_CHECK(nvs_flash_erase()); + ESP_ERROR_CHECK(nvs_flash_init()); + } else { + ESP_ERROR_CHECK(result); + } +} + +} // namespace + +extern "C" void app_main(void) { + const char* const reset_reason = reset_reason_name(esp_reset_reason()); + ESP_LOGI("APP", "Radiance3D native ESP-IDF startup; reset=%s", reset_reason); + initialize_nvs(); + const bool initialized = radiance3d::controller_runtime_initialize(reset_reason); + if (!initialized) { + ESP_LOGE("APP", "physical controller initialization failed; outputs remain disabled"); + } + radiance3d::controller_runtime_start(); +} diff --git a/firmware/controller/main/controller_runtime.cpp b/firmware/controller/main/controller_runtime.cpp new file mode 100644 index 0000000..f4189ae --- /dev/null +++ b/firmware/controller/main/controller_runtime.cpp @@ -0,0 +1,581 @@ +#include "controller_runtime.hpp" + +#include "axis_controller.hpp" +#include "hardware_config.hpp" +#include "hardware_profile_generated.hpp" +#include "idf_hardware_platform.hpp" +#include "idf_step_pulse_scheduler.hpp" +#include "physical_motion_controller.hpp" +#include "protocol.hpp" +#include "tmc2209_driver.hpp" + +#include "driver/gpio.h" +#include "driver/uart.h" +#include "esp_err.h" +#include "esp_attr.h" +#include "esp_log.h" +#include "esp_task_wdt.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/event_groups.h" +#include "freertos/queue.h" +#include "freertos/task.h" + +#include +#include +#include + +namespace radiance3d { +namespace { + +constexpr char kAppTag[] = "APP"; +constexpr char kProtocolTag[] = "PROTOCOL"; +constexpr char kMotionTag[] = "MOTION"; +constexpr char kSafetyTag[] = "SAFETY"; +constexpr char kDiagnosticsTag[] = "DIAGNOSTICS"; +constexpr std::size_t kMaximumHostLine = 255; +// STATUS includes two full axis payloads. Leave room for additive protocol +// fields instead of silently truncating a valid v1 response. +constexpr std::size_t kMaximumOutboundLine = 1024; +constexpr std::uint32_t kMotionTaskPriority = 8; +constexpr std::uint32_t kSafetyTaskPriority = 9; +constexpr std::uint32_t kProtocolTaskPriority = 5; +constexpr std::uint32_t kDiagnosticsTaskPriority = 3; + +constexpr EventBits_t kAzimuthEnabled = BIT0; +constexpr EventBits_t kElevationEnabled = BIT1; +constexpr EventBits_t kMotionActive = BIT2; + +enum class MotionMessageType : std::uint8_t { + host_command, + emergency_stop, + heartbeat_timeout, + diagnostics_tick, +}; + +struct MotionMessage { + MotionMessageType type{MotionMessageType::host_command}; + std::uint32_t token{0}; + char line[kMaximumHostLine + 1]{}; +}; + +struct OutboundMessage { + std::uint32_t token{0}; + char line[kMaximumOutboundLine]{}; +}; + +struct SafetyMessage { + bool host_activity{false}; +}; + +struct IsrInput { + bool emergency_stop{false}; + int pin{-1}; + bool active_low{true}; + int azimuth_enable_pin{-1}; + bool azimuth_disable_level{true}; + int elevation_enable_pin{-1}; + bool elevation_disable_level{true}; +}; + +struct Runtime { + Runtime() + : config(provisional_esp32_dev_config()), + azimuth_driver(platform, config.azimuth.driver), + elevation_driver(platform, config.elevation.driver), + azimuth_timer(config.azimuth.driver.step_pin), + elevation_timer(config.elevation.driver.step_pin), + azimuth_axis(platform, azimuth_driver, config.azimuth.axis, + &azimuth_timer), + elevation_axis(platform, elevation_driver, config.elevation.axis, + &elevation_timer), + controller(platform, azimuth_axis, elevation_axis, config), + protocol(controller, config.protocol_version) { + emergency_input.emergency_stop = true; + emergency_input.pin = config.emergency_stop_pin; + emergency_input.active_low = config.emergency_stop_active_low; + emergency_input.azimuth_enable_pin = config.azimuth.driver.enable_pin; + emergency_input.azimuth_disable_level = + config.azimuth.driver.enable_active_low; + emergency_input.elevation_enable_pin = config.elevation.driver.enable_pin; + emergency_input.elevation_disable_level = + config.elevation.driver.enable_active_low; + azimuth_home_input.emergency_stop = false; + azimuth_home_input.pin = config.azimuth.axis.home_switch_pin; + elevation_home_input.emergency_stop = false; + elevation_home_input.pin = config.elevation.axis.home_switch_pin; + } + + IdfHardwarePlatform platform; + PhysicalControllerConfig config; + Tmc2209Driver azimuth_driver; + Tmc2209Driver elevation_driver; + IdfStepPulseScheduler azimuth_timer; + IdfStepPulseScheduler elevation_timer; + AxisController azimuth_axis; + AxisController elevation_axis; + PhysicalMotionController controller; + ProtocolEngine protocol; + QueueHandle_t motion_queue{nullptr}; + QueueHandle_t outbound_queue{nullptr}; + QueueHandle_t safety_queue{nullptr}; + EventGroupHandle_t state_events{nullptr}; + TaskHandle_t motion_task{nullptr}; + TaskHandle_t safety_task{nullptr}; + portMUX_TYPE emergency_lock = portMUX_INITIALIZER_UNLOCKED; + bool emergency_stop_pending{false}; + bool controller_initialized{false}; + bool host_uart_initialized{false}; + const char* reset_reason{"UNKNOWN"}; + IsrInput emergency_input{}; + IsrInput azimuth_home_input{}; + IsrInput elevation_home_input{}; +}; + +// Never call the C++ static-local accessor from an IRAM GPIO ISR: its guard +// path lives in flash. The Runtime instance itself has static DRAM storage. +DRAM_ATTR Runtime* g_runtime_for_isr{nullptr}; + +Runtime& runtime() { + static Runtime instance; + return instance; +} + +bool copy_line(char* const destination, const std::size_t destination_size, + const std::string& source) { + if (destination_size == 0 || source.size() >= destination_size) { + return false; + } + const std::size_t length = source.size(); + std::memcpy(destination, source.data(), length); + destination[length] = '\0'; + return true; +} + +bool queue_motion_message(const MotionMessage& message) { + Runtime& state = runtime(); + if (state.motion_queue == nullptr || + xQueueSend(state.motion_queue, &message, 0) != pdPASS) { + return false; + } + if (state.motion_task != nullptr) { + xTaskNotifyGive(state.motion_task); + } + return true; +} + +void request_emergency_stop() { + Runtime& state = runtime(); + portENTER_CRITICAL(&state.emergency_lock); + state.emergency_stop_pending = true; + portEXIT_CRITICAL(&state.emergency_lock); + if (state.motion_task != nullptr) { + xTaskNotifyGive(state.motion_task); + } +} + +bool take_emergency_stop_request() { + Runtime& state = runtime(); + portENTER_CRITICAL(&state.emergency_lock); + const bool pending = state.emergency_stop_pending; + state.emergency_stop_pending = false; + portEXIT_CRITICAL(&state.emergency_lock); + return pending; +} + +bool queue_outbound(const std::string& line, const std::uint32_t token = 0) { + Runtime& state = runtime(); + if (state.outbound_queue == nullptr) { + return false; + } + OutboundMessage message; + message.token = token; + if (!copy_line(message.line, sizeof(message.line), line)) { + ESP_LOGE(kProtocolTag, "outbound protocol line exceeds %u bytes", + static_cast(kMaximumOutboundLine - 1)); + if (token == 0 || + !copy_line(message.line, sizeof(message.line), + "ERR INTERNAL response exceeds maximum line length")) { + return false; + } + } + if (xQueueSend(state.outbound_queue, &message, 0) != pdPASS) { + ESP_LOGW(kProtocolTag, "outbound queue full; dropping line"); + return false; + } + return true; +} + +void update_shared_state() { + Runtime& state = runtime(); + EventBits_t clear_bits = kAzimuthEnabled | kElevationEnabled | kMotionActive; + EventBits_t set_bits = 0; + const ControllerState& controller_state = state.controller.state(); + if (controller_state.azimuth.enabled) { + set_bits |= kAzimuthEnabled; + } + if (controller_state.elevation.enabled) { + set_bits |= kElevationEnabled; + } + if (controller_state.azimuth.moving || controller_state.elevation.moving) { + set_bits |= kMotionActive; + } + xEventGroupClearBits(state.state_events, clear_bits); + if (set_bits != 0) { + xEventGroupSetBits(state.state_events, set_bits); + } +} + +void safe_outputs() { + Runtime& state = runtime(); + const PhysicalAxisDefinition axes[] = {state.config.azimuth, state.config.elevation}; + for (const PhysicalAxisDefinition& axis : axes) { + state.platform.configure_pin(axis.driver.step_pin, PinMode::output); + state.platform.write_pin(axis.driver.step_pin, false); + state.platform.configure_pin(axis.driver.direction_pin, PinMode::output); + state.platform.write_pin(axis.driver.direction_pin, false); + state.platform.configure_pin(axis.driver.enable_pin, PinMode::output); + state.platform.write_pin(axis.driver.enable_pin, axis.driver.enable_active_low); + } +} + +bool initialize_host_uart() { + uart_config_t config = {}; + config.baud_rate = static_cast(generated_profile::kHostUartBaud); + config.data_bits = UART_DATA_8_BITS; + config.parity = UART_PARITY_DISABLE; + config.stop_bits = UART_STOP_BITS_1; + config.flow_ctrl = UART_HW_FLOWCTRL_DISABLE; + config.source_clk = UART_SCLK_DEFAULT; + if (uart_param_config(UART_NUM_0, &config) != ESP_OK || + uart_set_pin(UART_NUM_0, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE, + UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE) != ESP_OK) { + return false; + } + const esp_err_t install = + uart_driver_install(UART_NUM_0, 1024, 1024, 0, nullptr, 0); + return install == ESP_OK || install == ESP_ERR_INVALID_STATE; +} + +void write_host_line(const char* const line) { + const std::size_t length = std::strlen(line); + uart_write_bytes(UART_NUM_0, line, length); + uart_write_bytes(UART_NUM_0, "\n", 1); + uart_wait_tx_done(UART_NUM_0, pdMS_TO_TICKS(50)); +} + +void service_motion_message(const MotionMessage& message) { + Runtime& state = runtime(); + switch (message.type) { + case MotionMessageType::host_command: + queue_outbound(state.protocol.handle(message.line), message.token); + break; + case MotionMessageType::emergency_stop: + state.controller.emergency_stop(); + break; + case MotionMessageType::heartbeat_timeout: + queue_outbound(state.protocol.host_heartbeat_timeout()); + break; + case MotionMessageType::diagnostics_tick: + // This still runs through the motion owner, but only reads UART while + // both axes are idle. Active movement never waits on a TMC timeout. + state.controller.service_diagnostics(); + break; + } +} + +void register_task_watchdog() { + if (esp_task_wdt_add(nullptr) != ESP_OK) { + ESP_LOGW(kAppTag, "task watchdog registration unavailable for this task"); + } +} + +void motion_task(void*) { + Runtime& state = runtime(); + register_task_watchdog(); + state.azimuth_timer.set_motion_task(xTaskGetCurrentTaskHandle()); + state.elevation_timer.set_motion_task(xTaskGetCurrentTaskHandle()); + queue_outbound(std::string("EVENT STARTUP READY=") + + (state.controller_initialized ? "1" : "0") + + " DRIVERS_ENABLED=0 BOARD=" + state.config.board_name + + " RESET=" + state.reset_reason); + for (;;) { + if (take_emergency_stop_request()) { + // Any active e-stop edge is deliberately fail-safe-latched. The ISR has + // already dropped STEP and the enable pins; this establishes coherent + // controller state even if the input bounces before debounce completes. + state.controller.emergency_stop(); + } + MotionMessage message; + while (xQueueReceive(state.motion_queue, &message, 0) == pdPASS) { + service_motion_message(message); + } + const std::string event = state.protocol.service(); + if (!event.empty()) { + queue_outbound(event); + } + update_shared_state(); + esp_task_wdt_reset(); + // GPIO/STEP callbacks and all queue producers notify this task. A timeout + // keeps debounce, timeouts, and diagnostics progressing even when idle. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1)); + } +} + +bool emergency_active() { + Runtime& state = runtime(); + const bool level = state.platform.read_pin(state.config.emergency_stop_pin); + return state.config.emergency_stop_active_low ? !level : level; +} + +void safety_task(void*) { + Runtime& state = runtime(); + register_task_watchdog(); + bool host_seen = false; + bool heartbeat_tripped = false; + std::int64_t last_host_activity_us = 0; + for (;;) { + SafetyMessage incoming; + while (xQueueReceive(state.safety_queue, &incoming, 0) == pdPASS) { + if (incoming.host_activity) { + host_seen = true; + heartbeat_tripped = false; + last_host_activity_us = esp_timer_get_time(); + } + } + + // GPIO ISR notification and host activity both use this wakeup. Do not + // delay here: controller-side input debounce runs outside the ISR, while + // an asserted edge is immediately made fail-safe by request_emergency_stop. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(25)); + if (emergency_active()) { + request_emergency_stop(); + } + + const EventBits_t flags = xEventGroupGetBits(state.state_events); + const bool drivers_enabled = + (flags & (kAzimuthEnabled | kElevationEnabled)) != 0; + const std::int64_t now_us = esp_timer_get_time(); + if (host_seen && !heartbeat_tripped && drivers_enabled && + now_us - last_host_activity_us >= + static_cast(CONFIG_RADIANCE3D_HOST_HEARTBEAT_TIMEOUT_MS) * + 1000) { + MotionMessage timeout; + timeout.type = MotionMessageType::heartbeat_timeout; + if (queue_motion_message(timeout)) { + heartbeat_tripped = true; + } + } + esp_task_wdt_reset(); + } +} + +void diagnostics_task(void*) { + register_task_watchdog(); + TickType_t next_wake = xTaskGetTickCount(); + for (;;) { + vTaskDelayUntil(&next_wake, + pdMS_TO_TICKS(CONFIG_RADIANCE3D_DIAGNOSTICS_INTERVAL_MS)); + MotionMessage tick; + tick.type = MotionMessageType::diagnostics_tick; + if (!queue_motion_message(tick)) { + ESP_LOGW(kDiagnosticsTag, "motion queue full while scheduling diagnostics"); + } + esp_task_wdt_reset(); + } +} + +void protocol_task(void*) { + Runtime& state = runtime(); + register_task_watchdog(); + char line[kMaximumHostLine + 1]{}; + std::size_t length = 0; + std::uint32_t token = 0; + bool discarding_overlong_line = false; + for (;;) { + OutboundMessage pending; + while (xQueueReceive(state.outbound_queue, &pending, 0) == pdPASS) { + write_host_line(pending.line); + } + std::uint8_t character = 0; + const int received = uart_read_bytes(UART_NUM_0, &character, 1, + pdMS_TO_TICKS(20)); + if (received <= 0) { + esp_task_wdt_reset(); + continue; + } + if (character == '\r') { + continue; + } + if (discarding_overlong_line) { + if (character == '\n') { + discarding_overlong_line = false; + } + continue; + } + if (character != '\n') { + if (character >= 0x20 && character <= 0x7e && + length < kMaximumHostLine) { + line[length++] = static_cast(character); + } else { + length = 0; + discarding_overlong_line = true; + write_host_line( + "ERR INVALID_ARGUMENT input line must be printable ASCII and at most 255 bytes"); + } + continue; + } + line[length] = '\0'; + ++token; + SafetyMessage activity; + activity.host_activity = true; + if (xQueueSend(state.safety_queue, &activity, 0) == pdPASS && + state.safety_task != nullptr) { + xTaskNotifyGive(state.safety_task); + } + MotionMessage command; + command.type = MotionMessageType::host_command; + command.token = token; + std::memcpy(command.line, line, length + 1); + length = 0; + if (!queue_motion_message(command)) { + write_host_line("ERR BUSY command queue full"); + continue; + } + // Commands are parsed synchronously by the motion owner, while queued + // asynchronous events continue to be forwarded before the response. + bool response_sent = false; + while (!response_sent) { + OutboundMessage outbound; + if (xQueueReceive(state.outbound_queue, &outbound, pdMS_TO_TICKS(250)) != + pdPASS) { + write_host_line("ERR INTERNAL command response timeout"); + break; + } + write_host_line(outbound.line); + response_sent = outbound.token == token; + } + esp_task_wdt_reset(); + } +} + +void IRAM_ATTR input_isr(void* const argument) { + Runtime* const state = g_runtime_for_isr; + if (state == nullptr) { + return; + } + const IsrInput* const input = static_cast(argument); + BaseType_t higher_priority_woken = pdFALSE; + if (input->emergency_stop) { + const bool level = + gpio_get_level(static_cast(input->pin)) != 0; + const bool active = input->active_low ? !level : level; + if (active) { + state->azimuth_timer.emergency_stop_from_isr(); + state->elevation_timer.emergency_stop_from_isr(); + // Driver disable is intentionally a direct cache-safe GPIO write here, + // not a queue operation that could be full. The motion task latches + // fault/trust state immediately after being notified. + gpio_set_level(static_cast(input->azimuth_enable_pin), + input->azimuth_disable_level ? 1 : 0); + gpio_set_level(static_cast(input->elevation_enable_pin), + input->elevation_disable_level ? 1 : 0); + portENTER_CRITICAL_ISR(&state->emergency_lock); + state->emergency_stop_pending = true; + portEXIT_CRITICAL_ISR(&state->emergency_lock); + } + if (state->motion_task != nullptr && active) { + vTaskNotifyGiveFromISR(state->motion_task, &higher_priority_woken); + } + if (state->safety_task != nullptr) { + vTaskNotifyGiveFromISR(state->safety_task, &higher_priority_woken); + } + } else if (state->motion_task != nullptr) { + vTaskNotifyGiveFromISR(state->motion_task, &higher_priority_woken); + } + if (higher_priority_woken == pdTRUE) { + portYIELD_FROM_ISR(); + } +} + +bool configure_input_interrupts() { + Runtime& state = runtime(); + const esp_err_t install = gpio_install_isr_service(ESP_INTR_FLAG_IRAM); + if (install != ESP_OK && install != ESP_ERR_INVALID_STATE) { + return false; + } + const int pins[] = {state.config.emergency_stop_pin, + state.config.azimuth.axis.home_switch_pin, + state.config.elevation.axis.home_switch_pin}; + IsrInput* const inputs[] = {&state.emergency_input, &state.azimuth_home_input, + &state.elevation_home_input}; + for (std::size_t index = 0; index < 3; ++index) { + const gpio_num_t pin = static_cast(pins[index]); + if (gpio_set_intr_type(pin, GPIO_INTR_ANYEDGE) != ESP_OK || + gpio_isr_handler_add(pin, input_isr, inputs[index]) != ESP_OK) { + return false; + } + } + return true; +} + +} // namespace + +bool controller_runtime_initialize(const char* const reset_reason) { + Runtime& state = runtime(); + g_runtime_for_isr = &state; + state.reset_reason = reset_reason == nullptr ? "UNKNOWN" : reset_reason; + safe_outputs(); + state.motion_queue = xQueueCreate(16, sizeof(MotionMessage)); + state.outbound_queue = xQueueCreate(24, sizeof(OutboundMessage)); + state.safety_queue = xQueueCreate(8, sizeof(SafetyMessage)); + state.state_events = xEventGroupCreate(); + state.host_uart_initialized = initialize_host_uart(); + if (state.motion_queue == nullptr || state.outbound_queue == nullptr || + state.safety_queue == nullptr || state.state_events == nullptr || + !state.host_uart_initialized) { + ESP_LOGE(kAppTag, "could not initialize native queues, event group, or UART0"); + return false; + } + const GpioValidationResult gpio = validate_esp32_gpio(state.config); + if (gpio.bootstrapping_pin_mask != 0) { + const std::string warning = + "EVENT WARNING CODE=ESP32_BOOTSTRAP_GPIO MASK=" + + std::to_string(gpio.bootstrapping_pin_mask); + queue_outbound(warning); + ESP_LOGW("CONFIG", "ESP32 bootstrap GPIO mask=%llu", + static_cast(gpio.bootstrapping_pin_mask)); + } + state.controller_initialized = state.controller.initialize(); + if (!configure_input_interrupts()) { + ESP_LOGE(kSafetyTag, "could not configure input interrupts"); + state.controller_initialized = false; + } + update_shared_state(); + return state.controller_initialized; +} + +void controller_runtime_start() { + Runtime& state = runtime(); + esp_task_wdt_config_t watchdog = {}; + watchdog.timeout_ms = CONFIG_RADIANCE3D_TASK_WDT_TIMEOUT_MS; + watchdog.idle_core_mask = 0; + watchdog.trigger_panic = true; + const esp_err_t wdt = esp_task_wdt_init(&watchdog); + if (wdt != ESP_OK && wdt != ESP_ERR_INVALID_STATE) { + ESP_LOGW(kAppTag, "could not initialize task watchdog: %s", esp_err_to_name(wdt)); + } + xTaskCreate(motion_task, "radiance_motion", CONFIG_RADIANCE3D_MOTION_TASK_STACK, + nullptr, kMotionTaskPriority, &state.motion_task); + xTaskCreate(safety_task, "radiance_safety", CONFIG_RADIANCE3D_SAFETY_TASK_STACK, + nullptr, kSafetyTaskPriority, &state.safety_task); + xTaskCreate(protocol_task, "radiance_protocol", + CONFIG_RADIANCE3D_PROTOCOL_TASK_STACK, nullptr, + kProtocolTaskPriority, nullptr); + xTaskCreate(diagnostics_task, "radiance_diagnostics", + CONFIG_RADIANCE3D_DIAGNOSTICS_TASK_STACK, nullptr, + kDiagnosticsTaskPriority, nullptr); + ESP_LOGI(kAppTag, "tasks started without core affinity"); +} + +} // namespace radiance3d diff --git a/firmware/controller/main/controller_runtime.hpp b/firmware/controller/main/controller_runtime.hpp new file mode 100644 index 0000000..920d904 --- /dev/null +++ b/firmware/controller/main/controller_runtime.hpp @@ -0,0 +1,10 @@ +#pragma once + +namespace radiance3d { + +// Startup is split from app_main so reset/NVS initialization remains explicit +// and task creation can be tested independently of global constructors. +bool controller_runtime_initialize(const char* reset_reason); +void controller_runtime_start(); + +} // namespace radiance3d diff --git a/firmware/controller/main/idf_component.yml b/firmware/controller/main/idf_component.yml new file mode 100644 index 0000000..d62b361 --- /dev/null +++ b/firmware/controller/main/idf_component.yml @@ -0,0 +1,2 @@ +dependencies: + idf: ">=5.5.4,<5.6.0" diff --git a/firmware/controller/partitions.csv b/firmware/controller/partitions.csv new file mode 100644 index 0000000..8e09be2 --- /dev/null +++ b/firmware/controller/partitions.csv @@ -0,0 +1,4 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1536K, diff --git a/firmware/controller/platformio.ini b/firmware/controller/platformio.ini deleted file mode 100644 index 31fadc6..0000000 --- a/firmware/controller/platformio.ini +++ /dev/null @@ -1,24 +0,0 @@ -[platformio] -default_envs = native - -[env] -build_flags = - -D RADIANCE3D_PROTOCOL_VERSION=1 -build_src_filter = +<*.cpp> - -[env:native] -platform = native -test_framework = unity -test_build_src = yes -build_flags = - ${env.build_flags} - -std=c++14 - -[env:esp32dev] -platform = espressif32 -board = esp32dev -framework = arduino -monitor_speed = 115200 - -; The esp32dev target is the Version 1 firmware baseline; the exact board revision -; and GPIO mapping remain pending validation against the installed hardware. diff --git a/firmware/controller/sdkconfig.defaults b/firmware/controller/sdkconfig.defaults new file mode 100644 index 0000000..f421d59 --- /dev/null +++ b/firmware/controller/sdkconfig.defaults @@ -0,0 +1,30 @@ +# ESP-IDF v5.5 baseline for the provisional ESP-WROOM-32 controller. +CONFIG_IDF_TARGET="esp32" +CONFIG_FREERTOS_HZ=1000 + +# The USB-UART0 line is the structured host protocol. Leave the ESP-IDF +# console disabled so ESP_LOG output cannot corrupt that line protocol. +CONFIG_ESP_CONSOLE_NONE=y +CONFIG_LOG_DEFAULT_LEVEL_WARN=y +CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y + +# Safety defaults. Physical brownout behavior still requires hardware testing. +CONFIG_ESP_BROWNOUT_DET=y +CONFIG_ESP_TASK_WDT_EN=y +CONFIG_ESP_TASK_WDT_PANIC=y +CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 + +# E-stop and GPTimer callbacks keep their GPIO/timer control path available +# while flash cache is disabled. All callback-reachable wrapper code is +# marked IRAM_ATTR and its state is static DRAM. +CONFIG_GPIO_CTRL_FUNC_IN_IRAM=y +CONFIG_GPTIMER_ISR_CACHE_SAFE=y +CONFIG_GPTIMER_CTRL_FUNC_IN_IRAM=y + +CONFIG_RADIANCE3D_HOST_HEARTBEAT_TIMEOUT_MS=2000 +CONFIG_RADIANCE3D_TASK_WDT_TIMEOUT_MS=5000 +CONFIG_RADIANCE3D_PROTOCOL_TASK_STACK=4096 +CONFIG_RADIANCE3D_MOTION_TASK_STACK=6144 +CONFIG_RADIANCE3D_SAFETY_TASK_STACK=3072 +CONFIG_RADIANCE3D_DIAGNOSTICS_TASK_STACK=3072 +CONFIG_RADIANCE3D_DIAGNOSTICS_INTERVAL_MS=1000 diff --git a/firmware/controller/src/axis_controller.cpp b/firmware/controller/src/axis_controller.cpp index 09eac79..610568f 100644 --- a/firmware/controller/src/axis_controller.cpp +++ b/firmware/controller/src/axis_controller.cpp @@ -8,9 +8,11 @@ namespace radiance3d { namespace { -constexpr std::uint64_t kStepPulseWidthUs = 2; -constexpr std::uint64_t kDirectionSetupUs = 2; -constexpr std::uint64_t kMinimumStepLowUs = 2; +// GPTimer's documented practical minimum alarm period is 5 us. This remains +// comfortably above the TMC2209 STEP/DIR minima and avoids sub-period races. +constexpr std::uint64_t kStepPulseWidthUs = 5; +constexpr std::uint64_t kDirectionSetupUs = 5; +constexpr std::uint64_t kMinimumStepLowUs = 5; constexpr std::uint64_t kDriverStatusIntervalUs = 100000; bool elapsed(const std::uint64_t now, const std::uint64_t started, @@ -21,8 +23,12 @@ bool elapsed(const std::uint64_t now, const std::uint64_t started, } // namespace AxisController::AxisController(HardwarePlatform& platform, StepperDriver& driver, - PhysicalAxisConfig config) - : platform_(platform), driver_(driver), config_(config) {} + PhysicalAxisConfig config, + StepPulseScheduler* pulse_scheduler) + : platform_(platform), + driver_(driver), + config_(config), + pulse_scheduler_(pulse_scheduler) {} const PhysicalAxisConfig& AxisController::config() const { return config_; } @@ -43,9 +49,14 @@ bool AxisController::degrees_to_steps(const double degrees, if (!std::isfinite(degrees) || !config_.motion.valid()) { return false; } + std::int64_t steps_per_revolution = 0; + if (!config_.motion.steps_per_output_revolution_exact( + steps_per_revolution)) { + return false; + } const long double scaled = static_cast(degrees) * - static_cast(config_.motion.steps_per_output_revolution()) / + static_cast(steps_per_revolution) / 360.0L; if (scaled > static_cast(std::numeric_limits::max()) || @@ -58,23 +69,29 @@ bool AxisController::degrees_to_steps(const double degrees, } double AxisController::steps_to_degrees(const std::int64_t steps) const { + std::int64_t steps_per_revolution = 0; + if (!config_.motion.steps_per_output_revolution_exact( + steps_per_revolution)) { + return 0.0; + } return static_cast(steps) * 360.0 / - config_.motion.steps_per_output_revolution(); + static_cast(steps_per_revolution); } bool AxisController::motor_full_steps_to_output_steps( const std::int64_t motor_full_steps, std::int64_t& output_steps) const { - const long double scaled = - static_cast(motor_full_steps) * - static_cast(config_.motion.microsteps) * - static_cast(config_.motion.gear_ratio); - if (scaled > - static_cast(std::numeric_limits::max()) || - scaled < - static_cast(std::numeric_limits::min())) { + std::int64_t output_steps_per_full_step = 0; + if (!config_.motion.output_steps_per_motor_full_step( + output_steps_per_full_step) || + (motor_full_steps > 0 && + motor_full_steps > std::numeric_limits::max() / + output_steps_per_full_step) || + (motor_full_steps < 0 && + motor_full_steps < std::numeric_limits::min() / + output_steps_per_full_step)) { return false; } - output_steps = static_cast(std::llround(scaled)); + output_steps = motor_full_steps * output_steps_per_full_step; return true; } @@ -97,7 +114,8 @@ bool AxisController::initialize() { if (config_.name == nullptr || config_.name[0] == '\0' || config_.home_switch_pin < 0 || !config_.motion.valid() || config_.motion.motor_rms_current_ma == 0 || - !platform_.configure_pin(config_.home_switch_pin, PinMode::input_pullup) || + !platform_.configure_pin(config_.home_switch_pin, + config_.home_switch_input_mode) || !driver_.initialize()) { state_.fault = driver_.is_connected() ? FaultCode::invalid_configuration @@ -114,6 +132,11 @@ bool AxisController::initialize() { state_.fault = FaultCode::invalid_configuration; return false; } + if (pulse_scheduler_ != nullptr && !pulse_scheduler_->initialize()) { + driver_.disable(); + state_.fault = FaultCode::invalid_configuration; + return false; + } driver_.disable(); update_home_switch(platform_.monotonic_micros()); state_.last_driver_status = driver_.read_status(); @@ -230,7 +253,14 @@ MotionResult AxisController::start_step_move( current_speed_steps_s_ = std::min(requested_speed_steps_s_, std::max(1.0, std::sqrt(2.0 * acceleration_steps_s2))); - next_edge_us_ = motion_started_us_ + kDirectionSetupUs; + if (pulse_scheduler_ != nullptr) { + if (!pulse_scheduler_->schedule_pulse(kDirectionSetupUs)) { + return fail(FaultCode::driver_communication, + TrustLossReason::driver_fault, true); + } + } else { + next_edge_us_ = motion_started_us_ + kDirectionSetupUs; + } step_high_ = false; return succeed(); } @@ -334,6 +364,9 @@ std::uint64_t AxisController::step_interval_us( } void AxisController::stop_pulse_generation() { + if (pulse_scheduler_ != nullptr) { + pulse_scheduler_->stop(); + } if (step_high_) { driver_.set_step(false); } @@ -351,6 +384,35 @@ void AxisController::finish_motion() { } void AxisController::service_step_generator(const std::uint64_t now_us) { + if (pulse_scheduler_ != nullptr) { + if (pulse_scheduler_->consume_scheduler_fault()) { + fail(FaultCode::driver_communication, TrustLossReason::driver_fault, + true); + return; + } + const std::uint32_t completed = pulse_scheduler_->consume_completed_pulses(); + for (std::uint32_t index = 0; index < completed && state_.moving; ++index) { + state_.internal_step_position += step_direction_; + state_.commanded_position_deg = + steps_to_degrees(state_.internal_step_position); + const std::int64_t remaining = + std::llabs(state_.target_step_position - state_.internal_step_position); + if (remaining == 0) { + finish_motion(); + break; + } + const std::uint64_t interval = step_interval_us(remaining); + const std::uint64_t low_time_us = + std::max(kMinimumStepLowUs, interval - kStepPulseWidthUs); + if (!pulse_scheduler_->schedule_pulse( + static_cast(low_time_us))) { + fail(FaultCode::driver_communication, TrustLossReason::driver_fault, + true); + break; + } + } + return; + } if (!state_.moving || now_us < next_edge_us_) { return; } @@ -466,6 +528,16 @@ void AxisController::service_driver_status(const std::uint64_t now_us) { } } +void AxisController::service_diagnostics() { + // TMC UART receives may wait for their bounded timeout. A scheduler-backed + // move relies on this task to arm the next one-shot GPTimer pulse, so never + // perform that I/O while an axis is moving. + if (state_.moving) { + return; + } + service_driver_status(platform_.monotonic_micros()); +} + void AxisController::service() { const std::uint64_t now_us = platform_.monotonic_micros(); update_home_switch(now_us); @@ -485,7 +557,6 @@ void AxisController::service() { } service_step_generator(now_us); service_homing(now_us); - service_driver_status(now_us); } MotionResult AxisController::stop(const bool invalidate_position) { diff --git a/firmware/controller/src/esp32_platform.cpp b/firmware/controller/src/esp32_platform.cpp deleted file mode 100644 index 5fc12e6..0000000 --- a/firmware/controller/src/esp32_platform.cpp +++ /dev/null @@ -1,103 +0,0 @@ -#include "esp32_platform.hpp" - -#ifdef ARDUINO - -#include - -namespace radiance3d { - -bool ArduinoEsp32Platform::configure_pin(const int pin, const PinMode mode) { - if (pin < 0) { - return false; - } - switch (mode) { - case PinMode::input: - pinMode(pin, INPUT); - break; - case PinMode::input_pullup: - pinMode(pin, INPUT_PULLUP); - break; - case PinMode::output: - pinMode(pin, OUTPUT); - break; - } - return true; -} - -void ArduinoEsp32Platform::write_pin(const int pin, const bool high) { - digitalWrite(pin, high ? HIGH : LOW); -} - -bool ArduinoEsp32Platform::read_pin(const int pin) const { - return digitalRead(pin) == HIGH; -} - -std::uint64_t ArduinoEsp32Platform::monotonic_micros() const { - return static_cast(esp_timer_get_time()); -} - -HardwareSerial* ArduinoEsp32Platform::uart(const std::uint8_t channel) const { - if (channel == 1) { - return &Serial1; - } - if (channel == 2) { - return &Serial2; - } - return nullptr; -} - -bool ArduinoEsp32Platform::begin_uart(const std::uint8_t channel, - const int tx_pin, const int rx_pin, - const std::uint32_t baud) { - HardwareSerial* serial = uart(channel); - if (serial == nullptr) { - return false; - } - serial->begin(baud, SERIAL_8N1, rx_pin, tx_pin); - return true; -} - -void ArduinoEsp32Platform::flush_uart_input(const std::uint8_t channel) { - HardwareSerial* serial = uart(channel); - if (serial == nullptr) { - return; - } - while (serial->available() > 0) { - serial->read(); - } -} - -bool ArduinoEsp32Platform::write_uart(const std::uint8_t channel, - const std::uint8_t* data, - const std::size_t length) { - HardwareSerial* serial = uart(channel); - return serial != nullptr && serial->write(data, length) == length; -} - -std::size_t ArduinoEsp32Platform::read_uart( - const std::uint8_t channel, std::uint8_t* data, - const std::size_t maximum_length, const std::uint32_t timeout_ms) { - HardwareSerial* serial = uart(channel); - if (serial == nullptr) { - return 0; - } - const std::uint64_t started = monotonic_micros(); - const std::uint64_t timeout_us = - static_cast(timeout_ms) * 1000ULL; - std::size_t received = 0; - while (received < maximum_length && - monotonic_micros() - started < timeout_us) { - while (serial->available() > 0 && received < maximum_length) { - data[received++] = static_cast(serial->read()); - } - if (received >= 8) { - break; - } - yield(); - } - return received; -} - -} // namespace radiance3d - -#endif diff --git a/firmware/controller/src/hardware_config.cpp b/firmware/controller/src/hardware_config.cpp index a5e0470..6e5c9d4 100644 --- a/firmware/controller/src/hardware_config.cpp +++ b/firmware/controller/src/hardware_config.cpp @@ -1,5 +1,7 @@ #include "hardware_config.hpp" +#include "hardware_profile_generated.hpp" + #include #include @@ -14,82 +16,88 @@ bool bootstrapping_gpio(const int pin) { return pin == 0 || pin == 2 || pin == 5 || pin == 12 || pin == 15; } -AxisConfig azimuth_motion() { +PinMode input_mode(const bool pullup, const bool pulldown) { + if (pullup) { + return PinMode::input_pullup; + } + if (pulldown) { + return PinMode::input_pulldown; + } + return PinMode::input; +} + +AxisConfig motion_from_profile( + const generated_profile::AxisProfile& profile) { AxisConfig config; - config.motor_full_steps_per_revolution = 200; - config.microsteps = 16; - config.motor_rms_current_ma = 650; - config.hold_current_percent = 30; - config.gear_ratio = 1.0; - config.direction_inverted = false; - config.home_offset_deg = 0.0; - config.minimum_angle_deg = 0.0; - config.maximum_angle_deg = 360.0; - config.maximum_speed_deg_per_s = 10.0; - config.acceleration_deg_per_s2 = 20.0; - config.settling_time_ms = 250; - config.motion_timeout_ms = 60000; - config.maximum_bench_test_steps = 3200; - config.homing.switch_normally_closed = true; - config.homing.direction_negative = true; - config.homing.debounce_ms = 10; - config.homing.speed_deg_per_s = 5.0; - config.homing.slow_approach_deg_per_s = 1.0; - config.homing.backoff_deg = 3.0; - config.homing.timeout_ms = 60000; + config.motor_full_steps_per_revolution = + profile.motor_full_steps_per_revolution; + config.microsteps = profile.microsteps; + config.motor_rms_current_ma = profile.commissioning_current_ma; + config.hold_current_percent = profile.hold_current_percent; + config.gear_ratio = {profile.gear_ratio_numerator, + profile.gear_ratio_denominator}; + config.direction_inverted = profile.direction_inverted; + config.home_offset_deg = profile.home_offset_deg; + config.minimum_angle_deg = profile.minimum_angle_deg; + config.maximum_angle_deg = profile.maximum_angle_deg; + config.maximum_speed_deg_per_s = profile.maximum_speed_deg_per_s; + config.acceleration_deg_per_s2 = profile.acceleration_deg_per_s2; + config.settling_time_ms = profile.settling_time_ms; + config.motion_timeout_ms = profile.motion_timeout_ms; + config.maximum_bench_test_steps = profile.maximum_bench_test_steps; + config.homing.switch_normally_closed = profile.home_switch_normally_closed; + config.homing.direction_negative = profile.homing_direction_negative; + config.homing.debounce_ms = profile.home_switch_debounce_ms; + config.homing.speed_deg_per_s = profile.home_speed_deg_per_s; + config.homing.slow_approach_deg_per_s = profile.slow_home_speed_deg_per_s; + config.homing.backoff_deg = profile.homing_backoff_deg; + config.homing.timeout_ms = profile.motion_timeout_ms; return config; } -AxisConfig elevation_motion() { - AxisConfig config = azimuth_motion(); - config.minimum_angle_deg = -90.0; - config.maximum_angle_deg = 90.0; - config.maximum_speed_deg_per_s = 8.0; - config.acceleration_deg_per_s2 = 15.0; - config.homing.speed_deg_per_s = 4.0; - return config; +PhysicalAxisDefinition axis_from_profile( + const generated_profile::AxisProfile& profile) { + PhysicalAxisDefinition definition; + definition.axis.name = profile.name; + definition.axis.motion = motion_from_profile(profile); + definition.axis.home_switch_pin = profile.home_switch_pin; + definition.axis.home_switch_input_mode = + input_mode(profile.home_switch_pullup, profile.home_switch_pulldown); + definition.driver.uart_channel = profile.uart_channel; + definition.driver.address = profile.uart_address; + definition.driver.uart_tx_pin = profile.uart_tx_pin; + definition.driver.uart_rx_pin = profile.uart_rx_pin; + definition.driver.step_pin = profile.step_pin; + definition.driver.direction_pin = profile.direction_pin; + definition.driver.enable_pin = profile.enable_pin; + definition.driver.enable_active_low = generated_profile::kTmcEnableActiveLow; + definition.driver.direction_inverted = profile.direction_inverted; + definition.driver.sense_resistor_milliohms = + generated_profile::kTmcSenseResistorMilliohms; + definition.driver.maximum_rms_current_ma = profile.maximum_rms_current_ma; + definition.driver.uart_baud = generated_profile::kTmcUartBaud; + definition.driver.uart_timeout_ms = generated_profile::kTmcUartTimeoutMs; + definition.driver.uart_single_wire = + generated_profile::kTmcSingleWirePdnUart; + definition.driver.write_echo_expected = + generated_profile::kTmcWriteEchoExpected; + return definition; } } // namespace PhysicalControllerConfig provisional_esp32_dev_config() { PhysicalControllerConfig config; - config.board_name = "esp32dev-v1-baseline"; - config.protocol_version = 1; - - config.azimuth.axis.name = "azimuth"; - config.azimuth.axis.motion = azimuth_motion(); - config.azimuth.axis.home_switch_pin = 32; - config.azimuth.driver.uart_channel = 1; - config.azimuth.driver.address = 0; - // GPIO assignments remain provisional until the exact ESP32 development-board - // revision and carrier pinout are confirmed. - config.azimuth.driver.uart_tx_pin = 22; - config.azimuth.driver.uart_rx_pin = 21; - config.azimuth.driver.step_pin = 25; - config.azimuth.driver.direction_pin = 26; - config.azimuth.driver.enable_pin = 27; - config.azimuth.driver.direction_inverted = - config.azimuth.axis.motion.direction_inverted; - config.azimuth.driver.maximum_rms_current_ma = 1000; - - config.elevation.axis.name = "elevation"; - config.elevation.axis.motion = elevation_motion(); - config.elevation.axis.home_switch_pin = 33; - config.elevation.driver.uart_channel = 2; - config.elevation.driver.address = 0; - config.elevation.driver.uart_tx_pin = 17; - config.elevation.driver.uart_rx_pin = 16; - config.elevation.driver.step_pin = 18; - config.elevation.driver.direction_pin = 19; - config.elevation.driver.enable_pin = 23; - config.elevation.driver.direction_inverted = - config.elevation.axis.motion.direction_inverted; - config.elevation.driver.maximum_rms_current_ma = 1000; - - config.emergency_stop_pin = 13; - config.emergency_stop_active_low = true; - config.emergency_stop_debounce_ms = 10; + config.board_name = generated_profile::kBoardName; + config.protocol_version = generated_profile::kProtocolVersion; + config.azimuth = axis_from_profile(generated_profile::kAzimuth); + config.elevation = axis_from_profile(generated_profile::kElevation); + config.emergency_stop_pin = generated_profile::kEmergencyStopPin; + config.emergency_stop_active_low = generated_profile::kEmergencyStopActiveLow; + config.emergency_stop_input_mode = input_mode( + generated_profile::kEmergencyStopPullup, + generated_profile::kEmergencyStopPulldown); + config.emergency_stop_debounce_ms = generated_profile::kEmergencyStopDebounceMs; return config; } diff --git a/firmware/controller/src/main.cpp b/firmware/controller/src/main.cpp deleted file mode 100644 index 87ab864..0000000 --- a/firmware/controller/src/main.cpp +++ /dev/null @@ -1,106 +0,0 @@ -#include "protocol.hpp" - -#ifdef ARDUINO - -#include "axis_controller.hpp" -#include "esp32_platform.hpp" -#include "hardware_config.hpp" -#include "physical_motion_controller.hpp" -#include "tmc2209_driver.hpp" - -#include - -namespace { - -radiance3d::ArduinoEsp32Platform platform; -radiance3d::PhysicalControllerConfig physical_config = - radiance3d::provisional_esp32_dev_config(); -radiance3d::Tmc2209Driver azimuth_driver( - platform, physical_config.azimuth.driver); -radiance3d::Tmc2209Driver elevation_driver( - platform, physical_config.elevation.driver); -radiance3d::AxisController azimuth_axis( - platform, azimuth_driver, physical_config.azimuth.axis); -radiance3d::AxisController elevation_axis( - platform, elevation_driver, physical_config.elevation.axis); -radiance3d::PhysicalMotionController controller( - platform, azimuth_axis, elevation_axis, physical_config); -radiance3d::ProtocolEngine engine(controller); -String incoming; -std::uint32_t last_host_activity_ms = 0; -bool host_seen = false; -bool host_watchdog_tripped = false; -constexpr std::uint32_t kHostWatchdogMs = 2000; - -} // namespace - -void setup() { - Serial.begin(115200); - const radiance3d::GpioValidationResult gpio = - radiance3d::validate_esp32_gpio(physical_config); - if (gpio.bootstrapping_pin_mask != 0) { - Serial.print("EVENT WARNING CODE=ESP32_BOOTSTRAP_GPIO MASK="); - Serial.println(static_cast(gpio.bootstrapping_pin_mask)); - } - const bool initialized = controller.initialize(); - Serial.print("EVENT STARTUP READY="); - Serial.print(initialized ? 1 : 0); - Serial.print(" DRIVERS_ENABLED=0 BOARD="); - Serial.println(physical_config.board_name); -} - -void loop() { - if (host_seen && !host_watchdog_tripped && - millis() - last_host_activity_ms >= kHostWatchdogMs && - (controller.state().azimuth.enabled || - controller.state().elevation.enabled)) { - controller.stop(); - controller.set_enabled(false); - host_watchdog_tripped = true; - Serial.println( - "EVENT FAULT CODE=DRIVER_DISABLED DETAIL=HOST_HEARTBEAT_TIMEOUT"); - } - - const std::string event = engine.service(); - if (!event.empty()) { - Serial.println(event.c_str()); - } - - while (Serial.available() > 0) { - const char character = static_cast(Serial.read()); - if (character == '\n') { - last_host_activity_ms = millis(); - host_seen = true; - host_watchdog_tripped = false; - Serial.println(engine.handle(incoming.c_str()).c_str()); - incoming = ""; - } else if (character != '\r') { - if (incoming.length() < 255) { - incoming += character; - } else { - incoming = ""; - Serial.println("ERR INVALID_ARGUMENT input line exceeds 255 bytes"); - } - } - } -} - -#elif !defined(UNIT_TEST) - -#include -#include - -int main() { - radiance3d::ProtocolEngine engine; - std::string line; - while (std::getline(std::cin, line)) { - std::cout << engine.handle(line) << '\n'; - const std::string event = engine.service(); - if (!event.empty()) { - std::cout << event << '\n'; - } - } - return 0; -} - -#endif diff --git a/firmware/controller/src/motion_controller.cpp b/firmware/controller/src/motion_controller.cpp index 51581af..c575eac 100644 --- a/firmware/controller/src/motion_controller.cpp +++ b/firmware/controller/src/motion_controller.cpp @@ -2,6 +2,7 @@ #include #include +#include #include namespace radiance3d { @@ -16,9 +17,21 @@ bool angle_in_range(const double value, const AxisConfig& config) { } // namespace +bool RationalGearRatio::valid() const { + return numerator > 0 && denominator > 0; +} + +double RationalGearRatio::as_double() const { + return valid() ? static_cast(numerator) / + static_cast(denominator) + : 0.0; +} + double AxisConfig::steps_per_output_revolution() const { - return static_cast(motor_full_steps_per_revolution) * - static_cast(microsteps) * gear_ratio; + std::int64_t steps = 0; + return steps_per_output_revolution_exact(steps) + ? static_cast(steps) + : 0.0; } double AxisConfig::commanded_step_angle_deg() const { @@ -27,8 +40,10 @@ double AxisConfig::commanded_step_angle_deg() const { } bool AxisConfig::valid() const { + std::int64_t steps = 0; return motor_full_steps_per_revolution > 0 && microsteps > 0 && - finite_positive(gear_ratio) && std::isfinite(home_offset_deg) && + gear_ratio.valid() && steps_per_output_revolution_exact(steps) && + std::isfinite(home_offset_deg) && std::isfinite(minimum_angle_deg) && std::isfinite(maximum_angle_deg) && minimum_angle_deg < maximum_angle_deg && home_offset_deg >= minimum_angle_deg && home_offset_deg <= maximum_angle_deg && @@ -41,6 +56,33 @@ bool AxisConfig::valid() const { homing.timeout_ms > 0; } +bool AxisConfig::output_steps_per_motor_full_step( + std::int64_t& steps) const { + if (microsteps == 0 || !gear_ratio.valid()) { + return false; + } + const std::int64_t scaled = + static_cast(microsteps) * gear_ratio.numerator; + if (scaled <= 0 || scaled % gear_ratio.denominator != 0) { + return false; + } + steps = scaled / gear_ratio.denominator; + return steps > 0; +} + +bool AxisConfig::steps_per_output_revolution_exact( + std::int64_t& steps) const { + std::int64_t per_full_step = 0; + if (motor_full_steps_per_revolution == 0 || + !output_steps_per_motor_full_step(per_full_step) || + per_full_step > std::numeric_limits::max() / + motor_full_steps_per_revolution) { + return false; + } + steps = per_full_step * motor_full_steps_per_revolution; + return steps > 0; +} + bool ControllerConfig::valid() const { return azimuth.valid() && elevation.valid() && motion_timeout_ms > 0; } diff --git a/firmware/controller/src/physical_motion_controller.cpp b/firmware/controller/src/physical_motion_controller.cpp index 794fed6..0df0687 100644 --- a/firmware/controller/src/physical_motion_controller.cpp +++ b/firmware/controller/src/physical_motion_controller.cpp @@ -102,11 +102,29 @@ void PhysicalMotionController::synchronize_state() { state_.azimuth = azimuth_.state(); state_.elevation = elevation_.state(); state_.emergency_stop_active = emergency_latched_; + if (emergency_latched_) { + state_.fault = FaultCode::emergency_stop; + } else { + const FaultCode axis_fault = active_axis_fault(); + if (axis_fault != FaultCode::none) { + state_.fault = axis_fault; + } + } state_.stopped = emergency_latched_ || (!state_.azimuth.moving && !state_.elevation.moving && state_.fault == FaultCode::stopped); } +FaultCode PhysicalMotionController::active_axis_fault() const { + // Emergency stop is handled by synchronize_state() first. Prefer an + // azimuth fault only when both occur in the same service cycle; either is + // promoted to the controller so protocol events cannot claim completion. + if (azimuth_.state().fault != FaultCode::none) { + return azimuth_.state().fault; + } + return elevation_.state().fault; +} + bool PhysicalMotionController::initialize() { state_ = ControllerState{}; const GpioValidationResult gpio = @@ -117,7 +135,7 @@ bool PhysicalMotionController::initialize() { } if (physical_config_.emergency_stop_pin >= 0 && !platform_.configure_pin(physical_config_.emergency_stop_pin, - PinMode::input_pullup)) { + physical_config_.emergency_stop_input_mode)) { state_.fault = FaultCode::invalid_configuration; return false; } @@ -175,6 +193,16 @@ void PhysicalMotionController::service() { synchronize_state(); } +void PhysicalMotionController::service_diagnostics() { + if (!initialized_ || emergency_latched_ || azimuth_.state().moving || + elevation_.state().moving) { + return; + } + azimuth_.service_diagnostics(); + elevation_.service_diagnostics(); + synchronize_state(); +} + MotionResult PhysicalMotionController::home( const AxisSelection axis, const std::uint32_t command_id) { if (!initialized_ || emergency_latched_) { @@ -233,6 +261,10 @@ MotionResult PhysicalMotionController::move_absolute( MotionResult PhysicalMotionController::move_relative( const AxisSelection axis, const double delta_deg, const double speed_deg_per_s, const std::uint32_t command_id) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } if (axis == AxisSelection::both) { const MotionResult azimuth_result = azimuth_.move_relative_degrees( delta_deg, speed_deg_per_s, command_id); @@ -250,9 +282,8 @@ MotionResult PhysicalMotionController::move_relative( return MotionResult{true, FaultCode::none}; } AxisController* selected = selected_axis(axis); - if (selected == nullptr || emergency_latched_) { - return reject(emergency_latched_ ? FaultCode::emergency_stop - : FaultCode::invalid_argument); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); } const MotionResult result = selected->move_relative_degrees( delta_deg, speed_deg_per_s, command_id); @@ -263,10 +294,13 @@ MotionResult PhysicalMotionController::move_relative( MotionResult PhysicalMotionController::bench_move_steps( const AxisSelection axis, const std::int64_t signed_steps, const std::uint32_t command_id) { - AxisController* selected = selected_axis(axis); - if (selected == nullptr || emergency_latched_) { + if (!initialized_ || emergency_latched_) { return reject(emergency_latched_ ? FaultCode::emergency_stop - : FaultCode::invalid_argument); + : FaultCode::invalid_configuration); + } + AxisController* selected = selected_axis(axis); + if (selected == nullptr) { + return reject(FaultCode::invalid_argument); } const MotionResult result = selected->bench_move_steps(signed_steps, command_id); @@ -326,12 +360,17 @@ MotionResult PhysicalMotionController::clear_fault() { } MotionResult PhysicalMotionController::set_enabled(const bool enabled) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } const MotionResult azimuth_result = azimuth_.set_enabled(enabled); const MotionResult elevation_result = elevation_.set_enabled(enabled); if (!azimuth_result.ok || !elevation_result.ok) { return reject(!azimuth_result.ok ? azimuth_result.fault : elevation_result.fault); } + state_.fault = enabled ? FaultCode::none : FaultCode::driver_disabled; synchronize_state(); return MotionResult{true, enabled ? FaultCode::none : FaultCode::driver_disabled}; @@ -339,6 +378,10 @@ MotionResult PhysicalMotionController::set_enabled(const bool enabled) { MotionResult PhysicalMotionController::set_axis_enabled( const AxisSelection axis, const bool enabled) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } if (axis == AxisSelection::both) { return set_enabled(enabled); } @@ -347,28 +390,59 @@ MotionResult PhysicalMotionController::set_axis_enabled( return reject(FaultCode::invalid_argument); } const MotionResult result = selected->set_enabled(enabled); + if (result.ok) { + state_.fault = enabled ? FaultCode::none : FaultCode::driver_disabled; + } synchronize_state(); return result; } MotionResult PhysicalMotionController::set_axis_current( const AxisSelection axis, const std::uint16_t rms_current_ma) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } AxisController* selected = selected_axis(axis); if (selected == nullptr) { return reject(FaultCode::invalid_argument); } const MotionResult result = selected->set_current(rms_current_ma); + if (result.ok) { + if (axis == AxisSelection::azimuth) { + config_.azimuth = azimuth_.config().motion; + physical_config_.azimuth.axis.motion = config_.azimuth; + } else { + config_.elevation = elevation_.config().motion; + physical_config_.elevation.axis.motion = config_.elevation; + } + state_.fault = FaultCode::none; + } synchronize_state(); return result; } MotionResult PhysicalMotionController::set_axis_microsteps( const AxisSelection axis, const std::uint16_t microsteps) { + if (!initialized_ || emergency_latched_) { + return reject(emergency_latched_ ? FaultCode::emergency_stop + : FaultCode::invalid_configuration); + } AxisController* selected = selected_axis(axis); if (selected == nullptr) { return reject(FaultCode::invalid_argument); } const MotionResult result = selected->set_microsteps(microsteps); + if (result.ok) { + if (axis == AxisSelection::azimuth) { + config_.azimuth = azimuth_.config().motion; + physical_config_.azimuth.axis.motion = config_.azimuth; + } else { + config_.elevation = elevation_.config().motion; + physical_config_.elevation.axis.motion = config_.elevation; + } + state_.fault = FaultCode::none; + } synchronize_state(); return result; } diff --git a/firmware/controller/src/protocol.cpp b/firmware/controller/src/protocol.cpp index 3b2b595..d188d7e 100644 --- a/firmware/controller/src/protocol.cpp +++ b/firmware/controller/src/protocol.cpp @@ -8,10 +8,6 @@ #include #include -#ifndef RADIANCE3D_PROTOCOL_VERSION -#define RADIANCE3D_PROTOCOL_VERSION 1 -#endif - namespace radiance3d { namespace { @@ -190,9 +186,11 @@ ProtocolEngine::ProtocolEngine() : default_controller_(provisional_simulator_config()), controller_(&default_controller_) {} -ProtocolEngine::ProtocolEngine(MotionController& controller) +ProtocolEngine::ProtocolEngine(MotionController& controller, + const std::uint32_t protocol_version) : default_controller_(provisional_simulator_config()), - controller_(&controller) {} + controller_(&controller), + protocol_version_(protocol_version) {} const ControllerState& ProtocolEngine::state() const { return controller_->state(); @@ -323,7 +321,7 @@ std::string ProtocolEngine::handle_command(const std::string& line, } return "OK IDENTIFY DEVICE=Radiance3D CONTROLLER=motion " "PROTOCOL=" + - std::to_string(RADIANCE3D_PROTOCOL_VERSION) + + std::to_string(protocol_version_) + " MODE=" + (controller_ == &default_controller_ ? "SIMULATOR" : "PHYSICAL"); } @@ -489,7 +487,7 @@ std::string ProtocolEngine::handle_command(const std::string& line, << "OK MOTOR_CONFIG AXIS=" << axis_text << " FULL_STEPS=" << config.motor_full_steps_per_revolution << " MICROSTEPS=" << config.microsteps - << " GEAR_RATIO=" << config.gear_ratio + << " GEAR_RATIO=" << config.gear_ratio.as_double() << " MIN_DEG=" << config.minimum_angle_deg << " MAX_DEG=" << config.maximum_angle_deg << " MAX_SPEED=" << config.maximum_speed_deg_per_s @@ -634,4 +632,15 @@ std::string ProtocolEngine::service() { return event; } +std::string ProtocolEngine::host_heartbeat_timeout() { + controller_->stop(); + controller_->set_enabled(false); + const ControllerState& current = state(); + previous_estop_ = current.emergency_stop_active; + previous_fault_ = current.fault; + previous_azimuth_moving_ = current.azimuth.moving; + previous_elevation_moving_ = current.elevation.moving; + return "EVENT FAULT CODE=DRIVER_DISABLED DETAIL=HOST_HEARTBEAT_TIMEOUT"; +} + } // namespace radiance3d diff --git a/firmware/controller/src/tmc2209_driver.cpp b/firmware/controller/src/tmc2209_driver.cpp index cab8380..95de55d 100644 --- a/firmware/controller/src/tmc2209_driver.cpp +++ b/firmware/controller/src/tmc2209_driver.cpp @@ -50,6 +50,7 @@ std::uint16_t Tmc2209Driver::configured_microsteps() const { bool Tmc2209Driver::valid_config() const { return config_.address <= 3 && config_.uart_channel > 0 && + config_.uart_channel <= 2 && config_.uart_tx_pin >= 0 && config_.uart_rx_pin >= 0 && config_.step_pin >= 0 && config_.direction_pin >= 0 && config_.enable_pin >= 0 && config_.sense_resistor_milliohms > 0 && @@ -103,24 +104,30 @@ bool Tmc2209Driver::read_register(const std::uint8_t address, return false; } - std::uint8_t response[16] = {}; - const std::size_t received = - platform_.read_uart(config_.uart_channel, response, sizeof(response), - config_.uart_timeout_ms); - if (received < 8) { - return false; - } - for (std::size_t offset = 0; offset + 8 <= received; ++offset) { - const std::uint8_t* frame = response + offset; - if (frame[0] != kSync || frame[1] != kMasterAddress || - frame[2] != address || calculate_crc(frame, 7) != frame[7]) { - continue; + // A PDN_UART line can echo the request before returning the driver's reply. + // Read a bounded number of chunks and scan the aggregate buffer for a CRC + // valid reply rather than assuming the first bytes are the reply frame. + std::uint8_t response[32] = {}; + std::size_t received = 0; + const std::uint8_t maximum_reads = config_.write_echo_expected ? 4 : 2; + for (std::uint8_t read_count = 0; + read_count < maximum_reads && received < sizeof(response); + ++read_count) { + received += platform_.read_uart( + config_.uart_channel, response + received, sizeof(response) - received, + config_.uart_timeout_ms); + for (std::size_t offset = 0; offset + 8 <= received; ++offset) { + const std::uint8_t* frame = response + offset; + if (frame[0] != kSync || frame[1] != kMasterAddress || + frame[2] != address || calculate_crc(frame, 7) != frame[7]) { + continue; + } + value = (static_cast(frame[3]) << 24) | + (static_cast(frame[4]) << 16) | + (static_cast(frame[5]) << 8) | + static_cast(frame[6]); + return true; } - value = (static_cast(frame[3]) << 24) | - (static_cast(frame[4]) << 16) | - (static_cast(frame[5]) << 8) | - static_cast(frame[6]); - return true; } return false; } @@ -132,6 +139,13 @@ bool Tmc2209Driver::verify_write_counter(const std::uint8_t before) { static_cast(before + 1U); } +bool Tmc2209Driver::write_register_verified(const std::uint8_t address, + const std::uint32_t value) { + std::uint32_t before = 0; + return read_register(kRegisterIfcnt, before) && write_register(address, value) && + verify_write_counter(static_cast(before)); +} + bool Tmc2209Driver::initialize() { connected_ = false; enabled_ = false; @@ -145,25 +159,20 @@ bool Tmc2209Driver::initialize() { platform_.write_pin(config_.direction_pin, config_.direction_inverted); disable(); if (!platform_.begin_uart(config_.uart_channel, config_.uart_tx_pin, - config_.uart_rx_pin, config_.uart_baud)) { + config_.uart_rx_pin, config_.uart_baud) || + !platform_.configure_uart_half_duplex(config_.uart_channel, + config_.uart_single_wire)) { return false; } - std::uint32_t ifcnt = 0; - if (!read_register(kRegisterIfcnt, ifcnt)) { - return false; - } gconf_ = kGconfPdnDisable | kGconfMstepRegisterSelect | kGconfMultistepFilter; - if (!write_register(kRegisterGconf, gconf_) || - !verify_write_counter(static_cast(ifcnt))) { - return false; - } - if (!write_register(kRegisterSlaveconf, 2UL << 8) || - !write_register(kRegisterTpowerdown, 10) || - !write_register(kRegisterPwmconf, - 0xC10D0024UL | kPwmAutoscale | kPwmAutograd) || - !write_register(kRegisterGstat, 0x07UL)) { + if (!write_register_verified(kRegisterGconf, gconf_) || + !write_register_verified(kRegisterSlaveconf, 2UL << 8) || + !write_register_verified(kRegisterTpowerdown, 10) || + !write_register_verified(kRegisterPwmconf, + 0xC10D0024UL | kPwmAutoscale | kPwmAutograd) || + !write_register_verified(kRegisterGstat, 0x07UL)) { return false; } connected_ = true; @@ -234,8 +243,8 @@ bool Tmc2209Driver::set_current_milliamps( const std::uint32_t ihold_irun = static_cast(hold) | (static_cast(run) << 8) | (6UL << 16); - if (!write_register(kRegisterChopconf, chopconf_) || - !write_register(kRegisterIholdIrun, ihold_irun)) { + if (!write_register_verified(kRegisterChopconf, chopconf_) || + !write_register_verified(kRegisterIholdIrun, ihold_irun)) { connected_ = false; disable(); return false; @@ -286,7 +295,7 @@ bool Tmc2209Driver::set_microsteps(const std::uint16_t microsteps) { } chopconf_ = (chopconf_ & ~kChopconfMresMask) | (static_cast(code) << 24); - if (!write_register(kRegisterChopconf, chopconf_)) { + if (!write_register_verified(kRegisterChopconf, chopconf_)) { connected_ = false; disable(); return false; @@ -305,7 +314,12 @@ bool Tmc2209Driver::set_interpolation(const bool enabled) { } else { chopconf_ &= ~kChopconfInterpolation; } - return write_register(kRegisterChopconf, chopconf_); + if (!write_register_verified(kRegisterChopconf, chopconf_)) { + connected_ = false; + disable(); + return false; + } + return true; } bool Tmc2209Driver::set_chopper_mode(const ChopperMode mode) { @@ -318,7 +332,12 @@ bool Tmc2209Driver::set_chopper_mode(const ChopperMode mode) { } else { gconf_ &= ~kGconfSpreadcycle; } - return write_register(kRegisterGconf, gconf_); + if (!write_register_verified(kRegisterGconf, gconf_)) { + connected_ = false; + disable(); + return false; + } + return true; } DriverFault Tmc2209Driver::primary_fault(const DriverStatus& status) { diff --git a/scripts/generate_hardware_profile_header.py b/scripts/generate_hardware_profile_header.py new file mode 100644 index 0000000..5bcdfae --- /dev/null +++ b/scripts/generate_hardware_profile_header.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Validate the provisional hardware profile and emit a C++ configuration header. + +The generated header is deliberately a build artifact. Firmware defaults are not +maintained in a second handwritten C++ table: both the native ESP-IDF and host CMake +builds consume this output from the checked-in JSON profile. +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from decimal import Decimal, InvalidOperation +from math import gcd +from pathlib import Path +from typing import Any + + +class ProfileError(ValueError): + """Raised when a profile is not safe enough to compile into firmware.""" + + +def _mapping(value: object, path: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise ProfileError(f"{path} must be an object") + return value + + +def _int(value: object, path: str, *, minimum: int = 0, maximum: int | None = None) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ProfileError(f"{path} must be an integer") + if value < minimum or (maximum is not None and value > maximum): + range_text = f">= {minimum}" if maximum is None else f"in {minimum}..{maximum}" + raise ProfileError(f"{path} must be {range_text}") + return value + + +def _number(value: object, path: str, *, positive: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ProfileError(f"{path} must be a number") + parsed = float(value) + if not math.isfinite(parsed) or (positive and parsed <= 0.0): + qualifier = "finite and positive" if positive else "finite" + raise ProfileError(f"{path} must be {qualifier}") + return parsed + + +def _bool(value: object, path: str) -> bool: + if not isinstance(value, bool): + raise ProfileError(f"{path} must be true or false") + return value + + +def _string(value: object, path: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ProfileError(f"{path} must be a non-empty string") + return value + + +def _fraction(value: object, path: str) -> tuple[int, int]: + try: + decimal = Decimal(str(value)) + except (InvalidOperation, ValueError) as error: + raise ProfileError(f"{path} must be a finite decimal") from error + if not decimal.is_finite() or decimal <= 0: + raise ProfileError(f"{path} must be finite and positive") + sign, digits, exponent = decimal.as_tuple() + if sign: + raise ProfileError(f"{path} must be positive") + numerator = 0 + for digit in digits: + numerator = numerator * 10 + digit + denominator = 1 + if exponent < 0: + denominator = 10 ** (-exponent) + else: + numerator *= 10**exponent + divisor = gcd(numerator, denominator) + numerator //= divisor + denominator //= divisor + if numerator > 2**31 - 1 or denominator > 2**31 - 1: + raise ProfileError(f"{path} cannot be represented as a 32-bit rational") + return numerator, denominator + + +def _validate_input_pull( + profile: dict[str, Any], key_prefix: str, path_prefix: str +) -> None: + pullup = _bool(profile.get(f"{key_prefix}_pullup"), f"{path_prefix}_pullup") + pulldown = _bool(profile.get(f"{key_prefix}_pulldown"), f"{path_prefix}_pulldown") + if pullup and pulldown: + raise ProfileError(f"{path_prefix} cannot enable both pull-up and pull-down") + + +def _axis(profile: dict[str, Any], name: str) -> dict[str, Any]: + axis = _mapping(profile["axes"].get(name), f"axes.{name}") + prefix = f"axes.{name}" + required_numbers = ( + "minimum_angle_deg", + "maximum_angle_deg", + "home_offset_deg", + "max_speed_deg_s", + "acceleration_deg_s2", + "home_speed_deg_s", + "slow_home_speed_deg_s", + "homing_backoff_deg", + ) + for key in required_numbers: + _number(axis.get(key), f"{prefix}.{key}", positive=key not in {"minimum_angle_deg", "maximum_angle_deg", "home_offset_deg"}) + if _number(axis["maximum_angle_deg"], f"{prefix}.maximum_angle_deg") <= _number( + axis["minimum_angle_deg"], f"{prefix}.minimum_angle_deg" + ): + raise ProfileError(f"{prefix} maximum angle must exceed minimum angle") + for key, upper in ( + ("uart_channel", 2), + ("uart_address", 3), + ("uart_tx_pin", 39), + ("uart_rx_pin", 39), + ("step_pin", 39), + ("direction_pin", 39), + ("enable_pin", 39), + ("home_switch_pin", 39), + ("motor_full_steps_per_revolution", 65535), + ("microsteps", 256), + ("commissioning_current_ma", 65535), + ("maximum_rms_current_ma", 65535), + ("hold_current_percent", 100), + ("home_switch_debounce_ms", 60000), + ("settling_time_ms", 60000), + ("maximum_bench_test_steps", 2**31 - 1), + ("motion_timeout_ms", 2**31 - 1), + ): + _int(axis.get(key), f"{prefix}.{key}", minimum=1 if key not in {"uart_address"} else 0, maximum=upper) + if axis["commissioning_current_ma"] > axis["maximum_rms_current_ma"]: + raise ProfileError(f"{prefix} commissioning current exceeds its safe ceiling") + _string(axis.get("driver"), f"{prefix}.driver") + _string(axis.get("driver_profile"), f"{prefix}.driver_profile") + _bool(axis.get("direction_inverted"), f"{prefix}.direction_inverted") + _bool(axis.get("home_switch_normally_closed"), f"{prefix}.home_switch_normally_closed") + _bool(axis.get("homing_direction_negative"), f"{prefix}.homing_direction_negative") + _validate_input_pull(axis, "home_switch", f"{prefix}.home_switch") + numerator, denominator = _fraction(axis.get("gear_ratio"), f"{prefix}.gear_ratio") + # One physical motor full step must map to an integral number of emitted + # STEP pulses. This prevents a valid-looking decimal ratio from silently + # requiring fractional pulse counts at runtime. + if (axis["microsteps"] * numerator) % denominator != 0: + raise ProfileError( + f"{prefix}.gear_ratio and microsteps must produce integral output steps" + ) + return axis + + +def validate_profile(profile: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + controller = _mapping(profile.get("controller"), "controller") + _string(controller.get("board"), "controller.board") + _string(controller.get("module"), "controller.module") + _int(controller.get("protocol_version"), "controller.protocol_version", minimum=1, maximum=255) + _int(controller.get("usb_serial_baud"), "controller.usb_serial_baud", minimum=1200, maximum=2_000_000) + _int(controller.get("emergency_stop_pin"), "controller.emergency_stop_pin", minimum=0, maximum=39) + _bool(controller.get("emergency_stop_active_low"), "controller.emergency_stop_active_low") + _validate_input_pull(controller, "emergency_stop", "controller.emergency_stop") + _int( + controller.get("emergency_stop_debounce_ms"), + "controller.emergency_stop_debounce_ms", + minimum=1, + maximum=60000, + ) + + drivers = _mapping(profile.get("drivers"), "drivers") + _string(drivers.get("driver_family"), "drivers.driver_family") + _int(drivers.get("uart_baud", 115200), "drivers.uart_baud", minimum=1200, maximum=2_000_000) + _int(drivers.get("uart_timeout_ms", 20), "drivers.uart_timeout_ms", minimum=1, maximum=1000) + _int(drivers.get("sense_resistor_milliohms", 110), "drivers.sense_resistor_milliohms", minimum=1, maximum=1000) + _bool(drivers.get("enable_active_low", True), "drivers.enable_active_low") + _bool(drivers.get("single_wire_pdn_uart", True), "drivers.single_wire_pdn_uart") + _bool(drivers.get("write_echo_expected", True), "drivers.write_echo_expected") + + axes = _mapping(profile.get("axes"), "axes") + if set(("azimuth", "elevation")) - set(axes): + raise ProfileError("axes must contain azimuth and elevation") + azimuth = _axis(profile, "azimuth") + elevation = _axis(profile, "elevation") + pins = [ + controller["emergency_stop_pin"], + *[ + axis[key] + for axis in (azimuth, elevation) + for key in ("uart_tx_pin", "uart_rx_pin", "step_pin", "direction_pin", "enable_pin", "home_switch_pin") + ], + ] + if len(pins) != len(set(pins)): + raise ProfileError("controller and axis GPIO assignments must be unique") + for axis in (azimuth, elevation): + if axis["step_pin"] >= 34 or axis["direction_pin"] >= 34 or axis["enable_pin"] >= 34 or axis["uart_tx_pin"] >= 34: + raise ProfileError("STEP/DIR/ENABLE/TX cannot use input-only ESP32 GPIOs") + return controller, drivers, {"azimuth": azimuth, "elevation": elevation} + + +def _literal(value: str) -> str: + return json.dumps(value, ensure_ascii=True) + + +def _boolean(value: bool) -> str: + return "true" if value else "false" + + +def _floating(value: object) -> str: + return f"{float(value):.12g}" + + +def _axis_initializer(name: str, axis: dict[str, Any]) -> str: + numerator, denominator = _fraction(axis["gear_ratio"], "gear_ratio") + values = ( + _literal(name), + str(axis["uart_channel"]), + str(axis["uart_address"]), + str(axis["uart_tx_pin"]), + str(axis["uart_rx_pin"]), + str(axis["step_pin"]), + str(axis["direction_pin"]), + str(axis["enable_pin"]), + str(axis["home_switch_pin"]), + _boolean(axis["home_switch_normally_closed"]), + _boolean(axis["home_switch_pullup"]), + _boolean(axis["home_switch_pulldown"]), + _boolean(axis["homing_direction_negative"]), + str(axis["home_switch_debounce_ms"]), + str(axis["motor_full_steps_per_revolution"]), + str(axis["microsteps"]), + str(axis["commissioning_current_ma"]), + str(axis["maximum_rms_current_ma"]), + str(axis["hold_current_percent"]), + str(numerator), + str(denominator), + _boolean(axis["direction_inverted"]), + _floating(axis["home_offset_deg"]), + _floating(axis["minimum_angle_deg"]), + _floating(axis["maximum_angle_deg"]), + _floating(axis["max_speed_deg_s"]), + _floating(axis["acceleration_deg_s2"]), + _floating(axis["home_speed_deg_s"]), + _floating(axis["slow_home_speed_deg_s"]), + _floating(axis["homing_backoff_deg"]), + str(axis["settling_time_ms"]), + str(axis["maximum_bench_test_steps"]), + str(axis["motion_timeout_ms"]), + ) + return ",\n ".join(values) + + +def render_header(controller: dict[str, Any], drivers: dict[str, Any], axes: dict[str, dict[str, Any]]) -> str: + return f"""// Generated by scripts/generate_hardware_profile_header.py. Do not edit. +#pragma once + +#include + +namespace radiance3d {{ +namespace generated_profile {{ + +struct AxisProfile {{ + const char* name; + std::uint8_t uart_channel; + std::uint8_t uart_address; + int uart_tx_pin; + int uart_rx_pin; + int step_pin; + int direction_pin; + int enable_pin; + int home_switch_pin; + bool home_switch_normally_closed; + bool home_switch_pullup; + bool home_switch_pulldown; + bool homing_direction_negative; + std::uint32_t home_switch_debounce_ms; + std::uint16_t motor_full_steps_per_revolution; + std::uint16_t microsteps; + std::uint16_t commissioning_current_ma; + std::uint16_t maximum_rms_current_ma; + std::uint8_t hold_current_percent; + std::int32_t gear_ratio_numerator; + std::int32_t gear_ratio_denominator; + bool direction_inverted; + double home_offset_deg; + double minimum_angle_deg; + double maximum_angle_deg; + double maximum_speed_deg_per_s; + double acceleration_deg_per_s2; + double home_speed_deg_per_s; + double slow_home_speed_deg_per_s; + double homing_backoff_deg; + std::uint32_t settling_time_ms; + std::int64_t maximum_bench_test_steps; + std::uint32_t motion_timeout_ms; +}}; + +constexpr const char kBoardName[] = {_literal(controller["board"])}; +constexpr const char kBoardModule[] = {_literal(controller["module"])}; +constexpr std::uint32_t kProtocolVersion = {controller["protocol_version"]}U; +constexpr std::uint32_t kHostUartBaud = {controller["usb_serial_baud"]}U; +constexpr int kEmergencyStopPin = {controller["emergency_stop_pin"]}; +constexpr bool kEmergencyStopActiveLow = {_boolean(controller["emergency_stop_active_low"])}; +constexpr bool kEmergencyStopPullup = {_boolean(controller["emergency_stop_pullup"])}; +constexpr bool kEmergencyStopPulldown = {_boolean(controller["emergency_stop_pulldown"])}; +constexpr std::uint32_t kEmergencyStopDebounceMs = {controller["emergency_stop_debounce_ms"]}U; +constexpr std::uint32_t kTmcUartBaud = {drivers.get("uart_baud", 115200)}U; +constexpr std::uint32_t kTmcUartTimeoutMs = {drivers.get("uart_timeout_ms", 20)}U; +constexpr std::uint16_t kTmcSenseResistorMilliohms = {drivers.get("sense_resistor_milliohms", 110)}U; +constexpr bool kTmcEnableActiveLow = {_boolean(drivers.get("enable_active_low", True))}; +constexpr bool kTmcSingleWirePdnUart = {_boolean(drivers.get("single_wire_pdn_uart", True))}; +constexpr bool kTmcWriteEchoExpected = {_boolean(drivers.get("write_echo_expected", True))}; + +constexpr AxisProfile kAzimuth = {{ + {_axis_initializer("azimuth", axes["azimuth"])} +}}; + +constexpr AxisProfile kElevation = {{ + {_axis_initializer("elevation", axes["elevation"])} +}}; + +}} // namespace generated_profile +}} // namespace radiance3d +""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", type=Path, required=True) + parser.add_argument("--output", type=Path) + parser.add_argument("--validate-only", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.validate_only and args.output is not None: + print("--validate-only and --output cannot be combined", file=sys.stderr) + return 2 + if not args.validate_only and args.output is None: + print("--output is required unless --validate-only is used", file=sys.stderr) + return 2 + try: + profile = _mapping(json.loads(args.profile.read_text(encoding="utf-8")), "profile") + controller, drivers, axes = validate_profile(profile) + except (OSError, json.JSONDecodeError, ProfileError) as error: + print(f"hardware profile validation failed: {error}", file=sys.stderr) + return 1 + if args.validate_only: + print(f"hardware profile valid: {args.profile}") + return 0 + assert args.output is not None + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render_header(controller, drivers, axes), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 2743331ab650acf505190cc1734276f3b77a5450 Mon Sep 17 00:00:00 2001 From: bostromdev Date: Fri, 31 Jul 2026 01:23:43 -0400 Subject: [PATCH 12/13] test(firmware): cover native protocol and safety paths --- .github/workflows/firmware.yml | 44 +++++-- firmware/controller/host/CMakeLists.txt | 93 ++++++++++++++ firmware/controller/host/simulator_main.cpp | 64 ++++++++++ .../host/test_support/host_test.hpp | 117 ++++++++++++++++++ firmware/controller/test/README.md | 18 ++- .../controller/test/test_axis/test_main.cpp | 62 +++++++++- .../controller/test/test_driver/test_main.cpp | 42 ++++++- .../controller/test/test_motion/test_main.cpp | 24 +++- .../test/test_physical/test_main.cpp | 97 ++++++++++++++- scripts/check_repository.py | 69 ++++++++++- .../tests/test_firmware_simulator_protocol.py | 108 ++++++++++++++++ 11 files changed, 718 insertions(+), 20 deletions(-) create mode 100644 firmware/controller/host/CMakeLists.txt create mode 100644 firmware/controller/host/simulator_main.cpp create mode 100644 firmware/controller/host/test_support/host_test.hpp create mode 100644 software/tests/test_firmware_simulator_protocol.py diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 512be62..5059754 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -4,32 +4,58 @@ on: pull_request: paths: - "firmware/**" + - "software/src/radiance3d/transport.py" + - "software/src/radiance3d/motion_client.py" + - "software/tests/test_firmware_simulator_protocol.py" + - "scripts/generate_hardware_profile_header.py" - ".github/workflows/firmware.yml" push: branches: [main] paths: - "firmware/**" + - "software/src/radiance3d/transport.py" + - "software/src/radiance3d/motion_client.py" + - "software/tests/test_firmware_simulator_protocol.py" + - "scripts/generate_hardware_profile_header.py" - ".github/workflows/firmware.yml" permissions: contents: read jobs: - native-simulator: + host-firmware: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install PlatformIO - run: python -m pip install "platformio>=6.1,<7" - - name: Build native simulator + - name: Configure portable firmware build working-directory: firmware/controller - run: pio run -e native - - name: Test motion and protocol behavior + run: cmake -S host -B build-host -DCMAKE_BUILD_TYPE=Release + - name: Build portable simulator and tests working-directory: firmware/controller - run: pio test -e native - - name: Compile provisional ESP32 target + run: cmake --build build-host --parallel 2 + - name: Test portable motion and protocol behavior working-directory: firmware/controller - run: pio run -e esp32dev + run: ctest --test-dir build-host --output-on-failure + - name: Test Python host compatibility against the native simulator + working-directory: software + run: | + python -m pip install -e ".[dev]" + RADIANCE3D_SIMULATOR="$GITHUB_WORKSPACE/firmware/controller/build-host/radiance3d-simulator" pytest -q tests/test_firmware_simulator_protocol.py + + esp-idf: + runs-on: ubuntu-latest + container: espressif/idf:v5.5.4 + steps: + - uses: actions/checkout@v4 + - name: Configure ESP32 target + working-directory: firmware/controller + run: idf.py set-target esp32 + - name: Build native ESP-IDF firmware + working-directory: firmware/controller + run: idf.py build + - name: Report native firmware size + working-directory: firmware/controller + run: idf.py size diff --git a/firmware/controller/host/CMakeLists.txt b/firmware/controller/host/CMakeLists.txt new file mode 100644 index 0000000..3397ae2 --- /dev/null +++ b/firmware/controller/host/CMakeLists.txt @@ -0,0 +1,93 @@ +cmake_minimum_required(VERSION 3.20) +project(radiance3d_controller_host LANGUAGES CXX) + +include(CTest) +find_package(Python3 REQUIRED COMPONENTS Interpreter) + +set(RADIANCE3D_CONTROLLER_DIR "${CMAKE_CURRENT_LIST_DIR}/..") +set(RADIANCE3D_REPOSITORY_DIR "${RADIANCE3D_CONTROLLER_DIR}/../..") +set(RADIANCE3D_GENERATED_INCLUDE_DIR "${CMAKE_BINARY_DIR}/generated") +file(MAKE_DIRECTORY "${RADIANCE3D_GENERATED_INCLUDE_DIR}") +set(RADIANCE3D_GENERATED_PROFILE_HEADER + "${RADIANCE3D_GENERATED_INCLUDE_DIR}/hardware_profile_generated.hpp") +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${RADIANCE3D_CONTROLLER_DIR}/../config/provisional-esp32dev-v1.json" + "${RADIANCE3D_REPOSITORY_DIR}/scripts/generate_hardware_profile_header.py") + +execute_process( + COMMAND "${Python3_EXECUTABLE}" + "${RADIANCE3D_REPOSITORY_DIR}/scripts/generate_hardware_profile_header.py" + --profile "${RADIANCE3D_CONTROLLER_DIR}/../config/provisional-esp32dev-v1.json" + --output "${RADIANCE3D_GENERATED_PROFILE_HEADER}" + RESULT_VARIABLE RADIANCE3D_PROFILE_RESULT + ERROR_VARIABLE RADIANCE3D_PROFILE_ERROR +) +if(NOT RADIANCE3D_PROFILE_RESULT EQUAL 0) + message(FATAL_ERROR "Could not generate hardware profile header: ${RADIANCE3D_PROFILE_ERROR}") +endif() + +set(CMAKE_CXX_STANDARD 14) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Some Command Line Tools installations keep libc++ only in the active SDK. +# Add it as a system include when present; normal macOS and non-macOS builds +# keep their compiler-default standard-library search paths unchanged. +set(RADIANCE3D_HOST_SDK_CXX_INCLUDE "") +if(APPLE) + execute_process( + COMMAND xcrun --show-sdk-path + OUTPUT_VARIABLE RADIANCE3D_HOST_SDK_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(EXISTS "${RADIANCE3D_HOST_SDK_PATH}/usr/include/c++/v1/cstdint") + set(RADIANCE3D_HOST_SDK_CXX_INCLUDE + "${RADIANCE3D_HOST_SDK_PATH}/usr/include/c++/v1") + endif() +endif() + +add_library(radiance3d_core + "${RADIANCE3D_CONTROLLER_DIR}/src/motion_controller.cpp" + "${RADIANCE3D_CONTROLLER_DIR}/src/protocol.cpp" +) +target_include_directories(radiance3d_core PUBLIC + "${RADIANCE3D_CONTROLLER_DIR}/include" + "${RADIANCE3D_GENERATED_INCLUDE_DIR}" +) +if(RADIANCE3D_HOST_SDK_CXX_INCLUDE) + target_include_directories(radiance3d_core SYSTEM BEFORE PUBLIC + "${RADIANCE3D_HOST_SDK_CXX_INCLUDE}" + ) +endif() + +add_library(radiance3d_physical + "${RADIANCE3D_CONTROLLER_DIR}/src/axis_controller.cpp" + "${RADIANCE3D_CONTROLLER_DIR}/src/hardware_config.cpp" + "${RADIANCE3D_CONTROLLER_DIR}/src/physical_motion_controller.cpp" + "${RADIANCE3D_CONTROLLER_DIR}/src/tmc2209_driver.cpp" +) +target_link_libraries(radiance3d_physical PUBLIC radiance3d_core) +target_include_directories(radiance3d_physical PUBLIC + "${RADIANCE3D_CONTROLLER_DIR}/include" + "${RADIANCE3D_GENERATED_INCLUDE_DIR}" +) + +add_executable(radiance3d-simulator simulator_main.cpp) +target_link_libraries(radiance3d-simulator PRIVATE radiance3d_core) + +add_library(radiance3d_host_test INTERFACE) +target_include_directories(radiance3d_host_test INTERFACE + "${CMAKE_CURRENT_LIST_DIR}/test_support" +) + +function(add_radiance3d_test name source) + add_executable(${name} "${source}") + target_link_libraries(${name} PRIVATE radiance3d_physical radiance3d_host_test) + add_test(NAME ${name} COMMAND ${name}) +endfunction() + +add_radiance3d_test(test_axis "${RADIANCE3D_CONTROLLER_DIR}/test/test_axis/test_main.cpp") +add_radiance3d_test(test_driver "${RADIANCE3D_CONTROLLER_DIR}/test/test_driver/test_main.cpp") +add_radiance3d_test(test_motion "${RADIANCE3D_CONTROLLER_DIR}/test/test_motion/test_main.cpp") +add_radiance3d_test(test_physical "${RADIANCE3D_CONTROLLER_DIR}/test/test_physical/test_main.cpp") diff --git a/firmware/controller/host/simulator_main.cpp b/firmware/controller/host/simulator_main.cpp new file mode 100644 index 0000000..b558f1f --- /dev/null +++ b/firmware/controller/host/simulator_main.cpp @@ -0,0 +1,64 @@ +#include "protocol.hpp" + +#include +#include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#include +#endif + +namespace { + +constexpr std::chrono::milliseconds kPollInterval{20}; +constexpr std::chrono::seconds kHostHeartbeatTimeout{2}; + +bool stdin_ready() { +#if defined(__unix__) || defined(__APPLE__) + pollfd input = {}; + input.fd = STDIN_FILENO; + input.events = POLLIN; + return poll(&input, 1, static_cast(kPollInterval.count())) > 0; +#else + return true; +#endif +} + +void print_line(const std::string& line) { + std::cout << line << '\n'; + std::cout.flush(); +} + +} // namespace + +int main() { + radiance3d::ProtocolEngine engine; + std::string line; + bool host_seen = false; + bool heartbeat_tripped = false; + auto last_host_activity = std::chrono::steady_clock::now(); + for (;;) { + if (stdin_ready()) { + if (!std::getline(std::cin, line)) { + break; + } + last_host_activity = std::chrono::steady_clock::now(); + host_seen = true; + heartbeat_tripped = false; + print_line(engine.handle(line)); + } + const auto now = std::chrono::steady_clock::now(); + if (host_seen && !heartbeat_tripped && + (engine.state().azimuth.enabled || engine.state().elevation.enabled) && + now - last_host_activity >= kHostHeartbeatTimeout) { + print_line(engine.host_heartbeat_timeout()); + heartbeat_tripped = true; + } + const std::string event = engine.service(); + if (!event.empty()) { + print_line(event); + } + } + return 0; +} diff --git a/firmware/controller/host/test_support/host_test.hpp b/firmware/controller/host/test_support/host_test.hpp new file mode 100644 index 0000000..0946974 --- /dev/null +++ b/firmware/controller/host/test_support/host_test.hpp @@ -0,0 +1,117 @@ +#pragma once + +// A tiny host-only Unity compatibility layer. ESP-IDF component tests use +// ESP-IDF's Unity component; portable CTest builds avoid a network dependency. + +#include +#include +#include +#include +#include +#include +#include + +namespace radiance3d_host_test { + +class AssertionFailure final : public std::runtime_error { + public: + explicit AssertionFailure(const std::string& message) : std::runtime_error(message) {} +}; + +inline int& failures() { + static int value = 0; + return value; +} + +inline int begin() { return 0; } + +inline int end() { return failures(); } + +[[noreturn]] inline void fail(const char* expression, const char* file, int line, + const std::string& detail = "") { + std::ostringstream output; + output << file << ':' << line << ": assertion failed: " << expression; + if (!detail.empty()) { + output << " (" << detail << ')'; + } + throw AssertionFailure(output.str()); +} + +template +inline void equal(const Expected& expected, const Actual& actual, const char* expression, + const char* file, int line) { + if (!(expected == actual)) { + fail(expression, file, line); + } +} + +template +inline void not_equal(const Expected& expected, const Actual& actual, + const char* expression, const char* file, int line) { + if (expected == actual) { + fail(expression, file, line); + } +} + +template +inline void float_within(const Expected& delta, const Actual& expected, const Actual& actual, + const char* expression, const char* file, int line) { + if (std::fabs(static_cast(expected) - static_cast(actual)) > + static_cast(delta)) { + fail(expression, file, line); + } +} + +template +inline void run(const char* name, Function&& function) { + try { + function(); + } catch (const std::exception& error) { + ++failures(); + std::cerr << "FAILED " << name << ": " << error.what() << '\n'; + } catch (...) { + ++failures(); + std::cerr << "FAILED " << name << ": unknown exception\n"; + } +} + +} // namespace radiance3d_host_test + +#define UNITY_BEGIN() ::radiance3d_host_test::begin() +#define UNITY_END() ::radiance3d_host_test::end() +#define RUN_TEST(function) \ + ::radiance3d_host_test::run(#function, [] { \ + setUp(); \ + try { \ + function(); \ + } catch (...) { \ + tearDown(); \ + throw; \ + } \ + tearDown(); \ + }) + +#define TEST_ASSERT_TRUE(condition) \ + do { \ + if (!(condition)) { \ + ::radiance3d_host_test::fail(#condition, __FILE__, __LINE__); \ + } \ + } while (false) +#define TEST_ASSERT_FALSE(condition) TEST_ASSERT_TRUE(!(condition)) +#define TEST_ASSERT_EQUAL(expected, actual) \ + ::radiance3d_host_test::equal((expected), (actual), #expected " == " #actual, \ + __FILE__, __LINE__) +#define TEST_ASSERT_NOT_EQUAL(expected, actual) \ + ::radiance3d_host_test::not_equal((expected), (actual), #expected " != " #actual, \ + __FILE__, __LINE__) +#define TEST_ASSERT_EQUAL_INT(expected, actual) TEST_ASSERT_EQUAL((expected), (actual)) +#define TEST_ASSERT_EQUAL_INT64(expected, actual) TEST_ASSERT_EQUAL((expected), (actual)) +#define TEST_ASSERT_EQUAL_UINT8(expected, actual) TEST_ASSERT_EQUAL((expected), (actual)) +#define TEST_ASSERT_EQUAL_UINT16(expected, actual) TEST_ASSERT_EQUAL((expected), (actual)) +#define TEST_ASSERT_EQUAL_UINT32(expected, actual) TEST_ASSERT_EQUAL((expected), (actual)) +#define TEST_ASSERT_NOT_EQUAL_INT64(expected, actual) TEST_ASSERT_NOT_EQUAL((expected), (actual)) +#define TEST_ASSERT_EQUAL_STRING(expected, actual) \ + TEST_ASSERT_TRUE(std::strcmp((expected), (actual)) == 0) +#define TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual) \ + ::radiance3d_host_test::float_within((delta), (expected), (actual), \ + #expected " ~= " #actual, __FILE__, __LINE__) diff --git a/firmware/controller/test/README.md b/firmware/controller/test/README.md index d9d8837..906688b 100644 --- a/firmware/controller/test/README.md +++ b/firmware/controller/test/README.md @@ -1,12 +1,18 @@ # Firmware tests -PlatformIO native tests exercise configuration-derived angular conversion, configured -travel limits, homing, position confidence, driver-disable behavior, and the protocol -synchronization boundary. Run them with: +Portable CTest executables exercise configuration-derived angular conversion, rational +gearing, configured travel limits, homing, trust loss, driver-disable behavior, +TMC2209 CRC/IFCNT/write-echo handling, GPIO validation, e-stop latching, coordinated +completion, and protocol synchronization. ```bash -pio test -e native +cmake -S ../host -B ../build-host -DCMAKE_BUILD_TYPE=Release +cmake --build ../build-host +ctest --test-dir ../build-host --output-on-failure ``` -These tests exercise the simulator only. Physical hardware tests must state board -revision, wiring, load, supply, and safety controls. +ESP-IDF CI compiles the physical project with `idf.py build`. Target-side tests require +an ESP32 test device and are not presented as a substitute for commissioning. Python CI +also runs the unchanged serial host client against the CMake-built simulator. Physical +test records must state board revision, wiring, load, supply, measurement instrument, +and safety controls. diff --git a/firmware/controller/test/test_axis/test_main.cpp b/firmware/controller/test/test_axis/test_main.cpp index ea98eba..48fcfb0 100644 --- a/firmware/controller/test/test_axis/test_main.cpp +++ b/firmware/controller/test/test_axis/test_main.cpp @@ -1,4 +1,8 @@ +#ifdef ESP_PLATFORM #include +#else +#include "host_test.hpp" +#endif #include #include @@ -103,6 +107,39 @@ class FakeDriver final : public radiance3d::StepperDriver { bool positive_{true}; }; +class FakePulseScheduler final : public radiance3d::StepPulseScheduler { + public: + bool initialize() override { + initialized = true; + return true; + } + bool schedule_pulse(std::uint32_t delay_before_rising_us) override { + if (!initialized || active) { + return false; + } + active = true; + last_delay_us = delay_before_rising_us; + ++scheduled_pulses; + return true; + } + void stop() override { active = false; } + std::uint32_t consume_completed_pulses() override { + const std::uint32_t value = completed_pulses; + completed_pulses = 0; + return value; + } + void complete_one() { + active = false; + ++completed_pulses; + } + + bool initialized{false}; + bool active{false}; + std::uint32_t last_delay_us{0}; + std::uint32_t scheduled_pulses{0}; + std::uint32_t completed_pulses{0}; +}; + radiance3d::PhysicalAxisConfig axis_config() { radiance3d::PhysicalAxisConfig config; config.name = "elevation"; @@ -110,7 +147,7 @@ radiance3d::PhysicalAxisConfig axis_config() { config.motion.motor_full_steps_per_revolution = 200; config.motion.microsteps = 1; config.motion.motor_rms_current_ma = 400; - config.motion.gear_ratio = 1.0; + config.motion.gear_ratio = {1, 1}; config.motion.minimum_angle_deg = -90.0; config.motion.maximum_angle_deg = 90.0; config.motion.home_offset_deg = 0.0; @@ -203,6 +240,28 @@ void test_valid_motion_finishes_on_integer_target_and_limits_are_strict() { axis.move_absolute_degrees(-91.0, 20.0).fault); } +void test_timer_scheduler_keeps_integer_position_ownership_in_axis_core() { + FakePlatform platform; + FakeDriver driver; + FakePulseScheduler scheduler; + radiance3d::AxisController axis(platform, driver, axis_config(), &scheduler); + TEST_ASSERT_TRUE(axis.initialize()); + axis.mutable_state().position_trusted = true; + axis.mutable_state().homed = true; + + TEST_ASSERT_TRUE(axis.move_absolute_degrees(18.0, 20.0, 8).ok); + TEST_ASSERT_TRUE(scheduler.active); + TEST_ASSERT_TRUE(scheduler.last_delay_us >= 5U); + for (int index = 0; index < 10; ++index) { + scheduler.complete_one(); + axis.service(); + } + + TEST_ASSERT_FALSE(axis.state().moving); + TEST_ASSERT_EQUAL_INT64(10, axis.state().internal_step_position); + TEST_ASSERT_EQUAL_UINT32(10, scheduler.scheduled_pulses); +} + void test_stop_and_timeout_disable_motion_and_lose_trust() { FakePlatform platform; FakeDriver driver; @@ -330,6 +389,7 @@ int main(int, char**) { RUN_TEST(test_conversions_use_integer_steps_and_half_away_from_zero_rounding); RUN_TEST(test_absolute_and_relative_motion_require_homing); RUN_TEST(test_valid_motion_finishes_on_integer_target_and_limits_are_strict); + RUN_TEST(test_timer_scheduler_keeps_integer_position_ownership_in_axis_core); RUN_TEST(test_stop_and_timeout_disable_motion_and_lose_trust); RUN_TEST(test_stuck_active_home_switch_fails_without_motion); RUN_TEST(test_homing_times_out_when_switch_never_activates); diff --git a/firmware/controller/test/test_driver/test_main.cpp b/firmware/controller/test/test_driver/test_main.cpp index a80e1f4..b7f382f 100644 --- a/firmware/controller/test/test_driver/test_main.cpp +++ b/firmware/controller/test/test_driver/test_main.cpp @@ -1,5 +1,10 @@ +#ifdef ESP_PLATFORM #include +#else +#include "host_test.hpp" +#endif +#include #include #include #include @@ -13,6 +18,8 @@ class FakePlatform final : public radiance3d::HardwarePlatform { public: bool uart_present{true}; bool uart_started{false}; + bool half_duplex_requested{false}; + bool echo_read_requests{false}; bool pin_values[64]{}; std::array registers{}; @@ -37,6 +44,11 @@ class FakePlatform final : public radiance3d::HardwarePlatform { return true; } + bool configure_uart_half_duplex(std::uint8_t, bool enabled) override { + half_duplex_requested = enabled; + return true; + } + void flush_uart_input(std::uint8_t) override {} bool write_uart(std::uint8_t, const std::uint8_t* data, @@ -64,6 +76,10 @@ class FakePlatform final : public radiance3d::HardwarePlatform { if (length == 4 && data[0] == 0x05 && data[1] <= 3 && radiance3d::Tmc2209Driver::calculate_crc(data, 3) == data[3]) { pending_register_ = data[2]; + if (echo_read_requests) { + std::copy(data, data + length, echoed_request_.begin()); + echo_pending_ = true; + } return true; } return false; @@ -71,7 +87,18 @@ class FakePlatform final : public radiance3d::HardwarePlatform { std::size_t read_uart(std::uint8_t, std::uint8_t* data, std::size_t maximum_length, std::uint32_t) override { - if (!uart_present || maximum_length < 8) { + if (!uart_present) { + return 0; + } + if (echo_pending_) { + if (maximum_length < echoed_request_.size()) { + return 0; + } + std::copy(echoed_request_.begin(), echoed_request_.end(), data); + echo_pending_ = false; + return echoed_request_.size(); + } + if (maximum_length < 8) { return 0; } const std::uint32_t value = registers[pending_register_]; @@ -88,6 +115,8 @@ class FakePlatform final : public radiance3d::HardwarePlatform { private: std::uint8_t pending_register_{0}; + std::array echoed_request_{}; + bool echo_pending_{false}; }; radiance3d::Tmc2209Config config() { @@ -117,10 +146,20 @@ void test_successful_initialization_starts_disabled_and_probes_uart() { TEST_ASSERT_TRUE(driver.initialize()); TEST_ASSERT_TRUE(driver.is_connected()); TEST_ASSERT_TRUE(platform.uart_started); + TEST_ASSERT_TRUE(platform.half_duplex_requested); TEST_ASSERT_TRUE(platform.pin_values[27]); TEST_ASSERT_EQUAL_UINT32(0, platform.registers[0x01]); } +void test_single_wire_read_echo_is_ignored_before_crc_valid_reply() { + FakePlatform platform; + platform.echo_read_requests = true; + radiance3d::Tmc2209Driver driver(platform, config()); + + TEST_ASSERT_TRUE(driver.initialize()); + TEST_ASSERT_TRUE(driver.set_microsteps(32)); +} + void test_failed_uart_probe_keeps_driver_disabled() { FakePlatform platform; platform.uart_present = false; @@ -183,6 +222,7 @@ void test_diagnostics_map_faults_and_critical_fault_disables_output() { int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_successful_initialization_starts_disabled_and_probes_uart); + RUN_TEST(test_single_wire_read_echo_is_ignored_before_crc_valid_reply); RUN_TEST(test_failed_uart_probe_keeps_driver_disabled); RUN_TEST(test_invalid_driver_address_is_rejected); RUN_TEST(test_current_is_configurable_and_safe_ceiling_is_enforced); diff --git a/firmware/controller/test/test_motion/test_main.cpp b/firmware/controller/test/test_motion/test_main.cpp index 3992924..7ff4042 100644 --- a/firmware/controller/test/test_motion/test_main.cpp +++ b/firmware/controller/test/test_motion/test_main.cpp @@ -1,4 +1,8 @@ +#ifdef ESP_PLATFORM #include +#else +#include "host_test.hpp" +#endif #include @@ -16,7 +20,7 @@ void test_angular_conversion_is_derived_from_configuration() { AxisConfig config; config.motor_full_steps_per_revolution = 200; config.microsteps = 16; - config.gear_ratio = 3.0; + config.gear_ratio = {3, 1}; TEST_ASSERT_FLOAT_WITHIN(0.001f, 9600.0f, static_cast(config.steps_per_output_revolution())); @@ -24,6 +28,22 @@ void test_angular_conversion_is_derived_from_configuration() { static_cast(config.commanded_step_angle_deg())); } +void test_rational_gear_ratio_is_exact_and_rejects_invalid_denominator() { + AxisConfig config; + config.motor_full_steps_per_revolution = 200; + config.microsteps = 16; + config.gear_ratio = {5, 2}; + + TEST_ASSERT_FLOAT_WITHIN(0.001f, 8000.0f, + static_cast(config.steps_per_output_revolution())); + config.gear_ratio.denominator = 0; + TEST_ASSERT_FALSE(config.valid()); + + // A fractional output pulse is not representable by a STEP/DIR driver. + config.gear_ratio = {1, 3}; + TEST_ASSERT_FALSE(config.valid()); +} + void test_motion_requires_homing_and_uses_configured_limits() { ProtocolEngine engine; @@ -59,6 +79,7 @@ void test_status_labels_position_as_commanded_and_untrusted_at_startup() { TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("POSITION_KIND=COMMANDED")); TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("AZ_TRUSTED=0")); TEST_ASSERT_NOT_EQUAL(std::string::npos, status.find("EL_TRUSTED=0")); + TEST_ASSERT_TRUE(status.size() < 1024U); } void test_correlated_commands_reject_duplicate_and_stale_ids() { @@ -169,6 +190,7 @@ void test_simulator_models_homing_failure_and_reset_trust_loss() { int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_angular_conversion_is_derived_from_configuration); + RUN_TEST(test_rational_gear_ratio_is_exact_and_rejects_invalid_denominator); RUN_TEST(test_motion_requires_homing_and_uses_configured_limits); RUN_TEST(test_stop_invalidates_position_and_requires_rehoming); RUN_TEST(test_driver_disable_invalidates_position_confidence); diff --git a/firmware/controller/test/test_physical/test_main.cpp b/firmware/controller/test/test_physical/test_main.cpp index c1f8655..d601c33 100644 --- a/firmware/controller/test/test_physical/test_main.cpp +++ b/firmware/controller/test/test_physical/test_main.cpp @@ -1,10 +1,15 @@ +#ifdef ESP_PLATFORM #include +#else +#include "host_test.hpp" +#endif #include #include #include "axis_controller.hpp" #include "hardware_config.hpp" +#include "hardware_profile_generated.hpp" #include "physical_motion_controller.hpp" #include "protocol.hpp" @@ -168,6 +173,40 @@ void test_provisional_gpio_is_valid_and_validation_rejects_conflicts() { TEST_ASSERT_NOT_EQUAL_INT64(0, result.bootstrapping_pin_mask); } +void test_compiled_defaults_are_generated_from_the_hardware_profile() { + const auto config = radiance3d::provisional_esp32_dev_config(); + + TEST_ASSERT_EQUAL_STRING(radiance3d::generated_profile::kBoardName, + config.board_name); + TEST_ASSERT_EQUAL_INT(radiance3d::generated_profile::kEmergencyStopPin, + config.emergency_stop_pin); + TEST_ASSERT_EQUAL_UINT32(radiance3d::generated_profile::kProtocolVersion, + config.protocol_version); + TEST_ASSERT_EQUAL_INT(radiance3d::generated_profile::kAzimuth.step_pin, + config.azimuth.driver.step_pin); + TEST_ASSERT_EQUAL_INT(radiance3d::generated_profile::kElevation.step_pin, + config.elevation.driver.step_pin); + TEST_ASSERT_EQUAL_UINT16( + radiance3d::generated_profile::kAzimuth.commissioning_current_ma, + config.azimuth.axis.motion.motor_rms_current_ma); + TEST_ASSERT_EQUAL_UINT16( + radiance3d::generated_profile::kElevation.commissioning_current_ma, + config.elevation.axis.motion.motor_rms_current_ma); + TEST_ASSERT_EQUAL_UINT8(30, config.azimuth.axis.motion.hold_current_percent); + TEST_ASSERT_EQUAL_UINT8(40, config.elevation.axis.motion.hold_current_percent); + TEST_ASSERT_EQUAL_UINT16( + radiance3d::generated_profile::kAzimuth.maximum_rms_current_ma, + config.azimuth.driver.maximum_rms_current_ma); + TEST_ASSERT_EQUAL_UINT32(radiance3d::generated_profile::kTmcUartBaud, + config.azimuth.driver.uart_baud); + TEST_ASSERT_EQUAL_UINT32(radiance3d::generated_profile::kTmcUartTimeoutMs, + config.elevation.driver.uart_timeout_ms); + TEST_ASSERT_TRUE(config.azimuth.driver.uart_single_wire); + TEST_ASSERT_TRUE(config.azimuth.driver.write_echo_expected); + TEST_ASSERT_EQUAL_INT(1, config.azimuth.axis.motion.gear_ratio.numerator); + TEST_ASSERT_EQUAL_INT(1, config.azimuth.axis.motion.gear_ratio.denominator); +} + void test_safe_startup_initializes_both_axes_disabled_and_untrusted() { Fixture fixture; @@ -208,7 +247,11 @@ void test_one_axis_critical_fault_stops_coordinated_move_and_loses_trust() { radiance3d::DriverFault::overtemperature_shutdown; fixture.platform.advance(101000); - fixture.controller.service(); + // Diagnostics are intentionally deferred while motion is active so a TMC + // timeout cannot delay native GPTimer pulse scheduling. Finish the move + // owner cycle, then invoke the explicit idle-only diagnostic path. + fixture.controller.stop(); + fixture.controller.service_diagnostics(); TEST_ASSERT_FALSE(fixture.azimuth.state().moving); TEST_ASSERT_FALSE(fixture.elevation.state().moving); @@ -241,6 +284,54 @@ void test_emergency_stop_latches_both_axes_and_requires_released_input() { TEST_ASSERT_FALSE(fixture.controller.state().emergency_stop_active); } +void test_latched_estop_rejects_enable_and_runtime_motor_changes() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + fixture.controller.emergency_stop(); + + TEST_ASSERT_FALSE(fixture.controller.set_enabled(true).ok); + TEST_ASSERT_FALSE( + fixture.controller.set_axis_enabled(radiance3d::AxisSelection::azimuth, + true) + .ok); + TEST_ASSERT_FALSE( + fixture.controller.set_axis_current(radiance3d::AxisSelection::azimuth, + 650) + .ok); + TEST_ASSERT_FALSE( + fixture.controller.set_axis_microsteps(radiance3d::AxisSelection::azimuth, + 8) + .ok); + TEST_ASSERT_FALSE(fixture.azimuth_driver.enabled); + TEST_ASSERT_FALSE(fixture.elevation_driver.enabled); +} + +void test_motor_config_tracks_accepted_runtime_changes() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + TEST_ASSERT_TRUE( + fixture.controller.set_axis_current(radiance3d::AxisSelection::azimuth, + 700) + .ok); + TEST_ASSERT_TRUE( + fixture.controller.set_axis_microsteps(radiance3d::AxisSelection::azimuth, + 8) + .ok); + TEST_ASSERT_EQUAL_UINT16(700, fixture.controller.config().azimuth.motor_rms_current_ma); + TEST_ASSERT_EQUAL_UINT16(8, fixture.controller.config().azimuth.microsteps); +} + +void test_physical_protocol_uses_profile_protocol_version() { + Fixture fixture; + TEST_ASSERT_TRUE(fixture.controller.initialize()); + radiance3d::ProtocolEngine engine(fixture.controller, + fixture.config.protocol_version); + const std::string identify = engine.handle("IDENTIFY"); + TEST_ASSERT_NOT_EQUAL(std::string::npos, + identify.find("PROTOCOL=" + + std::to_string(fixture.config.protocol_version))); +} + void test_stop_all_stops_both_axes_and_invalidates_active_move() { Fixture fixture; TEST_ASSERT_TRUE(fixture.controller.initialize()); @@ -279,10 +370,14 @@ void test_protocol_emits_completion_only_after_both_axes_stop() { int main(int, char**) { UNITY_BEGIN(); RUN_TEST(test_provisional_gpio_is_valid_and_validation_rejects_conflicts); + RUN_TEST(test_compiled_defaults_are_generated_from_the_hardware_profile); RUN_TEST(test_safe_startup_initializes_both_axes_disabled_and_untrusted); RUN_TEST(test_coordinated_move_completes_only_after_both_axes_finish); RUN_TEST(test_one_axis_critical_fault_stops_coordinated_move_and_loses_trust); RUN_TEST(test_emergency_stop_latches_both_axes_and_requires_released_input); + RUN_TEST(test_latched_estop_rejects_enable_and_runtime_motor_changes); + RUN_TEST(test_motor_config_tracks_accepted_runtime_changes); + RUN_TEST(test_physical_protocol_uses_profile_protocol_version); RUN_TEST(test_stop_all_stops_both_axes_and_invalidates_active_move); RUN_TEST(test_protocol_emits_completion_only_after_both_axes_stop); return UNITY_END(); diff --git a/scripts/check_repository.py b/scripts/check_repository.py index a039255..45220f9 100755 --- a/scripts/check_repository.py +++ b/scripts/check_repository.py @@ -5,6 +5,7 @@ import json import re +import subprocess import sys from pathlib import Path @@ -21,7 +22,13 @@ "data/schemas/scan-v1.schema.json", "data/examples/simulated/dipole-like-scan.json", "software/pyproject.toml", - "firmware/controller/platformio.ini", + "firmware/controller/CMakeLists.txt", + "firmware/controller/main/CMakeLists.txt", + "firmware/controller/main/Kconfig.projbuild", + "firmware/controller/main/idf_component.yml", + "firmware/controller/partitions.csv", + "firmware/controller/sdkconfig.defaults", + "firmware/controller/host/CMakeLists.txt", "docs/architecture/overview.md", "docs/firmware/protocol.md", "docs/software/file-formats.md", @@ -58,6 +65,60 @@ def check_json() -> list[str]: return errors +def check_hardware_profile() -> list[str]: + generator = ROOT / "scripts" / "generate_hardware_profile_header.py" + profile = ROOT / "firmware" / "config" / "provisional-esp32dev-v1.json" + try: + result = subprocess.run( + [sys.executable, str(generator), "--profile", str(profile), "--validate-only"], + check=False, + capture_output=True, + text=True, + ) + except OSError as exc: + return [f"could not validate hardware profile: {exc}"] + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown error" + return [f"hardware profile validation failed: {detail}"] + return [] + + +def check_physical_firmware_has_no_arduino_dependency() -> list[str]: + forbidden = ( + "#include ", + "HardwareSerial", + "pinMode(", + "digitalWrite(", + "digitalRead(", + "void setup(", + "void loop(", + "Ticker.h", + "ESP32TimerInterrupt", + "TMCStepper", + "AccelStepper", + "ArduinoJson", + "framework = arduino", + ) + errors: list[str] = [] + firmware = ROOT / "firmware" / "controller" + for path in firmware.rglob("*"): + if not path.is_file() or ( + path.suffix not in {".cpp", ".hpp", ".h", ".ini", ".cmake"} + and path.name != "CMakeLists.txt" + ): + continue + text = path.read_text(encoding="utf-8") + for marker in forbidden: + if marker in text: + errors.append(f"{path.relative_to(ROOT)}: Arduino dependency {marker!r}") + for api in ("delay", "millis", "micros"): + if re.search(rf"\b{api}\s*\(", text): + errors.append( + f"{path.relative_to(ROOT)}: Arduino dependency {api + '()'!r}" + ) + return errors + + def check_internal_links() -> list[str]: errors: list[str] = [] for markdown in ROOT.rglob("*.md"): @@ -81,6 +142,10 @@ def check_empty_files() -> list[str]: allowed = {ROOT / "software" / "src" / "radiance3d" / "py.typed"} errors: list[str] = [] for path in ROOT.rglob("*"): + # ESP-IDF creates empty generated stamp/source placeholders under its + # ignored build directory. They are not repository artifacts. + if "build" in path.relative_to(ROOT).parts: + continue if path.is_file() and path.stat().st_size == 0 and path not in allowed: errors.append(f"{path.relative_to(ROOT)}: empty file") return errors @@ -90,6 +155,8 @@ def main() -> int: errors = [ *check_required_paths(), *check_json(), + *check_hardware_profile(), + *check_physical_firmware_has_no_arduino_dependency(), *check_internal_links(), *check_empty_files(), ] diff --git a/software/tests/test_firmware_simulator_protocol.py b/software/tests/test_firmware_simulator_protocol.py new file mode 100644 index 0000000..c078190 --- /dev/null +++ b/software/tests/test_firmware_simulator_protocol.py @@ -0,0 +1,108 @@ +"""End-to-end protocol regression against the native C++ simulator binary.""" + +from __future__ import annotations + +import os +import select +import subprocess +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from radiance3d.motion_client import PhysicalMotionController +from radiance3d.transport import SerialTransport + + +class SimulatorSerial: + """Small pyserial-shaped adapter around the native simulator process.""" + + def __init__(self, executable: Path, *, timeout: float) -> None: + self.timeout = timeout + self._process: subprocess.Popen[bytes] = subprocess.Popen( + [str(executable)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + @property + def is_open(self) -> bool: + return self._process.poll() is None + + def reset_input_buffer(self) -> None: + assert self._process.stdout is not None + while select.select([self._process.stdout], [], [], 0.0)[0]: + self._process.stdout.readline() + + def write(self, data: bytes) -> int: + assert self._process.stdin is not None + self._process.stdin.write(data) + return len(data) + + def flush(self) -> None: + assert self._process.stdin is not None + self._process.stdin.flush() + + def readline(self) -> bytes: + assert self._process.stdout is not None + readable, _, _ = select.select([self._process.stdout], [], [], self.timeout) + return self._process.stdout.readline() if readable else b"" + + def close(self) -> None: + if self.is_open: + self._process.terminate() + try: + self._process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=1.0) + + +@pytest.fixture +def simulator_factory() -> Callable[..., SimulatorSerial]: + configured_path = os.environ.get("RADIANCE3D_SIMULATOR") + if configured_path is None: + pytest.skip("native firmware simulator is not configured") + executable = Path(configured_path) + if not executable.is_file(): + pytest.skip(f"native firmware simulator is missing: {executable}") + + def factory(**kwargs: Any) -> SimulatorSerial: + timeout = kwargs.get("timeout") + assert isinstance(timeout, float) + return SimulatorSerial(executable, timeout=timeout) + + return factory + + +def test_python_host_client_remains_compatible_with_native_simulator( + simulator_factory: Callable[..., SimulatorSerial], +) -> None: + transport = SerialTransport("native-simulator", serial_factory=simulator_factory) + client = PhysicalMotionController(transport) + + client.connect() + assert client.capabilities().uart_diagnostics + assert client.axis_configuration("AZ").microsteps == 16 + assert client.home().confidence.value == "trusted" + position = client.move_to(18.0, 9.0, 5.0) + assert position.azimuth_deg == 18.0 + assert position.elevation_deg == 9.0 + client.disconnect() + + +def test_native_simulator_enforces_the_v1_host_heartbeat( + simulator_factory: Callable[..., SimulatorSerial], +) -> None: + transport = SerialTransport("native-simulator", serial_factory=simulator_factory) + transport.connect() + assert transport.request("HOME BOTH").startswith("OK ID=1 HOME") + + time.sleep(2.1) + assert transport.read_event(0.5) == ( + "EVENT FAULT CODE=DRIVER_DISABLED DETAIL=HOST_HEARTBEAT_TIMEOUT" + ) + transport.disconnect() From 93a45dc12a851aeea412c73c2028a056a85fffcb Mon Sep 17 00:00:00 2001 From: bostromdev Date: Fri, 31 Jul 2026 01:23:47 -0400 Subject: [PATCH 13/13] docs(firmware): document native ESP-IDF migration --- CONTRIBUTING.md | 3 +- docs/architecture/adr-native-esp-idf.md | 57 ++++++++++ docs/architecture/design-decisions.md | 1 + docs/development/setup.md | 25 ++-- docs/development/testing.md | 12 +- docs/firmware/configuration.md | 22 ++-- docs/firmware/esp-idf-architecture.md | 114 +++++++++++++++++++ docs/firmware/esp-idf-migration-inventory.md | 26 +++-- docs/firmware/esp-idf-migration.md | 38 +++++++ docs/firmware/overview.md | 39 ++++--- docs/firmware/protocol.md | 14 ++- docs/firmware/troubleshooting.md | 48 ++++++++ docs/hardware/tmc2209-commissioning.md | 19 ++-- docs/index.md | 4 + firmware/controller/README.md | 51 ++++++--- 15 files changed, 396 insertions(+), 77 deletions(-) create mode 100644 docs/architecture/adr-native-esp-idf.md create mode 100644 docs/firmware/esp-idf-architecture.md create mode 100644 docs/firmware/esp-idf-migration.md create mode 100644 docs/firmware/troubleshooting.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6ca813..291c50f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,8 @@ documented interface, testable behavior, or an evidence-backed design decision. 2. Keep commits focused and avoid generated artifacts. 3. Run `python scripts/check_repository.py`. 4. For software changes, run Ruff, mypy, and pytest from `software/`. -5. For firmware changes, run the native simulator build with PlatformIO. +5. For firmware changes, run the host CMake/CTest suite and `idf.py build` when + ESP-IDF is available. 6. Update relevant documentation and the changelog when behavior changes. Pull requests should explain the problem, the chosen design, validation performed, diff --git a/docs/architecture/adr-native-esp-idf.md b/docs/architecture/adr-native-esp-idf.md new file mode 100644 index 0000000..55602e3 --- /dev/null +++ b/docs/architecture/adr-native-esp-idf.md @@ -0,0 +1,57 @@ +# ADR: Use native ESP-IDF for physical motion firmware + +**Status:** Accepted for the physical ESP32 target. Hardware validation remains +pending. + +## Context + +The Version 1 controller needs two independent STEP outputs, two TMC2209 UART +links, a structured USB serial protocol, non-blocking homing and motion, a latched +emergency input, a host heartbeat watchdog, reset inspection, and explicit fault +handling. The portable protocol and motion rules also need to remain executable on a +host simulator. + +The previous physical target used an Arduino runtime through PlatformIO. Its +cooperative loop combined GPIO pulse generation, switch debounce, protocol I/O, and +driver UART diagnostics. That was sufficient for an early baseline, but it did not +make task ownership, watchdog behavior, or ESP32 peripheral configuration explicit. + +## Decision + +Use native ESP-IDF v5.5.4 for the physical ESP32 firmware. The physical entry point +is `app_main()`. ESP-IDF owns GPIO, UART, GPTimer, FreeRTOS queues/tasks/event groups, +task watchdog registration, reset-reason inspection, NVS initialization, brownout +configuration, and logging. + +Portable C++ remains separate from ESP-IDF adapters: + +- `motion_controller` and `protocol` remain host-testable core behavior. +- `axis_controller`, `physical_motion_controller`, and `tmc2209_driver` remain + driver-neutral C++ behind small platform interfaces. +- `components/platform_idf` supplies GPIO/UART/GPTimer implementations. +- `main` owns task setup and task-to-task messages. + +The physical project is built with `idf.py`; a separate host CMake project builds the +simulator and portable tests. Arduino is not an ESP-IDF component and is not a +dependency of the physical target. + +## Alternatives considered + +| Alternative | Decision | +| --- | --- | +| Remain on Arduino-ESP32 | Rejected because the native task, watchdog, timer, and UART ownership would remain implicit. | +| Use Arduino as an ESP-IDF component | Rejected because it would retain the runtime dependency this migration removes. | +| Retain PlatformIO with Arduino | Rejected as the physical source of truth; it does not satisfy the native-runtime requirement. | +| Rust | Deferred; this controlled migration preserves the existing tested C++ core. | +| MicroPython | Rejected for this deterministic-control and driver-UART use case. | + +## Consequences + +Advantages include official vendor APIs, explicit resource ownership, GPTimer-backed +STEP edges, clearer host-protocol isolation, and a maintainable long-term embedded +architecture. Costs include a more involved setup, more verbose APIs, a new CMake +layout, and required physical revalidation. + +Native ESP-IDF is not automatically better for every ESP32 application. This decision +is specific to the controller's timing, safety, and peripheral requirements. It does +not establish measured timing, motor operation, or reliability claims. diff --git a/docs/architecture/design-decisions.md b/docs/architecture/design-decisions.md index 445cb65..8d54db5 100644 --- a/docs/architecture/design-decisions.md +++ b/docs/architecture/design-decisions.md @@ -15,6 +15,7 @@ records become worthwhile. | D-008 | Accepted | Use degrees and the documented forward/right/up coordinate convention at public boundaries. | It keeps firmware configuration and scan files inspectable; radians remain internal to math utilities. | | D-009 | Accepted | Treat Version 1 position as open-loop commanded position with explicit confidence. | Step counting is not independent verification; fault, reset, disable, stop, or suspected missed steps must force re-homing. | | D-010 | Accepted | Preserve schema 1.0.0 reads while requiring schema 1.1.0 metadata for new scans. | Existing examples and external prototypes can migrate without weakening the new provenance contract. | +| D-011 | Accepted | Use native ESP-IDF v5.5.4 for physical motion firmware. | Explicit native task, timer, UART, watchdog, GPIO, and reset control are required while the portable C++ core remains testable. See [ADR](adr-native-esp-idf.md). | New entries should state context, alternatives, consequences, and evidence. Changing an accepted data or protocol contract requires a versioning and migration plan. diff --git a/docs/development/setup.md b/docs/development/setup.md index 86b989a..8d7dbba 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -1,6 +1,7 @@ # Development setup -Prerequisites are Git, Python 3.11+, and PlatformIO for firmware work. +Prerequisites are Git, Python 3.11+, CMake for portable firmware tests, and ESP-IDF +v5.5.4 for physical firmware work. ```bash cd software @@ -13,11 +14,21 @@ ruff format --check . mypy cd ../firmware/controller -pio run -e native -pio test -e native -pio run -e esp32dev +cmake -S host -B build-host -DCMAKE_BUILD_TYPE=Release +cmake --build build-host +ctest --test-dir build-host --output-on-failure ``` -Run `python scripts/check_repository.py` from the repository root. No secrets or paid -services are required. The serial extra is optional for simulator-only work and does -not assume a fixed device path. +For the physical target, install and export Espressif's pinned release, then run: + +```bash +cd firmware/controller +idf.py set-target esp32 +idf.py build +idf.py size +``` + +Run `python scripts/check_repository.py` from the repository root. CI also runs the +Python host client against the CMake-built simulator. No secrets or paid services are +required. The serial extra is optional for simulator-only work and does not assume a +fixed device path. diff --git a/docs/development/testing.md b/docs/development/testing.md index 3ae78a8..03fc46b 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -2,12 +2,16 @@ - **Repository:** required paths, valid JSON, simulated provenance, and internal links. - **Software:** model invariants, CLI results, formatting, lint, and strict type checks. -- **Firmware:** native simulator build/tests plus compilation of the provisional - ESP32 target. Native tests cover UART framing, current limits, diagnostics, integer - conversion, motion, homing, safety, dual-axis completion, and protocol contracts. +- **Firmware:** portable CMake/CTest simulator and core tests plus native ESP-IDF + compilation of the provisional ESP32 target. Tests cover UART framing/write echo, + current ceilings, diagnostics, rational conversion, motion, homing, safety, + dual-axis completion, generated-profile consistency, and protocol contracts. +- **Host/firmware integration:** the unchanged Python serial client runs against the + compiled native simulator in CI, including identity, command correlation, and + heartbeat behavior. - **Schemas:** metaschema check plus example validation. - **Hardware:** future test records must identify exact revisions, setup, instruments, raw data, and safety controls. -A passing native test or ESP32 compilation is not evidence that the selected board, +A passing portable test or ESP32 compilation is not evidence that the selected board, carrier, motor, switch, power supply, thermal design, or mechanics work physically. diff --git a/docs/firmware/configuration.md b/docs/firmware/configuration.md index 8fdd330..722db5b 100644 --- a/docs/firmware/configuration.md +++ b/docs/firmware/configuration.md @@ -3,8 +3,10 @@ Version 1 separates board/pin configuration from driver-neutral motion behavior. The physical target and simulator use the same typed motion fields. The Version 1 hardware profile is stored in -[`firmware/config/provisional-esp32dev-v1.json`](../../firmware/config/provisional-esp32dev-v1.json); -the compiled defaults live in `hardware_config.cpp`. +[`firmware/config/provisional-esp32dev-v1.json`](../../firmware/config/provisional-esp32dev-v1.json). +Every native ESP-IDF and host CMake build validates this JSON and generates +`hardware_profile_generated.hpp`; `hardware_config.cpp` consumes that generated header +rather than maintaining a second defaults table. ## Axis fields @@ -14,7 +16,7 @@ Each azimuth and elevation axis defines: - `microsteps`; - configurable `motor_rms_current_ma`; - configurable maximum RMS-current ceiling and hold-current percentage; -- `gear_ratio`; +- exact rational gear ratio generated from the profile's decimal `gear_ratio` field; - calculated `steps_per_output_revolution`; - direction inversion; - home offset in degrees; @@ -31,12 +33,18 @@ but Version 1 does not implement it. ## Controller fields Controller configuration includes emergency-stop pin/polarity/debounce, protocol -version, serial rate, board name, and all GPIO assignments. Startup rejects duplicate -pins and input-only STEP/DIR/ENABLE/TX assignments and reports ESP32 bootstrapping-pin -use as a warning. +version, serial rate, board name, all GPIO assignments, UART settings, carrier +polarity, and current ceiling. Startup rejects duplicate pins and input-only +STEP/DIR/ENABLE/TX assignments and reports ESP32 bootstrapping-pin use as a warning. + +Kconfig holds only build/runtime-system settings such as task stacks, watchdog duration, +heartbeat duration, and diagnostics interval. NVS initializes at boot but does not yet +persist motor tuning; current, speed, limits, gear ratio, and offsets remain bounded by +the version-controlled profile. This avoids an unsafe or undocumented runtime override. The example uses 650 mA RMS as the initial commissioning current with a 1000 mA -software ceiling. Before energizing a motor, verify this value against the selected +software ceiling. The Version 1 profile uses 30% azimuth hold current and 40% elevation +hold current. Before energizing a motor, verify these values against the selected motor rating, the carrier's actual sense resistor and schematic, cooling, load, and measured temperatures. ESP32 signals are 3.3 V and no attached module is assumed 5 V tolerant. The example is not a verified pinout or motor calibration. diff --git a/docs/firmware/esp-idf-architecture.md b/docs/firmware/esp-idf-architecture.md new file mode 100644 index 0000000..f51a4af --- /dev/null +++ b/docs/firmware/esp-idf-architecture.md @@ -0,0 +1,114 @@ +# Native ESP-IDF firmware architecture + +The physical ESP32 controller uses native ESP-IDF v5.5.4. The source tree keeps +portable behavior under the existing C++ interfaces and places ESP-IDF-only adapters +under `firmware/controller/components/platform_idf`. + +## Startup and safe state + +`app_main()` initializes NVS, records the ESP32 reset reason, constructs the validated +hardware profile, drives both STEP pins low, makes both enable pins inactive, and then +initializes GPIO inputs, UART0 host transport, TMC UARTs, TMC probes, motion timers, +queues, event groups, ISRs, and tasks. Both axes begin disabled and position-untrusted. + +Any startup failure leaves the enable outputs inactive and is reported as a structured +`EVENT STARTUP READY=0` line. `RESET=` is included in every startup event; +after every reset, homing is required before absolute motion. + +## Task ownership + +| Task | Priority | Stack | Ownership | +| --- | ---: | ---: | --- | +| Safety | 9 | 3072 | E-stop input supervision and host-heartbeat enforcement. | +| Motion | 8 | 6144 | The only owner of physical controller, axes, TMC UARTs, motion state, homing, and protocol engine. | +| Protocol | 5 | 4096 | UART0 framing and serialized host responses/events. | +| Diagnostics | 3 | 3072 | Periodically requests an idle-only diagnostic read through the motion owner; it never touches a driver directly. | + +Tasks are intentionally not pinned to an ESP32 core. No measured reason currently +justifies affinity. Kconfig owns task stacks, watchdog duration, heartbeat duration, +and diagnostics interval; it does not duplicate the board profile or runtime tuning. + +`QueueHandle_t` carries typed host commands, safety requests, and outbound lines. +`EventGroupHandle_t` exposes enabled/moving state to the safety task. Task notifications +wake the motion task on queue writes, GPIO input changes, and completed GPTimer pulses. +The motion task is the single writer of `PhysicalMotionController`, preventing UART and +axis-state races. + +## STEP timing + +Each axis uses a one-shot 1 MHz GPTimer scheduler. The timer callback performs only: + +1. STEP high; +2. reschedule for a conservative five-microsecond pulse width; +3. STEP low, disarm, increment a completion counter, and notify the motion task. + +Direction setup, acceleration/deceleration, integer position updates, homing, +timeouts, and all UART work stay in the motion task. TMC diagnostics run only while +both axes are idle, so a bounded UART timeout cannot delay arming a following pulse. +A scheduler can force STEP low from the e-stop ISR; the same cache-safe ISR also drives +both enable pins inactive, and the motion task latches fault/trust state. The design +preserves conservative high/low widths and direction setup but does **not** +claim measured pulse timing or jitter. Logic-analyzer validation is required. + +GPTimer was selected over RMT/MCPWM/`esp_timer` because the profile's configured step +rates are below 90 steps/s per axis and two small one-shot schedulers are the simplest +testable design with independent axes and immediate disarm behavior. + +## GPIO and emergency input + +`IdfHardwarePlatform` centralizes GPIO configuration through `gpio_config`. The +generated profile is checked for duplicate pins, output-capability conflicts, and ESP32 +boot-strapping pins before driver probing. The profile selects input pull-up, pull-down, +or no internal bias independently for home and e-stop inputs; profile polarity remains +configurable. Bootstrap-pin use is reported as a structured startup warning. + +GPIO ISRs do the minimum cache-safe work. An active e-stop edge immediately pulls STEP +low, disables both drivers, and requests a motion-owner latch; input debounce and all +state/protocol work remain outside the ISR. Any asserted edge is intentionally +fail-safe-latched even if it later bounces, so timer and motion state cannot diverge. +Firmware e-stop supplements—rather than replaces—a physical motor-power disconnect. + +## UART and logging + +UART0 is exclusively the structured host protocol at the baud rate generated from the +hardware profile (115200 for Version 1). ESP-IDF console output is disabled in +`sdkconfig.defaults`, so `ESP_LOGx` cannot corrupt protocol frames. Boot ROM output +before the application starts may still be visible on some boards and must not be +treated as protocol data. + +UART1 and UART2 are dedicated to the azimuth and elevation TMC2209 links. The adapter +uses normal ESP-IDF UART mode with bounded timeouts; PDN_UART one-wire behavior is an +external electrical topology, not ESP-IDF RS-485/RTS mode. TX must join PDN_UART through +the carrier-required resistor and RX must observe that same bus. The driver scans bounded +receive chunks for a CRC-valid reply so write echo cannot be mistaken for a reply and +verifies IFCNT around configuration writes. The exact carrier PDN_UART wiring and R10 +value remain hardware-validation items. + +Use tags `APP`, `CONFIG`, `PROTOCOL`, `MOTION`, `AXIS_AZ`, `AXIS_EL`, `TMC2209_AZ`, +`TMC2209_EL`, `SAFETY`, and `DIAGNOSTICS`. Current default logging is intentionally +quiet on the protocol board; use a debugger or a separately wired diagnostics transport +for detailed field logs. + +## Watchdogs, reset, and brownout + +Three mechanisms have different purposes: + +| Mechanism | Purpose | Response | +| --- | --- | --- | +| ESP-IDF task watchdog | Detects stalled registered tasks. | Reset; next startup reports reset reason and begins disabled/untrusted. | +| Host heartbeat watchdog | Detects an absent host while a driver is enabled. | Stop, disable drivers, invalidate trust, emit `HOST_HEARTBEAT_TIMEOUT`. | +| Motion/homing/driver timeouts | Detects a motion, homing, or TMC communication failure. | Fault, disable affected hardware, invalidate trust. | +| Physical e-stop | Operator safety input. | Immediately pull STEP low and disable outputs in the ISR, then latch state and notify the host in task context. | + +ESP32 brownout detection is enabled. A brownout restart is reported as `RESET=BROWNOUT` +and never restores position trust. The LM2596 display is not evidence of transient +behavior; power-drop and brownout tests remain pending. + +## Configuration + +`firmware/config/provisional-esp32dev-v1.json` is the authoritative Version 1 profile. +The build validates it and generates `hardware_profile_generated.hpp`, consumed by both +ESP-IDF and host CMake builds. Build settings that are not hardware profile values live +in Kconfig. NVS is initialized but no unsafe runtime motor configuration is persisted: +runtime current/speed settings remain bounded by the generated profile and reset to a +known safe baseline after reboot. diff --git a/docs/firmware/esp-idf-migration-inventory.md b/docs/firmware/esp-idf-migration-inventory.md index 8c98678..706ab73 100644 --- a/docs/firmware/esp-idf-migration-inventory.md +++ b/docs/firmware/esp-idf-migration-inventory.md @@ -52,11 +52,12 @@ driver-neutral behind `StepperDriver`. The profile at `firmware/config/provisional-esp32dev-v1.json` and compiled defaults in `src/hardware_config.cpp` duplicate the same provisional hardware -configuration. The native build will generate one validated configuration -header from the JSON profile and test it for consistency. The request says -the elevation hold current is 40%, while the source profile and current -firmware say 30%. The migration preserves the existing documented 30% -baseline until that physical-hardware decision is separately validated. +configuration. The native build generates one validated configuration header +from the JSON profile and tests it for consistency. The audit found an elevation +hold-current mismatch (30% in the pre-migration profile versus 40% in the +Version 1 request); the migrated profile now uses the requested 40% elevation +value while retaining 30% for azimuth. Both remain physically unvalidated +commissioning values. The current public `GEAR_RATIO` is a decimal value and the profile is `1.0`. No unvalidated gear-ratio change is part of this migration; portable @@ -66,13 +67,14 @@ conversion code remains host-testable. The documented maximum pulse rates are below 90 steps/second per axis. Two independent one-shot GPTimer schedulers are the simplest native mechanism for -the required two-microsecond pulse and direction setup timing while retaining -immediate shutdown. This is an architecture decision, not a claim of -measured timing accuracy; logic-analyzer validation remains required. +the required timing boundary while retaining immediate shutdown. ESP-IDF GPTimer +documents sub-5-us alarm periods as unsuitable for reliable control, so the +implementation uses conservative 5-us STEP high/setup/low minima. This is an +architecture decision, not a claim of measured timing accuracy; logic-analyzer +validation remains required. ## Validation constraints at inventory time -No usable `idf.py` or PlatformIO executable is installed locally. The native -project will pin ESP-IDF v5.5.4 in documentation and CI. Portable CMake/CTest -and Python checks can run locally; ESP-IDF compile, target component tests, and -hardware tests require a provisioned ESP-IDF environment and target hardware. +The native project pins ESP-IDF v5.5.4 in documentation and CI. Portable CMake/CTest +and Python checks run locally; an ESP-IDF build verifies target compilation. Physical +timing, target component tests, and hardware tests still require a provisioned target. diff --git a/docs/firmware/esp-idf-migration.md b/docs/firmware/esp-idf-migration.md new file mode 100644 index 0000000..b3a79d2 --- /dev/null +++ b/docs/firmware/esp-idf-migration.md @@ -0,0 +1,38 @@ +# ESP-IDF migration notes + +This migration changes the physical ESP32 runtime from Arduino-ESP32 to native +ESP-IDF without changing protocol version 1 or the Python motion API. + +## What changed + +- Physical startup is `app_main()` with FreeRTOS protocol, motion, safety, and + diagnostics tasks. +- GPIO, UART, GPTimer, task watchdog, NVS initialization, reset reason, brownout + configuration, and logging use ESP-IDF APIs. +- The host simulator is a separate portable CMake executable. +- `platformio.ini`, Arduino headers, Arduino serial/GPIO adapter, and the Arduino + `setup()`/`loop()` entry point were removed. +- The board profile is validated and compiled from JSON rather than repeated in a + handwritten C++ defaults table. +- Gear ratio is stored in the portable core as a rational number while retaining the + exact decimal `GEAR_RATIO` protocol field. + +## Compatibility + +The protocol remains version 1. Device identity, command IDs, stale/duplicate +handling, line framing, heartbeat event wording, faults, trust semantics, simulator +mode, and host Python types are unchanged. Startup includes an additive reset-reason +field. Existing host parsers accept additive fields. + +The native simulator now models the two-second host-heartbeat timeout in addition to +motion, homing, driver absence/thermal faults, e-stop commands, reset trust loss, and +protocol behavior. The CI path runs the unchanged Python serial client against the +compiled simulator. + +## Validation boundary + +Portable CTest and Python integration tests are evidence of compilation/simulation and +protocol compatibility. ESP-IDF build is evidence of target compilation. Neither is +evidence of measured pulse timing, motor motion, emergency-stop latency, UART wiring, +thermal behavior, or brownout behavior. See the [commissioning guide](../hardware/tmc2209-commissioning.md) +for the physical test sequence. diff --git a/docs/firmware/overview.md b/docs/firmware/overview.md index fe4a6a0..8474660 100644 --- a/docs/firmware/overview.md +++ b/docs/firmware/overview.md @@ -1,25 +1,24 @@ # Firmware overview -The ESP32 firmware now has physical and simulated implementations behind the same -`MotionController` and `StepperDriver` boundaries. The physical path consists of an -Arduino ESP32 platform adapter, two TMC2209 UART/STEP/DIR drivers, one reusable -non-blocking axis controller per motor, a dual-axis coordinator, and protocol engine. +The ESP32 physical firmware uses native ESP-IDF v5.5.4 and the host simulator uses a +separate portable CMake target. Both retain the same `MotionController`, +`StepperDriver`, and protocol boundaries. The physical path uses two TMC2209 +UART/STEP/DIR drivers, reusable non-blocking axis controllers, a dual-axis coordinator, +and a protocol engine owned by the motion task. -The controller uses integer microsteps as authoritative position. It services STEP -edges, acceleration, switch debounce, homing, driver diagnostics, emergency stop, and -serial input without long delay loops. A coordinated command completes only after -both axes stop. Critical faults stop both axes when a coordinated move is active. -The current profile reflects the Version 1 hardware baseline, but the exact GPIO map, -carrier wiring, and initial current remain pending validation against the physical -hardware. +Integer microsteps remain authoritative commanded position. GPTimer emits physical +STEP high/low edges; the motion task owns acceleration, switch debounce, homing, +diagnostics, timeouts, trust state, and dual-axis completion. Protocol, safety, and +diagnostics tasks communicate with it through FreeRTOS queues and notifications. A +critical coordinated-move fault stops both axes. -The TMC2209 implementation is a small, datasheet-based register driver rather than a -third-party motion library. This keeps timer ownership, stop behavior, dual-axis -servicing, and native tests explicit. It supports addressed UART checks, IFCNT write -verification, RMS current and hold-current configuration, microsteps, interpolation, -stealthChop/spreadCycle selection, and diagnostic mapping. +The TMC2209 implementation remains a small data-sheet-based register driver, not a +third-party motion library. It supports CRC framing, bounded UART timeouts, write-echo +filtering, IFCNT verification, RMS/hold current configuration, microsteps, +interpolation, stealthChop/spreadCycle selection, and diagnostic mapping. -The simulator models the public state/fault contract, not electrical waveforms. -The `esp32dev` environment compiles the physical implementation, but the board and -carrier pin map remain pending validation until the exact board and modules are -inspected. See the [commissioning guide](../hardware/tmc2209-commissioning.md). +The current profile remains a Version 1 provisional baseline. GPIO mapping, carrier +PDN_UART wiring, R10/sense resistance, current, pulse timing, e-stop latency, power, +and mechanical behavior require physical validation. See the +[native architecture](esp-idf-architecture.md) and +[commissioning guide](../hardware/tmc2209-commissioning.md). diff --git a/docs/firmware/protocol.md b/docs/firmware/protocol.md index 20e3881..5a465a4 100644 --- a/docs/firmware/protocol.md +++ b/docs/firmware/protocol.md @@ -25,6 +25,11 @@ The controller also emits `EVENT FAULT CODE=` and the accepted scan/move command and both axes are done. It then applies the configured settling delay. +At native physical startup, the controller emits +`EVENT STARTUP READY=<0|1> DRIVERS_ENABLED=0 BOARD= RESET=`. +`RESET` is additive diagnostic information; it does not change protocol version 1 or +restore position trust after a reset. + ## General commands | Command | Arguments | Purpose | @@ -69,7 +74,8 @@ interrupted motion, driver disable, configuration changes affecting scale, and f homing make position untrusted. Clearing a fault does not restore trust; successful homing does. -On the physical ESP32 target, two seconds without a command/heartbeat while a driver -is enabled stops motion, disables both drivers, and emits a host-timeout fault event. -The Python motion client sends heartbeats while waiting. This watchdog does not make -USB serial or software an emergency-rated control path. +On the physical ESP32 target and native simulator, two seconds without a +command/heartbeat while a driver is enabled stops motion, disables both drivers, and +emits `EVENT FAULT CODE=DRIVER_DISABLED DETAIL=HOST_HEARTBEAT_TIMEOUT`. The Python +motion client sends heartbeats while waiting. This watchdog does not make USB serial or +software an emergency-rated control path. diff --git a/docs/firmware/troubleshooting.md b/docs/firmware/troubleshooting.md new file mode 100644 index 0000000..f96ad0e --- /dev/null +++ b/docs/firmware/troubleshooting.md @@ -0,0 +1,48 @@ +# Native ESP-IDF firmware troubleshooting + +## The host client receives non-protocol text + +UART0 is reserved for the structured protocol. Confirm +`CONFIG_ESP_CONSOLE_UART_NONE=y` is present and do not enable serial logs on UART0. +Some ESP32 boot ROM messages can precede application startup; reset the serial input +buffer before `IDENTIFY` and do not parse those messages as protocol lines. + +## `EVENT STARTUP READY=0` + +Keep motor power disconnected. Inspect the reported board/profile, validate the JSON +profile, check duplicate/boot-strap pins, then verify each TMC UART path and carrier +pinout. The controller intentionally leaves both drivers disabled on this condition. + +## TMC2209 communication fault or absent driver + +Confirm common ground, UART1/UART2 assignment, RX/TX/PDN_UART topology, address +straps, the exact carrier R10/sense resistor, and 3.3 V logic compatibility. The +single-wire PDN_UART profile is provisional; do not assume a carrier exposes the same +electrical circuit as another board revision. + +## Host heartbeat timeout + +The host must send a command or `HEARTBEAT` at least every two seconds while a driver +is enabled. The Python client does this while waiting for asynchronous motion. A +timeout stops and disables drivers and invalidates position; inspect the host/USB link, +then home again. + +## E-stop cannot clear + +Release the physical input, wait for debounce, resolve the hazard, then issue +`CLEAR_FAULT` or `RESET_ESTOP`. Firmware cannot clear a latched e-stop while the input +is active. Use the physical motor-power disconnect when needed. + +## Unexpected reset or `RESET=BROWNOUT` + +Drivers are disabled and position is untrusted after every reset. Rehome before any +absolute move. Inspect supply wiring, LM2596 adjustment, USB backfeed, bulk capacitance, +and motor load with appropriate instruments; a buck's front-panel voltage is not a +transient measurement. + +## Build failures + +Use ESP-IDF v5.5.4 and run the shell export script before `idf.py`. Remove only the +project's generated `firmware/controller/build/` directory when changing target or +toolchain; do not delete source profile or hardware records. Portable tests use CMake +under `firmware/controller/host` and do not require ESP-IDF. diff --git a/docs/hardware/tmc2209-commissioning.md b/docs/hardware/tmc2209-commissioning.md index 322c307..9dd2b3e 100644 --- a/docs/hardware/tmc2209-commissioning.md +++ b/docs/hardware/tmc2209-commissioning.md @@ -15,14 +15,15 @@ BIGTREETECH TMC2209 V1.3 drivers. The motors are YEJMKJ/LYLANMO NEMA 17 bipolar ESP32 board revision, TMC2209 carrier revision, UART wiring, R10 setting, sense resistor, and pinout remain pending validation. -Startup sets STEP low, keeps the enable pins inactive, validates pins/configuration, -starts the UARTs, probes each driver, reads diagnostics, writes current/microstep -settings, reads switches and the e-stop input, and leaves both axes disabled and -untrusted. A missing driver is a structured fault and cannot be enabled. - -The firmware follows the TMC2209 datagram/CRC/register definition and verifies -register writes with IFCNT. STEP timing remains conservative and must be measured on -hardware before final claims are made. See the +Native ESP-IDF startup sets STEP low, keeps the enable pins inactive, validates the +generated profile, configures GPIO/UART/GPTimer resources, probes each driver, reads +diagnostics, writes current/microstep settings, reads switches and the e-stop input, +and leaves both axes disabled and untrusted. A missing driver is a structured fault and +cannot be enabled. + +The firmware follows the TMC2209 datagram/CRC/register definition, bounds UART reads, +filters expected write echo, and verifies register writes with IFCNT. STEP timing +remains conservative and must be measured on hardware before final claims are made. See the [Analog Devices TMC2209 datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/TMC2209_datasheet_rev1.09.pdf). ## Version 1 hardware profile @@ -79,7 +80,7 @@ rated phase current Final current must be verified using actual torque requirements, motor temperature, driver temperature, and long-duration testing. Reduced hold current is configured as -30% while stationary. +30% for azimuth and 40% for elevation in the current provisional profile. ## Homing and position trust diff --git a/docs/index.md b/docs/index.md index febbe07..61570d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,10 @@ interfaces unless a page explicitly marks behavior as implemented and tested. - [Data flow](architecture/data-flow.md) - [Scan file format](software/file-formats.md) - [Motion protocol](firmware/protocol.md) +- [Native ESP-IDF firmware architecture](firmware/esp-idf-architecture.md) +- [ESP-IDF migration notes](firmware/esp-idf-migration.md) +- [Firmware troubleshooting](firmware/troubleshooting.md) +- [Native ESP-IDF decision](architecture/adr-native-esp-idf.md) - [TMC2209 commissioning](hardware/tmc2209-commissioning.md) - [Development setup](development/setup.md) - [Roadmap](development/roadmap.md) diff --git a/firmware/controller/README.md b/firmware/controller/README.md index aa513f0..b774513 100644 --- a/firmware/controller/README.md +++ b/firmware/controller/README.md @@ -1,18 +1,43 @@ -# Motion controller foundation +# Motion controller -The controller currently implements an in-memory `MotionController` for protocol and -host integration work. It compiles as a native command-line program and as an ESP32 -Arduino target for the Version 1 hardware baseline. The physical wiring, carrier -revision, and bring-up details remain pending validation. +The physical ESP32 target is a native ESP-IDF v5.5.4 project. Portable C++ core code +and the simulator build independently with host CMake. The physical wiring, carrier +revision, PDN_UART topology, pulse timing, and bring-up results remain pending +validation. + +## ESP-IDF build and flash + +Install the pinned ESP-IDF release using Espressif's documented setup, then export its +environment in each shell: + +```bash +git clone -b v5.5.4 --recursive https://github.com/espressif/esp-idf.git ~/esp/esp-idf-v5.5.4 +~/esp/esp-idf-v5.5.4/install.sh esp32 +. ~/esp/esp-idf-v5.5.4/export.sh + +cd firmware/controller +idf.py set-target esp32 +idf.py build +idf.py size +idf.py flash +idf.py monitor +``` + +UART0 is the line-oriented host protocol. Console logging is disabled so `idf.py +monitor` should not be used as a debug-log console on a connected controller; use the +host protocol or a separate diagnostics path. `idf.py monitor` can still observe the +protocol line and any early ROM output. + +## Host simulator and portable tests ```bash -pio run -e native -pio test -e native +cd firmware/controller +cmake -S host -B build-host -DCMAKE_BUILD_TYPE=Release +cmake --build build-host +ctest --test-dir build-host --output-on-failure +./build-host/radiance3d-simulator ``` -The native process reads one command per line from standard input and writes one -response per line. See [the protocol specification](../../docs/firmware/protocol.md). -The simulator enforces configuration-derived limits and position-confidence rules. -Board selection, electrical limits, motor-driver carrier details, pins, physical -homing, and physical emergency-stop behavior remain pending validation against the -actual hardware. +The simulator reads one command per line from standard input and writes responses and +events to standard output. See [the protocol specification](../../docs/firmware/protocol.md) +and [native architecture](../../docs/firmware/esp-idf-architecture.md).