diff --git a/src/suunto_nautic.c b/src/suunto_nautic.c index ae7d17e..132f682 100644 --- a/src/suunto_nautic.c +++ b/src/suunto_nautic.c @@ -1086,6 +1086,115 @@ suunto_nautic_device_list (dc_device_t *abstract, dc_buffer_t *out) return DC_STATUS_SUCCESS; } +// True for a token that is exactly "..", each part +// < 256; on success fills a/b/c. +static int +suunto_nautic_parse_version (const char *tok, size_t len, unsigned int *a, unsigned int *b, unsigned int *c) +{ + unsigned int part[3] = {0}, idx = 0, digits = 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (ch >= '0' && ch <= '9') { + part[idx] = part[idx] * 10 + (unsigned int) (ch - '0'); + if (part[idx] > 255 || ++digits > 3) + return 0; + } else if (ch == '.') { + if (digits == 0 || ++idx > 2) + return 0; + digits = 0; + } else { + return 0; + } + } + if (idx != 2 || digits == 0) + return 0; + *a = part[0]; *b = part[1]; *c = part[2]; + return 1; +} + +// True for a token that is exactly 12 chars, all [0-9A-F] -- the watch serial +// form, e.g. "2604C3003306". +static int +suunto_nautic_is_serial (const char *tok, size_t len) +{ + if (len != 12) + return 0; + for (size_t i = 0; i < len; i++) { + char ch = tok[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F'))) + return 0; + } + return 1; +} + +/* + * Best-effort device info. A short fetch of /Info returns a device-identity + * record: a run of NUL-separated strings mixed in with binary framing bytes, + * + * Suunto\0 Nautic\0 Vaasa\0 T\0 2604C3003306\0 01385D42<...>\0 + * 2.55.46\0 \0 ... SIM\0 \0 BID\0 \0 + * BLE Mac Address\0 \0 WiFi Mac Address\0 \0 + * + * (confirmed on a live Nautic, firmware 2.55.46, via fetch_device_info.py). + * The watch serial is the first 12-char uppercase-hex token and the firmware + * the first "N.N.N" token; the serial always precedes the firmware, and the + * BLE/WiFi MACs (also 12 hex) come after it -- so stop looking for a serial + * once the firmware token is seen. Layered on top of the download; every + * failure path here is non-fatal. + * + * dc_event_devinfo_t carries unsigned ints only: the firmware is packed + * (a << 16) | (b << 8) | c and the hex serial truncated to its low 32 bits. + * A consumer wanting the faithful strings should read the BLE advertised + * name (serial) or GET /Info directly (firmware). + */ +static void +suunto_nautic_emit_devinfo (dc_device_t *abstract) +{ + dc_buffer_t *info = dc_buffer_new (0); + if (info == NULL) + return; + + if (suunto_nautic_device_short_fetch (abstract, "/Info", info) != DC_STATUS_SUCCESS) { + dc_buffer_free (info); + return; // not fatal -- just no device info this time + } + + const unsigned char *d = dc_buffer_get_data (info); + size_t n = dc_buffer_get_size (info); + + dc_event_devinfo_t devinfo; + memset (&devinfo, 0, sizeof (devinfo)); + + // Walk NUL-delimited tokens; the record's strings are NUL-terminated, and + // the binary framing bytes between them fail both tests. + size_t start = 0; + for (size_t i = 0; i < n; i++) { + if (d[i] != 0) + continue; + const char *tok = (const char *) (d + start); + size_t toklen = i - start; + unsigned int a, b, c; + if (!devinfo.firmware && suunto_nautic_parse_version (tok, toklen, &a, &b, &c)) + devinfo.firmware = (a << 16) | (b << 8) | c; + else if (!devinfo.firmware && !devinfo.serial && suunto_nautic_is_serial (tok, toklen)) { + char buf[13]; + memcpy (buf, tok, 12); + buf[12] = 0; + devinfo.serial = (unsigned int) strtoul (buf, NULL, 16); + } + start = i + 1; + } + + dc_buffer_free (info); + + if (devinfo.firmware || devinfo.serial) { + INFO (abstract->context, "Device info: firmware=%u.%u.%u serial=0x%08x", + (devinfo.firmware >> 16) & 0xFF, (devinfo.firmware >> 8) & 0xFF, + devinfo.firmware & 0xFF, devinfo.serial); + device_event_emit (abstract, DC_EVENT_DEVINFO, &devinfo); + } +} + static dc_status_t suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback, void *userdata) { @@ -1116,6 +1225,9 @@ suunto_nautic_device_foreach (dc_device_t *abstract, dc_dive_callback_t callback device_event_emit (abstract, DC_EVENT_VENDOR, &vendor); dc_buffer_free (mode); + // Best-effort firmware / serial via DC_EVENT_DEVINFO; never fatal. + suunto_nautic_emit_devinfo (abstract); + progress.current = 1; device_event_emit (abstract, DC_EVENT_PROGRESS, &progress); diff --git a/src/suunto_nautic_parser.c b/src/suunto_nautic_parser.c index e68ccb1..1c73053 100644 --- a/src/suunto_nautic_parser.c +++ b/src/suunto_nautic_parser.c @@ -29,12 +29,13 @@ * immediately, before the value. * * Decoded chunks: 0x12 (1Hz absolute pressure / temperature), 0x16 - * (depth, cylinder pressures, NDL, time-to-surface), 0x17 (surface - * pressure), 0x0B (GPS), plus the dynamically-assigned dive-event - * subgroups. Chunks 0x08 (activity), 0x0E (satellite info) and 0x14 - * (battery) have fixed lengths that are used for resync (see - * suunto_nautic_sbem_fixed_length) but map to no dc_field/dc_sample and - * are not otherwise decoded. Chunks 0x23/0x24 are raw accelerometer / + * (depth, cylinder pressures, gas time remaining, NDL, time-to-surface), + * 0x17 (surface pressure), 0x0B (GPS), 0x0F (heart rate, Ocean only), + * 0x08 (activity -> dive mode), plus the dynamically-assigned dive-event + * subgroups. Chunks 0x0E (satellite + * info) and 0x14 (battery) have fixed lengths that are used for resync + * (see suunto_nautic_sbem_fixed_length) but map to no dc_field/dc_sample + * and are not otherwise decoded. Chunks 0x23/0x24 are raw accelerometer / * gyroscope dumps for client-side dead reckoning, emitted through * DC_SAMPLE_VENDOR. Unknown chunk ids are skipped, so extending the * decoder is additive. @@ -59,9 +60,14 @@ #define SBEM_MAGIC_SIZE 8 #define CHUNK_TIMELINE_BASE 0x01 -#define CHUNK_ACTIVITY 0x08 +#define CHUNK_ACTIVITY 0x08 // [timeDelta:2][sportId:1][customModeId: ascii] +// Suunto sport ids seen on the Nautic/Ocean, from the app's ActivityType: +// 51 = scuba, 61 = free diving, 62 = mermaiding. Only the last two are apnea. +#define SPORT_ID_FREEDIVE 61 +#define SPORT_ID_MERMAIDING 62 #define CHUNK_GPS 0x0B #define CHUNK_GPS_ACCURACY 0x0E // [timeDelta:2][dEHPE:int8][dEVPE:int8][?:2] +#define CHUNK_HEARTRATE 0x0F // [timeDelta:2][hr:uint8 bpm] -- Ocean wrist HR only #define CHUNK_BATTERY 0x14 // [timeDelta:2][current:int16][voltage:uint16 mV][charge:uint8 %] // High-rate IMU: [timeDelta:2][algoTS:uint32][accel/gyro/mag X,Y,Z:int16], a // 24-byte payload. The chunk id is FIRMWARE-DEPENDENT: 0x23 on the 195-byte @@ -148,6 +154,7 @@ typedef struct suunto_nautic_parser_t { double maxdepth; // meters double avgdepth; // meters unsigned int have_temperature; + double temperature_surface; // first (surface) reading double temperature_minimum; double temperature_maximum; unsigned int ntanks; @@ -158,6 +165,7 @@ typedef struct suunto_nautic_parser_t { double atmospheric; // bar unsigned int have_datetime; dc_ticks_t datetime; // dive start, UNIX seconds + dc_divemode_t divemode; // from the CHUNK_ACTIVITY sport id; OC unless apnea // From the /Summary SBEM section appended after the profile, if present. unsigned int ngasmixes; dc_gasmix_t gasmix[MAX_GASMIXES]; @@ -430,6 +438,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback unsigned int depth_count = 0; unsigned int have_temperature = 0; + double temperature_surface = 0.0; double temperature_minimum = 0.0; double temperature_maximum = 0.0; @@ -458,6 +467,12 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback unsigned int have_datetime = 0; + // Open circuit unless a CHUNK_ACTIVITY sport id says the dive was apnea. + // The Nautic/Ocean are recreational OC computers (no CCR/SCR), and with no + // activity chunk at all OC is the right default -- far better than the + // dc_divemode_t zero value (freedive) a missing field leaves behind. + dc_divemode_t divemode = DC_DIVEMODE_OC; + // GPS horizontal/vertical position error, int8-delta-accumulated (chunk 0x0E). int ehpe = 0, evpe = 0; @@ -472,6 +487,8 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback double temperature = array_uint16_le (chunk.data + 16) / 100.0 - 273.15; if (!have_temperature) { + // The first reading is taken at/near the surface at dive start. + temperature_surface = temperature; temperature_minimum = temperature_maximum = temperature; have_temperature = 1; } else { @@ -533,6 +550,23 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback break; // full tank record doesn't fit this chunk if (chunk.data[base] != i) break; // not a real tank slot + + // Gas time remaining: uint32 LE seconds at record +10, for + // the primary cylinder. 0xFFFFFFFF means not computed (no + // AI, or not enough data yet). This is the app's + // Cylinders[].GasTime; exposed as RBT minutes, the same way + // suunto_eonsteel does. + if (i == 0 && callback) { + unsigned int gastime = array_uint32_le (chunk.data + base + 10); + if (gastime != 0xFFFFFFFF && gastime != 0) { + dc_sample_value_t sample = {0}; + sample.time = (unsigned int) time_ms; + callback (DC_SAMPLE_TIME, &sample, userdata); + sample.rbt = gastime / 60; + callback (DC_SAMPLE_RBT, &sample, userdata); + } + } + for (unsigned int field = 0; field < 2; field++) { unsigned int pressure_pa = array_uint32_le (chunk.data + base + 2 + field * 4); if (pressure_pa == 0) @@ -700,16 +734,35 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback callback (DC_SAMPLE_EVENT, &sample, userdata); } } else if (chunk.id == CHUNK_GAS_SWITCH && chunk.size >= 4) { - // [timeDelta:2][gasnumber:int16 LE]. - if (callback) { + // [timeDelta:2][gasnumber:int16 LE] -- 0-based, and equal to the + // cylinder slot / gas-mix index. + int gasnum = (int16_t) array_uint16_le (chunk.data + 2); + if (gasnum >= 0 && callback) { dc_sample_value_t sample = {0}; sample.time = (unsigned int) time_ms; callback (DC_SAMPLE_TIME, &sample, userdata); + // Modern channel: the active gas-mix index. + sample.gasmix = (unsigned int) gasnum; + callback (DC_SAMPLE_GASMIX, &sample, userdata); + // Legacy channel, for consumers that only read events. sample.event.type = SAMPLE_EVENT_GASCHANGE; sample.event.flags = SAMPLE_FLAGS_BEGIN; - sample.event.value = (unsigned int) (int16_t) array_uint16_le (chunk.data + 2); + sample.event.value = (unsigned int) gasnum; callback (DC_SAMPLE_EVENT, &sample, userdata); } + } else if (chunk.id == CHUNK_HEARTRATE && chunk.size >= 3) { + // [timeDelta:2][hr:uint8 bpm]. Optical wrist HR, present only on + // the Suunto Ocean (the Nautic / Nautic S have no HR sensor, so + // the chunk never appears there). Byte-exact against the app + // export's per-sample HR on a real Ocean dive (66-113 bpm). + unsigned int hr = chunk.data[2]; + if (hr && callback) { + dc_sample_value_t sample = {0}; + sample.time = (unsigned int) time_ms; + callback (DC_SAMPLE_TIME, &sample, userdata); + sample.heartbeat = hr; + callback (DC_SAMPLE_HEARTBEAT, &sample, userdata); + } } else if (chunk.id == CHUNK_BATTERY && chunk.size >= 7) { // Battery telemetry -> DC_SAMPLE_VENDOR kind 1. // (Current at chunk.data+2 is int16 but its scale isn't confirmed, @@ -774,6 +827,15 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback // SurfacePressure (offset 2) is used; last one logged wins. have_atmospheric = 1; atmospheric = array_float_le (chunk.data + 2) / 100000.0; + } else if (chunk.id == CHUNK_ACTIVITY && chunk.size >= 3) { + // [timeDelta:2][sportId:1][customModeId: ascii]. The sport id + // is the app's ActivityType; 61/62 are the apnea sports, every + // other diving id is open circuit. + unsigned int sport = chunk.data[2]; + if (sport == SPORT_ID_FREEDIVE || sport == SPORT_ID_MERMAIDING) + divemode = DC_DIVEMODE_FREEDIVE; + else + divemode = DC_DIVEMODE_OC; } } @@ -790,6 +852,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->maxdepth = maxdepth; parser->avgdepth = depth_count ? depth_sum / depth_count : 0.0; parser->have_temperature = have_temperature; + parser->temperature_surface = temperature_surface; parser->temperature_minimum = temperature_minimum; parser->temperature_maximum = temperature_maximum; parser->ntanks = ntanks; @@ -799,6 +862,7 @@ suunto_nautic_parser_parse (dc_parser_t *abstract, dc_sample_callback_t callback parser->have_atmospheric = have_atmospheric; parser->atmospheric = atmospheric; parser->have_datetime = have_datetime; + parser->divemode = divemode; // Gradient factors and gas mixes from the appended /Summary section. parser->ngasmixes = 0; @@ -866,6 +930,11 @@ suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, uns case DC_FIELD_AVGDEPTH: *((double *) value) = parser->avgdepth; break; + case DC_FIELD_TEMPERATURE_SURFACE: + if (!parser->have_temperature) + return DC_STATUS_UNSUPPORTED; + *((double *) value) = parser->temperature_surface; + break; case DC_FIELD_TEMPERATURE_MINIMUM: if (!parser->have_temperature) return DC_STATUS_UNSUPPORTED; @@ -925,6 +994,9 @@ suunto_nautic_parser_get_field (dc_parser_t *abstract, dc_field_type_t type, uns return DC_STATUS_UNSUPPORTED; *((dc_decomodel_t *) value) = parser->decomodel; break; + case DC_FIELD_DIVEMODE: + *((dc_divemode_t *) value) = parser->divemode; + break; default: return DC_STATUS_UNSUPPORTED; }