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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-12
4
+
5
+ Initial release.
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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Senml
4
+ VERSION = "0.1.0"
5
+ 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: []