A robust GPS tracking and geofencing platform built with Ruby on Rails. Receives location data from multiple GPS device types, stores it in PostgreSQL with PostGIS, and provides real-time geofence monitoring with webhook alerts.
- Multi-Device Support: Unified ingestion for 8+ GPS device protocols
- Real-Time Geofencing: PostGIS-powered spatial queries with enter/exit detection
- Webhook Alerts: Automatic notifications when devices cross geofence boundaries
- Background Processing: Sidekiq-powered message queue for reliable data processing
- Clean Architecture: Modular design with decoders, services, and repositories
| Device | Protocol | Endpoint |
|---|---|---|
| Globalstar SmartOne | Binary STU/PRV | POST /globalstar/stu, POST /globalstar/prv |
| Queclink GL200 | CSV | POST /gl200/msg |
| Queclink GL300 | CSV | POST /gl300/msg |
| Queclink GL300MA | CSV | POST /gl300ma/msg |
| SPOT Trace | XML | POST /spot_trace/msg |
| GPS306A | NMEA CSV | POST /gps306a/msg |
| Xexun TK1022 | CSV | POST /xexun_tk1022/msg |
| Smart BDGPS | CSV | POST /smart_bdgps/msg |
- Ruby: 3.2.9
- Rails: 7.0.8
- Database: PostgreSQL 15+ with PostGIS 3.3
- Background Jobs: Sidekiq 7.0
- Web Server: Puma 6.0
- Geospatial: RGeo, activerecord-postgis-adapter
- Type Checking: RBS (Ruby Signature)
app/
├── controllers/ # HTTP endpoints for device data ingestion
├── decoders/ # Protocol-specific message parsers
│ ├── base_decoder.rb # Abstract base with common methods
│ ├── globalstar_decoder.rb # Binary payload decoding
│ ├── gl200_decoder.rb # Queclink CSV parsing
│ ├── spot_trace_decoder.rb # XML message parsing
│ └── gps306a_decoder.rb # NMEA coordinate conversion
├── factories/ # Object creation patterns
│ └── parsed_message_factory.rb
├── models/ # ActiveRecord models
│ ├── parsed_message.rb # Unified message storage
│ ├── location_msg.rb # Location data with PostGIS
│ ├── geofence.rb # PostGIS polygon boundaries
│ ├── fence_state.rb # Device-to-fence state tracking
│ └── fence_alert.rb # Alert history
├── repositories/ # Data access patterns
│ └── fence_state_repository.rb
├── services/ # Business logic
│ ├── geofence_check_service.rb # Fence boundary algorithm
│ └── xml_response_builder.rb # Globalstar XML responses
├── value_objects/ # Immutable data containers
│ └── coordinates.rb
└── workers/ # Sidekiq background jobs
├── globalstar_worker.rb
├── gl200_worker.rb
├── spot_trace_worker.rb
├── gps306a_worker.rb
└── fence_alert_worker.rb
sig/ # RBS type signatures
├── coordinates.rbs
├── base_decoder.rbs
├── globalstar_decoder.rbs
├── gl200_decoder.rbs
├── spot_trace_decoder.rbs
├── gps306a_decoder.rbs
├── parsed_message_factory.rbs
├── fence_state_repository.rbs
├── geofence_check_service.rbs
└── xml_response_builder.rbs
This project uses RBS (Ruby Signature) for static type definitions. Type signatures are in the sig/ directory.
bundle exec rbs -I sig validate# sig/coordinates.rbs
class Coordinates
attr_reader latitude: Float?
attr_reader longitude: Float?
def initialize: (Float | String | nil, Float | String | nil) -> void
def valid?: () -> bool
def to_s: () -> String
def to_rgeo_point: (?factory: untyped?) -> untyped
end| Class | Signature File |
|---|---|
Coordinates |
sig/coordinates.rbs |
BaseDecoder |
sig/base_decoder.rbs |
GlobalstarDecoder |
sig/globalstar_decoder.rbs |
Gl200Decoder |
sig/gl200_decoder.rbs |
SpotTraceDecoder |
sig/spot_trace_decoder.rbs |
Gps306aDecoder |
sig/gps306a_decoder.rbs |
ParsedMessageFactory |
sig/parsed_message_factory.rbs |
FenceStateRepository |
sig/fence_state_repository.rbs |
GeofenceCheckService |
sig/geofence_check_service.rbs |
XmlResponseBuilder |
sig/xml_response_builder.rbs |
- Ruby 3.2.9
- PostgreSQL 15+ with PostGIS extension
- Redis (for Sidekiq)
git clone git@github.com:your-org/gps-catcher.git
cd gps-catcherbundle installCreate .env or set environment variables:
export DATABASE_HOST=localhost
export DATABASE_USERNAME=your_username
export DATABASE_PASSWORD=your_password
export DATABASE_NAME=gps# Create database with PostGIS extension
psql -c "CREATE DATABASE gps_development;"
psql -d gps_development -c "CREATE EXTENSION postgis;"
# Run migrations
bin/rails db:migrate# Terminal 1: Rails server
bin/rails server
# Terminal 2: Sidekiq worker
bundle exec sidekiqproduction:
adapter: postgis
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: <%= ENV["DATABASE_HOST"] %>
database: <%= ENV.fetch("DATABASE_NAME") { "gps" } %>
username: <%= ENV["DATABASE_USERNAME"] %>
password: <%= ENV["DATABASE_PASSWORD"] %>| Variable | Description | Default |
|---|---|---|
DATABASE_HOST |
PostgreSQL host | localhost |
DATABASE_USERNAME |
Database user | System user |
DATABASE_PASSWORD |
Database password | - |
DATABASE_NAME |
Database name | gps |
RAILS_MAX_THREADS |
Connection pool size | 5 |
REDIS_URL |
Redis URL for Sidekiq | redis://localhost:6379 |
All device endpoints accept POST requests with raw device data:
# Globalstar STU message
curl -X POST https://your-server/globalstar/stu \
-H "Content-Type: application/xml" \
-d @stu_message.xml
# GL200 location message
curl -X POST https://your-server/gl200/msg \
-d "message=+RESP:GTFRI,..."# Check if ESN is inside/outside fences
curl "https://your-server/geofence/check?esn=DEVICE_ESN"# Decode GL200 message without storing
curl "https://your-server/v1/device/gl200/decode?msg=+RESP:GTFRI,..."
# Decode SPOT message
curl "https://your-server/v1/device/spot/decode" \
-H "Content-Type: application/xml" \
-d @spot_message.xmlbin/rails testbin/rails test test/decoders/globalstar_decoder_test.rb
bin/rails test test/services/geofence_check_service_test.rbCOVERAGE=true bin/rails test| Directory | Coverage |
|---|---|
test/decoders/ |
Protocol parsing, coordinate conversion |
test/services/ |
Geofence algorithm, XML building |
test/repositories/ |
Data access patterns |
test/models/ |
ActiveRecord validations |
test/controllers/ |
HTTP endpoints |
# config/puma.rb is pre-configured
bundle exec puma -C config/puma.rbPuma Service (/etc/systemd/system/puma.service):
[Unit]
Description=Puma HTTP Server
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/gps-catcher
ExecStart=/usr/local/bin/bundle exec puma -C config/puma.rb
Restart=always
[Install]
WantedBy=multi-user.targetSidekiq Service (/etc/systemd/system/sidekiq.service):
[Unit]
Description=Sidekiq
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/gps-catcher
ExecStart=/usr/local/bin/bundle exec sidekiq
Restart=always
[Install]
WantedBy=multi-user.targetupstream puma {
server unix:/var/www/gps-catcher/tmp/sockets/puma.sock;
}
server {
listen 80;
server_name gps.example.com;
location / {
proxy_pass http://puma;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}GitHub Actions workflow runs on every push:
# .github/workflows/ci.yml
- Tests against PostgreSQL 15 with PostGIS 3.3
- Ruby 3.2.9 with bundler caching
- Security audit with bundler-auditDevice → Controller → Worker → Decoder → ParsedMessage → PostgreSQL
↓
GeofenceCheckService
↓
FenceAlertWorker → Webhook
- Device sends location data via HTTP POST
- Controller validates request, queues for processing
- Worker (Sidekiq) processes message asynchronously
- Decoder parses protocol-specific format into unified structure
- ParsedMessage deduplicates and stores in PostgreSQL
- GeofenceCheckService evaluates position against active fences
- FenceAlertWorker sends webhook on state transitions
The geofence system detects when devices enter or exit defined boundaries:
- Query active geofences for the device ESN
- For each fence, check if coordinates are inside (PostGIS
ST_Contains) - Compare current state with previous state
- On state change (enter/exit), create alert and trigger webhook
# Example: Check if point is inside geofence
geofence.contains?(latitude, longitude)
# Uses PostGIS: ST_Contains(boundary, ST_Point(lng, lat))Messages are deduplicated using a composite hash:
message_id = Digest::MD5.hexdigest("#{esn}#{source}#{value}#{occurred_at}")Duplicate messages return the existing record instead of creating duplicates.
- Fork the repository
- Create a feature branch (
git checkout -b feature/new-device) - Write tests first
- Implement the feature
- Ensure all tests pass (
bin/rails test) - Submit a pull request
Proprietary - All rights reserved.