From 6d3071118cd59fc0a42af46daf5a6188c7f66351 Mon Sep 17 00:00:00 2001 From: Jim Ancona Date: Mon, 19 Aug 2024 17:38:55 -0400 Subject: [PATCH] Search be proximity to a place --- README.md | 12 +++++--- dmrfill.go | 17 +++++++++++ geonames.go | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ repeaterbook.go | 68 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 geonames.go diff --git a/README.md b/README.md index e1d1565..9224676 100644 --- a/README.md +++ b/README.md @@ -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 @@ -120,6 +122,8 @@ 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 @@ -127,6 +131,8 @@ In the case of analog FM zone names, all repeaters go into the specified zone, s 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 @@ -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. - \ No newline at end of file +[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. diff --git a/dmrfill.go b/dmrfill.go index 2468c76..864649b 100644 --- a/dmrfill.go +++ b/dmrfill.go @@ -89,6 +89,9 @@ var ( open bool onAir bool tgSource string + location string + radius float64 + radiusUnits string verbose bool veryVerbose bool ) @@ -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") } @@ -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 } diff --git a/geonames.go b/geonames.go new file mode 100644 index 0000000..b6145d3 --- /dev/null +++ b/geonames.go @@ -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" +} diff --git a/repeaterbook.go b/repeaterbook.go index eb6c707..0325ea7 100644 --- a/repeaterbook.go +++ b/repeaterbook.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "net/http" "net/url" "os" @@ -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 @@ -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++ { @@ -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) @@ -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) + } } } }