"""AR-Autopilot deployment package compiler — Sprint 4. An ``.appack`` is a ZIP archive that contains everything needed to deploy a configured autopilot to a vessel: / ├── manifest.json — metadata, checksums, schema version ├── project.yaml — full ProjectConfig (human-readable) ├── firmware_config.h — generated C header consumed by the firmware build └── install_notes.txt — human summary for the field engineer The format is intentionally transparent (plain ZIP, readable YAML/JSON/C) so that a field engineer can inspect the package contents without special tools. Typical workflow ---------------- 1. Integrator configures a ``ProjectConfig`` in the Studio (Project tab). 2. Clicks "Compile .appack…" → this compiler runs. 3. Studio writes the .appack to disk. 4. Field engineer transfers the .appack to the vessel. 5. Future Sprint: Studio "Deploy" tab extracts the package, calls PlatformIO with the generated header, and flashes the board. """ from __future__ import annotations import hashlib import json import zipfile from datetime import UTC, datetime from pathlib import Path from textwrap import dedent from arautopilot.core.actuator_config import ActuatorType from arautopilot.core.project_config import ProjectConfig from arautopilot.core.sensor_config import DualRudderSensorConfig, RudderSensorType from arautopilot.version import __version__ _SCHEMA_VERSION = "0.1.0" class AppackCompiler: """Compiles a ``ProjectConfig`` into an ``.appack`` deployment archive.""" def __init__(self, project: ProjectConfig) -> None: if not project.client_name.strip(): raise ValueError("project.client_name must not be empty") if not project.project_name.strip(): raise ValueError("project.project_name must not be empty") self._project = project def compile(self, output_path: Path | str) -> Path: """Build the ``.appack`` archive and write it to *output_path*. Returns the resolved output path. """ out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) project_yaml = self._project.to_yaml() firmware_header = self._generate_firmware_header() install_notes = self._generate_install_notes() manifest = self._build_manifest(project_yaml, firmware_header) prefix = str(self._project.project_id) with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf: zf.writestr(f"{prefix}/manifest.json", json.dumps(manifest, indent=2)) zf.writestr(f"{prefix}/project.yaml", project_yaml) zf.writestr(f"{prefix}/firmware_config.h", firmware_header) zf.writestr(f"{prefix}/install_notes.txt", install_notes) return out # ------------------------------------------------------------------ # Manifest # ------------------------------------------------------------------ def _build_manifest(self, project_yaml: str, firmware_header: str) -> dict: return { "appack_schema": _SCHEMA_VERSION, "arautopilot_version": __version__, "compiled_at": datetime.now(UTC).isoformat(), "project_id": str(self._project.project_id), "client_name": self._project.client_name, "project_name": self._project.project_name, "vessel_name": self._project.vessel.name, "vessel_type": self._project.vessel.type.value, "actuator_type": self._project.vessel.actuator.type.value, "checksums": { "project_yaml_sha256": _sha256(project_yaml), "firmware_config_h_sha256": _sha256(firmware_header), }, } # ------------------------------------------------------------------ # Firmware header generator # ------------------------------------------------------------------ def _generate_firmware_header(self) -> str: p = self._project v = p.vessel a = v.actuator pid = v.pid sensors = v.sensors actuator_enum = _actuator_type_to_enum(a.type) primary_sensor_enum = _sensor_type_to_enum(sensors.primary.type) redundant_enabled = "1" if sensors.has_redundancy else "0" redundant_cs = sensors.redundant.spi_cs_gpio if sensors.redundant else 0 redundant_sensor_enum = ( _sensor_type_to_enum(sensors.redundant.type) if sensors.redundant else "RUDDER_SENSOR_NONE" ) diverge_alarm = sensors.primary.divergence_alarm_deg diverge_failover = sensors.primary.divergence_failover_deg lines = [ "// AUTO-GENERATED by AR-Autopilot Studio — DO NOT EDIT BY HAND.", f"// Project : {p.project_name}", f"// Client : {p.client_name}", f"// Vessel : {v.name} ({v.type.value})", f"// Generated: {datetime.now(UTC).isoformat()}", "//", "#pragma once", "", "// --- Project identity -------------------------------------------------", f'#define AR_PROJECT_ID "{p.project_id}"', f'#define AR_CLIENT_NAME "{p.client_name}"', f'#define AR_PROJECT_NAME "{p.project_name}"', f'#define AR_VESSEL_NAME "{v.name}"', "", "// --- Vessel kinematics ------------------------------------------------", f"#define AR_VESSEL_LENGTH_M {v.length_m:.1f}f", f"#define AR_VESSEL_MAX_SPEED_KN {v.max_speed_kn:.1f}f", "", "// --- Actuator ---------------------------------------------------------", f"#define AR_ACTUATOR_TYPE {actuator_enum}", f"#define AR_RUDDER_ANGLE_LIMIT_DEG {a.max_rudder_angle_deg:.1f}f", f"#define AR_ACTUATOR_DEADBAND_PCT {a.deadband_pct:.1f}f", f"#define AR_ACTUATOR_MAX_RATE_DPS {a.max_rate_dps:.1f}f", f"#define AR_ACTUATOR_MAX_CURRENT_A {a.max_current_a:.1f}f", f"#define AR_ACTUATOR_ASYM_STBD {a.asymmetry_stbd_over_port:.3f}f", "", "// --- Rudder sensors ---------------------------------------------------", f"#define AR_SENSOR_PRIMARY_TYPE {primary_sensor_enum}", f"#define AR_SENSOR_PRIMARY_CS_GPIO {sensors.primary.spi_cs_gpio}", f"#define AR_SENSOR_PRIMARY_FSD_DEG {sensors.primary.full_scale_deg:.1f}f", f"#define AR_SENSOR_REDUNDANT {redundant_enabled}", f"#define AR_SENSOR_REDUNDANT_TYPE {redundant_sensor_enum}", f"#define AR_SENSOR_REDUNDANT_CS_GPIO {redundant_cs}", f"#define AR_SENSOR_DIVERGE_ALARM_DEG {diverge_alarm:.1f}f", f"#define AR_SENSOR_DIVERGE_FAILOVER_DEG {diverge_failover:.1f}f", "", "// --- PID base gains (integrator IP — not exposed to operator) ---------", f"#define AR_PID_INNER_KP {pid.inner_loop_base.kp:.4f}f", f"#define AR_PID_INNER_KI {pid.inner_loop_base.ki:.4f}f", f"#define AR_PID_INNER_KD {pid.inner_loop_base.kd:.4f}f", f"#define AR_PID_OUTER_KP {pid.outer_loop_base.kp:.4f}f", f"#define AR_PID_OUTER_KI {pid.outer_loop_base.ki:.4f}f", f"#define AR_PID_OUTER_KD {pid.outer_loop_base.kd:.4f}f", f"#define AR_PID_ROT_FF {pid.rot_feedforward_gain:.4f}f", f"#define AR_PID_DEADBAND_DEG {pid.setpoint_deadband_deg:.2f}f", f"#define AR_PID_RATE_LIMIT_DPS {pid.setpoint_rate_limit_dps:.2f}f", f"#define AR_PID_ANTI_WINDUP {pid.anti_windup_limit:.2f}f", "", "// End of generated header.", ] return "\n".join(lines) + "\n" # ------------------------------------------------------------------ # Install notes # ------------------------------------------------------------------ def _generate_install_notes(self) -> str: p = self._project v = p.vessel a = v.actuator s = v.sensors return dedent(f"""\ AR-Autopilot — Field Installation Notes ======================================== Project : {p.project_name} Client : {p.client_name} Vessel : {v.name} ({v.type.value.replace("_", " ").title()}) Generated: {datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")} ACTUATOR -------- Type : {a.type.value.replace("_", " ").title()} Model / label : {a.name or "(not specified)"} Rudder limit : ±{a.max_rudder_angle_deg:.1f}° (software limit — verify ≤ mechanical stop) Deadband : {a.deadband_pct:.1f} % Max slew rate : {a.max_rate_dps:.1f} °/s Overcurrent : {a.max_current_a:.1f} A trip SENSORS ------- Primary sensor : {s.primary.type.value.upper()} GPIO CS={s.primary.spi_cs_gpio} Full-scale : ±{s.primary.full_scale_deg:.1f}° Redundant sensor: {"ENABLED" if s.has_redundancy else "DISABLED"} {"Redundant type : " + s.redundant.type.value.upper() + " GPIO CS=" + str(s.redundant.spi_cs_gpio) if s.redundant else ""} Divergence alarm : {s.primary.divergence_alarm_deg:.1f}° Divergence failover : {s.primary.divergence_failover_deg:.1f}° COMMISSIONING STEPS (Sprint 7 wizard will automate these) ----------------------------------------------------------- 1. Flash firmware with this .appack (Flash Console or pio upload). 2. Connect primary rudder sensor to SPI CS GPIO {s.primary.spi_cs_gpio}. {"3. Connect redundant sensor to SPI CS GPIO " + str(s.redundant.spi_cs_gpio) + "." if s.redundant else "3. (Redundant sensor not configured.)"} 4. Verify rudder moves to ±{a.max_rudder_angle_deg:.1f}° and stops. 5. Run Modbus client test: python tools/modbus_client_test.py 6. Engage HEADING_HOLD and confirm heading capture. 7. Tune PID gains via Modbus if required. SUPPORT ------- Contact: AR Suite — alro65@gmail.com """) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _sha256(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest() def _actuator_type_to_enum(t: ActuatorType) -> str: return { ActuatorType.HYDRAULIC_REVERSIBLE: "ACTUATOR_HYDRAULIC_REVERSIBLE", ActuatorType.ELECTRIC_DC_REVERSIBLE: "ACTUATOR_ELECTRIC_DC", ActuatorType.SERVOMOTOR_FEEDBACK: "ACTUATOR_SERVOMOTOR", ActuatorType.STERNDRIVE_ANALOG: "ACTUATOR_STERNDRIVE_ANALOG", }.get(t, "ACTUATOR_UNKNOWN") def _sensor_type_to_enum(t: RudderSensorType) -> str: return { RudderSensorType.AS5048A_SPI: "RUDDER_SENSOR_AS5048A_SPI", RudderSensorType.POTENTIOMETER: "RUDDER_SENSOR_POTENTIOMETER", }.get(t, "RUDDER_SENSOR_UNKNOWN")