Add notifications

This commit is contained in:
Eugene Burmakin 2024-07-04 22:20:12 +02:00
parent 09152b505d
commit bb2beb519b
33 changed files with 589 additions and 43 deletions

View file

@ -1 +1 @@
0.8.2 0.8.3

View file

@ -5,7 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.8.1] — 2024-06-30 ## [0.8.3] — 2024-07-03
### Added
- Notifications system. Now you will receive a notification when an import or export is finished, when stats update is completed and if any error occurs during any of these processes. Notifications are displayed in the top right corner of the screen and are stored in the database. You can see all your notifications on the Notifications page.
- Swagger API docs for /api/v1/owntracks/points You can find the API docs at `/api-docs`.
---
## [0.8.2] — 2024-06-30
### Added ### Added

File diff suppressed because one or more lines are too long

View file

@ -3,8 +3,16 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include Pundit::Authorization include Pundit::Authorization
before_action :unread_notifications
protected protected
def unread_notifications
return [] unless current_user
@unread_notifications ||= Notification.where(user: current_user).unread
end
def authenticate_api_key def authenticate_api_key
return head :unauthorized unless current_api_user return head :unauthorized unless current_api_user

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
class NotificationsController < ApplicationController
before_action :authenticate_user!
before_action :set_notification, only: %i[show destroy]
def index
@notifications = current_user.notifications.paginate(page: params[:page], per_page: 25)
end
def show; end
def destroy
@notification.destroy!
redirect_to notifications_url, notice: 'Notification was successfully destroyed.', status: :see_other
end
private
def set_notification
@notification = Notification.find(params[:id])
end
end

View file

@ -13,7 +13,21 @@ class ImportJob < ApplicationJob
raw_points: result[:raw_points], doubles: result[:doubles], processed: result[:processed] raw_points: result[:raw_points], doubles: result[:doubles], processed: result[:processed]
) )
Notifications::Create.new(
user:,
kind: :info,
title: 'Import finished',
content: "Import \"#{import.name}\" successfully finished."
).call
StatCreatingJob.perform_later(user_id) StatCreatingJob.perform_later(user_id)
rescue StandardError => e
Notifications::Create.new(
user:,
kind: :error,
title: 'Import failed',
content: "Import \"#{import.name}\" failed: #{e.message}"
).call
end end
private private

View file

@ -3,8 +3,7 @@
class Owntracks::PointCreatingJob < ApplicationJob class Owntracks::PointCreatingJob < ApplicationJob
queue_as :default queue_as :default
# TODO: after deprecation of old endpoint, make user_id required def perform(point_params, user_id)
def perform(point_params, user_id = nil)
parsed_params = OwnTracks::Params.new(point_params).call parsed_params = OwnTracks::Params.new(point_params).call
return if point_exists?(parsed_params, user_id) return if point_exists?(parsed_params, user_id)

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Notification < ApplicationRecord
belongs_to :user
validates :title, :content, :kind, presence: true
enum kind: { info: 0, warning: 1, error: 2 }
scope :unread, -> { where(read_at: nil) }
end

View file

@ -11,6 +11,7 @@ class User < ApplicationRecord
has_many :stats, dependent: :destroy has_many :stats, dependent: :destroy
has_many :tracked_points, class_name: 'Point', dependent: :destroy has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy
after_create :create_api_key after_create :create_api_key

View file

@ -26,6 +26,10 @@ class CreateStats
stat.save stat.save
end end
end end
Notifications::Create.new(user:, kind: :info, title: 'Stats updated', content: 'Stats updated').call
rescue StandardError => e
Notifications::Create.new(user:, kind: :error, title: 'Stats update failed', content: e.message).call
end end
end end

View file

