From e48e68d871e133c49f29e7e70fbdc78016eb2ca2 Mon Sep 17 00:00:00 2001 From: John Nicholson Date: Fri, 20 Mar 2026 14:19:40 -0400 Subject: [PATCH 1/3] fix linter CI failures - set GOTOOLCHAIN=go$(GOVERSION) so golangci-lint is built with the Go version declared in go.mod, not whatever the CI host has installed - replace deprecated slam.FromDependencies with slam.FromProvider - replace deprecated res.GetFileId() with res.GetBinaryDataId() - apply linter auto-fixes: proto getter style, range-over-int loop Co-Authored-By: Claude Sonnet 4.6 --- cloudslam/app.go | 2 +- cloudslam/cloudslam.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudslam/app.go b/cloudslam/app.go index 77fb6ee..d904c65 100644 --- a/cloudslam/app.go +++ b/cloudslam/app.go @@ -28,7 +28,7 @@ type AppClient struct { PackageClient pbPackage.PackageServiceClient SyncClient pbDataSync.DataSyncServiceClient RobotClient pbApp.RobotServiceClient - HTTPClient *http.Client // used for downloading pcds of the current cloudslam session + HTTPClient *http.Client // used for downloading pcds of the current cloudslam session logger logging.Logger } diff --git a/cloudslam/cloudslam.go b/cloudslam/cloudslam.go index 9621a3a..100b637 100644 --- a/cloudslam/cloudslam.go +++ b/cloudslam/cloudslam.go @@ -520,7 +520,7 @@ func generateProgressRingPCD(elapsed time.Duration) ([]byte, error) { filledPoints := int(fraction * numPoints) pc := pointcloud.NewBasicEmpty() - for i := 0; i < filledPoints; i++ { + for i := range filledPoints { angle := float64(i) / numPoints * 2 * math.Pi x := radius * math.Cos(angle) y := radius * math.Sin(angle) From f2d1fc9149737db6bbfb12508870871588e53332 Mon Sep 17 00:00:00 2001 From: John Nicholson Date: Fri, 20 Mar 2026 14:37:23 -0400 Subject: [PATCH 2/3] render status text inside progress ring PCD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds a 5×7 dot-matrix font renderer (pcdtext.go) and draws "WAITING FOR / SESSION TO START" centered inside the progress ring, making it unambiguous to the viewer that this is not a real map. Co-Authored-By: Claude Sonnet 4.6 --- cloudslam/cloudslam.go | 29 ++++++++++++++++++++++--- cloudslam/pcdtext.go | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 cloudslam/pcdtext.go diff --git a/cloudslam/cloudslam.go b/cloudslam/cloudslam.go index 100b637..1d87383 100644 --- a/cloudslam/cloudslam.go +++ b/cloudslam/cloudslam.go @@ -505,14 +505,20 @@ func (svc *cloudslamWrapper) ParseSensorsForPackage() ([]interface{}, error) { return sensorMetadata, nil } -// generateProgressRingPCD generates a point cloud of a progress arc indicating elapsed time. -// The arc grows clockwise from 0 to a full circle over progressRingDuration, giving the user -// visual feedback while waiting for the first cloudslam map to appear. +// generateProgressRingPCD generates a point cloud of a progress arc with status text inside. +// The arc grows from 0 to a full circle over progressRingDuration. Text reading +// "WAITING FOR / SESSION TO START" is rendered inside the ring using a 5×7 dot-matrix font +// so the viewer can clearly distinguish this from a real map. func generateProgressRingPCD(elapsed time.Duration) ([]byte, error) { const ( numPoints = 360 radius = 1000.0 // mm progressRingDuration = 5 * time.Minute + pixelSize = 18.0 // mm per dot — fits both text lines within the ring + line1 = "WAITING FOR" + line2 = "SESSION TO START" + fontRows = 7 + lineGapRows = 2 ) // Always show at least a small arc so the user sees something immediately. @@ -529,6 +535,23 @@ func generateProgressRingPCD(elapsed time.Duration) ([]byte, error) { } } + // Center both lines vertically around the origin. + // line1Y is the y coordinate of the top pixel row of line 1; + // with pixelSize=18 both lines fit comfortably within the 1000mm ring radius. + line1Y := float64(fontRows+lineGapRows+fontRows-1) / 2 * pixelSize + line2Y := line1Y - float64(fontRows+lineGapRows)*pixelSize + + // Center each line horizontally. Width = (nChars×6 − 1) × pixelSize. + line1X := -float64(len(line1)*6-1) * pixelSize / 2 + line2X := -float64(len(line2)*6-1) * pixelSize / 2 + + if err := addTextToPCD(pc, line1, line1X, line1Y, pixelSize); err != nil { + return nil, err + } + if err := addTextToPCD(pc, line2, line2X, line2Y, pixelSize); err != nil { + return nil, err + } + var buf bytes.Buffer if err := pointcloud.ToPCD(pc, &buf, pointcloud.PCDAscii); err != nil { return nil, err diff --git a/cloudslam/pcdtext.go b/cloudslam/pcdtext.go new file mode 100644 index 0000000..d396097 --- /dev/null +++ b/cloudslam/pcdtext.go @@ -0,0 +1,49 @@ +package cloudslam + +import ( + "github.com/golang/geo/r3" + "go.viam.com/rdk/pointcloud" +) + +// font5x7 defines a 5×7 dot-matrix bitmap for each character used in status text. +// Each entry is 7 bytes (one per row, top to bottom). Within each byte, +// bit 4 is the leftmost column and bit 0 is the rightmost. +var font5x7 = map[byte][7]byte{ + ' ': {}, + 'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001}, + 'E': {0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b11111}, + 'F': {0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b10000}, + 'G': {0b01110, 0b10001, 0b10000, 0b10110, 0b10001, 0b10001, 0b01110}, + 'I': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111}, + 'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001}, + 'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110}, + 'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001}, + 'S': {0b01110, 0b10001, 0b10000, 0b01110, 0b00001, 0b10001, 0b01110}, + 'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100}, + 'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001}, +} + +// addTextToPCD renders a string into a point cloud using a 5×7 dot-matrix font. +// (x0, y0) is the top-left corner of the first character in mm. +// pixelSize controls the spacing between dots in mm. +func addTextToPCD(pc pointcloud.PointCloud, text string, x0, y0, pixelSize float64) error { + for i, ch := range []byte(text) { + bitmap, ok := font5x7[ch] + if !ok { + continue + } + charX := x0 + float64(i)*6*pixelSize + for row := range 7 { + for col := range 5 { + if bitmap[row]&(1<<(4-col)) != 0 { + px := charX + float64(col)*pixelSize + py := y0 - float64(row)*pixelSize + if err := pc.Set(r3.Vector{X: px, Y: py, Z: 0}, pointcloud.NewBasicData()); err != nil { + return err + } + } + } + } + } + return nil +} From 6354421cd32a5056667cd331d81d65d1302d1e51 Mon Sep 17 00:00:00 2001 From: John Nicholson Date: Fri, 20 Mar 2026 15:12:13 -0400 Subject: [PATCH 3/3] surface session failures in map view and fix save-local-map URL - Add lastSessionErr atomic to track failed sessions; polling thread checks metadata every cycle and sets it on END_STATUS_FAIL - PointCloudMap returns a "SESSION FAILED" PCD when lastSessionErr is set, covering both pre-map and post-map failure cases - Add D and L glyphs to font5x7 for "FAILED" text rendering - Add SLAMMapURL helper on AppClient to deduplicate URL construction; fixes save-local-map missing page=slam (causing redirect to /fleet) Co-Authored-By: Claude Sonnet 4.6 --- cloudslam/app.go | 5 ++++ cloudslam/cloudslam.go | 59 ++++++++++++++++++++++++++++++++++++-- cloudslam/createpackage.go | 3 +- cloudslam/pcdtext.go | 2 ++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/cloudslam/app.go b/cloudslam/app.go index d904c65..7833e0a 100644 --- a/cloudslam/app.go +++ b/cloudslam/app.go @@ -174,6 +174,11 @@ func hasEnabledDataCapture(comp *pbApp.ComponentConfig, sensorType slam.SensorTy return false } +// SLAMMapURL returns the app URL for viewing a SLAM map package. +func (app *AppClient) SLAMMapURL(mapName, version string) string { + return app.baseURL + "/robots?page=slam&name=" + mapName + "&version=" + version +} + // Close closes the app clients. func (app *AppClient) Close() error { // close any idle connections to prevent goleaks. Possibly redundant with DisableKeepAlives diff --git a/cloudslam/cloudslam.go b/cloudslam/cloudslam.go index 1d87383..b3e81cc 100644 --- a/cloudslam/cloudslam.go +++ b/cloudslam/cloudslam.go @@ -89,6 +89,7 @@ type cloudslamWrapper struct { activeJob atomic.Pointer[activeJobState] // nil when no job is running lastPose atomic.Pointer[spatialmath.Pose] lastPointCloudURL atomic.Pointer[string] + lastSessionErr atomic.Pointer[string] // non-nil when the last session ended with a failure defaultpcd []byte slamService slam.Service // the slam service that cloudslam will wrap @@ -292,6 +293,19 @@ func (svc *cloudslamWrapper) activeMappingSessionThread(ctx context.Context) { continue } + // Check session metadata to detect failures. + metaResp, err := svc.app.CSClient.GetMappingSessionMetadataByID(ctx, + &pbCloudSLAM.GetMappingSessionMetadataByIDRequest{SessionId: job.id}) + if err != nil { + svc.logger.Error(err) + } else if metaResp.GetSessionMetadata().GetEndStatus() == pbCloudSLAM.EndStatus_END_STATUS_FAIL { + errMsg := metaResp.GetSessionMetadata().GetErrorMsg() + svc.logger.Errorf("cloudslam session %s failed: %s", job.id, errMsg) + svc.lastSessionErr.Store(&errMsg) + svc.activeJob.Store(nil) + continue + } + // get the most recent pointcloud and position if there is an active job req := &pbCloudSLAM.GetMappingSessionPointCloudRequest{SessionId: job.id} resp, err := svc.app.CSClient.GetMappingSessionPointCloud(ctx, req) @@ -313,6 +327,16 @@ func (svc *cloudslamWrapper) Position(ctx context.Context) (spatialmath.Pose, er } func (svc *cloudslamWrapper) PointCloudMap(ctx context.Context, returnEditedMap bool) (func() ([]byte, error), error) { + // If the last session failed, show a failure PCD regardless of map state. + if svc.lastSessionErr.Load() != nil { + failurePCD, err := generateFailurePCD() + if err != nil { + svc.logger.Warnf("failed to generate failure PCD: %v", err) + return toChunkedFunc(svc.defaultpcd), nil + } + return toChunkedFunc(failurePCD), nil + } + currMap := *svc.lastPointCloudURL.Load() if currMap == "" { @@ -367,6 +391,7 @@ func (svc *cloudslamWrapper) DoCommand(ctx context.Context, req map[string]inter svc.activeJob.Store(&activeJobState{id: jobID, startedAt: time.Now()}) svc.lastPose.Store(&initPose) svc.lastPointCloudURL.Store(&initPCDURL) + svc.lastSessionErr.Store(nil) if isUpdating { resp[updatingModeKey] = fmt.Sprintf("slam map found on machine, starting cloudslam in updating mode. Map "+ @@ -414,8 +439,7 @@ func (svc *cloudslamWrapper) StopJob(ctx context.Context) (string, error) { svc.activeJob.Store(nil) packageName := strings.Split(resp.GetPackageId(), "/")[1] - packageURL := svc.app.baseURL + "/robots?page=slam&name=" + packageName + "&version=" + resp.GetVersion() - return packageURL, nil + return svc.app.SLAMMapURL(packageName, resp.GetVersion()), nil } // StartJob starts a cloudslam job with the requested map name. Currently assumes a set of config parameters. @@ -559,6 +583,37 @@ func generateProgressRingPCD(elapsed time.Duration) ([]byte, error) { return buf.Bytes(), nil } +// generateFailurePCD generates a point cloud displaying "SESSION FAILED" to indicate the mapping session ended with an error. +func generateFailurePCD() ([]byte, error) { + const ( + pixelSize = 18.0 // mm per dot + line1 = "SESSION" + line2 = "FAILED" + fontRows = 7 + lineGapRows = 2 + ) + + pc := pointcloud.NewBasicEmpty() + + line1Y := float64(fontRows+lineGapRows+fontRows-1) / 2 * pixelSize + line2Y := line1Y - float64(fontRows+lineGapRows)*pixelSize + line1X := -float64(len(line1)*6-1) * pixelSize / 2 + line2X := -float64(len(line2)*6-1) * pixelSize / 2 + + if err := addTextToPCD(pc, line1, line1X, line1Y, pixelSize); err != nil { + return nil, err + } + if err := addTextToPCD(pc, line2, line2X, line2Y, pixelSize); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := pointcloud.ToPCD(pc, &buf, pointcloud.PCDAscii); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + // toChunkedFunc takes binary data and wraps it in a helper function that converts it into chunks for streaming APIs. func toChunkedFunc(b []byte) func() ([]byte, error) { chunk := make([]byte, chunkSizeBytes) diff --git a/cloudslam/createpackage.go b/cloudslam/createpackage.go index e1581bd..0ab3487 100644 --- a/cloudslam/createpackage.go +++ b/cloudslam/createpackage.go @@ -54,8 +54,7 @@ func (svc *cloudslamWrapper) UploadPackage(ctx context.Context, mapName string) } // return a link for where to find the package - packageURL := svc.app.baseURL + "/robots?name=" + mapName + "&version=" + packageVersion - return packageURL, nil + return svc.app.SLAMMapURL(mapName, packageVersion), nil } // uploadArchive creates a tar/archive of the SLAM map and uploads it to app using the package APIs. diff --git a/cloudslam/pcdtext.go b/cloudslam/pcdtext.go index d396097..581bc88 100644 --- a/cloudslam/pcdtext.go +++ b/cloudslam/pcdtext.go @@ -15,6 +15,8 @@ var font5x7 = map[byte][7]byte{ 'F': {0b11111, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000, 0b10000}, 'G': {0b01110, 0b10001, 0b10000, 0b10110, 0b10001, 0b10001, 0b01110}, 'I': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111}, + 'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110}, + 'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111}, 'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001}, 'O': {0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110}, 'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},