Initial commit

This commit is contained in:
brentperteet
2026-06-24 11:12:44 -05:00
commit 5703c05c1d
30 changed files with 8149 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
pointone/
postman/
.venv/
__pycache__/
*.py[cod]

181
AT_Commands_Guide_EN.md Normal file
View 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
View 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

View 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
View 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
View 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
View 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
View 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()

1486
app.py Normal file

File diff suppressed because it is too large Load Diff

178
capture_raw_ntrip.py Normal file
View 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
View 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
View 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())

View 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
View 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: 515 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()

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
GraphQL API
Token
eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhOWM4OTZlMy1iMjA0LTQ3N2QtOTY0Zi0yMTFlM2Y3YTFmOGIiLCJpYXQiOjE3Nzk0NjgwNjYsImV4cCI6MTc4MjA2MDA2Niwicm9sZXMiOiJST0xFX1JFQURfV1JJVEUifQ.sl6ueKLvhtwb7Mj3yDmGLA49B304-Wh1rwVnkahVUhayzyidL8UsyFaa9ieT4Om4v328tVtutfTc_lHSjneN0Q

34
questions.txt Normal file
View 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
View File

@@ -0,0 +1,3 @@
fastapi>=0.111
uvicorn[standard]>=0.29
bleak>=0.22

254
rtcm_208_analyzer.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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")