@ -23,9 +23,23 @@ class Exports::Create
File.open(file_path, 'w') { |file| file.write(data) } File.open(file_path, 'w') { |file| file.write(data) }
export.update!(status: :completed, url: "exports/#{export.name}.json") export.update!(status: :completed, url: "exports/#{export.name}.json")
Notifications::Create.new(
user:,
kind: :info,
title: 'Export finished',
content: "Export \"#{export.name}\" successfully finished."
).call
rescue StandardError => e rescue StandardError => e
Rails.logger.error("====Export failed to create: #{e.message}") Rails.logger.error("====Export failed to create: #{e.message}")
Notifications::Create.new(
user:,
kind: :error,
title: 'Export failed',
content: "Export \"#{export.name}\" failed: #{e.message}"
).call
export.update!(status: :failed) export.update!(status: :failed)
end end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Notifications::Create
attr_reader :user, :kind, :title, :content
def initialize(user:, kind:, title:, content:)
@user = user
@kind = kind
@title = title
@content = content
end
def call
Notification.create!(user:, kind:, title:, content:)
end
end

View file

@ -6,7 +6,7 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.11.1/dist/full.css" rel="stylesheet" type="text/css"> <link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>

View file

@ -1,6 +1,6 @@
<% content_for :title, 'Map' %> <% content_for :title, 'Map' %>
<div class='w-4/5 mt-10'> <div class='w-4/5 mt-8'>
<div class="flex flex-col space-y-4 mb-4 w-full"> <div class="flex flex-col space-y-4 mb-4 w-full">
<%= form_with url: map_path, method: :get do |f| %> <%= form_with url: map_path, method: :get do |f| %>
<div class="flex flex-col md:flex-row md:space-x-4 md:items-end"> <div class="flex flex-col md:flex-row md:space-x-4 md:items-end">
@ -55,7 +55,7 @@
</div> </div>
</div> </div>
<div class='w-1/5 mt-10'> <div class='w-1/5 mt-8'>
<%= render 'shared/right_sidebar' %> <%= render 'shared/right_sidebar' %>
</div> </div>

View file

@ -0,0 +1,17 @@
<div role="<%= notification.kind %>" class="<%= notification.kind %> shadow-lg" id="<%= dom_id notification %>">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold"><%= link_to notification.title, notification, class: 'link hover:no-underline' %></h3>
<div class="text-s"><%= time_ago_in_words notification.created_at %> ago</div>
</div>
</div>

View file

@ -0,0 +1,12 @@
<% content_for :title, "Notifications" %>
<div class="w-full">
<div class="flex justify-between items-center">
<h1 class="font-bold text-4xl">Notifications</h1>
</div>
<div id="notifications" class="min-w-full">
<% @notifications.each do |notification| %>
<%= render notification %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,14 @@
<div class="mx-auto md:w-2/3 w-full flex">
<div class="mx-auto">
<% if notice.present? %>
<p class="py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-lg inline-block" id="notice"><%= notice %></p>
<% end %>
<%= render @notification %>
<%= link_to "Back to notifications", notifications_path, class: "ml-2 rounded-lg py-3 px-5 bg-gray-100 inline-block font-medium" %>
<div class="inline-block ml-2">
<%= button_to "Destroy this notification", @notification, method: :delete, class: "mt-2 rounded-lg py-3 px-5 bg-gray-100 font-medium" %>
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
<div class="navbar bg-base-100 mb-5"> <div class="navbar bg-base-100">
<div class="navbar-start"> <div class="navbar-start">
<div class="dropdown"> <div class="dropdown">
<label tabindex="0" class="btn btn-ghost lg:hidden"> <label tabindex="0" class="btn btn-ghost lg:hidden">
@ -46,22 +46,53 @@
</ul> </ul>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">
<%# menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52 %>
<ul class="menu menu-horizontal bg-base-100 rounded-box px-1"> <ul class="menu menu-horizontal bg-base-100 rounded-box px-1">
<% if user_signed_in? %> <% if user_signed_in? %>
<li> <div class="dropdown dropdown-end dropdown-bottom">
<details> <div tabindex="0" role="button" class='btn btn-sm btn-ghost hover:btn-ghost'>
<summary> <svg
<%= "#{current_user.email}" %> xmlns="http://www.w3.org/2000/svg"
</summary> class="h-5 w-5"
<ul class="p-2 bg-base-100 rounded-t-none z-10"> fill="none"
<li><%= link_to 'Account', edit_user_registration_path %></li> viewBox="0 0 24 24"
<li><%= link_to 'Settings', settings_path %></li> stroke="currentColor">
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %></li> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<% if @unread_notifications.present? %>
<span class="badge badge-xs badge-primary"></span>
<% end %>
</div>
<ul tabindex="0" class="dropdown-content z-100 menu p-2 shadow-lg bg-base-100 rounded-box min-w-52">
<% if @unread_notifications.any? %>
<li><%= link_to 'See all', notifications_path %></li>
<div class="divider p-0 m-0"></div>
<% end %>
<% @unread_notifications.first(10).each do |notification| %>
<li>
<a>
<%= notification.title %>
<span class="badge badge-xs justify-self-end badge-<%= notification.kind %>"></span>
</a>
</li>
<% end %>
</ul> </ul>
</details> </div>
</li> <li>
</details> <details>
<summary>
<%= "#{current_user.email}" %>
</summary>
<ul class="p-2 bg-base-100 rounded-t-none z-10">
<li><%= link_to 'Account', edit_user_registration_path %></li>
<li><%= link_to 'Settings', settings_path %></li>
<li><%= link_to 'Logout', destroy_user_session_path, method: :delete, data: { turbo_method: :delete } %></li>
</ul>
</details>
</li>
<% else %> <% else %>
<li><%= link_to 'Login', new_user_session_path %></li> <li><%= link_to 'Login', new_user_session_path %></li>
<% end %> <% end %>

