Skip to content

Create new "Unmanaged" board for allowing external (Ethernet at first) boards #3065

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions core/frontend/src/components/autopilot/MasterEndpointManager.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
<template>
<div class="master-endpoint-manager d-flex flex-column align-center">
<v-card
width="100%"
class="pa-4"
>
<v-card-title class="text-h6 mb-2">
Master Endpoint Configuration
</v-card-title>

<v-form
ref="form"
v-model="form_valid"
lazy-validation
>
<v-select
v-model="endpoint.connection_type"
:items="endpoint_types"
label="Connection Type"
:rules="[validate_required_field]"
@change="updateDefaultPlace"
/>

<v-text-field
v-model="endpoint.place"
:rules="[validate_required_field, is_ip_address_path, is_useable_ip_address]"
label="IP/Device"
/>

<v-text-field
v-model.number="endpoint.argument"
label="Port/Baudrate"
:rules="[is_socket_port_baudrate]"
/>

<v-card-actions class="mt-4">
<v-spacer />
<v-btn
color="primary"
:loading="saving"
:disabled="!form_valid || !has_changes"
@click="saveEndpoint"
>
Save Changes
</v-btn>
</v-card-actions>
</v-form>
</v-card>

<v-snackbar
v-model="show_success"
color="success"
timeout="3000"
>
Master endpoint updated successfully
<template #action="{ attrs }">
<v-btn
text
v-bind="attrs"
@click="show_success = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>

<script lang="ts">
import Vue from 'vue'

import Notifier from '@/libs/notifier'
import autopilot from '@/store/autopilot_manager'
import beacon from '@/store/beacon'
import { AutopilotEndpoint, EndpointType, userFriendlyEndpointType } from '@/types/autopilot'
import { autopilot_service } from '@/types/frontend_services'
import { VForm } from '@/types/vuetify'
import back_axios from '@/utils/api'
import {
isBaudrate, isFilepath, isIpAddress, isNotEmpty, isSocketPort,
} from '@/utils/pattern_validators'

const notifier = new Notifier(autopilot_service)

const defaultEndpoint: AutopilotEndpoint = {
name: 'master',
owner: 'User',
connection_type: EndpointType.udpin,
place: '0.0.0.0',
argument: 14551,
persistent: true,
protected: false,
enabled: true,
}

export default Vue.extend({
name: 'MasterEndpointManager',
data() {
return {
form_valid: true,
saving: false,
show_success: false,
original_endpoint: { ...defaultEndpoint },
endpoint: { ...defaultEndpoint },
}
},
computed: {
endpoint_types(): {value: EndpointType, text: string}[] {
return Object.entries(EndpointType).map(
(type) => ({ value: type[1], text: userFriendlyEndpointType(type[1]) }),
)
},
form(): VForm {
return this.$refs.form as VForm
},
user_ip_address(): string {
return beacon.client_ip_address
},
available_ips(): string[] {
return [...new Set(beacon.available_domains.map((domain) => domain.ip))]
},
has_changes(): boolean {
return this.endpoint.connection_type !== this.original_endpoint.connection_type
|| this.endpoint.place !== this.original_endpoint.place
|| this.endpoint.argument !== this.original_endpoint.argument
},
},
mounted() {
this.fetchCurrentEndpoint()
},
methods: {
validate_required_field(input: string): (true | string) {
return isNotEmpty(input) ? true : 'Required field.'
},
is_ip_address_path(input: string): (true | string) {
return isIpAddress(input) || isFilepath(input) ? true : 'Invalid IP/Device-path.'
},
is_useable_ip_address(input: string): (true | string) {
if ([EndpointType.udpin, EndpointType.tcpin].includes(this.endpoint.connection_type)) {
if (!['0.0.0.0', ...this.available_ips].includes(input)) {
return 'This IP is not available at any of the network interfaces.'
}
}
if ([EndpointType.udpout, EndpointType.tcpout].includes(this.endpoint.connection_type)) {
if (input === '0.0.0.0') return '0.0.0.0 as a client is undefined behavior.'
}
return true
},
is_socket_port_baudrate(input: number): (true | string) {
if (typeof input === 'string') {
return 'Please use an integer value.'
}
return isSocketPort(input) || isBaudrate(input) ? true : 'Invalid Port/Baudrate.'
},
updateDefaultPlace(): void {
switch (this.endpoint.connection_type) {
case EndpointType.udpin:
case EndpointType.tcpin:
this.endpoint.place = '0.0.0.0'
break
case EndpointType.udpout:
case EndpointType.tcpout:
this.endpoint.place = this.user_ip_address
break
default:
this.endpoint.place = '/dev/ttyAMA1' // Serial3
}
},
async fetchCurrentEndpoint(): Promise<void> {
try {
const response = await back_axios({
method: 'get',
url: `${autopilot.API_URL}/endpoints/manual_board_master_endpoint`,
timeout: 10000,
})
const endpoint_data = {
...defaultEndpoint,
...response.data,
}
this.endpoint = { ...endpoint_data }
this.original_endpoint = { ...endpoint_data }
} catch (error) {
notifier.pushBackError('MASTER_ENDPOINT_FETCH_FAIL', error)
}
},
async saveEndpoint(): Promise<void> {
if (!this.form.validate()) {
return
}

this.saving = true
try {
await back_axios({
method: 'post',
url: `${autopilot.API_URL}/endpoints/manual_board_master_endpoint`,
timeout: 10000,
data: this.endpoint,
})
this.original_endpoint = { ...this.endpoint }
this.show_success = true
} catch (error) {
notifier.pushBackError('MASTER_ENDPOINT_SAVE_FAIL', error)
} finally {
this.saving = false
}
},
},
})
</script>

