diff --git a/note/errors.go b/note/errors.go index 4c3d2d7..87bba25 100644 --- a/note/errors.go +++ b/note/errors.go @@ -7,211 +7,269 @@ 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}" -// ErrTrackerNoExist (golint) -const ErrTrackerNoExist = "{tracker-noexist}" - -// ErrTrackerExists (golint) -const ErrTrackerExists = "{tracker-exists}" - -// ErrTransportConnected (golint) -const ErrTransportConnected = "{connected}" - -// ErrTransportDisconnected (golint) -const ErrTransportDisconnected = "{disconnected}" - -// ErrTransportConnecting (golint) -const ErrTransportConnecting = "{connecting}" +var _ = defineError(ErrNoteExists, http.StatusConflict) -// ErrTransportConnectFailure (golint) -const ErrTransportConnectFailure = "{connect-failure}" +// ErrTooManyNotes (golint) +const ErrTooManyNotes = "{too-many-notes}" -// ErrTransportConnectedClosed (golint) -const ErrTransportConnectedClosed = "{connected-closed}" +var _ = defineError(ErrTooManyNotes, http.StatusBadRequest) -// ErrTransportWaitService (golint) -const ErrTransportWaitService = "{wait-service}" +// ErrTrackerNoExist (golint) +const ErrTrackerNoExist = "{tracker-noexist}" -// ErrTransportWaitData (golint) -const ErrTransportWaitData = "{wait-data}" +var _ = defineError(ErrTrackerNoExist, http.StatusNotFound) -// ErrTransportWaitGateway (golint) -const ErrTransportWaitGateway = "{wait-gateway}" +// ErrTrackerExists (golint) +const ErrTrackerExists = "{tracker-exists}" -// ErrTransportWaitModule (golint) -const ErrTransportWaitModule = "{wait-module}" +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}" -// ErrIdle (golint) -const ErrIdle = "{idle}" - -// ErrNtnIdle (golint) -const ErrNtnIdle = "{ntn-idle}" +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}" -// ErrCardHeartbeat (golint) +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}" -// ErrDoNotRoute (golint) -const ErrDoNotRoute = "{do-not-route}" - -// ErrAddToFleet (golint) -const ErrAddToFleet = "{add-to-fleet}" - -// ErrRemoveFromFleet (golint) -const ErrRemoveFromFleet = "{remove-from-fleet}" - -// ErrLeaveFleetAlone (golint) -const ErrLeaveFleetAlone = "{leave-fleet-alone}" +var _ = defineError(ErrAccessDenied, http.StatusForbidden) // ErrWebPayload (golint) const ErrWebPayload = "{web-payload}" -// ErrHubMode (golint) +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}" -// ErrGPSInactive (golint) -const ErrGPSInactive = "{gps-inactive}" +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}" -// ErrDeviceDelay5 (golint) +// These can be sent from Notehub to the notecard to indicate it should pause before reconnecting +// Currently unused const ErrDeviceDelay5 = "{device-delay-5}" - -// ErrDeviceDelay10 (golint) const ErrDeviceDelay10 = "{device-delay-10}" - -// ErrDeviceDelay15 (golint) const ErrDeviceDelay15 = "{device-delay-15}" - -// ErrDeviceDelay20 (golint) const ErrDeviceDelay20 = "{device-delay-20}" - -// ErrDeviceDelay30 (golint) const ErrDeviceDelay30 = "{device-delay-30}" - -// ErrDeviceDelay60 (golint) const ErrDeviceDelay60 = "{device-delay-60}" // ErrorContains tests to see if an error contains an error keyword that we might expect @@ -222,6 +280,37 @@ func ErrorContains(err error, errKeyword string) bool { 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) diff --git a/note/event.go b/note/event.go index 46a115f..96c1c48 100644 --- a/note/event.go +++ b/note/event.go @@ -41,6 +41,9 @@ const EventSessionEnd = "session.end" // EventGeolocation (golint) const EventGeolocation = "device.geolocation" +// EventTower (golint) +const EventTower = "device.tower" + // EventSocket (golint) const EventSocket = "web.socket" diff --git a/note/note.go b/note/note.go index 4d15b10..6e7689f 100644 --- a/note/note.go +++ b/note/note.go @@ -7,6 +7,7 @@ package note import ( "bytes" "encoding/json" + "math" "strings" "time" ) @@ -56,11 +57,16 @@ type History struct { // Info is a general "content" structure type Info struct { - NoteID string `json:"id,omitempty"` - When int64 `json:"time,omitempty"` - Body *map[string]interface{} `json:"body,omitempty"` - Payload *[]byte `json:"payload,omitempty"` - Deleted bool `json:"deleted,omitempty"` + 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 @@ -185,8 +191,21 @@ func (note *Note) When() (when int64) { if note.Histories == nil || len(*note.Histories) == 0 { return 0 } - histories := *note.Histories - return histories[0].When + 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 diff --git a/note/notefile.go b/note/notefile.go index f161f9c..536da93 100644 --- a/note/notefile.go +++ b/note/notefile.go @@ -31,6 +31,9 @@ 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" diff --git a/note/session.go b/note/session.go index 81a352a..4d1732a 100644 --- a/note/session.go +++ b/note/session.go @@ -29,6 +29,8 @@ type DeviceSession struct { 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"` @@ -66,8 +68,9 @@ type DeviceSession struct { Voltage float64 `json:"voltage,omitempty"` Temp float64 `json:"temp,omitempty"` // Type of session - ContinuousSession bool `json:"continuous,omitempty"` - TLSSession bool `json:"tls,omitempty"` + ContinuousSession bool `json:"continuous,omitempty"` + TLSSession bool `json:"tls,omitempty"` + TimeBoundedSession bool `json:"time_bounded,omitempty"` // For keeping track of when the last work was done for a session LastWorkDone int64 `json:"work,omitempty"` // Number of Events routed diff --git a/notecard/cobs.go b/notecard/cobs.go index a379d18..0b203a6 100644 --- a/notecard/cobs.go +++ b/notecard/cobs.go @@ -40,7 +40,9 @@ func CobsEncodedLength(length int) int { func CobsEncode(input []byte, xor byte) ([]byte, error) { length := len(input) inOffset := 0 - output := make([]byte, CobsEncodedLength(len(input))) + // 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 @@ -66,3 +68,14 @@ func CobsEncode(input []byte, xor byte) ([]byte, error) { 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/notecard/cobs_test.go b/notecard/cobs_test.go index 8d7fbcc..f085a3f 100644 --- a/notecard/cobs_test.go +++ b/notecard/cobs_test.go @@ -27,3 +27,222 @@ func TestCob(t *testing.T) { 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/notecard/i2c-unix.go b/notecard/i2c-unix.go index 7820c6a..790eed8 100644 --- a/notecard/i2c-unix.go +++ b/notecard/i2c-unix.go @@ -83,9 +83,12 @@ func i2cWriteBytes(buf []byte, i2cAddr int) (err error) { i2cAddr = notecardDefaultI2CAddress } time.Sleep(1 * time.Millisecond) // By design, must not send more than once every 1Ms - reg := make([]byte, 1) + + // Single allocation for header + payload (avoids make + append pattern) + reg := make([]byte, 1+len(buf)) reg[0] = byte(len(buf)) - reg = append(reg, buf...) + copy(reg[1:], buf) + i2cLock.Lock() openI2CPort.device = &i2c.Dev{Bus: openI2CPort.bus, Addr: uint16(i2cAddr)} err = openI2CPort.device.Tx(reg, nil) @@ -103,13 +106,14 @@ func i2cReadBytes(datalen int, i2cAddr int) (outbuf []byte, available int, err e } 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 - reg := make([]byte, 2) - reg[0] = byte(0) - reg[1] = byte(datalen) i2cLock.Lock() openI2CPort.device = &i2c.Dev{Bus: openI2CPort.bus, Addr: uint16(i2cAddr)} - err = openI2CPort.device.Tx(reg, readbuf) + err = openI2CPort.device.Tx(reg[:], readbuf) i2cLock.Unlock() if err == nil { break diff --git a/notecard/lease.go b/notecard/lease.go index 8aad1a9..9887d47 100644 --- a/notecard/lease.go +++ b/notecard/lease.go @@ -128,7 +128,7 @@ func leaseClose(context *Context) { } // Perform a remote transaction -func leaseTransaction(context *Context, portConfig int, noResponse bool, reqJSON []byte) (rspJSON []byte, err error) { +func leaseTransaction(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) { // Perform the lease transaction req := LeaseTransaction{} diff --git a/notecard/notecard.go b/notecard/notecard.go index 3c6da96..b263e4f 100644 --- a/notecard/notecard.go +++ b/notecard/notecard.go @@ -45,6 +45,14 @@ var ( 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 @@ -125,7 +133,7 @@ type Context struct { 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) (rspJSON []byte, 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 @@ -285,7 +293,10 @@ func cardResetSerial(context *Context, portConfig int) (err error) { // anything pending on serial", because the nature of read() is // that it blocks (until timeout) if there's nothing available. var length int - buf := make([]byte, 2048) + bufPtr := serialReadBufPool.Get().(*[]byte) + buf := *bufPtr + defer serialReadBufPool.Put(bufPtr) + for { if debugSerialIO { fmt.Printf("cardResetSerial: about to write newline\n") @@ -781,8 +792,8 @@ func (context *Context) SendBytes(reqBytes []byte) (err error) { _ = context.Reset(portConfig) } - // Do the send, with no response requested - _, err = context.TransactionFn(context, portConfig, true, reqBytes) + // Do the send, with no response requested and no delays (binary transfer) + _, err = context.TransactionFn(context, portConfig, true, reqBytes, false) // Done unlockTrans(false, portConfig) @@ -812,8 +823,8 @@ func (context *Context) receiveBytes(portConfig int) (rspBytes []byte, err error // Request is empty var reqBytes []byte - // Perform the transaction - rspBytes, err = context.TransactionFn(context, portConfig, false, reqBytes) + // Perform the transaction with no delays (binary transfer) + rspBytes, err = context.TransactionFn(context, portConfig, false, reqBytes, false) unlockTrans(false, portConfig) @@ -866,18 +877,15 @@ func (context *Context) transactionJSON(reqJSON []byte, multiport bool, portConf if !DoNotReterminateJSON { // Make sure that the JSON has a single \n terminator - for { - if strings.HasSuffix(string(reqJSON), "\n") { - reqJSON = []byte(strings.TrimSuffix(string(reqJSON), "\n")) - continue - } - if strings.HasSuffix(string(reqJSON), "\r") { - reqJSON = []byte(strings.TrimSuffix(string(reqJSON), "\r")) - continue + // Use byte operations instead of string conversions + for len(reqJSON) > 0 { + last := reqJSON[len(reqJSON)-1] + if last != '\n' && last != '\r' { + break } - break + reqJSON = reqJSON[:len(reqJSON)-1] } - reqJSON = []byte(string(reqJSON) + "\n") + reqJSON = append(reqJSON, '\n') } } @@ -932,8 +940,8 @@ func (context *Context) transactionJSON(reqJSON []byte, multiport bool, portConf } - // Perform the transaction - rspJSON, err = context.TransactionFn(context, portConfig, noResponseRequested, reqJSON) + // 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 { @@ -1067,7 +1075,7 @@ func (context *Context) transactionJSON(reqJSON []byte, multiport bool, portConf } // 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) (rspJSON []byte, err error) { +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) @@ -1116,7 +1124,9 @@ func cardTransactionSerial(context *Context, portConfig int, noResponse bool, re if segLeft == 0 { break } - time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + if delay { + time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + } } } @@ -1129,9 +1139,17 @@ func cardTransactionSerial(context *Context, portConfig int, noResponse bool, re // 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 - buf := make([]byte, 2048) if debugSerialIO { fmt.Printf("cardTransactionSerial: about to read up to %d bytes\n", len(buf)) } @@ -1170,7 +1188,9 @@ func cardTransactionSerial(context *Context, portConfig int, noResponse bool, re continue } rspJSON = append(rspJSON, buf[:length]...) - if !strings.Contains(string(rspJSON), "\n") { + + // Use bytes.IndexByte instead of strings.Contains + if bytes.IndexByte(rspJSON, '\n') == -1 { continue } @@ -1179,22 +1199,37 @@ func cardTransactionSerial(context *Context, portConfig int, noResponse bool, re break } - // At this point, if we split the string at \n its len must be >= 2 - // If the json didn't END in \n, we are still collecting a partial line - lines := strings.Split(string(rspJSON), "\n") - lastLine := lines[len(lines)-1] - secondToLastLine := lines[len(lines)-2] - if lastLine != "" { + // 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 = []byte(lastLine) + 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 = []byte{} + rspJSON = rspJSON[:0] continue } @@ -1235,7 +1270,7 @@ func cardTransactionSerial(context *Context, portConfig int, noResponse bool, re } // 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) (rspJSON []byte, err error) { +func cardTransactionI2C(context *Context, portConfig int, noResponse bool, reqJSON []byte, delay bool) (rspJSON []byte, err error) { // Initialize timing parameters if RequestSegmentMaxLen < 0 { RequestSegmentMaxLen = CardRequestI2CSegmentMaxLen @@ -1261,11 +1296,13 @@ func cardTransactionI2C(context *Context, portConfig int, noResponse bool, reqJS chunkoffset += chunklen jsonbufLen -= chunklen sentInSegment += chunklen - if sentInSegment > RequestSegmentMaxLen { - sentInSegment = 0 + if delay { + if sentInSegment > RequestSegmentMaxLen { + sentInSegment = 0 + time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) + } time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) } - time.Sleep(time.Duration(RequestSegmentDelayMs) * time.Millisecond) } // If no response, we're done diff --git a/notecard/request.go b/notecard/request.go index 706a9de..1515aa2 100644 --- a/notecard/request.go +++ b/notecard/request.go @@ -5,6 +5,8 @@ package notecard import ( + "errors" + "github.com/blues/note-go/note" ) @@ -349,6 +351,7 @@ type Request struct { 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"` @@ -482,6 +485,13 @@ type Request struct { 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.) diff --git a/notecard/test.go b/notecard/test.go index f11df30..d2a4f61 100644 --- a/notecard/test.go +++ b/notecard/test.go @@ -65,6 +65,13 @@ type CardTest struct { 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 diff --git a/notehub/api/devices.go b/notehub/api/devices.go index 1d9b081..8abd504 100644 --- a/notehub/api/devices.go +++ b/notehub/api/devices.go @@ -45,6 +45,7 @@ type GetDeviceResponse struct { 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"` diff --git a/notehub/request.go b/notehub/request.go index e3b43ea..1468b84 100644 --- a/notehub/request.go +++ b/notehub/request.go @@ -5,6 +5,7 @@ package notehub import ( + "encoding/json" "fmt" "strings" @@ -128,9 +129,59 @@ const HubCompressModeSnappy = "snappy" // HubCompressModeCobs (golint) const HubCompressModeCobs = "cobs" +// RequestType handles the polymorphic "type" field (int32 for sessions, string for uploads) +type RequestType struct { + sessionType int32 + fileType UploadType +} + +func (rt *RequestType) SessionType() int32 { + return rt.sessionType +} + +func (rt *RequestType) FileType() UploadType { + return rt.fileType +} + +func (rt *RequestType) SetSessionType(st int32) { + rt.sessionType = st + rt.fileType = "" +} + +func (rt *RequestType) SetFileType(ft UploadType) { + rt.fileType = ft + rt.sessionType = 0 +} + +func (rt *RequestType) UnmarshalJSON(data []byte) error { + // Try int32 first + var i int32 + if err := json.Unmarshal(data, &i); err == nil { + rt.sessionType = i + rt.fileType = "" + return nil + } + // Try string + var s string + if err := json.Unmarshal(data, &s); err == nil { + rt.sessionType = 0 + rt.fileType = UploadType(s) + return nil + } + return nil +} + +func (rt RequestType) MarshalJSON() ([]byte, error) { + if rt.fileType != "" { + return json.Marshal(string(rt.fileType)) + } + return json.Marshal(rt.sessionType) +} + // HubRequest is is the core data structure for notehub-specific requests type HubRequest struct { notecard.Request `json:",omitempty"` + Type *RequestType `json:"type,omitempty"` // Shadows Request.Type to handle both int32 and string Contact *note.Contact `json:"contact,omitempty"` AppUID string `json:"app,omitempty"` FleetUID string `json:"fleet,omitempty"` @@ -139,7 +190,6 @@ type HubRequest struct { 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"` @@ -153,6 +203,34 @@ type HubRequest struct { DryRun bool `json:"dry_run,omitempty"` } +func (h *HubRequest) FileType() UploadType { + if h.Type == nil { + return "" + } + return h.Type.FileType() +} + +func (h *HubRequest) SetFileType(ft UploadType) { + if h.Type == nil { + h.Type = &RequestType{} + } + h.Type.SetFileType(ft) +} + +func (h *HubRequest) SessionType() int32 { + if h.Type == nil { + return 0 + } + return h.Type.SessionType() +} + +func (h *HubRequest) SetSessionType(st int32) { + if h.Type == nil { + h.Type = &RequestType{} + } + h.Type.SetSessionType(st) +} + type UploadType string const (