Skip to content

Commit aa0918f

Browse files
frontend: detect and list other vehicles on blueos
1 parent 04d31d3 commit aa0918f

File tree

4 files changed

+401
-11
lines changed

4 files changed

+401
-11
lines changed

core/frontend/src/components/app/VehicleBanner.vue

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,19 @@
1818
>
1919
({{ system_id }})
2020
</span>
21+
</p>
22+
<div class="action-buttons-container">
2123
<v-btn
22-
class="mx-2 edit-icon"
24+
class="mx-1"
2325
fab
2426
dark
2527
x-small
2628
@click="openDialog"
2729
>
28-
<v-icon>
29-
mdi-pencil
30-
</v-icon>
30+
<v-icon>mdi-pencil</v-icon>
3131
</v-btn>
32-
</p>
32+
<VehiclePicker />
33+
</div>
3334
<v-spacer />
3435
<image-picker
3536
size="35px"
@@ -74,11 +75,13 @@ import bag from '@/store/bag'
7475
import beacon from '@/store/beacon'
7576
7677
import ImagePicker from './ImagePicker.vue'
78+
import VehiclePicker from './VehiclePicker.vue'
7779
7880
export default Vue.extend({
7981
name: 'VehicleBanner',
8082
components: {
8183
ImagePicker,
84+
VehiclePicker,
8285
},
8386
data() {
8487
return {
@@ -166,7 +169,7 @@ export default Vue.extend({
166169
})
167170
</script>
168171
<style scoped>
169-
#vehicle-name:hover .edit-icon{
172+
#vehicle-banner:hover .action-buttons-container{
170173
display: inline-flex;
171174
}
172175
@@ -181,10 +184,13 @@ export default Vue.extend({
181184
margin-bottom: 0 !important;
182185
position: relative;
183186
}
184-
.edit-icon {
187+
188+
.action-buttons-container {
185189
display: none;
186-
right: 0;
187-
bottom: 0;
188-
position: absolute;
190+
position: fixed;
191+
right: 70px;
192+
z-index: 10;
193+
align-items: center;
194+
gap: 4px;
189195
}
190196
</style>
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<template>
2+
<div class="vehicle-picker">
3+
<v-btn
4+
:loading="loading"
5+
class="mx-1"
6+
fab
7+
dark
8+
x-small
9+
@click="toggleDropdown"
10+
>
11+
<v-icon>{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
12+
</v-btn>
13+
14+
<v-menu
15+
v-model="expanded"
16+
:close-on-content-click="false"
17+
offset-y
18+
min-width="150"
19+
>
20+
<template #activator="{ }" />
21+
22+
<v-card>
23+
<v-card-title class="subtitle-2 pa-2">
24+
Other Vehicles detected on your network
25+
<v-spacer />
26+
<v-btn
27+
icon
28+
x-small
29+
@click="refreshServices"
30+
>
31+
<v-icon small>
32+
mdi-refresh
33+
</v-icon>
34+
</v-btn>
35+
</v-card-title>
36+
37+
<v-divider />
38+
39+
<v-list v-if="availableVehicles.length > 0" dense>
40+
<v-list-item
41+
v-for="vehicle in availableVehicles"
42+
:key="vehicle.hostname"
43+
>
44+
<v-list-item-avatar>
45+
<v-img
46+
v-if="vehicle.imagePath"
47+
:src="vehicle.imagePath"
48+
:alt="vehicle.hostname"
49+
aspect-ratio="1"
50+
class="vehicle-image"
51+
>
52+
<template #error>
53+
<v-icon color="primary">
54+
mdi-submarine
55+
</v-icon>
56+
</template>
57+
</v-img>
58+
<v-icon v-else color="primary">
59+
mdi-submarine
60+
</v-icon>
61+
</v-list-item-avatar>
62+
<v-list-item-content>
63+
<v-list-item-title>
64+
<span class="vehicle-name">
65+
{{ vehicle.hostname }}
66+
</span>
67+
{{ allMyIps.includes(vehicle.ips[0]) ? '(This Vehicle)' : '' }}
68+
</v-list-item-title>
69+
<v-list-item-title v-for="ip in vehicle.ips" :key="ip" class="caption" @click="navigateToVehicle(ip)">
70+
{{ ip }}
71+
<v-icon x-small color="primary">
72+
mdi-launch
73+
</v-icon>
74+
</v-list-item-title>
75+
</v-list-item-content>
76+
</v-list-item>
77+
</v-list>
78+
79+
<v-card-text v-else-if="!loading" class="text-center text--secondary">
80+
<div v-if="error">
81+
<v-icon color="error">
82+
mdi-alert-circle
83+
</v-icon>
84+
<div class="caption">
85+
{{ error }}
86+
</div>
87+
</div>
88+
<div v-else>
89+
No vehicles discovered
90+
</div>
91+
</v-card-text>
92+
93+
<v-card-text v-else class="text-center">
94+
<v-progress-circular
95+
indeterminate
96+
size="24"
97+
width="2"
98+
/>
99+
<div class="caption mt-2">
100+
Discovering vehicles...
101+
</div>
102+
</v-card-text>
103+
</v-card>
104+
</v-menu>
105+
</div>
106+
</template>
107+
108+
<script lang="ts">
109+
import Vue from 'vue'
110+
111+
import beacon from '@/store/beacon'
112+
import system_information from '@/store/system-information'
113+
114+
export default Vue.extend({
115+
name: 'VehiclePicker',
116+
data() {
117+
return {
118+
expanded: false,
119+
}
120+
},
121+
computed: {
122+
loading(): boolean {
123+
return beacon.vehicles_loading
124+
},
125+
error(): string | null {
126+
return beacon.vehicles_error
127+
},
128+
availableVehicles() {
129+
return beacon.available_vehicles
130+
},
131+
allMyIps(): string[] {
132+
return system_information.system?.network.flatMap((network) => network.ips.map((ip) => ip.split('/')[0])) || []
133+
},
134+
},
135+
methods: {
136+
toggleDropdown() {
137+
this.expanded = !this.expanded
138+
if (this.expanded && this.availableVehicles.length === 0) {
139+
beacon.fetchDiscoveredServices()
140+
}
141+
},
142+
143+
async refreshServices() {
144+
await beacon.fetchDiscoveredServices()
145+
},
146+
147+
navigateToVehicle(ip: string) {
148+
window.open(`http://${ip}`, '_blank')
149+
this.expanded = false
150+
},
151+
},
152+
})
153+
</script>
154+
155+
<style scoped>
156+
.vehicle-picker {
157+
display: inline-block;
158+
}
159+
160+
.caption:hover {
161+
background-color: rgba(0, 0, 0, 0.04);
162+
cursor: pointer;
163+
}
164+
165+
.vehicle-image {
166+
border-radius: 4px;
167+
}
168+
169+
.v-list-item:hover {
170+
background-color: rgba(0, 0, 0, 0.04);
171+
}
172+
173+
.vehicle-name {
174+
font-size: 1.2rem;
175+
}
176+
</style>

core/frontend/src/store/beacon.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import axios from 'axios'
12
import {
23
Action,
34
getModule, Module, Mutation, VuexModule,
@@ -10,6 +11,16 @@ import { Domain } from '@/types/beacon'
1011
import { beacon_service } from '@/types/frontend_services'
1112
import back_axios, { isBackendOffline } from '@/utils/api'
1213

14+
interface DiscoveredServices {
15+
[ip: string]: string[]
16+
}
17+
18+
export interface Vehicle {
19+
ips: string[]
20+
hostname: string
21+
imagePath?: string
22+
}
23+
1324
const notifier = new Notifier(beacon_service)
1425

1526
let prefetched_domains = false
@@ -37,6 +48,12 @@ class BeaconStore extends VuexModule {
3748

3849
vehicle_name = ''
3950

51+
available_vehicles: Vehicle[] = []
52+
53+
vehicles_loading = false
54+
55+
vehicles_error: string | null = null
56+
4057
fetchAvailableDomainsTask = new OneMoreTime(
4158
{ delay: 5000 },
4259
)
@@ -90,6 +107,21 @@ class BeaconStore extends VuexModule {
90107
this.listeners_count -= 1
91108
}
92109

110+
@Mutation
111+
setAvailableVehicles(vehicles: Vehicle[]): void {
112+
this.available_vehicles = vehicles
113+
}
114+
115+
@Mutation
116+
setVehiclesLoading(loading: boolean): void {
117+
this.vehicles_loading = loading
118+
}
119+
120+
@Mutation
121+
setVehiclesError(error: string | null): void {
122+
this.vehicles_error = error
123+
}
124+
93125
@Action
94126
async setHostname(hostname: string): Promise<boolean> {
95127
hostname = hostname.trim().toLowerCase()
@@ -234,6 +266,71 @@ class BeaconStore extends VuexModule {
234266
}
235267
}
236268

269+
@Action
270+
async fetchDiscoveredServices(): Promise<void> {
271+
this.setVehiclesLoading(true)
272+
this.setVehiclesError(null)
273+
this.setAvailableVehicles([])
274+
275+
try {
276+
const response = await back_axios({
277+
method: 'get',
278+
url: `${this.API_URL}/discovered_services`,
279+
timeout: 10000,
280+
})
281+
282+
const discoveredServices: DiscoveredServices = response.data
283+
const ips = Object.keys(discoveredServices)
284+
285+
if (ips.length === 0) {
286+
this.setVehiclesError('No vehicles discovered')
287+
this.setVehiclesLoading(false)
288+
return
289+
}
290+
291+
const vehicles: Vehicle[] = []
292+
293+
await Promise.all(ips.map(async (ip) => {
294+
try {
295+
const hostnameResponse = await axios.get(`http://${ip}/beacon/v1.0/hostname`, {
296+
timeout: 5000,
297+
})
298+
299+
let imagePath: string | undefined
300+
try {
301+
const imageResponse = await axios.get(`http://${ip}/bag/v1.0/get/vehicle.image_path`, {
302+
timeout: 3000,
303+
})
304+
imagePath = `http://${ip}${imageResponse.data.url}`
305+
} catch {
306+
// Image not available, use fallback
307+
}
308+
309+
vehicles.push({
310+
ips: [ip],
311+
hostname: hostnameResponse.data,
312+
imagePath,
313+
})
314+
} catch (error) {
315+
console.warn(`Failed to fetch hostname for ${ip}:`, error)
316+
}
317+
}))
318+
319+
vehicles.sort((a, b) => a.hostname.localeCompare(b.hostname))
320+
this.setAvailableVehicles(vehicles)
321+
322+
if (vehicles.length === 0) {
323+
this.setVehiclesError('No accessible vehicles found')
324+
}
325+
} catch (error) {
326+
console.error('Failed to fetch discovered services:', error)
327+
this.setVehiclesError('Failed to discover vehicles')
328+
this.setAvailableVehicles([])
329+
} finally {
330+
this.setVehiclesLoading(false)
331+
}
332+
}
333+
237334
@Action
238335
// eslint-disable-next-line @typescript-eslint/no-explicit-any
239336
async registerBeaconListener(object: any): Promise<void> {

0 commit comments

Comments
 (0)