diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc0e7ba..0b0b0da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/verb-reference.md b/docs/verb-reference.md index 61532560..36e84e35 100644 --- a/docs/verb-reference.md +++ b/docs/verb-reference.md @@ -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 diff --git a/docs/verbs.json b/docs/verbs.json index 88a41bd9..bc16e7ab 100644 --- a/docs/verbs.json +++ b/docs/verbs.json @@ -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" diff --git a/internal/app/session/firmwarelib/boardlist_test.go b/internal/app/session/firmwarelib/boardlist_test.go index 06242812..cbd0eebb 100644 --- a/internal/app/session/firmwarelib/boardlist_test.go +++ b/internal/app/session/firmwarelib/boardlist_test.go @@ -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)) diff --git a/internal/app/session/firmwarelib/checking_test.go b/internal/app/session/firmwarelib/checking_test.go new file mode 100644 index 00000000..a6f48144 --- /dev/null +++ b/internal/app/session/firmwarelib/checking_test.go @@ -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) + } + } +} diff --git a/internal/app/session/firmwarelib/domain.go b/internal/app/session/firmwarelib/domain.go index 698fd5a6..fe9afdac 100644 --- a/internal/app/session/firmwarelib/domain.go +++ b/internal/app/session/firmwarelib/domain.go @@ -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. diff --git a/internal/app/session/firmwarelib/firmwarecatalogue.go b/internal/app/session/firmwarelib/firmwarecatalogue.go index 8e6b3335..27c6974d 100644 --- a/internal/app/session/firmwarelib/firmwarecatalogue.go +++ b/internal/app/session/firmwarelib/firmwarecatalogue.go @@ -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 @@ -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() { @@ -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 - diff --git a/internal/app/session/firmwarelib/firmwarelib.verbs.json b/internal/app/session/firmwarelib/firmwarelib.verbs.json index 1d7cbaf2..b001b324 100644 --- a/internal/app/session/firmwarelib/firmwarelib.verbs.json +++ b/internal/app/session/firmwarelib/firmwarelib.verbs.json @@ -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" diff --git a/internal/app/session/firmwarelib/firmwarescan.go b/internal/app/session/firmwarelib/firmwarescan.go index cef9b7d1..c7c6ab26 100644 --- a/internal/app/session/firmwarelib/firmwarescan.go +++ b/internal/app/session/firmwarelib/firmwarescan.go @@ -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 @@ -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{ @@ -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 +} diff --git a/internal/app/session/libraryrows.go b/internal/app/session/libraryrows.go index 25cc9548..3ad78efa 100644 --- a/internal/app/session/libraryrows.go +++ b/internal/app/session/libraryrows.go @@ -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 diff --git a/internal/app/state/network.go b/internal/app/state/network.go index 57113260..994b853e 100644 --- a/internal/app/state/network.go +++ b/internal/app/state/network.go @@ -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 diff --git a/internal/ui/workbench/firmwarepanel.go b/internal/ui/workbench/firmwarepanel.go index b386b6b2..94240a55 100644 --- a/internal/ui/workbench/firmwarepanel.go +++ b/internal/ui/workbench/firmwarepanel.go @@ -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 { diff --git a/internal/ui/workbench/firmwarerow.go b/internal/ui/workbench/firmwarerow.go index fb766cd9..e657c435 100644 --- a/internal/ui/workbench/firmwarerow.go +++ b/internal/ui/workbench/firmwarerow.go @@ -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{ @@ -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 { @@ -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 { diff --git a/internal/ui/workbench/firmwarewindow.go b/internal/ui/workbench/firmwarewindow.go index 2fc99ea4..94d92d0f 100644 --- a/internal/ui/workbench/firmwarewindow.go +++ b/internal/ui/workbench/firmwarewindow.go @@ -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: diff --git a/pkg/client-go/meshbench/types.go b/pkg/client-go/meshbench/types.go index 52b3564e..9687ee17 100644 --- a/pkg/client-go/meshbench/types.go +++ b/pkg/client-go/meshbench/types.go @@ -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. diff --git a/pkg/client-python/meshbench/types.py b/pkg/client-python/meshbench/types.py index d33b2bf7..83771f45 100644 --- a/pkg/client-python/meshbench/types.py +++ b/pkg/client-python/meshbench/types.py @@ -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: