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 +30/−0
- Setup.hs +2/−0
- src/Data/TTN.hs +5/−0
- src/Data/TTN/Types.hs +384/−0
- test/DownlinkSpec.hs +23/−0
- test/ParseSpec.hs +208/−0
- test/Spec.hs +1/−0
- test/SpecHelper.hs +9/−0
- ttn.cabal +45/−0
+ 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