Marathon-Mann (Linux-Magazin, März 2025)

Der Messwert der Bits pro Sekunde, die gerade durch den Internetanschluss sausen, gibt Aufschluss darüber, ob das Heimnetzwerk im grünen Bereich operiert oder ob gerade jemand im Haushalt unbotmäßig viel Bandbreite verbraucht. Fließt der gesamte Internet-Datenverkehr durch einen zentralen Router wie meine Pfsense-Appliance, lassen sich die flitzenden Bits einfach über mehrere Sekunden gemittelt zählen, zum Beispiel mit dem nützlichen Programm vnstat.

Auf dem Pfsense-Router installiert sich das Tool als normales Paket mittels pkg install vnstat wie im FreeBSD-Ökosystem üblich und service vnstat start startet den Dämon, der laufend über die Durchsatzmenge Buch führt und die Messwerte in einer eigenen Binärdatenbank ablegt. Nach etwas Vorlaufzeit können User dann abfragen, wie viele Bits geflossen sind, in den letzten paar Minuten, Stunden, Tagen, Wochen, Monaten oder Jahren. Abbildung 1 zeigt die Ausgabe des Tools auf der Kommandozeile als Antwort auf eine Anfrage nach dem Durchfluss in beide Richtungen, in Echtzeit.

Abbildung 1: Das vnstat-Kommando auf der Firewall zeigt die Auslastung in bits/sec an

Echtzeitauslastung anzeigen

Im Beispiel bestimmt der Parameter -i igb0 das WAN-Interface des Routers und -tr verlangt nach der Realtime-Auslastung desselben. Normalerweise lauscht das Tool fünf Sekunden, bis es das Ergebnis gemittelt ausgibt, der zusätzliche unbenamte Parameter 2 verkürzt die Zeitspanne auf zwei Sekunden. Die gemessenen Werte für rx (Receive, also Download) und tx (Transmit, also Upload) gibt das Tool entweder in bit/s, kbit/s oder mbit/s aus, je nachdem in welchen Größenordnungen sich der Messwert bewegt.

Abbildung 2: Normale Tabellenausgabe von vnstat

Abbildung 3: Down- und Upload einmal anders illustriert

Nun könnte ein Go-Programm diese Werte regelmäßig abholen und auf andere Art und Weise anzeigen. Die Utility vnstat bringt bereits das Rüstzeug für interessante Statistiken mit (Abbildung 2), aber wie wäre es mit zwei Comicfiguren als Läufer, von denen der für den Download von rechts nach links und der für den Upload in der Gegenrichtung marschiert, mit einer Geschwindigkeit, die dem gemessenen Durchflusswert entspricht? Abbildung 3 zeigt die fertige Applikation, ein Go-Programm, das die Läufer dynamisch mit dem Fyne-Framework darstellt. Dabei bewegen sich die Sprinter nicht nur von links nach rechts, sondern schlackern im Laufschritt auch mit ihren Gliedmaßen. Das passiert durch schnell überladene Einzelbilder, wie bei einem Zeichentrickfilm. Dazu in Kürze mehr.

Sicher ohne Passwort

Damit sich das Go-Programm ohne Angabe des Passworts auf dem Admin-Account des Routers einloggen und das vnstat-Kommando dort in einer Shell abfeuern kann, muss erstens auf dem FreeBSD-System ein ssh-Daemon laufen und auf einem eingestellten Port lauschen. Letzterer ist in den Einstellungen ab Werk deaktiviert, aber unter System/Advanced auf der Pfsense-Weboberfläche finden sich im unteren Bereich die zur Aktivierung notwendigen Schalter (Abbildung 4).

Abbildung 4: SSH auf Pfsense für Shell-Kommandos aktivieren

