mirror of
https://github.com/skinnyrad/Lora-Scanner.git
synced 2026-11-04 12:52:29 +01:00
344 lines
9.2 KiB
Go
344 lines
9.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
"github.com/skinnyrad/Lora-Scanner/internal/frames"
|
|
"github.com/skinnyrad/Lora-Scanner/internal/hub"
|
|
)
|
|
|
|
const (
|
|
// A dead gateway fails fast, but a slow one (the log page is large and
|
|
// the gateway's CGI is slow) gets time to finish sending it.
|
|
dialTimeout = 5 * time.Second
|
|
headerTimeout = 10 * time.Second
|
|
requestTimeout = 45 * time.Second
|
|
// Offline gateways are retried this often until they answer again.
|
|
RetryInterval = 5 * time.Second
|
|
// A gateway is only marked offline after this many failed polls in a row,
|
|
// so one slow or dropped response doesn't flip it.
|
|
FailThreshold = 2
|
|
tickEvery = time.Second
|
|
)
|
|
|
|
var (
|
|
rssiRe = regexp.MustCompile(`"Rssi":(-?\d+)`)
|
|
hiddenStyle = "display: none;"
|
|
)
|
|
|
|
// Row is one packet parsed from the gateway's log-traffic page.
|
|
type Row struct {
|
|
DevID string
|
|
Freq float64
|
|
RSSI *int
|
|
Text string
|
|
// Detail is the hidden row's JSON-ish text (address, size, RSSI, SNR, MIC...).
|
|
Detail string
|
|
}
|
|
|
|
// Parse extracts packet rows from a Dragino /cgi-bin/log-traffic.has page.
|
|
// Visible rows are summaries; each is followed by a hidden row holding JSON-ish
|
|
// details (not valid JSON, hence the regex for RSSI).
|
|
func Parse(r io.Reader) ([]Row, error) {
|
|
doc, err := goquery.NewDocumentFromReader(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
table := doc.Find("table").First()
|
|
var rows []Row
|
|
table.Find("tr").Each(func(i int, tr *goquery.Selection) {
|
|
if i == 0 || strings.Contains(tr.AttrOr("style", ""), hiddenStyle) {
|
|
return
|
|
}
|
|
var cells []string
|
|
tr.Find("td").Each(func(_ int, td *goquery.Selection) {
|
|
cells = append(cells, strings.TrimSpace(td.Text()))
|
|
})
|
|
if len(cells) < 2 {
|
|
return
|
|
}
|
|
fields := cells[1:] // first cell is the chevron
|
|
if len(fields) < 4 {
|
|
return
|
|
}
|
|
freq, err := strconv.ParseFloat(strings.TrimSpace(fields[3]), 64)
|
|
if err != nil {
|
|
return
|
|
}
|
|
devID := extractDevID(fields[len(fields)-1])
|
|
if devID == "" {
|
|
return
|
|
}
|
|
row := Row{DevID: devID, Freq: freq, Text: strings.Join(fields, " | ")}
|
|
hidden := tr.NextFiltered("tr")
|
|
if hidden.Length() > 0 && strings.Contains(hidden.AttrOr("style", ""), hiddenStyle) {
|
|
row.Detail = strings.Join(strings.Fields(hidden.Text()), " ")
|
|
if m := rssiRe.FindStringSubmatch(hidden.Text()); m != nil {
|
|
if v, err := strconv.Atoi(m[1]); err == nil {
|
|
row.RSSI = &v
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
})
|
|
return rows, nil
|
|
}
|
|
|
|
// extractDevID turns "Dev Addr: 27FD3566, Size: 20" into "27FD3566".
|
|
func extractDevID(content string) string {
|
|
first := strings.SplitN(content, ",", 2)[0]
|
|
parts := strings.Split(first, ":")
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
|
|
// Gateway states reported to the UI.
|
|
const (
|
|
StateUnknown = "unknown" // not polled yet
|
|
StateOnline = "online" // last poll succeeded
|
|
StateOffline = "offline" // last poll failed
|
|
)
|
|
|
|
// GatewayStatus is what the last poll of a gateway found.
|
|
type GatewayStatus struct {
|
|
State string `json:"state"`
|
|
Error string `json:"error,omitempty"`
|
|
LastSuccess *time.Time `json:"lastSuccess"`
|
|
}
|
|
|
|
// StatusMsg is broadcast on the hub whenever any gateway changes state.
|
|
type StatusMsg struct {
|
|
Type string `json:"type"` // "gateways"
|
|
Gateways map[string]GatewayStatus `json:"gateways"`
|
|
}
|
|
|
|
type gwState struct {
|
|
GatewayStatus
|
|
lastFinish time.Time
|
|
running bool
|
|
fails int // consecutive failed polls
|
|
}
|
|
|
|
type Scraper struct {
|
|
Config *Config
|
|
Frames *frames.Store
|
|
Hub *hub.Hub // optional: receives StatusMsg on state changes
|
|
User string
|
|
Pass string
|
|
Client *http.Client
|
|
URLFor func(ip string) string
|
|
|
|
mu sync.Mutex // guards seen and states
|
|
seen map[string]struct{}
|
|
state map[string]*gwState
|
|
|
|
retry time.Duration // offline retry cadence (RetryInterval; tests shorten it)
|
|
tick time.Duration
|
|
}
|
|
|
|
func NewScraper(cfg *Config, f *frames.Store, user, pass string) *Scraper {
|
|
return &Scraper{
|
|
Config: cfg, Frames: f, User: user, Pass: pass,
|
|
Client: &http.Client{
|
|
Timeout: requestTimeout,
|
|
Transport: &http.Transport{
|
|
// A host that silently drops packets must fail fast, not after the full request timeout.
|
|
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
|
|
ResponseHeaderTimeout: headerTimeout,
|
|
},
|
|
},
|
|
URLFor: func(ip string) string { return fmt.Sprintf("http://%s:8000/cgi-bin/log-traffic.has", ip) },
|
|
seen: map[string]struct{}{},
|
|
state: map[string]*gwState{},
|
|
retry: RetryInterval,
|
|
tick: tickEvery,
|
|
}
|
|
}
|
|
|
|
// Run polls until ctx ends. Each gateway has its own schedule: every
|
|
// Config.PollInterval() while online, every RetryInterval while offline or
|
|
// confirming a failed poll. A gateway whose previous scrape is still running is skipped.
|
|
func (s *Scraper) Run(ctx context.Context) {
|
|
t := time.NewTicker(s.tick)
|
|
defer t.Stop()
|
|
for {
|
|
s.launch(ctx, false)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScrapeAll scrapes every idle gateway now, regardless of schedule, and waits.
|
|
func (s *Scraper) ScrapeAll(ctx context.Context) { s.launch(ctx, true)() }
|
|
|
|
// launch starts a scrape for each gateway that is due (or all idle ones when
|
|
// force is set) and returns a func that waits for them.
|
|
func (s *Scraper) launch(ctx context.Context, force bool) (wait func()) {
|
|
ips := s.Config.List()
|
|
interval := s.Config.PollInterval()
|
|
now := time.Now()
|
|
|
|
s.mu.Lock()
|
|
configured := make(map[string]bool, len(ips))
|
|
for _, ip := range ips {
|
|
configured[ip] = true
|
|
}
|
|
for ip, st := range s.state {
|
|
if !configured[ip] && !st.running {
|
|
delete(s.state, ip) // removed from the list
|
|
}
|
|
}
|
|
var start []string
|
|
for _, ip := range ips {
|
|
st := s.state[ip]
|
|
if st == nil {
|
|
st = &gwState{GatewayStatus: GatewayStatus{State: StateUnknown}}
|
|
s.state[ip] = st
|
|
}
|
|
if st.running {
|
|
continue
|
|
}
|
|
wait := interval
|
|
if st.State != StateOnline || st.fails > 0 { // offline, or confirming a failure
|
|
wait = s.retry
|
|
}
|
|
if force || st.lastFinish.IsZero() || now.Sub(st.lastFinish) >= wait {
|
|
st.running = true
|
|
start = append(start, ip)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
var wg sync.WaitGroup
|
|
for _, ip := range start {
|
|
wg.Add(1)
|
|
go func(ip string) {
|
|
defer wg.Done()
|
|
began := time.Now()
|
|
s.finish(ip, s.scrapeOne(ctx, ip), time.Since(began))
|
|
}(ip)
|
|
}
|
|
return wg.Wait
|
|
}
|
|
|
|
// finish records a poll result and announces state changes.
|
|
func (s *Scraper) finish(ip string, err error, took time.Duration) {
|
|
s.mu.Lock()
|
|
st := s.state[ip]
|
|
if st == nil { // removed while the scrape was running
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
prev := st.State
|
|
st.running = false
|
|
st.lastFinish = time.Now()
|
|
if err != nil {
|
|
st.fails++
|
|
// One failure is only a warning: the state flips after FailThreshold in a row.
|
|
if st.fails >= FailThreshold {
|
|
st.State, st.Error = StateOffline, err.Error()
|
|
}
|
|
} else {
|
|
now := time.Now()
|
|
st.fails = 0
|
|
st.State, st.Error, st.LastSuccess = StateOnline, "", &now
|
|
}
|
|
cur := st.State
|
|
changed := prev != cur
|
|
s.mu.Unlock()
|
|
|
|
if !changed {
|
|
return
|
|
}
|
|
if cur == StateOffline {
|
|
fmt.Printf("gateway %s offline after %d failed polls: %v (last took %s); retrying every %s\n",
|
|
ip, FailThreshold, err, took.Round(time.Millisecond), s.retry)
|
|
} else if prev == StateOffline {
|
|
fmt.Printf("gateway %s back online\n", ip)
|
|
}
|
|
if s.Hub != nil {
|
|
s.Hub.Broadcast(s.StatusMsg())
|
|
}
|
|
}
|
|
|
|
// Statuses reports the last poll result for every configured gateway.
|
|
func (s *Scraper) Statuses() map[string]GatewayStatus {
|
|
ips := s.Config.List()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make(map[string]GatewayStatus, len(ips))
|
|
for _, ip := range ips {
|
|
if st := s.state[ip]; st != nil {
|
|
out[ip] = st.GatewayStatus
|
|
} else {
|
|
out[ip] = GatewayStatus{State: StateUnknown}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Scraper) StatusMsg() StatusMsg { return StatusMsg{Type: "gateways", Gateways: s.Statuses()} }
|
|
|
|
func (s *Scraper) request(ctx context.Context, method, ip string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, s.URLFor(ip), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.SetBasicAuth(s.User, s.Pass)
|
|
return s.Client.Do(req)
|
|
}
|
|
|
|
func (s *Scraper) scrapeOne(ctx context.Context, ip string) error {
|
|
resp, err := s.request(ctx, http.MethodGet, ip)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("status %s", resp.Status)
|
|
}
|
|
// Read the whole page first so a stalled download is reported as such and
|
|
// nothing is half-parsed; the next poll sees the same rows again (deduped).
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("reading page (got %d bytes): %w", len(body), err)
|
|
}
|
|
rows, err := Parse(bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
// Oldest first so survey order is chronological (the page lists newest first).
|
|
for i := len(rows) - 1; i >= 0; i-- {
|
|
r := rows[i]
|
|
key := fmt.Sprintf("%s_%v_%s", r.DevID, r.Freq, r.Text)
|
|
if _, dup := s.seen[key]; dup {
|
|
continue
|
|
}
|
|
s.seen[key] = struct{}{}
|
|
raw := r.Text
|
|
if r.Detail != "" {
|
|
raw += "\n" + r.Detail
|
|
}
|
|
s.Frames.Add(frames.Frame{
|
|
Kind: frames.KindLoRaWAN, Source: ip, Identifier: r.DevID,
|
|
Freq: r.Freq, RSSI: r.RSSI, Info: r.Text, Raw: raw,
|
|
})
|
|
}
|
|
return nil
|
|
}
|