{-# LANGUAGE BlockArguments #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} {-# LANGUAGE DerivingVia #-} {-# LANGUAGE LambdaCase #-} module OwnTracks.Location -- | https://owntracks.org/booklet/tech/json/ (BatteryStatus(..), Trigger(..), MonitoringMode(..), Location(..)) where import Data.Aeson import Data.Aeson.Types (Parser) import Data.ByteString (ByteString) import Data.ByteString.Base64 import Data.Functor ((<&>)) import Data.Text (Text) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) import Data.Time ( UTCTime, defaultTimeLocale, parseTimeM) import Database.Persist import GHC.Generics (Generic) data BatteryStatus = Unknown | Unplugged | Charging | Full deriving (Generic, Show, Eq, Enum) data Trigger = Ping -- ^ ping issued randomly by background task (iOS,Android) | CircularRegionEnterLeave -- ^ circular region enter/leave event (iOS,Android) | CircularRegionEnterLeavePlus -- ^ circular region enter/leave event for +follow regions (iOS) | BeaconRegionEnterLeave -- ^ beacon region enter/leave event (iOS) | ReportLocationResponse -- ^ response to a reportLocation cmd message (iOS,Android) | ManualTrigger -- ^ manual publish requested by the user (iOS,Android) | Timer -- ^ timer based publish in move move (iOS) | LocationsServices -- ^ updated by Settings/Privacy/Locations Services/System Services/Frequent Locations monitoring (iOS) deriving (Generic, Show, Eq) instance FromJSON Trigger where parseJSON (String s) = case s of "p" -> pure Ping "c" -> pure CircularRegionEnterLeave "C" -> pure CircularRegionEnterLeavePlus "b" -> pure BeaconRegionEnterLeave "r" -> pure ReportLocationResponse "m" -> pure ManualTrigger "t" -> pure Timer "v" -> pure LocationsServices _ -> fail "Unknown Trigger Type (not one of p, c, C, b, r, m, t, v)" parseJSON _ = fail "Trigger Type must be a string" data MonitoringMode = Quiet | Manual | Significant | Move deriving (Generic, Show, Eq) instance FromJSON MonitoringMode where parseJSON (Number i) = case i of -1 -> pure Quiet 0 -> pure Manual 1 -> pure Significant 2 -> pure Move _ -> fail "Unknown Monitoring Mode (not in -1,..,2)" parseJSON _ = fail "Monitoring Mode must be a number" data Connection = Wifi { connectionSSID :: Maybe Text -- ^ if available, is the unique name of the WLAN. (iOS,string/optional) , connectionBSSID :: Maybe Text -- ^ if available, identifies the access point. (iOS,string/optional) } | Offline | Mobile deriving (Generic, Show, Eq) -- | https://owntracks.org/booklet/tech/json/ data Location = Location { locationAccuracy :: Maybe Int -- ^ Accuracy of the reported location in meters without unit (iOS,Android/integer/meters/optional) , locationAltitude :: Maybe Int -- ^ Altitude measured above sea level (iOS,Android/integer/meters/optional) , locationBattery :: Maybe Int -- ^ Device battery level (iOS,Android/integer/percent/optional) , locationBatteryStatus :: BatteryStatus -- ^ Battery Status 0=unknown, 1=unplugged, 2=charging, 3=full (iOS, Android) , locationCourse :: Maybe Int -- ^ Course over ground (iOS/integer/degree/optional) , locationLatitude :: Double -- ^ latitude (iOS,Android/float/degree/required) , locationLongitude :: Double -- ^ longitude (iOS,Android/float/degree/required) , locationRegionRadios :: Maybe Int -- ^ radius around the region when entering/leaving (iOS/integer/meters/optional) , locationTrigger :: Maybe Trigger -- ^ trigger for the location report (iOS,Android/string/optional) , locationTrackerId :: Maybe Text -- ^ Tracker ID used to display the initials of a user (iOS,Android/string/optional) required for http mode , locationTimestamp :: UTCTime -- ^ UNIX epoch timestamp in seconds of the location fix (iOS,Android/integer/epoch/required) , locationVerticalAccuracy :: Maybe Int -- ^ vertical accuracy of the alt element (iOS/integer/meters/optional) , locationVelocity :: Maybe Int -- ^ velocity (iOS,Android/integer/kmh/optional) , locationBarometricPressure :: Maybe Double -- ^ barometric pressure (iOS/float/kPa/optional/extended data) , locationPointOfInterestName :: Maybe Text -- ^ point of interest name (iOS/string/optional) , locationImage :: Maybe ByteString -- ^ Base64 encoded image associated with the poi (iOS/string/optional) , locationImageName :: Maybe Text -- ^ Name of the image associated with the poi (iOS/string/optional) , locationConnection :: Maybe Connection -- ^ Internet connectivity status (route to host) when the message is created (iOS,Android/string/optional/extended data) , locationTag :: Maybe Text -- ^ name of the tag (iOS/string/optional) , locationTopic :: Maybe Text -- ^ (only in HTTP payloads) contains the original publish topic (e.g. owntracks/jane/phone). (iOS,Android >= 2.4,string) , locationInRegions :: Maybe [Text] -- ^ contains a list of regions the device is currently in (e.g. ["Home","Garage"]). Might be empty. (iOS,Android/list of strings/optional) , locationInRegionIds :: Maybe [Text] -- ^ contains a list of region IDs the device is currently in (e.g. ["6da9cf","3defa7"]). Might be empty. (iOS,Android/list of strings/optional) , locationCreatedAt :: Maybe UTCTime -- ^ identifies the time at which the message is constructed (if it differs from locationTimestamp, which is the timestamp of the GPS fix) (iOS,Android/integer/epoch/optional) , locationMonitoringMode :: Maybe MonitoringMode -- ^ identifies the monitoring mode at which the message is constructed (significant=1, move=2) (iOS/integer/optional) , locationRandomId :: Maybe Text -- ^ random identifier to be used by consumers to correlate & distinguish send/return messages (Android/string) , locationMotionActivities :: Maybe Text -- ^ contains a list of motion states detected by iOS' motion manager (a combination of stationary, walking, running, automotive, cycling, and/or unknown, e.g. ["cycling"]). (iOS/list of strings/optional) } deriving (Generic, Show, Eq) instance FromJSON Location where parseJSON (Object v) = Location <$> v .: "acc" <*> v .: "alt" <*> v .: "batt" <*> (v .: "bs" <&> toEnum) <*> v .: "cog" <*> v .: "lat" <*> v .: "lon" <*> v .: "rad" <*> v .: "t" <*> v .: "tid" <*> (v .: "tst" >>= parseUnixTime) <*> v .: "vac" <*> v .: "vel" <*> v .: "p" <*> v .: "poi" <*> (v .: "image" >>= mapM fromBase64) <*> v .: "imagename" <*> (v .: "conn" >>= mapM parseConnection) <*> v .: "tag" <*> v .: "topic" <*> v .: "inregions" <*> v .: "inrids" <*> (v .: "created_at" >>= mapM parseUnixTime) <*> v .: "m" <*> v .: "_id" <*> v .: "motionactivities" where parseUnixTime :: String -> Parser UTCTime parseUnixTime = parseTimeM False defaultTimeLocale "%s" parseConnection = withText "Connection" \case "o" -> pure Offline "m" -> pure Mobile "w" -> Wifi <$> v .: "SSID" <*> v .: "BSSID" fromBase64 v = case decodeBase64Untyped (encodeUtf8 v) of Right bytes -> pure bytes Left err -> fail $ "image field could not be read: " <> T.unpack err