takagi 0.1.0 → 1.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.
Files changed (197) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +70 -7
  3. data/.yard/templates/default/layout/html/layout.erb +34 -0
  4. data/AGENTS.md +16 -0
  5. data/CHANGELOG.md +158 -1
  6. data/CODE_OF_CONDUCT.md +1 -1
  7. data/README.md +590 -23
  8. data/ROADMAP.md +55 -0
  9. data/Rakefile +4 -4
  10. data/Steepfile +39 -0
  11. data/bin/takagi-dev +159 -0
  12. data/docs/FIRST_PLUGIN_GUIDE.md +224 -0
  13. data/docs/HOOKS.md +31 -0
  14. data/examples/client_lifecycle_example.rb +118 -0
  15. data/examples/cloud_gateway_app.rb +217 -0
  16. data/examples/nested_api_app.rb +258 -0
  17. data/examples/simple_device_app.rb +71 -0
  18. data/examples/takagi.yml +138 -0
  19. data/lib/takagi/application.rb +256 -0
  20. data/lib/takagi/base/middleware_management.rb +39 -0
  21. data/lib/takagi/base/plugin_management.rb +75 -0
  22. data/lib/takagi/base/reactor_management.rb +104 -0
  23. data/lib/takagi/base/server_lifecycle.rb +156 -0
  24. data/lib/takagi/base.rb +103 -11
  25. data/lib/takagi/branding.rb +88 -0
  26. data/lib/takagi/cbor/decoder.rb +385 -0
  27. data/lib/takagi/cbor/encoder.rb +260 -0
  28. data/lib/takagi/cbor/error.rb +17 -0
  29. data/lib/takagi/cbor/version.rb +9 -0
  30. data/lib/takagi/client/response.rb +236 -0
  31. data/lib/takagi/client.rb +265 -0
  32. data/lib/takagi/client_base.rb +204 -0
  33. data/lib/takagi/coap/code_helpers.rb +190 -0
  34. data/lib/takagi/coap/registries/base.rb +165 -0
  35. data/lib/takagi/coap/registries/content_format.rb +71 -0
  36. data/lib/takagi/coap/registries/message_type.rb +69 -0
  37. data/lib/takagi/coap/registries/method.rb +38 -0
  38. data/lib/takagi/coap/registries/option.rb +71 -0
  39. data/lib/takagi/coap/registries/response.rb +93 -0
  40. data/lib/takagi/coap/registries/signaling.rb +34 -0
  41. data/lib/takagi/coap/signaling.rb +10 -0
  42. data/lib/takagi/coap.rb +37 -0
  43. data/lib/takagi/composite_router.rb +186 -0
  44. data/lib/takagi/config.rb +337 -0
  45. data/lib/takagi/controller/resource_allocator.rb +164 -0
  46. data/lib/takagi/controller/thread_pool.rb +144 -0
  47. data/lib/takagi/controller.rb +319 -0
  48. data/lib/takagi/core/attribute_set.rb +128 -0
  49. data/lib/takagi/discovery/core_link_format.rb +137 -0
  50. data/lib/takagi/errors.rb +536 -0
  51. data/lib/takagi/event_bus/address_prefix.rb +142 -0
  52. data/lib/takagi/event_bus/async_executor.rb +235 -0
  53. data/lib/takagi/event_bus/coap_bridge.rb +208 -0
  54. data/lib/takagi/event_bus/future.rb +153 -0
  55. data/lib/takagi/event_bus/lru_cache.rb +157 -0
  56. data/lib/takagi/event_bus/message_buffer.rb +237 -0
  57. data/lib/takagi/event_bus/observer_cleanup.rb +110 -0
  58. data/lib/takagi/event_bus/scope.rb +74 -0
  59. data/lib/takagi/event_bus.rb +594 -0
  60. data/lib/takagi/helpers.rb +88 -0
  61. data/lib/takagi/hooks.rb +82 -0
  62. data/lib/takagi/initializer.rb +18 -0
  63. data/lib/takagi/logger.rb +15 -6
  64. data/lib/takagi/message/base.rb +155 -0
  65. data/lib/takagi/message/deduplication_cache.rb +84 -0
  66. data/lib/takagi/message/inbound.rb +147 -0
  67. data/lib/takagi/message/outbound.rb +223 -0
  68. data/lib/takagi/message/request.rb +158 -0
  69. data/lib/takagi/message/retransmission_manager.rb +193 -0
  70. data/lib/takagi/middleware/authentication.rb +19 -0
  71. data/lib/takagi/middleware/caching.rb +23 -0
  72. data/lib/takagi/middleware/debugging.rb +16 -0
  73. data/lib/takagi/middleware/logging.rb +14 -0
  74. data/lib/takagi/middleware/metrics.rb +440 -0
  75. data/lib/takagi/middleware/rate_limiting.rb +24 -0
  76. data/lib/takagi/middleware_stack.rb +166 -0
  77. data/lib/takagi/network/base.rb +76 -0
  78. data/lib/takagi/network/framing/tcp.rb +222 -0
  79. data/lib/takagi/network/framing/udp.rb +110 -0
  80. data/lib/takagi/network/registry.rb +72 -0
  81. data/lib/takagi/network/tcp.rb +60 -0
  82. data/lib/takagi/network/tcp_sender.rb +21 -0
  83. data/lib/takagi/network/udp.rb +61 -0
  84. data/lib/takagi/network/udp_sender.rb +20 -0
  85. data/lib/takagi/observable/emitter.rb +62 -0
  86. data/lib/takagi/observable/reactor.rb +488 -0
  87. data/lib/takagi/observable/registry.rb +122 -0
  88. data/lib/takagi/observe_registry.rb +10 -0
  89. data/lib/takagi/observer/client.rb +68 -0
  90. data/lib/takagi/observer/registry.rb +137 -0
  91. data/lib/takagi/observer/sender.rb +39 -0
  92. data/lib/takagi/observer/watcher.rb +43 -0
  93. data/lib/takagi/plugin.rb +313 -0
  94. data/lib/takagi/profiles.rb +176 -0
  95. data/lib/takagi/reactor.rb +23 -0
  96. data/lib/takagi/reactor_registry.rb +64 -0
  97. data/lib/takagi/registry/base.rb +268 -0
  98. data/lib/takagi/response_builder.rb +141 -0
  99. data/lib/takagi/router/metadata_extractor.rb +133 -0
  100. data/lib/takagi/router/route_matcher.rb +83 -0
  101. data/lib/takagi/router.rb +284 -25
  102. data/lib/takagi/serialization/base.rb +102 -0
  103. data/lib/takagi/serialization/cbor_serializer.rb +92 -0
  104. data/lib/takagi/serialization/json_serializer.rb +96 -0
  105. data/lib/takagi/serialization/octet_stream_serializer.rb +82 -0
  106. data/lib/takagi/serialization/registry.rb +187 -0
  107. data/lib/takagi/serialization/text_serializer.rb +87 -0
  108. data/lib/takagi/serialization.rb +117 -0
  109. data/lib/takagi/server/multi.rb +41 -0
  110. data/lib/takagi/server/registry.rb +71 -0
  111. data/lib/takagi/server/tcp.rb +249 -0
  112. data/lib/takagi/server/udp.rb +139 -0
  113. data/lib/takagi/server/udp_worker.rb +174 -0
  114. data/lib/takagi/server.rb +1 -31
  115. data/lib/takagi/server_registry.rb +10 -0
  116. data/lib/takagi/tcp_client.rb +142 -0
  117. data/lib/takagi/version.rb +2 -1
  118. data/lib/takagi.rb +24 -3
  119. data/sig/takagi/application.rbs +48 -0
  120. data/sig/takagi/base/middleware_management.rbs +33 -0
  121. data/sig/takagi/base/reactor_management.rbs +52 -0
  122. data/sig/takagi/base/server_lifecycle.rbs +54 -0
  123. data/sig/takagi/base.rbs +48 -0
  124. data/sig/takagi/cbor/decoder.rbs +171 -0
  125. data/sig/takagi/cbor/encoder.rbs +146 -0
  126. data/sig/takagi/cbor/error.rbs +19 -0
  127. data/sig/takagi/cbor/version.rbs +7 -0
  128. data/sig/takagi/client/response.rbs +148 -0
  129. data/sig/takagi/client.rbs +119 -0
  130. data/sig/takagi/client_base.rbs +135 -0
  131. data/sig/takagi/coap/code_helpers.rbs +91 -0
  132. data/sig/takagi/coap/registries/base.rbs +95 -0
  133. data/sig/takagi/coap/registries/content_format.rbs +47 -0
  134. data/sig/takagi/coap/registries/message_type.rbs +53 -0
  135. data/sig/takagi/coap/registries/method.rbs +27 -0
  136. data/sig/takagi/coap/registries/option.rbs +43 -0
  137. data/sig/takagi/coap/registries/response.rbs +52 -0
  138. data/sig/takagi/coap.rbs +24 -0
  139. data/sig/takagi/composite_router.rbs +46 -0
  140. data/sig/takagi/config.rbs +134 -0
  141. data/sig/takagi/controller.rbs +73 -0
  142. data/sig/takagi/core/attribute_set.rbs +57 -0
  143. data/sig/takagi/discovery/core_link_format.rbs +50 -0
  144. data/sig/takagi/event_bus/address_prefix.rbs +78 -0
  145. data/sig/takagi/event_bus/async_executor.rbs +88 -0
  146. data/sig/takagi/event_bus/coap_bridge.rbs +93 -0
  147. data/sig/takagi/event_bus/future.rbs +78 -0
  148. data/sig/takagi/event_bus/lru_cache.rbs +86 -0
  149. data/sig/takagi/event_bus/message_buffer.rbs +133 -0
  150. data/sig/takagi/event_bus/observer_cleanup.rbs +62 -0
  151. data/sig/takagi/event_bus.rbs +320 -0
  152. data/sig/takagi/helpers.rbs +34 -0
  153. data/sig/takagi/initializer.rbs +9 -0
  154. data/sig/takagi/logger.rbs +17 -0
  155. data/sig/takagi/message/base.rbs +64 -0
  156. data/sig/takagi/message/deduplication_cache.rbs +49 -0
  157. data/sig/takagi/message/inbound.rbs +76 -0
  158. data/sig/takagi/message/outbound.rbs +48 -0
  159. data/sig/takagi/message/request.rbs +32 -0
  160. data/sig/takagi/message/retransmission_manager.rbs +76 -0
  161. data/sig/takagi/middleware/authentication.rbs +11 -0
  162. data/sig/takagi/middleware/caching.rbs +13 -0
  163. data/sig/takagi/middleware/debugging.rbs +9 -0
  164. data/sig/takagi/middleware/logging.rbs +7 -0
  165. data/sig/takagi/middleware/metrics.rbs +15 -0
  166. data/sig/takagi/middleware/rate_limiting.rbs +13 -0
  167. data/sig/takagi/middleware_stack.rbs +69 -0
  168. data/sig/takagi/network/tcp_sender.rbs +10 -0
  169. data/sig/takagi/network/udp_sender.rbs +14 -0
  170. data/sig/takagi/observe_registry.rbs +36 -0
  171. data/sig/takagi/observer/client.rbs +36 -0
  172. data/sig/takagi/observer/sender.rbs +12 -0
  173. data/sig/takagi/observer/watcher.rbs +18 -0
  174. data/sig/takagi/profiles.rbs +33 -0
  175. data/sig/takagi/reactor.rbs +20 -0
  176. data/sig/takagi/reactor_registry.rbs +14 -0
  177. data/sig/takagi/response_builder.rbs +12 -0
  178. data/sig/takagi/router/metadata_extractor.rbs +71 -0
  179. data/sig/takagi/router/route_matcher.rbs +43 -0
  180. data/sig/takagi/router.rbs +166 -0
  181. data/sig/takagi/serialization.rbs +32 -0
  182. data/sig/takagi/server/multi.rbs +16 -0
  183. data/sig/takagi/server/tcp.rbs +42 -0
  184. data/sig/takagi/server/udp.rbs +52 -0
  185. data/sig/takagi/server/udp_worker.rbs +42 -0
  186. data/sig/takagi/server.rbs +4 -0
  187. data/sig/takagi/server_registry.rbs +71 -0
  188. data/sig/takagi/tcp_client.rbs +23 -0
  189. data/sig/takagi/version.rbs +5 -0
  190. data/takagi.gemspec +37 -35
  191. metadata +204 -31
  192. data/.idea/.gitignore +0 -8
  193. data/.idea/misc.xml +0 -4
  194. data/.idea/modules.xml +0 -8
  195. data/.idea/takagi.iml +0 -81
  196. data/.idea/vcs.xml +0 -6
  197. data/lib/takagi/message.rb +0 -75
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'forwardable'
5
+ require_relative 'client_base'
6
+ require_relative 'message/retransmission_manager'
7
+
8
+ module Takagi
9
+ # Unified Takagi Client for communicating with Takagi servers over CoAP.
10
+ #
11
+ # Supports multiple protocols (UDP, TCP) with automatic protocol detection
12
+ # based on URI scheme or explicit protocol parameter.
13
+ #
14
+ # @example Block-based with protocol auto-detection (recommended)
15
+ # Takagi::Client.new('coap://localhost:5683') do |client|
16
+ # client.get('/temperature')
17
+ # end
18
+ #
19
+ # @example Block-based with explicit protocol
20
+ # Takagi::Client.new('localhost:5683', protocol: :tcp) do |client|
21
+ # client.get('/temperature')
22
+ # end
23
+ #
24
+ # @example Manual lifecycle management
25
+ # client = Takagi::Client.new('coap://localhost:5683')
26
+ # begin
27
+ # client.get('/temperature')
28
+ # ensure
29
+ # client.close
30
+ # end
31
+ class Client < ClientBase
32
+ extend Forwardable
33
+
34
+ # Delegate public methods to the implementation
35
+ def_delegators :@impl, :server_uri, :timeout, :callbacks, :closed?
36
+ def_delegators :@impl, :get, :post, :put, :delete, :on
37
+ def_delegators :@impl, :get_json, :post_json, :put_json, :close
38
+
39
+ # Creates a new client and optionally yields it to a block.
40
+ #
41
+ # @param server_uri [String] URL of the Takagi server (e.g., 'coap://localhost:5683', 'localhost:5683')
42
+ # @param timeout [Integer] Maximum time to wait for a response
43
+ # @param protocol [Symbol, nil] Protocol to use (:udp, :tcp, or nil for auto-detection from URI)
44
+ # @param use_retransmission [Boolean] Enable RFC 7252 §4.2 compliant retransmission for UDP (default: true)
45
+ # @yield [client] Optionally yields the client to a block and auto-closes afterward
46
+ # @return [Client, Object] Returns the client instance, or the block's return value if a block is given
47
+ #
48
+ # @example Protocol auto-detection from URI
49
+ # client = Takagi::Client.new('coap://localhost:5683') # Uses UDP
50
+ # client = Takagi::Client.new('coap+tcp://localhost:5683') # Uses TCP
51
+ #
52
+ # @example Explicit protocol specification
53
+ # client = Takagi::Client.new('localhost:5683', protocol: :tcp)
54
+ # client = Takagi::Client.new('localhost:5683', protocol: :udp)
55
+ #
56
+ # @example With block (auto-close)
57
+ # Takagi::Client.new('coap://localhost:5683') do |client|
58
+ # client.get('/resource')
59
+ # end
60
+ def initialize(server_uri, timeout: 5, protocol: nil, use_retransmission: true)
61
+ # Detect protocol from URI if not explicitly specified
62
+ @protocol = protocol || detect_protocol(server_uri)
63
+
64
+ # Delegate to the appropriate client implementation
65
+ @impl = create_client_impl(server_uri, timeout, use_retransmission)
66
+
67
+ # If a block is given, yield and auto-close
68
+ return unless block_given?
69
+
70
+ begin
71
+ yield(self)
72
+ ensure
73
+ close
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Detects the protocol from the URI scheme using transport registry
80
+ # @param uri_string [String] The URI to parse
81
+ # @return [Symbol] :tcp or :udp
82
+ def detect_protocol(uri_string)
83
+ uri = URI(uri_string.start_with?('coap') ? uri_string : "coap://#{uri_string}")
84
+
85
+ # NEW: Use transport registry to find transport for scheme
86
+ transport = Takagi::Network::Registry.for_scheme(uri.scheme)
87
+
88
+ if transport
89
+ # Map transport class to symbol
90
+ case transport.scheme
91
+ when 'coap+tcp', 'coaps+tcp'
92
+ :tcp
93
+ else
94
+ :udp
95
+ end
96
+ else
97
+ :udp # Default to UDP if transport not found
98
+ end
99
+ rescue URI::InvalidURIError, Takagi::Network::Registry::TransportNotFoundError
100
+ :udp # Default to UDP if URI parsing fails or transport not found
101
+ end
102
+
103
+ # Creates the appropriate client implementation based on protocol
104
+ # @param server_uri [String] Server URI
105
+ # @param timeout [Integer] Request timeout
106
+ # @param use_retransmission [Boolean] Enable retransmission for UDP
107
+ # @return [UdpClient, TcpClient] The client implementation
108
+ def create_client_impl(server_uri, timeout, use_retransmission)
109
+ # Normalize URI to include scheme if not present
110
+ normalized_uri = normalize_uri(server_uri)
111
+
112
+ case @protocol
113
+ when :tcp
114
+ require_relative 'tcp_client'
115
+ TcpClient.new(normalized_uri, timeout: timeout)
116
+ when :udp
117
+ UdpClient.new(normalized_uri, timeout: timeout, use_retransmission: use_retransmission)
118
+ else
119
+ raise ArgumentError, "Unknown protocol: #{@protocol}. Use :udp or :tcp"
120
+ end
121
+ end
122
+
123
+ # Normalizes URI to include appropriate scheme
124
+ # @param uri_string [String] The URI string
125
+ # @return [String] Normalized URI with scheme
126
+ def normalize_uri(uri_string)
127
+ return uri_string if uri_string.start_with?('coap')
128
+
129
+ scheme = @protocol == :tcp ? 'coap+tcp' : 'coap'
130
+ "#{scheme}://#{uri_string}"
131
+ end
132
+ end
133
+
134
+ # UDP-specific client implementation (internal)
135
+ # Users should use Takagi::Client with protocol: :udp instead
136
+ class UdpClient < ClientBase
137
+ # Initializes the UDP client
138
+ # @param server_uri [String] URL of the Takagi server
139
+ # @param timeout [Integer] Maximum time to wait for a response
140
+ # @param use_retransmission [Boolean] Enable RFC 7252 §4.2 compliant retransmission (default: true)
141
+ def initialize(server_uri, timeout: 5, use_retransmission: true)
142
+ super(server_uri, timeout: timeout)
143
+ @use_retransmission = use_retransmission
144
+
145
+ return unless @use_retransmission
146
+
147
+ @retransmission_manager = Takagi::Message::RetransmissionManager.new
148
+ @retransmission_manager.start
149
+ end
150
+
151
+ protected
152
+
153
+ # Stops the retransmission manager thread
154
+ def cleanup_resources
155
+ @retransmission_manager&.stop
156
+ super
157
+ end
158
+
159
+ private
160
+
161
+ # Executes a request to the server using Takagi::Message::Request
162
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
163
+ # @param path [String] Resource path
164
+ # @param payload [String] (optional) Data for POST/PUT requests
165
+ # @param options [Hash] (optional) CoAP options { option_number => value }
166
+ # @param type [Integer, nil] (optional) CoAP message type
167
+ # @param callback [Proc] (optional) Callback function for processing the response
168
+ def request(method, path, payload = nil, options: {}, type: nil, &callback)
169
+ uri = URI.join(server_uri.to_s, path)
170
+ message = Takagi::Message::Request.new(
171
+ method: method,
172
+ uri: uri,
173
+ payload: payload,
174
+ type: type,
175
+ options: options
176
+ )
177
+
178
+ if @use_retransmission
179
+ request_with_retransmission(message, uri, &callback)
180
+ else
181
+ request_simple(message, uri, &callback)
182
+ end
183
+ end
184
+
185
+ # Simple request without retransmission (legacy mode)
186
+ def request_simple(message, uri, &callback)
187
+ socket = UDPSocket.new
188
+ socket.send(message.to_bytes, 0, uri.host, uri.port || 5683)
189
+
190
+ unless socket.wait_readable(@timeout)
191
+ puts 'TakagiClient Error: Request timeout'
192
+ return
193
+ end
194
+
195
+ response, = socket.recvfrom(1024)
196
+ deliver_raw_response(response, &callback)
197
+ rescue StandardError => e
198
+ puts "TakagiClient Error: #{e.message}"
199
+ ensure
200
+ socket&.close unless socket&.closed?
201
+ end
202
+
203
+ # RFC 7252 §4.2 compliant request with automatic retransmission
204
+ def request_with_retransmission(message, uri, &callback)
205
+ socket = UDPSocket.new
206
+ state = send_with_retransmission(message, socket, uri)
207
+ socket.close
208
+ handle_response_state(state, &callback)
209
+ rescue StandardError => e
210
+ puts "TakagiClient Error: #{e.message}"
211
+ end
212
+
213
+ def send_with_retransmission(message, socket, uri)
214
+ state = { response_received: false, response_data: nil, error: nil }
215
+
216
+ @retransmission_manager.send_confirmable(
217
+ message.message_id, message.to_bytes, socket, uri.host, uri.port || 5683
218
+ ) { |resp, err| update_state(state, resp, err) }
219
+
220
+ wait_for_response(message, socket, state)
221
+ state
222
+ end
223
+
224
+ def wait_for_response(message, socket, state)
225
+ start_time = Time.now
226
+ check_socket_for_response(message, socket, state) until state[:response_received] || (Time.now - start_time) > @timeout
227
+ end
228
+
229
+ def check_socket_for_response(message, socket, state)
230
+ return unless socket.wait_readable(0.1)
231
+
232
+ state[:response_data], = socket.recvfrom(1024)
233
+ @retransmission_manager.handle_response(message.message_id, state[:response_data])
234
+ state[:response_received] = true
235
+ rescue StandardError => e
236
+ update_state(state, nil, e.message)
237
+ end
238
+
239
+ def update_state(state, response_data, error)
240
+ state[:response_data] = response_data
241
+ state[:error] = error
242
+ state[:response_received] = true
243
+ end
244
+
245
+ def handle_response_state(state, &callback)
246
+ if state[:response_received] && !state[:error]
247
+ deliver_response(state[:response_data], &callback)
248
+ elsif state[:error]
249
+ puts "TakagiClient Error: #{state[:error]}"
250
+ else
251
+ puts 'TakagiClient Error: Request timeout'
252
+ end
253
+ end
254
+
255
+ def deliver_raw_response(response, &callback)
256
+ if callback
257
+ callback.call(response)
258
+ elsif @callbacks[:response]
259
+ @callbacks[:response].call(response)
260
+ else
261
+ puts response
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'json'
5
+
6
+ module Takagi
7
+ # Base class for Takagi clients, providing common functionality
8
+ # for both UDP (CoAP) and TCP (CoAP over TCP) clients.
9
+ #
10
+ # This class defines the common interface and lifecycle management
11
+ # that all Takagi clients should follow.
12
+ class ClientBase
13
+ attr_reader :server_uri, :timeout, :callbacks
14
+
15
+ # Initializes the base client
16
+ # @param server_uri [String] URL of the Takagi server
17
+ # @param timeout [Integer] Maximum time to wait for a response
18
+ def initialize(server_uri, timeout: 5)
19
+ @server_uri = URI(server_uri)
20
+ @timeout = timeout
21
+ @callbacks = {}
22
+ @closed = false
23
+ end
24
+
25
+ # Registers a callback for a given event
26
+ # @param event [Symbol] Event name (e.g., :response)
27
+ # @param callback [Proc] Callback function to handle the event
28
+ def on(event, &callback)
29
+ @callbacks[event] = callback
30
+ end
31
+
32
+ # Sends a GET request
33
+ # @param path [String] Resource path
34
+ # @param options [Hash] CoAP options (e.g. { 17 => 50 } for Accept=application/json)
35
+ # @param type [Integer, nil] CoAP message type (CON/NON/ACK/RST). Defaults to CON.
36
+ # @param callback [Proc] (optional) Callback function for processing the response
37
+ def get(path, options: {}, type: nil, &block)
38
+ request(:get, path, nil, options: options, type: type, &block)
39
+ end
40
+
41
+ # Sends a POST request
42
+ # @param path [String] Resource path
43
+ # @param payload [String, Hash] Data to send (Hash is JSON-encoded automatically)
44
+ # @param options [Hash] CoAP options (e.g. { 12 => 50 } for Content-Format=application/json)
45
+ # @param type [Integer, nil] CoAP message type
46
+ # @param callback [Proc] (optional) Callback function for processing the response
47
+ def post(path, payload = nil, options: {}, type: nil, &block)
48
+ request(:post, path, payload, options: options, type: type, &block)
49
+ end
50
+
51
+ # Sends a PUT request
52
+ # @param path [String] Resource path
53
+ # @param payload [String, Hash] Data to send
54
+ # @param options [Hash] CoAP options
55
+ # @param type [Integer, nil] CoAP message type
56
+ # @param callback [Proc] (optional) Callback function for processing the response
57
+ def put(path, payload = nil, options: {}, type: nil, &block)
58
+ request(:put, path, payload, options: options, type: type, &block)
59
+ end
60
+
61
+ # Sends a DELETE request
62
+ # @param path [String] Resource path
63
+ # @param options [Hash] CoAP options
64
+ # @param type [Integer, nil] CoAP message type
65
+ # @param callback [Proc] (optional) Callback function for processing the response
66
+ def delete(path, options: {}, type: nil, &block)
67
+ request(:delete, path, nil, options: options, type: type, &block)
68
+ end
69
+
70
+ # Sends an OBSERVE request (RFC 7641). The Observe option is added automatically;
71
+ # additional CoAP options can be supplied via `options:`.
72
+ # @param path [String] Resource path
73
+ # @param options [Hash] CoAP options (Observe is injected automatically)
74
+ # @param callback [Proc] (optional) Callback function for processing notifications
75
+ def observe(path, options: {}, &block)
76
+ merged = { Takagi::CoAP::Registries::Option::OBSERVE => 0 }.merge(options)
77
+ request(:get, path, nil, options: merged, &block)
78
+ end
79
+
80
+ # Sends a POST request with JSON payload (convenience method)
81
+ # @param path [String] Resource path
82
+ # @param data [Hash, Array] Data to encode as JSON
83
+ # @param options [Hash] CoAP options
84
+ # @param callback [Proc] (optional) Callback function for processing the response
85
+ def post_json(path, data, options: {}, &block)
86
+ cf = Takagi::CoAP::Registries::ContentFormat::JSON
87
+ merged = { Takagi::CoAP::Registries::Option::CONTENT_FORMAT => cf }.merge(options)
88
+ request(:post, path, JSON.generate(data), options: merged, &block)
89
+ end
90
+
91
+ # Sends a PUT request with JSON payload (convenience method)
92
+ def put_json(path, data, options: {}, &block)
93
+ cf = Takagi::CoAP::Registries::ContentFormat::JSON
94
+ merged = { Takagi::CoAP::Registries::Option::CONTENT_FORMAT => cf }.merge(options)
95
+ request(:put, path, JSON.generate(data), options: merged, &block)
96
+ end
97
+
98
+ # Sends a GET request and automatically parses JSON response (convenience method)
99
+ def get_json(path, options: {}, &block)
100
+ cf = Takagi::CoAP::Registries::ContentFormat::JSON
101
+ merged = { Takagi::CoAP::Registries::Option::ACCEPT => cf }.merge(options)
102
+ if block_given?
103
+ get(path, options: merged) do |response|
104
+ data = response.is_a?(String) ? parse_json_response(response) : response.json
105
+ block.call(data)
106
+ end
107
+ else
108
+ result = nil
109
+ get(path, options: merged) { |response| result = response.is_a?(String) ? parse_json_response(response) : response.json }
110
+ result
111
+ end
112
+ end
113
+
114
+ # Closes the client and releases any resources.
115
+ # This should be called when the client is no longer needed to prevent
116
+ # resource leaks in long-running processes.
117
+ #
118
+ # Subclasses should override this method to perform specific cleanup
119
+ # and then call super.
120
+ def close
121
+ return if @closed
122
+
123
+ cleanup_resources
124
+ @closed = true
125
+ end
126
+
127
+ # Check if the client has been closed
128
+ # @return [Boolean] true if the client is closed
129
+ def closed?
130
+ @closed
131
+ end
132
+
133
+ # Creates a new client and yields it to the block, ensuring it's closed afterward.
134
+ # This is the recommended way to use clients to prevent resource leaks.
135
+ #
136
+ # @param server_uri [String] URL of the Takagi server
137
+ # @param timeout [Integer] Maximum time to wait for a response
138
+ # @param options [Hash] Additional options passed to the subclass constructor
139
+ # @yield [client] Gives the client to the block
140
+ # @return [Object] The return value of the block
141
+ #
142
+ # @example
143
+ # Takagi::Client.open('coap://localhost:5683') do |client|
144
+ # client.get('/temperature')
145
+ # end
146
+ def self.open(server_uri, timeout: 5, **options, &block)
147
+ client = new(server_uri, timeout: timeout, **options)
148
+ block.call(client)
149
+ ensure
150
+ client&.close
151
+ end
152
+
153
+ protected
154
+
155
+ # Subclasses must implement this method to perform the actual request
156
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
157
+ # @param path [String] Resource path
158
+ # @param payload [String] (optional) Data for POST/PUT requests
159
+ # @param options [Hash] (optional) CoAP options { option_number => value }
160
+ # @param type [Integer, nil] (optional) CoAP message type
161
+ # @param callback [Proc] (optional) Callback function for processing the response
162
+ def request(_method, _path, _payload = nil, options: {}, type: nil, &_callback)
163
+ raise NotImplementedError, "#{self.class} must implement #request"
164
+ end
165
+
166
+ # Subclasses can override this to perform specific cleanup
167
+ # Called by #close before marking the client as closed
168
+ def cleanup_resources
169
+ # Default: no-op
170
+ end
171
+
172
+ # Delivers a response using the callback or registered callback
173
+ # Wraps raw response data in a Response object for convenience
174
+ # @param response_data [String] The response data to deliver
175
+ # @param callback [Proc] Optional callback for this specific request
176
+ def deliver_response(response_data, &callback)
177
+ # Wrap response in Response object for better DX
178
+ require_relative 'client/response'
179
+ response = Client::Response.new(response_data)
180
+
181
+ return callback.call(response) if callback
182
+ return @callbacks[:response].call(response) if @callbacks[:response]
183
+
184
+ # Default: print response details
185
+ if response.success?
186
+ puts "[#{response.code_name}] #{response.payload}"
187
+ else
188
+ puts "[ERROR #{response.code_name}] #{response.payload}"
189
+ end
190
+ end
191
+
192
+ # Helper to parse JSON response from raw data
193
+ # @param response_data [String] Raw response data
194
+ # @return [Hash, Array, nil] Parsed JSON or nil
195
+ def parse_json_response(response_data)
196
+ inbound = Takagi::Message::Inbound.new(response_data)
197
+ return nil unless inbound.payload
198
+
199
+ JSON.parse(inbound.payload)
200
+ rescue JSON::ParserError
201
+ nil
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Takagi
4
+ module CoAP
5
+ # Utility methods for working with CoAP codes.
6
+ #
7
+ # Provides conversion between different code representations and
8
+ # lookups across all registries.
9
+ module CodeHelpers
10
+ # Convert a code to its human-readable string representation
11
+ #
12
+ # @param code [Integer, String, Symbol] Code to convert
13
+ # @return [String] Human-readable code string
14
+ #
15
+ # @example
16
+ # CodeHelpers.to_string(69) # => "2.05 Content"
17
+ # CodeHelpers.to_string(:get) # => "GET"
18
+ # CodeHelpers.to_string("2.05") # => "2.05 Content"
19
+ def self.to_string(code)
20
+ case code
21
+ when Integer
22
+ # Try method registry first
23
+ if code < 32
24
+ Registries::Method.name_for(code) || numeric_to_string(code)
25
+ # Try signaling registry for 7.xx codes
26
+ elsif code >= 224
27
+ Registries::Signaling.name_for(code) || numeric_to_string(code)
28
+ # Try response registry
29
+ elsif code >= 64
30
+ Registries::Response.name_for(code) || numeric_to_string(code)
31
+ else
32
+ numeric_to_string(code)
33
+ end
34
+ when Symbol
35
+ # Try method registry
36
+ Registries::Method.value_for(code)&.then { |v| Registries::Method.name_for(v) } ||
37
+ # Try response registry
38
+ Registries::Response.value_for(code)&.then { |v| Registries::Response.name_for(v) } ||
39
+ code.to_s
40
+ when String
41
+ # If already in dotted format, try to lookup
42
+ if code =~ /^(\d)\.(\d{2})$/
43
+ val = string_to_numeric(code)
44
+ Registries::Response.name_for(val) || code
45
+ else
46
+ code
47
+ end
48
+ else
49
+ code.to_s
50
+ end
51
+ end
52
+
53
+ # Convert a code to its numeric representation
54
+ #
55
+ # @param code [Integer, String, Symbol] Code to convert
56
+ # @return [Integer] Numeric code
57
+ #
58
+ # @example
59
+ # CodeHelpers.to_numeric(:get) # => 1
60
+ # CodeHelpers.to_numeric("2.05") # => 69
61
+ # CodeHelpers.to_numeric("2.05 Content") # => 69
62
+ # CodeHelpers.to_numeric(:content) # => 69
63
+ def self.to_numeric(code)
64
+ case code
65
+ when Integer
66
+ code
67
+ when Symbol
68
+ Registries::Method.value_for(code) || Registries::Response.value_for(code) || Registries::Signaling.value_for(code) || 0
69
+ when String
70
+ # Handle "2.05" or "2.05 Content" formats
71
+ if code =~ /^(\d)\.(\d{2})/
72
+ string_to_numeric("#{::Regexp.last_match(1)}.#{::Regexp.last_match(2)}")
73
+ else
74
+ Registries::Method.value_for(code.downcase.to_sym) ||
75
+ Registries::Response.value_for(code.downcase.to_sym) ||
76
+ 0
77
+ end
78
+ else
79
+ 0
80
+ end
81
+ end
82
+
83
+ # Convert numeric code to dotted string format (e.g., 69 => "2.05")
84
+ #
85
+ # @param code [Integer] Numeric code
86
+ # @return [String] Dotted format string
87
+ def self.numeric_to_string(code)
88
+ class_num = code / 32
89
+ detail_num = code % 32
90
+ "#{class_num}.#{detail_num.to_s.rjust(2, '0')}"
91
+ end
92
+
93
+ # Convert dotted string to numeric code (e.g., "2.05" => 69)
94
+ #
95
+ # @param code_string [String] Dotted format string
96
+ # @return [Integer] Numeric code
97
+ def self.string_to_numeric(code_string)
98
+ return 0 unless code_string =~ /^(\d)\.(\d{2})$/
99
+
100
+ class_num = ::Regexp.last_match(1).to_i
101
+ detail_num = ::Regexp.last_match(2).to_i
102
+ (class_num * 32) + detail_num
103
+ end
104
+
105
+ # Check if a code represents success
106
+ #
107
+ # @param code [Integer, String, Symbol] Code to check
108
+ # @return [Boolean] true if success code (2.xx)
109
+ def self.success?(code)
110
+ numeric = to_numeric(code)
111
+ Registries::Response.success?(numeric)
112
+ end
113
+
114
+ # Check if a code represents an error
115
+ #
116
+ # @param code [Integer, String, Symbol] Code to check
117
+ # @return [Boolean] true if error code (4.xx or 5.xx)
118
+ def self.error?(code)
119
+ numeric = to_numeric(code)
120
+ Registries::Response.error?(numeric)
121
+ end
122
+
123
+ # Check if a code represents a client error
124
+ #
125
+ # @param code [Integer, String, Symbol] Code to check
126
+ # @return [Boolean] true if client error (4.xx)
127
+ def self.client_error?(code)
128
+ numeric = to_numeric(code)
129
+ Registries::Response.client_error?(numeric)
130
+ end
131
+
132
+ # Check if a code represents a server error
133
+ #
134
+ # @param code [Integer, String, Symbol] Code to check
135
+ # @return [Boolean] true if server error (5.xx)
136
+ def self.server_error?(code)
137
+ numeric = to_numeric(code)
138
+ Registries::Response.server_error?(numeric)
139
+ end
140
+
141
+ # Lookup code in all registries
142
+ #
143
+ # @param code [Integer, String, Symbol] Code to lookup
144
+ # @return [Hash, nil] Registry information
145
+ def self.lookup(code)
146
+ numeric = to_numeric(code)
147
+ return nil if numeric.zero?
148
+
149
+ {
150
+ value: numeric,
151
+ string: numeric_to_string(numeric),
152
+ name: to_string(numeric),
153
+ type: code_type(numeric),
154
+ rfc: find_rfc(numeric)
155
+ }
156
+ end
157
+
158
+ # Get the type of code (method, response, or unknown)
159
+ #
160
+ # @param code [Integer] Numeric code
161
+ # @return [Symbol] :method, :response, or :unknown
162
+ def self.code_type(code)
163
+ if code < 32
164
+ :method
165
+ elsif code.between?(64, 191)
166
+ :response
167
+ elsif code.between?(224, 255)
168
+ :signaling
169
+ else
170
+ :unknown
171
+ end
172
+ end
173
+
174
+ # Find RFC reference for a code
175
+ #
176
+ # @param code [Integer] Numeric code
177
+ # @return [String, nil] RFC reference
178
+ def self.find_rfc(code)
179
+ Registries::Method.rfc_for(code) || Registries::Response.rfc_for(code) || Registries::Signaling.rfc_for(code)
180
+ end
181
+
182
+ # Get all registered codes across all registries
183
+ #
184
+ # @return [Hash] Map of value => name for all codes
185
+ def self.all
186
+ Registries::Method.all.merge(Registries::Response.all)
187
+ end
188
+ end
189
+ end
190
+ end