Mit der Option "Public Key Only" akzeptiert der Daemon aus Sicherheitsgründen kein Passwort zum Login unter dem User admin, sondern nur einen öffentlichen Schlüssel, den der Installateur unter .ssh/authorized_keys im User-Verzeichnis auf dem Router ablegt (auf Berechtigungen achten!) und fürderhin dem Go-Programm freien Zugang auf die Shell genehmigt. Der Port 8022 statt des Standardports 22 ist ein zusätzlicher Gimmick, muss aber später in Listing 4 mit der Option -p beim Aufbau der ssh-Verbindung angegeben werden.

Daumenkino

Wie in einem Zeichentrickfilm mit dem rosaroten Panther kommt Bewegung in die Animation, in dem Einzelbilder wie in einem Daumenkino durchrattern. Mit wie viel Muskelschmalz Trickfilme vor dem Durchbruch mit Computergrafik produziert wurden, davon konnte ich mich neulich im Academy Museum of Motion Pictures in Los Angeles überzeugen (Abbildungen 5, 6).

Abbildung 5: Zeichentisch des Disney-Animators Frank Thomas (Academy Museum of Motion Pictures, Los Angeles)

Abbildung 6: Zoetrop mit scheinbar bewegter Zeichentrickfigur ([4]) (Academy Museum of Motion Pictures, Los Angeles)

Jeder dieser Frames zeigt die Figur in einem Zustand, der nur leicht von dem im vorherigen Frame abweicht, und wenn auch der nächste Frame die Figur wieder in die gleiche Richtung weiterbewegt, entsteht bei mehreren Frames pro Sekunde die Illusion einer animierten Comicfigur auf der Leinwand.

Abbildung 7: Frei verfügbare Sprites auf freepik.com

Abbildung 8: Einzelbilder der Animation als Sprite-Sheet

Damit nun das Programm nicht dutzende Dateien mit Einzelbildern von der Platte lesen muss, ist es üblich, alle Frames als Kacheln auf einem sogenanten Sprite-Sheet einzuzeichnen. So liest das Programm nur eine einzige Datei ein und sucht sich alle benötigten Frames heraus, indem es entsprechend der bekannten Abstände unter Angabe der x/y-Koordinaten im Bildspeicher herumfährt und die Kacheln entsprechend ihrer ebenfalls bekannten Höhe und Breite ausschneidet.

Kein Picasso, kein Problem

Künstlerich Begabte zeichnen sich ihre eigenen Sprites, aber wer kein Picasso ist, lädt lieber frei verfügbare Sprites von Seiten wie freepic.com herunter (Abbildung 7, [2] und [3]). Die Abstände der Frames vom Rand des Sprite-Sheets, sowie untereinander in x- und y-Richtung lassen sich mit einem Foto-Editor wie Gimp leicht als Pixelwerte ermitteln (Abbildung 9). Das Animationsprogramm wird später in Listing 1 die kostenlos heruntergeladene .png-Datei einlesen, die komprimierten Daten dekodieren, und dann die Pixel des Bilds in einer Struktur vom Typ image.Image aus Gos Standardbiliothek halten.

Abbildung 9: Abstände zwischen den Einzelbildern zum Ausschneiden

Will der Code zum Beispiel den zweiten Frame aus der zweiten Reihe ausschneiden (also den Frame mit der Indexnummer 6, da die Indexe bei Null starten und pro Reihe fünf Frames im Sprite liegen), liegt dessen linke obere Ecke auf der X-Koordinate xOff + width + xPad sowie auf der Y-Koordinate yOff + height + yPad.