View file

@ -23,6 +23,7 @@ Rails.application.routes.draw do
delete :bulk_destroy delete :bulk_destroy
end end
end end
resources :notifications, only: %i[index show destroy]
resources :stats, only: :index do resources :stats, only: :index do
collection do collection do
post :update post :update

View file

@ -0,0 +1,14 @@
class CreateNotifications < ActiveRecord::Migration[7.1]
def change
create_table :notifications do |t|
t.string :title, null: false
t.text :content, null: false
t.references :user, null: false, foreign_key: true
t.integer :kind, null: false, default: 0
t.datetime :read_at
t.timestamps
end
add_index :notifications, :kind
end
end

29
db/schema.rb generated
View file

@ -10,10 +10,24 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_06_30_093005) do ActiveRecord::Schema[7.1].define(version: 2024_07_03_105734) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace"
t.text "body"
t.string "resource_type"
t.bigint "resource_id"
t.string "author_type"
t.bigint "author_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["author_type", "author_id"], name: "index_active_admin_comments_on_author"
t.index ["namespace"], name: "index_active_admin_comments_on_namespace"
t.index ["resource_type", "resource_id"], name: "index_active_admin_comments_on_resource"
end
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@ -70,6 +84,18 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_30_093005) do
t.index ["user_id"], name: "index_imports_on_user_id" t.index ["user_id"], name: "index_imports_on_user_id"
end end
create_table "notifications", force: :cascade do |t|
t.string "title", null: false
t.text "content", null: false
t.bigint "user_id", null: false
t.integer "kind", default: 0, null: false
t.datetime "read_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["kind"], name: "index_notifications_on_kind"
t.index ["user_id"], name: "index_notifications_on_user_id"
end
create_table "points", force: :cascade do |t| create_table "points", force: :cascade do |t|
t.integer "battery_status" t.integer "battery_status"
t.string "ping" t.string "ping"
@ -142,6 +168,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_30_093005) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "notifications", "users"
add_foreign_key "points", "users" add_foreign_key "points", "users"
add_foreign_key "stats", "users" add_foreign_key "stats", "users"
end end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
FactoryBot.define do
factory :notification do
title { "MyString" }
content { "MyText" }
user
kind { :info }
read_at { nil }
end
end

View file

@ -18,5 +18,23 @@ RSpec.describe ImportJob, type: :job do
perform perform
end end
it 'creates a notification' do
expect { perform }.to change { Notification.count }.by(1)
end
context 'when there is an error' do
before do
allow_any_instance_of(OwnTracks::ExportParser).to receive(:call).and_raise(StandardError)
end
it 'does not create points' do
expect { perform }.not_to(change { Point.count })
end
it 'creates a notification' do
expect { perform }.to change { Notification.count }.by(1)
end
end
end end
end end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Notification, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:content) }
it { is_expected.to validate_presence_of(:kind) }
end
describe 'associations' do
it { is_expected.to belong_to(:user) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:kind).with_values(info: 0, warning: 1, error: 2) }
end
describe 'scopes' do
describe '.unread' do
let(:read_notification) { create(:notification, read_at: Time.current) }
let(:unread_notification) { create(:notification, read_at: nil) }
it 'returns only unread notifications' do
expect(described_class.unread).to eq([unread_notification])
end
end
end
end

