senml 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +114 -0
- data/SECURITY.md +11 -0
- data/data/labels.yml +19 -0
- data/data/units.yml +68 -0
- data/lib/senml/cbor.rb +189 -0
- data/lib/senml/json_codec.rb +48 -0
- data/lib/senml/labels.rb +13 -0
- data/lib/senml/pack.rb +128 -0
- data/lib/senml/record.rb +79 -0
- data/lib/senml/stream_reader.rb +75 -0
- data/lib/senml/validator.rb +118 -0
- data/lib/senml/version.rb +5 -0
- data/lib/senml.rb +69 -0
- data/sig/senml.rbs +86 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7ef841f3fb72ceb0281f1d3436b14f35e9962ffa9561b2491f946740816f755b
|
|
4
|
+
data.tar.gz: e2af948829e3b3a4a64691f5f2cf0f9d4d5fa824be914bc87520291f212b732d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fe3bd198b38e2c030089840be2a1c8b5550c69942c93fc9be4594e17605ed67c7e47465c7dcb42e44a9e4f0c6d718cb8944a639438e96c57dec786a7213b282f
|
|
7
|
+
data.tar.gz: d1e18dc6e68b9c7aa704df707b954a5018af81e635784a0034cf3b6d464eef548e8a3c692bd3403203c53d3fd45c00cb13b5aba83f7d2e9b42508e2802c0469b
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SenML
|
|
2
|
+
|
|
3
|
+
Read, validate, write, and resolve [RFC 8428](https://www.rfc-editor.org/rfc/rfc8428) sensor measurements in Ruby. Supports SenML JSON, incremental SenSML JSON, record fragments, and optional CBOR.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "senml"
|
|
9
|
+
# Optional on CRuby 3.3–4.0 and TruffleRuby:
|
|
10
|
+
gem "cbor", "~> 0.5"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Ruby 3.3 or later and JSON 2.19.4 or later, below version 4. JSON works on CRuby, JRuby, and TruffleRuby. The optional `cbor` native extension is tested on CRuby 3.3–4.0 and TruffleRuby; JRuby and Ruby 4.1 development builds run JSON features only. There are no dependencies on other measurement or JSON-format gems.
|
|
14
|
+
|
|
15
|
+
## Read and resolve
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "senml"
|
|
19
|
+
|
|
20
|
+
pack = SenML::Pack.build(base_name: "room:", base_time: 1_700_000_000, base_unit: "Cel") do |p|
|
|
21
|
+
p.add(n: "temperature", v: 21.5, t: 2)
|
|
22
|
+
p.add(n: "temperature", v: 21.8, t: 1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
parsed = SenML.parse(pack.to_json)
|
|
26
|
+
parsed.first.name # => "room:temperature"
|
|
27
|
+
parsed.first.time # => 1700000002
|
|
28
|
+
parsed.first["t"] # => 2 (the original field)
|
|
29
|
+
resolved = parsed.resolve(now: Time.at(1_700_000_000))
|
|
30
|
+
resolved.map(&:value) # => [21.8, 21.5]
|
|
31
|
+
resolved.first.time_at # => a UTC Time
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`name`, `unit`, `time`, `value`, and `sum` apply the current base fields. `[]` and `to_h` preserve original fields, including unknown noncritical extensions. `Record#data` lazily decodes unpadded base64url `vd` into a binary String. String and boolean measurements do not receive numeric base values. A record may have a value, a sum, or both; an inherited base value or base sum can supply a missing measurement field.
|
|
35
|
+
|
|
36
|
+
Resolution removes base fields, expands names, units, values and sums, converts relative times using `now`, and stably sorts by time. Version 10 is implicit; legacy positive versions up to 10 are retained when resolving. Future versions and mixed versions are rejected. A relative `Record#time_at` needs an explicit `now:`. Integers retain Ruby precision; nonintegral numbers use binary64 Float, and generated JSON preserves finite Float values across parsing.
|
|
37
|
+
|
|
38
|
+
## Streams and fragments
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
File.open("measurements.json", "rb") do |io|
|
|
42
|
+
SenML::StreamReader.new(io).each { |record| puts record.name }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
selected = pack.select_fragment("#rec=1,2-*")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The stream reader yields each complete object without retaining the whole array. It carries immutable base fields and version state between records. Repeated records reuse inherited base strings; name-prefix validation is cached until the base changes. `allow_incomplete: true` also accepts a SenSML stream that ends after a complete record without a closing array bracket; an unfinished object still fails. This reader handles JSON streams; CBOR parsing consumes a complete input String.
|
|
49
|
+
|
|
50
|
+
Fragments use one-based record indices and support single records, closed ranges, open ranges, and comma-separated combinations. Selection keeps original order, removes duplicates, and carries each selected record's effective base context, emitting inherited base fields only at the first selected record and when they change. Out-of-range and reversed ranges fail.
|
|
51
|
+
|
|
52
|
+
## Validation and limits
|
|
53
|
+
|
|
54
|
+
Strict parsing checks field types, value counts, resolved names, registered units, versions, and canonical unpadded base64url. Duplicate JSON keys and uppercase numeric exponents fail. `strict: false` preserves recoverable semantic problems as `pack.warnings`; field types, unknown critical labels ending in `_`, and version problems always fail. Writing and resolving perform strict validation again.
|
|
55
|
+
|
|
56
|
+
All APIs that construct packs or readers accept these positive integer limits:
|
|
57
|
+
|
|
58
|
+
| Option | Default | Applies to |
|
|
59
|
+
| --- | ---: | --- |
|
|
60
|
+
| `max_bytes` | 16,777,216 | Complete inputs/outputs and the JSON representation of native or decoded packs |
|
|
61
|
+
| `max_records` | 100,000 | Packs and streams |
|
|
62
|
+
| `max_record_bytes` | 1,048,576 | Each record, including stream buffers |
|
|
63
|
+
| `max_data_bytes` | 786,432 | Decoded binary measurement size |
|
|
64
|
+
| `max_depth` | 32 | Nested values; hard maximum 128 |
|
|
65
|
+
| `max_warnings` | 1,000 | Retained lenient diagnostics in packs and streams |
|
|
66
|
+
|
|
67
|
+
Native array construction and CBOR decoding also check the cumulative JSON representation size against `max_bytes`; the decoded representation may be larger than its CBOR input. Streams use record limits instead of a total byte limit. Exceeding `max_warnings` raises `LimitError` before yielding the next record; `reader.warnings` retains the diagnostics collected so far. Resolution checks `max_bytes` incrementally as base fields expand, before retaining an oversized resolved pack. Binary size is checked before decoding. `SenML::ParseError` reports malformed input or field types, `ValidationError` reports semantic problems with a `pointer`, `UnsupportedVersionError` identifies unknown versions, and `LimitError` reports exceeded bounds. A conversion that cannot preserve supported semantics raises `ConversionError`.
|
|
68
|
+
|
|
69
|
+
## Optional CBOR
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
require "senml/cbor"
|
|
73
|
+
bytes = pack.to_cbor
|
|
74
|
+
SenML.parse_cbor(bytes)
|
|
75
|
+
# Accept a complete SenSML indefinite-length CBOR array:
|
|
76
|
+
SenML.parse_cbor(stream_bytes, streaming: true)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
CBOR maps the RFC's integer labels and binary data values, preserves JSON-compatible extension fields, and supports decimal-fraction tag 4 with exponents from -400 to 400. A preflight scan checks lengths, nesting, duplicate map keys, and allowed types before the native decoder runs. Unknown integer labels, non-JSON binary extension values, other tags, indefinite strings, and lossy conversion of large integers fail explicitly. JSON nonintegral values use Float; conversion of arbitrary decimal fractions can round to binary64.
|
|
80
|
+
|
|
81
|
+
## Scope and reference data
|
|
82
|
+
|
|
83
|
+
| Supported | Outside this release |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| SenML and incremental SenSML JSON | XML and EXI |
|
|
86
|
+
| Base resolution and record fragments | Unit conversion and CoAP transport |
|
|
87
|
+
| Optional CBOR on the runtimes listed above | Incremental CBOR decoding |
|
|
88
|
+
| RFC 8428 version rules and registered labels | FETCH/PATCH and RFC 9100 feature versions |
|
|
89
|
+
|
|
90
|
+
The implementation supports RFC 8428 sections 4–6 and 9: SenML/SenSML JSON, base resolution, optional CBOR, and record fragments. It recognizes the IANA unit identifiers and registered labels checked on 2026-10-11, including `vlo` and the `ct`/`bct` labels from [RFC 9193](https://www.rfc-editor.org/rfc/rfc9193). Content-format strings are preserved opaquely, and `bct` resolves onto binary measurements. Unknown base labels stop resolution and fragment selection because their inheritance rules are unknown, including when resolving a later record that inherits the unsupported base.
|
|
91
|
+
|
|
92
|
+
XML, EXI, unit conversion, CoAP transport, FETCH/PATCH variants, and feature-version negotiation from RFC 9100 are outside this release. The library makes no network requests when parsing or resolving measurements.
|
|
93
|
+
|
|
94
|
+
Only independently authored identifier/type metadata is shipped in `data/`. RFC text, examples, grammars, IANA source files, and external implementation code are never committed or packaged. Development scripts fetch checksum-pinned references into ignored `tmp/` directories, retain notices, and extract test fixtures there. See [IANA terms](https://www.iana.org/help/licensing-terms), [IETF terms](https://trustee.ietf.org/license-info), and `script/sources.json` for sources and checksums.
|
|
95
|
+
|
|
96
|
+
The RFC's explicitly unfinished section 5.1.2 stream is closed after its final complete measurement for whole-pack tests. The first section 5.1.7 thermostat example starts with a base-only record, conflicting with section 4.2's value-or-sum requirement; strict parsing rejects the original, and its base name is moved into the first measurement for the roundtrip fixture. Original excerpts and corrections remain together in ignored fixture storage. [Erratum 6728](https://www.rfc-editor.org/errata/eid6728), held for document update, concerns an XML/CDDL comma and does not change the supported encodings.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
bundle install
|
|
102
|
+
bundle exec rake test
|
|
103
|
+
COVERAGE=1 bundle exec rake test
|
|
104
|
+
bundle exec rubocop
|
|
105
|
+
bundle exec rbs -I sig validate
|
|
106
|
+
bundle exec ruby script/interop.rb # Go and unzip required
|
|
107
|
+
bundle exec ruby script/benchmark.rb
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Tests cover all 14 RFC JSON examples, the full CBOR hex example, all five fragment examples, MUST violations, malformed and random CBOR, random numeric roundtrips, and a generated 100,000-record stream. CI compares normalization with pinned `farshidtz/senml` v2.0.0 (MIT), retaining its license in temporary storage. The IANA checker fails on unexpected registry changes; review updated sources and checksums before running `bundle exec ruby script/update_registry.rb --write`.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately through [GitHub security advisories](https://github.com/ydah/senml/security/advisories/new).
|
|
4
|
+
|
|
5
|
+
Parsing does not perform network requests or execute measurement content. Inputs have configurable byte, record, depth, and binary-data limits. Streaming applies per-record limits before retaining further bytes. CBOR is optional and uses a native extension after a bounded structural preflight scan; keep that dependency up to date.
|
|
6
|
+
|
|
7
|
+
Unknown critical labels and unsupported or mixed format versions fail even in lenient mode. Noncritical extension data is retained without interpretation. Applications must decide which sensor names, units, content formats, values, and timestamps they trust. Conversion and resolution do not authenticate measurements.
|
|
8
|
+
|
|
9
|
+
Use bounds appropriate to the application's workload. Parsing a complete pack and resolving it require memory proportional to the pack; use the JSON stream reader when whole-pack sorting is unnecessary. A stream can yield valid records before a later malformed record raises an error, so callers needing atomic ingestion must provide their own transaction boundary.
|
|
10
|
+
|
|
11
|
+
Lenient diagnostics stop with `LimitError` at `max_warnings` (default 1,000). Inherited base strings are immutable and shared; resolution stops before expanded output exceeds `max_bytes`. Unknown base context also prevents resolution of later records.
|
data/data/labels.yml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Independently authored implementation metadata.
|
|
2
|
+
bn: {cbor: -2, type: string, accessor: base_name, base: true, section: '4.1'}
|
|
3
|
+
bt: {cbor: -3, type: number, accessor: base_time, base: true, section: '4.1'}
|
|
4
|
+
bu: {cbor: -4, type: string, accessor: base_unit, base: true, section: '4.1'}
|
|
5
|
+
bv: {cbor: -5, type: number, accessor: base_value, base: true, section: '4.1'}
|
|
6
|
+
bs: {cbor: -6, type: number, accessor: base_sum, base: true, section: '4.1'}
|
|
7
|
+
bver: {cbor: -1, type: integer, accessor: base_version, base: true, section: '4.1'}
|
|
8
|
+
n: {cbor: 0, type: string, accessor: name, section: '4.2'}
|
|
9
|
+
u: {cbor: 1, type: string, accessor: unit, section: '4.2'}
|
|
10
|
+
v: {cbor: 2, type: number, accessor: numeric_value, section: '4.2'}
|
|
11
|
+
vs: {cbor: 3, type: string, accessor: string_value, section: '4.2'}
|
|
12
|
+
vb: {cbor: 4, type: boolean, accessor: boolean_value, section: '4.2'}
|
|
13
|
+
vd: {cbor: 8, type: string, accessor: data_value, section: '4.2'}
|
|
14
|
+
s: {cbor: 5, type: number, accessor: sum, section: '4.2'}
|
|
15
|
+
t: {cbor: 6, type: number, accessor: time, section: '4.2'}
|
|
16
|
+
ut: {cbor: 7, type: number, accessor: update_time, section: '4.2'}
|
|
17
|
+
vlo: {type: string, accessor: object_link, section: 'OMA LwM2M 1.1 7.4.5'}
|
|
18
|
+
bct: {type: string, accessor: base_content_format, base: true, section: 'RFC9193 4'}
|
|
19
|
+
ct: {type: string, accessor: content_format, section: 'RFC9193 3'}
|
data/data/units.yml
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
- m
|
|
3
|
+
- kg
|
|
4
|
+
- g
|
|
5
|
+
- s
|
|
6
|
+
- A
|
|
7
|
+
- K
|
|
8
|
+
- cd
|
|
9
|
+
- mol
|
|
10
|
+
- Hz
|
|
11
|
+
- rad
|
|
12
|
+
- sr
|
|
13
|
+
- "N"
|
|
14
|
+
- Pa
|
|
15
|
+
- J
|
|
16
|
+
- W
|
|
17
|
+
- C
|
|
18
|
+
- V
|
|
19
|
+
- F
|
|
20
|
+
- Ohm
|
|
21
|
+
- S
|
|
22
|
+
- Wb
|
|
23
|
+
- T
|
|
24
|
+
- H
|
|
25
|
+
- Cel
|
|
26
|
+
- lm
|
|
27
|
+
- lx
|
|
28
|
+
- Bq
|
|
29
|
+
- Gy
|
|
30
|
+
- Sv
|
|
31
|
+
- kat
|
|
32
|
+
- m2
|
|
33
|
+
- m3
|
|
34
|
+
- l
|
|
35
|
+
- m/s
|
|
36
|
+
- m/s2
|
|
37
|
+
- m3/s
|
|
38
|
+
- l/s
|
|
39
|
+
- W/m2
|
|
40
|
+
- cd/m2
|
|
41
|
+
- bit
|
|
42
|
+
- bit/s
|
|
43
|
+
- lat
|
|
44
|
+
- lon
|
|
45
|
+
- pH
|
|
46
|
+
- dB
|
|
47
|
+
- dBW
|
|
48
|
+
- Bspl
|
|
49
|
+
- count
|
|
50
|
+
- "/"
|
|
51
|
+
- "%"
|
|
52
|
+
- "%RH"
|
|
53
|
+
- "%EL"
|
|
54
|
+
- EL
|
|
55
|
+
- 1/s
|
|
56
|
+
- 1/min
|
|
57
|
+
- beat/min
|
|
58
|
+
- beats
|
|
59
|
+
- S/m
|
|
60
|
+
- B
|
|
61
|
+
- VA
|
|
62
|
+
- VAs
|
|
63
|
+
- var
|
|
64
|
+
- vars
|
|
65
|
+
- J/m
|
|
66
|
+
- kg/m3
|
|
67
|
+
- deg
|
|
68
|
+
- NTU
|
data/lib/senml/cbor.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../senml"
|
|
4
|
+
require "cbor"
|
|
5
|
+
module SenML
|
|
6
|
+
module CBORCodec
|
|
7
|
+
KEYS = LABELS.to_h { |label, spec| [spec.fetch("cbor", label), label] }.freeze
|
|
8
|
+
# Bound nesting and allocations, and reject duplicate keys before the native decoder loses them.
|
|
9
|
+
class Guard
|
|
10
|
+
def initialize(bytes, limits, streaming)
|
|
11
|
+
@bytes = bytes
|
|
12
|
+
@limits = limits
|
|
13
|
+
@streaming = streaming
|
|
14
|
+
@offset = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check
|
|
18
|
+
raise ParseError, "CBOR pack must be an array" unless @bytes.getbyte(0)&.div(32) == 4
|
|
19
|
+
raise ParseError, "SenML CBOR requires a definite-length array" if @bytes.getbyte(0) == 159 && !@streaming
|
|
20
|
+
|
|
21
|
+
scan(0)
|
|
22
|
+
raise ParseError, "trailing CBOR data" unless @offset == @bytes.bytesize
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def take(size)
|
|
28
|
+
raise ParseError, "truncated CBOR" if size > @bytes.bytesize - @offset
|
|
29
|
+
|
|
30
|
+
result = @bytes.byteslice(@offset, size)
|
|
31
|
+
@offset += size
|
|
32
|
+
result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def scan(depth)
|
|
36
|
+
raise LimitError, "CBOR exceeds max_depth" if depth > @limits[:max_depth]
|
|
37
|
+
|
|
38
|
+
initial = take(1).getbyte(0)
|
|
39
|
+
major = initial >> 5
|
|
40
|
+
info = initial & 31
|
|
41
|
+
size = case info
|
|
42
|
+
when 0..23 then info
|
|
43
|
+
when 24 then take(1).unpack1("C")
|
|
44
|
+
when 25 then take(2).unpack1("n")
|
|
45
|
+
when 26 then take(4).unpack1("N")
|
|
46
|
+
when 27 then take(8).unpack1("Q>")
|
|
47
|
+
when 31 then nil
|
|
48
|
+
else raise ParseError, "reserved CBOR argument"
|
|
49
|
+
end
|
|
50
|
+
case major
|
|
51
|
+
when 0, 1 then raise ParseError, "indefinite CBOR integer" unless size
|
|
52
|
+
when 2, 3
|
|
53
|
+
raise ParseError, "CBOR strings must have definite length" unless size
|
|
54
|
+
raise LimitError, "CBOR string exceeds record bound" if size > @limits[:max_record_bytes]
|
|
55
|
+
|
|
56
|
+
take(size)
|
|
57
|
+
when 4, 5
|
|
58
|
+
raise LimitError, "CBOR container exceeds input size" if size && size > @bytes.bytesize
|
|
59
|
+
raise LimitError, "CBOR pack exceeds max_records" if depth.zero? && size && size > @limits[:max_records]
|
|
60
|
+
|
|
61
|
+
count = 0
|
|
62
|
+
keys = {}
|
|
63
|
+
until size ? count == size : @bytes.getbyte(@offset) == 255
|
|
64
|
+
start = @offset
|
|
65
|
+
if major == 5
|
|
66
|
+
kind = @bytes.getbyte(@offset)&.div(32)
|
|
67
|
+
raise ParseError, "CBOR map keys must be integers or text" unless [0, 1, 3].include?(kind)
|
|
68
|
+
|
|
69
|
+
scan(depth + 1)
|
|
70
|
+
key = CBOR.decode(@bytes.byteslice(start, @offset - start))
|
|
71
|
+
raise ParseError, "duplicate CBOR map key" if keys.key?(key)
|
|
72
|
+
|
|
73
|
+
keys[key] = true
|
|
74
|
+
end
|
|
75
|
+
scan(depth + 1)
|
|
76
|
+
count += 1
|
|
77
|
+
raise LimitError, "CBOR record exceeds max_record_bytes" if depth.zero? && @offset - start > @limits[:max_record_bytes]
|
|
78
|
+
raise LimitError, "CBOR pack exceeds max_records" if depth.zero? && count > @limits[:max_records]
|
|
79
|
+
end
|
|
80
|
+
take(1) unless size
|
|
81
|
+
when 6
|
|
82
|
+
raise ParseError, "unsupported CBOR tag" unless size == 4
|
|
83
|
+
|
|
84
|
+
scan(depth + 1)
|
|
85
|
+
when 7
|
|
86
|
+
raise ParseError, "unsupported CBOR simple value" unless [20, 21, 22, 25, 26, 27].include?(info)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.decode(bytes, limits, streaming)
|
|
92
|
+
Guard.new(bytes, limits, streaming).check
|
|
93
|
+
raw = CBOR.decode(bytes)
|
|
94
|
+
raw.map do |record|
|
|
95
|
+
raise ParseError, "CBOR record must be a map" unless record.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
mapped = record.to_h do |key, value|
|
|
98
|
+
label = KEYS[key]
|
|
99
|
+
raise ParseError, "unknown integer CBOR label" if key.is_a?(Integer) && !label
|
|
100
|
+
|
|
101
|
+
label ||= key
|
|
102
|
+
raise ParseError, "CBOR labels must be text or defined integers" unless label.is_a?(String)
|
|
103
|
+
|
|
104
|
+
if label == "vd"
|
|
105
|
+
raise ParseError, "CBOR data value must be a byte string" unless value.is_a?(String) && value.encoding == Encoding::BINARY
|
|
106
|
+
|
|
107
|
+
value = [value].pack("m0").tr("+/", "-_").delete("=")
|
|
108
|
+
else
|
|
109
|
+
value = json_value(value)
|
|
110
|
+
end
|
|
111
|
+
[label, value]
|
|
112
|
+
end
|
|
113
|
+
raise ParseError, "duplicate translated CBOR label" unless mapped.size == record.size
|
|
114
|
+
|
|
115
|
+
mapped
|
|
116
|
+
end
|
|
117
|
+
rescue CBOR::UnpackError => e
|
|
118
|
+
raise ParseError, e.message
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.json_value(value)
|
|
122
|
+
case value
|
|
123
|
+
when CBOR::Tagged
|
|
124
|
+
raise ParseError, "invalid decimal fraction" unless value.tag == 4 && value.value.is_a?(Array) && value.value.length == 2 && value.value.all?(Integer)
|
|
125
|
+
|
|
126
|
+
exponent, mantissa = value.value
|
|
127
|
+
raise ParseError, "decimal fraction exponent outside supported range" unless exponent.between?(-400, 400)
|
|
128
|
+
|
|
129
|
+
(mantissa * (10.to_r**exponent)).to_f
|
|
130
|
+
when Hash then value.transform_values { |v| json_value(v) }
|
|
131
|
+
when Array then value.map { |v| json_value(v) }
|
|
132
|
+
when String
|
|
133
|
+
raise ConversionError, "unknown binary extension cannot be represented as JSON" if value.encoding == Encoding::BINARY
|
|
134
|
+
|
|
135
|
+
value
|
|
136
|
+
else value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.encode(records, limits)
|
|
141
|
+
values = records.map do |record|
|
|
142
|
+
record.to_h do |label, value|
|
|
143
|
+
value = label == "vd" ? Record.new(record, **limits).data : cbor_value(value)
|
|
144
|
+
[LABELS.fetch(label, {}).fetch("cbor", label), value]
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
CBOR.encode(values)
|
|
148
|
+
rescue CBOR::TypeError, RangeError => e
|
|
149
|
+
raise ConversionError, e.message
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.cbor_value(value)
|
|
153
|
+
case value
|
|
154
|
+
when Hash then value.transform_values { |v| cbor_value(v) }
|
|
155
|
+
when Array then value.map { |v| cbor_value(v) }
|
|
156
|
+
when Integer
|
|
157
|
+
return value if value.between?(-(2**64), (2**64) - 1)
|
|
158
|
+
|
|
159
|
+
number = value.to_f
|
|
160
|
+
raise ConversionError, "integer cannot be represented exactly in supported CBOR numbers" unless number.finite? && number.to_i == value
|
|
161
|
+
|
|
162
|
+
number
|
|
163
|
+
else value
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Parse a complete binary CBOR pack; load this API with require 'senml/cbor'.
|
|
169
|
+
# @param bytes [String] CBOR octets
|
|
170
|
+
# @param streaming [Boolean] permit a SenSML indefinite-length array
|
|
171
|
+
# @return [Pack]
|
|
172
|
+
def self.parse_cbor(bytes, strict: true, streaming: false, **options)
|
|
173
|
+
options = limits(options)
|
|
174
|
+
raise ParseError, "CBOR input must be a String" unless bytes.is_a?(String)
|
|
175
|
+
raise LimitError, "CBOR pack exceeds max_bytes" if bytes.bytesize > options[:max_bytes]
|
|
176
|
+
|
|
177
|
+
Pack.new(CBORCodec.decode(bytes, options, streaming), strict: strict, **options)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
class Pack
|
|
181
|
+
def to_cbor
|
|
182
|
+
self.class.new(to_h, **@limits)
|
|
183
|
+
result = CBORCodec.encode(to_h, @limits)
|
|
184
|
+
raise LimitError, "CBOR output exceeds max_bytes" if result.bytesize > @limits[:max_bytes]
|
|
185
|
+
|
|
186
|
+
result
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
module JSONCodec
|
|
5
|
+
class Number
|
|
6
|
+
def initialize(source)
|
|
7
|
+
raise ParseError, "numeric exponent must use lowercase e" if source.include?("E")
|
|
8
|
+
|
|
9
|
+
@value = Float(source)
|
|
10
|
+
end
|
|
11
|
+
attr_reader :value
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.parse(source, max_depth:)
|
|
15
|
+
source = source.dup.force_encoding(Encoding::UTF_8)
|
|
16
|
+
raise ParseError, "JSON must be valid UTF-8" unless source.valid_encoding?
|
|
17
|
+
|
|
18
|
+
convert(JSON.parse(source, allow_duplicate_key: false, decimal_class: Number, max_nesting: max_depth))
|
|
19
|
+
rescue JSON::ParserError, EncodingError => e
|
|
20
|
+
raise ParseError, e.message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.convert(value)
|
|
24
|
+
case value
|
|
25
|
+
when Hash then value.transform_values { |v| convert(v) }
|
|
26
|
+
when Array then value.map { |v| convert(v) }
|
|
27
|
+
when Number then value.value
|
|
28
|
+
else value
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.generate(value)
|
|
33
|
+
case value
|
|
34
|
+
when Hash then "{#{value.map { |k, v| "#{JSON.generate(k)}:#{generate(v)}" }.join(",")}}"
|
|
35
|
+
when Array then "[#{value.map { |v| generate(v) }.join(",")}]"
|
|
36
|
+
when Float
|
|
37
|
+
raise ValidationError, "nonfinite number" unless value.finite?
|
|
38
|
+
|
|
39
|
+
number = format("%.17g", value)
|
|
40
|
+
number += ".0" unless number.match?(/[.e]/)
|
|
41
|
+
number
|
|
42
|
+
else JSON.generate(value)
|
|
43
|
+
end
|
|
44
|
+
rescue JSON::GeneratorError, EncodingError => e
|
|
45
|
+
raise ValidationError, e.message
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/senml/labels.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
LABELS = YAML.safe_load_file(File.expand_path("../../data/labels.yml", __dir__)).transform_values do |fields|
|
|
5
|
+
fields.transform_values(&:freeze).freeze
|
|
6
|
+
end.freeze
|
|
7
|
+
BASE_LABELS = LABELS.select { |_, fields| fields["base"] }.keys.freeze
|
|
8
|
+
VALUE_LABELS = %w[v vs vb vd vlo].freeze
|
|
9
|
+
module Units
|
|
10
|
+
SYMBOLS = YAML.safe_load_file(File.expand_path("../../data/units.yml", __dir__)).map(&:freeze).freeze
|
|
11
|
+
def self.registered?(symbol) = SYMBOLS.include?(symbol)
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/senml/pack.rb
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
class Pack
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :warnings
|
|
8
|
+
|
|
9
|
+
def initialize(records, strict: true, **options)
|
|
10
|
+
@limits = SenML.limits(options)
|
|
11
|
+
raise ParseError, "pack must be an array" unless records.is_a?(Array)
|
|
12
|
+
raise ValidationError, "pack must contain at least one record" if records.empty?
|
|
13
|
+
raise LimitError, "pack exceeds max_records" if records.length > @limits[:max_records]
|
|
14
|
+
|
|
15
|
+
validator = Validator.new(strict: strict, limits: @limits)
|
|
16
|
+
bytes = 2
|
|
17
|
+
@records = records.each_with_index.map do |data, index|
|
|
18
|
+
data = data.to_h if data.is_a?(Record)
|
|
19
|
+
base = validator.accept(data, index)
|
|
20
|
+
bytes += validator.record_bytes + (index.zero? ? 0 : 1)
|
|
21
|
+
raise LimitError, "pack exceeds max_bytes" if bytes > @limits[:max_bytes]
|
|
22
|
+
|
|
23
|
+
Record.new(data, base: base, **@limits)
|
|
24
|
+
end
|
|
25
|
+
@warnings = validator.warnings
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def each(&) = @records.each(&)
|
|
29
|
+
def [](index) = @records[index]
|
|
30
|
+
def size = @records.size
|
|
31
|
+
alias length size
|
|
32
|
+
def to_h = @records.map(&:to_h)
|
|
33
|
+
|
|
34
|
+
def to_json(*_args)
|
|
35
|
+
self.class.new(to_h, **@limits)
|
|
36
|
+
text = JSONCodec.generate(to_h)
|
|
37
|
+
raise LimitError, "pack exceeds max_bytes" if text.bytesize > @limits[:max_bytes]
|
|
38
|
+
|
|
39
|
+
text
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Expand inherited base fields and stably sort absolute measurement times.
|
|
43
|
+
# @param now [Time, Integer, Float] reference for relative timestamps
|
|
44
|
+
# @return [Pack] a new pack without inherited base fields
|
|
45
|
+
def resolve(now: Time.now)
|
|
46
|
+
reference = now.is_a?(Time) ? now.to_f : now
|
|
47
|
+
raise ArgumentError, "now must be a finite number or Time" unless reference.is_a?(Numeric) && reference.finite?
|
|
48
|
+
|
|
49
|
+
bytes = 2 # JSON array delimiters
|
|
50
|
+
validator = Validator.new(strict: true, limits: @limits)
|
|
51
|
+
records = @records.each_with_index.map do |record, index|
|
|
52
|
+
resolved = record.resolved(now: reference)
|
|
53
|
+
validator.json_value(resolved, "/#{index}", 0)
|
|
54
|
+
bytes += JSONCodec.generate(resolved).bytesize + (index.zero? ? 0 : 1)
|
|
55
|
+
raise LimitError, "resolved pack exceeds max_bytes" if bytes > @limits[:max_bytes]
|
|
56
|
+
|
|
57
|
+
[resolved, index]
|
|
58
|
+
end
|
|
59
|
+
self.class.new(records.sort_by { |record, index| [record["t"], index] }.map(&:first), **@limits)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Select one-based indices and ranges, preserving inherited base context.
|
|
63
|
+
# @param fragment [String] RFC 8428 fragment, optionally beginning with '#'
|
|
64
|
+
# @return [Pack]
|
|
65
|
+
def select_fragment(fragment)
|
|
66
|
+
fragment = fragment.delete_prefix("#")
|
|
67
|
+
raise ValidationError, "invalid record fragment" unless fragment.match?(/\Arec=[1-9]\d*(?:-(?:[1-9]\d*|\*))?(?:,[1-9]\d*(?:-(?:[1-9]\d*|\*))?)*\z/)
|
|
68
|
+
|
|
69
|
+
selected = {}
|
|
70
|
+
fragment.delete_prefix("rec=").split(",").each do |range|
|
|
71
|
+
start, stop = range.split("-", 2)
|
|
72
|
+
first = start.to_i
|
|
73
|
+
last = if stop == "*"
|
|
74
|
+
size
|
|
75
|
+
else
|
|
76
|
+
(stop ? stop.to_i : first)
|
|
77
|
+
end
|
|
78
|
+
raise ValidationError, "fragment outside pack bounds" unless last.between?(first, size)
|
|
79
|
+
|
|
80
|
+
(first..last).each { |index| selected[index - 1] = true }
|
|
81
|
+
end
|
|
82
|
+
base = {}
|
|
83
|
+
emitted_base = {}
|
|
84
|
+
bytes = 2
|
|
85
|
+
selected_count = 0
|
|
86
|
+
records = @records.each_with_index.filter_map do |record, index|
|
|
87
|
+
data = record.to_h
|
|
88
|
+
unknown_base = data.keys.find { |key| key.start_with?("b") && !BASE_LABELS.include?(key) }
|
|
89
|
+
raise ConversionError, "cannot select with unknown base field #{unknown_base}" if unknown_base
|
|
90
|
+
|
|
91
|
+
BASE_LABELS.each { |label| base[label] = data[label] if data.key?(label) }
|
|
92
|
+
next unless selected[index]
|
|
93
|
+
|
|
94
|
+
changes = base.reject { |label, value| emitted_base.key?(label) && emitted_base[label] == value }
|
|
95
|
+
entry = changes.merge(data)
|
|
96
|
+
bytes += JSONCodec.generate(entry).bytesize + (selected_count.zero? ? 0 : 1)
|
|
97
|
+
raise LimitError, "selected pack exceeds max_bytes" if bytes > @limits[:max_bytes]
|
|
98
|
+
|
|
99
|
+
emitted_base = base.dup
|
|
100
|
+
selected_count += 1
|
|
101
|
+
entry
|
|
102
|
+
end
|
|
103
|
+
self.class.new(records, **@limits)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.build(base_name: nil, base_time: nil, base_unit: nil, **)
|
|
107
|
+
builder = Builder.new({ "bn" => base_name, "bt" => base_time, "bu" => base_unit }.compact)
|
|
108
|
+
yield builder
|
|
109
|
+
new(builder.records, **)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class Builder
|
|
113
|
+
attr_reader :records
|
|
114
|
+
|
|
115
|
+
def initialize(base)
|
|
116
|
+
@base = base
|
|
117
|
+
@records = []
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add(**values)
|
|
121
|
+
data = values.transform_keys(&:to_s)
|
|
122
|
+
data = @base.merge(data) if @records.empty?
|
|
123
|
+
@records << data
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
data/lib/senml/record.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
class Record
|
|
5
|
+
LABELS.each { |label, fields| define_method(fields.fetch("accessor")) { self[label] } }
|
|
6
|
+
def initialize(data, base: {}, **options)
|
|
7
|
+
@data = SenML.copy(data)
|
|
8
|
+
# Validator snapshots contain immutable strings, which can be shared even
|
|
9
|
+
# when a long base name applies to thousands of short records.
|
|
10
|
+
@base = base.transform_values { |value| value.frozen? ? value : SenML.copy(value).freeze }.freeze
|
|
11
|
+
@limits = SenML.limits(options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def [](key) = @data[key.to_s]
|
|
15
|
+
def to_h = SenML.copy(@data)
|
|
16
|
+
def name = @base.fetch("bn", "") + @data.fetch("n", "")
|
|
17
|
+
def unit = @data.fetch("u") { @base["bu"] }&.dup
|
|
18
|
+
def time = @base.fetch("bt", 0) + @data.fetch("t", 0)
|
|
19
|
+
|
|
20
|
+
def value
|
|
21
|
+
key = VALUE_LABELS.find { |label| @data.key?(label) }
|
|
22
|
+
return data if key == "vd"
|
|
23
|
+
return @data[key] if key && key != "v"
|
|
24
|
+
return nil unless key || @base.key?("bv")
|
|
25
|
+
|
|
26
|
+
@base.fetch("bv", 0) + @data.fetch("v", 0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sum
|
|
30
|
+
return unless @data.key?("s") || @base.key?("bs")
|
|
31
|
+
|
|
32
|
+
@base.fetch("bs", 0) + @data.fetch("s", 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def time_at(now: nil)
|
|
36
|
+
seconds = time
|
|
37
|
+
if seconds < ABSOLUTE_TIME
|
|
38
|
+
raise ConversionError, "relative time requires explicit now" unless now
|
|
39
|
+
|
|
40
|
+
seconds += now.is_a?(Time) ? now.to_r : now
|
|
41
|
+
end
|
|
42
|
+
Time.at(seconds).utc
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Decode the data value only when requested, after checking its size.
|
|
46
|
+
# @return [String, nil] binary octets, or nil if vd is absent
|
|
47
|
+
def data
|
|
48
|
+
encoded = @data["vd"]
|
|
49
|
+
return unless encoded
|
|
50
|
+
|
|
51
|
+
Validator.new(strict: true, limits: @limits).validate_data(encoded, "/vd")
|
|
52
|
+
padded = encoded.tr("-_", "+/") + ("=" * ((4 - (encoded.length % 4)) % 4))
|
|
53
|
+
padded.unpack1("m0")
|
|
54
|
+
rescue ArgumentError => e
|
|
55
|
+
raise ConversionError, e.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolved(now:)
|
|
59
|
+
Validator.new(strict: true, limits: @limits).accept(@base.merge(@data), 0)
|
|
60
|
+
unknown_base = (@base.keys | @data.keys).find { |key| key.start_with?("b") && !BASE_LABELS.include?(key) }
|
|
61
|
+
raise ConversionError, "cannot resolve unknown base field #{unknown_base}" if unknown_base
|
|
62
|
+
|
|
63
|
+
result = @data.except(*BASE_LABELS)
|
|
64
|
+
result["n"] = name
|
|
65
|
+
result["u"] = unit if unit
|
|
66
|
+
seconds = time
|
|
67
|
+
seconds += now if seconds < ABSOLUTE_TIME
|
|
68
|
+
raise ConversionError, "resolved time must be absolute and finite" unless seconds.finite? && seconds >= ABSOLUTE_TIME
|
|
69
|
+
|
|
70
|
+
result["t"] = seconds
|
|
71
|
+
result["v"] = @base.fetch("bv", 0) + @data.fetch("v", 0) if @data.key?("v") || (VALUE_LABELS.none? { |key| @data.key?(key) } && @base.key?("bv"))
|
|
72
|
+
result["s"] = sum if sum
|
|
73
|
+
result["ct"] ||= @base["bct"] if @data.key?("vd") && @base.key?("bct")
|
|
74
|
+
version = @base.fetch("bver", FORMAT_VERSION)
|
|
75
|
+
result["bver"] = version if version != FORMAT_VERSION
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
class StreamReader
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
attr_reader :warnings
|
|
8
|
+
|
|
9
|
+
def initialize(io, strict: true, allow_incomplete: false, **options)
|
|
10
|
+
@io = io
|
|
11
|
+
@strict = strict
|
|
12
|
+
@allow_incomplete = allow_incomplete
|
|
13
|
+
@limits = SenML.limits(options)
|
|
14
|
+
@warnings = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Yield records immediately; a later malformed record can still raise.
|
|
18
|
+
# @yieldparam record [Record] measurement with the current base context
|
|
19
|
+
# @return [StreamReader, Enumerator]
|
|
20
|
+
def each
|
|
21
|
+
return enum_for(:each) unless block_given?
|
|
22
|
+
|
|
23
|
+
validator = Validator.new(strict: @strict, limits: @limits)
|
|
24
|
+
@warnings = validator.warnings
|
|
25
|
+
phase = :start
|
|
26
|
+
buffer = +"".b
|
|
27
|
+
depth = 0
|
|
28
|
+
quoted = false
|
|
29
|
+
escaped = false
|
|
30
|
+
index = 0
|
|
31
|
+
while (chunk = @io.read(4096)) && !chunk.empty?
|
|
32
|
+
chunk.each_byte do |byte|
|
|
33
|
+
if depth.positive?
|
|
34
|
+
buffer << byte
|
|
35
|
+
raise LimitError, "stream record exceeds max_record_bytes" if buffer.bytesize > @limits[:max_record_bytes]
|
|
36
|
+
|
|
37
|
+
if quoted
|
|
38
|
+
if escaped then escaped = false
|
|
39
|
+
elsif byte == 92 then escaped = true
|
|
40
|
+
elsif byte == 34 then quoted = false
|
|
41
|
+
end
|
|
42
|
+
elsif byte == 34 then quoted = true
|
|
43
|
+
elsif [123, 91].include?(byte) then depth += 1
|
|
44
|
+
elsif [125, 93].include?(byte) then depth -= 1
|
|
45
|
+
end
|
|
46
|
+
raise LimitError, "stream record exceeds max_depth" if depth > @limits[:max_depth]
|
|
47
|
+
next unless depth.zero?
|
|
48
|
+
|
|
49
|
+
raw = JSONCodec.parse(buffer, max_depth: @limits[:max_depth])
|
|
50
|
+
base = validator.accept(raw, index)
|
|
51
|
+
yield Record.new(raw, base: base, **@limits)
|
|
52
|
+
index += 1
|
|
53
|
+
phase = :separator
|
|
54
|
+
elsif [9, 10, 13, 32].include?(byte)
|
|
55
|
+
next
|
|
56
|
+
elsif (phase == :start && byte == 91) || (phase == :separator && byte == 44)
|
|
57
|
+
phase = :record
|
|
58
|
+
elsif phase == :record && byte == 123
|
|
59
|
+
raise LimitError, "stream exceeds max_records" if index >= @limits[:max_records]
|
|
60
|
+
|
|
61
|
+
buffer = +"{".b
|
|
62
|
+
depth = 1
|
|
63
|
+
elsif phase == :separator && byte == 93
|
|
64
|
+
phase = :end
|
|
65
|
+
else
|
|
66
|
+
raise ParseError, "invalid SenSML array structure"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
raise ParseError, "incomplete SenSML array" unless phase == :end || (@allow_incomplete && index.positive? && depth.zero?)
|
|
71
|
+
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SenML
|
|
4
|
+
class Validator
|
|
5
|
+
attr_reader :base, :warnings, :version, :record_bytes
|
|
6
|
+
|
|
7
|
+
def initialize(strict:, limits:)
|
|
8
|
+
@strict = strict
|
|
9
|
+
@limits = limits
|
|
10
|
+
@base = {}
|
|
11
|
+
@warnings = []
|
|
12
|
+
@version = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def accept(record, index)
|
|
16
|
+
pointer = "/#{index}"
|
|
17
|
+
raise ParseError, "#{pointer}: record must be an object" unless record.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
json_value(record, pointer, 0)
|
|
20
|
+
@record_bytes = JSONCodec.generate(record).bytesize
|
|
21
|
+
raise LimitError, "record exceeds max_record_bytes" if @record_bytes > @limits[:max_record_bytes]
|
|
22
|
+
|
|
23
|
+
record.each do |label, value|
|
|
24
|
+
raise ParseError, "record labels must be strings" unless label.is_a?(String)
|
|
25
|
+
|
|
26
|
+
if label.end_with?("_") && !LABELS.key?(label)
|
|
27
|
+
escaped = label.gsub("~", "~0").gsub("/", "~1")
|
|
28
|
+
raise ValidationError.new("unknown critical field", pointer: "#{pointer}/#{escaped}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
spec = LABELS[label]
|
|
32
|
+
next unless spec
|
|
33
|
+
|
|
34
|
+
valid = case spec["type"]
|
|
35
|
+
when "string" then value.is_a?(String)
|
|
36
|
+
when "number" then (value.is_a?(Integer) || value.is_a?(Float)) && value.finite?
|
|
37
|
+
when "integer" then value.is_a?(Integer) && value.positive?
|
|
38
|
+
when "boolean" then [true, false].include?(value)
|
|
39
|
+
end
|
|
40
|
+
raise ParseError, "#{pointer}/#{label}: invalid #{spec["type"]} value" unless valid
|
|
41
|
+
end
|
|
42
|
+
BASE_LABELS.each do |key|
|
|
43
|
+
next unless record.key?(key)
|
|
44
|
+
|
|
45
|
+
value = record[key]
|
|
46
|
+
@base[key] = value.is_a?(String) ? value.dup.freeze : value
|
|
47
|
+
end
|
|
48
|
+
# Only the first unknown base name is needed to prevent unsafe resolution;
|
|
49
|
+
# do not retain arbitrary unknown base payloads across the whole stream.
|
|
50
|
+
@unknown_base ||= record.keys.find { |key| key.start_with?("b") && !BASE_LABELS.include?(key) }
|
|
51
|
+
@base[@unknown_base] = nil if @unknown_base
|
|
52
|
+
@base_name_valid = @base.fetch("bn", "").match?(%r{\A[A-Za-z0-9][A-Za-z0-9:._/-]*\z}) if record.key?("bn")
|
|
53
|
+
current = @base.fetch("bver", FORMAT_VERSION)
|
|
54
|
+
raise UnsupportedVersionError.new("unsupported version #{current}", pointer: "#{pointer}/bver") if current > FORMAT_VERSION
|
|
55
|
+
raise ValidationError.new("all records must use the same version", pointer: "#{pointer}/bver") if @version && @version != current
|
|
56
|
+
|
|
57
|
+
@version = current
|
|
58
|
+
fields = VALUE_LABELS.count { |key| record.key?(key) }
|
|
59
|
+
effective_value = fields == 1 || (fields.zero? && @base.key?("bv"))
|
|
60
|
+
effective_sum = record.key?("s") || @base.key?("bs")
|
|
61
|
+
violation("expected exactly one value, or a sum", pointer) if fields > 1 || (!effective_value && !effective_sum)
|
|
62
|
+
suffix = record.fetch("n", "")
|
|
63
|
+
valid_name = if @base.fetch("bn", "").empty?
|
|
64
|
+
suffix.match?(%r{\A[A-Za-z0-9][A-Za-z0-9:._/-]*\z})
|
|
65
|
+
else
|
|
66
|
+
@base_name_valid && suffix.match?(%r{\A[A-Za-z0-9:._/-]*\z})
|
|
67
|
+
end
|
|
68
|
+
violation("invalid resolved name", "#{pointer}/n") unless valid_name
|
|
69
|
+
%w[bu u].each do |key|
|
|
70
|
+
violation("unregistered unit", "#{pointer}/#{key}") if record.key?(key) && !Units.registered?(record[key])
|
|
71
|
+
end
|
|
72
|
+
violation("update time must be nonnegative", "#{pointer}/ut") if record.key?("ut") && record["ut"].negative?
|
|
73
|
+
validate_data(record["vd"], "#{pointer}/vd") if record.key?("vd")
|
|
74
|
+
@base.dup.freeze
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_data(text, pointer)
|
|
78
|
+
valid = text.match?(/\A[A-Za-z0-9_-]*\z/) && text.length % 4 != 1
|
|
79
|
+
if valid && !text.empty? && text.length % 4 != 0
|
|
80
|
+
value = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".index(text[-1])
|
|
81
|
+
valid = value.nobits?(text.length % 4 == 2 ? 15 : 3)
|
|
82
|
+
end
|
|
83
|
+
violation("expected canonical unpadded base64url", pointer) unless valid
|
|
84
|
+
raise LimitError, "binary value exceeds max_data_bytes" if text.bytesize * 3 / 4 > @limits[:max_data_bytes]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def json_value(value, pointer, depth)
|
|
88
|
+
raise LimitError, "value exceeds max_depth" if depth > @limits[:max_depth]
|
|
89
|
+
|
|
90
|
+
case value
|
|
91
|
+
when Hash
|
|
92
|
+
value.each do |key, child|
|
|
93
|
+
valid = key.is_a?(String) && key.valid_encoding? && (key.encoding == Encoding::UTF_8 || key.ascii_only?)
|
|
94
|
+
raise ParseError, "JSON keys must be valid UTF-8 text" unless valid
|
|
95
|
+
|
|
96
|
+
json_value(child, "#{pointer}/#{key.gsub("~", "~0").gsub("/", "~1")}", depth + 1)
|
|
97
|
+
end
|
|
98
|
+
when Array then value.each_with_index { |child, i| json_value(child, "#{pointer}/#{i}", depth + 1) }
|
|
99
|
+
when Float then raise ParseError, "nonfinite number" unless value.finite?
|
|
100
|
+
when String then raise ParseError, "invalid UTF-8 string" unless value.encoding == Encoding::UTF_8 ? value.valid_encoding? : value.ascii_only?
|
|
101
|
+
when Integer, TrueClass, FalseClass, NilClass
|
|
102
|
+
nil
|
|
103
|
+
else raise ParseError, "unsupported JSON value"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def violation(message, pointer)
|
|
110
|
+
error = ValidationError.new(message, pointer: pointer)
|
|
111
|
+
raise error if @strict
|
|
112
|
+
|
|
113
|
+
raise LimitError, "diagnostics exceed max_warnings" if @warnings.length >= @limits[:max_warnings]
|
|
114
|
+
|
|
115
|
+
@warnings << error
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
data/lib/senml.rb
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "senml/version"
|
|
7
|
+
|
|
8
|
+
module SenML
|
|
9
|
+
VERSION = Senml::VERSION
|
|
10
|
+
FORMAT_VERSION = 10
|
|
11
|
+
ABSOLUTE_TIME = 2**28
|
|
12
|
+
LIMITS = { max_bytes: 16_777_216, max_records: 100_000, max_record_bytes: 1_048_576, max_data_bytes: 786_432, max_depth: 32, max_warnings: 1_000 }.freeze
|
|
13
|
+
class Error < StandardError; end
|
|
14
|
+
class ParseError < Error; end
|
|
15
|
+
class ConversionError < Error; end
|
|
16
|
+
class LimitError < ParseError; end
|
|
17
|
+
|
|
18
|
+
class ValidationError < Error
|
|
19
|
+
attr_reader :pointer
|
|
20
|
+
|
|
21
|
+
def initialize(message, pointer: "")
|
|
22
|
+
@pointer = pointer
|
|
23
|
+
super("#{pointer.empty? ? "/" : pointer}: #{message}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class UnsupportedVersionError < ValidationError; end
|
|
28
|
+
|
|
29
|
+
def self.limits(options)
|
|
30
|
+
raise ArgumentError, "unknown limit option" unless (options.keys - LIMITS.keys).empty?
|
|
31
|
+
|
|
32
|
+
values = LIMITS.merge(options)
|
|
33
|
+
raise ArgumentError, "limits must be positive integers" unless values.values.all? { |v| v.is_a?(Integer) && v.positive? }
|
|
34
|
+
raise ArgumentError, "max_depth must be at most 128" if values[:max_depth] > 128
|
|
35
|
+
|
|
36
|
+
values
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.copy(value, depth = 0)
|
|
40
|
+
raise LimitError, "data exceeds safe copy depth" if depth > 128
|
|
41
|
+
|
|
42
|
+
case value
|
|
43
|
+
when Hash then value.to_h { |k, v| [k.dup, copy(v, depth + 1)] }
|
|
44
|
+
when Array then value.map { |v| copy(v, depth + 1) }
|
|
45
|
+
when String then value.dup
|
|
46
|
+
else value
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse a complete SenML JSON pack without executing or fetching its contents.
|
|
51
|
+
# @param source [String] UTF-8 JSON array
|
|
52
|
+
# @param strict [Boolean] raise on recoverable semantic violations
|
|
53
|
+
# @return [Pack]
|
|
54
|
+
# @raise [ParseError, ValidationError, LimitError]
|
|
55
|
+
def self.parse(source, strict: true, **options)
|
|
56
|
+
options = limits(options)
|
|
57
|
+
raise ParseError, "JSON input must be a String" unless source.is_a?(String)
|
|
58
|
+
raise LimitError, "pack exceeds max_bytes" if source.bytesize > options[:max_bytes]
|
|
59
|
+
|
|
60
|
+
records = JSONCodec.parse(source, max_depth: options[:max_depth])
|
|
61
|
+
Pack.new(records, strict: strict, **options)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
require_relative "senml/labels"
|
|
65
|
+
require_relative "senml/json_codec"
|
|
66
|
+
require_relative "senml/validator"
|
|
67
|
+
require_relative "senml/record"
|
|
68
|
+
require_relative "senml/pack"
|
|
69
|
+
require_relative "senml/stream_reader"
|
data/sig/senml.rbs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Senml
|
|
2
|
+
VERSION: String
|
|
3
|
+
end
|
|
4
|
+
module SenML
|
|
5
|
+
VERSION: String
|
|
6
|
+
FORMAT_VERSION: Integer
|
|
7
|
+
ABSOLUTE_TIME: Integer
|
|
8
|
+
type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
|
|
9
|
+
type object = Hash[String, json]
|
|
10
|
+
type number = Integer | Float
|
|
11
|
+
class Error < StandardError
|
|
12
|
+
end
|
|
13
|
+
class ParseError < Error
|
|
14
|
+
end
|
|
15
|
+
class ConversionError < Error
|
|
16
|
+
end
|
|
17
|
+
class LimitError < ParseError
|
|
18
|
+
end
|
|
19
|
+
class ValidationError < Error
|
|
20
|
+
attr_reader pointer: String
|
|
21
|
+
def initialize: (String, ?pointer: String) -> void
|
|
22
|
+
end
|
|
23
|
+
class UnsupportedVersionError < ValidationError
|
|
24
|
+
end
|
|
25
|
+
def self.parse: (String, ?strict: bool, **Integer) -> Pack
|
|
26
|
+
def self.parse_cbor: (String, ?strict: bool, ?streaming: bool, **Integer) -> Pack
|
|
27
|
+
module Units
|
|
28
|
+
SYMBOLS: Array[String]
|
|
29
|
+
def self.registered?: (String) -> bool
|
|
30
|
+
end
|
|
31
|
+
class Record
|
|
32
|
+
def initialize: (object, ?base: object, **Integer) -> void
|
|
33
|
+
def []: (String | Symbol) -> json
|
|
34
|
+
def to_h: () -> object
|
|
35
|
+
def name: () -> String
|
|
36
|
+
def unit: () -> String?
|
|
37
|
+
def time: () -> number
|
|
38
|
+
def value: () -> (number | String | bool)?
|
|
39
|
+
def sum: () -> number?
|
|
40
|
+
def time_at: (?now: Time | number | nil) -> Time
|
|
41
|
+
def data: () -> String?
|
|
42
|
+
def resolved: (now: number) -> object
|
|
43
|
+
def base_name: () -> String?
|
|
44
|
+
def base_time: () -> number?
|
|
45
|
+
def base_unit: () -> String?
|
|
46
|
+
def base_value: () -> number?
|
|
47
|
+
def base_sum: () -> number?
|
|
48
|
+
def base_version: () -> Integer?
|
|
49
|
+
def numeric_value: () -> number?
|
|
50
|
+
def string_value: () -> String?
|
|
51
|
+
def boolean_value: () -> bool?
|
|
52
|
+
def data_value: () -> String?
|
|
53
|
+
def update_time: () -> number?
|
|
54
|
+
def object_link: () -> String?
|
|
55
|
+
def base_content_format: () -> String?
|
|
56
|
+
def content_format: () -> String?
|
|
57
|
+
end
|
|
58
|
+
class Pack
|
|
59
|
+
include Enumerable[Record]
|
|
60
|
+
attr_reader warnings: Array[ValidationError]
|
|
61
|
+
def initialize: (Array[object | Record], ?strict: bool, **Integer) -> void
|
|
62
|
+
def each: () { (Record) -> void } -> Array[Record]
|
|
63
|
+
| () -> Enumerator[Record, Array[Record]]
|
|
64
|
+
def []: (Integer) -> Record?
|
|
65
|
+
def size: () -> Integer
|
|
66
|
+
alias length size
|
|
67
|
+
def to_h: () -> Array[object]
|
|
68
|
+
def to_json: (*untyped) -> String
|
|
69
|
+
def to_cbor: () -> String
|
|
70
|
+
def resolve: (?now: Time | number) -> Pack
|
|
71
|
+
def select_fragment: (String) -> Pack
|
|
72
|
+
def self.build: (?base_name: String?, ?base_time: number?, ?base_unit: String?, **untyped) { (Builder) -> void } -> Pack
|
|
73
|
+
class Builder
|
|
74
|
+
attr_reader records: Array[object]
|
|
75
|
+
def initialize: (object) -> void
|
|
76
|
+
def add: (**json) -> Builder
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
class StreamReader
|
|
80
|
+
include Enumerable[Record]
|
|
81
|
+
attr_reader warnings: Array[ValidationError]
|
|
82
|
+
def initialize: (untyped, ?strict: bool, ?allow_incomplete: bool, **Integer) -> void
|
|
83
|
+
def each: () { (Record) -> void } -> StreamReader
|
|
84
|
+
| () -> Enumerator[Record, StreamReader]
|
|
85
|
+
end
|
|
86
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: senml
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 2.19.4
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '4'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 2.19.4
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '4'
|
|
32
|
+
description: RFC 8428 SenML JSON, base-value resolution, incremental SenSML streams,
|
|
33
|
+
fragments, and optional CBOR.
|
|
34
|
+
email:
|
|
35
|
+
- t.yudai92@gmail.com
|
|
36
|
+
executables: []
|
|
37
|
+
extensions: []
|
|
38
|
+
extra_rdoc_files: []
|
|
39
|
+
files:
|
|
40
|
+
- CHANGELOG.md
|
|
41
|
+
- LICENSE.txt
|
|
42
|
+
- README.md
|
|
43
|
+
- SECURITY.md
|
|
44
|
+
- data/labels.yml
|
|
45
|
+
- data/units.yml
|
|
46
|
+
- lib/senml.rb
|
|
47
|
+
- lib/senml/cbor.rb
|
|
48
|
+
- lib/senml/json_codec.rb
|
|
49
|
+
- lib/senml/labels.rb
|
|
50
|
+
- lib/senml/pack.rb
|
|
51
|
+
- lib/senml/record.rb
|
|
52
|
+
- lib/senml/stream_reader.rb
|
|
53
|
+
- lib/senml/validator.rb
|
|
54
|
+
- lib/senml/version.rb
|
|
55
|
+
- sig/senml.rbs
|
|
56
|
+
homepage: https://github.com/ydah/senml
|
|
57
|
+
licenses:
|
|
58
|
+
- MIT
|
|
59
|
+
metadata:
|
|
60
|
+
allowed_push_host: https://rubygems.org
|
|
61
|
+
source_code_uri: https://github.com/ydah/senml
|
|
62
|
+
changelog_uri: https://github.com/ydah/senml/blob/main/CHANGELOG.md
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: 3.3.0
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 4.0.16
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Read, validate, and resolve SenML sensor measurements
|
|
81
|
+
test_files: []
|