openvibers/openvibe.link Commits

openvibe-node share <port>, and every share in openvibe-node status (CON-4) (#114)

OP OpenVibers committed 7 days ago · 150b8f0c95 · 11 files, +365 −9

* openvibe-node share <port>, and every share in openvibe-node status (CON-4)

* connect: the status test carries no fake key literal

Changes

ddiff --git a/.gitleaksignore b/.gitleaksignore
iindex 6d9b088..08aa404 100644
---- a/.gitleaksignore
++++ b/.gitleaksignore
@@@ -22,3 +22,7 @@ internal/memory/testdata/redact-corpus.json:private-key:6
internal/memory/testdata/redact-corpus.json:private-key:30
internal/memory/testdata/redact-corpus.json:private-key:42
internal/memory/testdata/redact-corpus.json:private-key:474
++
++# The Connect status test lists a share by its port; the fake key it used to carry was removed, but the
++# finding remains in this branch's first commit (a test literal, never a real key).
++internal/connect/live_test.go:generic-api-key:43
ddiff --git a/README.md b/README.md
iindex 3d02deb..300b3c3 100644
---- a/README.md
++++ b/README.md
@@@ -28,13 +28,14 @@ openvibe.bot shows this command with the robot's id and a fresh code. `--driver`
OpenVibe.Network gets `--network <URL> --pairing pair_… --code ABCD-1234` instead of `--robot`. Details, manual
install, Windows and configuration: [docs/install.md](docs/install.md).
--**Put a port on the web** with [OpenVibe Connect](https://connect.openvibe.website): `openvibe-node share --key ovc_… --port 8790`
--serves it at `https://<name>--<you>.openvibe.website` behind OpenVibe sign-in, and `openvibe-node share add` keeps it
--up with the service ([docs/connect.md](docs/connect.md)).
++**Put a port on the web** with [OpenVibe Connect](https://connect.openvibe.website): `openvibe-node share 8790`
++serves the share this computer was picked for at `https://<name>--<you>.openvibe.website` behind OpenVibe sign-in (or
++`openvibe-node share --key ovc_… --port 8790` with a key), and `openvibe-node share add` keeps it up with the service;
++`openvibe-node status` lists each share ([docs/connect.md](docs/connect.md)).
```sh
openvibe-node # guided first run: pair, check the link, report this machine's first job
--openvibe-node status # link, plugins, video, battery, stop latch
++openvibe-node status # link, plugins, video, battery, shares, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
openvibe-node quit # end the node cleanly (nothing latched), e.g. from a stream launcher
ddiff --git a/STATUS.json b/STATUS.json
iindex ee7289b..7d6da47 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -20,6 +20,7 @@
"the live screen: the machine's screen on openvibe.bot for its owner only, up to 30 minutes",
"the job worker (--worker): sandboxed function, code and linux jobs, and the OpenVibe.Codes job runner",
"the machine's roles from the network's hub (codes.worker, desks.host, ai.host, bot.robot): read at check-in, kept, and applied to what the machine was installed for; the local configuration always wins",
++ "OpenVibe Connect shares (CON-4): `openvibe-node share <port>` serves the share this computer was picked for, with no key; every saved share is in `openvibe-node status` (address, connected or why not, requests) and stops when the Node stops",
"one-command install on a Raspberry Pi or other Linux robot (I2C, SPI, camera tools, kit detection)",
"system service on systemd, launchd and Windows (or a Windows sign-in start)",
"ESP32 library (OpenVibeNode): pairing, control link, drive and actuator commands, telemetry, heartbeat, deadman and e-stop"
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex b8d6b85..f8cdcc1 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -808,6 +808,35 @@ func cmdAnswer(g *globals, cmd, id string, stdout io.Writer) error {
return nil
}
++// sharesText lists the OpenVibe Connect shares this Node keeps up (CON-4). A share that is reconnecting says why; a
++// key-served share is named by its port and never shows the key.
++func sharesText(shares []connect.Status) string {
++ if len(shares) == 0 {
++ return ""
++ }
++ var b strings.Builder
++ fmt.Fprintf(&b, "Shares: %d kept up\n", len(shares))
++ for _, s := range shares {
++ state := "not connected"
++ if s.Connected {
++ state = "connected"
++ if s.Requests > 0 {
++ state = fmt.Sprintf("connected, %d requests", s.Requests)
++ }
++ }
++ where := s.URL
++ if where == "" {
++ where = "(address shown once it connects)"
++ }
++ fmt.Fprintf(&b, " localhost:%d %s %s", s.Port, where, state)
++ if !s.Connected && s.LastError != "" {
++ fmt.Fprintf(&b, " (%s)", s.LastError)
++ }
++ b.WriteByte('\n')
++ }
++ return b.String()
++}
++
func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStatus}, 2*time.Second)
if err != nil {
@@@ -834,6 +863,7 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
link = "down: " + s.Link.LastError
}
fmt.Fprintf(stdout, "Link: %s, %d reconnects\n", link, s.Link.Reconnects)
++ fmt.Fprint(stdout, sharesText(s.Shares))
fmt.Fprintf(stdout, "Stop: %s\n", latchText(s.Latch))
fmt.Fprintf(stdout, "Limits: speed %.2f, turn %.2f, command deadline ≤ %d ms\n", s.Limits.MaxSpeed, s.Limits.MaxTurn, s.Limits.MaxCommandMS)
fmt.Fprintf(stdout, "Video: %s", s.Video.State)
ddiff --git a/cmd/openvibe-node/share.go b/cmd/openvibe-node/share.go
iindex 245afb3..261d45e 100644
---- a/cmd/openvibe-node/share.go
++++ b/cmd/openvibe-node/share.go
@@@ -7,6 +7,7 @@ import (
"io"
"os"
"os/signal"
++ "strconv"
"strings"
"syscall"
"time"
@@@ -25,8 +26,10 @@ type shareArgs struct {
}
const shareUsage = `usage:
-- openvibe-node share --share shr_… [--port N] serve a share this computer was picked for, until Ctrl-C (no key)
-- openvibe-node share add --share shr_… [--port N] keep it up from the Node service
++ openvibe-node share <port> serve the share this computer was picked for on that port, until Ctrl-C
++ openvibe-node share add <port> keep that share up from the Node service
++ openvibe-node share --share shr_… [--port N] serve one by its id, until Ctrl-C (no key)
++ openvibe-node share add --share shr_… [--port N] keep one by its id up from the Node service
openvibe-node share mine the shares this computer was picked for
openvibe-node share --key ovc_… --port N serve a share with its connect key, until Ctrl-C
openvibe-node share add --key ovc_… --port N keep it up from the Node service (restart the service to start it)
@@@ -87,6 +90,41 @@ func nodeShare(ctx context.Context, g *globals, a shareArgs) (connect.Share, fun
return s, tokens, nil
}
++// shareByPort finds the one share the owner picked this computer for that names local port a.port (CON-4). It is the
++// `openvibe-node share <port>` form: the machine's own node token buys the tunnel token, so there is no key.
++func shareByPort(ctx context.Context, g *globals, a shareArgs) (connect.Share, func(context.Context) (string, error), error) {
++ if a.port < 1 || a.port > 65535 {
++ return connect.Share{}, nil, fmt.Errorf("port %d is not a port", a.port)
++ }
++ creds, err := credentials.Load(g.paths.CredentialFile(), nil)
++ if err != nil || !creds.NetworkPaired() {
++ return connect.Share{}, nil, errors.New("this computer is not added to OpenVibe yet: add it on openvibe.bot/machines, then pick it on the share's page")
++ }
++ tokens := connectNodeToken(creds)
++ tok, err := tokens(ctx)
++ if err != nil {
++ return connect.Share{}, nil, err
++ }
++ mine, err := connect.NodeShares(ctx, nil, a.server, tok, "openvibe-node/"+version)
++ if err != nil {
++ return connect.Share{}, nil, err
++ }
++ var found []connect.NodeShare
++ for _, m := range mine {
++ if m.LocalPort == a.port {
++ found = append(found, m)
++ }
++ }
++ switch len(found) {
++ case 0:
++ return connect.Share{}, nil, fmt.Errorf("this computer does not serve localhost:%d: pick it on the share's page on connect.openvibe.website", a.port)
++ case 1:
++ return connect.Share{ID: found[0].ID, Port: found[0].LocalPort, Server: a.server, Name: found[0].Host}, tokens, nil
++ default:
++ return connect.Share{}, nil, fmt.Errorf("%d shares use localhost:%d; name one with --share shr_…", len(found), a.port)
++ }
++}
++
func cmdShare(g *globals, positional []string, a shareArgs, stdout, stderr io.Writer) error {
sub := ""
if len(positional) > 0 {
@@@ -95,11 +133,26 @@ func cmdShare(g *globals, positional []string, a shareArgs, stdout, stderr io.Wr
if a.key == "" {
a.key = os.Getenv("OV_CONNECT_KEY")
}
++ // `openvibe-node share <port>`: the share this computer was picked for, found by the local port its owner set on
++ // the share's page (CON-4). The page gives this form because there is no key to copy.
++ if a.share == "" && a.key == "" && a.port == 0 && len(positional) == 1 {
++ if p, err := strconv.Atoi(positional[0]); err == nil && p > 0 && p < 65536 {
++ a.port, positional, sub = p, nil, ""
++ }
++ }
switch sub {
case "", "run":
if a.share != "" {
return shareNow(g, a, stdout)
}
++ if a.key == "" && a.port != 0 {
++ s, _, err := shareByPort(context.Background(), g, a)
++ if err != nil {
++ return err
++ }
++ a.share, a.port = s.ID, s.Port
++ return shareNow(g, a, stdout)
++ }
if a.key == "" || a.port == 0 {
fmt.Fprintln(stderr, shareUsage)
return errUsage
@@@ -128,6 +181,19 @@ func cmdShare(g *globals, positional []string, a shareArgs, stdout, stderr io.Wr
}
return nil
case "add":
++ if a.share == "" && a.key == "" && a.port == 0 && len(positional) == 2 {
++ p, err := strconv.Atoi(positional[1])
++ if err != nil || p < 1 || p > 65535 {
++ fmt.Fprintln(stderr, shareUsage)
++ return errUsage
++ }
++ a.port = p
++ s, _, err := shareByPort(context.Background(), g, a)
++ if err != nil {
++ return err
++ }
++ a.share, a.port = s.ID, s.Port
++ }
if a.share != "" {
if err := g.paths.Ensure(); err != nil {
return err
ddiff --git a/cmd/openvibe-node/share_test.go b/cmd/openvibe-node/share_test.go
iindex 6451c24..eb36de9 100644
---- a/cmd/openvibe-node/share_test.go
++++ b/cmd/openvibe-node/share_test.go
@@@ -7,6 +7,8 @@ import (
"runtime"
"strings"
"testing"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
)
// `openvibe-node share`: usage, add, list (the key only redacted), remove, and the file only the Node's account reads.
@@@ -60,6 +62,8 @@ func TestShareByIDNeedsThePairing(t *testing.T) {
for _, args := range [][]string{
{"share", "--share", "shr_01K9ZZZZZZZZZZZZZZZZZZZZZZ", "--home", home},
{"share", "add", "--share", "shr_01K9ZZZZZZZZZZZZZZZZZZZZZZ", "--port", "8790", "--home", home},
++ {"share", "8790", "--home", home},
++ {"share", "add", "8790", "--home", home},
{"share", "mine", "--home", home},
} {
errb.Reset()
@@@ -72,3 +76,22 @@ func TestShareByIDNeedsThePairing(t *testing.T) {
t.Fatalf("a bad id: %d %q", code, errb.String())
}
}
++
++// `openvibe-node status` lists the shares the service keeps up (CON-4).
++func TestSharesText(t *testing.T) {
++ if got := sharesText(nil); got != "" {
++ t.Fatalf("no shares: %q", got)
++ }
++ got := sharesText([]connect.Status{
++ {ID: "shr_01K9ZZZZZZZZZZZZZZZZZZZZZZ", Port: 8790, Host: "bot--alex.openvibe.website", URL: "https://bot--alex.openvibe.website", Connected: true, Requests: 3},
++ {Port: 9001, LastError: "dial: connection refused"},
++ })
++ for _, want := range []string{"Shares: 2 kept up", "localhost:8790", "https://bot--alex.openvibe.website", "connected, 3 requests", "localhost:9001", "not connected", "dial: connection refused"} {
++ if !strings.Contains(got, want) {
++ t.Fatalf("status is missing %q:\n%s", want, got)
++ }
++ }
++ if strings.Contains(got, "ovc_") {
++ t.Fatalf("status printed a key:\n%s", got)
++ }
++}
ddiff --git a/docs/connect.md b/docs/connect.md
iindex b7fc071..cfdcafc 100644
---- a/docs/connect.md
++++ b/docs/connect.md
@@@ -12,9 +12,12 @@ principal). On a share's page on connect.openvibe.website, pick it under "Or ser
then on the computer:
```sh
++openvibe-node share 8790 # serve the share this computer was picked for on that port
openvibe-node share mine # the shares this computer was picked for
openvibe-node share --share shr_… # serve one now, until Ctrl-C
++openvibe-node share --share shr_… --port 8790 # the same, with the port named
sudo openvibe-node share add --share shr_… # or keep it up with the Node service
++sudo openvibe-node share add 8790 # the same, found by its local port
```
There is no key to copy or leak. Before every connection the Node buys its node token for OpenVibe Connect (audience
@@@ -52,10 +55,13 @@ openvibe-node share list
openvibe-node share remove 8790 # or the key, or the address
```
++`openvibe-node status` lists every share the service keeps up: its local port, its address on Connect, whether the
++tunnel is connected (and why not), and how many requests the app has answered. A share with no address yet says so.
++
The saved shares are in the Node's config directory, `connect-shares.json`, readable only by the Node's account
--(0600). The service starts every saved share when it starts, so restart it after `add` or `remove`. A share the server
--ends for good (its key was replaced, it was closed, or another computer took it over) stays stopped and says so in the
--log; `share list` shows each share's address once it has connected.
++(0600). The service starts every saved share when it starts, and stops every one when the Node stops; restart it after
++`add` or `remove`. A share the server ends for good (its key was replaced, it was closed, or another computer took it
++over) stays stopped and says so in the log; `share list` shows each share's address once it has connected.
## What the Node does and does not do
ddiff --git a/internal/connect/connect.go b/internal/connect/connect.go
iindex a4f5f29..c8de40b 100644
---- a/internal/connect/connect.go
++++ b/internal/connect/connect.go
@@@ -123,12 +123,30 @@ func Run(ctx context.Context, s Share, o Options) error {
if o.MaxWait == 0 {
o.MaxWait = 30 * time.Second
}
++ // The Node service's status lists the shares it keeps up (LiveShares). A foreground share has its own process,
++ // so the service's status never shows it.
++ markLive(s)
++ defer forgetLive(s)
++ userReady, userRequest := o.OnReady, o.OnRequest
++ o.OnReady = func(r Ready) {
++ liveReady(s, r)
++ if userReady != nil {
++ userReady(r)
++ }
++ }
++ o.OnRequest = func(method, path string, status int, d time.Duration) {
++ liveRequest(s)
++ if userRequest != nil {
++ userRequest(method, path, status, d)
++ }
++ }
wait := o.MinWait
for {
err := runOnce(ctx, s, o, func() { wait = o.MinWait })
if ctx.Err() != nil {
return ctx.Err()
}
++ liveDown(s, err)
if errors.Is(err, ErrStopped) {
return err
}
ddiff --git a/internal/connect/live.go b/internal/connect/live.go
nnew file mode 100644
iindex 0000000..f15dc8c
---- /dev/null
++++ b/internal/connect/live.go
@@@ -0,0 +1,107 @@
++package connect
++
++import (
++ "fmt"
++ "sort"
++ "sync"
++)
++
++// Status is one saved share's live state, as `openvibe-node status` (and its JSON) lists it. It never contains the
++// key: a share served by this computer is named by its shr_… id, and a key-served share by its port.
++type Status struct {
++ ID string `json:"id,omitempty"`
++ Port int `json:"port"`
++ Host string `json:"host,omitempty"`
++ URL string `json:"url,omitempty"`
++ Connected bool `json:"connected"`
++ Requests uint64 `json:"requests"`
++ LastError string `json:"last_error,omitempty"`
++}
++
++// live is the state of the shares this process keeps up. The Node service runs every saved share in this process
++// (RunAll), and its local status socket answers from here; a foreground `openvibe-node share` has its own process and
++// is not listed by the service's status.
++var live = struct {
++ sync.Mutex
++ shares map[string]*Status
++}{shares: map[string]*Status{}}
++
++// liveKey is the share's id, or its port for a share served with a connect key.
++func liveKey(s Share) string {
++ if s.ID != "" {
++ return s.ID
++ }
++ return fmt.Sprintf("port:%d", s.Port)
++}
++
++// markLive records a share that is about to connect (it stays listed while it reconnects).
++func markLive(s Share) {
++ live.Lock()
++ defer live.Unlock()
++ st := live.shares[liveKey(s)]
++ if st == nil {
++ st = &Status{ID: s.ID, Port: s.Port}
++ live.shares[liveKey(s)] = st
++ }
++ st.Port = s.Port
++ st.ID = s.ID
++}
++
++// liveReady records the address the server gave and that the share is connected.
++func liveReady(s Share, r Ready) {
++ live.Lock()
++ defer live.Unlock()
++ st := live.shares[liveKey(s)]
++ if st == nil {
++ st = &Status{ID: s.ID, Port: s.Port}
++ live.shares[liveKey(s)] = st
++ }
++ st.Host, st.URL, st.Connected, st.LastError = r.Host, r.URL, true, ""
++}
++
++// liveDown records that the share is not connected, and why when it was not the owner stopping it.
++func liveDown(s Share, err error) {
++ live.Lock()
++ defer live.Unlock()
++ st := live.shares[liveKey(s)]
++ if st == nil {
++ return
++ }
++ st.Connected = false
++ if err != nil {
++ st.LastError = errString(err)
++ }
++}
++
++// liveRequest counts one request the share answered (or tried to).
++func liveRequest(s Share) {
++ live.Lock()
++ defer live.Unlock()
++ if st := live.shares[liveKey(s)]; st != nil {
++ st.Requests++
++ }
++}
++
++// forgetLive drops a share when its runner ends, so a stopped share is not listed as still being served.
++func forgetLive(s Share) {
++ live.Lock()
++ defer live.Unlock()
++ delete(live.shares, liveKey(s))
++}
++
++// LiveShares is a snapshot of the shares this process keeps up, by port then id.
++func LiveShares() []Status {
++ live.Lock()
++ defer live.Unlock()
++ out := make([]Status, 0, len(live.shares))
++ for _, st := range live.shares {
++ out = append(out, *st)
++ }
++ sort.Slice(out, func(i, j int) bool {
++ if out[i].Port != out[j].Port {
++ return out[i].Port < out[j].Port
++ }
++ return out[i].ID < out[j].ID
++ })
++ return out
++}
ddiff --git a/internal/connect/live_test.go b/internal/connect/live_test.go
nnew file mode 100644
iindex 0000000..a65f8ef
---- /dev/null
++++ b/internal/connect/live_test.go
@@@ -0,0 +1,95 @@
++package connect
++
++import (
++ "context"
++ "errors"
++ "strings"
++ "testing"
++ "time"
++)
++
++// `openvibe-node status` lists the shares the service keeps up: their port, the address Connect gave, whether the
++// tunnel is up, how many requests the app answered, and why it last went down.
++func TestLiveSharesForStatus(t *testing.T) {
++ s := Share{ID: "shr_01K9ZZZZZZZZZZZZZZZZZZZZZZ", Port: 8790, Key: "ovc_hidden"}
++ markLive(s)
++ if got := LiveShares(); len(got) != 1 || got[0].Port != 8790 || got[0].Connected {
++ t.Fatalf("marked: %+v", got)
++ }
++ liveReady(s, Ready{Host: "agents--alex.openvibe.website", URL: "https://agents--alex.openvibe.website"})
++ liveRequest(s)
++ liveRequest(s)
++ got := LiveShares()
++ if len(got) != 1 || !got[0].Connected || got[0].Host != "agents--alex.openvibe.website" ||
++ got[0].URL != "https://agents--alex.openvibe.website" || got[0].Requests != 2 || got[0].LastError != "" {
++ t.Fatalf("ready: %+v", got)
++ }
++ if got[0].ID != s.ID {
++ t.Fatalf("the share id is listed: %+v", got)
++ }
++ liveDown(s, errors.New("dial: connection refused"))
++ got = LiveShares()
++ if got[0].Connected || got[0].LastError != "dial: connection refused" || got[0].Requests != 2 {
++ t.Fatalf("down: %+v", got)
++ }
++ forgetLive(s)
++ if got := LiveShares(); len(got) != 0 {
++ t.Fatalf("forgotten: %+v", got)
++ }
++}
++
++// A share served with a key is listed by its port; a status never contains a key at all.
++func TestLiveSharesHidesTheKey(t *testing.T) {
++ s := Share{Port: 9001}
++ markLive(s)
++ got := LiveShares()
++ if len(got) != 1 || got[0].ID != "" || got[0].Port != 9001 {
++ t.Fatalf("listed: %+v", got)
++ }
++ if got[0].URL != "" || got[0].Host != "" {
++ t.Fatalf("nothing to show yet: %+v", got)
++ }
++ forgetLive(s)
++}
++
++// The service's shares are listed while it runs and stop when its context ends: `openvibe-node status` is the service
++// process, and ending the Node ends every share it keeps up.
++func TestRunAllListsAndStopsWithTheNode(t *testing.T) {
++ f := newFakeServer(t, 0)
++ dir := t.TempDir()
++ if err := Add(dir, Share{Key: "ovc_" + strings.Repeat("L", 43), Port: 8790, Server: f.url()}); err != nil {
++ t.Fatal(err)
++ }
++ ctx, cancel := context.WithCancel(context.Background())
++ done := make(chan struct{})
++ go func() {
++ RunAll(ctx, dir, Options{})
++ close(done)
++ }()
++ select {
++ case <-f.conns:
++ case <-time.After(3 * time.Second):
++ cancel()
++ t.Fatal("the share did not connect")
++ }
++ var got []Status
++ for i := 0; i < 150; i++ {
++ got = LiveShares()
++ if len(got) == 1 && got[0].Connected {
++ break
++ }
++ time.Sleep(20 * time.Millisecond)
++ }
++ if len(got) != 1 || !got[0].Connected || got[0].Port != 8790 {
++ t.Fatalf("listed while running: %+v", got)
++ }
++ cancel()
++ select {
++ case <-done:
++ case <-time.After(3 * time.Second):
++ t.Fatal("RunAll did not return when the Node stopped")
++ }
++ if got := LiveShares(); len(got) != 0 {
++ t.Fatalf("not listed after the Node stops: %+v", got)
++ }
++}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 76050c2..183f43f 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -26,6 +26,7 @@ import (
"time"
"github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/connect"
"github.com/OpenVibers/OpenVibe.Node/internal/credentials"
"github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/fleet"
@@@ -1272,6 +1273,9 @@ type Status struct {
// Roles are what the machine does for the network, as the owner set them on the hub (plan L34): the ids of the
// catalogue. Empty when the owner set none.
Roles []string `json:"roles,omitempty"`
++ // Shares are the OpenVibe Connect shares this Node keeps up (CON-4): their port, address, whether the tunnel is
++ // up and how many requests the app answered. Empty when the Node serves none.
++ Shares []connect.Status `json:"shares,omitempty"`
}
type LinkStatus struct {
@@@ -1328,6 +1332,7 @@ func (n *Node) Status() Status {
}
s.Screen = n.screenStatus()
s.DeskStream = n.deskStreamStatus()
++ s.Shares = connect.LiveShares()
s.Video.State = video.StateOff
if n.pub != nil {
s.Video.State, s.Video.Frames, s.Video.LastError = n.pub.State(), n.pub.Frames(), n.pub.LastError()