View file

@ -12,8 +12,5 @@ RSpec.describe Point, type: :model do
it { is_expected.to validate_presence_of(:latitude) } it { is_expected.to validate_presence_of(:latitude) }
it { is_expected.to validate_presence_of(:longitude) } it { is_expected.to validate_presence_of(:longitude) }
it { is_expected.to validate_presence_of(:timestamp) } it { is_expected.to validate_presence_of(:timestamp) }
# Disabled them (for now) because they are not present in the Overland data
xit { is_expected.to validate_presence_of(:tracker_id) }
xit { is_expected.to validate_presence_of(:topic) }
end end
end end

View file

@ -9,6 +9,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:stats) } it { is_expected.to have_many(:stats) }
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) } it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
it { is_expected.to have_many(:exports).dependent(:destroy) } it { is_expected.to have_many(:exports).dependent(:destroy) }
it { is_expected.to have_many(:notifications).dependent(:destroy) }
end end
describe 'callbacks' do describe 'callbacks' do

View file

@ -1,16 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'users', type: :request do
# Skip this because user registration is disabled
xdescribe 'POST /create' do
let(:user_params) do
{ user: FactoryBot.attributes_for(:user) }
end
it 'creates master' do
expect { post '/users', params: user_params }.to change(User, :count).by(1)
end
end
end

View file

@ -0,0 +1,60 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/notifications', type: :request do
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
context 'when user is not logged in' do
it 'redirects to the login page' do
get notifications_url
expect(response).to redirect_to(new_user_session_url)
end
end
context 'when user is logged in' do
let(:user) { create(:user) }
before do
sign_in user
end
describe 'GET /index' do
it 'renders a successful response' do
get notifications_url
expect(response).to be_successful
end
end
describe 'GET /show' do
let(:notification) { create(:notification, user:) }
it 'renders a successful response' do
get notification_url(notification)
expect(response).to be_successful
end
end
describe 'DELETE /destroy' do
let!(:notification) { create(:notification, user:) }
it 'destroys the requested notification' do
expect do
delete notification_url(notification)
end.to change(Notification, :count).by(-1)
end
it 'redirects to the notifications list' do
delete notification_url(notification)
expect(response).to redirect_to(notifications_url)
end
end
end
end

View file

@ -30,6 +30,24 @@ RSpec.describe CreateStats do
expect(Stat.last.distance).to eq(563) expect(Stat.last.distance).to eq(563)
end end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
context 'when there is an error' do
before do
allow(Stat).to receive(:find_or_initialize_by).and_raise(StandardError)
end
it 'does not create stats' do
expect { create_stats }.not_to(change { Stat.count })
end
it 'created notifications' do
expect { create_stats }.to change { Notification.count }.by(1)
end
end
end end
end end
end end

View file

@ -28,6 +28,16 @@ RSpec.describe Exports::Create do
expect(export.reload.url).to eq("exports/#{export.name}.json") expect(export.reload.url).to eq("exports/#{export.name}.json")
end end
it 'updates the export status to completed' do
create_export
expect(export.reload.completed?).to be_truthy
end
it 'creates a notification' do
expect { create_export }.to change { Notification.count }.by(1)
end
context 'when an error occurs' do context 'when an error occurs' do
before do before do
allow(File).to receive(:open).and_raise(StandardError) allow(File).to receive(:open).and_raise(StandardError)
@ -38,6 +48,16 @@ RSpec.describe Exports::Create do
expect(export.reload.failed?).to be_truthy expect(export.reload.failed?).to be_truthy
end end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with('====Export failed to create: StandardError')
create_export
end
it 'creates a notification' do
expect { create_export }.to change { Notification.count }.by(1)
end
end end
end end
end end

View file