Listing 1: sprite.go

    01 package main
    02 import (
    03   "image"
    04   "image/draw"
    05   "image/png"
    06   "os"
    07 )
    08 type Sprite struct {
    09   xOff, yOff    int
    10   width, height int
    11   xPad, yPad    int
    12   columns       int
    13   reversed      bool
    14 }
    15 func NewSprite(reversed bool) *Sprite {
    16   return &Sprite{
    17     xOff: 313, yOff: 67,
    18     width: 205, height: 258,
    19     xPad: 27, yPad: 39,
    20     columns:  5,
    21     reversed: reversed,
    22   }
    23 }
    24 func (s *Sprite) Icons(file string) ([]image.Image, error) {
    25   icons := []image.Image{}
    26   img, err := loadPNG(file)
    27   if err != nil {
    28     return icons, err
    29   }
    30   for i := 0; i < 10; i++ {
    31     icon := s.extractIcon(img, i)
    32     if s.reversed {
    33       icon = flipH(icon)
    34     }
    35     icons = append(icons, icon)
    36   }
    37   return icons, nil
    38 }
    39 func loadPNG(path string) (image.Image, error) {
    40   file, err := os.Open(path)
    41   if err != nil {
    42     return nil, err
    43   }
    44   defer file.Close()
    45   img, err := png.Decode(file)
    46   if err != nil {
    47     return nil, err
    48   }
    49   return img, nil
    50 }
    51 func (s *Sprite) extractIcon(sheet image.Image, idx int) image.Image {
    52   col := idx % s.columns
    53   row := idx / s.columns
    54   x := s.xOff + col*(s.width+s.xPad)
    55   y := s.yOff + row*(s.height+s.yPad)
    56   iconRect := image.Rect(x, y, x+s.width, y+s.height)
    57   icon := image.NewRGBA(iconRect)
    58   draw.Draw(icon, iconRect, sheet, image.Point{x, y}, draw.Src)
    59   return icon
    60 }
    61 func flipH(src image.Image) image.Image {
    62   bounds := src.Bounds()
    63   width := bounds.Dx()
    64   height := bounds.Dy()
    65   dst := image.NewRGBA(bounds)
    66   for y := 0; y < height; y++ {
    67     for x := 0; x < width; x++ {
    68       flippedX := bounds.Max.X - 1 - x
    69       dst.Set(flippedX, bounds.Min.Y+y, src.At(bounds.Min.X+x, bounds.Min.Y+y))
    70     }
    71   }
    72   return dst
    73 }

Der Konstruktor NewSprite() ab Zeile 15 in Listing 1 definiert hierzu die Koordinaten und Abmessungen der Einzelbilder im Sprite-Sheet gemäß Abbildung 9. Im Parameter reversed gibt der Aufrufer auch noch ein Flag mit, das anzeigt, ob das extrahierte Icon später nach rechts oder links laufen soll. In letzterem Fall unterwirft die Funktion flipH() ab Zeile 61 alle Icons nach dem Einlesen einer horizontalen Spiegelung, stellt sie also seitenverkehrt dar.

Einzelne Icons mit ab 0 laufenden Indexnummern in idx extrahiert die Funktion extractIcon() ab Zeile 51. Da die zehn Icons im Sprite-Sheet in zwei Reihen zu je fünf angeordnet sind (Abbildung 8), rechnet die Funktion zunächst anhand der Indexnummer mittels Integer-Divison und -Modulo-Operation die Reihe und die Spalte des gesuchten Icons aus. So ist zum Beispiel Icon mit dem Index 8 das vorletzte in der zweiten Reihe, und demnach ist row=1 und col=3 (Indices ebenfalls bei Null beginnend).

Schrullige Bildverarbeitung

Eine Schrulle der in Zeile 58 verwendeten Draw()-Funktion aus Gos Image-Paket zum Ausschneiden eines Einzelbildes ist, dass dessen Koordinaten anschließend nicht notwendigerweise bei (0,0) beginnen. Vielmehr behält die Variable mit dem Teilbild eine Referenz auf das Gesamtbild und setzt in seinen Koordinaten einen (x,y)-Offset zur eigentlichen linken oberen Ecke des Teilbilds. Das muss die Funktion flipH() ab Zeile 61 berücksichtigen. Nähme sie beim Spiegeln dagegen an, dass die X- und Y-Koordinaten bei Null beginnen, käme der falsche Ausschnitt heraus. Der richtige Ansatz ist, in einer Doppelschleife ab Zeile 66 zuerst über alle Pixelzeilen gemäß der Einzelbildhöhe, dann über alle Spalten gemäß der -breite zu iterieren. In jeder Bildzeile werden dann die Pixel an gegenüberliegenden x-Werten vertauscht, wobei jedesmal die mit Bounds() aus dem Originalbild extrahierten x- und y-Offsets berücksichtigt werden, die nicht notwendigerweise mit den Indices der For-Schleife übereinstimmen.

