// ============================================================================= // nmea2000_consumer.cpp -- NMEA 2000 backbone consumer // ============================================================================= #include "nmea2000_consumer.h" #include // Override the default ESP32 CAN pins (16/4) to match our AR-NMEA-IO board. #include "../hal/pinout.h" #define ESP32_CAN_TX_PIN ((gpio_num_t)PIN_CAN_TX) #define ESP32_CAN_RX_PIN ((gpio_num_t)PIN_CAN_RX) #include #include #include "../system/ar_log.h" #include "../system/task_config.h" namespace arautopilot::protocols::nmea2000 { namespace { constexpr const char* TAG = "AR/N2K"; constexpr uint32_t STALE_THRESHOLD_MS = 5000; portMUX_TYPE g_mux = portMUX_INITIALIZER_UNLOCKED; HeadingSnapshot g_snap{ .heading_deg = 0.0f, .is_true = false, .rate_of_turn_dps = 0.0f, .heading_age_ms = 0, .rot_age_ms = 0, .heading_valid = false, .rot_valid = false, }; CogSogSnapshot g_cog_sog{ .cog_deg = NAN, .sog_kn = NAN, .age_ms = 0, .valid = false, }; NavDataSnapshot g_nav_data{ .xte_m = NAN, .dtw_m = NAN, .wp_name = {}, .age_ms = 0, .valid = false, }; HeadingControlSnapshot g_htc{ .commanded_heading_deg = NAN, .mode = HtcMode::UNKNOWN, .source_addr = 0xFF, .age_ms = 0, .valid = false, }; // Our own NMEA2000 source address — filter out self-echoes. constexpr uint8_t OWN_SOURCE_ADDR = 25; float rad_to_deg_pos(float rad) { float d = rad * (180.0f / (float)M_PI); // Normalise to 0..360. while (d < 0.0f) d += 360.0f; while (d >= 360.0f) d -= 360.0f; return d; } float rad_to_deg_signed(float rad) { return rad * (180.0f / (float)M_PI); } void HandleHeading(const tN2kMsg& msg) { unsigned char sid; double heading = 0.0, deviation = 0.0, variation = 0.0; tN2kHeadingReference ref = N2khr_Unavailable; if (!ParseN2kHeading(msg, sid, heading, deviation, variation, ref)) { return; } if (heading > 1e8) { // N2k "unavailable" markers come through as huge doubles. return; } const float heading_deg = rad_to_deg_pos((float)heading); const bool is_true = (ref == N2khr_true); const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); g_snap.heading_deg = heading_deg; g_snap.is_true = is_true; g_snap.heading_age_ms = now; g_snap.heading_valid = true; portEXIT_CRITICAL(&g_mux); AR_LOGV(TAG, "PGN 127250 heading=%.2f deg (%s)", heading_deg, is_true ? "true" : "magnetic"); } void HandleROT(const tN2kMsg& msg) { unsigned char sid; double rot = 0.0; if (!ParseN2kRateOfTurn(msg, sid, rot)) { return; } if (rot > 1e8 || rot < -1e8) { return; } const float rot_dps = rad_to_deg_signed((float)rot); const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); g_snap.rate_of_turn_dps = rot_dps; g_snap.rot_age_ms = now; g_snap.rot_valid = true; portEXIT_CRITICAL(&g_mux); AR_LOGV(TAG, "PGN 127251 ROT=%.3f deg/s", rot_dps); } void HandleCogSog(const tN2kMsg& msg) { unsigned char sid; tN2kHeadingReference ref; double cog = 0.0, sog = 0.0; if (!ParseN2kCOGSOGRapid(msg, sid, ref, cog, sog)) return; if (cog > 1e8 || sog > 1e8) return; const float cog_deg = rad_to_deg_pos((float)cog); const float sog_kn = (float)(sog * 1.94384); // m/s → knots const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); g_cog_sog.cog_deg = cog_deg; g_cog_sog.sog_kn = sog_kn; g_cog_sog.age_ms = now; g_cog_sog.valid = true; portEXIT_CRITICAL(&g_mux); AR_LOGV(TAG, "PGN 129026 COG=%.2f deg SOG=%.2f kn", cog_deg, sog_kn); } void HandleNavData(const tN2kMsg& msg) { unsigned char sid; double dist = 0.0, xte = 0.0; double origLat = 0.0, origLon = 0.0, destLat = 0.0, destLon = 0.0; double closingVel = 0.0; tN2kHeadingReference bearingRef = N2khr_Unavailable; tN2kDistanceCalculationType calcType = N2kdct_GreatCircle; bool perpCrossed = false, arrivalAlarm = false; int16_t origWpNum = 0; uint32_t destWpNum = 0, eta = 0; if (!ParseN2kNavigationInfo( msg, sid, dist, bearingRef, perpCrossed, arrivalAlarm, calcType, xte, origWpNum, origLat, origLon, destWpNum, eta, destLat, destLon, closingVel)) { return; } if (xte > 1e8 || xte < -1e8) return; const float xte_m = (float)xte; const float dtw_m = (dist < 1e8) ? (float)dist : NAN; const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); g_nav_data.xte_m = xte_m; g_nav_data.dtw_m = dtw_m; g_nav_data.age_ms = now; g_nav_data.valid = true; portEXIT_CRITICAL(&g_mux); AR_LOGV(TAG, "PGN 129284 XTE=%.2f m DTW=%.0f m", xte_m, dtw_m); } void HandleHeadingControl(const tN2kMsg& msg) { // Ignore our own broadcasts — concentrador uses a different source address. if (msg.Source == OWN_SOURCE_ADDR) return; tN2kOnOff rud_lim, off_hdg, off_trk, override_st; tN2kSteeringMode steering_mode; tN2kTurnMode turn_mode; tN2kHeadingReference hdg_ref; tN2kRudderDirectionOrder cmd_rud_dir; double cmd_rud_angle = N2kDoubleNA; double hdg_to_steer = N2kDoubleNA; double track = N2kDoubleNA; double rud_limit = N2kDoubleNA; double off_hdg_limit = N2kDoubleNA; double radius = N2kDoubleNA; double rot_order = N2kDoubleNA; double xte_lim = N2kDoubleNA; double vessel_hdg = N2kDoubleNA; if (!ParseN2kHeadingTrackControl(msg, rud_lim, off_hdg, off_trk, override_st, steering_mode, turn_mode, hdg_ref, cmd_rud_dir, cmd_rud_angle, hdg_to_steer, track, rud_limit, off_hdg_limit, radius, rot_order, xte_lim, vessel_hdg)) { return; } // Map NMEA2000 steering mode to our internal enum. HtcMode our_mode; switch (steering_mode) { case N2kSM_HeadingControl: our_mode = HtcMode::HEADING_HOLD; break; case N2kSM_MainSteering: our_mode = HtcMode::STANDBY; break; default: return; // ignore unsupported modes } const float hdg_deg = (hdg_to_steer < 1e8) ? rad_to_deg_pos((float)hdg_to_steer) : NAN; const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); g_htc.commanded_heading_deg = hdg_deg; g_htc.mode = our_mode; g_htc.source_addr = msg.Source; g_htc.age_ms = now; g_htc.valid = true; portEXIT_CRITICAL(&g_mux); AR_LOGI(TAG, "PGN 127237 src=%d mode=%d hdg=%.2f deg", msg.Source, (int)our_mode, hdg_deg); } void MessageHandler(const tN2kMsg& msg) { switch (msg.PGN) { case 127237L: HandleHeadingControl(msg); break; case 127250L: HandleHeading(msg); break; case 127251L: HandleROT(msg); break; case 129026L: HandleCogSog(msg); break; case 129284L: HandleNavData(msg); break; default: break; } } void RxTask(void* /*pv*/) { AR_LOGI(TAG, "nmea2000_consumer task started on core %d", xPortGetCoreID()); for (;;) { NMEA2000.ParseMessages(); const uint32_t now = millis(); portENTER_CRITICAL(&g_mux); if (g_snap.heading_valid && (now - g_snap.heading_age_ms) > STALE_THRESHOLD_MS) { g_snap.heading_valid = false; } if (g_snap.rot_valid && (now - g_snap.rot_age_ms) > STALE_THRESHOLD_MS) { g_snap.rot_valid = false; } portEXIT_CRITICAL(&g_mux); // Update COG/SOG and nav-data stale flags. portENTER_CRITICAL(&g_mux); if (g_cog_sog.valid && (now - g_cog_sog.age_ms) > STALE_THRESHOLD_MS) { g_cog_sog.valid = false; } if (g_nav_data.valid && (now - g_nav_data.age_ms) > STALE_THRESHOLD_MS) { g_nav_data.valid = false; } // HTC commands have a shorter stale window (3 s) — if the remote stops // sending, we don't want to keep acting on an old command. if (g_htc.valid && (now - g_htc.age_ms) > 3000U) { g_htc.valid = false; } portEXIT_CRITICAL(&g_mux); // 100 Hz polling is plenty -- the CAN driver buffers incoming frames. vTaskDelay(pdMS_TO_TICKS(10)); } } } // namespace void nmea2000_consumer_init() { NMEA2000.SetProductInformation( "AR-AP-1", // Manufacturer's Model serial code 100, // Manufacturer's product code "AR-Autopilot Controller v1", // Model ID AR_FW_VERSION, // SW version "AR-NMEA-IO v1.0" // Model version ); NMEA2000.SetDeviceInformation( 1, // Unique number 150, // Device function: Autopilot 40, // Device class: Steering and Control surfaces 2046 // Manufacturer code: reserved/test (real one needs NMEA registration) ); NMEA2000.SetMode(tNMEA2000::N2km_ListenAndNode, 25); NMEA2000.EnableForward(false); NMEA2000.SetMsgHandler(&MessageHandler); if (NMEA2000.Open()) { AR_LOGI(TAG, "nmea2000_consumer_init: stack open, CAN TX=%d RX=%d, " "subscribed to PGN 127250 + 127251", PIN_CAN_TX, PIN_CAN_RX); } else { AR_LOGE(TAG, "nmea2000_consumer_init: NMEA2000.Open() failed (CAN driver " "init error). System will continue but no N2K traffic."); } } void nmea2000_consumer_start_task() { xTaskCreatePinnedToCore(RxTask, "n2k_rx", AR_TASK_STACK_N2K_RX, nullptr, AR_TASK_PRIO_N2K_RX, nullptr, AR_TASK_CORE_COMMS); } HeadingSnapshot nmea2000_latest() { HeadingSnapshot copy; portENTER_CRITICAL(&g_mux); copy = g_snap; portEXIT_CRITICAL(&g_mux); return copy; } CogSogSnapshot nmea2000_cog_sog() { CogSogSnapshot copy; portENTER_CRITICAL(&g_mux); copy = g_cog_sog; portEXIT_CRITICAL(&g_mux); return copy; } NavDataSnapshot nmea2000_nav_data() { NavDataSnapshot copy; portENTER_CRITICAL(&g_mux); copy = g_nav_data; portEXIT_CRITICAL(&g_mux); return copy; } bool nmea2000_is_stale() { const auto s = nmea2000_latest(); return !s.heading_valid; } bool nmea2000_cog_is_stale() { return !nmea2000_cog_sog().valid; } HeadingControlSnapshot nmea2000_htc() { HeadingControlSnapshot copy; portENTER_CRITICAL(&g_mux); copy = g_htc; portEXIT_CRITICAL(&g_mux); return copy; } bool nmea2000_htc_is_stale() { return !nmea2000_htc().valid; } } // namespace arautopilot::protocols::nmea2000