Merge pull request #813 from Freika/fix/points-speed-kmh

Various fixes
This commit is contained in:
Evgenii Burmakin 2025-02-08 12:08:32 +01:00 committed by GitHub
commit 14912868f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2803 additions and 173 deletions

File diff suppressed because it is too large Load diff

View file

@ -107,3 +107,8 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.clickable-area,
.leaflet-interactive {
cursor: pointer !important;
}

View file

@ -13,8 +13,7 @@ import {
getSpeedColor getSpeedColor
} from "../maps/polylines"; } from "../maps/polylines";
import { fetchAndDrawAreas } from "../maps/areas"; import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers"; import { showFlashMessage, fetchAndDisplayPhotos, debounce } from "../maps/helpers";
@ -67,7 +66,7 @@ export default class extends Controller {
imperial: this.distanceUnit === 'mi', imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km', metric: this.distanceUnit === 'km',
maxWidth: 120 maxWidth: 120
}).addTo(this.map) }).addTo(this.map);
// Add stats control // Add stats control
const StatsControl = L.Control.extend({ const StatsControl = L.Control.extend({
@ -107,7 +106,13 @@ export default class extends Controller {
// Create a proper Leaflet layer for fog // Create a proper Leaflet layer for fog
this.fogOverlay = createFogOverlay(); this.fogOverlay = createFogOverlay();
this.areasLayer = L.layerGroup(); // Initialize areas layer // Create custom pane for areas
this.map.createPane('areasPane');
this.map.getPane('areasPane').style.zIndex = 650;
this.map.getPane('areasPane').style.pointerEvents = 'all';
// Initialize areasLayer as a feature group and add it to the map immediately
this.areasLayer = new L.FeatureGroup();
this.photoMarkers = L.layerGroup(); this.photoMarkers = L.layerGroup();
this.setupScratchLayer(this.countryCodesMap); this.setupScratchLayer(this.countryCodesMap);
@ -123,7 +128,7 @@ export default class extends Controller {
Heatmap: this.heatmapLayer, Heatmap: this.heatmapLayer,
"Fog of War": new this.fogOverlay(), "Fog of War": new this.fogOverlay(),
"Scratch map": this.scratchLayer, "Scratch map": this.scratchLayer,
Areas: this.areasLayer, Areas: this.areasLayer, // Add areasLayer to the control
Photos: this.photoMarkers Photos: this.photoMarkers
}; };
@ -248,10 +253,13 @@ export default class extends Controller {
} }
// Store panel state before disconnecting // Store panel state before disconnecting
if (this.rightPanel) { if (this.rightPanel) {
const finalState = document.querySelector('.leaflet-right-panel').style.display !== 'none' ? 'true' : 'false'; const panel = document.querySelector('.leaflet-right-panel');
const finalState = panel ? (panel.style.display !== 'none' ? 'true' : 'false') : 'false';
localStorage.setItem('mapPanelOpen', finalState); localStorage.setItem('mapPanelOpen', finalState);
} }
this.map.remove(); if (this.map) {
this.map.remove();
}
} }
setupSubscription() { setupSubscription() {
@ -567,17 +575,25 @@ export default class extends Controller {
}, },
}, },
}, },
edit: {
featureGroup: this.drawnItems
}
}); });
// Handle circle creation // Handle circle creation
this.map.on(L.Draw.Event.CREATED, (event) => { this.map.on('draw:created', (event) => {
const layer = event.layer; const layer = event.layer;
if (event.layerType === 'circle') { if (event.layerType === 'circle') {
handleAreaCreated(this.areasLayer, layer, this.apiKey); try {
// Add the layer to the map first
layer.addTo(this.map);
handleAreaCreated(this.areasLayer, layer, this.apiKey);
} catch (error) {
console.error("Error in handleAreaCreated:", error);
console.error(error.stack); // Add stack trace
}
} }
this.drawnItems.addLayer(layer);
}); });
} }

View file

