├── .gitignore ├── web ├── src │ ├── vite-env.d.ts │ ├── hooks │ │ ├── useApi.ts │ │ ├── useImu.ts │ │ ├── useGPS.ts │ │ ├── usePose.ts │ │ ├── useStatus.ts │ │ ├── useWheelTicks.ts │ │ ├── useEnv.tsx │ │ ├── useHighLevelStatus.ts │ │ ├── useWS.ts │ │ └── useConfig.tsx │ ├── components │ │ ├── Spinner.tsx │ │ ├── StyledTerminal.tsx │ │ ├── WheelTicksComponent.tsx │ │ ├── AsyncSwitch.tsx │ │ ├── AsyncDropDownButton.tsx │ │ ├── MowerStatus.tsx │ │ ├── utils.tsx │ │ ├── AsyncButton.tsx │ │ ├── ImuComponent.tsx │ │ ├── GpsComponent.tsx │ │ ├── DrawControl.tsx │ │ ├── HighLevelStatusComponent.tsx │ │ ├── StatusComponent.tsx │ │ ├── FlashGPSComponent.tsx │ │ └── MowerActions.tsx │ ├── wdyr.ts │ ├── global.d.ts │ ├── pages │ │ ├── SettingsPage.tsx │ │ ├── OpenMowerPage.tsx │ │ ├── SetupPage.tsx │ │ ├── LogsPage.tsx │ │ └── MapStyle.tsx │ ├── utils │ │ ├── map.tsx │ │ └── ansi.ts │ ├── main.tsx │ ├── routes │ │ └── root.tsx │ └── types │ │ ├── map.ts │ │ └── ros.ts ├── .npmrc ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── tsconfig.json ├── .eslintrc.cjs ├── index.html └── package.json ├── pkg ├── types │ ├── homekit.go │ ├── gps.go │ ├── ros.go │ ├── db.go │ ├── docker.go │ └── firmware.go ├── msgs │ ├── xbot_msgs │ │ ├── MapOverlay.go │ │ ├── ActionInfo.go │ │ ├── SensorDataDouble.go │ │ ├── SensorDataString.go │ │ ├── MapArea.go │ │ ├── MapOverlayPolygon.go │ │ ├── RegisterActionsSrv.go │ │ ├── RobotState.go │ │ ├── Map.go │ │ ├── WheelTick.go │ │ ├── AbsolutePose.go │ │ └── SensorInfo.go │ ├── mower_msgs │ │ ├── Perimeter.go │ │ ├── GPSControlSrv.go │ │ ├── ImuRaw.go │ │ ├── StartInAreaSrv.go │ │ ├── EmergencyStopSrv.go │ │ ├── MowerControlSrv.go │ │ ├── PerimeterControlSrv.go │ │ ├── ESCStatus.go │ │ ├── Status.go │ │ ├── HighLevelControlSrv.go │ │ └── HighLevelStatus.go │ ├── dynamic_reconfigure │ │ ├── BoolParameter.go │ │ ├── IntParameter.go │ │ ├── StrParameter.go │ │ ├── DoubleParameter.go │ │ ├── GroupState.go │ │ ├── ConfigDescription.go │ │ ├── Group.go │ │ ├── ParamDescription.go │ │ ├── Config.go │ │ ├── Reconfigure.go │ │ └── SensorLevels.go │ └── mower_map │ │ ├── MapArea.go │ │ ├── ReplaceMapSrvReq.go │ │ ├── ClearMapSrv.go │ │ ├── AppendMapSrv.go │ │ ├── ClearNavPointSrv.go │ │ ├── DeleteMowingAreaSrv.go │ │ ├── GetMowingAreaSrv.go │ │ ├── ConvertToNavigationAreaSrv.go │ │ ├── SetNavPointSrv.go │ │ ├── AddMowingAreaSrv.go │ │ ├── MapAreas.go │ │ ├── GetDockingPointSrv.go │ │ └── SetDockingPointSrv.go ├── api │ ├── types.go │ ├── tiles.go │ ├── utils.go │ ├── api.go │ ├── config.go │ ├── setup.go │ ├── settings.go │ └── containers.go └── providers │ ├── ublox.go │ ├── firmware_test.go │ ├── docker.go │ ├── db.go │ ├── homekit.go │ ├── mqtt.go │ └── firmware.go ├── setup ├── Robot.txt.get.ubx ├── Robot.txt.set.ubx └── txt2ubx.py ├── yarn.lock ├── .dockerignore ├── .env.dist ├── .idea ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── .gitignore ├── modules.xml └── misc.xml ├── .env ├── Makefile ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── openmower-gui.iml ├── docker-compose.yaml ├── .github └── workflows │ ├── clear-cache.yml │ └── docker-publish.yml ├── main.go ├── mower_config.sh ├── Dockerfile ├── generate_go_msgs.sh ├── README.md ├── go.mod └── asserts └── board.h /.gitignore: -------------------------------------------------------------------------------- 1 | /db 2 | /node_modules 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /pkg/types/homekit.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type IHAProvider interface { 4 | Init() 5 | } 6 | -------------------------------------------------------------------------------- /setup/Robot.txt.get.ubx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedbossneo/openmower-gui/HEAD/setup/Robot.txt.get.ubx -------------------------------------------------------------------------------- /setup/Robot.txt.set.ubx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedbossneo/openmower-gui/HEAD/setup/Robot.txt.set.ubx -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | -------------------------------------------------------------------------------- /web/.npmrc: -------------------------------------------------------------------------------- 1 | init.author.name = Cedric 2 | init.author.email = cedboss@me.com 3 | registry = https://registry.npmjs.org 4 | -------------------------------------------------------------------------------- /pkg/types/gps.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import "io" 4 | 5 | type IGpsProvider interface { 6 | FlashGPS(writer io.Writer) error 7 | } 8 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | web/dist 2 | web/node_modules 3 | .idea 4 | .git 5 | docker-compose.yaml 6 | .env 7 | mower_config.sh 8 | openmower-gui.iml 9 | -------------------------------------------------------------------------------- /web/src/hooks/useApi.ts: -------------------------------------------------------------------------------- 1 | import {Api} from "../api/Api.ts"; 2 | 3 | export const useApi = () => { 4 | const api = new Api(); 5 | api.baseUrl = "/api" 6 | return api; 7 | } -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | MOWER_CONFIG_FILE=mower_config.sh 2 | DOCKER_HOST=unix:///var/run/docker.sock 3 | WEB_DIR=./web/dist 4 | ROS_MASTER_URI=http://192.168.64.6:11311 5 | ROS_NODE_NAME=openmower-gui 6 | -------------------------------------------------------------------------------- /web/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import {Spin} from "antd"; 2 | 3 | export function Spinner() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | # Zeppelin ignored files 10 | /ZeppelinRemoteNotebooks/ 11 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/MapOverlay.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type MapOverlay struct { 10 | msg.Package `ros:"xbot_msgs"` 11 | Polygons []MapOverlayPolygon 12 | } 13 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /web/src/components/StyledTerminal.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledTerminal = styled.div` 4 | div.react-terminal-wrapper { 5 | padding-top: 35px; 6 | } 7 | 8 | div.react-terminal-wrapper > div.react-terminal-window-buttons { 9 | display: none; 10 | } 11 | `; -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/ActionInfo.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ActionInfo struct { 10 | msg.Package `ros:"xbot_msgs"` 11 | ActionId string 12 | ActionName string 13 | Enabled bool 14 | } 15 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/Perimeter.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type Perimeter struct { 10 | msg.Package `ros:"mower_msgs"` 11 | Left float32 12 | Center float32 13 | Right float32 14 | } 15 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/SensorDataDouble.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "time" 8 | ) 9 | 10 | type SensorDataDouble struct { 11 | msg.Package `ros:"xbot_msgs"` 12 | Stamp time.Time 13 | Data float64 14 | } 15 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/SensorDataString.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "time" 8 | ) 9 | 10 | type SensorDataString struct { 11 | msg.Package `ros:"xbot_msgs"` 12 | Stamp time.Time 13 | Data string 14 | } 15 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/BoolParameter.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type BoolParameter struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Value bool 13 | } 14 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/IntParameter.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type IntParameter struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Value int32 13 | } 14 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/StrParameter.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type StrParameter struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Value string 13 | } 14 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/DoubleParameter.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type DoubleParameter struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Value float64 13 | } 14 | -------------------------------------------------------------------------------- /web/src/wdyr.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import React from 'react'; 3 | import whyDidYouRender from '@welldone-software/why-did-you-render'; 4 | 5 | if (import.meta.env.DEV) { 6 | whyDidYouRender(React, { 7 | trackAllPureComponents: true, 8 | trackHooks: true, 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | MOWER_CONFIG_FILE=mower_config.sh 2 | DB_PATH=./db 3 | DOCKER_HOST=unix:///Users/cedric/.rd/docker.sock 4 | WEB_DIR=./web/dist 5 | #ROS_MASTER_URI=http://192.168.64.6:11311 6 | ROS_MASTER_URI=http://192.168.1.67:11311 7 | MAP_TILE_SERVER=http://localhost:5000 8 | #MAP_TILE_URI=/tiles/vt/lyrs=s,h&x={x}&y={y}&z={z} 9 | HOMEKIT_ENABLED=false 10 | MQTT_ENABLED=true 11 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/GroupState.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type GroupState struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | State bool 13 | Id int32 14 | Parent int32 15 | } 16 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/ConfigDescription.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ConfigDescription struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Groups []Group 12 | Max Config 13 | Min Config 14 | Dflt Config 15 | } 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | .PHONY: build run-gui run-backend run 3 | CURRENT_DIR := "${PWD}" 4 | 5 | deps: 6 | cd $(CURRENT_DIR)/web && yarn 7 | cd $(CURRENT_DIR) 8 | go mod download 9 | 10 | build: 11 | docker build -t openmower-gui . 12 | 13 | run-gui: 14 | cd $(CURRENT_DIR)/web && yarn dev --host 15 | cd $(CURRENT_DIR) 16 | 17 | run-backend: 18 | CGO_ENABLED=0 go run main.go 19 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/Group.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type Group struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Type string 13 | Parameters []ParamDescription 14 | Parent int32 15 | Id int32 16 | } 17 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/MapArea.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type MapArea struct { 11 | msg.Package `ros:"mower_map"` 12 | Name string 13 | Area geometry_msgs.Polygon 14 | Obstacles []geometry_msgs.Polygon 15 | } 16 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/MapArea.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type MapArea struct { 11 | msg.Package `ros:"xbot_msgs"` 12 | Name string 13 | Area geometry_msgs.Polygon 14 | Obstacles []geometry_msgs.Polygon 15 | } 16 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/ParamDescription.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ParamDescription struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Name string 12 | Type string 13 | Level uint32 14 | Description string 15 | EditMethod string 16 | } 17 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/ReplaceMapSrvReq.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import "github.com/bluenviron/goroslib/v2/pkg/msg" 6 | 7 | type MowerMapReplaceArea struct { 8 | Area MapArea 9 | IsNavigationArea bool `rosname:"isNavigationArea"` 10 | } 11 | 12 | type ReplaceMowingAreaSrvReq struct { 13 | msg.Package `ros:"mower_map"` 14 | Areas []MowerMapReplaceArea 15 | } 16 | -------------------------------------------------------------------------------- /pkg/types/ros.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "github.com/bluenviron/goroslib/v2" 6 | ) 7 | 8 | type IRosProvider interface { 9 | CallService(ctx context.Context, srvName string, srv any, req any, res any) error 10 | Subscribe(topic string, id string, cb func(msg []byte)) error 11 | UnSubscribe(topic string, id string) 12 | Publisher(topic string, obj interface{}) (*goroslib.Publisher, error) 13 | } 14 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // See https://containers.dev/implementors/json_reference/ for configuration reference 2 | { 3 | "name": "Untitled Node.js project", 4 | "build": { 5 | "dockerfile": "Dockerfile" 6 | }, 7 | "forwardPorts": [ 8 | 5173, 9 | 1883, 10 | 4006 11 | ], 12 | "remoteUser": "node", 13 | "customizations": { 14 | "jetbrains": { 15 | "backend": "WebStorm" 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/Config.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type Config struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Bools []BoolParameter 12 | Ints []IntParameter 13 | Strs []StrParameter 14 | Doubles []DoubleParameter 15 | Groups []GroupState 16 | } 17 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/MapOverlayPolygon.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type MapOverlayPolygon struct { 11 | msg.Package `ros:"xbot_msgs"` 12 | Polygon geometry_msgs.Polygon 13 | Color string 14 | Closed bool 15 | LineWidth float32 16 | } 17 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/ClearMapSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ClearMapSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | } 12 | 13 | type ClearMapSrvRes struct { 14 | msg.Package `ros:"mower_map"` 15 | } 16 | 17 | type ClearMapSrv struct { 18 | msg.Package `ros:"mower_map"` 19 | ClearMapSrvReq 20 | ClearMapSrvRes 21 | } 22 | -------------------------------------------------------------------------------- /web/src/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'react' 2 | 3 | declare module 'react' { 4 | interface HTMLAttributes { 5 | onPointerEnterCapture?: (e: React.PointerEvent) => void 6 | onPointerLeaveCapture?: (e: React.PointerEvent) => void 7 | } 8 | 9 | interface RefAttributes { 10 | onPointerEnterCapture?: (e: React.PointerEvent) => void 11 | onPointerLeaveCapture?: (e: React.PointerEvent) => void 12 | } 13 | } -------------------------------------------------------------------------------- /pkg/types/db.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type IDBProvider interface { 4 | // Set sets the value for the given key. 5 | Set(key string, value []byte) error 6 | 7 | // Get returns the value for the given key. 8 | Get(key string) ([]byte, error) 9 | 10 | // Delete deletes the value for the given key. 11 | Delete(key string) error 12 | 13 | // KeysWithSuffix returns a list keys with the give suffix. 14 | KeysWithSuffix(suffix string) ([]string, error) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/AppendMapSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type AppendMapSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | Bagfile string 12 | } 13 | 14 | type AppendMapSrvRes struct { 15 | msg.Package `ros:"mower_map"` 16 | } 17 | 18 | type AppendMapSrv struct { 19 | msg.Package `ros:"mower_map"` 20 | AppendMapSrvReq 21 | AppendMapSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/ClearNavPointSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ClearNavPointSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | } 12 | 13 | type ClearNavPointSrvRes struct { 14 | msg.Package `ros:"mower_map"` 15 | } 16 | 17 | type ClearNavPointSrv struct { 18 | msg.Package `ros:"mower_map"` 19 | ClearNavPointSrvReq 20 | ClearNavPointSrvRes 21 | } 22 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/GPSControlSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type GPSControlSrvReq struct { 10 | msg.Package `ros:"mower_msgs"` 11 | GpsEnabled uint8 12 | } 13 | 14 | type GPSControlSrvRes struct { 15 | msg.Package `ros:"mower_msgs"` 16 | } 17 | 18 | type GPSControlSrv struct { 19 | msg.Package `ros:"mower_msgs"` 20 | GPSControlSrvReq 21 | GPSControlSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/ImuRaw.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ImuRaw struct { 10 | msg.Package `ros:"mower_msgs"` 11 | Dt uint16 12 | Ax float64 13 | Ay float64 14 | Az float64 15 | Gx float64 16 | Gy float64 17 | Gz float64 18 | Mx float64 19 | My float64 20 | Mz float64 21 | } 22 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/StartInAreaSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type StartInAreaSrvReq struct { 10 | msg.Package `ros:"mower_msgs"` 11 | Area uint8 12 | } 13 | 14 | type StartInAreaSrvRes struct { 15 | msg.Package `ros:"mower_msgs"` 16 | } 17 | 18 | type StartInAreaSrv struct { 19 | msg.Package `ros:"mower_msgs"` 20 | StartInAreaSrvReq 21 | StartInAreaSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/EmergencyStopSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type EmergencyStopSrvReq struct { 10 | msg.Package `ros:"mower_msgs"` 11 | Emergency uint8 12 | } 13 | 14 | type EmergencyStopSrvRes struct { 15 | msg.Package `ros:"mower_msgs"` 16 | } 17 | 18 | type EmergencyStopSrv struct { 19 | msg.Package `ros:"mower_msgs"` 20 | EmergencyStopSrvReq 21 | EmergencyStopSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/DeleteMowingAreaSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type DeleteMowingAreaSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | Index uint32 12 | } 13 | 14 | type DeleteMowingAreaSrvRes struct { 15 | msg.Package `ros:"mower_map"` 16 | } 17 | 18 | type DeleteMowingAreaSrv struct { 19 | msg.Package `ros:"mower_map"` 20 | DeleteMowingAreaSrvReq 21 | DeleteMowingAreaSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/types/docker.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "github.com/docker/docker/api/types" 6 | "io" 7 | ) 8 | 9 | type IDockerProvider interface { 10 | ContainerList(ctx context.Context) ([]types.Container, error) 11 | ContainerLogs(ctx context.Context, containerID string) (io.ReadCloser, error) 12 | ContainerStart(ctx context.Context, containerID string) error 13 | ContainerStop(ctx context.Context, containerID string) error 14 | ContainerRestart(ctx context.Context, containerID string) error 15 | } 16 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/GetMowingAreaSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type GetMowingAreaSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | Index uint32 12 | } 13 | 14 | type GetMowingAreaSrvRes struct { 15 | msg.Package `ros:"mower_map"` 16 | Area MapArea 17 | } 18 | 19 | type GetMowingAreaSrv struct { 20 | msg.Package `ros:"mower_map"` 21 | GetMowingAreaSrvReq 22 | GetMowingAreaSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/MowerControlSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type MowerControlSrvReq struct { 10 | msg.Package `ros:"mower_msgs"` 11 | MowEnabled uint8 12 | MowDirection uint8 13 | } 14 | 15 | type MowerControlSrvRes struct { 16 | msg.Package `ros:"mower_msgs"` 17 | } 18 | 19 | type MowerControlSrv struct { 20 | msg.Package `ros:"mower_msgs"` 21 | MowerControlSrvReq 22 | MowerControlSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/Reconfigure.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ReconfigureReq struct { 10 | msg.Package `ros:"dynamic_reconfigure"` 11 | Config Config 12 | } 13 | 14 | type ReconfigureRes struct { 15 | msg.Package `ros:"dynamic_reconfigure"` 16 | Config Config 17 | } 18 | 19 | type Reconfigure struct { 20 | msg.Package `ros:"dynamic_reconfigure"` 21 | ReconfigureReq 22 | ReconfigureRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/PerimeterControlSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type PerimeterControlSrvReq struct { 10 | msg.Package `ros:"mower_msgs"` 11 | ListenOn uint8 `rosname:"listenOn"` 12 | } 13 | 14 | type PerimeterControlSrvRes struct { 15 | msg.Package `ros:"mower_msgs"` 16 | } 17 | 18 | type PerimeterControlSrv struct { 19 | msg.Package `ros:"mower_msgs"` 20 | PerimeterControlSrvReq 21 | PerimeterControlSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/RegisterActionsSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type RegisterActionsSrvReq struct { 10 | msg.Package `ros:"xbot_msgs"` 11 | NodePrefix string 12 | Actions []ActionInfo 13 | } 14 | 15 | type RegisterActionsSrvRes struct { 16 | msg.Package `ros:"xbot_msgs"` 17 | } 18 | 19 | type RegisterActionsSrv struct { 20 | msg.Package `ros:"xbot_msgs"` 21 | RegisterActionsSrvReq 22 | RegisterActionsSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [ 7 | react({ 8 | jsxImportSource: '@welldone-software/why-did-you-render', // <----- 9 | }), 10 | ], 11 | server: { 12 | host: '0.0.0.0', 13 | proxy: { 14 | '/api': { 15 | target: 'http://localhost:4006', 16 | ws: true, 17 | } 18 | } 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /pkg/msgs/dynamic_reconfigure/SensorLevels.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package dynamic_reconfigure 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | const ( 10 | SensorLevels_RECONFIGURE_CLOSE int8 = 3 11 | SensorLevels_RECONFIGURE_STOP int8 = 1 12 | SensorLevels_RECONFIGURE_RUNNING int8 = 0 13 | ) 14 | 15 | type SensorLevels struct { 16 | msg.Package `ros:"dynamic_reconfigure"` 17 | msg.Definitions `ros:"byte RECONFIGURE_CLOSE=3,byte RECONFIGURE_STOP=1,byte RECONFIGURE_RUNNING=0"` 18 | } 19 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/ConvertToNavigationAreaSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type ConvertToNavigationAreaSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | Index uint32 12 | } 13 | 14 | type ConvertToNavigationAreaSrvRes struct { 15 | msg.Package `ros:"mower_map"` 16 | } 17 | 18 | type ConvertToNavigationAreaSrv struct { 19 | msg.Package `ros:"mower_map"` 20 | ConvertToNavigationAreaSrvReq 21 | ConvertToNavigationAreaSrvRes 22 | } 23 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/SetNavPointSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type SetNavPointSrvReq struct { 11 | msg.Package `ros:"mower_map"` 12 | NavPose geometry_msgs.Pose 13 | } 14 | 15 | type SetNavPointSrvRes struct { 16 | msg.Package `ros:"mower_map"` 17 | } 18 | 19 | type SetNavPointSrv struct { 20 | msg.Package `ros:"mower_map"` 21 | SetNavPointSrvReq 22 | SetNavPointSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/AddMowingAreaSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type AddMowingAreaSrvReq struct { 10 | msg.Package `ros:"mower_map"` 11 | Area MapArea 12 | IsNavigationArea bool `rosname:"isNavigationArea"` 13 | } 14 | 15 | type AddMowingAreaSrvRes struct { 16 | msg.Package `ros:"mower_map"` 17 | } 18 | 19 | type AddMowingAreaSrv struct { 20 | msg.Package `ros:"mower_map"` 21 | AddMowingAreaSrvReq 22 | AddMowingAreaSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/MapAreas.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type MapAreas struct { 10 | msg.Package `ros:"mower_map"` 11 | MapWidth float64 `rosname:"mapWidth"` 12 | MapHeight float64 `rosname:"mapHeight"` 13 | MapCenterX float64 `rosname:"mapCenterX"` 14 | MapCenterY float64 `rosname:"mapCenterY"` 15 | NavigationAreas []MapArea `rosname:"navigationAreas"` 16 | MowingAreas []MapArea `rosname:"mowingAreas"` 17 | } 18 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/GetDockingPointSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type GetDockingPointSrvReq struct { 11 | msg.Package `ros:"mower_map"` 12 | } 13 | 14 | type GetDockingPointSrvRes struct { 15 | msg.Package `ros:"mower_map"` 16 | DockingPose geometry_msgs.Pose 17 | } 18 | 19 | type GetDockingPointSrv struct { 20 | msg.Package `ros:"mower_map"` 21 | GetDockingPointSrvReq 22 | GetDockingPointSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /pkg/msgs/mower_map/SetDockingPointSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_map 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | ) 9 | 10 | type SetDockingPointSrvReq struct { 11 | msg.Package `ros:"mower_map"` 12 | DockingPose geometry_msgs.Pose 13 | } 14 | 15 | type SetDockingPointSrvRes struct { 16 | msg.Package `ros:"mower_map"` 17 | } 18 | 19 | type SetDockingPointSrv struct { 20 | msg.Package `ros:"mower_map"` 21 | SetDockingPointSrvReq 22 | SetDockingPointSrvRes 23 | } 24 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/RobotState.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type RobotState struct { 10 | msg.Package `ros:"xbot_msgs"` 11 | BatteryPercentage float32 12 | Emergency bool 13 | IsCharging bool 14 | GpsPercentage float32 15 | CurrentActionProgress float32 16 | CurrentState string 17 | CurrentSubState string 18 | CurrentArea int16 19 | CurrentPath int16 20 | CurrentPathIndex int16 21 | RobotPose AbsolutePose 22 | } 23 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | # Install basic development tools 4 | RUN apt update && apt install -y less man-db sudo wget 5 | 6 | RUN wget https://go.dev/dl/go1.22.4.linux-amd64.tar.gz 7 | RUN rm -rf /usr/local/go && tar -C /usr/local -xzf go1.22.4.linux-amd64.tar.gz && rm -f go1.22.4.linux-amd64.tar.gz 8 | 9 | # Ensure default `node` user has access to `sudo` 10 | ARG USERNAME=node 11 | RUN echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ 12 | && chmod 0440 /etc/sudoers.d/$USERNAME 13 | 14 | # Set `DEVCONTAINER` environment variable to help with orientation 15 | ENV DEVCONTAINER=true 16 | 17 | ENV PATH=$PATH:/usr/local/go/bin -------------------------------------------------------------------------------- /pkg/api/types.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type OkResponse struct { 4 | Ok string `json:"ok,omitempty"` 5 | } 6 | type ErrorResponse struct { 7 | Error string `json:"error,omitempty"` 8 | } 9 | 10 | type GetSettingsResponse struct { 11 | Settings map[string]string `json:"settings,omitempty"` 12 | } 13 | 14 | type GetConfigResponse struct { 15 | TileUri string `json:"tileUri"` 16 | } 17 | 18 | type Container struct { 19 | ID string `json:"id"` 20 | Names []string `json:"names"` 21 | Labels map[string]string `json:"labels"` 22 | State string `json:"state"` 23 | } 24 | 25 | type ContainerListResponse struct { 26 | Containers []Container `json:"containers"` 27 | } 28 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/Map.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | type Map struct { 10 | msg.Package `ros:"xbot_msgs"` 11 | MapWidth float64 `rosname:"mapWidth"` 12 | MapHeight float64 `rosname:"mapHeight"` 13 | MapCenterX float64 `rosname:"mapCenterX"` 14 | MapCenterY float64 `rosname:"mapCenterY"` 15 | NavigationAreas []MapArea `rosname:"navigationAreas"` 16 | WorkingArea []MapArea `rosname:"workingArea"` 17 | DockX float64 `rosname:"dockX"` 18 | DockY float64 `rosname:"dockY"` 19 | DockHeading float64 `rosname:"dockHeading"` 20 | } 21 | -------------------------------------------------------------------------------- /web/src/components/WheelTicksComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Col, Row, Statistic} from "antd"; 2 | import {useWheelTicks} from "../hooks/useWheelTicks.ts"; 3 | 4 | export function WheelTicksComponent() { 5 | const wheelTicks = useWheelTicks(); 6 | return 7 | 8 | 9 | 10 | 11 | ; 12 | } -------------------------------------------------------------------------------- /openmower-gui.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": [ 24 | "src" 25 | ], 26 | "references": [{ "path": "./tsconfig.node.json"}] 27 | } 28 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | root: true, 5 | env: { browser: true, es2020: true }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 10 | 'plugin:react-hooks/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | project: true, 17 | tsconfigRootDir: __dirname, 18 | }, 19 | plugins: ['react-refresh'], 20 | rules: { 21 | 'react-refresh/only-export-components': [ 22 | 'warn', 23 | { allowConstantExport: true }, 24 | ], 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /web/src/hooks/useImu.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {Imu} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const useImu = () => { 6 | const [imu, setImu] = useState({}) 7 | const imuStream = useWS(() => { 8 | console.log({ 9 | message: "IMU Stream closed", 10 | }) 11 | }, () => { 12 | console.log({ 13 | message: "IMU Stream connected", 14 | }) 15 | }, 16 | (e) => { 17 | setImu(JSON.parse(e)) 18 | }) 19 | useEffect(() => { 20 | imuStream.start("/api/openmower/subscribe/imu",) 21 | return () => { 22 | imuStream.stop() 23 | } 24 | }, []); 25 | return imu; 26 | }; -------------------------------------------------------------------------------- /web/src/hooks/useGPS.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {AbsolutePose} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const useGPS = () => { 6 | const [gps, setGps] = useState({}) 7 | const gpsStream = useWS(() => { 8 | console.log({ 9 | message: "GPS Stream closed", 10 | 11 | }) 12 | }, () => { 13 | console.log({ 14 | message: "GPS Stream connected", 15 | }) 16 | }, 17 | (e) => { 18 | setGps(JSON.parse(e)) 19 | }) 20 | useEffect(() => { 21 | gpsStream.start("/api/openmower/subscribe/gps",) 22 | return () => { 23 | gpsStream.stop() 24 | } 25 | }, []); 26 | return gps; 27 | }; -------------------------------------------------------------------------------- /web/src/hooks/usePose.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {AbsolutePose} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const usePose = () => { 6 | const [pose, setPose] = useState({}) 7 | const poseStream = useWS(() => { 8 | console.log({ 9 | message: "POSE Stream closed", 10 | 11 | }) 12 | }, () => { 13 | console.log({ 14 | message: "POSE Stream connected", 15 | }) 16 | }, 17 | (e) => { 18 | setPose(JSON.parse(e)) 19 | }) 20 | useEffect(() => { 21 | poseStream.start("/api/openmower/subscribe/pose",) 22 | return () => { 23 | poseStream.stop() 24 | } 25 | }, []); 26 | return pose; 27 | }; -------------------------------------------------------------------------------- /web/src/hooks/useStatus.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {Status} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const useStatus = () => { 6 | const [status, setStatus] = useState({}) 7 | const statusStream = useWS(() => { 8 | console.log({ 9 | message: "Status Stream closed", 10 | }) 11 | }, () => { 12 | console.log({ 13 | message: "Status Stream connected", 14 | }) 15 | }, 16 | (e) => { 17 | setStatus(JSON.parse(e)) 18 | }) 19 | useEffect(() => { 20 | statusStream.start("/api/openmower/subscribe/status",) 21 | return () => { 22 | statusStream.stop() 23 | } 24 | }, []); 25 | return status; 26 | }; -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | watchtower: 5 | image: containrrr/watchtower 6 | restart: unless-stopped 7 | volumes: 8 | - /var/run/docker.sock:/var/run/docker.sock 9 | gui: 10 | container_name: openmower-gui 11 | labels: 12 | "com.centurylinklabs.watchtower.enable": true 13 | project: openmower 14 | app: gui 15 | image: ghcr.io/cedbossneo/openmower-gui:master 16 | restart: unless-stopped 17 | network_mode: host 18 | privileged: true 19 | environment: 20 | ROS_MASTER_URI: http://localhost:11311 21 | MOWER_CONFIG_FILE: /config/mower_config.sh 22 | DOCKER_HOST: unix:///var/run/docker.sock 23 | volumes: 24 | - /dev:/dev 25 | - ./db:/app/db 26 | - ./config/om:/config 27 | - /var/run/docker.sock:/var/run/docker.sock 28 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | OpenMower GUI 10 | 11 | 12 |
13 | 14 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /web/src/hooks/useWheelTicks.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {WheelTick} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const useWheelTicks = () => { 6 | const [wheelTicks, setWheelTicks] = useState({}) 7 | const ticksStream = useWS(() => { 8 | console.log({ 9 | message: "Wheel Ticks Stream closed", 10 | }) 11 | }, () => { 12 | console.log({ 13 | message: "Wheel Ticks Stream connected", 14 | }) 15 | }, 16 | (e) => { 17 | setWheelTicks(JSON.parse(e)) 18 | }) 19 | useEffect(() => { 20 | ticksStream.start("/api/openmower/subscribe/ticks",) 21 | return () => { 22 | ticksStream.stop() 23 | } 24 | }, []); 25 | return wheelTicks; 26 | }; -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/ESCStatus.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | const ( 10 | ESCStatus_ESC_STATUS_DISCONNECTED uint8 = 99 11 | ESCStatus_ESC_STATUS_ERROR uint8 = 100 12 | ESCStatus_ESC_STATUS_STALLED uint8 = 150 13 | ESCStatus_ESC_STATUS_OK uint8 = 200 14 | ESCStatus_ESC_STATUS_RUNNING uint8 = 201 15 | ) 16 | 17 | type ESCStatus struct { 18 | msg.Package `ros:"mower_msgs"` 19 | msg.Definitions `ros:"uint8 ESC_STATUS_DISCONNECTED=99,uint8 ESC_STATUS_ERROR=100,uint8 ESC_STATUS_STALLED=150,uint8 ESC_STATUS_OK=200,uint8 ESC_STATUS_RUNNING=201"` 20 | Status uint8 21 | Current float32 22 | Tacho uint32 23 | Rpm int16 24 | TemperatureMotor float32 25 | TemperaturePcb float32 26 | } 27 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/WheelTick.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "time" 8 | ) 9 | 10 | const ( 11 | WheelTick_WHEEL_VALID_FL uint8 = 1 12 | WheelTick_WHEEL_VALID_FR uint8 = 2 13 | WheelTick_WHEEL_VALID_RL uint8 = 4 14 | WheelTick_WHEEL_VALID_RR uint8 = 8 15 | ) 16 | 17 | type WheelTick struct { 18 | msg.Package `ros:"xbot_msgs"` 19 | msg.Definitions `ros:"uint8 WHEEL_VALID_FL=1,uint8 WHEEL_VALID_FR=2,uint8 WHEEL_VALID_RL=4,uint8 WHEEL_VALID_RR=8"` 20 | Stamp time.Time 21 | WheelTickFactor uint32 22 | ValidWheels uint8 23 | WheelDirectionFl uint8 24 | WheelTicksFl uint32 25 | WheelDirectionFr uint8 26 | WheelTicksFr uint32 27 | WheelDirectionRl uint8 28 | WheelTicksRl uint32 29 | WheelDirectionRr uint8 30 | WheelTicksRr uint32 31 | } 32 | -------------------------------------------------------------------------------- /web/src/hooks/useEnv.tsx: -------------------------------------------------------------------------------- 1 | import {useApi} from "./useApi.ts"; 2 | import {App} from "antd"; 3 | import {useEffect, useState} from "react"; 4 | 5 | export const useEnv = () => { 6 | const guiApi = useApi() 7 | const {notification} = App.useApp(); 8 | const [env, setEnv] = useState>({}) 9 | useEffect(() => { 10 | (async () => { 11 | try { 12 | const envs = await guiApi.config.envsList() 13 | if (envs.error) { 14 | throw new Error(envs.error.error ?? "") 15 | } 16 | setEnv(envs.data) 17 | } catch (e: any) { 18 | notification.error({ 19 | message: "Failed to load config", 20 | description: e.message, 21 | }) 22 | } 23 | })() 24 | }, []) 25 | return env 26 | } -------------------------------------------------------------------------------- /.github/workflows/clear-cache.yml: -------------------------------------------------------------------------------- 1 | name: Clear cache 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | actions: write 8 | 9 | jobs: 10 | clear-cache: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Clear cache 14 | uses: actions/github-script@v6 15 | with: 16 | script: | 17 | console.log("About to clear") 18 | const caches = await github.rest.actions.getActionsCacheList({ 19 | owner: context.repo.owner, 20 | repo: context.repo.repo, 21 | }) 22 | for (const cache of caches.data.actions_caches) { 23 | console.log(cache) 24 | github.rest.actions.deleteActionsCacheById({ 25 | owner: context.repo.owner, 26 | repo: context.repo.repo, 27 | cache_id: cache.id, 28 | }) 29 | } 30 | console.log("Clear completed") -------------------------------------------------------------------------------- /pkg/providers/ublox.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "golang.org/x/sys/execabs" 5 | "golang.org/x/xerrors" 6 | "io" 7 | ) 8 | 9 | type UbloxProvider struct { 10 | } 11 | 12 | func NewUbloxProvider() *UbloxProvider { 13 | u := &UbloxProvider{} 14 | return u 15 | } 16 | 17 | func (fp *UbloxProvider) FlashGPS(writer io.Writer) error { 18 | //Build firmware 19 | _, _ = writer.Write([]byte("------> Uploading GPS configuration...\n")) 20 | cmd := execabs.Command("/bin/bash", "-c", "ubxload --port /dev/gps --baudrate 115200 --timeout 0.05 --infile Robot.txt.set.ubx --verbosity 3") 21 | cmd.Dir = "/app/setup" 22 | cmd.Stdout = writer 23 | cmd.Stderr = writer 24 | err := cmd.Run() 25 | if err != nil { 26 | _, _ = writer.Write([]byte("------> Error while building and uploading firmware: " + err.Error() + "\n")) 27 | return xerrors.Errorf("error while flashing gps: %w", err) 28 | } 29 | _, _ = writer.Write([]byte("------> GPS flashed\n")) 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /web/src/components/AsyncSwitch.tsx: -------------------------------------------------------------------------------- 1 | import {Switch, SwitchProps} from "antd"; 2 | import * as React from "react"; 3 | 4 | export const AsyncSwitch: React.FC) => Promise 6 | }> = (props) => { 7 | const {onAsyncChange, ...rest} = props; 8 | const [loading, setLoading] = React.useState(false) 9 | const handleChange = (checked: boolean, event: React.MouseEvent) => { 10 | if (props.onChange !== undefined) { 11 | props.onChange(checked, event) 12 | } else if (onAsyncChange !== undefined) { 13 | setLoading(true) 14 | onAsyncChange(checked, event).then(() => { 15 | setLoading(false) 16 | }).catch(() => { 17 | setLoading(false) 18 | }) 19 | } 20 | } 21 | return 22 | } 23 | 24 | export default AsyncSwitch; -------------------------------------------------------------------------------- /pkg/api/tiles.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/cedbossneo/openmower-gui/pkg/types" 5 | "github.com/gin-gonic/gin" 6 | "net/http" 7 | "net/http/httputil" 8 | "net/url" 9 | ) 10 | 11 | func proxy(dbProvider types.IDBProvider) func(c *gin.Context) { 12 | return func(c *gin.Context) { 13 | tileServer, err := dbProvider.Get("system.map.tileServer") 14 | if err != nil { 15 | panic(err) 16 | } 17 | remote, err := url.Parse(string(tileServer)) 18 | if err != nil { 19 | panic(err) 20 | } 21 | 22 | proxy := httputil.NewSingleHostReverseProxy(remote) 23 | proxy.Director = func(req *http.Request) { 24 | req.Header = c.Request.Header 25 | req.Host = remote.Host 26 | req.URL.Scheme = remote.Scheme 27 | req.URL.Host = remote.Host 28 | req.URL.Path = c.Param("proxyPath") 29 | } 30 | 31 | proxy.ServeHTTP(c.Writer, c.Request) 32 | } 33 | } 34 | func TilesProxy(r *gin.Engine, dbProvider types.IDBProvider) { 35 | r.Any("/tiles/*proxyPath", proxy(dbProvider)) 36 | } 37 | -------------------------------------------------------------------------------- /web/src/hooks/useHighLevelStatus.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {HighLevelStatus} from "../types/ros.ts"; 3 | import {useWS} from "./useWS.ts"; 4 | 5 | export const useHighLevelStatus = () => { 6 | const [highLevelStatus, setHighLevelStatus] = useState({}) 7 | const highLevelStatusStream = useWS(() => { 8 | console.log({ 9 | message: "High Level Status Stream closed", 10 | }) 11 | }, () => { 12 | console.log({ 13 | message: "High Level Status Stream connected", 14 | }) 15 | }, 16 | (e) => { 17 | setHighLevelStatus(JSON.parse(e)) 18 | }) 19 | useEffect(() => { 20 | highLevelStatusStream.start("/api/openmower/subscribe/highLevelStatus",) 21 | return () => { 22 | highLevelStatusStream.stop() 23 | } 24 | }, []); 25 | return {highLevelStatus, stop: highLevelStatusStream.stop, start: highLevelStatusStream.start}; 26 | } -------------------------------------------------------------------------------- /web/src/pages/SettingsPage.tsx: -------------------------------------------------------------------------------- 1 | import {Col, Row, Typography} from "antd"; 2 | import {SettingsComponent} from "../components/SettingsComponent.tsx"; 3 | import {Submit} from "@formily/antd-v5"; 4 | import AsyncButton from "../components/AsyncButton.tsx"; 5 | 6 | export const SettingsPage = () => { 7 | return ( 8 | 9 | Settings 10 | 11 | 12 | { 13 | return [ 14 | Save settings, 15 | Restart OpenMower, 16 | Restart GUI 17 | ] 18 | }}/> 19 | 20 | 21 | ) 22 | } 23 | 24 | export default SettingsPage; -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/cedbossneo/openmower-gui/pkg/api" 5 | "github.com/cedbossneo/openmower-gui/pkg/providers" 6 | "github.com/joho/godotenv" 7 | ) 8 | 9 | func main() { 10 | _ = godotenv.Load() 11 | 12 | dbProvider := providers.NewDBProvider() 13 | dockerProvider := providers.NewDockerProvider() 14 | rosProvider := providers.NewRosProvider(dbProvider) 15 | firmwareProvider := providers.NewFirmwareProvider(dbProvider) 16 | ubloxProvider := providers.NewUbloxProvider() 17 | homekitEnabled, err := dbProvider.Get("system.homekit.enabled") 18 | if err != nil { 19 | panic(err) 20 | } 21 | if string(homekitEnabled) == "true" { 22 | providers.NewHomeKitProvider(rosProvider, dbProvider) 23 | } 24 | mqttEnabled, err := dbProvider.Get("system.mqtt.enabled") 25 | if err != nil { 26 | panic(err) 27 | } 28 | if string(mqttEnabled) == "true" { 29 | providers.NewMqttProvider(rosProvider, dbProvider) 30 | } 31 | api.NewAPI(dbProvider, dockerProvider, rosProvider, firmwareProvider, ubloxProvider) 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/AsyncDropDownButton.tsx: -------------------------------------------------------------------------------- 1 | import {Dropdown} from "antd"; 2 | import * as React from "react"; 3 | import {DropdownButtonProps} from "antd/es/dropdown"; 4 | 5 | export const AsyncDropDownButton: React.FC Promise 8 | } 9 | }> = (props) => { 10 | const [loading, setLoading] = React.useState(false) 11 | const handleClick = (event: any) => { 12 | if (props.menu.onAsyncClick !== undefined) { 13 | setLoading(true) 14 | props.menu.onAsyncClick(event).then(() => { 15 | setLoading(false) 16 | }).catch(() => { 17 | setLoading(false) 18 | }) 19 | } 20 | } 21 | const {menu, ...rest} = props 22 | return {props.children} 26 | } 27 | 28 | export default AsyncDropDownButton; -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/Status.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "time" 8 | ) 9 | 10 | const ( 11 | Status_MOWER_STATUS_INITIALIZING uint8 = 0 12 | Status_MOWER_STATUS_OK uint8 = 255 13 | ) 14 | 15 | type Status struct { 16 | msg.Package `ros:"mower_msgs"` 17 | msg.Definitions `ros:"uint8 MOWER_STATUS_INITIALIZING=0,uint8 MOWER_STATUS_OK=255"` 18 | Stamp time.Time 19 | MowerStatus uint8 20 | RaspberryPiPower bool 21 | GpsPower bool 22 | EscPower bool 23 | RainDetected bool 24 | SoundModuleAvailable bool 25 | SoundModuleBusy bool 26 | UiBoardAvailable bool 27 | UltrasonicRanges [5]float32 28 | Emergency bool 29 | VCharge float32 30 | VBattery float32 31 | ChargeCurrent float32 32 | MowEnabled bool 33 | LeftEscStatus ESCStatus 34 | RightEscStatus ESCStatus 35 | MowEscStatus ESCStatus 36 | } 37 | -------------------------------------------------------------------------------- /pkg/api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/mitchellh/mapstructure" 6 | "io" 7 | "strings" 8 | "unicode" 9 | ) 10 | 11 | func snakeToCamel(in string) string { 12 | tmp := []rune(in) 13 | tmp[0] = unicode.ToUpper(tmp[0]) 14 | for i := 0; i < len(tmp); i++ { 15 | if tmp[i] == '_' { 16 | tmp[i+1] = unicode.ToUpper(tmp[i+1]) 17 | tmp = append(tmp[:i], tmp[i+1:]...) 18 | i-- 19 | } 20 | } 21 | return string(tmp) 22 | } 23 | 24 | func unmarshalROSMessage[T any](reader io.ReadCloser, out T) error { 25 | var m map[string]interface{} 26 | all, err := io.ReadAll(reader) 27 | if err != nil { 28 | return err 29 | } 30 | err = json.Unmarshal(all, &m) 31 | if err != nil { 32 | return err 33 | } 34 | decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 35 | Result: out, 36 | MatchName: func(mapKey, fieldName string) bool { 37 | return strings.ToLower(fieldName) == strings.ToLower(mapKey) 38 | }, 39 | }) 40 | err = decoder.Decode(m) 41 | if err != nil { 42 | return err 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/HighLevelControlSrv.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | const ( 10 | HighLevelControlSrvReq_COMMAND_START uint8 = 1 11 | HighLevelControlSrvReq_COMMAND_HOME uint8 = 2 12 | HighLevelControlSrvReq_COMMAND_S1 uint8 = 3 13 | HighLevelControlSrvReq_COMMAND_S2 uint8 = 4 14 | HighLevelControlSrvReq_COMMAND_RESET_EMERGENCY uint8 = 254 15 | HighLevelControlSrvReq_COMMAND_DELETE_MAPS uint8 = 255 16 | ) 17 | 18 | type HighLevelControlSrvReq struct { 19 | msg.Package `ros:"mower_msgs"` 20 | msg.Definitions `ros:"uint8 COMMAND_START=1,uint8 COMMAND_HOME=2,uint8 COMMAND_S1=3,uint8 COMMAND_S2=4,uint8 COMMAND_RESET_EMERGENCY=254,uint8 COMMAND_DELETE_MAPS=255"` 21 | Command uint8 22 | } 23 | 24 | type HighLevelControlSrvRes struct { 25 | msg.Package `ros:"mower_msgs"` 26 | } 27 | 28 | type HighLevelControlSrv struct { 29 | msg.Package `ros:"mower_msgs"` 30 | HighLevelControlSrvReq 31 | HighLevelControlSrvRes 32 | } 33 | -------------------------------------------------------------------------------- /web/src/components/MowerStatus.tsx: -------------------------------------------------------------------------------- 1 | import {useHighLevelStatus} from "../hooks/useHighLevelStatus.ts"; 2 | import {Col, Row, Statistic} from "antd"; 3 | import {PoweroffOutlined, WifiOutlined} from "@ant-design/icons" 4 | import {progressFormatterSmall, stateRenderer} from "./utils.tsx"; 5 | 6 | export const MowerStatus = () => { 7 | const {highLevelStatus} = useHighLevelStatus(); 8 | return 9 | 11 | 0 ? "green" : "red"}} 13 | />} 14 | valueStyle={{fontSize: "14px"}} precision={0} 15 | value={(highLevelStatus.GpsQualityPercent ?? 0) * 100} 16 | suffix={"%"}/> 17 | } 19 | valueStyle={{fontSize: "14px"}} precision={2} 20 | value={(highLevelStatus.BatteryPercent ?? 0) * 100} 21 | formatter={progressFormatterSmall}/> 22 | ; 23 | } -------------------------------------------------------------------------------- /web/src/components/utils.tsx: -------------------------------------------------------------------------------- 1 | import {CheckCircleTwoTone, CloseCircleTwoTone} from "@ant-design/icons"; 2 | import {Progress} from "antd"; 3 | 4 | export const booleanFormatter = (value: any) => (value === "On" || value === "Yes") ? 5 | : ; 7 | export const booleanFormatterInverted = (value: any) => (value === "On" || value === "Yes") ? 8 | : ; 10 | export const stateRenderer = (value: string | undefined) => { 11 | switch (value) { 12 | case "IDLE": 13 | return "Idle" 14 | case "MOWING": 15 | return "Mowing" 16 | case "DOCKING": 17 | return "Docking" 18 | case "UNDOCKING": 19 | return "Undocking" 20 | case "AREA_RECORDING": 21 | return "Area Recording" 22 | default: 23 | return "Unknown" 24 | } 25 | }; 26 | export const progressFormatter = (value: any) => { 27 | return 28 | }; 29 | 30 | export const progressFormatterSmall = (value: any) => { 31 | return 32 | }; -------------------------------------------------------------------------------- /pkg/providers/firmware_test.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "github.com/cedbossneo/openmower-gui/pkg/types" 5 | "github.com/stretchr/testify/assert" 6 | "os" 7 | "testing" 8 | ) 9 | 10 | func TestBuildBoard(t *testing.T) { 11 | dbProvider := NewDBProvider() 12 | firmwareProvider := NewFirmwareProvider(dbProvider) 13 | config := types.FirmwareConfig{ 14 | BoardType: "BOARD_YARDFORCE500", 15 | PanelType: "PANEL_TYPE_YARDFORCE_500_CLASSIC", 16 | DebugType: "NONE", 17 | MaxChargeCurrent: 28, 18 | LimitVoltage150MA: 29, 19 | MaxChargeVoltage: 29, 20 | BatChargeCutoffVoltage: 29, 21 | OneWheelLiftEmergencyMillis: 10000, 22 | BothWheelsLiftEmergencyMillis: 100, 23 | TiltEmergencyMillis: 1000, 24 | StopButtonEmergencyMillis: 100, 25 | PlayButtonClearEmergencyMillis: 1000, 26 | ExternalImuAcceleration: true, 27 | ExternalImuAngular: true, 28 | MasterJ18: true, 29 | MaxMps: 0.6, 30 | } 31 | res, err := firmwareProvider.buildBoardHeader("./asserts/board.h.template", config) 32 | assert.NoError(t, err) 33 | file, err := os.ReadFile("./asserts/board.h") 34 | assert.Equal(t, string(file), string(res)) 35 | } 36 | -------------------------------------------------------------------------------- /web/src/components/AsyncButton.tsx: -------------------------------------------------------------------------------- 1 | import {App, Button, ButtonProps} from "antd"; 2 | import * as React from "react"; 3 | 4 | 5 | export const AsyncButton: React.FC & React.MouseEvent) => Promise 7 | }> = (props) => { 8 | 9 | const {notification} = App.useApp(); 10 | const {onAsyncClick, ...rest} = props; 11 | const [loading, setLoading] = React.useState(false) 12 | const handleClick = (event: React.MouseEvent & React.MouseEvent) => { 13 | if (props.onChange !== undefined) { 14 | props.onChange(event) 15 | } else if (onAsyncClick !== undefined) { 16 | setLoading(true) 17 | onAsyncClick(event).then(() => { 18 | setLoading(false) 19 | }).catch((e) => { 20 | setLoading(false) 21 | if (console.error) 22 | console.error(e); 23 | notification.error({ 24 | message: 'An error occured', 25 | description: e.message, 26 | }) 27 | }) 28 | } 29 | } 30 | return 31 | } 32 | 33 | export default AsyncButton; -------------------------------------------------------------------------------- /pkg/msgs/mower_msgs/HighLevelStatus.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package mower_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | const ( 10 | HighLevelStatus_HIGH_LEVEL_STATE_NULL uint8 = 0 11 | HighLevelStatus_HIGH_LEVEL_STATE_IDLE uint8 = 1 12 | HighLevelStatus_HIGH_LEVEL_STATE_AUTONOMOUS uint8 = 2 13 | HighLevelStatus_HIGH_LEVEL_STATE_RECORDING uint8 = 3 14 | HighLevelStatus_SUBSTATE_1 uint8 = 0 15 | HighLevelStatus_SUBSTATE_2 uint8 = 1 16 | HighLevelStatus_SUBSTATE_3 uint8 = 2 17 | HighLevelStatus_SUBSTATE_4 uint8 = 3 18 | HighLevelStatus_SUBSTATE_SHIFT uint8 = 6 19 | ) 20 | 21 | type HighLevelStatus struct { 22 | msg.Package `ros:"mower_msgs"` 23 | msg.Definitions `ros:"uint8 HIGH_LEVEL_STATE_NULL=0,uint8 HIGH_LEVEL_STATE_IDLE=1,uint8 HIGH_LEVEL_STATE_AUTONOMOUS=2,uint8 HIGH_LEVEL_STATE_RECORDING=3,uint8 SUBSTATE_1=0,uint8 SUBSTATE_2=1,uint8 SUBSTATE_3=2,uint8 SUBSTATE_4=3,uint8 SUBSTATE_SHIFT=6"` 24 | State uint8 25 | StateName string 26 | SubStateName string 27 | CurrentArea int16 28 | CurrentPath int16 29 | CurrentPathIndex int16 30 | GpsQualityPercent float32 31 | BatteryPercent float32 32 | IsCharging bool 33 | Emergency bool 34 | } 35 | -------------------------------------------------------------------------------- /mower_config.sh: -------------------------------------------------------------------------------- 1 | export OM_MQTT_HOSTNAME="your_mqtt_broker" 2 | export OM_PERIMETER_SIGNAL="False" 3 | export OM_MOWING_ANGLE_INCREMENT="0.1" 4 | export OM_GPS_PROTOCOL="UBX" 5 | export OM_DATUM_LONG="2.1661984" 6 | export OM_BATTERY_EMPTY_VOLTAGE="23.0" 7 | export OM_MOWING_MOTOR_TEMP_HIGH="80.0" 8 | export OM_MOWING_MOTOR_TEMP_LOW="40.0" 9 | export OM_NTRIP_PORT="2101" 10 | export OM_MQTT_USER="" 11 | export OM_MQTT_ENABLE="False" 12 | export OM_UNDOCK_DISTANCE="1.0" 13 | export OM_OUTLINE_COUNT="4" 14 | export OM_AUTOMATIC_MODE="0" 15 | export OM_BATTERY_CAPACITY_MAH="3000" 16 | export OM_USE_NTRIP="True" 17 | export OM_NTRIP_USER="centipede" 18 | export OM_USE_F9R_SENSOR_FUSION="False" 19 | export OM_MOWER_GAMEPAD="xbox360" 20 | export OM_USE_RELATIVE_POSITION="False" 21 | export OM_NTRIP_HOSTNAME="caster.centipede.fr" 22 | export OM_NTRIP_ENDPOINT="OUIL" 23 | export OM_GPS_TIMEOUT_SEC="5.0" 24 | export OM_ENABLE_MOWER="True" 25 | export OM_MQTT_PORT="1883" 26 | export OM_MOWING_ANGLE_OFFSET_IS_ABSOLUTE="False" 27 | export OM_DOCKING_DISTANCE="1.0" 28 | export OM_DOCKING_EXTRA_TIME="0.0" 29 | export OM_BATTERY_FULL_VOLTAGE="28.0" 30 | export OM_MQTT_PASSWORD="" 31 | export OM_NTRIP_PASSWORD="centipede" 32 | export OM_OUTLINE_OFFSET="0.05" 33 | export OM_ENABLE_RECORDING_ALL="False" 34 | export OM_DATUM_LAT="48.883195238" 35 | export OM_TOOL_WIDTH="0.13" 36 | export OM_GPS_WAIT_TIME_SEC="10.0" 37 | export OM_MOWING_ANGLE_OFFSET="0" 38 | -------------------------------------------------------------------------------- /web/src/utils/map.tsx: -------------------------------------------------------------------------------- 1 | import {Quaternion} from "../types/ros.ts"; 2 | import {Converter} from 'usng.js' 3 | 4 | // @ts-ignore 5 | export var converter = new Converter(); 6 | export const earth = 6371008.8; //radius of the earth in kilometer 7 | export const pi = Math.PI; 8 | 9 | export function getQuaternionFromHeading(heading: number): Quaternion { 10 | const q = { 11 | X: 0, 12 | Y: 0, 13 | Z: 0, 14 | W: 0, 15 | } as Quaternion 16 | q.W = Math.cos(heading / 2) 17 | q.Z = Math.sin(heading / 2) 18 | return q 19 | } 20 | 21 | export function drawLine(offsetX: number, offsetY: number, datum: [number, number, number], y: number, x: number, orientation: number): [number, number] { 22 | const endX = x + Math.cos(orientation); 23 | const endY = y + Math.sin(orientation); 24 | return transpose(offsetX, offsetY, datum, endY, endX); 25 | } 26 | 27 | export const transpose = (offsetX: number, offsetY: number, datum: [number, number, number], y: number, x: number): [number, number] => { 28 | let utMtoLL = converter.UTMtoLL(datum[1] + y + offsetY, datum[0] + x + offsetX, datum[2]); 29 | return [utMtoLL.lon, utMtoLL.lat] 30 | }; 31 | export const itranspose = (offsetX: number, offsetY: number, datum: [number, number, number], y: number, x: number): [number, number] => { 32 | //Inverse the transpose function 33 | const coords: [number, number, number] = [0, 0, 0] 34 | converter.LLtoUTM(y, x, coords) 35 | return [coords[0] - datum[0] - offsetX, coords[1] - datum[1] - offsetY] 36 | }; -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.21 as build-go 2 | 3 | COPY . /app 4 | WORKDIR /app 5 | RUN CGO_ENABLED=0 go build -o openmower-gui 6 | 7 | FROM node:18 as build-web 8 | COPY ./web /web 9 | WORKDIR /web 10 | RUN yarn && yarn build 11 | 12 | FROM ubuntu:22.04 as deps 13 | RUN apt-get update && apt-get install -y ca-certificates curl python3 python3-pip python3-venv libjim-dev\ 14 | git build-essential unzip wget autoconf automake pkg-config texinfo libtool libftdi-dev libusb-1.0-0-dev 15 | RUN apt-get install -y rpi.gpio-common || true 16 | RUN git clone --recursive --branch rpi-common --depth=1 https://github.com/raspberrypi/openocd.git 17 | RUN cd openocd && ./bootstrap with-submodules && ./configure --enable-ftdi --enable-sysfsgpio --enable-bcm2835gpio && make -j$(nproc) && make install && cd .. && rm -rf openocd 18 | RUN curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o get-platformio.py && python3 get-platformio.py 19 | RUN python3 -m pip install --upgrade pygnssutils 20 | RUN mkdir -p /usr/local/bin && ln -s ~/.platformio/penv/bin/platformio /usr/local/bin/platformio && ln -s ~/.platformio/penv/bin/pio /usr/local/bin/pio && ln -s ~/.platformio/penv/bin/piodebuggdb /usr/local/bin/piodebuggdb 21 | 22 | FROM deps 23 | COPY ./setup /app/setup 24 | COPY --from=build-web /web/dist /app/web 25 | COPY --from=build-go /app/openmower-gui /app/openmower-gui 26 | ENV WEB_DIR=/app/web 27 | ENV DB_PATH=/app/db 28 | WORKDIR /app 29 | CMD ["/app/openmower-gui"] 30 | -------------------------------------------------------------------------------- /web/src/pages/OpenMowerPage.tsx: -------------------------------------------------------------------------------- 1 | import {Card, Col, Row, Typography} from "antd"; 2 | import {MowerActions} from "../components/MowerActions.tsx"; 3 | import {StatusComponent} from "../components/StatusComponent.tsx"; 4 | import {HighLevelStatusComponent} from "../components/HighLevelStatusComponent.tsx"; 5 | import {ImuComponent} from "../components/ImuComponent.tsx"; 6 | import {WheelTicksComponent} from "../components/WheelTicksComponent.tsx"; 7 | import {GpsComponent} from "../components/GpsComponent.tsx"; 8 | 9 | export const OpenMowerPage = () => { 10 | return 11 | 12 | OpenMower 13 | 14 | 15 | 16 | 17 | 18 | 19 | {} 20 | 21 | 22 | 23 | {} 24 | 25 | 26 | 27 | {} 28 | 29 | 30 | 31 | 32 | {} 33 | 34 | 35 | 36 | 37 | {} 38 | 39 | 40 | 41 | } 42 | 43 | export default OpenMowerPage; -------------------------------------------------------------------------------- /web/src/components/ImuComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Col, Row, Statistic} from "antd"; 2 | import {useImu} from "../hooks/useImu.ts"; 3 | 4 | export function ImuComponent() { 5 | const imu = useImu(); 6 | return 7 | 9 | 11 | 13 | 15 | 17 | 19 | 20 | 21 | 22 | ; 23 | } -------------------------------------------------------------------------------- /web/src/hooks/useWS.ts: -------------------------------------------------------------------------------- 1 | import useWebSocket from "react-use-websocket"; 2 | import {useState} from "react"; 3 | 4 | export const useWS = (onError: (e: Error) => void, onInfo: (msg: string) => void, onData: (data: T, first?: boolean) => void) => { 5 | const [uri, setUri] = useState(null); 6 | const [first, setFirst] = useState(false) 7 | const ws = useWebSocket(uri, { 8 | share: true, 9 | onOpen: () => { 10 | console.log(`Opened stream ${uri}`) 11 | onInfo("Stream connected") 12 | }, 13 | onError: () => { 14 | console.log(`Error on stream ${uri}`) 15 | onError(new Error(`Stream error`)) 16 | }, 17 | onClose: () => { 18 | console.log(`Stream closed ${uri}`) 19 | onError(new Error(`Stream closed`)) 20 | }, 21 | onMessage: (e) => { 22 | if (first) { 23 | setFirst(false) 24 | } 25 | onData(atob(e.data) as T, first); 26 | } 27 | }); 28 | const start = (uri: string) => { 29 | setFirst(true) 30 | const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; 31 | if (import.meta.env.DEV) { 32 | setUri(`${protocol}://localhost:4006${uri}`) 33 | } else { 34 | setUri(`${protocol}://${window.location.host}${uri}`) 35 | } 36 | }; 37 | const stop = () => { 38 | console.log(`Closing stream ${ws.getWebSocket()?.url}`) 39 | setUri(null) 40 | setFirst(false) 41 | } 42 | return {start, stop, sendJsonMessage: ws.sendJsonMessage} 43 | } 44 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/cedbossneo/openmower-gui/docs" 5 | "github.com/cedbossneo/openmower-gui/pkg/providers" 6 | "github.com/cedbossneo/openmower-gui/pkg/types" 7 | "github.com/gin-contrib/cors" 8 | "github.com/gin-contrib/static" 9 | "github.com/gin-gonic/gin" 10 | swaggerfiles "github.com/swaggo/files" 11 | ginSwagger "github.com/swaggo/gin-swagger" 12 | "log" 13 | ) 14 | 15 | // gin-swagger middleware 16 | // swagger embed files 17 | 18 | func NewAPI(dbProvider types.IDBProvider, dockerProvider types.IDockerProvider, rosProvider types.IRosProvider, firmwareProvider *providers.FirmwareProvider, ubloxProvider *providers.UbloxProvider) { 19 | httpAddr, err := dbProvider.Get("system.api.addr") 20 | if err != nil { 21 | log.Fatal(err) 22 | } 23 | 24 | gin.SetMode(gin.ReleaseMode) 25 | docs.SwaggerInfo.BasePath = "/api" 26 | r := gin.Default() 27 | config := cors.DefaultConfig() 28 | config.AllowAllOrigins = true 29 | config.AllowWebSockets = true 30 | r.Use(cors.New(config)) 31 | webDirectory, err := dbProvider.Get("system.api.webDirectory") 32 | if err != nil { 33 | log.Fatal(err) 34 | } 35 | r.Use(static.Serve("/", static.LocalFile(string(webDirectory), false))) 36 | apiGroup := r.Group("/api") 37 | ConfigRoute(apiGroup, dbProvider) 38 | SettingsRoutes(apiGroup, dbProvider) 39 | ContainersRoutes(apiGroup, dockerProvider) 40 | OpenMowerRoutes(apiGroup, rosProvider) 41 | SetupRoutes(apiGroup, firmwareProvider, ubloxProvider) 42 | tileServer, err := dbProvider.Get("system.map.enabled") 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | if string(tileServer) == "true" { 47 | TilesProxy(r, dbProvider) 48 | } 49 | r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 50 | r.Run(string(httpAddr)) 51 | } 52 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/AbsolutePose.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | "github.com/bluenviron/goroslib/v2/pkg/msgs/geometry_msgs" 8 | "github.com/bluenviron/goroslib/v2/pkg/msgs/std_msgs" 9 | ) 10 | 11 | const ( 12 | AbsolutePose_SOURCE_GPS uint8 = 1 13 | AbsolutePose_SOURCE_LIGHTHOUSE uint8 = 2 14 | AbsolutePose_SOURCE_SENSOR_FUSION uint8 = 100 15 | AbsolutePose_FLAG_GPS_RTK uint16 = 1 16 | AbsolutePose_FLAG_GPS_RTK_FIXED uint16 = 2 17 | AbsolutePose_FLAG_GPS_RTK_FLOAT uint16 = 4 18 | AbsolutePose_FLAG_GPS_DEAD_RECKONING uint16 = 8 19 | AbsolutePose_FLAG_SENSOR_FUSION_RECENT_ABSOLUTE_POSE uint16 = 1 20 | AbsolutePose_FLAG_SENSOR_FUSION_DEAD_RECKONING uint16 = 8 21 | ) 22 | 23 | type AbsolutePose struct { 24 | msg.Package `ros:"xbot_msgs"` 25 | msg.Definitions `ros:"uint8 SOURCE_GPS=1,uint8 SOURCE_LIGHTHOUSE=2,uint8 SOURCE_SENSOR_FUSION=100,uint16 FLAG_GPS_RTK=1,uint16 FLAG_GPS_RTK_FIXED=2,uint16 FLAG_GPS_RTK_FLOAT=4,uint16 FLAG_GPS_DEAD_RECKONING=8,uint16 FLAG_SENSOR_FUSION_RECENT_ABSOLUTE_POSE=1,uint16 FLAG_SENSOR_FUSION_DEAD_RECKONING=8"` 26 | Header std_msgs.Header 27 | SensorStamp uint32 28 | ReceivedStamp uint32 29 | Source uint8 30 | Flags uint16 31 | OrientationValid uint8 32 | MotionVectorValid uint8 33 | PositionAccuracy float32 34 | OrientationAccuracy float32 35 | Pose geometry_msgs.PoseWithCovariance 36 | MotionVector geometry_msgs.Vector3 37 | VehicleHeading float64 38 | MotionHeading float64 39 | } 40 | -------------------------------------------------------------------------------- /web/src/hooks/useConfig.tsx: -------------------------------------------------------------------------------- 1 | import {useApi} from "./useApi.ts"; 2 | import {App} from "antd"; 3 | import {useEffect, useState} from "react"; 4 | 5 | export const useConfig = (keys: string[]) => { 6 | const guiApi = useApi() 7 | const {notification} = App.useApp(); 8 | const [config, setConfig] = useState>({}) 9 | const handleSetConfig = async (newConfig: Record) => { 10 | try { 11 | const offsetConfig = await guiApi.config.keysSetCreate(newConfig) 12 | if (offsetConfig.error) { 13 | throw new Error(offsetConfig.error.error ?? "") 14 | } 15 | setConfig(oldConfig => ({ 16 | ...oldConfig, 17 | ...offsetConfig.data 18 | })) 19 | } catch (e: any) { 20 | notification.error({ 21 | message: "Failed to save config", 22 | description: e.message, 23 | }) 24 | } 25 | } 26 | useEffect(() => { 27 | (async () => { 28 | try { 29 | const keysMap: Record = {} 30 | keys.forEach((key) => { 31 | keysMap[key] = "" 32 | }) 33 | const offsetConfig = await guiApi.config.keysGetCreate(keysMap) 34 | if (offsetConfig.error) { 35 | throw new Error(offsetConfig.error.error ?? "") 36 | } 37 | setConfig(offsetConfig.data) 38 | } catch (e: any) { 39 | notification.error({ 40 | message: "Failed to load config", 41 | description: e.message, 42 | }) 43 | } 44 | })() 45 | }, []) 46 | return {config, setConfig: handleSetConfig} 47 | } -------------------------------------------------------------------------------- /pkg/types/firmware.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type IFirmwareProvider interface { 8 | FlashFirmware(writer io.Writer, config FirmwareConfig) error 9 | } 10 | 11 | type FirmwareConfig struct { 12 | File string `json:"file"` 13 | Repository string `json:"repository"` 14 | Branch string `json:"branch"` 15 | Version string `json:"version"` 16 | BoardType string `json:"boardType"` 17 | PanelType string `json:"panelType"` 18 | DebugType string `json:"debugType"` 19 | DisableEmergency bool `json:"disableEmergency"` 20 | MaxMps float32 `json:"maxMps"` 21 | MaxChargeCurrent float32 `json:"maxChargeCurrent"` 22 | LimitVoltage150MA float32 `json:"limitVoltage150MA"` 23 | MaxChargeVoltage float32 `json:"maxChargeVoltage"` 24 | BatChargeCutoffVoltage float32 `json:"batChargeCutoffVoltage"` 25 | OneWheelLiftEmergencyMillis int `json:"oneWheelLiftEmergencyMillis"` 26 | BothWheelsLiftEmergencyMillis int `json:"bothWheelsLiftEmergencyMillis"` 27 | TiltEmergencyMillis int `json:"tiltEmergencyMillis"` 28 | StopButtonEmergencyMillis int `json:"stopButtonEmergencyMillis"` 29 | PlayButtonClearEmergencyMillis int `json:"playButtonClearEmergencyMillis"` 30 | ExternalImuAcceleration bool `json:"externalImuAcceleration"` 31 | ExternalImuAngular bool `json:"externalImuAngular"` 32 | MasterJ18 bool `json:"masterJ18"` 33 | TickPerM float32 `json:"tickPerM"` 34 | WheelBase float32 `json:"wheelBase"` 35 | PerimeterWire bool `json:"perimeterWire"` 36 | } 37 | -------------------------------------------------------------------------------- /pkg/providers/docker.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | types2 "github.com/cedbossneo/openmower-gui/pkg/types" 7 | "github.com/docker/docker/api/types" 8 | docker "github.com/docker/docker/client" 9 | "github.com/sirupsen/logrus" 10 | "io" 11 | ) 12 | 13 | type DockerProvider struct { 14 | client *docker.Client 15 | } 16 | 17 | func NewDockerProvider() types2.IDockerProvider { 18 | client, err := docker.NewClientWithOpts(docker.FromEnv) 19 | if err != nil { 20 | logrus.Error(err) 21 | } 22 | return &DockerProvider{client: client} 23 | } 24 | 25 | func (i *DockerProvider) ContainerList(ctx context.Context) ([]types.Container, error) { 26 | if i.client == nil { 27 | return nil, errors.New("docker client is not initialized") 28 | } 29 | return i.client.ContainerList(ctx, types.ContainerListOptions{ 30 | All: true, 31 | }) 32 | } 33 | 34 | func (i *DockerProvider) ContainerLogs(ctx context.Context, containerID string) (io.ReadCloser, error) { 35 | if i.client == nil { 36 | return nil, errors.New("docker client is not initialized") 37 | } 38 | return i.client.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true, Tail: "100"}) 39 | } 40 | 41 | func (i *DockerProvider) ContainerStart(ctx context.Context, containerID string) error { 42 | if i.client == nil { 43 | return errors.New("docker client is not initialized") 44 | } 45 | return i.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}) 46 | } 47 | 48 | func (i *DockerProvider) ContainerStop(ctx context.Context, containerID string) error { 49 | if i.client == nil { 50 | return errors.New("docker client is not initialized") 51 | } 52 | return i.client.ContainerStop(ctx, containerID, nil) 53 | } 54 | 55 | func (i *DockerProvider) ContainerRestart(ctx context.Context, containerID string) error { 56 | return i.client.ContainerRestart(ctx, containerID, nil) 57 | } 58 | -------------------------------------------------------------------------------- /web/src/main.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/order 2 | import './wdyr'; 3 | import React from 'react' 4 | import ReactDOM from 'react-dom/client' 5 | import {createHashRouter, RouterProvider,} from "react-router-dom"; 6 | import Root from "./routes/root.tsx"; 7 | import SettingsPage from "./pages/SettingsPage.tsx"; 8 | import LogsPage from "./pages/LogsPage.tsx"; 9 | import OpenMowerPage from "./pages/OpenMowerPage.tsx"; 10 | import MapPage from "./pages/MapPage.tsx"; 11 | import SetupPage from "./pages/SetupPage.tsx"; 12 | import {App, Row} from "antd"; 13 | import {MowerStatus} from "./components/MowerStatus.tsx"; 14 | import {Spinner} from "./components/Spinner.tsx"; 15 | 16 | const router = createHashRouter([ 17 | { 18 | path: "/", 19 | element: , 20 | children: [ 21 | { 22 | element: , 23 | path: "/settings", 24 | }, 25 | { 26 | element: , 27 | path: "/logs", 28 | }, 29 | { 30 | element: , 31 | path: "/openmower", 32 | }, 33 | { 34 | element: , 35 | path: "/map", 36 | }, 37 | { 38 | element: , 39 | path: "/setup", 40 | } 41 | ] 42 | }, 43 | ]); 44 | 45 | ReactDOM.createRoot(document.getElementById('root')!).render( 46 | 47 | 48 | 50 | 51 | 52 | }> 53 | 54 | 55 | 56 | , 57 | ) 58 | -------------------------------------------------------------------------------- /web/src/components/GpsComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Col, Row, Statistic} from "antd"; 2 | import {useGPS} from "../hooks/useGPS.ts"; 3 | import { booleanFormatter, booleanFormatterInverted } from "./utils.tsx"; 4 | import { AbsolutePoseFlags as Flags } from "../types/ros.ts"; 5 | 6 | export function GpsComponent() { 7 | const gps = useGPS(); 8 | 9 | const flags = gps.Flags ?? 0; 10 | let fixType = "\u2013"; 11 | if ((flags & Flags.FIXED) != 0) { 12 | fixType = "FIX"; 13 | } else if ((flags & Flags.FLOAT) != 0) { 14 | fixType = "FLOAT"; 15 | } 16 | 17 | return <> 18 | 19 | 21 | 23 | 24 | 26 | 27 | 28 | 29 | 31 | 33 | 35 | 36 | ; 37 | } 38 | -------------------------------------------------------------------------------- /generate_go_msgs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 3 | # Clone the repository https://github.com/ClemensElflein/open_mower_ros in a temporary directory 4 | OM_DIR=/tmp/open_mower_ros 5 | DYN_DIR=/tmp/dynamic_reconfigure 6 | git clone https://github.com/ClemensElflein/open_mower_ros $OM_DIR 7 | git clone https://github.com/ros/dynamic_reconfigure $DYN_DIR 8 | cd $OM_DIR && git submodule update --init --recursive 9 | cd $SCRIPT_DIR 10 | # Use msg-import to import the messages from the temporary directory 11 | declare -a PACKAGES_NAME 12 | declare -a PACKAGES_PATH 13 | 14 | PACKAGES_NAME[0]="xbot_msgs" 15 | PACKAGES_PATH[0]="$OM_DIR/src/lib/xbot_msgs/msg" 16 | PACKAGES_NAME[1]="mower_msgs" 17 | PACKAGES_PATH[1]="$OM_DIR/src/mower_msgs/msg" 18 | PACKAGES_NAME[2]="mower_map" 19 | PACKAGES_PATH[2]="$OM_DIR/src/mower_map/msg" 20 | 21 | PACKAGES_NAME[3]="xbot_msgs" 22 | PACKAGES_PATH[3]="$OM_DIR/src/lib/xbot_msgs/srv" 23 | PACKAGES_NAME[4]="mower_msgs" 24 | PACKAGES_PATH[4]="$OM_DIR/src/mower_msgs/srv" 25 | PACKAGES_NAME[5]="mower_map" 26 | PACKAGES_PATH[5]="$OM_DIR/src/mower_map/srv" 27 | 28 | PACKAGES_NAME[6]="dynamic_reconfigure" 29 | PACKAGES_PATH[6]="$DYN_DIR/srv" 30 | PACKAGES_NAME[7]="dynamic_reconfigure" 31 | PACKAGES_PATH[7]="$DYN_DIR/msg" 32 | for pkg in ${!PACKAGES_NAME[@]}; do 33 | mkdir -p pkg/msgs/${PACKAGES_NAME[$pkg]} 34 | msgList=$(find ${PACKAGES_PATH[$pkg]} -name '*.msg') 35 | for file in $msgList; do 36 | echo "Importing message $file" 37 | filename=$(basename $file) 38 | filename="${filename%.*}" 39 | $GOPATH/bin/msg-import --rospackage=${PACKAGES_NAME[$pkg]} --gopackage=${PACKAGES_NAME[$pkg]} $file > pkg/msgs/${PACKAGES_NAME[$pkg]}/${filename}.go 40 | done 41 | srvList=$(find ${PACKAGES_PATH[$pkg]} -name '*.srv') 42 | for file in $srvList; do 43 | echo "Importing service $file" 44 | filename=$(basename $file) 45 | filename="${filename%.*}" 46 | $GOPATH/bin/srv-import --rospackage=${PACKAGES_NAME[$pkg]} --gopackage=${PACKAGES_NAME[$pkg]} $file > pkg/msgs/${PACKAGES_NAME[$pkg]}/${filename}.go 47 | done 48 | done 49 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openmower-gui", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "generate:api": "swagger-typescript-api -p ../docs/swagger.json -o src/api" 12 | }, 13 | "dependencies": { 14 | "@formily/antd-v5": "^1.2.1", 15 | "@formily/core": "^2.3.1", 16 | "@formily/react": "^2.3.1", 17 | "@mapbox/mapbox-gl-draw": "^1.4.3", 18 | "@microsoft/fetch-event-source": "^2.0.1", 19 | "@turf/area": "^7.0.0", 20 | "@turf/centroid": "^7.0.0", 21 | "@turf/union": "^7.0.0", 22 | "@types/mapbox-gl": "^3.1.0", 23 | "@types/mapbox__mapbox-gl-draw": "^1.4.6", 24 | "antd": "^5.18.3", 25 | "localforage": "^1.10.0", 26 | "mapbox-gl": "^3.4.0", 27 | "match-sorter": "^6.3.4", 28 | "moment": "^2.30.1", 29 | "prop-types": "^15.8.1", 30 | "react": "^18.3.1", 31 | "react-dom": "^18.3.1", 32 | "react-is": "^18.3.1", 33 | "react-joystick-component": "^6.2.1", 34 | "react-map-gl": "^7.1.7", 35 | "react-router-dom": "^6.23.1", 36 | "react-terminal-ui": "^1.3.0", 37 | "react-use-websocket": "4.8.1", 38 | "styled-components": "^6.1.11", 39 | "usng.js": "^0.4.5" 40 | }, 41 | "devDependencies": { 42 | "@types/react": "^18.3.3", 43 | "@types/react-dom": "^18.3.0", 44 | "@types/three": "^0.165.0", 45 | "@typescript-eslint/eslint-plugin": "^5.61.0", 46 | "@typescript-eslint/parser": "^5.61.0", 47 | "@vitejs/plugin-react": "^4.3.1", 48 | "@welldone-software/why-did-you-render": "^8.0.3", 49 | "eslint": "^8.44.0", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "eslint-plugin-react-refresh": "^0.4.1", 52 | "swagger-typescript-api": "^13.0.8", 53 | "typescript": "^5.5.2", 54 | "vite": "^5.3.1" 55 | }, 56 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 57 | } 58 | -------------------------------------------------------------------------------- /pkg/msgs/xbot_msgs/SensorInfo.go: -------------------------------------------------------------------------------- 1 | //autogenerated:yes 2 | //nolint:revive,lll 3 | package xbot_msgs 4 | 5 | import ( 6 | "github.com/bluenviron/goroslib/v2/pkg/msg" 7 | ) 8 | 9 | const ( 10 | SensorInfo_TYPE_STRING uint8 = 1 11 | SensorInfo_TYPE_DOUBLE uint8 = 2 12 | SensorInfo_VALUE_DESCRIPTION_UNKNOWN uint8 = 0 13 | SensorInfo_VALUE_DESCRIPTION_TEMPERATURE uint8 = 1 14 | SensorInfo_VALUE_DESCRIPTION_VELOCITY uint8 = 2 15 | SensorInfo_VALUE_DESCRIPTION_ACCELERATION uint8 = 3 16 | SensorInfo_VALUE_DESCRIPTION_VOLTAGE uint8 = 4 17 | SensorInfo_VALUE_DESCRIPTION_CURRENT uint8 = 5 18 | SensorInfo_VALUE_DESCRIPTION_PERCENT uint8 = 6 19 | SensorInfo_VALUE_DESCRIPTION_DISTANCE uint8 = 7 20 | SensorInfo_VALUE_DESCRIPTION_RPM uint8 = 8 21 | SensorInfo_FLAG_GPS_RTK uint16 = 1 22 | SensorInfo_FLAG_GPS_RTK_FIXED uint16 = 2 23 | SensorInfo_FLAG_GPS_RTK_FLOAT uint16 = 4 24 | SensorInfo_FLAG_GPS_DEAD_RECKONING uint16 = 8 25 | SensorInfo_FLAG_SENSOR_FUSION_RECENT_ABSOLUTE_POSE uint16 = 1 26 | SensorInfo_FLAG_SENSOR_FUSION_DEAD_RECKONING uint16 = 8 27 | ) 28 | 29 | type SensorInfo struct { 30 | msg.Package `ros:"xbot_msgs"` 31 | msg.Definitions `ros:"uint8 TYPE_STRING=1,uint8 TYPE_DOUBLE=2,uint8 VALUE_DESCRIPTION_UNKNOWN=0,uint8 VALUE_DESCRIPTION_TEMPERATURE=1,uint8 VALUE_DESCRIPTION_VELOCITY=2,uint8 VALUE_DESCRIPTION_ACCELERATION=3,uint8 VALUE_DESCRIPTION_VOLTAGE=4,uint8 VALUE_DESCRIPTION_CURRENT=5,uint8 VALUE_DESCRIPTION_PERCENT=6,uint8 VALUE_DESCRIPTION_DISTANCE=7,uint8 VALUE_DESCRIPTION_RPM=8,uint16 FLAG_GPS_RTK=1,uint16 FLAG_GPS_RTK_FIXED=2,uint16 FLAG_GPS_RTK_FLOAT=4,uint16 FLAG_GPS_DEAD_RECKONING=8,uint16 FLAG_SENSOR_FUSION_RECENT_ABSOLUTE_POSE=1,uint16 FLAG_SENSOR_FUSION_DEAD_RECKONING=8"` 32 | SensorId string 33 | SensorName string 34 | ValueType uint8 35 | ValueDescription uint8 36 | Unit string 37 | HasMinMax bool 38 | MinValue float64 39 | MaxValue float64 40 | HasCriticalLow bool 41 | LowerCriticalValue float64 42 | HasCriticalHigh bool 43 | UpperCriticalValue float64 44 | } 45 | -------------------------------------------------------------------------------- /web/src/routes/root.tsx: -------------------------------------------------------------------------------- 1 | import {Outlet, useMatches, useNavigate} from "react-router-dom"; 2 | import {Layout, Menu, MenuProps} from "antd"; 3 | import { 4 | HeatMapOutlined, 5 | MessageOutlined, 6 | RobotOutlined, 7 | RocketFilled, 8 | RocketOutlined, 9 | SettingOutlined 10 | } from '@ant-design/icons'; 11 | import {useEffect} from "react"; 12 | 13 | let menu: MenuProps['items'] = [ 14 | { 15 | key: '/openmower', 16 | label: 'OpenMower', 17 | icon: 18 | }, 19 | { 20 | key: '/setup', 21 | label: 'Setup', 22 | icon: 23 | }, 24 | { 25 | key: '/settings', 26 | label: 'Settings', 27 | icon: 28 | }, 29 | { 30 | key: '/map', 31 | label: 'Map', 32 | icon: 33 | }, 34 | { 35 | key: '/logs', 36 | label: 'Logs', 37 | icon: 38 | }, 39 | { 40 | key: 'new', 41 | label: What's new, 42 | icon: , 43 | } 44 | ]; 45 | 46 | export default () => { 47 | const route = useMatches() 48 | const navigate = useNavigate() 49 | useEffect(() => { 50 | if (route.length === 1 && route[0].pathname === "/") { 51 | navigate({ 52 | pathname: '/openmower', 53 | }) 54 | } 55 | }, [route, navigate]) 56 | return ( 57 | 58 | 62 | { 65 | if (info.key !== 'new') { 66 | navigate({ 67 | pathname: info.key, 68 | }) 69 | } 70 | }} selectedKeys={route.map(r => r.pathname)} items={menu}/> 71 | 72 | 73 | 74 | 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /web/src/components/DrawControl.tsx: -------------------------------------------------------------------------------- 1 | import MapboxDraw from '@mapbox/mapbox-gl-draw'; 2 | import type {ControlPosition} from 'react-map-gl'; 3 | import {useControl} from 'react-map-gl'; 4 | import {useEffect} from "react"; 5 | import DirectSelectWithBoxMode from '../modes/DirectSelectWithBoxMode'; 6 | 7 | type DrawControlProps = ConstructorParameters[0] & { 8 | position?: ControlPosition; 9 | features?: any[]; 10 | editMode?: boolean; 11 | 12 | onCreate: (evt: any) => void; 13 | onUpdate: (evt: any) => void; 14 | onCombine: (evt: any) => void; 15 | onDelete: (evt: any) => void; 16 | onSelectionChange: (evt: any) => void; 17 | onOpenDetails: (evt: any) => void; 18 | }; 19 | 20 | export default function DrawControl(props: DrawControlProps) { 21 | const mp = useControl( 22 | () => new MapboxDraw({ 23 | ...props, 24 | modes: { 25 | ...MapboxDraw.modes, 26 | direct_select: DirectSelectWithBoxMode 27 | } 28 | }), 29 | ({ map }) => { 30 | map.on('draw.create', props.onCreate); 31 | map.on('draw.update', props.onUpdate); 32 | map.on('draw.combine', props.onCombine); 33 | map.on('draw.delete', props.onDelete); 34 | map.on('draw.selectionchange', props.onSelectionChange); 35 | map.on('feature.open', props.onOpenDetails); 36 | }, 37 | ({map}) => { 38 | map.off('draw.create', props.onCreate); 39 | map.off('draw.update', props.onUpdate); 40 | map.off('draw.combine', props.onCombine); 41 | map.off('draw.delete', props.onDelete); 42 | map.off('draw.selectionchange', props.onSelectionChange); 43 | map.off('feature.open', props.onOpenDetails); 44 | } 45 | , 46 | { 47 | position: props.position, 48 | } 49 | ); 50 | useEffect(() => { 51 | if (mp) { 52 | if (props.features) { 53 | mp.deleteAll(); 54 | props.features.forEach((f) => { 55 | mp.add(f); 56 | }) 57 | } 58 | } 59 | }, [mp, props.features]); 60 | useEffect(() => { 61 | if (mp) { 62 | if (!props.editMode) { 63 | mp.changeMode('simple_select'); 64 | } else { 65 | mp.changeMode('draw_polygon'); 66 | } 67 | } 68 | }, [mp, props.editMode]); 69 | return null; 70 | } 71 | 72 | DrawControl.defaultProps = { 73 | onCreate: () => { 74 | }, 75 | onUpdate: () => { 76 | }, 77 | onDelete: () => { 78 | }, 79 | onCombine: () => { 80 | }, 81 | onSelectionChange: () => { 82 | }, 83 | onOpenDetails: () => { 84 | }, 85 | }; 86 | -------------------------------------------------------------------------------- /web/src/components/HighLevelStatusComponent.tsx: -------------------------------------------------------------------------------- 1 | import {Col, Row, Statistic} from "antd"; 2 | import {booleanFormatter, booleanFormatterInverted, progressFormatter, stateRenderer} from "./utils.tsx"; 3 | import {useHighLevelStatus} from "../hooks/useHighLevelStatus.ts"; 4 | import {useStatus} from "../hooks/useStatus.ts"; 5 | import {useSettings} from "../hooks/useSettings.ts"; 6 | 7 | export function HighLevelStatusComponent() { 8 | const {highLevelStatus} = useHighLevelStatus() 9 | const status = useStatus() 10 | const {settings} = useSettings() 11 | const estimateRemainingChargingTime = () => { 12 | if (!status.VBattery || !status.ChargeCurrent || status.ChargeCurrent == 0) { 13 | return "∞" 14 | } 15 | let capacity = (settings["OM_BATTERY_CAPACITY_MAH"] ?? "3000.0"); 16 | let full = (settings["OM_BATTERY_FULL_VOLTAGE"] ?? "28.0"); 17 | let empty = (settings["OM_BATTERY_EMPTY_VOLTAGE"] ?? "23.0"); 18 | if (!capacity || !full || !empty) { 19 | return "∞" 20 | } 21 | const estimatedAmpsPerVolt = parseFloat(capacity) / (parseFloat(full) - parseFloat(empty)) 22 | let estimatedRemainingAmps = (parseFloat(full) - (status.VBattery ?? 0)) * estimatedAmpsPerVolt; 23 | if (estimatedRemainingAmps < 10) { 24 | return "∞" 25 | } 26 | let remaining = estimatedRemainingAmps / ((status.ChargeCurrent ?? 0) * 1000) 27 | if (remaining < 0) { 28 | return "∞" 29 | } 30 | return Date.now() + remaining * (1000 * 60 * 60) 31 | }; 32 | return 33 | 35 | 38 | 40 | 42 | 44 | 46 | ; 47 | } -------------------------------------------------------------------------------- /setup/txt2ubx.py: -------------------------------------------------------------------------------- 1 | """ 2 | txt2ubx.py 3 | 4 | Utility which converts u-center *.txt configuration files to binary *.ubx files. 5 | 6 | Two output files are produced: 7 | 8 | *.get.ubx - contains GET (MON-VER and CFG-VALGET) messages mirroring the input file 9 | *.set.ubx - contains SET (converted CFG-VALSET) messages which can be used to set configuration 10 | 11 | The *.set.ubx file can be loaded into PyGPSClient's UBX Configuration Load/Save/record facility 12 | and uploaded to the receiver. 13 | 14 | Created on 27 Apr 2023 15 | 16 | @author: semuadmin 17 | """ 18 | 19 | from pyubx2 import UBXMessage, GET, SET 20 | 21 | 22 | def txt2ubx(fname: str): 23 | """ 24 | Convert txt configuration file to ubx 25 | 26 | :param fname str: txt config file name 27 | """ 28 | 29 | with open(fname, "r", encoding="utf-8") as infile: 30 | with open(fname + ".get.ubx", "wb") as outfile_get: 31 | with open(fname + ".set.ubx", "wb") as outfile_set: 32 | read = 0 33 | write = 0 34 | errors = 0 35 | for line in infile: 36 | try: 37 | read += 1 38 | parts = line.replace(" ", "").split("-") 39 | data = bytes.fromhex(parts[-1]) 40 | cls = data[0:1] 41 | mid = data[1:2] 42 | # lenb = data[2:4] 43 | version = data[4:5] 44 | layer = data[5:6] 45 | position = data[6:8] 46 | cfgdata = data[8:] 47 | payload = version + layer + position + cfgdata 48 | ubx = UBXMessage(cls, mid, GET, payload=payload) 49 | outfile_get.write(ubx.serialize()) 50 | # only convert CFG-VALGET 51 | if not (cls == b"\x06" and mid == b"\x8b"): 52 | continue 53 | layers = b"\x04" # flash only 54 | transaction = b"\x00" # not transactional 55 | reserved0 = b"\x00" 56 | payload = version + layers + transaction + reserved0 + cfgdata 57 | # create a CFG-VALSET message from the input CFG-VALGET 58 | ubx = UBXMessage(b"\x06", b"\x8a", SET, payload=payload) 59 | outfile_set.write(ubx.serialize()) 60 | write += 1 61 | except Exception as err: # pylint: disable=broad-exception-caught 62 | print(err) 63 | errors += 1 64 | continue 65 | 66 | print(f"{read} GET messages read, {write} SET messages written, {errors} errors") 67 | 68 | 69 | txt2ubx("Robot.txt") 70 | -------------------------------------------------------------------------------- /pkg/api/config.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/cedbossneo/openmower-gui/pkg/types" 5 | "github.com/gin-gonic/gin" 6 | ) 7 | 8 | func ConfigRoute(r *gin.RouterGroup, db types.IDBProvider) { 9 | ConfigEnvRoute(r, db) 10 | ConfigGetKeysRoute(r, db) 11 | ConfigSetKeysRoute(r, db) 12 | } 13 | 14 | // ConfigGetKeysRoute get config from backend 15 | // 16 | // @Summary get config from backend 17 | // @Description get config from backend 18 | // @Tags config 19 | // @Produce json 20 | // @Param settings body map[string]string true "settings" 21 | // @Success 200 {object} map[string]string 22 | // @Failure 500 {object} ErrorResponse 23 | // @Router /config/keys/get [post] 24 | func ConfigGetKeysRoute(r *gin.RouterGroup, db types.IDBProvider) gin.IRoutes { 25 | return r.POST("/config/keys/get", func(context *gin.Context) { 26 | var body gin.H 27 | err := context.BindJSON(&body) 28 | if err != nil { 29 | context.JSON(500, ErrorResponse{ 30 | Error: err.Error(), 31 | }) 32 | return 33 | } 34 | for key, _ := range body { 35 | get, err := db.Get(key) 36 | if err != nil { 37 | continue 38 | } 39 | body[key] = string(get) 40 | } 41 | 42 | context.JSON(200, body) 43 | }) 44 | } 45 | 46 | // ConfigSetKeysRoute set config to backend 47 | // 48 | // @Summary set config to backend 49 | // @Description set config to backend 50 | // @Tags config 51 | // @Produce json 52 | // @Param settings body map[string]string true "settings" 53 | // @Success 200 {object} map[string]string 54 | // @Failure 500 {object} ErrorResponse 55 | // @Router /config/keys/set [post] 56 | func ConfigSetKeysRoute(r *gin.RouterGroup, db types.IDBProvider) gin.IRoutes { 57 | return r.POST("/config/keys/set", func(context *gin.Context) { 58 | var body gin.H 59 | err := context.BindJSON(&body) 60 | if err != nil { 61 | context.JSON(500, ErrorResponse{ 62 | Error: err.Error(), 63 | }) 64 | return 65 | } 66 | for key, value := range body { 67 | err := db.Set(key, []byte(value.(string))) 68 | if err != nil { 69 | continue 70 | } 71 | } 72 | context.JSON(200, body) 73 | }) 74 | } 75 | 76 | // ConfigEnvRoute get config from backend 77 | // 78 | // @Summary get config env from backend 79 | // @Description get config env from backend 80 | // @Tags config 81 | // @Produce json 82 | // @Produce json 83 | // @Success 200 {object} GetConfigResponse 84 | // @Failure 500 {object} ErrorResponse 85 | // @Router /config/envs [get] 86 | func ConfigEnvRoute(r *gin.RouterGroup, db types.IDBProvider) gin.IRoutes { 87 | return r.GET("/config/envs", func(context *gin.Context) { 88 | tileUri, err := db.Get("system.map.tileUri") 89 | if err != nil { 90 | context.JSON(500, ErrorResponse{ 91 | Error: err.Error(), 92 | }) 93 | return 94 | } 95 | context.JSON(200, GetConfigResponse{ 96 | TileUri: string(tileUri), 97 | }) 98 | }) 99 | } 100 | -------------------------------------------------------------------------------- /pkg/api/setup.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bufio" 5 | "github.com/cedbossneo/openmower-gui/pkg/types" 6 | "github.com/gin-gonic/gin" 7 | "io" 8 | ) 9 | 10 | func SetupRoutes(r *gin.RouterGroup, provider types.IFirmwareProvider, ubloxProvider types.IGpsProvider) { 11 | group := r.Group("/setup") 12 | FlashBoard(group, provider) 13 | FlashGPS(group, ubloxProvider) 14 | } 15 | 16 | // FlashGPS flash the gps configuration 17 | // 18 | // @Summary flash the gps configuration 19 | // @Description flash the gps configuration 20 | // @Tags setup 21 | // @Accept json 22 | // @Produce text/event-stream 23 | // @Success 200 {object} OkResponse 24 | // @Failure 500 {object} ErrorResponse 25 | // @Router /setup/flashGPS [post] 26 | func FlashGPS(group *gin.RouterGroup, provider types.IGpsProvider) gin.IRoutes { 27 | return group.POST("/flashGPS", func(context *gin.Context) { 28 | reader, writer := io.Pipe() 29 | rd := bufio.NewReader(reader) 30 | go func() { 31 | err := provider.FlashGPS(writer) 32 | if err != nil { 33 | writer.CloseWithError(err) 34 | } else { 35 | writer.Close() 36 | } 37 | }() 38 | context.Stream(func(w io.Writer) bool { 39 | line, _, err2 := rd.ReadLine() 40 | if err2 != nil { 41 | if err2 == io.EOF { 42 | context.SSEvent("end", "end") 43 | return false 44 | } 45 | context.SSEvent("error", err2.Error()) 46 | return false 47 | } 48 | context.SSEvent("message", string(line)) 49 | return true 50 | }) 51 | }) 52 | } 53 | 54 | // FlashBoard flash the mower board with the given config 55 | // 56 | // @Summary flash the mower board with the given config 57 | // @Description flash the mower board with the given config 58 | // @Tags setup 59 | // @Accept json 60 | // @Produce text/event-stream 61 | // @Param settings body types.FirmwareConfig true "config" 62 | // @Success 200 {object} OkResponse 63 | // @Failure 500 {object} ErrorResponse 64 | // @Router /setup/flashBoard [post] 65 | func FlashBoard(r *gin.RouterGroup, provider types.IFirmwareProvider) gin.IRoutes { 66 | return r.POST("/flashBoard", func(c *gin.Context) { 67 | var config types.FirmwareConfig 68 | var err error 69 | err = c.BindJSON(&config) 70 | if err != nil { 71 | c.JSON(500, ErrorResponse{ 72 | Error: err.Error(), 73 | }) 74 | return 75 | } 76 | reader, writer := io.Pipe() 77 | rd := bufio.NewReader(reader) 78 | go func() { 79 | err = provider.FlashFirmware(writer, config) 80 | if err != nil { 81 | writer.CloseWithError(err) 82 | } else { 83 | writer.Close() 84 | } 85 | }() 86 | c.Stream(func(w io.Writer) bool { 87 | line, _, err2 := rd.ReadLine() 88 | if err2 != nil { 89 | if err2 == io.EOF { 90 | c.SSEvent("end", "end") 91 | return false 92 | } 93 | c.SSEvent("error", err2.Error()) 94 | return false 95 | } 96 | c.SSEvent("message", string(line)) 97 | return true 98 | }) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/providers/db.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "errors" 5 | "git.mills.io/prologic/bitcask" 6 | "golang.org/x/xerrors" 7 | "os" 8 | ) 9 | 10 | type DBProvider struct { 11 | db *bitcask.Bitcask 12 | } 13 | 14 | var EnvFallbacks = map[string]string{ 15 | "system.api.addr": "API_ADDR", 16 | "system.api.webDirectory": "WEB_DIR", 17 | "system.map.enabled": "MAP_TILE_ENABLED", 18 | "system.map.tileServer": "MAP_TILE_SERVER", 19 | "system.map.tileUri": "MAP_TILE_URI", 20 | "system.homekit.enabled": "HOMEKIT_ENABLED", 21 | "system.mqtt.enabled": "MQTT_ENABLED", 22 | "system.mqtt.prefix": "MQTT_PREFIX", 23 | "system.mqtt.host": "MQTT_HOST", 24 | "system.mower.configFile": "MOWER_CONFIG_FILE", 25 | "system.ros.masterUri": "ROS_MASTER_URI", 26 | "system.ros.nodeName": "ROS_NODE_NAME", 27 | "system.ros.nodeHost": "ROS_NODE_HOST", 28 | "system.homekit.pincode": "HOMEKIT_PINCODE", 29 | } 30 | var Defaults = map[string]string{ 31 | "system.api.addr": ":4006", 32 | "system.api.webDirectory": "/app/web", 33 | "system.map.enabled": "false", 34 | "system.map.tileServer": "http://localhost:5000", 35 | "system.map.tileUri": "/tiles/vt/lyrs=s,h&x={x}&y={y}&z={z}", 36 | "system.homekit.enabled": "false", 37 | "system.homekit.pincode": "00102003", 38 | "system.mqtt.enabled": "false", 39 | "system.mqtt.host": ":1883", 40 | "system.mqtt.prefix": "/gui", 41 | "system.mower.configFile": "/config/mower_config.sh", 42 | "system.ros.masterUri": "http://localhost:11311", 43 | "system.ros.nodeName": "openmower-gui", 44 | "system.ros.nodeHost": "localhost", 45 | } 46 | 47 | func (d *DBProvider) Set(key string, value []byte) error { 48 | return d.db.Put([]byte(key), value) 49 | } 50 | 51 | func (d *DBProvider) Get(key string) ([]byte, error) { 52 | value, err := d.db.Get([]byte(key)) 53 | if err != nil || value == nil || len(value) == 0 { 54 | if !errors.Is(err, bitcask.ErrKeyNotFound) { 55 | return nil, err 56 | } 57 | if EnvFallbacks[key] != "" && os.Getenv(EnvFallbacks[key]) != "" { 58 | return []byte(os.Getenv(EnvFallbacks[key])), nil 59 | } 60 | if Defaults[key] != "" { 61 | return []byte(Defaults[key]), nil 62 | } 63 | return nil, xerrors.Errorf("config key %s not found", key) 64 | } 65 | return value, nil 66 | } 67 | 68 | func (d *DBProvider) Delete(key string) error { 69 | return d.db.Delete([]byte(key)) 70 | } 71 | 72 | func (d *DBProvider) KeysWithSuffix(suffix string) ([]string, error) { 73 | var keys []string 74 | err := d.db.Scan([]byte(suffix), func(key []byte) error { 75 | keys = append(keys, string(key)) 76 | return nil 77 | }) 78 | if err != nil { 79 | return nil, err 80 | } 81 | return keys, nil 82 | } 83 | 84 | func (d *DBProvider) GetWithEnvFallback(key string, env string, def string) string { 85 | value, err := d.Get(key) 86 | if err != nil || value == nil || len(value) == 0 { 87 | if os.Getenv(env) == "" { 88 | return def 89 | } else { 90 | return os.Getenv(env) 91 | } 92 | } 93 | return string(value) 94 | } 95 | 96 | func NewDBProvider() *DBProvider { 97 | var err error 98 | d := &DBProvider{} 99 | d.db, err = bitcask.Open(os.Getenv("DB_PATH")) 100 | if err != nil { 101 | panic(err) 102 | } 103 | return d 104 | } 105 | -------------------------------------------------------------------------------- /pkg/providers/homekit.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/brutella/hap" 7 | "github.com/brutella/hap/accessory" 8 | log2 "github.com/brutella/hap/log" 9 | "github.com/cedbossneo/openmower-gui/pkg/msgs/mower_msgs" 10 | types2 "github.com/cedbossneo/openmower-gui/pkg/types" 11 | "log" 12 | "os" 13 | "os/signal" 14 | "syscall" 15 | ) 16 | 17 | type HomeKitProvider struct { 18 | rosProvider types2.IRosProvider 19 | mower *accessory.Switch 20 | db types2.IDBProvider 21 | } 22 | 23 | func NewHomeKitProvider(rosProvider types2.IRosProvider, idbProvider types2.IDBProvider) *HomeKitProvider { 24 | h := &HomeKitProvider{} 25 | h.db = idbProvider 26 | h.rosProvider = rosProvider 27 | h.Init() 28 | return h 29 | } 30 | 31 | func (hc *HomeKitProvider) Init() { 32 | // Create the switch accessory. 33 | as := hc.registerAccessories() 34 | hc.subscribeToRos() 35 | hc.launchServer(as) 36 | } 37 | 38 | func (hc *HomeKitProvider) registerAccessories() *accessory.A { 39 | hc.mower = accessory.NewSwitch(accessory.Info{Name: "OpenMower"}) 40 | hc.mower.Switch.On.OnValueRemoteUpdate(func(on bool) { 41 | var err error 42 | if on { 43 | err = hc.rosProvider.CallService(context.Background(), "/mower_service/high_level_control", &mower_msgs.HighLevelControlSrv{}, &mower_msgs.HighLevelControlSrvReq{ 44 | Command: 1, 45 | }, &mower_msgs.HighLevelControlSrvRes{}) 46 | } else { 47 | err = hc.rosProvider.CallService(context.Background(), "/mower_service/high_level_control", &mower_msgs.HighLevelControlSrv{}, &mower_msgs.HighLevelControlSrvReq{ 48 | Command: 2, 49 | }, &mower_msgs.HighLevelControlSrvRes{}) 50 | } 51 | if err != nil { 52 | log.Println(err) 53 | } 54 | }) 55 | return hc.mower.A 56 | } 57 | 58 | func (hc *HomeKitProvider) launchServer(as *accessory.A) { 59 | // Store the data in the "./db" directory. 60 | log2.Debug.Enable() 61 | // Create the hap server. 62 | server, err := hap.NewServer(hc.db, as) 63 | server.Addr = ":8000" 64 | pinCode, err := hc.db.Get("system.homekit.pincode") 65 | if err != nil { 66 | log.Panic(err) 67 | } 68 | server.Pin = string(pinCode) 69 | if err != nil { 70 | // stop if an error happens 71 | log.Panic(err) 72 | } 73 | 74 | // Setup a listener for interrupts and SIGTERM signals 75 | // to stop the server. 76 | c := make(chan os.Signal) 77 | signal.Notify(c, os.Interrupt) 78 | signal.Notify(c, syscall.SIGTERM) 79 | 80 | ctx, cancel := context.WithCancel(context.Background()) 81 | go func() { 82 | <-c 83 | // Stop delivering signals. 84 | signal.Stop(c) 85 | // Cancel the context to stop the server. 86 | cancel() 87 | }() 88 | 89 | go func() { 90 | // Run the server. 91 | server.ListenAndServe(ctx) 92 | }() 93 | 94 | } 95 | 96 | func (hc *HomeKitProvider) subscribeToRos() { 97 | hc.rosProvider.Subscribe("/mower_logic/current_state", "ha-status", func(msg []byte) { 98 | var status mower_msgs.HighLevelStatus 99 | err := json.Unmarshal(msg, &status) 100 | if err != nil { 101 | log.Println(err) 102 | return 103 | } 104 | if status.StateName == "MOWING" || status.StateName == "DOCKING" || status.StateName == "UNDOCKING" { 105 | hc.mower.Switch.On.SetValue(true) 106 | } else { 107 | hc.mower.Switch.On.SetValue(false) 108 | } 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /pkg/api/settings.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "github.com/cedbossneo/openmower-gui/pkg/types" 6 | "github.com/gin-gonic/gin" 7 | "github.com/joho/godotenv" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | func SettingsRoutes(r *gin.RouterGroup, dbProvider types.IDBProvider) { 13 | GetSettings(r, dbProvider) 14 | PostSettings(r, dbProvider) 15 | } 16 | 17 | // PostSettings saves the settings to the mower_config.sh file 18 | // 19 | // @Summary saves the settings to the mower_config.sh file 20 | // @Description saves the settings to the mower_config.sh file 21 | // @Tags settings 22 | // @Accept json 23 | // @Produce json 24 | // @Param settings body map[string]any true "settings" 25 | // @Success 200 {object} OkResponse 26 | // @Failure 500 {object} ErrorResponse 27 | // @Router /settings [post] 28 | func PostSettings(r *gin.RouterGroup, dbProvider types.IDBProvider) gin.IRoutes { 29 | return r.POST("/settings", func(c *gin.Context) { 30 | var settingsPayload map[string]any 31 | err := c.BindJSON(&settingsPayload) 32 | if err != nil { 33 | c.JSON(500, ErrorResponse{ 34 | Error: err.Error(), 35 | }) 36 | return 37 | } 38 | mowerConfigFile, err := dbProvider.Get("system.mower.configFile") 39 | if err != nil { 40 | c.JSON(500, ErrorResponse{ 41 | Error: err.Error(), 42 | }) 43 | return 44 | } 45 | var settings = map[string]any{} 46 | configFileContent, err := os.ReadFile(string(mowerConfigFile)) 47 | if err == nil { 48 | parse, err := godotenv.Parse(strings.NewReader(string(configFileContent))) 49 | if err == nil { 50 | for s, s2 := range parse { 51 | settings[s] = s2 52 | } 53 | } 54 | } 55 | for key, value := range settingsPayload { 56 | settings[key] = value 57 | } 58 | // Write settings to file mower_config.sh 59 | var fileContent string 60 | for key, value := range settings { 61 | if value == true { 62 | value = "True" 63 | } 64 | if value == false { 65 | value = "False" 66 | } 67 | fileContent += "export " + key + "=" + fmt.Sprintf("%#v", value) + "\n" 68 | } 69 | err = os.WriteFile(string(mowerConfigFile), []byte(fileContent), 0644) 70 | if err != nil { 71 | c.JSON(500, ErrorResponse{ 72 | Error: err.Error(), 73 | }) 74 | return 75 | } 76 | c.JSON(200, OkResponse{}) 77 | }) 78 | } 79 | 80 | // GetSettings returns a JSON object with the settings 81 | // 82 | // @Summary returns a JSON object with the settings 83 | // @Description returns a JSON object with the settings 84 | // @Tags settings 85 | // @Produce json 86 | // @Success 200 {object} GetSettingsResponse 87 | // @Failure 500 {object} ErrorResponse 88 | // @Router /settings [get] 89 | func GetSettings(r *gin.RouterGroup, dbProvider types.IDBProvider) gin.IRoutes { 90 | return r.GET("/settings", func(c *gin.Context) { 91 | mowerConfigFilePath, err := dbProvider.Get("system.mower.configFile") 92 | if err != nil { 93 | c.JSON(500, ErrorResponse{ 94 | Error: err.Error(), 95 | }) 96 | return 97 | } 98 | file, err := os.ReadFile(string(mowerConfigFilePath)) 99 | if err != nil { 100 | c.JSON(500, ErrorResponse{ 101 | Error: err.Error(), 102 | }) 103 | return 104 | } 105 | settings, err := godotenv.Parse(strings.NewReader(string(file))) 106 | if err != nil { 107 | c.JSON(500, ErrorResponse{ 108 | Error: err.Error(), 109 | }) 110 | return 111 | } 112 | c.JSON(200, GetSettingsResponse{ 113 | Settings: settings, 114 | }) 115 | }) 116 | } 117 | -------------------------------------------------------------------------------- /web/src/pages/SetupPage.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {Submit} from '@formily/antd-v5' 3 | import {CheckCircleOutlined} from '@ant-design/icons' 4 | import {Button, Card, Col, Row, Steps, Typography} from "antd"; 5 | import {FlashBoardComponent} from "../components/FlashBoardComponent.tsx"; 6 | import {SettingsComponent} from "../components/SettingsComponent.tsx"; 7 | import AsyncButton from "../components/AsyncButton.tsx"; 8 | import {FlashGPSComponent} from "../components/FlashGPSComponent.tsx"; 9 | import {SettingsConfig} from "../hooks/useSettings.ts"; 10 | 11 | const {Step} = Steps; 12 | 13 | const SetupWizard: React.FC = () => { 14 | const [currentStep, setCurrentStep] = useState(0); 15 | 16 | const handleNext = () => { 17 | setCurrentStep(currentStep + 1); 18 | }; 19 | 20 | const handlePrevious = () => { 21 | setCurrentStep(currentStep - 1); 22 | }; 23 | 24 | const steps = [ 25 | { 26 | title: 'Flash motherboard firmware', 27 | content: ( 28 | 29 | 30 | 31 | ), 32 | }, 33 | { 34 | title: 'Flash GPS configuration', 35 | content: ( 36 | 37 | 38 | 39 | ), 40 | }, 41 | { 42 | title: 'Configure OpenMower', 43 | content: ( 44 | 45 | { 46 | return [ 47 | , 48 | { 49 | await save(values); 50 | await restartOM(); 51 | await restartGUI(); 52 | handleNext(); 53 | }}>Save and restart, 54 | ] 55 | }}/> 56 | 57 | ), 58 | }, 59 | { 60 | title: 'Setup complete', 61 | content: ( 62 | 63 | 64 | 65 | 67 | Congratulations, your Mower is now fully 68 | configured 69 | 70 | 71 | { 72 | window.location.href = "/#/openmower"; 73 | }}>Go to dashboard 74 | 75 | 76 | 77 | ) 78 | } 79 | ]; 80 | 81 | 82 | return 83 | 84 | Setup 85 | WARNING: This setup wizard will flash your 86 | motherboard firmware and the GPS configuration. Run at your own risk and be careful with voltage 87 | settings if you change them. 88 | 89 | 90 | 91 | {steps.map((step) => ( 92 | 93 | ))} 94 | 95 | 96 | 97 |
{steps[currentStep].content}
98 | 99 |
; 100 | }; 101 | 102 | export default SetupWizard; -------------------------------------------------------------------------------- /web/src/components/StatusComponent.tsx: -------------------------------------------------------------------------------- 1 | import {ESCStatus} from "../types/ros.ts"; 2 | import {Card, Col, Row, Statistic} from "antd"; 3 | import {booleanFormatter} from "./utils.tsx"; 4 | import {useStatus} from "../hooks/useStatus.ts"; 5 | 6 | export function StatusComponent() { 7 | const status = useStatus(); 8 | const renderEscStatus = (escStatus: ESCStatus | undefined) => { 9 | return 10 | 11 | 12 | 13 | 14 | 16 | 18 | 19 | }; 20 | return 21 | 22 | 23 | 24 | 27 | 30 | 32 | 34 | 36 | 39 | 42 | 45 | 47 | 49 | 50 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | {renderEscStatus(status.LeftEscStatus)} 59 | 60 | 61 | 62 | 63 | {renderEscStatus(status.RightEscStatus)} 64 | 65 | 66 | 67 | 68 | {renderEscStatus(status.MowEscStatus)} 69 | 70 | 71 | ; 72 | } 73 | -------------------------------------------------------------------------------- /web/src/components/FlashGPSComponent.tsx: -------------------------------------------------------------------------------- 1 | import {App, Button, Col, Modal, Row, Typography} from "antd"; 2 | import {useState} from "react"; 3 | import {fetchEventSource} from "@microsoft/fetch-event-source"; 4 | import {FormButtonGroup} from "@formily/antd-v5"; 5 | import {StyledTerminal} from "./StyledTerminal.tsx"; 6 | import Terminal, {ColorMode, TerminalOutput} from "react-terminal-ui"; 7 | import AsyncButton from "./AsyncButton.tsx"; 8 | 9 | export const FlashGPSComponent = (props: { onNext: () => void, onPrevious: () => void }) => { 10 | const {notification} = App.useApp(); 11 | const [data, setData] = useState() 12 | const flashGPS = async () => { 13 | try { 14 | console.log({ 15 | message: "Flashing firmware", 16 | }); 17 | await fetchEventSource(`/api/setup/flashGPS`, { 18 | method: "POST", 19 | keepalive: false, 20 | headers: { 21 | Accept: "text/event-stream", 22 | }, 23 | onopen(res) { 24 | if (res.ok && res.status === 200) { 25 | console.log({ 26 | message: "Connected to log stream", 27 | }); 28 | } else if ( 29 | res.status >= 400 && 30 | res.status < 500 && 31 | res.status !== 429 32 | ) { 33 | notification.error({ 34 | message: "Error retrieving log stream", 35 | description: res.statusText, 36 | }); 37 | } 38 | setData([]) 39 | return Promise.resolve() 40 | }, 41 | onmessage(event) { 42 | if (event.event == "end") { 43 | notification.success({ 44 | message: "GPS flashed", 45 | }); 46 | setTimeout(() => { 47 | props.onNext(); 48 | }, 10000); 49 | return; 50 | } else if (event.event == "error") { 51 | notification.error({ 52 | message: "Error flashing gps", 53 | description: event.data, 54 | }); 55 | return; 56 | } else { 57 | setData((data) => [...(data ?? []), event.data]); 58 | } 59 | }, 60 | onclose() { 61 | notification.success({ 62 | message: "Logs stream closed", 63 | }); 64 | }, 65 | onerror(err) { 66 | notification.error({ 67 | message: "Error retrieving log stream", 68 | description: err.toString(), 69 | }); 70 | }, 71 | }); 72 | } catch (e: any) { 73 | notification.error({ 74 | message: "Error flashing gps", 75 | description: e.toString(), 76 | }); 77 | } 78 | }; 79 | return 80 | 81 | 82 | Click on the button below to flash your uBlox Z-F9P GPS Configuration. 83 | 84 | 0)} 88 | cancelButtonProps={{style: {display: "none"}}} 89 | onOk={() => { 90 | setData([]) 91 | }} 92 | > 93 | 94 | 95 | {(data ?? []).map((line, index) => { 96 | return {line}; 97 | })} 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Flash GPS Configuration 106 | 107 | 108 | ; 109 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenMower GUI 2 | 3 | A GUI for the OpenMower project. 4 | 5 | ## Demo 6 | 7 | [https://youtu.be/x45rdy4czQ0](https://youtu.be/x45rdy4czQ0) 8 | 9 | ## Installation 10 | 11 | ### If you are using Mowgli-Docker 12 | 13 | If you are using mowgli-docker, you can skip this part as it's now included in the docker-compose file. 14 | 15 | ### If your are using OpenMowerOS 16 | 17 | OpenMowerOS uses podman and containers are managed by systemd. 18 | 19 | First, create the /boot/openmower/db directory : 20 | 21 | ```bash 22 | mkdir /boot/openmower/db 23 | ``` 24 | 25 | Create a gui.service file in `/etc/systemd/system/` with the following content: 26 | 27 | ```bash 28 | [Unit] 29 | Description=Podman container - gui.service 30 | Documentation=man:podman-generate-systemd(1) 31 | Wants=network.target 32 | After=network-online.target NetworkManager.service 33 | StartLimitInterval=120 34 | StartLimitBurst=10 35 | 36 | [Service] 37 | Environment=PODMAN_SYSTEMD_UNIT=%n 38 | Type=forking 39 | Restart=always 40 | RestartSec=15s 41 | TimeoutStartSec=1h 42 | TimeoutStopSec=120s 43 | 44 | ExecStartPre=/bin/rm -f %t/container-gui.pid %t/container-gui.ctr-id 45 | 46 | ExecStart=/usr/bin/podman run --conmon-pidfile %t/container-gui.pid --cidfile %t/container-gui.ctr-id --cgroups=no-conmon \ 47 | --replace --detach --tty --privileged \ 48 | --name openmower-gui \ 49 | --network=host \ 50 | --env MOWER_CONFIG_FILE=/config/mower_config.sh \ 51 | --env DOCKER_HOST=unix:///run/podman/podman.sock \ 52 | --env ROS_MASTER_URI=http://localhost:11311 \ 53 | --volume /dev:/dev \ 54 | --volume /run/podman/podman.sock:/run/podman/podman.sock \ 55 | --volume /boot/openmower/db:/app/db \ 56 | --volume /boot/openmower/mower_config.txt:/config/mower_config.sh \ 57 | --label io.containers.autoupdate=image \ 58 | ghcr.io/cedbossneo/openmower-gui:master 59 | 60 | #ExecStartPost=/usr/bin/podman image prune --all --force 61 | 62 | ExecStop=/usr/bin/podman stop --ignore --cidfile %t/container-gui.ctr-id -t 10 63 | ExecStopPost=/usr/bin/podman rm --ignore --force --cidfile %t/container-gui.ctr-id 64 | PIDFile=%t/container-gui.pid 65 | 66 | [Install] 67 | WantedBy=multi-user.target default.target 68 | ``` 69 | 70 | Then enable and start the service: 71 | 72 | ```bash 73 | sudo systemctl enable gui.service 74 | sudo systemctl start gui.service 75 | ``` 76 | 77 | ## Usage 78 | 79 | Once the container is running, you can access the GUI by opening a browser and going 80 | to `http://:4006` 81 | 82 | ### HomeKit 83 | 84 | The password to use OpenMower in iOS home app is 00102003 85 | Do not forget to set env var HOMEKIT_ENABLED to true 86 | 87 | ### MQTT 88 | 89 | MQTT server is listening on port 1883 90 | 91 | See [ros.ts](web%2Fsrc%2Ftypes%2Fros.ts) for topic types 92 | 93 | Available topics : 94 | 95 | - /gui/mower_logic/current_state 96 | - /gui/mower/status 97 | - /gui/xbot_positioning/xb_pose 98 | - /gui/imu/data_raw 99 | - /gui/mower/wheel_ticks 100 | - /gui/xbot_monitoring/map 101 | - /gui/slic3r_coverage_planner/path_marker_array 102 | - /gui/move_base_flex/FTCPlanner 103 | - /gui/mowing_path 104 | 105 | Available commands : 106 | 107 | - /gui/call/mower_service/high_level_control [HighLevelControlSrv.go](pkg%2Fmsgs%2Fmower_msgs%2FHighLevelControlSrv.go) 108 | - /gui/call/mower_service/emergency [EmergencyStopSrv.go](pkg%2Fmsgs%2Fmower_msgs%2FEmergencyStopSrv.go) 109 | - /gui/call/mower_logic/set_parameters [Reconfigure.go](pkg%2Fmsgs%2Fdynamic_reconfigure%2FReconfigure.go) 110 | - /gui/call/mower_service/mow_enabled [MowerControlSrv.go](pkg%2Fmsgs%2Fmower_msgs%2FMowerControlSrv.go) 111 | - /gui/call/mower_service/start_in_area [StartInAreaSrv.go](pkg%2Fmsgs%2Fmower_msgs%2FStartInAreaSrv.go) 112 | 113 | Do not forget to set env var MQTT_ENABLED to true 114 | 115 | ### Env variables 116 | 117 | - MOWER_CONFIG_FILE=mower_config.sh : config file location 118 | - DOCKER_HOST=unix:///var/run/docker.sock : socker socket 119 | - ROS_MASTER_URI=http://localhost:11311 : ros master uri 120 | - ROS_NODE_NAME=openmower-gui : node name 121 | - ROS_NODE_HOST=:4006 : listening port 122 | - MQTT_ENABLED=true : enable mqtt 123 | - MQTT_HOST=:1883 : listening port 124 | - HOMEKIT_ENABLED=true : enable homekit 125 | - MAP_TILE_ENABLED=true : enable map tiles 126 | - MAP_TILE_SERVER=http://localhost:5000 : custom map tile server (see https://github.com/2m/openmower-map-tiles for 127 | usage) 128 | - MAP_TILE_URI=/tiles/vt/lyrs=s,h&x={x}&y={y}&z={z} 129 | 130 | # Contributing 131 | 132 | PR are welcomed :-) 133 | 134 | You can run the gui into VSCode or WebStorm with devcontainer 135 | 136 | Then use make deps to install dependencies, open a terminal run make run-gui for the frontend and make run-backend for 137 | the backend 138 | 139 | To generate go msgs, just run inside the repository this docker command: 140 | 141 | docker run -v $PWD:/app ghcr.io/cedbossneo/openmower-gui:generate-msg 142 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | #schedule: 10 | # - cron: '30,0 * * * *' 11 | workflow_dispatch: 12 | push: 13 | branches: [ "master" ] 14 | pull_request: 15 | branches: 16 | - 'master' 17 | 18 | # permissions are needed if pushing to ghcr.io 19 | permissions: 20 | packages: write 21 | 22 | env: 23 | # Use docker.io for Docker Hub if empty 24 | REGISTRY: ghcr.io 25 | # github.repository as / 26 | REGISTRY_IMAGE: ghcr.io/${{ github.repository }} 27 | 28 | 29 | jobs: 30 | build: 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | arch: [ linux/arm64 , linux/amd64 ] 35 | include: 36 | - arch: linux/arm64 37 | builder: ${{ vars.CUSTOM_RUNNER_ARM64 || 'buildjet-4vcpu-ubuntu-2204-arm' }} 38 | - arch: linux/amd64 39 | builder: ${{ vars.CUSTOM_RUNNER_AMD64 || 'buildjet-4vcpu-ubuntu-2204' }} 40 | name: Build - ${{matrix.arch}} 41 | runs-on: ${{matrix.builder}} 42 | permissions: 43 | contents: read 44 | packages: write 45 | # This is used to complete the identity challenge 46 | # with sigstore/fulcio when running outside of PRs. 47 | id-token: write 48 | 49 | steps: 50 | # Login against a Docker registry except on PR 51 | # https://github.com/docker/login-action 52 | - name: Log into registry ${{ env.REGISTRY }} 53 | uses: docker/login-action@v2 54 | with: 55 | registry: ${{ env.REGISTRY }} 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | 59 | - name: Checkout repository 60 | uses: actions/checkout@v3 61 | 62 | # Extract metadata (tags, labels) for Docker 63 | # https://github.com/docker/metadata-action 64 | - name: Extract Docker metadata 65 | id: meta 66 | uses: docker/metadata-action@v4 67 | with: 68 | images: ${{ env.REGISTRY_IMAGE }} 69 | 70 | - name: Set up Docker Buildx 71 | uses: docker/setup-buildx-action@v2 72 | 73 | # Build and push Docker image with Buildx (don't push on PR) 74 | # https://github.com/docker/build-push-action 75 | - name: Build and push Docker image 76 | id: build-and-push 77 | uses: docker/build-push-action@v4 78 | with: 79 | platforms: ${{ matrix.arch }} 80 | context: . 81 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 82 | labels: ${{ steps.meta.outputs.labels }} 83 | cache-from: type=registry,ref=${{ env.REGISTRY_IMAGE }}:master 84 | cache-to: type=inline 85 | - name: Export digest 86 | run: | 87 | mkdir -p /tmp/digests 88 | digest="${{ steps.build-and-push.outputs.digest }}" 89 | touch "/tmp/digests/${digest#sha256:}" 90 | - name: sanitise arch name for artifact 91 | run: | 92 | clean=${{ matrix.arch }} 93 | clean=${clean////-} 94 | echo "ARTIFACT_NAME=digests-${clean}" >> $GITHUB_ENV 95 | - name: Upload digest 96 | uses: actions/upload-artifact@v4 97 | with: 98 | name: digests-${{ env.ARTIFACT_NAME }} 99 | path: /tmp/digests/* 100 | if-no-files-found: error 101 | retention-days: 1 102 | merge: 103 | runs-on: ubuntu-latest 104 | needs: 105 | - build 106 | steps: 107 | - name: Download digests 108 | uses: actions/download-artifact@v4 109 | with: 110 | pattern: digests-* 111 | merge-multiple: true 112 | path: /tmp/digests 113 | - name: Set up QEMU (arm64) 114 | if: runner.arch != 'arm64' 115 | uses: docker/setup-qemu-action@v2 116 | with: 117 | platforms: arm64 118 | - name: Set up QEMU (x64) 119 | if: runner.arch != 'x64' 120 | uses: docker/setup-qemu-action@v2 121 | with: 122 | platforms: amd64 123 | - name: Set up Docker Buildx 124 | uses: docker/setup-buildx-action@v2 125 | - name: Docker meta 126 | id: meta 127 | uses: docker/metadata-action@v4 128 | with: 129 | images: ${{ env.REGISTRY_IMAGE }} 130 | - name: Login to Docker Hub 131 | uses: docker/login-action@v2 132 | with: 133 | registry: ${{ env.REGISTRY }} 134 | username: ${{ github.actor }} 135 | password: ${{ secrets.GITHUB_TOKEN }} 136 | - name: Create manifest list and push 137 | working-directory: /tmp/digests 138 | run: | 139 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 140 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 141 | - name: Inspect image 142 | run: | 143 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} 144 | -------------------------------------------------------------------------------- /pkg/api/containers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/base64" 7 | "fmt" 8 | types2 "github.com/cedbossneo/openmower-gui/pkg/types" 9 | "github.com/docker/docker/api/types" 10 | "github.com/gin-gonic/gin" 11 | "github.com/gorilla/websocket" 12 | "github.com/samber/lo" 13 | "io" 14 | "log" 15 | "net/http" 16 | ) 17 | 18 | func ContainersRoutes(r *gin.RouterGroup, provider types2.IDockerProvider) { 19 | group := r.Group("/containers") 20 | ContainerListRoutes(group, provider) 21 | ContainerLogsRoutes(group, provider) 22 | ContainerCommandRoutes(group, provider) 23 | } 24 | 25 | // ContainerListRoutes list all containers 26 | // 27 | // @Name list 28 | // @Summary list all containers 29 | // @Description list all containers 30 | // @Tags containers 31 | // @Produce json 32 | // @Success 200 {object} ContainerListResponse 33 | // @Failure 500 {object} ErrorResponse 34 | // @Router /containers [get] 35 | func ContainerListRoutes(group *gin.RouterGroup, provider types2.IDockerProvider) { 36 | group.GET("/", func(c *gin.Context) { 37 | containers, err := provider.ContainerList(c.Request.Context()) 38 | if err != nil { 39 | c.JSON(500, ErrorResponse{Error: err.Error()}) 40 | return 41 | } 42 | c.JSON(200, ContainerListResponse{Containers: lo.Map(containers, func(container types.Container, idx int) Container { 43 | if container.Labels == nil { 44 | container.Labels = map[string]string{} 45 | } 46 | if lo.Contains(container.Names, "/openmower") { 47 | container.Labels["project"] = "openmower" 48 | container.Labels["app"] = "openmower" 49 | } 50 | if lo.Contains(container.Names, "/openmower-gui") { 51 | container.Labels["project"] = "openmower" 52 | container.Labels["app"] = "gui" 53 | } 54 | return Container{ 55 | ID: container.ID, 56 | Names: container.Names, 57 | Labels: container.Labels, 58 | State: container.State, 59 | } 60 | })}) 61 | }) 62 | } 63 | 64 | // ContainerCommandRoutes execute a command on a container 65 | // 66 | // @Summary execute a command on a container 67 | // @Description execute a command on a container 68 | // @Tags containers 69 | // @Produce json 70 | // @Param containerId path string true "container id" 71 | // @Param command path string true "command to execute (start/stop/restart)" 72 | // @Success 200 {object} OkResponse 73 | // @Failure 500 {object} ErrorResponse 74 | // @Router /containers/{containerId}/{command} [post] 75 | func ContainerCommandRoutes(group *gin.RouterGroup, provider types2.IDockerProvider) { 76 | group.POST("/:containerId/:command", func(c *gin.Context) { 77 | containerID := c.Param("containerId") 78 | command := c.Param("command") 79 | var err error 80 | 81 | switch command { 82 | case "restart": 83 | err = provider.ContainerRestart(c.Request.Context(), containerID) 84 | case "stop": 85 | err = provider.ContainerStop(c.Request.Context(), containerID) 86 | case "start": 87 | err = provider.ContainerStart(c.Request.Context(), containerID) 88 | } 89 | if err != nil { 90 | c.JSON(500, ErrorResponse{Error: err.Error()}) 91 | return 92 | } 93 | c.JSON(200, OkResponse{}) 94 | }) 95 | } 96 | 97 | // ContainerLogsRoutes stream container logs 98 | // 99 | // @Summary get container logs 100 | // @Description get container logs 101 | // @Tags containers 102 | // @Produce text/event-stream 103 | // @Param containerId path string true "container id" 104 | // @Router /containers/{containerId}/logs [get] 105 | func ContainerLogsRoutes(group *gin.RouterGroup, provider types2.IDockerProvider) { 106 | var upgrader = websocket.Upgrader{ 107 | ReadBufferSize: 1024, 108 | WriteBufferSize: 1024, 109 | CheckOrigin: func(r *http.Request) bool { return true }, 110 | } 111 | 112 | group.GET("/:containerId/logs", func(c *gin.Context) { 113 | containerID := c.Param("containerId") 114 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) 115 | if err != nil { 116 | return 117 | } 118 | defer func(conn *websocket.Conn) { 119 | err := conn.Close() 120 | if err != nil { 121 | fmt.Println("error closing websocket connection: ", err.Error()) 122 | } 123 | }(conn) 124 | 125 | /* 126 | read the logs from docker using docker SDK. be noticed that the Follow value must set to true. 127 | */ 128 | reader, err := provider.ContainerLogs(context.Background(), containerID) 129 | if err != nil { 130 | fmt.Println("error reader: ", err.Error()) 131 | return 132 | } 133 | defer func(reader io.ReadCloser) { 134 | err := reader.Close() 135 | if err != nil { 136 | fmt.Println("error closing reader: ", err.Error()) 137 | } 138 | }(reader) 139 | /* 140 | send log lines to channel 141 | */ 142 | rd := bufio.NewReader(reader) 143 | for { 144 | // read lines from the reader 145 | str, _, err := rd.ReadLine() 146 | if err != nil { 147 | log.Println("Read Error:", err.Error()) 148 | return 149 | } 150 | // send events to client 151 | err = conn.WriteMessage(websocket.TextMessage, []byte(base64.StdEncoding.EncodeToString(str))) 152 | if err != nil { 153 | log.Println("Write Error:", err.Error()) 154 | return 155 | } 156 | // send the lines to channel 157 | } 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cedbossneo/openmower-gui 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.1 6 | 7 | require ( 8 | git.mills.io/prologic/bitcask v1.0.2 9 | github.com/bluenviron/goroslib/v2 v2.1.4 10 | github.com/brutella/hap v0.0.31 11 | github.com/docker/distribution v2.8.2+incompatible 12 | github.com/docker/docker v20.10.24+incompatible 13 | github.com/gin-contrib/cors v1.4.0 14 | github.com/gin-contrib/static v0.0.1 15 | github.com/gin-gonic/gin v1.9.1 16 | github.com/go-git/go-git/v5 v5.8.1 17 | github.com/gorilla/websocket v1.5.0 18 | github.com/joho/godotenv v1.5.1 19 | github.com/mitchellh/mapstructure v1.5.0 20 | github.com/mochi-mqtt/server/v2 v2.4.0 21 | github.com/paulmach/orb v0.10.0 22 | github.com/samber/lo v1.38.1 23 | github.com/sirupsen/logrus v1.9.0 24 | github.com/stretchr/testify v1.8.4 25 | github.com/swaggo/files v1.0.1 26 | github.com/swaggo/gin-swagger v1.6.0 27 | github.com/swaggo/swag v1.16.1 28 | golang.org/x/sys v0.10.0 29 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 30 | ) 31 | 32 | require ( 33 | dario.cat/mergo v1.0.0 // indirect 34 | github.com/KyleBanks/depth v1.2.1 // indirect 35 | github.com/Microsoft/go-winio v0.6.1 // indirect 36 | github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect 37 | github.com/PuerkitoBio/purell v1.1.1 // indirect 38 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 39 | github.com/abcum/lcp v0.0.0-20201209214815-7a3f3840be81 // indirect 40 | github.com/acomagu/bufpipe v1.0.4 // indirect 41 | github.com/brutella/dnssd v1.2.10 // indirect 42 | github.com/bytedance/sonic v1.9.1 // indirect 43 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 44 | github.com/cloudflare/circl v1.3.3 // indirect 45 | github.com/davecgh/go-spew v1.1.1 // indirect 46 | github.com/docker/go-connections v0.4.0 // indirect 47 | github.com/docker/go-units v0.5.0 // indirect 48 | github.com/emirpasic/gods v1.18.1 // indirect 49 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 50 | github.com/gin-contrib/sse v0.1.0 // indirect 51 | github.com/go-chi/chi v1.5.4 // indirect 52 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 53 | github.com/go-git/go-billy/v5 v5.4.1 // indirect 54 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 55 | github.com/go-openapi/jsonreference v0.19.6 // indirect 56 | github.com/go-openapi/spec v0.20.4 // indirect 57 | github.com/go-openapi/swag v0.19.15 // indirect 58 | github.com/go-playground/locales v0.14.1 // indirect 59 | github.com/go-playground/universal-translator v0.18.1 // indirect 60 | github.com/go-playground/validator/v10 v10.14.0 // indirect 61 | github.com/goccy/go-json v0.10.2 // indirect 62 | github.com/gofrs/flock v0.8.0 // indirect 63 | github.com/gogo/protobuf v1.3.2 // indirect 64 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 65 | github.com/gookit/color v1.5.4 // indirect 66 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 67 | github.com/josharian/intern v1.0.0 // indirect 68 | github.com/json-iterator/go v1.1.12 // indirect 69 | github.com/kevinburke/ssh_config v1.2.0 // indirect 70 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 71 | github.com/leodido/go-urn v1.2.4 // indirect 72 | github.com/mailru/easyjson v0.7.6 // indirect 73 | github.com/mattn/go-isatty v0.0.19 // indirect 74 | github.com/miekg/dns v1.1.54 // indirect 75 | github.com/moby/term v0.5.0 // indirect 76 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 77 | github.com/modern-go/reflect2 v1.0.2 // indirect 78 | github.com/morikuni/aec v1.0.0 // indirect 79 | github.com/opencontainers/go-digest v1.0.0 // indirect 80 | github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b // indirect 81 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 82 | github.com/pjbgf/sha1cd v0.3.0 // indirect 83 | github.com/pkg/errors v0.9.1 // indirect 84 | github.com/plar/go-adaptive-radix-tree v1.0.4 // indirect 85 | github.com/pmezard/go-difflib v1.0.0 // indirect 86 | github.com/rs/xid v1.4.0 // indirect 87 | github.com/sergi/go-diff v1.1.0 // indirect 88 | github.com/skeema/knownhosts v1.2.0 // indirect 89 | github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect 90 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 91 | github.com/ugorji/go/codec v1.2.11 // indirect 92 | github.com/xanzy/ssh-agent v0.3.3 // indirect 93 | github.com/xiam/to v0.0.0-20200126224905-d60d31e03561 // indirect 94 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 95 | golang.org/x/arch v0.3.0 // indirect 96 | golang.org/x/crypto v0.11.0 // indirect 97 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect 98 | golang.org/x/mod v0.10.0 // indirect 99 | golang.org/x/net v0.12.0 // indirect 100 | golang.org/x/text v0.11.0 // indirect 101 | golang.org/x/time v0.3.0 // indirect 102 | golang.org/x/tools v0.9.1 // indirect 103 | google.golang.org/protobuf v1.30.0 // indirect 104 | gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect 105 | gopkg.in/warnings.v0 v0.1.2 // indirect 106 | gopkg.in/yaml.v2 v2.4.0 // indirect 107 | gopkg.in/yaml.v3 v3.0.1 // indirect 108 | gotest.tools/v3 v3.5.0 // indirect 109 | ) 110 | -------------------------------------------------------------------------------- /web/src/utils/ansi.ts: -------------------------------------------------------------------------------- 1 | // Reference to https://github.com/sindresorhus/ansi-regex 2 | const _regANSI = /(?:(?:\u001b\[)|\u009b)(?:(?:[0-9]{1,3})?(?:(?:;[0-9]{0,3})*)?[A-M|f-m])|\u001b[A-M]/; 3 | 4 | const _defColors: Record = { 5 | reset: ['fff', '000'], // [FOREGROUD_COLOR, BACKGROUND_COLOR] 6 | black: '000', 7 | red: 'ff0000', 8 | green: '209805', 9 | yellow: 'e8bf03', 10 | blue: '0000ff', 11 | magenta: 'ff00ff', 12 | cyan: '00ffee', 13 | lightgrey: 'f0f0f0', 14 | darkgrey: '888' 15 | }; 16 | const _styles: Record = { 17 | 30: 'black', 18 | 31: 'red', 19 | 32: 'green', 20 | 33: 'yellow', 21 | 34: 'blue', 22 | 35: 'magenta', 23 | 36: 'cyan', 24 | 37: 'lightgrey' 25 | }; 26 | const _openTags: Record = { 27 | '1': 'font-weight:bold', // bold 28 | '2': 'opacity:0.5', // dim 29 | '3': '', // italic 30 | '4': '', // underscore 31 | '8': 'display:none', // hidden 32 | '9': '' // delete 33 | }; 34 | const _closeTags: Record = { 35 | '23': '', // reset italic 36 | '24': '', // reset underscore 37 | '29': '' // reset delete 38 | }; 39 | 40 | [0, 21, 22, 27, 28, 39, 49].forEach(function (n) { 41 | _closeTags[n] = '' 42 | }) 43 | 44 | /** 45 | * Converts text with ANSI color codes to HTML markup. 46 | * @param {String} text 47 | * @returns {*} 48 | */ 49 | function ansiHTML(text: string) { 50 | // Returns the text if the string has no ANSI escape code. 51 | if (!_regANSI.test(text)) { 52 | return text 53 | } 54 | 55 | // Cache opened sequence. 56 | const ansiCodes: any[] = []; 57 | // Replace with markup. 58 | let ret = text.replace(/\x1b\[(\d+)m/g, function (_, seq) { 59 | const ot = _openTags[seq]; 60 | if (ot) { 61 | // If current sequence has been opened, close it. 62 | if (!!~ansiCodes.indexOf(seq)) { // eslint-disable-line no-extra-boolean-cast 63 | ansiCodes.pop() 64 | return '' 65 | } 66 | // Open tag. 67 | ansiCodes.push(seq) 68 | return ot[0] === '<' ? ot : '' 69 | } 70 | 71 | const ct = _closeTags[seq]; 72 | if (ct) { 73 | // Pop sequence 74 | ansiCodes.pop() 75 | return ct 76 | } 77 | return '' 78 | }); 79 | 80 | // Make sure tags are closed. 81 | const l = ansiCodes.length 82 | ;(l > 0) && (ret += Array(l + 1).join('')) 83 | 84 | return ret 85 | } 86 | 87 | /** 88 | * Customize colors. 89 | * @param {Object} colors reference to _defColors 90 | */ 91 | ansiHTML.setColors = function (colors: typeof _defColors) { 92 | if (typeof colors !== 'object') { 93 | throw new Error('`colors` parameter must be an Object.') 94 | } 95 | 96 | const _finalColors: Record = {}; 97 | for (let key in _defColors) { 98 | let hex = colors.hasOwnProperty(key) ? colors[key] : null; 99 | if (!hex) { 100 | _finalColors[key] = _defColors[key] 101 | continue 102 | } 103 | if ('reset' === key) { 104 | if (typeof hex === 'string') { 105 | hex = [hex] 106 | } 107 | if (!Array.isArray(hex) || hex.length === 0 || hex.some(function (h) { 108 | return typeof h !== 'string' 109 | })) { 110 | throw new Error('The value of `' + key + '` property must be an Array and each item could only be a hex string, e.g.: FF0000') 111 | } 112 | const defHexColor = _defColors[key]; 113 | if (!hex[0]) { 114 | hex[0] = defHexColor[0] 115 | } 116 | if (hex.length === 1 || !hex[1]) { 117 | hex = [hex[0]] 118 | hex.push(defHexColor[1]) 119 | } 120 | 121 | hex = hex.slice(0, 2) 122 | } else if (typeof hex !== 'string') { 123 | throw new Error('The value of `' + key + '` property must be a hex string, e.g.: FF0000') 124 | } 125 | _finalColors[key] = hex 126 | } 127 | _setTags(_finalColors) 128 | } 129 | 130 | /** 131 | * Reset colors. 132 | */ 133 | ansiHTML.reset = function () { 134 | _setTags(_defColors) 135 | } 136 | 137 | /** 138 | * Expose tags, including open and close. 139 | * @type {Object} 140 | */ 141 | ansiHTML.tags = { 142 | open: _openTags, 143 | close: _closeTags 144 | } 145 | 146 | function _setTags(colors: typeof _defColors) { 147 | // reset all 148 | _openTags['0'] = 'font-weight:normal;opacity:1;color:#' + colors.reset[0] + ';background:#' + colors.reset[1] 149 | // inverse 150 | _openTags['7'] = 'color:#' + colors.reset[1] + ';background:#' + colors.reset[0] 151 | // dark grey 152 | _openTags['90'] = 'color:#' + colors.darkgrey 153 | 154 | for (let code in _styles) { 155 | const color = _styles[code]; 156 | const oriColor = colors[color] || '000'; 157 | _openTags[code] = 'color:#' + oriColor 158 | _openTags[(parseInt(code) + 10).toString()] = 'background:#' + oriColor 159 | } 160 | } 161 | 162 | ansiHTML.reset() 163 | 164 | export default ansiHTML -------------------------------------------------------------------------------- /pkg/providers/mqtt.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/brutella/hap/accessory" 7 | "github.com/cedbossneo/openmower-gui/pkg/msgs/dynamic_reconfigure" 8 | "github.com/cedbossneo/openmower-gui/pkg/msgs/mower_msgs" 9 | types2 "github.com/cedbossneo/openmower-gui/pkg/types" 10 | mqtt "github.com/mochi-mqtt/server/v2" 11 | "github.com/mochi-mqtt/server/v2/hooks/auth" 12 | "github.com/mochi-mqtt/server/v2/listeners" 13 | "github.com/mochi-mqtt/server/v2/packets" 14 | "github.com/sirupsen/logrus" 15 | "golang.org/x/xerrors" 16 | "reflect" 17 | "time" 18 | 19 | "log" 20 | "os" 21 | "os/signal" 22 | "syscall" 23 | ) 24 | 25 | type MqttProvider struct { 26 | rosProvider types2.IRosProvider 27 | mower *accessory.Switch 28 | server *mqtt.Server 29 | dbProvider *DBProvider 30 | prefix string 31 | } 32 | 33 | func NewMqttProvider(rosProvider types2.IRosProvider, dbProvider *DBProvider) *MqttProvider { 34 | h := &MqttProvider{} 35 | h.rosProvider = rosProvider 36 | h.dbProvider = dbProvider 37 | h.Init() 38 | return h 39 | } 40 | 41 | func (hc *MqttProvider) Init() { 42 | hc.prefix = "/gui" 43 | dbPrefix, err := hc.dbProvider.Get("system.mqtt.prefix") 44 | if err == nil { 45 | hc.prefix = string(dbPrefix) 46 | } else { 47 | logrus.Error(xerrors.Errorf("Failed to get system.mqtt.prefix: %w", err)) 48 | } 49 | hc.launchServer() 50 | hc.subscribeToRos() 51 | hc.subscribeToMqtt() 52 | } 53 | 54 | func (hc *MqttProvider) launchServer() { 55 | // Create signals channel to run server until interrupted 56 | sigs := make(chan os.Signal, 1) 57 | done := make(chan bool, 1) 58 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 59 | go func() { 60 | <-sigs 61 | done <- true 62 | }() 63 | 64 | // Create the new MQTT Server. 65 | hc.server = mqtt.New(&mqtt.Options{ 66 | InlineClient: true, 67 | }) 68 | 69 | // Allow all connections. 70 | _ = hc.server.AddHook(new(auth.AllowHook), nil) 71 | 72 | // Create a TCP listener on a standard port. 73 | port, err := hc.dbProvider.Get("system.mqtt.host") 74 | if err != nil { 75 | log.Fatal(err) 76 | } 77 | tcp := listeners.NewTCP("t1", string(port), nil) 78 | err = hc.server.AddListener(tcp) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | go func() { 84 | err := hc.server.Serve() 85 | if err != nil { 86 | log.Fatal(err) 87 | } 88 | }() 89 | } 90 | 91 | func (hc *MqttProvider) subscribeToRos() { 92 | hc.subscribeToRosTopic("/mower_logic/current_state", "mqtt-mower-logic") 93 | hc.subscribeToRosTopic("/mower/status", "mqtt-mower-status") 94 | hc.subscribeToRosTopic("/xbot_positioning/xb_pose", "mqtt-pose") 95 | hc.subscribeToRosTopic("/xbot_driver_gps/xb_pose", "mqtt-gps") 96 | hc.subscribeToRosTopic("/imu/data_raw", "mqtt-imu") 97 | hc.subscribeToRosTopic("/mower/wheel_ticks", "mqtt-ticks") 98 | hc.subscribeToRosTopic("/xbot_monitoring/map", "mqtt-map") 99 | hc.subscribeToRosTopic("/slic3r_coverage_planner/path_marker_array", "mqtt-path") 100 | hc.subscribeToRosTopic("/move_base_flex/FTCPlanner/global_plan", "mqtt-plan") 101 | hc.subscribeToRosTopic("/mowing_path", "mqtt-mowing-path") 102 | } 103 | 104 | func (hc *MqttProvider) subscribeToRosTopic(topic string, id string) { 105 | err := hc.rosProvider.Subscribe(topic, id, func(msg []byte) { 106 | time.Sleep(500 * time.Millisecond) 107 | err := hc.server.Publish(hc.prefix+topic, msg, true, 0) 108 | if err != nil { 109 | logrus.Error(xerrors.Errorf("Failed to publish to %s: %w", topic, err)) 110 | } 111 | }) 112 | if err != nil { 113 | logrus.Error(xerrors.Errorf("Failed to subscribe to %s: %w", topic, err)) 114 | } 115 | } 116 | 117 | func (hc *MqttProvider) subscribeToMqtt() { 118 | subscribeToMqttCall(hc.server, hc.rosProvider, hc.prefix, "/mower_service/high_level_control", &mower_msgs.HighLevelControlSrv{}, &mower_msgs.HighLevelControlSrvReq{}, &mower_msgs.HighLevelControlSrvRes{}) 119 | subscribeToMqttCall(hc.server, hc.rosProvider, hc.prefix, "/mower_service/emergency", &mower_msgs.EmergencyStopSrv{}, &mower_msgs.EmergencyStopSrvReq{}, &mower_msgs.EmergencyStopSrvRes{}) 120 | subscribeToMqttCall(hc.server, hc.rosProvider, hc.prefix, "/mower_logic/set_parameters", &dynamic_reconfigure.Reconfigure{}, &dynamic_reconfigure.ReconfigureReq{}, &dynamic_reconfigure.ReconfigureRes{}) 121 | subscribeToMqttCall(hc.server, hc.rosProvider, hc.prefix, "/mower_service/mow_enabled", &mower_msgs.MowerControlSrv{}, &mower_msgs.MowerControlSrvReq{}, &mower_msgs.MowerControlSrvRes{}) 122 | subscribeToMqttCall(hc.server, hc.rosProvider, hc.prefix, "/mower_service/start_in_area", &mower_msgs.StartInAreaSrv{}, &mower_msgs.StartInAreaSrvReq{}, &mower_msgs.StartInAreaSrvRes{}) 123 | } 124 | 125 | func subscribeToMqttCall[SRV any, REQ any, RES any](server *mqtt.Server, rosProvider types2.IRosProvider, prefix, topic string, srv SRV, req REQ, res RES) { 126 | err := server.Subscribe(prefix+"/call"+topic, 1, func(cl *mqtt.Client, sub packets.Subscription, pk packets.Packet) { 127 | logrus.Info("Received " + topic) 128 | var newReq = reflect.New(reflect.TypeOf(req).Elem()).Interface() 129 | err := json.Unmarshal(pk.Payload, newReq) 130 | if err != nil { 131 | logrus.Error(xerrors.Errorf("Failed to unmarshal %s: %w", topic, err)) 132 | return 133 | } 134 | err = rosProvider.CallService(context.Background(), topic, srv, newReq, res) 135 | if err != nil { 136 | logrus.Error(xerrors.Errorf("Failed to call %s: %w", topic, err)) 137 | } 138 | }) 139 | if err != nil { 140 | logrus.Error(xerrors.Errorf("Failed to subscribe to %s: %w", topic, err)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /web/src/pages/LogsPage.tsx: -------------------------------------------------------------------------------- 1 | import {App, Col, Row, Select, Typography} from "antd"; 2 | import {useEffect, useState} from "react"; 3 | import Terminal, {ColorMode, TerminalOutput} from "react-terminal-ui"; 4 | import AsyncButton from "../components/AsyncButton.tsx"; 5 | import {useWS} from "../hooks/useWS.ts"; 6 | import {useApi} from "../hooks/useApi.ts"; 7 | import {StyledTerminal} from "../components/StyledTerminal.tsx"; 8 | import ansiHTML from "../utils/ansi.ts"; 9 | import {MowerActions} from "../components/MowerActions.tsx"; 10 | 11 | type ContainerList = { value: string, label: string, status: "started" | "stopped", labels: Record }; 12 | export const LogsPage = () => { 13 | const guiApi = useApi(); 14 | const {notification} = App.useApp(); 15 | const [containers, setContainers] = useState([]); 16 | const [containerId, setContainerId] = useState(undefined); 17 | const [data, setData] = useState([]) 18 | const stream = useWS(() => { 19 | notification.error({ 20 | message: "Logs stream closed", 21 | }); 22 | }, () => { 23 | console.log({ 24 | message: "Logs stream connected", 25 | }) 26 | }, (e, first) => { 27 | setData((data) => { 28 | if (first) { 29 | return [e]; 30 | } 31 | return [...data, e] 32 | }) 33 | }); 34 | 35 | async function listContainers() { 36 | try { 37 | const containers = await guiApi.containers.containersList(); 38 | if (containers.error) { 39 | throw new Error(containers.error.error) 40 | } 41 | let options = containers.data.containers?.flatMap((container) => { 42 | if (!container.names || !container.id) { 43 | return []; 44 | } 45 | return [{ 46 | label: container.labels?.app ? `${container.labels.app} ( ${container.names[0].replace("/", "")} )` : container.names[0].replace("/", ""), 47 | value: container.id, 48 | status: container.state == "running" ? "started" : "stopped", 49 | labels: container.labels ?? {} 50 | }] 51 | }); 52 | setContainers(options ?? []); 53 | if (!!options?.length && !containerId) { 54 | setContainerId(options[0].value) 55 | } 56 | } catch (e: any) { 57 | notification.error({ 58 | message: "Failed to list containers", 59 | description: e.message 60 | }) 61 | } 62 | } 63 | 64 | useEffect(() => { 65 | (async () => { 66 | await listContainers(); 67 | })(); 68 | }, []) 69 | 70 | useEffect(() => { 71 | if (containerId) { 72 | stream.start(`/api/containers/${containerId}/logs`); 73 | return () => { 74 | stream?.stop(); 75 | } 76 | } 77 | }, [containerId]) 78 | const commandContainer = (command: "start" | "stop" | "restart") => async () => { 79 | const messages = { 80 | "start": "Container started", 81 | "stop": "Container stopped", 82 | "restart": "Container restarted" 83 | }; 84 | try { 85 | if (containerId) { 86 | const res = await guiApi.containers.containersCreate(containerId, command); 87 | if (res.error) { 88 | throw new Error(res.error.error) 89 | } 90 | if (command === "start" || command === "restart") { 91 | stream.start(`/api/containers/${containerId}/logs`); 92 | } else { 93 | stream?.stop(); 94 | } 95 | await listContainers(); 96 | notification.success({ 97 | message: messages[command], 98 | }) 99 | } 100 | } catch (e: any) { 101 | notification.error({ 102 | message: `Failed to ${command} container`, 103 | description: e.message 104 | }) 105 | } 106 | }; 107 | const selectedContainer = containers.find((container) => container.value === containerId); 108 | return 109 | 110 | Container logs 111 | 112 | 113 | 114 | 115 | 116 | options={containers} value={containerId} style={{marginRight: 10}} onSelect={(value) => { 117 | setContainerId(value); 118 | }}/> 119 | { 120 | selectedContainer && selectedContainer.status === "started" && <> 121 | Restart 122 | Stop 124 | 125 | } 126 | { 127 | selectedContainer && selectedContainer.status === "stopped" && 128 | Start 129 | } 130 | 131 | 132 | 133 | 134 | {data.map((line, index) => { 135 | return 136 |
137 |
138 | })} 139 |
140 |
141 | 142 |
143 | } 144 | 145 | export default LogsPage; -------------------------------------------------------------------------------- /web/src/components/MowerActions.tsx: -------------------------------------------------------------------------------- 1 | import {useApi} from "../hooks/useApi.ts"; 2 | import {Card, Col, Divider, Row} from "antd"; 3 | import AsyncButton from "./AsyncButton.tsx"; 4 | import React from "react"; 5 | import styled from "styled-components"; 6 | import AsyncDropDownButton from "./AsyncDropDownButton.tsx"; 7 | import {useHighLevelStatus} from "../hooks/useHighLevelStatus.ts"; 8 | 9 | const ActionsCard = styled(Card)` 10 | .ant-card-body > button { 11 | margin-right: 10px; 12 | margin-bottom: 10px; 13 | } 14 | `; 15 | 16 | export const useMowerAction = () => { 17 | const guiApi = useApi() 18 | return (command: string, args: Record = {}) => async () => { 19 | try { 20 | const res = await guiApi.openmower.callCreate(command, args) 21 | if (res.error) { 22 | throw new Error(res.error.error) 23 | } 24 | } catch (e: any) { 25 | throw new Error(e.message) 26 | } 27 | }; 28 | }; 29 | 30 | export const MowerActions: React.FC = (props) => { 31 | const {highLevelStatus} = useHighLevelStatus(); 32 | const mowerAction = useMowerAction() 33 | const actionMenuItems: { 34 | key: string, 35 | label: string, 36 | actions: { command: string, args: any }[], 37 | danger?: boolean 38 | }[] = [ 39 | { 40 | key: "mower_s1", 41 | label: "Area Recording", 42 | actions: [{ 43 | command: "high_level_control", 44 | args: { 45 | Command: 3, 46 | } 47 | }] 48 | }, 49 | { 50 | key: "mower_s2", 51 | label: "Mow Next Area", 52 | actions: [{ 53 | command: "high_level_control", 54 | args: { 55 | Command: 4, 56 | } 57 | }] 58 | }, 59 | { 60 | key: highLevelStatus.StateName == "IDLE" ? "continue" : "pause", 61 | label: highLevelStatus.StateName == "IDLE" ? "Continue" : "Pause", 62 | actions: highLevelStatus.StateName == "IDLE" ? [{ 63 | command: "mower_logic", args: { 64 | Config: { 65 | Bools: [{ 66 | Name: "manual_pause_mowing", 67 | Value: false 68 | }] 69 | } 70 | } 71 | }, { 72 | command: "high_level_control", 73 | args: { 74 | Command: 1, 75 | } 76 | }] : [{ 77 | command: "mower_logic", args: { 78 | Config: { 79 | Bools: [{ 80 | Name: "manual_pause_mowing", 81 | Value: true 82 | }] 83 | } 84 | } 85 | }] 86 | }, 87 | { 88 | key: "emergency_off", 89 | "label": "Emergency Off", 90 | "danger": true, 91 | actions: [{ 92 | command: "emergency", 93 | args: { 94 | Emergency: 0, 95 | } 96 | }] 97 | }, 98 | { 99 | key: "mow_forward", 100 | "label": "Blade Forward", 101 | actions: [{ 102 | command: "mow_enabled", 103 | args: {MowEnabled: 1, MowDirection: 0} 104 | }] 105 | }, 106 | { 107 | key: "mow_backward", 108 | "label": "Blade Backward", 109 | actions: [{ 110 | command: "mow_enabled", 111 | args: {MowEnabled: 1, MowDirection: 1} 112 | }] 113 | }, 114 | { 115 | key: "mow_off", 116 | "label": "Blade Off", 117 | "danger": true, 118 | actions: [{ 119 | command: "mow_enabled", 120 | args: {MowEnabled: 0, MowDirection: 0} 121 | }] 122 | }, 123 | ]; 124 | let children = props.children; 125 | if (children && Array.isArray(children)) { 126 | children = children.map(c => { 127 | return c ? {c} : null 128 | }) 129 | } else if (children) { 130 | children = {children} 131 | } 132 | return 133 | 134 | {children} 135 | {children ? : null} 136 | 137 | {highLevelStatus.StateName == "IDLE" ? Start : null} 140 | {highLevelStatus.StateName !== "IDLE" ? Home : null} 143 | 144 | 145 | {!highLevelStatus.Emergency ? 146 | Emergency On : null} 148 | {highLevelStatus.Emergency ? 149 | Emergency Off : null} 151 | 152 | 153 | { 156 | const item = actionMenuItems.find(item => item.key == e.key) 157 | for (const action of (item?.actions ?? [])) { 158 | await mowerAction(action.command, action.args)(); 159 | } 160 | } 161 | }}> 162 | More 163 | 164 | 165 | 166 | ; 167 | }; -------------------------------------------------------------------------------- /web/src/types/map.ts: -------------------------------------------------------------------------------- 1 | 2 | import type {BBox, Feature, Polygon, Point, Position, LineString} from 'geojson'; 3 | import {MapArea, Point32} from "../types/ros.ts"; 4 | 5 | import {transpose} from "../utils/map.tsx"; 6 | 7 | export class MowingFeature { 8 | id: string; 9 | type: 'Feature'; 10 | 11 | constructor(id: string) { 12 | this.type = 'Feature'; 13 | this.id = id; 14 | } 15 | } 16 | 17 | export class PointFeatureBase extends MowingFeature implements Feature { 18 | 19 | geometry: Point; 20 | properties: { 21 | color: string, 22 | feature_type: string 23 | } 24 | 25 | constructor(id: string, coordinate: Position, feature_type:string) { 26 | super(id); 27 | 28 | this.properties = { 29 | color : 'black', 30 | feature_type: feature_type 31 | }; 32 | this.geometry = {type:'Point', coordinates: coordinate} as Point; 33 | } 34 | 35 | setColor(color:string) { 36 | this.properties.color = color; 37 | } 38 | } 39 | 40 | export class LineFeatureBase extends MowingFeature implements Feature { 41 | 42 | geometry: LineString; 43 | properties: { 44 | color: string, 45 | width: number, 46 | feature_type: string 47 | } 48 | 49 | constructor(id: string, coordinates: Position[], color: string, feature_type:string) { 50 | super(id); 51 | 52 | this.properties = { 53 | color : color, 54 | width : 1, 55 | feature_type: feature_type 56 | }; 57 | this.geometry = {type:'LineString', coordinates: coordinates} as LineString; 58 | } 59 | } 60 | 61 | export class PathFeature extends LineFeatureBase { 62 | constructor(id: string, coordinates: Position[], color: string, lineWidth = 1) { 63 | super(id, coordinates,color, 'path'); 64 | this.properties.width = lineWidth; 65 | } 66 | } 67 | 68 | export class ActivePathFeature extends LineFeatureBase { 69 | constructor(id: string, coordinates: Position[]) { 70 | super(id, coordinates, 'orange', 'active_path'); 71 | this.properties.width = 3; 72 | } 73 | } 74 | 75 | export class MowerFeatureBase extends PointFeatureBase { 76 | constructor(coordinate: Position) { 77 | super('mower', coordinate,'mower'); 78 | this.setColor('#00a6ff'); 79 | } 80 | } 81 | 82 | export class DockFeatureBase extends PointFeatureBase { 83 | constructor(coordinate: Position) { 84 | super('dock', coordinate,'dock'); 85 | this.setColor('#ff00f2'); 86 | } 87 | } 88 | 89 | 90 | export class MowingFeatureBase extends MowingFeature implements Feature { 91 | geometry: Polygon; 92 | 93 | properties: { 94 | color: string 95 | , name? :string 96 | , index: number 97 | , mowing_order: number 98 | , feature_type: string 99 | } 100 | bbox?: BBox | undefined; 101 | 102 | 103 | constructor(id: string, feature_type: string) { 104 | super(id) 105 | this.type = 'Feature'; 106 | this.properties = { 107 | color : 'black' 108 | , index : 0 109 | , mowing_order:9999 110 | , feature_type: feature_type 111 | }; 112 | this.geometry = {type:'Polygon', coordinates:[]} as Polygon; 113 | } 114 | 115 | setGeometry(geometry: Polygon) { 116 | this.geometry = geometry; 117 | } 118 | 119 | transpose( points: Point32[], offsetX: number, offsetY: number, datum: [number,number,number]) { 120 | this.geometry.coordinates = [points.map((point) => { 121 | return transpose(offsetX, offsetY, datum, point.Y||0, point.X||0) 122 | })]; 123 | } 124 | 125 | 126 | 127 | setColor(color: string) : MowingFeatureBase { 128 | this.properties.color = color; 129 | return this; 130 | } 131 | } 132 | 133 | 134 | export class ObstacleFeature extends MowingFeatureBase { 135 | mowing_area: MowingAreaFeature; 136 | 137 | constructor(id: string, mowing_area: MowingAreaFeature) { 138 | super(id, 'obstacle'); 139 | this.setColor("#bf0000"); 140 | this.mowing_area = mowing_area; 141 | } 142 | 143 | getMowingArea() : MowingAreaFeature { 144 | return this.mowing_area; 145 | } 146 | 147 | } 148 | 149 | export class MapAreaFeature extends MowingFeatureBase { 150 | area?: MapArea; 151 | 152 | constructor(id: string, feature_type: string) { 153 | super(id, feature_type); 154 | } 155 | 156 | setArea( area: MapArea, offsetX: number, offsetY: number, datum: [number,number,number]) { 157 | this.area = area; 158 | this.transpose(area.Area?.Points??[], offsetX, offsetY, datum); 159 | } 160 | 161 | 162 | getArea(): MapArea | undefined { 163 | return this.area; 164 | } 165 | } 166 | 167 | 168 | export class NavigationFeature extends MapAreaFeature { 169 | constructor(id: string) { 170 | super(id, 'navigation'); 171 | this.setColor("white"); 172 | } 173 | } 174 | 175 | export class MowingAreaFeature extends MapAreaFeature { 176 | 177 | //mowing_order: number; 178 | 179 | 180 | constructor(id: string, mowing_order: number ) { 181 | super(id, 'workarea'); 182 | this.properties.mowing_order = mowing_order; 183 | 184 | this.setName(''); 185 | this.setColor("#01d30d"); 186 | 187 | } 188 | 189 | setArea( area: MapArea, offsetX: number , offsetY: number, datum: [number,number,number] ) { 190 | super.setArea(area, offsetX, offsetY, datum); 191 | this.setName(area.Name ?? '') 192 | } 193 | 194 | 195 | setName(name: string) : MowingAreaFeature { 196 | this.properties['name'] = name; 197 | if (this.area) 198 | this.area.Name = name; 199 | return this; 200 | } 201 | 202 | getName() : string { 203 | return this.properties?.name ? this.properties?.name : ''; 204 | } 205 | 206 | 207 | getMowingOrder() : number { 208 | return this.properties.mowing_order; 209 | } 210 | 211 | setMowingOrder(val: number) : MowingAreaFeature{ 212 | this.properties.mowing_order = val; 213 | return this; 214 | } 215 | 216 | getIndex() : number { 217 | return this.properties.mowing_order-1; 218 | } 219 | 220 | getLabel() : string { 221 | const name = this.getName(); 222 | return name ? name + " (" + this.getMowingOrder().toString() +")" : "Area " + this.getMowingOrder().toString(); 223 | } 224 | 225 | 226 | } -------------------------------------------------------------------------------- /pkg/providers/firmware.go: -------------------------------------------------------------------------------- 1 | package providers 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/cedbossneo/openmower-gui/pkg/types" 7 | "github.com/go-git/go-git/v5" 8 | "github.com/go-git/go-git/v5/plumbing" 9 | "golang.org/x/sys/execabs" 10 | "golang.org/x/xerrors" 11 | "io" 12 | "os" 13 | "text/template" 14 | ) 15 | 16 | type FirmwareProvider struct { 17 | db types.IDBProvider 18 | } 19 | 20 | func NewFirmwareProvider(db types.IDBProvider) *FirmwareProvider { 21 | u := &FirmwareProvider{ 22 | db: db, 23 | } 24 | return u 25 | } 26 | 27 | // BuildBoardHeader Open file ../../setup/board.h, apply go template to it with config and return the result 28 | func (fp *FirmwareProvider) buildBoardHeader(templateFile string, config types.FirmwareConfig) ([]byte, error) { 29 | if config.BatChargeCutoffVoltage > 29 { 30 | config.BatChargeCutoffVoltage = 29 31 | } 32 | if config.MaxChargeVoltage > 29 { 33 | config.MaxChargeVoltage = 29 34 | } 35 | if config.LimitVoltage150MA > 29 { 36 | config.LimitVoltage150MA = 29 37 | } 38 | if config.MaxChargeCurrent > 1.5 { 39 | config.MaxChargeCurrent = 1.5 40 | } 41 | files, err := template.ParseFiles(templateFile) 42 | if err != nil { 43 | return nil, err 44 | } 45 | buffer := bytes.NewBuffer(nil) 46 | err = files.Execute(buffer, config) 47 | if err != nil { 48 | return nil, err 49 | } 50 | return buffer.Bytes(), nil 51 | } 52 | 53 | func (fp *FirmwareProvider) FlashFirmware(writer io.Writer, config types.FirmwareConfig) error { 54 | if config.Repository == "" && config.File == "" { 55 | return xerrors.Errorf("repository or file is required") 56 | } 57 | if config.Branch == "" && config.Repository != "" { 58 | return xerrors.Errorf("branch is empty") 59 | } 60 | configJson, err := json.Marshal(config) 61 | if err != nil { 62 | return err 63 | } 64 | err = fp.db.Set("gui.firmware.config", configJson) 65 | if err != nil { 66 | return err 67 | } 68 | switch config.BoardType { 69 | case "BOARD_VERMUT_YARDFORCE500": 70 | return fp.flashVermut(writer, config) 71 | default: 72 | return fp.flashMowgli(writer, config) 73 | } 74 | } 75 | 76 | func (fp *FirmwareProvider) flashMowgli(writer io.Writer, config types.FirmwareConfig) error { 77 | _, _ = writer.Write([]byte("------> Cloning repository " + config.Repository + "@" + config.Branch + "...\n")) 78 | //Clone git repository, checkout branch, build board.h, build firmware with platformio, flash firmware with platformio 79 | referenceName := plumbing.ReferenceName("refs/heads/" + config.Branch) 80 | //Check if repository is already cloned 81 | _, err := os.Stat(os.TempDir() + "/mowgli") 82 | if err == nil { 83 | //Branch is not correct, delete repository 84 | err = os.RemoveAll(os.TempDir() + "/mowgli") 85 | if err != nil { 86 | _, _ = writer.Write([]byte("------> Error while removing repository: " + err.Error() + "\n")) 87 | return xerrors.Errorf("error while removing repository: %w", err) 88 | } 89 | } 90 | _, err = git.PlainClone(os.TempDir()+"/mowgli", false, &git.CloneOptions{ 91 | URL: config.Repository, 92 | SingleBranch: true, 93 | ReferenceName: referenceName, 94 | Progress: writer, 95 | }) 96 | if err != nil { 97 | _, _ = writer.Write([]byte("------> Error while cloning repository: " + err.Error() + "\n")) 98 | return xerrors.Errorf("error while cloning repository: %w", err) 99 | } 100 | _, _ = writer.Write([]byte("------> Repository cloned\n")) 101 | //Build board.h 102 | _, _ = writer.Write([]byte("------> Building board.h...\n")) 103 | boardTemplated, err := fp.buildBoardHeader(os.TempDir()+"/mowgli/stm32/ros_usbnode/include/board.h.template", config) 104 | if err != nil { 105 | _, _ = writer.Write([]byte("------> Error while building board.h: " + err.Error() + "\n")) 106 | return xerrors.Errorf("error while building board.h: %w", err) 107 | } 108 | err = os.WriteFile(os.TempDir()+"/mowgli/stm32/ros_usbnode/include/board.h", boardTemplated, 0644) 109 | if err != nil { 110 | _, _ = writer.Write([]byte("------> Error while writing board.h: " + err.Error() + "\n")) 111 | return xerrors.Errorf("error while writing board.h: %w", err) 112 | } 113 | _, _ = writer.Write([]byte("------> board.h built\n")) 114 | //Build firmware 115 | _, _ = writer.Write([]byte("------> Building and uploading firmware...\n")) 116 | cmd := execabs.Command("/bin/bash", "-c", "platformio run -t upload") 117 | cmd.Dir = os.TempDir() + "/mowgli/stm32/ros_usbnode" 118 | cmd.Stdout = writer 119 | cmd.Stderr = writer 120 | err = cmd.Run() 121 | if err != nil { 122 | _, _ = writer.Write([]byte("------> Error while building and uploading firmware: " + err.Error() + "\n")) 123 | return xerrors.Errorf("error while flashing firmware: %w", err) 124 | } 125 | _, _ = writer.Write([]byte("------> Firmware flashed\n")) 126 | return nil 127 | } 128 | 129 | func (fp *FirmwareProvider) flashVermut(writer io.Writer, config types.FirmwareConfig) error { 130 | // Download the firmware from https://github.com/ClemensElflein/OpenMower/releases/download/latest/firmware.zip to /tmp/firmware.zip 131 | // Unzip /tmp/firmware.zip to /tmp/firmware 132 | // Flash the firmware by running command openocd -f interface/raspberrypi-swd.cfg -f target/rp2040.cfg -c "program ./firmware_download/firmware/$OM_HARDWARE_VERSION/firmware.elf verify reset exit" 133 | 134 | _, _ = writer.Write([]byte("------> Downloading firmware...\n")) 135 | cmd := execabs.Command("/bin/bash", "-c", "wget -O "+os.TempDir()+"/firmware.zip "+config.File) 136 | cmd.Stdout = writer 137 | cmd.Stderr = writer 138 | err := cmd.Run() 139 | if err != nil { 140 | _, _ = writer.Write([]byte("------> Error while downloading firmware: " + err.Error() + "\n")) 141 | return xerrors.Errorf("error while downloading firmware: %w", err) 142 | } 143 | _, _ = writer.Write([]byte("------> Firmware downloaded\n")) 144 | 145 | _, _ = writer.Write([]byte("------> Unzipping firmware...\n")) 146 | cmd = execabs.Command("/bin/bash", "-c", "unzip -o "+os.TempDir()+"/firmware.zip -d "+os.TempDir()+"/firmware") 147 | cmd.Stdout = writer 148 | cmd.Stderr = writer 149 | err = cmd.Run() 150 | if err != nil { 151 | _, _ = writer.Write([]byte("------> Error while unzipping firmware: " + err.Error() + "\n")) 152 | return xerrors.Errorf("error while unzipping firmware: %w", err) 153 | } 154 | _, _ = writer.Write([]byte("------> Firmware unzipped\n")) 155 | 156 | _, _ = writer.Write([]byte("------> Flashing firmware...\n")) 157 | cmd = execabs.Command("/bin/bash", "-c", "echo \"10\" > /sys/class/gpio/export && echo \"out\" > /sys/class/gpio/gpio10/direction && echo \"1\" > /sys/class/gpio/gpio10/value && openocd -f interface/raspberrypi-swd.cfg -f target/rp2040.cfg -c \"program "+os.TempDir()+"/firmware/firmware/"+config.Version+"/firmware.elf verify reset exit\"") 158 | cmd.Stdout = writer 159 | cmd.Stderr = writer 160 | err = cmd.Run() 161 | if err != nil { 162 | _, _ = writer.Write([]byte("------> Error while flashing firmware: " + err.Error() + "\n")) 163 | return xerrors.Errorf("error while flashing firmware: %w", err) 164 | } 165 | _, _ = writer.Write([]byte("------> Firmware flashed\n")) 166 | return nil 167 | } 168 | -------------------------------------------------------------------------------- /web/src/types/ros.ts: -------------------------------------------------------------------------------- 1 | export type ColorRGBA = { 2 | R: number 3 | G: number 4 | B: number 5 | A: number 6 | } 7 | 8 | export type Joy = { 9 | /* 10 | Axes []float32 11 | Buttons []int32 12 | */ 13 | Axes?: number[] 14 | Buttons?: number[] 15 | } 16 | 17 | export type Marker = { 18 | /* 19 | Header std_msgs.Header 20 | Ns string 21 | Id int32 22 | Type int32 23 | Action int32 24 | Pose geometry_msgs.Pose 25 | Scale geometry_msgs.Vector3 26 | Color std_msgs.ColorRGBA 27 | Lifetime time.Duration 28 | FrameLocked bool 29 | Points []geometry_msgs.Point 30 | Colors []std_msgs.ColorRGBA 31 | Text string 32 | MeshResource string 33 | MeshUseEmbeddedMaterials bool 34 | */ 35 | Ns: string 36 | Id: number 37 | Type: number 38 | Action: number 39 | Pose: Pose 40 | Scale: Vector3 41 | Color: ColorRGBA 42 | Lifetime: number 43 | FrameLocked: boolean 44 | Points: Point[] 45 | Colors: ColorRGBA[] 46 | Text: string 47 | MeshResource: string 48 | MeshUseEmbeddedMaterials: boolean 49 | } 50 | 51 | export type PoseStamped = { 52 | /* 53 | Pose Pose 54 | */ 55 | Pose?: Pose 56 | } 57 | 58 | export type Path = { 59 | /* 60 | Poses []geometry_msgs.PoseStamped 61 | 62 | */ 63 | Poses?: PoseStamped[] 64 | } 65 | 66 | export type MarkerArray = { 67 | Markers: Marker[] 68 | } 69 | 70 | export type Point32 = { 71 | /* 72 | X float32 73 | Y float32 74 | Z float32 75 | */ 76 | X?: number 77 | Y?: number 78 | Z?: number 79 | } 80 | 81 | export type Twist = { 82 | Linear?: Vector3 83 | Angular?: Vector3 84 | } 85 | 86 | export type Polygon = { 87 | /* 88 | Points []Point32 89 | */ 90 | Points?: Point32[] 91 | } 92 | 93 | export type MapArea = { 94 | /* 95 | Name string 96 | Area geometry_msgs.Polygon 97 | Obstacles []geometry_msgs.Polygon 98 | */ 99 | Name?: string 100 | Area?: Polygon 101 | Obstacles?: Polygon[] 102 | } 103 | 104 | export type Map = { 105 | /* 106 | MapWidth float64`rosname:"mapWidth"` 107 | MapHeight float64`rosname:"mapHeight"` 108 | MapCenterX float64`rosname:"mapCenterX"` 109 | MapCenterY float64 `rosname:"mapCenterY"` 110 | NavigationAreas []MapArea `rosname:"navigationAreas"` 111 | WorkingArea []MapArea `rosname:"workingArea"` 112 | DockX float64 `rosname:"dockX"` 113 | DockY float64`rosname:"dockY"` 114 | DockHeading float64`rosname:"dockHeading"` 115 | */ 116 | MapWidth?: number 117 | MapHeight?: number 118 | MapCenterX?: number 119 | MapCenterY?: number 120 | NavigationAreas?: MapArea[] 121 | WorkingArea?: MapArea[] 122 | DockX?: number 123 | DockY?: number 124 | DockHeading?: number 125 | } 126 | 127 | export type WheelTick = { 128 | /* 129 | WheelTickFactor uint32 130 | ValidWheels uint8 131 | WheelDirectionFl uint8 132 | WheelTicksFl uint32 133 | WheelDirectionFr uint8 134 | WheelTicksFr uint32 135 | WheelDirectionRl uint8 136 | WheelTicksRl uint32 137 | WheelDirectionRr uint8 138 | WheelTicksRr uint32 139 | */ 140 | WheelTickFactor?: number 141 | ValidWheels?: number 142 | WheelDirectionFl?: number 143 | WheelTicksFl?: number 144 | WheelDirectionFr?: number 145 | WheelTicksFr?: number 146 | WheelDirectionRl?: number 147 | WheelTicksRl?: number 148 | WheelDirectionRr?: number 149 | WheelTicksRr?: number 150 | } 151 | 152 | export type Status = { 153 | MowerStatus?: number 154 | RaspberryPiPower?: boolean 155 | GpsPower?: boolean 156 | EscPower?: boolean 157 | RainDetected?: boolean 158 | SoundModuleAvailable?: boolean 159 | SoundModuleBusy?: boolean 160 | UiBoardAvailable?: boolean 161 | UltrasonicRanges?: [number, number, number, number, number] 162 | Emergency?: boolean 163 | VCharge?: number 164 | VBattery?: number 165 | ChargeCurrent?: number 166 | LeftEscStatus?: ESCStatus 167 | RightEscStatus?: ESCStatus 168 | MowEscStatus?: ESCStatus 169 | MowEnabled?: boolean 170 | } 171 | 172 | export type ESCStatus = { 173 | Status?: string 174 | Current?: number 175 | Tacho?: number 176 | Rpm?: number 177 | TemperatureMotor?: number 178 | TemperaturePcb?: number 179 | } 180 | 181 | export type Point = { 182 | /* 183 | X float64 184 | Y float64 185 | Z float64 186 | */ 187 | X?: number 188 | Y?: number 189 | Z?: number 190 | } 191 | 192 | export type Quaternion = { 193 | /* 194 | X float64 195 | Y float64 196 | Z float64 197 | W float64 198 | */ 199 | X?: number 200 | Y?: number 201 | Z?: number 202 | W?: number 203 | } 204 | 205 | export type Pose = { 206 | /* 207 | Position Point 208 | Orientation Quaternion 209 | */ 210 | Position?: Point 211 | Orientation?: Quaternion 212 | } 213 | 214 | export type PoseWithCovariance = { 215 | /* 216 | Pose Pose 217 | Covariance [36]float64 218 | */ 219 | Pose?: Pose 220 | Covariance?: number[] 221 | } 222 | 223 | export type Vector3 = { 224 | /* 225 | X float64 226 | Y float64 227 | Z float64 228 | */ 229 | X?: number 230 | Y?: number 231 | Z?: number 232 | } 233 | 234 | export type HighLevelStatus = { 235 | /* 236 | State uint8 237 | StateName string 238 | SubStateName string 239 | GpsQualityPercent float32 240 | BatteryPercent float32 241 | IsCharging bool 242 | Emergency bool 243 | */ 244 | State?: number 245 | StateName?: string 246 | SubStateName?: string 247 | GpsQualityPercent?: number 248 | BatteryPercent?: number 249 | IsCharging?: boolean 250 | Emergency?: boolean 251 | CurrentArea?: number 252 | CurrentPath?: number 253 | CurrentPathIndex?: number 254 | } 255 | 256 | export const enum AbsolutePoseFlags { 257 | RTK = 1, 258 | FIXED = 2, 259 | FLOAT = 4, 260 | DEAD_RECKONING = 8, 261 | } 262 | 263 | export type AbsolutePose = { 264 | /* 265 | SensorStamp uint32 266 | ReceivedStamp uint32 267 | Source uint8 268 | Flags uint16 269 | OrientationValid uint8 270 | MotionVectorValid uint8 271 | PositionAccuracy float32 272 | OrientationAccuracy float32 273 | Pose geometry_msgs.PoseWithCovariance 274 | MotionVector geometry_msgs.Vector3 275 | VehicleHeading float64 276 | MotionHeading float64 277 | */ 278 | SensorStamp?: number 279 | ReceivedStamp?: number 280 | Source?: number 281 | Flags?: AbsolutePoseFlags 282 | OrientationValid?: number 283 | MotionVectorValid?: number 284 | PositionAccuracy?: number 285 | OrientationAccuracy?: number 286 | Pose?: PoseWithCovariance 287 | MotionVector?: Vector3 288 | VehicleHeading?: number 289 | MotionHeading?: number 290 | } 291 | 292 | export type Imu = { 293 | /* 294 | Orientation geometry_msgs.Quaternion 295 | OrientationCovariance [9]float64 296 | AngularVelocity geometry_msgs.Vector3 297 | AngularVelocityCovariance [9]float64 298 | LinearAcceleration geometry_msgs.Vector3 299 | LinearAccelerationCovariance [9]float64 300 | */ 301 | Orientation?: Quaternion 302 | OrientationCovariance?: number[] 303 | AngularVelocity?: Vector3 304 | AngularVelocityCovariance?: number[] 305 | LinearAcceleration?: Vector3 306 | LinearAccelerationCovariance?: number[] 307 | } 308 | -------------------------------------------------------------------------------- /web/src/pages/MapStyle.tsx: -------------------------------------------------------------------------------- 1 | export const MapStyle = [ 2 | { 3 | 'id': 'gl-draw-polygon-fill-inactive', 4 | 'type': 'fill', 5 | 'filter': ['all', 6 | ['==', 'active', 'false'], 7 | ['==', '$type', 'Polygon'], 8 | ['!=', 'mode', 'static'] 9 | ], 10 | 'paint': { 11 | 'fill-color': '#3bb2d0', 12 | 'fill-outline-color': '#3bb2d0', 13 | 'fill-opacity': 0.1 14 | } 15 | }, 16 | { 17 | 'id': 'gl-draw-polygon-fill-active', 18 | 'type': 'fill', 19 | 'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], 20 | 'paint': { 21 | 'fill-color': '#fbb03b', 22 | 'fill-outline-color': '#fbb03b', 23 | 'fill-opacity': 0.1 24 | } 25 | }, 26 | { 27 | 'id': 'gl-draw-polygon-midpoint', 28 | 'type': 'circle', 29 | 'filter': ['all', 30 | ['==', '$type', 'Point'], 31 | ['==', 'meta', 'midpoint']], 32 | 'paint': { 33 | 'circle-radius': 3, 34 | 'circle-color': '#fbb03b' 35 | } 36 | }, 37 | { 38 | 'id': 'gl-draw-polygon-stroke-inactive', 39 | 'type': 'line', 40 | 'filter': ['all', 41 | ['==', 'active', 'false'], 42 | ['==', '$type', 'Polygon'], 43 | ['!=', 'mode', 'static'] 44 | ], 45 | 'layout': { 46 | 'line-cap': 'round', 47 | 'line-join': 'round' 48 | }, 49 | 'paint': { 50 | 'line-color': '#3bb2d0', 51 | 'line-width': 2 52 | } 53 | }, 54 | { 55 | 'id': 'gl-draw-polygon-stroke-active', 56 | 'type': 'line', 57 | 'filter': ['all', ['==', 'active', 'true'], ['==', '$type', 'Polygon']], 58 | 'layout': { 59 | 'line-cap': 'round', 60 | 'line-join': 'round' 61 | }, 62 | 'paint': { 63 | 'line-color': '#fbb03b', 64 | 'line-dasharray': [0.2, 2], 65 | 'line-width': 2 66 | } 67 | }, 68 | { 69 | 'id': 'gl-draw-line-inactive', 70 | 'type': 'line', 71 | 'filter': ['all', 72 | ['==', 'active', 'false'], 73 | ['==', '$type', 'LineString'], 74 | ['!=', 'mode', 'static'] 75 | ], 76 | 'layout': { 77 | 'line-cap': 'round', 78 | 'line-join': 'round' 79 | }, 80 | 'paint': { 81 | 'line-color': '#3bb2d0', 82 | 'line-width': 2 83 | } 84 | }, 85 | { 86 | 'id': 'gl-draw-line-active', 87 | 'type': 'line', 88 | 'filter': ['all', 89 | ['==', '$type', 'LineString'], 90 | ['==', 'active', 'true'] 91 | ], 92 | 'layout': { 93 | 'line-cap': 'round', 94 | 'line-join': 'round' 95 | }, 96 | 'paint': { 97 | 'line-color': '#fbb03b', 98 | 'line-dasharray': [0.2, 2], 99 | 'line-width': 2 100 | } 101 | }, 102 | { 103 | 'id': 'gl-draw-polygon-and-line-vertex-stroke-inactive', 104 | 'type': 'circle', 105 | 'filter': ['all', 106 | ['==', 'meta', 'vertex'], 107 | ['==', '$type', 'Point'], 108 | ['!=', 'mode', 'static'] 109 | ], 110 | 'paint': { 111 | 'circle-radius': 5, 112 | 'circle-color': '#fff' 113 | } 114 | }, 115 | { 116 | 'id': 'gl-draw-polygon-and-line-vertex-inactive', 117 | 'type': 'circle', 118 | 'filter': ['all', 119 | ['==', 'meta', 'vertex'], 120 | ['==', '$type', 'Point'], 121 | ['!=', 'mode', 'static'] 122 | ], 123 | 'paint': { 124 | 'circle-radius': 3, 125 | 'circle-color': '#fbb03b' 126 | } 127 | }, 128 | { 129 | 'id': 'gl-draw-point-point-stroke-inactive', 130 | 'type': 'circle', 131 | 'filter': ['all', 132 | ['==', 'active', 'false'], 133 | ['==', '$type', 'Point'], 134 | ['==', 'meta', 'feature'], 135 | ['!=', 'mode', 'static'] 136 | ], 137 | 'paint': { 138 | 'circle-radius': 5, 139 | 'circle-opacity': 1, 140 | 'circle-color': '#fff' 141 | } 142 | }, 143 | { 144 | 'id': 'gl-draw-point-inactive', 145 | 'type': 'circle', 146 | 'filter': ['all', 147 | ['==', 'active', 'false'], 148 | ['==', '$type', 'Point'], 149 | ['==', 'meta', 'feature'], 150 | ['!=', 'mode', 'static'] 151 | ], 152 | 'paint': { 153 | 'circle-radius': 3, 154 | 'circle-color': '#3bb2d0' 155 | } 156 | }, 157 | { 158 | 'id': 'gl-draw-point-stroke-active', 159 | 'type': 'circle', 160 | 'filter': ['all', 161 | ['==', '$type', 'Point'], 162 | ['==', 'active', 'true'], 163 | ['!=', 'meta', 'midpoint'] 164 | ], 165 | 'paint': { 166 | 'circle-radius': 7, 167 | 'circle-color': '#fff' 168 | } 169 | }, 170 | { 171 | 'id': 'gl-draw-point-active', 172 | 'type': 'circle', 173 | 'filter': ['all', 174 | ['==', '$type', 'Point'], 175 | ['!=', 'meta', 'midpoint'], 176 | ['==', 'active', 'true']], 177 | 'paint': { 178 | 'circle-radius': 5, 179 | 'circle-color': '#fbb03b' 180 | } 181 | }, 182 | { 183 | 'id': 'gl-draw-polygon-fill-static', 184 | 'type': 'fill', 185 | 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], 186 | 'paint': { 187 | 'fill-color': '#404040', 188 | 'fill-outline-color': '#404040', 189 | 'fill-opacity': 0.1 190 | } 191 | }, 192 | { 193 | 'id': 'gl-draw-polygon-stroke-static', 194 | 'type': 'line', 195 | 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Polygon']], 196 | 'layout': { 197 | 'line-cap': 'round', 198 | 'line-join': 'round' 199 | }, 200 | 'paint': { 201 | 'line-color': '#404040', 202 | 'line-width': 2 203 | } 204 | }, 205 | { 206 | 'id': 'gl-draw-line-static', 207 | 'type': 'line', 208 | 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'LineString']], 209 | 'layout': { 210 | 'line-cap': 'round', 211 | 'line-join': 'round' 212 | }, 213 | 'paint': { 214 | 'line-color': '#404040', 215 | 'line-width': 2 216 | } 217 | }, 218 | { 219 | 'id': 'gl-draw-point-static', 220 | 'type': 'circle', 221 | 'filter': ['all', ['==', 'mode', 'static'], ['==', '$type', 'Point']], 222 | 'paint': { 223 | 'circle-radius': 5, 224 | 'circle-color': '#404040' 225 | } 226 | }, { 227 | 'id': 'gl-draw-polygon-color-picker', 228 | 'type': 'fill', 229 | 'filter': ['all', 230 | ['==', 'active', 'false'], 231 | ['==', '$type', 'Polygon'], 232 | ['!=', 'mode', 'static'], 233 | ['has', 'user_color'], 234 | ], 235 | 'paint': { 236 | 'fill-color': ['get', 'user_color'], 237 | 'fill-outline-color': '#D20C0C', 238 | 'fill-opacity': 0.5 239 | } 240 | }, 241 | { 242 | 'id': 'gl-draw-point-color-picker', 243 | 'type': 'circle', 244 | 'filter': ['all', 245 | ['==', 'active', 'false'], 246 | ['==', '$type', 'Point'], 247 | ['==', 'meta', 'feature'], 248 | ['!=', 'mode', 'static'], 249 | ['has', 'user_color'], 250 | ], 251 | 'paint': { 252 | 'circle-radius': 3, 253 | 'circle-color': ['get', 'user_color'], 254 | } 255 | }, 256 | { 257 | 'id': 'gl-draw-line-color-picker-width', 258 | 'type': 'line', 259 | 'filter': ['all', 260 | ['==', 'active', 'false'], 261 | ['==', '$type', 'LineString'], 262 | ['==', 'meta', 'feature'], 263 | ['!=', 'mode', 'static'], 264 | ['has', 'user_color'], 265 | ['has', 'user_width'] 266 | ], 267 | 'paint': { 268 | 'line-color': ['get', 'user_color'], 269 | 'line-width': ['get', 'user_width'] 270 | } 271 | }, 272 | { 273 | 'id': 'gl-draw-line-color-picker', 274 | 'type': 'line', 275 | 'filter': ['all', 276 | ['==', 'active', 'false'], 277 | ['==', '$type', 'LineString'], 278 | ['==', 'meta', 'feature'], 279 | ['!=', 'mode', 'static'], 280 | ['has', 'user_color'], 281 | ], 282 | 'paint': { 283 | 'line-color': ['get', 'user_color'], 284 | } 285 | }, 286 | ]; -------------------------------------------------------------------------------- /asserts/board.h: -------------------------------------------------------------------------------- 1 | #ifndef __BOARD_H 2 | #define __BOARD_H 3 | 4 | #ifdef __cplusplus 5 | extern "C" 6 | { 7 | #endif 8 | 9 | // this is the sofware version that any other Mowgli components like MowgliRover will match against 10 | 11 | #define MOWGLI_SW_VERSION_MAJOR 1 12 | #define MOWGLI_SW_VERSION_BRANCH 11 /* even = stable, odd = testing/unstable */ 13 | #define MOWGLI_SW_VERSION_MINOR 1 14 | 15 | /******************************************************************************** 16 | * BOARD SELECTION 17 | * the specific board setting are set a the end of this file 18 | ********************************************************************************/ 19 | 20 | #define BOARD_YARDFORCE500 1 21 | 22 | 23 | 24 | /* definition type don't modify */ 25 | #define DEBUG_TYPE_NONE 0 26 | #define DEBUG_TYPE_UART 1 27 | #define DEBUG_TYPE_SWO 2 28 | 29 | /* Publish Mowgli Topics */ 30 | //#define ROS_PUBLISH_MOWGLI 31 | 32 | /* different type of panel are possible */ 33 | #define PANEL_TYPE_NONE 0 34 | #define PANEL_TYPE_YARDFORCE_500_CLASSIC 1 35 | #define PANEL_TYPE_YARDFORCE_LUV1000RI 2 36 | #define PANEL_TYPE_YARDFORCE_900_ECO 3 37 | 38 | #if defined(BOARD_YARDFORCE500) 39 | 40 | #define PANEL_TYPE PANEL_TYPE_YARDFORCE_500_CLASSIC 41 | #define BLADEMOTOR_LENGTH_RECEIVED_MSG 16 42 | #define DEBUG_TYPE DEBUG_TYPE_UART 43 | 44 | #define MAX_MPS 0.6 // Allow maximum speed of 1.0 m/s 45 | #define PWM_PER_MPS 300.0 // PWM value of 300 means 1 m/s bot speed so we divide by 4 to have correct robot speed but still progressive speed 46 | #define TICKS_PER_M 300.0 // Motor Encoder ticks per meter 47 | #define WHEEL_BASE 0.325 // The distance between the center of the wheels in meters 48 | 49 | #define OPTION_ULTRASONIC 0 50 | #define OPTION_BUMPER 0 51 | #elif defined(BOARD_LUV1000RI) 52 | #define PANEL_TYPE PANEL_TYPE_YARDFORCE_500_CLASSIC 53 | #define BLADEMOTOR_LENGTH_RECEIVED_MSG 14 54 | 55 | #define DEBUG_TYPE 0 56 | 57 | #define OPTION_ULTRASONIC 1 58 | #define OPTION_BUMPER 0 59 | 60 | #define MAX_MPS 0.6 // Allow maximum speed of 1.0 m/s 61 | #define PWM_PER_MPS 300.0 // PWM value of 300 means 1 m/s bot speed so we divide by 4 to have correct robot speed but still progressive speed 62 | #define TICKS_PER_M 300.0 // Motor Encoder ticks per meter 63 | #define WHEEL_BASE 0.285 // The distance between the center of the wheels in meters 64 | 65 | #else 66 | 67 | #error "No board selection" 68 | #endif 69 | 70 | 71 | 72 | /// nominal max charge current is 1.0 Amp 73 | #define MAX_CHARGE_CURRENT 1.50f 74 | /// limite voltag when switching in 150mA mode 75 | #define LIMIT_VOLTAGE_150MA 29.00f 76 | /// Max voltage allowed 29.4 77 | #define MAX_CHARGE_VOLTAGE 29.00f 78 | /// Max battery voltage allowed 79 | #define BAT_CHARGE_CUTOFF_VOLTAGE 29.00f 80 | /// We consider the battery is full when in CV mode the current below 0.1A 81 | #define CHARGE_END_LIMIT_CURRENT 0.08f 82 | // if voltage is greater than this assume we are docked 83 | #define MIN_DOCKED_VOLTAGE 20.0f 84 | // if voltage is lower this assume battery is disconnected 85 | #define MIN_BATTERY_VOLTAGE 5.0f 86 | 87 | // if current is greater than this assume the battery is charging 88 | #define MIN_CHARGE_CURRENT 0.1f 89 | #define LOW_BAT_THRESHOLD 25.2f /* near 20% SOC */ 90 | #define LOW_CRI_THRESHOLD 23.5f /* near 0% SOC */ 91 | 92 | // Emergency sensor timeouts 93 | #define ONE_WHEEL_LIFT_EMERGENCY_MILLIS 10000 94 | #define BOTH_WHEELS_LIFT_EMERGENCY_MILLIS 100 95 | #define TILT_EMERGENCY_MILLIS 1000 // used for both the mechanical and accelerometer based detection 96 | #define STOP_BUTTON_EMERGENCY_MILLIS 100 97 | #define PLAY_BUTTON_CLEAR_EMERGENCY_MILLIS 1000 98 | #define IMU_ONBOARD_INCLINATION_THRESHOLD 0x38 // stock firmware uses 0x2C (way more allowed inclination) 99 | 100 | // Enable Emergency debugging 101 | //#define EMERGENCY_DEBUG 102 | 103 | // IMU configuration options 104 | 105 | #define EXTERNAL_IMU_ACCELERATION 1 106 | 107 | 108 | #define EXTERNAL_IMU_ANGULAR 1 109 | 110 | 111 | // Force disable IMU to be detected - CURRENTLY THIS SETTING DOES NOT WORK! 112 | //#define DISABLE_ALTIMU10v5 113 | //#define DISABLE_MPU6050 114 | //#define DISABLE_WT901 115 | 116 | // we use J18 (Red 9 pin connector as Master Serial Port) 117 | #define MASTER_J18 1 118 | 119 | // enable Drive and Blade Motor UARTS 120 | #define DRIVEMOTORS_USART_ENABLED 1 121 | #define BLADEMOTOR_USART_ENABLED 1 122 | #define PANEL_USART_ENABLED 1 123 | 124 | // our IMU hangs of a bigbanged I2C bus on J18 125 | #define SOFT_I2C_ENABLED 1 126 | 127 | #define LED_PIN GPIO_PIN_2 128 | #define LED_GPIO_PORT GPIOB 129 | #define LED_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE() 130 | 131 | /* 24V Supply */ 132 | #define TF4_PIN GPIO_PIN_5 133 | #define TF4_GPIO_PORT GPIOC 134 | #define TF4_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE() 135 | 136 | /* Blade Motor nRESET - (HIGH for no RESET) */ 137 | #define PAC5223RESET_PIN GPIO_PIN_14 138 | #define PAC5223RESET_GPIO_PORT GPIOE 139 | #define PAC5223RESET_GPIO_CLK_ENABLE() __HAL_RCC_GPIOE_CLK_ENABLE() 140 | 141 | /* Drive Motors - HC366 OE Pins (LOW to enable) */ 142 | #define PAC5210RESET_PIN GPIO_PIN_15 143 | #define PAC5210RESET_GPIO_PORT GPIOE 144 | #define PAC5210RESET_GPIO_CLK_ENABLE() __HAL_RCC_GPIOE_CLK_ENABLE() 145 | 146 | /* Charge Control Pins - HighSide/LowSide MosFET */ 147 | #define CHARGE_LOWSIDE_PIN GPIO_PIN_8 148 | #define CHARGE_HIGHSIDE_PIN GPIO_PIN_9 149 | #define CHARGE_GPIO_PORT GPIOE 150 | #define CHARGE_GPIO_CLK_ENABLE() __HAL_RCC_GPIOE_CLK_ENABLE(); 151 | 152 | /* Stop button - (HIGH when pressed) */ 153 | #define STOP_BUTTON_YELLOW_PIN GPIO_PIN_0 154 | #define STOP_BUTTON_YELLOW_PORT GPIOC 155 | #define STOP_BUTTON_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE() 156 | #define STOP_BUTTON_WHITE_PIN GPIO_PIN_8 157 | #define STOP_BUTTON_WHITE_PORT GPIOC 158 | 159 | /* Mechanical tilt - (HIGH when set) */ 160 | #define TILT_PIN GPIO_PIN_8 161 | #define TILT_PORT GPIOA 162 | #define TILT_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() 163 | 164 | /* Wheel lift - (HIGH when set) */ 165 | #define WHEEL_LIFT_BLUE_PIN GPIO_PIN_0 166 | #define WHEEL_LIFT_BLUE_PORT GPIOD 167 | #define WHEEL_LIFT_GPIO_CLK_ENABLE() __HAL_RCC_GPIOD_CLK_ENABLE() 168 | #define WHEEL_LIFT_RED_PIN GPIO_PIN_1 169 | #define WHEEL_LIFT_RED_PORT GPIOD 170 | 171 | /* Play button - (LOW when pressed) */ 172 | #define PLAY_BUTTON_PIN GPIO_PIN_7 173 | #define PLAY_BUTTON_PORT GPIOC 174 | #define PLAY_BUTTON_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE() 175 | 176 | /* Home button - (LOW when pressed) */ 177 | #define HOME_BUTTON_PIN GPIO_PIN_13 178 | #define HOME_BUTTON_PORT GPIOB 179 | #define HOME_BUTTON_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE() 180 | 181 | 182 | /* Rain Sensor - (LOW when active) */ 183 | #define RAIN_SENSOR_PIN GPIO_PIN_2 184 | #define RAIN_SENSOR_PORT GPIOE 185 | #define RAIN_SENSOR_GPIO_CLK_ENABLE() __HAL_RCC_GPIOE_CLK_ENABLE() 186 | 187 | /* STOP HALL Sensor - (HIGH when set) */ 188 | #define HALLSTOP_RIGHT_PIN GPIO_PIN_2 189 | #define HALLSTOP_LEFT_PIN GPIO_PIN_3 190 | #define HALLSTOP_PORT GPIOD 191 | #define HALLSTOP_GPIO_CLK_ENABLE() __HAL_RCC_GPIOD_CLK_ENABLE() 192 | 193 | /* either J6 or J18 can be the master USART port */ 194 | #ifdef MASTER_J6 195 | /* USART1 (J6 Pin 1 (TX) Pin 2 (RX)) */ 196 | #define MASTER_USART_INSTANCE USART1 197 | #define MASTER_USART_RX_PIN GPIO_PIN_10 198 | #define MASTER_USART_RX_PORT GPIOA 199 | #define MASTER_USART_TX_PIN GPIO_PIN_9 200 | #define MASTER_USART_TX_PORT GPIOA 201 | #define MASTER_USART_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() 202 | #define MASTER_USART_USART_CLK_ENABLE() __HAL_RCC_USART1_CLK_ENABLE() 203 | #define MASTER_USART_IRQ USART1_IRQn 204 | #endif 205 | #ifdef MASTER_J18 206 | /* UART4 (J18 Pin 7 (TX) Pin 8 (RX)) */ 207 | #define MASTER_USART_INSTANCE UART4 208 | #define MASTER_USART_RX_PIN GPIO_PIN_11 209 | #define MASTER_USART_RX_PORT GPIOC 210 | #define MASTER_USART_TX_PIN GPIO_PIN_10 211 | #define MASTER_USART_TX_PORT GPIOC 212 | #define MASTER_USART_GPIO_CLK_ENABLE() __HAL_RCC_GPIOC_CLK_ENABLE() 213 | #define MASTER_USART_USART_CLK_ENABLE() __HAL_RCC_UART4_CLK_ENABLE() 214 | #define MASTER_USART_IRQ UART4_IRQn 215 | #endif 216 | 217 | #ifdef DRIVEMOTORS_USART_ENABLED 218 | /* drive motors PAC 5210 (USART2) */ 219 | #define DRIVEMOTORS_USART_INSTANCE USART2 220 | 221 | #define DRIVEMOTORS_USART_RX_PIN GPIO_PIN_6 222 | #define DRIVEMOTORS_USART_RX_PORT GPIOD 223 | 224 | #define DRIVEMOTORS_USART_TX_PIN GPIO_PIN_5 225 | #define DRIVEMOTORS_USART_TX_PORT GPIOD 226 | 227 | #define DRIVEMOTORS_USART_GPIO_CLK_ENABLE() __HAL_RCC_GPIOD_CLK_ENABLE() 228 | #define DRIVEMOTORS_USART_USART_CLK_ENABLE() __HAL_RCC_USART2_CLK_ENABLE() 229 | 230 | #define DRIVEMOTORS_USART_IRQ USART2_IRQn 231 | #define DRIVEMOTORS_MSG_LEN 12 232 | #endif 233 | 234 | #ifdef BLADEMOTOR_USART_ENABLED 235 | /* blade motor PAC 5223 (USART3) */ 236 | #define BLADEMOTOR_USART_INSTANCE USART3 237 | 238 | #define BLADEMOTOR_USART_RX_PIN GPIO_PIN_11 239 | #define BLADEMOTOR_USART_RX_PORT GPIOB 240 | 241 | #define BLADEMOTOR_USART_TX_PIN GPIO_PIN_10 242 | #define BLADEMOTOR_USART_TX_PORT GPIOB 243 | 244 | #define BLADEMOTOR_USART_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE() 245 | #define BLADEMOTOR_USART_USART_CLK_ENABLE() __HAL_RCC_USART3_CLK_ENABLE() 246 | #endif 247 | 248 | #ifdef PANEL_USART_ENABLED 249 | #define PANEL_USART_INSTANCE USART1 250 | 251 | #define PANEL_USART_RX_PIN GPIO_PIN_10 252 | #define PANEL_USART_RX_PORT GPIOA 253 | 254 | #define PANEL_USART_TX_PIN GPIO_PIN_9 255 | #define PANEL_USART_TX_PORT GPIOA 256 | 257 | #define PANEL_USART_GPIO_CLK_ENABLE() __HAL_RCC_GPIOA_CLK_ENABLE() 258 | #define PANEL_USART_USART_CLK_ENABLE() __HAL_RCC_USART1_CLK_ENABLE() 259 | #define PANEL_USART_IRQ USART1_IRQn 260 | #endif 261 | 262 | // J18 has the SPI3 pins, as we dont use SPI3, we recycle them for I2C Bitbanging (for our Pololu ALtIMU-10v5) 263 | #ifdef SOFT_I2C_ENABLED 264 | #define SOFT_I2C_SCL_PIN GPIO_PIN_3 265 | #define SOFT_I2C_SCL_PORT GPIOB 266 | #define SOFT_I2C_SDA_PIN GPIO_PIN_4 267 | #define SOFT_I2C_SDA_PORT GPIOB 268 | 269 | #define SOFT_I2C_GPIO_CLK_ENABLE() __HAL_RCC_GPIOB_CLK_ENABLE(); 270 | #endif 271 | 272 | #ifdef __cplusplus 273 | } 274 | #endif 275 | 276 | #endif /* __BOARD_H */ 277 | --------------------------------------------------------------------------------