├── .gitignore ├── LICENSE ├── README.md ├── buoybot.go ├── configexample.json ├── crontab.txt └── observations.sql /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | 27 | # OS X 28 | .DS_Store 29 | .AppleDouble 30 | .LSOverride 31 | 32 | # Icon must end with two \r 33 | Icon 34 | 35 | 36 | # Thumbnails 37 | ._* 38 | 39 | # Files that might appear in the root of a volume 40 | .DocumentRevisions-V100 41 | .fseventsd 42 | .Spotlight-V100 43 | .TemporaryItems 44 | .Trashes 45 | .VolumeIcon.icns 46 | 47 | # Directories potentially created on remote AFP share 48 | .AppleDB 49 | .AppleDesktop 50 | Network Trash Folder 51 | Temporary Items 52 | .apdisk 53 | 54 | # Credentials 55 | config.json 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 john beil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BuoyBot 2 | BuoyBot is a twitter bot that periodically tweets updates from NBDC Station 46026 (the San Francisco Buoy). 3 | Tide data is from NOAA Station 9414275 (Ocean Beach, San Francisco, California). 4 | 5 | BuoyBot is live on Twitter: https://twitter.com/SFBuoy 6 | 7 | Feature requests and code contributions are welcome. 8 | 9 | ## Usage 10 | All testing has been done on Ubuntu 18.04 LTS. 11 | 12 | BuoyBot runs at 10 minutes past the hour since NBDC observations are taken at 50 minutes past the hour and updates are available approximately 15 minutes thereafter. 13 | 14 | BuoyBot is designed to be run at pre-defined intervals via Cron. Crontab.txt contains the cron entry required to run BuoyBot. Twitter and database credentials need to be saved in a config.json file. The configexample.json file contains the template that should be used. Path to config.js is stored in a CONFIGPATH environment variable that needs to be configured by the user. 15 | 16 | BuoyBot saves its hourly observations in a Postgres database. This needs to be configured by the user or the database code needs to be removed. Observations.sql contains the necessary Postgres table schema for BuoyBot. 17 | 18 | ## Tide Data Note 19 | Buoybot presumes that the database contains relevant tide predictions. This data can be obtained from github.com/johnbeil/tidecrawler 20 | 21 | 22 | ## Development Roadmap: 23 | - Use environment variables rather than config.json for credentials 24 | - Tweet at high and low tides 25 | -------------------------------------------------------------------------------- /buoybot.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 John Beil. 2 | // Use of this source code is governed by the MIT License. 3 | // The MIT license can be found in the LICENSE file. 4 | 5 | // BuoyBot 1.6 6 | // Obtains latest observation for NBDC Station 46026 7 | // Saves observation to database 8 | // Obtains next tide from database 9 | // Tweets observation and tide prediction from @SFBuoy 10 | // See README.md for setup information 11 | // Note tide data from github.com/johnbeil/tidecrawler 12 | 13 | package main 14 | 15 | import ( 16 | "database/sql" 17 | "encoding/json" 18 | "flag" 19 | "fmt" 20 | "io/ioutil" 21 | "log" 22 | "math" 23 | "net/http" 24 | "os" 25 | "strconv" 26 | "strings" 27 | "time" 28 | 29 | "github.com/ChimeraCoder/anaconda" 30 | _ "github.com/lib/pq" 31 | ) 32 | 33 | // First two rows of text file, fixed width delimited, used for debugging 34 | const header = "#YY MM DD hh mm WDIR WSPD GST WVHT DPD APD MWD PRES ATMP WTMP DEWP VIS PTDY TIDE\n#yr mo dy hr mn degT m/s m/s m sec sec degT hPa degC degC degC nmi hPa ft" 35 | 36 | // URL for SF Buoy Observations 37 | const noaaURL = "http://www.ndbc.noaa.gov/data/realtime2/46026.txt" 38 | 39 | // Observation struct stores buoy observation data 40 | type Observation struct { 41 | Date time.Time 42 | WindDirection string 43 | WindSpeed float64 44 | SignificantWaveHeight float64 45 | DominantWavePeriod int 46 | AveragePeriod float64 47 | MeanWaveDirection string 48 | AirTemperature float64 49 | WaterTemperature float64 50 | } 51 | 52 | // Config struct stores Twitter and Database credentials 53 | type Config struct { 54 | UserName string `json:"UserName"` 55 | ConsumerKey string `json:"ConsumerKey"` 56 | ConsumerSecret string `json:"ConsumerSecret"` 57 | Token string `json:"Token"` 58 | TokenSecret string `json:"TokenSecret"` 59 | DatabaseURL string `json:"DatabaseUrl"` 60 | DatabaseUser string `json:"DatabaseUser"` 61 | DatabasePassword string `json:"DatabasePassword"` 62 | DatabaseName string `json:"DatabaseName"` 63 | } 64 | 65 | // Tide stores a tide prediction from the database 66 | type Tide struct { 67 | Date string 68 | Day string 69 | Time string 70 | PredictionFt float64 71 | PredictionCm int64 72 | HighLow string 73 | } 74 | 75 | // Variable for database 76 | var db *sql.DB 77 | 78 | // BuoyBot execution 79 | func main() { 80 | fmt.Println("Starting BuoyBot...") 81 | 82 | // Load configuration 83 | config := Config{} 84 | loadConfig(&config) 85 | 86 | // Load database 87 | dbinfo := fmt.Sprintf("user=%s password=%s host=%s dbname=%s sslmode=disable", 88 | config.DatabaseUser, config.DatabasePassword, config.DatabaseURL, config.DatabaseName) 89 | var err error 90 | db, err = sql.Open("postgres", dbinfo) 91 | if err != nil { 92 | log.Fatal(err) 93 | } 94 | defer db.Close() 95 | 96 | // Check database connection 97 | err = db.Ping() 98 | if err != nil { 99 | log.Fatal("Error: Could not establish connection with the database.", err) 100 | } 101 | 102 | // Parse command line argument. 103 | arg := flag.Bool("test", false, "a boolean value") 104 | flag.Parse() 105 | 106 | // Get current observation and store in struct 107 | var observation Observation 108 | observation = getObservation() 109 | 110 | // Obtain next tide from database 111 | tide := getTide() 112 | 113 | // Format tide 114 | tideOutput := processTide(tide) 115 | 116 | // Format observation given Observation and tideOutput 117 | observationOutput := formatObservation(observation, tideOutput) 118 | 119 | // Tweet observation unless test argument passed via command line. 120 | // Only save onservation to database if not in test mode. 121 | if *arg == true { 122 | fmt.Println("Test mode: Tweet disabled.") 123 | fmt.Println(observationOutput) 124 | } else { 125 | tweetCurrent(config, observationOutput) 126 | // Save current observation in database 127 | saveObservation(observation) 128 | } 129 | 130 | // Shutdown BuoyBot 131 | fmt.Println("Exiting BuoyBot...") 132 | } 133 | 134 | // Fetches and parses latest NBDC observation and returns data in Observation struct 135 | func getObservation() Observation { 136 | observationRaw := getDataFromURL(noaaURL) 137 | observationData := parseData(observationRaw) 138 | return observationData 139 | } 140 | 141 | // Given Observation struct, saves most recent observation in database 142 | func saveObservation(o Observation) { 143 | _, err := db.Exec("INSERT INTO observations(observationtime, windspeed, winddirection, significantwaveheight, dominantwaveperiod, averageperiod, meanwavedirection, airtemperature, watertemperature) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9)", o.Date, o.WindSpeed, o.WindDirection, o.SignificantWaveHeight, o.DominantWavePeriod, o.AveragePeriod, o.MeanWaveDirection, o.AirTemperature, o.WaterTemperature) 144 | if err != nil { 145 | log.Fatal("Error saving observation:", err) 146 | } 147 | } 148 | 149 | // Given config and observation, tweets latest update 150 | func tweetCurrent(config Config, o string) { 151 | fmt.Println("Preparing to tweet observation...") 152 | api := anaconda.NewTwitterApiWithCredentials(config.Token, config.TokenSecret, config.ConsumerKey, config.ConsumerSecret) 153 | tweet, err := api.PostTweet(o, nil) 154 | if err != nil { 155 | fmt.Println("update error:", err) 156 | } else { 157 | fmt.Println("Tweet posted:") 158 | fmt.Println(tweet.Text) 159 | } 160 | } 161 | 162 | // Given URL, returns raw data with recent observations from NBDC 163 | func getDataFromURL(url string) (body []byte) { 164 | resp, err := http.Get(url) 165 | if err != nil { 166 | log.Fatal("Error fetching data:", err) 167 | } 168 | defer resp.Body.Close() 169 | body, err = ioutil.ReadAll(resp.Body) 170 | if err != nil { 171 | log.Fatal("ioutil error reading resp.Body:", err) 172 | } 173 | // fmt.Println("Status:", resp.Status) 174 | return 175 | } 176 | 177 | // Given path to config.js file, loads credentials 178 | func loadConfig(config *Config) { 179 | // Load path to config from CONFIGPATH environment variable 180 | configpath := os.Getenv("CONFIGPATH") 181 | file, _ := os.Open(configpath) 182 | decoder := json.NewDecoder(file) 183 | err := decoder.Decode(&config) 184 | if err != nil { 185 | log.Fatal("Error loading config.json:", err) 186 | } 187 | } 188 | 189 | // Given raw data, parses latest observation and returns Observation struct 190 | func parseData(d []byte) Observation { 191 | // Each line contains 19 data points 192 | // Headers are in the first two lines 193 | // Latest observation data is in the third line 194 | // Other lines are not needed 195 | 196 | // Extracts relevant data into variable for processing 197 | var data = string(d[188:281]) 198 | // Convert most recent observation into array of strings 199 | datafield := strings.Fields(data) 200 | 201 | // Process date/time and convert to PST 202 | rawtime := strings.Join(datafield[0:5], " ") 203 | t, err := time.Parse("2006 01 02 15 04", rawtime) 204 | if err != nil { 205 | log.Fatal("error processing rawtime:", err) 206 | } 207 | loc, err := time.LoadLocation("America/Los_Angeles") 208 | if err != nil { 209 | log.Fatal("error processing location", err) 210 | } 211 | t = t.In(loc) 212 | 213 | // Convert wind direction from degrees to cardinal 214 | winddegrees, _ := strconv.ParseInt(datafield[5], 0, 64) 215 | windcardinal := direction(winddegrees) 216 | 217 | // Convert wind speed from m/s to mph 218 | windspeedms, _ := strconv.ParseFloat((datafield[6]), 64) 219 | windspeedmph := windspeedms / 0.44704 220 | 221 | // Convert wave height from meters to feet 222 | 223 | waveheightmeters, _ := strconv.ParseFloat(datafield[8], 64) 224 | waveheightfeet := waveheightmeters * 3.28084 225 | 226 | // Convert wave direction from degrees to cardinal 227 | wavedegrees, _ := strconv.ParseInt(datafield[11], 0, 64) 228 | wavecardinal := direction(wavedegrees) 229 | 230 | // Convert air temp from C to F 231 | airtempC, _ := strconv.ParseFloat(datafield[13], 64) 232 | airtempF := airtempC*9/5 + 32 233 | airtempF = RoundPlus(airtempF, 1) 234 | 235 | // Convert water temp from C to F 236 | watertempC, err := strconv.ParseFloat(datafield[14], 64) 237 | if err != nil { 238 | fmt.Println(err) 239 | // Get prior observation and store in struct 240 | var lastObservation Observation 241 | lastObservation = getLastObservation() 242 | fmt.Printf("Last observation:\n%+v\n", lastObservation) 243 | watertempC := lastObservation.WaterTemperature 244 | fmt.Println("Prior temp is: ", watertempC) 245 | } 246 | watertempF := watertempC*9/5 + 32 247 | watertempF = RoundPlus(watertempF, 1) 248 | 249 | // Create Observation struct and populate with parsed data 250 | var o Observation 251 | o.Date = t 252 | o.WindDirection = windcardinal 253 | o.WindSpeed = windspeedmph 254 | o.SignificantWaveHeight = waveheightfeet 255 | o.DominantWavePeriod, err = strconv.Atoi(datafield[9]) 256 | if err != nil { 257 | fmt.Println(err) 258 | o.AveragePeriod = 0 259 | // log.Fatal("o.DominantWavePeriod:", err) 260 | } 261 | o.AveragePeriod, err = strconv.ParseFloat(datafield[10], 64) 262 | if err != nil { 263 | fmt.Println(err) 264 | o.AveragePeriod = 0 265 | // log.Fatal("o.AveragePeriod:", err) 266 | } 267 | o.MeanWaveDirection = wavecardinal 268 | o.AirTemperature = airtempF 269 | o.WaterTemperature = watertempF 270 | 271 | // Print loaded observations 272 | fmt.Printf("%+v\n", o) 273 | 274 | // Return populated observation struct 275 | return o 276 | } 277 | 278 | // Given Observation and tide string, returns formatted text for tweet 279 | func formatObservation(o Observation, tide string) string { 280 | output := fmt.Sprint(o.Date.Format(time.RFC822), "\nSwell: ", strconv.FormatFloat(float64(o.SignificantWaveHeight), 'f', 1, 64), "ft at ", o.DominantWavePeriod, " sec from ", o.MeanWaveDirection, "\nWind: ", strconv.FormatFloat(float64(o.WindSpeed), 'f', 0, 64), "mph from ", o.WindDirection, "\n", tide, "\nTemp: Air ", o.AirTemperature, "F / Water: ", o.WaterTemperature, "F") 281 | return output 282 | } 283 | 284 | //getLastObservation selects the prior observation from the database and returns an Observation struct 285 | func getLastObservation() Observation { 286 | var o Observation 287 | err := db.QueryRow("select observationtime, winddirection, windspeed, significantwaveheight, dominantwaveperiod, averageperiod, meanwavedirection, airtemperature, watertemperature from observations order by observationtime desc limit 1").Scan(&o.Date, &o.WindDirection, &o.WindSpeed, &o.SignificantWaveHeight, &o.DominantWavePeriod, &o.AveragePeriod, &o.MeanWaveDirection, &o.AirTemperature, &o.WaterTemperature) 288 | if err != nil { 289 | log.Fatal("getLastObservation function error:", err) 290 | } 291 | return o 292 | } 293 | 294 | // getTide selects the next tide prediction from the database and returns a Tide struct 295 | // server time and 296 | func getTide() Tide { 297 | var tide Tide 298 | err := db.QueryRow("select date, day, time, predictionft, highlow from tidedata where datetime >= current_timestamp - interval '8 hours' order by datetime limit 1").Scan(&tide.Date, &tide.Day, &tide.Time, &tide.PredictionFt, &tide.HighLow) 299 | if err != nil { 300 | log.Fatal("getTide function error:", err) 301 | } 302 | return tide 303 | } 304 | 305 | // processTide returns a formatted string given a Tide struct 306 | func processTide(t Tide) string { 307 | if t.HighLow == "H" { 308 | t.HighLow = "High" 309 | } else { 310 | t.HighLow = "Low" 311 | } 312 | s := "Tide: " + t.HighLow + " " + strconv.FormatFloat(float64(t.PredictionFt), 'f', 1, 64) + "ft at " + t.Time 313 | // fmt.Println(s) 314 | return s 315 | } 316 | 317 | // Round input to nearest integer given Float64 and return Float64 318 | func Round(f float64) float64 { 319 | return math.Floor(f + .5) 320 | } 321 | 322 | // RoundPlus truncates a Float64 to a specified number of decimals given Int and Float64, returning Float64 323 | func RoundPlus(f float64, places int) float64 { 324 | shift := math.Pow(10, float64(places)) 325 | return Round(f*shift) / shift 326 | } 327 | 328 | // Given degrees returns cardinal direction or error message 329 | func direction(deg int64) string { 330 | switch { 331 | case deg < 0: 332 | return "ERROR - DEGREE LESS THAN ZERO" 333 | case deg <= 11: 334 | return "N" 335 | case deg <= 34: 336 | return "NNE" 337 | case deg <= 56: 338 | return "NE" 339 | case deg <= 79: 340 | return "ENE" 341 | case deg <= 101: 342 | return "E" 343 | case deg <= 124: 344 | return "ESE" 345 | case deg <= 146: 346 | return "SE" 347 | case deg <= 169: 348 | return "SSE" 349 | case deg <= 191: 350 | return "S" 351 | case deg <= 214: 352 | return "SSW" 353 | case deg <= 236: 354 | return "SW" 355 | case deg <= 259: 356 | return "WSW" 357 | case deg <= 281: 358 | return "W" 359 | case deg <= 304: 360 | return "WNW" 361 | case deg <= 326: 362 | return "NW" 363 | case deg <= 349: 364 | return "NNW" 365 | case deg <= 360: 366 | return "N" 367 | default: 368 | return "ERROR - DEGREE GREATER THAN 360" 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /configexample.json: -------------------------------------------------------------------------------- 1 | // rename this config.json and add configuration data below 2 | { 3 | "UserName": "twittername", // no @ symbol 4 | "ConsumerKey": "xxxxxxxxxx", 5 | "ConsumerSecret": "xxxxxxxxxx", 6 | "Token": "xxxxxxxxxxxxxxxxxxxx", 7 | "TokenSecret": "xxxxxxxxxx", 8 | "DatabaseUrl": "xx.xxx.xx.xx", 9 | "DatabaseUser": "xxxxx", 10 | "DatabasePassword": "xxxxxxxx", 11 | "DatabaseName": "xxxx" 12 | } 13 | -------------------------------------------------------------------------------- /crontab.txt: -------------------------------------------------------------------------------- 1 | # Crontab commands to run BuoyBot at regular intervals. 2 | # Tested on Ubuntu 14.04. 3 | # Set to run on the 10th minute of every hour. 4 | # Set to run every day of the month. 5 | # Set to run every month of the year. 6 | # Set to run on every day of the week. 7 | # Change the path to the location of your buoybot executable. 8 | # Loads user env variables, must set CONFIGPATH to the location of config.json 9 | # Logs last run to buoybot.log, useful for debugging 10 | 11 | # Add to crontab by running `crontab -e` 12 | 13 | 10 * * * * . $HOME/.profile; /home/deploy/go/bin/BuoyBot > /home/deploy/buoybot.log 2>&1 14 | -------------------------------------------------------------------------------- /observations.sql: -------------------------------------------------------------------------------- 1 | -- PREPARE DATABASE 2 | 3 | CREATE TABLE observations 4 | ( 5 | uid serial NOT NULL, 6 | observationtime timestamp, 7 | windspeed real, 8 | winddirection varchar (3), 9 | significantwaveheight real, 10 | dominantwaveperiod integer, 11 | averageperiod real, 12 | meanwavedirection varchar (3), 13 | airtemperature real, 14 | watertemperature real 15 | ); 16 | 17 | ALTER TABLE observations 18 | ADD COLUMN rowcreated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(); 19 | 20 | -- Extract CSV 21 | \copy (SELECT observationtime, significantwaveheight, dominantwaveperiod, averageperiod, airtemperature, watertemperature FROM observations) TO data.csv CSV DELIMITER ','; 22 | 23 | -- Select Max wave height ever 24 | SELECT * FROM observations ORDER BY significantwaveheight DESC LIMIT 1; 25 | 26 | -- Select Max wave height in given year 27 | SELECT * FROM observations WHERE EXTRACT(year FROM "rowcreated") = 2016 ORDER BY significantwaveheight DESC LIMIT 1; 28 | 29 | -- Select last 10 observations 30 | SELECT * FROM observations ORDER BY rowcreated DESC LIMIT 10; 31 | --------------------------------------------------------------------------------