Example clients

Two complete clients. Both run against a test server that sends the real protocol frames — they are not merely typed out.

What they do beyond the bare minimum

  • Display the attribution, looked up by source id — the same licence text can repeat in sources[], so keying on it would silently drop entries.
  • Watch dropped in the heartbeat: losing data does not stay hidden.
  • Reconnect after 5–30 s of jitter on close 4012, and not at all on close 4002/4010/4011. A missing or invalid key never reaches this switch as a close code: it is rejected with HTTP 401 before the WebSocket upgrade, so it surfaces as a failed connection attempt instead.

Python

Using the websockets library:

pip install websockets
export PELYR_KEY=pk_…
python stream.py
import asyncio, json, os, random, sys, websockets

URL = os.environ.get("PELYR_URL", "wss://stream.pelyr.com/v1/stream")
KEY = os.environ.get("PELYR_KEY")
if not KEY:
    sys.exit("PELYR_KEY missing")

ABO = {
    "type": "subscribe",
    "id": "ostsee",
    "bbox": [{"west": 18.0, "south": 59.0, "east": 26.0, "north": 66.0}],
    "fields": "position",
}

async def einmal() -> int:
    # Survives the welcome frame -- "position" below needs it for every
    # single frame, not just once at connect time.
    namensnennung: dict[str, str] = {}

    async with websockets.connect(
        URL,
        additional_headers={"Authorization": f"Bearer {KEY}"},
        ping_interval=None,
    ) as ws:
        async for roh in ws:
            frame = json.loads(roh)
            typ = frame.get("type")

            if typ == "welcome":
                # Each feed has exactly one id from 100 up; several feeds
                # share an id only when they deliberately run under one
                # collective attribution (id 1 = Pelyr). Look up by id, never
                # by licence text: every data frame carries its id in
                # "license", and whoever wants the attribution of the data
                # actually received remembers this mapping and looks it up
                # per frame (see "position" below).
                namensnennung = {q["id"]: q["attribution"] for q in frame["sources"]}
                for text in namensnennung.values():
                    print(text)
                await ws.send(json.dumps(ABO))

            elif typ == "position":
                d = frame["data"]
                # This frame's own attribution, looked up by license -- not
                # the list of every possible source from above.
                print(d["mmsi"], d["lat"], d["lon"], d.get("sog"),
                      namensnennung.get(frame["license"]))

            elif typ == "heartbeat" and frame["dropped"]:
                print("Datenverlust:", frame["dropped"], file=sys.stderr)

    return 1000

async def main() -> None:
    while True:
        try:
            code = await einmal()
        except websockets.ConnectionClosed as ende:
            code = ende.code

        # Endgueltig -- Wiederverbinden aendert nichts und erzeugt nur Last.
        if code in (4002, 4010, 4011):
            sys.exit(f"beendet, Code {code}")

        # 4012 heisst Redeploy: mit Jitter wiederkommen, nicht sofort.
        await asyncio.sleep(random.uniform(5, 30) if code == 4012 else 2.0)

asyncio.run(main())

Go

Using github.com/coder/websocket:

go get github.com/coder/websocket
export PELYR_KEY=pk_…
go run stream.go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "maps"
    "math/rand"
    "net/http"
    "os"
    "slices"
    "time"

    "github.com/coder/websocket"
)

type rahmen struct {
    Typ     string `json:"type"`
    Sources []struct {
        ID          string `json:"id"`
        License     string `json:"license"`
        Attribution string `json:"attribution"`
    } `json:"sources"`
    License string `json:"license"`
    Data    struct {
        MMSI int64    `json:"mmsi"`
        Lat  *float64 `json:"lat"`
        Lon  *float64 `json:"lon"`
    } `json:"data"`
    Dropped uint64 `json:"dropped"`
}

const abo = `{"type":"subscribe","id":"ostsee",` +
    `"bbox":[{"west":18,"south":59,"east":26,"north":66}],"fields":"position"}`

func einmal(ctx context.Context, key string) int {
    c, _, err := websocket.Dial(ctx, "wss://stream.pelyr.com/v1/stream",
        &websocket.DialOptions{
            HTTPHeader: http.Header{"Authorization": {"Bearer " + key}},
        })
    if err != nil {
        return 0
    }
    defer c.CloseNow()
    c.SetReadLimit(1 << 20)

    // Survives the welcome frame -- "position" below needs it for every
    // single frame, not just once at connect time.
    namensnennung := map[string]string{}

    for {
        _, roh, err := c.Read(ctx)
        if err != nil {
            return int(websocket.CloseStatus(err))
        }
        var r rahmen
        if json.Unmarshal(roh, &r) != nil {
            continue
        }

        switch r.Typ {
        case "welcome":
            // Each feed has exactly one id from 100 up; several feeds
            // share an id only when they deliberately run under one
            // collective attribution (id 1 = Pelyr). Look up by id, never
            // by licence text: every data frame carries its id in
            // "license", and whoever wants the attribution of the data
            // actually received remembers this mapping and looks it up
            // per frame (see "position" below).
            namensnennung = map[string]string{}
            for _, q := range r.Sources {
                namensnennung[q.ID] = q.Attribution
            }
            // Sorted by id -- map iteration order is randomized in Go.
            for _, id := range slices.Sorted(maps.Keys(namensnennung)) {
                fmt.Println(namensnennung[id])
            }
            if c.Write(ctx, websocket.MessageText, []byte(abo)) != nil {
                return 0
            }
        case "position":
            var lat, lon float64
            if r.Data.Lat != nil {
                lat = *r.Data.Lat
            }
            if r.Data.Lon != nil {
                lon = *r.Data.Lon
            }
            // This frame's own attribution, looked up by license -- not
            // the list of every possible source from above.
            fmt.Println(r.Data.MMSI, lat, lon, namensnennung[r.License])
        case "heartbeat":
            if r.Dropped > 0 {
                fmt.Fprintln(os.Stderr, "Datenverlust:", r.Dropped)
            }
        }
    }
}

func main() {
    key := os.Getenv("PELYR_KEY")
    ctx := context.Background()
    for {
        switch code := einmal(ctx, key); code {
        case 4002, 4010, 4011:
            fmt.Fprintf(os.Stderr, "beendet, Code %d\n", code)
            os.Exit(1)
        case 4012:
            time.Sleep(time.Duration(5+rand.Intn(25)) * time.Second)
        default:
            time.Sleep(2 * time.Second)
        }
    }
}

The key comes from the environment, not from the source.