Ra-ru-rick, Zeichentrick

Die zehn so ausgeschnittenen Einzelbilder muss nun das verwendete GUI-Framework Fyne so schnell hintereinander anzeigen, dass wie bei einem Zeichentrickfilm die Illusion einer Bewegung entsteht. Listing 2 definiert dazu ab Zeile 8 die Struktur Flicker, die die Frames als Array speichert, und in Reverse festhält, ob der Sprinter nach rechts oder links läuft. Die Funktion LoadSprite() lädt die Frames mit Hilfe von Icons() aus Listing 1 aus der Datei mit dem Sprite-Sheet. Damit Fyne die Frames anzeigen kann, importiert NewImageFromImage die Image-Objekte in Fyne und Zeile 28 hängt jeden neuen Frame ans Ende des Arrays Frames in der Instanzstruktur zum späteren Gebrauch an.

Listing 2: flicker.go

    01 package main
    02 import (
    03   "time"
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/canvas"
    06   "fyne.io/fyne/v2/container"
    07 )
    08 type Flicker struct {
    09   Frames   []*canvas.Image
    10   Reversed bool
    11 }
    12 func NewFlicker(reversed bool) *Flicker {
    13   return &Flicker{
    14     Reversed: reversed,
    15   }
    16 }
    17 func (f *Flicker) LoadSprite(spriteFile string) error {
    18   s := NewSprite(f.Reversed)
    19   icons, err := s.Icons(spriteFile)
    20   if err != nil {
    21     return err
    22   }
    23   for i, img := range icons {
    24     icon := s.extractIcon(img, i)
    25     canvasImage := canvas.NewImageFromImage(icon)
    26     canvasImage.FillMode = canvas.ImageFillContain
    27     canvasImage.SetMinSize(fyne.NewSize(100, 100))
    28     f.Frames = append(f.Frames, canvasImage)
    29   }
    30   return nil
    31 }
    32 func (f *Flicker) Animate() (*fyne.Container, chan float64) {
    33   ch := make(chan float64)
    34   con := container.NewMax(f.Frames[0])
    35   speed := 0.0
    36   count := 0.0
    37   go func() {
    38     for {
    39       select {
    40       case speed = <-ch:
    41         speed = limiter(speed)
    42       case <-time.After(100 * time.Millisecond):
    43         count += speed / MaxSpeed * 2
    44         frame := f.Frames[(int(count) % len(f.Frames))]
    45         con.RemoveAll()
    46         con.Add(frame)
    47         frame.Refresh()
    48       }
    49     }
    50   }()
    51   return con, ch
    52 }

Der Trickfilm beginnt mit Animate() ab Zeile 32 zu laufen. Die Startgeschwindigkeit speed des Läufers steht am Anfang auf 0.0 und kann im Laufe des Programms bis auf maximal 100.0 anwachsen. Die ab Zeile 37 nebenläufig gestartete Goroutine betritt eine Endlosschleife mit select-Anweisung, die im Normalfall darauf wartet, dass in Zeile 42 der Trickfilm-Timer alle 100 Millisekunden abläuft.