@ -2,7 +2,7 @@
require 'swagger_helper' require 'swagger_helper'
describe 'Batches API', type: :request do describe 'Overland Batches API', type: :request do
path '/api/v1/overland/batches' do path '/api/v1/overland/batches' do
post 'Creates a batch of points' do post 'Creates a batch of points' do
request_body_example value: { request_body_example value: {

View file

@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'OwnTracks Points API', type: :request do
path '/api/v1/owntracks/points' do
post 'Creates a point' do
request_body_example value: {
'batt': 85,
'lon': -74.0060,
'acc': 8,
'bs': 2,
'inrids': [
'5f1d1b'
],
'BSSID': 'b0:f2:8:45:94:33',
'SSID': 'Home Wifi',
'vac': 3,
'inregions': [
'home'
],
'lat': 40.7128,
'topic': 'owntracks/jane/iPhone 12 Pro',
't': 'p',
'conn': 'w',
'm': 1,
'tst': 1706965203,
'alt': 41,
'_type': 'location',
'tid': 'RO',
'_http': true,
'ghash': 'u33d773',
'isorcv': '2024-02-03T13:00:03Z',
'isotst': '2024-02-03T13:00:03Z',
'disptst': '2024-02-03 13:00:03'
}
tags 'Points'
consumes 'application/json'
parameter name: :point, in: :body, schema: {
type: :object,
properties: {
batt: { type: :number },
lon: { type: :number },
acc: { type: :number },
bs: { type: :number },
inrids: { type: :array },
BSSID: { type: :string },
SSID: { type: :string },
vac: { type: :number },
inregions: { type: :array },
lat: { type: :number },
topic: { type: :string },
t: { type: :string },
conn: { type: :string },
m: { type: :number },
tst: { type: :number },
alt: { type: :number },
_type: { type: :string },
tid: { type: :string },
_http: { type: :boolean },
ghash: { type: :string },
isorcv: { type: :string },
isotst: { type: :string },
disptst: { type: :string }
},
required: %w[owntracks/jane]
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'Point created' do
let(:file_path) { 'spec/fixtures/files/owntracks/export.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:point) { json['test']['iphone-12-pro'].first }
let(:api_key) { create(:user).api_key }
run_test!
end
response '401', 'Unauthorized' do
let(:file_path) { 'spec/fixtures/files/owntracks/export.json' }
let(:file) { File.open(file_path) }
let(:json) { JSON.parse(file.read) }
let(:point) { json['test']['iphone-12-pro'].first }
let(:api_key) { nil }
run_test!
end
end
end
end

View file

@ -102,6 +102,106 @@ paths:
wifi: unknown wifi: unknown
battery_state: unknown battery_state: unknown
battery_level: 0 battery_level: 0
"/api/v1/owntracks/points":
post:
summary: Creates a point
tags:
- Points
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: Point created
'401':
description: Unauthorized
requestBody:
content:
application/json:
schema:
type: object
properties:
batt:
type: number
lon:
type: number
acc:
type: number
bs:
type: number
inrids:
type: array
BSSID:
type: string
SSID:
type: string
vac:
type: number
inregions:
type: array
lat:
type: number
topic:
type: string
t:
type: string
conn:
type: string
m:
type: number
tst:
type: number
alt:
type: number
_type:
type: string
tid:
type: string
_http:
type: boolean
ghash:
type: string
isorcv:
type: string
isotst:
type: string
disptst:
type: string
required:
- owntracks/jane
examples:
'0':
summary: Creates a point
value:
batt: 85
lon: -74.006
acc: 8
bs: 2
inrids:
- 5f1d1b
BSSID: b0:f2:8:45:94:33
SSID: Home Wifi
vac: 3
inregions:
- home
lat: 40.7128
topic: owntracks/jane/iPhone 12 Pro
t: p
conn: w
m: 1
tst: 1706965203
alt: 41
_type: location
tid: RO
_http: true
ghash: u33d773
isorcv: '2024-02-03T13:00:03Z'
isotst: '2024-02-03T13:00:03Z'
disptst: '2024-02-03 13:00:03'
servers: servers:
- url: http://{defaultHost} - url: http://{defaultHost}
variables: variables: