Skip to content

Commit

Permalink
Search be proximity to a place
Browse files Browse the repository at this point in the history
  • Loading branch information
jancona committed Aug 19, 2024
1 parent f8f5f2b commit 6d30711
Show file tree
Hide file tree
Showing 4 changed files with 160 additions and 13 deletions.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ In the case of analog FM zone names, all repeaters go into the specified zone, s
Pattern for forming DMR group list names (default zone + ' $time_slot')
-in string
Input QDMR Codeplug YAML file (default STDIN)
-loc string
Center location for proximity search, e.g. 'Bangor, ME', 'München'
-na
Use North American RepeaterBook database. Set it to 'false' to query outside the US, Canada and Mexico. (default true)
-name_lim int
Expand All @@ -120,13 +122,17 @@ In the case of analog FM zone names, all repeaters go into the specified zone, s
Output QDMR Codeplug YAML file (default STDOUT)
-power string
Channel power setting, one of ('Min' 'Low' 'Mid' 'High' 'Max') (default "High")
-radius float
Radius for proximity search (default 25)
-tg
Only include DMR repeaters that have talkgroups defined (default true)
-tg_source string
One of ('most' 'rfinder' 'details').
RadioID has two fields that may contain talkgroup info, 'details' and 'rfinder'.
By default dmrfill uses the data from whichever field has the most talkgroups defined.
Selecting 'rfinder' or 'details' uses the named field. (default "most")
-units string
Distance units for proximity search, one of ('miles' 'km') (default "miles")
-v verbose logging
-vv
more verbose logging
Expand All @@ -143,9 +149,7 @@ In the case of analog FM zone names, all repeaters go into the specified zone, s
[RadioID](https://radioid.net/) issues DMR and NXDN ID's. It also maintains a DMR repeater database that includes talkgroup information. The [site](https://radioid.net/) also includes a repeater map and contact generator tool.

### QDMR
[QDMR](https://dm3mat.darc.de/qdmr/) is the open source tool that inspired `dmrfill`. Bydefining and documenting a codeplug standard file format, along with tools for reading, writing and editing them, QDMR made `dmrfill` possible.
[QDMR](https://dm3mat.darc.de/qdmr/) is the open source tool that inspired `dmrfill`. By defining and documenting a codeplug standard file format, along with tools for reading, writing and editing them, QDMR made `dmrfill` possible.

<!--
### Geonames
-->
[Geonames](https://www.geonames.org/) provides a geographical database that covers all countries and contains over eleven million placenames that are available for download free of charge. `dmrfill` uses their API to convert placenames to longitude/latitude in order to do proximity queries.
17 changes: 17 additions & 0 deletions dmrfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ var (
open bool
onAir bool
tgSource string
location string
radius float64
radiusUnits string
verbose bool
veryVerbose bool
)
Expand All @@ -112,6 +115,9 @@ func init() {
RadioID has two fields that may contain talkgroup info, 'details' and 'rfinder'.
By default dmrfill uses the data from whichever field has the most talkgroups defined.
Selecting 'rfinder' or 'details' uses the named field.`)
flag.StringVar(&location, "loc", "", "Center location for proximity search, e.g. 'Bangor, ME', 'München'")
flag.Float64Var(&radius, "radius", 25, "Radius for proximity search")
flag.StringVar(&radiusUnits, "units", "miles", "Distance units for proximity search, one of ('miles' 'km')")
flag.BoolVar(&verbose, "v", false, "verbose logging")
flag.BoolVar(&veryVerbose, "vv", false, "more verbose logging")
}
Expand Down Expand Up @@ -458,6 +464,17 @@ func parseArguments() (io.ReadCloser, io.WriteCloser) {
fatal("tg_source must be one of (most rfinder details)")
}

if radius <= 0.0 {
fatal("radius must be greater than zero")
}

switch radiusUnits {
case "miles", "km":
// good
default:
fatal("units must be one of (miles km)")
}

return yamlReader, yamlWriter
}

Expand Down
76 changes: 76 additions & 0 deletions geonames.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"encoding/json"
"net/http"
"net/url"
)

// API Doc: https://www.geonames.org/export/ws-overview.html
// Example: http://api.geonames.org/searchJSON?q=harfords%20point,%20me&maxRows=10&username=
const geonamesURL = "http://api.geonames.org/searchJSON"

func QueryGeonames(query string) (*GeonamesResults, error) {
var base = geonamesURL

baseURL, err := url.Parse(base)
if err != nil {
panic("Error parsing base URL " + base + ": " + err.Error())
}
params := url.Values{}
params.Add("q", query)
params.Add("maxRows", "1")
params.Add("username", "dmrfill")
baseURL.RawQuery = params.Encode()
logVerbose("Geonames URL %s", baseURL.String())
req, err := http.NewRequest("GET", baseURL.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Cache-Control", "max-age=3600") // Cache results for an hour
resp, err := cachingHttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.Header.Get("X-From-Cache") == "1" {
logVerbose("using cached response")
}
logVeryVerbose("response status %s", resp.Status)
logVeryVerbose("response headers: %#v", resp.Header)
decoder := json.NewDecoder(resp.Body)
result := GeonamesResults{
Geonames: []GeonamesResult{},
}
err = decoder.Decode(&result)
if err != nil {
return nil, err
}
logVerbose("found %d results", result.TotalResultsCount)
// pretty.Println(result)
return &result, nil
}

type GeonamesResults struct {
TotalResultsCount int
Geonames []GeonamesResult
}

type GeonamesResult struct {
GeonameId int `json:"geonameId"` // 4966529
Lat string `json:"lat"` // "45.49477"
Lng string `json:"lng"` // "-69.6145"
Name string `json:"name"` // "Harfords Point"
ToponymName string `json:"toponymName"` // "Harfords Point"
AdminName1 string `json:"adminName1"` // "Maine"
AdminCode1 string `json:"adminCode1"` // "ME"
CountryId string `json:"countryId"` // "6252001"
CountryCode string `json:"countryCode"` // "US"
CountryName string `json:"countryName"` // "United States"
Fcl string `json:"fcl"` // "T"
FclName string `json:"fclName"` // "mountain,hill,rock,... "
Population int `json:"population"` // 0
FcodeName string `json:"fcodeName"` // "cape"
Fcode string `json:"fcode"` // "CAPE"
}
68 changes: 59 additions & 9 deletions repeaterbook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
Expand All @@ -17,6 +18,8 @@ import (
const repeaterBookNA = "https://www.repeaterbook.com/api/export.php"
const repeaterBookROW = "https://www.repeaterbook.com/api/exportROW.php"

const kmPerMile = 1.609344

// Supported query parameters
var repeaterBookQueryParamNames = map[string]struct{}{
"callsign": {}, // Repeater callsign
Expand All @@ -30,9 +33,20 @@ var repeaterBookQueryParamNames = map[string]struct{}{
"emcomm": {}, // ARES, RACES, SKYWARN, CANWARN
"stype": {}, // Service type. Only required when searching for GMRS repeaters. ex: stype=gmrs
}

// Supported proximity query parameters
var repeaterBookProxQueryParamNames = map[string]struct{}{
"qtype": {}, // Proximity search "prox"
"lat": {}, // Proximity search latitude
"lng": {}, // Proximity search longitude
"dist": {}, // Proximity search distance
"dunit": {}, // Proximity search distance units (km, ?)
}

// RepeaterBookResult JSON field names
var repeaterBookResultFields = map[string]string{}

// Put the RadioIDResult JSON field names in a map
// Put the RepeaterBookResult JSON field names in a map
func init() {
st := reflect.TypeOf(RepeaterBookResult{})
for i := 0; i < st.NumField(); i++ {
Expand All @@ -57,6 +71,27 @@ func QueryRepeaterBook(filters filterFlags) (*RepeaterBookResults, error) {
if onAir {
filters.Set("operational_status=On-air")
}
if location != "" {
// Do a proximity search
gResult, err := QueryGeonames(location)
if err != nil {
logError("Error reverse geocoding location '%s': %v", location, err)
os.Exit(1)
}
if gResult.TotalResultsCount < 1 {
logError("No location found for '%s'", location)
os.Exit(1)
}
if radiusUnits == "miles" {
radius = radius * kmPerMile
}
filters.Set("qtype=prox")
filters.Set("dunit=km")
filters.Set(fmt.Sprintf("dist=%f", radius))
filters.Set(fmt.Sprintf("lat=%s", gResult.Geonames[0].Lat))
filters.Set(fmt.Sprintf("lng=%s", gResult.Geonames[0].Lng))
}

baseURL, err := url.Parse(base)
if err != nil {
logError("Error parsing base URL %s: %v", base, err)
Expand All @@ -69,16 +104,31 @@ func QueryRepeaterBook(filters filterFlags) (*RepeaterBookResults, error) {
// Query params
params := url.Values{}
for _, f := range filters {
_, ok := repeaterBookQueryParamNames[f.key]
if ok && len(f.value) == 1 {
// RepeaterBook doesn't OR multiple filter parameters, it just uses the last one
for _, v := range f.value {
params.Add(f.key, v)
if location == "" {
_, ok := repeaterBookQueryParamNames[f.key]
if ok && len(f.value) == 1 {
// RepeaterBook doesn't OR multiple filter parameters, it just uses the last one
// for _, v := range f.value {
params.Add(f.key, f.value[0])
// }
} else {
_, ok := repeaterBookResultFields[f.key]
if ok {
resultFilters = append(resultFilters, f)
}
}
} else {
_, ok := repeaterBookResultFields[f.key]
if ok {
resultFilters = append(resultFilters, f)
_, ok := repeaterBookProxQueryParamNames[f.key]
if ok && len(f.value) == 1 {
// RepeaterBook doesn't OR multiple filter parameters, it just uses the last one
// for _, v := range f.value {
params.Add(f.key, f.value[0])
// }
} else {
_, ok := repeaterBookResultFields[f.key]
if ok {
resultFilters = append(resultFilters, f)
}
}
}
}
Expand Down

0 comments on commit 6d30711

Please sign in to comment.