Zeile 43 erhöht entsprechend der eingestellten Geschwindigkeit speed im Verhältnis zur Maximalgeschwindigkeit der Zähler count dergestalt, dass bei Vollgas die Anzeige gleich zwei Frames vorgespult. Das alte Einzelbild wird dem Fyne-Container con nun entzogen und das neue aktuelle mit Add() untergeschoben. Ein Refresh() frischt die Anzeige auf, das Ganze erfolgt 10 Mal pro Sekunde, sodass eine flüssige Bewegung entsteht.

Läufer läuft

Der Läufer bewegt nicht nur seine Gliedmaßen, sondern bewegt sich in seinem Fyne-Container auch noch von links nach rechts (Upload) oder in der Gegenrichtung (Download).

Listing 2 kapselt den Code zum Voranschubsen des Läufers ebenfalls in objektorientierter Manier, der Konstruktor NewMover nimmt ebenfalls ein Flag reverse entgegen, das angibt, ob die nächste Fahrt diesmal vorwärts oder rückwärts geht. Die Funktion Animate() gibt ähnlich wie in Listing 1 zwei Parameter zurück, einmal einen Fyne-Container, in dem der Avatar sich bewegt und zweitens einen Channel, durch den der Aufrufer später im laufenden Betrieb die Geschwindigkeit der Animation beeinflussen kann. Schiebt später der Aufrufer einen neuen Fließkommawert in den Channel, liest ihn eine nebenläufige Goroutine ab Zeile 23 im case der select-Anweisung in Zeile 28 aus dem Channel und setzt die lokale (und per Closure weiter bestehende) Variable speed auf den neuen Wert und treibt den Läufer an.

Listing 3: mover.go

    01 package main
    02 import (
    03   "time"
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/container"
    06 )
    07 type Mover struct {
    08   Reverse bool
    09 }
    10 func NewMover(reverse bool) *Mover {
    11   return &Mover{
    12     Reverse: reverse,
    13   }
    14 }
    15 func (m *Mover) Animate(obj fyne.CanvasObject) (*fyne.Container, chan float64) {
    16   con := container.NewWithoutLayout(obj)
    17   speed := MinSpeed
    18   ch := make(chan float64)
    19   direction := 1.0
    20   if m.Reverse {
    21     direction = -1.0
    22   }
    23   go func() {
    24     pos := float32(0)
    25     obj.Hide()
    26     for {
    27       select {
    28       case speed = <-ch:
    29         speed = limiter(speed)
    30         if !obj.Visible() {
    31           if m.Reverse {
    32             pos = con.Size().Width - obj.Size().Width
    33           }
    34           obj.Show()
    35         }
    36       case <-time.After(10 * time.Millisecond):
    37         pos += float32(speed * direction / MaxSpeed)
    38       }
    39       if m.Reverse {
    40         if pos < -obj.Size().Width {
    41           pos = con.Size().Width
    42         }
    43       } else {
    44         if pos > con.Size().Width {
    45           pos = -obj.Size().Width
    46         }
    47       }
    48       obj.Move(fyne.NewPos(pos, (con.Size().Height-obj.Size().Height)/2))
    49       con.Refresh()
    50     }
    51   }()
    52   return con, ch
    53 }
    54 const MaxSpeed = 100.0
    55 const MinSpeed = 0.0
    56 func limiter(speed float64) float64 {
    57   if speed > MaxSpeed {
    58     return MaxSpeed
    59   } else if speed < MinSpeed {
    60     return MinSpeed
    61   }
    62   return speed
    63 }

Läuft der Trickfilm-Timer in Zeile 36 nach 100 Millisekunden ab, erhält die Position pos des zu bewegenden Grafik-Objects obj einen neuen Wert. Der ergibt sich aus der in der Zwischenzeit mit der Geschwindigkeit speed zurückgelegten Strecke und der Laufrichtung in direction.

Hinaus- und Hineinschlüpfen