<style scoped>
.master-endpoint-manager {
width: 100%;
max-width: 600px;
margin: 0 auto;
}
</style>
29 changes: 26 additions & 3 deletions core/frontend/src/views/Autopilot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@
<br>
</div>
<not-safe-overlay />
<v-expansion-panels>
<v-expansion-panels v-if="is_external_board">
<v-expansion-panel>
<v-expansion-panel-header>
Master endpoint
</v-expansion-panel-header>
<v-expansion-panel-content>
<master-endpoint-manager />
</v-expansion-panel-content>
</v-expansion-panel>
</v-expansion-panels>
<v-expansion-panels v-else>
<v-expansion-panel>
<v-expansion-panel-header>
Firmware update
Expand Down Expand Up @@ -78,7 +88,7 @@
Change board
</v-btn>
<v-btn
v-if="settings.is_pirate_mode"
v-if="settings.is_pirate_mode && board_supports_start_stop"
class="ma-1"
:block="$vuetify.breakpoint.xs"
color="secondary"
Expand All @@ -88,7 +98,7 @@
Start autopilot
</v-btn>
<v-btn
v-if="settings.is_pirate_mode"
v-if="settings.is_pirate_mode && board_supports_start_stop"
class="ma-1"
:block="$vuetify.breakpoint.xs"
color="secondary"
Expand All @@ -98,6 +108,7 @@
Stop autopilot
</v-btn>
<v-btn
v-if="settings.is_pirate_mode && board_supports_restart"
color="primary"
class="ma-1"
:block="$vuetify.breakpoint.xs"
Expand Down Expand Up @@ -127,6 +138,7 @@ import {
import AutopilotSerialConfiguration from '@/components/autopilot/AutopilotSerialConfiguration.vue'
import BoardChangeDialog from '@/components/autopilot/BoardChangeDialog.vue'
import FirmwareManager from '@/components/autopilot/FirmwareManager.vue'
import MasterEndpointManager from '@/components/autopilot/MasterEndpointManager.vue'
import NotSafeOverlay from '@/components/common/NotSafeOverlay.vue'
import { MavAutopilot } from '@/libs/MAVLink2Rest/mavlink2rest-ts/messages/mavlink2rest-enum'
import Notifier from '@/libs/notifier'
Expand All @@ -148,6 +160,7 @@ export default Vue.extend({
FirmwareManager,
AutopilotSerialConfiguration,
NotSafeOverlay,
MasterEndpointManager,
},
data() {
return {
Expand All @@ -161,6 +174,13 @@ export default Vue.extend({
}
},
computed: {
board_supports_start_stop(): boolean {
return this.current_board?.name !== 'Manual'
},
board_supports_restart(): boolean {
// this is a mavlink command, all boards should support it
return true
},
autopilot_info(): Record<string, string> {
let version = 'Unknown'
if (this.firmware_info) {
Expand Down Expand Up @@ -201,6 +221,9 @@ export default Vue.extend({
}
return ['Navigator', 'Navigator64', 'SITL'].includes(boardname)
},
is_external_board(): boolean {
return autopilot.current_board?.name === 'Manual'
},
current_board(): FlightController | null {
return autopilot.current_board
},
Expand Down
10 changes: 10 additions & 0 deletions core/services/ardupilot_manager/api/v1/routers/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ async def remove_endpoints(endpoints: Set[Endpoint] = Body(...)) -> Any:
@endpoints_router_v1.put("/", status_code=status.HTTP_200_OK)
async def update_endpoints(endpoints: Set[Endpoint] = Body(...)) -> Any:
await autopilot.update_endpoints(endpoints)


@endpoints_router_v1.post("/manual_board_master_endpoint", summary="Set the master endpoint for an manual board.")
async def set_manual_board_master_endpoint(endpoint: Endpoint) -> bool:
return await autopilot.set_manual_board_master_endpoint(endpoint)


@endpoints_router_v1.get("/manual_board_master_endpoint", summary="Get the master endpoint for an manual board.")
def get_manual_board_master_endpoint() -> Any:
return autopilot.get_manual_board_master_endpoint()
34 changes: 32 additions & 2 deletions core/services/ardupilot_manager/autopilot_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,12 @@ def load_preferred_router(self) -> Optional[str]:
def get_available_routers(self) -> List[str]:
return [router.name() for router in self.mavlink_manager.available_interfaces()]

async def start_manual_board(self, board: FlightController) -> None:
self._current_board = board
self.master_endpoint = self.get_manual_board_master_endpoint()
self.ardupilot_subprocess = None
await self.start_mavlink_manager(self.master_endpoint)

async def start_sitl(self) -> None:
self._current_board = BoardDetector.detect_sitl()
if not self.firmware_manager.is_firmware_installed(self._current_board):
Expand Down Expand Up @@ -486,9 +492,9 @@ def get_board_to_be_used(self, boards: List[FlightController]) -> FlightControll
# SITL should only be used if explicitly set by user, in which case it's a preferred board and the
# previous return logic will get it. We do this to prevent the user thinking that it's physical board
# is correctly running when in fact it was SITL automatically starting.
real_boards = [board for board in boards if board.type != PlatformType.SITL]
real_boards = [board for board in boards if board.type not in [PlatformType.SITL, PlatformType.Manual]]
if not real_boards:
raise RuntimeError("Only available board is SITL, and it wasn't explicitly chosen.")
raise RuntimeError("No physical board detected and SITL/Manual board aren't explicitly chosen.")
real_boards.sort(key=lambda board: board.platform)
return real_boards[0]

Expand Down Expand Up @@ -584,6 +590,8 @@ async def start_ardupilot(self) -> None:
await self.start_serial(flight_controller)
elif flight_controller.platform == Platform.SITL:
await self.start_sitl()
elif flight_controller.platform == Platform.Manual:
await self.start_manual_board(flight_controller)
else:
raise RuntimeError(f"Invalid board type: {flight_controller}")
finally:
Expand Down Expand Up @@ -662,3 +670,25 @@ async def install_firmware_from_url(

async def restore_default_firmware(self, board: FlightController) -> None:
await self.firmware_manager.restore_default_firmware(board)

async def set_manual_board_master_endpoint(self, endpoint: Endpoint) -> bool:
self.configuration["manual_board_master_endpoint"] = endpoint.as_dict()
self.settings.save(self.configuration)
self.mavlink_manager.master_endpoint = endpoint
await self.mavlink_manager.restart()
return True

def get_manual_board_master_endpoint(self) -> Endpoint:
default_master_endpoint = Endpoint(
name="Manual Board Master Endpoint",
owner=self.settings.app_name,
connection_type=EndpointType.UDPServer,
place="0.0.0.0",
argument=14551,
persistent=True,
enabled=True,
)
endpoint = self.configuration.get("manual_board_master_endpoint", None)
if endpoint is None:
return default_master_endpoint
return Endpoint(**endpoint)
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def detect_sitl() -> FlightController:
return FlightController(name="SITL", manufacturer="ArduPilot Team", platform=Platform.SITL)

@classmethod
async def detect(cls, include_sitl: bool = True) -> List[FlightController]:
async def detect(cls, include_sitl: bool = True, include_manual: bool = True) -> List[FlightController]:
"""Return a list of available flight controllers

Arguments:
Expand All @@ -99,4 +99,7 @@ async def detect(cls, include_sitl: bool = True) -> List[FlightController]:
if include_sitl:
available.append(Detector.detect_sitl())

if include_manual:
available.append(FlightController(name="Manual", manufacturer="Manual", platform=Platform.Manual))

return available
Loading