Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
pointone/
|
||||
postman/
|
||||
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
181
AT_Commands_Guide_EN.md
Normal file
181
AT_Commands_Guide_EN.md
Normal file
@@ -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>,<SIM_ready>,<NTRIP_flag>,<CSQ>,<data_back>,<upload_size>,<NTRIP_size>,GNSS,<status>,<reserved>,star,<SNR45>,<SNR48>,<SNR50>,<visible_stars>
|
||||
OK
|
||||
```
|
||||
|
||||
**Parameter Explanation**:
|
||||
- `<4G_link>`: 4G network connection status (0=disconnected, 1=connected)
|
||||
- `<SIM_ready>`: SIM card ready status (0=not ready, 1=ready)
|
||||
- `<NTRIP_flag>`: NTRIP connection status (0=disconnected, 1=connected)
|
||||
- `<CSQ>`: Signal quality (0-31, higher is better)
|
||||
- `<data_back>`: Data upload status (0=idle, 1=uploading)
|
||||
- `<upload_size>`: Upload queue size (bytes)
|
||||
- `<NTRIP_size>`: NTRIP data queue size (bytes)
|
||||
- `<status>`: GNSS module status
|
||||
- `<SNR45>`, `<SNR48>`, `<SNR50>`: Number of satellites with SNR > 45/48/50 dB
|
||||
- `<visible_stars>`: 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,<frequency>
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `<frequency>`: 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,<frequency>
|
||||
OK
|
||||
```
|
||||
|
||||
**Parameter Explanation**:
|
||||
- `<frequency>`: 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,<latitude>,<longitude>,<altitude>,<distance>
|
||||
OK
|
||||
```
|
||||
|
||||
**Response Example** (when RTCM data is not available):
|
||||
```
|
||||
AT+RTCMBASEPOS=GET,0.0,0.0,0.0,0.0
|
||||
OK
|
||||
```
|
||||
|
||||
**Parameter Explanation**:
|
||||
- `<latitude>`: Base station latitude in degrees (positive=North, negative=South)
|
||||
- `<longitude>`: Base station longitude in degrees (positive=East, negative=West)
|
||||
- `<altitude>`: Base station altitude/elevation in meters
|
||||
- `<distance>`: 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
|
||||
544
AT_Commands_Reference.md
Normal file
544
AT_Commands_Reference.md
Normal file
@@ -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,<flag>,<apn>,<username>,<password>\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,<flag>,<apn>,<username>,<password>
|
||||
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,<angle>\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,<angle>
|
||||
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,<type>,<json>,<gnpos>,<gndev>,<gga>,<gst>,<rmc>,<vtg>,<gsv>,<gsa>\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,<type>,<json>,<gnpos>,<gndev>,<gga>,<gst>,<rmc>,<vtg>,<gsv>,<gsa>
|
||||
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,<enable>,<server>,<port>\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,<enable>,<server>,<port>
|
||||
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,<type>,USERNAME,<username>,PASSWORD,<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,<type>,<username>,<password>
|
||||
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,<enable>,<server>,<port>,<mountpoint>,<username>,<password>\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,<enable>,<server>,<port>,<mountpoint>,<username>,<password>
|
||||
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,<server>,<port>\r\n
|
||||
```
|
||||
|
||||
**Format (Mode 2 - NTRIP Caster):**
|
||||
```
|
||||
AT+BASE_PARM=SET,2,<server>,<port>,<mountpoint>,<username>,<password>\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,<server>,<port>
|
||||
OK
|
||||
```
|
||||
|
||||
**Response (Mode 2):**
|
||||
```
|
||||
AT+BASE_PARM=GET,2,<server>,<port>,<mountpoint>,<username>,<password>
|
||||
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,<mode>\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,<mode>
|
||||
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
|
||||
122
AT_UPLOADDATA_MQTT_Configuration_EN.md
Normal file
122
AT_UPLOADDATA_MQTT_Configuration_EN.md
Normal file
@@ -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,<freq>,<ip>,<port>\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,<user>,PASSWORD,<password>,CLIENTID,<clientid>,TOPIC,<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,<freq>,<ip>,<port>
|
||||
OK
|
||||
```
|
||||
|
||||
### Query Protocol Type and MQTT Parameters
|
||||
|
||||
```
|
||||
AT+UPLOADDATA_TYPE=GET\r\n
|
||||
```
|
||||
|
||||
**Response Format (MQTT):**
|
||||
```
|
||||
AT+UPLOADDATA_TYPE=GET,2,USERNAME,<user>,PASSWORD,<pass>,CLIENTID,<id>,TOPIC,<topic>,SUBTOPIC,<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
|
||||
```
|
||||
542
Custom_NMEA_Sentences.md
Normal file
542
Custom_NMEA_Sentences.md
Normal file
@@ -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:**
|
||||
```
|
||||
$<sentence>*<checksum>\r\n
|
||||
```
|
||||
- `$` - Start delimiter
|
||||
- `<sentence>` - Sentence identifier and data fields (comma-separated)
|
||||
- `*` - Checksum delimiter
|
||||
- `<checksum>` - 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,<lat>,<lon>,<alt>,<altCorr>,<status>,<hdop>,<hrms>,<vrms>,<satUsed>,<satView>,<speed>,<heading>,<battV>,<battPct>,<ntripFlag>,<rtcmSize>,<age>,<timestamp>,<tiltAngle>*<checksum>\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,<sn>,<pcb_version>,<fw_version>,<imei>,<imsi>,<iccid>*<checksum>\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
|
||||
88
SCRIPT_SUMMARY.md
Normal file
88
SCRIPT_SUMMARY.md
Normal file
@@ -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_<timestamp>.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_<timestamp>.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.
|
||||
278
analyze_rtcm_binary.py
Normal file
278
analyze_rtcm_binary.py
Normal file
@@ -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()
|
||||
291
analyze_rtcm_correct.py
Normal file
291
analyze_rtcm_correct.py
Normal file
@@ -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()
|
||||
178
capture_raw_ntrip.py
Normal file
178
capture_raw_ntrip.py
Normal file
@@ -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()
|
||||
317
debug_1005.py
Normal file
317
debug_1005.py
Normal file
@@ -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()
|
||||
184
ntrip/ble_client.py
Normal file
184
ntrip/ble_client.py
Normal file
@@ -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())
|
||||
270
ntrip/bluetooth_nmea_parser.py
Normal file
270
ntrip/bluetooth_nmea_parser.py
Normal file
@@ -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())
|
||||
453
ntrip/client.py
Normal file
453
ntrip/client.py
Normal file
@@ -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()
|
||||
110
ntrip_log_20260605_120848.txt
Normal file
110
ntrip_log_20260605_120848.txt
Normal file
@@ -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
|
||||
207
ntrip_message_survey.py
Normal file
207
ntrip_message_survey.py
Normal file
@@ -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()
|
||||
271
ntrip_test.py
Normal file
271
ntrip_test.py
Normal file
@@ -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()
|
||||
288
parse_chunked_rtcm.py
Normal file
288
parse_chunked_rtcm.py
Normal file
@@ -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()
|
||||
420
parse_rtcm_messages.py
Normal file
420
parse_rtcm_messages.py
Normal file
@@ -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())
|
||||
5
pointone.md
Normal file
5
pointone.md
Normal file
@@ -0,0 +1,5 @@
|
||||
GraphQL API
|
||||
|
||||
Token
|
||||
|
||||
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhOWM4OTZlMy1iMjA0LTQ3N2QtOTY0Zi0yMTFlM2Y3YTFmOGIiLCJpYXQiOjE3Nzk0NjgwNjYsImV4cCI6MTc4MjA2MDA2Niwicm9sZXMiOiJST0xFX1JFQURfV1JJVEUifQ.sl6ueKLvhtwb7Mj3yDmGLA49B304-Wh1rwVnkahVUhayzyidL8UsyFaa9ieT4Om4v328tVtutfTc_lHSjneN0Q
|
||||
34
questions.txt
Normal file
34
questions.txt
Normal file
@@ -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?
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.29
|
||||
bleak>=0.22
|
||||
254
rtcm_208_analyzer.py
Normal file
254
rtcm_208_analyzer.py
Normal file
@@ -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()
|
||||
190
rtcm_208_capture.py
Normal file
190
rtcm_208_capture.py
Normal file
@@ -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=<offset> count=<length> | hexdump -C")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
288
rtcm_208_decoder.py
Normal file
288
rtcm_208_decoder.py
Normal file
@@ -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()
|
||||
401
rtcm_detailed_parser.py
Normal file
401
rtcm_detailed_parser.py
Normal file
@@ -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}")
|
||||
354
rtcm_parser.py
Normal file
354
rtcm_parser.py
Normal file
@@ -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,
|
||||
}
|
||||
194
rtcm_to_csv.py
Normal file
194
rtcm_to_csv.py
Normal file
@@ -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()
|
||||
57
tcp_listener.py
Normal file
57
tcp_listener.py
Normal file
@@ -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()
|
||||
133
test_ecef_convert.py
Normal file
133
test_ecef_convert.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user