Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ had changed in them - which is the gap this file exists to close.
columns were wider than the rail, so the header wrapped a letter to a line
and left room for one row. A panel too narrow for the full row now lays
rows out the way the Inspector does.
- **The firmware library says "checking" until the catalogue answers.** A
build not yet downloaded was marked unavailable while the catalogue was
still being asked, and for good if it could not be reached, so a first run
opened on a library of builds that looked unpublished. A row whose half of
the catalogue is unreachable now offers a retry. `firmware.library` rows
carry `checking` and `catalogue_down`.

## [0.3.0] - 2026-10-24

Expand Down
2 changes: 1 addition & 1 deletion docs/verb-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1863,7 +1863,7 @@ List every build there is, on disk and published together, so a build nobody has

**Takes** nothing.

**Answers** `builds`, `count`. The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use` and `unavailable`.
**Answers** `builds`, `count`. The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use`, `unavailable`, `checking` and `catalogue_down`. A build that is not on disk is `checking` until the catalogue answers, `catalogue_down` if it could not be reached, and `unavailable` only once it answered without it.

**Example** - list what could be run, fetched or not

Expand Down
2 changes: 1 addition & 1 deletion docs/verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2005,7 +2005,7 @@
"builds",
"count"
],
"answers": "The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use` and `unavailable`.",
"answers": "The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use`, `unavailable`, `checking` and `catalogue_down`. A build that is not on disk is `checking` until the catalogue answers, `catalogue_down` if it could not be reached, and `unavailable` only once it answered without it.",
"example": {
"params": {},
"what": "list what could be run, fetched or not"
Expand Down
5 changes: 4 additions & 1 deletion internal/app/session/firmwarelib/boardlist_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ func TestPublishedBoardsReachTheLibrary(t *testing.T) {
t.Skip("the release catalogue came back empty, which is not this code's doing")
}

got := publishedBoards(ctx)
got, err := publishedBoards(ctx)
if err != nil {
t.Skipf("the release catalogue was not reachable the second time: %v", err)
}
if len(got) == 0 {
t.Fatalf("the catalogue listed %d images and none of them reached the "+
"library: the emulated half is missing again", len(imgs))
Expand Down
92 changes: 92 additions & 0 deletions internal/app/session/firmwarelib/checking_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package firmwarelib

import (
"context"
"testing"

"github.com/MeshBench/meshbench/internal/app/session"
"github.com/MeshBench/meshbench/internal/app/state"
"github.com/MeshBench/meshbench/internal/world/scenario"
)

// A build nobody has yet, while the catalogue has not answered, is "checking",
// not "not published".
//
// On a fresh install Setup sends a new user to the library to download a build
// for each role, and the library opened by calling every one of them
// unavailable: it had asked the catalogue and not yet heard back.
func TestTheLibrarySaysCheckingUntilTheCatalogueAnswers(t *testing.T) {
cache := t.TempDir()
t.Setenv("XDG_CACHE_HOME", cache)
t.Setenv("LOCALAPPDATA", cache)

nodes := []scenario.Node{
{Name: "hill", Kind: scenario.SimpleRepeater, Firmware: scenario.FirmwareRef{Version: "repeater-v1.17.1"}},
{Name: "deck", Kind: scenario.SimpleRepeater, Firmware: scenario.FirmwareRef{
Version: "v1.17.1", Board: "LilyGo_TDeck"}},
}
store := state.New(10)
sim := &session.Sim{}
sim.BuildSeeded(nodes, 869.618, 1)
register(store, sim)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go store.Run(ctx)

// A fetch in flight, held there so the test does not reach the network.
catalogueOf(sim).fetching = true
row := func(version string) state.FirmwareRow {
t.Helper()
if _, err := store.Do(ctx, "firmware.library", nil); err != nil {
t.Fatal(err)
}
for _, r := range store.Snapshot().Library {
if r.Version == version {
return r
}
}
t.Fatalf("the pinned build %s has no row", version)
return state.FirmwareRow{}
}
native, board := func() state.FirmwareRow { return row("repeater-v1.17.1") },
func() state.FirmwareRow { return row("v1.17.1") }

for name, r := range map[string]state.FirmwareRow{"native": native(), "board": board()} {
if !r.Checking || r.Unavailable || r.CatalogueDown {
t.Errorf("%s, while the catalogue is asked: %+v, want checking only", name, r)
}
}

// The native half could not be reached: not known, rather than not
// there. The board half answered, and the image is not in it.
if _, err := store.Do(ctx, "firmware.published", publishedAnswer{nativeDown: true}); err != nil {
t.Fatal(err)
}
if r := native(); !r.CatalogueDown || r.Unavailable || r.Checking {
t.Errorf("native, after its catalogue failed: %+v, want catalogue down only", r)
}
if r := board(); !r.Unavailable || r.CatalogueDown || r.Checking {
t.Errorf("board, after its catalogue answered: %+v, want unavailable only", r)
}

// And the other way about.
if _, err := store.Do(ctx, "firmware.published", publishedAnswer{boardsDown: true}); err != nil {
t.Fatal(err)
}
if r := native(); !r.Unavailable || r.CatalogueDown || r.Checking {
t.Errorf("native, after its catalogue answered: %+v, want unavailable only", r)
}
if r := board(); !r.CatalogueDown || r.Unavailable || r.Checking {
t.Errorf("board, after its catalogue failed: %+v, want catalogue down only", r)
}

// Both reached, neither has it: now both are unavailable.
if _, err := store.Do(ctx, "firmware.published", []publishedBuild{}); err != nil {
t.Fatal(err)
}
for name, r := range map[string]state.FirmwareRow{"native": native(), "board": board()} {
if !r.Unavailable || r.Checking || r.CatalogueDown {
t.Errorf("%s, after the catalogue answered without it: %+v, want unavailable only", name, r)
}
}
}
5 changes: 5 additions & 0 deletions internal/app/session/firmwarelib/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ type catalogue struct {
// fetch has answered, empty after a fetch that failed.
published []publishedBuild
fetching bool
// nativeDown and boardsDown are the last fetch not reaching that half of
// the catalogue, so a build missing from it means "could not ask" rather
// than "nothing is there". Two, because the halves are served from
// different places and one can answer while the other does not.
nativeDown, boardsDown bool
}

// catalogueOf is the one catalogue a Sim has, made on first use.
Expand Down
30 changes: 22 additions & 8 deletions internal/app/session/firmwarelib/firmwarecatalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,27 @@ func fillLibrary(s *session.Sim, w *state.World) {
r := add(role, n.Firmware.Version, n.Firmware.Board)
r.InUse++
}
cat := catalogueOf(s)
out := make([]state.FirmwareRow, 0, len(rows))
for _, r := range rows {
// Nothing on disk and nothing published for this machine, yet nodes
// point at it: the scenario is asking for a build nobody here can
// run, and the row should say so rather than wait to be discovered
// at start time.
r.Unavailable = !r.OnDisk && !published[key(r.Role, r.Version, r.Board)]
if !r.OnDisk && !published[key(r.Role, r.Version, r.Board)] {
switch {
case cat.fetching || cat.published == nil:
// Not known yet. Said as unavailable, this is the first
// thing a new user saw: Setup sends them to the library to
// download a build for each role, and the library told them
// none was published, because it had not heard back yet.
r.Checking = true
case r.Board == "" && cat.nativeDown, r.Board != "" && cat.boardsDown:
r.CatalogueDown = true
default:
// Nothing on disk and nothing published for this machine,
// yet nodes point at it: the scenario is asking for a build
// nobody here can run, and the row should say so rather
// than wait to be discovered at start time.
r.Unavailable = true
}
}
out = append(out, *r)
}
// In use first, then what is here, then the rest: the order somebody
Expand Down Expand Up @@ -112,11 +126,11 @@ func fillLibrary(s *session.Sim, w *state.World) {
// library, so this keeps the boards MeshBench can actually run and, for each
// board and role, the newest version. Older ones stay one click away in the
// firmware picker, which reads the catalogue directly.
func publishedBoards(ctx context.Context) []publishedBuild {
func publishedBoards(ctx context.Context) ([]publishedBuild, error) {
cat := &emulated.BoardCatalogue{CacheDir: firmware.DefaultCacheDir()}
imgs, err := cat.ListAll(ctx)
if err != nil {
return nil
return nil, err
}
known := map[string]bool{}
for _, b := range hw.Boards() {
Expand Down Expand Up @@ -157,7 +171,7 @@ func publishedBoards(ctx context.Context) []publishedBuild {
}
return out[i].role < out[j].role
})
return out
return out, nil
}

// newerVersion compares vX.Y.Z by number, so v1.9.0 sorts below v1.17.0 -
Expand Down
2 changes: 1 addition & 1 deletion internal/app/session/firmwarelib/firmwarelib.verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"builds",
"count"
],
"answers": "The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use` and `unavailable`.",
"answers": "The catalogue is asked over the network in the background and its answer lands seconds later, so the first call in a session answers from disk alone and is worth making again. Each row in `builds` carries `role`, `version`, `board`, `bytes`, `on_disk`, `path`, `in_use`, `unavailable`, `checking` and `catalogue_down`. A build that is not on disk is `checking` until the catalogue answers, `catalogue_down` if it could not be reached, and `unavailable` only once it answered without it.",
"example": {
"params": {},
"what": "list what could be run, fetched or not"
Expand Down
31 changes: 26 additions & 5 deletions internal/app/session/firmwarelib/firmwarescan.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ func registerFirmwareScan(st *state.Store, s *session.Sim) {
})

st.HandleInternal("firmware.published", func(w *state.World, p any) (any, error) {
list, ok := p.([]publishedBuild)
if !ok {
var ans publishedAnswer
switch v := p.(type) {
case publishedAnswer:
ans = v
case []publishedBuild:
ans.builds = v
default:
return nil, session.WrongCallback("firmware.published")
}
list := ans.builds
if list == nil {
list = []publishedBuild{}
}
catalogueOf(s).published = list
catalogueOf(s).nativeDown, catalogueOf(s).boardsDown = ans.nativeDown, ans.boardsDown
catalogueOf(s).fetching = false
// Rebuild the rows here, or the fetch lands in a field nobody reads
// again: the panel asks for the library once, the network answers a
Expand Down Expand Up @@ -64,7 +74,8 @@ func startPublishedFetch(s *session.Sim, st *state.Store) {
// The native builds: MeshCore compiled for this machine, which is how
// most nodes run.
cat := &firmware.NativeCatalogue{CacheDir: firmware.DefaultCacheDir()}
if images, err := cat.List(ctx); err == nil {
images, nativeErr := cat.List(ctx)
if nativeErr == nil {
for _, img := range images {
if img.ForThisMachine() {
list = append(list, publishedBuild{
Expand All @@ -78,9 +89,19 @@ func startPublishedFetch(s *session.Sim, st *state.Store) {
// never did, so the whole emulated half of the library was missing: on
// Linux the native builds hid it, and on a Mac, where no native build
// exists yet, the panel was simply empty.
list = append(list, publishedBoards(ctx)...)
boards, boardsErr := publishedBoards(ctx)
list = append(list, boards...)
// An empty non-nil list on failure, so a dead network is one failed
// fetch rather than a fetch per frame.
_, _ = st.Do(context.Background(), "firmware.published", list)
_, _ = st.Do(context.Background(), "firmware.published", publishedAnswer{
builds: list, nativeDown: nativeErr != nil, boardsDown: boardsErr != nil,
})
}()
}

// publishedAnswer is what a fetch hands back: the builds, and which half of
// the catalogue could not be reached.
type publishedAnswer struct {
builds []publishedBuild
nativeDown, boardsDown bool
}
1 change: 1 addition & 0 deletions internal/app/session/libraryrows.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func libraryRows(rows []state.FirmwareRow) []map[string]any {
// What a delete would break, and why a row nobody can honour is
// not simply hidden.
"in_use": b.InUse, "unavailable": b.Unavailable,
"checking": b.Checking, "catalogue_down": b.CatalogueDown,
})
}
return out
Expand Down
7 changes: 7 additions & 0 deletions internal/app/state/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,13 @@ type FirmwareRow struct {
// which reads as the library losing builds rather than as a pin nobody
// can honour.
Unavailable bool
// Checking marks a row whose build is not on disk while the catalogue of
// published builds has not answered yet. It may well be published; until
// the answer is in, nobody can say it is not.
Checking bool
// CatalogueDown marks a row whose build is not on disk when the catalogue
// could not be reached, so whether it is published is not known.
CatalogueDown bool
// Native marks a build for this machine rather than an image for a board.
// The two are not interchangeable and only one of them can be renamed.
Native bool
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/workbench/firmwarepanel.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ var fwCols = []struct {
width int
}{
{"Role", 210}, {"Version", 250}, {"Runs as", 140}, {"Size", 70},
{"On disk", 70}, {"Used by", 90}, {"Use for role", 150}, {"Actions", 70},
{"On disk", 70}, {"Used by", 90}, {"Use for role", 150}, {"Actions", 96},
}

func (p *firmwarePanel) Draw(t *theme.Theme, gtx layout.Context, s *state.Snapshot) layout.Dimensions {
Expand Down
20 changes: 17 additions & 3 deletions internal/ui/workbench/firmwarerow.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ func (p *firmwarePanel) row(t *theme.Theme, gtx layout.Context, s *state.Snapsho
}
if w.act.Clicked(gtx) {
switch {
case r.Unavailable:
// Nothing to download and nothing to delete.
case r.CatalogueDown:
// Ask again: the answer was "could not reach it", not "no".
if p.OnAction != nil {
p.OnAction("firmware.rescan", nil)
}
case r.Unavailable || r.Checking:
// Nothing to download and nothing to delete - yet, for one of them.
case !r.OnDisk:
if p.OnAction != nil {
p.OnAction("firmware.download", map[string]any{
Expand Down Expand Up @@ -81,8 +86,13 @@ func (p *firmwarePanel) row(t *theme.Theme, gtx layout.Context, s *state.Snapsho
// nothing published this machine could fetch. Saying so here is the
// difference between "the library lost a build" and "these nodes are
// pinned to something that was never built for you".
if r.Unavailable {
switch {
case r.Unavailable:
runsAs += " - not published for this machine"
case r.Checking:
runsAs += " - checking what is published"
case r.CatalogueDown:
runsAs += " - the catalogue could not be reached"
}
size := "-"
if r.OnDisk {
Expand Down Expand Up @@ -140,6 +150,10 @@ func (p *firmwarePanel) row(t *theme.Theme, gtx layout.Context, s *state.Snapsho
// Nothing to fetch, so no button that implies
// there is.
label, ink = "unavailable", t.P.Warn
} else if r.Checking {
label, ink = "checking", t.P.Dim
} else if r.CatalogueDown {
label, ink = "retry", t.P.Accent
} else if !r.OnDisk {
label, ink = "get", t.P.Accent
} else if p.confirm == key {
Expand Down
4 changes: 4 additions & 0 deletions internal/ui/workbench/firmwarewindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,10 @@ func (p *firmwareWindowPanel) stateChip(t *theme.Theme, gtx layout.Context,
switch {
case !r.OnDisk && r.Unavailable:
label, ink = "not published for this machine", t.P.Warn
case !r.OnDisk && r.Checking:
label, ink = "checking what is published", t.P.Dim
case !r.OnDisk && r.CatalogueDown:
label, ink = "catalogue unreachable - refresh to ask again", t.P.Warn
case !r.OnDisk:
label, ink = "not downloaded", t.P.Dim
case r.Native:
Expand Down
5 changes: 5 additions & 0 deletions pkg/client-go/meshbench/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,11 @@ type Build struct {
// fails at start, which reads as the library losing builds rather than as
// a pin nobody can honour.
Unavailable bool `json:"unavailable"`
// Checking marks a build not on disk while the catalogue has not answered
// yet, and CatalogueDown one whose catalogue could not be reached: in both
// it may well be published, and nobody can yet say it is not.
Checking bool `json:"checking"`
CatalogueDown bool `json:"catalogue_down"`
}

// BuildDetails is one build in full: what a library row cannot hold.
Expand Down
5 changes: 5 additions & 0 deletions pkg/client-python/meshbench/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ class Build:
#: published. Pinning to one succeeds and then fails at start, which reads
#: as the library losing builds rather than as a pin nobody can honour.
unavailable: bool = False
#: Not on disk while the catalogue has not answered yet (``checking``),
#: or could not be reached (``catalogue_down``): in both it may well be
#: published, and nobody can yet say it is not.
checking: bool = False
catalogue_down: bool = False

def __str__(self) -> str:
if not self.board:
Expand Down