Was am linken Containerrand passiert, definiert Zeile 40 für den Rückwärtslauf. In diesem Fall ist pos weit im Negativen, und das bewegte Objekt bereits in seiner gesamten Breite über die linke Containergrenze hinausgeschlüpft. Zeile 41 lässt es deswegen an der rechten Containergrenze langsam wieder erscheinen. Den umgekehrten Fall im Vorauslauf prüft Zeile 44 und lässt das bewegte Objekt am linken Rand wieder erscheinen, falls es rechts über die Contanergrenze gerauscht ist. Dass Fyne negative Koordinaten klaglos verarbeitet und das verschobene Objekt einfach nicht oder nur teilweise darstellt, ist defininitiv hilfreich.

Dass die Geschwindigkeitsbegrenzung nach oben auf 100.0 eingehalten wird und auch keine negativen Geschwindigkeiten durch den Channel durchgereicht werden, dafür sorgt die Funktion limiter ab Zeile 56. Die Konstanten MaxSpeed und MinSpeed gelten übrigens nicht nur in Listing 2, sondern in allen fünf Listings, da alle im Paket main operieren.

Radarmessung für Bandbreite

Wie weiß nun die GUI, wie schnell die Bits durch die ISP-Leitung flitzen? Wie eingangs erwähnt läuft hierzu auf der Firewall ein vnstat-Prozess, der eifrig misst und mitschreibt. Für den aktuellen Wert muss sich die GUI per ssh auf der Firewall einloggen und den vnstat-Befehl abfeuern. Listing 4 übernimmt diese Aufgabe in Go.

Listing 4: vnstat.go

    01 package main
    02 import (
    03   "fmt"
    04   "github.com/dustin/go-humanize"
    05   "math"
    06   "os/exec"
    07   "regexp"
    08 )
    09 func vnstat() (float64, float64, error) {
    10   rx := float64(0)
    11   tx := float64(0)
    12   cmd := exec.Command("ssh", "-p", "8022", "mschilli@192.168.0.1", "vnstat", "-i",
    13     "igb0", "-tr", "2")
    14   output, err := cmd.Output()
    15   if err != nil {
    16     return rx, tx, err
    17   }
    18   rateRex := regexp.MustCompile(`(?m)^\s+([rt]x)\s+([\d.]+)\s+(\S+)`)
    19   matches := rateRex.FindAllStringSubmatch(string(output), 2)
    20   for _, match := range matches {
    21     if match[1] == "rx" {
    22       rx, err = toBits(match[2], match[3])
    23     } else if match[1] == "tx" {
    24       tx, err = toBits(match[2], match[3])
    25     } else {
    26       return rx, tx, fmt.Errorf("Unknown entry %s", match[1])
    27     }
    28     if err != nil {
    29       return rx, tx, err
    30     }
    31   }
    32   return rx, tx, nil
    33 }
    34 func toBits(str string, unit string) (float64, error) {
    35   s := str + string(unit[0])
    36   i, err := humanize.ParseBytes(s)
    37   return float64(i), err
    38 }
    39 func toBitRate(bps float64) string {
    40   return humanize.Bytes(uint64(bps)) + "it/sec"
    41 }
    42 func speedFromRate(x float64) float64 {
    43   return math.Sqrt(x / 1000.0)
    44 }

Das Kommando, das sich mittels ssh mit der IP-Adresse der Firewall auf dem eingestellten Port verbindet, zeigt Zeile 12. Die eingangs gezeigte Ausgabe des Tools aus Abbildung 1 fieselt anschließend der reguläre Ausdruck ab Zeile 20 auseinander und extrahiert zwei Werte, rx und tx, die jeweils als Fließkkommazahl mit Einheit vorliegen, also zum Beispiel "1.3 Mbit/s". Die Funktion toBits() ab Zeile 36 macht daraus maschinenlesbare Bits pro Sekunde, unter Verwendung des Pakets humanize von Github. Umgekehrt wandelt toBitRate() ab Zeile 41 einen Bitwert wieder in einen menschenlesbaren String zurück, das wird später die GUI zur Anzeige nutzen.

