diff --git a/go.mod b/go.mod index f514ac1..8a6e4f5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/note-cli go 1.15 replace github.com/blues/note-cli/lib => ./lib +replace github.com/blues/note-go => ./note-go // uncomment this for easier testing locally // replace github.com/blues/note-go => ../hub/note-go diff --git a/note-go/.circleci/config.yml b/note-go/.circleci/config.yml new file mode 100644 index 0000000..2d15f72 --- /dev/null +++ b/note-go/.circleci/config.yml @@ -0,0 +1,90 @@ +# Golang CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-go/ for more details +version: 2 +jobs: + build-unix-and-windows: + docker: + - image: circleci/golang:1.14.4 + working_directory: /go/src/github.com/blues/note-go + steps: + - checkout + - run: export GOOS=linux GOARCH=amd64 ; ./build.sh && ./package.sh + - run: export GOOS=linux GOARCH=arm ; ./build.sh && ./package.sh + - run: export GOOS=windows GOARCH=386 ; ./build.sh && ./package.sh + - run: export GOOS=windows GOARCH=amd64 ; ./build.sh && ./package.sh + - run: find ./build/ -type f + - store_artifacts: + path: ./build/packages/ + - persist_to_workspace: + root: . + paths: + - ./build/packages/ + build-macos: + macos: + xcode: 11.3.0 + steps: + - checkout + - run: pwd + - run: echo $PATH + - run: + name: install go + command: | + curl https://dl.google.com/go/go1.14.4.darwin-amd64.tar.gz | + tar -C "$HOME" -xz # install go to $HOME/go/ + - run: + name: build and package + command: | + export PATH="$PATH:$HOME/go/bin" + ./build.sh + ./package.sh + - store_artifacts: + path: ./build/packages/ + - persist_to_workspace: + root: . + paths: + - ./build/packages/ + publish-github-release: + docker: + - image: cibuilds/github:0.10 + steps: + # We need to do a checkout so the CIRCLE_PROJECT_REPONAME and CIRCLE_SHA1 vars are populated + # for the command below. + - checkout + - attach_workspace: + at: . + - run: ls -l ./build/packages/ + - run: + name: "Publish Release on GitHub" + command: | + ghr -t "${GITHUB_TOKEN}" -u "${CIRCLE_PROJECT_USERNAME}" -r "${CIRCLE_PROJECT_REPONAME}" \ + -c "${CIRCLE_SHA1}" -delete "${CIRCLE_TAG}" ./build/packages/ + # The GITHUB_TOKEN is generated here: https://github.com/settings/tokens for the + # notebot-ci user and securely set here: + # https://app.circleci.com/settings/project/github/blues/note-go/environment-variables + +workflows: + version: 2 + build-and-publish: + jobs: + - build-macos: + filters: + # Because we don't filter out certain branches this code implicitly says `build-macos` + # will run for all builds triggered by a branch push or PR. But in the circle-ci ui we + # chose to only build for PRs here: + # https://app.circleci.com/settings/project/github/blues/note-go/advanced + tags: &PUBLISH_TAG_FILTER_REGEX + # Unlike branch-triggered builds, we do filter down to certain tags. Match v1.2.3 etc. + # i.e. the only tags which can trigger must look like they're tagging a release. + only: /^v\d+\.\d+\.\d+$/ + - build-unix-and-windows: + filters: + tags: *PUBLISH_TAG_FILTER_REGEX + - publish-github-release: + requires: + - build-macos + - build-unix-and-windows + filters: + branches: + ignore: /.*/ + tags: *PUBLISH_TAG_FILTER_REGEX diff --git a/note-go/.gitignore b/note-go/.gitignore new file mode 100644 index 0000000..23930f1 --- /dev/null +++ b/note-go/.gitignore @@ -0,0 +1,36 @@ +# jetbrains IDEs config files +**/.idea +build + +# Production build proucts +./build/ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# auto- generated files # +###################### +*~ +\#*\# +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# VS Code configuration files +.vscode/* +# whitelist +!.vscode/launch.json diff --git a/note-go/.vscode/launch.json b/note-go/.vscode/launch.json new file mode 100644 index 0000000..aee041d --- /dev/null +++ b/note-go/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to ./notecard", + "type": "go", + "request": "attach", + "mode":"local", + "processId": 6968, // Fill this in with the PID of your notecard process. + "remotePath": "/go/src/github.com/blues/note-go", + // Sadly this doesn't work. Even if you set isBackground on the task. + //"preLaunchTask": "Compose w/ Debuggable Noteboard", + }, + ], +} \ No newline at end of file diff --git a/note-go/CODE_OF_CONDUCT.md b/note-go/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..50838cf --- /dev/null +++ b/note-go/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of conduct + +By participating in this project, you agree to abide by the +[Blues Inc code of conduct][1]. + +[1]: https://blues.github.io/opensource/code-of-conduct + diff --git a/note-go/CONTRIBUTING.md b/note-go/CONTRIBUTING.md new file mode 100644 index 0000000..53f322f --- /dev/null +++ b/note-go/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing to blues/note-go + +We love pull requests from everyone. By participating in this project, you +agree to abide by the Blues Inc [code of conduct]. + +[code of conduct]: https://blues.github.io/opensource/code-of-conduct + +Here are some ways *you* can contribute: + +* by using alpha, beta, and prerelease versions +* by reporting bugs +* by suggesting new features +* by writing or editing documentation +* by writing specifications +* by writing code ( **no patch is too small** : fix typos, add comments, +clean up inconsistent whitespace ) +* by refactoring code +* by closing [issues][] +* by reviewing patches + +[issues]: https://github.com/blues/note-go/issues + +## Submitting an Issue + +* We use the [GitHub issue tracker][issues] to track bugs and features. +* Before submitting a bug report or feature request, check to make sure it + hasn't + already been submitted. +* When submitting a bug report, please include a [Gist][] that includes a stack + trace and any details that may be necessary to reproduce the bug, including + your release version, Go version, and operating system. Ideally, a bug report + should include a pull request with failing specs. + +[gist]: https://gist.github.com/ + +## Cleaning up issues + +* Issues that have no response from the submitter will be closed after 30 days. +* Issues will be closed once they're assumed to be fixed or answered. If the + maintainer is wrong, it can be opened again. +* If your issue is closed by mistake, please understand and explain the issue. + We will happily reopen the issue. + +## Submitting a Pull Request +1. [Fork][fork] the [official repository][repo]. +2. [Create a topic branch.][branch] +3. Implement your feature or bug fix. +4. Add, commit, and push your changes. +5. [Submit a pull request.][pr] + +## Notes +* Please add tests if you changed code. Contributions without tests won't be +* accepted. If you don't know how to add tests, please put in a PR and leave a +* comment asking for help. We love helping! + +[repo]: https://github.com/blues/note-go/tree/master +[fork]: https://help.github.com/articles/fork-a-repo/ +[branch]: +https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/ +[pr]: https://help.github.com/articles/creating-a-pull-request-from-a-fork/ + +Inspired by +https://github.com/thoughtbot/factory_bot/blob/master/CONTRIBUTING.md + diff --git a/note-go/LICENSE b/note-go/LICENSE new file mode 100644 index 0000000..aed1af8 --- /dev/null +++ b/note-go/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2019 Blues Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/note-go/README.md b/note-go/README.md new file mode 100644 index 0000000..83492ff --- /dev/null +++ b/note-go/README.md @@ -0,0 +1,32 @@ +# [Blues Wireless][blues] + +The note-go Go library for communicating with Blues Wireless Notecard via serial or I²C. + +This library allows you to control a Notecard by coding in Go. +Your program may configure Notecard and send Notes to [Notehub.io][notehub]. + +See also: +* [note-c][note-c] for C bindings +* [note-python][note-python] for Python bindings + +## Installing +For all releases, we have compiled the notecard utility for different OS and architectures [here](https://github.com/blues/note-go/releases). +If you don't see your OS and architecture supported, please file an issue and we'll add it to new releases. + +[blues]: https://blues.com +[notehub]: https://notehub.io +[note-arduino]: https://github.com/blues/note-arduino +[note-c]: https://github.com/blues/note-c +[note-go]: https://github.com/blues/note-go +[note-python]: https://github.com/blues/note-python + +## Dependencies +- Install Go and the Go tools [(here)](https://golang.org/doc/install) + +## Compiling the notecard utility +If you want to build the latest, follow the directions below. +```bash +$ cd tools/notecard +$ go get -u . +$ go build . +``` diff --git a/note-go/build.sh b/note-go/build.sh new file mode 100755 index 0000000..6a50482 --- /dev/null +++ b/note-go/build.sh @@ -0,0 +1,38 @@ +#! /usr/bin/env bash +# +# Copyright 2020 Blues Inc. All rights reserved. +# Use of this source code is governed by licenses granted by the +# copyright holder including that found in the LICENSE file. +# +######### Bash Boilerplate ########## +set -euo pipefail # strict mode +readonly SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "$SCRIPT_DIR" # cd to this script's dir +######### End Bash Boilerplate ########## + +# +# note-go build.sh +# +# This script builds all the note-go executables (note, notecard, notehub) by +# looking for any folder containing a main.go and running `go build`. +# +# Parameters: Set $GOOS and $GOARCH to cross compile for different platforms. +# +# Output: Executables are saved in "./build/$GOOS/$GOARCH/" +# + +# Add GOOS and GOARCH to our environment. (and other GO vars we don't need) +eval "$(go env)" + +readonly BUILD_EXE_DIR="$SCRIPT_DIR/build/$GOOS/$GOARCH/" +mkdir -p "$BUILD_EXE_DIR" + +# Build each executable binary +# build_dirs is an array of all the folders which contain a main.go +IFS=$'\r\n' GLOBIGNORE='*' command eval 'build_dirs=($(find . -name main.go -print0 | xargs -0n1 dirname))' +for dir in "${build_dirs[@]}"; do + ( + cd "$dir" + go build ${GO_BUILD_FLAGS:-} -o "$BUILD_EXE_DIR" + ) +done diff --git a/note-go/go.mod b/note-go/go.mod new file mode 100644 index 0000000..7295be5 --- /dev/null +++ b/note-go/go.mod @@ -0,0 +1,17 @@ +module github.com/blues/note-go + +// 2023-02-26 Raspberry Pi apt-get only is updated to 1.15 +go 1.15 + +require ( + github.com/gofrs/flock v0.7.1 + github.com/shirou/gopsutil/v3 v3.21.6 + github.com/stretchr/testify v1.7.0 + go.bug.st/serial v1.6.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + periph.io/x/conn/v3 v3.7.0 + periph.io/x/host/v3 v3.8.0 +) diff --git a/note-go/go.sum b/note-go/go.sum new file mode 100644 index 0000000..b835d1f --- /dev/null +++ b/note-go/go.sum @@ -0,0 +1,39 @@ +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk= +github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI= +github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM= +github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= +github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/shirou/gopsutil/v3 v3.21.6 h1:vU7jrp1Ic/2sHB7w6UNs7MIkn7ebVtTb5D9j45o9VYE= +github.com/shirou/gopsutil/v3 v3.21.6/go.mod h1:JfVbDpIBLVzT8oKbvMg9P3wEIMDDpVn+LwHTKj0ST88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tklauser/go-sysconf v0.3.6 h1:oc1sJWvKkmvIxhDHeKWvZS4f6AW+YcoguSfRF2/Hmo4= +github.com/tklauser/go-sysconf v0.3.6/go.mod h1:MkWzOF4RMCshBAMXuhXJs64Rte09mITnppBXY/rYEFI= +github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefldA= +github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= +go.bug.st/serial v1.6.1 h1:VSSWmUxlj1T/YlRo2J104Zv3wJFrjHIl/T3NeruWAHY= +go.bug.st/serial v1.6.1/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= +golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +periph.io/x/conn/v3 v3.7.0 h1:f1EXLn4pkf7AEWwkol2gilCNZ0ElY+bxS4WE2PQXfrA= +periph.io/x/conn/v3 v3.7.0/go.mod h1:ypY7UVxgDbP9PJGwFSVelRRagxyXYfttVh7hJZUHEhg= +periph.io/x/d2xx v0.1.0/go.mod h1:OflHQcWZ4LDP/2opGYbdXSP/yvWSnHVFO90KRoyobWY= +periph.io/x/host/v3 v3.8.0 h1:T5ojZ2wvnZHGPS4h95N2ZpcCyHnsvH3YRZ1UUUiv5CQ= +periph.io/x/host/v3 v3.8.0/go.mod h1:rzOLH+2g9bhc6pWZrkCrmytD4igwQ2vxFw6Wn6ZOlLY= diff --git a/note-go/note/access.go b/note-go/note/access.go new file mode 100644 index 0000000..1aec57c --- /dev/null +++ b/note-go/note/access.go @@ -0,0 +1,96 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +// The full URN of a resource for permissioning purposes is: +// app:xxx-xxxx-xxxx-xxxx:dev:xxxxxxxxxxx:file:xxxx + +// ACResourceApp is the app (project) resource, which is the appUID that always begins with this string +const ACResourceApp = "app:" + +// ACResourceApps is the resource for all apps +const ACResourceApps = "app:*" + +// ACResourceDevice is the device resource, which is the deviceUID that always begins with this string +const ACResourceDevice = "dev:" + +// ACResourceDevices is the resource for all devices +const ACResourceDevices = "dev:*" + +// ACResourceNotefile is the notefile resource and its note-level actions, +// which is the notefileID prefixed with this string +const ACResourceNotefile = "file:" + +// ACResourceNotefiles is the resource for all notefiles and all meta-notefile-level actions +const ACResourceNotefiles = "file:*" + +// ACResourceAccount is an account resource, which is the accountUID that always begins with this string +const ACResourceAccount = "account:" + +// ACResourceAccounts is the resource for all accounts and all meta-account-level actions +const ACResourceAccounts = "account:*" + +// ACResourceRoute is an route resource, which is the routeUID that always begins with this string +const ACResourceRoute = "route:" + +// ACResourceRoutes is the resource for all routes and all meta-route-level actions +const ACResourceRoutes = "route:*" + +// ACResourceNotecardFirmwares is the resource for all notecard firmware +const ACResourceNotecardFirmwares = "notecard:*" + +// ACResourceUserFirmwares is the resource for all user firmware +const ACResourceUserFirmwares = "firmware:*" + +// ACResourceSep is the separator for building compound resource names +const ACResourceSep = ":" + +// Entire vocabulary of allowed actions on resources + +// ACActionRead (golint) +const ACActionRead = "read" + +// ACActionUpdate (golint) +const ACActionUpdate = "update" + +// ACActionCreate (golint) +const ACActionCreate = "create" + +// ACActionDelete (golint) +const ACActionDelete = "delete" + +// ACActionMonitor (golint) +const ACActionMonitor = "monitor" + +// Ways of combining actions into one + +// ACActionAnd ensures that all of these actions are allowed +const ACActionAnd = "&" + +// ACActionOr ensures that any of these actions are allowed +const ACActionOr = "|" + +// The entire palette of valid actions, as a comma-separated list + +// ACValidActionsApp are actions allowed on apps +const ACValidActionsApp = "app:create,app:read,app:update,app:delete,app:monitor" + +// ACValidActionsDev are actions allowed on devices +const ACValidActionsDev = "dev:read,dev:update,dev:delete,dev:monitor" + +// ACValidActionsFile are actions allowed on notefiles +const ACValidActionsFile = "file:create,file:read,file:update,file:delete" + +// ACValidActionsAccount are actions allowed on accounts +const ACValidActionsAccount = "account:create,account:read,account:update,account:delete" + +// ACValidActionsRoute are actions allowed on routes +const ACValidActionsRoute = "route:create,route:read,route:update,route:delete" + +// ACValidActionsNotecard are actions allowed on notecard firmware +const ACValidActionsNotecard = "notecard:create,notecard:read,notecard:update,notecard:delete" + +// ACValidActionsFirmware are actions allowed on user firmware +const ACValidActionsFirmware = "firmware:create,firmware:read,firmware:update,firmware:delete" diff --git a/note-go/note/contacts.go b/note-go/note/contacts.go new file mode 100644 index 0000000..5be7645 --- /dev/null +++ b/note-go/note/contacts.go @@ -0,0 +1,25 @@ +package note + +// Contact has the basic contact info structure +// +// NOTE: This structure's underlying storage has been decoupled from the use of +// the structure in business logic. As such, please share any changes to these +// structures with cloud services to ensure that storage and testing frameworks +// are kept in sync with these structures used for business logic +type Contact struct { + Name string `json:"name,omitempty"` + Affiliation string `json:"org,omitempty"` + Role string `json:"role,omitempty"` + Email string `json:"email,omitempty"` +} + +// Contacts has contact info for this app +// +// NOTE: This structure's underlying storage has been decoupled from the use of +// the structure in business logic. As such, please share any changes to these +// structures with cloud services to ensure that storage and testing frameworks +// are kept in sync with these structures used for business logic +type Contacts struct { + Admin *Contact `json:"admin,omitempty"` + Tech *Contact `json:"tech,omitempty"` +} diff --git a/note-go/note/dfu.go b/note-go/note/dfu.go new file mode 100644 index 0000000..0e57a2e --- /dev/null +++ b/note-go/note/dfu.go @@ -0,0 +1,55 @@ +// Copyright 2025 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +// Package note dfu.go contains DFU-related structures generated/parsed by the notecard +package note + +// DFUState is the state of the DFU in progress +type DFUState struct { + Type string `json:"type,omitempty"` + File string `json:"file,omitempty"` + Length uint32 `json:"length,omitempty"` + CRC32 uint32 `json:"crc32,omitempty"` + MD5 string `json:"md5,omitempty"` + Phase string `json:"mode,omitempty"` + Status string `json:"status,omitempty"` + BeganSecs uint32 `json:"began,omitempty"` + RetryCount uint32 `json:"retry,omitempty"` + ConsecutiveErrors uint32 `json:"errors,omitempty"` + BinaryRetries uint32 `json:"binretry,omitempty"` + DFUStartCount uint32 `json:"dfu_started,omitempty"` + DFUCompletedCount uint32 `json:"dfu_completed,omitempty"` + ODFUStartedCount uint32 `json:"odfu_started,omitempty"` + ODFUTarget string `json:"odfu_target,omitempty"` + ReadFromService uint32 `json:"read,omitempty"` + UpdatedSecs uint32 `json:"updated,omitempty"` + DownloadComplete bool `json:"dl_complete,omitempty"` + DisabledReason string `json:"disabled,omitempty"` + MinNotecardVersion string `json:"min_card_version,omitempty"` + + // This will always point to the current running version + Version string `json:"version,omitempty"` +} + +// DFUEnv is the data structure passed to Notehub when DFU info changes +type DFUEnv struct { + Card *DFUState `json:"card,omitempty"` + User *DFUState `json:"user,omitempty"` + Modem *DFUState `json:"modem,omitempty"` + Star *DFUState `json:"star,omitempty"` +} + +type DfuPhase string + +const ( + DfuPhaseUnknown DfuPhase = "" + DfuPhaseIdle DfuPhase = "idle" + DfuPhaseError DfuPhase = "error" + DfuPhaseDownloading DfuPhase = "downloading" + DfuPhaseSideloading DfuPhase = "sideloading" + DfuPhaseReady DfuPhase = "ready" + DfuPhaseReadyRetry DfuPhase = "ready-retry" + DfuPhaseUpdating DfuPhase = "updating" + DfuPhaseCompleted DfuPhase = "completed" +) diff --git a/note-go/note/errors.go b/note-go/note/errors.go new file mode 100644 index 0000000..87bba25 --- /dev/null +++ b/note-go/note/errors.go @@ -0,0 +1,349 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +// Package note errors.go contains programmatically-testable error strings +package note + +import ( + "fmt" + "net/http" + "strings" +) + +// ErrTimeout (golint) +const ErrTimeout = "{timeout}" + +var _ = defineError(ErrTimeout, http.StatusRequestTimeout) + +// ErrInternalTimeout of a notehub-to-notehub transaction (golint) +const ErrInternalTimeout = "{internal-timeout}" + +var _ = defineError(ErrInternalTimeout, http.StatusGatewayTimeout) + +// ErrRouteTimeout of a notehub-to-customer-service transaction (golint) +const ErrRouteTimeout = "{route-timeout}" + +var _ = defineError(ErrRouteTimeout, http.StatusRequestTimeout) + +// ErrClosed (golint) +const ErrClosed = "{closed}" + +var _ = defineError(ErrClosed, http.StatusGone) + +// ErrFileNoExist (golint) +const ErrFileNoExist = "{file-noexist}" + +var _ = defineError(ErrFileNoExist, http.StatusNotFound) + +// ErrNotefileName (golint) +const ErrNotefileName = "{notefile-bad-name}" + +var _ = defineError(ErrNotefileName, http.StatusBadRequest) + +// ErrNotefileInUse (golint) +const ErrNotefileInUse = "{notefile-in-use}" + +var _ = defineError(ErrNotefileInUse, http.StatusConflict) + +// ErrNotefileExists (golint) +const ErrNotefileExists = "{notefile-exists}" + +var _ = defineError(ErrNotefileExists, http.StatusConflict) + +// ErrNotefileNoExist (golint) +const ErrNotefileNoExist = "{notefile-noexist}" + +var _ = defineError(ErrNotefileNoExist, http.StatusNotFound) + +// ErrNotefileQueueDisallowed (golint) +const ErrNotefileQueueDisallowed = "{notefile-queue-disallowed}" + +var _ = defineError(ErrNotefileQueueDisallowed, http.StatusBadRequest) + +// ErrNoteNoExist (golint) +const ErrNoteNoExist = "{note-noexist}" + +var _ = defineError(ErrNoteNoExist, http.StatusNotFound) + +// ErrNoteExists (golint) +const ErrNoteExists = "{note-exists}" + +var _ = defineError(ErrNoteExists, http.StatusConflict) + +// ErrTooManyNotes (golint) +const ErrTooManyNotes = "{too-many-notes}" + +var _ = defineError(ErrTooManyNotes, http.StatusBadRequest) + +// ErrTrackerNoExist (golint) +const ErrTrackerNoExist = "{tracker-noexist}" + +var _ = defineError(ErrTrackerNoExist, http.StatusNotFound) + +// ErrTrackerExists (golint) +const ErrTrackerExists = "{tracker-exists}" + +var _ = defineError(ErrTrackerExists, http.StatusConflict) + +// ErrNetwork (golint) +const ErrNetwork = "{network}" + +var _ = defineError(ErrNetwork, http.StatusServiceUnavailable) + +// ErrRegistrationFailure (golint) +const ErrRegistrationFailure = "{registration-failure}" + +var _ = defineError(ErrRegistrationFailure, http.StatusServiceUnavailable) + +// ErrExtendedNetworkFailure (golint) +const ErrExtendedNetworkFailure = "{extended-network-failure}" + +var _ = defineError(ErrExtendedNetworkFailure, http.StatusServiceUnavailable) + +// ErrExtendedServiceFailure (golint) +const ErrExtendedServiceFailure = "{extended-service-failure}" + +var _ = defineError(ErrExtendedServiceFailure, http.StatusServiceUnavailable) + +// ErrHostUnreachable (golint) +const ErrHostUnreachable = "{host-unreachable}" + +var _ = defineError(ErrHostUnreachable, http.StatusServiceUnavailable) + +// ErrDFUNotReady (golint) +const ErrDFUNotReady = "{dfu-not-ready}" + +var _ = defineError(ErrDFUNotReady, http.StatusServiceUnavailable) + +// ErrDFUInProgress (golint) +const ErrDFUInProgress = "{dfu-in-progress}" + +var _ = defineError(ErrDFUInProgress, http.StatusServiceUnavailable) + +// ErrAuth (golint) +const ErrAuth = "{auth}" + +var _ = defineError(ErrAuth, http.StatusUnauthorized) + +// ErrTicket (golint) +const ErrTicket = "{ticket}" + +var _ = defineError(ErrTicket, http.StatusUnauthorized) + +// ErrHubNoHandler (golint) +const ErrHubNoHandler = "{no-handler}" + +var _ = defineError(ErrHubNoHandler, http.StatusInternalServerError) + +// ErrDeviceNotFound (golint) +const ErrDeviceNotFound = "{device-noexist}" + +var _ = defineError(ErrDeviceNotFound, http.StatusNotFound) + +// ErrDeviceNotSpecified (golint) +const ErrDeviceNotSpecified = "{device-none}" + +var _ = defineError(ErrDeviceNotSpecified, http.StatusBadRequest) + +// ErrDeviceId (golint) +const ErrDeviceId = "{device-id-invalid}" + +var _ = defineError(ErrDeviceId, http.StatusBadRequest) + +// ErrDeviceDisabled (golint) +const ErrDeviceDisabled = "{device-disabled}" + +var _ = defineError(ErrDeviceDisabled, http.StatusBadRequest) + +// ErrProductNotFound (golint) +const ErrProductNotFound = "{product-noexist}" + +var _ = defineError(ErrProductNotFound, http.StatusNotFound) + +// ErrProductNotSpecified (golint) +const ErrProductNotSpecified = "{product-none}" + +var _ = defineError(ErrProductNotSpecified, http.StatusBadRequest) + +// ErrAppNotFound (golint) +const ErrAppNotFound = "{app-noexist}" + +var _ = defineError(ErrAppNotFound, http.StatusNotFound) + +// ErrAppNotSpecified (golint) +const ErrAppNotSpecified = "{app-none}" + +var _ = defineError(ErrAppNotSpecified, http.StatusBadRequest) + +// ErrAppDeleted (golint) +const ErrAppDeleted = "{app-deleted}" + +var _ = defineError(ErrAppDeleted, http.StatusGone) + +// ErrAppExists (golint) +const ErrAppExists = "{app-exists}" + +var _ = defineError(ErrAppExists, http.StatusConflict) + +// ErrFleetNotFound (golint) +const ErrFleetNotFound = "{fleet-noexist}" + +var _ = defineError(ErrFleetNotFound, http.StatusNotFound) + +// ErrCardIo (golint) +const ErrCardIo = "{io}" + +var _ = defineError(ErrCardIo, http.StatusBadGateway) + +// ErrCardHeartbeat (golint) Doesn't seem to be used as a request error +const ErrCardHeartbeat = "{heartbeat}" + +// ErrAccessDenied (golint) +const ErrAccessDenied = "{access-denied}" + +var _ = defineError(ErrAccessDenied, http.StatusForbidden) + +// ErrWebPayload (golint) +const ErrWebPayload = "{web-payload}" + +var _ = defineError(ErrWebPayload, http.StatusBadRequest) + +// ErrHubMode (golint) Unused +const ErrHubMode = "{hub-mode}" + +// ErrTemplateIncompatible (golint) +const ErrTemplateIncompatible = "{template-incompatible}" + +var _ = defineError(ErrTemplateIncompatible, http.StatusBadRequest) + +// ErrSyntax (golint) +const ErrSyntax = "{syntax}" + +var _ = defineError(ErrSyntax, http.StatusBadRequest) + +// ErrIncompatible (golint) +const ErrIncompatible = "{incompatible}" + +var _ = defineError(ErrIncompatible, http.StatusNotAcceptable) + +// ErrReqNotSupported (golint) +const ErrReqNotSupported = "{not-supported}" + +var _ = defineError(ErrReqNotSupported, http.StatusNotImplemented) + +// ErrTooBig (golint) +const ErrTooBig = "{too-big}" + +var _ = defineError(ErrTooBig, http.StatusRequestEntityTooLarge) + +// ErrJson (golint) +const ErrJson = "{not-json}" + +var _ = defineError(ErrJson, http.StatusBadRequest) + +// Status messages returned by the notecard in request.Status +const StatusIdle = "{idle}" +const StatusNtnIdle = "{ntn-idle}" +const StatusTransportConnected = "{connected}" +const StatusTransportDisconnected = "{disconnected}" +const StatusTransportConnecting = "{connecting}" +const StatusTransportConnectFailure = "{connect-failure}" +const StatusTransportConnectedClosed = "{connected-closed}" +const StatusTransportWaitService = "{wait-service}" +const StatusTransportWaitData = "{wait-data}" +const StatusTransportWaitGateway = "{wait-gateway}" +const StatusTransportWaitModule = "{wait-module}" +const StatusGPSInactive = "{gps-inactive}" + +// These are returned from JSONata transforms as special strings to indicate the given behavior +// Used by Smart Fleets and during routing +const ErrAddToFleet = "{add-to-fleet}" +const ErrRemoveFromFleet = "{remove-from-fleet}" +const ErrLeaveFleetAlone = "{leave-fleet-alone}" +const ErrDoNotRoute = "{do-not-route}" + +// These can be sent from Notehub to the notecard to indicate it should pause before reconnecting +// Currently unused +const ErrDeviceDelay5 = "{device-delay-5}" +const ErrDeviceDelay10 = "{device-delay-10}" +const ErrDeviceDelay15 = "{device-delay-15}" +const ErrDeviceDelay20 = "{device-delay-20}" +const ErrDeviceDelay30 = "{device-delay-30}" +const ErrDeviceDelay60 = "{device-delay-60}" + +// ErrorContains tests to see if an error contains an error keyword that we might expect +func ErrorContains(err error, errKeyword string) bool { + if err == nil { + return false + } + return strings.Contains(fmt.Sprintf("%s", err), errKeyword) +} + +var errToHttpStatusMap map[string]int + +func defineError(errKeyword string, httpStatus int) string { + if errToHttpStatusMap == nil { + errToHttpStatusMap = make(map[string]int) + } + errToHttpStatusMap[errKeyword] = httpStatus + return errKeyword +} + +// This scans a response.Err string for known error keywords and returns the appropriate HTTP status code +// If there are multiple error keywords, the first one found is used as the source for the code. +// We choose the first one because that should be the most relevant to the specific failure. +// If no known error keywords are found, we return HTTP 500 Internal Server Error. +func ErrorHttpStatus(errstr string) int { + if errstr == "" { + return http.StatusOK + } + start := strings.Index(errstr, "{") + end := strings.Index(errstr, "}") + if start == -1 || end < start { + // Error message without a keyword. Assume it's an internal server error + return http.StatusInternalServerError + } + errKeyword := errstr[start : end+1] + if status, present := errToHttpStatusMap[errKeyword]; present { + return status + } + return http.StatusInternalServerError +} + +// ErrorClean removes all error keywords from an error string +func ErrorClean(err error) error { + errstr := fmt.Sprintf("%s", err) + for { + left := strings.SplitN(errstr, "{", 2) + if len(left) == 1 { + break + } + errstr = left[0] + b := strings.SplitN(left[1], "}", 2) + if len(b) > 1 { + errstr += strings.TrimPrefix(b[1], " ") + } + } + return fmt.Errorf("%s", errstr) +} + +// ErrorString safely returns a string from any error, returning "" for nil +func ErrorString(err error) string { + if err == nil { + return "" + } + return fmt.Sprintf("%s", err) +} + +// ErrorJSON returns a JSON object with nothing but an error code, and with an optional message +func ErrorJSON(message string, err error) (rspJSON []byte) { + if message == "" { + rspJSON = []byte(fmt.Sprintf("{\"err\":\"%q\"}", err)) + } else if err == nil { + rspJSON = []byte(fmt.Sprintf("{\"err\":\"%q\"}", message)) + } else { + rspJSON = []byte(fmt.Sprintf("{\"err\":\"%q: %q\"}", message, err)) + } + return +} diff --git a/note-go/note/event.go b/note-go/note/event.go new file mode 100644 index 0000000..96c1c48 --- /dev/null +++ b/note-go/note/event.go @@ -0,0 +1,267 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +import ( + "time" +) + +// EventAdd (golint) +const EventAdd = "note.add" + +// EventUpdate (golint) +const EventUpdate = "note.update" + +// EventDelete (golint) +const EventDelete = "note.delete" + +// EventTest (golint) +const EventTest = "test" + +// EventPost (golint) +const EventPost = "post" + +// EventPut (golint) +const EventPut = "put" + +// EventGet (golint) +const EventGet = "get" + +// EventNoAction (golint) +const EventNoAction = "" + +// EventSessionBegin (golint) +const EventSessionBegin = "session.begin" + +// EventSessionEndNotehub (golint) +const EventSessionEnd = "session.end" + +// EventGeolocation (golint) +const EventGeolocation = "device.geolocation" + +// EventTower (golint) +const EventTower = "device.tower" + +// EventSocket (golint) +const EventSocket = "web.socket" + +// EventWebhook (golint) +const EventWebhook = "webhook" + +// Event is the request structure passed to the Notification proc +// +// NOTE: This structure's underlying storage has been decoupled from the use of +// the structure in business logic. As such, please share any changes to these +// structures with cloud services to ensure that storage and testing frameworks +// are kept in sync with these structures used for business logic +type Event struct { + EventUID string `json:"event,omitempty"` + // Indicates whether or not this event is a "platform event" - that is, an event generated automatically + // somewhere in the notecard or notehub largely for administrative purposes that doesn't pertain to either + // implicit or explicit user data. + Platform bool `json:"platform,omitempty"` + // These fields, and only these fields, are regarded as "user data". All + // the rest of the fields are regarded as "metadata". + When int64 `json:"when,omitempty"` + NotefileID string `json:"file,omitempty"` + NoteID string `json:"note,omitempty"` + Body *map[string]interface{} `json:"body,omitempty"` + Payload []byte `json:"payload,omitempty"` + Details *map[string]interface{} `json:"details,omitempty"` + // Metadata + SessionUID string `json:"session,omitempty"` + SessionBegan int64 `json:"session_began,omitempty"` + TLS bool `json:"tls,omitempty"` + Transport string `json:"transport,omitempty"` + Continuous bool `json:"continuous,omitempty"` + BestID string `json:"best_id,omitempty"` + DeviceUID string `json:"device,omitempty"` + DeviceSN string `json:"sn,omitempty"` + ProductUID string `json:"product,omitempty"` + AppUID string `json:"app,omitempty"` + Received float64 `json:"received,omitempty"` + Req string `json:"req,omitempty"` + Error string `json:"err,omitempty"` + Updates int32 `json:"updates,omitempty"` + Deleted bool `json:"deleted,omitempty"` + Sent bool `json:"queued,omitempty"` + Bulk bool `json:"bulk,omitempty"` + BulkReceived float64 `json:"batch_received,omitempty"` + BulkNumber uint32 `json:"batch_number,omitempty"` + BulkTotal uint32 `json:"batch_total,omitempty"` + FirmwareHost string `json:"firmware_host,omitempty"` + FirmwareNotecard string `json:"firmware_notecard,omitempty"` + // This field is ONLY used when we remove the payload for storage reasons, to show the app how large it was + MissingPayloadLength int64 `json:"payload_length,omitempty"` + // Location + BestLocationType string `json:"best_location_type,omitempty"` + BestLocationWhen int64 `json:"best_location_when,omitempty"` + BestLat float64 `json:"best_lat,omitempty"` + BestLon float64 `json:"best_lon,omitempty"` + BestLocation string `json:"best_location,omitempty"` + BestCountry string `json:"best_country,omitempty"` + BestTimeZone string `json:"best_timezone,omitempty"` + Where string `json:"where_olc,omitempty"` + WhereWhen int64 `json:"where_when,omitempty"` + WhereLat float64 `json:"where_lat,omitempty"` + WhereLon float64 `json:"where_lon,omitempty"` + WhereLocation string `json:"where_location,omitempty"` + WhereCountry string `json:"where_country,omitempty"` + WhereTimeZone string `json:"where_timezone,omitempty"` + TowerWhen int64 `json:"tower_when,omitempty"` + TowerLat float64 `json:"tower_lat,omitempty"` + TowerLon float64 `json:"tower_lon,omitempty"` + TowerCountry string `json:"tower_country,omitempty"` + TowerLocation string `json:"tower_location,omitempty"` + TowerTimeZone string `json:"tower_timezone,omitempty"` + TowerID string `json:"tower_id,omitempty"` + TriWhen int64 `json:"tri_when,omitempty"` + TriLat float64 `json:"tri_lat,omitempty"` + TriLon float64 `json:"tri_lon,omitempty"` + TriLocation string `json:"tri_location,omitempty"` + TriCountry string `json:"tri_country,omitempty"` + TriTimeZone string `json:"tri_timezone,omitempty"` + TriPoints int32 `json:"tri_points,omitempty"` + + // Triangulation + Triangulate *map[string]interface{} `json:"triangulate,omitempty"` + // "Routed" environment variables beginning with a "$" prefix + Env *map[string]string `json:"environment,omitempty"` + Status EventRoutingStatus `json:"status,omitempty"` + FleetUIDs *[]string `json:"fleets,omitempty"` + + // ONLY POPULATED FOR EventSessionBegin with info both from notecard and notehub + DeviceSKU string `json:"sku,omitempty"` + DeviceOrderingCode string `json:"ordering_code,omitempty"` + DeviceFirmware int64 `json:"firmware,omitempty"` + Bearer string `json:"bearer,omitempty"` + CellID string `json:"cellid,omitempty"` + Bssid string `json:"bssid,omitempty"` + Ssid string `json:"ssid,omitempty"` + Iccid string `json:"iccid,omitempty"` + Apn string `json:"apn,omitempty"` + Rssi int `json:"rssi,omitempty"` + Sinr int `json:"sinr,omitempty"` + Rsrp int `json:"rsrp,omitempty"` + Rsrq int `json:"rsrq,omitempty"` + Rat string `json:"rat,omitempty"` + Bars uint32 `json:"bars,omitempty"` + Voltage float64 `json:"voltage,omitempty"` + Temp float64 `json:"temp,omitempty"` + Moved int64 `json:"moved,omitempty"` + Orientation string `json:"orientation,omitempty"` + PowerCharging bool `json:"power_charging,omitempty"` + PowerUsb bool `json:"power_usb,omitempty"` + PowerPrimary bool `json:"power_primary,omitempty"` + PowerMahUsed float64 `json:"power_mah,omitempty"` + + // ONLY POPULATED FOR EventSessionEnd because it comes from the notehub + NotehubLastWorkDone int64 `json:"hub_last_work_done,omitempty"` + NotehubDurationSecs int64 `json:"hub_duration_secs,omitempty"` + NotehubEventCount int64 `json:"hub_events_routed,omitempty"` + NotehubRcvdBytes uint32 `json:"hub_rcvd_bytes,omitempty"` + NotehubSentBytes uint32 `json:"hub_sent_bytes,omitempty"` + NotehubTCPSessions uint32 `json:"hub_tcp_sessions,omitempty"` + NotehubTLSSessions uint32 `json:"hub_tls_sessions,omitempty"` + NotehubRcvdNotes uint32 `json:"hub_rcvd_notes,omitempty"` + NotehubSentNotes uint32 `json:"hub_sent_notes,omitempty"` + + // ONLY POPULATED for EventSessionEndNotecard because it comes from the notecard + NotecardRcvdBytes uint32 `json:"card_rcvd_bytes,omitempty"` + NotecardSentBytes uint32 `json:"card_sent_bytes,omitempty"` + NotecardRcvdBytesSecondary uint32 `json:"card_rcvd_bytes_secondary,omitempty"` + NotecardSentBytesSecondary uint32 `json:"card_sent_bytes_secondary,omitempty"` + NotecardTCPSessions uint32 `json:"card_tcp_sessions,omitempty"` + NotecardTLSSessions uint32 `json:"card_tls_sessions,omitempty"` + NotecardRcvdNotes uint32 `json:"card_rcvd_notes,omitempty"` + NotecardSentNotes uint32 `json:"card_sent_notes,omitempty"` +} + +type EventRoutingStatus string + +const ( + EventStatusEmpty EventRoutingStatus = "" + EventStatusSuccess EventRoutingStatus = "success" + EventStatusFailure EventRoutingStatus = "failure" + EventStatusInProgress EventRoutingStatus = "in_progress" +) + +// RouteLogEntry is the log entry used by notification processing +type RouteLogEntry struct { + EventSerial int64 `json:"event,omitempty"` + RouteSerial int64 `json:"route,omitempty"` + Date time.Time `json:"date,omitempty"` + Attn bool `json:"attn,omitempty"` + Status string `json:"status,omitempty"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` + Source RoutingSource `json:"source,omitempty"` + + // Time in milliseconds that the route took to process + // We're making a simplifying assumption that the route will always + // take at least 1ms. So 0 means we didn't record the duration. + Duration int64 `json:"duration,omitempty"` +} + +type RoutingSource uint8 + +const ( + RoutingSourceUnknown RoutingSource = iota + RoutingSourceNormal + RoutingSourceProxy + RoutingSourceRetry + RoutingSourceManual + RoutingSourceDirect + RoutingSourceTest +) + +// String returns a string representation of the routing source +func (s RoutingSource) String() string { + switch s { + case RoutingSourceUnknown: + return "" // display nothing if no entry/default + case RoutingSourceNormal: + return "Normal Routing" + case RoutingSourceProxy: + return "Web Proxy Request" + case RoutingSourceRetry: + return "Auto-Retry" + case RoutingSourceManual: + return "Manual Reroute" + case RoutingSourceDirect: + return "Direct Routing" //only used for test events, should never show in route logs + case RoutingSourceTest: + return "Test" // only used for tests + default: + return "invalid" + } +} + +// GetAggregateEventStatus returns the status of the event given all +// of the route logs for the event. +// +// The aggregate status is determined by taking the most recent status +// for each route. If any of these are failures then the overall status +// is EventStatusFailure, otherwise it's EventStatusSuccess +func GetAggregateEventStatus(logs []RouteLogEntry) EventRoutingStatus { + if len(logs) == 0 { + return EventStatusEmpty + } + + latest := make(map[int64]RouteLogEntry) + for _, log := range logs { + if val, ok := latest[log.RouteSerial]; !ok || log.Date.After(val.Date) { + latest[log.RouteSerial] = log + } + } + + for _, latestLogEntry := range latest { + if latestLogEntry.Attn { + return EventStatusFailure + } + } + + return EventStatusSuccess +} diff --git a/note-go/note/message.go b/note-go/note/message.go new file mode 100644 index 0000000..70e3aaa --- /dev/null +++ b/note-go/note/message.go @@ -0,0 +1,67 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +// MessageAddress is the network routing information for a message +type MessageAddress struct { + Hub string `json:"hub,omitempty"` + ProductUID string `json:"product,omitempty"` + DeviceUID string `json:"device,omitempty"` + DeviceSN string `json:"sn,omitempty"` + Active uint32 `json:"active,omitempty"` +} + +// MessageContact is the entity sending a message, who may have multiple devices/addresses +type MessageContact struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + StoreTags []string `json:"stags,omitempty"` + Addresses []MessageAddress `json:"addresses,omitempty"` +} + +// Message is the core message data structure. Note that when stored in a map or a note, +// the UID is not present but rather is the map key or noteID. +type Message struct { + UID string `json:"id,omitempty"` + Sent uint32 `json:"sent,omitempty"` + Received uint32 `json:"received,omitempty"` + From MessageContact `json:"from,omitempty"` + To []MessageContact `json:"to,omitempty"` + Tags []string `json:"tags,omitempty"` + StoreTags []string `json:"stags,omitempty"` + ContentType string `json:"type,omitempty"` + Content string `json:"content,omitempty"` + Body *map[string]interface{} `json:"body,omitempty"` +} + +// MessageOutbox is the place from which messages are sent +const MessageOutbox = "messages.qo" + +// MessageInbox is the place into which messages are received +const MessageInbox = "messages.qi" + +// MessageStore is the place where the user retains messages +const MessageStore = "messages.db" + +// ContactStore is the place where the user retains contact info +const ContactStore = "contacts.db" + +// MessageContentASCII is just simple ASCII text +const MessageContentASCII = "" + +// MessageTagImportant indicates that the sender feels that this is an important message +const MessageTagImportant = "important" + +// MessageTagUrgent indicates that the sender feels that this is an urgent message +const MessageTagUrgent = "urgent" + +// MessageSTagSent indicates that this was a sent message +const MessageSTagSent = "sent" + +// MessageSTagReceived indicates that this was a received message +const MessageSTagReceived = "received" + +// ContactOwnerNoteID indicates that this is my contact +const ContactOwnerNoteID = "owner" diff --git a/note-go/note/note.go b/note-go/note/note.go new file mode 100644 index 0000000..6e7689f --- /dev/null +++ b/note-go/note/note.go @@ -0,0 +1,259 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +import ( + "bytes" + "encoding/json" + "math" + "strings" + "time" +) + +// DefaultDeviceEndpointID is the default endpoint name of the edge, chosen for its length in protocol messages +const DefaultDeviceEndpointID = "" + +// DefaultHubEndpointID is the default endpoint name of the hub, chosen for its length in protocol messages +const DefaultHubEndpointID = "1" + +// HubDefaultInboundNotefile is the hard-wired default notefile for user data +const HubDefaultInboundNotefile = "data.qi" + +// HubDefaultOutboundNotefile is the hard-wired default notefile for user data +const HubDefaultOutboundNotefile = "data.qo" + +// Note is the most fundamental data structure, containing +// user data referred to as its "body" and its "payload". All +// access to these fields, and changes to these fields, must +// be done indirectly through the note API. +type Note struct { + Body map[string]interface{} `json:"b,omitempty"` + Payload []byte `json:"p,omitempty"` + Change int64 `json:"c,omitempty"` + Histories *[]History `json:"h,omitempty"` + Conflicts *[]Note `json:"x,omitempty"` + Updates int32 `json:"u,omitempty"` + Deleted bool `json:"d,omitempty"` + Sent bool `json:"s,omitempty"` + Bulk bool `json:"k,omitempty"` + XPOff uint32 `json:"O,omitempty"` + XPLen uint32 `json:"L,omitempty"` + Tower *TowerLocation `json:"T,omitempty"` +} + +// History records the update history, optimized so that if the most recent entry +// is by the same endpoint as an update/delete, that entry is re-used. The primary use +// of History is for conflict detection, and you don't need to detect conflicts +// against yourself. +type History struct { + When int64 `json:"w,omitempty"` + Where string `json:"l,omitempty"` + WhereWhen int64 `json:"m,omitempty"` + EndpointID string `json:"e,omitempty"` + Sequence int32 `json:"s,omitempty"` +} + +// Info is a general "content" structure +type Info struct { + NoteID string `json:"id,omitempty"` + When int64 `json:"time,omitempty"` + WhereLat float64 `json:"lat,omitempty"` + WhereLon float64 `json:"lon,omitempty"` + WhereWhen int64 `json:"ltime,omitempty"` + Body *map[string]interface{} `json:"body,omitempty"` + Payload *[]byte `json:"payload,omitempty"` + Deleted bool `json:"deleted,omitempty"` + Edge bool `json:"edge,omitempty"` + Pending bool `json:"pending,omitempty"` +} + +// CreateNote creates the core data structure for an object, given a JSON body +func CreateNote(body []byte, payload []byte) (newNote Note, err error) { + newNote.Payload = payload + err = newNote.SetBody(body) + return +} + +// SetBody sets the application-supplied Body field of a given Note given some JSON +func (note *Note) SetBody(body []byte) (err error) { + if len(body) == 0 { + note.Body = nil + } else { + note.Body = map[string]interface{}{} + err = JSONUnmarshal(body, ¬e.Body) + if err != nil { + return + } + } + return +} + +// JSONToBody unmarshals the specified object and returns it as a map[string]interface{} +func JSONToBody(bodyJSON []byte) (body map[string]interface{}, err error) { + err = JSONUnmarshal(bodyJSON, &body) + return +} + +// ObjectToJSON Marshals the specified object and returns it as a []byte +func ObjectToJSON(object interface{}) (bodyJSON []byte, err error) { + bodyJSON, err = JSONMarshal(object) + return +} + +// ObjectToBody Marshals the specified object and returns it as map +func ObjectToBody(object interface{}) (body map[string]interface{}, err error) { + var bodyJSON []byte + bodyJSON, err = JSONMarshal(object) + if err == nil { + err = JSONUnmarshal(bodyJSON, &body) + } + return +} + +// BodyToObject Unmarshals the specified map into an object +func BodyToObject(body *map[string]interface{}, object interface{}) (err error) { + if body == nil { + return + } + var bodyJSON []byte + bodyJSON, err = JSONMarshal(body) + if err == nil { + err = JSONUnmarshal(bodyJSON, object) + } + return +} + +// SetPayload sets the application-supplied Payload field of a given Note, +// which must be binary bytes that will ultimately be rendered as base64 in JSON +func (note *Note) SetPayload(payload []byte) { + note.Payload = payload +} + +// Close closes and frees the object on a note { +func (note *Note) Close() { +} + +// Dup duplicates the note +func (note *Note) Dup() Note { + newNote := *note + return newNote +} + +// GetBody retrieves the application-specific Body of a given Note +func (note *Note) GetBody() []byte { + if note.Body == nil { + return []byte("{}") + } + data, err := JSONMarshal(note.Body) + if err != nil { + return []byte("{}") + } + return data +} + +// GetPayload retrieves the Payload from a given Note +func (note *Note) GetPayload() []byte { + return note.Payload +} + +// EndpointID determines the endpoint that last modified the note +func (note *Note) EndpointID() string { + if note.Histories == nil { + return "" + } + histories := *note.Histories + if len(histories) == 0 { + return "" + } + return histories[0].EndpointID +} + +// HasConflicts determines whether or not a given Note has conflicts +func (note *Note) HasConflicts() bool { + if note.Conflicts == nil { + return false + } + return len(*note.Conflicts) != 0 +} + +// GetConflicts fetches the conflicts, so that they may be displayed +func (note *Note) GetConflicts() []Note { + if note.Conflicts == nil { + return []Note{} + } + return *note.Conflicts +} + +// GetWhen retrieves the epoch modification time +func (note *Note) When() (when int64) { + if note.Histories == nil || len(*note.Histories) == 0 { + return 0 + } + h := (*note.Histories)[0] + if h.When < 1483228800 || h.When > math.MaxUint32 { + // Before 1/1/2017 or can't fit into a uint32 + h.When = 0 + } + return h.When +} + +// GetEndpointID retrieves the endpoint that last modified the note +func (note *Note) GetEndpointID() (endpointID string) { + if note.Histories == nil || len(*note.Histories) == 0 { + return "" + } + h := (*note.Histories)[0] + return h.EndpointID +} + +// GetModified retrieves information about the note's modification +func (note *Note) GetModified() (isAvailable bool, endpointID string, when string, where string, updates int32) { + if note.Histories == nil || len(*note.Histories) == 0 { + return + } + histories := *note.Histories + endpointID = histories[0].EndpointID + when = time.Unix(0, histories[0].When*1000000000).UTC().Format("2006-01-02T15:04:05Z") + where = histories[0].Where + updates = histories[0].Sequence + isAvailable = true + return +} + +// JSONUnmarshal uses JSON Numbers, rather than assuming Floats. This fixes an issue +// in which, when decoding to an arbitrary interface, the JSON package decodes +// large numbers (like Unix epoch) into floats. +func JSONUnmarshal(data []byte, v interface{}) (err error) { + d := json.NewDecoder(strings.NewReader(string(data))) + d.UseNumber() + return d.Decode(v) +} + +// JSONMarshal is the equivalent to the json package's Marshal, however it does not escape HTML +// sitting inside JSON strings. +func JSONMarshal(v interface{}) ([]byte, error) { + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + err := encoder.Encode(v) + clean := bytes.TrimSuffix(buffer.Bytes(), []byte("\n")) + return clean, err +} + +// JSONMarshalIndent is like Marshal but applies Indent to format the output. +// Each JSON element in the output will begin on a new line beginning with prefix +// followed by one or more copies of indent according to the indentation nesting. +func JSONMarshalIndent(v interface{}, prefix, indent string) ([]byte, error) { + b, err := JSONMarshal(v) + if err != nil { + return nil, err + } + var buf bytes.Buffer + err = json.Indent(&buf, b, prefix, indent) + if err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/note-go/note/notefile.go b/note-go/note/notefile.go new file mode 100644 index 0000000..536da93 --- /dev/null +++ b/note-go/note/notefile.go @@ -0,0 +1,99 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +// TrackNotefile is the hard-wired notefile that the notecard can use for tracking the device +const TrackNotefile = "_track.qo" + +// NotecardRequestNotefile is a special notefile for sending notecard requests +const NotecardRequestNotefile = "_req.qis" + +// NotecardResponseNotefile is a special notefile for sending notecard responses +const NotecardResponseNotefile = "_rsp.qos" + +// LogNotefile is the hard-wired notefile that the notecard uses for debug logging +const LogNotefile = "_log.qo" + +// EnvNotefile is the hard-wired notefile that the notecard uses for env vars +const EnvNotefile = "_env.dbs" + +// SessionNotefile is the hard-wired notefile that the notehub uses when starting a session +const SessionNotefile = "_session.qo" + +// HealthNotefile is the hard-wired notefile that the notecard uses for health-related info +const HealthNotefile = "_health.qo" + +// HealthHostNotefile is the hard-wired notefile that the host uses for health-related info +const HealthHostNotefile = "_health_host.qo" + +// GeolocationNotefile is the hard-wired notefile that the notehub uses when performing a geolocation +const GeolocationNotefile = "_geolocate.qo" + +// TowerNotefile is the hard-wired notefile that the notehub uses when performing tower updates +const TowerNotefile = "_tower.qo" + +// SocketNotefile is the hard-wired notefile that the notehub uses when doing websocket I/O +const SocketNotefile = "_socket.qo" + +// WebNotefile is the hard-wired notefile that the notehub uses when performing web requests +const WebNotefile = "_web.qo" + +// WatchdogNotefile is the hard-wired notefile that the notehub uses when adding watchdog messages +const WatchdogNotefile = "_watchdog.qo" + +// SyncPriorityLowest (golint) +const SyncPriorityLowest = -3 + +// SyncPriorityLower (golint) +const SyncPriorityLower = -2 + +// SyncPriorityLow (golint) +const SyncPriorityLow = -1 + +// SyncPriorityNormal (golint) +const SyncPriorityNormal = 0 + +// SyncPriorityHigh (golint) +const SyncPriorityHigh = 1 + +// SyncPriorityHigher (golint) +const SyncPriorityHigher = 2 + +// SyncPriorityHighest (golint) +const SyncPriorityHighest = 3 + +// NotefileInfo has parameters about the Notefile +type NotefileInfo struct { + // The count of modified notes in this notefile. This is used in the Req API, but not in the Notebox info + Changes int `json:"changes,omitempty"` + // The count of total notes in this notefile. This is used in the Req API, but not in the Notebox info + Total int `json:"total,omitempty"` + // This is a unidirectional "to-hub" or "from-hub" endpoint + SyncHubEndpointID string `json:"sync_hub_endpoint,omitempty"` + // Relative positive/negative priority of data, with 0 being normal + SyncPriority int `json:"sync_priority,omitempty"` + // Timed: Target for sync period, if modified and if the value hasn't been synced sooner + SyncPeriodSecs int `json:"sync_secs,omitempty"` + // ReqTime is specified if notes stored in this notefile must have a valid time associated with them + ReqTime bool `json:"req_time,omitempty"` + // ReqLoc is specified if notes stored in this notefile must have a valid location associated with them + ReqLoc bool `json:"req_loc,omitempty"` + // AnonAddAllowed is specified if anyone is allowed to drop into this notefile without authentication + AnonAddAllowed bool `json:"anon_add,omitempty"` + // ImportTime is the epoch time of when an external data source (such as a feed) last sync'ed data inbound + ImportTime int64 `json:"import_time,omitempty"` + // ExportTime is the epoch time of when an external data source (such as a feed) last sync'ed data outbound + ExportTime int64 `json:"export_time,omitempty"` +} + +// Information about notefiles and their templates +type NotefileDesc struct { + NotefileID string `json:"file,omitempty"` + Info NotefileInfo `json:"info,omitempty"` + BodyTemplate string `json:"body_template,omitempty"` + PayloadTemplate uint32 `json:"payload_template,omitempty"` + TemplateFormat uint32 `json:"template_format,omitempty"` + TemplatePort uint16 `json:"template_port,omitempty"` +} diff --git a/note-go/note/session.go b/note-go/note/session.go new file mode 100644 index 0000000..ded8c58 --- /dev/null +++ b/note-go/note/session.go @@ -0,0 +1,161 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +// DeviceSession is the basic unit of recorded device usage history +type DeviceSession struct { + // Session ID that can be mapped to the events created during that session + SessionUID string `json:"session,omitempty"` + // When the session was initially opened + SessionBegan int64 `json:"session_began,omitempty"` + // When a persistent session was last updated + SessionUpdated int64 `json:"session_updated,omitempty"` + // Why a session was opened + WhySessionOpened string `json:"why_session_opened,omitempty"` + // When the session was initially opened + SessionEnded int64 `json:"session_ended,omitempty"` + // Why the session was closed + WhySessionClosed string `json:"why_session_closed,omitempty"` + // Log key for this session + SessionLogKey string `json:"session_log_key,omitempty"` + // Info from the device structure + DeviceUID string `json:"device,omitempty"` + DeviceSN string `json:"sn,omitempty"` + ProductUID string `json:"product,omitempty"` + FleetUIDs []string `json:"fleets,omitempty"` + // Protocol:IP:port address of the handler serving the session + Handler string `json:"handler,omitempty"` + // Cell ID where the session originated and quality ("mcc,mnc,lac,cellid") + CellID string `json:"cell,omitempty"` + // Elevation of cell tower if known + Elevation float64 `json:"elevation,omitempty"` + // Parameters passed by device as a result of scanning towers/APs + ScanResults *[]byte `json:"scan,omitempty"` + Triangulate *map[string]interface{} `json:"triangulate,omitempty"` + // Network connection information sent by the notecard + Rssi int `json:"rssi,omitempty"` + Sinr int `json:"sinr,omitempty"` + Rsrp int `json:"rsrp,omitempty"` + Rsrq int `json:"rsrq,omitempty"` + Bars int `json:"bars,omitempty"` + Rat string `json:"rat,omitempty"` + Bearer string `json:"bearer,omitempty"` + Ip string `json:"ip,omitempty"` + Bssid string `json:"bssid,omitempty"` + Ssid string `json:"ssid,omitempty"` + Iccid string `json:"iccid,omitempty"` + Apn string `json:"apn,omitempty"` + // Composed by wire.go for use in Request.Transport && Event.Transport + Transport string `json:"transport,omitempty"` + // Last known tower and triangulated location as determined at the start of session + Tower TowerLocation `json:"tower,omitempty"` + Tri TowerLocation `json:"tri,omitempty"` + // Last known capture time of a note routed through this session + When int64 `json:"when,omitempty"` + // Last known GPS location of a note routed through this session + WhereWhen int64 `json:"where_when,omitempty"` + WhereOLC string `json:"where,omitempty"` + WhereLat float64 `json:"where_lat,omitempty"` + WhereLon float64 `json:"where_lon,omitempty"` + WhereLocation string `json:"where_location,omitempty"` + WhereCountry string `json:"where_country,omitempty"` + WhereTimeZone string `json:"where_timezone,omitempty"` + // Flag indicating whether the usage data is based on actual stats from the device + IsUsageActual bool `json:"usage_actual,omitempty"` + // Physical device info + Voltage float64 `json:"voltage,omitempty"` + Temp float64 `json:"temp,omitempty"` + // Type of session + ContinuousSession bool `json:"continuous,omitempty"` + TLSSession bool `json:"tls,omitempty"` + // For keeping track of when the last work was done for a session + LastWorkDone int64 `json:"work,omitempty"` + // Number of Events routed + EventCount int64 `json:"events,omitempty"` + // Motion of the notecard + Moved int64 `json:"moved,omitempty"` + Orientation string `json:"orientation,omitempty"` + // Last known power stats at start of session + HighPowerSecsTotal uint32 `json:"hp_secs_total,omitempty"` + HighPowerSecsData uint32 `json:"hp_secs_data,omitempty"` + HighPowerSecsGPS uint32 `json:"hp_secs_gps,omitempty"` + HighPowerCyclesTotal uint32 `json:"hp_cycles_total,omitempty"` + HighPowerCyclesData uint32 `json:"hp_cycles_data,omitempty"` + HighPowerCyclesGPS uint32 `json:"hp_cycles_gps,omitempty"` + // Amount of packet usage within the session, keyed by PSID + PacketUsage map[string]PacketUsage `json:"packet_usage,omitempty"` + // Total device usage at the beginning of the period + ThisPtr *DeviceUsage `json:"this,omitempty"` + // Total device usage at the beginning of the next period, whenever it happens to occur + NextPtr *DeviceUsage `json:"next,omitempty"` + // Usage during the period - initially estimated, but then corrected when we get to the next period + PeriodPtr *DeviceUsage `json:"period,omitempty"` + // NotecardPowerSource flags + PowerCharging bool `json:"power_charging,omitempty"` + PowerUsb bool `json:"power_usb,omitempty"` + PowerPrimary bool `json:"power_primary,omitempty"` + // Mojo power usage + PowerMahUsed float64 `json:"power_mah,omitempty"` + // Information about failed connections PRIOR to this one + PenaltySecs uint32 `json:"penalty_secs,omitempty"` + FailedConnects uint32 `json:"failed_connects,omitempty"` + // Socket-relate + SocketAlias string `json:"socket_alias,omitempty"` + SocketConnectError string `json:"socket_connect_error,omitempty"` + SocketBytesSent int64 `json:"socket_bytes_sent,omitempty"` + SocketBytesRcvd int64 `json:"socket_bytes_rcvd,omitempty"` +} + +func (s *DeviceSession) This() *DeviceUsage { + if s.ThisPtr == nil { + s.ThisPtr = &DeviceUsage{} + } + return s.ThisPtr +} + +func (s *DeviceSession) Next() *DeviceUsage { + if s.NextPtr == nil { + s.NextPtr = &DeviceUsage{} + } + return s.NextPtr +} + +func (s *DeviceSession) Period() *DeviceUsage { + if s.PeriodPtr == nil { + s.PeriodPtr = &DeviceUsage{} + } + return s.PeriodPtr +} + +// Indication of the packet usage within a session +type PacketUsage struct { + Updated int64 `json:"updated,omitempty"` + DownlinkPackets int64 `json:"dl_p,omitempty"` + DownlinkBytes int64 `json:"dl_b,omitempty"` + DownlinkBytesBillable int64 `json:"dl_bb,omitempty"` + UplinkPackets int64 `json:"ul_p,omitempty"` + UplinkBytes int64 `json:"ul_b,omitempty"` + UplinkBytesBillable int64 `json:"ul_bb,omitempty"` + BillableMinBytesPerPacket int64 `json:"bmbpp,omitempty"` +} + +// TowerLocation is a location structure generated by a lookup +type TowerLocation struct { + Source string `json:"source,omitempty"` // source of this location + When int64 `json:"time,omitempty"` // time when this location was ascertained + Name string `json:"n,omitempty"` // name of the location + CountryCode string `json:"c,omitempty"` // country code + Lat float64 `json:"lat,omitempty"` // latitude + Lon float64 `json:"lon,omitempty"` // longitude + TimeZone string `json:"zone,omitempty"` // timezone name + MCC int `json:"mcc,omitempty"` + MNC int `json:"mnc,omitempty"` + LAC int `json:"lac,omitempty"` + CID int `json:"cid,omitempty"` + OLC string `json:"l,omitempty"` // open location code + TimeZoneID int `json:"z,omitempty"` // timezone id (see tz.go) + Deprecated int64 `json:"count,omitempty"` // (no longer used or supported) + Towers int `json:"towers,omitempty"` // number of triangulation points +} diff --git a/note-go/note/usage.go b/note-go/note/usage.go new file mode 100644 index 0000000..8c2ff82 --- /dev/null +++ b/note-go/note/usage.go @@ -0,0 +1,21 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +// DeviceUsage is the device usage metric representing values from the beginning of time, since Provisioned +type DeviceUsage struct { + Since int64 `json:"since,omitempty"` + DurationSecs uint32 `json:"duration,omitempty"` + RcvdBytes uint32 `json:"bytes_rcvd,omitempty"` + SentBytes uint32 `json:"bytes_sent,omitempty"` + RcvdBytesSecondary uint32 `json:"bytes_rcvd_secondary,omitempty"` + SentBytesSecondary uint32 `json:"bytes_sent_secondary,omitempty"` + TCPSessions uint32 `json:"sessions_tcp,omitempty"` + TLSSessions uint32 `json:"sessions_tls,omitempty"` + PacketSessions uint32 `json:"sessions_packet,omitempty"` + WebhookSessions uint32 `json:"sessions_webhook,omitempty"` + RcvdNotes uint32 `json:"notes_rcvd,omitempty"` + SentNotes uint32 `json:"notes_sent,omitempty"` +} diff --git a/note-go/note/words.go b/note-go/note/words.go new file mode 100644 index 0000000..5747e96 --- /dev/null +++ b/note-go/note/words.go @@ -0,0 +1,2196 @@ +// Copyright 2020 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package note + +import ( + "hash/fnv" + "sort" + "strconv" + "strings" + "sync" +) + +// Word index data structure +type Word struct { + WordIndex uint +} + +var ( + sortedWords []Word + sortedWordsInitialized = false + sortedWordsInitLock sync.RWMutex +) + +// Class used to sort an index of words +type byWord []Word + +func (a byWord) Len() int { return len(a) } +func (a byWord) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a byWord) Less(i, j int) bool { return words2048[a[i].WordIndex] < words2048[a[j].WordIndex] } + +// WordToNumber converts a single word to a number +func WordToNumber(word string) (num uint, success bool) { + // Initialize sorted words array if necessary + if !sortedWordsInitialized { + sortedWordsInitLock.Lock() + if !sortedWordsInitialized { + + // Init the index array + sortedWords = make([]Word, 2048) + for i := 0; i < 2048; i++ { + sortedWords[i].WordIndex = uint(i) + } + + // Sort the array + sort.Sort(byWord(sortedWords)) + + // We're now initialized + sortedWordsInitialized = true + } + sortedWordsInitLock.Unlock() + } + + // First normalize the word + word = strings.ToLower(word) + + // Do a binary chop to find the word or its insertion slot + i := sort.Search(2048, func(i int) bool { return words2048[sortedWords[i].WordIndex] >= word }) + + // Exit if found. (If we failed to match the result, it's an insertion slot.) + if i < 2048 && words2048[sortedWords[i].WordIndex] == word { + return sortedWords[i].WordIndex, true + } + + return 0, false +} + +// WordsToNumber looks up a number from two or three simple words +func WordsToNumber(words string) (num uint32, found bool) { + var left, middle, right uint + var success bool + + // For convenience, if a number is supplied just return that number. I do this so + // that you can use this same method to parse either a number or the words to get that number. + word := strings.Split(words, "-") + if len(word) == 1 { + + // See if this parses cleanly as a number + i64, err := strconv.ParseUint(words, 10, 32) + if err == nil { + return uint32(i64), true + } + return 0, false + } + + // Convert two or three words to numbers, msb to lsb + if len(word) == 2 { + middle, success = WordToNumber(word[0]) + if !success { + return 0, false + } + right, success = WordToNumber(word[1]) + if !success { + return 0, false + } + } else { + left, success = WordToNumber(word[0]) + if !success { + return 0, false + } + middle, success = WordToNumber(word[1]) + if !success { + return 0, false + } + right, success = WordToNumber(word[2]) + if !success { + return 0, false + } + } + + // Map back to bit fields + result := uint32(left) << 22 + result |= uint32(middle) << 11 + result |= uint32(right) + + return result, true +} + +// WordsFromString hashes a string with a 32-bit function and converts it to three simple words +func WordsFromString(in string) (out string) { + hash := fnv.New32a() + inbytes := []byte(in) + hash.Write(inbytes) + hashval := hash.Sum32() + out = WordsFromNumber(hashval) + return +} + +// WordsFromNumber converts a number to three simple words +func WordsFromNumber(number uint32) string { + // Break the 32-bit uint down into 3 bit fields + left := (number >> 22) & 0x000003ff + middle := (number >> 11) & 0x000007ff + right := number & 0x000007ff + + // If the high order is 0, which is frequently the case, just use two words + if left == 0 { + return words2048[middle] + "-" + words2048[right] + } + return words2048[left] + "-" + words2048[middle] + "-" + words2048[right] +} + +// 2048 words, ORDERED but alphabetically unsorted +var words2048 = []string{ + "act", + "add", + "age", + "ago", + "point", + "big", + "all", + "and", + "any", + "arm", + "art", + "ash", + "ask", + "bad", + "bag", + "ban", + "bar", + "bat", + "bay", + "bed", + "bee", + "beg", + "bet", + "bid", + "air", + "bit", + "bow", + "box", + "boy", + "bug", + "bus", + "buy", + "cab", + "can", + "cap", + "car", + "cat", + "cop", + "cow", + "cry", + "cue", + "cup", + "cut", + "dad", + "day", + "die", + "dig", + "dip", + "dog", + "dot", + "dry", + "due", + "ear", + "eat", + "egg", + "ego", + "end", + "era", + "etc", + "eye", + "fan", + "far", + "fat", + "fee", + "few", + "fit", + "fix", + "fly", + "fog", + "for", + "fun", + "fur", + "gap", + "gas", + "get", + "gun", + "gut", + "guy", + "gym", + "hat", + "hay", + "her", + "hey", + "him", + "hip", + "his", + "hit", + "hot", + "how", + "hug", + "huh", + "ice", + "its", + "jar", + "jaw", + "jet", + "job", + "joy", + "key", + "kid", + "kit", + "lab", + "lap", + "law", + "leg", + "let", + "lid", + "lie", + "lip", + "log", + "lot", + "low", + "mars", + "mango", + "map", + "may", + "mix", + "mom", + "mud", + "net", + "new", + "nod", + "not", + "now", + "nut", + "oak", + "odd", + "off", + "oil", + "old", + "one", + "our", + "out", + "owe", + "own", + "pad", + "pan", + "pat", + "pay", + "pen", + "pet", + "pie", + "pig", + "pin", + "pit", + "pop", + "pot", + "put", + "rat", + "raw", + "red", + "rib", + "rid", + "rip", + "row", + "run", + "say", + "see", + "set", + "she", + "shy", + "sir", + "sit", + "six", + "ski", + "sky", + "son", + "spy", + "sum", + "sun", + "tag", + "tap", + "tax", + "tea", + "ten", + "the", + "tie", + "tip", + "toe", + "top", + "toy", + "try", + "two", + "use", + "van", + "war", + "way", + "web", + "who", + "why", + "win", + "wow", + "yes", + "yet", + "you", + "able", + "acid", + "aide", + "ally", + "also", + "amid", + "area", + "army", + "atop", + "aunt", + "auto", + "away", + "baby", + "back", + "bake", + "ball", + "band", + "bank", + "bare", + "barn", + "base", + "bath", + "beam", + "bean", + "bear", + "beat", + "beef", + "beer", + "bell", + "belt", + "bend", + "best", + "bias", + "bike", + "bill", + "bind", + "bird", + "bite", + "blue", + "boat", + "body", + "boil", + "bold", + "bolt", + "bomb", + "bond", + "bone", + "book", + "boom", + "boot", + "born", + "boss", + "both", + "bowl", + "buck", + "bulb", + "bulk", + "bull", + "burn", + "bury", + "bush", + "busy", + "cage", + "cake", + "call", + "calm", + "camp", + "card", + "care", + "cart", + "case", + "cash", + "cast", + "cave", + "cell", + "chef", + "chew", + "chin", + "chip", + "chop", + "cite", + "city", + "clay", + "clip", + "club", + "clue", + "coal", + "coat", + "code", + "coin", + "cold", + "come", + "cook", + "cool", + "cope", + "copy", + "cord", + "core", + "corn", + "cost", + "coup", + "crew", + "crop", + "cure", + "cute", + "dare", + "dark", + "data", + "date", + "dawn", + "dead", + "deal", + "dear", + "debt", + "deck", + "deem", + "deep", + "deer", + "deny", + "desk", + "diet", + "dirt", + "dish", + "dock", + "doll", + "door", + "dose", + "down", + "drag", + "draw", + "drop", + "drug", + "drum", + "duck", + "dumb", + "dump", + "dust", + "duty", + "each", + "earn", + "ease", + "east", + "easy", + "echo", + "edge", + "edit", + "else", + "even", + "ever", + "evil", + "exam", + "exit", + "face", + "fact", + "fade", + "fail", + "fair", + "fall", + "fame", + "fare", + "farm", + "fast", + "fate", + "feed", + "feel", + "file", + "fill", + "film", + "find", + "fine", + "fire", + "firm", + "fish", + "five", + "flag", + "flat", + "flee", + "flip", + "flow", + "fold", + "folk", + "food", + "foot", + "fork", + "form", + "four", + "free", + "from", + "fuel", + "full", + "fund", + "gain", + "game", + "gang", + "gate", + "gaze", + "gear", + "gene", + "gift", + "girl", + "give", + "glad", + "goal", + "goat", + "gold", + "golf", + "good", + "grab", + "gray", + "grin", + "grip", + "grow", + "half", + "hall", + "hand", + "hang", + "hard", + "harm", + "hate", + "haul", + "have", + "head", + "heal", + "hear", + "heat", + "heel", + "help", + "herb", + "here", + "hero", + "hers", + "hide", + "high", + "hike", + "hill", + "hint", + "hire", + "hold", + "home", + "hook", + "hope", + "horn", + "host", + "hour", + "huge", + "hunt", + "hurt", + "icon", + "idea", + "into", + "iron", + "item", + "jail", + "jazz", + "join", + "joke", + "jump", + "jury", + "just", + "keep", + "kick", + "kilt", + "kind", + "king", + "kiss", + "knee", + "know", + "lack", + "lake", + "lamp", + "land", + "lane", + "last", + "late", + "lawn", + "lead", + "leaf", + "lean", + "leap", + "left", + "lend", + "lens", + "less", + "life", + "lift", + "like", + "limb", + "line", + "link", + "lion", + "list", + "live", + "load", + "loan", + "lock", + "long", + "look", + "loop", + "loss", + "lost", + "lots", + "loud", + "love", + "luck", + "lung", + "mail", + "main", + "make", + "mall", + "many", + "mark", + "mask", + "mass", + "mate", + "math", + "meal", + "mean", + "meat", + "meet", + "melt", + "menu", + "mere", + "mild", + "milk", + "mill", + "mind", + "mine", + "miss", + "mode", + "mood", + "moon", + "more", + "most", + "move", + "much", + "must", + "myth", + "nail", + "name", + "near", + "neat", + "neck", + "need", + "nest", + "news", + "next", + "nice", + "nine", + "none", + "noon", + "norm", + "nose", + "note", + "odds", + "okay", + "once", + "only", + "onto", + "open", + "ours", + "oven", + "over", + "pace", + "pack", + "page", + "pain", + "pair", + "pale", + "palm", + "pant", + "park", + "part", + "pass", + "past", + "path", + "peak", + "peel", + "peer", + "pick", + "pile", + "pill", + "pine", + "pink", + "pipe", + "plan", + "play", + "plea", + "plot", + "plus", + "poem", + "poet", + "poke", + "pole", + "poll", + "pond", + "pool", + "poor", + "pork", + "port", + "pose", + "post", + "pour", + "pray", + "pull", + "pump", + "pure", + "push", + "quit", + "race", + "rack", + "rage", + "rail", + "rain", + "rank", + "rare", + "rate", + "read", + "real", + "rear", + "rely", + "rent", + "rest", + "rice", + "rich", + "ride", + "ring", + "riot", + "rise", + "risk", + "road", + "rock", + "role", + "roll", + "roof", + "room", + "root", + "rope", + "rose", + "ruin", + "rule", + "rush", + "sack", + "safe", + "sail", + "sake", + "sale", + "salt", + "same", + "sand", + "save", + "scan", + "seal", + "seat", + "seed", + "seek", + "seem", + "self", + "sell", + "send", + "sexy", + "shed", + "ship", + "shoe", + "shop", + "shot", + "show", + "shut", + "side", + "sign", + "silk", + "sing", + "sink", + "site", + "size", + "skip", + "slam", + "slip", + "slot", + "slow", + "snap", + "snow", + "soak", + "soap", + "soar", + "sock", + "sofa", + "soft", + "soil", + "sole", + "some", + "song", + "soon", + "sort", + "soul", + "soup", + "spin", + "spit", + "spot", + "star", + "stay", + "stem", + "step", + "stir", + "stop", + "such", + "suck", + "suit", + "sure", + "swim", + "tail", + "take", + "tale", + "talk", + "tall", + "tank", + "tape", + "task", + "team", + "tear", + "teen", + "tell", + "tend", + "tent", + "term", + "test", + "text", + "than", + "that", + "them", + "then", + "they", + "thin", + "this", + "thus", + "tide", + "tile", + "till", + "time", + "tiny", + "tire", + "toll", + "tone", + "tool", + "toss", + "tour", + "town", + "trap", + "tray", + "tree", + "trim", + "trip", + "tube", + "tuck", + "tune", + "turn", + "twin", + "type", + "unit", + "upon", + "urge", + "used", + "user", + "vary", + "vast", + "very", + "view", + "vote", + "wage", + "wait", + "wake", + "walk", + "wall", + "want", + "warn", + "wash", + "wave", + "weak", + "wear", + "weed", + "week", + "well", + "west", + "what", + "when", + "whip", + "whom", + "wide", + "wink", + "wild", + "will", + "wind", + "wine", + "wing", + "wipe", + "wire", + "wise", + "wish", + "with", + "wolf", + "word", + "work", + "wrap", + "yard", + "yeah", + "year", + "yell", + "your", + "zone", + "true", + "about", + "above", + "actor", + "adapt", + "added", + "admit", + "adopt", + "after", + "again", + "agent", + "agree", + "ahead", + "aisle", + "alarm", + "album", + "alien", + "alike", + "alive", + "alley", + "allow", + "alone", + "along", + "alter", + "among", + "angle", + "ankle", + "apart", + "apple", + "apply", + "arena", + "argue", + "arise", + "armed", + "array", + "arrow", + "aside", + "asset", + "avoid", + "await", + "awake", + "award", + "aware", + "basic", + "beach", + "beast", + "begin", + "being", + "belly", + "below", + "bench", + "birth", + "blare", + "blade", + "bling", + "blank", + "blast", + "blend", + "bless", + "blind", + "blink", + "block", + "blond", + "blotter", + "board", + "boast", + "bonus", + "boost", + "booth", + "brain", + "brake", + "brand", + "brave", + "bread", + "break", + "brick", + "bride", + "brief", + "bring", + "broad", + "brood", + "brush", + "buddy", + "build", + "bunch", + "burst", + "buyer", + "cabin", + "cable", + "candy", + "cargo", + "carry", + "carve", + "catch", + "cause", + "cease", + "chain", + "chair", + "chaos", + "charm", + "chart", + "chase", + "cheat", + "check", + "cheek", + "cheer", + "chest", + "chief", + "child", + "chill", + "chunk", + "claim", + "class", + "clean", + "clear", + "clerk", + "click", + "cliff", + "climb", + "cling", + "clock", + "close", + "cloth", + "cloud", + "coach", + "coast", + "color", + "couch", + "could", + "count", + "court", + "cover", + "crave", + "craft", + "crash", + "crawl", + "crater", + "creek", + "crime", + "cross", + "crowd", + "crown", + "crush", + "curve", + "cycle", + "daily", + "dance", + "death", + "debut", + "delay", + "dense", + "depth", + "diary", + "dirty", + "donor", + "doubt", + "dough", + "dozen", + "draft", + "drain", + "drama", + "dream", + "dress", + "dried", + "drift", + "drill", + "drink", + "drive", + "drown", + "drunk", + "dying", + "eager", + "early", + "earth", + "salty", + "elbow", + "elder", + "elect", + "elite", + "empty", + "enact", + "enemy", + "enjoy", + "enter", + "entry", + "equal", + "equip", + "erase", + "essay", + "event", + "every", + "exact", + "exist", + "extra", + "faint", + "faith", + "fatal", + "fault", + "favor", + "fence", + "fever", + "fewer", + "fiber", + "field", + "fifth", + "fifty", + "fight", + "final", + "first", + "fixed", + "flame", + "flash", + "fleet", + "flesh", + "float", + "flood", + "floor", + "flour", + "fluid", + "focus", + "force", + "forth", + "forty", + "forum", + "found", + "frame", + "fraud", + "fresh", + "front", + "frown", + "fruit", + "fully", + "funny", + "genre", + "ghost", + "giant", + "given", + "glass", + "globe", + "glory", + "glove", + "grace", + "grade", + "grain", + "grand", + "grant", + "grape", + "grasp", + "grass", + "gravel", + "great", + "green", + "greet", + "grief", + "gross", + "group", + "guard", + "guess", + "guest", + "guide", + "guilt", + "habit", + "happy", + "harsh", + "heart", + "heavy", + "hello", + "hence", + "honey", + "honor", + "horse", + "hotel", + "house", + "human", + "humor", + "hurry", + "ideal", + "image", + "imply", + "index", + "inner", + "input", + "irony", + "issue", + "jeans", + "joint", + "judge", + "juice", + "juror", + "kneel", + "kayak", + "knock", + "known", + "label", + "labor", + "large", + "laser", + "later", + "laugh", + "layer", + "learn", + "least", + "leave", + "legal", + "lemon", + "level", + "light", + "limit", + "liver", + "lobby", + "local", + "logic", + "loose", + "lover", + "lower", + "loyal", + "lucky", + "lunch", + "magic", + "major", + "maker", + "march", + "match", + "maybe", + "mayor", + "medal", + "media", + "merit", + "metal", + "meter", + "midst", + "might", + "minor", + "mixed", + "model", + "month", + "moral", + "motor", + "mount", + "mouse", + "mouth", + "movie", + "music", + "naked", + "olive", + "cricket", + "nerve", + "never", + "jade", + "night", + "noise", + "north", + "novel", + "nurse", + "occur", + "ocean", + "offer", + "often", + "onion", + "opera", + "orbit", + "order", + "other", + "ought", + "outer", + "owner", + "paint", + "panel", + "panic", + "paper", + "party", + "pasta", + "patch", + "pause", + "phase", + "phone", + "photo", + "piano", + "piece", + "pilot", + "pitch", + "pizza", + "place", + "plain", + "plant", + "plate", + "plead", + "aim", + "porch", + "pound", + "power", + "press", + "price", + "pride", + "prime", + "print", + "prior", + "prize", + "proof", + "proud", + "prove", + "pulse", + "punch", + "purse", + "quest", + "quick", + "quiet", + "quite", + "quote", + "radar", + "radio", + "raise", + "rally", + "ranch", + "range", + "rapid", + "ratio", + "reach", + "react", + "ready", + "realm", + "rebel", + "refer", + "relax", + "reply", + "rider", + "ridge", + "rifle", + "right", + "risky", + "rival", + "river", + "robot", + "round", + "route", + "royal", + "rumor", + "rural", + "salad", + "sales", + "sauce", + "scale", + "scare", + "scene", + "scent", + "scope", + "score", + "screw", + "seize", + "sense", + "serve", + "seven", + "shade", + "shake", + "shall", + "shame", + "shape", + "share", + "shark", + "sharp", + "sheep", + "sheer", + "sheet", + "shelf", + "shell", + "shift", + "shirt", + "shock", + "shoot", + "shore", + "short", + "shout", + "shove", + "shrug", + "sight", + "silly", + "since", + "sixth", + "skill", + "skirt", + "skull", + "slave", + "sleep", + "slice", + "slide", + "slope", + "small", + "smart", + "smell", + "smile", + "smoke", + "snake", + "sneak", + "solar", + "solid", + "solve", + "sorry", + "sound", + "south", + "space", + "spare", + "spark", + "speak", + "speed", + "spell", + "spend", + "spill", + "spine", + "spite", + "split", + "spoon", + "sport", + "spray", + "squad", + "stack", + "staff", + "stage", + "stair", + "stake", + "stand", + "stare", + "start", + "state", + "steak", + "steam", + "steel", + "steep", + "steer", + "stick", + "stiff", + "still", + "stock", + "stone", + "store", + "storm", + "story", + "stove", + "straw", + "strip", + "study", + "stuff", + "style", + "sugar", + "suite", + "sunny", + "super", + "sweat", + "sweep", + "sweet", + "swell", + "swing", + "sword", + "table", + "taste", + "teach", + "thank", + "their", + "theme", + "there", + "these", + "thick", + "thigh", + "thing", + "think", + "third", + "those", + "three", + "throw", + "thumb", + "tight", + "tired", + "title", + "today", + "tooth", + "topic", + "total", + "touch", + "tough", + "towel", + "tower", + "trace", + "track", + "trade", + "trail", + "train", + "trait", + "treat", + "trend", + "trial", + "tribe", + "trick", + "troop", + "truck", + "truly", + "trunk", + "trust", + "truth", + "tumor", + "twice", + "twist", + "uncle", + "under", + "union", + "unite", + "unity", + "until", + "upper", + "upset", + "urban", + "usual", + "valid", + "value", + "video", + "virus", + "visit", + "vital", + "vocal", + "voice", + "voter", + "wagon", + "waist", + "waste", + "watch", + "water", + "weave", + "weigh", + "weird", + "whale", + "wheat", + "wheel", + "where", + "which", + "while", + "whoop", + "whole", + "whose", + "wider", + "worm", + "works", + "world", + "worry", + "worth", + "would", + "wound", + "wrist", + "write", + "wrong", + "yield", + "young", + "yours", + "youth", + "false", + "abroad", + "absorb", + "accent", + "accept", + "access", + "accuse", + "across", + "action", + "active", + "actual", + "adjust", + "admire", + "affect", + "afford", + "agency", + "agenda", + "almost", + "always", + "amount", + "animal", + "annual", + "answer", + "anyone", + "anyway", + "appear", + "around", + "arrest", + "arrive", + "artist", + "aspect", + "assert", + "assess", + "assign", + "assist", + "assume", + "assure", + "attach", + "attack", + "attend", + "author", + "ballot", + "banana", + "banker", + "barrel", + "basket", + "battle", + "beauty", + "become", + "before", + "behalf", + "behave", + "behind", + "belief", + "belong", + "beside", + "better", + "beyond", + "bitter", + "bloody", + "border", + "borrow", + "bottle", + "bounce", + "branch", + "breath", + "breeze", + "bridge", + "bright", + "broken", + "broker", + "bronze", + "brutal", + "bubble", + "bucket", + "bullet", + "bureau", + "butter", + "button", + "camera", + "campus", + "candle", + "canvas", + "carbon", + "career", + "carpet", + "carrot", + "casino", + "casual", + "cattle", + "center", + "change", + "charge", + "cheese", + "choice", + "choose", + "circle", + "client", + "clinic", + "closed", + "closet", + "coffee", + "collar", + "combat", + "comedy", + "commit", + "comply", + "cookie", + "corner", + "cotton", + "county", + "cousin", + "create", + "credit", + "crisis", + "cruise", + "custom", + "dancer", + "danger", + "deadly", + "dealer", + "debate", + "debris", + "decade", + "deeply", + "defeat", + "defend", + "define", + "degree", + "depart", + "depend", + "depict", + "deploy", + "deputy", + "derive", + "desert", + "design", + "desire", + "detail", + "detect", + "device", + "devote", + "differ", + "dining", + "dinner", + "direct", + "divide", + "doctor", + "domain", + "donate", + "double", + "drawer", + "driver", + "during", + "easily", + "eating", + "editor", + "effect", + "effort", + "either", + "eleven", + "emerge", + "empire", + "employ", + "enable", + "endure", + "energy", + "engage", + "engine", + "enough", + "enroll", + "ensure", + "entire", + "entity", + "equity", + "escape", + "estate", + "evolve", + "exceed", + "except", + "expand", + "expect", + "expert", + "export", + "expose", + "extend", + "extent", + "fabric", + "factor", + "fairly", + "family", + "famous", + "farmer", + "faster", + "father", + "fellow", + "fierce", + "figure", + "filter", + "fishy", + "finish", + "firmly", + "fiscal", + "flavor", + "flight", + "flower", + "flying", + "follow", + "forest", + "forget", + "formal", + "format", + "former", + "foster", + "fourth", + "freely", + "freeze", + "friend", + "frozen", + "future", + "galaxy", + "garage", + "garden", + "garlic", + "gather", + "gender", + "genius", + "gifted", + "glance", + "global", + "golden", + "ground", + "growth", + "guitar", + "handle", + "happen", + "hardly", + "hazard", + "health", + "heaven", + "height", + "hidden", + "highly", + "hockey", + "honest", + "hunger", + "hungry", + "hunter", + "ignore", + "immune", + "impact", + "import", + "impose", + "income", + "indeed", + "infant", + "inform", + "injure", + "injury", + "inmate", + "insect", + "inside", + "insist", + "intact", + "intend", + "intent", + "invent", + "invest", + "invite", + "island", + "itself", + "jacket", + "jungle", + "junior", + "ladder", + "lately", + "latter", + "launch", + "lawyer", + "leader", + "league", + "legacy", + "legend", + "length", + "lesson", + "letter", + "likely", + "liquid", + "listen", + "little", + "living", + "locate", + "lovely", + "mainly", + "makeup", + "manage", + "manual", + "marble", + "margin", + "marine", + "market", + "master", + "matter", + "medium", + "member", + "memory", + "mentor", + "merely", + "method", + "middle", + "minute", + "mirror", + "mobile", + "modern", + "modest", + "modify", + "moment", + "monkey", + "mostly", + "mother", + "motion", + "motive", + "museum", + "mutter", + "mutual", + "myself", + "narrow", + "nation", + "native", + "nature", + "nearby", + "nearly", + "needle", + "nobody", + "normal", + "notice", + "notion", + "number", + "object", + "obtain", + "occupy", + "office", + "online", + "oppose", + "option", + "orange", + "origin", + "others", + "outfit", + "outlet", + "output", + "oxygen", + "palace", + "parade", + "parent", + "parish", + "partly", + "patent", + "patrol", + "patron", + "pencil", + "people", + "pepper", + "period", + "permit", + "person", + "phrase", + "pickup", + "pillow", + "planet", + "player", + "please", + "plenty", + "plunge", + "pocket", + "poetry", + "policy", + "poster", + "potato", + "powder", + "prefer", + "pretty", + "priest", + "profit", + "prompt", + "proper", + "public", + "purple", + "pursue", + "puzzle", + "rabbit", + "random", + "rarely", + "rather", + "rating", + "reader", + "really", + "reason", + "recall", + "recent", + "recipe", + "record", + "reduce", + "reform", + "refuse", + "regain", + "regard", + "regime", + "region", + "reject", + "relate", + "relief", + "remain", + "remark", + "remind", + "remote", + "remove", + "rental", + "repair", + "repeat", + "report", + "rescue", + "resign", + "resist", + "resort", + "result", + "resume", + "retail", + "retain", + "retire", + "return", + "reveal", + "review", + "reward", + "rhythm", + "ribbon", + "ritual", + "rocket", + "rubber", + "ruling", + "runner", + "safely", + "safety", + "salary", + "salmon", + "sample", + "saving", + "scared", + "scheme", + "school", + "scream", + "screen", + "script", + "search", + "season", + "second", + "secret", + "sector", + "secure", + "seldom", + "select", + "seller", + "senior", + "sensor", + "series", + "settle", + "severe", + "shadow", + "shorts", + "should", + "shrimp", + "signal", + "silent", + "silver", + "simple", + "simply", + "singer", + "single", + "sister", + "sleeve", + "slight", + "slowly", + "smooth", + "soccer", + "social", + "sodium", + "soften", + "softly", + "solely", + "source", + "speech", + "sphere", + "spirit", + "spread", + "spring", + "square", + "stable", + "stance", + "statue", + "status", + "steady", + "strain", + "streak", + "stream", + "street", + "stress", + "strict", + "strike", + "string", + "stroke", + "strong", + "studio", + "stupid", + "submit", + "subtle", + "suburb", + "sudden", + "suffer", + "summer", + "summit", + "supply", + "surely", + "survey", + "switch", + "symbol", + "system", + "tackle", + "tactic", + "talent", + "target", + "temple", + "tender", + "tennis", + "thanks", + "theory", + "thirty", + "though", + "thread", + "thrive", + "throat", + "ticket", + "timber", + "timing", + "tissue", + "toilet", + "tomato", + "tonic", + "toward", + "tragic", + "trauma", + "travel", + "treaty", + "tribal", + "tunnel", + "turkey", + "twelve", + "twenty", + "unfair", + "unfold", + "unique", + "unless", + "unlike", + "update", + "useful", + "vacuum", + "valley", + "vanish", + "vendor", + "verbal", + "versus", + "vessel", + "viewer", + "virtue", + "vision", + "visual", + "volume", + "voting", + "wander", + "warmth", + "wealth", + "weapon", + "weekly", + "weight", + "widely", + "window", + "winner", + "winter", + "wisdom", + "within", + "wonder", + "wooden", + "worker", + "writer", + "yellow", +} + +// end diff --git a/note-go/note/words_test.go b/note-go/note/words_test.go new file mode 100644 index 0000000..202f685 --- /dev/null +++ b/note-go/note/words_test.go @@ -0,0 +1,19 @@ +package note + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWordsFromString(t *testing.T) { + cases := map[string]string{ + "dev:foobar": "near-eat-read", + "dev:123456778": "farm-quiet-dumb", + "dev:qwerty": "flour-water-stock", + } + + for k, v := range cases { + require.Equal(t, v, WordsFromString(k)) + } +} diff --git a/note-go/notecard/cobs.go b/note-go/notecard/cobs.go new file mode 100644 index 0000000..0b203a6 --- /dev/null +++ b/note-go/notecard/cobs.go @@ -0,0 +1,81 @@ +// Copyright 2023 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +// Decode with optional XOR +func CobsDecode(input []byte, xor byte) ([]byte, error) { + output := make([]byte, len(input)) + length := len(output) + inOffset := 0 + outOffset := inOffset + startOffset, endOffset := outOffset, inOffset+length + var code, copy uint8 = 0xFF, 0 + for ; inOffset < endOffset; copy-- { + if copy != 0 { + output[outOffset] = input[inOffset] ^ xor + outOffset, inOffset = outOffset+1, inOffset+1 + } else { + if code != 0xFF { + output[outOffset] = 0 + outOffset = outOffset + 1 + } + code = input[inOffset] ^ xor + copy, inOffset = code, inOffset+1 + if code == 0 { + break + } + } + } + return output[startOffset:outOffset], nil +} + +// Get the maximum size of the cobs-encoded buffer +func CobsEncodedLength(length int) int { + return length + (1 + (length / 254)) +} + +// Encode with optional XOR +func CobsEncode(input []byte, xor byte) ([]byte, error) { + length := len(input) + inOffset := 0 + // Allocate with +1 capacity so append(result, '\n') won't reallocate + maxLen := CobsEncodedLength(len(input)) + output := make([]byte, maxLen, maxLen+1) + outOffset := 0 + outStartOffset := outOffset + var ch, code uint8 + code = 1 + outCodeOffset := outOffset + outOffset = outOffset + 1 + for length > 0 { + ch = input[inOffset] + inOffset = inOffset + 1 + length = length - 1 + if ch != 0 { + output[outOffset] = ch ^ xor + outOffset = outOffset + 1 + code = code + 1 + } + if ch == 0 || code == 0xFF { + output[outCodeOffset] = code ^ xor + code = 1 + outCodeOffset = outOffset + outOffset = outOffset + 1 + } + } + output[outCodeOffset] = code ^ xor + return output[outStartOffset:outOffset], nil +} + +// CobsEncodeAppend encodes data and appends a delimiter in one operation. +// This avoids the reallocation that would occur with append(CobsEncode(...), delim). +func CobsEncodeAppend(input []byte, xor byte, delimiter byte) ([]byte, error) { + encoded, err := CobsEncode(input, xor) + if err != nil { + return nil, err + } + // Since CobsEncode allocates with +1 capacity, this append won't reallocate + return append(encoded, delimiter), nil +} diff --git a/note-go/notecard/cobs_test.go b/note-go/notecard/cobs_test.go new file mode 100644 index 0000000..f085a3f --- /dev/null +++ b/note-go/notecard/cobs_test.go @@ -0,0 +1,248 @@ +package notecard + +import ( + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCob(t *testing.T) { + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + min := 100 + max := 1000 + len := rng.Intn(max-min+1) + min + buf := make([]byte, len) + xor := byte(rng.Int()) + + _, err := rng.Read(buf) + require.NoError(t, err) + + encoded, err := CobsEncode(buf, xor) + require.NoError(t, err) + + decoded, err := CobsDecode(encoded, xor) + require.NoError(t, err) + + require.Equal(t, buf, decoded) +} + +func TestCobsEdgeCases(t *testing.T) { + tests := []struct { + name string + input []byte + xor byte + }{ + {"empty", []byte{}, 0}, + {"single zero", []byte{0}, 0}, + {"single nonzero", []byte{1}, 0}, + {"two zeros", []byte{0, 0}, 0}, + {"trailing zero", []byte{1, 2, 0}, 0}, + {"leading zero", []byte{0, 1, 2}, 0}, + {"middle zero", []byte{1, 0, 2}, 0}, + {"no zeros", []byte{1, 2, 3}, 0}, + {"all zeros 3", []byte{0, 0, 0}, 0}, + {"with xor", []byte{1, 2, 0, 3}, '\n'}, + {"254 bytes no zero", make254NonZero(), 0}, + {"255 bytes no zero", make255NonZero(), 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := CobsEncode(tt.input, tt.xor) + require.NoError(t, err, "encode failed") + + decoded, err := CobsDecode(encoded, tt.xor) + require.NoError(t, err, "decode failed") + + require.Equal(t, tt.input, decoded, "roundtrip failed: encoded=%v", encoded) + }) + } +} + +func make254NonZero() []byte { + b := make([]byte, 254) + for i := range b { + b[i] = byte(i%255) + 1 + } + return b +} + +func make255NonZero() []byte { + b := make([]byte, 255) + for i := range b { + b[i] = byte(i%255) + 1 + } + return b +} + +// TestCobsKnownValues tests encoding against known expected output values. +// These are the canonical COBS encodings per the specification. +// This catches regressions where encode/decode are broken in compatible but wrong ways. +func TestCobsKnownValues(t *testing.T) { + tests := []struct { + name string + input []byte + xor byte + expected []byte + }{ + // Standard COBS test vectors (xor=0) + { + name: "single zero", + input: []byte{0x00}, + xor: 0, + expected: []byte{0x01, 0x01}, + }, + { + name: "single nonzero", + input: []byte{0x01}, + xor: 0, + expected: []byte{0x02, 0x01}, + }, + { + name: "two zeros", + input: []byte{0x00, 0x00}, + xor: 0, + expected: []byte{0x01, 0x01, 0x01}, + }, + { + name: "three nonzero bytes", + input: []byte{0x01, 0x02, 0x03}, + xor: 0, + expected: []byte{0x04, 0x01, 0x02, 0x03}, + }, + { + name: "zero in middle", + input: []byte{0x01, 0x00, 0x02}, + xor: 0, + expected: []byte{0x02, 0x01, 0x02, 0x02}, + }, + { + name: "leading zero", + input: []byte{0x00, 0x01, 0x02}, + xor: 0, + expected: []byte{0x01, 0x03, 0x01, 0x02}, + }, + { + name: "trailing zero", + input: []byte{0x01, 0x02, 0x00}, + xor: 0, + expected: []byte{0x03, 0x01, 0x02, 0x01}, + }, + { + name: "Hello", + input: []byte{'H', 'e', 'l', 'l', 'o'}, + xor: 0, + expected: []byte{0x06, 'H', 'e', 'l', 'l', 'o'}, + }, + } + + for _, tt := range tests { + t.Run(tt.name+" encode", func(t *testing.T) { + encoded, err := CobsEncode(tt.input, tt.xor) + require.NoError(t, err) + require.Equal(t, tt.expected, encoded, "encoded output mismatch") + }) + + t.Run(tt.name+" decode", func(t *testing.T) { + decoded, err := CobsDecode(tt.expected, tt.xor) + require.NoError(t, err) + require.Equal(t, tt.input, decoded, "decoded output mismatch") + }) + } +} + +// TestCobsXORRoundtrip tests that XOR mode (used to eliminate newlines) roundtrips correctly. +// XOR mode is Blues-specific; there's no external standard, so we only test roundtrip. +func TestCobsXORRoundtrip(t *testing.T) { + xor := byte('\n') // 0x0A - what note-c/notecard uses + + tests := []struct { + name string + input []byte + }{ + {"single zero", []byte{0x00}}, + {"single nonzero", []byte{0x01}}, + {"contains newline", []byte{0x01, '\n', 0x02}}, + {"multiple newlines", []byte{'\n', 0x01, '\n', '\n', 0x02, '\n'}}, + {"all newlines", []byte{'\n', '\n', '\n'}}, + {"binary with newlines", func() []byte { + b := make([]byte, 256) + for i := range b { + b[i] = byte(i) + } + return b + }()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encoded, err := CobsEncode(tt.input, xor) + require.NoError(t, err) + + // Verify no newlines in encoded output (the whole point of XOR mode) + for i, b := range encoded { + require.NotEqual(t, byte('\n'), b, "found newline at position %d in encoded output", i) + } + + decoded, err := CobsDecode(encoded, xor) + require.NoError(t, err) + require.Equal(t, tt.input, decoded, "roundtrip failed") + }) + } +} + +// TestCobs254ByteBoundary tests the critical 254-byte boundary where COBS +// must insert an extra code byte. This is a common source of bugs. +func TestCobs254ByteBoundary(t *testing.T) { + // 254 non-zero bytes: [0xFF, 254 data bytes, 0x01] + // The 0xFF means "254 data bytes follow, no implicit zero after" + // The trailing 0x01 terminates the stream (0 more data bytes) + data254 := make([]byte, 254) + for i := range data254 { + data254[i] = byte(i) + 1 // 1, 2, 3, ..., 254 + } + + encoded254, err := CobsEncode(data254, 0) + require.NoError(t, err) + require.Len(t, encoded254, 256, "254 non-zero bytes encode to 256 bytes") + require.Equal(t, byte(0xFF), encoded254[0], "first code byte should be 0xFF") + require.Equal(t, byte(0x01), encoded254[255], "trailing code byte should be 0x01") + + decoded254, err := CobsDecode(encoded254, 0) + require.NoError(t, err) + require.Equal(t, data254, decoded254) + + // 255 non-zero bytes: [0xFF, 254 data bytes, 0x02, 1 data byte] + data255 := make([]byte, 255) + for i := range data255 { + data255[i] = byte(i) + 1 + } + data255[254] = 1 // Last byte wraps to 1 + + encoded255, err := CobsEncode(data255, 0) + require.NoError(t, err) + require.Len(t, encoded255, 257, "255 non-zero bytes encode to 257 bytes") + require.Equal(t, byte(0xFF), encoded255[0], "first code byte should be 0xFF") + require.Equal(t, byte(0x02), encoded255[255], "second code byte should be 0x02") + + decoded255, err := CobsDecode(encoded255, 0) + require.NoError(t, err) + require.Equal(t, data255, decoded255) + + // 253 non-zero bytes: [0xFE, 253 data bytes] - no extra code byte needed + data253 := make([]byte, 253) + for i := range data253 { + data253[i] = byte(i) + 1 + } + + encoded253, err := CobsEncode(data253, 0) + require.NoError(t, err) + require.Len(t, encoded253, 254, "253 non-zero bytes encode to 254 bytes") + require.Equal(t, byte(0xFE), encoded253[0], "code byte should be 0xFE for 253 data bytes") + + decoded253, err := CobsDecode(encoded253, 0) + require.NoError(t, err) + require.Equal(t, data253, decoded253) +} diff --git a/note-go/notecard/i2c-unix.go b/note-go/notecard/i2c-unix.go new file mode 100644 index 0000000..790eed8 --- /dev/null +++ b/note-go/notecard/i2c-unix.go @@ -0,0 +1,180 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. +// Forked from github.com/d2r2/go-i2c +// Forked from github.com/davecheney/i2c + +//go:build !windows + +// Before usage you must load the i2c-dev kernel module. +// Each i2c bus can address 127 independent i2c devices, and most +// linux systems contain several buses. + +// Note: I2C Device Interface is accessed through periph.io library +// Example: https://github.com/google/periph/blob/master/devices/bmxx80/bmx280.go + +package notecard + +import ( + "fmt" + "sync" + "time" + + "periph.io/x/conn/v3/driver/driverreg" + "periph.io/x/conn/v3/i2c" + "periph.io/x/conn/v3/i2c/i2creg" + "periph.io/x/host/v3" +) + +const ( + // I2CSlave is the slave device address + I2CSlave = 0x0703 +) + +// I2C is the handle to the I2C subsystem +type I2C struct { + host *driverreg.State + bus i2c.BusCloser + device *i2c.Dev +} + +// The open I2C port +var ( + hostInitialized bool + openI2CPort *I2C + i2cLock sync.RWMutex +) + +// Our default I2C address +const notecardDefaultI2CAddress = 0x17 + +// Get the default i2c device +func i2cDefault() (port string, portConfig int) { + port = "" // Null string opens first available bus + portConfig = notecardDefaultI2CAddress + return +} + +// Open the i2c port +func i2cOpen(port string, portConfig int) (err error) { + // Open the periph.io host + if !hostInitialized { + openI2CPort = &I2C{} + openI2CPort.host, err = host.Init() + if err != nil { + return + } + } + + // Open the I2C instance + i2cLock.Lock() + openI2CPort.bus, err = i2creg.Open(port) + i2cLock.Unlock() + if err != nil { + return + } + + return nil +} + +// WriteBytes writes a buffer to I2C +func i2cWriteBytes(buf []byte, i2cAddr int) (err error) { + if i2cAddr == 0 { + i2cAddr = notecardDefaultI2CAddress + } + time.Sleep(1 * time.Millisecond) // By design, must not send more than once every 1Ms + + // Single allocation for header + payload (avoids make + append pattern) + reg := make([]byte, 1+len(buf)) + reg[0] = byte(len(buf)) + copy(reg[1:], buf) + + i2cLock.Lock() + openI2CPort.device = &i2c.Dev{Bus: openI2CPort.bus, Addr: uint16(i2cAddr)} + err = openI2CPort.device.Tx(reg, nil) + i2cLock.Unlock() + if err != nil { + err = fmt.Errorf("wb: %s", err) + } + return +} + +// ReadBytes reads a buffer from I2C and returns how many are still pending +func i2cReadBytes(datalen int, i2cAddr int) (outbuf []byte, available int, err error) { + if i2cAddr == 0 { + i2cAddr = notecardDefaultI2CAddress + } + time.Sleep(1 * time.Millisecond) // By design, must not send more than once every 1Ms + readbuf := make([]byte, datalen+2) + + // Pre-allocate register buffer once outside retry loop + reg := [2]byte{0, byte(datalen)} + + for i := 0; ; i++ { // Retry just for robustness + i2cLock.Lock() + openI2CPort.device = &i2c.Dev{Bus: openI2CPort.bus, Addr: uint16(i2cAddr)} + err = openI2CPort.device.Tx(reg[:], readbuf) + i2cLock.Unlock() + if err == nil { + break + } + if i >= 10 { + err = fmt.Errorf("rb: %s", err) + return + } + time.Sleep(2 * time.Millisecond) + } + if len(readbuf) < 2 { + err = fmt.Errorf("rb: not enough data (%d < 2)", len(readbuf)) + return + } + available = int(readbuf[0]) + if available > 253 { + err = fmt.Errorf("rb: available too large (%d >253)", available) + return + } + good := readbuf[1] + if len(readbuf) < int(2+good) { + err = fmt.Errorf("rb: insufficient data (%d < %d)", len(readbuf), 2+good) + return + } + if 2 > 2+good { + if false { + fmt.Printf("i2cReadBytes(%d): %v\n", datalen, readbuf) + } + err = fmt.Errorf("rb: %d bytes returned while expecting %d", good, datalen) + return + } + outbuf = readbuf[2 : 2+good] + return +} + +// Close I2C +func i2cClose() (err error) { + i2cLock.Lock() + err = openI2CPort.bus.Close() + i2cLock.Unlock() + return +} + +// Enum I2C ports +func i2cPortEnum() (allports []string, usbports []string, notecardports []string, err error) { + // Open the periph.io host + if !hostInitialized { + openI2CPort = &I2C{} + openI2CPort.host, err = host.Init() + if err != nil { + return + } + } + + // Enum + for _, ref := range i2creg.All() { + port := ref.Name + if ref.Number != -1 { + allports = append(allports, port) + notecardports = append(notecardports, port) + } + } + return +} diff --git a/note-go/notecard/i2c-windows.go b/note-go/notecard/i2c-windows.go new file mode 100644 index 0000000..3cea9dc --- /dev/null +++ b/note-go/notecard/i2c-windows.go @@ -0,0 +1,49 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +//go:build windows + +package notecard + +import ( + "fmt" +) + +// Get the default i2c device +func i2cDefault() (port string, portConfig int) { + port = "???" + portConfig = 0x17 + return +} + +// Set the port config of the open port +func i2cSetConfig(portConfig int) (err error) { + return fmt.Errorf("i2c not yet implemented") +} + +// Open the i2c port +func i2cOpen(port string, portConfig int) (err error) { + return fmt.Errorf("i2c not yet implemented") +} + +// WriteBytes writes a buffer to I2C +func i2cWriteBytes(buf []byte, i2cAddr int) (err error) { + return fmt.Errorf("i2c not yet implemented") +} + +// ReadBytes reads a buffer from I2C and returns how many are still pending +func i2cReadBytes(datalen int, i2cAddr int) (outbuf []byte, available int, err error) { + err = fmt.Errorf("i2c not yet implemented") + return +} + +// Close I2C +func i2cClose() error { + return fmt.Errorf("i2c not yet implemented") +} + +// Enum I2C ports +func i2cPortEnum() (allports []string, usbports []string, notecardports []string, err error) { + return +} diff --git a/note-go/notecard/lease.go b/note-go/notecard/lease.go new file mode 100644 index 0000000..9887d47 --- /dev/null +++ b/note-go/notecard/lease.go @@ -0,0 +1,196 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/blues/note-go/note" +) + +// Leaseing service parameters +const leaseTransactionService = "https://notepod.io:8123" +const leaseTraceService = "proxy.notepod.io:123" + +// Lease transaction +type LeaseTransaction struct { + Request string `json:"req,omitempty"` + Lessor string `json:"lessor,omitempty"` + Scope string `json:"scope,omitempty"` + Expires int64 `json:"expires,omitempty"` + Error string `json:"err,omitempty"` + DeviceUID string `json:"device,omitempty"` + NoResponse bool `json:"no_response,omitempty"` + ReqJSON string `json:"request_json,omitempty"` + RspJSON string `json:"response_json,omitempty"` +} + +// Request types +const ( + ReqReserve = "reserve" + ReqTransaction = "transaction" +) + +// Perform an HTTP transaction to the lease service +func leaseService(req LeaseTransaction, promoteError bool) (rsp LeaseTransaction, err error) { + + reqj, err := json.Marshal(req) + if err != nil { + return rsp, err + } + + // Send the transaction + hreq, err := http.NewRequest("POST", leaseTransactionService, bytes.NewBuffer(reqj)) + if err != nil { + return rsp, fmt.Errorf("%s %s", err, note.ErrCardIo) + } + hcli := &http.Client{Timeout: time.Second * 90} + hrsp, err := hcli.Do(hreq) + if err != nil { + return rsp, fmt.Errorf("%s %s", err, note.ErrCardIo) + } + defer hrsp.Body.Close() + + // Read the response + var rspjb bytes.Buffer + _, err = io.Copy(&rspjb, hrsp.Body) + if err != nil { + return rsp, fmt.Errorf("%s %s", err, note.ErrCardIo) + } + rspj := rspjb.Bytes() + + err = note.JSONUnmarshal(rspj, &rsp) + if err != nil { + return rsp, fmt.Errorf("%s %s", err, note.ErrCardIo) + } + + if promoteError && rsp.Error != "" { + return rsp, fmt.Errorf("%s", rsp.Error) + } + + return rsp, nil + +} + +// Open or reopen the remote card by taking out a lease, or by renewing the lease. +func leaseReopen(context *Context, portConfig int) (err error) { + + context.portIsOpen = false + + // Don't reopen if tracing + if InitialTraceMode { + context.portIsOpen = true + return + } + + // Find out our unique ID + context.leaseLessor = callerID() + + // Perform the lease transaction + req := LeaseTransaction{} + req.Request = ReqReserve + req.Lessor = context.leaseLessor + req.Scope = context.leaseScope + req.Expires = context.leaseExpires + rsp, err := leaseService(req, true) + if err != nil { + return err + } + + // Trace so that we can find out when + if context.leaseExpires == 0 { + fmt.Printf("%s reserved until %s\n", rsp.DeviceUID, time.Unix(rsp.Expires, 0).Local().Format("03:04:05 PM MST")) + } + + // Save the deviceUID to the allocated device + context.leaseScope = rsp.Scope + context.leaseExpires = rsp.Expires + context.leaseDeviceUID = rsp.DeviceUID + + // Open + context.portIsOpen = true + + return +} + +// Close a remote notecard +func leaseClose(context *Context) { + context.portIsOpen = false +} + +// Perform a remote transaction +func leaseTransaction(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) { + + // Perform the lease transaction + req := LeaseTransaction{} + req.Request = ReqTransaction + req.Lessor = context.leaseLessor + req.DeviceUID = context.leaseDeviceUID + req.ReqJSON = string(reqJSON) + req.NoResponse = noResponse + rsp, err := leaseService(req, true) + if err != nil { + return rspJSON, err + } + + // Done + return []byte(rsp.RspJSON), nil + +} + +// Lease trace open +func leaseTraceOpen(context *Context) (err error) { + + // Scope must be a specific device UID for trace + if !strings.HasPrefix(context.port, "dev:") { + return fmt.Errorf("trace is only allowed when a deviceUID is specified") + } + + // Open the service connection + tcpServer, err := net.ResolveTCPAddr("tcp", leaseTraceService) + if err != nil { + return + } + context.leaseTraceConn, err = net.DialTCP("tcp", nil, tcpServer) + if err != nil { + return + } + + // Write an initial non-json line containing scope, to signal to the service that this is a trace connection + leaseTraceWrite(context, []byte(context.port+"\n")) + + // Done + return + +} + +// Lease trace read function +func leaseTraceRead(context *Context) (data []byte, err error) { + + buf := make([]byte, 2048) + length, err := context.leaseTraceConn.Read(buf) + if err != nil { + if err == io.EOF { + // Just a read timeout + return data, nil + } + return data, err + } + + return buf[:length], nil + +} + +// Lease trace write function +func leaseTraceWrite(context *Context, data []byte) { + context.leaseTraceConn.Write(data) +} diff --git a/note-go/notecard/net.go b/note-go/notecard/net.go new file mode 100644 index 0000000..deca28e --- /dev/null +++ b/note-go/notecard/net.go @@ -0,0 +1,82 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +const ( + NetworkBearerUnknown = -1 + NetworkBearerGsm = 0 + NetworkBearerTdScdma = 1 + NetworkBearerWcdma = 2 + NetworkBearerCdma2000 = 3 + NetworkBearerWiMax = 4 + NetworkBearerLteTdd = 5 + NetworkBearerLteFdd = 6 + NetworkBearerNBIot = 7 + NetworkBearerWLan = 21 + NetworkBearerBluetooth = 22 + NetworkBearerIeee802p15p4 = 23 + NetworkBearerEthernet = 41 + NetworkBearerDsl = 42 + NetworkBearerPlc = 43 +) + +// NetInfo is the composite structure with all networking connection info +type NetInfo struct { + Iccid string `json:"iccid,omitempty"` + Iccid2 string `json:"iccid2,omitempty"` + IccidExternal string `json:"iccid_external,omitempty"` + Imsi string `json:"imsi,omitempty"` + Imsi2 string `json:"imsi2,omitempty"` + ImsiExternal string `json:"imsi_external,omitempty"` + Imei string `json:"imei,omitempty"` + ModemFirmware string `json:"modem,omitempty"` + Band string `json:"band,omitempty"` + AccessTechnology string `json:"rat,omitempty"` + AccessTechnologyFilter string `json:"ratf,omitempty"` + ReportedAccessTechnology string `json:"ratr,omitempty"` + ReportedCarrier string `json:"carrier,omitempty"` + Bssid string `json:"bssid,omitempty"` + Ssid string `json:"ssid,omitempty"` + // Internal vs external SIM used at any given moment + InternalSIMSelected bool `json:"internal,omitempty"` + // Radio signal strength in dBm, or ModemValueUnknown if it is not + // available from the modem. + RssiRange int32 `json:"rssir,omitempty"` + // GSM RxQual, or ModemValueUnknown if it is not available from the modem. + Rxqual int32 `json:"rxqual,omitempty"` + // General received signal strength, in dBm + Rssi int32 `json:"rssi,omitempty"` + // An integer indicating the reference signal received power (RSRP) + Rsrp int32 `json:"rsrp,omitempty"` + // An integer indicating the signal to interference plus noise ratio (SINR). + // Logarithmic value of SINR. Values are in 1/5th of a dB. The range is 0-250 + // which translates to -20dB - +30dB + Sinr int32 `json:"sinr,omitempty"` + // An integer indicating the reference signal received quality (RSRQ) + Rsrq int32 `json:"rsrq,omitempty"` + // An integer indicating relative signal strength in a human-readable way + Bars uint32 `json:"bars,omitempty"` + // IP address assigned to the device + IP string `json:"ip,omitempty"` + // IP address that the device is talking to (if known) + Gw string `json:"gateway,omitempty"` + // Device APN name + Apn string `json:"apn,omitempty"` + // Location area code (16 bits) or ModemValueUnknown if it is not avail from modem + Lac uint32 `json:"lac,omitempty"` + // Cell ID (28 bits) or ModemValueUnknown if it is not available from the modem. + Cellid uint32 `json:"cid,omitempty"` + // Network info + NetworkBearer int32 `json:"bearer,omitempty"` + Mcc uint32 `json:"mcc,omitempty"` + Mnc uint32 `json:"mnc,omitempty"` + // Modem debug + ModemDebugEvents int32 `json:"modem_test_events,omitempty"` + // Overcurrent events + OvercurrentEvents int32 `json:"oc_events,omitempty"` + OvercurrentEventSecs int32 `json:"oc_event_time,omitempty"` + // When the signal strength fields were last updated + Modified int64 `json:"updated,omitempty"` +} diff --git a/note-go/notecard/notecard.go b/note-go/notecard/notecard.go new file mode 100644 index 0000000..b263e4f --- /dev/null +++ b/note-go/notecard/notecard.go @@ -0,0 +1,1658 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "bytes" + "encoding/json" + "fmt" + "hash/crc32" + "io" + "net" + "os" + "os/user" + "strconv" + "strings" + "sync" + "time" + + "github.com/blues/note-go/note" + "go.bug.st/serial" +) + +// Debug serial I/O +var debugSerialIO = false + +// InitialDebugMode is the debug mode that the context is initialized with +var InitialDebugMode = false + +// InitialTraceMode is whether or not we will be entering trace mode, to prevent reservationsa +var InitialTraceMode = false + +// InitialResetMode says whether or not we should reset the port on entry +var InitialResetMode = true + +// Protect against multiple concurrent callers, because across different operating systems it is +// not at all clear that concurrency is allowed on a single I/O device. An exception is made +// for the I2C 'multiport' case (exposed by TransactionRequestToPort) where we allow multiple +// concurrent I2C transactions on a single device. (This capability was needed for the +// Notefarm, but it's unclear if anyone uses this multi-notecard concurrency capability anymore +// now that it's deprecated.) +var ( + transLock sync.RWMutex + multiportTransLock [128]sync.RWMutex +) + +// Buffer pool for serial read operations to reduce GC pressure +var serialReadBufPool = sync.Pool{ + New: func() interface{} { + buf := make([]byte, 2048) + return &buf + }, +} + +// Default transaction timeout (before receiving anything from the notecard) +const transactionTimeoutMsDefault = 30000 + +// IgnoreWindowsHWErrSecs is the amount of time to ignore a Windows serial communiction error. +var IgnoreWindowsHWErrSecs = 2 + +// Module communication interfaces +const ( + NotecardInterfaceSerial = "serial" + NotecardInterfaceI2C = "i2c" + NotecardInterfaceLease = "lease" +) + +// The number of minutes that we'll round up so that notecard reservations don't thrash +const reservationModulusMinutes = 5 + +// CardI2CMax controls chunk size that's socially appropriate on the I2C bus. +// It must be 1-253 bytes as per spec (which allows space for the 2-byte header in a 255-byte read) +const CardI2CMax = 253 + +// The notecard is a real-time device that has a fixed size interrupt buffer. We can push data +// at it far, far faster than it can process it, therefore we push it in segments with a pause +// between each segment. + +// CardRequestSerialSegmentMaxLen (golint) +const CardRequestSerialSegmentMaxLen = 250 + +// CardRequestSerialSegmentDelayMs (golint) +const CardRequestSerialSegmentDelayMs = 250 + +// CardRequestI2CSegmentMaxLen (golint) +const CardRequestI2CSegmentMaxLen = 250 + +// CardRequestI2CSegmentDelayMs (golint) +const CardRequestI2CSegmentDelayMs = 250 + +// RequestSegmentMaxLen (golint) +var RequestSegmentMaxLen = -1 + +// RequestSegmentDelayMs (golint) +var RequestSegmentDelayMs = -1 + +var DoNotReterminateJSON = false + +// Transaction retry logic +const requestRetriesAllowed = 5 + +// IoErrorIsRecoverable is a configuration parameter describing library capabilities. +// Set this to true if the error recovery of the implementation supports re-open. On all implementations +// tested to date, I can't yet get the close/reopen working the way it does on microcontrollers. For +// example, on the go serial, I get a nil pointer dereference within the go library. This MAY have +// soemthing to do with the fact that we don't cleanly implement the shutdown/restart of the inputHandler +// in trace, in which case that should be fixed. In the meantime, this is disabled. +const IoErrorIsRecoverable = true + +// Context for the port that is open +type Context struct { + // True to emit trace output + Debug bool + + // Pretty-print trace output JSON + Pretty bool + + // Disable generation of User Agent object + DisableUA bool + + // Reset should be done on next transaction + resetRequired bool + reopenRequired bool + reopenBecauseOfOpen bool + + // Sequence number + lastRequestSeqno int + + // Class functions + PortEnumFn func() (allports []string, usbports []string, notecardports []string, err error) + PortDefaultsFn func() (port string, portConfig int) + CloseFn func(context *Context) + ReopenFn func(context *Context, portConfig int) (err error) + ResetFn func(context *Context, portConfig int) (err error) + TransactionFn func(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) + + // Transaction timeout (0 for default) + transactionTimeoutMs int + + // User-specified heartbeat function + HeartbeatCtx interface{} + HeartbeatFn func(context *Context, userCtx interface{}, response []byte) bool + + // Trace functions + traceOpenFn func(context *Context) (err error) + traceReadFn func(context *Context) (data []byte, err error) + traceWriteFn func(context *Context, data []byte) + + // Port data + iface string + isLocal bool + port string + portConfig int + portIsOpen bool + + // Serial instance state + isSerial bool + serialPort serial.Port + serialUseDefault bool + serialName string + serialConfig serial.Mode + + // Serial I/O timeout helpers + ioStartSignal chan int + ioCompleteSignal chan bool + ioTimeoutSignal chan bool + + // I2C + i2cMultiport bool + + // Lease state + leaseScope string + leaseExpires int64 + leaseLessor string + leaseDeviceUID string + leaseTraceConn net.Conn +} + +// Report a critical card error +func cardReportError(context *Context, err error) { + if context == nil { + return + } + if context.Debug { + fmt.Printf("*** %s\n", err) + } + if IoErrorIsRecoverable { + time.Sleep(500 * time.Millisecond) + context.reopenRequired = true + } +} + +// Set the transaction function +func (context *Context) GetTransactionTimeoutMs() int { + if context.transactionTimeoutMs == 0 { + return transactionTimeoutMsDefault + } + return context.transactionTimeoutMs +} + +// Set the request timeout (0 to restore for default) +func (context *Context) SetTransactionTimeoutMs(msec int) { + context.transactionTimeoutMs = msec +} + +// Set or clear the heartbeat function +func (context *Context) SetTransactionHeartbeatFn(userFn func(context *Context, userCtx interface{}, rsp []byte) bool, userCtx interface{}) { + context.HeartbeatFn = userFn + context.HeartbeatCtx = userCtx +} + +// DebugOutput enables/disables debug output +func (context *Context) DebugOutput(enabled bool, pretty bool) { + context.Debug = enabled + context.Pretty = pretty +} + +// EnumPorts returns the list of all available ports on the specified interface +func (context *Context) EnumPorts() (allports []string, usbports []string, notecardports []string, err error) { + if context.PortEnumFn == nil { + return + } + return context.PortEnumFn() +} + +// PortDefaults gets the defaults for the specified port +func (context *Context) PortDefaults() (port string, portConfig int) { + if context.PortDefaultsFn == nil { + return + } + return context.PortDefaultsFn() +} + +// Identify this Notecard connection +func (context *Context) Identify() (protocol string, port string, portConfig int) { + if context.isSerial { + return "serial", context.serialName, context.serialConfig.BaudRate + } + return "I2C", context.port, context.portConfig +} + +// Defaults gets the default interface, port, and config +func Defaults() (moduleInterface string, port string, portConfig int) { + moduleInterface = NotecardInterfaceSerial + port, portConfig = serialDefault() + return +} + +// Open the card to establish communications +func Open(moduleInterface string, port string, portConfig int) (context *Context, err error) { + if moduleInterface == "" { + moduleInterface, _, _ = Defaults() + } + + switch moduleInterface { + case NotecardInterfaceSerial: + context, err = OpenSerial(port, portConfig) + context.isLocal = true + case NotecardInterfaceI2C: + context, err = OpenI2C(port, portConfig) + context.isLocal = true + case NotecardInterfaceLease: + context, err = OpenLease(port, portConfig) + default: + err = fmt.Errorf("unknown interface: %s", moduleInterface) + } + if err != nil { + cardReportError(nil, err) + err = fmt.Errorf("error opening port: %s %s", err, note.ErrCardIo) + return + } + context.iface = moduleInterface + return +} + +// Reset serial to a known state. Note that this is performed by sending +// a newline and then draining the input buffer. If a newline is not +// received, it is NOT a bug because, for example, Starnote does not +// perform the echo'ing of \n *by design*. +func cardResetSerial(context *Context, portConfig int) (err error) { + // Exit if not open + if !context.portIsOpen { + err = fmt.Errorf("port not open " + note.ErrCardIo) + cardReportError(context, err) + return + } + + // In order to ensure that we're not getting the reply to a failed + // transaction from a prior session, drain any pending input prior + // to transmitting a command. Note that we use this technique of + // looking for a known reply to \n, rather than just "draining + // anything pending on serial", because the nature of read() is + // that it blocks (until timeout) if there's nothing available. + var length int + bufPtr := serialReadBufPool.Get().(*[]byte) + buf := *bufPtr + defer serialReadBufPool.Put(bufPtr) + + for { + if debugSerialIO { + fmt.Printf("cardResetSerial: about to write newline\n") + } + serialIOBegin(context, context.GetTransactionTimeoutMs()) + _, err = context.serialPort.Write([]byte("\n")) + err = serialIOEnd(context, err) + if debugSerialIO { + fmt.Printf(" back with err = %v\n", err) + } + if err != nil { + err = fmt.Errorf("error transmitting to module: %s %s", err, note.ErrCardIo) + cardReportError(context, err) + return + } + time.Sleep(250 * time.Millisecond) + if debugSerialIO { + fmt.Printf("cardResetSerial: about to read up to %d bytes\n", len(buf)) + } + readBeganMs := int(time.Now().UnixNano() / 1000000) + serialIOBegin(context, 750) + length, err = context.serialPort.Read(buf) + err = serialIOEnd(context, err) + readElapsedMs := int(time.Now().UnixNano()/1000000) - readBeganMs + if debugSerialIO { + fmt.Printf(" back after %d ms with len = %d err = %v\n", readElapsedMs, length, err) + } + if readElapsedMs == 0 && length == 0 && err == io.EOF { + // On Linux, hardware port failures come back simply as immediate EOF + err = fmt.Errorf("hardware failure") + } + if err != nil { + // Ignore errors after reset, as the only purpose of reset is to drain the input buffer + err = CardReopenSerial(context, portConfig) + return err + } + somethingFound := false + nonCRLFFound := false + for i := 0; i < length && !nonCRLFFound; i++ { + if false { + fmt.Printf("chr: 0x%02x '%c'\n", buf[i], buf[i]) + } + if buf[i] != '\r' { + somethingFound = true + if buf[i] != '\n' { + nonCRLFFound = true + } + } + } + if somethingFound && !nonCRLFFound { + break + } + } + + // Done + return +} + +// Serial I/O timeout helper function for Windows +func serialTimeoutHelper(context *Context, portConfig int) { + for { + timeoutMs := <-context.ioStartSignal + timeout := false + select { + case <-context.ioCompleteSignal: + case <-time.After(time.Duration(timeoutMs) * time.Millisecond): + timeout = true + if debugSerialIO { + fmt.Printf("serialTimeoutHelper: timeout\n") + } + cardCloseSerial(context) + } + context.ioTimeoutSignal <- timeout + } +} + +// Begin a serial I/O +func serialIOBegin(context *Context, timeoutMs int) { + context.ioStartSignal <- timeoutMs + if debugSerialIO { + if !context.portIsOpen { + fmt.Printf("serialIoBegin: WARNING: PORT NOT OPEN\n") + } + fmt.Printf("serialIOBegin: begin timeout of %d ms\n", timeoutMs) + } +} + +// End a serial I/O +func serialIOEnd(context *Context, errIn error) (errOut error) { + errOut = errIn + context.ioCompleteSignal <- true + timeout := <-context.ioTimeoutSignal + select { + case <-context.ioCompleteSignal: + if debugSerialIO { + fmt.Printf("serialIOEnd: ioComplete ate the completed signal (timeout: %v)\n", timeout) + } + default: + if debugSerialIO { + fmt.Printf("serialIOEnd: ioComplete nothing to eat (timeout: %v)\n", timeout) + } + } + if timeout { + errOut = fmt.Errorf("serial I/O timeout %s", note.ErrCardIo) + } + return +} + +// OpenSerial opens the card on serial +func OpenSerial(port string, portConfig int) (context *Context, err error) { + // Create the context structure + context = &Context{} + context.Debug = InitialDebugMode + context.port = port + context.portConfig = portConfig + context.lastRequestSeqno = 0 + + // Set up class functions + context.PortEnumFn = serialPortEnum + context.PortDefaultsFn = serialDefault + context.CloseFn = cardCloseSerial + context.ReopenFn = CardReopenSerial + context.ResetFn = cardResetSerial + context.TransactionFn = cardTransactionSerial + context.traceOpenFn = serialTraceOpen + context.traceReadFn = serialTraceRead + context.traceWriteFn = serialTraceWrite + + // Record serial configuration, and whether or not we are using the default + context.isSerial = true + context.serialName, context.serialConfig.BaudRate = serialDefault() + if port == "" { + context.serialUseDefault = true + } else { + context.serialName = port + + } + if portConfig != 0 { + context.serialConfig.BaudRate = portConfig + } + + // Set up I/O port close channels, because Windows needs a bit of help in timing out I/O's. + context.ioStartSignal = make(chan int, 1) + context.ioCompleteSignal = make(chan bool, 1) + context.ioTimeoutSignal = make(chan bool, 1) + go serialTimeoutHelper(context, portConfig) + + // For serial, we defer the port open until the first transaction so that we can + // support the concept of dynamically inserted devices, as in "notecard -scan" mode. + context.reopenBecauseOfOpen = true + context.reopenRequired = true + + // All set + return +} + +// Reset I2C to a known good state +func cardResetI2C(context *Context, portConfig int) (err error) { + // Synchronize by guaranteeing not only that I2C works, but that we drain the remainder of any + // pending partial reply from a previously-aborted session. + chunklen := 0 + for { + + // Read the next chunk of available data + _, available, err2 := i2cReadBytes(chunklen, portConfig) + if err2 != nil { + err = fmt.Errorf("error reading chunk: %s %s", err2, note.ErrCardIo) + return + } + + // If nothing left, we're ready to transmit a command to receive the data + if available == 0 { + break + } + + // For the next iteration, reaad the min of what's available and what we're permitted to read + chunklen = available + if chunklen > CardI2CMax { + chunklen = CardI2CMax + } + + } + + // Done + return +} + +// OpenI2C opens the card on I2C +func OpenI2C(port string, portConfig int) (context *Context, err error) { + + // Create the context structure + context = &Context{} + context.Debug = InitialDebugMode + context.lastRequestSeqno = 0 + + // Open + context.portIsOpen = false + + // Use default if not specified + if port == "" { + port, portConfig = i2cDefault() + } + context.port = port + context.portConfig = portConfig + + // Set up class functions + context.PortEnumFn = i2cPortEnum + context.PortDefaultsFn = i2cDefault + context.CloseFn = cardCloseI2C + context.ReopenFn = cardReopenI2C + context.ResetFn = cardResetI2C + context.TransactionFn = cardTransactionI2C + + // Open the I2C port + err = i2cOpen(port, portConfig) + if err != nil { + if false { + ports, _, _, _ := I2CPorts() + fmt.Printf("Available ports: %v\n", ports) + } + err = fmt.Errorf("i2c init error: %s", err) + return + } + + // Open + context.portIsOpen = true + + // Done + return +} + +// Reset the port +func (context *Context) Reset(portConfig int) (err error) { + context.resetRequired = false + if context.ResetFn == nil { + return + } + return context.ResetFn(context, portConfig) +} + +// Close the port +func (context *Context) Close() { + context.CloseFn(context) +} + +// Close serial +func cardCloseSerial(context *Context) { + if !context.portIsOpen { + if debugSerialIO { + fmt.Printf("cardCloseSerial: port not open\n") + } + } else { + if debugSerialIO { + fmt.Printf("cardCloseSerial: closed\n") + } + context.serialPort.Close() + context.portIsOpen = false + } +} + +// Close I2C +func cardCloseI2C(context *Context) { + _ = i2cClose() + context.portIsOpen = false +} + +// ReopenIfRequired reopens the port but only if required +func (context *Context) ReopenIfRequired(portConfig int) (err error) { + if context.reopenRequired { + err = context.ReopenFn(context, portConfig) + } + return +} + +// Reopen the port +func (context *Context) Reopen(portConfig int) (err error) { + context.reopenRequired = false + err = context.ReopenFn(context, portConfig) + return +} + +// Reopen serial +func CardReopenSerial(context *Context, portConfig int) (err error) { + + // Close if open + cardCloseSerial(context) + + // Handle deferred insertion + if context.serialUseDefault { + context.serialName, context.serialConfig.BaudRate = serialDefault() + } + if context.serialName == "" { + return fmt.Errorf("error opening serial port: serial device not available %s", note.ErrCardIo) + } + + if portConfig != 0 { + context.serialConfig.BaudRate = portConfig + } + // Set default speed if not set + if context.serialConfig.BaudRate == 0 { + _, context.serialConfig.BaudRate = serialDefault() + } + + // Open the serial port + if debugSerialIO { + fmt.Printf("CardReopenSerial: about to open '%s'\n", context.serialName) + } + context.serialPort, err = serial.Open(context.serialName, &context.serialConfig) + if debugSerialIO { + fmt.Printf(" back with err = %v\n", err) + } + if err != nil { + return fmt.Errorf("error opening serial port %s at %d: %s %s", context.serialName, context.serialConfig.BaudRate, err, note.ErrCardIo) + } + + context.portIsOpen = true + + // Done with the reopen + context.reopenRequired = false + + // Unless we've been instructed not to reset on open, reset serial to a known good state + if context.reopenBecauseOfOpen { + context.reopenBecauseOfOpen = false + if InitialResetMode { + err = cardResetSerial(context, portConfig) + } + } + + // Done + return err +} + +// Reopen I2C +func cardReopenI2C(context *Context, portConfig int) (err error) { + fmt.Printf("error i2c reopen not yet supported since I can't test it yet\n") + return +} + +// SerialDefaults returns the default serial parameters +func SerialDefaults() (port string, portConfig int) { + return serialDefault() +} + +// I2CDefaults returns the default serial parameters +func I2CDefaults() (port string, portConfig int) { + return i2cDefault() +} + +// SerialPorts returns the list of available serial ports +func SerialPorts() (allports []string, usbports []string, notecardports []string, err error) { + return serialPortEnum() +} + +// I2CPorts returns the list of available I2C ports +func I2CPorts() (allports []string, usbports []string, notecardports []string, err error) { + return i2cPortEnum() +} + +// TransactionRequest performs a card transaction with a Req structure +func (context *Context) TransactionRequest(req Request) (rsp Request, err error) { + return context.transactionRequest(req, false, 0) +} + +// TransactionRequestToPort performs a card transaction with a Req structure, to a specified port +func (context *Context) TransactionRequestToPort(req Request, portConfig int) (rsp Request, err error) { + return context.transactionRequest(req, true, portConfig) +} + +// transactionRequest performs a card transaction with a Req structure, to the current or specified port +func (context *Context) transactionRequest(req Request, multiport bool, portConfig int) (rsp Request, err error) { + reqJSON, err2 := note.JSONMarshal(req) + if err2 != nil { + err = fmt.Errorf("error marshaling request for module: %s", err2) + return + } + var rspJSON []byte + rspJSON, err = context.transactionJSON(reqJSON, multiport, portConfig) + if err != nil { + // Give transaction's error precedence, except that if we get an error unmarshaling + // we want to make sure that we indicate to the caller that there was an I/O error (corruption) + err2 := note.JSONUnmarshal(rspJSON, &rsp) + if err2 != nil { + err = fmt.Errorf("%s %s", err, note.ErrCardIo) + } + return + } + err = note.JSONUnmarshal(rspJSON, &rsp) + if err != nil { + err = fmt.Errorf("error unmarshaling reply from module: %s %s: %s", err, note.ErrCardIo, rspJSON) + } + return +} + +// NewRequest creates a new request that is guaranteed to get a response +// from the Notecard. Note that this method is provided merely as syntactic sugar, as of the form +// req := notecard.NewRequest("note.add") +func NewRequest(reqType string) (req map[string]interface{}) { + return map[string]interface{}{ + "req": reqType, + } +} + +// NewCommand creates a new command that requires no response from the notecard. +func NewCommand(reqType string) (cmd map[string]interface{}) { + return map[string]interface{}{ + "cmd": reqType, + } +} + +// NewBody creates a new body. Note that this method is provided +// merely as syntactic sugar, as of the form +// body := note.NewBody() +func NewBody() (body map[string]interface{}) { + return make(map[string]interface{}) +} + +// Request performs a card transaction with a JSON structure and doesn't return a response +// (This is for semantic compatibility with other languages.) +func (context *Context) Request(req map[string]interface{}) (err error) { + _, err = context.Transaction(req) + return +} + +// RequestResponse performs a card transaction with a JSON structure and doesn't return a response +// (This is for semantic compatibility with other languages.) +func (context *Context) RequestResponse(req map[string]interface{}) (rsp map[string]interface{}, err error) { + return context.Transaction(req) +} + +// Response is used in rare cases where there is a transaction that returns multiple responses +func (context *Context) Response() (rsp map[string]interface{}, err error) { + return context.Transaction(nil) +} + +// Transaction performs a card transaction with a JSON structure +func (context *Context) Transaction(req map[string]interface{}) (rsp map[string]interface{}, err error) { + // Handle the special case where we are just processing a response + var reqJSON []byte + if req == nil { + reqJSON = []byte("") + } else { + + // Marshal the request to JSON + reqJSON, err = note.JSONMarshal(req) + if err != nil { + err = fmt.Errorf("error marshaling request for module: %s", err) + return + } + + } + + // Perform the transaction + rspJSON, err2 := context.TransactionJSON(reqJSON) + if err2 != nil { + err = fmt.Errorf("error from TransactionJSON: %s", err2) + return + } + + // Unmarshal for convenience of the caller + err = note.JSONUnmarshal(rspJSON, &rsp) + if err != nil { + err = fmt.Errorf("error unmarshaling reply from module: %s %s: %s", err, note.ErrCardIo, rspJSON) + return + } + + // Done + return +} + +// ReceiveBytes receives arbitrary Bytes from the Notecard +func (context *Context) ReceiveBytes() (rspBytes []byte, err error) { + return context.receiveBytes(0) +} + +// SendBytes sends arbitrary Bytes to the Notecard +func (context *Context) SendBytes(reqBytes []byte) (err error) { + + // Only operate on port 0 + portConfig := 0 + + // Only one caller at a time accessing the I/O port + lockTrans(false, portConfig) + + // Reopen if error + err = context.ReopenIfRequired(portConfig) + if err != nil { + unlockTrans(false, portConfig) + return + } + + // Do a reset if one was pending + if context.resetRequired { + _ = context.Reset(portConfig) + } + + // Do the send, with no response requested and no delays (binary transfer) + _, err = context.TransactionFn(context, portConfig, true, reqBytes, false) + + // Done + unlockTrans(false, portConfig) + return + +} + +// receiveBytes receives arbitrary Bytes from the Notecard, using the current or specified port +func (context *Context) receiveBytes(portConfig int) (rspBytes []byte, err error) { + // Only one caller at a time accessing the I/O port + lockTrans(false, portConfig) + + // Reopen if error + err = context.ReopenIfRequired(portConfig) + if err != nil { + unlockTrans(false, portConfig) + if context.Debug { + fmt.Printf("%s\n", err) + } + return + } + + // Do a reset if one was pending + if context.resetRequired { + _ = context.Reset(portConfig) + } + + // Request is empty + var reqBytes []byte + // Perform the transaction with no delays (binary transfer) + rspBytes, err = context.TransactionFn(context, portConfig, false, reqBytes, false) + + unlockTrans(false, portConfig) + + // Done + return +} + +// TransactionJSON performs a card transaction using raw JSON []bytes +func (context *Context) TransactionJSON(reqJSON []byte) (rspJSON []byte, err error) { + return context.transactionJSON(reqJSON, false, 0) +} + +// TransactionJSONToPort performs a card transaction using raw JSON []bytes to a specified port +func (context *Context) TransactionJSONToPort(reqJSON []byte, portConfig int) (rspJSON []byte, err error) { + return context.transactionJSON(reqJSON, true, portConfig) +} + +// transactionJSON performs a card transaction using raw JSON []bytes, to the current or specified port +func (context *Context) transactionJSON(reqJSON []byte, multiport bool, portConfig int) (rspJSON []byte, err error) { + // Remember in the context if we've ever seen multiport I/O, for timeout computation + if multiport { + context.i2cMultiport = true + } + + // Unmarshal the request to peek inside it. Also, accept a zero-length request as a valid case + // because we use this in the test fixture where we just accept pure responses w/o requests. + var req Request + var noResponseRequested bool + if len(reqJSON) > 0 { + + // Make sure that it is valid JSON, because the transports won't validate this + // and they may misbehave if they do not get a valid JSON response back. + err = note.JSONUnmarshal(reqJSON, &req) + if err != nil { + return + } + + // If this is a hub.set, generate a user agent object if one hasn't already been supplied + if !context.DisableUA && (req.Req == ReqHubSet || req.Cmd == ReqHubSet) && req.Body == nil { + ua := context.UserAgent() + if ua != nil { + req.Body = &ua + reqJSON, _ = note.JSONMarshal(req) + } + } + + // Determine whether or not a response will be expected from the notecard by + // examining the req and cmd fields + noResponseRequested = req.Req == "" && req.Cmd != "" + + if !DoNotReterminateJSON { + // Make sure that the JSON has a single \n terminator + // Use byte operations instead of string conversions + for len(reqJSON) > 0 { + last := reqJSON[len(reqJSON)-1] + if last != '\n' && last != '\r' { + break + } + reqJSON = reqJSON[:len(reqJSON)-1] + } + reqJSON = append(reqJSON, '\n') + } + } + + // Debug + if context.Debug { + var j []byte + if context.Pretty { + j, _ = note.JSONMarshalIndent(req, "", " ") + } else { + j, _ = note.JSONMarshal(req) + } + fmt.Printf("%s\n", string(j)) + } + + // If it is a request (as opposed to a command), include a CRC so that the + // request might be retried if it is received in a corrupted state. (We can + // only do this on requests because for cmd's there is no 'response channel' + // where we can find out that the cmd failed. Note that a Seqno is included + // as part of the CRC data so that two identical requests occurring within the + // modulus of seqno never are mistaken as being the same request being retried. + lastRequestRetries := 0 + lastRequestCrcAdded := false + if !noResponseRequested { + reqJSON = crcAdd(reqJSON, context.lastRequestSeqno) + lastRequestCrcAdded = true + } + + // Only one caller at a time accessing the I/O port + lockTrans(multiport, portConfig) + + // Transaction retry loop. Note that "err" must be set before breaking out of loop + err = nil + for lastRequestRetries <= requestRetriesAllowed { + + // Only do reopen/reset in the single-port case, because we may not be talking to the port in error + if !multiport { + + // Reopen if error + err = context.ReopenIfRequired(portConfig) + if err != nil { + unlockTrans(multiport, portConfig) + if context.Debug { + fmt.Printf("%s\n", err) + } + return + } + + // Do a reset if one was pending + if context.resetRequired { + _ = context.Reset(portConfig) + } + + } + + // Perform the transaction with delays (JSON requires pacing for the Notecard) + rspJSON, err = context.TransactionFn(context, portConfig, noResponseRequested, reqJSON, true) + if err != nil { + // We can defer the error if a single port, but we need to reset it NOW if multiport + if multiport { + if context.ResetFn != nil { + _ = context.ResetFn(context, portConfig) + } + } else { + context.resetRequired = true + } + } + + // If no response expected, we won't be retrying + if noResponseRequested { + break + } + + // Decode the response to create an error if the response JSON was badly formatted. + // do this because it's SUPER inconvenient to always be checking for a response error + // vs an error on the transaction itself + if err == nil { + var rsp Request + err = note.JSONUnmarshal(rspJSON, &rsp) + if err != nil { + err = fmt.Errorf("error unmarshaling reply from module: %s %s: %s", err, note.ErrCardIo, rspJSON) + } else { + if rsp.Err != "" { + if req.Req == "" { + err = fmt.Errorf("%s", rsp.Err) + } else { + err = fmt.Errorf("%s: %s", req.Req, rsp.Err) + } + } + } + } + + // Don't retry these transactions for obvious reasons + if req.Req == ReqCardRestore || req.Req == ReqCardRestart { + break + } + + // If an I/O error, retry + if note.ErrorContains(err, note.ErrCardIo) && !note.ErrorContains(err, note.ErrReqNotSupported) { + // We can defer the error if a single port, but we need to reset it NOW if multiport + if multiport { + if context.ResetFn != nil { + _ = context.ResetFn(context, portConfig) + } + } else { + context.resetRequired = true + } + lastRequestRetries++ + if context.Debug { + fmt.Printf("retrying I/O error detected by host: %s\n", err) + } + time.Sleep(500 * time.Millisecond) + continue + } + + // If an error, stop transaction processing here + if err != nil { + break + } + + // If we sent a CRC in the request, examine the response JSON to see if + // it has a CRC error. Note that the CRC is stripped from the + // rspJSON as a side-effect of this method. + if lastRequestCrcAdded { + rspJSON, err = crcError(rspJSON, context.lastRequestSeqno) + if err != nil { + lastRequestRetries++ + if context.Debug { + fmt.Printf("retrying: %s\n", err) + } + time.Sleep(500 * time.Millisecond) + continue + } + + } + + // Transaction completed + break + + } + + // Bump the request sequence number now that we've processed this request, success or error + context.lastRequestSeqno++ + + // If this was a card restore, we want to hold everyone back if we reset the card if it + // isn't a multiport case. But in multiport, we only want to hold this caller back. + if (req.Req == ReqCardRestore) && req.Reset { + // Special case card.restore, reset:true does not cause a reboot. + unlockTrans(multiport, portConfig) + } else if context.isLocal && (req.Req == ReqCardRestore || req.Req == ReqCardRestart) { + if multiport { + unlockTrans(multiport, portConfig) + time.Sleep(12 * time.Second) + } else { + context.reopenRequired = true + time.Sleep(8 * time.Second) + unlockTrans(multiport, portConfig) + } + } else { + unlockTrans(multiport, portConfig) + } + + // If no response, we're done + if noResponseRequested { + rspJSON = []byte("{}") + return + } + + // Debug + if context.Debug { + responseJSON := rspJSON + if context.Pretty { + var rsp Request + e := note.JSONUnmarshal(responseJSON, &rsp) + if e == nil { + prettyJSON, e := note.JSONMarshalIndent(rsp, " ", " ") + if e == nil { + fmt.Printf("==> ") + responseJSON = append(prettyJSON, byte('\n')) + } + } + } + fmt.Printf("%s", string(responseJSON)) + } + + // Done + return +} + +// Perform a card transaction over serial under the assumption that request already has '\n' terminator +func cardTransactionSerial(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) { + // Exit if not open + if !context.portIsOpen { + err = fmt.Errorf("port not open " + note.ErrCardIo) + cardReportError(context, err) + return + } + + // Initialize timing parameters + if RequestSegmentMaxLen < 0 { + RequestSegmentMaxLen = CardRequestSerialSegmentMaxLen + } + if RequestSegmentDelayMs < 0 { + RequestSegmentDelayMs = CardRequestSerialSegmentDelayMs + } + + // Set the serial read timeout to 30 seconds, preventing reads under windows from stalling indefinitely on a serial error. + _ = context.serialPort.SetReadTimeout(30 * time.Second) + + // Handle the special case where we are looking only for a reply + if len(reqJSON) > 0 { + + // Transmit the request in segments so as not to overwhelm the notecard's interrupt buffers + segOff := 0 + segLeft := len(reqJSON) + for { + segLen := segLeft + if segLen > RequestSegmentMaxLen { + segLen = RequestSegmentMaxLen + } + if debugSerialIO { + fmt.Printf("cardTransactionSerial: about to write %d bytes\n", segLen) + } + serialIOBegin(context, context.GetTransactionTimeoutMs()) + _, err = context.serialPort.Write(reqJSON[segOff : segOff+segLen]) + err = serialIOEnd(context, err) + if debugSerialIO { + fmt.Printf(" back with err = %v\n", err) + } + if err != nil { + err = fmt.Errorf("error transmitting to module: %s %s", err, note.ErrCardIo) + cardReportError(context, err) + return + } + segOff += segLen + segLeft -= segLen + if segLeft == 0 { + break + } + if delay { + time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + } + } + + } + + // If no response, we're done + if noResponse { + return + } + + // Read the reply until we get '\n' at the end + waitBegan := time.Now() + waitExpires := waitBegan.Add(time.Duration(context.GetTransactionTimeoutMs()) * time.Millisecond) + + // Get pooled buffer for reading to reduce allocations + bufPtr := serialReadBufPool.Get().(*[]byte) + buf := *bufPtr + defer serialReadBufPool.Put(bufPtr) + + // Pre-allocate response buffer + rspJSON = make([]byte, 0, 4096) + + for { + var length int + if debugSerialIO { + fmt.Printf("cardTransactionSerial: about to read up to %d bytes\n", len(buf)) + } + readBeganMs := int(time.Now().UnixNano() / 1000000) + waitRemainingMs := int(time.Until(waitExpires).Milliseconds()) + serialIOBegin(context, waitRemainingMs) + length, err = context.serialPort.Read(buf) + err = serialIOEnd(context, err) + readElapsedMs := int(time.Now().UnixNano()/1000000) - readBeganMs + if debugSerialIO { + fmt.Printf(" back after %d ms with len = %d err = %v\n", readElapsedMs, length, err) + } + if false { + err2 := err + if err2 == nil { + err2 = fmt.Errorf("none") + } + fmt.Printf("req: elapsed:%d len:%d err:%s '%s'\n", readElapsedMs, length, err2, string(buf[:length])) + } + if readElapsedMs == 0 && length == 0 && err == io.EOF { + // On Linux, hardware port failures come back simply as immediate EOF + err = fmt.Errorf("hardware failure") + } + if err != nil { + if err == io.EOF { + // Just a read timeout + continue + } + // Ignore [flaky, rare, Windows] hardware errors for up to several seconds + if (time.Now().Unix() - waitBegan.Unix()) > int64(IgnoreWindowsHWErrSecs) { + err = fmt.Errorf("error reading from module: %s %s", err, note.ErrCardIo) + cardReportError(context, err) + return + } + time.Sleep(1 * time.Second) + continue + } + rspJSON = append(rspJSON, buf[:length]...) + + // Use bytes.IndexByte instead of strings.Contains + if bytes.IndexByte(rspJSON, '\n') == -1 { + continue + } + + // We now have at least one whole line. If we're just gathering a reply, we're done. + if len(reqJSON) == 0 { + break + } + + // Find the last newline position + lastNewline := bytes.LastIndexByte(rspJSON, '\n') + if lastNewline == -1 { + continue + } + + // Check if there's a partial line after the last newline + if lastNewline < len(rspJSON)-1 { + // The reply should be only a single line. However, if the user had been + // in trace mode (likely on USB) we may be receiving trace lines that + // were sent to us and inserted into the serial buffer prior to the JSON reply. + rspJSON = rspJSON[lastNewline+1:] + continue + } + + // Find the second-to-last line + prevNewline := -1 + if lastNewline > 0 { + prevNewline = bytes.LastIndexByte(rspJSON[:lastNewline], '\n') + } + + var secondToLastLine []byte + if prevNewline == -1 { + secondToLastLine = rspJSON[:lastNewline] + } else { + secondToLastLine = rspJSON[prevNewline+1 : lastNewline] + } + + // Skip the line if it's empty or doesn't look like JSON + if len(secondToLastLine) == 0 || secondToLastLine[0] != '{' { + rspJSON = rspJSON[:0] + continue + } + + // ** We now have a clean response in rspJSON ** + + // We're done if it's not a heartbeat + fn := context.HeartbeatFn + if fn == nil { + break + } + m := make(map[string]string) + if json.Unmarshal(rspJSON, &m) != nil { + break + } + v, errPresent := m["err"] + if !errPresent { + break + } + if !strings.Contains(v, note.ErrCardHeartbeat) { + break + } + + // Call the heartbeat function, and abort if it requests that we do so + if fn(context, context.HeartbeatCtx, rspJSON) { + err = fmt.Errorf("aborted by heartbeat function") + cardReportError(context, err) + return + } + + // Reset the JSON and timeout and start again + rspJSON = []byte{} + waitBegan = time.Now() + waitExpires = waitBegan.Add(time.Duration(context.GetTransactionTimeoutMs()) * time.Millisecond) + } + + // Done + return +} + +// Perform a card transaction over I2C under the assumption that request already has '\n' terminator +func cardTransactionI2C(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) { + // Initialize timing parameters + if RequestSegmentMaxLen < 0 { + RequestSegmentMaxLen = CardRequestI2CSegmentMaxLen + } + if RequestSegmentDelayMs < 0 { + RequestSegmentDelayMs = CardRequestI2CSegmentDelayMs + } + + // Transmit the request in chunks, but also in segments so as not to overwhelm the notecard's interrupt buffers + chunkoffset := 0 + jsonbufLen := len(reqJSON) + sentInSegment := 0 + for jsonbufLen > 0 { + chunklen := CardI2CMax + if jsonbufLen < chunklen { + chunklen = jsonbufLen + } + err = i2cWriteBytes(reqJSON[chunkoffset:chunkoffset+chunklen], portConfig) + if err != nil { + err = fmt.Errorf("write error: %s %s", err, note.ErrCardIo) + return + } + chunkoffset += chunklen + jsonbufLen -= chunklen + sentInSegment += chunklen + if delay { + if sentInSegment > RequestSegmentMaxLen { + sentInSegment = 0 + time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + } + time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + } + } + + // If no response, we're done + if noResponse { + return + } + + // Loop, building a reply buffer out of received chunks. We'll build the reply in the same + // buffer we used to transmit, and will grow it as necessary. + jsonbufLen = 0 + receivedNewline := false + chunklen := 0 + waitBegan := time.Now() + waitExpires := waitBegan.Add(time.Duration(context.GetTransactionTimeoutMs()) * time.Millisecond) + for { + + // Read the next chunk + readbuf, available, err2 := i2cReadBytes(chunklen, portConfig) + if err2 != nil { + err = fmt.Errorf("read error: %s %s", err2, note.ErrCardIo) + return + } + + // Append to the JSON being accumulated + rspJSON = append(rspJSON, readbuf...) + readlen := len(readbuf) + jsonbufLen += readlen + + // If we received something, reset the expiration to what we'd expect for just the + // I/O portion of a transaction. + if readlen > 0 { + waitExpires = time.Now().Add(time.Duration(5) * time.Second) + } + + // If the last byte of the chunk is \n, chances are that we're done. However, just so + // that we pull everything pending from the module, we only exit when we've received + // a newline AND there's nothing left available from the module. + if readlen > 0 && readbuf[readlen-1] == '\n' { + receivedNewline = true + } + + // For the next iteration, reaad the min of what's available and what we're permitted to read + chunklen = available + if chunklen > CardI2CMax { + chunklen = CardI2CMax + } + + // If there's something available on the notecard for us to receive, do it + if chunklen > 0 { + continue + } + + // See if we're done + if !receivedNewline { + + // If we've timed out and nothing's available, exit + expired := time.Now().After(waitExpires) + if context.i2cMultiport { + expired = time.Now().After(waitBegan.Add(time.Duration(90) * time.Second)) + } + if expired { + err = fmt.Errorf("transaction timeout (%d bytes received before timeout) %s", jsonbufLen, note.ErrCardIo+note.ErrTimeout) + return + } + + // Continue receiving + continue + } + + // ** We now have a clean response in rspJSON ** + + // We're done if it's not a heartbeat + fn := context.HeartbeatFn + if fn == nil { + break + } + m := make(map[string]string) + if json.Unmarshal(rspJSON, &m) != nil { + break + } + v, errPresent := m["err"] + if !errPresent { + break + } + if !strings.Contains(v, note.ErrCardHeartbeat) { + break + } + + // Call the heartbeat function, and abort if it requests that we do so + if fn(context, context.HeartbeatCtx, rspJSON) { + err = fmt.Errorf("aborted by heartbeat function") + cardReportError(context, err) + return + } + + // Reset the JSON and timeout and start again + rspJSON = []byte{} + waitBegan = time.Now() + waitExpires = waitBegan.Add(time.Duration(context.GetTransactionTimeoutMs()) * time.Millisecond) + } + + // Done + return +} + +// OpenLease opens a remote card with a lease +func OpenLease(leaseScope string, leaseMins int) (context *Context, err error) { + + // Create the context structure + context = &Context{} + context.Debug = InitialDebugMode + context.port = leaseScope + context.portConfig = 0 + context.lastRequestSeqno = 0 + + // Prevent accidental reservation for excessive durations e.g. 115200 minutes + if leaseMins > 120 { + err = fmt.Errorf("leasing a notecard has a 120 minute limit, but got %d", leaseMins) + return + } + + // Set up class functions + context.CloseFn = leaseClose + context.ReopenFn = leaseReopen + context.TransactionFn = leaseTransaction + context.traceOpenFn = leaseTraceOpen + context.traceReadFn = leaseTraceRead + context.traceWriteFn = leaseTraceWrite + + // Record serial configuration + context.leaseScope = leaseScope + if leaseMins == 0 { + leaseMins = 1 + } + leaseMins = (((leaseMins - 1) / reservationModulusMinutes) + 1) * reservationModulusMinutes + context.leaseExpires = time.Now().Unix() + int64(leaseMins*60) + + // Open the port + err = context.ReopenFn(context, context.portConfig) + if err != nil { + err = fmt.Errorf("error taking out lease: %s %s", err, note.ErrCardIo) + return + } + + // All set + return +} + +// Lock the appropriate mutex for the transaction +func lockTrans(multiport bool, portConfig int) { + if multiport && portConfig >= 0 && portConfig < 128 { + multiportTransLock[portConfig].Lock() + } else { + transLock.Lock() + } +} + +// Unlock the appropriate mutex for the transaction +func unlockTrans(multiport bool, portConfig int) { + if multiport && portConfig >= 0 && portConfig < 128 { + multiportTransLock[portConfig].Unlock() + } else { + transLock.Unlock() + } +} + +// Get the CallerID for this requestor, increasing the likelihood of getting the same +// reservation between tests which may be run across different machines and across +// different processes on the same machine. +func callerID() (id string) { + + // See if it's specified in the environment + id = os.Getenv("NOTEFARM_CALLERID") + if id != "" { + return + } + + user, err := user.Current() + if user != nil && err == nil { + id = user.Username + } + + hostname, err := os.Hostname() + if hostname != "" && err == nil { + id += "@" + hostname + } + + if id == "" { + // Use the mac address if we have no other name + interfaces, err := net.Interfaces() + if err == nil { + for _, i := range interfaces { + if i.Flags&net.FlagUp != 0 && !bytes.Equal(i.HardwareAddr, nil) { + // Don't use random as we have a real address + id = i.HardwareAddr.String() + break + } + } + } + } + + return +} + +// Serial trace open +func serialTraceOpen(context *Context) (err error) { + return +} + +// Serial trace read function +func serialTraceRead(context *Context) (data []byte, err error) { + + // Exit if not open + if !context.portIsOpen { + return data, fmt.Errorf("port not open " + note.ErrCardIo) + } + + // Do the read + var length int + buf := make([]byte, 2048) + readBeganMs = int(time.Now().UnixNano() / 1000000) + length, err = context.serialPort.Read(buf) + readElapsedMs := int(time.Now().UnixNano()/1000000) - readBeganMs + if false { + fmt.Printf("mon: elapsed:%d len:%d err:%s '%s'\n", readElapsedMs, length, err, string(buf[:length])) + } + if readElapsedMs == 0 && length == 0 && err == io.EOF { + // On Linux, hardware port failures come back simply as immediate EOF + err = fmt.Errorf("hardware failure") + } + if readElapsedMs == 0 && length == 0 { + // On Linux, sudden unplug comes back simply as immediate '' + err = fmt.Errorf("hardware unplugged or rebooted probably") + } + if err != nil { + if err == io.EOF { + // Just a read timeout + return data, nil + } + return data, fmt.Errorf("%s %s", err, note.ErrCardIo) + } + + return buf[:length], nil +} + +// Serial trace write function +func serialTraceWrite(context *Context, data []byte) { + context.serialPort.Write(data) +} + +// Add a crc to the JSON transaction +func crcAdd(reqJson []byte, seqno int) []byte { + + // Exit if invalid + if len(reqJson) < 2 { + return reqJson + } + + // Extract any terminator present so it isn't included in the checksum + reqJsonStr := string(reqJson) + terminator := "" + temp := strings.Split(reqJsonStr, "}") + if len(temp) > 1 { + terminator = temp[len(temp)-1] + reqJsonStr = strings.Join(temp[0:len(temp)-1], "}") + "}" + } + + // Compute the CRC of the JSON + crc := crc32.ChecksumIEEE([]byte(reqJsonStr)) + + // Strip the suffix and prepare for crc concatenation. Note that + // the decode side assumes that either a space or comma was added + reqJsonStr = strings.TrimSuffix(reqJsonStr, "}") + if !strings.Contains(reqJsonStr, ":") { + reqJsonStr += " " + } else { + reqJsonStr += "," + } + + // Append the CRC + reqJsonStr += fmt.Sprintf("\"crc\":\"%04X:%08X\"}", seqno, crc) + + // Done + return []byte(reqJsonStr + terminator) +} + +// Test and remove CRC from transaction JSON +// Note that if a CRC field is not present in the JSON, it is considered +// a valid transaction because old Notecards do not have the code +// with which to calculate and piggyback a CRC field. Note that the +// CRC is stripped from the input JSON regardless of whether or not +// there was an error. +func crcError(rspJson []byte, shouldBeSeqno int) (retJson []byte, err error) { + + // Exit silently if invalid + if len(rspJson) < 2 { + return rspJson, nil + } + + // Extract any terminator present so it isn't included in the checksum + rspJsonStr := string(rspJson) + terminator := "" + temp := strings.Split(rspJsonStr, "}") + if len(temp) > 1 { + terminator = temp[len(temp)-1] + rspJsonStr = strings.Join(temp[0:len(temp)-1], "}") + "}" + } + + // Minimum valid JSON is "{}" (2 bytes) and must end with a closing "}". If + // it's not there, it's ok because it could be an old notecard + crcFieldLength := 22 // ,"crc":"SSSS:CCCCCCCC" + if len(rspJsonStr) < crcFieldLength+2 || !strings.HasSuffix(rspJsonStr, "}") { + return rspJson, nil + } + + // Split the string into its json and non-json components + t1 := strings.Split(rspJsonStr, " \"crc\":\"") + if len(t1) != 2 { + t1 = strings.Split(rspJsonStr, ",\"crc\":\"") + } + if len(t1) != 2 { + return rspJson, nil + } + stripped := t1[0] + "}" + t2 := strings.Split(t1[1], ":") + if len(t2) != 2 { + return rspJson, fmt.Errorf("badly formatted CRC seqno") + } + seqnoHex := t2[0] + seqno, err := strconv.ParseInt(seqnoHex, 16, 64) + if err != nil { + return rspJson, fmt.Errorf("badly formatted hex CRC seqno") + } + shouldBeCrcHex := strings.TrimSuffix(t2[1], "\"}") + shouldBeCrc, err := strconv.ParseInt(shouldBeCrcHex, 16, 64) + if err != nil { + return rspJson, fmt.Errorf("badly formatted hex CRC") + } + + // Compute the CRC of the JSON + crc := crc32.ChecksumIEEE([]byte(stripped)) + + // Test values + if shouldBeSeqno != int(seqno) { + return rspJson, fmt.Errorf("sequence number mismatch (%d != %d)", seqno, shouldBeSeqno) + } + if uint32(shouldBeCrc) != crc { + return rspJson, fmt.Errorf("CRC mismatch") + } + + // Done + return []byte(stripped + terminator), nil +} diff --git a/note-go/notecard/play.go b/note-go/notecard/play.go new file mode 100644 index 0000000..87767df --- /dev/null +++ b/note-go/notecard/play.go @@ -0,0 +1,223 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "bufio" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/blues/note-go/note" +) + +// Interactive I/O +var ( + iInputHandlerActive = false + iWatch = false + uiLock sync.RWMutex +) + +// Interactive enters interactive request/response mode, disabling trace in case +// that was the last mode entered +func (context *Context) Interactive(watch bool, watchLevel int, prompt bool, watchCommand string, quitCommand string) (err error) { + var rsp Request + var colWidth int + var cols int + var subsystem []string + var subsystemDisplayName []string + + // Set the watch on/off based upon whether there is a command + iWatch = watch + + // Initialize for watching + linesDisplayed := 0 + + // Get the template for the trace log results. We need to get this regardless of whether watch + // is initially on because it might be turned on later + rsp, err = context.TransactionRequest(Request{Req: "note.get", NotefileID: "_synclog.qi", Start: true}) + if err != nil { + return + } + for _, entry := range strings.Split(rsp.Status, ",") { + str := strings.Split(entry, ":") + if len(str) >= 2 { + cols++ + subsystem = append(subsystem, str[0]) + subsystemDisplayName = append(subsystemDisplayName, str[1]) + if len(str[1]) > colWidth { + colWidth = len(str[1]) + } + } + } + colWidth += 4 + + if iWatch { + + // Print an opening banner if necessary + now := time.Now().Local().Format("03:04:05 PM MST") + rsp, err = context.TransactionRequest(Request{Req: "note.get", NotefileID: "_synclog.qi"}) + if err == nil && rsp.Body == nil { + fmt.Printf("%s waiting for sync activity\n", now) + } + + } + + // Now that we know we can speak to the notecard, spawn the input handlers + if !iInputHandlerActive { + go interactiveInputHandler(context, prompt, watchCommand, quitCommand) + for !iInputHandlerActive { + time.Sleep(100 * time.Millisecond) + } + } + + // Loop, printing data + prevTimeSecs := int64(0) + for err == nil || note.ErrorContains(err, note.ErrNoteNoExist) { + + // Exit if the handler exited + if !iInputHandlerActive { + err = nil + break + } + + // Loop if not watching + if !iWatch { + time.Sleep(500 * time.Millisecond) + continue + } + + // Get the next entry + rsp, err = context.TransactionRequest(Request{Req: "note.get", NotefileID: "_synclog.qi", Delete: true}) + if err != nil { + if !note.ErrorContains(err, note.ErrNoteNoExist) { + uiLock.Lock() + fmt.Printf("\r%s\n", err) + uiLock.Unlock() + } + time.Sleep(1000 * time.Millisecond) + continue + } + if rsp.Body == nil { + time.Sleep(1000 * time.Millisecond) + continue + } + var bodyJSON []byte + bodyJSON, err = note.ObjectToJSON(rsp.Body) + if err != nil { + break + } + var body SyncLogBody + err = note.JSONUnmarshal(bodyJSON, &body) + if err != nil { + break + } + if body.DetailLevel > uint32(watchLevel) { + continue + } + + // Lock output for a moment + uiLock.Lock() + + // Output a header if it will help readability + if linesDisplayed%250 == 0 { + fmt.Printf("\n%s ", strings.Repeat(" ", len(time.Now().Local().Format("03:04:05 PM MST")))) + for i := 0; i < cols; i++ { + fmt.Printf("%s%s", + subsystemDisplayName[i], + strings.Repeat(" ", colWidth-len(subsystemDisplayName[i]))) + } + fmt.Printf("\n\n") + } else { + // Output a spacer if there is a distance in time + if body.TimeSecs != 0 && body.TimeSecs > prevTimeSecs+30 { + fmt.Printf("\n") + } + } + linesDisplayed++ + + // Display either the time OR the 'secs since boot' if time isn't available + prevTimeSecs = body.TimeSecs + timebuf := time.Unix(int64(body.TimeSecs), 0).Local().Format("03:04:05 PM MST") + if body.TimeSecs == 0 { + str := fmt.Sprintf("%d", body.BootMs) + timebuf = fmt.Sprintf("%s%s", str, strings.Repeat(" ", len(timebuf)-len(str))) + } + + // Display indentation + fmt.Printf("\r%s ", timebuf) + indentstr := "." + strings.Repeat(" ", colWidth-1) + for _, ss := range subsystem { + if ss == body.Subsystem { + break + } + fmt.Printf("%s", indentstr) + } + + // Display the message + if watchLevel < SyncLogLevelProg { + fmt.Printf("%s\n", note.ErrorClean(fmt.Errorf(body.Text))) + } else { + fmt.Printf("%s\n", body.Text) + } + + // Release the UI + uiLock.Unlock() + + } + + // Done + iInputHandlerActive = false + return +} + +// Watch for console input +func interactiveInputHandler(context *Context, prompt bool, watchCommand string, quitCommand string) { + // Mark as active, in case we invoke this multiple times + iInputHandlerActive = true + + // Create a scanner to watch stdin + scanner := bufio.NewScanner(os.Stdin) + var message string + + // Send the command to the module + for iInputHandlerActive { + if prompt { + uiLock.Lock() + fmt.Printf("> ") + uiLock.Unlock() + } + scanner.Scan() + message = scanner.Text() + if message == quitCommand { + iInputHandlerActive = false + break + } + if message == "" { + continue + } + uiLock.Lock() + if watchCommand != "" && message == watchCommand { + if iWatch { + iWatch = false + fmt.Printf("watch off\n") + } else { + iWatch = true + fmt.Printf("watch ON\n") + } + uiLock.Unlock() + continue + } + rspJSON, err := context.TransactionJSON([]byte(message)) + if err != nil { + fmt.Printf("error: %s\n", err) + } else { + fmt.Printf("%s", rspJSON) + } + uiLock.Unlock() + } +} diff --git a/note-go/notecard/request.go b/note-go/notecard/request.go new file mode 100644 index 0000000..1515aa2 --- /dev/null +++ b/note-go/notecard/request.go @@ -0,0 +1,536 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "errors" + + "github.com/blues/note-go/note" +) + +// Request Types (L suffix means Legacy as of 2019-11-18, and can be removed after we ship) + +// ReqFileAdd (golint) +const ReqFileAdd = "file.add" + +// ReqFileSet (golint) +const ReqFileSet = "file.set" + +// ReqFileDelete (golint) +const ReqFileDelete = "file.delete" + +// ReqFileClear (golint) +const ReqFileClear = "file.clear" + +// ReqFileGetL (golint) +const ReqFileGetL = "file.get" + +// ReqFileChanges (golint) +const ReqFileChanges = "file.changes" + +// ReqFileChangesPending (golint) +const ReqFileChangesPending = "file.changes.pending" + +// ReqFileSync (golint) +const ReqFileSync = "file.sync" + +// ReqFileStats (golint) +const ReqFileStats = "file.stats" + +// ReqNotesGetL (golint) +const ReqNotesGetL = "notes.get" + +// ReqNoteChanges (golint) +const ReqNoteChanges = "note.changes" + +// ReqNoteAdd (golint) +const ReqNoteAdd = "note.add" + +// ReqNoteTemplate (golint) +const ReqNoteTemplate = "note.template" + +// ReqNoteGet (golint) +const ReqNoteGet = "note.get" + +// ReqNoteUpdate (golint) +const ReqNoteUpdate = "note.update" + +// ReqNoteDelete (golint) +const ReqNoteDelete = "note.delete" + +// ReqNoteEncrypt (golint) +const ReqNoteEncrypt = "note.encrypt" + +// ReqNoteDecrypt (golint) +const ReqNoteDecrypt = "note.decrypt" + +// ReqCardTime (golint) +const ReqCardTime = "card.time" + +// ReqCardRandom (golint) +const ReqCardRandom = "card.random" + +// ReqCardSleep (golint) +const ReqCardSleep = "card.sleep" + +// ReqCardContact (golint) +const ReqCardContact = "card.contact" + +// ReqCardAttn (golint) +const ReqCardAttn = "card.attn" + +// ReqCardStatus (golint) +const ReqCardStatus = "card.status" + +// ReqCardRestart (golint) +const ReqCardRestart = "card.restart" + +// ReqCardCheckpoint (golint) +const ReqCardCheckpoint = "card.checkpoint" + +// ReqCardRestore (golint) +const ReqCardRestore = "card.restore" + +// ReqCardLocation (golint) +const ReqCardLocation = "card.location" + +// ReqCardLocationMode (golint) +const ReqCardLocationMode = "card.location.mode" + +// ReqCardLocationTrack (golint) +const ReqCardLocationTrack = "card.location.track" + +// ReqCardTriangulate (golint) +const ReqCardTriangulate = "card.triangulate" + +// ReqCardTemp (golint) +const ReqCardTemp = "card.temp" + +// ReqCardIllumination (golint) +const ReqCardIllumination = "card.illumination" + +// ReqCardVoltage (golint) +const ReqCardVoltage = "card.voltage" + +// ReqCardPower (golint) +const ReqCardPower = "card.power" + +// ReqCardMotion (golint) +const ReqCardMotion = "card.motion" + +// ReqCardMotionMode (golint) +const ReqCardMotionMode = "card.motion.mode" + +// ReqCardMotionSync (golint) +const ReqCardMotionSync = "card.motion.sync" + +// ReqCardMotionTrack (golint) +const ReqCardMotionTrack = "card.motion.track" + +// ReqCardIO (golint) +const ReqCardIO = "card.io" + +// ReqCardAUX (golint) +const ReqCardAUX = "card.aux" + +// ReqCardAUXSerial (golint) +const ReqCardAUXSerial = "card.aux.serial" + +// ReqCardMonitor (golint) +const ReqCardMonitor = "card.monitor" + +// ReqCardCarrier (golint) +const ReqCardCarrier = "card.carrier" + +// ReqCardTrace (golint) +const ReqCardTrace = "card.trace" + +// ReqCardUsageGet (golint) +const ReqCardUsageGet = "card.usage.get" + +// ReqCardUsageTest (golint) +const ReqCardUsageTest = "card.usage.test" + +// ReqEnvModified (golint) +const ReqEnvModified = "env.modified" + +// ReqEnvGet (golint) +const ReqEnvGet = "env.get" + +// ReqEnvSet (golint) +const ReqEnvSet = "env.set" + +// ReqVarSet (golint) +const ReqVarSet = "var.set" + +// ReqVarGet (golint) +const ReqVarGet = "var.get" + +// ReqVarDelete (golint) +const ReqVarDelete = "var.delete" + +// ReqEnvTemplate (golint) +const ReqEnvTemplate = "env.template" + +// ReqEnvDefault (golint) +const ReqEnvDefault = "env.default" + +// ReqEnvTime (golint) +const ReqEnvTime = "env.time" + +// ReqEnvLocation (golint) +const ReqEnvLocation = "env.location" + +// ReqEnvSync (golint) +const ReqEnvSync = "env.sync" + +// ReqWeb (golint) +const ReqWeb = "web" + +// ReqCardBinary (golint) +const ReqCardBinary = "card.binary" + +// ReqCardBinaryGet (golint) +const ReqCardBinaryGet = "card.binary.get" + +// ReqCardBinaryPut (golint) +const ReqCardBinaryPut = "card.binary.put" + +// ReqWebGet (golint) +const ReqWebGet = "web.get" + +// ReqWebPut (golint) +const ReqWebPut = "web.put" + +// ReqWebPost (golint) +const ReqWebPost = "web.post" + +// ReqWebDelete (golint) +const ReqWebDelete = "web.delete" + +// ReqDFUStatus (golint) +const ReqDFUStatus = "dfu.status" + +// ReqDFUGet (golint) +const ReqDFUGet = "dfu.get" + +// ReqDFUPut (golint) +const ReqDFUPut = "dfu.put" + +// ReqCardDFU (golint) +const ReqCardDFU = "card.dfu" + +// ReqEnvVersion (golint) +const ReqEnvVersion = "env.version" + +// ReqCardVersion (golint) +const ReqCardVersion = "card.version" + +// ReqCardBootloader (golint) +const ReqCardBootloader = "card.bootloader" + +// ReqCardTest (golint) +const ReqCardTest = "card.test" + +// ReqCardSetup (golint) +const ReqCardSetup = "card.setup" + +// ReqCardWireless (golint) +const ReqCardWireless = "card.wireless" + +// ReqCardTransport (golint) +const ReqCardTransport = "card.transport" + +// ReqCardWirelessPenalty (golint) +const ReqCardWirelessPenalty = "card.wireless.penalty" + +// ReqCardWirelessSignal (golint) +const ReqCardWirelessSignal = "card.wireless.signal" + +// ReqCardWiFi (golint) +const ReqCardWiFi = "card.wifi" + +// ReqCardLog (golint) +const ReqCardLog = "card.log" + +// ReqHubSync (golint) +const ReqHubSync = "hub.sync" + +// ReqHubSyncL (golint) +const ReqHubSyncL = "service.sync" + +// ReqHubLog (golint) +const ReqHubLog = "hub.log" + +// ReqHubLogL (golint) +const ReqHubLogL = "service.log" + +// ReqHubEnvL (golint) +const ReqHubEnvL = "hub.env" + +// ReqHubEnvLL (golint) +const ReqHubEnvLL = "service.env" + +// ReqHubSet (golint) +const ReqHubSet = "hub.set" + +// ReqHubSetL (golint) +const ReqHubSetL = "service.set" + +// ReqHubGet (golint) +const ReqHubGet = "hub.get" + +// ReqHubGetL (golint) +const ReqHubGetL = "service.get" + +// ReqHubStatus (golint) +const ReqHubStatus = "hub.status" + +// ReqHubStatusL (golint) +const ReqHubStatusL = "service.status" + +// ReqHubSignal (golint) +const ReqHubSignal = "hub.signal" + +// ReqHubSignalL (golint) +const ReqHubSignalL = "service.signal" + +// ReqHubSyncStatus (golint) +const ReqHubSyncStatus = "hub.sync.status" + +// ReqHubSyncStatusL (golint) +const ReqHubSyncStatusL = "service.sync.status" + +// ReqHubDFUGet (golint) +const ReqHubDFUGet = "hub.dfu.get" + +// ReqHubHubDFUGetL (golint) +const ReqHubDFUGetL = "dfu.service.get" + +// ReqHubFileGet (golint) +const ReqHubFileGet = "hub.file.get" + +// Request is the core API request/response data structure +type Request struct { + Req string `json:"req,omitempty"` + Cmd string `json:"cmd,omitempty"` + Err string `json:"err,omitempty"` + RequestID uint32 `json:"id,omitempty"` + Transport string `json:"transport,omitempty"` + NotefileID string `json:"file,omitempty"` + TrackerID string `json:"tracker,omitempty"` + NoteID string `json:"note,omitempty"` + Body *map[string]interface{} `json:"body,omitempty"` + Payload *[]byte `json:"payload,omitempty"` + Deleted bool `json:"deleted,omitempty"` + Start bool `json:"start,omitempty"` + Stop bool `json:"stop,omitempty"` + Delete bool `json:"delete,omitempty"` + USB bool `json:"usb,omitempty"` + Primary bool `json:"primary,omitempty"` + Edge bool `json:"edge,omitempty"` + Connected bool `json:"connected,omitempty"` + Secure bool `json:"secure,omitempty"` + Unsecure bool `json:"unsecure,omitempty"` + Alert bool `json:"alert,omitempty"` + Retry bool `json:"retry,omitempty"` + Signals int32 `json:"signals,omitempty"` + Max int32 `json:"max,omitempty"` + Changes int32 `json:"changes,omitempty"` + Seconds int32 `json:"seconds,omitempty"` + SecondsV string `json:"vseconds,omitempty"` + Minutes int32 `json:"minutes,omitempty"` + MinutesV string `json:"vminutes,omitempty"` + Hours int32 `json:"hours,omitempty"` + HoursV string `json:"vhours,omitempty"` + Days int32 `json:"days,omitempty"` + Result int32 `json:"result,omitempty"` + I2C int32 `json:"i2c,omitempty"` + Status string `json:"status,omitempty"` + Version string `json:"version,omitempty"` + Name string `json:"name,omitempty"` + Label string `json:"label,omitempty"` + Org string `json:"org,omitempty"` + Role string `json:"role,omitempty"` + Email string `json:"email,omitempty"` + Area string `json:"area,omitempty"` + Country string `json:"country,omitempty"` + Zone string `json:"zone,omitempty"` + Mode string `json:"mode,omitempty"` + Host string `json:"host,omitempty"` + Movements string `json:"movements,omitempty"` + ProductUID string `json:"product,omitempty"` + DeviceUID string `json:"device,omitempty"` + RouteUID string `json:"route,omitempty"` + Files *[]string `json:"files,omitempty"` + Names *[]string `json:"names,omitempty"` + FileInfo *map[string]note.NotefileInfo `json:"info,omitempty"` + FileDesc *[]note.NotefileDesc `json:"desc,omitempty"` + Notes *map[string]note.Info `json:"notes,omitempty"` + Pad int32 `json:"pad,omitempty"` + Storage int32 `json:"storage,omitempty"` + LocationOLC string `json:"olc,omitempty"` + Latitude float64 `json:"lat,omitempty"` + Longitude float64 `json:"lon,omitempty"` + LocationTime int64 `json:"ltime,omitempty"` + Value float64 `json:"value,omitempty"` + ValueV string `json:"vvalue,omitempty"` + SN string `json:"sn,omitempty"` + APN string `json:"apn,omitempty"` + Text string `json:"text,omitempty"` + Base int32 `json:"base,omitempty"` + Offset int32 `json:"offset,omitempty"` + Length int32 `json:"length,omitempty"` + Total int32 `json:"total,omitempty"` + BytesSent uint32 `json:"bytes_sent,omitempty"` + BytesReceived uint32 `json:"bytes_received,omitempty"` + BytesSentSecondary uint32 `json:"bytes_sent_secondary,omitempty"` + BytesReceivedSecondary uint32 `json:"bytes_received_secondary,omitempty"` + NotesSent uint32 `json:"notes_sent,omitempty"` + NotesReceived uint32 `json:"notes_received,omitempty"` + SessionsStandard uint32 `json:"sessions_standard,omitempty"` + SessionsSecure uint32 `json:"sessions_secure,omitempty"` + Megabytes uint32 `json:"megabytes,omitempty"` + BytesPerDay int32 `json:"bytes_per_day,omitempty"` + DataRate float64 `json:"rate,omitempty"` + NumBytes int32 `json:"bytes,omitempty"` + Template bool `json:"template,omitempty"` + Allow bool `json:"allow,omitempty"` + Align bool `json:"align,omitempty"` + Limit bool `json:"limit,omitempty"` + Pending bool `json:"pending,omitempty"` + Charging bool `json:"charging,omitempty"` + On bool `json:"on,omitempty"` + Off bool `json:"off,omitempty"` + ReqTime bool `json:"reqtime,omitempty"` + ReqLoc bool `json:"reqloc,omitempty"` + Trace string `json:"trace,omitempty"` + Usage *[]string `json:"usage,omitempty"` + State *[]PinState `json:"state,omitempty"` + Time int64 `json:"time,omitempty"` // Time is defined as uint32 on Notecard and int64 on Notehub. See the rationale below. + Motion uint32 `json:"motion,omitempty"` + VMin float64 `json:"vmin,omitempty"` + VMax float64 `json:"vmax,omitempty"` + VAvg float64 `json:"vavg,omitempty"` + Daily float64 `json:"daily,omitempty"` + Weekly float64 `json:"weekly,omitempty"` + Montly float64 `json:"monthly,omitempty"` + Verify bool `json:"verify,omitempty"` + Confirm bool `json:"confirm,omitempty"` + Port int32 `json:"port,omitempty"` + Set bool `json:"set,omitempty"` + Reset bool `json:"reset,omitempty"` + Flag bool `json:"flag,omitempty"` + Calibration float64 `json:"calibration,omitempty"` + Heartbeat bool `json:"heartbeat,omitempty"` + Threshold int32 `json:"threshold,omitempty"` + Count uint32 `json:"count,omitempty"` + Sync bool `json:"sync,omitempty"` + Live bool `json:"live,omitempty"` + Now bool `json:"now,omitempty"` + Type int32 `json:"type,omitempty"` + Number int64 `json:"number,omitempty"` + SKU string `json:"sku,omitempty"` + OrderingCode string `json:"ordering_code,omitempty"` + Board string `json:"board,omitempty"` + Net *NetInfo `json:"net,omitempty"` + Sensitivity int32 `json:"sensitivity,omitempty"` + Requested int32 `json:"requested,omitempty"` + Completed int32 `json:"completed,omitempty"` + WiFi bool `json:"wifi,omitempty"` + Cell bool `json:"cell,omitempty"` + GPS bool `json:"gps,omitempty"` + LoRa bool `json:"lora,omitempty"` + NTN bool `json:"ntn,omitempty"` + Inbound int32 `json:"inbound,omitempty"` + InboundV string `json:"vinbound,omitempty"` + Outbound int32 `json:"outbound,omitempty"` + OutboundV string `json:"voutbound,omitempty"` + Duration int32 `json:"duration,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Pressure float64 `json:"pressure,omitempty"` + Humidity float64 `json:"humidity,omitempty"` + MinVersion string `json:"minver,omitempty"` + SSID string `json:"ssid,omitempty"` + Password string `json:"password,omitempty"` + Security string `json:"security,omitempty"` + Key string `json:"key,omitempty"` + Method string `json:"method,omitempty"` + Content string `json:"content,omitempty"` + Min int32 `json:"min,omitempty"` + Add int32 `json:"add,omitempty"` + Encrypt bool `json:"encrypt,omitempty"` + Decrypt bool `json:"decrypt,omitempty"` + Alt bool `json:"alt,omitempty"` + Scan bool `json:"scan,omitempty"` + Journey bool `json:"journey,omitempty"` + UOff bool `json:"uoff,omitempty"` + UMin bool `json:"umin,omitempty"` + UPeriodic bool `json:"uperiodic,omitempty"` + Milliseconds int32 `json:"ms,omitempty"` + Full bool `json:"full,omitempty"` + Async bool `json:"async,omitempty"` + Binary bool `json:"binary,omitempty"` + Cobs int32 `json:"cobs,omitempty"` + Append bool `json:"append,omitempty"` + Details *map[string]interface{} `json:"details,omitempty"` + Tower *note.TowerLocation `json:"tower,omitempty"` + Change float64 `json:"change,omitempty"` + Format string `json:"format,omitempty"` + Voltage float64 `json:"voltage,omitempty"` + MilliampHours float64 `json:"milliamp_hours,omitempty"` + Default bool `json:"default,omitempty"` + In bool `json:"in,omitempty"` +} + +func (req *Request) Error() error { + if req.Err != "" { + return errors.New(req.Err) + } + return nil +} + +// A Note on Time +// The Notecard protocol communicates the Time value as a uint32. However, this is non-standard and problematic for Notehub which would have to +// constantly cast it to the modern Unix standard of int64 (i.e., the time_t type in Posix C libraries.) +// In older OSes, Unix time (time_t) was int32. It was never defined as a uint32. +// Thus by converting the uint32 to int64, it may allow us to delay the 2038 problem to 2106. + +// PinState describes the state of an AUX pin for hardware-related Notecard requests +type PinState struct { + High bool `json:"high,omitempty"` + Low bool `json:"low,omitempty"` + Count []uint32 `json:"count,omitempty"` +} + +// SyncLogLevelMajor is just major events +const SyncLogLevelMajor = 0 + +// SyncLogLevelMinor is just major and minor events +const SyncLogLevelMinor = 1 + +// SyncLogLevelDetail is major, minor, and detailed events +const SyncLogLevelDetail = 2 + +// SyncLogLevelProg is everything plus programmatically-targeted +const SyncLogLevelProg = 3 + +// SyncLogLevelAll is all events +const SyncLogLevelAll = SyncLogLevelProg + +// SyncLogLevelNone is no events +const SyncLogLevelNone = -1 + +// SyncLogNotefile is the special notefile containing sync log info +const SyncLogNotefile = "_synclog.qi" + +// SyncLogBody is the data structure used in the SyncLogNotefile +type SyncLogBody struct { + TimeSecs int64 `json:"time,omitempty"` + BootMs int64 `json:"sequence,omitempty"` + DetailLevel uint32 `json:"level,omitempty"` + Subsystem string `json:"subsystem,omitempty"` + Text string `json:"text,omitempty"` +} diff --git a/note-go/notecard/serial-default.go b/note-go/notecard/serial-default.go new file mode 100644 index 0000000..4690c47 --- /dev/null +++ b/note-go/notecard/serial-default.go @@ -0,0 +1,87 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "strings" + + "go.bug.st/serial/enumerator" +) + +// Notecard's USB VID/PID +const ( + bluesincVID = "30A4" + notecardPID = "0001" +) + +// Get the default serial device +func defaultSerialDefault() (device string, speed int) { + // Enum all ports + speed = 115200 + ports, err2 := enumerator.GetDetailedPortsList() + if err2 != nil { + return + } + if len(ports) == 0 { + return + } + + // First, look for the notecard + for _, port := range ports { + if port.IsUSB { + if strings.EqualFold(port.VID, bluesincVID) && strings.EqualFold(port.PID, notecardPID) { + device = port.Name + return + } + } + } + + // Otherwise, look for anything from Blues + for _, port := range ports { + if port.IsUSB && strings.EqualFold(port.VID, bluesincVID) { + device = port.Name + return + } + } + + // Not found + return +} + +// Set or display the serial port +func defaultSerialPortEnum() (allports []string, usbports []string, notecardports []string, err error) { + // Enum all ports + ports, err2 := enumerator.GetDetailedPortsList() + if err2 != nil { + err = err2 + return + } + if len(ports) == 0 { + return + } + + // First, look for the notecard + for _, port := range ports { + allports = append(allports, port.Name) + if port.IsUSB { + usbports = append(usbports, port.Name) + if strings.EqualFold(port.VID, bluesincVID) && strings.EqualFold(port.PID, notecardPID) { + notecardports = append(notecardports, port.Name) + } + } + } + + // Otherwise, look for anything from Blues + if len(notecardports) == 0 { + for _, port := range ports { + if port.IsUSB && strings.EqualFold(port.VID, bluesincVID) { + notecardports = append(notecardports, port.Name) + } + } + } + + // Done + return +} diff --git a/note-go/notecard/serial-unix.go b/note-go/notecard/serial-unix.go new file mode 100644 index 0000000..c1c7de7 --- /dev/null +++ b/note-go/notecard/serial-unix.go @@ -0,0 +1,17 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +//go:build !windows + +package notecard + +// Get the default serial device +func serialDefault() (device string, speed int) { + return defaultSerialDefault() +} + +// Set or display the serial port +func serialPortEnum() (allports []string, usbports []string, notecardports []string, err error) { + return defaultSerialPortEnum() +} diff --git a/note-go/notecard/serial-windows.go b/note-go/notecard/serial-windows.go new file mode 100644 index 0000000..770871c --- /dev/null +++ b/note-go/notecard/serial-windows.go @@ -0,0 +1,24 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +//go:build windows + +// If you have odd serial port behavior (where responses are apparently lost or delayed), try this: +// 1) open Control Panel -> Device Manager -> Ports (COM & LPT) +// 2) right-click for USB Serial Device Properties on the appropriate port +// 3) Port Settings tab +// 4) Click Advanced... button +// 5) UN-CHECK "Use FIFO buffers" + +package notecard + +// Get the default serial device +func serialDefault() (device string, speed int) { + return defaultSerialDefault() +} + +// Set or display the serial port +func serialPortEnum() (allports []string, usbports []string, notecardports []string, err error) { + return defaultSerialPortEnum() +} diff --git a/note-go/notecard/test.go b/note-go/notecard/test.go new file mode 100644 index 0000000..d2a4f61 --- /dev/null +++ b/note-go/notecard/test.go @@ -0,0 +1,98 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +// CardTest is a structure that is returned by the notecard after completing its self-test +type CardTest struct { + DeviceUID string `json:"device,omitempty"` + Error string `json:"err,omitempty"` + Status string `json:"status,omitempty"` + Tests string `json:"tests,omitempty"` + FailTest string `json:"fail_test,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + Info string `json:"info,omitempty"` + BoardVersion uint32 `json:"board,omitempty"` + BoardType uint32 `json:"board_type,omitempty"` + Modem string `json:"modem,omitempty"` + ICCID string `json:"iccid,omitempty"` + ICCID2 string `json:"iccid2,omitempty"` + IMSI string `json:"imsi,omitempty"` + IMSI2 string `json:"imsi2,omitempty"` + IMEI string `json:"imei,omitempty"` + Apn string `json:"apn,omitempty"` + Band string `json:"band,omitempty"` + Channel string `json:"channel,omitempty"` + When uint32 `json:"when,omitempty"` + SKU string `json:"sku,omitempty"` + OrderingCode string `json:"ordering_code,omitempty"` + DefaultProductUID string `json:"default_product,omitempty"` + SIMActivationKey string `json:"key,omitempty"` + SIMless bool `json:"simless,omitempty"` + Station string `json:"station,omitempty"` + Operator string `json:"operator,omitempty"` + Check uint32 `json:"check,omitempty"` + CellUsageBytes uint32 `json:"cell_used,omitempty"` + CellProvisionedTime uint32 `json:"cell_provisioned,omitempty"` + // Firmware info + FirmwareOrg string `json:"org,omitempty"` + FirmwareProduct string `json:"product,omitempty"` + FirmwareVersion string `json:"version,omitempty"` + FirmwareMajor uint32 `json:"ver_major,omitempty"` + FirmwareMinor uint32 `json:"ver_minor,omitempty"` + FirmwarePatch uint32 `json:"ver_patch,omitempty"` + FirmwareBuild uint32 `json:"ver_build,omitempty"` + FirmwareBuilt string `json:"built,omitempty"` + // Certificate and cert info + CertSN string `json:"certsn,omitempty"` + Cert string `json:"cert,omitempty"` + // Card initialization requests + SetupRequests string `json:"setup,omitempty"` + // Detailed information about LSE stability + LSEStability string `json:"lse,omitempty"` + // LoRa notecard provisioning info + DevEui string `json:"deveui,omitempty"` + AppEui string `json:"appeui,omitempty"` + AppKey string `json:"appkey,omitempty"` + FreqPlan string `json:"freqplan,omitempty"` + LWVersion string `json:"lorawan,omitempty"` + PHVersion string `json:"regional,omitempty"` + // For manufacturing + CPN string `json:"cpn,omitempty"` + // For Iridium + IriSku string `json:"iri_sku,omitempty"` + IriSn string `json:"iri_sn,omitempty"` + IriImei string `json:"iri_imei,omitempty"` + IriIccid string `json:"iri_iccid,omitempty"` + // For Starnote + Hardware string `json:"hardware,omitempty"` + Mtu uint16 `json:"mtu,omitempty"` + DownMtu uint16 `json:"down_mtu,omitempty"` + UpMtu uint16 `json:"up_mtu,omitempty"` + Policy string `json:"policy,omitempty"` + Cid uint32 `json:"cid,omitempty"` +} + +// Remove fields that are not useful or are sensitive when externalizing for public consumption +func CardTestExternalized(ct CardTest) CardTest { + ct.BoardVersion = 0 // distracting + ct.BoardType = 0 // distracting + ct.SIMActivationKey = "" // security + ct.Station = "" // privacy + ct.Operator = "" // privacy + ct.Check = 0 // invalid after externalizing + ct.FirmwareOrg = "" // distracting + ct.FirmwareProduct = "" // distracting + ct.FirmwareMajor = 0 // distracting + ct.FirmwareMinor = 0 // distracting + ct.FirmwarePatch = 0 // distracting + ct.FirmwareBuild = 0 // distracting + ct.FirmwareBuilt = "" // distracting + ct.CertSN = "" // security + ct.Cert = "" // security + ct.LSEStability = "" // distracting + ct.SetupRequests = "" // security + ct.LSEStability = "" // distracting + return ct +} diff --git a/note-go/notecard/trace.go b/note-go/notecard/trace.go new file mode 100644 index 0000000..24ffe36 --- /dev/null +++ b/note-go/notecard/trace.go @@ -0,0 +1,106 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" +) + +// The time when the last read began +var ( + readBeganMs = 0 + inputHandlerActive = false +) + +// Trace the incoming serial output AND connect the input handler +func (context *Context) Trace() (err error) { + + // Tracing only works for USB and AUX ports + if context.traceOpenFn == nil { + return fmt.Errorf("tracing is not available on this port") + } + + // Ensure that we have a reservation + err = context.ReopenIfRequired(context.portConfig) + if err != nil { + return err + } + + // Open the trace port + err = context.traceOpenFn(context) + if err != nil { + cardReportError(context, err) + return + } + + // Spawn the input handler + if !inputHandlerActive { + go inputHandler(context) + } + + // Loop, echoing to the console + for { + + buf, err := context.traceReadFn(context) + if err != nil { + cardReportError(context, err) + time.Sleep(2 * time.Second) + continue + } + + if len(buf) > 0 { + fmt.Printf("%s", buf) + } + + } + +} + +// Watch for console input +func inputHandler(context *Context) { + // Mark as active, in case we invoke this multiple times + inputHandlerActive = true + + // Create a scanner to watch stdin + scanner := bufio.NewScanner(os.Stdin) + var message string + + for { + + scanner.Scan() + message = scanner.Text() + + if strings.HasPrefix(message, "^") { + if !context.portIsOpen { + for _, r := range message[1:] { + switch { + // 'a' - 'z' + case 97 <= r && r <= 122: + ba := make([]byte, 1) + ba[0] = byte(r - 96) + context.traceWriteFn(context, ba) + // 'A' - 'Z' + case 65 <= r && r <= 90: + ba := make([]byte, 1) + ba[0] = byte(r - 64) + context.traceWriteFn(context, ba) + } + } + } + } else { + // Send the command to the module + if !context.portIsOpen { + time.Sleep(250 * time.Millisecond) + } else { + context.traceWriteFn(context, []byte(message)) + context.traceWriteFn(context, []byte("\n")) + } + } + } +} diff --git a/note-go/notecard/ua.go b/note-go/notecard/ua.go new file mode 100644 index 0000000..20bfeda --- /dev/null +++ b/note-go/notecard/ua.go @@ -0,0 +1,49 @@ +// Copyright 2017 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notecard + +import ( + "fmt" + "runtime" + + "github.com/shirou/gopsutil/v3/cpu" + /* "github.com/shirou/gopsutil/v3/host" // Deprecated */ + "github.com/shirou/gopsutil/v3/mem" +) + +// UserAgent generates a User Agent object for a given interface +func (context *Context) UserAgent() (ua map[string]interface{}) { + ua = map[string]interface{}{} + ua["agent"] = "note-go" + ua["compiler"] = fmt.Sprintf("%s %s/%s", runtime.Version(), runtime.GOOS, runtime.GOARCH) + + ua["req_interface"] = context.iface + if context.isSerial { + ua["req_port"] = context.serialName + } else { + ua["req_port"] = context.port + } + + m, _ := mem.VirtualMemory() + ua["cpu_mem"] = m.Total + + c, _ := cpu.Info() + if len(c) >= 1 { + ua["cpu_mhz"] = int(c[0].Mhz) + ua["cpu_cores"] = int(c[0].Cores) + ua["cpu_vendor"] = c[0].VendorID + ua["cpu_name"] = c[0].ModelName + } + + /* Deprecated + h, _ := host.Info() + ua["os_name"] = h.OS // freebsd, linux + ua["os_platform"] = h.Platform // ubuntu, linuxmint + ua["os_family"] = h.PlatformFamily // debian, rhel + ua["os_version"] = h.PlatformVersion // complete OS version + */ + + return +} diff --git a/note-go/notehub/api/billing.go b/note-go/notehub/api/billing.go new file mode 100644 index 0000000..bd4f2c2 --- /dev/null +++ b/note-go/notehub/api/billing.go @@ -0,0 +1,15 @@ +package api + +// GetBillingAccountResponse v1 +// +// The response object for getting a billing account. +type GetBillingAccountResponse struct { + UID string `json:"uid"` + Name string `json:"name"` + // "billing_admin", "billing_manager", or "project_creator" + Role string `json:"role"` +} + +type GetBillingAccountsResponse struct { + BillingAccounts []GetBillingAccountResponse `json:"billing_accounts"` +} diff --git a/note-go/notehub/api/devices.go b/note-go/notehub/api/devices.go new file mode 100644 index 0000000..8abd504 --- /dev/null +++ b/note-go/notehub/api/devices.go @@ -0,0 +1,165 @@ +package api + +import ( + "strings" + + "github.com/blues/note-go/note" +) + +// GetDevicesResponse v1 +// +// The response object for getting devices. +type GetDevicesResponse struct { + Devices []GetDeviceResponse `json:"devices"` + HasMore bool `json:"has_more"` +} + +// Part of the response object for a device. +type DeviceHealthLogEntry struct { + When string `json:"when"` + Text string `json:"text"` + Alert bool `json:"alert"` +} + +// DeviceResponse v1 +// +// The response object for a device. +type GetDeviceResponse struct { + UID string `json:"uid"` + SerialNumber string `json:"serial_number,omitempty"` + SKU string `json:"sku,omitempty"` + + // RFC3339 timestamps, in UTC. + Provisioned string `json:"provisioned"` + LastActivity *string `json:"last_activity"` + + FirmwareHost string `json:"firmware_host,omitempty"` + FirmwareNotecard string `json:"firmware_notecard,omitempty"` + + Contact *ContactResponse `json:"contact,omitempty"` + + ProductUID string `json:"product_uid"` + FleetUIDs []string `json:"fleet_uids"` + + TowerInfo *TowerInformation `json:"tower_info,omitempty"` + TowerLocation *Location `json:"tower_location,omitempty"` + GPSLocation *Location `json:"gps_location,omitempty"` + TriangulatedLocation *Location `json:"triangulated_location,omitempty"` + BestLocation *Location `json:"best_location,omitempty"` + + Voltage float64 `json:"voltage"` + Temperature float64 `json:"temperature"` + DFUEnv *note.DFUEnv `json:"dfu,omitempty"` + Disabled bool `json:"disabled,omitempty"` + Tags string `json:"tags,omitempty"` + + // Activity + RecentActivityBase string `json:"recent_event_base,omitempty"` + RecentEventCount []int `json:"recent_event_count,omitempty"` + RecentSessionCount []int `json:"recent_session_count,omitempty"` + RecentSessionSeconds []int `json:"recent_session_seconds,omitempty"` + + // Health + HealthLog []DeviceHealthLogEntry `json:"health_log,omitempty"` +} + +// GetDevicesPublicKeysResponse v1 +// +// The response object for retrieving a collection of devices' public keys +type GetDevicesPublicKeysResponse struct { + DevicePublicKeys []DevicePublicKey `json:"device_public_keys"` + HasMore bool `json:"has_more"` +} + +// DevicePublicKey v1 +// +// A structure representing the public key for a specific device +type DevicePublicKey struct { + UID string `json:"uid"` + PublicKey string `json:"key"` +} + +// ProvisionDeviceRequest v1 +// +// The request object for provisioning a device +type ProvisionDeviceRequest struct { + ProductUID string `json:"product_uid"` + DeviceSN string `json:"device_sn"` + FleetUIDs *[]string `json:"fleet_uids,omitempty"` +} + +// GetDeviceLatestResponse v1 +// +// The response object for retrieving the latest notefile values for a device +type GetDeviceLatestResponse struct { + LatestEvents []note.Event `json:"latest_events"` +} + +// Location v1 +// +// The response object for a location. +type Location struct { + When string `json:"when"` + Name string `json:"name"` + Country string `json:"country"` + Timezone string `json:"timezone"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` +} + +// TowerInformation v1 +// +// The response object for tower information. +type TowerInformation struct { + // Mobile Country Code + MCC int `json:"mcc"` + // Mobile Network Code + MNC int `json:"mnc"` + // Location Area Code + LAC int `json:"lac"` + CellID int `json:"cell_id"` +} + +// GetDeviceHealthLogResponse v1 +// +// The response object for getting a device's health log. +type GetDeviceHealthLogResponse struct { + HealthLog []HealthLogEntry `json:"health_log"` +} + +// HealthLogEntry v1 +// +// The response object for a health log entry. +type HealthLogEntry struct { + When string `json:"when"` + Alert bool `json:"alert"` + Text string `json:"text"` +} + +var allDfuPhases = []note.DfuPhase{ + note.DfuPhaseUnknown, + note.DfuPhaseIdle, + note.DfuPhaseError, + note.DfuPhaseDownloading, + note.DfuPhaseSideloading, + note.DfuPhaseReady, + note.DfuPhaseReadyRetry, + note.DfuPhaseUpdating, + note.DfuPhaseCompleted, +} + +func ParseDfuPhase(phase string) note.DfuPhase { + phase = strings.ToLower(phase) + for _, validPhase := range allDfuPhases { + if phase == string(validPhase) { + return validPhase + } + } + return note.DfuPhaseUnknown +} + +func IsDfuTerminal(phase note.DfuPhase) bool { + return phase == note.DfuPhaseError || + phase == note.DfuPhaseCompleted || + phase == note.DfuPhaseIdle +} diff --git a/note-go/notehub/api/environment_variables.go b/note-go/notehub/api/environment_variables.go new file mode 100644 index 0000000..484bd09 --- /dev/null +++ b/note-go/notehub/api/environment_variables.go @@ -0,0 +1,206 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +// GetAppEnvironmentVariablesResponse v1 +// +// The response object for getting app environment variables. +type GetAppEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this app. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutAppEnvironmentVariablesRequest v1 +// +// The request object for setting app environment variables. +type PutAppEnvironmentVariablesRequest struct { + // EnvironmentVariables + // + // The environment variables scoped at the app level + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutAppEnvironmentVariablesResponse v1 +// +// The response object for setting app environment variables. +type PutAppEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this app. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// DeleteAppEnvironmentVariableResponse v1 +// +// The response object for deleting an app environment variable. +type DeleteAppEnvironmentVariableResponse struct { + // EnvironmentVariables + // + // The environment variables for this app. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// GetFleetEnvironmentVariablesResponse v1 +// +// The response object for getting fleet environment variables. +type GetFleetEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this fleet. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutFleetEnvironmentVariablesRequest v1 +// +// The request object for setting fleet environment variables. +type PutFleetEnvironmentVariablesRequest struct { + // EnvironmentVariables + // + // The environment variables scoped at the fleet level + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutFleetEnvironmentVariablesResponse v1 +// +// The response object for setting fleet environment variables. +type PutFleetEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this fleet. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// DeleteFleetEnvironmentVariableResponse v1 +// +// The response object for deleting an fleet environment variable. +type DeleteFleetEnvironmentVariableResponse struct { + // EnvironmentVariables + // + // The environment variables for this fleet. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// GetDeviceEnvironmentVariablesResponse v1 +// +// The response object for getting device environment variables. +type GetDeviceEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this device that have been set using host firmware or the Notehub API or UI. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` + + // EnvironmentVariablesEnvDefault + // + // The environment variables that have been set using the env.default request through the Notecard API. + // + // required: true + EnvironmentVariablesEnvDefault map[string]string `json:"environment_variables_env_default"` + + // EnvironmentVariablesEffective + // + // The environment variables for the device as though they were fully resolved by resolution rules + // + // required: true + EnvironmentVariablesEffective map[string]string `json:"environment_variables_effective"` +} + +// PutDeviceEnvironmentVariablesRequest v1 +// +// The request object for setting device environment variables. +type PutDeviceEnvironmentVariablesRequest struct { + // EnvironmentVariables + // + // The environment variables scoped at the device level + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutDeviceEnvironmentVariablesResponse v1 +// +// The response object for setting device environment variables. +type PutDeviceEnvironmentVariablesResponse struct { + // EnvironmentVariables + // + // The environment variables for this device that have been set using host firmware or the Notehub API or UI. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// DeleteDeviceEnvironmentVariableResponse v1 +// +// The response object for deleting a device environment variable. +type DeleteDeviceEnvironmentVariableResponse struct { + // EnvironmentVariables + // + // The environment variables for this device that have been set using host firmware or the Notehub API or UI. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// GetDeviceEnvironmentVariablesWithPINResponse v1 +// +// The response object for getting device environment variables with a PIN. +type GetDeviceEnvironmentVariablesWithPINResponse struct { + // EnvironmentVariables + // + // The environment variables for this device that have been set using host firmware or the Notehub API or UI. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` + + // EnvironmentVariablesEnvDefault + // + // The environment variables that have been set using the env.default request through the Notecard API. + // + // required: true + EnvironmentVariablesEnvDefault map[string]string `json:"environment_variables_env_default"` +} + +// PutDeviceEnvironmentVariablesWithPINRequest v1 +// +// The request object for setting device environment variables with a PIN. (The PIN comes in via a header) +type PutDeviceEnvironmentVariablesWithPINRequest struct { + // EnvironmentVariables + // + // The environment variables scoped at the device level + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} + +// PutDeviceEnvironmentVariablesWithPINResponse v1 +// +// The response object for setting device environment variables with a PIN. +type PutDeviceEnvironmentVariablesWithPINResponse struct { + // EnvironmentVariables + // + // The environment variables for this device that have been set using host firmware or the Notehub API or UI. + // + // required: true + EnvironmentVariables map[string]string `json:"environment_variables"` +} diff --git a/note-go/notehub/api/error_defaults.go b/note-go/notehub/api/error_defaults.go new file mode 100644 index 0000000..18172f6 --- /dev/null +++ b/note-go/notehub/api/error_defaults.go @@ -0,0 +1,88 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +import "net/http" + +// ErrNotFound returns the default for an HTTP 404 NotFound +func ErrNotFound() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusNotFound), + Error: "The requested resource could not be found", + Code: http.StatusNotFound, + } +} + +// ErrUnauthorized returns the default for an HTTP 401 Unauthorized +func ErrUnauthorized() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusUnauthorized), + Error: "The request could not be authorized", + Code: http.StatusUnauthorized, + } +} + +// ErrForbidden returns the default for an HTTP 403 Forbidden +func ErrForbidden() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusForbidden), + Error: "The requested action was forbidden", + Code: http.StatusForbidden, + } +} + +// ErrMethodNotAllowed returns the default for an HTTP 405 Method Not Allowed +func ErrMethodNotAllowed() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusMethodNotAllowed), + Error: "Method not allowed on this endpoint", + Code: http.StatusMethodNotAllowed, + } +} + +// ErrInternalServerError returns the default for an HTTP 500 InternalServerError +func ErrInternalServerError() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusInternalServerError), + Error: "An internal server error occurred", + Code: http.StatusInternalServerError, + } +} + +// ErrEventsQueryTimeout returns the default for a GetEvents (and related) request that took too long +func ErrEventsQueryTimeout() ErrorResponse { + return ErrorResponse{ + Status: "Took too long", + Error: "Events query took too long to complete", + Code: http.StatusGatewayTimeout, + } +} + +// ErrBadRequest returns the default for an HTTP 400 BadRequest +func ErrBadRequest() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusBadRequest), + Error: "The request was malformed or contained invalid parameters", + Code: http.StatusBadRequest, + } +} + +// ErrUnsupportedMediaType returns the default for an HTTP 415 UnsupportedMediaType +func ErrUnsupportedMediaType() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusUnsupportedMediaType), + Error: "The request is using an unknown content type", + Code: http.StatusUnsupportedMediaType, + } +} + +// ErrConflict returns the default for an HTTP 409 Conflict +func ErrConflict() ErrorResponse { + return ErrorResponse{ + Status: http.StatusText(http.StatusConflict), + Error: "The resource could not be created due to a conflict", + Code: http.StatusConflict, + } +} diff --git a/note-go/notehub/api/errors.go b/note-go/notehub/api/errors.go new file mode 100644 index 0000000..7c36908 --- /dev/null +++ b/note-go/notehub/api/errors.go @@ -0,0 +1,80 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +import ( + "net/http" +) + +// ErrorResponse v1 +// +// The structure returned from HTTPS API calls when there is an error. +type ErrorResponse struct { + // Error represents the human readable error message. + // + // required: true + // type: string + Error string `json:"err"` + + // Code represents the standard status code + // + // required: true + // type: int + Code int `json:"code"` + + // Status is the machine readable string representation of the error code. + // + // required: true + // type: string + Status string `json:"status"` + + // Request is the request that was made that resulted in error. The url path would be sufficient. + // + // required: false + // type: string + Request string `json:"request,omitempty"` + + // Details are any additional information about the request that would be nice to in the response. + // The request body would be nice especially if there are a lot of parameters. + // + // required: false + // type: object + Details map[string]interface{} `json:"details,omitempty"` + + // Debug is any customer-facing information to aid in debugging. + // + // required: false + // type: string + Debug string `json:"debug,omitempty"` +} + +var SuspendedBillingAccountResponse = ErrorResponse{ + Code: http.StatusForbidden, + Status: "Forbidden", + Error: "this billing account is suspended", +} + +// WithRequest is a an easy way to add http.Request information to an error. +// It takes a http.Request object, parses the URI string into response.Request +// and adds the request Body (if it exists) into the response.Details["body"] as a string +func (e ErrorResponse) WithRequest(r *http.Request) ErrorResponse { + e.Request = r.RequestURI + if len(e.Details) == 0 { + e.Details = make(map[string]interface{}) + } + return e +} + +// WithError adds an error string from an error object into the response. +func (e ErrorResponse) WithError(err error) ErrorResponse { + e.Error = err.Error() + return e +} + +// WithDebug adds a debug string onto the error response object +func (e ErrorResponse) WithDebug(msg string) ErrorResponse { + e.Debug = msg + return e +} diff --git a/note-go/notehub/api/events.go b/note-go/notehub/api/events.go new file mode 100644 index 0000000..79dde42 --- /dev/null +++ b/note-go/notehub/api/events.go @@ -0,0 +1,30 @@ +package api + +import "github.com/blues/note-go/note" + +// GetEventsResponse v1 +// +// The response object for getting events. +type GetEventsResponse struct { + Events []note.Event `json:"events"` + Through string `json:"through,omitempty"` + HasMore bool `json:"has_more"` +} + +// GetEventsResponseSelectedFields v1 +// +// The response object for getting events with selected fields. +type GetEventsResponseSelectedFields struct { + Events []note.Event `json:"events"` + Through string `json:"through,omitempty"` + HasMore bool `json:"has_more"` +} + +// GetEventsByCursorResponse v1 +// +// The response object for getting events by cursor. +type GetEventsByCursorResponse struct { + Events []note.Event `json:"events"` + NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` +} diff --git a/note-go/notehub/api/fleet.go b/note-go/notehub/api/fleet.go new file mode 100644 index 0000000..df389ac --- /dev/null +++ b/note-go/notehub/api/fleet.go @@ -0,0 +1,78 @@ +package api + +// GetFleetsResponse v1 +// +// The response object for getting fleets. +type GetFleetsResponse struct { + Fleets []FleetResponse `json:"fleets"` +} + +// FleetResponse v1 +// +// The response object for a fleet. +type FleetResponse struct { + UID string `json:"uid"` + Label string `json:"label"` + // RFC3339 timestamp, in UTC. + Created string `json:"created"` + + EnvironmentVariables map[string]string `json:"environment_variables"` + + SmartRule string `json:"smart_rule,omitempty"` + WatchdogMins int64 `json:"watchdog_mins,omitempty"` + ConnectivityAssurance FleetConnectivityAssurance `json:"connectivity_assurance,omitempty"` +} + +// PutDeviceFleetsRequest v1 +// +// The request object for adding a device to fleets +type PutDeviceFleetsRequest struct { + // FleetUIDs + // + // The fleets the device belong to + // + // required: true + FleetUIDs []string `json:"fleet_uids"` +} + +// DeleteDeviceFleetsRequest v1 +// +// The request object for removing a device from fleets +type DeleteDeviceFleetsRequest struct { + // FleetUIDs + // + // The fleets the device should be disassociated from + // + // required: true + FleetUIDs []string `json:"fleet_uids"` +} + +// PostFleetRequest v1 +// +// The request object for adding a fleet for a project +type PostFleetRequest struct { + Label string `json:"label"` + + SmartRule string `json:"smart_rule,omitempty"` + WatchdogMins int64 `json:"watchdog_mins,omitempty"` +} + +// PutFleetRequest v1 +// +// The request object for updating a fleet within a project +type PutFleetRequest struct { + Label string `json:"label"` + AddDevices []string `json:"addDevices,omitempty"` + RemoveDevices []string `json:"removeDevices,omitempty"` + + SmartRule string `json:"smart_rule,omitempty"` + WatchdogMins int64 `json:"watchdog_mins,omitempty"` +} + +// FleetConnectivityAssurance v1 +// +// Includes, Enabled = Whether Connectivity Assurance is enabled for this fleet +// With flexibility to add more information in the future +type FleetConnectivityAssurance struct { + Enabled bool `json:"enabled"` +} diff --git a/note-go/notehub/api/job.go b/note-go/notehub/api/job.go new file mode 100644 index 0000000..f52aefa --- /dev/null +++ b/note-go/notehub/api/job.go @@ -0,0 +1,45 @@ +// Copyright 2025 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +// This header is present for every type of job +type HubJob struct { + Type HubJobType `json:"type,omitempty"` + Version string `json:"version,omitempty"` + Name string `json:"name,omitempty"` + Comment string `json:"comment,omitempty"` + Created int64 `json:"created,omitempty"` + CreatedBy string `json:"created_by,omitempty"` +} + +// This header is present for every type of report +type HubJobReport struct { + Type HubJobType `json:"type,omitempty"` + Version string `json:"version,omitempty"` + Comment string `json:"comment,omitempty"` + JobId string `json:"job_id"` + JobName string `json:"job_name"` + Status string `json:"status,omitempty"` + DryRun bool `json:"dry_run,omitempty"` + Cancel bool `json:"cancel,omitempty"` + SubmittedBy string `json:"who_submitted,omitempty"` + Submitted int64 `json:"when_submitted,omitempty"` + Started int64 `json:"when_started,omitempty"` + Updated int64 `json:"when_updated,omitempty"` + Completed int64 `json:"when_completed,omitempty"` +} + +// Types of jobs +type HubJobType string + +const ( + HubJobTypeUnspecified HubJobType = "" + HubJobTypeReconciliation HubJobType = "reconciliation" +) + +const ( + HubJobStatusCancelled = "cancelled" + HubJobStatusSubmitted = "submitted" +) diff --git a/note-go/notehub/api/job_reconciliation.go b/note-go/notehub/api/job_reconciliation.go new file mode 100644 index 0000000..b14c7f8 --- /dev/null +++ b/note-go/notehub/api/job_reconciliation.go @@ -0,0 +1,94 @@ +// Copyright 2025 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +// Current data format of the reconciliation job type. Note that major types +// require conversion, while minor types can be handled by the same code. +const HubJobReconciliationMajorVersion = 1 +const HubJobReconciliationMinorVersion = 1 + +// HubJobReconciliation is the format of a batch request file +type HubJobReconciliation struct { + Header HubJob `json:"job,omitempty"` + Comment string `json:"comment,omitempty"` + Select struct { + Comment string `json:"comment,omitempty"` + AllDevices bool `json:"all_devices,omitempty"` + DevicesInFleets []string `json:"devices_in_fleets,omitempty"` + Devices []string `json:"devices,omitempty"` + DevicesBySn []string `json:"devices_by_sn,omitempty"` + } `json:"select,omitempty"` + DefaultRequests HubJobReconciliationRequests `json:"default_requests,omitempty"` + DeviceRequests map[string]HubJobReconciliationRequests `json:"device_requests,omitempty"` + ReportOptions HubJobReconciliationReportOptions `json:"report_options,omitempty"` +} + +// HubReportReconciliation is the format of the report generated by a reconciliation job. +type HubReportReconciliation struct { + Comment string `json:"comment,omitempty"` + Header HubJobReport `json:"job,omitempty"` + Status HubJobReconciliationReportStatus `json:"status,omitempty"` + Report *HubJobReconciliationReport `json:"output,omitempty"` +} + +// HubJobReconciliationRequests is a structure defining requests to apply to a set of selected devices. +// Note that if ProvisionProductUID is specified, the device will be provisioned if it isn't already provisioned, +// else it will fail if not provisioned. Also note that sn_to_set and vars_to_set use the same syntax +// as our standard API calls in that the value '-' means to clear the value. +type HubJobReconciliationRequests struct { + Comment string `json:"comment,omitempty"` + ProvisionProductUID string `json:"provision_product,omitempty"` + Disable bool `json:"disable,omitempty"` + Enable bool `json:"enable,omitempty"` + CaDisable bool `json:"connectivity_assurance_disable,omitempty"` + CaEnable bool `json:"connectivity_assurance_enable,omitempty"` + SnToDefault string `json:"sn_to_default,omitempty"` + SnToSet string `json:"sn_to_set,omitempty"` + VarsToDefault map[string]string `json:"vars_to_default,omitempty"` + VarsToSet map[string]string `json:"vars_to_set,omitempty"` + FleetsToDefault []string `json:"fleets_to_default,omitempty"` + FleetsToJoin []string `json:"fleets_to_join,omitempty"` + FleetsToLeave []string `json:"fleets_to_leave,omitempty"` +} + +// HubJobReconciliationReportStatus is the status portion of a batch report file +type HubJobReconciliationReportStatus struct { + Error string `json:"error,omitempty"` + Errors map[string]string `json:"errors,omitempty"` + Actions map[string]string `json:"actions,omitempty"` + DeviceCount int `json:"device_count,omitempty"` + Provisioned []string `json:"provisioned,omitempty"` +} + +// HubJobReconciliationReport is the format of a batch report file +type HubJobReconciliationReport struct { + App *HubJobReconciliationAppReport `json:"project,omitempty"` + Devices map[string]HubJobReconciliationDeviceReport `json:"devices,omitempty"` +} + +// HubJobReconciliationReportOptions is a structure defining options for the report +type HubJobReconciliationReportOptions struct { + Comment string `json:"comment,omitempty"` + AppInfo bool `json:"app_info,omitempty"` + AppVars bool `json:"app_vars,omitempty"` + AppFleets bool `json:"app_fleets,omitempty"` + DeviceInfo bool `json:"device_info,omitempty"` + DeviceActivity bool `json:"device_activity,omitempty"` + DeviceHealth bool `json:"device_health,omitempty"` + DeviceVars bool `json:"device_vars,omitempty"` +} + +// HubJobReconciliationAppReport is a structure defining the app report +type HubJobReconciliationAppReport struct { + Info *GetAppResponse `json:"project_info,omitempty"` + Vars *GetAppEnvironmentVariablesResponse `json:"project_vars,omitempty"` + Fleets *GetFleetsResponse `json:"project_fleets,omitempty"` +} + +// HubJobReconciliationDeviceReport is a structure defining the device report +type HubJobReconciliationDeviceReport struct { + Info *GetDeviceResponse `json:"device_info,omitempty"` + Vars *GetDeviceEnvironmentVariablesResponse `json:"device_vars,omitempty"` +} diff --git a/note-go/notehub/api/products.go b/note-go/notehub/api/products.go new file mode 100644 index 0000000..b2b142b --- /dev/null +++ b/note-go/notehub/api/products.go @@ -0,0 +1,30 @@ +package api + +// GetProductsResponse v1 +// +// The response object for getting products. +type GetProductsResponse struct { + Products []ProductResponse `json:"products"` +} + +// ProductResponse v1 +// +// The response object for a product. +type ProductResponse struct { + UID string `json:"uid"` + Label string `json:"label"` + AutoProvisionFleets *[]string `json:"auto_provision_fleets"` + DisableDevicesByDefault bool `json:"disable_devices_by_default"` +} + +// PostProductRequest v1 +// +// The request object for adding a product. +type PostProductRequest struct { + ProductUID string `json:"product_uid"` + Label string `json:"label"` + // Not required + AutoProvisionFleets []string `json:"auto_provision_fleets"` + // Not required + DisableDevicesByDefault bool `json:"disable_devices_by_default"` +} diff --git a/note-go/notehub/api/project.go b/note-go/notehub/api/project.go new file mode 100644 index 0000000..fe003c3 --- /dev/null +++ b/note-go/notehub/api/project.go @@ -0,0 +1,40 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package api + +// GetAppResponse v1 +// +// The response object for getting an app. +type GetAppResponse struct { + UID string `json:"uid"` + Label string `json:"label"` + // RFC3339 timestamp, in UTC. + Created string `json:"created"` + + AdministrativeContact *ContactResponse `json:"administrative_contact"` + TechnicalContact *ContactResponse `json:"technical_contact"` + + // "owner", "developer", or "viewer" + Role *string `json:"role"` +} + +// ContactResponse v1 +// +// The response object for an app contact. +type ContactResponse struct { + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + Organization string `json:"organization"` +} + +// GenerateClientAppResponse v1 +// +// The response object for generating a new client app for +// a specific app +type GenerateClientAppResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} diff --git a/note-go/notehub/api/session.go b/note-go/notehub/api/session.go new file mode 100644 index 0000000..302e7f0 --- /dev/null +++ b/note-go/notehub/api/session.go @@ -0,0 +1,21 @@ +package api + +import "github.com/blues/note-go/note" + +// GetDeviceSessionsResponse is the structure returned from a GetDeviceSessions call +type GetDeviceSessionsResponse struct { + // Sessions + // + // The requested page of session logs for the device + // + // required: true + Sessions []note.DeviceSession `json:"sessions"` + + // HasMore + // + // A boolean indicating whether there is at least one more + // page of data available after this page + // + // required: true + HasMore bool `json:"has_more"` +} diff --git a/note-go/notehub/auth.go b/note-go/notehub/auth.go new file mode 100644 index 0000000..18dea81 --- /dev/null +++ b/note-go/notehub/auth.go @@ -0,0 +1,340 @@ +package notehub + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "os/signal" + "runtime" + "strings" + "time" +) + +type AccessToken struct { + Host string + Email string + AccessToken string + ExpiresAt time.Time +} + +// open opens the specified URL in the default browser of the user. +func open(url string) error { + var cmd string + var args []string + + switch runtime.GOOS { + case "windows": + cmd = "cmd" + args = []string{"/c", "start"} + case "darwin": + cmd = "open" + default: // "linux", "freebsd", "openbsd", "netbsd" + cmd = "xdg-open" + } + args = append(args, url) + return exec.Command(cmd, args...).Start() +} + +// listenOnAny tries each port in order and returns a bound net.Listener for the first available one. +func listenOnAny(ports []int) (net.Listener, int, error) { + for _, p := range ports { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) + if err == nil { + return ln, p, nil + } + } + return nil, 0, errors.New("no ports available") +} + +func randString(n int) string { + letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func RevokeAccessToken(hub, token string) error { + form := url.Values{ + "token": {token}, + "token_type_hint": {"access_token"}, + "client_id": {"notehub_cli"}, + } + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + fmt.Sprintf("https://%s/oauth2/revoke", hub), + strings.NewReader(form.Encode()), + ) + + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + // Per RFC 7009: 200 OK is returned even if the token is already revoked + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// InitiateBrowserBasedLogin starts the OAuth2 login flow by opening the user's browser. +// the `hub` parameter is the hostname of Notehub where it is assumed that an OAuth2 client +// with client ID `notehub_cli` is configured for authorization code flow. +func InitiateBrowserBasedLogin(notehubApiHost string) (*AccessToken, error) { + // this is the hard-coded OAuth client ID that's persisted in Hydra + clientId := "notehub_cli" + + if !strings.HasPrefix(notehubApiHost, "api.") { + notehubApiHost = "api." + notehubApiHost + } + + var notehubUiHost string + if notehubApiHost == "api.notefile.net" { + notehubUiHost = "notehub.io" + } else { + notehubUiHost = strings.TrimPrefix(notehubApiHost, "api.") + } + + // Try these ports in order until one is available: + // + // these ports are randomly chosen and hard-coded into + // the OAuth client in Hydra within Notehub (in the redirect_uris field) + ports := []int{58766, 58767, 58768, 58769, 42100, 42101, 42102, 42103} + + // Return values + var accessToken *AccessToken + var accessTokenErr error + + state := randString(16) + codeVerifier := randString(50) // must be at least 43 characters + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + + done := make(chan bool, 1) + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt) + defer signal.Reset(os.Interrupt) + + router := http.NewServeMux() + + // We'll fill this after we pick a port but declare it now so the handler can close over it. + chosenPort := 0 + + // The browser will be redirected to this endpoint with an authorization code + // and then this endpoint will exchange that authorization code for an access token + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + authorizationCode := r.URL.Query().Get("code") + callbackState := r.URL.Query().Get("state") + + errHandler := func(msg string) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "error: %s", msg) + fmt.Printf("error: %s\n", msg) + accessTokenErr = errors.New(msg) + } + + if callbackState != state { + errHandler("state mismatch") + return + } + + /////////////////////////////////////////// + // Exchange code for access token + /////////////////////////////////////////// + + tokenResp, err := http.Post( + (&url.URL{ + Scheme: "https", + Host: notehubUiHost, + Path: "/oauth2/token", + }).String(), + "application/x-www-form-urlencoded", + strings.NewReader(url.Values{ + "client_id": {clientId}, + "code": {authorizationCode}, + "code_verifier": {codeVerifier}, + "grant_type": {"authorization_code"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", chosenPort)}, + }.Encode()), + ) + if err != nil { + errHandler("error on /oauth2/token: " + err.Error()) + return + } + defer tokenResp.Body.Close() + + body, err := io.ReadAll(tokenResp.Body) + if err != nil { + errHandler("could not read body from /oauth2/token: " + err.Error()) + return + } + + var tokenData map[string]interface{} + if err := json.Unmarshal(body, &tokenData); err != nil { + errHandler("could not unmarshal body from /oauth2/token: " + err.Error()) + return + } + + if errCode, ok := tokenData["error"].(string); ok { + if errDescription, ok2 := tokenData["error_description"].(string); ok2 { + errHandler(fmt.Sprintf("%s: %s", errCode, errDescription)) + } else { + errHandler(errCode) + } + return + } + + accessTokenString, ok := tokenData["access_token"].(string) + if !ok { + errHandler("unexpected error: no access token returned") + return + } + + // be defensive about type + var expiresIn time.Duration + switch v := tokenData["expires_in"].(type) { + case float64: + expiresIn = time.Duration(v) * time.Second + case int: + expiresIn = time.Duration(v) * time.Second + default: + expiresIn = 0 + } + + /////////////////////////////////////////// + // Get user's information (specifically email) + /////////////////////////////////////////// + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://%s/userinfo", notehubApiHost), nil) + if err != nil { + errHandler("could not create request for /userinfo: " + err.Error()) + return + } + req.Header.Set("Authorization", "Bearer "+accessTokenString) + userinfoResp, err := http.DefaultClient.Do(req) + if err != nil { + errHandler("could not get userinfo: " + err.Error()) + return + } + defer userinfoResp.Body.Close() + + userinfoBody, err := io.ReadAll(userinfoResp.Body) + if err != nil { + errHandler("could not read body from /userinfo: " + err.Error()) + return + } + + var userinfoData map[string]interface{} + if err := json.Unmarshal(userinfoBody, &userinfoData); err != nil { + errHandler("could not unmarshal body from /userinfo: " + err.Error()) + return + } + + email, ok := userinfoData["email"].(string) + if !ok { + errHandler("could not retrieve email") + return + } + + /////////////////////////////////////////// + // Build the access token response + /////////////////////////////////////////// + + accessToken = &AccessToken{ + Host: notehubApiHost, + Email: email, + AccessToken: accessTokenString, + ExpiresAt: time.Now().Add(expiresIn), + } + + /////////////////////////////////////////// + // respond to the browser and quit + /////////////////////////////////////////// + + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "
Token exchange completed successfully
You may now close this window and return to the CLI application
") + + quit <- os.Interrupt + }) + + // Pick first available port and get a listener + listener, port, err := listenOnAny(ports) + if err != nil { + return nil, fmt.Errorf("could not bind any callback port: %w", err) + } + chosenPort = port + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", chosenPort), + Handler: router, + } + + // Wait for OAuth callback to be hit, then shutdown HTTP server + go func() { + <-quit + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil { + log.Printf("error: %v", err) + } + close(done) + }() + + // Start HTTP server waiting for OAuth callback + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("error: %v", err) + } + }() + + // Build the authorize URL using the chosen port + authorizeUrl := url.URL{ + Scheme: "https", + Host: notehubUiHost, + Path: "/oauth2/auth", + RawQuery: url.Values{ + "client_id": {clientId}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + "redirect_uri": {fmt.Sprintf("http://localhost:%d", chosenPort)}, + "response_type": {"code"}, + "scope": {"openid email"}, + "state": {state}, + }.Encode(), + } + + // Open web browser to authorize + fmt.Printf("Opening web browser to initiate authentication (redirect port %d)...\n", chosenPort) + if err := open(authorizeUrl.String()); err != nil { + fmt.Printf("error opening web browser: %v", err) + } + + // Wait for exchange to finish + <-done + return accessToken, accessTokenErr +} diff --git a/note-go/notehub/config.go b/note-go/notehub/config.go new file mode 100644 index 0000000..4bb21bb --- /dev/null +++ b/note-go/notehub/config.go @@ -0,0 +1,8 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notehub + +// DefaultAPIService (golint) +const DefaultAPIService = "api.notefile.net" diff --git a/note-go/notehub/dbquery.go b/note-go/notehub/dbquery.go new file mode 100644 index 0000000..81e613a --- /dev/null +++ b/note-go/notehub/dbquery.go @@ -0,0 +1,19 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notehub + +// DbQuery is the structure for a database query +type DbQuery struct { + Columns string `json:"columns,omitempty"` + Format string `json:"format,omitempty"` + Count bool `json:"count,omitempty"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` + NoHeader bool `json:"noheader,omitempty"` + Where string `json:"where,omitempty"` + Last string `json:"last,omitempty"` + Order string `json:"order,omitempty"` + Descending bool `json:"descending,omitempty"` +} diff --git a/note-go/notehub/request.go b/note-go/notehub/request.go new file mode 100644 index 0000000..e3b43ea --- /dev/null +++ b/note-go/notehub/request.go @@ -0,0 +1,257 @@ +// Copyright 2019 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the +// copyright holder including that found in the LICENSE file. + +package notehub + +import ( + "fmt" + "strings" + + "github.com/blues/note-go/note" + "github.com/blues/note-go/notecard" +) + +// Supported requests + +// HubDeviceContact (golint) +const HubDeviceContact = "hub.device.contact" + +// HubDeviceSessionBegin (golint) +const HubDeviceSessionBegin = "hub.device.session.begin" + +// HubDeviceSessionUsage (golint) +const HubDeviceSessionUsage = "hub.device.session.usage" + +// HubDeviceSessionEnd (golint) +const HubDeviceSessionEnd = "hub.device.session.end" + +// HubAppGetSchemas (golint) +const HubAppGetSchemas = "hub.app.schemas.get" + +// HubQuery (golint) +const HubQuery = "hub.app.data.query" + +// HubEventQuery (golint) +const HubEventQuery = "hub.app.event.query" + +// HubSessionQuery (golint) +const HubSessionQuery = "hub.app.session.query" + +// HubAppUpload (golint) +const HubAppUpload = "hub.app.upload.add" + +// HubUpload (golint) +const HubUpload = "hub.upload.add" + +// HubAppUploads (golint) +const HubAppUploads = "hub.app.upload.query" + +// HubAppJobSubmit (golint) +const HubAppJobSubmit = "hub.app.job.submit" + +// HubAppJobGet (golint) +const HubAppJobGet = "hub.app.job.get" + +// HubAppJobPut (golint) +const HubAppJobPut = "hub.app.job.put" + +// HubAppJobDelete (golint) +const HubAppJobDelete = "hub.app.job.delete" + +// HubAppJobsGet (golint) +const HubAppJobsGet = "hub.app.jobs.get" + +// HubAppReportGet (golint) +const HubAppReportGet = "hub.app.report.get" + +// HubAppReportDelete (golint) +const HubAppReportDelete = "hub.app.report.delete" + +// HubAppReportCancel (golint) +const HubAppReportCancel = "hub.app.report.cancel" + +// HubAppReportsGet (golint) +const HubAppReportsGet = "hub.app.reports.get" + +// HubUploads (golint) +const HubUploads = "hub.upload.query" + +// HubAppUploadSet (golint) +const HubAppUploadSet = "hub.app.upload.set" + +// HubUploadSet (golint) +const HubUploadSet = "hub.upload.set" + +// HubAppUploadDelete (golint) +const HubAppUploadDelete = "hub.app.upload.delete" + +// HubUploadDelete (golint) +const HubUploadDelete = "hub.upload.delete" + +// HubAppUploadRead (golint) +const HubAppUploadRead = "hub.app.upload.get" + +// HubUploadRead (golint) +const HubUploadRead = "hub.upload.get" + +// HubAppSetTransform (golint) +const HubAppSetTransform = "hub.app.transform.set" + +// HubAppGetTransform (golint) +const HubAppGetTransform = "hub.app.transform.get" + +// HubEnvSet (golint) +const HubEnvSet = "hub.env.set" + +// HubEnvGet (golint) +const HubEnvGet = "hub.env.get" + +// HubEnvScopeApp (golint) +const HubEnvScopeApp = "app" + +// HubEnvScopeProject (golint) +const HubEnvScopeProject = "project" + +// HubEnvScopeFleet (golint) +const HubEnvScopeFleet = "fleet" + +// HubEnvScopeFleets (golint) +const HubEnvScopeFleets = "fleets" + +// HubEnvScopeDevice (golint) +const HubEnvScopeDevice = "device" + +// HubCompressModeSnappy (golint) +const HubCompressModeSnappy = "snappy" + +// HubCompressModeCobs (golint) +const HubCompressModeCobs = "cobs" + +// HubRequest is is the core data structure for notehub-specific requests +type HubRequest struct { + notecard.Request `json:",omitempty"` + Contact *note.Contact `json:"contact,omitempty"` + AppUID string `json:"app,omitempty"` + FleetUID string `json:"fleet,omitempty"` + EventSerials []string `json:"events,omitempty"` + DbQuery *DbQuery `json:"query,omitempty"` + Uploads []UploadMetadata `json:"uploads,omitempty"` + Contains string `json:"contains,omitempty"` + Handlers *[]string `json:"handlers,omitempty"` + FileType UploadType `json:"type,omitempty"` + FileTags string `json:"tags,omitempty"` + FileNotes string `json:"filenotes,omitempty"` + Provision bool `json:"provision,omitempty"` + Scope string `json:"scope,omitempty"` + Env *map[string]string `json:"env,omitempty"` + FleetEnv *map[string]map[string]string `json:"fleet_env,omitempty"` + PIN string `json:"pin,omitempty"` + Compress string `json:"compress,omitempty"` + MD5 string `json:"md5,omitempty"` + DeviceEndpoint bool `json:"device_endpoint,omitempty"` + DryRun bool `json:"dry_run,omitempty"` +} + +type UploadType string + +const ( + UploadTypeUnknown UploadType = "" + UploadTypeHostFirmware UploadType = "firmware" + UploadTypeNotecardFirmware UploadType = "notecard" + UploadTypeModemFirmware UploadType = "modem" + UploadTypeStarnoteFirmware UploadType = "starnote" + UploadTypeUserData UploadType = "data" + UploadTypeJob UploadType = "job" +) + +var allFileTypes = []UploadType{ + UploadTypeUnknown, + UploadTypeHostFirmware, + UploadTypeNotecardFirmware, + UploadTypeModemFirmware, + UploadTypeStarnoteFirmware, + UploadTypeUserData, + UploadTypeJob, +} + +func ParseUploadType(fileType string) UploadType { + if fileType == "host" { + return UploadTypeHostFirmware + } + for _, validType := range allFileTypes { + if string(validType) == fileType { + return validType + } + } + return UploadTypeUnknown +} + +const TestFirmwareString = "(test firmware)" + +// HubRequestFileFirmware is firmware-specific metadata +type HubRequestFileFirmware struct { + // The organization accountable for the firmware - a display string + Organization string `json:"org,omitempty"` + // A description of the firmware - a display string + Description string `json:"description,omitempty"` + // The name and model number of the product containing the firmware - a display string + Product string `json:"product,omitempty"` + // The identifier of the only firmware that will be acceptable and downloaded to this device + Firmware string `json:"firmware,omitempty"` + // The composite version number of the firmware, generally major.minor.patch as a string + Version string `json:"version,omitempty"` + // The target CPU of the firmware (see notecard/src/board.h) + Target string `json:"target,omitempty"` + // The build number of the firmware, for numeric comparison + Major uint32 `json:"ver_major,omitempty"` + Minor uint32 `json:"ver_minor,omitempty"` + Patch uint32 `json:"ver_patch,omitempty"` + Build uint32 `json:"ver_build,omitempty"` + // The build number of the firmware, generally just a date and time + Built string `json:"built,omitempty"` + // The entity who built or is responsible for the firmware - a display string + Builder string `json:"builder,omitempty"` +} + +func (metadata HubRequestFileFirmware) VersionString() string { + return fmt.Sprintf("%d.%d.%d.%d", metadata.Major, metadata.Minor, metadata.Patch, metadata.Build) +} + +// UploadMetadata is the body of the object uploaded for each file +type UploadMetadata struct { + Name string `json:"name,omitempty"` + Length int `json:"length,omitempty"` + MD5 string `json:"md5,omitempty"` + CRC32 uint32 `json:"crc32,omitempty"` + Created int64 `json:"created,omitempty"` + Modified int64 `json:"modified,omitempty"` + Source string `json:"source,omitempty"` + Contains string `json:"contains,omitempty"` + Found string `json:"found,omitempty"` + FileType UploadType `json:"type,omitempty"` + Tags string `json:"tags,omitempty"` // comma-separated, no spaces, case-insensitive + Notes string `json:"notes,omitempty"` // Should be simple text + Firmware *HubRequestFileFirmware `json:"firmware,omitempty"` // This value is pulled out of the firmware binary itself + Version string `json:"version,omitempty"` // User-specified version string provided at time of upload + // Arbitrary metadata that the user may define - we don't interpret the schema at all + Info map[string]interface{} `json:"info,omitempty"` +} + +func (upload UploadMetadata) IsArchSpecificNotecardFirmware() bool { + return upload.FileType == UploadTypeNotecardFirmware && (strings.Contains(upload.Name, "-s3-") || + strings.Contains(upload.Name, "-u5-") || + strings.Contains(upload.Name, "-wl-")) +} + +func (upload UploadMetadata) IsPublished() bool { + for _, tag := range strings.Split(upload.Tags, ",") { + if strings.TrimSpace(strings.ToLower(tag)) == "publish" { + return true + } + } + return false +} + +// HubRequestFileTagPublish indicates that this should be published in the UI +const HubRequestFileTagPublish = "publish" diff --git a/note-go/package.sh b/note-go/package.sh new file mode 100755 index 0000000..a79d6f4 --- /dev/null +++ b/note-go/package.sh @@ -0,0 +1,47 @@ +#! /usr/bin/env bash +# +# Copyright 2020 Blues Inc. All rights reserved. +# Use of this source code is governed by licenses granted by the +# copyright holder including that found in the LICENSE file. +# +######### Bash Boilerplate ########## +set -euo pipefail # strict mode +readonly SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +cd "$SCRIPT_DIR" # cd to this script's dir +######### End Bash Boilerplate ########## + +# +# note-go package.sh +# +# This script packages the best note-go executables (notecard, notehub) into +# archives named note{card,hub}cli_${GOOS}_${GOARCH}.tar.gz or .zip in the case of +# ${GOOS}=windows. To be more user-friendly we call darwin 'macos' in the archive +# names. +# +# Parameters: This script uses ${GOOS} and ${GOARCH} determine where to look for the +# executables. +# +# Output: Archives are saved in "./build/packages/" +# + +# Add GOOS and GOARCH to our environment. (and other GO vars we don't need) +eval "$(go env)" + +readonly BUILD_EXE_DIR="$SCRIPT_DIR/build/${GOOS}/${GOARCH}/" +mkdir -p "$BUILD_EXE_DIR" +readonly BUILD_PACKAGE_DIR="$SCRIPT_DIR/build/packages/" +mkdir -p "$BUILD_PACKAGE_DIR" + +# compress the build products into an archive +cd "$BUILD_EXE_DIR" +if [ "${GOOS}" = "windows" ]; then + # -j means don't store directory names, just file names. Basically flattens everything into the root of the zip. + zip -j "$BUILD_PACKAGE_DIR/notecardcli_${GOOS}_${GOARCH}.zip" ./notecard.exe "$SCRIPT_DIR/notecard-driver-windows7.inf" + zip -j "$BUILD_PACKAGE_DIR/notehubcli_${GOOS}_${GOARCH}.zip" ./notehub.exe +elif [ "${GOOS}" = "darwin" ]; then + tar -czvf "$BUILD_PACKAGE_DIR/notecardcli_macos_${GOARCH}.tar.gz" ./notecard + tar -czvf "$BUILD_PACKAGE_DIR/notehubcli_macos_${GOARCH}.tar.gz" ./notehub +else + tar -czvf "$BUILD_PACKAGE_DIR/notecardcli_${GOOS}_${GOARCH}.tar.gz" ./notecard + tar -czvf "$BUILD_PACKAGE_DIR/notehubcli_${GOOS}_${GOARCH}.tar.gz" ./notehub +fi; diff --git a/notecard/main.go b/notecard/main.go index d905539..ce66293 100644 --- a/notecard/main.go +++ b/notecard/main.go @@ -97,6 +97,15 @@ func getFlagGroups() []lib.FlagGroup { lib.GetFlagByName("pcap"), }, }, + { + Name: "upload", + Description: "File Upload", + Flags: []*flag.Flag{ + lib.GetFlagByName("upload"), + lib.GetFlagByName("route"), + lib.GetFlagByName("target"), + }, + }, { Name: "notefile", Description: "Notefile Management", @@ -236,6 +245,14 @@ func main() { var actionPcap string flag.StringVar(&actionPcap, "pcap", "", "enable PCAP mode and stream packets to output file (required: 'usb' or 'aux')") + // Upload flags - for efficient binary file upload via web.post + var actionUpload string + flag.StringVar(&actionUpload, "upload", "", "upload a file to Notehub via a proxy route using efficient binary transfer") + var actionRoute string + flag.StringVar(&actionRoute, "route", "", "Notehub proxy route alias for upload (required with -upload)") + var actionTarget string + flag.StringVar(&actionTarget, "target", "", "optional URL path appended to the route (becomes 'name' in web.post); use [filename] for the uploaded filename") + // Parse these flags and also the note tool config flags err := lib.FlagParse(true, false) if err != nil { @@ -254,6 +271,12 @@ func main() { lib.PrintGroupedFlags(getFlagGroups(), "notecard") config.Print() fmt.Printf("\n") + fmt.Printf("Upload Usage:\n") + fmt.Printf(" notecard -upload