├── .github └── workflows │ └── container.yml ├── Dockerfile ├── LICENSE ├── README.md ├── chasemapper.service ├── chasemapper ├── __init__.py ├── atmosphere.py ├── bearings.py ├── config.py ├── earthmaths.py ├── geometry.py ├── gps.py ├── gpsd.py ├── habitat.py ├── listeners.py ├── logger.py ├── logread.py ├── predictor.py ├── sondehub.py └── tawhiri.py ├── doc ├── bearings.jpg ├── chasemapper.jpg ├── mark_recovered.png └── payload_options.png ├── gfs └── gfs_data_goes_here.txt ├── horusmapper.cfg.example ├── horusmapper.py ├── log_files └── chase_logs_go_here.txt ├── log_parse.py ├── log_playback.py ├── requirements.txt ├── static ├── css │ ├── Leaflet.PolylineMeasure.css │ ├── bootstrap.min.css │ ├── chasemapper.css │ ├── easy-button.css │ ├── font-awesome.min.css │ ├── images │ │ ├── layers-2x.png │ │ ├── layers.png │ │ ├── marker-icon-2x.png │ │ ├── marker-icon.png │ │ ├── marker-shadow.png │ │ ├── ui-icons_444444_256x240.png │ │ ├── ui-icons_555555_256x240.png │ │ └── ui-icons_777777_256x240.png │ ├── jquery-ui.css │ ├── leaflet-control-topcenter.css │ ├── leaflet-routing-machine.css │ ├── leaflet-sidebar.min.css │ ├── leaflet.css │ └── tabulator_simple.css ├── fonts │ └── fontawesome-webfont.woff ├── img │ ├── antenna-green.png │ ├── balloon-blue.png │ ├── balloon-green.png │ ├── balloon-pop.png │ ├── balloon-purple.png │ ├── balloon-red.png │ ├── car-blue-flip.png │ ├── car-blue.png │ ├── car-green-flip.png │ ├── car-green.png │ ├── car-red-flip.png │ ├── car-red.png │ ├── car-yellow-flip.png │ ├── car-yellow.png │ ├── parachute-blue.png │ ├── parachute-green.png │ ├── parachute-purple.png │ ├── parachute-red.png │ ├── payload-blue.png │ ├── payload-green.png │ ├── payload-purple.png │ ├── payload-red.png │ ├── target-blue.png │ ├── target-green.png │ ├── target-purple.png │ └── target-red.png └── js │ ├── Leaflet.Control.Custom.js │ ├── Leaflet.PolylineMeasure.js │ ├── balloon.js │ ├── bearings.js │ ├── car.js │ ├── d3.v3.min.js │ ├── easy-button.js │ ├── habitat.js │ ├── jquery-3.3.1.min.js │ ├── jquery-ui.min.js │ ├── leaflet-control-topcenter.js │ ├── leaflet-routing-machine.min.js │ ├── leaflet-sidebar.min.js │ ├── leaflet.js │ ├── leaflet.rotatedMarker.js │ ├── micropolar-v0.2.2.js │ ├── paho-mqtt.js │ ├── predictions.js │ ├── settings.js │ ├── socket.io.min.js │ ├── sondehub.js │ ├── tables.js │ ├── tabulator.min.js │ └── utils.js ├── templates ├── bearing_entry.html └── index.html └── utils └── bearing_o_clock.py /.github/workflows/container.yml: -------------------------------------------------------------------------------- 1 | name: Container image 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | tags: 8 | - 'v*' 9 | pull_request: 10 | workflow_dispatch: 11 | schedule: 12 | - cron: '15 14 * * 5' 13 | 14 | jobs: 15 | build: 16 | name: Build container image 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | platform: [linux/amd64, linux/386, linux/arm64, linux/arm/v6, linux/arm/v7] 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Declare platform 26 | run: | 27 | platform=${{ matrix.platform }} 28 | echo "PLATFORM=${platform//\//-}" >> $GITHUB_ENV 29 | 30 | - name: Cache wheels 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ github.workspace }}/wheels 34 | key: wheels-${{ env.PLATFORM }}-${{ github.run_id }} 35 | restore-keys: | 36 | wheels-${{ env.PLATFORM }}- 37 | 38 | - name: List wheels 39 | run: ls -lR ${{ github.workspace }}/wheels || true 40 | 41 | - name: Setup QEMU 42 | uses: docker/setup-qemu-action@v3 43 | 44 | - name: Setup Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Login to GitHub Container Registry 48 | uses: docker/login-action@v3 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.repository_owner }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: Calculate container metadata 55 | id: meta 56 | uses: docker/metadata-action@v5 57 | with: 58 | images: ghcr.io/${{ github.repository }} 59 | 60 | - name: Build stage 61 | id: build 62 | uses: docker/build-push-action@v6 63 | with: 64 | context: . 65 | platforms: ${{ matrix.platform }} 66 | provenance: false 67 | labels: ${{ steps.meta.outputs.labels }} 68 | outputs: type=local,dest=/tmp/build-output 69 | cache-to: type=local,dest=/tmp/build-cache,mode=max 70 | target: build 71 | 72 | - name: Final stage and push by digest 73 | id: final 74 | uses: docker/build-push-action@v6 75 | with: 76 | context: . 77 | platforms: ${{ matrix.platform }} 78 | provenance: false 79 | labels: ${{ steps.meta.outputs.labels }} 80 | outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' && 'true' || 'false' }} 81 | cache-from: type=local,src=/tmp/build-cache 82 | 83 | - name: Export digest 84 | if: ${{ github.event_name != 'pull_request' }} 85 | run: | 86 | mkdir -p /tmp/digests 87 | digest="${{ steps.final.outputs.digest }}" 88 | touch "/tmp/digests/${digest#sha256:}" 89 | 90 | - name: Upload digest 91 | if: ${{ github.event_name != 'pull_request' }} 92 | uses: actions/upload-artifact@v4 93 | with: 94 | name: digests-${{ env.PLATFORM }} 95 | path: /tmp/digests/* 96 | if-no-files-found: error 97 | retention-days: 1 98 | 99 | - name: Move and list wheels 100 | run: | 101 | mv -f /tmp/build-output/root/.cache/pip/wheels ${{ github.workspace }}/ || true 102 | ls -lR ${{ github.workspace }}/wheels || true 103 | 104 | merge: 105 | name: Publish multi-platform image 106 | if: ${{ github.event_name != 'pull_request' }} 107 | runs-on: ubuntu-latest 108 | needs: [build] 109 | steps: 110 | - name: Download digests 111 | uses: actions/download-artifact@v4 112 | with: 113 | path: /tmp/digests 114 | pattern: digests-* 115 | merge-multiple: true 116 | 117 | - name: Calculate container metadata 118 | id: meta 119 | uses: docker/metadata-action@v5 120 | with: 121 | images: ghcr.io/${{ github.repository }} 122 | 123 | - name: Setup Buildx 124 | uses: docker/setup-buildx-action@v2 125 | 126 | - name: Login to GitHub Container Registry 127 | uses: docker/login-action@v2 128 | with: 129 | registry: ghcr.io 130 | username: ${{ github.repository_owner }} 131 | password: ${{ secrets.GITHUB_TOKEN }} 132 | 133 | - name: Create and push manifest 134 | working-directory: /tmp/digests 135 | run: | 136 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 137 | $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) 138 | 139 | - name: Inspect container image 140 | run: | 141 | docker buildx imagetools inspect ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.version }} 142 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ------------------- 2 | # The build container 3 | # ------------------- 4 | FROM python:3.11-bookworm AS build 5 | 6 | # Upgrade base packages. 7 | RUN apt-get update && \ 8 | apt-get upgrade -y && \ 9 | apt-get install -y \ 10 | cmake \ 11 | libgeos-dev \ 12 | libatlas-base-dev && \ 13 | rm -rf /var/lib/apt/lists/* 14 | 15 | # Copy in existing wheels. 16 | COPY wheel[s]/ /root/.cache/pip/wheels/ 17 | 18 | # No wheels might exist. 19 | RUN mkdir -p /root/.cache/pip/wheels/ 20 | 21 | # Copy in requirements.txt. 22 | COPY requirements.txt /root/chasemapper/requirements.txt 23 | 24 | # Install Python packages. 25 | RUN pip3 install --user --break-system-packages --no-warn-script-location \ 26 | --ignore-installed -r /root/chasemapper/requirements.txt 27 | 28 | # Copy in chasemapper. 29 | COPY . /root/chasemapper 30 | 31 | # Download and install cusf_predictor_wrapper, and build predictor binary. 32 | ADD https://github.com/darksidelemm/cusf_predictor_wrapper/archive/master.zip \ 33 | /root/cusf_predictor_wrapper-master.zip 34 | RUN unzip /root/cusf_predictor_wrapper-master.zip -d /root && \ 35 | rm /root/cusf_predictor_wrapper-master.zip && \ 36 | mkdir -p /root/cusf_predictor_wrapper-master/src/build && \ 37 | cd /root/cusf_predictor_wrapper-master/src/build && \ 38 | cmake .. && \ 39 | make 40 | 41 | # ------------------------- 42 | # The application container 43 | # ------------------------- 44 | FROM python:3.11-bookworm 45 | EXPOSE 5001/tcp 46 | 47 | # Upgrade base packages and install application dependencies. 48 | RUN apt-get update && \ 49 | apt-get upgrade -y && \ 50 | apt-get install -y \ 51 | libeccodes0 \ 52 | libgeos-c1v5 \ 53 | libglib2.0-0 \ 54 | libatlas3-base \ 55 | libgfortran5 \ 56 | tini && \ 57 | rm -rf /var/lib/apt/lists/* 58 | 59 | # Copy any additional Python packages from the build container. 60 | COPY --from=build /root/.local /root/.local 61 | 62 | # Copy predictor binary from the build container. 63 | COPY --from=build /root/cusf_predictor_wrapper-master/src/build/pred \ 64 | /opt/chasemapper/ 65 | 66 | # Copy in chasemapper. 67 | COPY . /opt/chasemapper 68 | 69 | # Set the working directory. 70 | WORKDIR /opt/chasemapper 71 | 72 | # Ensure scripts from Python packages are in PATH. 73 | ENV PATH=/root/.local/bin:$PATH 74 | 75 | # Use tini as init. 76 | ENTRYPOINT ["/usr/bin/tini", "--"] 77 | 78 | # Run horusmapper.py. 79 | CMD ["python3", "/opt/chasemapper/horusmapper.py"] 80 | -------------------------------------------------------------------------------- /chasemapper.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=chasemapper 3 | After=syslog.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/python3 /home/pi/chasemapper/horusmapper.py 7 | Restart=always 8 | RestartSec=3 9 | WorkingDirectory=/home/pi/chasemapper/ 10 | User=pi 11 | SyslogIdentifier=chasemapper 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /chasemapper/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # 5 | # Copyright (C) 2018 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | 9 | # Now using Semantic Versioning (https://semver.org/) MAJOR.MINOR.PATCH 10 | 11 | __version__ = "1.5.4" 12 | -------------------------------------------------------------------------------- /chasemapper/atmosphere.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Atmosphere / Descent Rate Modelling 4 | # 5 | # Copyright (C) 2018 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import math 9 | 10 | 11 | def getDensity(altitude): 12 | """ 13 | Calculate the atmospheric density for a given altitude in metres. 14 | This is a direct port of the oziplotter Atmosphere class 15 | """ 16 | 17 | # Constants 18 | airMolWeight = 28.9644 # Molecular weight of air 19 | densitySL = 1.225 # Density at sea level [kg/m3] 20 | pressureSL = 101325 # Pressure at sea level [Pa] 21 | temperatureSL = 288.15 # Temperature at sea level [deg K] 22 | gamma = 1.4 23 | gravity = 9.80665 # Acceleration of gravity [m/s2] 24 | tempGrad = -0.0065 # Temperature gradient [deg K/m] 25 | RGas = 8.31432 # Gas constant [kg/Mol/K] 26 | R = 287.053 27 | deltaTemperature = 0.0 28 | 29 | # Lookup Tables 30 | altitudes = [0, 11000, 20000, 32000, 47000, 51000, 71000, 84852] 31 | pressureRels = [ 32 | 1, 33 | 2.23361105092158e-1, 34 | 5.403295010784876e-2, 35 | 8.566678359291667e-3, 36 | 1.0945601337771144e-3, 37 | 6.606353132858367e-4, 38 | 3.904683373343926e-5, 39 | 3.6850095235747942e-6, 40 | ] 41 | temperatures = [288.15, 216.65, 216.65, 228.65, 270.65, 270.65, 214.65, 186.946] 42 | tempGrads = [-6.5, 0, 1, 2.8, 0, -2.8, -2, 0] 43 | gMR = gravity * airMolWeight / RGas 44 | 45 | # Pick a region to work in 46 | i = 0 47 | if altitude > 0: 48 | while altitude > altitudes[i + 1]: 49 | i = i + 1 50 | 51 | # Lookup based on region 52 | baseTemp = temperatures[i] 53 | tempGrad = tempGrads[i] / 1000.0 54 | pressureRelBase = pressureRels[i] 55 | deltaAltitude = altitude - altitudes[i] 56 | temperature = baseTemp + tempGrad * deltaAltitude 57 | 58 | # Calculate relative pressure 59 | if math.fabs(tempGrad) < 1e-10: 60 | pressureRel = pressureRelBase * math.exp( 61 | -1 * gMR * deltaAltitude / 1000.0 / baseTemp 62 | ) 63 | else: 64 | pressureRel = pressureRelBase * math.pow( 65 | baseTemp / temperature, gMR / tempGrad / 1000.0 66 | ) 67 | 68 | # Add temperature offset 69 | temperature = temperature + deltaTemperature 70 | 71 | # Finally, work out the density... 72 | speedOfSound = math.sqrt(gamma * R * temperature) 73 | pressure = pressureRel * pressureSL 74 | density = densitySL * pressureRel * temperatureSL / temperature 75 | 76 | return density 77 | 78 | 79 | def seaLevelDescentRate(descent_rate, altitude): 80 | """ Calculate the descent rate at sea level, for a given descent rate at altitude """ 81 | 82 | rho = getDensity(altitude) 83 | return math.sqrt((rho / 1.225) * math.pow(descent_rate, 2)) 84 | 85 | 86 | def time_to_landing( 87 | current_altitude, current_descent_rate=-5.0, ground_asl=0.0, step_size=1 88 | ): 89 | """ Calculate an estimated time to landing (in seconds) of a payload, based on its current altitude and descent rate """ 90 | 91 | # A few checks on the input data. 92 | if current_descent_rate > 0.0: 93 | # If we are still ascending, return none. 94 | return None 95 | 96 | if current_altitude <= ground_asl: 97 | # If the current altitude is *below* ground level, we have landed. 98 | return 0 99 | 100 | # Calculate the sea level descent rate. 101 | _desc_rate = math.fabs(seaLevelDescentRate(current_descent_rate, current_altitude)) 102 | _drag_coeff = ( 103 | _desc_rate * 1.106797 104 | ) # Multiply descent rate by square root of sea-level air density (1.225). 105 | 106 | _alt = current_altitude 107 | _start_time = 0 108 | # Now step through the flight in second steps. 109 | # Once the altitude is below our ground level, stop, and return the elapsed time. 110 | while _alt >= ground_asl: 111 | _alt += step_size * -1 * (_drag_coeff / math.sqrt(getDensity(_alt))) 112 | _start_time += step_size 113 | 114 | return _start_time 115 | 116 | 117 | if __name__ == "__main__": 118 | # Test Cases 119 | _altitudes = [1000, 10000, 30000, 1000, 10000, 30000] 120 | _rates = [-10.0, -10.0, -10.0, -30.0, -30.0, -30.0] 121 | 122 | for i in range(len(_altitudes)): 123 | print("Altitude: %d m, Rate: %.2f m/s" % (_altitudes[i], _rates[i])) 124 | print("Density: %.5f" % getDensity(_altitudes[i])) 125 | print( 126 | "Sea Level Descent Rate: %.2f m/s" 127 | % seaLevelDescentRate(_rates[i], _altitudes[i]) 128 | ) 129 | _landing = time_to_landing(_altitudes[i], _rates[i]) 130 | _landing_min = _landing // 60 131 | _landing_sec = _landing % 60 132 | print( 133 | "Time to landing: %d sec, %s:%s " % (_landing, _landing_min, _landing_sec) 134 | ) 135 | print("") 136 | -------------------------------------------------------------------------------- /chasemapper/bearings.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Bearing Handler 4 | # 5 | # Copyright (C) 2019 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | # 9 | # TODO: 10 | # [ ] Store a rolling buffer of car positions, to enable fusing of 'old' bearings with previous car positions. 11 | # 12 | 13 | import logging 14 | import time 15 | 16 | from threading import Lock 17 | 18 | 19 | class Bearings(object): 20 | def __init__( 21 | self, socketio_instance=None, max_bearings=300, max_bearing_age=30 * 60 22 | ): 23 | 24 | # Reference to the socketio instance which will be used to pass data onto web clients 25 | self.sio = socketio_instance 26 | self.max_bearings = max_bearings 27 | self.max_age = max_bearing_age 28 | 29 | # Bearing store 30 | # Bearings are stored as a dict, with the key being the timestamp (time.time()) 31 | # when the bearing arrived in the system. 32 | # Each record contains: 33 | # { 34 | # 'timestamp': time.time(), # A copy of the arrival timestamp 35 | # 'src_timestamp': time.time(), # Optional timestamp provided by the source 36 | # 'lat': 0.0, # Bearing start latitude 37 | # 'lon': 0.0, # Bearing start longitude 38 | # 'speed': 0.0, # Car speed at time of bearing arrival 39 | # 'heading': 0.0, # Car heading at time of bearing arrival 40 | # 'heading_valid': False, # Indicates if the car heading is considered valid (i.e. was captured while moving) 41 | # 'raw_bearing': 0.0, # Raw bearing value 42 | # 'true_bearing': 0.0, # Bearing converted to degrees true 43 | # 'confidence': 0.0, # Arbitrary confidence value - TBD what ranges this will take. 44 | # } 45 | self.bearings = {} 46 | 47 | self.bearing_sources = [] 48 | 49 | self.bearing_lock = Lock() 50 | 51 | # Internal record of the chase car position, which is updated with incoming GPS data. 52 | # If incoming bearings do not contain lat/lon information, we fuse them with this position, 53 | # as long as it is valid. 54 | self.current_car_position = { 55 | "timestamp": None, # System timestamp from time.time() 56 | "datetime": None, # Datetime object from data source. 57 | "lat": 0.0, 58 | "lon": 0.0, 59 | "alt": 0.0, 60 | "heading": 0.0, 61 | "speed": 0.0, 62 | "heading_valid": False, 63 | "position_valid": False, 64 | } 65 | 66 | def update_car_position(self, position): 67 | """ Accept a new car position, in the form of a dictionary produced by a GenericTrack object 68 | (refer geometry.py). This is of the form: 69 | 70 | _state = { 71 | 'time' : _latest_position[0], # Datetime object, with timezone info 72 | 'lat' : _latest_position[1], 73 | 'lon' : _latest_position[2], 74 | 'alt' : _latest_position[3], 75 | 'ascent_rate': self.ascent_rate, # Not used here 76 | 'is_descending': self.is_descending, # Not used here 77 | 'landing_rate': self.landing_rate, # Not used here 78 | 'heading': self.heading, # Movement heading in degrees true 79 | 'heading_valid': self.heading_valid, # Indicates if heading was calculated when the car was moving 80 | 'speed': self.speed # Speed in m/s 81 | } 82 | 83 | """ 84 | 85 | # Attempt to build up new chase car position dict 86 | try: 87 | _car_pos = { 88 | "timestamp": time.time(), 89 | "datetime": position["time"], 90 | "lat": position["lat"], 91 | "lon": position["lon"], 92 | "alt": position["alt"], 93 | "heading": self.current_car_position["heading"], 94 | "heading_valid": position["heading_valid"], 95 | "speed": position["speed"], 96 | "position_valid": True, # Should we be taking this from upstream somewhere? 97 | } 98 | 99 | # Only gate through the heading if it is valid. 100 | if position["heading_valid"]: 101 | _car_pos["heading"] = position["heading"] 102 | 103 | # Mark position as invalid if we have zero lat/lon values 104 | if (_car_pos["lat"] == 0.0) and (_car_pos["lon"] == 0.0): 105 | _car_pos["position_valid"] = False 106 | 107 | # Replace car position state with new data 108 | self.current_car_position = _car_pos 109 | 110 | except Exception as e: 111 | logging.error("Bearing Handler - Invalid car position: %s" % str(e)) 112 | 113 | def add_bearing(self, bearing): 114 | """ Add a bearing into the store, fusing incoming data with the latest car position as required. 115 | 116 | bearing must be a dictionary with the following keys: 117 | 118 | # Absolute bearings - lat/lon and true bearing provided 119 | {'type': 'BEARING', 'bearing_type': 'absolute', 'latitude': latitude, 'longitude': longitude, 'bearing': bearing} 120 | 121 | # Relative bearings - only relative bearing is provided. 122 | {'type': 'BEARING', 'bearing_type': 'relative', 'bearing': bearing} 123 | 124 | The following optional fields can be provided: 125 | 'source': An identifier for the source of the bearings, i.e. 'kerberos-sdr', 'yagi-1' 126 | 'timestamp': A timestamp of the bearing provided by the source. 127 | 'confidence': A confidence value for the bearing, from 0 to [MAX VALUE ??] 128 | 'power': A reading of signal power 129 | 'raw_bearing_angles': A list of angles, associated with... 130 | 'raw_doa': A list of TDOA result values, for each of the provided angles. 131 | 132 | """ 133 | 134 | # Should never be passed a non-bearing dict, but check anyway, 135 | if bearing["type"] != "BEARING": 136 | return 137 | 138 | _arrival_time = time.time() 139 | 140 | # Get a copy of the current car position, in case it is updated 141 | _current_car_pos = self.current_car_position.copy() 142 | 143 | if "timestamp" in bearing: 144 | _src_timestamp = bearing["timestamp"] 145 | else: 146 | _src_timestamp = _arrival_time 147 | 148 | if "confidence" in bearing: 149 | _confidence = bearing["confidence"] 150 | else: 151 | _confidence = 100.0 152 | 153 | if "power" in bearing: 154 | _power = bearing["power"] 155 | else: 156 | _power = -1 157 | 158 | if "source" in bearing: 159 | _source = bearing["source"] 160 | else: 161 | _source = "unknown" 162 | 163 | try: 164 | if bearing["bearing_type"] == "relative": 165 | # Relative bearing - we need to fuse this with the current car position. 166 | 167 | # Temporary hack for KerberosSDR bearings, which are reflected across N/S 168 | if _source == "krakensdr_doa": 169 | bearing["bearing"] = 360.0 - bearing["bearing"] 170 | bearing["raw_doa"] = bearing["raw_doa"][::-1] 171 | 172 | 173 | _new_bearing = { 174 | "timestamp": _arrival_time, 175 | "src_timestamp": _src_timestamp, 176 | "lat": _current_car_pos["lat"], 177 | "lon": _current_car_pos["lon"], 178 | "speed": _current_car_pos["speed"], 179 | "heading": _current_car_pos["heading"], 180 | "heading_valid": _current_car_pos["heading_valid"], 181 | "raw_bearing": bearing["bearing"], 182 | "true_bearing": (bearing["bearing"] + _current_car_pos["heading"]) 183 | % 360.0, 184 | "confidence": _confidence, 185 | "power": _power, 186 | "source": _source, 187 | } 188 | 189 | # Allow override of the heading valid calculations if a hearing_override field is supplied 190 | if "heading_override" in bearing: 191 | _new_bearing["heading_valid"] = bearing["heading_override"] 192 | 193 | elif bearing["bearing_type"] == "absolute": 194 | # Absolute bearing - use the provided data as-is 195 | 196 | _new_bearing = { 197 | "timestamp": _arrival_time, 198 | "src_timestamp": _src_timestamp, 199 | "lat": bearing["latitude"], 200 | "lon": bearing["longitude"], 201 | "speed": 0.0, 202 | "heading": 0.0, 203 | "heading_valid": True, 204 | "raw_bearing": bearing["bearing"], 205 | "true_bearing": bearing["bearing"], 206 | "confidence": _confidence, 207 | "power": _power, 208 | "source": _source, 209 | } 210 | 211 | else: 212 | return 213 | 214 | except Exception as e: 215 | logging.error("Bearing Handler - Invalid input bearing: %s" % str(e)) 216 | return 217 | 218 | # We now have our bearing - now we need to store it 219 | self.bearing_lock.acquire() 220 | 221 | self.bearings["%.4f" % _arrival_time] = _new_bearing 222 | 223 | if _source not in self.bearing_sources: 224 | self.bearing_sources.append(_source) 225 | logging.info(f"Bearing Handler - New source of bearings: {_source}") 226 | 227 | # Now we need to do a clean-up of our bearing list. 228 | # At this point, we should always have at least 2 bearings in our store 229 | if len(self.bearings) == 1: 230 | self.bearing_lock.release() 231 | return 232 | 233 | # Keep a list of what we remove, so we can pass it on to the web clients. 234 | _removal_list = [] 235 | 236 | # Grab the list of bearing entries, and sort them by time 237 | _bearing_list = list(self.bearings.keys()) 238 | _bearing_list.sort() 239 | 240 | # First remove any excess entries - we only get one bearing at a time, so we can do this simply: 241 | if len(_bearing_list) > self.max_bearings: 242 | self.bearings.pop(_bearing_list[0]) 243 | _removal_list.append(_bearing_list[0]) 244 | _bearing_list = _bearing_list[1:] 245 | 246 | # Now we need to remove *old* bearings. 247 | _min_time = time.time() - self.max_age 248 | 249 | _curr_time = float(_bearing_list[0]) 250 | 251 | while _curr_time < _min_time: 252 | # Current entry is older than our limit, remove it. 253 | self.bearings.pop(_bearing_list[0]) 254 | _removal_list.append(_bearing_list[0]) 255 | _bearing_list = _bearing_list[1:] 256 | 257 | # Advance to the next entry in the list. 258 | _curr_time = float(_bearing_list[0]) 259 | 260 | self.bearing_lock.release() 261 | 262 | # Add in any raw DOA data we may have been given. 263 | if "raw_bearing_angles" in bearing: 264 | _new_bearing["raw_bearing_angles"] = bearing["raw_bearing_angles"] 265 | _new_bearing["raw_doa"] = bearing["raw_doa"] 266 | 267 | # Now we need to update the web clients on what has changed. 268 | _client_update = { 269 | "add": _new_bearing, 270 | "remove": _removal_list, 271 | "server_timestamp": time.time(), 272 | } 273 | 274 | self.sio.emit("bearing_change", _client_update, namespace="/chasemapper") 275 | 276 | def flush(self): 277 | """ Clear the bearing store """ 278 | self.bearing_lock.acquire() 279 | self.bearings = {} 280 | self.bearing_lock.release() 281 | -------------------------------------------------------------------------------- /chasemapper/config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper - Config Reader 4 | # 5 | # Copyright (C) 2018 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import logging 9 | import os 10 | 11 | try: 12 | # Python 2 13 | from ConfigParser import RawConfigParser 14 | except ImportError: 15 | # Python 3 16 | from configparser import RawConfigParser 17 | 18 | 19 | default_config = { 20 | # Start location for the map (until either a chase car position, or balloon position is available.) 21 | "default_lat": -34.9, 22 | "default_lon": 138.6, 23 | "default_alt": 0, 24 | "payload_max_age": 180, 25 | "thunderforest_api_key": "none", 26 | "stadia_api_key": "none", 27 | # Predictor settings 28 | "pred_enabled": True, # Enable running and display of predicted flight paths. 29 | "offline_predictions": False, # Use an offline GFS model and predictor instead of Tawhiri. 30 | # Default prediction settings (actual values will be used once the flight is underway) 31 | "pred_model": "Disabled", 32 | "pred_desc_rate": 6.0, 33 | "pred_burst": 28000, 34 | "show_abort": True, # Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) 35 | "pred_update_rate": 15, # Update predictor every 15 seconds. 36 | # Range Rings 37 | "range_rings_enabled": False, 38 | "range_ring_quantity": 5, 39 | "range_ring_spacing": 1000, 40 | "range_ring_weight": 1.5, 41 | "range_ring_color": "red", 42 | "range_ring_custom_color": "#FF0000", 43 | # Chase Car Speedometer 44 | "chase_car_speed": True, 45 | # Bearing processing 46 | "max_bearings": 300, 47 | "max_bearing_age": 30 * 60, 48 | "car_speed_gate": 10, 49 | "bearing_length": 10, 50 | "bearing_weight": 1.0, 51 | "bearing_color": "black", 52 | "bearing_custom_color": "#FF0000", 53 | # History 54 | "reload_last_position": False, 55 | } 56 | 57 | 58 | def parse_config_file(filename): 59 | """ Parse a Configuration File """ 60 | 61 | chase_config = default_config.copy() 62 | 63 | config = RawConfigParser() 64 | config.read(filename) 65 | 66 | # Map Defaults 67 | chase_config["flask_host"] = config.get("map", "flask_host") 68 | chase_config["flask_port"] = config.getint("map", "flask_port") 69 | chase_config["default_lat"] = config.getfloat("map", "default_lat") 70 | chase_config["default_lon"] = config.getfloat("map", "default_lon") 71 | chase_config["payload_max_age"] = config.getint("map", "payload_max_age") 72 | chase_config["thunderforest_api_key"] = config.get("map", "thunderforest_api_key") 73 | 74 | # GPSD Settings 75 | chase_config["car_gpsd_host"] = config.get("gpsd", "gpsd_host") 76 | chase_config["car_gpsd_port"] = config.getint("gpsd", "gpsd_port") 77 | 78 | # Serial GPS Settings 79 | chase_config["car_serial_port"] = config.get("gps_serial", "gps_port") 80 | chase_config["car_serial_baud"] = config.getint("gps_serial", "gps_baud") 81 | 82 | # Habitat Settings 83 | chase_config["habitat_upload_enabled"] = config.getboolean( 84 | "habitat", "habitat_upload_enabled" 85 | ) 86 | chase_config["habitat_call"] = config.get("habitat", "habitat_call") 87 | chase_config["habitat_update_rate"] = config.getint( 88 | "habitat", "habitat_update_rate" 89 | ) 90 | 91 | # Predictor 92 | chase_config["pred_enabled"] = config.getboolean("predictor", "predictor_enabled") 93 | chase_config["offline_predictions"] = config.getboolean( 94 | "predictor", "offline_predictions" 95 | ) 96 | chase_config["pred_burst"] = config.getfloat("predictor", "default_burst") 97 | chase_config["pred_desc_rate"] = config.getfloat( 98 | "predictor", "default_descent_rate" 99 | ) 100 | chase_config["pred_binary"] = config.get("predictor", "pred_binary") 101 | chase_config["pred_gfs_directory"] = config.get("predictor", "gfs_directory") 102 | chase_config["pred_model_download"] = config.get("predictor", "model_download") 103 | 104 | # Range Ring Settings 105 | chase_config["range_rings_enabled"] = config.getboolean( 106 | "range_rings", "range_rings_enabled" 107 | ) 108 | chase_config["range_ring_quantity"] = config.getint( 109 | "range_rings", "range_ring_quantity" 110 | ) 111 | chase_config["range_ring_spacing"] = config.getint( 112 | "range_rings", "range_ring_spacing" 113 | ) 114 | chase_config["range_ring_weight"] = config.getfloat( 115 | "range_rings", "range_ring_weight" 116 | ) 117 | chase_config["range_ring_color"] = config.get("range_rings", "range_ring_color") 118 | chase_config["range_ring_custom_color"] = config.get( 119 | "range_rings", "range_ring_custom_color" 120 | ) 121 | 122 | # Bearing Processing 123 | chase_config["max_bearings"] = config.getint("bearings", "max_bearings") 124 | chase_config["max_bearing_age"] = ( 125 | config.getint("bearings", "max_bearing_age") * 60 126 | ) # Convert to seconds 127 | if chase_config["max_bearing_age"] < 60: 128 | chase_config[ 129 | "max_bearing_age" 130 | ] = 60 # Make sure this number is something sane, otherwise things will break 131 | chase_config["car_speed_gate"] = ( 132 | config.getfloat("bearings", "car_speed_gate") / 3.6 133 | ) # Convert to m/s 134 | chase_config["bearing_length"] = config.getfloat("bearings", "bearing_length") 135 | chase_config["bearing_weight"] = config.getfloat("bearings", "bearing_weight") 136 | chase_config["bearing_color"] = config.get("bearings", "bearing_color") 137 | chase_config["bearing_custom_color"] = config.get( 138 | "bearings", "bearing_custom_color" 139 | ) 140 | 141 | # Offline Map Settings 142 | chase_config["tile_server_enabled"] = config.getboolean( 143 | "offline_maps", "tile_server_enabled" 144 | ) 145 | chase_config["tile_server_path"] = config.get("offline_maps", "tile_server_path") 146 | 147 | # Determine valid offline map layers. 148 | chase_config["offline_tile_layers"] = [] 149 | if chase_config["tile_server_enabled"]: 150 | for _dir in os.listdir(chase_config["tile_server_path"]): 151 | if os.path.isdir(os.path.join(chase_config["tile_server_path"], _dir)): 152 | chase_config["offline_tile_layers"].append(_dir) 153 | logging.info("Found Map Layers: %s" % str(chase_config["offline_tile_layers"])) 154 | 155 | try: 156 | chase_config["chase_car_speed"] = config.getboolean("speedo", "chase_car_speed") 157 | except: 158 | logging.info("Missing Chase Car Speedo Setting, using default (disabled)") 159 | chase_config["chase_car_speed"] = False 160 | 161 | try: 162 | chase_config["default_alt"] = config.getfloat("map", "default_alt") 163 | except: 164 | logging.info("Missing default_alt setting, using default (0m)") 165 | chase_config["default_alt"] = 0 166 | 167 | try: 168 | chase_config["stadia_api_key"] = config.get("map", "stadia_api_key") 169 | except: 170 | logging.info("Missing Stadia API Key setting, using default (none)") 171 | chase_config["stadia_api_key"] = "none" 172 | 173 | try: 174 | chase_config["turn_rate_threshold"] = config.getfloat("bearings", "turn_rate_threshold") 175 | except: 176 | logging.info("Missing turn rate gate setting, using default (4m/s)") 177 | chase_config["turn_rate_threshold"] = 4.0 178 | 179 | try: 180 | chase_config["ascent_rate_averaging"] = config.getint("predictor", "ascent_rate_averaging") 181 | except: 182 | logging.info("Missing ascent_rate_averaging setting, using default (10)") 183 | chase_config["ascent_rate_averaging"] = 10 184 | 185 | # Telemetry Source Profiles 186 | 187 | _profile_count = config.getint("profile_selection", "profile_count") 188 | _default_profile = config.getint("profile_selection", "default_profile") 189 | 190 | chase_config["selected_profile"] = "" 191 | chase_config["profiles"] = {} 192 | 193 | # Unit Selection 194 | 195 | chase_config["unitselection"] = config.get( 196 | "units", "unitselection", fallback="metric" 197 | ) 198 | if chase_config["unitselection"] != "imperial": 199 | chase_config[ 200 | "unitselection" 201 | ] = "metric" # unless imperial is explicitly requested do metric 202 | chase_config["switch_miles_feet"] = config.get( 203 | "units", "switch_miles_feet", fallback="400" 204 | ) 205 | 206 | for i in range(1, _profile_count + 1): 207 | _profile_section = "profile_%d" % i 208 | try: 209 | _profile_name = config.get(_profile_section, "profile_name") 210 | _profile_telem_source_type = config.get( 211 | _profile_section, "telemetry_source_type" 212 | ) 213 | _profile_telem_source_port = config.getint( 214 | _profile_section, "telemetry_source_port" 215 | ) 216 | _profile_car_source_type = config.get(_profile_section, "car_source_type") 217 | _profile_car_source_port = config.getint( 218 | _profile_section, "car_source_port" 219 | ) 220 | 221 | _profile_online_tracker = config.get(_profile_section, "online_tracker") 222 | 223 | chase_config["profiles"][_profile_name] = { 224 | "name": _profile_name, 225 | "telemetry_source_type": _profile_telem_source_type, 226 | "telemetry_source_port": _profile_telem_source_port, 227 | "car_source_type": _profile_car_source_type, 228 | "car_source_port": _profile_car_source_port, 229 | "online_tracker": _profile_online_tracker, 230 | } 231 | if _default_profile == i: 232 | chase_config["selected_profile"] = _profile_name 233 | 234 | except Exception as e: 235 | logging.error("Error reading profile section %d - %s" % (i, str(e))) 236 | 237 | if len(chase_config["profiles"].keys()) == 0: 238 | logging.critical("Could not read any profile data!") 239 | return None 240 | 241 | if chase_config["selected_profile"] not in chase_config["profiles"]: 242 | logging.critical("Default profile selection does not exist.") 243 | return None 244 | 245 | # History 246 | 247 | chase_config["reload_last_position"] = config.getboolean( 248 | "history", "reload_last_position", fallback=False 249 | ) 250 | 251 | return chase_config 252 | 253 | 254 | def read_config(filename, default_cfg="horusmapper.cfg.example"): 255 | """ Read in a Horus Mapper configuration file,and return as a dict. """ 256 | 257 | try: 258 | config_dict = parse_config_file(filename) 259 | except Exception as e: 260 | logging.error("Could not parse %s, trying default: %s" % (filename, str(e))) 261 | try: 262 | config_dict = parse_config_file(default_cfg) 263 | except Exception as e: 264 | logging.critical("Could not parse example config file! - %s" % str(e)) 265 | config_dict = None 266 | 267 | return config_dict 268 | 269 | 270 | if __name__ == "__main__": 271 | import sys 272 | 273 | logging.basicConfig( 274 | format="%(asctime)s %(levelname)s:%(message)s", 275 | stream=sys.stdout, 276 | level=logging.DEBUG, 277 | ) 278 | print(read_config(sys.argv[1])) 279 | -------------------------------------------------------------------------------- /chasemapper/earthmaths.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # Listeners 5 | # 6 | # Copyright (C) 2018 Mark Jessop 7 | # Released under GNU GPL v3 or later 8 | # 9 | 10 | from math import radians, degrees, sin, cos, atan2, sqrt, pi 11 | 12 | 13 | def position_info(listener, balloon): 14 | """ 15 | Calculate and return information from 2 (lat, lon, alt) tuples 16 | 17 | Copyright 2012 (C) Daniel Richman; GNU GPL 3 18 | 19 | Returns a dict with: 20 | 21 | - angle at centre 22 | - great circle distance 23 | - distance in a straight line 24 | - bearing (azimuth or initial course) 25 | - elevation (altitude) 26 | 27 | Input and output latitudes, longitudes, angles, bearings and elevations are 28 | in degrees, and input altitudes and output distances are in meters. 29 | """ 30 | 31 | # Earth: 32 | # radius = 6371000.0 33 | radius = 6364963.0 # Optimized for Australia :-) 34 | 35 | (lat1, lon1, alt1) = listener 36 | (lat2, lon2, alt2) = balloon 37 | 38 | lat1 = radians(lat1) 39 | lat2 = radians(lat2) 40 | lon1 = radians(lon1) 41 | lon2 = radians(lon2) 42 | 43 | # Calculate the bearing, the angle at the centre, and the great circle 44 | # distance using Vincenty's_formulae with f = 0 (a sphere). See 45 | # http://en.wikipedia.org/wiki/Great_circle_distance#Formulas and 46 | # http://en.wikipedia.org/wiki/Great-circle_navigation and 47 | # http://en.wikipedia.org/wiki/Vincenty%27s_formulae 48 | d_lon = lon2 - lon1 49 | sa = cos(lat2) * sin(d_lon) 50 | sb = (cos(lat1) * sin(lat2)) - (sin(lat1) * cos(lat2) * cos(d_lon)) 51 | bearing = atan2(sa, sb) 52 | aa = sqrt((sa ** 2) + (sb ** 2)) 53 | ab = (sin(lat1) * sin(lat2)) + (cos(lat1) * cos(lat2) * cos(d_lon)) 54 | angle_at_centre = atan2(aa, ab) 55 | great_circle_distance = angle_at_centre * radius 56 | 57 | # Armed with the angle at the centre, calculating the remaining items 58 | # is a simple 2D triangley circley problem: 59 | 60 | # Use the triangle with sides (r + alt1), (r + alt2), distance in a 61 | # straight line. The angle between (r + alt1) and (r + alt2) is the 62 | # angle at the centre. The angle between distance in a straight line and 63 | # (r + alt1) is the elevation plus pi/2. 64 | 65 | # Use sum of angle in a triangle to express the third angle in terms 66 | # of the other two. Use sine rule on sides (r + alt1) and (r + alt2), 67 | # expand with compound angle formulae and solve for tan elevation by 68 | # dividing both sides by cos elevation 69 | ta = radius + alt1 70 | tb = radius + alt2 71 | ea = (cos(angle_at_centre) * tb) - ta 72 | eb = sin(angle_at_centre) * tb 73 | elevation = atan2(ea, eb) 74 | 75 | # Use cosine rule to find unknown side. 76 | distance = sqrt((ta ** 2) + (tb ** 2) - 2 * tb * ta * cos(angle_at_centre)) 77 | 78 | # Give a bearing in range 0 <= b < 2pi 79 | if bearing < 0: 80 | bearing += 2 * pi 81 | 82 | return { 83 | "listener": listener, 84 | "balloon": balloon, 85 | "listener_radians": (lat1, lon1, alt1), 86 | "balloon_radians": (lat2, lon2, alt2), 87 | "angle_at_centre": degrees(angle_at_centre), 88 | "angle_at_centre_radians": angle_at_centre, 89 | "bearing": degrees(bearing), 90 | "bearing_radians": bearing, 91 | "great_circle_distance": great_circle_distance, 92 | "straight_distance": distance, 93 | "elevation": degrees(elevation), 94 | "elevation_radians": elevation, 95 | } 96 | 97 | 98 | def bearing_to_cardinal(bearing): 99 | """ Convert a bearing in degrees to a 16-point cardinal direction """ 100 | bearing = bearing % 360.0 101 | 102 | if bearing < 11.25: 103 | cardinal = "N" 104 | elif 11.25 <= bearing < 33.75: 105 | cardinal = "NNE" 106 | elif 33.75 <= bearing < 56.25: 107 | cardinal = "NE" 108 | elif 56.25 <= bearing < 78.75: 109 | cardinal = "ENE" 110 | elif 78.75 <= bearing < 101.25: 111 | cardinal = "E" 112 | elif 101.25 <= bearing < 123.75: 113 | cardinal = "ESE" 114 | elif 123.75 <= bearing < 146.25: 115 | cardinal = "SE" 116 | elif 146.25 <= bearing < 168.75: 117 | cardinal = "SSE" 118 | elif 168.75 <= bearing < 191.25: 119 | cardinal = "S" 120 | elif 191.25 <= bearing < 213.75: 121 | cardinal = "SSW" 122 | elif 213.75 <= bearing < 236.25: 123 | cardinal = "SW" 124 | elif 236.25 <= bearing < 258.75: 125 | cardinal = "WSW" 126 | elif 258.75 <= bearing < 281.25: 127 | cardinal = "W" 128 | elif 281.25 <= bearing < 303.75: 129 | cardinal = "WNW" 130 | elif 303.75 <= bearing < 326.25: 131 | cardinal = "NW" 132 | elif 326.25 <= bearing < 348.75: 133 | cardinal = "NNW" 134 | elif bearing >= 348.75: 135 | cardinal = "N" 136 | else: 137 | cardinal = "?" 138 | 139 | return cardinal 140 | -------------------------------------------------------------------------------- /chasemapper/geometry.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Flight Data to Geometry 4 | # 5 | # Copyright (C) 2018 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import traceback 9 | import logging 10 | import numpy as np 11 | from .atmosphere import * 12 | from .earthmaths import position_info 13 | 14 | 15 | class GenericTrack(object): 16 | """ 17 | A Generic 'track' object, which stores track positions for a payload or chase car. 18 | Telemetry is added using the add_telemetry method, which takes a dictionary with time/lat/lon/alt keys (at minimum). 19 | This object performs a running average of the ascent/descent rate, and calculates the predicted landing rate if the payload 20 | is in descent. 21 | The track history can be exported to a LineString using the to_line_string method. 22 | """ 23 | 24 | def __init__( 25 | self, ascent_averaging=6, landing_rate=5.0, heading_gate_threshold=0.0, turn_rate_threshold=4.0 26 | ): 27 | """ Create a GenericTrack Object. """ 28 | 29 | # Averaging rate. 30 | self.ASCENT_AVERAGING = ascent_averaging 31 | # Payload state. 32 | self.landing_rate = landing_rate 33 | # Heading gate threshold (only gate headings if moving faster than this value in m/s) 34 | self.heading_gate_threshold = heading_gate_threshold 35 | # Turn rate threshold - only gate headings if turning *slower* than this value in degrees/sec 36 | self.turn_rate_threshold = turn_rate_threshold 37 | 38 | self.ascent_rate = 0.0 39 | self.heading = 0.0 40 | self.turn_rate = 100.0 41 | self.heading_valid = False 42 | self.speed = 0.0 43 | self.is_descending = False 44 | 45 | self.supplied_heading = False 46 | self.heading_status = None 47 | 48 | 49 | self.prev_heading = 0.0 50 | self.prev_time = 0.0 51 | 52 | # Internal store of track history data. 53 | # Data is stored as a list-of-lists, with elements of [datetime, lat, lon, alt, comment] 54 | self.track_history = [] 55 | 56 | def add_telemetry(self, data_dict): 57 | """ 58 | Accept telemetry data as a dictionary with fields 59 | datetime, lat, lon, alt, comment 60 | """ 61 | 62 | try: 63 | _datetime = data_dict["time"] 64 | _lat = data_dict["lat"] 65 | _lon = data_dict["lon"] 66 | _alt = data_dict["alt"] 67 | if "comment" in data_dict.keys(): 68 | _comment = data_dict["comment"] 69 | else: 70 | _comment = "" 71 | 72 | self.track_history.append([_datetime, _lat, _lon, _alt, _comment]) 73 | 74 | # If we have been supplied a 'true' heading with the position, override the state to use that. 75 | # In this case we are assuming that the heading is being provided by some form of magnetic compass, 76 | # and is valid even when the car is stationary. 77 | if "heading" in data_dict: 78 | # Rotate heading data if we have enough data 79 | if len(self.track_history) >=2: 80 | self.prev_time = self.track_history[-2][0] 81 | self.prev_heading = self.heading 82 | 83 | self.heading = data_dict["heading"] 84 | self.supplied_heading = True 85 | 86 | if "heading_status" in data_dict: 87 | self.heading_status = data_dict["heading_status"] 88 | 89 | self.update_states() 90 | 91 | return self.get_latest_state() 92 | except: 93 | logging.error("Error reading input data: %s" % traceback.format_exc()) 94 | 95 | def get_latest_state(self): 96 | """ Get the latest position of the payload """ 97 | 98 | if len(self.track_history) == 0: 99 | return None 100 | else: 101 | _latest_position = self.track_history[-1] 102 | _state = { 103 | "time": _latest_position[0], 104 | "lat": _latest_position[1], 105 | "lon": _latest_position[2], 106 | "alt": _latest_position[3], 107 | "ascent_rate": self.ascent_rate, 108 | "is_descending": self.is_descending, 109 | "landing_rate": self.landing_rate, 110 | "heading": self.heading, 111 | "heading_valid": self.heading_valid, 112 | "heading_status": self.heading_status, 113 | "turn_rate": self.turn_rate, 114 | "speed": self.speed, 115 | } 116 | return _state 117 | 118 | def calculate_ascent_rate(self): 119 | """ Calculate the ascent/descent rate of the payload based on the available data """ 120 | if len(self.track_history) <= 1: 121 | return 0.0 122 | elif len(self.track_history) == 2: 123 | # Basic ascent rate case - only 2 samples. 124 | _time_delta = ( 125 | self.track_history[-1][0] - self.track_history[-2][0] 126 | ).total_seconds() 127 | _altitude_delta = self.track_history[-1][3] - self.track_history[-2][3] 128 | 129 | if _time_delta == 0: 130 | logging.warning( 131 | "Zero time-step encountered in ascent rate calculation - are multiple receivers reporting telemetry simultaneously?" 132 | ) 133 | return 0.0 134 | else: 135 | return _altitude_delta / _time_delta 136 | 137 | else: 138 | _num_samples = min(len(self.track_history), self.ASCENT_AVERAGING) 139 | _asc_rates = [] 140 | 141 | for _i in range(-1 * (_num_samples - 1), 0): 142 | _time_delta = ( 143 | self.track_history[_i][0] - self.track_history[_i - 1][0] 144 | ).total_seconds() 145 | _altitude_delta = ( 146 | self.track_history[_i][3] - self.track_history[_i - 1][3] 147 | ) 148 | try: 149 | _asc_rates.append(_altitude_delta / _time_delta) 150 | except ZeroDivisionError: 151 | logging.warning( 152 | "Zero time-step encountered in ascent rate calculation - are multiple receivers reporting telemetry simultaneously?" 153 | ) 154 | continue 155 | 156 | # _mean2_time_delta = ( 157 | # self.track_history[-1][0] - self.track_history[-1*_num_samples][0] 158 | # ).total_seconds() 159 | 160 | # _mean2_altitude_delta = ( 161 | # self.track_history[-1][3] - self.track_history[-1*_num_samples][3] 162 | # ) 163 | 164 | # _asc_rate2 = _mean2_altitude_delta / _mean2_time_delta 165 | 166 | #print(f"asc_rates: {_asc_rates}, Mean: {np.mean(_asc_rates)}") 167 | return np.mean(_asc_rates) 168 | 169 | def calculate_heading(self): 170 | """ Calculate the heading of the payload """ 171 | if len(self.track_history) <= 1: 172 | return 0.0 173 | else: 174 | _pos_1 = self.track_history[-2] 175 | _pos_2 = self.track_history[-1] 176 | 177 | # Save previous heading. 178 | self.prev_heading = self.heading 179 | self.prev_time = _pos_1[0] 180 | 181 | # Calculate new heading 182 | try: 183 | _pos_info = position_info( 184 | (_pos_1[1], _pos_1[2], _pos_1[3]), (_pos_2[1], _pos_2[2], _pos_2[3]) 185 | ) 186 | except ValueError: 187 | logging.debug("Math Domain Error in heading calculation - Identical Sequential Positions") 188 | return self.heading 189 | 190 | self.heading = _pos_info["bearing"] 191 | 192 | return self.heading 193 | 194 | 195 | def calculate_speed(self): 196 | """ Calculate Payload Speed in metres per second """ 197 | if len(self.track_history) <= 1: 198 | return 0.0 199 | else: 200 | _time_delta = ( 201 | self.track_history[-1][0] - self.track_history[-2][0] 202 | ).total_seconds() 203 | _pos_1 = self.track_history[-2] 204 | _pos_2 = self.track_history[-1] 205 | 206 | 207 | try: 208 | _pos_info = position_info( 209 | (_pos_1[1], _pos_1[2], _pos_1[3]), (_pos_2[1], _pos_2[2], _pos_2[3]) 210 | ) 211 | except ValueError: 212 | logging.debug("Math Domain Error in speed calculation - Identical Sequential Positions") 213 | return 0.0 214 | 215 | try: 216 | _speed = _pos_info["great_circle_distance"] / _time_delta 217 | except ZeroDivisionError: 218 | logging.warning( 219 | "Zero time-step encountered in speed calculation - are multiple receivers reporting telemetry simultaneously?" 220 | ) 221 | return 0.0 222 | 223 | return _speed 224 | 225 | 226 | def calculate_turn_rate(self): 227 | """ Calculate heading rate based on previous heading and current heading """ 228 | if len(self.track_history) > 2: 229 | # Grab current time 230 | _current_time = self.track_history[-1][0] 231 | 232 | _time_delta = (_current_time - self.prev_time).total_seconds() 233 | 234 | _heading_delta = (self.heading - self.prev_heading) % 360.0 235 | if _heading_delta >= 180.0: 236 | _heading_delta -= 360.0 237 | 238 | self.turn_rate = abs(_heading_delta)/_time_delta 239 | 240 | return self.turn_rate 241 | 242 | 243 | def update_states(self): 244 | """ Update internal states based on the current data """ 245 | self.ascent_rate = self.calculate_ascent_rate() 246 | self.speed = self.calculate_speed() 247 | 248 | # If we haven't been supplied a heading, calculate one 249 | if not self.supplied_heading: 250 | self.heading = self.calculate_heading() 251 | 252 | # Calculate the turn rate 253 | self.calculate_turn_rate() 254 | 255 | if self.supplied_heading: 256 | # Heading supplied - only threshold on turn rate. 257 | if self.turn_rate < self.turn_rate_threshold: 258 | self.heading_valid = True 259 | else: 260 | self.heading_valid = False 261 | 262 | else: 263 | # Heading calculated - threshold on speed and turn rate. 264 | if (self.speed > self.heading_gate_threshold) and (self.turn_rate < self.turn_rate_threshold): 265 | self.heading_valid = True 266 | else: 267 | self.heading_valid = False 268 | 269 | self.is_descending = self.ascent_rate < 0.0 270 | 271 | if self.is_descending: 272 | _current_alt = self.track_history[-1][3] 273 | self.landing_rate = seaLevelDescentRate(self.ascent_rate, _current_alt) 274 | 275 | def to_polyline(self): 276 | """ Generate and return a Leaflet PolyLine compatible array """ 277 | # Copy array into a numpy representation for easier slicing. 278 | if len(self.track_history) == 0: 279 | return [] 280 | elif len(self.track_history) == 1: 281 | # LineStrings need at least 2 points. If we only have a single point, 282 | # fudge it by duplicating the single point. 283 | _track_data_np = np.array([self.track_history[0], self.track_history[0]]) 284 | else: 285 | _track_data_np = np.array(self.track_history) 286 | # Produce new array 287 | _track_points = np.column_stack( 288 | (_track_data_np[:, 1], _track_data_np[:, 2], _track_data_np[:, 3]) 289 | ) 290 | 291 | return _track_points.tolist() 292 | 293 | def length(self): 294 | return len(self.track_history) 295 | -------------------------------------------------------------------------------- /chasemapper/gps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # GPS Communication Classes 5 | # 6 | # Copyright (C) 2018 Mark Jessop 7 | # Released under GNU GPL v3 or later 8 | # 9 | import logging 10 | import re 11 | import time 12 | import traceback 13 | from datetime import datetime 14 | from threading import Thread 15 | 16 | 17 | class SerialGPS(object): 18 | """ 19 | Read NMEA strings from a serial-connected GPS receiver 20 | """ 21 | 22 | def __init__( 23 | self, 24 | serial_port="/dev/ttyUSB0", 25 | serial_baud=9600, 26 | timeout=5, 27 | callback=None, 28 | uberdebug=False, 29 | unittest=False, 30 | ): 31 | """ 32 | Initialise a SerialGPS object. 33 | 34 | This class assumes the serial-connected GPS outputs GPRMC or GNRMC and GPGGA or GNGGA NMEA strings 35 | using 8N1 RS232 framing. It also assumes the GPGGA or GNGGA string is send after GPRMC or GNRMC. If this 36 | is not the case, position data may be up to 1 second out. 37 | 38 | Args: 39 | serial_port (str): Serial port (i.e. '/dev/ttyUSB0', or 'COM1') to receive data from. 40 | serial_baud (int): Baud rate. 41 | timeout (int): Serial port readline timeout (Seconds) 42 | callback (function): function to pass valid GPS positions to. 43 | GPS data is passed as a dictionary with fields matching the Horus UDP GPS message: 44 | packet = { 45 | 'type' : 'GPS', 46 | 'latitude': lat, 47 | 'longitude': lon, 48 | 'altitude': alt, 49 | 'speed': speed*3.6, # Convert speed to kph. 50 | 'valid': position_valid 51 | } 52 | """ 53 | 54 | self.serial_port = serial_port 55 | self.serial_baud = serial_baud 56 | self.timeout = timeout 57 | self.callback = callback 58 | self.uberdebug = uberdebug 59 | 60 | # Indication of what the last expected string is. 61 | self.last_string = "GGA" 62 | 63 | # Current GPS state, in a format which matches the Horus UDP 64 | # 'Chase Car Position' message. 65 | # Note that these packets do not contain a timestamp. 66 | self.gps_state = { 67 | "type": "GPS", 68 | "latitude": 0.0, 69 | "longitude": 0.0, 70 | "altitude": 0.0, 71 | "speed": 0.0, 72 | "fix_status": 0, 73 | "heading": None, 74 | "valid": False, 75 | } 76 | 77 | self.serial_thread_running = False 78 | self.serial_thread = None 79 | self.ser = None 80 | 81 | if not unittest: 82 | self.start() 83 | 84 | def start(self): 85 | """ Start the GPS thread """ 86 | if self.serial_thread != None: 87 | return 88 | else: 89 | self.serial_thread_running = True 90 | self.serial_thread = Thread(target=self.gps_thread) 91 | self.serial_thread.start() 92 | 93 | def close(self): 94 | """ Stop the GPS thread. """ 95 | self.serial_thread_running = False 96 | # Wait for the thread to close. 97 | if self.serial_thread != None: 98 | self.serial_thread.join() 99 | 100 | def gps_thread(self): 101 | """ 102 | Attempt to connect to a serial port and read lines of text from it. 103 | Pass all lines on to the NMEA parser function. 104 | """ 105 | 106 | try: 107 | import serial 108 | except ImportError: 109 | logging.critical("Could not import pyserial library!") 110 | return 111 | 112 | while self.serial_thread_running: 113 | # Attempt to connect to the serial port. 114 | while self.ser == None and self.serial_thread_running: 115 | try: 116 | self.ser = serial.Serial( 117 | port=self.serial_port, 118 | baudrate=self.serial_baud, 119 | timeout=self.timeout, 120 | ) 121 | logging.info( 122 | "SerialGPS - Connected to serial port %s" % self.serial_port 123 | ) 124 | except Exception as e: 125 | # Continue re-trying until we can connect to the serial port. 126 | # This should let the user connect the gps *after* this object if instantiated if required. 127 | logging.error("SerialGPS - Serial Port Error: %s" % e) 128 | logging.error( 129 | "SerialGPS - Sleeping 10s before attempting re-connect." 130 | ) 131 | time.sleep(10) 132 | self.ser = None 133 | continue 134 | 135 | # Read a line of (hopefully) NMEA from the serial port. 136 | try: 137 | data = self.ser.readline() 138 | except: 139 | # If we hit a serial read error, attempt to reconnect. 140 | logging.error( 141 | "SerialGPS - Error reading from serial device! Attempting to reconnect." 142 | ) 143 | self.ser = None 144 | continue 145 | 146 | # Attempt to parse data. 147 | try: 148 | self.parse_nmea(data.decode("ascii")) 149 | except ValueError: 150 | logging.debug( 151 | "SerialGPS - ValueError when attempting to parse data. GPS may not have lock" 152 | ) 153 | except: 154 | traceback.print_exc() 155 | pass 156 | 157 | # Clean up before exiting thread. 158 | try: 159 | self.ser.close() 160 | except: 161 | pass 162 | logging.info("SerialGPS - Closing Thread.") 163 | 164 | def dm_to_sd(self, dm): 165 | """ 166 | Converts a geographic coordiante given in "degres/minutes" dddmm.mmmm 167 | format (ie, "12319.943281" = 123 degrees, 19.953281 minutes) to a signed 168 | decimal (python float) format. 169 | Courtesy of https://github.com/Knio/pynmea2/ 170 | """ 171 | # '12319.943281' 172 | if not dm or dm == "0": 173 | return 0.0 174 | try: 175 | d, m = re.match(r"^(\d+)(\d\d\.\d+)$", dm).groups() 176 | except: 177 | return 0.0 178 | 179 | return float(d) + float(m) / 60 180 | 181 | def parse_nmea(self, data): 182 | """ 183 | Attempt to parse a line of NMEA data. 184 | If we have received a GPGGA or GNGGA string containing a position valid flag, 185 | send the data on to the callback function. 186 | """ 187 | if self.uberdebug: 188 | print(data.strip()) 189 | 190 | if ("$GPRMC" in data) or ("$GNRMC" in data): 191 | logging.debug("SerialGPS - Got GPRMC or GNRMC.") 192 | gprmc = data.split(",") 193 | gprmc_lat = self.dm_to_sd(gprmc[3]) 194 | gprmc_latns = gprmc[4] 195 | gprmc_lon = self.dm_to_sd(gprmc[5]) 196 | gprmc_lonew = gprmc[6] 197 | gprmc_speed = float(gprmc[7]) 198 | 199 | if gprmc_latns == "S": 200 | self.gps_state["latitude"] = gprmc_lat * -1.0 201 | else: 202 | self.gps_state["latitude"] = gprmc_lat 203 | 204 | if gprmc_lonew == "W": 205 | self.gps_state["longitude"] = gprmc_lon * -1.0 206 | else: 207 | self.gps_state["longitude"] = gprmc_lon 208 | 209 | self.gps_state["speed"] = gprmc_speed * 0.51444 * 3.6 210 | 211 | elif ("$GPGGA" in data) or ("$GNGGA" in data): 212 | logging.debug("SerialGPS - Got GPGGA or GNGGA.") 213 | gpgga = data.split(",") 214 | gpgga_lat = self.dm_to_sd(gpgga[2]) 215 | gpgga_latns = gpgga[3] 216 | gpgga_lon = self.dm_to_sd(gpgga[4]) 217 | gpgga_lonew = gpgga[5] 218 | gpgga_fixstatus = int(gpgga[6]) 219 | gpgga_numSV = int(gpgga[7]) 220 | self.gps_state["numSV"] = gpgga_numSV 221 | self.gps_state["fix_status"] = gpgga_fixstatus 222 | self.gps_state["altitude"] = float(gpgga[9]) 223 | 224 | if gpgga_latns == "S": 225 | self.gps_state["latitude"] = gpgga_lat * -1.0 226 | else: 227 | self.gps_state["latitude"] = gpgga_lat 228 | 229 | if gpgga_lonew == "W": 230 | self.gps_state["longitude"] = gpgga_lon * -1.0 231 | else: 232 | self.gps_state["longitude"] = gpgga_lon 233 | 234 | if gpgga_fixstatus == 0: 235 | self.gps_state["valid"] = False 236 | else: 237 | self.gps_state["valid"] = True 238 | 239 | if self.last_string == "GGA": 240 | self.send_to_callback() 241 | 242 | elif ("$GPTHS" in data) or ("$GNTHS" in data): 243 | # Very basic handling of the uBlox NEO-M8U-provided True heading data. 244 | # This data *appears* to be the output of the fused solution, once the system 245 | # has self-calibrated. 246 | # The GNTHS message can be enabled on the USB port by sending: $PUBX,40,THS,0,0,0,1,0,0*55\r\n 247 | # to the GPS. 248 | logging.debug("SerialGPS - Got Heading Info (GNTHS).") 249 | gnths = data.split(",") 250 | try: 251 | if len(gnths[1]) > 0: 252 | # Data is present in the heading field, try and parse it. 253 | gnths_heading = float(gnths[1]) 254 | # Get the heading validity field. 255 | gnths_valid = gnths[2] 256 | 257 | if gnths_valid != "V": 258 | # Treat anything other than 'V' as a valid heading 259 | self.gps_state["heading"] = gnths_heading 260 | else: 261 | self.gps_state["heading"] = None 262 | else: 263 | # Blank field, which means data is not valid. 264 | self.gps_state["heading"] = None 265 | 266 | 267 | # Assume that if we are receiving GNTHS strings, that they are the last in the batch. 268 | # Stop sending data when we get a GGA string. 269 | self.last_string = "THS" 270 | 271 | # Send to callback if we have lock. 272 | if self.gps_state["fix_status"] != 0: 273 | self.send_to_callback() 274 | 275 | except: 276 | # Failed to parse field, which probably means an invalid heading. 277 | logging.debug(f"Failed to parse GNTHS: {data}") 278 | # Invalidate the heading data, and revert to emitting messages on GGA strings. 279 | self.gps_state["heading"] = None 280 | self.last_string = "GGA" 281 | 282 | else: 283 | # Discard all other lines 284 | pass 285 | 286 | def send_to_callback(self): 287 | """ 288 | Send the current GPS data snapshot onto the callback function, 289 | if one exists. 290 | """ 291 | # Generate a copy of the gps state 292 | _state = self.gps_state.copy() 293 | 294 | if _state["heading"] is None: 295 | _state.pop("heading") 296 | 297 | # Attempt to pass it onto the callback function. 298 | if self.callback != None: 299 | try: 300 | self.callback(_state) 301 | except Exception as e: 302 | traceback.print_exc() 303 | logging.error( 304 | "SerialGPS - Error Passing data to callback - %s" % str(e) 305 | ) 306 | 307 | 308 | class GPSDGPS(object): 309 | """ Read GPS data from a GPSD server """ 310 | 311 | def __init__(self, hostname="127.0.0.1", port=2947, callback=None): 312 | """ Init """ 313 | pass 314 | 315 | 316 | if __name__ == "__main__": 317 | # 318 | # GPS Parser Test Script 319 | # Call with either: 320 | # $ python -m chasemapper.gps /dev/ttyUSB0 321 | # or 322 | # $ python -m chasemapper.gps /path/to/nmea_log.txt 323 | # 324 | import sys, time 325 | 326 | logging.basicConfig( 327 | format="%(asctime)s %(levelname)s:%(message)s", level=logging.DEBUG 328 | ) 329 | _port = sys.argv[1] 330 | _baud = 9600 331 | 332 | def print_data(data): 333 | print(data) 334 | 335 | if "tty" not in _port: 336 | unittest = True 337 | else: 338 | unittest = False 339 | 340 | _gps = SerialGPS( 341 | serial_port=_port, 342 | serial_baud=_baud, 343 | callback=print_data, 344 | uberdebug=True, 345 | unittest=unittest, 346 | ) 347 | 348 | if unittest: 349 | _f = open(_port, "r") 350 | for line in _f: 351 | _gps.parse_nmea(line) 352 | time.sleep(0.2) 353 | _f.close() 354 | else: 355 | time.sleep(100) 356 | _gps.close() 357 | -------------------------------------------------------------------------------- /chasemapper/habitat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # Habitat Communication (Chase car position upload) 5 | # 6 | # Copyright (C) 2018 Mark Jessop 7 | # Released under GNU GPL v3 or later 8 | # 9 | import datetime 10 | import logging 11 | import requests 12 | import time 13 | import traceback 14 | import json 15 | from base64 import b64encode 16 | from hashlib import sha256 17 | from threading import Thread, Lock 18 | 19 | try: 20 | # Python 2 21 | from Queue import Queue 22 | except ImportError: 23 | # Python 3 24 | from queue import Queue 25 | 26 | 27 | HABITAT_URL = "http://habitat.habhub.org/" 28 | 29 | url_habitat_uuids = HABITAT_URL + "_uuids?count=%d" 30 | url_habitat_db = HABITAT_URL + "habitat/" 31 | uuids = [] 32 | 33 | 34 | def ISOStringNow(): 35 | return "%sZ" % datetime.datetime.utcnow().isoformat() 36 | 37 | 38 | def postListenerData(doc, timeout=10): 39 | global uuids, url_habitat_db 40 | # do we have at least one uuid, if not go get more 41 | if len(uuids) < 1: 42 | fetchUuids() 43 | 44 | # Attempt to add UUID and time data to document. 45 | try: 46 | doc["_id"] = uuids.pop() 47 | except IndexError: 48 | logging.error("Habitat - Unable to post listener data - no UUIDs available.") 49 | return False 50 | 51 | doc["time_uploaded"] = ISOStringNow() 52 | 53 | try: 54 | _r = requests.post(url_habitat_db, json=doc, timeout=timeout) 55 | return True 56 | except Exception as e: 57 | logging.error("Habitat - Could not post listener data - %s" % str(e)) 58 | return False 59 | 60 | 61 | def fetchUuids(timeout=10): 62 | global uuids, url_habitat_uuids 63 | 64 | _retries = 5 65 | 66 | while _retries > 0: 67 | try: 68 | _r = requests.get(url_habitat_uuids % 10, timeout=timeout) 69 | uuids.extend(_r.json()["uuids"]) 70 | logging.debug("Habitat - Got UUIDs") 71 | return 72 | except Exception as e: 73 | logging.error( 74 | "Habitat - Unable to fetch UUIDs, retrying in 10 seconds - %s" % str(e) 75 | ) 76 | time.sleep(10) 77 | _retries = _retries - 1 78 | continue 79 | 80 | logging.error("Habitat - Gave up trying to get UUIDs.") 81 | return 82 | 83 | 84 | def initListenerCallsign(callsign, antenna=None, radio=None): 85 | doc = { 86 | "type": "listener_information", 87 | "time_created": ISOStringNow(), 88 | "data": {"callsign": callsign}, 89 | } 90 | 91 | if antenna != None: 92 | doc["data"]["antenna"] = antenna 93 | 94 | if radio != None: 95 | doc["data"]["radio"] = radio 96 | 97 | resp = postListenerData(doc) 98 | 99 | if resp is True: 100 | logging.debug("Habitat - Listener Callsign Initialized.") 101 | return True 102 | else: 103 | logging.error("Habitat - Unable to initialize callsign.") 104 | return False 105 | 106 | 107 | def uploadListenerPosition(callsign, lat, lon, alt, chase=True): 108 | """ Upload Listener Position """ 109 | 110 | doc = { 111 | "type": "listener_telemetry", 112 | "time_created": ISOStringNow(), 113 | "data": { 114 | "callsign": callsign, 115 | "chase": chase, 116 | "latitude": lat, 117 | "longitude": lon, 118 | "altitude": alt, 119 | "speed": 0, 120 | }, 121 | } 122 | 123 | # post position to habitat 124 | resp = postListenerData(doc) 125 | if resp is True: 126 | logging.debug("Habitat - Listener information uploaded.") 127 | return True 128 | else: 129 | logging.error("Habitat - Unable to upload listener information.") 130 | return False 131 | 132 | 133 | class HabitatChaseUploader(object): 134 | """ Upload supplied chase car positions to Habitat on a regular basis """ 135 | 136 | def __init__(self, update_rate=30, callsign="N0CALL", upload_enabled=True): 137 | """ Initialise the Habitat Chase uploader, and start the update thread """ 138 | 139 | self.update_rate = update_rate 140 | self.callsign = callsign 141 | self.callsign_init = False 142 | self.upload_enabled = upload_enabled 143 | 144 | self.car_position = None 145 | self.car_position_lock = Lock() 146 | 147 | self.uploader_thread_running = True 148 | self.uploader_thread = Thread(target=self.upload_thread) 149 | self.uploader_thread.start() 150 | 151 | logging.info("Habitat - Chase-Car Position Uploader Started") 152 | 153 | def update_position(self, position): 154 | """ Update the chase car position state 155 | This function accepts and stores a copy of the same dictionary structure produced by both 156 | Horus UDP broadcasts, and the serial GPS and GPSD modules 157 | """ 158 | 159 | with self.car_position_lock: 160 | self.car_position = position.copy() 161 | 162 | def upload_thread(self): 163 | """ Uploader thread """ 164 | while self.uploader_thread_running: 165 | 166 | # Grab a copy of the most recent car position. 167 | with self.car_position_lock: 168 | if self.car_position != None: 169 | _position = self.car_position.copy() 170 | else: 171 | _position = None 172 | 173 | if self.upload_enabled and _position != None: 174 | try: 175 | # If the listener callsign has not been initialized, init it. 176 | # We only need to do this once per callsign. 177 | if self.callsign_init != self.callsign: 178 | _resp = initListenerCallsign(self.callsign) 179 | if _resp: 180 | self.callsign_init = self.callsign 181 | 182 | # Upload the listener position. 183 | uploadListenerPosition( 184 | self.callsign, 185 | _position["latitude"], 186 | _position["longitude"], 187 | _position["altitude"], 188 | ) 189 | except Exception as e: 190 | logging.error( 191 | "Habitat - Error uploading chase-car position - %s" % str(e) 192 | ) 193 | 194 | # Wait for next update. 195 | _i = 0 196 | while (_i < self.update_rate) and self.uploader_thread_running: 197 | time.sleep(1) 198 | _i += 1 199 | 200 | def set_update_rate(self, rate): 201 | """ Set the update rate """ 202 | self.update_rate = int(rate) 203 | 204 | def set_callsign(self, call): 205 | """ Set the callsign """ 206 | self.callsign = call 207 | 208 | #def mark_payload_recovered(self, callsign, latitude, longitude, altitude, message): 209 | def mark_payload_recovered(self, serial=None, callsign=None, lat=0.0, lon=0.0, alt=0.0, message="", recovered=True): 210 | """ Upload an indication that a payload (radiosonde or otherwise) has been recovered """ 211 | 212 | if serial is None: 213 | return 214 | 215 | if recovered: 216 | _call = serial + " recovered by " + callsign 217 | else: 218 | _call = serial + " not recovered by " + callsign 219 | 220 | try: 221 | initListenerCallsign(_call, radio="", antenna=message) 222 | uploadListenerPosition(_call, lat, lon, alt, chase=False) 223 | except Exception as e: 224 | logging.error( 225 | "Habitat - Unable to mark payload as recovered - %s" % (str(e)) 226 | ) 227 | return 228 | 229 | logging.info("Habitat - Payload marked as recovered.") 230 | 231 | def close(self): 232 | self.uploader_thread_running = False 233 | try: 234 | self.uploader_thread.join() 235 | except: 236 | pass 237 | logging.info("Habitat - Chase-Car Position Uploader Closed") 238 | -------------------------------------------------------------------------------- /chasemapper/listeners.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # Listeners 5 | # 6 | # Copyright (C) 2018 Mark Jessop 7 | # Released under GNU GPL v3 or later 8 | # 9 | # These classes have been pulled in from the horuslib library, to avoid 10 | # requiring horuslib (hopefully soon-to-be retired) as a dependency. 11 | 12 | import socket, json, sys, traceback 13 | from threading import Thread 14 | from dateutil.parser import parse 15 | from datetime import datetime, timedelta 16 | 17 | MAX_JSON_LEN = 32768 18 | 19 | 20 | def fix_datetime(datetime_str, local_dt_str=None): 21 | """ 22 | Given a HH:MM:SS string from an telemetry sentence, produce a complete timestamp, using the current system time as a guide for the date. 23 | """ 24 | 25 | if local_dt_str is None: 26 | _now = datetime.utcnow() 27 | else: 28 | _now = parse(local_dt_str) 29 | 30 | # Are we in the rollover window? 31 | if _now.hour == 23 or _now.hour == 0: 32 | _outside_window = False 33 | else: 34 | _outside_window = True 35 | 36 | # Append on a timezone indicator if the time doesn't have one. 37 | if datetime_str.endswith("Z") or datetime_str.endswith("+00:00"): 38 | pass 39 | else: 40 | datetime_str += "Z" 41 | 42 | # Parsing just a HH:MM:SS will return a datetime object with the year, month and day replaced by values in the 'default' 43 | # argument. 44 | _telem_dt = parse(datetime_str, default=_now) 45 | 46 | if _outside_window: 47 | # We are outside the day-rollover window, and can safely use the current zulu date. 48 | return _telem_dt 49 | else: 50 | # We are within the window, and need to adjust the day backwards or forwards based on the sonde time. 51 | if _telem_dt.hour == 23 and _now.hour == 0: 52 | # Assume system clock running slightly fast, and subtract a day from the telemetry date. 53 | _telem_dt = _telem_dt - timedelta(days=1) 54 | 55 | elif _telem_dt.hour == 00 and _now.hour == 23: 56 | # System clock running slow. Add a day. 57 | _telem_dt = _telem_dt + timedelta(days=1) 58 | 59 | return _telem_dt 60 | 61 | 62 | class UDPListener(object): 63 | """ UDP Broadcast Packet Listener 64 | Listens for Horuslib UDP broadcast packets, and passes them onto a callback function 65 | """ 66 | 67 | def __init__( 68 | self, 69 | callback=None, 70 | summary_callback=None, 71 | gps_callback=None, 72 | bearing_callback=None, 73 | port=55672, 74 | ): 75 | 76 | self.udp_port = port 77 | self.callback = callback 78 | self.summary_callback = summary_callback 79 | self.gps_callback = gps_callback 80 | self.bearing_callback = bearing_callback 81 | 82 | self.listener_thread = None 83 | self.s = None 84 | self.udp_listener_running = False 85 | 86 | def handle_udp_packet(self, packet): 87 | """ Process a received UDP packet """ 88 | try: 89 | packet_dict = json.loads(packet.decode()) 90 | 91 | if self.callback is not None: 92 | self.callback(packet_dict) 93 | 94 | if packet_dict["type"] == "PAYLOAD_SUMMARY": 95 | if self.summary_callback is not None: 96 | self.summary_callback(packet_dict) 97 | 98 | if packet_dict["type"] == "PAYLOAD_TELEMETRY": 99 | if "time_string" in packet_dict.keys(): 100 | packet_dict["time"] = packet_dict["time_string"] 101 | if self.summary_callback is not None: 102 | self.summary_callback(packet_dict) 103 | 104 | if packet_dict["type"] == "GPS": 105 | if self.gps_callback is not None: 106 | self.gps_callback(packet_dict) 107 | 108 | if packet_dict["type"] == "BEARING": 109 | if self.bearing_callback is not None: 110 | self.bearing_callback(packet_dict) 111 | 112 | if packet_dict["type"] == "MODEM_STATS": 113 | if self.summary_callback is not None: 114 | self.summary_callback(packet_dict) 115 | 116 | except Exception as e: 117 | print("Could not parse packet: %s" % str(e)) 118 | traceback.print_exc() 119 | 120 | def udp_rx_thread(self): 121 | """ Listen for Broadcast UDP packets """ 122 | 123 | self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 124 | self.s.settimeout(1) 125 | self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 126 | try: 127 | self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 128 | except: 129 | pass 130 | self.s.bind(("", self.udp_port)) 131 | print("Started UDP Listener Thread.") 132 | self.udp_listener_running = True 133 | 134 | while self.udp_listener_running: 135 | try: 136 | m = self.s.recvfrom(MAX_JSON_LEN) 137 | except socket.timeout: 138 | m = None 139 | except: 140 | traceback.print_exc() 141 | 142 | if m != None: 143 | self.handle_udp_packet(m[0]) 144 | 145 | print("Closing UDP Listener") 146 | self.s.close() 147 | 148 | def start(self): 149 | if self.listener_thread is None: 150 | self.listener_thread = Thread(target=self.udp_rx_thread) 151 | self.listener_thread.start() 152 | 153 | def close(self): 154 | self.udp_listener_running = False 155 | self.listener_thread.join() 156 | 157 | 158 | class OziListener(object): 159 | """ 160 | Listen on a supplied UDP port for OziPlotter-compatible telemetry data. 161 | 162 | Incoming sentences are of the form: 163 | TELEMETRY.HH:MM:SS,latitude,longitude,altitude\n 164 | WAYPOINT,waypoint_name,latitude,longitude,comment\n 165 | """ 166 | 167 | allowed_sentences = ["TELEMETRY", "WAYPOINT"] 168 | 169 | def __init__( 170 | self, hostname="", port=8942, telemetry_callback=None, waypoint_callback=None 171 | ): 172 | 173 | self.input_host = hostname 174 | self.input_port = port 175 | self.telemetry_callback = telemetry_callback 176 | self.waypoint_callback = waypoint_callback 177 | 178 | self.start() 179 | 180 | def start(self): 181 | """ Start the UDP Listener Thread. """ 182 | self.udp_listener_running = True 183 | 184 | self.t = Thread(target=self.udp_rx_thread) 185 | self.t.start() 186 | 187 | def udp_rx_thread(self): 188 | """ 189 | Listen for incoming UDP packets, and pass them off to another function to be processed. 190 | """ 191 | 192 | self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 193 | self.s.settimeout(1) 194 | self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 195 | try: 196 | self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 197 | except: 198 | pass 199 | self.s.bind((self.input_host, self.input_port)) 200 | 201 | while self.udp_listener_running: 202 | try: 203 | m = self.s.recvfrom(1024) 204 | except socket.timeout: 205 | m = None 206 | except: 207 | traceback.print_exc() 208 | 209 | if m != None: 210 | try: 211 | self.handle_packet(m[0]) 212 | except: 213 | traceback.print_exc() 214 | print("ERROR: Couldn't handle packet correctly.") 215 | pass 216 | 217 | print("INFO: Closing UDP Listener Thread") 218 | self.s.close() 219 | 220 | def close(self): 221 | """ 222 | Close the UDP listener thread. 223 | """ 224 | self.udp_listener_running = False 225 | try: 226 | self.t.join() 227 | except: 228 | pass 229 | 230 | def handle_telemetry_packet(self, packet): 231 | """ Split a telemetry packet into time/lat/lon/alt, and pass it onto a callback """ 232 | 233 | _fields = packet.split(",") 234 | _short_time = _fields[1] 235 | _lat = float(_fields[2]) 236 | _lon = float(_fields[3]) 237 | _alt = float(_fields[4]) 238 | 239 | # Timestamp Handling 240 | # The 'short' timestamp (HH:MM:SS) is always assumed to be in UTC time. 241 | # To build up a complete datetime object, we use the system's current UTC time, and replace the HH:MM:SS part. 242 | _full_time = datetime.utcnow().strftime("%Y-%m-%dT") + _short_time + "Z" 243 | _time_dt = parse(_full_time) 244 | 245 | _time_dt = fix_datetime(_short_time) 246 | 247 | _output = { 248 | "time": _time_dt, 249 | "lat": _lat, 250 | "lon": _lon, 251 | "alt": _alt, 252 | "comment": "Telemetry Data", 253 | } 254 | 255 | self.telemetry_callback(_output) 256 | 257 | def handle_waypoint_packet(self, packet): 258 | """ Split a 'Waypoint' packet into fields, and pass onto a callback """ 259 | 260 | _fields = packet.split(",") 261 | _waypoint_name = _fields[1] 262 | _lat = float(_fields[2]) 263 | _lon = float(_fields[3]) 264 | _comment = _fields[4] 265 | 266 | _time_dt = datetime.utcnow() 267 | 268 | _output = { 269 | "time": _time_dt, 270 | "name": _waypoint_name, 271 | "lat": _lat, 272 | "lon": _lon, 273 | "comment": _comment, 274 | } 275 | 276 | self.waypoint_callback(_output) 277 | 278 | def handle_packet(self, packet): 279 | """ 280 | Check an incoming packet matches a valid type, and then forward it on. 281 | """ 282 | 283 | # Extract header (first field) 284 | packet_type = packet.split(",")[0] 285 | 286 | if packet_type not in self.allowed_sentences: 287 | print("ERROR: Got unknown packet: %s" % packet) 288 | return 289 | 290 | try: 291 | # Now send on the packet if we are allowed to. 292 | if packet_type == "TELEMETRY" and (self.telemetry_callback != None): 293 | self.handle_telemetry_packet(packet) 294 | 295 | # Generally we always want to pass on waypoint data. 296 | if packet_type == "WAYPOINT" and (self.waypoint_callback != None): 297 | self.handle_waypoint_packet(packet) 298 | 299 | except: 300 | print("ERROR: Error when handling packet.") 301 | traceback.print_exc() 302 | -------------------------------------------------------------------------------- /chasemapper/logger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Chase Logging 4 | # 5 | # Copyright (C) 2019 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import datetime 9 | import json 10 | import logging 11 | import os 12 | import pytz 13 | import time 14 | from threading import Thread, Lock 15 | 16 | try: 17 | # Python 2 18 | from Queue import Queue 19 | except ImportError: 20 | # Python 3 21 | from queue import Queue 22 | 23 | 24 | class ChaseLogger(object): 25 | """ Chase Data Logger Class. 26 | Log all chase data into a file as lines of JSON. 27 | """ 28 | 29 | def __init__(self, filename=None, log_dir="./log_files"): 30 | 31 | if filename is not None: 32 | # Use user-supplied filename if provided 33 | self.filename = filename 34 | else: 35 | # Otherwise, create a filename based on the current time. 36 | self.filename = os.path.join( 37 | log_dir, datetime.datetime.utcnow().strftime("%Y%m%d-%H%MZ.log") 38 | ) 39 | 40 | self.file_lock = Lock() 41 | 42 | # Input Queue. 43 | self.input_queue = Queue() 44 | 45 | # Open the file. 46 | try: 47 | self.f = open(self.filename, "a") 48 | logging.info("Logging - Opened log file %s." % self.filename) 49 | except Exception as e: 50 | self.log_error("Logging - Could not open log file - %s" % str(e)) 51 | return 52 | 53 | # Start queue processing thread. 54 | self.input_processing_running = True 55 | self.log_process_thread = Thread(target=self.process_queue) 56 | self.log_process_thread.start() 57 | 58 | def add_car_position(self, data): 59 | """ Log a chase car position update. 60 | Input dict expected to be in the format: 61 | { 62 | 'time' : _time_dt, 63 | 'lat' : _lat, 64 | 'lon' : _lon, 65 | 'alt' : _alt, 66 | 'comment': _comment 67 | } 68 | 69 | """ 70 | 71 | data["log_type"] = "CAR POSITION" 72 | data["log_time"] = pytz.utc.localize(datetime.datetime.utcnow()).isoformat() 73 | 74 | # Convert the input datetime object into a string. 75 | data["time"] = data["time"].isoformat() 76 | 77 | # Add it to the queue if we are running. 78 | if self.input_processing_running: 79 | self.input_queue.put(data) 80 | else: 81 | self.log_error("Processing not running, discarding.") 82 | 83 | def add_balloon_telemetry(self, data): 84 | """ Log balloon telemetry. 85 | """ 86 | 87 | data["log_type"] = "BALLOON TELEMETRY" 88 | data["log_time"] = pytz.utc.localize(datetime.datetime.utcnow()).isoformat() 89 | 90 | # Convert the input datetime object into a string. 91 | data["time"] = data["time_dt"].isoformat() 92 | # Remove the time_dt element (this cannot be serialised to JSON). 93 | data.pop("time_dt") 94 | 95 | # Add it to the queue if we are running. 96 | if self.input_processing_running: 97 | self.input_queue.put(data) 98 | else: 99 | self.log_error("Processing not running, discarding.") 100 | 101 | def add_balloon_prediction(self, data): 102 | """ Log a prediction run """ 103 | 104 | data["log_type"] = "PREDICTION" 105 | data["log_time"] = pytz.utc.localize(datetime.datetime.utcnow()).isoformat() 106 | 107 | # Add it to the queue if we are running. 108 | if self.input_processing_running: 109 | self.input_queue.put(data) 110 | else: 111 | self.log_error("Processing not running, discarding.") 112 | 113 | def add_bearing(self, data): 114 | """ Log a packet of bearing data """ 115 | 116 | data["log_type"] = "BEARING" 117 | data["log_time"] = pytz.utc.localize(datetime.datetime.utcnow()).isoformat() 118 | 119 | # Add it to the queue if we are running. 120 | if self.input_processing_running: 121 | self.input_queue.put(data) 122 | else: 123 | self.log_error("Processing not running, discarding.") 124 | 125 | def process_queue(self): 126 | """ Process data from the input queue, and write telemetry to log files. 127 | """ 128 | self.log_info("Started Chase Logger Thread.") 129 | 130 | while self.input_processing_running: 131 | 132 | # Process everything in the queue. 133 | self.file_lock.acquire() 134 | while self.input_queue.qsize() > 0: 135 | try: 136 | _data = self.input_queue.get_nowait() 137 | _data_str = json.dumps(_data) 138 | self.f.write(_data_str + "\n") 139 | except Exception as e: 140 | self.log_error("Error processing data - %s" % str(e)) 141 | 142 | self.file_lock.release() 143 | # Sleep while waiting for some new data. 144 | time.sleep(5) 145 | 146 | def running(self): 147 | """ Check if the logging thread is running. 148 | 149 | Returns: 150 | bool: True if the logging thread is running. 151 | """ 152 | return self.input_processing_running 153 | 154 | def close(self): 155 | try: 156 | self.input_processing_running = False 157 | self.f.close() 158 | except Exception as e: 159 | self.log_error("Error when closing - %s" % str(e)) 160 | 161 | self.log_info("Stopped Telemetry Logger Thread.") 162 | 163 | def log_debug(self, line): 164 | """ Helper function to log a debug message with a descriptive heading. 165 | Args: 166 | line (str): Message to be logged. 167 | """ 168 | logging.debug("Chase Logger - %s" % line) 169 | 170 | def log_info(self, line): 171 | """ Helper function to log an informational message with a descriptive heading. 172 | Args: 173 | line (str): Message to be logged. 174 | """ 175 | logging.info("Chase Logger - %s" % line) 176 | 177 | def log_error(self, line): 178 | """ Helper function to log an error message with a descriptive heading. 179 | Args: 180 | line (str): Message to be logged. 181 | """ 182 | logging.error("Chase Logger - %s" % line) 183 | -------------------------------------------------------------------------------- /chasemapper/logread.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Log read operations 4 | # 5 | # Copyright (C) 2019 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import datetime 9 | import json 10 | import logging 11 | import os 12 | import pytz 13 | import time 14 | 15 | # from datetime import datetime 16 | from dateutil.parser import parse 17 | 18 | 19 | def read_file(filename): 20 | """ Read log file, and output an array of dicts. """ 21 | _output = [] 22 | 23 | _f = open(filename, "r") 24 | for _line in _f: 25 | try: 26 | _data = json.loads(_line) 27 | _output.append(_data) 28 | except Exception as e: 29 | logging.debug("Error reading line: %s" % str(e)) 30 | if len(_output) != 0: 31 | logging.info("Read %d log entries from %s" % (len(_output), filename)) 32 | 33 | return _output 34 | 35 | 36 | def read_last_balloon_telemetry(): 37 | """ Read last balloon telemetry. Need to work back from last file to find balloon telemetry and read the last entry - don't return until whole file scanned 38 | """ 39 | _lasttelemetry = [] 40 | dirs = sorted( 41 | os.listdir("./log_files"), reverse=True 42 | ) # Generate a reverse sorted list - will have to look through to find last log_file with telemetry 43 | for file in dirs: 44 | if file.endswith(".log"): 45 | telemetry_found = False 46 | try: 47 | log = read_file("./log_files/" + file) 48 | except Exception as e: 49 | logging.debug("Error reading file - maybe in use: %s" % str(e)) 50 | 51 | for _entry in log: 52 | if _entry["log_type"] == "BALLOON TELEMETRY": 53 | telemetry_found = True 54 | _last_telemetry = _entry 55 | 56 | if telemetry_found == True: 57 | _last_telemetry["time_dt"] = parse(_last_telemetry.pop("time")) 58 | return _last_telemetry 59 | -------------------------------------------------------------------------------- /chasemapper/predictor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper - Predictor 4 | # 5 | # Copyright (C) 2018 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | import logging 9 | import subprocess 10 | from threading import Thread 11 | 12 | model_download_running = False 13 | 14 | 15 | def predictor_download_model(command, callback): 16 | """ Run the supplied command, which should download a GFS model and place it into the GFS directory 17 | 18 | When the downloader completes, or if an error is thrown, the status is passed to a callback function. 19 | """ 20 | global model_download_running 21 | 22 | if model_download_running: 23 | return 24 | 25 | model_download_running = True 26 | 27 | try: 28 | ret_code = subprocess.call(command, shell=True) 29 | except Exception as e: 30 | # Something broke when running the detection function. 31 | logging.error("Error when attempting to download model - %s" % (str(e))) 32 | model_download_running = False 33 | callback("Error - See log.") 34 | return 35 | 36 | model_download_running = False 37 | 38 | if ret_code == 0: 39 | logging.info("Model Download Completed.") 40 | callback("OK") 41 | return 42 | else: 43 | logging.error("Model Downloader returned code %d" % ret_code) 44 | callback("Error: Ret Code %d" % ret_code) 45 | return 46 | 47 | 48 | def predictor_spawn_download(command, callback=None): 49 | """ Spawn a model downloader in a new thread """ 50 | global model_download_running 51 | 52 | if model_download_running: 53 | return "Already Downloading." 54 | 55 | _download_thread = Thread( 56 | target=predictor_download_model, 57 | kwargs={"command": command, "callback": callback}, 58 | ) 59 | _download_thread.start() 60 | 61 | return "Started downloader." 62 | 63 | 64 | if __name__ == "__main__": 65 | import sys 66 | from .config import parse_config_file 67 | from cusfpredict.utils import gfs_model_age, available_gfs 68 | 69 | _cfg_file = sys.argv[1] 70 | 71 | _cfg = parse_config_file(_cfg_file) 72 | 73 | if _cfg["pred_model_download"] == "none": 74 | print("Model download not enabled.") 75 | sys.exit(1) 76 | 77 | predictor_download_model(_cfg["pred_model_download"]) 78 | 79 | print(available_gfs(_cfg["pred_gfs_directory"])) 80 | -------------------------------------------------------------------------------- /chasemapper/sondehub.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper 4 | # Sondehub Communication (Chase car position upload) 5 | # 6 | # Copyright (C) 2021 Mark Jessop 7 | # Released under GNU GPL v3 or later 8 | # 9 | import chasemapper 10 | import datetime 11 | import logging 12 | import requests 13 | import time 14 | import traceback 15 | import json 16 | from base64 import b64encode 17 | from hashlib import sha256 18 | from threading import Thread, Lock 19 | 20 | try: 21 | # Python 2 22 | from Queue import Queue 23 | except ImportError: 24 | # Python 3 25 | from queue import Queue 26 | 27 | 28 | class SondehubChaseUploader(object): 29 | """ Upload supplied chase car positions to Sondehub on a regular basis """ 30 | 31 | SONDEHUB_STATION_POSITION_URL = "https://api.v2.sondehub.org/listeners" 32 | SONDEHUB_STATION_POSITION_URL_AMATEUR = "https://api.v2.sondehub.org/amateur/listeners" 33 | SONDEHUB_SONDE_RECOVERED_URL = "https://api.v2.sondehub.org/recovered" 34 | SONDEHUB_SONDE_RECOVERED_URL_AMATEUR = "https://api.v2.sondehub.org/amateur/recovered" 35 | 36 | def __init__( 37 | self, 38 | update_rate=30, 39 | callsign="N0CALL", 40 | upload_enabled=True, 41 | upload_timeout=10, 42 | upload_retries=2, 43 | amateur=False # Upload to amateur DB instead of regular sondehub 44 | ): 45 | """ Initialise the Sondehub Chase uploader, and start the update thread """ 46 | 47 | self.update_rate = update_rate 48 | self.callsign = callsign 49 | self.callsign_init = False 50 | self.upload_enabled = upload_enabled 51 | self.upload_timeout = upload_timeout 52 | self.upload_retries = upload_retries 53 | self.amateur = amateur 54 | 55 | self.car_position = None 56 | self.car_position_lock = Lock() 57 | 58 | self.uploader_thread_running = True 59 | self.uploader_thread = Thread(target=self.upload_thread) 60 | self.uploader_thread.start() 61 | 62 | if amateur: 63 | self.position_url = self.SONDEHUB_STATION_POSITION_URL_AMATEUR 64 | self.recovery_url = self.SONDEHUB_SONDE_RECOVERED_URL_AMATEUR 65 | logging.info("Sondehub-Amateur - Chase-Car Position Uploader Started") 66 | else: 67 | self.position_url = self.SONDEHUB_STATION_POSITION_URL 68 | self.recovery_url = self.SONDEHUB_SONDE_RECOVERED_URL 69 | logging.info("Sondehub - Chase-Car Position Uploader Started") 70 | 71 | def update_position(self, position): 72 | """ Update the chase car position state 73 | This function accepts and stores a copy of the same dictionary structure produced by both 74 | Horus UDP broadcasts, and the serial GPS and GPSD modules 75 | """ 76 | 77 | with self.car_position_lock: 78 | self.car_position = position.copy() 79 | 80 | def upload_thread(self): 81 | """ Uploader thread """ 82 | while self.uploader_thread_running: 83 | 84 | # Grab a copy of the most recent car position. 85 | with self.car_position_lock: 86 | if self.car_position != None: 87 | _position = self.car_position.copy() 88 | else: 89 | _position = None 90 | 91 | if self.upload_enabled and _position != None: 92 | try: 93 | 94 | # Upload the listener position. 95 | self.upload_position( 96 | self.callsign, 97 | _position["latitude"], 98 | _position["longitude"], 99 | _position["altitude"], 100 | ) 101 | except Exception as e: 102 | logging.error( 103 | "Sondehub - Error uploading chase-car position - %s" % str(e) 104 | ) 105 | 106 | # Wait for next update. 107 | _i = 0 108 | while (_i < self.update_rate) and self.uploader_thread_running: 109 | time.sleep(1) 110 | _i += 1 111 | 112 | def set_update_rate(self, rate): 113 | """ Set the update rate """ 114 | self.update_rate = int(rate) 115 | 116 | def set_callsign(self, call): 117 | """ Set the callsign """ 118 | self.callsign = call 119 | 120 | def upload_position( 121 | self, callsign, latitude, longitude, altitude, antenna="Chase Car", mobile=True 122 | ): 123 | """ Upload a chase car position to Sondehub 124 | This uses the PUT /listeners API described here: 125 | https://github.com/projecthorus/sondehub-infra/wiki/API-(Beta) 126 | """ 127 | 128 | _position = { 129 | "software_name": "ChaseMapper", 130 | "software_version": chasemapper.__version__, 131 | "uploader_callsign": callsign, 132 | "uploader_position": [latitude, longitude, altitude], 133 | "uploader_antenna": antenna, 134 | "uploader_contact_email": "none@none.com", 135 | "mobile": mobile, 136 | } 137 | 138 | _retries = 0 139 | _upload_success = False 140 | 141 | _start_time = time.time() 142 | 143 | while _retries < self.upload_retries: 144 | # Run the request. 145 | try: 146 | headers = { 147 | "User-Agent": "chasemapper-" + chasemapper.__version__, 148 | "Content-Type": "application/json", 149 | } 150 | _req = requests.put( 151 | self.position_url, 152 | json=_position, 153 | # TODO: Revisit this second timeout value. 154 | timeout=(self.upload_timeout, 6.1), 155 | headers=headers, 156 | ) 157 | except Exception as e: 158 | logging.error("Sondehub - Upload Failed: %s" % str(e)) 159 | return 160 | 161 | if _req.status_code == 200: 162 | # 200 is the only status code that we accept. 163 | _upload_time = time.time() - _start_time 164 | logging.debug("Sondehub - Uploaded chase-car position to Sondehub.") 165 | _upload_success = True 166 | break 167 | 168 | elif _req.status_code == 500: 169 | # Server Error, Retry. 170 | _retries += 1 171 | continue 172 | 173 | else: 174 | logging.error( 175 | "Sondehub - Error uploading chase-car position to Sondehub. Status Code: %d %s." 176 | % (_req.status_code, _req.text) 177 | ) 178 | break 179 | 180 | if not _upload_success: 181 | logging.error( 182 | "Sondehub - Chase-car position upload failed after %d retries" 183 | % (_retries) 184 | ) 185 | logging.debug(f"Attempted to upload {json.dumps(_position)}") 186 | 187 | 188 | def mark_payload_recovered(self, serial=None, callsign=None, lat=0.0, lon=0.0, alt=0.0, message="", recovered=True): 189 | """ Upload an indication that a payload (radiosonde or otherwise) has been recovered """ 190 | 191 | if serial is None: 192 | return 193 | 194 | _doc = { 195 | "serial": serial, 196 | "lat": lat, 197 | "lon": lon, 198 | "alt": alt, 199 | "recovered": recovered, 200 | "recovered_by": callsign, 201 | "description": message 202 | } 203 | 204 | _retries = 0 205 | _upload_success = False 206 | 207 | _start_time = time.time() 208 | 209 | while _retries < self.upload_retries: 210 | # Run the request. 211 | try: 212 | headers = { 213 | "User-Agent": "chasemapper-" + chasemapper.__version__, 214 | "Content-Type": "application/json", 215 | } 216 | _req = requests.put( 217 | self.recovery_url, 218 | json=_doc, 219 | # TODO: Revisit this second timeout value. 220 | timeout=(self.upload_timeout, 6.1), 221 | headers=headers, 222 | ) 223 | except Exception as e: 224 | logging.error("Sondehub - Recovery Upload Failed: %s" % str(e)) 225 | return 226 | 227 | if _req.status_code == 200: 228 | # 200 is the only status code that we accept. 229 | _upload_time = time.time() - _start_time 230 | logging.info("Sondehub - Uploaded recovery notification to Sondehub.") 231 | _upload_success = True 232 | break 233 | 234 | elif _req.status_code == 400: 235 | try: 236 | _resp = json.loads(_req.text) 237 | logging.info(f"Sondehub - {_resp['message']}") 238 | except: 239 | logging.info(f"Sondehub - Got code 400 from Sondehub.") 240 | 241 | _upload_success = True 242 | break 243 | 244 | elif _req.status_code == 500: 245 | # Server Error, Retry. 246 | _retries += 1 247 | continue 248 | 249 | else: 250 | logging.error( 251 | "Sondehub - Error uploading recovery notification to Sondehub. Status Code: %d %s." 252 | % (_req.status_code, _req.text) 253 | ) 254 | break 255 | 256 | if not _upload_success: 257 | logging.error( 258 | "Sondehub - Recovery notification upload failed after %d retries" 259 | % (_retries) 260 | ) 261 | logging.debug(f"Attempted to upload {json.dumps(_doc)}") 262 | 263 | 264 | 265 | def close(self): 266 | self.uploader_thread_running = False 267 | try: 268 | self.uploader_thread.join() 269 | except: 270 | pass 271 | logging.info("Sondehub - Chase-Car Position Uploader Closed") 272 | -------------------------------------------------------------------------------- /chasemapper/tawhiri.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Project Horus - Browser-Based Chase Mapper - Tawhiri Interface 4 | # 5 | # Grab predictions from the Tawhiri Predictions API 6 | # Refer here for documentation on Tawhiri: https://tawhiri.readthedocs.io/en/latest/api.html 7 | # 8 | # Copyright (C) 2020 Mark Jessop 9 | # Released under GNU GPL v3 or later 10 | # 11 | import datetime 12 | import logging 13 | import pytz 14 | import requests 15 | import subprocess 16 | from dateutil.parser import parse 17 | from threading import Thread 18 | 19 | TAWHIRI_API_URL = "http://api.v2.sondehub.org/tawhiri" 20 | 21 | 22 | def get_tawhiri_prediction( 23 | launch_datetime, 24 | launch_latitude, 25 | launch_longitude, 26 | launch_altitude=0, 27 | ascent_rate=5.0, 28 | burst_altitude=30000.0, 29 | descent_rate=5.0, 30 | profile="standard_profile", 31 | dataset=None, 32 | timeout=10, 33 | ): 34 | """ Request a Prediction from the Tawhiri Predictor API """ 35 | 36 | # Localise supplied time to UTC if not already done 37 | if launch_datetime.tzinfo is None: 38 | launch_datetime = pytz.utc.localize(launch_datetime) 39 | 40 | # Create RFC3339-compliant timestamp 41 | _dt_rfc3339 = launch_datetime.isoformat() 42 | 43 | # Normalise longitude to range 0 to 360 44 | if launch_longitude < 0: 45 | launch_longitude += 360 46 | 47 | _params = { 48 | "launch_latitude": launch_latitude, 49 | "launch_longitude": launch_longitude, 50 | "launch_altitude": launch_altitude, 51 | "launch_datetime": _dt_rfc3339, 52 | "ascent_rate": ascent_rate, 53 | "descent_rate": descent_rate, 54 | "burst_altitude": burst_altitude, 55 | "profile": profile, 56 | } 57 | 58 | if dataset: 59 | _params["dataset"] = dataset 60 | 61 | logging.debug("Tawhiri - Requesting prediction using parameters: %s" % str(_params)) 62 | 63 | try: 64 | _r = requests.get(TAWHIRI_API_URL, params=_params, timeout=timeout) 65 | 66 | _json = _r.json() 67 | 68 | if "error" in _json: 69 | # The Tawhiri API has returned an error 70 | _error = "%s: %s" % (_json["error"]["type"], _json["error"]["description"]) 71 | 72 | logging.error("Tawhiri - %s" % _error) 73 | 74 | return None 75 | 76 | else: 77 | return parse_tawhiri_data(_json) 78 | 79 | except Exception as e: 80 | logging.error("Tawhiri - Error running prediction: %s" % str(e)) 81 | 82 | return None 83 | 84 | 85 | def parse_tawhiri_data(data): 86 | """ Parse a returned flight trajectory from Tawhiri, and convert it to a cusf_predictor_wrapper compatible format """ 87 | 88 | _epoch = pytz.utc.localize(datetime.datetime(1970, 1, 1)) 89 | # Extract dataset information 90 | _dataset = parse(data["request"]["dataset"]) 91 | _dataset = _dataset.strftime("%Y%m%d%Hz") 92 | 93 | _path = [] 94 | 95 | for _stage in data["prediction"]: 96 | _trajectory = _stage["trajectory"] 97 | 98 | for _point in _trajectory: 99 | 100 | # Normalise longitude to range -180 to 180 101 | if _point["longitude"] > 180: 102 | _point["longitude"] -= 360 103 | 104 | # Create UTC timestamp without using datetime.timestamp(), for Python 2.7 backwards compatibility. 105 | _dt = parse(_point["datetime"]) 106 | _dt_timestamp = (_dt - _epoch).total_seconds() 107 | _path.append( 108 | [ 109 | _dt_timestamp, 110 | _point["latitude"], 111 | _point["longitude"], 112 | _point["altitude"], 113 | ] 114 | ) 115 | 116 | _output = {"dataset": _dataset, "path": _path} 117 | 118 | return _output 119 | 120 | 121 | if __name__ == "__main__": 122 | import datetime 123 | import pprint 124 | 125 | logging.basicConfig( 126 | format="%(asctime)s %(levelname)s:%(message)s", level=logging.INFO 127 | ) 128 | 129 | _now = datetime.datetime.utcnow() 130 | 131 | # Regular complete-flightpath prediction 132 | _data = get_tawhiri_prediction( 133 | launch_datetime=_now, 134 | launch_latitude=-34.9499, 135 | launch_longitude=138.5194, 136 | launch_altitude=0, 137 | ) 138 | pprint.pprint(_data) 139 | 140 | # Descent prediction 141 | _data = get_tawhiri_prediction( 142 | launch_datetime=_now, 143 | launch_latitude=-34.9499, 144 | launch_longitude=138.5194, 145 | launch_altitude=10000, 146 | burst_altitude=10001, 147 | descent_rate=abs(-6.0), 148 | ) 149 | pprint.pprint(_data) 150 | -------------------------------------------------------------------------------- /doc/bearings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/doc/bearings.jpg -------------------------------------------------------------------------------- /doc/chasemapper.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/doc/chasemapper.jpg -------------------------------------------------------------------------------- /doc/mark_recovered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/doc/mark_recovered.png -------------------------------------------------------------------------------- /doc/payload_options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/doc/payload_options.png -------------------------------------------------------------------------------- /gfs/gfs_data_goes_here.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/gfs/gfs_data_goes_here.txt -------------------------------------------------------------------------------- /horusmapper.cfg.example: -------------------------------------------------------------------------------- 1 | # 2 | # Project Horus Chase-Mapper Configuration File 3 | # 4 | # Copy this file to horusmapper.cfg and modify as required. 5 | # 6 | # This is the default config file for chasemapper 1.5.0, be aware there might be changes and it might not run on older/newer versions. 7 | # 8 | 9 | # 10 | # Telemetry Source Profiles 11 | # Multiple Telemetry source profiles can be defined, and can be selected from 12 | # the web GUI. 13 | # 14 | [profile_selection] 15 | # How many profiles have been defined 16 | profile_count = 2 17 | # Index of the default profile (indexing from 1) 18 | default_profile = 1 19 | 20 | 21 | [profile_1] 22 | # An example configuration which displays telemetry from auto_rx, and obtains chase car positions from GPSD. 23 | # Profile name - will be shown in the web client. 24 | profile_name = auto-rx 25 | # Telemetry source type: 26 | # ozimux - Read data in OziMux format (TELEMETRY,HH:MM:SS,lat,lon,alt\n) 27 | # horus_udp - Read Horus UDP Broadcast 'Payload Summary' messages, as emitted by auto_rx and the horus-gui software. 28 | telemetry_source_type = horus_udp 29 | # Telemetry source port (UDP) (auto_rx defauts to 55673) 30 | telemetry_source_port = 55673 31 | 32 | # Car Position Source 33 | # none - No Chase-Car GPS 34 | # horus_udp - Read Horus UDP Broadcast 'Car GPS' messages 35 | # serial - Read GPS positions from a serial-connected GPS receiver. RECOMMENDED 36 | # gpsd - Poll a local GPSD instance for positions. (Known to have some reliability issues...) 37 | # station - Stationary position (set in the [map] section below) 38 | car_source_type = serial 39 | # Car position source port (UDP) - only used if horus_udp is selected, but still needs to be provided. 40 | car_source_port = 55672 41 | 42 | # Online Tracker System 43 | # Where to upload chase-car positions and balloon recovery notifications to. 44 | # Note - you can only select one of these at a time. 45 | # 46 | # sondehub = Sondehub v2 Database, for viewing on the SondeHub tracker (https://tracker.sondehub.org) 47 | # Use this for chasing meteorological radiosondes! 48 | # 49 | # sondehubamateur = SondeHub Amateur Database, for viewing on the SondeHub-Amateur Tracker (https://amateur.sondehub.org) 50 | # Use this when chasing your own flights, and you want to show up on the sondehub-amateur tracker. 51 | # 52 | online_tracker = sondehub 53 | 54 | # Other profiles can be defined in sections like the following: 55 | [profile_2] 56 | # Example profile to take telemetry data from an instance of horus-gui, which defaults to 57 | # emitting hrous_udp messages on port 55672, and using a Serial-connected GPS, with settings defined further below. 58 | # The horusdemodlib command-line utilites emit telemetry on the same port, so this profile will work with that too. 59 | profile_name = horus-gui 60 | telemetry_source_type = horus_udp 61 | telemetry_source_port = 55672 62 | 63 | # Car Position Source 64 | car_source_type = serial 65 | # Since we are using a serial GPS, the car_source_port argument isn't used, but still has to be defined. 66 | # Make sure to update the gps_serial settings further down in the configuration file! 67 | car_source_port = 55672 68 | 69 | # Online Tracker System 70 | # Where to upload chase-car positions and balloon recovery notifications to. 71 | # sondehub = Sondehub v2 Database, for viewing on the SondeHub tracker (https://tracker.sondehub.org) 72 | # sondehubamateur = SondeHub Amateur Database, for viewing on the SondeHub-Amateur Tracker (https://amateur.sondehub.org) 73 | online_tracker = sondehubamateur 74 | 75 | # If you want add more profiles, you can do so here, e.g. 76 | # [profile_3] 77 | # ... 78 | # [profile_4] 79 | # ... 80 | 81 | 82 | [gpsd] 83 | # GPSD Host/Port - Only used if selected in a telemetry profile above. 84 | # Note that GPSD support is somewhat buggy. 85 | gpsd_host = localhost 86 | gpsd_port = 2947 87 | 88 | 89 | [gps_serial] 90 | # Serial GPS Settings - Only used if selected in a telemetry profile above. 91 | # GPS serial device (i.e. /dev/ttyUSB0, COM1, etc...) 92 | gps_port = /dev/ttyUSB0 93 | # GPS baud rate 94 | gps_baud = 9600 95 | 96 | 97 | # Map Settings 98 | [map] 99 | # Host/port to host webserver on 100 | flask_host = 0.0.0.0 101 | flask_port = 5001 102 | 103 | # Default Map Centre & Stationary Position 104 | # If the profile's car_source_type is set to station, the following position will be indicated on the map 105 | # as a stationary receiver. 106 | default_lat = -34.9 107 | default_lon = 138.6 108 | default_alt = 0.0 109 | 110 | # How long to keep payload data (minutes) 111 | payload_max_age = 180 112 | 113 | # ThunderForest API Key 114 | # NOTE: OpenTopoMaps is now available by default, and is a good alternative to ThunderForest's outdoors map. 115 | # If you still want to use ThunderForest's Outdoors map (Topographic maps), you will need to 116 | # register for an API key here: https://manage.thunderforest.com/users/sign_up?plan_id=5 117 | # Once you have a key, enter it below: 118 | thunderforest_api_key = none 119 | 120 | # Stadia Maps API Key 121 | # Stadia provides an excellent Dark map layer (Alidade Smooth Dark), which is looking promising for use in a 'dark mode' theme. 122 | # Accessing this requires registering at https://client.stadiamaps.com/signup/ then creating a 'property' 123 | # and obtaining an API key. Once you have a key, enter it below: 124 | stadia_api_key = none 125 | 126 | # Predictor Settings 127 | # By default this will attempt to get predictions from the online Tawhiri Predictions API. 128 | # Optionally, you can enable offline predictions below. 129 | [predictor] 130 | # Enable Predictor (True/False) - This can also be enabled from the web client. 131 | predictor_enabled = True 132 | 133 | # Predictor defaults - these can also be modified at runtime in the web interface. 134 | default_burst = 30000 135 | default_descent_rate = 5.0 136 | 137 | # How many data points to average the payload's ascent rate over. 138 | # Note that this is data points, not *seconds*, so you may need to tune this for your payload's 139 | # position update rate. 140 | # Longer averaging means a smoother ascent rate. ~10 seems ok for a typical Horus Binary payload. 141 | ascent_rate_averaging = 10 142 | 143 | # Offline Predictions 144 | # Use of the offline predictor requires installing the CUSF Predictor Python Wrapper from here: 145 | # https://github.com/darksidelemm/cusf_predictor_wrapper 146 | # You also need to compile the predictor binary, and copy it into this directory. 147 | # 148 | # Note: This setting turns offline predictions *on* by default, which assumes there is a valid 149 | # GFS dataset already present and available. 150 | # If you will be using the 'Download Model' button, then leave this at False, and Offline predictions 151 | # will be enabled once a valid model is available. 152 | # Downloading of a new model can also be triggered by running: curl http://localhost:5001/download_model 153 | offline_predictions = False 154 | 155 | # Predictory Binary Location 156 | # Where to find the built CUSF predictor binary. This will usually be ./pred or pred.exe (on Windows) 157 | pred_binary = ./pred 158 | 159 | # Directory containing GFS model data. 160 | gfs_directory = ./gfs/ 161 | 162 | # Wind Model Download Command 163 | # Optional command to enable downloading of wind data via a web client button. 164 | # Example: 165 | # model_download = python3 -m cusfpredict.gfs --lat=-33 --lon=139 --latdelta=10 --londelta=10 -f 24 -m 0p50 -o gfs 166 | # The gfs directory (above) will be cleared of all data files once the new model is downloaded. 167 | model_download = none 168 | 169 | 170 | # 171 | # Offline Tile Server 172 | # 173 | # Allows serving of map tiles from a directory. 174 | # Each subdirectory is assumed to be a separate layer of map tiles, i.e. 'OSM', 'opencyclemap', 175 | # and is added to the map interface as a separate layer. 176 | # This feature can be used to serve up FoxtrotGPS's tile cache as layers, usually located in ~/Maps/ 177 | # 178 | [offline_maps] 179 | # Enable serving up maps from a directory of map tiles. 180 | tile_server_enabled = False 181 | 182 | # Path to map tiles. For FoxtrotGPS, this is usually ~/Maps/ 183 | # NOTE: This must be an ABSOLUTE directory, i.e. /home/pi/Maps/ , using ~/Maps/ will not work. 184 | 185 | tile_server_path = /home/pi/Maps/ 186 | 187 | # If running chasemapper within a docker container, comment out the above line, and uncomment the following: 188 | #tile_server_path = /opt/chasemapper/Maps/ 189 | 190 | # 191 | # SondeHub Chase-Car Position Upload 192 | # If you want, this application can upload your chase-car position to the SondeHub/SondeHub-Amateur trackers, 193 | # for those follwing along at home. 194 | # The settings below can be modified from the web interface, but they will default to what is set below on startup. 195 | # 196 | # Note - Variables in this section still refer to habitat to avoid breaking existing configurations. 197 | [habitat] 198 | # Enable uploading of chase-car position to SondeHub / SondeHub-Amateur (True / False) 199 | # Which tracker positions are uploaded to depends on the online_tracker setting of the selected 200 | # profile (further up in this config file). 201 | habitat_upload_enabled = False 202 | 203 | # Callsign to use when uploading. Note that _chase is automatically appended to this callsign 204 | # when displayed on the tracker maps. 205 | # i.e. N0CALL will show up as N0CALL_chase on sondehub.org 206 | habitat_call = N0CALL 207 | 208 | # Attempt to upload position to SondeHub every x seconds. 209 | habitat_update_rate = 30 210 | 211 | 212 | # 213 | # Range Rings 214 | # 215 | [range_rings] 216 | range_rings_enabled = False 217 | 218 | # Number of range rings to display. The first ring starts at the spacing set below. 219 | range_ring_quantity = 5 220 | 221 | # Spacing between rings, in metres. 222 | range_ring_spacing = 1000 223 | 224 | # Weight of the ring, in pixels. 225 | range_ring_weight = 1.5 226 | 227 | # Color of the range rings. 228 | # Valid options are: red, black, blue, green, custom 229 | range_ring_color = red 230 | 231 | # Custom range ring color, in hexadecimal #RRGGBB 232 | range_ring_custom_color = #FF0000 233 | 234 | # 235 | # Chase Car Speedometer 236 | # If enabled display the chase car speed at the bottom left of the display. 237 | # 238 | [speedo] 239 | chase_car_speed = True 240 | 241 | # 242 | # Bearing Processing 243 | # 244 | [bearings] 245 | 246 | # Number of bearings to store 247 | max_bearings = 300 248 | 249 | # Maximum age of bearings, in *minutes*. 250 | max_bearing_age = 10 251 | 252 | # Car heading speed gate 253 | # Only consider car headings to be valid if the car speed is greater than this value in *kph* 254 | car_speed_gate = 10 255 | 256 | # Turn rate threshold 257 | # Only plot bearings if the turn rate of the vehicle is less than this value, in degrees/second 258 | # This helps avoid plotting bearings when the heading and bearind data might be misaligned during 259 | # a turn (e.g. around a roundabout) 260 | # 4 degrees/second seems to work fairly well. 261 | turn_rate_threshold = 4.0 262 | 263 | # Visual Settings - these can be adjust in the Web GUI during runtime 264 | 265 | # Bearing length in km 266 | bearing_length = 10 267 | 268 | # Weight of the bearing lines, in pixels. 269 | bearing_weight = 1.0 270 | 271 | # Color of the bearings. 272 | # Valid options are: red, black, blue, green, white, custom 273 | bearing_color = red 274 | 275 | # Custom bearing color, in hexadecimal #RRGGBB 276 | bearing_custom_color = #FF0000 277 | 278 | 279 | 280 | [units] 281 | 282 | # unitselection allows choice of metric - the default or imperial - horizontal miles and feet for short distances, horizontal miles per hour, vertical feet, vertical feet per minute 283 | # this is applied only to the indications and to the range ring settings 284 | 285 | 286 | unitselection = metric 287 | #unitselection = imperial 288 | 289 | # Sensible choice of unit selection (all thresholds set in metric) 290 | 291 | # This is the threshold for switching from miles to feet, set in metres. 292 | switch_miles_feet = 400 293 | 294 | 295 | [history] 296 | 297 | # Enable load of last position from log files (True/False) 298 | reload_last_position = False 299 | 300 | 301 | -------------------------------------------------------------------------------- /log_files/chase_logs_go_here.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/log_files/chase_logs_go_here.txt -------------------------------------------------------------------------------- /log_playback.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # ChaseMapper - Log File Playback 4 | # 5 | # Copyright (C) 2019 Mark Jessop 6 | # Released under GNU GPL v3 or later 7 | # 8 | # 9 | # TODO: 10 | # [x] Playback of basic log entries (car position, balloon telemetry, bearings) 11 | # [ ] Playback and display of balloon prediction data 12 | # [ ] Skip forward / back through file 13 | # 14 | import json 15 | import socket 16 | import sys 17 | import time 18 | import datetime 19 | import traceback 20 | from dateutil.parser import * 21 | 22 | 23 | def send_bearing(json_data, udp_port=55672, hostname=''): 24 | """ 25 | Grab bearing data out of a json log entry and send it via UDP. 26 | 27 | Example bearing line: 28 | {"bearing": 112.0, 29 | "confidence": 47.24745556875042, 30 | "power": 25.694795608520508, 31 | "raw_bearing_angles": [0.0, 1.0, ... 359.0, 360.0], 32 | "raw_doa": [-4.722, -4.719, ... -4.724, -4.722], 33 | "source": "kerberos-sdr", 34 | "bearing_type": "relative", 35 | "log_time": "2019-08-19T11:21:51.714657+00:00", 36 | "type": "BEARING", 37 | "log_type": "BEARING"} 38 | """ 39 | # Also get bearings of form: 40 | # {"type": "BEARING", "bearing_type": "absolute", "source": "EasyBearing", "latitude": -34.9016115, 41 | #"longitude": 138.58986819999998, "bearing": 0, "log_type": "BEARING", "log_time": "2021-12-10T07:33:14.156227+00:00"} 42 | 43 | packet = json_data 44 | 45 | packet['replay_time'] = json_data['log_time'] 46 | 47 | if 'kerberos' in json_data['source']: 48 | # Log data from the kerberos has been flipped in bearing already. Need to make sure this isn't done twice. 49 | packet['source'] = 'replay' 50 | 51 | 52 | # Set up our UDP socket 53 | s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) 54 | s.settimeout(1) 55 | # Set up socket for broadcast, and allow re-use of the address 56 | s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1) 57 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 58 | try: 59 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 60 | except: 61 | pass 62 | s.bind(('',udp_port)) 63 | try: 64 | s.sendto(json.dumps(packet).encode('ascii'), (hostname, udp_port)) 65 | except socket.error as e: 66 | s.sendto(json.dumps(packet).encode('ascii'), ('127.0.0.1', udp_port)) 67 | 68 | if hostname != '': 69 | s.sendto(json.dumps(packet).encode('ascii'), ('127.0.0.1', udp_port)) 70 | 71 | 72 | def send_car_position(json_data, udp_port=55672): 73 | """ 74 | Grab car position data from a json log entry and emit it 75 | 76 | {"comment": "CAR", 77 | "log_time": "2019-08-19T11:21:52.303204+00:00", 78 | "lon": 138.68833, 79 | "log_type": "CAR POSITION", 80 | "time": "2019-08-19T11:21:52.300687+00:00", 81 | "lat": -34.71413666666667, 82 | "alt": 69.3, 83 | "speed": 17.118923473141397, 84 | "heading": 27.53170956683383} 85 | """ 86 | 87 | packet = { 88 | 'type' : 'GPS', 89 | 'latitude' : json_data['lat'], 90 | 'longitude' : json_data['lon'], 91 | 'altitude': json_data['alt'], 92 | 'speed': json_data['speed'], 93 | 'valid': True, 94 | 'replay_time': json_data['log_time'] 95 | } 96 | 97 | if 'heading' in json_data: 98 | packet['heading'] = json_data['heading'] 99 | 100 | # Set up our UDP socket 101 | s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) 102 | s.settimeout(1) 103 | # Set up socket for broadcast, and allow re-use of the address 104 | s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1) 105 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 106 | try: 107 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 108 | except: 109 | pass 110 | s.bind(('',udp_port)) 111 | try: 112 | s.sendto(json.dumps(packet).encode('ascii'), ('', udp_port)) 113 | except socket.error: 114 | s.sendto(json.dumps(packet).encode('ascii'), ('127.0.0.1', udp_port)) 115 | 116 | 117 | def send_balloon_telemetry(json_data, udp_port=55672): 118 | """ Grab balloon telemetry data from a JSON log entry and emit it 119 | 120 | {"sats": -1, 121 | "log_time": "2019-08-21T11:02:25.596045+00:00", 122 | "temp": -1, 123 | "lon": 138.000000, 124 | "callsign": "HORUS", 125 | "time": "2019-08-21T11:02:16+00:00", 126 | "lat": -34.000000, 127 | "alt": 100, 128 | "log_type": "BALLOON TELEMETRY"} 129 | 130 | """ 131 | 132 | packet = { 133 | 'type' : 'PAYLOAD_SUMMARY', 134 | 'latitude' : json_data['lat'], 135 | 'longitude' : json_data['lon'], 136 | 'altitude': json_data['alt'], 137 | 'callsign': json_data['callsign'], 138 | 'time': parse(json_data['time']).strftime("%H:%M:%S"), 139 | 'comment': "Log Playback", 140 | 'replay_time': json_data['log_time'] 141 | } 142 | 143 | # Set up our UDP socket 144 | s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) 145 | s.settimeout(1) 146 | # Set up socket for broadcast, and allow re-use of the address 147 | s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1) 148 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 149 | try: 150 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 151 | except: 152 | pass 153 | s.bind(('',udp_port)) 154 | try: 155 | s.sendto(json.dumps(packet).encode('ascii'), ('', udp_port)) 156 | except socket.error: 157 | s.sendto(json.dumps(packet).encode('ascii'), ('127.0.0.1', udp_port)) 158 | 159 | 160 | def playback_json(filename, udp_port=55672, speed=1.0, start_time = 0, hostname=''): 161 | """ Read in a JSON log file and play it back in real-time, or with a speed factor """ 162 | 163 | with open(filename, 'r') as _log_file: 164 | 165 | try: 166 | _first_line = _log_file.readline() 167 | _log_data = json.loads(_first_line) 168 | _previous_time = parse(_log_data['log_time']) 169 | _first_time = _previous_time 170 | except Exception as e: 171 | print("First line of file must be a valid log entry - %s" % str(e)) 172 | return 173 | 174 | for _line in _log_file: 175 | try: 176 | _log_data = json.loads(_line) 177 | 178 | _new_time = parse(_log_data['log_time']) 179 | 180 | _time_delta = (_new_time - _previous_time).total_seconds() 181 | _previous_time = _new_time 182 | 183 | # Running timer 184 | _run_time = (_new_time - _first_time).total_seconds() 185 | 186 | 187 | if _run_time < start_time: 188 | continue 189 | 190 | _time_min = int(_run_time)//60 191 | _time_sec = _run_time%60.0 192 | 193 | if (_time_delta < 100): 194 | time.sleep(_time_delta/speed) 195 | 196 | if _log_data['log_type'] == 'CAR POSITION': 197 | send_car_position(_log_data, udp_port) 198 | print("%s - %02d:%.2f - Car Position" % (_log_data['log_time'], _time_min, _time_sec)) 199 | 200 | elif _log_data['log_type'] == 'BEARING': 201 | send_bearing(_log_data, udp_port, hostname=hostname) 202 | print("%s - %02d:%.2f - Bearing Data" % (_log_data['log_time'], _time_min, _time_sec)) 203 | 204 | elif _log_data['log_type'] == 'BALLOON TELEMETRY': 205 | send_balloon_telemetry(_log_data, udp_port) 206 | print("%02d:%.2f - Balloon Telemetry (%s)" % (_time_min, _time_sec, _log_data['callsign'])) 207 | 208 | elif _log_data['log_type'] == 'PREDICTION': 209 | print("%02d:%.2f - Prediction (Not re-played)" % (_time_min, _time_sec)) 210 | 211 | else: 212 | print("%02d:%.2f - Unknown: %s" % (_time_min, _time_sec, _log_data['log_type'])) 213 | 214 | except Exception as e: 215 | print("Invalid log entry: %s" % str(e)) 216 | 217 | 218 | 219 | 220 | 221 | if __name__ == '__main__': 222 | 223 | filename = "" 224 | speed = 1.0 225 | start_time = 0 226 | hostname = 'localhost' 227 | udp_port = 55672 228 | 229 | if len(sys.argv) == 2: 230 | filename = sys.argv[1] 231 | elif len(sys.argv) == 3: 232 | filename = sys.argv[1] 233 | speed = float(sys.argv[2]) 234 | elif len(sys.argv) == 4: 235 | filename = sys.argv[1] 236 | speed = float(sys.argv[2]) 237 | start_time = float(sys.argv[3])*60 238 | else: 239 | print("USAGE: python log_playback.py filename.log ") 240 | 241 | playback_json(filename, udp_port, speed, start_time, hostname=hostname) 242 | 243 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | eccodes 2 | cusfpredict 3 | flask 4 | flask-socketio 5 | lxml 6 | numpy 7 | python-dateutil 8 | pytz 9 | requests 10 | pyserial 11 | simple-websocket 12 | -------------------------------------------------------------------------------- /static/css/Leaflet.PolylineMeasure.css: -------------------------------------------------------------------------------- 1 | .leaflet-control { 2 | cursor: pointer; 3 | } 4 | 5 | a.polyline-measure-controlOnBgColor, a.polyline-measure-controlOnBgColor:hover { 6 | background-color: #8f8; 7 | } 8 | 9 | .polyline-measure-unicode-icon { 10 | font-size: 19px; 11 | font-weight: bold; 12 | } 13 | 14 | a.polyline-measure-clearControl:active { 15 | background-color: #f88; 16 | } 17 | 18 | .polyline-measure-tooltip { 19 | font: 10px Arial, Helvetica, sans-serif; 20 | line-height: 10px; 21 | background-color: rgba(255, 255, 170, 0.7); 22 | border-radius: 3px; 23 | box-shadow: 1px 1px 4px #888; 24 | margin: 0; 25 | padding: 2px; 26 | width: auto !important; 27 | height: auto !important; 28 | white-space: nowrap; 29 | text-align: right; 30 | } 31 | 32 | .polyline-measure-tooltip-end { 33 | background-color: rgba(255, 255, 40, 0.7); 34 | } 35 | 36 | .polyline-measure-tooltip-total { 37 | color: #006; 38 | font-weight: bold; 39 | } 40 | 41 | .polyline-measure-tooltip-difference { 42 | color: #060; 43 | font-style: italic; 44 | } 45 | 46 | .polyline-measure-popupTooltip { 47 | font: 11px Arial, Helvetica, sans-serif; 48 | line-height: 11px; 49 | } 50 | -------------------------------------------------------------------------------- /static/css/chasemapper.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | html, body, #map { 6 | height: 100%; 7 | width: 100vw; 8 | } 9 | 10 | .slimContainer { 11 | position: relative; 12 | margin: 20px auto; 13 | width: 290px; 14 | } 15 | .slimContainer hr { 16 | margin-bottom: 10px; 17 | } 18 | .slimContainer .row { 19 | width: 280px; 20 | display: block; 21 | margin: 5px; 22 | vertical-align: middle; 23 | position: relative; 24 | } 25 | .slimContainer .row.info { 26 | margin-top: 10px; 27 | } 28 | .slimContainer .row > span { 29 | float: left; 30 | } 31 | .slimContainer .row.option > span { 32 | width: 270px; 33 | } 34 | .slimContainer .row.option > span { 35 | line-height: 30px; 36 | } 37 | .slimContainer .row > span.r { 38 | float: right; 39 | } 40 | 41 | .paramRow { 42 | margin: 5px; 43 | display:flex; 44 | align-items: center; 45 | } 46 | 47 | .paramSelector { 48 | display: inline-block; 49 | margin-left: auto; 50 | } 51 | .paramEntry { 52 | display: inline-block; 53 | margin-left: auto; 54 | text-align: right; 55 | padding-right: 0.2em; 56 | width: 10em; 57 | } 58 | 59 | .paramEntryLeft { 60 | display: inline-block; 61 | margin-left: auto; 62 | text-align: left; 63 | padding-right: 0.2em; 64 | width: 10em; 65 | } 66 | 67 | .predictorModelValue { 68 | display: inline-block; 69 | margin-left: auto; 70 | text-align: right; 71 | } 72 | 73 | .timeToLanding { 74 | color:red; 75 | font-weight: bold; 76 | font-size:5em; 77 | } 78 | 79 | .chaseCarSpeed { 80 | color:black; 81 | font-weight: bold; 82 | font-size:3em; 83 | } 84 | 85 | .logTimeData { 86 | color:black; 87 | font-weight: bold; 88 | font-size:3em; 89 | } 90 | 91 | .bearingData { 92 | color:red; 93 | font-weight: bold; 94 | font-size:5em; 95 | } 96 | 97 | .dataAgeHeader { 98 | color:black; 99 | font-weight: bold; 100 | text-decoration: underline; 101 | font-size:1em; 102 | } 103 | .dataAgeOK { 104 | color:black; 105 | font-weight: bold; 106 | font-size:1em; 107 | } 108 | .dataAgeBad { 109 | color:red; 110 | font-weight: bold; 111 | font-size:1.5em; 112 | } 113 | 114 | .largeTableRow { 115 | font-size:200%; 116 | } 117 | 118 | .logText { 119 | font-size:70%; 120 | } 121 | 122 | #followPayloadButton { 123 | width:45px; 124 | height:45px; 125 | font-size:20px; 126 | line-height:45px; 127 | } 128 | 129 | #followCarButton { 130 | width:45px; 131 | height:45px; 132 | font-size:20px; 133 | line-height:45px; 134 | } 135 | 136 | #followCarButton { 137 | width:45px; 138 | height:45px; 139 | font-size:20px; 140 | line-height:45px; 141 | } 142 | 143 | #bearingCW10Deg, #bearingCW5Deg, #bearingCW1Deg, #bearingCCW10Deg, #bearingCCW5Deg, #bearingCCW1Deg { 144 | width:45px; 145 | height:45px; 146 | font-size:20px; 147 | line-height:45px; 148 | } 149 | 150 | .custom_label { 151 | background: rgba(0, 0, 0, 0) !important; 152 | border: none !important; 153 | font-size: 12px; 154 | font-weight: bold; 155 | color: black; 156 | text-shadow: 157 | -1px -1px 2px #FFF, 158 | 1px -1px 2px #FFF, 159 | -1px 1px 2px #FFF, 160 | 1px 1px 2px #FFF; 161 | box-shadow: none !important; 162 | } 163 | 164 | .leaflet-control-container .leaflet-routing-container-hide { 165 | display: none; 166 | } 167 | 168 | .centerWrapper:before { 169 | content:''; 170 | height: 100%; 171 | display: inline-block; 172 | vertical-align: middle; 173 | } 174 | 175 | .center_btn { 176 | background: rgba(0, 0, 0, 0); 177 | right:10px; 178 | display:inline-block; 179 | vertical-align: middle; 180 | float: left; 181 | 182 | } 183 | 184 | .ui-dialog { z-index: 1000 !important ;} 185 | 186 | .form-switch { 187 | display: flex !important; 188 | margin: 5px; 189 | flex-direction: row-reverse !important; 190 | justify-content: space-between !important; 191 | } -------------------------------------------------------------------------------- /static/css/easy-button.css: -------------------------------------------------------------------------------- 1 | .leaflet-bar button, 2 | .leaflet-bar button:hover { 3 | background-color: #fff; 4 | border: none; 5 | border-bottom: 1px solid #ccc; 6 | width: 26px; 7 | height: 26px; 8 | line-height: 26px; 9 | display: block; 10 | text-align: center; 11 | text-decoration: none; 12 | color: black; 13 | } 14 | 15 | .leaflet-bar button { 16 | background-position: 50% 50%; 17 | background-repeat: no-repeat; 18 | overflow: hidden; 19 | display: block; 20 | } 21 | 22 | .leaflet-bar button:hover { 23 | background-color: #f4f4f4; 24 | } 25 | 26 | .leaflet-bar button:first-of-type { 27 | border-top-left-radius: 4px; 28 | border-top-right-radius: 4px; 29 | } 30 | 31 | .leaflet-bar button:last-of-type { 32 | border-bottom-left-radius: 4px; 33 | border-bottom-right-radius: 4px; 34 | border-bottom: none; 35 | } 36 | 37 | .leaflet-bar.disabled, 38 | .leaflet-bar button.disabled { 39 | cursor: default; 40 | pointer-events: none; 41 | opacity: .4; 42 | } 43 | 44 | .easy-button-button .button-state{ 45 | display: block; 46 | width: 100%; 47 | height: 100%; 48 | position: relative; 49 | } 50 | 51 | 52 | .leaflet-touch .leaflet-bar button { 53 | width: 30px; 54 | height: 30px; 55 | line-height: 30px; 56 | } 57 | -------------------------------------------------------------------------------- /static/css/images/layers-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/layers-2x.png -------------------------------------------------------------------------------- /static/css/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/layers.png -------------------------------------------------------------------------------- /static/css/images/marker-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/marker-icon-2x.png -------------------------------------------------------------------------------- /static/css/images/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/marker-icon.png -------------------------------------------------------------------------------- /static/css/images/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/marker-shadow.png -------------------------------------------------------------------------------- /static/css/images/ui-icons_444444_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/ui-icons_444444_256x240.png -------------------------------------------------------------------------------- /static/css/images/ui-icons_555555_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/ui-icons_555555_256x240.png -------------------------------------------------------------------------------- /static/css/images/ui-icons_777777_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/css/images/ui-icons_777777_256x240.png -------------------------------------------------------------------------------- /static/css/leaflet-control-topcenter.css: -------------------------------------------------------------------------------- 1 | /*********************************************** 2 | leaflet-control-topcenter.scss, 3 | 4 | (c) 2016, FCOO 5 | 6 | https://github.com/FCOO/leaflet-control-topcenter 7 | https://github.com/FCOO 8 | 9 | 10 | ************************************************/ 11 | /* control positioning */ 12 | .leaflet-center { 13 | position: relative !important; 14 | left: 0; 15 | right: 0; 16 | align-items: center; 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; } 20 | .leaflet-center .leaflet-control { 21 | bottom: 0; } 22 | 23 | .leaflet-control-container .leaflet-control-bottomcenter { 24 | position: absolute; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; } 28 | -------------------------------------------------------------------------------- /static/css/leaflet-routing-machine.css: -------------------------------------------------------------------------------- 1 | .leaflet-routing-container, .leaflet-routing-error { 2 | width: 320px; 3 | background-color: white; 4 | padding-top: 4px; 5 | transition: all 0.2s ease; 6 | box-sizing: border-box; 7 | } 8 | 9 | .leaflet-control-container .leaflet-routing-container-hide { 10 | width: 32px; 11 | height: 32px; 12 | } 13 | 14 | .leaflet-routing-container h2 { 15 | font-size: 14px; 16 | } 17 | 18 | .leaflet-routing-container h3 { 19 | font-size: 12px; 20 | font-weight: normal; 21 | } 22 | 23 | .leaflet-routing-collapsible .leaflet-routing-geocoders { 24 | margin-top: 20px; 25 | } 26 | 27 | .leaflet-routing-alt, .leaflet-routing-geocoders, .leaflet-routing-error { 28 | padding: 6px; 29 | margin-top: 2px; 30 | margin-bottom: 6px; 31 | border-bottom: 1px solid #ccc; 32 | max-height: 320px; 33 | overflow-y: auto; 34 | transition: all 0.2s ease; 35 | } 36 | 37 | .leaflet-control-container .leaflet-routing-container-hide .leaflet-routing-alt, 38 | .leaflet-control-container .leaflet-routing-container-hide .leaflet-routing-geocoders { 39 | display: none; 40 | } 41 | 42 | .leaflet-bar .leaflet-routing-alt:last-child { 43 | border-bottom: none; 44 | } 45 | 46 | .leaflet-routing-alt-minimized { 47 | color: #888; 48 | max-height: 64px; 49 | overflow: hidden; 50 | cursor: pointer; 51 | } 52 | 53 | .leaflet-routing-alt table { 54 | border-collapse: collapse; 55 | } 56 | 57 | .leaflet-routing-alt tr:hover { 58 | background-color: #eee; 59 | cursor: pointer; 60 | } 61 | 62 | .leaflet-routing-alt::-webkit-scrollbar { 63 | width: 8px; 64 | } 65 | 66 | .leaflet-routing-alt::-webkit-scrollbar-track { 67 | border-radius: 2px; 68 | background-color: #eee; 69 | } 70 | 71 | .leaflet-routing-alt::-webkit-scrollbar-thumb { 72 | border-radius: 2px; 73 | background-color: #888; 74 | } 75 | 76 | .leaflet-routing-icon { 77 | background-image: url('leaflet.routing.icons.png'); 78 | -webkit-background-size: 240px 20px; 79 | background-size: 240px 20px; 80 | background-repeat: no-repeat; 81 | margin: 0; 82 | content: ''; 83 | display: inline-block; 84 | vertical-align: top; 85 | width: 20px; 86 | height: 20px; 87 | } 88 | 89 | .leaflet-routing-icon-continue { background-position: 0 0; } 90 | .leaflet-routing-icon-sharp-right { background-position: -20px 0; } 91 | .leaflet-routing-icon-turn-right { background-position: -40px 0; } 92 | .leaflet-routing-icon-bear-right { background-position: -60px 0; } 93 | .leaflet-routing-icon-u-turn { background-position: -80px 0; } 94 | .leaflet-routing-icon-sharp-left { background-position: -100px 0; } 95 | .leaflet-routing-icon-turn-left { background-position: -120px 0; } 96 | .leaflet-routing-icon-bear-left { background-position: -140px 0; } 97 | .leaflet-routing-icon-depart { background-position: -160px 0; } 98 | .leaflet-routing-icon-enter-roundabout { background-position: -180px 0; } 99 | .leaflet-routing-icon-arrive { background-position: -200px 0; } 100 | .leaflet-routing-icon-via { background-position: -220px 0; } 101 | 102 | .leaflet-routing-geocoders div { 103 | padding: 4px 0px 4px 0px; 104 | } 105 | 106 | .leaflet-routing-geocoders input { 107 | width: 303px; 108 | width: calc(100% - 4px); 109 | line-height: 1.67; 110 | border: 1px solid #ccc; 111 | } 112 | 113 | .leaflet-routing-geocoders button { 114 | font: bold 18px 'Lucida Console', Monaco, monospace; 115 | border: 1px solid #ccc; 116 | border-radius: 4px; 117 | background-color: white; 118 | margin: 0; 119 | margin-right: 3px; 120 | float: right; 121 | cursor: pointer; 122 | transition: background-color 0.2s ease; 123 | } 124 | 125 | .leaflet-routing-add-waypoint:after { 126 | content: '+'; 127 | } 128 | 129 | .leaflet-routing-reverse-waypoints:after { 130 | font-weight: normal; 131 | content: '\21c5'; 132 | } 133 | 134 | .leaflet-routing-geocoders button:hover { 135 | background-color: #eee; 136 | } 137 | 138 | .leaflet-routing-geocoders input,.leaflet-routing-remove-waypoint,.leaflet-routing-geocoder { 139 | position: relative; 140 | } 141 | 142 | .leaflet-routing-geocoder-result { 143 | font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif; 144 | position: absolute; 145 | max-height: 0; 146 | overflow: hidden; 147 | transition: all 0.5s ease; 148 | z-index: 1000; /* Arbitrary, but try to be above "most" things. */ 149 | } 150 | 151 | .leaflet-routing-geocoder-result table { 152 | width: 100%; 153 | border: 1px solid #ccc; 154 | border-radius: 0 0 4px 4px; 155 | background-color: white; 156 | cursor: pointer; 157 | } 158 | 159 | .leaflet-routing-geocoder-result-open { 160 | max-height: 800px; 161 | } 162 | 163 | .leaflet-routing-geocoder-selected, .leaflet-routing-geocoder-result tr:hover { 164 | background-color: #eee; 165 | } 166 | 167 | .leaflet-routing-geocoder-no-results { 168 | font-style: italic; 169 | color: #888; 170 | } 171 | 172 | .leaflet-routing-remove-waypoint { 173 | background-color: transparent; 174 | display: inline-block; 175 | vertical-align: middle; 176 | cursor: pointer; 177 | } 178 | 179 | .leaflet-routing-remove-waypoint:after { 180 | position: absolute; 181 | display: block; 182 | width: 15px; 183 | height: 1px; 184 | z-index: 1; 185 | right: 1px; 186 | top: 4px; 187 | bottom: 0; 188 | margin: auto; 189 | padding: 2px; 190 | font-size: 18px; 191 | font-weight: bold; 192 | content: "\00d7"; 193 | text-align: center; 194 | cursor: pointer; 195 | color: #ccc; 196 | background: white; 197 | padding-bottom: 16px; 198 | margin-top: -16px; 199 | padding-right: 4px; 200 | line-height: 1; 201 | } 202 | 203 | .leaflet-routing-remove-waypoint:hover { 204 | color: black; 205 | } 206 | 207 | .leaflet-routing-instruction-distance { 208 | width: 48px; 209 | } 210 | 211 | .leaflet-routing-collapse-btn { 212 | position: absolute; 213 | top: 0; 214 | right: 6px; 215 | font-size: 24px; 216 | color: #ccc; 217 | font-weight: bold; 218 | } 219 | 220 | .leaflet-routing-collapse-btn:after { 221 | content: '\00d7'; 222 | } 223 | 224 | .leaflet-routing-container-hide .leaflet-routing-collapse-btn { 225 | position: relative; 226 | left: 4px; 227 | top: 4px; 228 | display: block; 229 | width: 26px; 230 | height: 23px; 231 | background-image: url('routing-icon.png'); 232 | } 233 | 234 | .leaflet-routing-container-hide .leaflet-routing-collapse-btn:after { 235 | content: none; 236 | } 237 | 238 | .leaflet-top .leaflet-routing-container.leaflet-routing-container-hide { 239 | margin-top: 10px !important; 240 | } 241 | .leaflet-right .leaflet-routing-container.leaflet-routing-container-hide { 242 | margin-right: 10px !important; 243 | } 244 | .leaflet-bottom .leaflet-routing-container.leaflet-routing-container-hide { 245 | margin-bottom: 10px !important; 246 | } 247 | .leaflet-left .leaflet-routing-container.leaflet-routing-container-hide { 248 | margin-left: 10px !important; 249 | } 250 | 251 | @media only screen and (max-width: 640px) { 252 | .leaflet-routing-container { 253 | margin: 0 !important; 254 | padding: 0 !important; 255 | width: 100%; 256 | height: 100%; 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /static/css/leaflet-sidebar.min.css: -------------------------------------------------------------------------------- 1 | .sidebar{position:absolute;top:0;bottom:0;width:100%;overflow:hidden;z-index:2000;box-shadow:0 1px 5px rgba(0,0,0,.65)}.sidebar.collapsed{width:40px}@media (min-width:768px) and (max-width:991px){.sidebar{width:305px}.sidebar-pane{min-width:265px}}@media (min-width:992px) and (max-width:1199px){.sidebar{width:390px}}@media (min-width:1200px){.sidebar{width:460px}}.sidebar-left{left:0}.sidebar-right{right:0}@media (min-width:768px){.sidebar{top:10px;bottom:10px;transition:width .5s}.sidebar-left{left:10px}.sidebar-right{right:10px}}.sidebar-tabs{top:0;bottom:0;height:100%;background-color:#fff}.sidebar-left .sidebar-tabs{left:0}.sidebar-right .sidebar-tabs{right:0}.sidebar-tabs,.sidebar-tabs>ul{position:absolute;width:40px;margin:0;padding:0;list-style-type:none}.sidebar-tabs>li,.sidebar-tabs>ul>li{width:100%;height:40px;color:#333;font-size:12pt;overflow:hidden;transition:all 80ms}.sidebar-tabs>li:hover,.sidebar-tabs>ul>li:hover{color:#000;background-color:#eee}.sidebar-tabs>li.active,.sidebar-tabs>ul>li.active{color:#fff;background-color:#0074d9}.sidebar-tabs>li.disabled,.sidebar-tabs>ul>li.disabled{color:rgba(51,51,51,.4)}.sidebar-tabs>li.disabled:hover,.sidebar-tabs>ul>li.disabled:hover{background:0 0}.sidebar-tabs>li.disabled>a,.sidebar-tabs>ul>li.disabled>a{cursor:default}.sidebar-tabs>li>a,.sidebar-tabs>ul>li>a{display:block;width:100%;height:100%;line-height:40px;color:inherit;text-decoration:none;text-align:center}.sidebar-tabs>ul+ul{bottom:0}.sidebar-content{position:absolute;top:0;bottom:0;background-color:rgba(255,255,255,.95);overflow-x:hidden;overflow-y:auto}.sidebar-left .sidebar-content{left:40px;right:0}.sidebar-right .sidebar-content{left:0;right:40px}.sidebar.collapsed>.sidebar-content{overflow-y:hidden}.sidebar-pane{display:none;left:0;right:0;box-sizing:border-box;padding:10px 20px}.sidebar-pane.active{display:block}.sidebar-header{margin:-10px -20px 0;height:40px;padding:0 20px;line-height:40px;font-size:14.4pt;color:#fff;background-color:#0074d9}.sidebar-right .sidebar-header{padding-left:40px}.sidebar-close{position:absolute;top:0;width:40px;height:40px;text-align:center;cursor:pointer}.sidebar-left .sidebar-close{right:0}.sidebar-right .sidebar-close{left:0}.sidebar-left~.sidebar-map{margin-left:40px}.sidebar-right~.sidebar-map{margin-right:40px}.sidebar.leaflet-touch{box-shadow:none;border-right:2px solid rgba(0,0,0,.2)}@media (min-width:768px) and (max-width:991px){.sidebar-left~.sidebar-map .leaflet-left{left:315px}.sidebar-right~.sidebar-map .leaflet-right{right:315px}}@media (min-width:992px) and (max-width:1199px){.sidebar-pane{min-width:350px}.sidebar-left~.sidebar-map .leaflet-left{left:400px}.sidebar-right~.sidebar-map .leaflet-right{right:400px}}@media (min-width:1200px){.sidebar-pane{min-width:420px}.sidebar-left~.sidebar-map .leaflet-left{left:470px}.sidebar-right~.sidebar-map .leaflet-right{right:470px}}@media (min-width:768px){.sidebar-left~.sidebar-map{margin-left:0}.sidebar-right~.sidebar-map{margin-right:0}.sidebar{border-radius:4px}.sidebar.leaflet-touch{border:2px solid rgba(0,0,0,.2)}.sidebar-left~.sidebar-map .leaflet-left{transition:left .5s}.sidebar-left.collapsed~.sidebar-map .leaflet-left{left:50px}.sidebar-right~.sidebar-map .leaflet-right{transition:right .5s}.sidebar-right.collapsed~.sidebar-map .leaflet-right{right:50px}} -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /static/img/antenna-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/antenna-green.png -------------------------------------------------------------------------------- /static/img/balloon-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/balloon-blue.png -------------------------------------------------------------------------------- /static/img/balloon-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/balloon-green.png -------------------------------------------------------------------------------- /static/img/balloon-pop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/balloon-pop.png -------------------------------------------------------------------------------- /static/img/balloon-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/balloon-purple.png -------------------------------------------------------------------------------- /static/img/balloon-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/balloon-red.png -------------------------------------------------------------------------------- /static/img/car-blue-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-blue-flip.png -------------------------------------------------------------------------------- /static/img/car-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-blue.png -------------------------------------------------------------------------------- /static/img/car-green-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-green-flip.png -------------------------------------------------------------------------------- /static/img/car-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-green.png -------------------------------------------------------------------------------- /static/img/car-red-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-red-flip.png -------------------------------------------------------------------------------- /static/img/car-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-red.png -------------------------------------------------------------------------------- /static/img/car-yellow-flip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-yellow-flip.png -------------------------------------------------------------------------------- /static/img/car-yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/car-yellow.png -------------------------------------------------------------------------------- /static/img/parachute-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/parachute-blue.png -------------------------------------------------------------------------------- /static/img/parachute-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/parachute-green.png -------------------------------------------------------------------------------- /static/img/parachute-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/parachute-purple.png -------------------------------------------------------------------------------- /static/img/parachute-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/parachute-red.png -------------------------------------------------------------------------------- /static/img/payload-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/payload-blue.png -------------------------------------------------------------------------------- /static/img/payload-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/payload-green.png -------------------------------------------------------------------------------- /static/img/payload-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/payload-purple.png -------------------------------------------------------------------------------- /static/img/payload-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/payload-red.png -------------------------------------------------------------------------------- /static/img/target-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/target-blue.png -------------------------------------------------------------------------------- /static/img/target-green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/target-green.png -------------------------------------------------------------------------------- /static/img/target-purple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/target-purple.png -------------------------------------------------------------------------------- /static/img/target-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/projecthorus/chasemapper/02b3da36e222039ea83f9c55703f4a7873fbc7a1/static/img/target-red.png -------------------------------------------------------------------------------- /static/js/Leaflet.Control.Custom.js: -------------------------------------------------------------------------------- 1 | (function (window, document, undefined) { 2 | L.Control.Custom = L.Control.extend({ 3 | version: '1.0.1', 4 | options: { 5 | position: 'topright', 6 | id: '', 7 | title: '', 8 | classes: '', 9 | content: '', 10 | style: {}, 11 | datas: {}, 12 | events: {}, 13 | }, 14 | container: null, 15 | onAdd: function (map) { 16 | this.container = L.DomUtil.create('div'); 17 | this.container.id = this.options.id; 18 | this.container.title = this.options.title; 19 | this.container.className = this.options.classes; 20 | this.container.innerHTML = this.options.content; 21 | 22 | for (var option in this.options.style) 23 | { 24 | this.container.style[option] = this.options.style[option]; 25 | } 26 | 27 | for (var data in this.options.datas) 28 | { 29 | this.container.dataset[data] = this.options.datas[data]; 30 | } 31 | 32 | 33 | /* Prevent click events propagation to map */ 34 | L.DomEvent.disableClickPropagation(this.container); 35 | 36 | /* Prevent right click event propagation to map */ 37 | L.DomEvent.on(this.container, 'contextmenu', function (ev) 38 | { 39 | L.DomEvent.stopPropagation(ev); 40 | }); 41 | 42 | /* Prevent scroll events propagation to map when cursor on the div */ 43 | L.DomEvent.disableScrollPropagation(this.container); 44 | 45 | for (var event in this.options.events) 46 | { 47 | L.DomEvent.on(this.container, event, this.options.events[event], this.container); 48 | } 49 | 50 | return this.container; 51 | }, 52 | 53 | onRemove: function (map) { 54 | for (var event in this.options.events) 55 | { 56 | L.DomEvent.off(this.container, event, this.options.events[event], this.container); 57 | } 58 | }, 59 | }); 60 | 61 | L.control.custom = function (options) { 62 | return new L.Control.Custom(options); 63 | }; 64 | 65 | }(window, document)); -------------------------------------------------------------------------------- /static/js/car.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - Car Position 3 | // 4 | // Copyright (C) 2019 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | var range_rings = []; 9 | var range_rings_on = false; 10 | 11 | 12 | function destroyRangeRings(){ 13 | // Remove each range ring from the map. 14 | range_rings.forEach(function(element){ 15 | element.remove(); 16 | }); 17 | // Clear the range ring array. 18 | range_rings = []; 19 | range_rings_on = false; 20 | } 21 | 22 | 23 | function createRangeRings(position){ 24 | var _ring_quantity = parseInt($('#ringQuantity').val()); 25 | var _ring_weight = parseFloat($('#ringWeight').val()); 26 | var _ring_spacing = parseFloat($('#ringSpacing').val()); 27 | var _ring_color = $('#ringColorSelect').val(); 28 | var _ring_custom_color = $('#ringCustomColor').val(); 29 | 30 | var _radius = _ring_spacing; 31 | var _color = "#FF0000"; 32 | if (chase_config['unitselection'] == "imperial") { _radius = _ring_spacing*0.3048;} 33 | 34 | if(_ring_color == "red"){ 35 | _color = "#FF0000"; 36 | } else if (_ring_color == "black"){ 37 | _color = "#000000"; 38 | } else if (_ring_color == "blue"){ 39 | _color = "#0000FF"; 40 | } else if (_ring_color == "green"){ 41 | _color = "#00FF00"; 42 | } else if (_ring_color == "custom"){ 43 | _color = _ring_custom_color; 44 | } 45 | 46 | for(var i=0; i<_ring_quantity; i++){ 47 | var _ring = L.circle(position, { 48 | fill: false, 49 | color: _color, 50 | radius: _radius, 51 | weight: _ring_weight, 52 | opacity: 0.7 53 | }).addTo(map); 54 | range_rings.push(_ring); 55 | if (chase_config['unitselection'] == "metric") { _radius += _ring_spacing;} 56 | if (chase_config['unitselection'] == "imperial") { _radius += _ring_spacing*0.3048;} 57 | } 58 | 59 | range_rings_on = true; 60 | 61 | } 62 | 63 | 64 | function recenterRangeRings(position){ 65 | 66 | if ((document.getElementById("rangeRingsEnabled").checked == true) && (range_rings_on == false)){ 67 | // We have rings enabled, but haven't been able to create them yet. 68 | // Create them. 69 | updateRangeRings(); 70 | return; 71 | } else { 72 | // Otherwise, just update the centre position of each ring. 73 | range_rings.forEach(function(element){ 74 | element.setLatLng(position); 75 | }); 76 | } 77 | } 78 | 79 | 80 | function updateRangeRings(){ 81 | 82 | // Grab the range ring settings. 83 | var _ring_enabled = document.getElementById("rangeRingsEnabled").checked; 84 | 85 | // Check if we actually have a chase car position to work with. 86 | var _position = chase_car_position.latest_data; 87 | 88 | if (_position.length == 0){ 89 | // No position available yet. Don't do anything. 90 | return; 91 | } 92 | // Otherwise, it looks like we have a position. 93 | 94 | if ((_ring_enabled == true) && (range_rings_on == false)){ 95 | // The user had just enabled the range rings, so we need to create them. 96 | createRangeRings(_position); 97 | 98 | 99 | } else if ((_ring_enabled == false) && (range_rings_on == true)){ 100 | // The user has disabled the range rings, so we remove them from the map. 101 | destroyRangeRings(); 102 | 103 | } else { 104 | // Some other parameter has been changed. 105 | // Destroy, then re-create the range rings. 106 | destroyRangeRings(); 107 | createRangeRings(_position); 108 | 109 | } 110 | 111 | } 112 | 113 | var reconfigureCarMarker = function(profile_name){ 114 | // Remove chase-car marker if it exists, and is not used. 115 | if( (chase_config.profiles[profile_name].car_source_type === "none") || (chase_config.profiles[profile_name].car_source_type === "station")){ 116 | if (chase_car_position.marker !== "NONE"){ 117 | chase_car_position.marker.remove(); 118 | chase_car_position.path.remove(); 119 | } 120 | } 121 | 122 | if (chase_config.profiles[profile_name].car_source_type === "station") { 123 | // If we are using a stationary profile, add the station icon to the map. 124 | // Add our station location marker. 125 | home_marker = L.marker([chase_config.default_lat, chase_config.default_lon, chase_config.default_alt], 126 | {title: 'Receiver Location', icon: homeIcon} 127 | ).addTo(map); 128 | } 129 | 130 | // If we are switching to a profile with a live car position source, remove the home station Icon 131 | if ((chase_config.profiles[profile_name].car_source_type === "serial") || (chase_config.profiles[profile_name].car_source_type === "gpsd") || (chase_config.profiles[profile_name].car_source_type === "horus_udp")){ 132 | if(home_marker !== "NONE"){ 133 | home_marker.remove(); 134 | } 135 | } 136 | } 137 | 138 | 139 | var devicePositionCallback = function(position){ 140 | // Pass a Device position update onto the back-end for processing and re-distribution. 141 | var device_pos = {time:position.timestamp, latitude:position.coords.latitude, longitude:position.coords.longitude, altitude:position.coords.altitude}; 142 | socket.emit('device_position', device_pos); 143 | } 144 | 145 | var devicePositionError = function(error){ 146 | console.log(error.message); 147 | } -------------------------------------------------------------------------------- /static/js/easy-button.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | 3 | // This is for grouping buttons into a bar 4 | // takes an array of `L.easyButton`s and 5 | // then the usual `.addTo(map)` 6 | L.Control.EasyBar = L.Control.extend({ 7 | 8 | options: { 9 | position: 'topleft', // part of leaflet's defaults 10 | id: null, // an id to tag the Bar with 11 | leafletClasses: true // use leaflet classes? 12 | }, 13 | 14 | 15 | initialize: function(buttons, options){ 16 | 17 | if(options){ 18 | L.Util.setOptions( this, options ); 19 | } 20 | 21 | this._buildContainer(); 22 | this._buttons = []; 23 | 24 | for(var i = 0; i < buttons.length; i++){ 25 | buttons[i]._bar = this; 26 | buttons[i]._container = buttons[i].button; 27 | this._buttons.push(buttons[i]); 28 | this.container.appendChild(buttons[i].button); 29 | } 30 | 31 | }, 32 | 33 | 34 | _buildContainer: function(){ 35 | this._container = this.container = L.DomUtil.create('div', ''); 36 | this.options.leafletClasses && L.DomUtil.addClass(this.container, 'leaflet-bar easy-button-container leaflet-control'); 37 | this.options.id && (this.container.id = this.options.id); 38 | }, 39 | 40 | 41 | enable: function(){ 42 | L.DomUtil.addClass(this.container, 'enabled'); 43 | L.DomUtil.removeClass(this.container, 'disabled'); 44 | this.container.setAttribute('aria-hidden', 'false'); 45 | return this; 46 | }, 47 | 48 | 49 | disable: function(){ 50 | L.DomUtil.addClass(this.container, 'disabled'); 51 | L.DomUtil.removeClass(this.container, 'enabled'); 52 | this.container.setAttribute('aria-hidden', 'true'); 53 | return this; 54 | }, 55 | 56 | 57 | onAdd: function () { 58 | return this.container; 59 | }, 60 | 61 | addTo: function (map) { 62 | this._map = map; 63 | 64 | for(var i = 0; i < this._buttons.length; i++){ 65 | this._buttons[i]._map = map; 66 | } 67 | 68 | var container = this._container = this.onAdd(map), 69 | pos = this.getPosition(), 70 | corner = map._controlCorners[pos]; 71 | 72 | L.DomUtil.addClass(container, 'leaflet-control'); 73 | 74 | if (pos.indexOf('bottom') !== -1) { 75 | corner.insertBefore(container, corner.firstChild); 76 | } else { 77 | corner.appendChild(container); 78 | } 79 | 80 | return this; 81 | } 82 | 83 | }); 84 | 85 | L.easyBar = function(){ 86 | var args = [L.Control.EasyBar]; 87 | for(var i = 0; i < arguments.length; i++){ 88 | args.push( arguments[i] ); 89 | } 90 | return new (Function.prototype.bind.apply(L.Control.EasyBar, args)); 91 | }; 92 | 93 | // L.EasyButton is the actual buttons 94 | // can be called without being grouped into a bar 95 | L.Control.EasyButton = L.Control.extend({ 96 | 97 | options: { 98 | position: 'topleft', // part of leaflet's defaults 99 | 100 | id: null, // an id to tag the button with 101 | 102 | type: 'replace', // [(replace|animate)] 103 | // replace swaps out elements 104 | // animate changes classes with all elements inserted 105 | 106 | states: [], // state names look like this 107 | // { 108 | // stateName: 'untracked', 109 | // onClick: function(){ handle_nav_manually(); }; 110 | // title: 'click to make inactive', 111 | // icon: 'fa-circle', // wrapped with 112 | // } 113 | 114 | leafletClasses: true, // use leaflet styles for the button 115 | tagName: 'button', 116 | }, 117 | 118 | 119 | 120 | initialize: function(icon, onClick, title, id){ 121 | 122 | // clear the states manually 123 | this.options.states = []; 124 | 125 | // add id to options 126 | if(id != null){ 127 | this.options.id = id; 128 | } 129 | 130 | // storage between state functions 131 | this.storage = {}; 132 | 133 | // is the last item an object? 134 | if( typeof arguments[arguments.length-1] === 'object' ){ 135 | 136 | // if so, it should be the options 137 | L.Util.setOptions( this, arguments[arguments.length-1] ); 138 | } 139 | 140 | // if there aren't any states in options 141 | // use the early params 142 | if( this.options.states.length === 0 && 143 | typeof icon === 'string' && 144 | typeof onClick === 'function'){ 145 | 146 | // turn the options object into a state 147 | this.options.states.push({ 148 | icon: icon, 149 | onClick: onClick, 150 | title: typeof title === 'string' ? title : '' 151 | }); 152 | } 153 | 154 | // curate and move user's states into 155 | // the _states for internal use 156 | this._states = []; 157 | 158 | for(var i = 0; i < this.options.states.length; i++){ 159 | this._states.push( new State(this.options.states[i], this) ); 160 | } 161 | 162 | this._buildButton(); 163 | 164 | this._activateState(this._states[0]); 165 | 166 | }, 167 | 168 | _buildButton: function(){ 169 | 170 | this.button = L.DomUtil.create(this.options.tagName, ''); 171 | 172 | if (this.options.tagName === 'button') { 173 | this.button.setAttribute('type', 'button'); 174 | } 175 | 176 | if (this.options.id ){ 177 | this.button.id = this.options.id; 178 | } 179 | 180 | if (this.options.leafletClasses){ 181 | L.DomUtil.addClass(this.button, 'easy-button-button leaflet-bar-part leaflet-interactive'); 182 | } 183 | 184 | // don't let double clicks and mousedown get to the map 185 | L.DomEvent.addListener(this.button, 'dblclick', L.DomEvent.stop); 186 | L.DomEvent.addListener(this.button, 'mousedown', L.DomEvent.stop); 187 | L.DomEvent.addListener(this.button, 'mouseup', L.DomEvent.stop); 188 | 189 | // take care of normal clicks 190 | L.DomEvent.addListener(this.button,'click', function(e){ 191 | L.DomEvent.stop(e); 192 | this._currentState.onClick(this, this._map ? this._map : null ); 193 | this._map && this._map.getContainer().focus(); 194 | }, this); 195 | 196 | // prep the contents of the control 197 | if(this.options.type == 'replace'){ 198 | this.button.appendChild(this._currentState.icon); 199 | } else { 200 | for(var i=0;i"']/) ){ 345 | 346 | // if so, the user should have put in html 347 | // so move forward as such 348 | tmpIcon = ambiguousIconString; 349 | 350 | // then it wasn't html, so 351 | // it's a class list, figure out what kind 352 | } else { 353 | ambiguousIconString = ambiguousIconString.replace(/(^\s*|\s*$)/g,''); 354 | tmpIcon = L.DomUtil.create('span', ''); 355 | 356 | if( ambiguousIconString.indexOf('fa-') === 0 ){ 357 | L.DomUtil.addClass(tmpIcon, 'fa ' + ambiguousIconString) 358 | } else if ( ambiguousIconString.indexOf('glyphicon-') === 0 ) { 359 | L.DomUtil.addClass(tmpIcon, 'glyphicon ' + ambiguousIconString) 360 | } else { 361 | L.DomUtil.addClass(tmpIcon, /*rollwithit*/ ambiguousIconString) 362 | } 363 | 364 | // make this a string so that it's easy to set innerHTML below 365 | tmpIcon = tmpIcon.outerHTML; 366 | } 367 | 368 | return tmpIcon; 369 | } 370 | 371 | })(); 372 | -------------------------------------------------------------------------------- /static/js/habitat.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - Habitat Data Scraping 3 | // 4 | // Copyright (C) 2021 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | 9 | // URL to scrape recent vehicle position data from. 10 | // TODO: Allow adjustment of the number of positions to request. 11 | var spacenearus_url = "http://spacenear.us/tracker/datanew.php?mode=2hours&type=positions&format=json&max_positions=100&position_id="; 12 | // Record of the last position ID, so we only request new data. 13 | var spacenearus_last_position_id = 0; 14 | // Keep track of whether an asynchronous AJAX request is in progress. 15 | // Not really sure if this is necessary. 16 | var snear_request_running = false; 17 | 18 | 19 | function process_habitat_vehicles(data){ 20 | // Check we have a 'valid' response to process. 21 | if (data === null || 22 | !data.positions || 23 | !data.positions.position || 24 | !data.positions.position.length) { 25 | snear_request_running = false; 26 | return; 27 | } 28 | 29 | data.positions.position.forEach(function(position){ 30 | // Update the highest position ID, so we don't request old data. 31 | if (position.position_id > spacenearus_last_position_id){ 32 | spacenearus_last_position_id = position.position_id; 33 | } 34 | 35 | var vcallsign = position.vehicle; 36 | 37 | // Check this isn't our callsign. 38 | // If it is, don't process it. 39 | if (vcallsign.startsWith(chase_config.habitat_call)){ 40 | return; 41 | } 42 | 43 | // Determine if the vehicle is a chase car. 44 | // This is denoted by _chase at the end of the callsign. 45 | if(vcallsign.search(/(chase)/i) != -1) { 46 | 47 | var v_lat = parseFloat(position.gps_lat); 48 | var v_lon = parseFloat(position.gps_lon); 49 | var v_alt = parseFloat(position.gps_alt); 50 | 51 | // If the vehicle is already known to us, then update it position. 52 | // Update any existing entries (even if the range is above the threshold) 53 | if (chase_vehicles.hasOwnProperty(vcallsign)){ 54 | 55 | // Only update if the position ID of this position is newer than that last seen. 56 | if (chase_vehicles[vcallsign].position_id < position.position_id){ 57 | //console.log("Updating: " + vcallsign); 58 | // Update the position ID. 59 | chase_vehicles[vcallsign].position_id = position.position_id; 60 | 61 | // Since we don't always get a heading with the vehicle position, calculate it. 62 | var old_v_pos = {lat:chase_vehicles[vcallsign].latest_data[0], 63 | lon: chase_vehicles[vcallsign].latest_data[1], 64 | alt:chase_vehicles[vcallsign].latest_data[2]}; 65 | var new_v_pos = {lat: v_lat, lon:v_lon, alt:v_alt}; 66 | chase_vehicles[vcallsign].heading = calculate_lookangles(old_v_pos, new_v_pos).azimuth; 67 | 68 | // Update the position data. 69 | chase_vehicles[vcallsign].latest_data = [v_lat, v_lon, v_alt]; 70 | 71 | // Update the marker position. 72 | chase_vehicles[vcallsign].marker.setLatLng(chase_vehicles[vcallsign].latest_data).update(); 73 | 74 | // Rotate/replace the icon to match the bearing. 75 | var _car_heading = chase_vehicles[vcallsign].heading - 90.0; 76 | if (_car_heading<=90.0){ 77 | chase_vehicles[vcallsign].marker.setIcon(habitat_car_icons[chase_vehicles[vcallsign].colour]); 78 | chase_vehicles[vcallsign].marker.setRotationAngle(_car_heading); 79 | }else{ 80 | // We are travelling West - we need to use the flipped car icon. 81 | _car_heading = _car_heading - 180.0; 82 | chase_vehicles[vcallsign].marker.setIcon(habitat_car_icons_flipped[chase_vehicles[vcallsign].colour]); 83 | chase_vehicles[vcallsign].marker.setRotationAngle(_car_heading); 84 | } 85 | return; 86 | } 87 | 88 | 89 | // No need to go any further. 90 | 91 | return; 92 | } 93 | 94 | // Otherwise, we need to decide if we're going to add it or not. 95 | // Determine the vehicle distance from our current position. 96 | var v_pos = {lat: v_lat, lon:v_lon, alt:v_alt}; 97 | if (chase_car_position.marker === "NONE"){ 98 | var my_pos = {lat:chase_config.default_lat, lon:chase_config.default_lon, alt:0}; 99 | }else{ 100 | var my_pos = {lat:chase_car_position.latest_data[0], lon:chase_car_position.latest_data[1], alt:chase_car_position.latest_data[2]}; 101 | } 102 | var v_range = calculate_lookangles(my_pos, v_pos).range/1000.0; 103 | 104 | // If the range is less than the threshold, add it to our list of chase vehicles. 105 | if(v_range < vehicle_max_range){ 106 | //console.log("Adding: " + vcallsign); 107 | chase_vehicles[vcallsign] = {}; 108 | // Initialise a few default values 109 | chase_vehicles[vcallsign].heading = 90; 110 | chase_vehicles[vcallsign].latest_data = [v_lat, v_lon, v_alt]; 111 | chase_vehicles[vcallsign].position_id = position.position_id; 112 | 113 | // Get an index for the car icon. This is incremented for each vehicle, 114 | // giving each a different colour. 115 | chase_vehicles[vcallsign].colour = car_colour_values[car_colour_idx]; 116 | car_colour_idx = (car_colour_idx+1)%car_colour_values.length; 117 | 118 | // Create marker 119 | chase_vehicles[vcallsign].marker = L.marker(chase_vehicles[vcallsign].latest_data, 120 | {title:vcallsign, 121 | icon: habitat_car_icons[chase_vehicles[vcallsign].colour], 122 | rotationOrigin: "center center"}) 123 | .addTo(map); 124 | // Keep our own record of if this marker has been added to a map, 125 | // as we shouldn't be using the private _map property of the marker object. 126 | chase_vehicles[vcallsign].onmap = true; 127 | 128 | // Add tooltip, with custom CSS which removes all tooltip borders, and adds a text shadow. 129 | chase_vehicles[vcallsign].marker.bindTooltip(vcallsign, 130 | {permanent: true, 131 | direction: 'center', 132 | offset:[0,25], 133 | className:'custom_label'}).openTooltip(); 134 | } 135 | } 136 | 137 | }); 138 | 139 | snear_request_running = false; 140 | } 141 | 142 | // Request the latest 100 vehicle positions from spacenear.us 143 | function get_habitat_vehicles(){ 144 | var snear_request_url = spacenearus_url + spacenearus_last_position_id; 145 | 146 | if(!snear_request_running){ 147 | snear_request_running = true; 148 | console.log("Requesting vehicles from Habitat...") 149 | $.ajax({ 150 | url: snear_request_url, 151 | dataType: 'json', 152 | timeout: 15000, 153 | async: true, // Yes, this is deprecated... 154 | success: function(data) { 155 | process_habitat_vehicles(data); 156 | } 157 | }); 158 | } 159 | } 160 | 161 | 162 | -------------------------------------------------------------------------------- /static/js/leaflet-control-topcenter.js: -------------------------------------------------------------------------------- 1 | /**************************************************************************** 2 | leaflet-control-topcenter.js, 3 | 4 | (c) 2016, FCOO 5 | 6 | https://github.com/FCOO/leaflet-control-topcenter 7 | https://github.com/FCOO 8 | 9 | ****************************************************************************/ 10 | (function (L /*, window, document, undefined*/) { 11 | "use strict"; 12 | 13 | //Extend Map._initControlPos to also create a topcenter-container 14 | L.Map.prototype._initControlPos = function ( _initControlPos ) { 15 | return function () { 16 | //Original function/method 17 | _initControlPos.apply(this, arguments); 18 | 19 | //Adding new control-containers 20 | 21 | //topcenter is the same as the rest of control-containers 22 | this._controlCorners['topcenter'] = L.DomUtil.create('div', 'leaflet-top leaflet-center', this._controlContainer); 23 | 24 | //bottomcenter need an extra container to be placed at the bottom 25 | this._controlCorners['bottomcenter'] = 26 | L.DomUtil.create( 27 | 'div', 28 | 'leaflet-bottom leaflet-center', 29 | L.DomUtil.create('div', 'leaflet-control-bottomcenter', this._controlContainer) 30 | ); 31 | }; 32 | } (L.Map.prototype._initControlPos); 33 | }(L, this, document)); 34 | -------------------------------------------------------------------------------- /static/js/leaflet-sidebar.min.js: -------------------------------------------------------------------------------- 1 | L.Control.Sidebar=L.Control.extend({includes:L.Evented.prototype||L.Mixin.Events,options:{position:"left"},initialize:function(t,s){var i,e;for(L.setOptions(this,s),this._sidebar=L.DomUtil.get(t),L.DomUtil.addClass(this._sidebar,"sidebar-"+this.options.position),L.Browser.touch&&L.DomUtil.addClass(this._sidebar,"leaflet-touch"),i=this._sidebar.children.length-1;i>=0;i--)"DIV"==(e=this._sidebar.children[i]).tagName&&L.DomUtil.hasClass(e,"sidebar-content")&&(this._container=e);for(this._tabitems=this._sidebar.querySelectorAll("ul.sidebar-tabs > li, .sidebar-tabs > ul > li"),i=this._tabitems.length-1;i>=0;i--)this._tabitems[i]._sidebar=this;for(this._panes=[],this._closeButtons=[],i=this._container.children.length-1;i>=0;i--)if("DIV"==(e=this._container.children[i]).tagName&&L.DomUtil.hasClass(e,"sidebar-pane")){this._panes.push(e);for(var o=e.querySelectorAll(".sidebar-close"),a=0,l=o.length;a=0;s--){var e=(i=this._tabitems[s]).querySelector("a");e.hasAttribute("href")&&"#"==e.getAttribute("href").slice(0,1)&&L.DomEvent.on(e,"click",L.DomEvent.preventDefault).on(e,"click",this._onClick,i)}for(s=this._closeButtons.length-1;s>=0;s--)i=this._closeButtons[s],L.DomEvent.on(i,"click",this._onCloseClick,this);return this},removeFrom:function(t){console.log("removeFrom() has been deprecated, please use remove() instead as support for this function will be ending soon."),this.remove(t)},remove:function(t){var s,i;for(this._map=null,s=this._tabitems.length-1;s>=0;s--)i=this._tabitems[s],L.DomEvent.off(i.querySelector("a"),"click",this._onClick);for(s=this._closeButtons.length-1;s>=0;s--)i=this._closeButtons[s],L.DomEvent.off(i,"click",this._onCloseClick,this);return this},open:function(t){var s,i;for(s=this._panes.length-1;s>=0;s--)(i=this._panes[s]).id==t?L.DomUtil.addClass(i,"active"):L.DomUtil.hasClass(i,"active")&&L.DomUtil.removeClass(i,"active");for(s=this._tabitems.length-1;s>=0;s--)(i=this._tabitems[s]).querySelector("a").hash=="#"+t?L.DomUtil.addClass(i,"active"):L.DomUtil.hasClass(i,"active")&&L.DomUtil.removeClass(i,"active");return this.fire("content",{id:t}),L.DomUtil.hasClass(this._sidebar,"collapsed")&&(this.fire("opening"),L.DomUtil.removeClass(this._sidebar,"collapsed")),this},close:function(){for(var t=this._tabitems.length-1;t>=0;t--){var s=this._tabitems[t];L.DomUtil.hasClass(s,"active")&&L.DomUtil.removeClass(s,"active")}return L.DomUtil.hasClass(this._sidebar,"collapsed")||(this.fire("closing"),L.DomUtil.addClass(this._sidebar,"collapsed")),this},_onClick:function(){L.DomUtil.hasClass(this,"active")?this._sidebar.close():L.DomUtil.hasClass(this,"disabled")||this._sidebar.open(this.querySelector("a").hash.slice(1))},_onCloseClick:function(){this.close()}}),L.control.sidebar=function(t,s){return new L.Control.Sidebar(t,s)}; -------------------------------------------------------------------------------- /static/js/leaflet.rotatedMarker.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // save these original methods before they are overwritten 3 | var proto_initIcon = L.Marker.prototype._initIcon; 4 | var proto_setPos = L.Marker.prototype._setPos; 5 | 6 | var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); 7 | 8 | L.Marker.addInitHook(function () { 9 | var iconOptions = this.options.icon && this.options.icon.options; 10 | var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; 11 | if (iconAnchor) { 12 | iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); 13 | } 14 | this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom' ; 15 | this.options.rotationAngle = this.options.rotationAngle || 0; 16 | 17 | // Ensure marker keeps rotated during dragging 18 | this.on('drag', function(e) { e.target._applyRotation(); }); 19 | }); 20 | 21 | L.Marker.include({ 22 | _initIcon: function() { 23 | proto_initIcon.call(this); 24 | }, 25 | 26 | _setPos: function (pos) { 27 | proto_setPos.call(this, pos); 28 | this._applyRotation(); 29 | }, 30 | 31 | _applyRotation: function () { 32 | if(this.options.rotationAngle) { 33 | this._icon.style[L.DomUtil.TRANSFORM+'Origin'] = this.options.rotationOrigin; 34 | 35 | if(oldIE) { 36 | // for IE 9, use the 2D rotation 37 | this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; 38 | } else { 39 | // for modern browsers, prefer the 3D accelerated version 40 | this._icon.style[L.DomUtil.TRANSFORM] += ' rotateZ(' + this.options.rotationAngle + 'deg)'; 41 | } 42 | } 43 | }, 44 | 45 | setRotationAngle: function(angle) { 46 | this.options.rotationAngle = angle; 47 | this.update(); 48 | return this; 49 | }, 50 | 51 | setRotationOrigin: function(origin) { 52 | this.options.rotationOrigin = origin; 53 | this.update(); 54 | return this; 55 | } 56 | }); 57 | })(); 58 | -------------------------------------------------------------------------------- /static/js/predictions.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - Prediction Path Handlers 3 | // 4 | // Copyright (C) 2019 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | function handlePrediction(data){ 9 | // We expect the fields: callsign, pred_path, pred_landing, and abort_path and abort_landing, if abort predictions are enabled. 10 | var _callsign = data.callsign; 11 | var _pred_path = data.pred_path; 12 | var _pred_landing = data.pred_landing; 13 | 14 | // It's possible (though unlikely) that we get sent a prediction track before telemetry data. 15 | // In this case, just return. 16 | if (balloon_positions.hasOwnProperty(data.callsign) == false){ 17 | return; 18 | } 19 | 20 | // Add the landing marker if it doesnt exist. 21 | var _landing_text = _callsign + " Landing " + data.pred_landing[0].toFixed(5) + ", " + data.pred_landing[1].toFixed(5); 22 | if (balloon_positions[_callsign].pred_marker == null){ 23 | balloon_positions[_callsign].pred_marker = L.marker(data.pred_landing,{title:_callsign + " Landing", icon: balloonLandingIcons[balloon_positions[_callsign].colour]}) 24 | .bindTooltip(_landing_text ,{permanent:false,direction:'right'}); 25 | if (balloon_positions[_callsign].visible == true){ 26 | balloon_positions[_callsign].pred_marker.addTo(map); 27 | // Add listener to copy prediction coords to clipboard. 28 | balloon_positions[_callsign].pred_marker.on('click', function(e) { 29 | var _landing_pos_text = e.latlng.lat.toFixed(5) + ", " + e.latlng.lng.toFixed(5); 30 | textToClipboard(_landing_pos_text); 31 | }); 32 | } 33 | }else{ 34 | balloon_positions[_callsign].pred_marker.setLatLng(data.pred_landing); 35 | balloon_positions[_callsign].pred_marker.setTooltipContent(_landing_text); 36 | } 37 | if(data.burst.length == 3){ 38 | // There is burst data! 39 | var _burst_txt = _callsign + " Burst (" + data.burst[2].toFixed(0) + "m)"; 40 | if (balloon_positions[_callsign].burst_marker == null){ 41 | balloon_positions[_callsign].burst_marker = L.marker(data.burst,{title:_burst_txt, icon: burstIcon}) 42 | .bindTooltip(_burst_txt,{permanent:false,direction:'right'}); 43 | 44 | if (balloon_positions[_callsign].visible == true){ 45 | balloon_positions[_callsign].burst_marker.addTo(map); 46 | } 47 | }else{ 48 | balloon_positions[_callsign].burst_marker.setLatLng(data.burst); 49 | balloon_positions[_callsign].burst_marker.setTooltipContent(_burst_txt); 50 | } 51 | }else{ 52 | // No burst data, or we are in descent. 53 | if (balloon_positions[_callsign].burst_marker != null){ 54 | // Remove the burst icon from the map. 55 | balloon_positions[_callsign].burst_marker.remove(); 56 | balloon_positions[_callsign].burst_marker = null; 57 | } 58 | } 59 | // Update the predicted path. 60 | balloon_positions[_callsign].pred_path.setLatLngs(data.pred_path); 61 | 62 | if (data.abort_landing.length == 3){ 63 | // Only update the abort data if there is actually abort data to show. 64 | if (balloon_positions[_callsign].abort_marker == null){ 65 | balloon_positions[_callsign].abort_marker = L.marker(data.abort_landing,{title:_callsign + " Abort", icon: abortIcon}) 66 | .bindTooltip(_callsign + " Abort Landing",{permanent:false,direction:'right'}); 67 | if((chase_config.show_abort == true) && (balloon_positions[_callsign].visible == true)){ 68 | balloon_positions[_callsign].abort_marker.addTo(map); 69 | } 70 | }else{ 71 | balloon_positions[_callsign].abort_marker.setLatLng(data.abort_landing); 72 | } 73 | 74 | balloon_positions[_callsign].abort_path.setLatLngs(data.abort_path); 75 | }else{ 76 | // Clear out the abort and abort marker data. 77 | balloon_positions[_callsign].abort_path.setLatLngs([]); 78 | 79 | if (balloon_positions[_callsign].abort_marker != null){ 80 | balloon_positions[_callsign].abort_marker.remove(); 81 | balloon_positions[_callsign].abort_marker = null; 82 | } 83 | } 84 | // Reset the prediction data age counter. 85 | pred_data_age = 0.0; 86 | 87 | // Update the routing engine. 88 | //if (balloon_currently_following === data.callsign){ 89 | // router.setWaypoints([L.latLng(chase_car_position.latest_data[0],chase_car_position.latest_data[1]), L.latLng(data.pred_landing[0], data.pred_landing[1])]); 90 | //} 91 | } -------------------------------------------------------------------------------- /static/js/settings.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - Settings 3 | // 4 | // Copyright (C) 2019 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | // Global map settings 9 | var prediction_opacity = 0.6; 10 | var parachute_min_alt = 300; // Show the balloon as a 'landed' payload below this altitude. 11 | 12 | var car_bad_age = 5.0; 13 | var payload_bad_age = 30.0; 14 | 15 | 16 | // Chase Mapper Configuration Parameters. 17 | // These are dummy values which will be populated on startup. 18 | var chase_config = { 19 | // Start location for the map (until either a chase car position, or balloon position is available.) 20 | default_lat: -34.9, 21 | default_lon: 138.6, 22 | 23 | // Predictor settings 24 | pred_enabled: true, // Enable running and display of predicted flight paths. 25 | // Default prediction settings (actual values will be used once the flight is underway) 26 | pred_desc_rate: 6.0, 27 | pred_burst: 28000, 28 | pred_update_rate: 15, 29 | pred_model: 'Disabled', 30 | show_abort: true, // Show a prediction of an 'abort' paths (i.e. if the balloon bursts *now*) 31 | offline_tile_layers: [], 32 | habitat_call: 'N0CALL' 33 | }; 34 | 35 | 36 | function serverSettingsUpdate(data){ 37 | // Accept a json blob of settings data from the client, and update our local store. 38 | chase_config = data; 39 | // Update a few fields based on this data. 40 | $("#predictorModelValue").text(chase_config.pred_model); 41 | $('#burstAlt').val(chase_config.pred_burst.toFixed(0)); 42 | $('#descentRate').val(chase_config.pred_desc_rate.toFixed(1)); 43 | $('#predUpdateRate').val(chase_config.pred_update_rate.toFixed(0)); 44 | $('#habitatUpdateRate').val(chase_config.habitat_update_rate.toFixed(0)); 45 | $("#predictorEnabled").prop('checked', chase_config.pred_enabled); 46 | $("#habitatUploadEnabled").prop('checked', chase_config.habitat_upload_enabled); 47 | $("#showOtherCars").prop('checked', chase_config.habitat_upload_enabled); 48 | $("#habitatCall").val(chase_config.habitat_call); 49 | $("#abortPredictionEnabled").prop('checked', chase_config.show_abort); 50 | 51 | // Range ring settings. 52 | $('#ringQuantity').val(chase_config.range_ring_quantity.toFixed(0)); 53 | $('#ringSpacing').val(chase_config.range_ring_spacing.toFixed(0)); 54 | $('#ringWeight').val(chase_config.range_ring_weight.toFixed(1)); 55 | $('#ringColorSelect').val(chase_config.range_ring_color); 56 | $('#ringCustomColor').val(chase_config.range_ring_custom_color); 57 | $('#rangeRingsEnabled').prop('checked', chase_config.range_rings_enabled); 58 | 59 | // Chase Car Speedometer 60 | $('#showCarSpeed').prop('checked', chase_config.chase_car_speed); 61 | 62 | // Bearing settings 63 | $('#bearingLength').val(chase_config.bearing_length.toFixed(0)); 64 | $('#bearingWeight').val(chase_config.bearing_weight.toFixed(1)); 65 | $('#bearingColorSelect').val(chase_config.bearing_color); 66 | $('#bearingCustomColor').val(chase_config.bearing_custom_color); 67 | $('#bearingMaximumAge').val((chase_config.max_bearing_age/60.0).toFixed(0)); 68 | 69 | // Clear and populate the profile selection. 70 | $('#profileSelect').children('option:not(:first)').remove(); 71 | 72 | $.each(chase_config.profiles, function(key, value) { 73 | $('#profileSelect') 74 | .append($("") 75 | .attr("value",key) 76 | .text(key)); 77 | }); 78 | $("#profileSelect").val(chase_config.selected_profile); 79 | 80 | // Update version 81 | $('#chasemapper_version').html(chase_config.version); 82 | 83 | } 84 | 85 | function clientSettingsUpdate(){ 86 | // Read in changs to various user-modifyable settings, and send updates to the server. 87 | chase_config.pred_enabled = document.getElementById("predictorEnabled").checked; 88 | chase_config.show_abort = document.getElementById("abortPredictionEnabled").checked; 89 | chase_config.habitat_upload_enabled = document.getElementById("habitatUploadEnabled").checked; 90 | chase_config.habitat_call = $('#habitatCall').val() 91 | 92 | // Attempt to parse the text field values. 93 | var _burst_alt = parseFloat($('#burstAlt').val()); 94 | if (isNaN(_burst_alt) == false){ 95 | chase_config.pred_burst = _burst_alt; 96 | } 97 | var _desc_rate = parseFloat($('#descentRate').val()); 98 | if (isNaN(_desc_rate) == false){ 99 | chase_config.pred_desc_rate = _desc_rate 100 | } 101 | var _update_rate = parseInt($('#predUpdateRate').val()); 102 | if (isNaN(_update_rate) == false){ 103 | chase_config.pred_update_rate = _update_rate 104 | } 105 | 106 | var _habitat_update_rate = parseInt($('#habitatUpdateRate').val()); 107 | if (isNaN(_habitat_update_rate) == false){ 108 | chase_config.habitat_update_rate = _habitat_update_rate 109 | } 110 | 111 | 112 | socket.emit('client_settings_update', chase_config); 113 | }; -------------------------------------------------------------------------------- /static/js/sondehub.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - SondeHub Websockets Connection. 3 | // 4 | // Copyright (C) 2022 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | 9 | function handleSondeHubWebSocketPacket(data){ 10 | // Handle a packet of vehicle / listener telemetry from a SondeHub / SondeHub-Amateur Websockets Connection. 11 | 12 | // Only process frames where the 'mobile' flag is present and is true. 13 | if (data.hasOwnProperty('mobile')){ 14 | if(data['mobile'] == true){ 15 | // We have found a mobile station! 16 | //console.log(data); 17 | 18 | // Extract position. 19 | var v_lat = parseFloat(data.uploader_position[0]); 20 | var v_lon = parseFloat(data.uploader_position[1]); 21 | var v_alt = parseFloat(data.uploader_position[2]); 22 | var vcallsign = data.uploader_callsign; 23 | 24 | // If the vehicle is already known to us, then update it position. 25 | // Update any existing entries (even if the range is above the threshold) 26 | if (chase_vehicles.hasOwnProperty(vcallsign)){ 27 | //console.log("Updating: " + vcallsign); 28 | // Update the position ID. 29 | chase_vehicles[vcallsign].position_id = data.ts; 30 | 31 | // Since we don't always get a heading with the vehicle position, calculate it. 32 | var old_v_pos = {lat:chase_vehicles[vcallsign].latest_data[0], 33 | lon: chase_vehicles[vcallsign].latest_data[1], 34 | alt:chase_vehicles[vcallsign].latest_data[2]}; 35 | var new_v_pos = {lat: v_lat, lon:v_lon, alt:v_alt}; 36 | chase_vehicles[vcallsign].heading = calculate_lookangles(old_v_pos, new_v_pos).azimuth; 37 | 38 | // Update the position data. 39 | chase_vehicles[vcallsign].latest_data = [v_lat, v_lon, v_alt]; 40 | 41 | // Update the marker position. 42 | chase_vehicles[vcallsign].marker.setLatLng(chase_vehicles[vcallsign].latest_data).update(); 43 | 44 | // Rotate/replace the icon to match the bearing. 45 | var _car_heading = chase_vehicles[vcallsign].heading - 90.0; 46 | if (_car_heading<=90.0){ 47 | chase_vehicles[vcallsign].marker.setIcon(habitat_car_icons[chase_vehicles[vcallsign].colour]); 48 | chase_vehicles[vcallsign].marker.setRotationAngle(_car_heading); 49 | }else{ 50 | // We are travelling West - we need to use the flipped car icon. 51 | _car_heading = _car_heading - 180.0; 52 | chase_vehicles[vcallsign].marker.setIcon(habitat_car_icons_flipped[chase_vehicles[vcallsign].colour]); 53 | chase_vehicles[vcallsign].marker.setRotationAngle(_car_heading); 54 | } 55 | 56 | } else { 57 | 58 | // Otherwise, we need to decide if we're going to add it or not. 59 | 60 | // Is it us? 61 | if(vcallsign.startsWith(chase_config.habitat_call)){ 62 | // Don't add! 63 | return; 64 | } 65 | 66 | // Determine the vehicle distance from our current position. 67 | var v_pos = {lat: v_lat, lon:v_lon, alt:v_alt}; 68 | if (chase_car_position.marker === "NONE"){ 69 | var my_pos = {lat:chase_config.default_lat, lon:chase_config.default_lon, alt:0}; 70 | }else{ 71 | var my_pos = {lat:chase_car_position.latest_data[0], lon:chase_car_position.latest_data[1], alt:chase_car_position.latest_data[2]}; 72 | } 73 | var v_range = calculate_lookangles(my_pos, v_pos).range/1000.0; 74 | 75 | // If the range is less than the threshold, add it to our list of chase vehicles. 76 | if(v_range < vehicle_max_range){ 77 | //console.log("Adding: " + vcallsign); 78 | chase_vehicles[vcallsign] = {}; 79 | // Initialise a few default values 80 | chase_vehicles[vcallsign].heading = 90; 81 | chase_vehicles[vcallsign].latest_data = [v_lat, v_lon, v_alt]; 82 | chase_vehicles[vcallsign].position_id = data.ts; 83 | 84 | // Get an index for the car icon. This is incremented for each vehicle, 85 | // giving each a different colour. 86 | chase_vehicles[vcallsign].colour = car_colour_values[car_colour_idx]; 87 | car_colour_idx = (car_colour_idx+1)%car_colour_values.length; 88 | 89 | // Create marker 90 | chase_vehicles[vcallsign].marker = L.marker(chase_vehicles[vcallsign].latest_data, 91 | {title:vcallsign, 92 | icon: habitat_car_icons[chase_vehicles[vcallsign].colour], 93 | rotationOrigin: "center center"}); 94 | 95 | // Add tooltip, with custom CSS which removes all tooltip borders, and adds a text shadow. 96 | chase_vehicles[vcallsign].marker.bindTooltip(vcallsign, 97 | {permanent: true, 98 | direction: 'center', 99 | offset:[0,25], 100 | className:'custom_label'}).openTooltip(); 101 | if(document.getElementById("showOtherCars").checked){ 102 | // Add the car to the map if we have the show other cars button checked. 103 | chase_vehicles[vcallsign].marker.addTo(map); 104 | // Keep our own record of if this marker has been added to a map, 105 | // as we shouldn't be using the private _map property of the marker object. 106 | chase_vehicles[vcallsign].onmap = true; 107 | } 108 | 109 | } 110 | } 111 | } 112 | } 113 | } 114 | 115 | 116 | function flush_sondehub_vehicles(){ 117 | for (_car in chase_vehicles){ 118 | // Remove from map if present. 119 | if(chase_vehicles[_car].onmap){ 120 | chase_vehicles[_car].marker.remove(); 121 | chase_vehicles[_car].onmap = false; 122 | } 123 | delete chase_vehicles[_car]; 124 | } 125 | } 126 | 127 | // 128 | // SondeHub Websockets connection. 129 | // 130 | var livedata = "wss://ws-reader.v2.sondehub.org/"; 131 | var clientID = "ChaseMapper-" + Math.floor(Math.random() * 10000000000); 132 | var client; 133 | var clientConnected = false; 134 | var clientActive = false; 135 | var clientTopic; 136 | 137 | function onConnect() { 138 | if (chase_config.profiles[chase_config.selected_profile].online_tracker === "sondehub") { 139 | var topic = "listener/#"; 140 | client.subscribe(topic); 141 | clientTopic = topic; 142 | } else if (chase_config.profiles[chase_config.selected_profile].online_tracker === "sondehubamateur") { 143 | var topic = "amateur-listener/#"; 144 | client.subscribe(topic); 145 | clientTopic = topic; 146 | } else { 147 | return; 148 | } 149 | clientConnected = true; 150 | clientActive = true; 151 | console.log("SondeHub Websockets Connected - Subscribed to " + clientTopic); 152 | }; 153 | 154 | function connectionError(error) { 155 | clientConnected = false; 156 | clientActive = false; 157 | console.log("SondeHub Websockets Connection Error"); 158 | }; 159 | 160 | function onConnectionLost(responseObject) { 161 | if (responseObject.errorCode !== 0) { 162 | clientConnected = false; 163 | clientActive = false; 164 | console.log("SondeHub Websockets Connection Lost"); 165 | } 166 | }; 167 | 168 | function onMessageArrived(message) { 169 | try { 170 | if (clientActive) { 171 | var frame = JSON.parse(message.payloadString.toString()); 172 | handleSondeHubWebSocketPacket(frame); 173 | } 174 | } 175 | catch(err) {} 176 | }; 177 | 178 | function startSondeHubWebsockets() { 179 | if(document.getElementById("showOtherCars").checked){ 180 | // Clear off any vehicles on the map. 181 | flush_sondehub_vehicles(); 182 | 183 | if(clientConnected == false){ 184 | // Not connected yet. Start a new connection. 185 | client = new Paho.Client(livedata, clientID); 186 | client.onConnectionLost = onConnectionLost; 187 | client.onMessageArrived = onMessageArrived; 188 | client.connect({onSuccess:onConnect,onFailure:connectionError,reconnect:true}); 189 | } else { 190 | // Already connected, un-sub and re-sub to the correct topic. 191 | client.unsubscribe(clientTopic); 192 | onConnect(); 193 | } 194 | } else { 195 | if(clientConnected || (client != null)){ 196 | client.disconnect(); 197 | clientConnected = false; 198 | console.log("SondeHub Websockets Disconnected.") 199 | } 200 | } 201 | } 202 | 203 | 204 | // Show/Hide all vehicles. 205 | function show_sondehub_vehicles(){ 206 | var state = document.getElementById("showOtherCars").checked; 207 | 208 | for (_car in chase_vehicles){ 209 | // Add to map, if its not already on there. 210 | if(state){ 211 | if(!chase_vehicles[_car].onmap){ 212 | chase_vehicles[_car].marker.addTo(map); 213 | chase_vehicles[_car].onmap = true; 214 | } 215 | } else{ 216 | if(chase_vehicles[_car].onmap){ 217 | chase_vehicles[_car].marker.remove(); 218 | chase_vehicles[_car].onmap = false; 219 | } 220 | } 221 | } 222 | 223 | // Re-connect to websockets if necessary. 224 | startSondeHubWebsockets(); 225 | } 226 | 227 | 228 | 229 | 230 | 231 | 232 | /* Habitat ChaseCar lib (copied from SondeHub Tracker) 233 | * Uploads geolocation for chase cars to habitat 234 | * 235 | * Author: Rossen Gerogiev / Mark Jessop 236 | * Requires: jQuery 237 | * 238 | * Updated to SondeHub v2 by Mark Jessop 239 | */ 240 | 241 | ChaseCar = { 242 | db_uri: "https://api.v2.sondehub.org/listeners", // Sondehub API 243 | recovery_uri: "https://api.v2.sondehub.org/recovered", 244 | }; 245 | 246 | // Updated SondeHub position upload function. 247 | // Refer PUT listeners API here: https://generator.swagger.io/?url=https://raw.githubusercontent.com/projecthorus/sondehub-infra/main/swagger.yaml 248 | // @callsign string 249 | // @position object (geolocation position object) 250 | ChaseCar.updatePosition = function(callsign, position) { 251 | if(!position || !position.coords) return; 252 | 253 | // Set altitude to zero if not provided. 254 | _position_alt = ((!!position.coords.altitude) ? position.coords.altitude : 0); 255 | 256 | var _doc = { 257 | "software_name": "SondeHub Tracker", 258 | "software_version": "{VER}", 259 | "uploader_callsign": callsign, 260 | "uploader_position": [position.coords.latitude, position.coords.longitude, _position_alt], 261 | "uploader_antenna": "Mobile Station", 262 | "uploader_contact_email": "none@none.com", 263 | "mobile": true 264 | }; 265 | 266 | // push the doc to sondehub 267 | $.ajax({ 268 | type: "PUT", 269 | url: ChaseCar.db_uri, 270 | contentType: "application/json; charset=utf-8", 271 | dataType: "json", 272 | data: JSON.stringify(_doc), 273 | }); 274 | }; 275 | 276 | 277 | ChaseCar.markRecovered = function(serial, lat, lon, recovered, callsign, notes){ 278 | 279 | var _doc = { 280 | "serial": serial, 281 | "lat": lat, 282 | "lon": lon, 283 | "alt": 0.0, 284 | "recovered": recovered, 285 | "recovered_by": callsign, 286 | "description": notes 287 | }; 288 | 289 | $.ajax({ 290 | type: "PUT", 291 | url: ChaseCar.recovery_uri, 292 | contentType: "application/json; charset=utf-8", 293 | dataType: "json", 294 | data: JSON.stringify(_doc), 295 | }).done(function(data) { 296 | console.log(data); 297 | alert("Recovery Reported OK!"); 298 | }) 299 | .fail(function(jqXHR, textStatus, error) { 300 | try { 301 | _fail_resp = JSON.parse(jqXHR.responseText); 302 | alert("Error Submitting Recovery Report: " + _fail_resp.message); 303 | } catch(err) { 304 | alert("Error Submitting Recovery Report."); 305 | } 306 | }) 307 | 308 | } -------------------------------------------------------------------------------- /static/js/utils.js: -------------------------------------------------------------------------------- 1 | // 2 | // Project Horus - Browser-Based Chase Mapper - Utility Functions 3 | // 4 | // Copyright (C) 2019 Mark Jessop 5 | // Released under GNU GPL v3 or later 6 | // 7 | 8 | // Color cycling for balloon traces and icons - Hopefully 4 colors should be enough for now! 9 | var colour_values = ['blue','green','purple']; 10 | var colour_idx = 0; 11 | 12 | // Create a set of icons for the different colour values. 13 | var balloonAscentIcons = {}; 14 | var balloonDescentIcons = {}; 15 | var balloonLandingIcons = {}; 16 | var balloonPayloadIcons = {}; 17 | 18 | // TODO: Make these /static URLS be filled in with templates (or does it not matter?) 19 | for (_col in colour_values){ 20 | balloonAscentIcons[colour_values[_col]] = L.icon({ 21 | iconUrl: "/static/img/balloon-" + colour_values[_col] + '.png', 22 | iconSize: [46, 85], 23 | iconAnchor: [23, 76] 24 | }); 25 | balloonDescentIcons[colour_values[_col]] = L.icon({ 26 | iconUrl: "/static/img/parachute-" + colour_values[_col] + '.png', 27 | iconSize: [46, 84], 28 | iconAnchor: [23, 76] 29 | }); 30 | balloonLandingIcons[colour_values[_col]] = L.icon({ 31 | iconUrl: "/static/img/target-" + colour_values[_col] + '.png', 32 | iconSize: [20, 20], 33 | iconAnchor: [10, 10] 34 | }); 35 | balloonPayloadIcons[colour_values[_col]] = L.icon({ 36 | iconUrl: "/static/img/payload-" + colour_values[_col] + '.png', 37 | iconSize: [17, 18], 38 | iconAnchor: [8, 14] 39 | }); 40 | } 41 | 42 | // Burst Icon 43 | var burstIcon = L.icon({ 44 | iconUrl: "/static/img/balloon-pop.png", 45 | iconSize: [20,20], 46 | iconAnchor: [10,10] 47 | }); 48 | 49 | // Abort prediction icon (red) 50 | var abortIcon = L.icon({ 51 | iconUrl: "/static/img/target-red.png", 52 | iconSize: [20,20], 53 | iconAnchor: [10,10] 54 | }); 55 | 56 | // Icons for our own chase car. 57 | var carIcon = L.icon({ 58 | iconUrl: "/static/img/car-blue.png", 59 | iconSize: [55,25], 60 | iconAnchor: [27,12] // Revisit this 61 | }); 62 | 63 | var carIconFlip = L.icon({ 64 | iconUrl: "/static/img/car-blue-flip.png", 65 | iconSize: [55,25], 66 | iconAnchor: [27,12] // Revisit this 67 | }); 68 | 69 | // Home Icon. 70 | var homeIcon = L.icon({ 71 | iconUrl: '/static/img/antenna-green.png', 72 | iconSize: [26, 34], 73 | iconAnchor: [13, 34] 74 | }); 75 | 76 | 77 | // Habitat (or APRS?) sourced chase car icons. 78 | var car_colour_values = ['red', 'green', 'yellow']; 79 | var car_colour_idx = 0; 80 | var habitat_car_icons = {}; 81 | var habitat_car_icons_flipped = {}; 82 | for (_col in car_colour_values){ 83 | habitat_car_icons[car_colour_values[_col]] = L.icon({ 84 | iconUrl: "/static/img/car-"+car_colour_values[_col]+".png", 85 | iconSize: [55,25], 86 | iconAnchor: [27,12] // Revisit this 87 | }); 88 | 89 | habitat_car_icons_flipped[car_colour_values[_col]] = L.icon({ 90 | iconUrl: "/static/img/car-"+car_colour_values[_col]+"-flip.png", 91 | iconSize: [55,25], 92 | iconAnchor: [27,12] // Revisit this 93 | }); 94 | } 95 | 96 | 97 | // calculates look angles between two points 98 | // format of a and b should be {lon: 0, lat: 0, alt: 0} 99 | // returns {elevention: 0, azimut: 0, bearing: "", range: 0} 100 | // 101 | // based on earthmath.py 102 | // Copyright 2012 (C) Daniel Richman; GNU GPL 3 103 | 104 | var DEG_TO_RAD = Math.PI / 180.0; 105 | var EARTH_RADIUS = 6371000.0; 106 | 107 | function calculate_lookangles(a, b) { 108 | // degrees to radii 109 | a.lat = a.lat * DEG_TO_RAD; 110 | a.lon = a.lon * DEG_TO_RAD; 111 | b.lat = b.lat * DEG_TO_RAD; 112 | b.lon = b.lon * DEG_TO_RAD; 113 | 114 | var d_lon = b.lon - a.lon; 115 | var sa = Math.cos(b.lat) * Math.sin(d_lon); 116 | var sb = (Math.cos(a.lat) * Math.sin(b.lat)) - (Math.sin(a.lat) * Math.cos(b.lat) * Math.cos(d_lon)); 117 | var bearing = Math.atan2(sa, sb); 118 | var aa = Math.sqrt(Math.pow(sa, 2) + Math.pow(sb, 2)); 119 | var ab = (Math.sin(a.lat) * Math.sin(b.lat)) + (Math.cos(a.lat) * Math.cos(b.lat) * Math.cos(d_lon)); 120 | var angle_at_centre = Math.atan2(aa, ab); 121 | var great_circle_distance = angle_at_centre * EARTH_RADIUS; 122 | 123 | ta = EARTH_RADIUS + a.alt; 124 | tb = EARTH_RADIUS + b.alt; 125 | ea = (Math.cos(angle_at_centre) * tb) - ta; 126 | eb = Math.sin(angle_at_centre) * tb; 127 | var elevation = Math.atan2(ea, eb) / DEG_TO_RAD; 128 | 129 | // Use Math.coMath.sine rule to find unknown side. 130 | var distance = Math.sqrt(Math.pow(ta, 2) + Math.pow(tb, 2) - 2 * tb * ta * Math.cos(angle_at_centre)); 131 | 132 | // Give a bearing in range 0 <= b < 2pi 133 | bearing += (bearing < 0) ? 2 * Math.PI : 0; 134 | bearing /= DEG_TO_RAD; 135 | 136 | var value = Math.round(bearing % 90); 137 | value = ((bearing > 90 && bearing < 180) || (bearing > 270 && bearing < 360)) ? 90 - value : value; 138 | 139 | var str_bearing = "" + ((bearing < 90 || bearing > 270) ? 'N' : 'S')+ " " + value + '° ' + ((bearing < 180) ? 'E' : 'W'); 140 | 141 | return { 142 | 'elevation': elevation, 143 | 'azimuth': bearing, 144 | 'range': distance, 145 | 'bearing': str_bearing 146 | }; 147 | } 148 | 149 | function textToClipboard(text) { 150 | // Copy a string to the user's clipboard. 151 | // From here: https://stackoverflow.com/questions/33855641/copy-output-of-a-javascript-variable-to-the-clipboard 152 | var dummy = document.createElement("textarea"); 153 | document.body.appendChild(dummy); 154 | dummy.value = text; 155 | dummy.select(); 156 | document.execCommand("copy"); 157 | document.body.removeChild(dummy); 158 | } -------------------------------------------------------------------------------- /utils/bearing_o_clock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # ChaseMapper - Bearing o'Clock 4 | # 5 | # Add bearings based on O'Clock position (1 through 12) 6 | # Run with: python bearing_o_clock.py bearing_source_name 7 | # 8 | # Copyright (C) 2019 Mark Jessop 9 | # Released under GNU GPL v3 or later 10 | # 11 | # 12 | import json 13 | import socket 14 | import sys 15 | import time 16 | import datetime 17 | import traceback 18 | 19 | 20 | def send_relative_bearing(bearing, source, heading_override=False, udp_port=55672): 21 | """ 22 | Send a basic relative bearing 23 | """ 24 | packet = { 25 | 'type' : 'BEARING', 26 | 'bearing' : bearing, 27 | 'bearing_type': 'relative', 28 | 'source': source 29 | } 30 | 31 | if heading_override: 32 | packet["heading_override"] = True 33 | 34 | # Set up our UDP socket 35 | s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM) 36 | s.settimeout(1) 37 | # Set up socket for broadcast, and allow re-use of the address 38 | s.setsockopt(socket.SOL_SOCKET,socket.SO_BROADCAST,1) 39 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 40 | try: 41 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) 42 | except: 43 | pass 44 | s.bind(('',udp_port)) 45 | try: 46 | s.sendto(json.dumps(packet).encode('ascii'), ('', udp_port)) 47 | except socket.error: 48 | s.sendto(json.dumps(packet).encode('ascii'), ('127.0.0.1', udp_port)) 49 | 50 | 51 | if __name__ == "__main__": 52 | if len(sys.argv) > 1: 53 | _source = sys.argv[1] 54 | else: 55 | _source = "o_clock_entry" 56 | 57 | try: 58 | while True: 59 | 60 | print("Enter O-Clock Bearing (1-12):") 61 | _val = input() 62 | 63 | try: 64 | _val_int = int(_val) 65 | 66 | _bearing = (_val_int%12)*30 67 | 68 | print(f"Sending Relative Bearing: {_bearing}") 69 | 70 | send_relative_bearing(_bearing, _source, heading_override=True) 71 | except Exception as e: 72 | print(f"Error handling input: {str(e)}") 73 | except KeyboardInterrupt: 74 | sys.exit(0) 75 | 76 | 77 | --------------------------------------------------------------------------------