@ -1,54 +1,104 @@
import { showFlashMessage } from "./helpers";
export function handleAreaCreated(areasLayer, layer, apiKey) { export function handleAreaCreated(areasLayer, layer, apiKey) {
console.log('handleAreaCreated called with apiKey:', apiKey);
const radius = layer.getRadius(); const radius = layer.getRadius();
const center = layer.getLatLng(); const center = layer.getLatLng();
const formHtml = ` const formHtml = `
<div class="card w-96 max-w-sm bg-content-100 shadow-xl"> <div class="card w-96">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">New Area</h2> <h2 class="card-title">New Area</h2>
<form id="circle-form"> <form id="circle-form" class="space-y-4">
<div class="form-control"> <div class="form-control">
<label for="circle-name" class="label"> <input type="text"
<span class="label-text">Name</span> id="circle-name"
</label> name="area[name]"
<input type="text" id="circle-name" name="area[name]" class="input input-bordered input-ghost focus:input-ghost w-full max-w-xs" required> class="input input-bordered w-full"
placeholder="Enter area name"
autofocus
required>
</div> </div>
<input type="hidden" name="area[latitude]" value="${center.lat}"> <input type="hidden" name="area[latitude]" value="${center.lat}">
<input type="hidden" name="area[longitude]" value="${center.lng}"> <input type="hidden" name="area[longitude]" value="${center.lng}">
<input type="hidden" name="area[radius]" value="${radius}"> <input type="hidden" name="area[radius]" value="${radius}">
<div class="card-actions justify-end mt-4"> <div class="flex justify-between mt-4">
<button type="submit" class="btn btn-primary">Save</button> <button type="button"
class="btn btn-outline"
onclick="this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button').click()">
Cancel
</button>
<button type="button" id="save-area-btn" class="btn btn-primary">Save Area</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
`; `;
layer.bindPopup( console.log('Binding popup to layer');
formHtml, { layer.bindPopup(formHtml, {
maxWidth: "auto", maxWidth: "auto",
minWidth: 300 minWidth: 300,
} closeButton: true,
).openPopup(); closeOnClick: false,
className: 'area-form-popup'
}).openPopup();
layer.on('popupopen', () => { console.log('Adding layer to areasLayer');
const form = document.getElementById('circle-form');
if (!form) return;
form.addEventListener('submit', (e) => {
e.preventDefault();
saveArea(new FormData(form), areasLayer, layer, apiKey);
});
});
// Add the layer to the areas layer group
areasLayer.addLayer(layer); areasLayer.addLayer(layer);
// Bind the event handler immediately after opening the popup
setTimeout(() => {
console.log('Setting up form handlers');
const form = document.getElementById('circle-form');
const saveButton = document.getElementById('save-area-btn');
const nameInput = document.getElementById('circle-name');
console.log('Form:', form);
console.log('Save button:', saveButton);
console.log('Name input:', nameInput);
if (!form || !saveButton || !nameInput) {
console.error('Required elements not found');
return;
}
// Focus the name input
nameInput.focus();
// Remove any existing click handlers
const newSaveButton = saveButton.cloneNode(true);
saveButton.parentNode.replaceChild(newSaveButton, saveButton);
// Add click handler
newSaveButton.addEventListener('click', (e) => {
console.log('Save button clicked');
e.preventDefault();
e.stopPropagation();
if (!nameInput.value.trim()) {
console.log('Name is empty');
nameInput.classList.add('input-error');
return;
}
console.log('Creating FormData');
const formData = new FormData(form);
formData.forEach((value, key) => {
console.log(`FormData: ${key} = ${value}`);
});
console.log('Calling saveArea');
saveArea(formData, areasLayer, layer, apiKey);
});
}, 100); // Small delay to ensure DOM is ready
} }
export function saveArea(formData, areasLayer, layer, apiKey) { export function saveArea(formData, areasLayer, layer, apiKey) {
console.log('saveArea called with apiKey:', apiKey);
const data = {}; const data = {};
formData.forEach((value, key) => { formData.forEach((value, key) => {
console.log('FormData entry:', key, value);
const keys = key.split('[').map(k => k.replace(']', '')); const keys = key.split('[').map(k => k.replace(']', ''));
if (keys.length > 1) { if (keys.length > 1) {
if (!data[keys[0]]) data[keys[0]] = {}; if (!data[keys[0]]) data[keys[0]] = {};
@ -58,18 +108,21 @@ export function saveArea(formData, areasLayer, layer, apiKey) {
} }
}); });
console.log('Sending fetch request with data:', data);
fetch(`/api/v1/areas?api_key=${apiKey}`, { fetch(`/api/v1/areas?api_key=${apiKey}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json'}, headers: { 'Content-Type': 'application/json'},
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
.then(response => { .then(response => {
console.log('Received response:', response);
if (!response.ok) { if (!response.ok) {
throw new Error('Network response was not ok'); throw new Error('Network response was not ok');
} }
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
console.log('Area saved successfully:', data);
layer.closePopup(); layer.closePopup();
layer.bindPopup(` layer.bindPopup(`
Name: ${data.name}<br> Name: ${data.name}<br>
@ -79,9 +132,13 @@ export function saveArea(formData, areasLayer, layer, apiKey) {
// Add event listener for the delete button // Add event listener for the delete button
layer.on('popupopen', () => { layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', () => { const deleteButton = document.querySelector('.delete-area');
deleteArea(data.id, areasLayer, layer, apiKey); if (deleteButton) {
}); deleteButton.addEventListener('click', (e) => {
e.preventDefault();
deleteArea(data.id, areasLayer, layer, apiKey);
});
}
}); });
}) })
.catch(error => { .catch(error => {
@ -104,6 +161,8 @@ export function deleteArea(id, areasLayer, layer, apiKey) {
}) })
.then(data => { .then(data => {
areasLayer.removeLayer(layer); // Remove the layer from the areas layer group areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
showFlashMessage('notice', `Area was successfully deleted!`);
}) })
.catch(error => { .catch(error => {
console.error('There was a problem with the delete request:', error); console.error('There was a problem with the delete request:', error);
@ -111,6 +170,7 @@ export function deleteArea(id, areasLayer, layer, apiKey) {
} }
export function fetchAndDrawAreas(areasLayer, apiKey) { export function fetchAndDrawAreas(areasLayer, apiKey) {
console.log('Fetching areas...');
fetch(`/api/v1/areas?api_key=${apiKey}`, { fetch(`/api/v1/areas?api_key=${apiKey}`, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -124,33 +184,91 @@ export function fetchAndDrawAreas(areasLayer, apiKey) {
return response.json(); return response.json();
}) })
.then(data => { .then(data => {
// Clear existing areas
areasLayer.clearLayers();
data.forEach(area => { data.forEach(area => {
// Check if necessary fields are present
if (area.latitude && area.longitude && area.radius && area.name && area.id) { if (area.latitude && area.longitude && area.radius && area.name && area.id) {
const layer = L.circle([area.latitude, area.longitude], { // Convert string coordinates to numbers
radius: area.radius, const lat = parseFloat(area.latitude);
const lng = parseFloat(area.longitude);
const radius = parseFloat(area.radius);
// Create circle with custom pane
const circle = L.circle([lat, lng], {
radius: radius,
color: 'red', color: 'red',
fillColor: '#f03', fillColor: '#f03',
fillOpacity: 0.5 fillOpacity: 0.5,
}).bindPopup(` weight: 2,
Name: ${area.name}<br> interactive: true,
Radius: ${Math.round(area.radius)} meters<br> bubblingMouseEvents: false,
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a> pane: 'areasPane'
`);
areasLayer.addLayer(layer); // Add to areas layer group
// Add event listener for the delete button
layer.on('popupopen', () => {
document.querySelector('.delete-area').addEventListener('click', (e) => {
e.preventDefault();
if (confirm('Are you sure you want to delete this area?')) {
deleteArea(area.id, areasLayer, layer, apiKey);
}
});
}); });
} else {
console.error('Area missing required fields:', area); // Bind popup content
const popupContent = `
<div class="card w-full">
<div class="card-body">
<h2 class="card-title">${area.name}</h2>
<p>Radius: ${Math.round(radius)} meters</p>
<p>Center: [${lat.toFixed(4)}, ${lng.toFixed(4)}]</p>
<div class="flex justify-end mt-4">
<button class="btn btn-sm btn-error delete-area" data-id="${area.id}">Delete</button>
</div>
</div>
</div>
`;
circle.bindPopup(popupContent);
// Add delete button handler when popup opens
circle.on('popupopen', () => {
const deleteButton = document.querySelector('.delete-area[data-id="' + area.id + '"]');
if (deleteButton) {
deleteButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (confirm('Are you sure you want to delete this area?')) {
deleteArea(area.id, areasLayer, circle, apiKey);
}
});
}
});
// Add to layer group
areasLayer.addLayer(circle);
// Wait for the circle to be added to the DOM
setTimeout(() => {
const circlePath = circle.getElement();
if (circlePath) {
// Add CSS styles
circlePath.style.cursor = 'pointer';
circlePath.style.transition = 'all 0.3s ease';
// Add direct DOM event listeners
circlePath.addEventListener('click', (e) => {
e.stopPropagation();
circle.openPopup();
});
circlePath.addEventListener('mouseenter', (e) => {
e.stopPropagation();
circle.setStyle({
fillOpacity: 0.8,
weight: 3
});
});
circlePath.addEventListener('mouseleave', (e) => {
e.stopPropagation();
circle.setStyle({
fillOpacity: 0.5,
weight: 2
});
});
}
}, 100);
} }
}); });
}) })

View file

@ -144,7 +144,7 @@ class GoogleMaps::PhoneTakeoutParser
end end
def parse_raw_array(raw_data) def parse_raw_array(raw_data)
raw_data.map do |data_point| raw_data.flat_map do |data_point|
if data_point.dig('visit', 'topCandidate', 'placeLocation') if data_point.dig('visit', 'topCandidate', 'placeLocation')
parse_visit_place_location(data_point) parse_visit_place_location(data_point)
elsif data_point.dig('activity', 'start') && data_point.dig('activity', 'end') elsif data_point.dig('activity', 'start') && data_point.dig('activity', 'end')
@ -152,7 +152,7 @@ class GoogleMaps::PhoneTakeoutParser
elsif data_point['timelinePath'] elsif data_point['timelinePath']
parse_timeline_path(data_point) parse_timeline_path(data_point)
end end
end.flatten.compact end.compact
end end
def parse_semantic_segments(semantic_segments) def parse_semantic_segments(semantic_segments)

View file

@ -16,7 +16,7 @@ class OwnTracks::Params
altitude: params[:alt], altitude: params[:alt],
accuracy: params[:acc], accuracy: params[:acc],
vertical_accuracy: params[:vac], vertical_accuracy: params[:vac],
velocity: params[:vel], velocity: speed,
ssid: params[:SSID], ssid: params[:SSID],
bssid: params[:BSSID], bssid: params[:BSSID],
tracker_id: params[:tid], tracker_id: params[:tid],
@ -69,4 +69,16 @@ class OwnTracks::Params
else 'unknown' else 'unknown'
end end
end end
def speed
return params[:vel] unless owntracks_point?
# OwnTracks speed is in km/h, so we need to convert it to m/s
# Reference: https://owntracks.org/booklet/tech/json/
((params[:vel].to_f * 1000) / 3600).round(1).to_s
end
def owntracks_point?
params[:topic].present?
end
end end

View file

@ -1,5 +1,5 @@
2024-03-01T09:03:09Z * {"bs":2,"p":100.266,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.332,"vel":0,"t":"p","BSSID":"b0:f2:8:45:94:33","SSID":"Home Wifi","conn":"w","vac":4,"acc":10,"tst":1709283789,"lat":52.225,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} 2024-03-01T09:03:09Z * {"bs":2,"p":100.266,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.332,"vel":5,"t":"p","BSSID":"b0:f2:8:45:94:33","SSID":"Home Wifi","conn":"w","vac":4,"acc":10,"tst":1709283789,"lat":52.225,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true}
2024-03-01T17:46:02Z * {"bs":1,"p":100.28,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.333,"t":"p","vel":0,"BSSID":"b0:f2:8:45:94:33","conn":"w","SSID":"Home Wifi","vac":3,"cog":98,"acc":9,"tst":1709315162,"lat":52.226,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true} 2024-03-01T17:46:02Z * {"bs":1,"p":100.28,"batt":94,"_type":"location","tid":"RO","topic":"owntracks/test/iPhone 12 Pro","alt":36,"lon":13.333,"t":"p","vel":5,"BSSID":"b0:f2:8:45:94:33","conn":"w","SSID":"Home Wifi","vac":3,"cog":98,"acc":9,"tst":1709315162,"lat":52.226,"m":1,"inrids":["5f1d1b"],"inregions":["home"],"_http":true}
2024-03-01T18:26:55Z * {"lon":13.334,"acc":5,"wtst":1696359532,"event":"leave","rid":"5f1d1b","desc":"home","topic":"owntracks/test/iPhone 12 Pro/event","lat":52.227,"t":"c","tst":1709317615,"tid":"RO","_type":"transition","_http":true} 2024-03-01T18:26:55Z * {"lon":13.334,"acc":5,"wtst":1696359532,"event":"leave","rid":"5f1d1b","desc":"home","topic":"owntracks/test/iPhone 12 Pro/event","lat":52.227,"t":"c","tst":1709317615,"tid":"RO","_type":"transition","_http":true}
2024-03-01T18:26:55Z * {"cog":40,"batt":85,"lon":13.335,"acc":5,"bs":1,"p":100.279,"vel":3,"vac":3,"lat":52.228,"topic":"owntracks/test/iPhone 12 Pro","t":"c","conn":"m","m":1,"tst":1709317615,"alt":36,"_type":"location","tid":"RO","_http":true} 2024-03-01T18:26:55Z * {"cog":40,"batt":85,"lon":13.335,"acc":5,"bs":1,"p":100.279,"vel":3,"vac":3,"lat":52.228,"topic":"owntracks/test/iPhone 12 Pro","t":"c","conn":"m","m":1,"tst":1709317615,"alt":36,"_type":"location","tid":"RO","_http":true}
2024-03-01T18:28:30Z * {"cog":38,"batt":85,"lon":13.336,"acc":5,"bs":1,"p":100.349,"vel":3,"vac":3,"lat":52.229,"topic":"owntracks/test/iPhone 12 Pro","t":"v","conn":"m","m":1,"tst":1709317710,"alt":35,"_type":"location","tid":"RO","_http":true} 2024-03-01T18:28:30Z * {"cog":38,"batt":85,"lon":13.336,"acc":5,"bs":1,"p":100.349,"vel":3,"vac":3,"lat":52.229,"topic":"owntracks/test/iPhone 12 Pro","t":"v","conn":"m","m":1,"tst":1709317710,"alt":35,"_type":"location","tid":"RO","_http":true}

View file

@ -26,7 +26,7 @@ RSpec.describe OwnTracks::ExportParser do
'altitude' => 36, 'altitude' => 36,
'accuracy' => 10, 'accuracy' => 10,
'vertical_accuracy' => 4, 'vertical_accuracy' => 4,
'velocity' => '0', 'velocity' => '1.4',
'connection' => 'wifi', 'connection' => 'wifi',
'ssid' => 'Home Wifi', 'ssid' => 'Home Wifi',
'bssid' => 'b0:f2:8:45:94:33', 'bssid' => 'b0:f2:8:45:94:33',
@ -51,7 +51,7 @@ RSpec.describe OwnTracks::ExportParser do
'tid' => 'RO', 'tid' => 'RO',
'tst' => 1_709_283_789, 'tst' => 1_709_283_789,
'vac' => 4, 'vac' => 4,
'vel' => 0, 'vel' => 5,
'SSID' => 'Home Wifi', 'SSID' => 'Home Wifi',
'batt' => 94, 'batt' => 94,
'conn' => 'w', 'conn' => 'w',
@ -64,6 +64,12 @@ RSpec.describe OwnTracks::ExportParser do
} }
) )
end end
it 'correctly converts speed' do
parser
expect(Point.first.velocity).to eq('1.4')
end
end end
end end
end end

View file

@ -20,7 +20,7 @@ RSpec.describe OwnTracks::Params do
altitude: 36, altitude: 36,
accuracy: 10, accuracy: 10,
vertical_accuracy: 4, vertical_accuracy: 4,
velocity: 0, velocity: '1.4',
ssid: 'Home Wifi', ssid: 'Home Wifi',
bssid: 'b0:f2:8:45:94:33', bssid: 'b0:f2:8:45:94:33',
tracker_id: 'RO', tracker_id: 'RO',
@ -39,7 +39,7 @@ RSpec.describe OwnTracks::Params do
'topic' => 'owntracks/test/iPhone 12 Pro', 'topic' => 'owntracks/test/iPhone 12 Pro',
'alt' => 36, 'alt' => 36,
'lon' => 13.332, 'lon' => 13.332,
'vel' => 0, 'vel' => 5,
't' => 'p', 't' => 'p',
'BSSID' => 'b0:f2:8:45:94:33', 'BSSID' => 'b0:f2:8:45:94:33',
'SSID' => 'Home Wifi', 'SSID' => 'Home Wifi',

View file

@ -16,10 +16,26 @@ describe 'Areas API', type: :request do
parameter name: :area, in: :body, schema: { parameter name: :area, in: :body, schema: {
type: :object, type: :object,
properties: { properties: {
name: { type: :string }, name: {
latitude: { type: :number }, type: :string,
longitude: { type: :number }, example: 'Home',
radius: { type: :number } description: 'The name of the area'
},
latitude: {
type: :number,
example: 40.7128,
description: 'The latitude of the area'
},
longitude: {
type: :number,
example: -74.0060,
description: 'The longitude of the area'
},
radius: {
type: :number,
example: 100,
description: 'The radius of the area in meters'
}
}, },
required: %w[name latitude longitude radius] required: %w[name latitude longitude radius]
} }
@ -47,11 +63,31 @@ describe 'Areas API', type: :request do
items: { items: {
type: :object, type: :object,
properties: { properties: {
id: { type: :integer }, id: {
name: { type: :string }, type: :integer,
latitude: { type: :number }, example: 1,
longitude: { type: :number }, description: 'The ID of the area'
radius: { type: :number } },
name: {
type: :string,
example: 'Home',
description: 'The name of the area'
},
latitude: {
type: :number,
example: 40.7128,
description: 'The latitude of the area'
},
longitude: {
type: :number,
example: -74.0060,
description: 'The longitude of the area'
},
radius: {
type: :number,
example: 100,
description: 'The radius of the area in meters'
}
}, },
required: %w[id name latitude longitude radius] required: %w[id name latitude longitude radius]
} }

View file

@ -9,7 +9,12 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do
description 'Returns a list of visited cities and countries based on tracked points within the specified date range' description 'Returns a list of visited cities and countries based on tracked points within the specified date range'
produces 'application/json' produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true parameter name: :api_key,
in: :query,
type: :string,
required: true,
example: 'a1b2c3d4e5f6g7h8i9j0',
description: 'Your API authentication key'
parameter name: :start_at, parameter name: :start_at,
in: :query, in: :query,
type: :string, type: :string,
@ -32,6 +37,36 @@ RSpec.describe 'Api::V1::Countries::VisitedCities', type: :request do
data: { data: {
type: :array, type: :array,
description: 'Array of countries and their visited cities', description: 'Array of countries and their visited cities',
example: [
{
country: 'Germany',
cities: [
{
city: 'Berlin',
points: 4394,
timestamp: 1_724_868_369,
stayed_for: 24_490
},
{
city: 'Munich',
points: 2156,
timestamp: 1_724_782_369,
stayed_for: 12_450
}
]
},
{
country: 'France',
cities: [
{
city: 'Paris',
points: 3267,
timestamp: 1_724_695_969,
stayed_for: 18_720
}
]
}
],
items: { items: {
type: :object, type: :object,
properties: { properties: {

View file

@ -8,6 +8,22 @@ describe 'Health API', type: :request do
tags 'Health' tags 'Health'
produces 'application/json' produces 'application/json'
response '200', 'Healthy' do response '200', 'Healthy' do
schema type: :object,
properties: {
status: { type: :string }
}
header 'X-Dawarich-Response',
type: :string,
required: true,
example: 'Hey, I\'m alive!',
description: "Depending on the authentication status of the request, the response will be different. If the request is authenticated, the response will be 'Hey, I'm alive and authenticated!'. If the request is not authenticated, the response will be 'Hey, I'm alive!'."
header 'X-Dawarich-Version',
type: :string,
required: true,
example: '1.0.0',
description: 'The version of the application, for example: 1.0.0'
run_test! run_test!
end end
end end

View file

@ -26,7 +26,8 @@ describe 'Overland Batches API', type: :request do
deferred: 0, deferred: 0,
significant_change: 'unknown', significant_change: 'unknown',
locations_in_payload: 1, locations_in_payload: 1,
device_id: 'Swagger', device_id: 'iOS device #166',
unique_id: '1234567890',
wifi: 'unknown', wifi: 'unknown',
battery_state: 'unknown', battery_state: 'unknown',
battery_level: 0 battery_level: 0
@ -39,36 +40,100 @@ describe 'Overland Batches API', type: :request do
parameter name: :locations, in: :body, schema: { parameter name: :locations, in: :body, schema: {
type: :object, type: :object,
properties: { properties: {
type: { type: :string }, type: { type: :string, example: 'Feature' },
geometry: { geometry: {
type: :object, type: :object,
properties: { properties: {
type: { type: :string }, type: { type: :string, example: 'Point' },
coordinates: { type: :array } coordinates: { type: :array, example: [13.356718, 52.502397] }
} }
}, },
properties: { properties: {
type: :object, type: :object,
properties: { properties: {
timestamp: { type: :string }, timestamp: {
altitude: { type: :number }, type: :string,
speed: { type: :number }, example: '2021-06-01T12:00:00Z',
horizontal_accuracy: { type: :number }, description: 'Timestamp in ISO 8601 format'
vertical_accuracy: { type: :number }, },
motion: { type: :array }, altitude: {
pauses: { type: :boolean }, type: :number,
activity: { type: :string }, example: 0,
desired_accuracy: { type: :number }, description: 'Altitude in meters'
deferred: { type: :number }, },
significant_change: { type: :string }, speed: {
locations_in_payload: { type: :number }, type: :number,
device_id: { type: :string }, example: 0,
wifi: { type: :string }, description: 'Speed in meters per second'
battery_state: { type: :string }, },
battery_level: { type: :number } horizontal_accuracy: {
} type: :number,
}, example: 0,
required: %w[geometry properties] description: 'Horizontal accuracy in meters'
},
vertical_accuracy: {
type: :number,
example: 0,
description: 'Vertical accuracy in meters'
},
motion: {
type: :array,
example: %w[walking running driving cycling stationary],
description: 'Motion type, for example: automotive_navigation, fitness, other_navigation or other'
},
activity: {
type: :string,
example: 'unknown',
description: 'Activity type, for example: automotive_navigation, fitness, other_navigation or other'
},
desired_accuracy: {
type: :number,
example: 0,
description: 'Desired accuracy in meters'
},
deferred: {
type: :number,
example: 0,
description: 'the distance in meters to defer location updates'
},
significant_change: {
type: :string,
example: 'disabled',
description: 'a significant change mode, disabled, enabled or exclusive'
},
locations_in_payload: {
type: :number,
example: 1,
description: 'the number of locations in the payload'
},
device_id: {
type: :string,
example: 'iOS device #166',
description: 'the device id'
},
unique_id: {
type: :string,
example: '1234567890',
description: 'the device\'s Unique ID as set by Apple'
},
wifi: {
type: :string,
example: 'unknown',
description: 'the WiFi network name'
},
battery_state: {
type: :string,
example: 'unknown',
description: 'the battery state, unknown, unplugged, charging or full'
},
battery_level: {
type: :number,
example: 0,
description: 'the battery level percentage, from 0 to 1'
}
},
required: %w[geometry properties]
}
} }
} }

View file

@ -39,29 +39,29 @@ describe 'OwnTracks Points API', type: :request do
parameter name: :point, in: :body, schema: { parameter name: :point, in: :body, schema: {
type: :object, type: :object,
properties: { properties: {
batt: { type: :number }, batt: { type: :number, description: 'Device battery level (percentage)' },
lon: { type: :number }, lon: { type: :number, description: 'Longitude coordinate' },
acc: { type: :number }, acc: { type: :number, description: 'Accuracy of position in meters' },
bs: { type: :number }, bs: { type: :number, description: 'Battery status (0=unknown, 1=unplugged, 2=charging, 3=full)' },
inrids: { type: :array }, inrids: { type: :array, description: 'Array of region IDs device is currently in' },
BSSID: { type: :string }, BSSID: { type: :string, description: 'Connected WiFi access point MAC address' },
SSID: { type: :string }, SSID: { type: :string, description: 'Connected WiFi network name' },
vac: { type: :number }, vac: { type: :number, description: 'Vertical accuracy in meters' },
inregions: { type: :array }, inregions: { type: :array, description: 'Array of region names device is currently in' },
lat: { type: :number }, lat: { type: :number, description: 'Latitude coordinate' },
topic: { type: :string }, topic: { type: :string, description: 'MQTT topic in format owntracks/user/device' },
t: { type: :string }, t: { type: :string, description: 'Type of message (p=position, c=circle, etc)' },
conn: { type: :string }, conn: { type: :string, description: 'Connection type (w=wifi, m=mobile, o=offline)' },
m: { type: :number }, m: { type: :number, description: 'Motion state (0=stopped, 1=moving)' },
tst: { type: :number }, tst: { type: :number, description: 'Timestamp in Unix epoch time' },
alt: { type: :number }, alt: { type: :number, description: 'Altitude in meters' },
_type: { type: :string }, _type: { type: :string, description: 'Internal message type (usually "location")' },
tid: { type: :string }, tid: { type: :string, description: 'Tracker ID used to display the initials of a user' },
_http: { type: :boolean }, _http: { type: :boolean, description: 'Whether message was sent via HTTP (true) or MQTT (false)' },
ghash: { type: :string }, ghash: { type: :string, description: 'Geohash of location' },
isorcv: { type: :string }, isorcv: { type: :string, description: 'ISO 8601 timestamp when message was received' },
isotst: { type: :string }, isotst: { type: :string, description: 'ISO 8601 timestamp of the location fix' },
disptst: { type: :string } disptst: { type: :string, description: 'Human-readable timestamp of the location fix' }
}, },
required: %w[owntracks/jane] required: %w[owntracks/jane]
} }

View file

@ -101,27 +101,73 @@ describe 'Points API', type: :request do
geometry: { geometry: {
type: :object, type: :object,
properties: { properties: {
type: { type: :string }, type: {
coordinates: { type: :array, items: { type: :number } } type: :string,
example: 'Point',
description: 'the geometry type, always Point'
},
coordinates: {
type: :array,
items: {
type: :number,
example: [-122.40530871, 37.74430413],
description: 'the coordinates of the point, longitude and latitude'
}
}
} }
}, },
properties: { properties: {
type: :object, type: :object,
properties: { properties: {
timestamp: { type: :string }, timestamp: {
horizontal_accuracy: { type: :number }, type: :string,
vertical_accuracy: { type: :number }, example: '2025-01-17T21:03:01Z',
altitude: { type: :number }, description: 'the timestamp of the point'
speed: { type: :number }, },
speed_accuracy: { type: :number }, horizontal_accuracy: {
course: { type: :number }, type: :number,
course_accuracy: { type: :number }, example: 5,
track_id: { type: :string }, description: 'the horizontal accuracy of the point in meters'
device_id: { type: :string } },
vertical_accuracy: {
type: :number,
example: -1,
description: 'the vertical accuracy of the point in meters'
},
altitude: {
type: :number,
example: 0,
description: 'the altitude of the point in meters'
},
speed: {
type: :number,
example: 92.088,
description: 'the speed of the point in meters per second'
},
speed_accuracy: {
type: :number,
example: 0,
description: 'the speed accuracy of the point in meters per second'
},
course_accuracy: {
type: :number,
example: 0,
description: 'the course accuracy of the point in degrees'
},
track_id: {
type: :string,
example: '799F32F5-89BB-45FB-A639-098B1B95B09F',
description: 'the track id of the point set by the device'
},
device_id: {
type: :string,
example: '8D5D4197-245B-4619-A88B-2049100ADE46',
description: 'the device id of the point set by the device'
}
} }
} },
}, required: %w[geometry properties]
required: %w[geometry properties] }
} }
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key' parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'

View file

@ -20,12 +20,26 @@ describe 'Settings API', type: :request do
parameter name: :settings, in: :body, schema: { parameter name: :settings, in: :body, schema: {
type: :object, type: :object,
properties: { properties: {
route_opacity: { type: :number }, route_opacity: {
meters_between_routes: { type: :number }, type: :number,
minutes_between_routes: { type: :number }, example: 0.3,
fog_of_war_meters: { type: :number }, description: 'the opacity of the route, float between 0 and 1'
time_threshold_minutes: { type: :number }, },
merge_threshold_minutes: { type: :number } meters_between_routes: {
type: :number,
example: 100,
description: 'the distance between routes in meters'
},
minutes_between_routes: {
type: :number,
example: 100,
description: 'the time between routes in minutes'
},
fog_of_war_meters: {
type: :number,
example: 100,
description: 'the fog of war distance in meters'
}
}, },
optional: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters optional: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes] time_threshold_minutes merge_threshold_minutes]
@ -49,12 +63,26 @@ describe 'Settings API', type: :request do
settings: { settings: {
type: :object, type: :object,
properties: { properties: {
route_opacity: { type: :string }, route_opacity: {
meters_between_routes: { type: :string }, type: :string,
minutes_between_routes: { type: :string }, example: 0.3,
fog_of_war_meters: { type: :string }, description: 'the opacity of the route, float between 0 and 1'
time_threshold_minutes: { type: :string }, },
merge_threshold_minutes: { type: :string } meters_between_routes: {
type: :string,
example: 100,
description: 'the distance between routes in meters'
},
minutes_between_routes: {
type: :string,
example: 100,
description: 'the time between routes in minutes'
},
fog_of_war_meters: {
type: :string,
example: 100,
description: 'the fog of war distance in meters'
}
}, },
required: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters required: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes] time_threshold_minutes merge_threshold_minutes]

View file

@ -29,12 +29,20 @@ paths:
properties: properties:
name: name:
type: string type: string
example: Home
description: The name of the area
latitude: latitude:
type: number type: number
example: 40.7128
description: The latitude of the area
longitude: longitude:
type: number type: number
example: -74.006
description: The longitude of the area
radius: radius:
type: number type: number
example: 100
description: The radius of the area in meters
required: required:
- name - name
- latitude - latitude
@ -71,14 +79,24 @@ paths:
properties: properties:
id: id:
type: integer type: integer
example: 1
description: The ID of the area
name: name:
type: string type: string
example: Home
description: The name of the area
latitude: latitude:
type: number type: number
example: 40.7128
description: The latitude of the area
longitude: longitude:
type: number type: number
example: -74.006
description: The longitude of the area
radius: radius:
type: number type: number
example: 100
description: The radius of the area in meters
required: required:
- id - id
- name - name
@ -117,6 +135,8 @@ paths:
- name: api_key - name: api_key
in: query in: query
required: true required: true
example: a1b2c3d4e5f6g7h8i9j0
description: Your API authentication key
schema: schema:
type: string type: string
- name: start_at - name: start_at
@ -146,6 +166,23 @@ paths:
data: data:
type: array type: array
description: Array of countries and their visited cities description: Array of countries and their visited cities
example:
- country: Germany
cities:
- city: Berlin
points: 4394
timestamp: 1724868369
stayed_for: 24490
- city: Munich
points: 2156
timestamp: 1724782369
stayed_for: 12450
- country: France
cities:
- city: Paris
points: 3267
timestamp: 1724695969
stayed_for: 18720
items: items:
type: object type: object
properties: properties:
@ -192,6 +229,27 @@ paths:
responses: responses:
'200': '200':
description: Healthy description: Healthy
headers:
X-Dawarich-Response:
type: string
required: true
example: Hey, I'm alive!
description: Depending on the authentication status of the request,
the response will be different. If the request is authenticated, the
response will be 'Hey, I'm alive and authenticated!'. If the request
is not authenticated, the response will be 'Hey, I'm alive!'.
X-Dawarich-Version:
type: string
required: true
example: 1.0.0
description: 'The version of the application, for example: 1.0.0'
content:
application/json:
schema:
type: object
properties:
status:
type: string
"/api/v1/overland/batches": "/api/v1/overland/batches":
post: post:
summary: Creates a batch of points summary: Creates a batch of points
@ -217,51 +275,97 @@ paths:
properties: properties:
type: type:
type: string type: string
example: Feature
geometry: geometry:
type: object type: object
properties: properties:
type: type:
type: string type: string
example: Point
coordinates: coordinates:
type: array type: array
example:
- 13.356718
- 52.502397
properties: properties:
type: object type: object
properties: properties:
timestamp: timestamp:
type: string type: string
example: '2021-06-01T12:00:00Z'
description: Timestamp in ISO 8601 format
altitude: altitude:
type: number type: number
example: 0
description: Altitude in meters
speed: speed:
type: number type: number
example: 0
description: Speed in meters per second
horizontal_accuracy: horizontal_accuracy:
type: number type: number
example: 0
description: Horizontal accuracy in meters
vertical_accuracy: vertical_accuracy:
type: number type: number
example: 0
description: Vertical accuracy in meters
motion: motion:
type: array type: array
pauses: example:
type: boolean - walking
- running
- driving
- cycling
- stationary
description: 'Motion type, for example: automotive_navigation,
fitness, other_navigation or other'
activity: activity:
type: string type: string
example: unknown
description: 'Activity type, for example: automotive_navigation,
fitness, other_navigation or other'
desired_accuracy: desired_accuracy:
type: number type: number
example: 0
description: Desired accuracy in meters
deferred: deferred:
type: number type: number
example: 0
description: the distance in meters to defer location updates
significant_change: significant_change:
type: string type: string
example: disabled
description: a significant change mode, disabled, enabled or
exclusive
locations_in_payload: locations_in_payload:
type: number type: number
example: 1
description: the number of locations in the payload
device_id: device_id:
type: string type: string
example: 'iOS device #166'
description: the device id
unique_id:
type: string
example: '1234567890'
description: the device's Unique ID as set by Apple
wifi: wifi:
type: string type: string
example: unknown
description: the WiFi network name
battery_state: battery_state:
type: string type: string
example: unknown
description: the battery state, unknown, unplugged, charging
or full
battery_level: battery_level:
type: number type: number
required: example: 0
- geometry description: the battery level percentage, from 0 to 1
- properties required:
- geometry
- properties
examples: examples:
'0': '0':
summary: Creates a batch of points summary: Creates a batch of points
@ -286,7 +390,8 @@ paths:
deferred: 0 deferred: 0
significant_change: unknown significant_change: unknown
locations_in_payload: 1 locations_in_payload: 1
device_id: Swagger device_id: 'iOS device #166'
unique_id: '1234567890'
wifi: unknown wifi: unknown
battery_state: unknown battery_state: unknown
battery_level: 0 battery_level: 0
@ -315,50 +420,74 @@ paths:
properties: properties:
batt: batt:
type: number type: number
description: Device battery level (percentage)
lon: lon:
type: number type: number
description: Longitude coordinate
acc: acc:
type: number type: number
description: Accuracy of position in meters
bs: bs:
type: number type: number
description: Battery status (0=unknown, 1=unplugged, 2=charging,
3=full)
inrids: inrids:
type: array type: array
description: Array of region IDs device is currently in
BSSID: BSSID:
type: string type: string
description: Connected WiFi access point MAC address
SSID: SSID:
type: string type: string
description: Connected WiFi network name
vac: vac:
type: number type: number
description: Vertical accuracy in meters
inregions: inregions:
type: array type: array
description: Array of region names device is currently in
lat: lat:
type: number type: number
description: Latitude coordinate
topic: topic:
type: string type: string
description: MQTT topic in format owntracks/user/device
t: t:
type: string type: string
description: Type of message (p=position, c=circle, etc)
conn: conn:
type: string type: string
description: Connection type (w=wifi, m=mobile, o=offline)
m: m:
type: number type: number
description: Motion state (0=stopped, 1=moving)
tst: tst:
type: number type: number
description: Timestamp in Unix epoch time
alt: alt:
type: number type: number
description: Altitude in meters
_type: _type:
type: string type: string
description: Internal message type (usually "location")
tid: tid:
type: string type: string
description: Tracker ID used to display the initials of a user
_http: _http:
type: boolean type: boolean
description: Whether message was sent via HTTP (true) or MQTT (false)
ghash: ghash:
type: string type: string
description: Geohash of location
isorcv: isorcv:
type: string type: string
description: ISO 8601 timestamp when message was received
isotst: isotst:
type: string type: string
description: ISO 8601 timestamp of the location fix
disptst: disptst:
type: string type: string
description: Human-readable timestamp of the location fix
required: required:
- owntracks/jane - owntracks/jane
examples: examples:
@ -725,36 +854,58 @@ paths:
properties: properties:
type: type:
type: string type: string
example: Point
description: the geometry type, always Point
coordinates: coordinates:
type: array type: array
items: items:
type: number type: number
example:
- -122.40530871
- 37.74430413
description: the coordinates of the point, longitude and latitude
properties: properties:
type: object type: object
properties: properties:
timestamp: timestamp:
type: string type: string
example: '2025-01-17T21:03:01Z'
description: the timestamp of the point
horizontal_accuracy: horizontal_accuracy:
type: number type: number
example: 5
description: the horizontal accuracy of the point in meters
vertical_accuracy: vertical_accuracy:
type: number type: number
example: -1
description: the vertical accuracy of the point in meters
altitude: altitude:
type: number type: number
example: 0
description: the altitude of the point in meters
speed: speed:
type: number type: number
example: 92.088
description: the speed of the point in meters per second
speed_accuracy: speed_accuracy:
type: number type: number
course: example: 0
type: number description: the speed accuracy of the point in meters per second
course_accuracy: course_accuracy:
type: number type: number
example: 0
description: the course accuracy of the point in degrees
track_id: track_id:
type: string type: string
example: 799F32F5-89BB-45FB-A639-098B1B95B09F
description: the track id of the point set by the device
device_id: device_id:
type: string type: string
required: example: 8D5D4197-245B-4619-A88B-2049100ADE46
- geometry description: the device id of the point set by the device
- properties required:
- geometry
- properties
examples: examples:
'0': '0':
summary: Creates a batch of points summary: Creates a batch of points
@ -821,16 +972,20 @@ paths:
properties: properties:
route_opacity: route_opacity:
type: number type: number
example: 0.3
description: the opacity of the route, float between 0 and 1
meters_between_routes: meters_between_routes:
type: number type: number
example: 100
description: the distance between routes in meters
minutes_between_routes: minutes_between_routes:
type: number type: number
example: 100
description: the time between routes in minutes
fog_of_war_meters: fog_of_war_meters:
type: number type: number
time_threshold_minutes: example: 100
type: number description: the fog of war distance in meters
merge_threshold_minutes:
type: number
optional: optional:
- route_opacity - route_opacity
- meters_between_routes - meters_between_routes
@ -873,16 +1028,21 @@ paths:
properties: properties:
route_opacity: route_opacity:
type: string type: string
example: 0.3
description: the opacity of the route, float between 0 and
1
meters_between_routes: meters_between_routes:
type: string type: string
example: 100
description: the distance between routes in meters
minutes_between_routes: minutes_between_routes:
type: string type: string
example: 100
description: the time between routes in minutes
fog_of_war_meters: fog_of_war_meters:
type: string type: string
time_threshold_minutes: example: 100
type: string description: the fog of war distance in meters
merge_threshold_minutes:
type: string
required: required:
- route_opacity - route_opacity
- meters_between_routes - meters_between_routes