commit 5703c05c1dbfe6261218890e79573334358e3aab Author: brentperteet Date: Wed Jun 24 11:12:44 2026 -0500 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..459fd02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +pointone/ +postman/ + +.venv/ +__pycache__/ +*.py[cod] diff --git a/AT_Commands_Guide_EN.md b/AT_Commands_Guide_EN.md new file mode 100644 index 0000000..affd4a9 --- /dev/null +++ b/AT_Commands_Guide_EN.md @@ -0,0 +1,181 @@ +# AT Commands User Guide + +This document provides detailed usage instructions for the main AT commands supported by the H11 device. + +## 1. AT+DEV_INIT_STA=GET + +**Function**: Query device initialization status + +**Command Format**: +``` +AT+DEV_INIT_STA=GET +``` + +**Response Example**: +``` +AT+DEV_INIT_STA=GET,4G,<4G_link>,,,,,,,GNSS,,,star,,,, +OK +``` + +**Parameter Explanation**: +- `<4G_link>`: 4G network connection status (0=disconnected, 1=connected) +- ``: SIM card ready status (0=not ready, 1=ready) +- ``: NTRIP connection status (0=disconnected, 1=connected) +- ``: Signal quality (0-31, higher is better) +- ``: Data upload status (0=idle, 1=uploading) +- ``: Upload queue size (bytes) +- ``: NTRIP data queue size (bytes) +- ``: GNSS module status +- ``, ``, ``: Number of satellites with SNR > 45/48/50 dB +- ``: Total number of visible satellites + +**Use Cases**: +- Check device module initialization status after startup +- Diagnose device connectivity and module status +- Monitor network and GNSS module status + +--- + +## 2. AT+NEMATIME=SET + +**Function**: Set NMEA output frequency + +**Command Format**: +``` +AT+NEMATIME=SET, +``` + +**Parameters**: +- ``: Output frequency in Hz (supported values: 1, 2, 5, 10) + +**Response Example**: +``` +AT+NEMATIME=SET +OK +``` + +**Error Response**: +``` +AT+NEMATIME=SET +ERROR +``` + +**Use Cases**: +- Configure GNSS position data output rate +- Adjust positioning data update frequency based on application requirements +- Balance between update rate and power consumption + +--- + +## 3. AT+NEMATIME=GET + +**Function**: Query current NMEA output frequency + +**Command Format**: +``` +AT+NEMATIME=GET +``` + +**Response Example**: +``` +AT+NEMATIME=GET, +OK +``` + +**Parameter Explanation**: +- ``: Current NMEA output frequency in Hz + +**Use Cases**: +- Verify the current NMEA output frequency configuration +- Ensure frequency configuration has taken effect +- Troubleshoot positioning data update issues + +--- + +## 4. AT+RTCMBASEPOS=GET + +**Function**: Query base station position and distance from RTCM data source + +**Command Format**: +``` +AT+RTCMBASEPOS=GET +``` + +**Response Example** (when RTCM data is available): +``` +AT+RTCMBASEPOS=GET,,,, +OK +``` + +**Response Example** (when RTCM data is not available): +``` +AT+RTCMBASEPOS=GET,0.0,0.0,0.0,0.0 +OK +``` + +**Parameter Explanation**: +- ``: Base station latitude in degrees (positive=North, negative=South) +- ``: Base station longitude in degrees (positive=East, negative=West) +- ``: Base station altitude/elevation in meters +- ``: Distance from device to base station in meters + +**Use Cases**: +- Obtain current base station position information in RTK mode +- Monitor distance to reference station +- Assess differential positioning quality +- Verify RTK connection status + +--- + +## Command Usage Examples + +### Query device initialization status +``` +→ AT+DEV_INIT_STA=GET +← AT+DEV_INIT_STA=GET,4G,1,1,1,25,0,0,1024,GNSS,1,0,star,8,5,2,12 +← OK +``` + +### Set NMEA frequency to 1Hz +``` +→ AT+NEMATIME=SET,1 +← AT+NEMATIME=SET +← OK +``` + +### Query current NMEA frequency +``` +→ AT+NEMATIME=GET +← AT+NEMATIME=GET,1 +← OK +``` + +### Query base station position and distance +``` +→ AT+RTCMBASEPOS=GET +← AT+RTCMBASEPOS=GET,31.135370,121.287729,22.467,1234.56 +← OK +``` + +--- + +## Important Notes + +1. **Command Format**: All AT commands start with "AT" and end with "\r\n" +2. **Case Insensitive**: Commands are case-insensitive +3. **Response Timeout**: Recommended timeout for each command is 5 seconds +4. **Response Format**: Successful execution returns "OK", failure returns "ERROR" +5. **RTCM Base Station Info**: Base station position is only available after receiving valid RTCM 1005/1006 messages +6. **Frequency Setting**: Device may need restart for frequency changes to take effect +7. **Line Ending**: Use CR+LF (\r\n) as line terminator +8. **Serial Parameters**: Default baud rate is 115200 bps + +--- + +## Related Information + +- **NMEA**: National Marine Electronics Association protocol, widely used in GPS/GNSS devices +- **RTCM**: Radio Technical Commission for Maritime Services, data format for differential positioning +- **RTK**: Real-Time Kinematic positioning technology +- **CSQ**: Signal Quality indicator (0-31, where 31 is excellent) +- **SNR**: Signal-to-Noise Ratio measured in dB diff --git a/AT_Commands_Reference.md b/AT_Commands_Reference.md new file mode 100644 index 0000000..1800be8 --- /dev/null +++ b/AT_Commands_Reference.md @@ -0,0 +1,544 @@ +# AT Commands Reference Guide + +## Overview + +This document provides detailed information about AT commands for controlling the H11hw GNSS RTK device via Bluetooth. All commands follow the format `AT+COMMAND=ACTION` where ACTION is either `SET` or `GET`. + +**Response Format:** +- Success: `AT+COMMAND=ACTION\r\nOK\r\n` +- Error: `AT+COMMAND=ACTION\r\nERROR\r\n` + +--- + +## 1. APN Configuration + +Configure the APN (Access Point Name) for cellular network connection. + +### AT+APN=SET + +**Description:** Set APN parameters for 4G/LTE network connection. + +**Format:** +``` +AT+APN=SET,,,,\r\n +``` + +**Parameters:** +- `flag`: Enable/disable custom APN + - `0` = Use default APN (automatic) + - `1` = Use custom APN +- `apn`: APN name (string, max 64 chars) +- `username`: APN username (string, max 32 chars, optional) +- `password`: APN password (string, max 32 chars, optional) + +**Examples:** +``` +AT+APN=SET,0\r\n +AT+APN=SET,1,internet.v6.telekom,telekom,tm\r\n +AT+APN=SET,1,cmnet\r\n +``` + +**Response:** +``` +AT+APN=SET +OK +``` + +### AT+APN=GET + +**Description:** Query current APN configuration. + +**Format:** +``` +AT+APN=GET\r\n +``` + +**Response:** +``` +AT+APN=GET,,,, +OK +``` + +**Example:** +``` +AT+APN=GET,1,internet.v6.telekom,telekom,tm +OK +``` + +--- + +## 2. OLED Display Rotation + +Control the OLED screen orientation. + +### AT+OLEDROTATE=SET + +**Description:** Set OLED display rotation angle. + +**Format:** +``` +AT+OLEDROTATE=SET,\r\n +``` + +**Parameters:** +- `angle`: Rotation angle in degrees + - `0` = Normal (0°) + - `1` = Rotated 180° + +**Examples:** +``` +AT+OLEDROTATE=SET,0\r\n +AT+OLEDROTATE=SET,1\r\n +``` + +**Response:** +``` +AT+OLEDROTATE=SET +OK +``` + +### AT+OLEDROTATE=GET + +**Description:** Query current OLED rotation setting. + +**Format:** +``` +AT+OLEDROTATE=GET\r\n +``` + +**Response:** +``` +AT+OLEDROTATE=GET, +OK +``` + +--- + +## 3. Bluetooth Output Configuration + +Configure which NMEA sentences and custom messages are output via Bluetooth. + +### AT+BT_OUT=SET + +**Description:** Configure Bluetooth data output format and content. + +**Format:** +``` +AT+BT_OUT=SET,,,,,,,,,,\r\n +``` + +**Parameters:** +- `type`: Output mode + - `0` = Standard mode (output all raw GNSS data) + - `1` = Custom mode (selective output based on following parameters) +- `json`: Output JSON format position data (0=disable, 1=enable) +- `gnpos`: Output custom GNPOS sentence (0=disable, 1=enable) +- `gndev`: Output custom GNDEV sentence (0=disable, 1=enable) +- `gga`: Output NMEA GGA sentence (0=disable, 1=enable) +- `gst`: Output NMEA GST sentence (0=disable, 1=enable) +- `rmc`: Output NMEA RMC sentence (0=disable, 1=enable) +- `vtg`: Output NMEA VTG sentence (0=disable, 1=enable) +- `gsv`: Output NMEA GSV sentence (0=disable, 1=enable) +- `gsa`: Output NMEA GSA sentence (0=disable, 1=enable) + +**Examples:** +``` +AT+BT_OUT=SET,0\r\n +AT+BT_OUT=SET,1,1,1,1,1,1,1,1,1,1\r\n +AT+BT_OUT=SET,1,0,1,1,1,0,0,0,0,0\r\n +AT+BT_OUT=SET,1,,,,1,1,1,1\r\n +``` + +**Notes:** +- Empty parameters retain previous values +- When `type=0`, all other parameters are ignored +- When `type=1`, you can selectively enable/disable each output + +**Response:** +``` +AT+BT_OUT=SET +OK +``` + +### AT+BT_OUT=GET + +**Description:** Query current Bluetooth output configuration. + +**Format:** +``` +AT+BT_OUT=GET\r\n +``` + +**Response:** +``` +AT+BT_OUT=GET,,,,,,,,,, +OK +``` + +--- + +## 4. Data Upload Configuration + +Configure network data upload parameters (TCP/HTTP/MQTT). + +### AT+UPLOADDATA_PARM=SET + +**Description:** Set data upload server parameters. + +**Format:** +``` +AT+UPLOADDATA_PARM=SET,,,\r\n +``` + +**Parameters:** +- `enable`: Enable/disable data upload + - `0` = Disable + - `1` = Enable +- `server`: Server address (IP or domain name, max 128 chars) +- `port`: Server port number (1-65535) + +**Examples:** +``` +AT+UPLOADDATA_PARM=SET,0\r\n +AT+UPLOADDATA_PARM=SET,1,192.168.0.1,2202\r\n +AT+UPLOADDATA_PARM=SET,1,data.example.com,8080\r\n +``` + +**Response:** +``` +AT+UPLOADDATA_PARM=SET +OK +``` + +### AT+UPLOADDATA_PARM=GET + +**Description:** Query data upload server configuration. + +**Format:** +``` +AT+UPLOADDATA_PARM=GET\r\n +``` + +**Response:** +``` +AT+UPLOADDATA_PARM=GET,,, +OK +``` + +--- + +## 5. Data Upload Type Configuration + +Configure upload protocol and authentication. + +### AT+UPLOADDATA_TYPE=SET + +**Description:** Set upload protocol type and credentials. + +**Format:** +``` +AT+UPLOADDATA_TYPE=SET,,USERNAME,,PASSWORD,\r\n +``` + +**Parameters:** +- `type`: Upload protocol type + - `0` = TCP + - `1` = HTTP + - `2` = MQTT +- `username`: Authentication username (required for MQTT) +- `password`: Authentication password (required for MQTT) + +**Examples:** +``` +AT+UPLOADDATA_TYPE=SET,0\r\n +AT+UPLOADDATA_TYPE=SET,1\r\n +AT+UPLOADDATA_TYPE=SET,2,USERNAME,user11,PASSWORD,password11\r\n +``` + +**Response:** +``` +AT+UPLOADDATA_TYPE=SET +OK +``` + +### AT+UPLOADDATA_TYPE=GET + +**Description:** Query upload protocol configuration. + +**Format:** +``` +AT+UPLOADDATA_TYPE=GET\r\n +``` + +**Response:** +``` +AT+UPLOADDATA_TYPE=GET,,, +OK +``` + +--- + +## 6. Rover Mode Configuration + +Configure NTRIP client parameters for RTK rover mode. + +### AT+ROVER_PARM=SET + +**Description:** Set NTRIP/CORS server parameters for receiving RTK corrections. + +**Format:** +``` +AT+ROVER_PARM=SET,,,,,,\r\n +``` + +**Parameters:** +- `enable`: Enable/disable NTRIP client + - `0` = Disable + - `1` = Enable +- `server`: NTRIP server address (IP or domain name) +- `port`: NTRIP server port (typically 2101 or 2102) +- `mountpoint`: NTRIP mountpoint name +- `username`: NTRIP authentication username +- `password`: NTRIP authentication password + +**Examples:** +``` +AT+ROVER_PARM=SET,0\r\n +AT+ROVER_PARM=SET,1,211.144.118.5,2102,RTCM32,username,password\r\n +AT+ROVER_PARM=SET,1,sh.mijiatech.cn,2102,22C018,zd,zd\r\n +``` + +**Response:** +``` +AT+ROVER_PARM=SET +OK +``` + +### AT+ROVER_PARM=GET + +**Description:** Query NTRIP client configuration. + +**Format:** +``` +AT+ROVER_PARM=GET\r\n +``` + +**Response:** +``` +AT+ROVER_PARM=GET,,,,,, +OK +``` + +--- + +## 7. Base Station Mode Configuration + +Configure base station parameters for outputting RTK corrections. + +### AT+BASE_PARM=SET + +**Description:** Set base station mode parameters. + +**Format (Mode 0 - Disable):** +``` +AT+BASE_PARM=SET,0\r\n +``` + +**Format (Mode 1 - TCP Server):** +``` +AT+BASE_PARM=SET,1,,\r\n +``` + +**Format (Mode 2 - NTRIP Caster):** +``` +AT+BASE_PARM=SET,2,,,,,\r\n +``` + +**Parameters:** +- Mode `0`: Disable base station mode +- Mode `1`: TCP server mode + - `server`: Server address to send RTCM data + - `port`: Server port +- Mode `2`: NTRIP caster mode + - `server`: NTRIP caster address + - `port`: NTRIP caster port + - `mountpoint`: Mountpoint name + - `username`: Authentication username + - `password`: Authentication password + +**Examples:** +``` +AT+BASE_PARM=SET,0\r\n +AT+BASE_PARM=SET,1,192.168.1.100,2102\r\n +AT+BASE_PARM=SET,2,sh.mijiatech.cn,2102,RTCM32-1,zd,zd\r\n +``` + +**Response:** +``` +AT+BASE_PARM=SET +OK +``` + +### AT+BASE_PARM=GET + +**Description:** Query base station configuration. + +**Format:** +``` +AT+BASE_PARM=GET\r\n +``` + +**Response (Mode 0):** +``` +AT+BASE_PARM=GET,0 +OK +``` + +**Response (Mode 1):** +``` +AT+BASE_PARM=GET,1,, +OK +``` + +**Response (Mode 2):** +``` +AT+BASE_PARM=GET,2,,,,, +OK +``` + +--- + +## 8. GNSS Mode Configuration + +Configure device operating mode (Rover/Base/Static). + +### AT+GNSS_MODE=SET + +**Description:** Set GNSS operating mode. + +**Format:** +``` +AT+GNSS_MODE=SET,\r\n +``` + +**Parameters:** +- `mode`: Operating mode + - `0` = Rover mode (mobile RTK positioning) + - `1` = Base station mode (output RTK corrections) + - `2` = Static mode (stationary positioning) + +**Examples:** +``` +AT+GNSS_MODE=SET,0\r\n +AT+GNSS_MODE=SET,1\r\n +AT+GNSS_MODE=SET,2\r\n +``` + +**Response:** +``` +AT+GNSS_MODE=SET +OK +``` + +**Notes:** +- Changing mode may require device restart +- Base mode requires fixed position configuration +- Rover mode requires NTRIP configuration for RTK + +### AT+GNSS_MODE=GET + +**Description:** Query current GNSS operating mode. + +**Format:** +``` +AT+GNSS_MODE=GET\r\n +``` + +**Response:** +``` +AT+GNSS_MODE=GET, +OK +``` + +**Example:** +``` +AT+GNSS_MODE=GET,0 +OK +``` + +--- + +## Command Summary Table + +| Command | SET | GET | Description | +|---------|-----|-----|-------------| +| AT+APN | ✓ | ✓ | Configure cellular APN | +| AT+OLEDROTATE | ✓ | ✓ | Set display rotation | +| AT+BT_OUT | ✓ | ✓ | Configure Bluetooth output | +| AT+UPLOADDATA_PARM | ✓ | ✓ | Set upload server | +| AT+UPLOADDATA_TYPE | ✓ | ✓ | Set upload protocol | +| AT+ROVER_PARM | ✓ | ✓ | Configure NTRIP client | +| AT+BASE_PARM | ✓ | ✓ | Configure base station | +| AT+GNSS_MODE | ✓ | ✓ | Set operating mode | + +--- + +## Common Usage Scenarios + +### Scenario 1: Configure Rover Mode for RTK Positioning + +``` +AT+GNSS_MODE=SET,0\r\n +AT+ROVER_PARM=SET,1,rtk.server.com,2101,MOUNT01,user,pass\r\n +AT+BT_OUT=SET,1,0,1,1,1,1,0,0,0,0\r\n +``` + +### Scenario 2: Configure Base Station Mode + +``` +AT+GNSS_MODE=SET,1\r\n +AT+BASE_PARM=SET,2,caster.server.com,2101,BASE01,user,pass\r\n +``` + +### Scenario 3: Enable All NMEA Output via Bluetooth + +``` +AT+BT_OUT=SET,1,0,1,1,1,1,1,1,1,1\r\n +``` + +### Scenario 4: Configure Custom APN + +``` +AT+APN=SET,1,internet,username,password\r\n +``` + +--- + +## Error Handling + +**Common Error Responses:** +- `ERROR` - Invalid command format or parameters +- No response - Command timeout (check Bluetooth connection) + +**Troubleshooting:** +1. Ensure commands end with `\r\n` +2. Check parameter count and format +3. Verify Bluetooth connection is active +4. Wait for response before sending next command + +--- + +## Notes + +- All commands are case-sensitive +- Commands must end with `\r\n` (carriage return + line feed) +- String parameters should not contain commas +- Empty parameters in SET commands retain previous values +- Configuration changes are saved to flash memory automatically +- Some changes may require device restart to take effect + +--- + +**Document Version:** 1.0 +**Firmware Version:** 1.2.37 +**Last Updated:** 2026-05-07 diff --git a/AT_UPLOADDATA_MQTT_Configuration_EN.md b/AT_UPLOADDATA_MQTT_Configuration_EN.md new file mode 100644 index 0000000..5666e33 --- /dev/null +++ b/AT_UPLOADDATA_MQTT_Configuration_EN.md @@ -0,0 +1,122 @@ +# MQTT Data Upload Configuration via AT Commands + +## Overview + +Configure device data upload functionality using `AT+UPLOADDATA_PARM` and `AT+UPLOADDATA_TYPE` commands. Configuration is saved to Flash and persists across power cycles. + +--- + +## Configuration Steps + +### Step 1: Set Server Address and Upload Frequency + +**Command:** `AT+UPLOADDATA_PARM=SET` + +**Format:** +``` +AT+UPLOADDATA_PARM=SET,,,\r\n +``` + +**Parameters:** + +| Parameter | Description | Values | +|-----------|-------------|--------| +| freq | Upload frequency | 0=OFF, 1=1s, 2=2s, 5=5s, 10=10s, 255=Follow GGA rate | +| ip | Server address | IP or domain name, max 99 bytes | +| port | Server port | 1~65535 | + +**Example:** +``` +AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883 +``` + +**Response:** +``` +OK +``` + +--- + +### Step 2: Set Protocol Type to MQTT and Configure Authentication + +**Command:** `AT+UPLOADDATA_TYPE=SET` + +**Format:** +``` +AT+UPLOADDATA_TYPE=SET,2,USERNAME,,PASSWORD,,CLIENTID,,TOPIC,\r\n +``` + +**Parameters:** + +| Parameter | Description | Max Length | +|-----------|-------------|------------| +| type | Protocol type, use `2` for MQTT | — | +| USERNAME | MQTT username | 63 bytes | +| PASSWORD | MQTT password | 63 bytes | +| CLIENTID | MQTT client ID | 63 bytes | +| TOPIC | Publish topic | 63 bytes | + +**Example:** +``` +AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data +``` + +**Response:** +``` +OK +``` + +--- + +## Query Current Configuration + +### Query Server Parameters + +``` +AT+UPLOADDATA_PARM=GET\r\n +``` + +**Response Format:** +``` +AT+UPLOADDATA_PARM=GET,,, +OK +``` + +### Query Protocol Type and MQTT Parameters + +``` +AT+UPLOADDATA_TYPE=GET\r\n +``` + +**Response Format (MQTT):** +``` +AT+UPLOADDATA_TYPE=GET,2,USERNAME,,PASSWORD,,CLIENTID,,TOPIC,,SUBTOPIC, +OK +``` + +--- + +## Protocol Type Reference + +| type | Protocol | Description | +|------|----------|-------------| +| 0 | TCP | Raw TCP connection, no additional parameters | +| 1 | HTTP | HTTP POST upload, requires USERNAME/PASSWORD | +| 2 | MQTT | MQTT publish, requires USERNAME/PASSWORD/CLIENTID/TOPIC | +| 3 | JT808 | JT808 protocol | + +--- + +## Complete Configuration Example + +``` +# 1. Set server address with 1-second upload interval +AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883 + +# 2. Set MQTT protocol and authentication +AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data + +# 3. Verify configuration +AT+UPLOADDATA_PARM=GET +AT+UPLOADDATA_TYPE=GET +``` diff --git a/Custom_NMEA_Sentences.md b/Custom_NMEA_Sentences.md new file mode 100644 index 0000000..6833cc0 --- /dev/null +++ b/Custom_NMEA_Sentences.md @@ -0,0 +1,542 @@ +# Custom NMEA Sentences Reference + +## Overview + +This document describes the custom NMEA-format sentences (GNPOS and GNDEV) output by the H11hw GNSS RTK device. These proprietary sentences provide comprehensive positioning data and device information in a standardized NMEA format. + +**NMEA Format Structure:** +``` +$*\r\n +``` +- `$` - Start delimiter +- `` - Sentence identifier and data fields (comma-separated) +- `*` - Checksum delimiter +- `` - Two-digit hexadecimal XOR checksum +- `\r\n` - End delimiter (CR+LF) + +--- + +## 1. GNPOS - Position Data Sentence + +### Description + +The GNPOS sentence provides comprehensive real-time positioning information including coordinates, accuracy metrics, satellite status, and system information. + +### Output Frequency + +- **Default:** Every 800ms (1.25 Hz) +- **Configurable:** Via `AT+BT_OUT=SET` command + +### Format + +``` +$GNPOS,,,,,,,,,,,,,,,,,,,*\r\n +``` + +### Field Definitions + +| Field | Index | Type | Unit | Description | Example | +|-------|-------|------|------|-------------|---------| +| Sentence ID | 0 | String | - | Fixed identifier | GNPOS | +| lat | 1 | Float | degrees | Latitude (signed, 9 decimals) | 31.140518542 | +| lon | 2 | Float | degrees | Longitude (signed, 9 decimals) | 121.284018564 | +| alt | 3 | Float | meters | Altitude above sea level (3 decimals) | 45.123 | +| altCorr | 4 | Float | meters | Corrected altitude (3 decimals) | 45.456 | +| status | 5 | Integer | - | Positioning status (see table below) | 4 | +| hdop | 6 | Float | - | Horizontal Dilution of Precision (2 decimals) | 0.85 | +| hrms | 7 | Float | meters | Horizontal RMS error (3 decimals) | 0.012 | +| vrms | 8 | Float | meters | Vertical RMS error (3 decimals) | 0.018 | +| satUsed | 9 | Integer | - | Number of satellites used in solution | 18 | +| satView | 10 | Integer | - | Number of satellites visible | 24 | +| speed | 11 | Float | km/h | Ground speed (3 decimals) | 12.345 | +| heading | 12 | Float | degrees | Heading/course over ground (2 decimals) | 135.67 | +| battV | 13 | Float | volts | Battery voltage (2 decimals) | 4.15 | +| battPct | 14 | Integer | % | Battery percentage (0-100) | 85 | +| ntripFlag | 15 | Integer | - | NTRIP connection status (0=disconnected, 1=connected) | 1 | +| rtcmSize | 16 | Integer | bytes | RTCM data size received | 1024 | +| age | 17 | Float | seconds | Differential correction age (1 decimal) | 1.2 | +| timestamp | 18 | Integer | seconds | System timestamp (Unix time) | 1714953600 | +| tiltAngle | 19 | Float | degrees | Device tilt angle (1 decimal) | 2.5 | + +### Positioning Status Values + +| Value | Status | Description | +|-------|--------|-------------| +| 0 | No Fix | No valid position | +| 1 | Single Point | Autonomous GPS/GNSS positioning | +| 2 | DGPS | Differential GPS (SBAS corrected) | +| 4 | RTK Fixed | RTK fixed solution (cm-level accuracy) | +| 5 | RTK Float | RTK float solution (dm-level accuracy) | + +### Example Output + +``` +$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,12.345,135.67,4.15,85,1,1024,1.2,1714953600,2.5*5A\r\n +``` + +### Parsing Example (Python) + +```python +def parse_gnpos(sentence): + # Remove $, checksum, and whitespace + data = sentence.strip().split('*')[0].lstrip('$') + fields = data.split(',') + + if fields[0] != 'GNPOS' or len(fields) != 20: + return None + + return { + 'latitude': float(fields[1]), + 'longitude': float(fields[2]), + 'altitude': float(fields[3]), + 'altitude_corrected': float(fields[4]), + 'status': int(fields[5]), + 'hdop': float(fields[6]), + 'hrms': float(fields[7]), + 'vrms': float(fields[8]), + 'satellites_used': int(fields[9]), + 'satellites_visible': int(fields[10]), + 'speed_kmh': float(fields[11]), + 'heading': float(fields[12]), + 'battery_voltage': float(fields[13]), + 'battery_percent': int(fields[14]), + 'ntrip_connected': bool(int(fields[15])), + 'rtcm_size': int(fields[16]), + 'correction_age': float(fields[17]), + 'timestamp': int(fields[18]), + 'tilt_angle': float(fields[19]) + } +``` + +### Parsing Example (C) + +```c +typedef struct { + double latitude; + double longitude; + float altitude; + float altitude_corrected; + int status; + float hdop; + float hrms; + float vrms; + int satellites_used; + int satellites_visible; + float speed_kmh; + float heading; + float battery_voltage; + int battery_percent; + int ntrip_connected; + int rtcm_size; + float correction_age; + long long timestamp; + float tilt_angle; +} gnpos_data_t; + +int parse_gnpos(const char* sentence, gnpos_data_t* data) { + if (strncmp(sentence, "$GNPOS,", 7) != 0) { + return -1; + } + + int result = sscanf(sentence, + "$GNPOS,%lf,%lf,%f,%f,%d,%f,%f,%f,%d,%d,%f,%f,%f,%d,%d,%d,%f,%lld,%f*", + &data->latitude, + &data->longitude, + &data->altitude, + &data->altitude_corrected, + &data->status, + &data->hdop, + &data->hrms, + &data->vrms, + &data->satellites_used, + &data->satellites_visible, + &data->speed_kmh, + &data->heading, + &data->battery_voltage, + &data->battery_percent, + &data->ntrip_connected, + &data->rtcm_size, + &data->correction_age, + &data->timestamp, + &data->tilt_angle + ); + + return (result == 19) ? 0 : -1; +} +``` + +--- + +## 2. GNDEV - Device Information Sentence + +### Description + +The GNDEV sentence provides device identification and cellular module information. This sentence is useful for device management, inventory tracking, and remote diagnostics. + +### Output Frequency + +- **Bluetooth disconnected:** Every 5 seconds +- **Bluetooth connected (first 10 seconds):** Every 800ms (fast sync) +- **Bluetooth connected (after 10 seconds):** Every 5 seconds + +### Format + +``` +$GNDEV,,,,,,*\r\n +``` + +### Field Definitions + +| Field | Index | Type | Length | Description | Example | +|-------|-------|------|--------|-------------|---------| +| Sentence ID | 0 | String | - | Fixed identifier | GNDEV | +| sn | 1 | String | Variable | Device serial number (unique ID) | H11-20240507-001 | +| pcb_version | 2 | String | Variable | PCB hardware version | V1.2 | +| fw_version | 3 | String | Variable | Firmware version (x.x.x format) | 1.2.37 | +| imei | 4 | String | 15 | 4G module IMEI (International Mobile Equipment Identity) | 866123456789012 | +| imsi | 5 | String | 15 | SIM card IMSI (International Mobile Subscriber Identity) | 460012345678901 | +| iccid | 6 | String | 20 | SIM card ICCID (Integrated Circuit Card ID) | 89860123456789012345 | + +### Example Output + +``` +$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n +``` + +### Parsing Example (Python) + +```python +def parse_gndev(sentence): + # Remove $, checksum, and whitespace + data = sentence.strip().split('*')[0].lstrip('$') + fields = data.split(',') + + if fields[0] != 'GNDEV' or len(fields) != 7: + return None + + return { + 'serial_number': fields[1], + 'pcb_version': fields[2], + 'firmware_version': fields[3], + 'imei': fields[4], + 'imsi': fields[5], + 'iccid': fields[6] + } +``` + +### Parsing Example (C) + +```c +typedef struct { + char serial_number[32]; + char pcb_version[16]; + char firmware_version[16]; + char imei[16]; + char imsi[16]; + char iccid[32]; +} gndev_data_t; + +int parse_gndev(const char* sentence, gndev_data_t* data) { + if (strncmp(sentence, "$GNDEV,", 7) != 0) { + return -1; + } + + int result = sscanf(sentence, + "$GNDEV,%31[^,],%15[^,],%15[^,],%15[^,],%15[^,],%31[^*]*", + data->serial_number, + data->pcb_version, + data->firmware_version, + data->imei, + data->imsi, + data->iccid + ); + + return (result == 6) ? 0 : -1; +} +``` + +--- + +## 3. Checksum Calculation + +### Algorithm + +The checksum is calculated as the XOR of all characters between `$` and `*` (exclusive). + +### Example (C) + +```c +uint8_t calculate_nmea_checksum(const char* sentence) { + uint8_t checksum = 0; + const char* ptr = sentence; + + // Skip '$' if present + if (*ptr == '$') ptr++; + + // XOR all characters until '*' or end + while (*ptr && *ptr != '*') { + checksum ^= *ptr; + ptr++; + } + + return checksum; +} + +// Verify checksum +int verify_nmea_checksum(const char* sentence) { + const char* asterisk = strchr(sentence, '*'); + if (!asterisk) return 0; + + uint8_t calculated = calculate_nmea_checksum(sentence); + uint8_t received = 0; + sscanf(asterisk + 1, "%02X", &received); + + return (calculated == received); +} +``` + +### Example (Python) + +```python +def calculate_nmea_checksum(sentence): + # Remove $ and everything after * + data = sentence.lstrip('$').split('*')[0] + checksum = 0 + for char in data: + checksum ^= ord(char) + return checksum + +def verify_nmea_checksum(sentence): + if '*' not in sentence: + return False + data, checksum_str = sentence.split('*') + calculated = calculate_nmea_checksum(data) + received = int(checksum_str[:2], 16) + return calculated == received +``` + +--- + +## 4. Integration Guide + +### Enabling Custom Sentences + +Use the `AT+BT_OUT=SET` command to enable GNPOS and GNDEV output: + +``` +AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0\r\n +``` + +This enables: +- Custom mode (`type=1`) +- GNPOS sentence (`gnpos=1`) +- GNDEV sentence (`gndev=1`) +- Disables JSON and standard NMEA sentences + +### Complete Data Stream Example + +``` +$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n +$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,12.345,135.67,4.15,85,1,1024,1.2,1714953600,2.5*5A\r\n +$GNPOS,31.140518543,121.284018565,45.124,45.457,4,0.85,0.012,0.018,18,24,12.346,135.68,4.15,85,1,1024,1.2,1714953601,2.5*5B\r\n +$GNPOS,31.140518544,121.284018566,45.125,45.458,4,0.85,0.012,0.018,18,24,12.347,135.69,4.15,85,1,1024,1.2,1714953602,2.5*5C\r\n +$GNDEV,H11-20240507-001,V1.2,1.2.37,866123456789012,460012345678901,89860123456789012345*3F\r\n +``` + +### Bluetooth Receiver Implementation + +```python +import serial +import time + +class H11RTKReceiver: + def __init__(self, port, baudrate=115200): + self.serial = serial.Serial(port, baudrate, timeout=1) + self.gnpos_callback = None + self.gndev_callback = None + + def set_gnpos_callback(self, callback): + self.gnpos_callback = callback + + def set_gndev_callback(self, callback): + self.gndev_callback = callback + + def read_loop(self): + buffer = "" + while True: + data = self.serial.read(256).decode('utf-8', errors='ignore') + buffer += data + + while '\n' in buffer: + line, buffer = buffer.split('\n', 1) + line = line.strip() + + if line.startswith('$GNPOS,'): + if verify_nmea_checksum(line): + gnpos_data = parse_gnpos(line) + if self.gnpos_callback and gnpos_data: + self.gnpos_callback(gnpos_data) + + elif line.startswith('$GNDEV,'): + if verify_nmea_checksum(line): + gndev_data = parse_gndev(line) + if self.gndev_callback and gndev_data: + self.gndev_callback(gndev_data) + +# Usage example +def on_position_update(data): + print(f"Position: {data['latitude']:.9f}, {data['longitude']:.9f}") + print(f"Status: {data['status']}, Satellites: {data['satellites_used']}") + print(f"Accuracy: H={data['hrms']:.3f}m, V={data['vrms']:.3f}m") + +def on_device_info(data): + print(f"Device: {data['serial_number']}") + print(f"Firmware: {data['firmware_version']}") + print(f"IMEI: {data['imei']}") + +receiver = H11RTKReceiver('/dev/rfcomm0') +receiver.set_gnpos_callback(on_position_update) +receiver.set_gndev_callback(on_device_info) +receiver.read_loop() +``` + +--- + +## 5. Data Quality Indicators + +### RTK Solution Quality + +| Status | HRMS Range | VRMS Range | Typical Accuracy | +|--------|------------|------------|------------------| +| RTK Fixed (4) | < 0.02m | < 0.03m | 1-2 cm horizontal, 2-3 cm vertical | +| RTK Float (5) | 0.1-0.5m | 0.2-1.0m | 10-50 cm horizontal, 20-100 cm vertical | +| DGPS (2) | 0.5-2.0m | 1.0-3.0m | 0.5-2 m horizontal, 1-3 m vertical | +| Single (1) | 2.0-10.0m | 3.0-15.0m | 2-10 m horizontal, 3-15 m vertical | + +### HDOP Quality Assessment + +| HDOP Value | Quality | Description | +|------------|---------|-------------| +| < 1.0 | Excellent | Ideal satellite geometry | +| 1.0 - 2.0 | Good | Acceptable for RTK | +| 2.0 - 5.0 | Moderate | Usable but degraded | +| > 5.0 | Poor | Unreliable positioning | + +### Correction Age Guidelines + +| Age (seconds) | RTK Quality | Recommendation | +|---------------|-------------|----------------| +| < 3.0 | Excellent | Optimal RTK performance | +| 3.0 - 10.0 | Good | Acceptable for most applications | +| 10.0 - 30.0 | Degraded | Consider reconnecting | +| > 30.0 | Poor | RTK solution unreliable | + +--- + +## 6. Troubleshooting + +### No GNPOS/GNDEV Output + +**Possible Causes:** +1. Bluetooth output not configured +2. Custom mode disabled +3. Bluetooth connection lost + +**Solutions:** +``` +AT+BT_OUT=GET\r\n # Check current configuration +AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0\r\n # Enable custom sentences +``` + +### Invalid Checksum + +**Possible Causes:** +1. Data corruption during transmission +2. Bluetooth interference +3. Buffer overflow + +**Solutions:** +- Verify checksum calculation algorithm +- Check Bluetooth signal strength +- Increase receive buffer size + +### Incorrect Data Values + +**Possible Causes:** +1. GNSS not initialized +2. No satellite fix +3. Antenna disconnected + +**Solutions:** +- Check `status` field (should be > 0) +- Verify `satView` and `satUsed` (should be > 4) +- Check antenna connection + +--- + +## 7. Best Practices + +### Data Processing + +1. **Always verify checksums** before parsing data +2. **Check positioning status** before using coordinates +3. **Monitor correction age** for RTK applications +4. **Validate satellite count** (minimum 4 for 3D fix) +5. **Check HDOP values** for solution quality + +### Performance Optimization + +1. **Buffer management:** Use circular buffers for continuous data streams +2. **Parsing efficiency:** Pre-compile regex patterns or use fixed-format parsing +3. **Callback design:** Keep callbacks lightweight, defer heavy processing +4. **Error handling:** Implement timeout and retry mechanisms + +### Application Design + +1. **Position filtering:** Apply Kalman filtering for smooth trajectories +2. **Status monitoring:** Track RTK solution quality over time +3. **Battery management:** Monitor voltage and percentage for low-power warnings +4. **Connection health:** Track NTRIP status and correction age + +--- + +## Appendix A: Complete Message Examples + +### RTK Fixed Solution (High Quality) + +``` +$GNPOS,31.140518542,121.284018564,45.123,45.456,4,0.85,0.012,0.018,18,24,0.000,0.00,4.15,85,1,1024,1.2,1714953600,0.5*XX\r\n +``` +- Status: 4 (RTK Fixed) +- HRMS: 0.012m (1.2cm horizontal accuracy) +- VRMS: 0.018m (1.8cm vertical accuracy) +- 18 satellites used, 24 visible +- NTRIP connected, 1.2s correction age + +### RTK Float Solution (Medium Quality) + +``` +$GNPOS,31.140518542,121.284018564,45.123,45.456,5,1.20,0.250,0.450,15,22,5.234,45.30,4.10,80,1,1024,3.5,1714953600,1.2*XX\r\n +``` +- Status: 5 (RTK Float) +- HRMS: 0.250m (25cm horizontal accuracy) +- VRMS: 0.450m (45cm vertical accuracy) +- 15 satellites used, 22 visible +- NTRIP connected, 3.5s correction age + +### Single Point Solution (Low Quality) + +``` +$GNPOS,31.140518542,121.284018564,45.123,45.456,1,2.50,3.500,5.200,8,15,12.345,135.67,3.95,65,0,0,0.0,1714953600,2.5*XX\r\n +``` +- Status: 1 (Single Point) +- HRMS: 3.500m (3.5m horizontal accuracy) +- VRMS: 5.200m (5.2m vertical accuracy) +- 8 satellites used, 15 visible +- NTRIP disconnected + +--- + +**Document Version:** 1.0 +**Firmware Version:** 1.2.37 +**Last Updated:** 2026-05-07 diff --git a/SCRIPT_SUMMARY.md b/SCRIPT_SUMMARY.md new file mode 100644 index 0000000..d37982c --- /dev/null +++ b/SCRIPT_SUMMARY.md @@ -0,0 +1,88 @@ +# Script Summary + +This repo is centered on GNSS/RTK work: BLE communication with an H11-style receiver, NTRIP caster testing, RTCM stream capture/parsing, and a Point One GraphQL console. + +## Setup + +Install the Python dependencies used by the FastAPI/BLE tools: + +```powershell +pip install -r requirements.txt +``` + +Some scripts also need optional packages that are not listed in `requirements.txt`: + +```powershell +pip install pyserial bleak +``` + +## Root Python Scripts + +| Script | What it does | Syntax | +| --- | --- | --- | +| `app.py` | FastAPI web app for BLE scan/connect/send, AT command UI, NMEA parsing, measurement logging, NTRIP correction streaming, and log/weather review. Serves `static/index.html` and `static/app.js`. | `python app.py` then open `http://127.0.0.1:8100` | +| `tcp_listener.py` | Simple raw TCP server that prints incoming data from any client. | `python tcp_listener.py --host 0.0.0.0 --port 12000` | +| `capture_raw_ntrip.py` | Connects to the hardcoded NTRIP caster, sends periodic GGA, captures 60 seconds of raw stream bytes to `ntrip_raw_.bin`, and writes a text log. | `python capture_raw_ntrip.py` | +| `ntrip_test.py` | Connects to the hardcoded NTRIP caster and prints parsed RTCM messages, base station position messages, baseline distance, and running stats. | `python ntrip_test.py` | +| `ntrip_message_survey.py` | Surveys the hardcoded caster for 60 seconds and counts RTCM message types, with special attention to 1005/1006 base position messages. | `python ntrip_message_survey.py` | +| `rtcm_to_csv.py` | Captures RTCM from the hardcoded caster for a configurable duration, parses selected message types with `RTCMDetailedParser`, and exports CSV. | `python rtcm_to_csv.py --duration 60 --output rtcm_messages.csv --types 1005,1006,1008 --verbose` | +| `parse_rtcm_messages.py` | Offline RTCM v3 frame scanner. Reads a binary file or stdin, verifies CRC-24Q, auto/raw/chunked decodes, prints messages, and can export frames/CSV/JSONL. | `python parse_rtcm_messages.py ntrip_raw_20260605_120848.bin --mode auto --hex --csv messages.csv --jsonl messages.jsonl --out-dir frames --write-stream clean.bin` | +| `parse_chunked_rtcm.py` | Offline analyzer for HTTP chunked NTRIP captures. Dechunks the input, optionally saves clean RTCM bytes, and summarizes RTCM messages. | `python parse_chunked_rtcm.py ntrip_raw_20260605_120848.bin --save-clean --max-messages 100` | +| `analyze_rtcm_binary.py` | Offline binary/ASCII scanner for RTCM-like frames. Shows ASCII blocks, message type counts, and optional payload hex. Does not verify CRC. | `python analyze_rtcm_binary.py ntrip_raw_20260605_120848.bin --hex --type 208 --max-messages 10` | +| `analyze_rtcm_correct.py` | Offline stream analyzer with bit-level RTCM type/station parsing plus ASCII block detection. | `python analyze_rtcm_correct.py ntrip_raw_20260605_120848.bin --hex --type 1005 --max-items 100 --no-ascii` | +| `debug_1005.py` | Live debug tool for hardcoded caster. Captures RTCM 1005/1006 messages and prints detailed bit fields, ECEF, WGS84 position, and rover baseline distance. | `python debug_1005.py` | +| `rtcm_208_capture.py` | Live capture tool for proprietary RTCM type 208. Writes payloads to `rtcm_208_raw_.bin` and an index file. | `python rtcm_208_capture.py` | +| `rtcm_208_decoder.py` | Live type-208 decoder. Captures type 208, tries ASCII/UTF-8/CSV interpretation, and writes `rtcm_208_messages.csv` plus parsed fields if present. | `python rtcm_208_decoder.py` | +| `rtcm_208_analyzer.py` | Live type-208 analyzer against the `POLARIS_LOCAL` mountpoint. Hex-dumps samples and compares static/dynamic bytes. | `python rtcm_208_analyzer.py` | +| `test_ecef_convert.py` | Standalone sanity test for converting hardcoded ECEF coordinates to latitude/longitude/altitude and comparing to a rover position. | `python test_ecef_convert.py` | + +## Root Python Modules + +These are primarily imported by other scripts, but they can also be used from a Python shell or another script. + +| Module | What it does | Usage syntax | +| --- | --- | --- | +| `rtcm_parser.py` | Streaming RTCM parser with HTTP chunked decoding, message stats, 1005/1006 base station position parsing, 1033 descriptor handling, and ECEF-to-LLA conversion. Used by `app.py` and `ntrip_test.py`. | `from rtcm_parser import RTCMParser` | +| `rtcm_detailed_parser.py` | More detailed RTCM parser with CSV export. Handles 1005, 1006, 1007, 1008, 1033, MSM4/MSM7 summaries, 1019, and 1020. Used by `rtcm_to_csv.py`. | `from rtcm_detailed_parser import RTCMDetailedParser` | + +Example module use: + +```python +from rtcm_detailed_parser import RTCMDetailedParser + +parser = RTCMDetailedParser() +messages = parser.parse_messages(data, timestamp="2026-06-22T12:00:00Z") +parser.export_to_csv("rtcm_messages.csv", message_types=[1005, 1006]) +``` + +## `ntrip/` Python Scripts + +| Script | What it does | Syntax | +| --- | --- | --- | +| `ntrip/client.py` | Configurable NTRIP forwarder. Pulls RTCM from a hardcoded caster, optionally forwards to serial or TCP, sends periodic GGA, can parse NMEA from serial, and prints RTCM/debug stats. Edit settings at the top of the file before use. | `python ntrip\client.py` | +| `ntrip/ble_client.py` | BLE scanner/client using `bleak`. Finds the configured BLE receiver, subscribes to a NMEA notify characteristic, and prints raw/NMEA data. Edit BLE UUID/name constants if needed. | `python ntrip\ble_client.py` | +| `ntrip/bluetooth_nmea_parser.py` | Bluetooth serial NMEA parser using `pyserial`. Reads GGA/GSA sentences from the configured serial port and prints fix quality, DOP, estimated accuracy, and position. | `python ntrip\bluetooth_nmea_parser.py` | + +## Web and Browser JavaScript + +| File | What it does | Syntax | +| --- | --- | --- | +| `static/app.js` | Browser-side client for `app.py`. Handles BLE scan/connect, characteristic selection, command building/sending, WebSocket events, measurement logging, log viewing, plots, and dashboard metrics. | Served by `python app.py`; open `http://127.0.0.1:8100` | +| `pointone/server/index.js` | Local Node web server for the Point One GraphQL console. Serves static UI files and proxies `/api/graphql` requests with a configured bearer token. | `cd pointone; npm start` or `node server/index.js` | +| `pointone/server/pointone-config.js` | Default Node console config: port, GraphQL URL, token from env, headers, timeout, plus optional local override. | Set env vars, then `cd pointone; npm start` | +| `pointone/server/pointone-config.local.js` | Local override for Point One GraphQL config. Contains local access tokens and should be treated as private machine-specific config. | Loaded automatically by `pointone/server/index.js` | +| `pointone/public/app.js` | Browser-side Point One GraphQL console. Loads config, offers example queries, sends GraphQL requests to `/api/graphql`, formats variables, and copies cURL. | Served by `cd pointone; npm start`; open `http://localhost:5177` | + +## Postman Assets + +| File | What it does | Syntax | +| --- | --- | --- | +| `postman/Soracom_API.postman_collection.json` | Postman collection for Soracom API requests. | Import into Postman | +| `postman/Soracom_API.postman_environment.json` | Postman environment values for the Soracom collection. | Import into Postman | +| `postman/soracom-api.en.yaml` | Soracom OpenAPI spec. | Import into Postman or another OpenAPI tool | + +## Notes + +- Several live NTRIP scripts have caster credentials, mountpoints, and rover coordinates hardcoded near the top of the file. +- `pointone/server/pointone-config.local.js` contains local access tokens. Keep it private and avoid committing/sharing it. +- The most robust offline parser is `parse_rtcm_messages.py` because it validates RTCM CRC-24Q and supports raw/chunked input. diff --git a/analyze_rtcm_binary.py b/analyze_rtcm_binary.py new file mode 100644 index 0000000..d428e01 --- /dev/null +++ b/analyze_rtcm_binary.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Analyze binary file for RTCM3 messages and ASCII data. +Searches for RTCM3 message headers (0xD3) and displays message info. +Also identifies ASCII text blocks. +""" +import sys +import argparse +from pathlib import Path + + +def find_rtcm_messages(data: bytes) -> list[dict]: + """Find all RTCM3 messages in binary data.""" + messages = [] + i = 0 + + while i < len(data): + # RTCM3 messages start with 0xD3 + if data[i] == 0xD3 and i + 2 < len(data): + # Parse header + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 # header + payload + CRC + + if i + msg_total_len <= len(data) and length >= 3: + # Extract message type (first 12 bits of payload) + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + + messages.append({ + 'offset': i, + 'type': msg_type, + 'length': length, + 'total_length': msg_total_len, + 'header': data[i:i+3], + 'payload': data[i+3:i+3+length], + 'crc': data[i+3+length:i+3+length+3] if i+3+length+3 <= len(data) else None, + }) + + i += msg_total_len + continue + i += 1 + + return messages + + +def find_ascii_blocks(data: bytes, min_length: int = 10) -> list[dict]: + """Find blocks of ASCII printable text.""" + blocks = [] + start = None + + for i, byte in enumerate(data): + is_printable = 32 <= byte < 127 or byte in [9, 10, 13] # Include tab, LF, CR + + if is_printable: + if start is None: + start = i + else: + if start is not None and (i - start) >= min_length: + text = data[start:i].decode('ascii', errors='ignore') + blocks.append({ + 'offset': start, + 'length': i - start, + 'text': text, + }) + start = None + + # Handle final block + if start is not None and (len(data) - start) >= min_length: + text = data[start:].decode('ascii', errors='ignore') + blocks.append({ + 'offset': start, + 'length': len(data) - start, + 'text': text, + }) + + return blocks + + +def hex_dump(data: bytes, offset: int = 0, max_bytes: int = 64) -> str: + """Create a hex dump of data.""" + lines = [] + data = data[:max_bytes] + + for i in range(0, len(data), 16): + chunk = data[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{offset + i:08X} {hex_part:<48} {ascii_part}") + + return "\n".join(lines) + + +def get_message_description(msg_type: int) -> str: + """Get human-readable description for RTCM message type.""" + descriptions = { + 1005: "Stationary RTK Reference Station ARP", + 1006: "Stationary RTK Reference Station ARP + Antenna Height", + 1007: "Antenna Descriptor", + 1008: "Antenna Descriptor & Serial Number", + 1019: "GPS Ephemerides", + 1020: "GLONASS Ephemerides", + 1033: "Receiver and Antenna Descriptors", + 1074: "GPS MSM4", + 1075: "GPS MSM5", + 1077: "GPS MSM7", + 1084: "GLONASS MSM4", + 1085: "GLONASS MSM5", + 1087: "GLONASS MSM7", + 1094: "Galileo MSM4", + 1095: "Galileo MSM5", + 1097: "Galileo MSM7", + 1124: "BeiDou MSM4", + 1125: "BeiDou MSM5", + 1127: "BeiDou MSM7", + 1230: "GLONASS Code-Phase Biases", + } + return descriptions.get(msg_type, f"Type {msg_type}") + + +def analyze_file(filename: str, show_ascii: bool = True, show_messages: bool = True, + show_hex: bool = False, filter_type: int = None, max_messages: int = None): + """Analyze binary file for RTCM messages and ASCII data.""" + + path = Path(filename) + if not path.exists(): + print(f"ERROR: File not found: {filename}") + return + + print(f"Analyzing: {filename}") + print(f"File size: {path.stat().st_size:,} bytes") + print(f"{'=' * 80}\n") + + # Read file + data = path.read_bytes() + + # Find ASCII blocks + if show_ascii: + print(f"ASCII TEXT BLOCKS (10+ chars):") + print(f"{'─' * 80}") + ascii_blocks = find_ascii_blocks(data, min_length=10) + + if ascii_blocks: + for block in ascii_blocks[:20]: # Show first 20 blocks + print(f"Offset: 0x{block['offset']:08X} ({block['offset']}) - Length: {block['length']} bytes") + # Show text with visible whitespace + text = block['text'].replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t') + if len(text) > 200: + text = text[:200] + "..." + print(f" Text: {text}") + print() + + if len(ascii_blocks) > 20: + print(f"... and {len(ascii_blocks) - 20} more ASCII blocks\n") + else: + print(" (none found)\n") + + print() + + # Find RTCM messages + if show_messages: + print(f"RTCM3 MESSAGES:") + print(f"{'─' * 80}") + messages = find_rtcm_messages(data) + + if messages: + # Group by type + type_counts = {} + for msg in messages: + msg_type = msg['type'] + type_counts[msg_type] = type_counts.get(msg_type, 0) + 1 + + print(f"Found {len(messages)} RTCM3 messages\n") + print(f"Message type summary:") + for msg_type in sorted(type_counts.keys()): + desc = get_message_description(msg_type) + print(f" Type {msg_type:4d}: {type_counts[msg_type]:4d} messages - {desc}") + print() + + # Show individual messages + print(f"Individual messages:") + print(f"{'─' * 80}") + + shown_count = 0 + for i, msg in enumerate(messages): + msg_type = msg['type'] + + # Apply filter if specified + if filter_type is not None and msg_type != filter_type: + continue + + # Apply max messages limit + if max_messages is not None and shown_count >= max_messages: + remaining = sum(1 for m in messages[i:] if filter_type is None or m['type'] == filter_type) + if remaining > 0: + print(f"\n... and {remaining} more messages (use --max-messages to show more)") + break + + shown_count += 1 + + desc = get_message_description(msg_type) + print(f"\nMessage #{i+1}: Type {msg_type:4d} - {desc}") + print(f" Offset: 0x{msg['offset']:08X} ({msg['offset']})") + print(f" Payload length: {msg['length']} bytes") + print(f" Total length: {msg['total_length']} bytes") + + # Show hex dump of payload + if show_hex: + print(f" Payload hex (first 64 bytes):") + hex_lines = hex_dump(msg['payload'], msg['offset'] + 3, max_bytes=64) + for line in hex_lines.split('\n'): + print(f" {line}") + + # Check for ASCII in payload + payload = msg['payload'] + printable_count = sum(1 for b in payload if 32 <= b < 127) + printable_percent = (printable_count / len(payload) * 100) if len(payload) > 0 else 0 + + if printable_percent > 50: + print(f" Payload is {printable_percent:.0f}% ASCII printable") + try: + text = payload.decode('ascii', errors='ignore') + text_display = text.replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t') + if len(text_display) > 200: + text_display = text_display[:200] + "..." + print(f" ASCII: {text_display}") + except: + pass + + # Special handling for specific message types + if msg_type == 208: + print(f" ★ MESSAGE 208 - Proprietary/Custom") + else: + print(" (no RTCM3 messages found)\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Analyze binary file for RTCM3 messages and ASCII data", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic analysis + python analyze_rtcm_binary.py ntrip_raw_20250605_120000.bin + + # Show hex dumps + python analyze_rtcm_binary.py ntrip_raw_20250605_120000.bin --hex + + # Only show message type 208 + python analyze_rtcm_binary.py ntrip_raw_20250605_120000.bin --type 208 + + # Show only first 10 messages + python analyze_rtcm_binary.py ntrip_raw_20250605_120000.bin --max-messages 10 + + # Skip ASCII blocks, show messages only + python analyze_rtcm_binary.py ntrip_raw_20250605_120000.bin --no-ascii + """ + ) + + parser.add_argument('filename', help='Binary file to analyze') + parser.add_argument('--no-ascii', action='store_true', help='Do not show ASCII blocks') + parser.add_argument('--no-messages', action='store_true', help='Do not show RTCM messages') + parser.add_argument('--hex', action='store_true', help='Show hex dumps of message payloads') + parser.add_argument('--type', type=int, help='Filter to show only specific message type') + parser.add_argument('--max-messages', type=int, help='Maximum number of messages to display') + + args = parser.parse_args() + + analyze_file( + args.filename, + show_ascii=not args.no_ascii, + show_messages=not args.no_messages, + show_hex=args.hex, + filter_type=args.type, + max_messages=args.max_messages + ) + + +if __name__ == "__main__": + main() diff --git a/analyze_rtcm_correct.py b/analyze_rtcm_correct.py new file mode 100644 index 0000000..02df2db --- /dev/null +++ b/analyze_rtcm_correct.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +""" +Correct RTCM3 message analyzer with proper bit-level parsing. +Also detects non-RTCM data (ASCII, NMEA) in the stream. +""" +import sys +import argparse +from pathlib import Path + + +def parse_rtcm_stream(data: bytes) -> list[dict]: + """Parse RTCM3 stream, identifying both RTCM messages and non-RTCM data.""" + items = [] + i = 0 + + while i < len(data): + # Check for RTCM3 message (0xD3 header) + if data[i] == 0xD3 and i + 2 < len(data): + # Parse header: 0xD3 + 2 bytes (6 bits reserved + 10 bits length) + reserved = (data[i+1] >> 2) & 0x3F + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 # header + payload + CRC + + if i + msg_total_len <= len(data) and length >= 3: + payload = data[i+3:i+3+length] + + # Extract message type - CORRECTLY from 12 bits + # Bits 0-11 of payload contain message type + msg_type = (payload[0] << 4) | (payload[1] >> 4) + + # Extract station ID (typically bits 12-23, next 12 bits) + station_id = ((payload[1] & 0x0F) << 8) | payload[2] + + # CRC + crc = data[i+3+length:i+3+length+3] if i+3+length+3 <= len(data) else None + + items.append({ + 'type': 'rtcm', + 'offset': i, + 'message_type': msg_type, + 'station_id': station_id, + 'reserved': reserved, + 'length': length, + 'total_length': msg_total_len, + 'payload': payload, + 'crc': crc, + }) + + i += msg_total_len + continue + + # Check for ASCII data (NMEA, text, etc.) + # Look for printable ASCII or common NMEA starters + if data[i] == ord('$') or (32 <= data[i] < 127): + # Find end of ASCII block + start = i + while i < len(data) and (32 <= data[i] < 127 or data[i] in [9, 10, 13]): + i += 1 + + if i > start: + text = data[start:i].decode('ascii', errors='ignore') + items.append({ + 'type': 'ascii', + 'offset': start, + 'length': i - start, + 'text': text, + }) + continue + + # Unknown/binary data + i += 1 + + return items + + +def get_message_description(msg_type: int) -> str: + """Get human-readable description for RTCM message type.""" + descriptions = { + 1001: "GPS L1 RTK Observables", + 1002: "GPS L1 RTK Observables (Extended)", + 1003: "GPS L1/L2 RTK Observables", + 1004: "GPS L1/L2 RTK Observables (Extended)", + 1005: "Stationary RTK Reference Station ARP", + 1006: "Stationary RTK Reference Station ARP + Antenna Height", + 1007: "Antenna Descriptor", + 1008: "Antenna Descriptor & Serial Number", + 1009: "GLONASS L1 RTK Observables", + 1010: "GLONASS L1 RTK Observables (Extended)", + 1011: "GLONASS L1/L2 RTK Observables", + 1012: "GLONASS L1/L2 RTK Observables (Extended)", + 1013: "System Parameters", + 1019: "GPS Ephemerides", + 1020: "GLONASS Ephemerides", + 1033: "Receiver and Antenna Descriptors", + 1071: "GPS MSM1", + 1072: "GPS MSM2", + 1073: "GPS MSM3", + 1074: "GPS MSM4", + 1075: "GPS MSM5", + 1076: "GPS MSM6", + 1077: "GPS MSM7", + 1081: "GLONASS MSM1", + 1082: "GLONASS MSM2", + 1083: "GLONASS MSM3", + 1084: "GLONASS MSM4", + 1085: "GLONASS MSM5", + 1086: "GLONASS MSM6", + 1087: "GLONASS MSM7", + 1091: "Galileo MSM1", + 1092: "Galileo MSM2", + 1093: "Galileo MSM3", + 1094: "Galileo MSM4", + 1095: "Galileo MSM5", + 1096: "Galileo MSM6", + 1097: "Galileo MSM7", + 1121: "BeiDou MSM1", + 1122: "BeiDou MSM2", + 1123: "BeiDou MSM3", + 1124: "BeiDou MSM4", + 1125: "BeiDou MSM5", + 1126: "BeiDou MSM6", + 1127: "BeiDou MSM7", + 1230: "GLONASS Code-Phase Biases", + } + return descriptions.get(msg_type, f"Unknown/Proprietary Type {msg_type}") + + +def hex_dump(data: bytes, offset: int = 0, max_bytes: int = 64) -> str: + """Create a hex dump of data.""" + lines = [] + data = data[:max_bytes] + + for i in range(0, len(data), 16): + chunk = data[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{offset + i:08X} {hex_part:<48} {ascii_part}") + + return "\n".join(lines) + + +def analyze_file(filename: str, show_hex: bool = False, filter_type: int = None, + max_items: int = 50, show_ascii: bool = True): + """Analyze binary file with correct RTCM parsing.""" + + path = Path(filename) + if not path.exists(): + print(f"ERROR: File not found: {filename}") + return + + print(f"Analyzing: {filename}") + print(f"File size: {path.stat().st_size:,} bytes") + print(f"{'=' * 80}\n") + + # Read and parse + data = path.read_bytes() + items = parse_rtcm_stream(data) + + # Statistics + rtcm_count = sum(1 for item in items if item['type'] == 'rtcm') + ascii_count = sum(1 for item in items if item['type'] == 'ascii') + + print(f"STREAM SUMMARY:") + print(f"{'─' * 80}") + print(f"Total RTCM messages: {rtcm_count}") + print(f"Total ASCII blocks: {ascii_count}") + print() + + # Message type distribution + if rtcm_count > 0: + type_counts = {} + station_ids = set() + + for item in items: + if item['type'] == 'rtcm': + msg_type = item['message_type'] + type_counts[msg_type] = type_counts.get(msg_type, 0) + 1 + station_ids.add(item['station_id']) + + print(f"RTCM MESSAGE TYPES:") + print(f"{'─' * 80}") + for msg_type in sorted(type_counts.keys()): + desc = get_message_description(msg_type) + count = type_counts[msg_type] + print(f" Type {msg_type:4d}: {count:5d} messages - {desc}") + + print() + print(f"Station IDs found: {sorted(station_ids)}") + print() + + # Show individual items + print(f"STREAM CONTENT (first {max_items} items):") + print(f"{'=' * 80}\n") + + shown = 0 + for i, item in enumerate(items): + if shown >= max_items: + remaining = len(items) - i + print(f"\n... and {remaining} more items (use --max-items to show more)") + break + + # Filter if requested + if filter_type is not None and (item['type'] != 'rtcm' or item.get('message_type') != filter_type): + continue + + shown += 1 + + if item['type'] == 'rtcm': + msg_type = item['message_type'] + desc = get_message_description(msg_type) + + print(f"[{i+1}] RTCM Message {msg_type:4d} - {desc}") + print(f" Offset: 0x{item['offset']:08X} ({item['offset']})") + print(f" Station ID: {item['station_id']}") + print(f" Length: {item['length']} bytes payload, {item['total_length']} bytes total") + + if show_hex: + print(f" Payload (first 64 bytes):") + hex_lines = hex_dump(item['payload'], item['offset'] + 3, max_bytes=64) + for line in hex_lines.split('\n'): + print(f" {line}") + + # Check for ASCII in payload + payload = item['payload'] + printable_count = sum(1 for b in payload if 32 <= b < 127) + printable_percent = (printable_count / len(payload) * 100) if len(payload) > 0 else 0 + + if printable_percent > 30: + print(f" Contains {printable_percent:.0f}% ASCII text:") + try: + text = payload.decode('ascii', errors='ignore') + text_display = text.replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t') + if len(text_display) > 150: + text_display = text_display[:150] + "..." + print(f" {text_display}") + except: + pass + + print() + + elif item['type'] == 'ascii' and show_ascii: + text = item['text'].replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t') + if len(text) > 150: + text = text[:150] + "..." + + print(f"[{i+1}] ASCII DATA") + print(f" Offset: 0x{item['offset']:08X} ({item['offset']})") + print(f" Length: {item['length']} bytes") + print(f" Text: {text}") + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Analyze RTCM3 binary file with correct message type parsing", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic analysis + python analyze_rtcm_correct.py ntrip_raw_20250605_120000.bin + + # Show hex dumps + python analyze_rtcm_correct.py ntrip_raw_20250605_120000.bin --hex + + # Only show specific message type + python analyze_rtcm_correct.py ntrip_raw_20250605_120000.bin --type 1005 + + # Show more items + python analyze_rtcm_correct.py ntrip_raw_20250605_120000.bin --max-items 100 + """ + ) + + parser.add_argument('filename', help='Binary file to analyze') + parser.add_argument('--hex', action='store_true', help='Show hex dumps of message payloads') + parser.add_argument('--type', type=int, help='Filter to show only specific message type') + parser.add_argument('--max-items', type=int, default=50, help='Maximum number of items to display') + parser.add_argument('--no-ascii', action='store_true', help='Hide ASCII blocks in output') + + args = parser.parse_args() + + analyze_file( + args.filename, + show_hex=args.hex, + filter_type=args.type, + max_items=args.max_items, + show_ascii=not args.no_ascii + ) + + +if __name__ == "__main__": + main() diff --git a/app.py b/app.py new file mode 100644 index 0000000..9023210 --- /dev/null +++ b/app.py @@ -0,0 +1,1486 @@ +import asyncio +import base64 +import json +import math +import re +import socket +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from bleak import BleakClient, BleakScanner +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, Field + +from rtcm_parser import RTCMParser + + +APP_DIR = Path(__file__).parent +LOG_DIR = APP_DIR / "logs" +EARTH_RADIUS_M = 6371008.8 +NWS_TIMEOUT_S = 8 +NWS_USER_AGENT = "maglink-gnss-logger/1.0" +NUS_SERVICE = "6e400001-b5a3-f393-e0a9-e50e24dcca9e" +NUS_RX_WRITE = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" +NUS_TX_NOTIFY = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" + +# NTRIP configuration (hardcoded for initial implementation) +NTRIP_CASTER_HOST = "truertk.pointonenav.com" +NTRIP_CASTER_PORT = 2101 +NTRIP_MOUNTPOINT = "AUTO" +NTRIP_USERNAME = "9t7fwfbm57" +NTRIP_PASSWORD = "96m7bec9g8" +NTRIP_LAT = 36.1140884 +NTRIP_LON = -97.0880663 +NTRIP_ALT = 390.0 +RTK_FIXED_STREAK_LENGTH = 5 + + +COMMANDS: list[dict[str, Any]] = [ + { + "name": "AT+APN", + "title": "APN Configuration", + "description": "Configure cellular APN settings.", + "set_params": [ + {"name": "flag", "label": "Custom APN", "type": "select", "options": [["0", "Default"], ["1", "Custom"]]}, + {"name": "apn", "label": "APN", "type": "text", "optional": True}, + {"name": "username", "label": "Username", "type": "text", "optional": True}, + {"name": "password", "label": "Password", "type": "password", "optional": True}, + ], + "examples": ["AT+APN=SET,0", "AT+APN=SET,1,internet.v6.telekom,telekom,tm"], + }, + { + "name": "AT+OLEDROTATE", + "title": "OLED Rotation", + "description": "Set display orientation.", + "set_params": [ + {"name": "angle", "label": "Rotation", "type": "select", "options": [["0", "Normal"], ["1", "180 degrees"]]}, + ], + "examples": ["AT+OLEDROTATE=SET,0", "AT+OLEDROTATE=SET,1"], + }, + { + "name": "AT+BT_OUT", + "title": "Bluetooth Output", + "description": "Configure Bluetooth output mode and sentence selection.", + "set_params": [ + {"name": "type", "label": "Mode", "type": "select", "options": [["0", "Standard"], ["1", "Custom"]]}, + {"name": "json", "label": "JSON", "type": "bool", "optional": True}, + {"name": "gnpos", "label": "GNPOS", "type": "bool", "optional": True}, + {"name": "gndev", "label": "GNDEV", "type": "bool", "optional": True}, + {"name": "gga", "label": "GGA", "type": "bool", "optional": True}, + {"name": "gst", "label": "GST", "type": "bool", "optional": True}, + {"name": "rmc", "label": "RMC", "type": "bool", "optional": True}, + {"name": "vtg", "label": "VTG", "type": "bool", "optional": True}, + {"name": "gsv", "label": "GSV", "type": "bool", "optional": True}, + {"name": "gsa", "label": "GSA", "type": "bool", "optional": True}, + ], + "examples": ["AT+BT_OUT=SET,0", "AT+BT_OUT=SET,1,0,1,1,0,0,0,0,0,0"], + }, + { + "name": "AT+UPLOADDATA_PARM", + "title": "Upload Server", + "description": "Set upload frequency, server address, and server port.", + "set_params": [ + { + "name": "freq", + "label": "Frequency", + "type": "select", + "options": [["0", "Off"], ["1", "1 sec"], ["2", "2 sec"], ["5", "5 sec"], ["10", "10 sec"], ["255", "Follow GGA"]], + }, + {"name": "server", "label": "Server", "type": "text", "optional": True}, + {"name": "port", "label": "Port", "type": "number", "optional": True}, + ], + "examples": ["AT+UPLOADDATA_PARM=SET,0", "AT+UPLOADDATA_PARM=SET,1,mqtt.example.com,1883"], + }, + { + "name": "AT+UPLOADDATA_TYPE", + "title": "Upload Protocol", + "description": "Set upload protocol and optional MQTT authentication/publish settings.", + "set_params": [ + {"name": "type", "label": "Protocol", "type": "select", "options": [["0", "TCP"], ["1", "HTTP"], ["2", "MQTT"], ["3", "JT808"]]}, + {"name": "username", "label": "Username", "type": "text", "optional": True, "prefix": "USERNAME"}, + {"name": "password", "label": "Password", "type": "password", "optional": True, "prefix": "PASSWORD"}, + {"name": "clientid", "label": "Client ID", "type": "text", "optional": True, "prefix": "CLIENTID"}, + {"name": "topic", "label": "Topic", "type": "text", "optional": True, "prefix": "TOPIC"}, + ], + "examples": [ + "AT+UPLOADDATA_TYPE=SET,0", + "AT+UPLOADDATA_TYPE=SET,2,USERNAME,myuser,PASSWORD,mypass,CLIENTID,device001,TOPIC,/gnss/data", + ], + }, + { + "name": "AT+ROVER_PARM", + "title": "Rover NTRIP", + "description": "Configure NTRIP client parameters for rover mode.", + "set_params": [ + {"name": "enable", "label": "Enable", "type": "select", "options": [["0", "Disabled"], ["1", "Enabled"]]}, + {"name": "server", "label": "Server", "type": "text", "optional": True}, + {"name": "port", "label": "Port", "type": "number", "optional": True}, + {"name": "mountpoint", "label": "Mountpoint", "type": "text", "optional": True}, + {"name": "username", "label": "Username", "type": "text", "optional": True}, + {"name": "password", "label": "Password", "type": "password", "optional": True}, + ], + "examples": ["AT+ROVER_PARM=SET,0", "AT+ROVER_PARM=SET,1,rtk.server.com,2101,MOUNT01,user,pass"], + }, + { + "name": "AT+BASE_PARM", + "title": "Base Station", + "description": "Configure base station correction output.", + "set_params": [ + {"name": "mode", "label": "Mode", "type": "select", "options": [["0", "Disabled"], ["1", "TCP Server"], ["2", "NTRIP Caster"]]}, + {"name": "server", "label": "Server", "type": "text", "optional": True}, + {"name": "port", "label": "Port", "type": "number", "optional": True}, + {"name": "mountpoint", "label": "Mountpoint", "type": "text", "optional": True}, + {"name": "username", "label": "Username", "type": "text", "optional": True}, + {"name": "password", "label": "Password", "type": "password", "optional": True}, + ], + "examples": ["AT+BASE_PARM=SET,0", "AT+BASE_PARM=SET,2,caster.server.com,2101,BASE01,user,pass"], + }, + { + "name": "AT+GNSS_MODE", + "title": "GNSS Mode", + "description": "Set rover, base, or static operating mode.", + "set_params": [ + {"name": "mode", "label": "Mode", "type": "select", "options": [["0", "Rover"], ["1", "Base"], ["2", "Static"]]}, + ], + "examples": ["AT+GNSS_MODE=SET,0", "AT+GNSS_MODE=SET,1", "AT+GNSS_MODE=SET,2"], + }, + { + "name": "AT+DEV_INIT_STA", + "title": "Device Initialization Status", + "description": "Query 4G, SIM, NTRIP, upload queue, GNSS, and satellite SNR status.", + "set_params": [], + "examples": ["AT+DEV_INIT_STA=GET"], + }, + { + "name": "AT+NEMATIME", + "title": "NMEA Output Frequency", + "description": "Set or query the GNSS/NMEA position output frequency.", + "set_params": [ + {"name": "frequency", "label": "Frequency", "type": "select", "options": [["1", "1 Hz"], ["2", "2 Hz"], ["5", "5 Hz"], ["10", "10 Hz"]]}, + ], + "examples": ["AT+NEMATIME=GET", "AT+NEMATIME=SET,1", "AT+NEMATIME=SET,5", "AT+NEMATIME=SET,10"], + }, + { + "name": "AT+RTCMBASEPOS", + "title": "RTCM Base Position", + "description": "Query the RTCM reference station latitude, longitude, altitude, and distance.", + "set_params": [], + "examples": ["AT+RTCMBASEPOS=GET"], + }, +] + + +class ConnectRequest(BaseModel): + address: str + tx_char: str | None = None + rx_char: str | None = None + + +class CharacteristicRequest(BaseModel): + tx_char: str | None = None + rx_char: str | None = None + + +class SendRequest(BaseModel): + command: str = Field(min_length=1) + append_crlf: bool = True + response: bool = False + + +class BuildCommandRequest(BaseModel): + name: str + action: str + params: list[str | None] = [] + + +class ScanRequest(BaseModel): + timeout: float = Field(default=5.0, ge=1.0, le=30.0) + + +class MeasurementStartRequest(BaseModel): + notes: str = Field(default="", max_length=4000) + + +class NTRIPConnectRequest(BaseModel): + host: str | None = None + port: int | None = None + mountpoint: str | None = None + username: str | None = None + password: str | None = None + latitude: float | None = None + longitude: float | None = None + altitude: float | None = None + + +@dataclass +class ParsedLine: + kind: str + data: dict[str, Any] | None = None + checksum_ok: bool | None = None + + +class Hub: + def __init__(self) -> None: + self.websockets: set[WebSocket] = set() + self.history: list[dict[str, Any]] = [] + self.max_history = 250 + + async def connect(self, websocket: WebSocket) -> None: + await websocket.accept() + self.websockets.add(websocket) + for event in self.history[-75:]: + await websocket.send_json(event) + + def disconnect(self, websocket: WebSocket) -> None: + self.websockets.discard(websocket) + + async def broadcast(self, event: dict[str, Any]) -> None: + event.setdefault("ts", datetime.now(timezone.utc).isoformat()) + self.history.append(event) + self.history = self.history[-self.max_history :] + stale: list[WebSocket] = [] + for websocket in list(self.websockets): + try: + await websocket.send_json(event) + except Exception: + stale.append(websocket) + for websocket in stale: + self.disconnect(websocket) + + +hub = Hub() + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def clean_uuid(uuid: str | None) -> str | None: + return uuid.lower() if uuid else None + + +def calculate_nmea_checksum(sentence: str) -> int: + data = sentence.strip().lstrip("$").split("*", 1)[0] + checksum = 0 + for char in data: + checksum ^= ord(char) + return checksum + + +def verify_nmea_checksum(sentence: str) -> bool: + if "*" not in sentence: + return False + try: + received = int(sentence.split("*", 1)[1][:2], 16) + except ValueError: + return False + return calculate_nmea_checksum(sentence) == received + + +def parse_float(value: str) -> float | None: + return float(value) if value else None + + +def parse_int(value: str) -> int | None: + return int(value) if value else None + + +def parse_nmea_coordinate(value: str, hemisphere: str, degree_digits: int) -> float | None: + if not value or not hemisphere: + return None + try: + degrees = float(value[:degree_digits]) + minutes = float(value[degree_digits:]) + except ValueError: + return None + coordinate = degrees + minutes / 60.0 + if hemisphere.upper() in {"S", "W"}: + coordinate *= -1 + return coordinate + + +def parse_gnpos(sentence: str) -> dict[str, Any] | None: + data = sentence.strip().split("*", 1)[0].lstrip("$") + fields = data.split(",") + if len(fields) != 20 or fields[0] != "GNPOS": + return None + status = parse_int(fields[5]) + status_names = {0: "No Fix", 1: "Single Point", 2: "DGPS", 4: "RTK Fixed", 5: "RTK Float"} + return { + "latitude": parse_float(fields[1]), + "longitude": parse_float(fields[2]), + "altitude_m": parse_float(fields[3]), + "altitude_corrected_m": parse_float(fields[4]), + "status": status, + "status_text": status_names.get(status, "Unknown"), + "hdop": parse_float(fields[6]), + "hrms_m": parse_float(fields[7]), + "vrms_m": parse_float(fields[8]), + "satellites_used": parse_int(fields[9]), + "satellites_visible": parse_int(fields[10]), + "speed_kmh": parse_float(fields[11]), + "heading_deg": parse_float(fields[12]), + "battery_voltage": parse_float(fields[13]), + "battery_percent": parse_int(fields[14]), + "ntrip_connected": bool(parse_int(fields[15]) or 0), + "rtcm_size_bytes": parse_int(fields[16]), + "correction_age_s": parse_float(fields[17]), + "timestamp": parse_int(fields[18]), + "tilt_angle_deg": parse_float(fields[19]), + } + + +def parse_gga(sentence: str) -> dict[str, Any] | None: + data = sentence.strip().split("*", 1)[0].lstrip("$") + fields = data.split(",") + if len(fields) < 15 or not fields[0].endswith("GGA"): + return None + quality = parse_int(fields[6]) + quality_names = {0: "No Fix", 1: "GPS Fix", 2: "DGPS", 4: "RTK Fixed", 5: "RTK Float"} + return { + "latitude": parse_nmea_coordinate(fields[2], fields[3], 2), + "longitude": parse_nmea_coordinate(fields[4], fields[5], 3), + "altitude_m": parse_float(fields[9]), + "status": quality, + "status_text": quality_names.get(quality, "Unknown"), + "hdop": parse_float(fields[8]), + "satellites_used": parse_int(fields[7]), + "timestamp": fields[1] or None, + "sentence_type": fields[0], + } + + +def parse_rmc(sentence: str) -> dict[str, Any] | None: + data = sentence.strip().split("*", 1)[0].lstrip("$") + fields = data.split(",") + if len(fields) < 12 or not fields[0].endswith("RMC"): + return None + valid = fields[2] == "A" + speed_knots = parse_float(fields[7]) + return { + "latitude": parse_nmea_coordinate(fields[3], fields[4], 2) if valid else None, + "longitude": parse_nmea_coordinate(fields[5], fields[6], 3) if valid else None, + "status": 1 if valid else 0, + "status_text": "Valid" if valid else "No Fix", + "speed_kmh": speed_knots * 1.852 if speed_knots is not None else None, + "heading_deg": parse_float(fields[8]), + "timestamp": fields[1] or None, + "date": fields[9] or None, + "sentence_type": fields[0], + } + + +def parse_gndev(sentence: str) -> dict[str, Any] | None: + data = sentence.strip().split("*", 1)[0].lstrip("$") + fields = data.split(",") + if len(fields) != 7 or fields[0] != "GNDEV": + return None + return { + "serial_number": fields[1], + "pcb_version": fields[2], + "firmware_version": fields[3], + "imei": fields[4], + "imsi": fields[5], + "iccid": fields[6], + } + + +def parse_line(line: str) -> ParsedLine: + if line.startswith("$GNPOS,"): + checksum_ok = verify_nmea_checksum(line) + return ParsedLine("gnpos", parse_gnpos(line) if checksum_ok else None, checksum_ok) + if line.startswith("$GNDEV,"): + checksum_ok = verify_nmea_checksum(line) + return ParsedLine("gndev", parse_gndev(line) if checksum_ok else None, checksum_ok) + if line.startswith("$"): + checksum_ok = verify_nmea_checksum(line) + sentence_type = line.strip().split("*", 1)[0].lstrip("$").split(",", 1)[0] + if sentence_type.endswith("GGA"): + return ParsedLine("gga", parse_gga(line) if checksum_ok else None, checksum_ok) + if sentence_type.endswith("RMC"): + return ParsedLine("rmc", parse_rmc(line) if checksum_ok else None, checksum_ok) + return ParsedLine("nmea", None, checksum_ok) + if line == "OK": + return ParsedLine("ok") + if line == "ERROR": + return ParsedLine("error") + if line.startswith("AT+"): + return ParsedLine("at_response") + return ParsedLine("text") + + +def format_command(name: str, action: str, params: list[str | None]) -> str: + action = action.upper() + if action not in {"GET", "SET"}: + raise ValueError("action must be GET or SET") + command = f"{name}={action}" + if action == "SET": + normalized = ["" if p is None else str(p) for p in params] + while normalized and normalized[-1] == "": + normalized.pop() + if normalized: + command += "," + ",".join(normalized) + return command + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2 + return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a))) + + +def point_offsets_m(point: dict[str, Any], origin_lat: float, origin_lon: float) -> tuple[float, float]: + lat = float(point["latitude"]) + lon = float(point["longitude"]) + east = haversine_m(origin_lat, origin_lon, origin_lat, lon) + north = haversine_m(origin_lat, origin_lon, lat, origin_lon) + if lon < origin_lon: + east *= -1 + if lat < origin_lat: + north *= -1 + return east, north + + +def percentile(values: list[float], pct: float) -> float | None: + if not values: + return None + ordered = sorted(values) + if len(ordered) == 1: + return ordered[0] + rank = (len(ordered) - 1) * pct + lower = math.floor(rank) + upper = math.ceil(rank) + if lower == upper: + return ordered[lower] + return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower) + + +def numeric_values(points: list[dict[str, Any]], key: str) -> list[float]: + values = [] + for point in points: + value = point.get(key) + if value is None: + continue + try: + values.append(float(value)) + except (TypeError, ValueError): + continue + return values + + +def is_rtk_fixed_point(point: dict[str, Any]) -> bool: + try: + return int(point.get("status")) == 4 + except (TypeError, ValueError): + return False + + +def rtk_fixed_streak_points(points: list[dict[str, Any]], streak_length: int = RTK_FIXED_STREAK_LENGTH) -> list[dict[str, Any]]: + selected = [] + streak = 0 + for point in points: + if is_rtk_fixed_point(point): + streak += 1 + if streak >= streak_length: + selected.append(point) + else: + streak = 0 + return selected + + +def mean(values: list[float]) -> float | None: + return sum(values) / len(values) if values else None + + +def root_mean_square(values: list[float]) -> float | None: + return math.sqrt(sum(value * value for value in values) / len(values)) if values else None + + +def calculate_position_metrics(points: list[dict[str, Any]]) -> dict[str, Any]: + valid_points = [ + point + for point in points + if point.get("latitude") is not None and point.get("longitude") is not None + ] + count = len(valid_points) + if count == 0: + return {"count": 0} + + mean_lat = sum(float(point["latitude"]) for point in valid_points) / count + mean_lon = sum(float(point["longitude"]) for point in valid_points) / count + offsets = [point_offsets_m(point, mean_lat, mean_lon) for point in valid_points] + radial_errors = [haversine_m(mean_lat, mean_lon, float(point["latitude"]), float(point["longitude"])) for point in valid_points] + east_errors = [offset[0] for offset in offsets] + north_errors = [offset[1] for offset in offsets] + rms = math.sqrt(sum(error * error for error in radial_errors) / count) + std_e = math.sqrt(sum(error * error for error in east_errors) / count) + std_n = math.sqrt(sum(error * error for error in north_errors) / count) + span_e = max(east_errors) - min(east_errors) + span_n = max(north_errors) - min(north_errors) + hrms_values = numeric_values(valid_points, "hrms_m") + vrms_values = numeric_values(valid_points, "vrms_m") + receiver_hrms_rms = root_mean_square(hrms_values) + within_receiver_hrms = [ + error <= float(point["hrms_m"]) + for point, error in zip(valid_points, radial_errors) + if point.get("hrms_m") is not None + ] + status_counts: dict[str, int] = {} + for point in valid_points: + status = point.get("status_text") or point.get("status") + label = str(status) if status is not None else "Unknown" + status_counts[label] = status_counts.get(label, 0) + 1 + + return { + "count": count, + "gnpos_count": sum(1 for point in valid_points if point.get("source") == "gnpos"), + "mean_latitude": mean_lat, + "mean_longitude": mean_lon, + "rms_m": rms, + "cep50_m": percentile(radial_errors, 0.50), + "cep95_m": percentile(radial_errors, 0.95), + "r95_m": percentile(radial_errors, 0.95), + "drms_m": math.sqrt(std_e * std_e + std_n * std_n), + "two_drms_m": 2 * math.sqrt(std_e * std_e + std_n * std_n), + "std_east_m": std_e, + "std_north_m": std_n, + "mean_error_m": sum(radial_errors) / count, + "max_error_m": max(radial_errors), + "span_east_m": span_e, + "span_north_m": span_n, + "span_2d_m": math.sqrt(span_e * span_e + span_n * span_n), + "receiver_estimate_count": len(hrms_values), + "receiver_hrms_mean_m": mean(hrms_values), + "receiver_hrms_rms_m": receiver_hrms_rms, + "receiver_hrms_min_m": min(hrms_values) if hrms_values else None, + "receiver_hrms_max_m": max(hrms_values) if hrms_values else None, + "receiver_hrms_latest_m": hrms_values[-1] if hrms_values else None, + "receiver_vrms_mean_m": mean(vrms_values), + "receiver_vrms_rms_m": root_mean_square(vrms_values), + "receiver_vrms_latest_m": vrms_values[-1] if vrms_values else None, + "rms_minus_receiver_hrms_m": rms - receiver_hrms_rms if receiver_hrms_rms is not None else None, + "rms_to_receiver_hrms_ratio": rms / receiver_hrms_rms if receiver_hrms_rms and receiver_hrms_rms > 0 else None, + "within_receiver_hrms_percent": (sum(within_receiver_hrms) / len(within_receiver_hrms) * 100) if within_receiver_hrms else None, + "status_counts": status_counts, + } + + +def make_log_point(line: str, kind: str, data: dict[str, Any]) -> dict[str, Any] | None: + lat = data.get("latitude") + lon = data.get("longitude") + if lat is None or lon is None: + return None + return { + "received_at": utc_now(), + "source": kind, + "latitude": lat, + "longitude": lon, + "altitude_m": data.get("altitude_m"), + "status": data.get("status"), + "status_text": data.get("status_text"), + "hdop": data.get("hdop"), + "hrms_m": data.get("hrms_m"), + "vrms_m": data.get("vrms_m"), + "satellites_used": data.get("satellites_used"), + "raw_nmea": line, + } + + +def qv_value(data: dict[str, Any], key: str) -> Any: + value = data.get(key) + if isinstance(value, dict): + return value.get("value") + return value + + +def nws_get_json(url: str) -> dict[str, Any]: + request = urllib.request.Request( + url, + headers={ + "User-Agent": NWS_USER_AGENT, + "Accept": "application/geo+json, application/json", + }, + ) + with urllib.request.urlopen(request, timeout=NWS_TIMEOUT_S) as response: + return json.loads(response.read().decode("utf-8")) + + +def fetch_nws_weather(lat: float, lon: float) -> dict[str, Any]: + point_url = f"https://api.weather.gov/points/{lat:.6f},{lon:.6f}" + point_data = nws_get_json(point_url) + point_props = point_data.get("properties") or {} + stations_url = point_props.get("observationStations") + if not stations_url: + raise RuntimeError("NWS did not return observation stations for this location") + + stations_data = nws_get_json(stations_url) + stations = stations_data.get("features") or [] + if not stations: + raise RuntimeError("NWS did not return any nearby observation stations") + + station_props = stations[0].get("properties") or {} + station_id = station_props.get("stationIdentifier") or str(station_props.get("@id", "")).rstrip("/").split("/")[-1] + if not station_id: + raise RuntimeError("NWS station record did not include a station identifier") + + observation_url = f"https://api.weather.gov/stations/{urllib.parse.quote(station_id)}/observations/latest" + observation_data = nws_get_json(observation_url) + observation_props = observation_data.get("properties") or {} + + return { + "record_type": "weather", + "captured_at": utc_now(), + "provider": "NWS", + "source_urls": { + "point": point_url, + "stations": stations_url, + "observation": observation_url, + }, + "location": { + "latitude": lat, + "longitude": lon, + "forecast_office": point_props.get("forecastOffice"), + "grid_id": point_props.get("gridId"), + "grid_x": point_props.get("gridX"), + "grid_y": point_props.get("gridY"), + "timezone": point_props.get("timeZone"), + }, + "station": { + "id": station_id, + "name": station_props.get("name"), + "url": station_props.get("@id"), + "timezone": station_props.get("timeZone"), + }, + "observation": { + "timestamp": observation_props.get("timestamp"), + "text_description": observation_props.get("textDescription"), + "temperature_c": qv_value(observation_props, "temperature"), + "dewpoint_c": qv_value(observation_props, "dewpoint"), + "relative_humidity_percent": qv_value(observation_props, "relativeHumidity"), + "wind_direction_deg": qv_value(observation_props, "windDirection"), + "wind_speed_kmh": qv_value(observation_props, "windSpeed"), + "wind_gust_kmh": qv_value(observation_props, "windGust"), + "barometric_pressure_pa": qv_value(observation_props, "barometricPressure"), + "sea_level_pressure_pa": qv_value(observation_props, "seaLevelPressure"), + "visibility_m": qv_value(observation_props, "visibility"), + "precipitation_last_hour_m": qv_value(observation_props, "precipitationLastHour"), + }, + } + + +class PositionLogger: + def __init__(self, log_dir: Path) -> None: + self.log_dir = log_dir + self.active = False + self.path: Path | None = None + self.file: Any = None + self.points: list[dict[str, Any]] = [] + self.metrics: dict[str, Any] = {"count": 0} + self.notes = "" + self.weather: dict[str, Any] | None = None + self.weather_attempted = False + + @property + def filename(self) -> str | None: + return self.path.name if self.path else None + + def start(self, notes: str = "") -> dict[str, Any]: + if self.active: + return self.status(include_points=True) + self.log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + self.path = self.log_dir / f"nmea_{timestamp}.jsonl" + self.file = self.path.open("a", encoding="utf-8") + self.points = [] + self.metrics = {"count": 0} + self.notes = notes.strip() + self.weather = None + self.weather_attempted = False + self.active = True + self._write({"record_type": "session", "started_at": utc_now(), "filename": self.filename, "notes": self.notes}) + return self.status(include_points=True) + + def stop(self) -> dict[str, Any]: + if not self.active: + return self.status(include_points=True) + self.metrics = calculate_position_metrics(self.points) + self._write({"record_type": "session_end", "stopped_at": utc_now(), "notes": self.notes, "metrics": self.metrics}) + if self.file: + self.file.close() + self.file = None + self.active = False + return self.status(include_points=True) + + def record(self, line: str, kind: str, data: dict[str, Any] | None) -> list[dict[str, Any]]: + if not self.active or data is None: + return [] + point = make_log_point(line, kind, data) + if point is None: + return [] + point["index"] = len(self.points) + 1 + self.points.append(point) + self._write({"record_type": "point", "point": point}) + self.metrics = calculate_position_metrics(self.points) + + events = [ + { + "type": "measurement_point", + "file": self.filename, + "count": len(self.points), + "point": point, + "metrics": self.metrics, + } + ] + if len(self.points) % 10 == 0: + events.append( + { + "type": "measurement_metrics", + "file": self.filename, + "count": len(self.points), + "metrics": self.metrics, + } + ) + return events + + async def capture_weather_if_needed(self, point: dict[str, Any]) -> dict[str, Any] | None: + if not self.active or self.weather_attempted or point.get("latitude") is None or point.get("longitude") is None: + return None + self.weather_attempted = True + target_path = self.path + target_filename = self.filename + try: + weather = await asyncio.to_thread(fetch_nws_weather, float(point["latitude"]), float(point["longitude"])) + except (OSError, RuntimeError, urllib.error.URLError, ValueError) as exc: + weather = { + "record_type": "weather_error", + "captured_at": utc_now(), + "provider": "NWS", + "location": { + "latitude": point.get("latitude"), + "longitude": point.get("longitude"), + }, + "error": str(exc), + } + if target_path: + self._write_to_path(target_path, weather) + if self.path == target_path: + self.weather = weather + return { + "type": "measurement_weather", + "file": target_filename, + "weather": weather, + } + + async def capture_weather_for_log(self, filename: str, point: dict[str, Any]) -> dict[str, Any]: + path = self._resolve_log_path(filename) + try: + weather = await asyncio.to_thread(fetch_nws_weather, float(point["latitude"]), float(point["longitude"])) + except (OSError, RuntimeError, urllib.error.URLError, ValueError) as exc: + weather = { + "record_type": "weather_error", + "captured_at": utc_now(), + "provider": "NWS", + "location": { + "latitude": point.get("latitude"), + "longitude": point.get("longitude"), + }, + "error": str(exc), + } + self._write_to_path(path, weather) + if self.path == path: + self.weather = weather + return weather + + def status(self, include_points: bool = False) -> dict[str, Any]: + if self.points and (self.metrics.get("count") != len(self.points) or "receiver_hrms_rms_m" not in self.metrics): + self.metrics = calculate_position_metrics(self.points) + status = { + "active": self.active, + "file": self.filename, + "count": len(self.points), + "metrics": self.metrics, + "notes": self.notes, + "weather": self.weather, + } + if include_points: + status["points"] = self.points + return status + + def logs(self) -> list[dict[str, Any]]: + self.log_dir.mkdir(parents=True, exist_ok=True) + logs = [] + for path in sorted(self.log_dir.glob("nmea_*.jsonl"), key=lambda item: item.stat().st_mtime, reverse=True): + stat = path.stat() + logs.append( + { + "filename": path.name, + "size_bytes": stat.st_size, + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + ) + return logs + + def load(self, filename: str, fixed_only: bool = False) -> dict[str, Any]: + path = self._resolve_log_path(filename) + points: list[dict[str, Any]] = [] + notes = "" + weather: dict[str, Any] | None = None + for line in path.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if record.get("record_type") == "session": + notes = str(record.get("notes") or "") + if record.get("record_type") in {"weather", "weather_error"}: + weather = record + if record.get("record_type") == "point" and isinstance(record.get("point"), dict): + point = record["point"] + point.setdefault("index", len(points) + 1) + points.append(point) + selected_points = rtk_fixed_streak_points(points) if fixed_only else points + return { + "file": path.name, + "points": selected_points, + "count": len(selected_points), + "total_count": len(points), + "fixed_only": fixed_only, + "fixed_streak_length": RTK_FIXED_STREAK_LENGTH if fixed_only else None, + "notes": notes, + "weather": weather, + "metrics": calculate_position_metrics(selected_points), + } + + def _write(self, record: dict[str, Any]) -> None: + if not self.file: + return + self.file.write(json.dumps(record, separators=(",", ":")) + "\n") + self.file.flush() + + def _write_to_path(self, path: Path, record: dict[str, Any]) -> None: + if self.path == path and self.file: + self._write(record) + return + with path.open("a", encoding="utf-8") as log_file: + log_file.write(json.dumps(record, separators=(",", ":")) + "\n") + + def _resolve_log_path(self, filename: str) -> Path: + path = self.log_dir / Path(filename).name + if path.parent.resolve() != self.log_dir.resolve() or not path.exists() or path.suffix != ".jsonl": + raise FileNotFoundError(filename) + return path + + +position_logger = PositionLogger(LOG_DIR) + + +class NTRIPClient: + """NTRIP client for receiving RTCM corrections.""" + + def __init__(self, hub: Hub) -> None: + self.hub = hub + self.connected = False + self.socket: socket.socket | None = None + self.thread: threading.Thread | None = None + self.stop_event = threading.Event() + self.parser = RTCMParser() + self.start_time: float | None = None + self.config: dict[str, Any] = {} + self.rover_position: dict[str, float] | None = None + self.loop: asyncio.AbstractEventLoop | None = None + + def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self.loop = loop + + def is_connected(self) -> bool: + return self.connected + + def _build_gga(self, lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence for NTRIP.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + + # Convert to NMEA format + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = [ + "GPGGA", + now_utc, + lat_str, lat_hemi, + lon_str, lon_hemi, + "1", # GPS fix + "12", # Number of satellites + "1.0", # HDOP + f"{alt:.1f}", "M", + "", "M", + "", "", + ] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + sentence = f"${core}*{checksum:02X}\r\n" + return sentence.encode("ascii") + + def _make_ntrip_request(self, host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: maglink-tester/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + def _broadcast_sync(self, event: dict[str, Any]) -> None: + """Broadcast event from worker thread to websocket clients.""" + if self.loop: + asyncio.run_coroutine_threadsafe(self.hub.broadcast(event), self.loop) + + def _worker(self) -> None: + """Worker thread for NTRIP connection.""" + try: + host = self.config["host"] + port = self.config["port"] + mountpoint = self.config["mountpoint"] + username = self.config["username"] + password = self.config["password"] + lat = self.config["latitude"] + lon = self.config["longitude"] + alt = self.config["altitude"] + + self._broadcast_sync({"type": "ntrip_status", "status": "connecting", "message": f"Connecting to {host}:{port}/{mountpoint}"}) + + # Connect to caster + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.socket.settimeout(30) + self.socket.connect((host, port)) + + # Send NTRIP request + self.socket.sendall(self._make_ntrip_request(host, port, mountpoint, username, password)) + + # Read HTTP response headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = self.socket.recv(1) + if not chunk: + raise ConnectionError("Caster closed before headers received") + header += chunk + + header_text = header.decode("iso-8859-1", errors="replace") + if "200 OK" not in header_text: + raise ConnectionError(f"NTRIP connection failed:\n{header_text}") + + self.connected = True + self.start_time = time.monotonic() + self._broadcast_sync({"type": "ntrip_status", "status": "connected", "message": "Connected to NTRIP caster"}) + + # Start GGA sender thread + def gga_sender(): + next_send = 0 + while not self.stop_event.is_set(): + now = time.monotonic() + if now >= next_send: + gga = self._build_gga(lat, lon, alt) + try: + self.socket.sendall(gga) + self._broadcast_sync({"type": "ntrip_gga", "message": "Sent position to caster"}) + except Exception: + break + next_send = now + 10 # Send every 10 seconds + time.sleep(0.5) + + gga_thread = threading.Thread(target=gga_sender, daemon=True) + gga_thread.start() + + # Main receive loop + while not self.stop_event.is_set(): + data = self.socket.recv(4096) + if not data: + raise ConnectionError("Caster closed connection") + + # Parse RTCM messages + messages = self.parser.parse_messages(data) + + # Broadcast message details + for msg in messages: + # Calculate baseline if base station position is available + if msg.get("base_position") and self.rover_position: + baseline_m = haversine_m( + self.rover_position["latitude"], + self.rover_position["longitude"], + msg["base_position"]["latitude"], + msg["base_position"]["longitude"] + ) + msg["baseline_m"] = baseline_m + + self._broadcast_sync({"type": "ntrip_rtcm", "message": msg}) + + # Broadcast stats periodically + if self.parser.message_count % 10 == 0: + stats = self.parser.get_stats() + elapsed = time.monotonic() - self.start_time if self.start_time else 0 + if elapsed > 0: + stats["bytes_per_hour"] = int(stats["total_bytes"] / elapsed * 3600) + self._broadcast_sync({"type": "ntrip_stats", "stats": stats}) + + except Exception as e: + self._broadcast_sync({"type": "ntrip_status", "status": "error", "message": str(e)}) + finally: + self.connected = False + if self.socket: + try: + self.socket.close() + except Exception: + pass + self._broadcast_sync({"type": "ntrip_status", "status": "disconnected", "message": "Disconnected from NTRIP caster"}) + + def connect(self, config: dict[str, Any]) -> dict[str, Any]: + """Start NTRIP connection.""" + if self.connected: + return {"error": "Already connected"} + + self.config = config + self.stop_event.clear() + self.parser = RTCMParser() + self.thread = threading.Thread(target=self._worker, daemon=True) + self.thread.start() + + return {"status": "connecting"} + + def disconnect(self) -> dict[str, Any]: + """Stop NTRIP connection.""" + if not self.connected and not self.thread: + return {"status": "not_connected"} + + self.stop_event.set() + if self.socket: + try: + self.socket.close() + except Exception: + pass + + if self.thread: + self.thread.join(timeout=2) + + self.connected = False + return {"status": "disconnected"} + + def update_rover_position(self, lat: float, lon: float, alt: float | None = None) -> None: + """Update rover position for baseline calculation.""" + self.rover_position = { + "latitude": lat, + "longitude": lon, + "altitude": alt if alt is not None else 0.0, + } + + def status(self) -> dict[str, Any]: + """Get NTRIP client status.""" + stats = self.parser.get_stats() + if self.start_time and self.connected: + elapsed = time.monotonic() - self.start_time + stats["bytes_per_hour"] = int(stats["total_bytes"] / elapsed * 3600) if elapsed > 0 else 0 + stats["connected_seconds"] = int(elapsed) + + return { + "connected": self.connected, + "config": self.config, + "stats": stats, + "rover_position": self.rover_position, + } + + +class BleBridge: + def __init__(self, hub: Hub) -> None: + self.hub = hub + self.client: BleakClient | None = None + self.address: str | None = None + self.name: str | None = None + self.tx_char: str | None = None + self.rx_char: str | None = None + self.services: list[dict[str, Any]] = [] + self.buffer = "" + self.loop: asyncio.AbstractEventLoop | None = None + + def attach_loop(self, loop: asyncio.AbstractEventLoop) -> None: + self.loop = loop + + def is_connected(self) -> bool: + return bool(self.client and self.client.is_connected) + + async def scan(self, timeout: float) -> list[dict[str, Any]]: + devices = await BleakScanner.discover(timeout=timeout) + results = [] + for device in devices: + results.append( + { + "address": device.address, + "name": device.name or "(unnamed)", + "rssi": getattr(device, "rssi", None), + "details": str(getattr(device, "details", "")), + } + ) + return sorted(results, key=lambda item: ((item["name"] or "").lower(), item["address"])) + + async def connect(self, address: str, tx_char: str | None = None, rx_char: str | None = None) -> dict[str, Any]: + await self.disconnect() + self.address = address + self.client = BleakClient(address, disconnected_callback=self._on_disconnect) + await self.hub.broadcast({"type": "status", "message": f"Connecting to {address}"}) + await self.client.connect(timeout=20.0) + self.services = await self._read_services() + self.tx_char, self.rx_char = self._choose_characteristics(tx_char, rx_char) + if self.rx_char: + await self.client.start_notify(self.rx_char, self._on_notify) + await self.hub.broadcast( + { + "type": "connection", + "connected": True, + "address": self.address, + "tx_char": self.tx_char, + "rx_char": self.rx_char, + "services": self.services, + } + ) + return self.status() + + async def set_characteristics(self, tx_char: str | None, rx_char: str | None) -> dict[str, Any]: + if not self.client or not self.client.is_connected: + raise RuntimeError("Not connected") + old_rx = self.rx_char + if old_rx and old_rx != rx_char: + try: + await self.client.stop_notify(old_rx) + except Exception: + pass + self.tx_char = clean_uuid(tx_char) + self.rx_char = clean_uuid(rx_char) + if self.rx_char and self.rx_char != old_rx: + await self.client.start_notify(self.rx_char, self._on_notify) + await self.hub.broadcast({"type": "connection", "connected": True, "tx_char": self.tx_char, "rx_char": self.rx_char}) + return self.status() + + async def disconnect(self) -> None: + if self.client: + try: + if self.rx_char and self.client.is_connected: + await self.client.stop_notify(self.rx_char) + except Exception: + pass + try: + if self.client.is_connected: + await self.client.disconnect() + finally: + self.client = None + self.tx_char = None + self.rx_char = None + self.buffer = "" + + async def send(self, command: str, append_crlf: bool = True, response: bool = False) -> dict[str, Any]: + if not self.client or not self.client.is_connected: + raise RuntimeError("Not connected") + if not self.tx_char: + raise RuntimeError("No writable TX characteristic selected") + payload = command + if append_crlf and not payload.endswith("\r\n"): + payload += "\r\n" + await self.client.write_gatt_char(self.tx_char, payload.encode("utf-8"), response=response) + await self.hub.broadcast({"type": "tx", "text": payload.replace("\r", "\\r").replace("\n", "\\n")}) + return {"sent": command, "bytes": len(payload.encode("utf-8")), "tx_char": self.tx_char} + + def status(self) -> dict[str, Any]: + return { + "connected": self.is_connected(), + "address": self.address, + "tx_char": self.tx_char, + "rx_char": self.rx_char, + "services": self.services, + } + + async def _read_services(self) -> list[dict[str, Any]]: + if not self.client: + return [] + try: + services = await self.client.get_services() + except AttributeError: + services = self.client.services + parsed: list[dict[str, Any]] = [] + for service in services: + parsed.append( + { + "uuid": clean_uuid(service.uuid), + "description": service.description, + "characteristics": [ + { + "uuid": clean_uuid(char.uuid), + "description": char.description, + "properties": list(char.properties), + } + for char in service.characteristics + ], + } + ) + return parsed + + def _choose_characteristics(self, tx_char: str | None, rx_char: str | None) -> tuple[str | None, str | None]: + requested_tx = clean_uuid(tx_char) + requested_rx = clean_uuid(rx_char) + all_chars = [char for service in self.services for char in service["characteristics"]] + uuids = {char["uuid"] for char in all_chars} + + chosen_tx = requested_tx if requested_tx in uuids else None + chosen_rx = requested_rx if requested_rx in uuids else None + if NUS_RX_WRITE in uuids: + chosen_tx = chosen_tx or NUS_RX_WRITE + if NUS_TX_NOTIFY in uuids: + chosen_rx = chosen_rx or NUS_TX_NOTIFY + + if not chosen_tx or not chosen_rx: + for service in self.services: + writes = [c for c in service["characteristics"] if "write" in c["properties"] or "write-without-response" in c["properties"]] + notifies = [c for c in service["characteristics"] if "notify" in c["properties"] or "indicate" in c["properties"]] + if writes and notifies: + chosen_tx = chosen_tx or writes[0]["uuid"] + chosen_rx = chosen_rx or notifies[0]["uuid"] + break + + if not chosen_tx: + writable = [c for c in all_chars if "write" in c["properties"] or "write-without-response" in c["properties"]] + chosen_tx = writable[0]["uuid"] if writable else None + if not chosen_rx: + notifying = [c for c in all_chars if "notify" in c["properties"] or "indicate" in c["properties"]] + chosen_rx = notifying[0]["uuid"] if notifying else None + return chosen_tx, chosen_rx + + def _on_disconnect(self, _client: BleakClient) -> None: + if self.loop: + self.loop.call_soon_threadsafe( + lambda: asyncio.create_task( + self.hub.broadcast({"type": "connection", "connected": False, "message": "BLE device disconnected"}) + ) + ) + + def _on_notify(self, _sender: int | str, data: bytearray) -> None: + text = bytes(data).decode("utf-8", errors="replace") + if self.loop: + self.loop.call_soon_threadsafe(lambda: asyncio.create_task(self._handle_rx(text))) + + async def _handle_rx(self, text: str) -> None: + await self.hub.broadcast({"type": "rx", "text": text}) + self.buffer += text + self.buffer = self.buffer[-8192:] + while "\n" in self.buffer: + line, self.buffer = self.buffer.split("\n", 1) + line = line.rstrip("\r").strip() + if not line: + continue + parsed = parse_line(line) + event: dict[str, Any] = {"type": "line", "line": line, "kind": parsed.kind} + if parsed.checksum_ok is not None: + event["checksum_ok"] = parsed.checksum_ok + if parsed.data is not None: + event["data"] = parsed.data + await self.hub.broadcast(event) + for measurement_event in position_logger.record(line, parsed.kind, parsed.data): + await self.hub.broadcast(measurement_event) + if measurement_event["type"] == "measurement_point": + weather_event = await position_logger.capture_weather_if_needed(measurement_event["point"]) + if weather_event: + await self.hub.broadcast(weather_event) + + # Update NTRIP rover position if we have position data + if parsed.data and "latitude" in parsed.data and "longitude" in parsed.data: + ntrip.update_rover_position( + parsed.data["latitude"], + parsed.data["longitude"], + parsed.data.get("altitude_m") + ) + + +ble = BleBridge(hub) +ntrip = NTRIPClient(hub) +app = FastAPI(title="H11 RTK BLE Command Console") +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost", "http://127.0.0.1", "http://localhost:8000", "http://127.0.0.1:8000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.mount("/static", StaticFiles(directory=APP_DIR / "static"), name="static") + + +@app.on_event("startup") +async def startup() -> None: + loop = asyncio.get_running_loop() + ble.attach_loop(loop) + ntrip.attach_loop(loop) + + +@app.on_event("shutdown") +async def shutdown() -> None: + await ble.disconnect() + ntrip.disconnect() + + +@app.get("/", response_class=HTMLResponse) +async def index() -> str: + return (APP_DIR / "static" / "index.html").read_text(encoding="utf-8") + + +@app.get("/api/commands") +async def commands() -> dict[str, Any]: + return {"commands": COMMANDS} + + +@app.get("/api/status") +async def status() -> dict[str, Any]: + return ble.status() + + +@app.post("/api/scan") +async def scan(req: ScanRequest) -> dict[str, Any]: + return {"devices": await ble.scan(req.timeout)} + + +@app.post("/api/connect") +async def connect(req: ConnectRequest) -> dict[str, Any]: + return await ble.connect(req.address, req.tx_char, req.rx_char) + + +@app.post("/api/characteristics") +async def set_characteristics(req: CharacteristicRequest) -> dict[str, Any]: + return await ble.set_characteristics(req.tx_char, req.rx_char) + + +@app.post("/api/disconnect") +async def disconnect() -> dict[str, Any]: + await ble.disconnect() + await hub.broadcast({"type": "connection", "connected": False, "message": "Disconnected"}) + return {"connected": False} + + +@app.post("/api/send") +async def send(req: SendRequest) -> dict[str, Any]: + return await ble.send(req.command, req.append_crlf, req.response) + + +@app.post("/api/build-command") +async def build_command(req: BuildCommandRequest) -> dict[str, Any]: + command = format_command(req.name, req.action, req.params) + return {"command": command} + + +@app.get("/api/measure/status") +async def measurement_status() -> dict[str, Any]: + return position_logger.status(include_points=True) + + +@app.post("/api/measure/start") +async def measurement_start(req: MeasurementStartRequest) -> dict[str, Any]: + status = position_logger.start(req.notes) + await hub.broadcast({"type": "measurement_status", **status}) + return status + + +@app.post("/api/measure/stop") +async def measurement_stop() -> dict[str, Any]: + if position_logger.active and position_logger.points and not position_logger.weather_attempted: + weather_event = await position_logger.capture_weather_if_needed(position_logger.points[0]) + if weather_event: + await hub.broadcast(weather_event) + status = position_logger.stop() + await hub.broadcast({"type": "measurement_status", **status}) + await hub.broadcast({"type": "measurement_metrics", "file": status["file"], "count": status["count"], "metrics": status["metrics"]}) + return status + + +@app.get("/api/measure/logs") +async def measurement_logs() -> dict[str, Any]: + return {"logs": position_logger.logs()} + + +@app.get("/api/measure/logs/{filename}") +async def measurement_log(filename: str, fixed_only: bool = False) -> dict[str, Any]: + try: + log = position_logger.load(filename, fixed_only=fixed_only) + if log["weather"] is None and log["points"]: + log["weather"] = await position_logger.capture_weather_for_log(filename, log["points"][0]) + return log + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Log not found") from None + + +@app.get("/api/ntrip/status") +async def ntrip_status() -> dict[str, Any]: + return ntrip.status() + + +@app.post("/api/ntrip/connect") +async def ntrip_connect(req: NTRIPConnectRequest) -> dict[str, Any]: + config = { + "host": req.host or NTRIP_CASTER_HOST, + "port": req.port or NTRIP_CASTER_PORT, + "mountpoint": req.mountpoint or NTRIP_MOUNTPOINT, + "username": req.username or NTRIP_USERNAME, + "password": req.password or NTRIP_PASSWORD, + "latitude": req.latitude or NTRIP_LAT, + "longitude": req.longitude or NTRIP_LON, + "altitude": req.altitude or NTRIP_ALT, + } + return ntrip.connect(config) + + +@app.post("/api/ntrip/disconnect") +async def ntrip_disconnect() -> dict[str, Any]: + return ntrip.disconnect() + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket) -> None: + await hub.connect(websocket) + try: + while True: + message = await websocket.receive_text() + if message == "ping": + await websocket.send_text(json.dumps({"type": "pong", "ts": utc_now()})) + except WebSocketDisconnect: + hub.disconnect(websocket) + except Exception: + hub.disconnect(websocket) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"ok": "true"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app:app", host="127.0.0.1", port=8100, reload=False) diff --git a/capture_raw_ntrip.py b/capture_raw_ntrip.py new file mode 100644 index 0000000..c757139 --- /dev/null +++ b/capture_raw_ntrip.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Capture raw NTRIP stream to binary file - no parsing, just raw bytes. +""" +import base64 +import socket +import time +from datetime import datetime + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.utcnow().strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: NTRIP-Raw-Capture/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def main(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = f"ntrip_raw_{timestamp}.bin" + log_file = f"ntrip_log_{timestamp}.txt" + + print(f"Raw NTRIP Stream Capture") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Output: {output_file}") + print(f"Log: {log_file}") + print(f"Duration: 60 seconds") + print(f"=" * 80) + print() + + last_gga_time = 0 + start_time = time.monotonic() + total_bytes = 0 + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read HTTP headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print(f"ERROR: Connection failed") + print(header.decode("iso-8859-1", errors="replace")) + return + + print("Connected! Capturing raw data stream...\n") + + # Open output files + with open(output_file, 'wb') as binfile, open(log_file, 'w') as logfile: + logfile.write(f"# NTRIP Raw Stream Capture Log\n") + logfile.write(f"# Started: {datetime.now().isoformat()}\n") + logfile.write(f"# Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}\n") + logfile.write(f"#\n") + logfile.write(f"# timestamp, elapsed_sec, bytes_received, total_bytes\n") + + # Capture loop - just write everything + while time.monotonic() - start_time < 60: + now = time.monotonic() + elapsed = now - start_time + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + gga = build_gga(LAT, LON, ALT) + sock.sendall(gga) + ts = datetime.now().isoformat() + logfile.write(f"{ts},{elapsed:.1f},GGA_SENT,{total_bytes}\n") + logfile.flush() + print(f"[{int(elapsed):3d}s] Sent GGA | Captured: {total_bytes:,} bytes") + last_gga_time = now + + # Receive raw data + data = sock.recv(4096) + if not data: + print("Connection closed by caster") + break + + # Write raw data to file + binfile.write(data) + binfile.flush() + + chunk_len = len(data) + total_bytes += chunk_len + + # Log this chunk + ts = datetime.now().isoformat() + logfile.write(f"{ts},{elapsed:.3f},{chunk_len},{total_bytes}\n") + + elapsed_final = time.monotonic() - start_time + print(f"\n[{int(elapsed_final):3d}s] Capture complete!") + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + import traceback + traceback.print_exc() + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + print(f"\n{'=' * 80}") + print(f"CAPTURE COMPLETE") + print(f"{'=' * 80}") + print(f"Total bytes: {total_bytes:,}") + print(f"Output file: {output_file}") + print(f"Log file: {log_file}") + print() + print("To view the binary file:") + print(f" hexdump -C {output_file} | head -100") + print(f" xxd {output_file} | head -100") + print() + print("To view just the first 1000 bytes:") + print(f" head -c 1000 {output_file} | hexdump -C") + print() + print("To search for RTCM message 208 (0xD3 header + type 208 = 0x0D0):") + print(" Look for pattern: D3 ?? ?? 0D 0x") + print(" where ?? ?? is the length field") + + +if __name__ == "__main__": + main() diff --git a/debug_1005.py b/debug_1005.py new file mode 100644 index 0000000..7202ac1 --- /dev/null +++ b/debug_1005.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +Debug RTCM 1005/1006 message parsing with detailed bit extraction. +""" +import base64 +import socket +import time +from datetime import datetime +import math + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.utcnow().strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: Debug-1005/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def parse_chunked_data(data: bytes) -> bytes: + """Parse HTTP chunked encoding.""" + clean_data = bytearray() + i = 0 + + while i < len(data): + line_end = data.find(b'\r\n', i) + if line_end == -1: + break + + chunk_size_line = data[i:line_end] + try: + chunk_size_str = chunk_size_line.decode('ascii', errors='ignore').split(';')[0].strip() + chunk_size = int(chunk_size_str, 16) + + if chunk_size == 0: + break + + chunk_data_start = line_end + 2 + chunk_data_end = chunk_data_start + chunk_size + + if chunk_data_end + 2 > len(data): + break + + chunk_data = data[chunk_data_start:chunk_data_end] + clean_data.extend(chunk_data) + i = chunk_data_end + 2 + + except (ValueError, UnicodeDecodeError): + i += 1 + continue + + return bytes(clean_data) + + +def debug_parse_1005(payload: bytes): + """Debug parse RTCM 1005 with detailed output.""" + print(f"\n{'=' * 80}") + print(f"RTCM 1005 MESSAGE DEBUG") + print(f"{'=' * 80}") + print(f"Payload length: {len(payload)} bytes") + print(f"Hex: {payload[:50].hex()}") + print() + + # Show as bits + bits_str = ''.join(f'{b:08b}' for b in payload[:25]) + print("First 200 bits:") + for i in range(0, min(200, len(bits_str)), 50): + print(f" {i:3d}: {bits_str[i:i+50]}") + print() + + # Parse fields manually + bit_array = [] + for byte in payload: + for i in range(7, -1, -1): + bit_array.append((byte >> i) & 1) + + def get_bits(start, length): + """Extract bits from array.""" + value = 0 + for i in range(length): + value = (value << 1) | bit_array[start + i] + return value + + def get_signed_bits(start, length): + """Extract signed value.""" + value = get_bits(start, length) + if value & (1 << (length - 1)): + value -= (1 << length) + return value + + pos = 0 + + # Message Number (12 bits) + msg_num = get_bits(pos, 12) + print(f"Bit {pos:3d}-{pos+11:3d} (12 bits): Message Number = {msg_num}") + pos += 12 + + # Station ID (12 bits) + station_id = get_bits(pos, 12) + print(f"Bit {pos:3d}-{pos+11:3d} (12 bits): Station ID = {station_id}") + pos += 12 + + # ITRF Year (6 bits) + itrf = get_bits(pos, 6) + print(f"Bit {pos:3d}-{pos+5:3d} (6 bits): ITRF Year = {itrf} ({1980 + itrf if itrf else 'N/A'})") + pos += 6 + + # GPS (1), GLONASS (1), Galileo (1), Ref Station (1) + gps = get_bits(pos, 1) + pos += 1 + glonass = get_bits(pos, 1) + pos += 1 + galileo = get_bits(pos, 1) + pos += 1 + ref_station = get_bits(pos, 1) + pos += 1 + print(f"Indicators: GPS={gps}, GLONASS={glonass}, Galileo={galileo}, RefStation={ref_station}") + + # ECEF-X (38 bits, signed, 0.0001m resolution) + ecef_x_raw = get_signed_bits(pos, 38) + ecef_x = ecef_x_raw * 0.0001 + print(f"Bit {pos:3d}-{pos+37:3d} (38 bits): ECEF-X = {ecef_x_raw} raw = {ecef_x:.4f} m") + pos += 38 + + # Single Receiver Oscillator (1 bit) + oscillator = get_bits(pos, 1) + pos += 1 + + # Reserved (1 bit) + pos += 1 + + # ECEF-Y (38 bits, signed) + ecef_y_raw = get_signed_bits(pos, 38) + ecef_y = ecef_y_raw * 0.0001 + print(f"Bit {pos:3d}-{pos+37:3d} (38 bits): ECEF-Y = {ecef_y_raw} raw = {ecef_y:.4f} m") + pos += 38 + + # Quarter Cycle Indicator (2 bits) + quarter = get_bits(pos, 2) + pos += 2 + + # ECEF-Z (38 bits, signed) + ecef_z_raw = get_signed_bits(pos, 38) + ecef_z = ecef_z_raw * 0.0001 + print(f"Bit {pos:3d}-{pos+37:3d} (38 bits): ECEF-Z = {ecef_z_raw} raw = {ecef_z:.4f} m") + pos += 38 + + print(f"\nTotal bits parsed: {pos}") + print() + + # Convert ECEF to LLA + print(f"ECEF Coordinates:") + print(f" X: {ecef_x:14.4f} m") + print(f" Y: {ecef_y:14.4f} m") + print(f" Z: {ecef_z:14.4f} m") + print() + + # WGS84 conversion + a = 6378137.0 + e2 = 6.69437999014e-3 + + lon_rad = math.atan2(ecef_y, ecef_x) + p = math.sqrt(ecef_x * ecef_x + ecef_y * ecef_y) + lat_rad = math.atan2(ecef_z, p * (1 - e2)) + + for _ in range(10): + N = a / math.sqrt(1 - e2 * math.sin(lat_rad) ** 2) + alt = p / math.cos(lat_rad) - N + lat_new = math.atan2(ecef_z, p * (1 - e2 * N / (N + alt))) + if abs(lat_new - lat_rad) < 1e-12: + break + lat_rad = lat_new + + N = a / math.sqrt(1 - e2 * math.sin(lat_rad) ** 2) + alt = p / math.cos(lat_rad) - N if abs(math.cos(lat_rad)) > 1e-10 else ecef_z / math.sin(lat_rad) - N * (1 - e2) + + lat = math.degrees(lat_rad) + lon = math.degrees(lon_rad) + + print(f"Geodetic Coordinates (WGS84):") + print(f" Latitude: {lat:12.7f}°") + print(f" Longitude: {lon:12.7f}°") + print(f" Altitude: {alt:12.2f} m") + print() + + # Distance from rover + rover_lat = 36.1140884 + rover_lon = -97.0880663 + + lat1_rad = math.radians(rover_lat) + lat2_rad = math.radians(lat) + delta_lat = math.radians(lat - rover_lat) + delta_lon = math.radians(lon - rover_lon) + a_hav = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2 + distance = 2 * 6371008.8 * math.asin(min(1.0, math.sqrt(a_hav))) + + print(f"Distance from rover ({rover_lat:.4f}°, {rover_lon:.4f}°):") + print(f" {distance:.2f} m ({distance/1000:.3f} km)") + + +def main(): + print("RTCM 1005/1006 Debug Tool") + print("Connecting to NTRIP caster...") + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print("Connected! Searching for RTCM 1005 or 1006 message...\n") + + last_gga = 0 + found_count = 0 + + while found_count < 3: + now = time.monotonic() + if now - last_gga >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + last_gga = now + + raw_data = sock.recv(4096) + if not raw_data: + break + + # Parse chunks + data = parse_chunked_data(raw_data) + + # Find RTCM messages + i = 0 + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + payload = data[i+3:i+3+length] + msg_type = (payload[0] << 4) | (payload[1] >> 4) + + if msg_type in [1005, 1006]: + found_count += 1 + print(f"\n{'#' * 80}") + print(f"FOUND MESSAGE {msg_type} (#{found_count})") + print(f"{'#' * 80}") + debug_parse_1005(payload) + + i += msg_total_len + continue + i += 1 + + except KeyboardInterrupt: + print("\nInterrupted") + except Exception as e: + print(f"ERROR: {e}") + import traceback + traceback.print_exc() + finally: + if 'sock' in locals(): + sock.close() + + +if __name__ == "__main__": + main() diff --git a/ntrip/ble_client.py b/ntrip/ble_client.py new file mode 100644 index 0000000..cdc30e7 --- /dev/null +++ b/ntrip/ble_client.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +BLE NTRIP Client for RTK Receiver + +Connects to an RTK receiver via Bluetooth Low Energy (BLE) to receive NMEA data +and forward RTCM corrections from an NTRIP caster. + +This script uses the bleak library for cross-platform BLE communication. +""" + +import asyncio +import sys +from bleak import BleakClient, BleakScanner + +# ============================================================================ +# Configuration +# ============================================================================ + +# BLE Device Configuration +DEVICE_NAME = "ML-NA001-250079-BLE" +DEVICE_UUID = "B2DDE9B2-881D-1BE2-5A1B-C44CB646BB1D" + +# BLE Service and Characteristic UUIDs +SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb" # Custom service +CHARACTERISTIC_UUID = "0000fff2-0000-1000-8000-00805f9b34fb" # Read/Notify characteristic for NMEA + +# Debug options +DEBUG_RAW_DATA = True # Show raw bytes received +DEBUG_NMEA = True # Show parsed NMEA sentences + + +# ============================================================================ +# BLE Connection and Data Handler +# ============================================================================ + +class BLENTRIPClient: + """BLE NTRIP client for RTK receiver.""" + + def __init__(self): + self.client = None + self.running = False + self.nmea_buffer = bytearray() + + async def find_device(self): + """Scan for the RTK receiver BLE device.""" + print(f"Scanning for BLE device: {DEVICE_NAME} ({DEVICE_UUID})...") + + devices = await BleakScanner.discover(timeout=10.0) + + for device in devices: + print(f"Found: {device.name} ({device.address})") + + # Match by UUID or name + if (device.address.upper() == DEVICE_UUID.upper() or + device.name == DEVICE_NAME): + print(f"✓ Found target device: {device.name} at {device.address}") + return device.address + + print(f"✗ Device not found. Scanned {len(devices)} devices.") + return None + + def notification_handler(self, sender, data): + """Handle incoming BLE notifications with NMEA data.""" + if DEBUG_RAW_DATA: + print(f"Raw data ({len(data)} bytes): {data.hex()}") + + # Add data to buffer + self.nmea_buffer.extend(data) + + # Process complete NMEA sentences (terminated with \r\n) + while b'\n' in self.nmea_buffer: + # Find the end of the sentence + newline_idx = self.nmea_buffer.index(b'\n') + sentence_bytes = self.nmea_buffer[:newline_idx + 1] + self.nmea_buffer = self.nmea_buffer[newline_idx + 1:] + + try: + # Decode NMEA sentence + sentence = sentence_bytes.decode('ascii').strip() + + if sentence and DEBUG_NMEA: + print(f"NMEA: {sentence}") + + # TODO: Parse NMEA sentences (GGA, etc.) for position data + + except UnicodeDecodeError as e: + print(f"Failed to decode NMEA data: {e}") + + async def connect_and_monitor(self): + """Connect to the BLE device and monitor NMEA data.""" + + # Find the device + device_address = await self.find_device() + if not device_address: + print("Could not find RTK receiver. Make sure device is powered on and in range.") + return False + + # Connect to device + print(f"\nConnecting to {device_address}...") + + try: + async with BleakClient(device_address) as client: + self.client = client + + if not client.is_connected: + print("✗ Failed to connect") + return False + + print(f"✓ Connected to {DEVICE_NAME}") + + # List available services and characteristics + print("\nAvailable services:") + for service in client.services: + print(f" Service: {service.uuid}") + for char in service.characteristics: + props = ','.join(char.properties) + print(f" Characteristic: {char.uuid} ({props})") + + # Start notifications on NMEA characteristic + print(f"\nSubscribing to NMEA notifications on {CHARACTERISTIC_UUID}...") + await client.start_notify(CHARACTERISTIC_UUID, self.notification_handler) + print("✓ Subscribed to notifications\n") + + # Keep connection alive and monitor data + self.running = True + print("Monitoring NMEA data (Ctrl+C to stop)...\n") + + while self.running: + await asyncio.sleep(1) + + # Check connection status + if not client.is_connected: + print("✗ Connection lost") + break + + # Stop notifications before disconnecting + await client.stop_notify(CHARACTERISTIC_UUID) + + except Exception as e: + print(f"✗ Error: {e}") + return False + + return True + + def stop(self): + """Stop the client.""" + self.running = False + + +# ============================================================================ +# Main Entry Point +# ============================================================================ + +async def main(): + """Main entry point.""" + client = BLENTRIPClient() + + try: + await client.connect_and_monitor() + except KeyboardInterrupt: + print("\n\nStopping...") + client.stop() + except Exception as e: + print(f"\n✗ Fatal error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + print("=" * 70) + print("BLE NTRIP Client for RTK Receiver") + print("=" * 70) + print() + + # Check if bleak is installed + try: + import bleak + except ImportError: + print("✗ Error: 'bleak' library not found") + print("\nInstall it with:") + print(" pip install bleak") + print() + sys.exit(1) + + asyncio.run(main()) diff --git a/ntrip/bluetooth_nmea_parser.py b/ntrip/bluetooth_nmea_parser.py new file mode 100644 index 0000000..b0d6416 --- /dev/null +++ b/ntrip/bluetooth_nmea_parser.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +Bluetooth Serial NMEA Parser for RTK Receiver +Connects to RTK receiver via Bluetooth and parses accuracy information from NMEA sentences. +""" + +import serial +import re +from datetime import datetime + + +# ========= USER SETTINGS ========= +BLUETOOTH_PORT = "/dev/tty.H11-230621-SerialPort" # macOS Bluetooth serial port +# On Linux, might be: /dev/rfcomm0 +# On Windows, might be: COM5 +BAUD_RATE = 115200 +# ================================= + + +def parse_gga(sentence): + """ + Parse NMEA GGA sentence for position quality and HDOP. + $GPGGA,time,lat,NS,lon,EW,quality,numSV,HDOP,alt,M,sep,M,diffAge,diffSta*CS + + Quality values: + 0 = Invalid + 1 = GPS fix (SPS) + 2 = DGPS fix + 3 = PPS fix + 4 = RTK Fixed + 5 = RTK Float + 6 = Estimated (dead reckoning) + 7 = Manual input mode + 8 = Simulation mode + """ + parts = sentence.split(',') + if len(parts) < 9: + return None + + try: + time_utc = parts[1] + lat = parts[2] + lat_dir = parts[3] + lon = parts[4] + lon_dir = parts[5] + quality = int(parts[6]) + num_sats = int(parts[7]) if parts[7] else 0 + hdop = float(parts[8]) if parts[8] else 0.0 + + quality_map = { + 0: "Invalid", + 1: "GPS (SPS)", + 2: "DGPS", + 3: "PPS", + 4: "RTK Fixed", + 5: "RTK Float", + 6: "Estimated", + 7: "Manual", + 8: "Simulation" + } + + return { + 'sentence': 'GGA', + 'time': time_utc, + 'quality': quality, + 'quality_str': quality_map.get(quality, f"Unknown({quality})"), + 'num_sats': num_sats, + 'hdop': hdop, + 'lat': lat, + 'lat_dir': lat_dir, + 'lon': lon, + 'lon_dir': lon_dir + } + except (ValueError, IndexError): + return None + + +def parse_gsa(sentence): + """ + Parse NMEA GSA sentence for DOP values (PDOP, HDOP, VDOP). + $GPGSA,mode,fix_type,sat1,...,sat12,PDOP,HDOP,VDOP*CS + """ + parts = sentence.split(',') + if len(parts) < 18: + return None + + try: + mode = parts[1] # M=Manual, A=Automatic + fix_type = int(parts[2]) if parts[2] else 0 # 1=no fix, 2=2D, 3=3D + + # DOPs are at the end + pdop = float(parts[-3]) if parts[-3] else 0.0 + hdop = float(parts[-2]) if parts[-2] else 0.0 + vdop_cs = parts[-1].split('*')[0] # Remove checksum + vdop = float(vdop_cs) if vdop_cs else 0.0 + + fix_map = { + 1: "No Fix", + 2: "2D Fix", + 3: "3D Fix" + } + + return { + 'sentence': 'GSA', + 'fix_type': fix_type, + 'fix_str': fix_map.get(fix_type, f"Unknown({fix_type})"), + 'pdop': pdop, + 'hdop': hdop, + 'vdop': vdop + } + except (ValueError, IndexError): + return None + + +def calculate_accuracy_estimate(hdop, quality): + """ + Estimate horizontal accuracy in meters based on HDOP and fix quality. + + Rough approximation: + - RTK Fixed: ~0.01-0.02m (1-2cm) + - RTK Float: ~0.1-1m (10cm-1m) + - DGPS: ~1-5m + - GPS (SPS): ~5-15m + + Accuracy ≈ HDOP × UERE (User Equivalent Range Error) + Where UERE varies by fix type + """ + uere_map = { + 4: 0.01, # RTK Fixed: 1cm UERE + 5: 0.3, # RTK Float: 30cm UERE + 2: 1.5, # DGPS: 1.5m UERE + 1: 5.0, # GPS: 5m UERE + 0: 999.0 # Invalid + } + + uere = uere_map.get(quality, 10.0) + accuracy = hdop * uere + + return accuracy + + +def format_position(lat, lat_dir, lon, lon_dir): + """Convert NMEA ddmm.mmmm format to decimal degrees.""" + try: + # Latitude: ddmm.mmmm + lat_deg = int(float(lat) / 100) + lat_min = float(lat) - (lat_deg * 100) + lat_decimal = lat_deg + (lat_min / 60.0) + if lat_dir == 'S': + lat_decimal = -lat_decimal + + # Longitude: dddmm.mmmm + lon_deg = int(float(lon) / 100) + lon_min = float(lon) - (lon_deg * 100) + lon_decimal = lon_deg + (lon_min / 60.0) + if lon_dir == 'W': + lon_decimal = -lon_decimal + + return lat_decimal, lon_decimal + except (ValueError, ZeroDivisionError): + return None, None + + +def main(): + print(f"Connecting to RTK receiver on {BLUETOOTH_PORT} @ {BAUD_RATE} baud...") + + try: + ser = serial.Serial(BLUETOOTH_PORT, BAUD_RATE, timeout=1) + print(f"✓ Connected to {BLUETOOTH_PORT}") + print("=" * 80) + print("Parsing NMEA stream for accuracy information...") + print("=" * 80) + + # Track latest values + latest_gga = None + latest_gsa = None + + while True: + try: + line = ser.readline().decode('ascii', errors='ignore').strip() + + if not line.startswith('$'): + continue + + # Parse GGA sentences (position quality, HDOP) + if 'GGA' in line: + gga = parse_gga(line) + if gga: + latest_gga = gga + + # Calculate estimated accuracy + accuracy = calculate_accuracy_estimate(gga['hdop'], gga['quality']) + + # Convert position to decimal degrees + lat_dd, lon_dd = format_position(gga['lat'], gga['lat_dir'], + gga['lon'], gga['lon_dir']) + + print(f"\n[{datetime.now().strftime('%H:%M:%S')}] Position Quality:") + print(f" Fix Type: {gga['quality_str']}") + print(f" Satellites: {gga['num_sats']}") + print(f" HDOP: {gga['hdop']:.2f}") + print(f" Est. Accuracy: {accuracy:.3f} m", end="") + + if gga['quality'] == 4: + print(f" ({accuracy*100:.1f} cm) ← RTK FIXED ✓") + elif gga['quality'] == 5: + print(f" ({accuracy*100:.1f} cm) ← RTK FLOAT") + else: + print() + + if lat_dd and lon_dd: + print(f" Position: {lat_dd:.8f}°, {lon_dd:.8f}°") + + # Parse GSA sentences (PDOP, HDOP, VDOP) + elif 'GSA' in line: + gsa = parse_gsa(line) + if gsa: + latest_gsa = gsa + + if gsa['fix_type'] >= 2: # Only show if we have a fix + print(f"\n[{datetime.now().strftime('%H:%M:%S')}] Dilution of Precision:") + print(f" Fix Type: {gsa['fix_str']}") + print(f" PDOP: {gsa['pdop']:.2f} (Position)") + print(f" HDOP: {gsa['hdop']:.2f} (Horizontal)") + print(f" VDOP: {gsa['vdop']:.2f} (Vertical)") + + # DOP quality interpretation + if gsa['hdop'] < 1.0: + dop_quality = "Excellent" + elif gsa['hdop'] < 2.0: + dop_quality = "Good" + elif gsa['hdop'] < 5.0: + dop_quality = "Moderate" + elif gsa['hdop'] < 10.0: + dop_quality = "Fair" + else: + dop_quality = "Poor" + + print(f" DOP Quality: {dop_quality}") + + except UnicodeDecodeError: + continue + except KeyboardInterrupt: + raise + except Exception as e: + print(f"Error parsing line: {e}") + continue + + except serial.SerialException as e: + print(f"✗ Failed to connect to {BLUETOOTH_PORT}: {e}") + print("\nTroubleshooting:") + print(" 1. Check that the Bluetooth device is paired") + print(" 2. Find the correct port:") + print(" macOS: ls /dev/tty.* | grep -i bluetooth") + print(" Linux: ls /dev/rfcomm*") + print(" Windows: Check Device Manager → Ports (COM & LPT)") + print(" 3. Update BLUETOOTH_PORT in this script") + return 1 + + except KeyboardInterrupt: + print("\n\nDisconnected.") + return 0 + + finally: + if 'ser' in locals() and ser.is_open: + ser.close() + + +if __name__ == "__main__": + exit(main()) diff --git a/ntrip/client.py b/ntrip/client.py new file mode 100644 index 0000000..0be50ef --- /dev/null +++ b/ntrip/client.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +""" +Simple NTRIP client to pull RTCM from a CORS/RTK caster and inject to a GNSS receiver. + +- Connects to caster with HTTP Basic Auth (NTRIP v2 headers). +- Sends NMEA GGA immediately and every GGA_INTERVAL seconds. +- Writes RTCM bytes to a serial port (or optional TCP out). +""" + +import base64 +import os +import socket +import sys +import time +import threading +from datetime import datetime, timezone +from typing import Optional + +try: + import serial # pip install pyserial +except ImportError: + serial = None + + +# ========= USER SETTINGS ========= +# --- Caster / NTRIP source --- +CASTER_HOST = "truertk.pointonenav.com" # e.g. "12.34.56.78" +CASTER_PORT = 2101 # e.g. 2101 +MOUNTPOINT = "AUTO" # e.g. "RTCM3" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +# --- Output to GNSS receiver --- +USE_SERIAL_OUT = False +SERIAL_PORT = "/dev/tty.ML-NA001-250079" # Windows: "COM5" +SERIAL_BAUD = 115200 + +# Optional: forward RTCM to TCP instead of serial (set USE_SERIAL_OUT=False) +USE_TCP_OUT = False +TCP_OUT_HOST = "127.0.0.1" +TCP_OUT_PORT = 2102 + +# --- GGA configuration --- +SEND_GGA = True +GGA_INTERVAL_SEC = 10 # caster-friendly: 5–15 seconds typical +# If you have a rough position, put it here (WGS84): +GGA_LAT_DEG = 36.1140884 # positive N, negative S +GGA_LON_DEG = -97.0880663 # positive E, negative W +GGA_ALT_M = 390.0 # orthometric (approx OK) + +# --- Misc/retry --- +RECV_BUF = 4096 +RECONNECT_DELAY_S = 5 +SOCK_TIMEOUT_S = 30 +USER_AGENT = "NTRIP pyclient/1.0" + +# --- Debug --- +DEBUG_RTCM = True # Show RTCM message stats +PARSE_NMEA = True # Parse NMEA from receiver (read from serial) +USE_RECEIVER_POS = True # Use receiver's actual position for GGA to caster +DEBUG_ACCURACY = True # Show receiver accuracy info +# ================================= + + +def nmea_checksum(sentence_no_dollar: str) -> str: + csum = 0 + for ch in sentence_no_dollar: + csum ^= ord(ch) + return f"{csum:02X}" + + +def format_lat_lon(lat_deg: float, lon_deg: float): + """ + Convert signed decimal degrees to NMEA ddmm.mmmm, dddmm.mmmm and hemispheres. + """ + # Latitude + lat_hemi = "N" if lat_deg >= 0 else "S" + lat_abs = abs(lat_deg) + lat_deg_i = int(lat_abs) + lat_min = (lat_abs - lat_deg_i) * 60.0 + lat_str = f"{lat_deg_i:02d}{lat_min:07.4f}" + + # Longitude + lon_hemi = "E" if lon_deg >= 0 else "W" + lon_abs = abs(lon_deg) + lon_deg_i = int(lon_abs) + lon_min = (lon_abs - lon_deg_i) * 60.0 + lon_str = f"{lon_deg_i:03d}{lon_min:07.4f}" + + return lat_str, lat_hemi, lon_str, lon_hemi + + +def parse_nmea_position(lat_nmea: str, lat_dir: str, lon_nmea: str, lon_dir: str): + """Convert NMEA ddmm.mmmm format to decimal degrees.""" + try: + # Latitude: ddmm.mmmm + lat_deg = int(float(lat_nmea) / 100) + lat_min = float(lat_nmea) - (lat_deg * 100) + lat_decimal = lat_deg + (lat_min / 60.0) + if lat_dir == 'S': + lat_decimal = -lat_decimal + + # Longitude: dddmm.mmmm + lon_deg = int(float(lon_nmea) / 100) + lon_min = float(lon_nmea) - (lon_deg * 100) + lon_decimal = lon_deg + (lon_min / 60.0) + if lon_dir == 'W': + lon_decimal = -lon_decimal + + return lat_decimal, lon_decimal + except (ValueError, ZeroDivisionError): + return None, None + + +def parse_gga(sentence: str): + """ + Parse NMEA GGA sentence. + Returns dict with position, quality, sats, hdop, altitude. + """ + parts = sentence.split(',') + if len(parts) < 15: + return None + + try: + return { + 'time': parts[1], + 'lat': parts[2], + 'lat_dir': parts[3], + 'lon': parts[4], + 'lon_dir': parts[5], + 'quality': int(parts[6]) if parts[6] else 0, + 'num_sats': int(parts[7]) if parts[7] else 0, + 'hdop': float(parts[8]) if parts[8] else 0.0, + 'altitude': float(parts[9]) if parts[9] else 0.0, + } + except (ValueError, IndexError): + return None + + +def build_gga(lat_deg: float, lon_deg: float, alt_m: float, fix_quality=1, sats=12, hdop=1.0) -> bytes: + """ + Build a minimal NMEA GGA sentence (UTC time now, fix provided). Returns CRLF-terminated bytes. + """ + now = datetime.now(timezone.utc).strftime("%H%M%S") + lat_str, lat_hemi, lon_str, lon_hemi = format_lat_lon(lat_deg, lon_deg) + + # GGA fields: + # $GPGGA,time,lat,NS,lon,EW,quality,numSV,HDOP,alt,M,sep,M,diffAge,diffStation + fields = [ + "GPGGA", + now, + lat_str, lat_hemi, + lon_str, lon_hemi, + str(fix_quality), # 1 = GPS fix, 4/5 = RTK; for caster seeding 1 is fine + f"{sats:02d}", + f"{hdop:.1f}", + f"{alt_m:.1f}", "M", # altitude + units + "", "M", # geoid separation unknown + "", "", # DGPS age/station + ] + core = ",".join(fields) + csum = nmea_checksum(core) + sentence = f"${core}*{csum}\r\n" + return sentence.encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + # NTRIP v2-style request. Mountpoint must be URL-encoded if it contains special chars; most are simple. + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: {USER_AGENT}\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +class RTCMForwarder: + def __init__(self): + self.ser = None + self.tcp_out_sock = None + self.total_bytes = 0 + self.msg_count = 0 + self.start_time = None + + # For tracking receiver position/status + self.latest_gga = None + self.nmea_buffer = "" + + def open(self): + self.start_time = time.monotonic() + if USE_SERIAL_OUT: + if serial is None: + raise RuntimeError("pyserial is not installed. Install with: pip install pyserial") + self.ser = serial.Serial(SERIAL_PORT, SERIAL_BAUD, timeout=0) + print(f"[OUT] Serial open {SERIAL_PORT} @ {SERIAL_BAUD}") + elif USE_TCP_OUT: + self.tcp_out_sock = socket.create_connection((TCP_OUT_HOST, TCP_OUT_PORT), timeout=5) + print(f"[OUT] TCP forward connected {TCP_OUT_HOST}:{TCP_OUT_PORT}") + else: + print("[OUT] No output configured; data will be discarded.") + + def read_nmea(self): + """Read and parse NMEA data from the receiver (non-blocking).""" + if not self.ser or not PARSE_NMEA: + return + + try: + # Read available data (non-blocking because timeout=0) + if self.ser.in_waiting > 0: + data = self.ser.read(self.ser.in_waiting) + self.nmea_buffer += data.decode('ascii', errors='ignore') + + # Process complete NMEA sentences + while '\n' in self.nmea_buffer: + line, self.nmea_buffer = self.nmea_buffer.split('\n', 1) + line = line.strip() + + if line.startswith('$') and 'GGA' in line: + gga = parse_gga(line) + if gga and gga['quality'] > 0: + self.latest_gga = gga + + if DEBUG_ACCURACY: + self._display_accuracy(gga) + except Exception: + # Don't crash on NMEA parse errors + pass + + def _display_accuracy(self, gga): + """Display receiver accuracy information.""" + quality_map = { + 0: "Invalid", + 1: "GPS (SPS)", + 2: "DGPS", + 3: "PPS", + 4: "RTK Fixed", + 5: "RTK Float", + 6: "Estimated", + } + + quality_str = quality_map.get(gga['quality'], f"Unknown({gga['quality']})") + + # Calculate estimated accuracy + uere_map = { + 4: 0.01, # RTK Fixed: 1cm + 5: 0.3, # RTK Float: 30cm + 2: 1.5, # DGPS: 1.5m + 1: 5.0, # GPS: 5m + 0: 999.0 + } + uere = uere_map.get(gga['quality'], 10.0) + accuracy = gga['hdop'] * uere + + # Convert position to decimal degrees + lat_dd, lon_dd = parse_nmea_position(gga['lat'], gga['lat_dir'], + gga['lon'], gga['lon_dir']) + + indicator = "" + if gga['quality'] == 4: + indicator = " ← RTK FIXED ✓" + elif gga['quality'] == 5: + indicator = " ← RTK FLOAT" + + print(f"[RX] {quality_str:12s} | Sats: {gga['num_sats']:2d} | HDOP: {gga['hdop']:4.1f} | " + f"Acc: {accuracy:6.3f}m ({accuracy*100:5.1f}cm){indicator}") + + if lat_dd and lon_dd: + print(f" Position: {lat_dd:11.7f}°, {lon_dd:11.7f}° | Alt: {gga['altitude']:6.1f}m") + + def get_position(self): + """Get the latest position from the receiver, or fallback to configured position.""" + if USE_RECEIVER_POS and self.latest_gga: + lat_dd, lon_dd = parse_nmea_position( + self.latest_gga['lat'], self.latest_gga['lat_dir'], + self.latest_gga['lon'], self.latest_gga['lon_dir'] + ) + if lat_dd and lon_dd: + return lat_dd, lon_dd, self.latest_gga['altitude'] + + # Fallback to configured position + return GGA_LAT_DEG, GGA_LON_DEG, GGA_ALT_M + + def write(self, data: bytes): + if not data: + return + + # Track statistics + self.total_bytes += len(data) + + # Debug: parse and display RTCM messages + if DEBUG_RTCM: + self._debug_rtcm(data) + + if self.ser: + self.ser.write(data) + elif self.tcp_out_sock: + try: + self.tcp_out_sock.sendall(data) + except Exception: + # attempt to reconnect once + try: + self.tcp_out_sock.close() + except Exception: + pass + self.tcp_out_sock = socket.create_connection((TCP_OUT_HOST, TCP_OUT_PORT), timeout=5) + self.tcp_out_sock.sendall(data) + # else: discard + + def _debug_rtcm(self, data: bytes): + """Parse and display RTCM3 message info""" + i = 0 + while i < len(data): + # RTCM3 messages start with 0xD3 + if data[i] == 0xD3 and i + 2 < len(data): + # Parse header: 0xD3 + 2 bytes (6 bits reserved + 10 bits length) + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 # header + payload + CRC + + if i + msg_total_len <= len(data) and length >= 3: + # Extract message type (first 12 bits of payload) + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + self.msg_count += 1 + + # Calculate bytes per hour + elapsed = time.monotonic() - self.start_time + if elapsed > 0: + bytes_per_hour = int(self.total_bytes / elapsed * 3600) + print(f"[RTCM] Msg #{self.msg_count}: Type {msg_type:4d}, {length:4d} bytes payload, {self.total_bytes:8d} total bytes ({bytes_per_hour:,} bytes/hour)") + else: + print(f"[RTCM] Msg #{self.msg_count}: Type {msg_type:4d}, {length:4d} bytes payload, {self.total_bytes:8d} total bytes") + + i += msg_total_len + continue + i += 1 + + def close(self): + try: + if self.ser: + self.ser.close() + if self.tcp_out_sock: + self.tcp_out_sock.close() + except Exception: + pass + + +def ntrip_loop(): + out = RTCMForwarder() + out.open() + + while True: + try: + print(f"[NTRIP] Connecting to {CASTER_HOST}:{CASTER_PORT} /{MOUNTPOINT}") + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(SOCK_TIMEOUT_S) + s.connect((CASTER_HOST, CASTER_PORT)) + + # Send request + s.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read HTTP response headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = s.recv(1) + if not chunk: + raise ConnectionError("Caster closed before headers were received.") + header += chunk + + header_text = header.decode("iso-8859-1", errors="replace") + if "200 OK" not in header_text: + s.close() + raise ConnectionError(f"NTRIP error or mount not found:\n{header_text}") + + print("[NTRIP] 200 OK – streaming RTCM") + + # Thread to send periodic GGA + stop_gga = threading.Event() + + def gga_sender(): + if not SEND_GGA: + return + # Many casters accept GGA after the HTTP header via the same socket + # (Write NMEA sentences to the socket; they are ignored by HTTP and consumed by caster) + next_send = 0 + while not stop_gga.is_set(): + now = time.monotonic() + if now >= next_send: + # Get position from receiver or use fallback + lat, lon, alt = out.get_position() + gga = build_gga(lat, lon, alt) + try: + s.sendall(gga) + if USE_RECEIVER_POS and out.latest_gga: + print(f"[GGA→] Sent receiver position to caster") + else: + print(f"[GGA→] Sent fallback position to caster") + except Exception: + break + next_send = now + GGA_INTERVAL_SEC + time.sleep(0.5) + + gga_thread = threading.Thread(target=gga_sender, daemon=True) + gga_thread.start() + + # Main receive loop + last_data_time = time.monotonic() + while True: + # Read NMEA from receiver (non-blocking) + out.read_nmea() + + data = s.recv(RECV_BUF) + if not data: + raise ConnectionError("Caster closed the connection.") + last_data_time = time.monotonic() + out.write(data) + + # Simple idle watchdog + if time.monotonic() - last_data_time > SOCK_TIMEOUT_S: + raise TimeoutError("No data from caster.") + + except KeyboardInterrupt: + print("\n[EXIT] Interrupted by user.") + out.close() + try: + s.close() + except Exception: + pass + sys.exit(0) + except Exception as e: + print(f"[WARN] {e}") + try: + s.close() + except Exception: + pass + print(f"[NTRIP] Reconnecting in {RECONNECT_DELAY_S}s…") + time.sleep(RECONNECT_DELAY_S) + continue + + +if __name__ == "__main__": + # Basic sanity checks + if not CASTER_HOST or not MOUNTPOINT or not USERNAME: + print("Please fill in CASTER_HOST, MOUNTPOINT, USERNAME, PASSWORD at the top of this script.") + sys.exit(1) + + if USE_SERIAL_OUT is False and USE_TCP_OUT is False: + print("No output path enabled. Set USE_SERIAL_OUT=True or USE_TCP_OUT=True.") + # continue anyway (discards data) + + ntrip_loop() diff --git a/ntrip_log_20260605_120848.txt b/ntrip_log_20260605_120848.txt new file mode 100644 index 0000000..3e29d97 --- /dev/null +++ b/ntrip_log_20260605_120848.txt @@ -0,0 +1,110 @@ +# NTRIP Raw Stream Capture Log +# Started: 2026-06-05T12:08:48.814958 +# Caster: truertk.pointonenav.com:2101/AUTO +# +# timestamp, elapsed_sec, bytes_received, total_bytes +2026-06-05T12:08:48.819634,0.2,GGA_SENT,0 +2026-06-05T12:08:49.019691,0.239,77,77 +2026-06-05T12:08:49.022652,0.444,12,89 +2026-06-05T12:08:49.022691,0.447,133,222 +2026-06-05T12:08:49.284891,0.447,260,482 +2026-06-05T12:08:49.314577,0.709,377,859 +2026-06-05T12:08:49.314664,0.738,344,1203 +2026-06-05T12:08:50.259171,0.739,420,1623 +2026-06-05T12:08:50.278339,1.683,209,1832 +2026-06-05T12:08:50.286953,1.702,44,1876 +2026-06-05T12:08:50.301690,1.711,308,2184 +2026-06-05T12:08:51.301677,1.726,260,2444 +2026-06-05T12:08:51.615117,2.726,166,2610 +2026-06-05T12:08:51.615157,3.039,203,2813 +2026-06-05T12:08:51.627033,3.039,44,2857 +2026-06-05T12:08:51.638348,3.051,308,3165 +2026-06-05T12:08:52.411257,3.062,49,3214 +2026-06-05T12:08:52.411298,3.835,96,3310 +2026-06-05T12:08:52.425120,3.835,260,3570 +2026-06-05T12:08:52.450610,3.849,359,3929 +2026-06-05T12:08:52.450648,3.875,54,3983 +2026-06-05T12:08:52.469267,3.875,308,4291 +2026-06-05T12:08:53.156775,3.893,420,4711 +2026-06-05T12:08:53.169453,4.581,253,4964 +2026-06-05T12:08:53.193380,4.593,308,5272 +2026-06-05T12:08:54.314436,4.617,627,5899 +2026-06-05T12:08:54.314480,5.738,354,6253 +2026-06-05T12:08:55.236885,5.738,252,6505 +2026-06-05T12:08:55.236923,6.661,8,6513 +2026-06-05T12:08:55.314710,6.661,367,6880 +2026-06-05T12:08:55.316377,6.739,354,7234 +2026-06-05T12:08:56.202486,6.740,260,7494 +2026-06-05T12:08:56.263052,7.626,367,7861 +2026-06-05T12:08:56.264277,7.687,354,8215 +2026-06-05T12:08:57.231497,7.688,260,8475 +2026-06-05T12:08:57.332075,8.655,150,8625 +2026-06-05T12:08:57.332116,8.756,571,9196 +2026-06-05T12:08:58.185202,8.756,260,9456 +2026-06-05T12:08:58.215201,9.609,367,9823 +2026-06-05T12:08:58.215240,9.639,2,9825 +2026-06-05T12:08:58.228294,9.639,44,9869 +2026-06-05T12:08:58.231550,9.652,308,10177 +2026-06-05T12:08:59.295212,9.655,260,10437 +2026-06-05T12:08:59.295313,10.7,GGA_SENT,10437 +2026-06-05T12:08:59.312619,10.719,6,10443 +2026-06-05T12:08:59.312655,10.737,361,10804 +2026-06-05T12:08:59.313149,10.737,354,11158 +2026-06-05T12:09:00.177219,10.737,260,11418 +2026-06-05T12:09:00.276784,11.601,160,11578 +2026-06-05T12:09:00.302288,11.701,212,11790 +2026-06-05T12:09:00.303562,11.726,349,12139 +2026-06-05T12:09:01.230983,11.727,260,12399 +2026-06-05T12:09:01.261464,12.655,367,12766 +2026-06-05T12:09:01.261544,12.685,46,12812 +2026-06-05T12:09:01.316498,12.685,308,13120 +2026-06-05T12:09:02.188572,12.740,145,13265 +2026-06-05T12:09:02.261747,13.612,260,13525 +2026-06-05T12:09:02.290634,13.686,160,13685 +2026-06-05T12:09:02.302271,13.715,253,13938 +2026-06-05T12:09:02.320659,13.726,308,14246 +2026-06-05T12:09:03.338043,13.745,260,14506 +2026-06-05T12:09:03.358835,14.762,721,15227 +2026-06-05T12:09:04.191269,14.783,260,15487 +2026-06-05T12:09:04.332383,15.615,160,15647 +2026-06-05T12:09:04.337283,15.756,12,15659 +2026-06-05T12:09:04.337319,15.761,197,15856 +2026-06-05T12:09:04.339221,15.761,34,15890 +2026-06-05T12:09:04.339255,15.763,318,16208 +2026-06-05T12:09:05.294759,15.763,260,16468 +2026-06-05T12:09:05.299445,16.719,367,16835 +2026-06-05T12:09:05.299507,16.723,46,16881 +2026-06-05T12:09:05.341359,16.723,308,17189 +2026-06-05T12:09:06.209219,16.765,420,17609 +2026-06-05T12:09:06.213469,17.633,212,17821 +2026-06-05T12:09:06.213625,17.637,41,17862 +2026-06-05T12:09:06.247500,17.638,308,18170 +2026-06-05T12:09:07.257358,17.671,260,18430 +2026-06-05T12:09:07.279411,18.681,367,18797 +2026-06-05T12:09:07.279521,18.703,46,18843 +2026-06-05T12:09:07.304044,18.703,308,19151 +2026-06-05T12:09:08.429200,18.728,260,19411 +2026-06-05T12:09:08.448160,19.853,160,19571 +2026-06-05T12:09:08.448196,19.872,253,19824 +2026-06-05T12:09:08.455415,19.872,308,20132 +2026-06-05T12:09:09.234825,19.879,260,20392 +2026-06-05T12:09:09.262104,20.659,160,20552 +2026-06-05T12:09:09.276976,20.686,212,20764 +2026-06-05T12:09:09.278263,20.701,41,20805 +2026-06-05T12:09:09.298815,20.702,308,21113 +2026-06-05T12:09:09.298917,20.7,GGA_SENT,21113 +2026-06-05T12:09:10.193170,20.723,258,21371 +2026-06-05T12:09:10.193208,21.617,2,21373 +2026-06-05T12:09:10.201106,21.617,150,21523 +2026-06-05T12:09:10.201132,21.625,10,21533 +2026-06-05T12:09:10.213447,21.625,209,21742 +2026-06-05T12:09:10.229631,21.637,44,21786 +2026-06-05T12:09:10.255607,21.654,308,22094 +2026-06-05T12:09:11.256219,21.680,629,22723 +2026-06-05T12:09:11.282766,22.680,44,22767 +2026-06-05T12:09:11.290326,22.707,308,23075 +2026-06-05T12:09:12.171149,22.714,145,23220 +2026-06-05T12:09:12.183925,23.595,260,23480 +2026-06-05T12:09:12.211229,23.608,150,23630 +2026-06-05T12:09:12.230999,23.635,571,24201 +2026-06-05T12:09:13.305303,23.655,981,25182 diff --git a/ntrip_message_survey.py b/ntrip_message_survey.py new file mode 100644 index 0000000..0ec91e3 --- /dev/null +++ b/ntrip_message_survey.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Simple script to survey what RTCM message types are sent by the caster over 60 seconds. +Useful to determine if the caster sends base station position messages (1005/1006). +""" +import base64 +import socket +import time +from datetime import datetime, timezone + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +# Position for GGA +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + +# Survey duration (seconds) +SURVEY_DURATION = 60 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: NTRIP-Survey/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def main(): + print(f"NTRIP Message Type Survey") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Duration: {SURVEY_DURATION} seconds") + print(f"=" * 80) + print() + + message_types = {} + last_gga_time = 0 + start_time = time.monotonic() + total_bytes = 0 + message_count = 0 + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print(f"Connected! Surveying for {SURVEY_DURATION} seconds...\n") + + # Survey loop + while time.monotonic() - start_time < SURVEY_DURATION: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + elapsed = int(now - start_time) + print(f"[{elapsed:3d}s] Sent GGA | Messages so far: {message_count}") + last_gga_time = now + + # Receive data + data = sock.recv(4096) + if not data: + print("Connection closed by caster") + break + + # Parse RTCM message types (simple extraction) + i = 0 + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + message_types[msg_type] = message_types.get(msg_type, 0) + 1 + total_bytes += msg_total_len + message_count += 1 + i += msg_total_len + continue + i += 1 + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + # Print results + elapsed = time.monotonic() - start_time + print(f"\n{'=' * 80}") + print("SURVEY RESULTS") + print(f"{'=' * 80}") + print(f"Duration: {elapsed:.1f} seconds") + print(f"Total messages: {message_count}") + print(f"Total bytes: {total_bytes:,}") + if elapsed > 0: + print(f"Rate: {int(total_bytes / elapsed * 3600):,} bytes/hour") + print(f"\nMessage types received:") + print(f"{'─' * 80}") + + # Sort by message type + for msg_type in sorted(message_types.keys()): + count = message_types[msg_type] + freq = count / elapsed if elapsed > 0 else 0 + + # Add description + descriptions = { + 1005: "Stationary RTK Reference Station ARP", + 1006: "Stationary RTK Reference Station ARP + Antenna Height", + 1019: "GPS Ephemerides", + 1020: "GLONASS Ephemerides", + 1033: "Receiver and Antenna Descriptors", + 1074: "GPS MSM4", + 1075: "GPS MSM5", + 1077: "GPS MSM7", + 1084: "GLONASS MSM4", + 1085: "GLONASS MSM5", + 1087: "GLONASS MSM7", + 1094: "Galileo MSM4", + 1095: "Galileo MSM5", + 1097: "Galileo MSM7", + 1124: "BeiDou MSM4", + 1125: "BeiDou MSM5", + 1127: "BeiDou MSM7", + 1230: "GLONASS Code-Phase Biases", + } + desc = descriptions.get(msg_type, "") + + # Highlight position messages + marker = " ← BASE POSITION" if msg_type in [1005, 1006] else "" + print(f" RTCM {msg_type:4d}: {count:5d} msgs ({freq:6.2f}/sec) {desc}{marker}") + + print(f"{'─' * 80}") + + # Check for position messages + has_position = any(msg_type in [1005, 1006] for msg_type in message_types) + if has_position: + print("\n✓ Caster DOES send base station position messages (1005/1006)") + else: + print("\n✗ Caster does NOT send base station position messages (1005/1006)") + print(" This caster may only provide observation data without station coordinates.") + print(" You may need to use a different mountpoint or obtain station coordinates") + print(" from the caster operator/documentation.") + + print(f"{'=' * 80}") + + +if __name__ == "__main__": + main() diff --git a/ntrip_test.py b/ntrip_test.py new file mode 100644 index 0000000..201bc48 --- /dev/null +++ b/ntrip_test.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Standalone NTRIP test script to observe RTCM stream behavior. +Connects to NTRIP caster and displays parsed RTCM message details. +""" +import base64 +import socket +import time +from datetime import datetime, timezone +import math + +from rtcm_parser import RTCMParser + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +# Position for GGA (hardcoded) +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + +# Debug options +DEBUG_HEX = False # Set to True to see hex dump of first 5 messages +DEBUG_1005_1006 = True # Show detailed debug for position messages + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + + # Convert to NMEA format + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = [ + "GPGGA", + now_utc, + lat_str, lat_hemi, + lon_str, lon_hemi, + "1", # GPS fix + "12", # Number of satellites + "1.0", # HDOP + f"{alt:.1f}", "M", + "", "M", + "", "", + ] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + sentence = f"${core}*{checksum:02X}\r\n" + return sentence.encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: NTRIP-Test/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate distance between two points using Haversine formula.""" + EARTH_RADIUS_M = 6371008.8 + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2 + return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a))) + + +def main(): + print(f"NTRIP Test Client (with HTTP Chunked Encoding Support)") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Rover Position: {LAT:.7f}°, {LON:.7f}° @ {ALT:.1f}m") + print(f"=" * 80) + print() + + parser = RTCMParser() + last_gga_time = 0 + start_time = time.monotonic() + + try: + # Connect to caster + print(f"[{time.strftime('%H:%M:%S')}] Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + + # Send NTRIP request + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read HTTP response headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Caster closed before headers received") + return + header += chunk + + header_text = header.decode("iso-8859-1", errors="replace") + if "200 OK" not in header_text: + print(f"ERROR: NTRIP connection failed:") + print(header_text) + return + + print(f"[{time.strftime('%H:%M:%S')}] Connected! Streaming RTCM data...\n") + + # Main receive loop + while True: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + gga = build_gga(LAT, LON, ALT) + sock.sendall(gga) + print(f"[{time.strftime('%H:%M:%S')}] → Sent GGA to caster") + last_gga_time = now + + # Receive data + data = sock.recv(4096) + if not data: + print(f"\n[{time.strftime('%H:%M:%S')}] Caster closed connection") + break + + # Debug: show hex dump of raw data for first few messages + if DEBUG_HEX and parser.message_count < 5: + print(f"\n[DEBUG] Raw data ({len(data)} bytes):") + print(" ".join(f"{b:02X}" for b in data[:min(100, len(data))])) + if len(data) > 100: + print(f"... ({len(data) - 100} more bytes)") + print() + + # Parse RTCM messages + messages = parser.parse_messages(data) + + for msg in messages: + timestamp = time.strftime('%H:%M:%S') + msg_type = msg["type"] + length = msg["length"] + index = msg["index"] + + # Build message info line + info = f"[{timestamp}] MSG #{index:4d} | RTCM {msg_type:4d} | {length:4d} bytes" + + # Add description if available + if "description" in msg: + info += f" | {msg['description']}" + + # Highlight position messages even if parsing failed + if msg_type in [1005, 1006]: + info += " ← BASE POSITION MESSAGE" + if "base_position" not in msg: + info += " (PARSING FAILED)" + if DEBUG_1005_1006 and "raw_hex" in msg: + info += f"\n Raw hex: {msg['raw_hex'][:80]}..." + elif DEBUG_1005_1006: + info += " (PARSED OK)" + + # Check for base station position + if "base_position" in msg: + base = msg["base_position"] + + # Check if this is first time seeing base station + is_first = parser.message_count == index and parser.base_station_position == base + + if is_first: + info += f"\n ╔════════════════════════════════════════════════════════════════╗" + info += f"\n ║ 🎯 BASE STATION POSITION ACQUIRED ║" + info += f"\n ╚════════════════════════════════════════════════════════════════╝" + else: + info += f"\n └─ BASE STATION INFO" + + info += f"\n Station ID: {base.get('station_id', 'N/A')}" + info += f"\n Position: {base['latitude']:.7f}°, {base['longitude']:.7f}°" + info += f"\n Altitude: {base['altitude_m']:.2f} m" + + # ECEF coordinates + if 'ecef_x' in base: + info += f"\n ECEF: X={base['ecef_x']:.4f}, Y={base['ecef_y']:.4f}, Z={base['ecef_z']:.4f}" + + # Calculate baseline distance + baseline = haversine_m(LAT, LON, base['latitude'], base['longitude']) + info += f"\n 📏 Baseline Distance: {baseline:.2f} m ({baseline/1000:.3f} km)" + + if "antenna_height_m" in base: + info += f"\n 📡 Antenna Height: {base['antenna_height_m']:.4f} m" + + if "itrf_year" in base and base["itrf_year"]: + info += f"\n ITRF Year: {base['itrf_year']}" + + print(info) + + # Print stats every 10 messages + if parser.message_count % 10 == 0 and parser.message_count > 0: + stats = parser.get_stats() + elapsed = time.monotonic() - start_time + if elapsed > 0: + bytes_per_hour = int(stats["total_bytes"] / elapsed * 3600) + print(f"\n{'─' * 80}") + print(f"STATS: {stats['message_count']} messages | {stats['total_bytes']:,} bytes | {bytes_per_hour:,} bytes/hour") + print(f"Message Types: {dict(list(stats['message_types'].items())[:10])}") + if stats["base_station"]: + base = stats["base_station"] + print(f"Base Station: {base['latitude']:.7f}°, {base['longitude']:.7f}° @ {base['altitude_m']:.2f}m") + baseline = haversine_m(LAT, LON, base['latitude'], base['longitude']) + print(f"Baseline: {baseline:.2f} m ({baseline/1000:.3f} km)") + print(f"{'─' * 80}\n") + + except KeyboardInterrupt: + print(f"\n\n[{time.strftime('%H:%M:%S')}] Interrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + # Print final stats + print(f"\n{'=' * 80}") + print("FINAL STATISTICS") + print(f"{'=' * 80}") + stats = parser.get_stats() + elapsed = time.monotonic() - start_time + print(f"Connected for: {int(elapsed)} seconds") + print(f"Total messages: {stats['message_count']}") + print(f"Total bytes: {stats['total_bytes']:,}") + if elapsed > 0: + print(f"Average rate: {int(stats['total_bytes'] / elapsed * 3600):,} bytes/hour") + print(f"\nMessage types received:") + for msg_type, count in sorted(stats['message_types'].items()): + print(f" RTCM {msg_type:4d}: {count:4d} messages") + if stats["base_station"]: + print(f"\nBase Station Information:") + base = stats["base_station"] + print(f" Station ID: {base.get('station_id', 'N/A')}") + print(f" Position: {base['latitude']:.7f}°, {base['longitude']:.7f}°") + print(f" Altitude: {base['altitude_m']:.2f} m") + baseline = haversine_m(LAT, LON, base['latitude'], base['longitude']) + print(f" Baseline from rover: {baseline:.2f} m ({baseline/1000:.3f} km)") + print(f"{'=' * 80}") + + +if __name__ == "__main__": + main() diff --git a/parse_chunked_rtcm.py b/parse_chunked_rtcm.py new file mode 100644 index 0000000..f7146b0 --- /dev/null +++ b/parse_chunked_rtcm.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Parse NTRIP stream with HTTP chunked transfer encoding. +Handles the chunk headers and extracts clean RTCM messages. +""" +import sys +import argparse +from pathlib import Path +from typing import Any + + +def parse_chunked_stream(data: bytes) -> tuple[bytes, list[dict]]: + """ + Parse HTTP chunked transfer encoded stream. + Returns: (clean_rtcm_data, chunk_log) + """ + chunks = [] + rtcm_data = bytearray() + i = 0 + + while i < len(data): + # Look for chunk size line (hex number followed by \r\n) + line_end = data.find(b'\r\n', i) + if line_end == -1: + # No more complete chunks + break + + chunk_size_line = data[i:line_end].decode('ascii', errors='ignore').strip() + + # Try to parse as hex chunk size + try: + # Chunk size may have optional extension after semicolon + chunk_size_str = chunk_size_line.split(';')[0].strip() + chunk_size = int(chunk_size_str, 16) + + # Move past the chunk size line + chunk_data_start = line_end + 2 # skip \r\n + + if chunk_size == 0: + # End of chunks + chunks.append({ + 'offset': i, + 'size_declared': 0, + 'size_actual': 0, + 'is_end': True, + }) + break + + # Extract chunk data + chunk_data_end = chunk_data_start + chunk_size + + if chunk_data_end + 2 > len(data): + # Incomplete chunk + break + + chunk_data = data[chunk_data_start:chunk_data_end] + + # Verify trailing \r\n + trailing = data[chunk_data_end:chunk_data_end + 2] + + chunks.append({ + 'offset': i, + 'size_declared': chunk_size, + 'size_actual': len(chunk_data), + 'chunk_data': chunk_data, + 'has_trailing_crlf': trailing == b'\r\n', + }) + + # Append to clean RTCM data + rtcm_data.extend(chunk_data) + + # Move to next chunk + i = chunk_data_end + 2 # skip trailing \r\n + + except (ValueError, UnicodeDecodeError): + # Not a valid chunk size, skip byte + i += 1 + continue + + return bytes(rtcm_data), chunks + + +def parse_rtcm_messages(data: bytes) -> list[dict]: + """Parse RTCM3 messages from clean data.""" + messages = [] + i = 0 + + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + payload = data[i+3:i+3+length] + msg_type = (payload[0] << 4) | (payload[1] >> 4) + station_id = ((payload[1] & 0x0F) << 8) | payload[2] + + messages.append({ + 'offset': i, + 'type': msg_type, + 'station_id': station_id, + 'length': length, + 'total_length': msg_total_len, + 'payload': payload, + }) + + i += msg_total_len + continue + i += 1 + + return messages + + +def get_message_description(msg_type: int) -> str: + """Get human-readable description for RTCM message type.""" + descriptions = { + 1005: "Stationary RTK Reference Station ARP", + 1006: "Stationary RTK Reference Station ARP + Antenna Height", + 1007: "Antenna Descriptor", + 1008: "Antenna Descriptor & Serial Number", + 1019: "GPS Ephemerides", + 1020: "GLONASS Ephemerides", + 1033: "Receiver and Antenna Descriptors", + 1074: "GPS MSM4", + 1075: "GPS MSM5", + 1077: "GPS MSM7", + 1084: "GLONASS MSM4", + 1085: "GLONASS MSM5", + 1087: "GLONASS MSM7", + 1094: "Galileo MSM4", + 1095: "Galileo MSM5", + 1097: "Galileo MSM7", + 1124: "BeiDou MSM4", + 1125: "BeiDou MSM5", + 1127: "BeiDou MSM7", + 1230: "GLONASS Code-Phase Biases", + } + return descriptions.get(msg_type, f"Type {msg_type}") + + +def analyze_file(filename: str, save_clean: bool = False, show_chunks: bool = True, + show_messages: bool = True, max_messages: int = 50): + """Analyze chunked NTRIP file.""" + + path = Path(filename) + if not path.exists(): + print(f"ERROR: File not found: {filename}") + return + + print(f"Analyzing: {filename}") + print(f"File size: {path.stat().st_size:,} bytes") + print(f"{'=' * 80}\n") + + # Read file + data = path.read_bytes() + + # Parse chunks + print("Parsing HTTP chunked transfer encoding...") + rtcm_data, chunks = parse_chunked_stream(data) + + print(f"Found {len(chunks)} chunks") + print(f"Clean RTCM data: {len(rtcm_data):,} bytes") + print() + + # Show chunk details + if show_chunks and chunks: + print(f"CHUNK DETAILS:") + print(f"{'─' * 80}") + + total_chunk_overhead = 0 + for i, chunk in enumerate(chunks[:20]): # Show first 20 chunks + if chunk.get('is_end'): + print(f"Chunk {i+1}: END (0-byte chunk)") + break + + size = chunk['size_declared'] + offset = chunk['offset'] + trailing = "✓" if chunk.get('has_trailing_crlf') else "✗" + + # Calculate overhead (chunk size line + \r\n + trailing \r\n) + size_line_len = len(hex(size)[2:]) + 2 # hex digits + \r\n + overhead = size_line_len + 2 # + trailing \r\n + total_chunk_overhead += overhead + + print(f"Chunk {i+1:3d}: Offset 0x{offset:08X}, Size {size:5d} bytes, Trailing CRLF {trailing}") + + if len(chunks) > 20: + print(f"... and {len(chunks) - 20} more chunks") + + print(f"\nTotal chunk overhead: {total_chunk_overhead:,} bytes") + print(f"Efficiency: {len(rtcm_data) / len(data) * 100:.1f}% (data vs. total)") + print() + + # Save clean RTCM data + if save_clean: + clean_filename = path.stem + "_clean.bin" + Path(clean_filename).write_bytes(rtcm_data) + print(f"✓ Saved clean RTCM data to: {clean_filename}\n") + + # Parse RTCM messages + if show_messages: + print(f"RTCM MESSAGES:") + print(f"{'─' * 80}") + + messages = parse_rtcm_messages(rtcm_data) + print(f"Found {len(messages)} RTCM messages\n") + + # Message type summary + type_counts = {} + station_ids = set() + + for msg in messages: + type_counts[msg['type']] = type_counts.get(msg['type'], 0) + 1 + station_ids.add(msg['station_id']) + + print("Message type summary:") + for msg_type in sorted(type_counts.keys()): + desc = get_message_description(msg_type) + count = type_counts[msg_type] + print(f" Type {msg_type:4d}: {count:5d} messages - {desc}") + + print(f"\nStation IDs: {sorted(station_ids)}") + print() + + # Show individual messages + print(f"Individual messages (first {max_messages}):") + print(f"{'─' * 80}\n") + + for i, msg in enumerate(messages[:max_messages]): + desc = get_message_description(msg['type']) + print(f"Message {i+1}: Type {msg['type']:4d} - {desc}") + print(f" Offset: 0x{msg['offset']:08X}, Station: {msg['station_id']}, Length: {msg['length']} bytes") + + # Check for ASCII content + payload = msg['payload'] + printable = sum(1 for b in payload if 32 <= b < 127) + if printable / len(payload) > 0.3: + text = payload.decode('ascii', errors='ignore') + text_clean = text.replace('\r', '\\r').replace('\n', '\\n') + if len(text_clean) > 100: + text_clean = text_clean[:100] + "..." + print(f" ASCII: {text_clean}") + + print() + + if len(messages) > max_messages: + print(f"... and {len(messages) - max_messages} more messages") + + +def main(): + parser = argparse.ArgumentParser( + description="Parse NTRIP stream with HTTP chunked transfer encoding", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Basic analysis + python parse_chunked_rtcm.py ntrip_raw_20250605_120000.bin + + # Save clean RTCM data (without chunk encoding) + python parse_chunked_rtcm.py ntrip_raw_20250605_120000.bin --save-clean + + # Show more messages + python parse_chunked_rtcm.py ntrip_raw_20250605_120000.bin --max-messages 100 + + # Skip chunk details, just show messages + python parse_chunked_rtcm.py ntrip_raw_20250605_120000.bin --no-chunks + """ + ) + + parser.add_argument('filename', help='Binary file to analyze') + parser.add_argument('--save-clean', action='store_true', help='Save clean RTCM data without chunk encoding') + parser.add_argument('--no-chunks', action='store_true', help='Skip chunk details') + parser.add_argument('--no-messages', action='store_true', help='Skip message details') + parser.add_argument('--max-messages', type=int, default=50, help='Maximum messages to show') + + args = parser.parse_args() + + analyze_file( + args.filename, + save_clean=args.save_clean, + show_chunks=not args.no_chunks, + show_messages=not args.no_messages, + max_messages=args.max_messages + ) + + +if __name__ == "__main__": + main() diff --git a/parse_rtcm_messages.py b/parse_rtcm_messages.py new file mode 100644 index 0000000..37e61a5 --- /dev/null +++ b/parse_rtcm_messages.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +Scan a binary stream for RTCM v3 messages. + +RTCM v3 frames are: + 0xD3 | 6 reserved bits + 10-bit payload length | payload | CRC-24Q + +This script does not depend on any local project modules. It searches byte-by-byte +for valid frames, verifies the CRC, prints what it finds, and can optionally write +each full RTCM frame to disk. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO, Iterable + + +PREAMBLE = 0xD3 +MAX_RTCM_PAYLOAD_LENGTH = 1023 +CRC24Q_POLY = 0x1864CFB +CRC24Q_MASK = 0xFFFFFF + + +@dataclass(frozen=True) +class RtcmMessage: + index: int + offset: int + message_type: int | None + payload_length: int + frame_length: int + crc: int + frame: bytes + + @property + def payload(self) -> bytes: + return self.frame[3:-3] + + @property + def payload_hex_preview(self) -> str: + preview = self.payload[:24].hex(" ") + if len(self.payload) > 24: + return f"{preview} ..." + return preview + + +@dataclass +class ScanStats: + bytes_read: int = 0 + bytes_skipped: int = 0 + invalid_headers: int = 0 + crc_failures: int = 0 + incomplete_tail_offset: int | None = None + + +@dataclass(frozen=True) +class DechunkResult: + data: bytes + chunks: int + start_offset: int + consumed_bytes: int + + +class ChunkedDecodeError(ValueError): + pass + + +def crc24q(data: bytes) -> int: + """Return the RTCM CRC-24Q value for data.""" + crc = 0 + for byte in data: + crc ^= byte << 16 + for _ in range(8): + crc <<= 1 + if crc & 0x1000000: + crc ^= CRC24Q_POLY + return crc & CRC24Q_MASK + + +def find_chunked_body_start(data: bytes) -> int: + """Return likely HTTP chunked body start offset.""" + header_end = data.find(b"\r\n\r\n") + if header_end == -1: + return 0 + + headers = data[:header_end].decode("iso-8859-1", errors="ignore").lower() + if "transfer-encoding:" in headers and "chunked" in headers: + return header_end + 4 + return 0 + + +def parse_chunk_size(line: bytes) -> int: + size_text = line.split(b";", 1)[0].strip() + if not size_text: + raise ChunkedDecodeError("empty chunk size") + try: + return int(size_text, 16) + except ValueError as exc: + raise ChunkedDecodeError(f"invalid chunk size: {line!r}") from exc + + +def dechunk_http_body(data: bytes, start_offset: int = 0) -> DechunkResult: + """Decode an HTTP chunked body from data[start_offset:].""" + pos = start_offset + decoded = bytearray() + chunks = 0 + + while True: + line_end = data.find(b"\r\n", pos) + if line_end == -1: + raise ChunkedDecodeError("missing chunk-size CRLF") + + size = parse_chunk_size(data[pos:line_end]) + pos = line_end + 2 + + if size == 0: + trailer_end = data.find(b"\r\n\r\n", pos) + if trailer_end == -1: + final_end = data.find(b"\r\n", pos) + consumed = len(data) if final_end == -1 else final_end + 2 + else: + consumed = trailer_end + 4 + return DechunkResult(bytes(decoded), chunks, start_offset, consumed) + + chunk_end = pos + size + if chunk_end + 2 > len(data): + raise ChunkedDecodeError("chunk extends beyond input") + if data[chunk_end : chunk_end + 2] != b"\r\n": + raise ChunkedDecodeError("missing CRLF after chunk data") + + decoded.extend(data[pos:chunk_end]) + chunks += 1 + pos = chunk_end + 2 + + if pos == len(data): + return DechunkResult(bytes(decoded), chunks, start_offset, pos) + + +def prepare_input_stream(data: bytes, mode: str) -> tuple[bytes, DechunkResult | None]: + if mode == "raw": + return data, None + + start_offset = find_chunked_body_start(data) + try: + dechunked = dechunk_http_body(data, start_offset) + except ChunkedDecodeError: + if mode == "chunked": + raise + return data, None + + if mode == "chunked": + return dechunked.data, dechunked + + raw_messages, _ = scan_rtcm_frames(data) + dechunked_messages, _ = scan_rtcm_frames(dechunked.data) + if len(dechunked_messages) > len(raw_messages): + return dechunked.data, dechunked + return data, None + + +def rtcm_message_type(payload: bytes) -> int | None: + """Extract the 12-bit RTCM message number from a payload.""" + if len(payload) < 2: + return None + return (payload[0] << 4) | (payload[1] >> 4) + + +def scan_rtcm_frames(data: bytes) -> tuple[list[RtcmMessage], ScanStats]: + """Find valid RTCM v3 frames in data.""" + stats = ScanStats(bytes_read=len(data)) + messages: list[RtcmMessage] = [] + pos = 0 + + while pos < len(data): + if data[pos] != PREAMBLE: + stats.bytes_skipped += 1 + pos += 1 + continue + + if pos + 3 > len(data): + stats.incomplete_tail_offset = pos + break + + second = data[pos + 1] + if second & 0xFC: + stats.invalid_headers += 1 + stats.bytes_skipped += 1 + pos += 1 + continue + + payload_length = ((second & 0x03) << 8) | data[pos + 2] + if payload_length > MAX_RTCM_PAYLOAD_LENGTH: + stats.invalid_headers += 1 + stats.bytes_skipped += 1 + pos += 1 + continue + + frame_length = 3 + payload_length + 3 + end = pos + frame_length + if end > len(data): + stats.incomplete_tail_offset = pos + break + + frame = data[pos:end] + expected_crc = int.from_bytes(frame[-3:], "big") + actual_crc = crc24q(frame[:-3]) + if actual_crc != expected_crc: + stats.crc_failures += 1 + stats.bytes_skipped += 1 + pos += 1 + continue + + payload = frame[3:-3] + messages.append( + RtcmMessage( + index=len(messages) + 1, + offset=pos, + message_type=rtcm_message_type(payload), + payload_length=payload_length, + frame_length=frame_length, + crc=expected_crc, + frame=frame, + ) + ) + pos = end + + return messages, stats + + +def write_frames(messages: Iterable[RtcmMessage], out_dir: Path) -> None: + out_dir.mkdir(parents=True, exist_ok=True) + for msg in messages: + msg_type = "unknown" if msg.message_type is None else str(msg.message_type) + path = out_dir / f"rtcm_{msg.index:05d}_type_{msg_type}_offset_{msg.offset}.bin" + path.write_bytes(msg.frame) + + +def write_csv(messages: Iterable[RtcmMessage], path: Path) -> None: + with path.open("w", newline="", encoding="utf-8") as fp: + writer = csv.DictWriter( + fp, + fieldnames=[ + "index", + "offset", + "message_type", + "payload_length", + "frame_length", + "crc_hex", + "payload_hex_preview", + ], + ) + writer.writeheader() + for msg in messages: + writer.writerow( + { + "index": msg.index, + "offset": msg.offset, + "message_type": msg.message_type, + "payload_length": msg.payload_length, + "frame_length": msg.frame_length, + "crc_hex": f"{msg.crc:06X}", + "payload_hex_preview": msg.payload_hex_preview, + } + ) + + +def write_jsonl(messages: Iterable[RtcmMessage], path: Path) -> None: + with path.open("w", encoding="utf-8") as fp: + for msg in messages: + fp.write( + json.dumps( + { + "index": msg.index, + "offset": msg.offset, + "message_type": msg.message_type, + "payload_length": msg.payload_length, + "frame_length": msg.frame_length, + "crc_hex": f"{msg.crc:06X}", + "payload_hex": msg.payload.hex(), + "frame_hex": msg.frame.hex(), + }, + separators=(",", ":"), + ) + + "\n" + ) + + +def print_messages( + messages: list[RtcmMessage], + stats: ScanStats, + show_hex: bool, + debug_1005: bool, +) -> None: + for msg in messages: + msg_type = "unknown" if msg.message_type is None else str(msg.message_type) + line = ( + f"#{msg.index:05d} offset={msg.offset:<10} " + f"type={msg_type:<5} payload={msg.payload_length:<4} " + f"frame={msg.frame_length:<4} crc=0x{msg.crc:06X}" + ) + if show_hex: + line += f" payload={msg.payload_hex_preview}" + print(line) + if debug_1005 and msg.message_type == 1005: + print(f" debug1005 frame_hex={msg.frame.hex(' ')}") + + print() + print(f"Valid RTCM messages: {len(messages)}") + print(f"Bytes read: {stats.bytes_read}") + print(f"Bytes skipped while searching: {stats.bytes_skipped}") + print(f"Invalid RTCM-like headers: {stats.invalid_headers}") + print(f"CRC failures: {stats.crc_failures}") + if stats.incomplete_tail_offset is not None: + print(f"Incomplete trailing candidate at offset: {stats.incomplete_tail_offset}") + + +def read_input(path: Path | None, stdin: BinaryIO) -> bytes: + if path is None: + return stdin.read() + return path.read_bytes() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Parse RTCM v3 messages from a .bin file or stdin." + ) + parser.add_argument( + "input", + nargs="?", + type=Path, + help="Binary file to scan. If omitted, reads from stdin.", + ) + parser.add_argument( + "--out-dir", + type=Path, + help="Directory where each valid full RTCM frame will be written as a .bin file.", + ) + parser.add_argument( + "--csv", + type=Path, + help="Write a CSV index of parsed messages.", + ) + parser.add_argument( + "--jsonl", + type=Path, + help="Write JSON Lines with message metadata and hex payload/frame content.", + ) + parser.add_argument( + "--mode", + choices=["auto", "raw", "chunked"], + default="auto", + help=( + "How to read the input: auto detects HTTP chunked transfer encoding, " + "raw scans bytes exactly as stored, chunked forces HTTP dechunking." + ), + ) + parser.add_argument( + "--write-stream", + type=Path, + help="Write the reconstructed byte stream that is scanned for RTCM frames.", + ) + parser.add_argument( + "--hex", + action="store_true", + help="Show a short payload hex preview in console output.", + ) + parser.add_argument( + "--debug-1005", + action="store_true", + help="Print the full RTCM 1005 frame bytes as hex in the console output.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + data = read_input(args.input, sys.stdin.buffer) + + try: + stream, dechunked = prepare_input_stream(data, args.mode) + except ChunkedDecodeError as exc: + print(f"Could not decode chunked input: {exc}", file=sys.stderr) + return 2 + + if dechunked: + print( + f"Decoded HTTP chunked transfer stream: " + f"{dechunked.chunks} chunks, {len(data)} input bytes -> {len(stream)} data bytes" + ) + print() + + if args.write_stream: + args.write_stream.write_bytes(stream) + print(f"Wrote scanned byte stream to {args.write_stream}") + print() + + messages, stats = scan_rtcm_frames(stream) + + print_messages(messages, stats, args.hex, args.debug_1005) + + if args.out_dir: + write_frames(messages, args.out_dir) + print(f"Wrote {len(messages)} frame file(s) to {args.out_dir}") + if args.csv: + write_csv(messages, args.csv) + print(f"Wrote CSV index to {args.csv}") + if args.jsonl: + write_jsonl(messages, args.jsonl) + print(f"Wrote JSONL details to {args.jsonl}") + + return 0 if messages else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pointone.md b/pointone.md new file mode 100644 index 0000000..f27318f --- /dev/null +++ b/pointone.md @@ -0,0 +1,5 @@ +GraphQL API + +Token + +eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhOWM4OTZlMy1iMjA0LTQ3N2QtOTY0Zi0yMTFlM2Y3YTFmOGIiLCJpYXQiOjE3Nzk0NjgwNjYsImV4cCI6MTc4MjA2MDA2Niwicm9sZXMiOiJST0xFX1JFQURfV1JJVEUifQ.sl6ueKLvhtwb7Mj3yDmGLA49B304-Wh1rwVnkahVUhayzyidL8UsyFaa9ieT4Om4v328tVtutfTc_lHSjneN0Q \ No newline at end of file diff --git a/questions.txt b/questions.txt new file mode 100644 index 0000000..1a66485 --- /dev/null +++ b/questions.txt @@ -0,0 +1,34 @@ + + +Is there a command to reboot the device? +Can I power off the device with a command? +Is there a command to get the current state of the cellular connection? +Can I increase the position output frequency? + +Does the implementation of TCP and MQTT upload work right now in v1.2.49? +I tried both but don't see any data hitting either my internet accessible MQTT or TCP servers + +AT+UPLOADDATA_TYPE=GET,0 +AT+UPLOADDATA_PARM=GET,1,hub.umagul.net,12000 + +AT+UPLOADDATA_TYPE=GET,2,USERNAME,user,PASSWORD,pass,CLIENTID,device1,TOPIC,cellular/data,SUBTOPIC, +AT+UPLOADDATA_PARM=GET,1,hub.umagul.net,1883 + +Can I see the TX/RX bytes transmitted over cellular for the current session? + + +I'm looking at the protocol for uploading arbitrary data. I don't want to have defined fields since the data I may want to upload may change. +I'm thinking about doing base64 encoding of my data to allow it to be artibrary and still compatible with the NMEA format. + +Something like: + +AT+UPLOADDATA_DATA=SET,1,ID,1234,DATA,eyJ0aWNrZXQiOiJBMTIzIiwiZGVwdGhfbSI6MS4yLCJmaXgiOiJSVEtfRklYRUQifQ== + +This would let me specify a message ID and base64 encoded data. I could send multiple uploads per message ID and recontrust on my server. + +I would need a way to get a response from the MQTT server to know if the message was accepted and processed. Can we subscribe to a topic to receive feedback? + +What is the longest sentance you can handle? +What is the longest field size you can handle and upload? + +Is there a way to get information about the base station through the RTCM/NTRIP data being received? diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..624e9bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.111 +uvicorn[standard]>=0.29 +bleak>=0.22 diff --git a/rtcm_208_analyzer.py b/rtcm_208_analyzer.py new file mode 100644 index 0000000..fd53d2f --- /dev/null +++ b/rtcm_208_analyzer.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +""" +Analyzer for RTCM message 208 (proprietary). +Captures message 208 and displays hex dump with field analysis. +""" +import base64 +import socket +import time +from datetime import datetime, timezone + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "POLARIS_LOCAL" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: RTCM-208-Analyzer/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def hex_dump(data: bytes, offset: int = 0, width: int = 16) -> str: + """Create a hex dump with ASCII representation.""" + lines = [] + for i in range(0, len(data), width): + chunk = data[i:i+width] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{offset + i:04X} {hex_part:<{width*3}} {ascii_part}") + return "\n".join(lines) + + +def analyze_message_208(data: bytes, msg_num: int): + """Analyze and display structure of message 208.""" + print(f"\n{'=' * 80}") + print(f"MESSAGE 208 #{msg_num} - Length: {len(data)} bytes") + print(f"{'=' * 80}") + + # Show hex dump + print("\nHex Dump:") + print(hex_dump(data)) + + # Try to extract some common fields + print(f"\n{'─' * 80}") + print("Field Analysis:") + print(f"{'─' * 80}") + + if len(data) >= 3: + # Message type (12 bits) + msg_type = (data[0] << 4) | (data[1] >> 4) + print(f"Message Type: {msg_type}") + + # Station/Reference ID (typically next 12 bits) + if len(data) >= 3: + ref_id = ((data[1] & 0x0F) << 8) | data[2] + print(f"Reference/Station ID: {ref_id}") + + # Show first 20 bytes as decimal + print(f"\nFirst bytes (decimal): {[data[i] for i in range(min(20, len(data)))]}") + + # Try to find patterns + print(f"\n{'─' * 80}") + print("Attempting pattern recognition:") + print(f"{'─' * 80}") + + # Look for typical RTCM structures + if len(data) >= 10: + # Check for possible timestamp (GPS TOW in ms) + possible_time_1 = (data[3] << 22) | (data[4] << 14) | (data[5] << 6) | (data[6] >> 2) + print(f"Possible GPS TOW (ms) at byte 3: {possible_time_1} ({possible_time_1/1000:.1f} sec)") + + # Check for possible coordinates or large numbers + for i in range(3, min(len(data) - 4, 10)): + val32 = (data[i] << 24) | (data[i+1] << 16) | (data[i+2] << 8) | data[i+3] + if val32 != 0: + val32_signed = val32 if val32 < 0x80000000 else val32 - 0x100000000 + print(f"32-bit value at byte {i}: {val32} (signed: {val32_signed})") + + +def main(): + print(f"RTCM Message 208 Analyzer") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Will capture first 10 instances of message 208") + print(f"=" * 80) + print() + + msg_208_samples = [] + last_gga_time = 0 + start_time = time.monotonic() + total_messages = 0 + msg_208_count = 0 + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print("Connected! Searching for message 208...\n") + + # Receive loop + while msg_208_count < 10: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + print(f"[{int(now - start_time):3d}s] Sent GGA | Total msgs: {total_messages}, Msg 208 found: {msg_208_count}") + last_gga_time = now + + # Receive data + data = sock.recv(4096) + if not data: + print("Connection closed") + break + + # Parse messages + i = 0 + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + msg_data = data[i+3:i+3+length] + total_messages += 1 + + if msg_type == 208: + msg_208_count += 1 + msg_208_samples.append(msg_data) + print(f"✓ Found message 208 #{msg_208_count} (length: {length} bytes)") + + if msg_208_count <= 3: # Show first 3 immediately + analyze_message_208(msg_data, msg_208_count) + + i += msg_total_len + continue + i += 1 + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + import traceback + traceback.print_exc() + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + # Final summary + print(f"\n{'=' * 80}") + print(f"SUMMARY") + print(f"{'=' * 80}") + print(f"Total messages received: {total_messages}") + print(f"Message 208 instances: {msg_208_count}") + + if msg_208_samples: + # Show statistics + lengths = [len(m) for m in msg_208_samples] + print(f"\nMessage 208 length statistics:") + print(f" Min: {min(lengths)} bytes") + print(f" Max: {max(lengths)} bytes") + print(f" Most common: {max(set(lengths), key=lengths.count)} bytes ({lengths.count(max(set(lengths), key=lengths.count))} instances)") + + # Compare first few messages to find common/changing fields + if len(msg_208_samples) >= 2: + print(f"\n{'─' * 80}") + print("Comparing first 2 messages to identify static vs. dynamic fields:") + print(f"{'─' * 80}") + msg1 = msg_208_samples[0] + msg2 = msg_208_samples[1] + min_len = min(len(msg1), len(msg2)) + + static_bytes = [] + dynamic_bytes = [] + + for i in range(min_len): + if msg1[i] == msg2[i]: + static_bytes.append(i) + else: + dynamic_bytes.append(i) + + print(f"Static byte positions: {static_bytes[:20]}{'...' if len(static_bytes) > 20 else ''}") + print(f"Dynamic byte positions: {dynamic_bytes[:20]}{'...' if len(dynamic_bytes) > 20 else ''}") + + print(f"\nByte-by-byte comparison (first 30 bytes):") + print("Byte# Msg1 Msg2 Same") + print("─" * 30) + for i in range(min(30, min_len)): + same = "✓" if msg1[i] == msg2[i] else "✗" + print(f"{i:4d} {msg1[i]:02X} {msg2[i]:02X} {same}") + + +if __name__ == "__main__": + main() diff --git a/rtcm_208_capture.py b/rtcm_208_capture.py new file mode 100644 index 0000000..681e2d6 --- /dev/null +++ b/rtcm_208_capture.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Capture raw RTCM message 208 to binary file for manual analysis. +""" +import base64 +import socket +import time +from datetime import datetime + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.utcnow().strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: RTCM-Capture/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def main(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = f"rtcm_208_raw_{timestamp}.bin" + index_file = f"rtcm_208_index_{timestamp}.txt" + + print(f"RTCM Message 208 Raw Capture") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Output: {output_file}") + print(f"Index: {index_file}") + print(f"Capturing for 60 seconds or 20 messages, whichever comes first...") + print(f"=" * 80) + print() + + last_gga_time = 0 + start_time = time.monotonic() + msg_208_count = 0 + total_messages = 0 + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print("Connected!\n") + + # Open output files + with open(output_file, 'wb') as binfile, open(index_file, 'w') as idxfile: + idxfile.write(f"# RTCM 208 Message Index\n") + idxfile.write(f"# Captured: {datetime.now().isoformat()}\n") + idxfile.write(f"# Format: message_number, offset, length, timestamp\n") + idxfile.write(f"#\n") + + file_offset = 0 + + # Receive loop + while time.monotonic() - start_time < 60 and msg_208_count < 20: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + elapsed = int(now - start_time) + print(f"[{elapsed:3d}s] Total: {total_messages}, Msg 208: {msg_208_count}/20") + last_gga_time = now + + # Receive data + data = sock.recv(4096) + if not data: + print("Connection closed") + break + + # Parse messages + i = 0 + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + total_messages += 1 + + if msg_type == 208: + msg_208_count += 1 + msg_data = data[i+3:i+3+length] # Just the payload + + # Write to binary file + binfile.write(msg_data) + binfile.flush() + + # Write to index + ts = datetime.now().isoformat() + idxfile.write(f"{msg_208_count},{file_offset},{length},{ts}\n") + idxfile.flush() + + print(f" ✓ Captured message 208 #{msg_208_count} - {length} bytes at offset {file_offset}") + + file_offset += length + + i += msg_total_len + continue + i += 1 + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + import traceback + traceback.print_exc() + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + print(f"\n{'=' * 80}") + print(f"CAPTURE COMPLETE") + print(f"{'=' * 80}") + print(f"Messages captured: {msg_208_count}") + print(f"Output file: {output_file} ({file_offset} bytes)") + print(f"Index file: {index_file}") + print() + print("To view the binary file:") + print(f" hexdump -C {output_file}") + print(f" xxd {output_file}") + print() + print("To extract a specific message (e.g., message #1):") + print(" 1. Look up offset and length in index file") + print(" 2. Use: dd if={output_file} bs=1 skip= count= | hexdump -C") + + +if __name__ == "__main__": + main() diff --git a/rtcm_208_decoder.py b/rtcm_208_decoder.py new file mode 100644 index 0000000..b8e5df7 --- /dev/null +++ b/rtcm_208_decoder.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Decoder for RTCM message 208 - appears to be ASCII/text based. +Captures and displays message 208 content in multiple formats. +""" +import base64 +import socket +import time +import csv +from datetime import datetime, timezone + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: RTCM-208-Decoder/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def decode_message_208(data: bytes, msg_num: int) -> dict: + """Decode message 208 and extract fields.""" + result = { + "message_number": msg_num, + "timestamp": datetime.now(timezone.utc).isoformat(), + "length_bytes": len(data), + "raw_hex": data.hex(), + } + + # Try decoding as ASCII text + try: + text = data.decode('ascii') + result["text_ascii"] = text + result["text_repr"] = repr(text) + except: + result["text_ascii"] = None + + # Try decoding as UTF-8 + try: + text_utf8 = data.decode('utf-8') + result["text_utf8"] = text_utf8 + except: + result["text_utf8"] = None + + # Check if it looks like comma-separated values + if result.get("text_ascii"): + text = result["text_ascii"].strip() + if "," in text or ";" in text: + result["appears_csv"] = True + # Try splitting by common delimiters + if "," in text: + parts = text.split(",") + result["csv_fields"] = parts + result["csv_field_count"] = len(parts) + elif ";" in text: + parts = text.split(";") + result["csv_fields"] = parts + result["csv_field_count"] = len(parts) + + # Check if it starts with specific markers + if len(data) >= 2: + if data[0:2] == b'\r\n': + result["starts_with_crlf"] = True + + # Look for printable characters percentage + printable_count = sum(1 for b in data if 32 <= b < 127 or b in [9, 10, 13]) + result["printable_percent"] = (printable_count / len(data) * 100) if len(data) > 0 else 0 + + return result + + +def main(): + print(f"RTCM Message 208 Decoder") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Capturing message 208 for 60 seconds...") + print(f"=" * 80) + print() + + messages_208 = [] + last_gga_time = 0 + start_time = time.monotonic() + total_messages = 0 + msg_208_count = 0 + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print("Connected! Capturing messages...\n") + + # Receive loop + while time.monotonic() - start_time < 60: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + elapsed = int(now - start_time) + print(f"[{elapsed:3d}s] Sent GGA | Total: {total_messages}, Msg 208: {msg_208_count}") + last_gga_time = now + + # Receive data + data = sock.recv(4096) + if not data: + print("Connection closed") + break + + # Parse messages + i = 0 + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + msg_data = data[i+3:i+3+length] + total_messages += 1 + + if msg_type == 208: + msg_208_count += 1 + decoded = decode_message_208(msg_data, msg_208_count) + messages_208.append(decoded) + + # Show first few messages + if msg_208_count <= 5: + print(f"\n{'─' * 80}") + print(f"Message 208 #{msg_208_count} - {length} bytes - {decoded['printable_percent']:.0f}% printable") + print(f"{'─' * 80}") + if decoded.get("text_ascii"): + print(f"ASCII: {decoded['text_repr']}") + if decoded.get("appears_csv"): + print(f"CSV Fields ({decoded['csv_field_count']}): {decoded['csv_fields']}") + else: + print(f"Hex: {decoded['raw_hex'][:100]}...") + + i += msg_total_len + continue + i += 1 + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + import traceback + traceback.print_exc() + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + # Analysis + print(f"\n{'=' * 80}") + print(f"ANALYSIS") + print(f"{'=' * 80}") + print(f"Total messages: {total_messages}") + print(f"Message 208 captured: {msg_208_count}") + + if messages_208: + # Check if they're all ASCII + all_ascii = all(m.get("text_ascii") is not None for m in messages_208) + print(f"\nAll message 208 are ASCII text: {all_ascii}") + + # Check for CSV pattern + csv_count = sum(1 for m in messages_208 if m.get("appears_csv")) + print(f"Messages that appear to be CSV: {csv_count}/{msg_208_count}") + + # Show field count distribution + if csv_count > 0: + field_counts = [m.get("csv_field_count", 0) for m in messages_208 if m.get("appears_csv")] + print(f"CSV field counts: min={min(field_counts)}, max={max(field_counts)}, mode={max(set(field_counts), key=field_counts.count)}") + + # Export to CSV + csv_filename = "rtcm_208_messages.csv" + print(f"\nExporting to CSV: {csv_filename}") + + with open(csv_filename, 'w', newline='', encoding='utf-8') as csvfile: + fieldnames = ["message_number", "timestamp", "length_bytes", "printable_percent", + "text_ascii", "text_repr", "csv_field_count", "raw_hex"] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + for msg in messages_208: + writer.writerow(msg) + + print(f"✓ Exported {len(messages_208)} messages to {csv_filename}") + + # If CSV-like, create a parsed CSV + if csv_count > 0: + csv_parsed_filename = "rtcm_208_parsed.csv" + print(f"\nExporting parsed CSV fields: {csv_parsed_filename}") + + # Find max field count + max_fields = max(m.get("csv_field_count", 0) for m in messages_208 if m.get("appears_csv")) + + with open(csv_parsed_filename, 'w', newline='', encoding='utf-8') as csvfile: + fieldnames = ["message_number", "timestamp"] + [f"field_{i}" for i in range(max_fields)] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for msg in messages_208: + if msg.get("appears_csv") and msg.get("csv_fields"): + row = { + "message_number": msg["message_number"], + "timestamp": msg["timestamp"] + } + for i, field in enumerate(msg["csv_fields"]): + row[f"field_{i}"] = field.strip() + writer.writerow(row) + + print(f"✓ Exported parsed fields to {csv_parsed_filename}") + + # Show sample text from a few messages + print(f"\n{'=' * 80}") + print("SAMPLE MESSAGE CONTENT:") + print(f"{'=' * 80}") + for i, msg in enumerate(messages_208[:3]): + print(f"\nMessage #{msg['message_number']}:") + if msg.get("text_ascii"): + # Show with visible whitespace + text = msg["text_ascii"].replace('\r', '\\r').replace('\n', '\\n').replace('\t', '\\t') + print(f" {text}") + else: + print(f" (binary data)") + + +if __name__ == "__main__": + main() diff --git a/rtcm_detailed_parser.py b/rtcm_detailed_parser.py new file mode 100644 index 0000000..87fa998 --- /dev/null +++ b/rtcm_detailed_parser.py @@ -0,0 +1,401 @@ +""" +Enhanced RTCM3 parser with detailed field extraction and CSV export. +Supports parsing common RTCM message types with human-readable output. +""" +import csv +import math +from datetime import datetime +from typing import Any + + +class RTCMDetailedParser: + """Enhanced RTCM parser that extracts detailed fields from messages.""" + + def __init__(self): + self.messages: list[dict[str, Any]] = [] + self.message_count = 0 + + def parse_messages(self, data: bytes, timestamp: str | None = None) -> list[dict[str, Any]]: + """Parse RTCM3 messages and extract detailed fields.""" + messages = [] + i = 0 + + while i < len(data): + if data[i] == 0xD3 and i + 2 < len(data): + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 + + if i + msg_total_len <= len(data) and length >= 3: + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + msg_data = data[i+3:i+3+length] + + self.message_count += 1 + + msg_info: dict[str, Any] = { + "timestamp": timestamp or datetime.utcnow().isoformat(), + "message_number": self.message_count, + "message_type": msg_type, + "length_bytes": length, + } + + # Parse specific message types + if msg_type == 1005: + msg_info.update(self._parse_1005(msg_data)) + elif msg_type == 1006: + msg_info.update(self._parse_1006(msg_data)) + elif msg_type == 1007: + msg_info.update(self._parse_1007(msg_data)) + elif msg_type == 1008: + msg_info.update(self._parse_1008(msg_data)) + elif msg_type == 1033: + msg_info.update(self._parse_1033(msg_data)) + elif msg_type in [1074, 1084, 1094, 1124]: + msg_info.update(self._parse_msm4(msg_data, msg_type)) + elif msg_type in [1077, 1087, 1097, 1127]: + msg_info.update(self._parse_msm7(msg_data, msg_type)) + elif msg_type == 1019: + msg_info.update(self._parse_1019(msg_data)) + elif msg_type == 1020: + msg_info.update(self._parse_1020(msg_data)) + + messages.append(msg_info) + self.messages.append(msg_info) + i += msg_total_len + continue + i += 1 + + return messages + + def _parse_1005(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1005: Stationary RTK Reference Station ARP.""" + result = {"message_name": "Stationary RTK Reference Station ARP"} + + try: + if len(data) < 19: + return result + + station_id = ((data[1] & 0x0F) << 8) | data[2] + itrf_year = (data[3] >> 2) & 0x3F + gps_ind = (data[3] >> 1) & 0x01 + glonass_ind = data[3] & 0x01 + galileo_ind = (data[4] >> 7) & 0x01 + ref_station_ind = (data[4] >> 6) & 0x01 + + # ECEF-X (38 bits signed) + ecef_x_raw = ((data[3] & 0x03) << 36) | (data[4] << 28) | (data[5] << 20) | (data[6] << 12) | (data[7] << 4) | (data[8] >> 4) + if ecef_x_raw & (1 << 37): + ecef_x_raw -= (1 << 38) + ecef_x = ecef_x_raw * 0.0001 + + # ECEF-Y (38 bits signed) + ecef_y_raw = ((data[8] & 0x0F) << 34) | (data[9] << 26) | (data[10] << 18) | (data[11] << 10) | (data[12] << 2) | (data[13] >> 6) + if ecef_y_raw & (1 << 37): + ecef_y_raw -= (1 << 38) + ecef_y = ecef_y_raw * 0.0001 + + # ECEF-Z (38 bits signed) + ecef_z_raw = ((data[13] & 0x3F) << 32) | (data[14] << 24) | (data[15] << 16) | (data[16] << 8) | data[17] + if ecef_z_raw & (1 << 37): + ecef_z_raw -= (1 << 38) + ecef_z = ecef_z_raw * 0.0001 + + lat, lon, alt = self._ecef_to_lla(ecef_x, ecef_y, ecef_z) + + result.update({ + "station_id": station_id, + "itrf_year": itrf_year + 1980 if itrf_year else None, + "gps_indicator": gps_ind, + "glonass_indicator": glonass_ind, + "galileo_indicator": galileo_ind, + "reference_station_indicator": ref_station_ind, + "ecef_x_m": ecef_x, + "ecef_y_m": ecef_y, + "ecef_z_m": ecef_z, + "latitude_deg": lat, + "longitude_deg": lon, + "altitude_m": alt, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1006(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1006: Stationary RTK Reference Station ARP with Antenna Height.""" + result = self._parse_1005(data) + result["message_name"] = "Stationary RTK Reference Station ARP + Antenna Height" + + try: + if len(data) >= 21: + antenna_height_raw = (data[18] << 8) | data[19] + result["antenna_height_m"] = antenna_height_raw * 0.0001 + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1007(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1007: Antenna Descriptor.""" + result = {"message_name": "Antenna Descriptor"} + + try: + if len(data) < 5: + return result + + station_id = ((data[1] & 0x0F) << 8) | data[2] + descriptor_len = data[3] + + if len(data) >= 4 + descriptor_len: + descriptor = data[4:4+descriptor_len].decode('ascii', errors='ignore') + result.update({ + "station_id": station_id, + "antenna_descriptor": descriptor, + "descriptor_length": descriptor_len, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1008(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1008: Antenna Descriptor & Serial Number.""" + result = {"message_name": "Antenna Descriptor & Serial Number"} + + try: + if len(data) < 5: + return result + + station_id = ((data[1] & 0x0F) << 8) | data[2] + descriptor_len = data[3] + + if len(data) >= 4 + descriptor_len: + descriptor = data[4:4+descriptor_len].decode('ascii', errors='ignore') + + # Setup ID (8 bits) + setup_id_offset = 4 + descriptor_len + if len(data) > setup_id_offset: + setup_id = data[setup_id_offset] + + # Serial number length and value + serial_len_offset = setup_id_offset + 1 + if len(data) > serial_len_offset: + serial_len = data[serial_len_offset] + serial_offset = serial_len_offset + 1 + + if len(data) >= serial_offset + serial_len: + serial_number = data[serial_offset:serial_offset+serial_len].decode('ascii', errors='ignore') + + result.update({ + "station_id": station_id, + "antenna_descriptor": descriptor, + "antenna_setup_id": setup_id, + "antenna_serial_number": serial_number, + "descriptor_length": descriptor_len, + "serial_length": serial_len, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1033(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1033: Receiver and Antenna Descriptors.""" + result = {"message_name": "Receiver and Antenna Descriptors"} + + try: + if len(data) < 6: + return result + + station_id = ((data[1] & 0x0F) << 8) | data[2] + + # Antenna descriptor + antenna_desc_len = data[3] + offset = 4 + antenna_descriptor = "" + if len(data) >= offset + antenna_desc_len: + antenna_descriptor = data[offset:offset+antenna_desc_len].decode('ascii', errors='ignore') + offset += antenna_desc_len + + # Antenna setup ID + antenna_setup_id = data[offset] if len(data) > offset else None + offset += 1 + + # Antenna serial number + antenna_serial_len = data[offset] if len(data) > offset else 0 + offset += 1 + antenna_serial = "" + if len(data) >= offset + antenna_serial_len: + antenna_serial = data[offset:offset+antenna_serial_len].decode('ascii', errors='ignore') + offset += antenna_serial_len + + # Receiver descriptor + receiver_desc_len = data[offset] if len(data) > offset else 0 + offset += 1 + receiver_descriptor = "" + if len(data) >= offset + receiver_desc_len: + receiver_descriptor = data[offset:offset+receiver_desc_len].decode('ascii', errors='ignore') + offset += receiver_desc_len + + # Receiver firmware + receiver_fw_len = data[offset] if len(data) > offset else 0 + offset += 1 + receiver_firmware = "" + if len(data) >= offset + receiver_fw_len: + receiver_firmware = data[offset:offset+receiver_fw_len].decode('ascii', errors='ignore') + offset += receiver_fw_len + + # Receiver serial number + receiver_serial_len = data[offset] if len(data) > offset else 0 + offset += 1 + receiver_serial = "" + if len(data) >= offset + receiver_serial_len: + receiver_serial = data[offset:offset+receiver_serial_len].decode('ascii', errors='ignore') + + result.update({ + "station_id": station_id, + "antenna_descriptor": antenna_descriptor, + "antenna_setup_id": antenna_setup_id, + "antenna_serial_number": antenna_serial, + "receiver_descriptor": receiver_descriptor, + "receiver_firmware": receiver_firmware, + "receiver_serial_number": receiver_serial, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_msm4(self, data: bytes, msg_type: int) -> dict[str, Any]: + """Parse MSM4 messages (basic observation data).""" + constellation = {1074: "GPS", 1084: "GLONASS", 1094: "Galileo", 1124: "BeiDou"} + result = { + "message_name": f"{constellation.get(msg_type, 'Unknown')} MSM4 Observations", + "constellation": constellation.get(msg_type, "Unknown"), + } + + try: + station_id = ((data[1] & 0x0F) << 8) | data[2] + epoch_time_raw = (data[3] << 22) | (data[4] << 14) | (data[5] << 6) | (data[6] >> 2) + epoch_time_ms = epoch_time_raw + + result.update({ + "station_id": station_id, + "epoch_time_ms": epoch_time_ms, + "multiple_message_bit": (data[6] >> 1) & 0x01, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_msm7(self, data: bytes, msg_type: int) -> dict[str, Any]: + """Parse MSM7 messages (full observation data).""" + constellation = {1077: "GPS", 1087: "GLONASS", 1097: "Galileo", 1127: "BeiDou"} + result = { + "message_name": f"{constellation.get(msg_type, 'Unknown')} MSM7 Observations", + "constellation": constellation.get(msg_type, "Unknown"), + } + + try: + station_id = ((data[1] & 0x0F) << 8) | data[2] + epoch_time_raw = (data[3] << 22) | (data[4] << 14) | (data[5] << 6) | (data[6] >> 2) + epoch_time_ms = epoch_time_raw + + result.update({ + "station_id": station_id, + "epoch_time_ms": epoch_time_ms, + "multiple_message_bit": (data[6] >> 1) & 0x01, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1019(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1019: GPS Ephemeris.""" + result = {"message_name": "GPS Ephemeris"} + + try: + if len(data) < 62: + return result + + satellite_id = (data[1] & 0x0F) << 2 | (data[2] >> 6) + week_number = ((data[2] & 0x3F) << 4) | (data[3] >> 4) + + result.update({ + "satellite_id": satellite_id, + "gps_week_number": week_number, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _parse_1020(self, data: bytes) -> dict[str, Any]: + """Parse RTCM 1020: GLONASS Ephemeris.""" + result = {"message_name": "GLONASS Ephemeris"} + + try: + if len(data) < 45: + return result + + satellite_id = (data[1] & 0x0F) << 2 | (data[2] >> 6) + + result.update({ + "satellite_id": satellite_id, + }) + except Exception as e: + result["parse_error"] = str(e) + + return result + + def _ecef_to_lla(self, x: float, y: float, z: float) -> tuple[float, float, float]: + """Convert ECEF to latitude, longitude, altitude (WGS84).""" + a = 6378137.0 + e2 = 6.69437999014e-3 + + lon = math.atan2(y, x) if (x != 0 or y != 0) else 0.0 + p = math.sqrt(x * x + y * y) + lat = math.atan2(z, p * (1 - e2)) + + for _ in range(10): + N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2) + alt = p / math.cos(lat) - N + lat_new = math.atan2(z, p * (1 - e2 * N / (N + alt))) + if abs(lat_new - lat) < 1e-12: + break + lat = lat_new + + N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2) + alt = p / math.cos(lat) - N if abs(math.cos(lat)) > 1e-10 else z / math.sin(lat) - N * (1 - e2) + + return math.degrees(lat), math.degrees(lon), alt + + def export_to_csv(self, filename: str, message_types: list[int] | None = None): + """Export parsed messages to CSV file.""" + if not self.messages: + return + + # Filter by message type if specified + messages_to_export = self.messages + if message_types: + messages_to_export = [m for m in self.messages if m.get("message_type") in message_types] + + if not messages_to_export: + return + + # Collect all unique field names + fieldnames = set() + for msg in messages_to_export: + fieldnames.update(msg.keys()) + + # Sort fieldnames for consistent column order + fieldnames = sorted(fieldnames) + + with open(filename, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + for msg in messages_to_export: + writer.writerow(msg) + + print(f"Exported {len(messages_to_export)} messages to {filename}") diff --git a/rtcm_parser.py b/rtcm_parser.py new file mode 100644 index 0000000..20b47c4 --- /dev/null +++ b/rtcm_parser.py @@ -0,0 +1,354 @@ +""" +RTCM3 message parser for extracting base station information and message details. +""" +import struct +from typing import Any + + +class RTCMParser: + """Parse RTCM3 messages to extract base station position and other details.""" + + def __init__(self): + self.base_station_position: dict[str, Any] | None = None + self.message_stats: dict[int, int] = {} + self.total_bytes = 0 + self.message_count = 0 + self.chunk_buffer = b"" # Buffer for incomplete chunks + + def parse_chunked_data(self, data: bytes) -> bytes: + """ + Parse HTTP chunked transfer encoding and return clean RTCM data. + Handles incomplete chunks across multiple calls. + """ + self.chunk_buffer += data + clean_data = bytearray() + + while True: + # Look for chunk size line (hex number followed by \r\n) + line_end = self.chunk_buffer.find(b'\r\n') + if line_end == -1: + # No complete chunk size line yet + break + + chunk_size_line = self.chunk_buffer[:line_end] + + try: + # Parse hex chunk size (may have extensions after semicolon) + chunk_size_str = chunk_size_line.decode('ascii', errors='ignore').split(';')[0].strip() + chunk_size = int(chunk_size_str, 16) + + if chunk_size == 0: + # End of chunks + self.chunk_buffer = self.chunk_buffer[line_end + 2:] + break + + # Check if we have the complete chunk data + chunk_data_start = line_end + 2 + chunk_data_end = chunk_data_start + chunk_size + + if chunk_data_end + 2 > len(self.chunk_buffer): + # Don't have complete chunk yet, wait for more data + break + + # Extract chunk data + chunk_data = self.chunk_buffer[chunk_data_start:chunk_data_end] + clean_data.extend(chunk_data) + + # Move past chunk data and trailing \r\n + self.chunk_buffer = self.chunk_buffer[chunk_data_end + 2:] + + except (ValueError, UnicodeDecodeError): + # Not a valid chunk, might be plain RTCM data + # Just return what we have and let RTCM parser handle it + clean_data.extend(self.chunk_buffer) + self.chunk_buffer = b"" + break + + return bytes(clean_data) + + def parse_messages(self, data: bytes, is_chunked: bool = True) -> list[dict[str, Any]]: + """ + Parse RTCM3 messages from data stream and return message details. + + Args: + data: Raw data from NTRIP stream + is_chunked: If True, parse HTTP chunked encoding first + """ + # Handle chunked encoding if needed + if is_chunked: + data = self.parse_chunked_data(data) + + messages = [] + i = 0 + + while i < len(data): + # RTCM3 messages start with 0xD3 + if data[i] == 0xD3 and i + 2 < len(data): + # Parse header: 0xD3 + 2 bytes (6 bits reserved + 10 bits length) + length = ((data[i+1] & 0x03) << 8) | data[i+2] + msg_total_len = 3 + length + 3 # header + payload + CRC + + if i + msg_total_len <= len(data) and length >= 3: + # Extract message type (first 12 bits of payload) + msg_type = (data[i+3] << 4) | (data[i+4] >> 4) + + # Extract full message + msg_data = data[i+3:i+3+length] + + self.message_count += 1 + self.total_bytes += msg_total_len + self.message_stats[msg_type] = self.message_stats.get(msg_type, 0) + 1 + + # Parse specific message types + msg_info: dict[str, Any] = { + "type": msg_type, + "length": length, + "total_length": msg_total_len, + "index": self.message_count, + "raw_hex": msg_data[:min(50, len(msg_data))].hex(), # First 50 bytes for debug + } + + # RTCM 1005: Stationary RTK reference station ARP + if msg_type == 1005: + pos = self._parse_1005(msg_data) + if pos: + msg_info["base_position"] = pos + self.base_station_position = pos + + # RTCM 1006: Stationary RTK reference station ARP with antenna height + elif msg_type == 1006: + pos = self._parse_1006(msg_data) + if pos: + msg_info["base_position"] = pos + self.base_station_position = pos + + # RTCM 1033: Receiver and antenna descriptors + elif msg_type == 1033: + desc = self._parse_1033(msg_data) + if desc: + msg_info["descriptors"] = desc + + # Add common observation message types + elif msg_type in [1074, 1084, 1094, 1124]: # GPS, GLONASS, Galileo, BeiDou MSM4 + msg_info["description"] = self._get_message_description(msg_type) + elif msg_type in [1075, 1085, 1095, 1125]: # GPS, GLONASS, Galileo, BeiDou MSM5 + msg_info["description"] = self._get_message_description(msg_type) + elif msg_type in [1077, 1087, 1097, 1127]: # GPS, GLONASS, Galileo, BeiDou MSM7 + msg_info["description"] = self._get_message_description(msg_type) + else: + msg_info["description"] = self._get_message_description(msg_type) + + messages.append(msg_info) + i += msg_total_len + continue + i += 1 + + return messages + + def _parse_1005(self, data: bytes) -> dict[str, Any] | None: + """Parse RTCM 1005 message (Stationary RTK reference station ARP).""" + try: + if len(data) < 19: + return None + + # Convert payload to bit array for easier bit-level access + bit_array = [] + for byte in data: + for i in range(7, -1, -1): + bit_array.append((byte >> i) & 1) + + def get_bits(start, length): + """Extract unsigned value from bit array.""" + value = 0 + for i in range(length): + value = (value << 1) | bit_array[start + i] + return value + + def get_signed_bits(start, length): + """Extract signed value from bit array (two's complement).""" + value = get_bits(start, length) + # Check sign bit + if value & (1 << (length - 1)): + # Negative number - convert from two's complement + value -= (1 << length) + return value + + pos = 0 + + # DF002: Message Number (12 bits) - skip, already know it's 1005 + pos += 12 + + # DF003: Reference Station ID (12 bits) + station_id = get_bits(pos, 12) + pos += 12 + + # DF021: ITRF Realization Year (6 bits) + itrf_year = get_bits(pos, 6) + pos += 6 + + # DF022: GPS Indicator (1 bit) + pos += 1 + + # DF023: GLONASS Indicator (1 bit) + pos += 1 + + # DF024: Reserved for Galileo (1 bit) + pos += 1 + + # DF141: Reference-Station Indicator (1 bit) + pos += 1 + + # DF025: Antenna Reference Point ECEF-X (38 bits, signed, 0.0001m LSB) + ecef_x_raw = get_signed_bits(pos, 38) + ecef_x = ecef_x_raw * 0.0001 + pos += 38 + + # DF142: Single Receiver Oscillator Indicator (1 bit) + pos += 1 + + # Reserved (1 bit) + pos += 1 + + # DF026: Antenna Reference Point ECEF-Y (38 bits, signed, 0.0001m LSB) + ecef_y_raw = get_signed_bits(pos, 38) + ecef_y = ecef_y_raw * 0.0001 + pos += 38 + + # DF364: Quarter Cycle Indicator (2 bits) + pos += 2 + + # DF027: Antenna Reference Point ECEF-Z (38 bits, signed, 0.0001m LSB) + ecef_z_raw = get_signed_bits(pos, 38) + ecef_z = ecef_z_raw * 0.0001 + pos += 38 + + # Convert ECEF to LLA + lat, lon, alt = self._ecef_to_lla(ecef_x, ecef_y, ecef_z) + + return { + "station_id": station_id, + "itrf_year": itrf_year + 1980 if itrf_year else None, + "ecef_x": ecef_x, + "ecef_y": ecef_y, + "ecef_z": ecef_z, + "latitude": lat, + "longitude": lon, + "altitude_m": alt, + } + except Exception as e: + import traceback + traceback.print_exc() + return None + + def _parse_1006(self, data: bytes) -> dict[str, Any] | None: + """Parse RTCM 1006 message (Stationary RTK reference station ARP with antenna height).""" + # First parse the 1005 portion + result = self._parse_1005(data) + if result: + try: + # RTCM 1006 has antenna height after the 1005 data + # The antenna height starts at bit position 140 (after 1005 fields) + # Antenna height is 16 bits unsigned + bits = int.from_bytes(data, byteorder='big') + bit_pos = 140 # After all 1005 fields + + antenna_height_raw = (bits >> (len(data) * 8 - bit_pos - 16)) & 0xFFFF + result["antenna_height_m"] = antenna_height_raw * 0.0001 # 0.1mm resolution + except: + pass + return result + + def _parse_1033(self, data: bytes) -> dict[str, Any] | None: + """Parse RTCM 1033 message (Receiver and antenna descriptors).""" + try: + if len(data) < 6: + return None + + # Station ID (12 bits) + station_id = ((data[1] & 0x0F) << 8) | data[2] + + # This is a complex variable-length message with text strings + # For simplicity, just return the station ID + return { + "station_id": station_id, + } + except Exception: + return None + + def _ecef_to_lla(self, x: float, y: float, z: float) -> tuple[float, float, float]: + """Convert ECEF coordinates to latitude, longitude, altitude (WGS84).""" + # WGS84 constants + a = 6378137.0 # Semi-major axis + e2 = 6.69437999014e-3 # First eccentricity squared + + # Longitude + lon = 0.0 + if x != 0 or y != 0: + import math + lon = math.atan2(y, x) + + # Latitude and altitude (iterative) + import math + p = math.sqrt(x * x + y * y) + lat = math.atan2(z, p * (1 - e2)) + + for _ in range(10): # Iterate to converge + N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2) + alt = p / math.cos(lat) - N + lat_new = math.atan2(z, p * (1 - e2 * N / (N + alt))) + if abs(lat_new - lat) < 1e-12: + break + lat = lat_new + + N = a / math.sqrt(1 - e2 * math.sin(lat) ** 2) + alt = p / math.cos(lat) - N if abs(math.cos(lat)) > 1e-10 else z / math.sin(lat) - N * (1 - e2) + + return math.degrees(lat), math.degrees(lon), alt + + def _get_message_description(self, msg_type: int) -> str: + """Get human-readable description for RTCM message type.""" + descriptions = { + 1001: "GPS L1 RTK Observables", + 1002: "GPS L1 RTK Observables (Extended)", + 1003: "GPS L1/L2 RTK Observables", + 1004: "GPS L1/L2 RTK Observables (Extended)", + 1005: "Stationary RTK Reference Station ARP", + 1006: "Stationary RTK Reference Station ARP + Antenna Height", + 1007: "Antenna Descriptor", + 1008: "Antenna Descriptor & Serial Number", + 1009: "GLONASS L1 RTK Observables", + 1010: "GLONASS L1 RTK Observables (Extended)", + 1011: "GLONASS L1/L2 RTK Observables", + 1012: "GLONASS L1/L2 RTK Observables (Extended)", + 1013: "System Parameters", + 1019: "GPS Ephemerides", + 1020: "GLONASS Ephemerides", + 1033: "Receiver and Antenna Descriptors", + 1074: "GPS MSM4 (Multi-Signal)", + 1075: "GPS MSM5 (Multi-Signal)", + 1076: "GPS MSM6 (Multi-Signal)", + 1077: "GPS MSM7 (Multi-Signal)", + 1084: "GLONASS MSM4 (Multi-Signal)", + 1085: "GLONASS MSM5 (Multi-Signal)", + 1086: "GLONASS MSM6 (Multi-Signal)", + 1087: "GLONASS MSM7 (Multi-Signal)", + 1094: "Galileo MSM4 (Multi-Signal)", + 1095: "Galileo MSM5 (Multi-Signal)", + 1096: "Galileo MSM6 (Multi-Signal)", + 1097: "Galileo MSM7 (Multi-Signal)", + 1124: "BeiDou MSM4 (Multi-Signal)", + 1125: "BeiDou MSM5 (Multi-Signal)", + 1126: "BeiDou MSM6 (Multi-Signal)", + 1127: "BeiDou MSM7 (Multi-Signal)", + 1230: "GLONASS Code-Phase Biases", + } + return descriptions.get(msg_type, f"RTCM Type {msg_type}") + + def get_stats(self) -> dict[str, Any]: + """Get parser statistics.""" + return { + "total_bytes": self.total_bytes, + "message_count": self.message_count, + "message_types": dict(sorted(self.message_stats.items())), + "base_station": self.base_station_position, + } diff --git a/rtcm_to_csv.py b/rtcm_to_csv.py new file mode 100644 index 0000000..6c6c39d --- /dev/null +++ b/rtcm_to_csv.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +RTCM message capture and CSV export tool. +Connects to NTRIP caster, parses messages with detailed field extraction, +and exports specific message types to CSV files. +""" +import base64 +import socket +import time +from datetime import datetime, timezone +import argparse + +from rtcm_detailed_parser import RTCMDetailedParser + + +# NTRIP configuration +CASTER_HOST = "truertk.pointonenav.com" +CASTER_PORT = 2101 +MOUNTPOINT = "AUTO" +USERNAME = "9t7fwfbm57" +PASSWORD = "96m7bec9g8" + +# Position for GGA +LAT = 36.1140884 +LON = -97.0880663 +ALT = 390.0 + + +def build_gga(lat: float, lon: float, alt: float) -> bytes: + """Build NMEA GGA sentence.""" + now_utc = datetime.now(timezone.utc).strftime("%H%M%S") + lat_hemi = "N" if lat >= 0 else "S" + lat_abs = abs(lat) + lat_deg = int(lat_abs) + lat_min = (lat_abs - lat_deg) * 60.0 + lat_str = f"{lat_deg:02d}{lat_min:07.4f}" + + lon_hemi = "E" if lon >= 0 else "W" + lon_abs = abs(lon) + lon_deg = int(lon_abs) + lon_min = (lon_abs - lon_deg) * 60.0 + lon_str = f"{lon_deg:03d}{lon_min:07.4f}" + + fields = ["GPGGA", now_utc, lat_str, lat_hemi, lon_str, lon_hemi, "1", "12", "1.0", f"{alt:.1f}", "M", "", "M", "", ""] + core = ",".join(fields) + checksum = 0 + for char in core: + checksum ^= ord(char) + return f"${core}*{checksum:02X}\r\n".encode("ascii") + + +def make_ntrip_request(host: str, port: int, mount: str, user: str, password: str) -> bytes: + """Create NTRIP v2 HTTP request.""" + auth = base64.b64encode(f"{user}:{password}".encode("utf-8")).decode("ascii") + req = ( + f"GET /{mount} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Ntrip-Version: Ntrip/2.0\r\n" + f"User-Agent: RTCM-CSV-Exporter/1.0\r\n" + f"Connection: close\r\n" + f"Authorization: Basic {auth}\r\n\r\n" + ) + return req.encode("ascii") + + +def main(): + parser_args = argparse.ArgumentParser(description="Capture RTCM messages and export to CSV") + parser_args.add_argument("--duration", type=int, default=60, help="Duration in seconds (default: 60)") + parser_args.add_argument("--output", type=str, default="rtcm_messages.csv", help="Output CSV filename") + parser_args.add_argument("--types", type=str, help="Comma-separated message types to capture (e.g., 1005,1006,1008)") + parser_args.add_argument("--verbose", action="store_true", help="Show detailed output") + args = parser_args.parse_args() + + # Parse message types filter + message_types_filter = None + if args.types: + message_types_filter = [int(x.strip()) for x in args.types.split(",")] + print(f"Filtering for message types: {message_types_filter}") + + print(f"RTCM Message Capture to CSV") + print(f"=" * 80) + print(f"Caster: {CASTER_HOST}:{CASTER_PORT}/{MOUNTPOINT}") + print(f"Duration: {args.duration} seconds") + print(f"Output: {args.output}") + print(f"=" * 80) + print() + + parser = RTCMDetailedParser() + last_gga_time = 0 + start_time = time.monotonic() + + try: + # Connect + print("Connecting...") + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(30) + sock.connect((CASTER_HOST, CASTER_PORT)) + sock.sendall(make_ntrip_request(CASTER_HOST, CASTER_PORT, MOUNTPOINT, USERNAME, PASSWORD)) + + # Read headers + header = b"" + while b"\r\n\r\n" not in header: + chunk = sock.recv(1) + if not chunk: + print("ERROR: Connection closed") + return + header += chunk + + if "200 OK" not in header.decode("iso-8859-1", errors="replace"): + print("ERROR: Connection failed") + return + + print(f"Connected! Capturing messages...\n") + + # Capture loop + while time.monotonic() - start_time < args.duration: + now = time.monotonic() + + # Send GGA every 10 seconds + if now - last_gga_time >= 10: + sock.sendall(build_gga(LAT, LON, ALT)) + elapsed = int(now - start_time) + print(f"[{elapsed:3d}s] Sent GGA | Messages captured: {parser.message_count}") + last_gga_time = now + + # Receive and parse data + data = sock.recv(4096) + if not data: + print("Connection closed by caster") + break + + timestamp = datetime.now(timezone.utc).isoformat() + messages = parser.parse_messages(data, timestamp) + + # Display verbose output + if args.verbose: + for msg in messages: + msg_type = msg.get("message_type", "?") + msg_name = msg.get("message_name", "Unknown") + print(f" [{timestamp}] RTCM {msg_type}: {msg_name}") + + # Show key fields for specific message types + if msg_type in [1005, 1006]: + if "latitude_deg" in msg: + print(f" Station: {msg.get('station_id')}, Lat: {msg.get('latitude_deg'):.7f}°, Lon: {msg.get('longitude_deg'):.7f}°") + elif msg_type in [1007, 1008]: + if "antenna_descriptor" in msg: + print(f" Station: {msg.get('station_id')}, Antenna: {msg.get('antenna_descriptor')}") + if "antenna_serial_number" in msg: + print(f" Serial: {msg.get('antenna_serial_number')}") + elif msg_type == 1033: + if "receiver_descriptor" in msg: + print(f" Station: {msg.get('station_id')}") + print(f" Receiver: {msg.get('receiver_descriptor')}") + print(f" Antenna: {msg.get('antenna_descriptor')}") + + except KeyboardInterrupt: + print("\n\nInterrupted by user") + except Exception as e: + print(f"\nERROR: {e}") + finally: + if 'sock' in locals(): + try: + sock.close() + except: + pass + + # Export to CSV + elapsed = time.monotonic() - start_time + print(f"\n{'=' * 80}") + print(f"Capture complete: {parser.message_count} messages in {elapsed:.1f} seconds") + print(f"{'=' * 80}") + + if parser.message_count > 0: + print(f"\nExporting to CSV: {args.output}") + parser.export_to_csv(args.output, message_types=message_types_filter) + print(f"✓ Export complete!") + + # Show message type summary + message_type_counts = {} + for msg in parser.messages: + msg_type = msg.get("message_type") + message_type_counts[msg_type] = message_type_counts.get(msg_type, 0) + 1 + + print(f"\nMessage types captured:") + for msg_type in sorted(message_type_counts.keys()): + count = message_type_counts[msg_type] + print(f" RTCM {msg_type:4d}: {count:5d} messages") + else: + print("No messages captured!") + + +if __name__ == "__main__": + main() diff --git a/tcp_listener.py b/tcp_listener.py new file mode 100644 index 0000000..2d1b8f5 --- /dev/null +++ b/tcp_listener.py @@ -0,0 +1,57 @@ +import argparse +import socket +import threading +from datetime import datetime + + +def timestamp() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def printable(data: bytes) -> str: + text = data.decode("utf-8", errors="replace") + return text.replace("\r", "\\r").replace("\n", "\\n\n") + + +def handle_client(conn: socket.socket, address: tuple[str, int]) -> None: + peer = f"{address[0]}:{address[1]}" + print(f"[{timestamp()}] connected {peer}", flush=True) + try: + with conn: + while True: + data = conn.recv(4096) + if not data: + break + print(f"[{timestamp()}] {peer} {len(data)} bytes") + print(printable(data), flush=True) + except ConnectionResetError: + print(f"[{timestamp()}] reset {peer}", flush=True) + except OSError as exc: + print(f"[{timestamp()}] socket error {peer}: {exc}", flush=True) + finally: + print(f"[{timestamp()}] disconnected {peer}", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Raw TCP listener that prints incoming data.") + parser.add_argument("--host", default="0.0.0.0", help="Interface to bind. Default: 0.0.0.0") + parser.add_argument("--port", type=int, default=12000, help="TCP port to listen on. Default: 12000") + args = parser.parse_args() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen() + print(f"[{timestamp()}] listening on {args.host}:{args.port}", flush=True) + + try: + while True: + conn, address = server.accept() + thread = threading.Thread(target=handle_client, args=(conn, address), daemon=True) + thread.start() + except KeyboardInterrupt: + print(f"\n[{timestamp()}] shutting down", flush=True) + + +if __name__ == "__main__": + main() diff --git a/test_ecef_convert.py b/test_ecef_convert.py new file mode 100644 index 0000000..5af2b71 --- /dev/null +++ b/test_ecef_convert.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Test ECEF to LLA conversion with your reported values. +""" +import math + + +def ecef_to_lla(x: float, y: float, z: float) -> tuple[float, float, float]: + """Convert ECEF to latitude, longitude, altitude (WGS84).""" + # WGS84 constants + a = 6378137.0 # Semi-major axis (meters) + e2 = 6.69437999014e-3 # First eccentricity squared + + # Calculate longitude + lon_rad = math.atan2(y, x) + lon = math.degrees(lon_rad) + + # Calculate latitude (iterative) + p = math.sqrt(x * x + y * y) + lat_rad = math.atan2(z, p * (1 - e2)) + + # Iterate to refine latitude and altitude + for _ in range(10): + N = a / math.sqrt(1 - e2 * math.sin(lat_rad) ** 2) + alt = p / math.cos(lat_rad) - N if abs(math.cos(lat_rad)) > 1e-10 else 0 + lat_new = math.atan2(z, p * (1 - e2 * N / (N + alt))) + if abs(lat_new - lat_rad) < 1e-12: + break + lat_rad = lat_new + + # Final altitude calculation + N = a / math.sqrt(1 - e2 * math.sin(lat_rad) ** 2) + if abs(math.cos(lat_rad)) > 1e-10: + alt = p / math.cos(lat_rad) - N + else: + alt = abs(z) / math.sin(lat_rad) - N * (1 - e2) + + lat = math.degrees(lat_rad) + + return lat, lon, alt + + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Calculate distance between two points using Haversine formula.""" + EARTH_RADIUS_M = 6371008.8 + lat1_rad = math.radians(lat1) + lat2_rad = math.radians(lat2) + delta_lat = math.radians(lat2 - lat1) + delta_lon = math.radians(lon2 - lon1) + a = math.sin(delta_lat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(delta_lon / 2) ** 2 + return 2 * EARTH_RADIUS_M * math.asin(min(1.0, math.sqrt(a))) + + +# Your reported ECEF values +ecef_x = -1755798.4445 +ecef_y = 11515742.5663 +ecef_z = -3206834.7481 + +print("Testing ECEF to LLA Conversion") +print("=" * 80) +print(f"\nInput ECEF Coordinates:") +print(f" X: {ecef_x:14.4f} m") +print(f" Y: {ecef_y:14.4f} m") +print(f" Z: {ecef_z:14.4f} m") + +# Check distance from Earth's center +distance_from_center = math.sqrt(ecef_x**2 + ecef_y**2 + ecef_z**2) +print(f"\nDistance from Earth center: {distance_from_center:,.2f} m ({distance_from_center/1000:.2f} km)") +print(f"Expected: ~6,371 km (Earth's radius)") + +if 6300000 < distance_from_center < 6500000: + print("✓ ECEF magnitude looks correct!") +else: + print("✗ WARNING: ECEF magnitude seems wrong!") + +# Convert to LLA +lat, lon, alt = ecef_to_lla(ecef_x, ecef_y, ecef_z) + +print(f"\nConverted Geodetic Coordinates (WGS84):") +print(f" Latitude: {lat:12.7f}°") +print(f" Longitude: {lon:12.7f}°") +print(f" Altitude: {alt:12.2f} m") + +# Check if coordinates make sense +if -90 <= lat <= 90 and -180 <= lon <= 180: + print("\n✓ Lat/Lon within valid range") +else: + print("\n✗ Lat/Lon OUT OF RANGE!") + +if -500 < alt < 10000: + print(f"✓ Altitude seems reasonable ({alt:.2f} m)") +else: + print(f"✗ WARNING: Altitude seems unusual ({alt:.2f} m)") + +# Your rover position +rover_lat = 36.1140884 +rover_lon = -97.0880663 + +print(f"\nYour Rover Position:") +print(f" Latitude: {rover_lat:12.7f}°") +print(f" Longitude: {rover_lon:12.7f}°") + +# Calculate baseline +baseline = haversine_distance(rover_lat, rover_lon, lat, lon) + +print(f"\nBaseline Distance:") +print(f" {baseline:,.2f} m") +print(f" {baseline/1000:,.3f} km") + +if baseline < 200000: # Less than 200 km + print(f"✓ Baseline seems reasonable for RTK service") +else: + print(f"✗ WARNING: Baseline very large ({baseline/1000:.1f} km)") + +# Show location description +print(f"\nBase Station Location:") +if lat > 0: + print(f" {abs(lat):.4f}° North", end="") +else: + print(f" {abs(lat):.4f}° South", end="") + +if lon > 0: + print(f", {abs(lon):.4f}° East") +else: + print(f", {abs(lon):.4f}° West") + +# Rough location check +if 24 < lat < 49 and -125 < lon < -66: + print(" 📍 Location: Continental United States") +elif 0 < lat < 90 and 60 < lon < 180: + print(" 📍 Location: Asia (possibly India/Southeast Asia)") +else: + print(" 📍 Location: Other")