Linear oder Logarithmisch?

Der Läufer bewegt sich mit einer virtuellen Geschwindigkeit zwischen 0 (Stillstand) und 100 (voller Spurt) vorwärts, abhängig davon, wieviele Bits pro Sekunde durch die Leitung flitzen. Allerdings bewegt sich die Bitrate im laufenden Betrieb durch mehrere Dimensionen. Ist kaum etwas los, dümpelt sie vielleicht bei 1kbit/sec dahin, aber bei Vollast fließt mit 10MBit/sec schon mal das 10.000-fache. Damit der Läufer bei im Leerlaufbetrieb nicht ganz stehen bleibt, sollte er bei 1kbit/sec mit Tempo 1 bummeln, und bei Vollast 10MBit/sec mit Tempo 100 auf der Y-Achse in Abbildung 10 spurten.

Abbildung 10: Läufergeschwindigkeit je nach Bit-Durchsatz

Eine passende Mapping-Funktion für derartige Zahlenbereiche, die sich über mehrere Dimensionen erstrecken, lässt sich nur schwer linear beschreiben, da muss normalerweise ein Logarithmus ran. Der nimmt allerdings das Drama des athletischen Wettkampfs raus, denn wenn der Läufer bei einer 1000-fachen Bitrate nur 4 mal schneller läuft, sieht das wenig glaubwürdig aus. Statt dessen nutzt Listing 4 in Zeile 45 die Funktion Quadratwurzelfunktion Sqrt() aus Gos Mathe-Paket, die dem Lauftempo größere Schwankungen verschreibt. Teilt Zeile 45 den X-Wert durch Tausend und zieht dann die Quadratwurzel, deckt die Konvertierung die gesuchte Verteilung von Abbildung 10 relativ gut ab.

Auf unsere Showbühne!

Nun muss das Hauptprogramm in Listing 5 alle Komponentenn vereinen und auf den großen Schirm bringen. Zuvor kommt in der Hilfsfunktion mkPanel() ab Zeile 13 zusammen was für eine Verbindungsrichtung zusammen gehört: Mit NewFlicker() das Daumenkino eines rasenden Läufers, dessen Animations-Container avaCon, und ein Update-Channel avaCh. Der Container wird mit NewMover() in ein Rechteck gepackt, und die Funktion Animate() sorgt dafür, dass letzteres sich seinerseits im Takt der Bitrate in Verbindungsrichtung bewegt. Und schließlich spendiert Zeile 33 dem Panel noch eine Digitalanzeige der Up- bzw. Download-Geschwindigkeit, als Text im meter-Widget.

Listing 5: marathon.go

    01 package main
    02 import (
    03   "os"
    04   "time"
    05   "fyne.io/fyne/v2"
    06   "fyne.io/fyne/v2/app"
    07   "fyne.io/fyne/v2/canvas"
    08   "fyne.io/fyne/v2/container"
    09   "fyne.io/fyne/v2/theme"
    10   "fyne.io/fyne/v2/widget"
    11 )
    12 const SpriteFile = "sprite.png"
    13 func mkPanel(isDownload bool) (*fyne.Container, func(float64)) {
    14   ava := NewFlicker(isDownload)
    15   err := ava.LoadSprite(SpriteFile)
    16   if err != nil {
    17     panic(err)
    18   }
    19   avaCon, avaCh := ava.Animate()
    20   avaCon.Resize(fyne.NewSize(100, 100))
    21   mv := NewMover(isDownload)
    22   mvCon, mvCh := mv.Animate(avaCon)
    23   meter := widget.NewLabel("")
    24   throttle := func(v float64) {
    25     meter.Text = toBitRate(v)
    26     meter.Refresh()
    27     avaCh <- speedFromRate(v)
    28     mvCh <- speedFromRate(v)
    29   }
    30   panel := container.NewVBox(meter, mvCon)
    31   return panel, throttle
    32 }
    33 func main() {
    34   myApp := app.New()
    35   myWindow := myApp.NewWindow("Bandwidth Marathon")
    36   down, downUpdate := mkPanel(true)
    37   up, upUpdate := mkPanel(false)
    38   border := canvas.NewRectangle(theme.DisabledColor())
    39   dual := container.NewVBox(down, up)
    40   all := container.NewMax(border, dual)
    41   myWindow.SetContent(all)
    42   myWindow.Resize(fyne.NewSize(float32(800), float32(300)))
    43   go func() {
    44     for {
    45       rx, tx, err := vnstat()
    46       if err != nil {
    47         panic(err)
    48       }
    49       upUpdate(tx)
    50       downUpdate(rx)
    51       time.Sleep(3 * time.Second)
    52     }
    53   }()
    54   myWindow.Canvas().SetOnTypedKey(
    55     func(ev *fyne.KeyEvent) {
    56       os.Exit(0)
    57     })
    58   myWindow.ShowAndRun()
    59 }

