packages feed

ttn (empty) → 0.1.0.0

raw patch · 9 files changed

+707/−0 lines, 9 filesdep +aesondep +basedep +bytestringsetup-changed

Dependencies added: aeson, base, bytestring, hspec, hspec-expectations, raw-strings-qq, text, time, timerep, ttn

Files

+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Richard Marko (c) 2018++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Author name here nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ src/Data/TTN.hs view
@@ -0,0 +1,5 @@+module Data.TTN (+    module Data.TTN.Types+  ) where++import Data.TTN.Types
+ src/Data/TTN/Types.hs view
@@ -0,0 +1,384 @@+{-# LANGUAGE RecordWildCards     #-}+{-# LANGUAGE OverloadedStrings   #-}+{-# LANGUAGE DeriveGeneric       #-}++module Data.TTN.Types where++import System.Exit (exitFailure, exitSuccess)+import System.IO (stderr, hPutStrLn)+import qualified Data.ByteString.Char8 as B+import System.Environment (getArgs)+import Control.Monad (forM_, mzero, join)+import Control.Applicative+import Data.Aeson(decodeStrict, eitherDecodeStrict,+                  Value(..), FromJSON(..), ToJSON(..),+                  pairs, withText, (.:), (.:?), (.=), object)+import Data.Monoid+import Data.Text (Text, unpack)+import GHC.Generics++import Data.Time.RFC3339+import Data.Time.LocalTime++-- | Workaround for https://github.com/bos/aeson/issues/287.+o .:?? val = fmap join (o .:? val)++data TTNZonedTime = TTNZonedTime { unwrap :: ZonedTime } deriving (Show, Generic)++instance ToJSON TTNZonedTime where+  toJSON     = toJSON . (formatTimeRFC3339 :: ZonedTime -> Text) . unwrap+  toEncoding =  toEncoding . (formatTimeRFC3339 :: ZonedTime -> Text) . unwrap++instance FromJSON TTNZonedTime where+  parseJSON = withText "TTNZonedTime" $ \x ->+    case parseTimeRFC3339 x of+      Nothing -> fail $ "Parsing RFC3339 value failed, got:" ++ show x+      Just t -> return $ TTNZonedTime t+++-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/event.go#L65+data Config = Config {+    configFrequency  :: Maybe Double+  , configDataRate   :: Maybe Text+  , configCounter    :: Maybe Double+  , configAirtime    :: Maybe Double+  , configPower      :: Maybe Double+  , configModulation :: Maybe Text+  } deriving (Show, Generic)++instance FromJSON Config where+  parseJSON (Object v) = Config+    <$> v .:?? "frequency"+    <*> v .:?? "data_rate"+    <*> v .:?? "counter"+    <*> v .:?? "airtime"+    <*> v .:?? "power"+    <*> v .:?? "modulation"+  parseJSON _          = mzero++instance ToJSON Config where+  toJSON     (Config {..}) = object [+      "frequency"   .= configFrequency+    , "data_rate"   .= configDataRate+    , "counter"     .= configCounter+    , "airtime"     .= configAirtime+    , "power"       .= configPower+    , "modulation"  .= configModulation+    ]+  toEncoding (Config {..}) = pairs (+       "frequency"  .= configFrequency+    <> "data_rate"  .= configDataRate+    <> "counter"    .= configCounter+    <> "airtime"    .= configAirtime+    <> "power"      .= configPower+    <> "modulation" .= configModulation+    )+++-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/gateway_metadata.go+-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/location_metadata.go+data GatewaysElt = GatewaysElt {+    gatewaysEltGtwId                  :: Maybe Text+  , gatewaysEltGtwTrusted             :: Maybe Bool+  , gatewaysEltTimestamp              :: Maybe Int+  , gatewaysEltFineTimestamp          :: Maybe Int+  , gatewaysEltFineTimestampEncrypted :: Maybe Text+  , gatewaysEltTime                   :: Maybe TTNZonedTime+  , gatewaysEltAntenna                :: Maybe Text+  , gatewaysEltChannel                :: Double+  , gatewaysEltRSSI                   :: Double+  , gatewaysEltSNR                    :: Double+  , gatewaysEltRFChain                :: Int+  , gatewaysEltLatitude               :: Maybe Double+  , gatewaysEltLongitude              :: Maybe Double+  , gatewaysEltAltitude               :: Maybe Int+  , gatewaysEltAccuracy               :: Maybe Int+  , gatewaysEltSource                 :: Maybe Text+  } deriving (Show, Generic)++instance FromJSON GatewaysElt where+  parseJSON (Object v) = GatewaysElt+    <$> v .:?? "gtw_id"+    <*> v .:?? "gtw_trusted"+    <*> v .:?? "timestamp"+    <*> v .:?? "fine_timestamp"+    <*> v .:?? "fine_timestamp_encrypted"+    <*> v .:?? "time"+    <*> v .:?? "antenna"+    <*> v .:   "channel"+    <*> v .:   "rssi"+    <*> v .:   "snr"+    <*> v .:   "rf_chain"+    <*> v .:?? "latitude"+    <*> v .:?? "longitude"+    <*> v .:?? "altitude"+    <*> v .:?? "accuracy"+    <*> v .:?? "source"+  parseJSON _          = mzero++instance ToJSON GatewaysElt where+  toJSON     (GatewaysElt {..}) = object [+      "gtw_id"                    .= gatewaysEltGtwId+    , "gtw_trusted"               .= gatewaysEltGtwTrusted+    , "timestamp"                 .= gatewaysEltTimestamp+    , "fine_timestamp"            .= gatewaysEltFineTimestamp+    , "fine_timestamp_encrypted"  .= gatewaysEltFineTimestampEncrypted+    , "time"                      .= gatewaysEltTime+    , "antenna"                   .= gatewaysEltAntenna+    , "channel"                   .= gatewaysEltChannel+    , "rssi"                      .= gatewaysEltRSSI+    , "snr"                       .= gatewaysEltSNR+    , "rf_chain"                  .= gatewaysEltRFChain+    , "latitude"                  .= gatewaysEltLatitude+    , "longitude"                 .= gatewaysEltLongitude+    , "altitude"                  .= gatewaysEltAltitude+    , "accuracy"                  .= gatewaysEltAccuracy+    , "source"                    .= gatewaysEltSource+    ]+  toEncoding (GatewaysElt {..}) = pairs (+       "gtw_id"                   .= gatewaysEltGtwId+    <> "gtw_trusted"              .= gatewaysEltGtwTrusted+    <> "timestamp"                .= gatewaysEltTimestamp+    <> "fine_timestamp"           .= gatewaysEltFineTimestamp+    <> "fine_timestamp_encrypted" .= gatewaysEltFineTimestampEncrypted+    <> "time"                     .= gatewaysEltTime+    <> "antenna"                  .= gatewaysEltAntenna+    <> "channel"                  .= gatewaysEltChannel+    <> "rssi"                     .= gatewaysEltRSSI+    <> "snr"                      .= gatewaysEltSNR+    <> "rf_chain"                 .= gatewaysEltRFChain+    <> "latitude"                 .= gatewaysEltLatitude+    <> "longitude"                .= gatewaysEltLongitude+    <> "altitude"                 .= gatewaysEltAltitude+    <> "accuracy"                 .= gatewaysEltAccuracy+    <> "source"                   .= gatewaysEltSource+    )+++-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/metadata.go+data Metadata = Metadata {+    metadataTime       :: Maybe TTNZonedTime+  , metadataFrequency  :: Maybe Double+  , metadataModulation :: Maybe Text+  , metadataDataRate   :: Maybe Text+  , metadataBitRate    :: Maybe Int+  , metadataAirtime    :: Maybe Double+  , metadataCodingRate :: Maybe Text+  , metadataGateways   :: [GatewaysElt]+  } deriving (Show, Generic)++instance FromJSON Metadata where+  parseJSON (Object v) = Metadata <$>+        v .:?? "time"+    <*> v .:?? "frequency"+    <*> v .:?? "modulation"+    <*> v .:?? "data_rate"+    <*> v .:?? "bit_rate"+    <*> v .:?? "airtime"+    <*> v .:?? "coding_rate"+    <*> v .:   "gateways"+  parseJSON _          = mzero++instance ToJSON Metadata where+  toJSON     (Metadata {..}) = object [+      "time"         .= metadataTime+    , "frequency"    .= metadataFrequency+    , "modulation"   .= metadataModulation+    , "data_rate"    .= metadataDataRate+    , "bit_rate"     .= metadataBitRate+    , "coding_rate"  .= metadataCodingRate+    , "airtime"      .= metadataAirtime+    , "gateways"     .= metadataGateways+    ]+  toEncoding (Metadata {..}) = pairs (+       "time" .= metadataTime+    <> "frequency"   .= metadataFrequency+    <> "modulation"  .= metadataModulation+    <> "data_rate"   .= metadataDataRate+    <> "bit_rate"    .= metadataBitRate+    <> "coding_rate" .= metadataCodingRate+    <> "airtime"     .= metadataAirtime+    <> "gateways"    .= metadataGateways+    )+++data Message = Message {+    messageDevId :: Text,+    messageAppId :: Text,+    messagePort  :: Double+  } deriving (Show, Generic)++instance FromJSON Message where+  parseJSON (Object v) = Message+    <$> v .: "dev_id"+    <*> v .: "app_id"+    <*> v .: "port"+  parseJSON _          = mzero++instance ToJSON Message where+  toJSON     (Message {..}) = object [+      "dev_id"  .= messageDevId+    , "app_id"  .= messageAppId+    , "port"    .= messagePort+    ]+  toEncoding (Message {..}) = pairs (+       "dev_id" .= messageDevId+    <> "app_id" .= messageAppId+    <> "port"   .= messagePort+    )+++-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/uplink_message.go+data Uplink = Uplink {+    uplinkConfig         :: Maybe Config+  , uplinkGatewayId      :: Maybe Text+  , uplinkDevId          :: Maybe Text+  , uplinkPayload        :: Maybe Text+  , uplinkCounter        :: Maybe Double+  , uplinkIsRetry        :: Maybe Bool+  , uplinkMetadata       :: Maybe Metadata+  , uplinkPayloadRaw     :: Maybe Text+  , uplinkMessage        :: Maybe Message+  , uplinkAppId          :: Maybe Text+  , uplinkConfirmed      :: Maybe Bool+  , uplinkHardwareSerial :: Maybe Text+  , uplinkPort           :: Maybe Double+  } deriving (Show, Generic)++instance FromJSON Uplink where+  parseJSON (Object v) = Uplink+    <$> v .:?? "config"+    <*> v .:?? "gateway_id"+    <*> v .:?? "dev_id"+    <*> v .:?? "payload"+    <*> v .:?? "counter"+    <*> v .:?? "is_retry"+    <*> v .:?? "metadata"+    <*> v .:?? "payload_raw"+    <*> v .:?? "message"+    <*> v .:?? "app_id"+    <*> v .:?? "confirmed"+    <*> v .:?? "hardware_serial"+    <*> v .:?? "port"+  parseJSON _          = mzero++instance ToJSON Uplink where+  toJSON     (Uplink {..}) = object [+      "config"           .= uplinkConfig+    , "gateway_id"       .= uplinkGatewayId+    , "dev_id"           .= uplinkDevId+    , "payload"          .= uplinkPayload+    , "counter"          .= uplinkCounter+    , "is_retry"         .= uplinkIsRetry+    , "metadata"         .= uplinkMetadata+    , "payload_raw"      .= uplinkPayloadRaw+    , "message"          .= uplinkMessage+    , "app_id"           .= uplinkAppId+    , "confirmed"        .= uplinkConfirmed+    , "hardware_serial"  .= uplinkHardwareSerial+    , "port"             .= uplinkPort+    ]+  toEncoding (Uplink {..}) = pairs (+       "config"          .= uplinkConfig+    <> "gateway_id"      .= uplinkGatewayId+    <> "dev_id"          .= uplinkDevId+    <> "payload"         .= uplinkPayload+    <> "counter"         .= uplinkCounter+    <> "is_retry"        .= uplinkIsRetry+    <> "metadata"        .= uplinkMetadata+    <> "payload_raw"     .= uplinkPayloadRaw+    <> "message"         .= uplinkMessage+    <> "app_id"          .= uplinkAppId+    <> "confirmed"       .= uplinkConfirmed+    <> "hardware_serial" .= uplinkHardwareSerial+    <> "port"            .= uplinkPort+    )++data Schedule = ScheduleReplace | ScheduleFirst | ScheduleLast+  deriving (Eq, Show)++instance FromJSON Schedule where+  parseJSON = withText "Schedule" $ \x ->+    case x of+      "replace" -> return ScheduleReplace+      "first"   -> return ScheduleFirst+      "last"    -> return ScheduleLast+      w         -> fail $ "Unknown schedule" ++ (unpack w)++instance ToJSON Schedule where+  toJSON ScheduleReplace = "replace"+  toJSON ScheduleFirst   = "first"+  toJSON ScheduleLast    = "last"++-- https://github.com/TheThingsNetwork/ttn/blob/develop/core/types/downlink_message.go+data Downlink = Downlink {+    downlinkAppId          :: Text+  , downlinkDevId          :: Text+  , downlinkPort           :: Double+  , downlinkPayloadRaw     :: Text+  , downlinkConfirmed      :: Bool+  , downlinkSchedule       :: Maybe Schedule+  } deriving (Show, Eq, Generic)++instance FromJSON Downlink where+  parseJSON (Object v) = Downlink+    <$> v .:   "app_id"+    <*> v .:   "dev_id"+    <*> v .:   "port"+    <*> v .:   "payload_raw"+    <*> v .:   "confirmed"+    <*> v .:?? "schedule"+  parseJSON _          = mzero++instance ToJSON Downlink where+  toJSON     (Downlink {..}) = object [+      "app_id"           .= downlinkAppId+    , "dev_id"           .= downlinkDevId+    , "port"             .= downlinkPort+    , "payload_raw"      .= downlinkPayloadRaw+    , "confirmed"        .= downlinkConfirmed+    , "schedule"         .= downlinkSchedule+    ]+  toEncoding (Downlink {..}) = pairs (+       "app_id"          .= downlinkAppId+    <> "dev_id"          .= downlinkDevId+    <> "port"            .= downlinkPort+    <> "payload_raw"     .= downlinkPayloadRaw+    <> "confirmed"       .= downlinkConfirmed+    <> "schedule"        .= downlinkSchedule+    )+++data Error = Error { errorMsg :: Text } deriving (Show)++instance FromJSON Error where+  parseJSON (Object v) = Error <$> v .: "error"+  parseJSON _          = mzero++instance ToJSON Error where+  toJSON (Error {..}) = object [ "error" .= errorMsg ]+  toEncoding (Error {..}) = pairs ( "error" .= errorMsg )++parse :: B.ByteString -> Either String Uplink+parse = eitherDecodeStrict++parseError :: B.ByteString -> Either String Error+parseError = eitherDecodeStrict++parseFile :: FilePath -> IO Uplink+parseFile filename = do+  input <- B.readFile filename+  case decodeStrict input of+    Nothing -> fatal $ case (decodeStrict input :: Maybe Value) of+                         Nothing -> "Invalid JSON file: "     ++ filename+                         Just v  -> "Mismatched JSON value from file: " ++ filename+    Just r  -> return (r :: Uplink)+  where+    fatal :: String -> IO a+    fatal msg = do hPutStrLn stderr msg+                   exitFailure++parseMany :: [String] -> IO ()+parseMany filenames = do+  forM_ filenames (\f -> parseFile f >>= (\p -> p `seq` putStrLn $ "Successfully parsed " ++ f))+  exitSuccess
+ test/DownlinkSpec.hs view
@@ -0,0 +1,23 @@+{-# LANGUAGE OverloadedStrings #-}++module DownlinkSpec where++import SpecHelper+import Data.Aeson++spec :: Spec+spec = do+  it "parses downlink" $ do+    let d = Downlink {+              downlinkAppId = "APP"+            , downlinkDevId = "DEV"+            , downlinkPort  = 0+            , downlinkPayloadRaw = "payload"+            , downlinkConfirmed = False+            , downlinkSchedule = Just ScheduleFirst+            }++    (decode . encode $ d) `shouldBe` (Just d)++main :: IO ()+main = hspec spec
+ test/ParseSpec.hs view
@@ -0,0 +1,208 @@+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE OverloadedStrings #-}++module ParseSpec where++import Text.RawString.QQ++import SpecHelper++spec :: Spec+spec = do+  it "parses samples" $ do+    mapM_ (\x -> parse x `shouldSatisfy` isRight) [p1, p2, p3, p4, p5, p6]++main :: IO ()+main = hspec spec++p1 = [r|+{+  "payload": "YPkvASYgAAAemoR/",+  "message": {+    "app_id": "basetest",+    "dev_id": "tester",+    "port": 0+  },+  "gateway_id": "eui-b827ebfffe114355",+  "config": {+    "modulation": "LORA",+    "data_rate": "SF9BW125",+    "airtime": 144384000,+    "frequency": 869525000,+    "power": 27+  }+}+|]++p2 = [r|+{+  "app_id": "basetest",+  "dev_id": "tester",+  "hardware_serial": "0004A30B001E0D4D",+  "port": 1,+  "counter": 1276,+  "confirmed": true,+  "payload_raw": "jZOoQaCyMkI=",+  "metadata": {+    "time": "2018-02-09T19:10:19.912012664Z",+    "frequency": 867.3,+    "modulation": "LORA",+    "data_rate": "SF12BW125",+    "airtime": 1482752000,+    "coding_rate": "4/5",+    "gateways": [+      {+        "gtw_id": "eui-b827ebfffec6e5d0",+        "timestamp": 2377743228,+        "time": "2018-02-09T19:10:19.884395Z",+        "channel": 4,+        "rssi": -83,+        "snr": 8.5,+        "rf_chain": 0,+        "latitude": 49.19936,+        "longitude": 16.57975,+        "altitude": 320+      },+      {+        "gtw_id": "eui-1dee0933d840ee1b",+        "timestamp": 4138754052,+        "time": "2018-02-09T19:16:34.416079Z",+        "channel": 4,+        "rssi": -115,+        "snr": -16,+        "rf_chain": 0+      }+    ]+  }+}+|]++p3 = [r|+{+  "payload": "YO0qASYgmwblb5If",+  "message": {+    "app_id": "basetest",+    "dev_id": "tester",+    "port": 0+  },+  "gateway_id": "eui-b827ebfffec6e5d0",+  "config": {+    "modulation": "LORA",+    "data_rate": "SF9BW125",+    "airtime": 144384000,+    "counter": 1691,+    "frequency": 869525000,+    "power": 27+  }+}+|]++p4 = [r|+{+  "app_id": "basetest",+  "dev_id": "tester",+  "hardware_serial": "0004A30B001E0D4D",+  "port": 1,+  "counter": 1276,+  "confirmed": true,+  "payload_raw": "jZOoQaCyMkI=",+  "metadata": {+    "time": "2018-02-09T19:10:19.912012664Z",+    "frequency": 867.3,+    "modulation": "LORA",+    "data_rate": "SF12BW125",+    "airtime": 1482752000,+    "coding_rate": "4/5",+    "gateways": [+      {+        "gtw_id": "eui-b827ebfffec6e5d0",+        "timestamp": 2377743228,+        "time": "2018-02-09T19:10:19.884395Z",+        "channel": 4,+        "rssi": -83,+        "snr": 8.5,+        "rf_chain": 0,+        "latitude": 49.19936,+        "longitude": 16.57975,+        "altitude": 320+      },+      {+        "gtw_id": "eui-1dee0933d840ee1b",+        "timestamp": 4138754052,+        "time": "2018-02-09T19:16:34.416079Z",+        "channel": 4,+        "rssi": -115,+        "snr": -16,+        "rf_chain": 0+      }+    ]+  }+}+|]++p5 = [r|+{+  "app_id": "basetest",+  "dev_id": "tester",+  "hardware_serial": "0004A30B001E0D4D",+  "port": 1,+  "counter": 0,+  "confirmed": true,+  "is_retry": true,+  "payload_raw": "SA==",+  "metadata": {+    "time": "2017-10-27T20:31:23.636977619Z",+    "frequency": 867.5,+    "modulation": "LORA",+    "data_rate": "SF12BW125",+    "coding_rate": "4/5",+    "gateways": [+      {+        "gtw_id": "eui-b827ebfffec6e5d0",+        "timestamp": 2729574940,+        "time": "2017-10-27T20:31:23.3235Z",+        "channel": 5,+        "rssi": -112,+        "snr": -9.2,+        "rf_chain": 0,+        "latitude": 49.19936,+        "longitude": 16.57975,+        "altitude": 320+      }+    ]+  }+}+|]++p6 = [r|+{+  "app_id": "basetest",+  "dev_id": "tester",+  "hardware_serial": "0004A30B001E0D4D",+  "port": 1,+  "counter": 3,+  "confirmed": true,+  "payload_raw": "8mmxQVBmM0I=",+  "metadata": {+    "time": "2017-10-28T03:43:47.534601976Z",+    "frequency": 867.1,+    "modulation": "LORA",+    "data_rate": "SF12BW125",+    "coding_rate": "4/5",+    "gateways": [+      {+        "gtw_id": "eui-b827ebfffec6e5d0",+        "timestamp": 2903809348,+        "time": "2017-10-28T03:43:47.361855Z",+        "channel": 3,+        "rssi": -119,+        "snr": -10.5,+        "rf_chain": 0,+        "latitude": 49.19936,+        "longitude": 16.57975,+        "altitude": 320+      }+    ]+  }+}+|]
+ test/Spec.hs view
@@ -0,0 +1,1 @@+{-# OPTIONS_GHC -F -pgmF hspec-discover #-}
+ test/SpecHelper.hs view
@@ -0,0 +1,9 @@+module SpecHelper+    ( module Test.Hspec+    , module Test.Hspec.Expectations.Contrib+    , module Data.TTN+    ) where++import Test.Hspec+import Test.Hspec.Expectations.Contrib+import Data.TTN
+ ttn.cabal view
@@ -0,0 +1,45 @@+name:                ttn+version:             0.1.0.0+synopsis:            Things Tracker Network JSON Types+description:         Parse JSONs from TTN MQTT API+homepage:            https://github.com/sorki/data-ttn+license:             BSD3+license-file:        LICENSE+author:              Richard Marko+maintainer:          srk@48.io+copyright:           2018 Richard Marko+category:            Web+build-type:          Simple+-- extra-source-files:+cabal-version:       >=1.10++library+  hs-source-dirs:      src+  exposed-modules:     Data.TTN+                     , Data.TTN.Types+  build-depends:       base >= 4.7 && < 5+                     , aeson+                     , timerep+                     , bytestring+                     , text+                     , time+  default-language:    Haskell2010++test-suite data-ttn-tests+  type:                exitcode-stdio-1.0+  hs-source-dirs:      test+  main-is:             Spec.hs+  other-modules:       ParseSpec+                       DownlinkSpec+                       SpecHelper+  build-depends:       base >= 4.7 && < 5+                     , ttn+                     , aeson+                     , hspec+                     , hspec-expectations+                     , raw-strings-qq+  default-language:    Haskell2010++source-repository head+  type:     git+  location: https://github.com/sorki/data-ttn