Wird in Go ein Wert in einen Channel gepumpt, kann ihn immer nur ein Empfänger abholen, lauschen mehrere, bekommt ihn derjenige, der am schnellsten zulangt. Ändert sich aber die vom Tool gemessene Bitrate, wollen zwei Channels bedient werden, der des Daumenkinos und der des bewegten Rechtecks. Die Lösung ist die ab Zeile 24 definierte Funktion throttle(), die der Code innerhalb der Funktion mkPanel() definiert. Am Ende wird die Funktion wie ein ganz normaler Wert an den Aufrufer zurückgereicht. Der Aufrufer kann sie später aufrufen und ihr den neuen Messwert überreichen. Unter der Haube reicht die Funktion den Messwert dann an die zwei Channels weiter und frischt auch noch die Digitalanzeige in meter auf. Praktisch, wenn eine Programmiersprache Funktionen wie ganz normale Variablen behandelt.

Das Hauptprogramm main() ab Zeile 33 muss nun noch eine neue Fyne-Applikation aufspannen und dem Hauptfenster die beiden neu erschaffenen Panels zum Layouten überreichen. Ein Container vom Typ VBox ordnet letztere übereinander an. Die nebenläufige Goroutine ab Zeile 43 tritt anschließend in eine Endlosschleife ein, die mit vnstat() die neuesten Messwerte rx und tx von der Firewall abholt und sie den beiden Funktionen upUpdate() und downUpdate() für die jeweilige Übertragungsrichtung zur Anzeige überreicht. Und nach drei Sekunden Pause geht es weiter in die nächste Runde.

Listing 6: build.sh

    1 $ go mod init marathon
    2 $ go mod tidy
    3 $ go build

Bevor ShowAndRun() in Zeile 58 auf Nimmerwiedersehen in die Haupt-Eventschleife des Fyne-Frameworks eintritt, definiert der Callback SetOnTypedKey() in Zeile 54 noch, dass die GUI-Applikation sich sauber zusammenfaltet, falls der User irgendeine Taste drückt.

Aus allen fünf Listings in einem Verzeichnis baut die bekannte Befehlssequenz aus Listing 6 das Binary marathon. Vor dem ersten Aufruf sollte es per Public Key einen ssh-Zugang auf den Router mit installierten und laufenden vnstat erhalten, dann kann der Sprint losgehen.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2025/03/snapshot/

[2]

Rennende Männchen als Sprites auf freepik.com: https://www.freepik.com/vectors/running-sprite

[3]

Freepik Lizenz: https://www.freepik.com/legal/terms-of-use#nav-freepik-license

[4]

Prä-Trickfilm-Animation mit Zoetrop https://de.wikipedia.org/wiki/Zoetrop

[5]

Sprite der rennenden Figur: https://img.freepik.com/free-vector/flat-character-animation-frames_23-2148938007.jpg

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc